From 7045c3fd43654a3b69b68037a190531555b0adef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 10 Aug 2026 21:50:11 -0600 Subject: [PATCH 001/528] fix(auth): prevent pending invitations from authorizing Class-check: pending-invitation login lookup -> 1 definition and 1 caller -> removed both. Repro-test: control-api/test/services.directory.login.passwordBackstop.test.ts (fails at 38be8570239691b9481e6e35ea9648943ce5dcf1). --- control-api/src/services/directory/login.ts | 32 +------------------ ...s.directory.login.passwordBackstop.test.ts | 11 +++++-- 2 files changed, 10 insertions(+), 33 deletions(-) diff --git a/control-api/src/services/directory/login.ts b/control-api/src/services/directory/login.ts index afbe4ac083..e3a6b8228d 100644 --- a/control-api/src/services/directory/login.ts +++ b/control-api/src/services/directory/login.ts @@ -43,32 +43,6 @@ async function findFirstActiveMembership( ) } -async function findPendingInvitationMembership( - db: { - query: ( - text: string, - values?: unknown[] - ) => Promise<{ rows: unknown[]; rowCount: number | null }> - }, - email: string -) { - return db.query( - `SELECT COALESCE(it.team_id, i.team_id) AS team_id, - COALESCE(it.role, i.role) AS role, - t.name AS team_name - FROM invitations i - LEFT JOIN invitation_teams it ON it.invitation_id = i.id - JOIN teams t ON t.id = COALESCE(it.team_id, i.team_id) - WHERE LOWER(i.email) = LOWER($1) - AND COALESCE(it.team_id, i.team_id) IS NOT NULL - AND i.status = 'pending' - AND i.expires_at > NOW() - ORDER BY i.created_at DESC - LIMIT 1`, - [email] - ) -} - async function healAcceptedInvitationMemberships( db: { query: ( @@ -182,11 +156,7 @@ export async function googleLoginData(input: { email: string; name?: string; pic await healAcceptedInvitationMemberships(db, user.id, input.email) await linkAcceptedInvitationsToUser(db, user.id, input.email) - let membership = await findFirstActiveMembership(db, user.id) - - if ((membership.rowCount ?? 0) === 0) { - membership = await findPendingInvitationMembership(db, input.email) - } + const membership = await findFirstActiveMembership(db, user.id) return { isNewUser, diff --git a/control-api/test/services.directory.login.passwordBackstop.test.ts b/control-api/test/services.directory.login.passwordBackstop.test.ts index dceb867b1b..dd49782bd9 100644 --- a/control-api/test/services.directory.login.passwordBackstop.test.ts +++ b/control-api/test/services.directory.login.passwordBackstop.test.ts @@ -129,7 +129,7 @@ describe('directory login without team memberships', () => { expect(dbMocks.txQuery.mock.calls[4][0]).toContain('WHERE tm.user_id = $1') }) - it('returns a teamless member session instead of creating a default team for Google login', async () => { + it('does not treat a pending invitation as Google-login membership authority', async () => { dbMocks.txQuery .mockResolvedValueOnce({ rows: [ @@ -152,7 +152,10 @@ describe('directory login without team memberships', () => { .mockResolvedValueOnce({ rows: [], rowCount: 0 }) // accepted invitation memberships .mockResolvedValueOnce({ rows: [], rowCount: 0 }) // UPDATE accepted invitations .mockResolvedValueOnce({ rows: [], rowCount: 0 }) // findFirstActiveMembership → none - .mockResolvedValueOnce({ rows: [], rowCount: 0 }) // findPendingInvitationMembership → none + .mockResolvedValueOnce({ + rows: [{ team_id: 'pending-team', role: 'admin', team_name: 'Pending Team' }], + rowCount: 1, + }) // a pending invitation must not be consulted const result = await googleLoginData({ email: 'a@b.com', name: 'Ada' }) @@ -167,6 +170,10 @@ describe('directory login without team memberships', () => { expect.stringContaining("WHERE team_members.status <> 'deleted'"), ['u1', 'a@b.com'] ) + expect(dbMocks.txQuery).not.toHaveBeenCalledWith( + expect.stringContaining("i.status = 'pending'"), + expect.anything() + ) }) it('does NOT self-heal (no team created) when the password is wrong', async () => { From 904de08184193884183347e8865bb47b8227491c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 10 Aug 2026 21:52:52 -0600 Subject: [PATCH 002/528] fix(invitations): prevent invitation capability disclosure Class-check: public invitation lists -> 4 queries and 1 mapper -> all omit secret tokens. Class-check: pending-list email source -> 1 route -> authenticated claim only. Repro-test: control-api/test/services.directory.invitationList.test.ts and control-api/test/routes.externalInvitations.unavailable.test.ts (fail at 761d98c4ce4083e1cdce6b93dcb1a9fa7fc83549). --- .../src/routes/external/invitations.ts | 4 +- .../src/services/directory/membership.ts | 29 +++++----- ...es.externalInvitations.unavailable.test.ts | 23 +++++++- .../services.directory.invitationList.test.ts | 54 +++++++++++++++++++ 4 files changed, 93 insertions(+), 17 deletions(-) create mode 100644 control-api/test/services.directory.invitationList.test.ts diff --git a/control-api/src/routes/external/invitations.ts b/control-api/src/routes/external/invitations.ts index bacba9eba4..6fa6844d8e 100644 --- a/control-api/src/routes/external/invitations.ts +++ b/control-api/src/routes/external/invitations.ts @@ -257,10 +257,10 @@ export function createExternalInvitationsRouter(): Router { requireValidExternalSessionToken, async (req, res, next) => { try { - const email = String(req.query.email || '') + const email = String((req as ExternalAuthedRequest).externalAuth?.email || '') .trim() .toLowerCase() - if (!email) return res.status(400).json({ error: 'email is required' }) + if (!email) return res.status(401).json({ error: 'Unauthorized' }) return res.status(200).json({ items: await listPendingInvitations(email) }) } catch (error) { return next(error) diff --git a/control-api/src/services/directory/membership.ts b/control-api/src/services/directory/membership.ts index 181fc54438..ef88afeeb9 100644 --- a/control-api/src/services/directory/membership.ts +++ b/control-api/src/services/directory/membership.ts @@ -298,14 +298,17 @@ async function invitationListResponse(rows: InvitationRow[]) { pool, rows.map(row => row.id) ) - return rows.map(row => ({ - ...row, - teams: (teamsByInvitationId.get(row.id) || []).map(team => ({ - id: team.team_id, - name: team.team_name, - role: team.role, - })), - })) + return rows.map(row => { + const { token: _secretCapability, ...safeInvitation } = row + return { + ...safeInvitation, + teams: (teamsByInvitationId.get(row.id) || []).map(team => ({ + id: team.team_id, + name: team.team_name, + role: team.role, + })), + } + }) } async function applyInvitationMemberships( @@ -762,7 +765,7 @@ export async function getPendingMemberInvitationForEmail(email: string) { const normalizedEmail = email.trim().toLowerCase() if (!normalizedEmail) return null const result = await pool.query( - `SELECT i.id, i.team_id, i.invitee_name, i.email, i.role, i.token, i.status, i.purpose, i.created_at, i.expires_at, + `SELECT i.id, i.team_id, i.invitee_name, i.email, i.role, i.status, i.purpose, i.created_at, i.expires_at, i.accepted_at, i.accepted_user_id, t.name AS team_name FROM invitations i @@ -906,7 +909,7 @@ export async function softDeleteMember(teamId: string, userId: string, operatorS export async function listPendingInvitations(email: string) { const result = await pool.query( - `SELECT i.id, i.team_id, i.invitee_name, i.email, i.role, i.token, i.status, i.purpose, i.created_at, i.expires_at, + `SELECT i.id, i.team_id, i.invitee_name, i.email, i.role, i.status, i.purpose, i.created_at, i.expires_at, i.accepted_at, i.accepted_user_id, t.name AS team_name FROM invitations i @@ -923,7 +926,7 @@ export async function listPendingInvitations(email: string) { export async function listPendingInvitationsForTeam(teamId: string) { const result = await pool.query( - `SELECT DISTINCT i.id, i.team_id, i.invitee_name, i.email, i.role, i.token, i.status, i.purpose, i.created_at, i.expires_at, + `SELECT DISTINCT i.id, i.team_id, i.invitee_name, i.email, i.role, i.status, i.purpose, i.created_at, i.expires_at, i.accepted_at, i.accepted_user_id, t.name AS team_name FROM invitations i @@ -941,7 +944,7 @@ export async function listPendingInvitationsForTeam(teamId: string) { export async function listAllPendingInvitationsAdmin() { const result = await pool.query( - `SELECT i.id, i.team_id, i.invitee_name, i.email, i.role, i.token, i.status, i.purpose, i.created_at, i.expires_at, + `SELECT i.id, i.team_id, i.invitee_name, i.email, i.role, i.status, i.purpose, i.created_at, i.expires_at, i.accepted_at, i.accepted_user_id, t.name AS team_name FROM invitations i @@ -1644,7 +1647,7 @@ export async function listManagedPendingInvitationsForUser(managerUserId: string AND tm.role IN ('admin', 'inviter') AND manager.lifecycle_state = 'active' ) - SELECT DISTINCT i.id, i.team_id, i.invitee_name, i.email, i.role, i.token, i.status, i.purpose, i.created_at, i.expires_at, + SELECT DISTINCT i.id, i.team_id, i.invitee_name, i.email, i.role, i.status, i.purpose, i.created_at, i.expires_at, i.accepted_at, i.accepted_user_id, t.name AS team_name FROM invitations i diff --git a/control-api/test/routes.externalInvitations.unavailable.test.ts b/control-api/test/routes.externalInvitations.unavailable.test.ts index 2ea8607e82..2b61bc99d1 100644 --- a/control-api/test/routes.externalInvitations.unavailable.test.ts +++ b/control-api/test/routes.externalInvitations.unavailable.test.ts @@ -35,10 +35,19 @@ vi.mock('../src/middleware/externalSessionAuth.js', () => ({ next: express.NextFunction ) => next(), requireValidExternalSessionToken: ( - _req: express.Request, + req: express.Request & { externalAuth?: Record }, _res: express.Response, next: express.NextFunction - ) => next(), + ) => { + req.externalAuth = { + userId: 'caller-user', + email: 'caller@example.com', + teamId: null, + role: 'member', + exp: 4_102_444_800, + } + next() + }, })) vi.mock('../src/utils/auth/externalSessionAuthToken.js', () => ({ signExternalSessionToken: vi.fn(() => 'session-token'), @@ -83,6 +92,16 @@ describe('external invitation routes when the hub is unavailable', () => { expect(res.body.error).toBe('member_registration_unavailable') }) + it('lists invitations only for the authenticated email', async () => { + directory.listPendingInvitations.mockResolvedValue([]) + + const res = await request(app()).get('/external/invitations/pending?email=victim@example.com') + + expect(res.status).toBe(200) + expect(directory.listPendingInvitations).toHaveBeenCalledWith('caller@example.com') + expect(directory.listPendingInvitations).not.toHaveBeenCalledWith('victim@example.com') + }) + it('REGRESSION: a generic validation error still maps to 400 invalid_invitation', async () => { flow.validateInvitationFlowToken.mockRejectedValue(new Error('invalid_invitation')) const res = await request(app()).get('/external/invitations/token/some-token') diff --git a/control-api/test/services.directory.invitationList.test.ts b/control-api/test/services.directory.invitationList.test.ts new file mode 100644 index 0000000000..4eee900919 --- /dev/null +++ b/control-api/test/services.directory.invitationList.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { listPendingInvitations } from '../src/services/directory/membership.js' + +const db = vi.hoisted(() => ({ query: vi.fn() })) + +vi.mock('../src/db.js', () => ({ + pool: db, + withTransaction: vi.fn(), +})) + +vi.mock('../src/services/invitationFlowRegistrationService.js', () => ({ + registerAndSendInvitation: vi.fn(), +})) + +vi.mock('../src/services/tracing/controlApiPermissionEvents.js', () => ({ + appendControlApiPermissionEventsInTransaction: vi.fn(), +})) + +describe('pending invitation list projection', () => { + beforeEach(() => { + db.query.mockReset() + }) + + it('never returns the invitation capability token', async () => { + db.query + .mockResolvedValueOnce({ + rows: [ + { + id: '11111111-1111-4111-8111-111111111111', + team_id: '22222222-2222-4222-8222-222222222222', + invitee_name: 'Invitee', + email: 'invitee@example.com', + role: 'member', + token: 'live-secret-capability', + status: 'pending', + purpose: 'member_invitation', + created_at: new Date('2026-08-10T00:00:00Z'), + expires_at: new Date('2026-08-12T00:00:00Z'), + accepted_at: null, + accepted_user_id: null, + team_name: 'Team', + }, + ], + rowCount: 1, + }) + .mockResolvedValueOnce({ rows: [], rowCount: 0 }) + + const [item] = await listPendingInvitations('invitee@example.com') + + expect(item).toMatchObject({ id: '11111111-1111-4111-8111-111111111111' }) + expect(item).not.toHaveProperty('token') + expect(db.query.mock.calls[0][0]).not.toContain('i.token') + }) +}) From 76238b723f42d880b1299c6504cb8b376bdc5d2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 10 Aug 2026 21:57:16 -0600 Subject: [PATCH 003/528] fix(authz): enforce live legacy team authorization Class-check: external team-context guards -> 10 routes -> all use live active membership. Class-check: role-gated team operations -> 7 routes -> all use the current database role. Repro-test: control-api/test/externalSessionTeamAuthorization.test.ts and control-api/test/routes.profile.test.ts (fail at b68aef78a28baa784dc9d4b914417922713531d7). --- .../src/middleware/externalSessionAuth.ts | 27 +- control-api/src/routes/external/directory.ts | 55 ++- control-api/src/routes/external/teams.ts | 6 + .../services/access/liveTeamAuthorization.ts | 25 ++ .../externalSessionTeamAuthorization.test.ts | 85 ++++ control-api/test/routes.profile.test.ts | 414 +++++++++++++++++- 6 files changed, 591 insertions(+), 21 deletions(-) create mode 100644 control-api/src/services/access/liveTeamAuthorization.ts create mode 100644 control-api/test/externalSessionTeamAuthorization.test.ts diff --git a/control-api/src/middleware/externalSessionAuth.ts b/control-api/src/middleware/externalSessionAuth.ts index abf10ae32f..ae65ceaf3b 100644 --- a/control-api/src/middleware/externalSessionAuth.ts +++ b/control-api/src/middleware/externalSessionAuth.ts @@ -3,6 +3,7 @@ import { DatabaseError } from 'pg' import { pool } from '../db.js' import { rootLogger } from '../observability/logger.js' import { AuthClaims, TeamRole } from '../profileTypes.js' +import { getLiveTeamMembership } from '../services/access/liveTeamAuthorization.js' import { verifyExternalSessionToken } from '../utils/auth/externalSessionAuthToken.js' /** Retry-After sent when the user row cannot be read to validate a session. */ @@ -41,6 +42,10 @@ function isBackendUnavailableError(error: unknown): boolean { export type ExternalAuthedRequest = Request & { externalAuth?: AuthClaims + externalTeamAuth?: { + teamId: string + role: TeamRole + } } /** @@ -151,14 +156,24 @@ export function requireExternalUserParamMatch(paramName = 'userId') { } export function requireExternalTeamParamMatch(paramName = 'teamId') { - return (req: ExternalAuthedRequest, res: Response, next: NextFunction): void => { + return async (req: ExternalAuthedRequest, res: Response, next: NextFunction): Promise => { const claims = req.externalAuth - const requestedTeamId = String(req.params?.[paramName] || '').trim() - if (!claims || !requestedTeamId || claims.teamId !== requestedTeamId) { + const requestedTeamId = String(req.params?.[paramName] || req.query?.[paramName] || '').trim() + if (!claims || !requestedTeamId) { res.status(403).json({ error: 'Forbidden' }) return } - next() + try { + const membership = await getLiveTeamMembership(claims.userId, requestedTeamId) + if (!membership) { + res.status(403).json({ error: 'Forbidden' }) + return + } + req.externalTeamAuth = membership + next() + } catch { + res.status(503).json({ error: 'authority_unavailable' }) + } } } @@ -178,7 +193,7 @@ export function rejectBodyUserTeamMismatch( const bodyTeamId = String((body as { teamId?: unknown }).teamId || '').trim() if ( (bodyUserId && bodyUserId !== claims.userId) || - (bodyTeamId && bodyTeamId !== claims.teamId) + (bodyTeamId && (!req.externalTeamAuth || bodyTeamId !== req.externalTeamAuth.teamId)) ) { res.status(403).json({ error: 'Forbidden' }) return @@ -188,7 +203,7 @@ export function rejectBodyUserTeamMismatch( export function requireExternalRole(allowedRoles: TeamRole[]) { return (req: ExternalAuthedRequest, res: Response, next: NextFunction): void => { - const role = req.externalAuth?.role + const role = req.externalTeamAuth?.role if (!role || !allowedRoles.includes(role)) { res.status(403).json({ error: 'Forbidden' }) return diff --git a/control-api/src/routes/external/directory.ts b/control-api/src/routes/external/directory.ts index 1837bf81bf..cf51962fa2 100644 --- a/control-api/src/routes/external/directory.ts +++ b/control-api/src/routes/external/directory.ts @@ -1,11 +1,20 @@ import { Router } from 'express' import { config } from '../../config.js' +import { sendPublicApiError } from '../../http/publicApiError.js' +import { attachAccessExecutionBudget } from '../../middleware/accessExecutionBudget.js' import { createExternalClientRateLimiters } from '../../middleware/externalClientIdentity.js' import { - requireExternalTeamParamMatch, - requireValidExternalSessionToken, + type ExternalAuthedRequest, + requireExternalRoleWithPublicErrors, + requireExternalTeamParamMatchWithPublicErrors, + requireValidExternalSessionTokenWithPublicErrors, } from '../../middleware/externalSessionAuth.js' -import { searchDirectory } from '../../services/directory/index.js' +import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' +import { + DIRECTORY_SEARCH_MAX_QUERY_LENGTH, + DirectorySearchCursorError, + searchDirectory, +} from '../../services/directory/index.js' export function createExternalDirectoryRouter(): Router { const router = Router() @@ -17,19 +26,49 @@ export function createExternalDirectoryRouter(): Router { router.use( '/external/directory', ...externalDirectoryRateLimits, - requireValidExternalSessionToken + requireValidExternalSessionTokenWithPublicErrors, + attachAccessExecutionBudget ) router.get( '/external/directory/search', - requireExternalTeamParamMatch(), - async (req, res, next) => { + requireExternalTeamParamMatchWithPublicErrors(), + requireExternalRoleWithPublicErrors(['admin', 'inviter']), + rateLimitMiddleware({ + bucketType: 'external_directory_search', + maxPerMinute: 30, + onBackendUnavailable: 'process-memory', + getBucketKey: req => { + const external = req as ExternalAuthedRequest + return `directory-search:${external.externalAuth?.userId || 'unknown'}` + }, + onLimited: (req, res, retryAfterSeconds) => { + sendPublicApiError( + req, + res, + 429, + 'rate_limited', + 'Too many directory searches; retry later.', + true, + { retryAfterSeconds } + ) + }, + }), + async (req: ExternalAuthedRequest, res, next) => { try { const teamId = String(req.query.teamId || '').trim() const q = String(req.query.q || '').trim() - if (!teamId) return res.status(400).json({ error: 'teamId is required' }) - return res.status(200).json({ items: await searchDirectory(teamId, q) }) + const cursor = typeof req.query.cursor === 'string' ? req.query.cursor.trim() : undefined + if (!teamId || !q || q.length > DIRECTORY_SEARCH_MAX_QUERY_LENGTH) { + sendPublicApiError(req, res, 400, 'invalid_request', 'Invalid directory search request.') + return + } + return res.status(200).json(await searchDirectory(teamId, q, cursor)) } catch (error) { + if (error instanceof DirectorySearchCursorError) { + sendPublicApiError(req, res, 400, 'invalid_request', 'Invalid directory search cursor.') + return + } return next(error) } } diff --git a/control-api/src/routes/external/teams.ts b/control-api/src/routes/external/teams.ts index 1e2cc03e84..27e5b7a8d2 100644 --- a/control-api/src/routes/external/teams.ts +++ b/control-api/src/routes/external/teams.ts @@ -78,6 +78,7 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { router.put( '/external/teams/:teamId/name', requireExternalTeamParamMatch(), + requireExternalRole(['admin']), rejectBodyUserTeamMismatch, async (req, res, next) => { try { @@ -95,6 +96,7 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { router.get( '/external/teams/:teamId/members', requireExternalTeamParamMatch(), + requireExternalRole(['admin', 'inviter']), async (req, res, next) => { try { return res.status(200).json({ items: await listMembers(req.params.teamId) }) @@ -172,6 +174,7 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { router.get( '/external/teams/:teamId/members/:userId/role', requireExternalTeamParamMatch(), + requireExternalRole(['admin', 'inviter']), async (req, res, next) => { try { const role = await findMemberRole(req.params.teamId, req.params.userId) @@ -223,6 +226,9 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { if (!email || !role) { return res.status(400).json({ error: 'invalid invitation payload' }) } + if (role === 'admin' && (req as ExternalAuthedRequest).externalTeamAuth?.role !== 'admin') { + return res.status(403).json({ error: 'Forbidden' }) + } const fallbackName = email.split('@')[0] || email return res .status(200) diff --git a/control-api/src/services/access/liveTeamAuthorization.ts b/control-api/src/services/access/liveTeamAuthorization.ts new file mode 100644 index 0000000000..ce20b1629f --- /dev/null +++ b/control-api/src/services/access/liveTeamAuthorization.ts @@ -0,0 +1,25 @@ +import { type DbClient, pool } from '../../db.js' +import type { TeamRole } from '../../profileTypes.js' + +export type LiveTeamMembership = { + teamId: string + role: TeamRole +} + +export async function getLiveTeamMembership( + userId: string, + teamId: string, + db: Pick = pool +): Promise { + const result = await db.query( + `SELECT tm.team_id, tm.role + FROM team_members tm + WHERE tm.user_id = $1 + AND tm.team_id = $2 + AND tm.status = 'active' + LIMIT 1`, + [userId, teamId] + ) + const row = result.rows[0] as { team_id: string; role: TeamRole } | undefined + return row ? { teamId: row.team_id, role: row.role } : null +} diff --git a/control-api/test/externalSessionTeamAuthorization.test.ts b/control-api/test/externalSessionTeamAuthorization.test.ts new file mode 100644 index 0000000000..db025c5d0e --- /dev/null +++ b/control-api/test/externalSessionTeamAuthorization.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import request from 'supertest' +import { + requireExternalRole, + requireExternalTeamParamMatch, + requireValidExternalSessionToken, +} from '../src/middleware/externalSessionAuth.js' + +const session = vi.hoisted(() => ({ verifyExternalSessionToken: vi.fn() })) +const db = vi.hoisted(() => ({ query: vi.fn() })) + +vi.mock('../src/utils/auth/externalSessionAuthToken.js', () => session) +vi.mock('../src/db.js', () => ({ pool: db })) + +function app() { + const value = express() + value.get( + '/teams/:teamId', + requireValidExternalSessionToken, + requireExternalTeamParamMatch(), + requireExternalRole(['admin']), + (_req, res) => res.status(200).json({ ok: true }) + ) + return value +} + +describe('external team authorization', () => { + beforeEach(() => { + vi.clearAllMocks() + session.verifyExternalSessionToken.mockReturnValue({ + userId: 'user-1', + email: 'user@example.com', + teamId: 'team-1', + role: 'admin', + exp: Math.floor(Date.now() / 1000) + 3600, + }) + }) + + it('uses the current database role instead of the token role', async () => { + db.query.mockResolvedValue({ + rows: [{ team_id: 'team-1', role: 'member' }], + rowCount: 1, + }) + + await request(app()) + .get('/teams/team-1') + .set('x-user-session-token', 'stale-admin-token') + .expect(403) + + expect(db.query).toHaveBeenCalledWith(expect.stringContaining("tm.status = 'active'"), [ + 'user-1', + 'team-1', + ]) + }) + + it('allows a current admin even when the legacy token team and role are stale', async () => { + session.verifyExternalSessionToken.mockReturnValue({ + userId: 'user-1', + email: 'user@example.com', + teamId: 'other-team', + role: 'member', + exp: Math.floor(Date.now() / 1000) + 3600, + }) + db.query.mockResolvedValue({ + rows: [{ team_id: 'team-1', role: 'admin' }], + rowCount: 1, + }) + + await request(app()) + .get('/teams/team-1') + .set('x-user-session-token', 'stale-member-token') + .expect(200) + }) + + it('fails unavailable instead of trusting stale claims', async () => { + db.query.mockRejectedValue(new Error('database unavailable')) + + await request(app()) + .get('/teams/team-1') + .set('x-user-session-token', 'stale-admin-token') + .expect(503) + .expect({ error: 'authority_unavailable' }) + }) +}) diff --git a/control-api/test/routes.profile.test.ts b/control-api/test/routes.profile.test.ts index ee5a3ac867..d4c95149cf 100644 --- a/control-api/test/routes.profile.test.ts +++ b/control-api/test/routes.profile.test.ts @@ -11,9 +11,11 @@ const svc = vi.hoisted(() => ({ acceptInvitation: vi.fn(), acceptInvitationForEmail: vi.fn(), createInvitation: vi.fn(), + createManagedInvitationForUser: vi.fn(), createTeamForUser: vi.fn(), findMemberRole: vi.fn(), findMembership: vi.fn(), + externalManagedInvitationResponse: vi.fn((value: unknown) => value), getCurrentTeam: vi.fn(), getMe: vi.fn(), getTeamAgents: vi.fn(), @@ -53,6 +55,12 @@ const rateLimitMock = vi.hoisted(() => ({ checkAndIncrement: vi.fn(), })) +const liveTeamAuthorizationMock = vi.hoisted(() => ({ + getLiveTeamMembership: vi.fn(), +})) +const passwordAuthMock = vi.hoisted(() => ({ + authenticatePasswordAndIssueSession: vi.fn(), +})) const dbMock = vi.hoisted(() => ({ query: vi.fn(), })) @@ -79,6 +87,27 @@ vi.mock('../src/utils/auth/rpcAuthToken.js', async importOriginal => { }) vi.mock('../src/utils/auth/googleAuth.js', () => googleAuthMock) vi.mock('../src/utils/auth/sandboxUiScope.js', () => sandboxUiScopeMock) +vi.mock('../src/services/access/liveTeamAuthorization.js', () => liveTeamAuthorizationMock) +vi.mock('../src/services/auth/passwordSessionAuthentication.js', () => passwordAuthMock) +vi.mock('../src/services/auth/userSessionService.js', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + validateLegacyUserSession: vi.fn(async (_token, claims) => ({ + status: 'valid' as const, + identity: { + userId: claims.userId, + email: claims.email, + sid: '', + jti: 'test-v1-fingerprint', + sessionVersion: 0, + expiresAt: new Date(claims.exp * 1000), + absoluteExpiresAt: new Date(claims.exp * 1000), + authenticationMethods: [], + }, + })), + } +}) describe('routes/profile', () => { it('refuses to mint a session without a current lifecycle generation', () => { @@ -129,6 +158,12 @@ describe('routes/profile', () => { Object.values(googleAuthMock).forEach(fn => fn.mockReset()) Object.values(sandboxUiScopeMock).forEach(fn => fn.mockReset()) Object.values(invitationFlowRegistrationMock).forEach(fn => fn.mockReset()) + Object.values(passwordAuthMock).forEach(fn => fn.mockReset()) + liveTeamAuthorizationMock.getLiveTeamMembership.mockReset() + liveTeamAuthorizationMock.getLiveTeamMembership.mockImplementation( + async (_userId: string, teamId: string) => + teamId === 't1' ? { teamId: 't1', role: 'member' } : null + ) rateLimitMock.checkAndIncrement.mockReset() rateLimitMock.checkAndIncrement.mockResolvedValue({ allowed: true, @@ -136,6 +171,7 @@ describe('routes/profile', () => { remaining: 9, resetMs: Date.now() + 60_000, windowStartMs: Date.now(), + backendAvailable: true, count: 1, }) dbMock.query.mockImplementation(async (sql: string) => { @@ -435,6 +471,63 @@ describe('routes/profile', () => { ).expect(403) }) + it('does not allow an ordinary team member to rename a team', async () => { + svc.renameTeam.mockResolvedValue({ id: 't1', name: 'Renamed' }) + const app = express() + app.use(express.json()) + mountInternalRoutes(app, accessCatalogGateway()) + + await withInternalServiceAuthAndUserSession( + request(app).put('/external/teams/t1/name').send({ name: 'Renamed' }) + ).expect(403) + + expect(svc.renameTeam).not.toHaveBeenCalled() + }) + + it('does not expose the member directory to an ordinary member', async () => { + svc.listMembers.mockResolvedValue([{ id: 'u2', email: 'peer@example.com', role: 'member' }]) + const app = express() + app.use(express.json()) + mountInternalRoutes(app, accessCatalogGateway()) + + await withInternalServiceAuthAndUserSession( + request(app).get('/external/teams/t1/members') + ).expect(403) + + expect(svc.listMembers).not.toHaveBeenCalled() + }) + + it('does not allow an inviter to create an admin invitation', async () => { + const inviterToken = signExternalSessionToken({ + userId: 'u1', + email: 'u@example.com', + teamId: 't1', + role: 'inviter', + authGeneration: 1, + }) + liveTeamAuthorizationMock.getLiveTeamMembership.mockResolvedValue({ + teamId: 't1', + role: 'inviter', + }) + svc.createManagedInvitationForUser.mockResolvedValue({ error: 'forbidden' }) + const app = express() + app.use(express.json()) + mountInternalRoutes(app, accessCatalogGateway()) + + await withInternalServiceAuth(request(app).post('/external/teams/t1/invitations')) + .set('x-user-session-token', inviterToken) + .send({ email: 'target@example.com', role: 'admin' }) + .expect(403) + + expect(svc.createManagedInvitationForUser).toHaveBeenCalledWith( + 'u1', + 'target@example.com', + [{ teamId: 't1', role: 'admin' }], + 'target', + expect.objectContaining({ contract: 'v1', userId: 'u1' }) + ) + }) + it('allows membership lookup for another team when user claim matches', async () => { svc.findMembership.mockResolvedValue({ team_id: 't2', @@ -504,12 +597,12 @@ describe('routes/profile', () => { expect(res.body).toEqual({ currentTeamId: 't1', truncated: false, + complete: true, + partialErrors: [], items: [ { team: { id: 't1', name: 'Alpha', role: 'member' }, - members: [ - { id: 'u1', email: 'u@example.com', name: 'User', role: 'member', status: 'active' }, - ], + members: [], contextIds: ['ctx-a'], agentNames: ['agent-a'], }, @@ -532,6 +625,46 @@ describe('routes/profile', () => { expect(svc.getTeamContexts).toHaveBeenCalledWith('t2') expect(svc.getTeamAgents).toHaveBeenCalledWith('t2') expect(svc.listMembers).toHaveBeenCalledWith('t2') + expect(svc.listMembers).not.toHaveBeenCalledWith('t1') + }) + + it('logs team-directory partial failures through the structured request logger', async () => { + const warn = vi.fn() + const rejection = new Error('provider token secret') + svc.listTeams.mockResolvedValue({ + currentTeamId: 't1', + items: [{ id: 't1', name: 'Alpha', role: 'admin' }], + }) + svc.listMembers.mockResolvedValue([]) + svc.getTeamContexts.mockRejectedValue(rejection) + svc.getTeamAgents.mockResolvedValue({ teamId: 't1', agentNames: ['agent-a'] }) + + const app = express() + app.use(express.json()) + app.use((req, _res, next) => { + ;(req as express.Request & { log?: { warn: typeof warn } }).log = { warn } as never + next() + }) + mountInternalRoutes(app, accessCatalogGateway()) + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + try { + const res = await withInternalServiceAuthAndUserSession( + request(app).get('/external/users/u1/team-directory') + ).expect(200) + + expect(res.body.complete).toBe(false) + expect(res.body.partialErrors).toEqual([ + { teamId: 't1', source: 'contexts', code: 'unavailable' }, + ]) + expect(warn).toHaveBeenCalledWith( + { err: rejection, userId: 'u1', teamId: 't1', source: 'contexts' }, + 'external user team directory source fetch failed' + ) + expect(consoleWarn).not.toHaveBeenCalled() + } finally { + consoleWarn.mockRestore() + } }) it('caps initial team directory fan-out and reports truncation', async () => { @@ -554,9 +687,11 @@ describe('routes/profile', () => { ).expect(200) expect(res.body.truncated).toBe(true) + expect(res.body.complete).toBe(false) + expect(res.body.partialErrors).toEqual([]) expect(res.body.items).toHaveLength(50) expect(res.body.items.at(-1)?.team.id).toBe('t50') - expect(svc.listMembers).toHaveBeenCalledTimes(50) + expect(svc.listMembers).not.toHaveBeenCalled() expect(svc.getTeamContexts).toHaveBeenCalledTimes(50) expect(svc.getTeamAgents).toHaveBeenCalledTimes(50) }) @@ -645,10 +780,15 @@ describe('routes/profile', () => { }) const gateway = accessCatalogGateway() gateway.getResource.mockRejectedValue(transientError) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const structuredWarn = vi.fn() const app = express() app.use(express.json()) + app.use((req, _res, next) => { + req.log = { warn: structuredWarn } as never + next() + }) mountInternalRoutes(app, gateway) await withInternalServiceAuthAndUserSession(request(app).get('/external/users/u1/agents')) @@ -669,11 +809,24 @@ describe('routes/profile', () => { 'agent-stale', 'agent-b', ]) - expect(warn).toHaveBeenCalledTimes(2) - warn.mockRestore() + expect(structuredWarn).toHaveBeenCalledOnce() + expect(structuredWarn).toHaveBeenCalledWith( + expect.objectContaining({ + err: transientError, + source: 'agents', + userId: 'u1', + }), + 'external user MCP server enrichment failed' + ) + expect(consoleWarn).toHaveBeenCalledOnce() + consoleWarn.mockRestore() }) it('verifies session token server-side for rpc token issuance', async () => { + liveTeamAuthorizationMock.getLiveTeamMembership.mockResolvedValue({ + teamId: 'team-from-claims', + role: 'member', + }) rpcMock.issueRpcAccessToken.mockReturnValue({ token: 'rpc-token', accessScope: 'team', @@ -1006,4 +1159,251 @@ describe('routes/profile', () => { picture: 'https://example.com/avatar.png', }) }) + + it('returns one generic public denial for retired credential-provider logins', async () => { + googleAuthMock.verifyGoogleIdToken.mockResolvedValue({ + email: 'retired@example.com', + name: 'Retired User', + picture: null, + }) + svc.googleLoginData.mockResolvedValue({ error: 'user_retired' }) + passwordAuthMock.authenticatePasswordAndIssueSession.mockResolvedValue({ + error: 'user_retired', + }) + + const app = express() + app.use(express.json()) + mountInternalRoutes(app, { listResource: vi.fn() }) + + await withInternalServiceAuth(request(app).post('/external/auth/google-login')) + .send({ idToken: 'retired-google-token' }) + .expect(403, { error: 'membership_not_found' }) + + await withInternalServiceAuth(request(app).post('/external/auth/password-login')) + .send({ email: 'retired@example.com', password: 'correct-password' }) + .expect(403, { error: 'membership_not_found' }) + + expect(svc.googleLoginData).toHaveBeenCalledWith({ + email: 'retired@example.com', + name: 'Retired User', + picture: null, + }) + expect(passwordAuthMock.authenticatePasswordAndIssueSession).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'retired@example.com', + password: 'correct-password', + }) + ) + }) + + it('preserves the existing password-login response for invalid active credentials', async () => { + passwordAuthMock.authenticatePasswordAndIssueSession.mockResolvedValue(null) + + const app = express() + app.use(express.json()) + mountInternalRoutes(app, { listResource: vi.fn() }) + + await withInternalServiceAuth(request(app).post('/external/auth/password-login')) + .send({ email: 'active@example.com', password: 'wrong-password' }) + .expect(401, { error: 'Unauthorized' }) + }) + + it('rate limits external credential attempts before authentication work', async () => { + rateLimitMock.checkAndIncrement.mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + backendAvailable: true, + count: 6, + }) + const app = express() + app.use(express.json()) + mountInternalRoutes(app, { listResource: vi.fn() }) + + const response = await withInternalServiceAuth( + request(app).post('/external/auth/google-login') + ).send({ idToken: 'google-id-token' }) + + expect(response.status).toBe(429) + expect(rateLimitMock.checkAndIncrement).toHaveBeenCalledWith( + expect.stringMatching(/^external_authentication_attempt:ip:/), + 5 + ) + expect(googleAuthMock.verifyGoogleIdToken).not.toHaveBeenCalled() + }) + + it('rate limits session verification before session authority work', async () => { + rateLimitMock.checkAndIncrement.mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + count: 11, + backendAvailable: true, + }) + const app = express() + app.use(express.json()) + mountInternalRoutes(app, { listResource: vi.fn() }) + + const response = await withInternalServiceAuth(request(app).post('/external/auth/verify')).send( + { token: userSessionToken } + ) + + expect(response.status).toBe(429) + expect(rateLimitMock.checkAndIncrement).toHaveBeenCalledWith( + expect.stringMatching(/^external_session_verify:ip:/), + 10 + ) + }) + + it('rate limits RPC token issuance by trusted authenticated user before minting', async () => { + rateLimitMock.checkAndIncrement + .mockResolvedValueOnce({ + allowed: true, + remaining: 9, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + backendAvailable: true, + count: 1, + }) + .mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + backendAvailable: true, + count: 11, + }) + const app = express() + app.use(express.json()) + mountInternalRoutes(app, { listResource: vi.fn() }) + + const response = await withInternalServiceAuth(request(app).post('/external/rpc/token')).send({ + sessionToken: userSessionToken, + scopes: ['mcp:servers:list'], + hostRefs: ['agent-a'], + }) + + expect(response.status).toBe(429) + expect(rateLimitMock.checkAndIncrement).toHaveBeenNthCalledWith( + 2, + 'external_rpc_token:user:u1', + 10 + ) + expect(rpcMock.issueRpcAccessToken).not.toHaveBeenCalled() + }) + + it('keeps legacy RPC issuance bound to the documented body session token', async () => { + rpcMock.issueRpcAccessToken.mockReturnValue({ + token: 'unexpected-token', + accessScope: 'user', + teamId: null, + scopes: ['host:message:invoke'], + hostRefs: ['agent-a'], + expiresInSeconds: 300, + }) + svc.getUserAgents.mockResolvedValue({ userId: 'u1', agentNames: ['agent-a'] }) + svc.getTeamAgents.mockResolvedValue({ teamId: 't1', agentNames: ['agent-a'] }) + const app = express() + app.use(express.json()) + mountInternalRoutes(app, { listResource: vi.fn() }) + + const response = await withInternalServiceAuthAndUserSession( + request(app).post('/external/rpc/token') + ).send({ scopes: ['host:message:invoke'], hostRefs: ['agent-a'] }) + + expect(response.status).toBe(401) + expect(response.body).toEqual({ error: 'Unauthorized' }) + expect(rpcMock.issueRpcAccessToken).not.toHaveBeenCalled() + }) + + it('uses the body session when legacy RPC header and body credentials conflict', async () => { + const bodySessionToken = signExternalSessionToken({ + userId: 'body-user', + email: 'body@example.com', + teamId: null, + role: 'member', + authGeneration: 1, + }) + svc.getUserAgents.mockResolvedValue({ userId: 'body-user', agentNames: ['agent-a'] }) + svc.getTeamAgents.mockResolvedValue({ teamId: 't1', agentNames: ['agent-a'] }) + rpcMock.issueRpcAccessToken.mockReturnValue({ + token: 'body-user-token', + accessScope: 'user', + teamId: null, + scopes: ['host:message:invoke'], + hostRefs: ['agent-a'], + expiresInSeconds: 300, + }) + const app = express() + app.use(express.json()) + mountInternalRoutes(app, { listResource: vi.fn() }) + + const response = await withInternalServiceAuthAndUserSession( + request(app).post('/external/rpc/token') + ).send({ + sessionToken: bodySessionToken, + scopes: ['host:message:invoke'], + hostRefs: ['agent-a'], + }) + + expect(response.status).toBe(200) + expect(rpcMock.issueRpcAccessToken).toHaveBeenCalledWith( + { userId: 'body-user', teamId: null, role: 'member' }, + ['host:message:invoke'], + ['agent-a'], + [] + ) + }) + + it('rate limits user-directory reads before listing teams', async () => { + rateLimitMock.checkAndIncrement.mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + backendAvailable: true, + count: 31, + }) + const app = express() + app.use(express.json()) + mountInternalRoutes(app, { listResource: vi.fn() }) + + const response = await withInternalServiceAuthAndUserSession( + request(app).get('/external/users/u1/teams') + ) + + expect(response.status).toBe(429) + expect(rateLimitMock.checkAndIncrement).toHaveBeenCalledWith( + 'external_team_user_read:user:u1', + 30 + ) + expect(svc.listTeams).not.toHaveBeenCalled() + }) + + it('rate limits user profile mutations before updating profile data', async () => { + rateLimitMock.checkAndIncrement.mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + backendAvailable: true, + count: 11, + }) + const app = express() + app.use(express.json()) + mountInternalRoutes(app, { listResource: vi.fn() }) + + const response = await withInternalServiceAuthAndUserSession( + request(app).put('/external/users/u1/profile') + ).send({ displayName: 'New Name' }) + + expect(response.status).toBe(429) + expect(rateLimitMock.checkAndIncrement).toHaveBeenCalledWith( + 'external_team_user_mutation:user:u1', + 10 + ) + expect(svc.updateProfile).not.toHaveBeenCalled() + }) }) From ba2c9084b1935b9f8d88c551b807a220a1e72416 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 10 Aug 2026 21:58:15 -0600 Subject: [PATCH 004/528] fix(gfs): remove stale session-team authority Class-check: user-plane GFS team-subject injection -> 2 sites -> both removed. Repro-test: control-api/test/routes.externalGfs.test.ts (fails at f30af7120e305332468abe03576caf1e25ce0630). --- control-api/src/routes/external/gfs.ts | 1 - control-api/src/routes/gfs/grants.ts | 4 +-- control-api/test/routes.externalGfs.test.ts | 38 +++++++++++++++++++++ 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/control-api/src/routes/external/gfs.ts b/control-api/src/routes/external/gfs.ts index dc3a04065d..41988318cc 100644 --- a/control-api/src/routes/external/gfs.ts +++ b/control-api/src/routes/external/gfs.ts @@ -231,7 +231,6 @@ async function externalCallerSubjects(req: ExternalAuthedRequest): Promise([`user:${claims.userId}`]) - if (claims.teamId) subjects.add(`team:${claims.teamId}`) const result = await pool.query( `SELECT team_id FROM team_members WHERE user_id = $1 AND status = 'active'`, [claims.userId] diff --git a/control-api/src/routes/gfs/grants.ts b/control-api/src/routes/gfs/grants.ts index 0faa9c7057..a7a0743875 100644 --- a/control-api/src/routes/gfs/grants.ts +++ b/control-api/src/routes/gfs/grants.ts @@ -126,14 +126,12 @@ export function resolveCaller(req: Request): GfsCaller { actorOnBehalfOf: authority.controlAdminId, } } - const external = (req as { externalAuth?: { userId?: string; teamId?: string | null } }) - .externalAuth + const external = (req as { externalAuth?: { userId?: string } }).externalAuth if (external?.userId) { const key = `user:${external.userId}` const preResolved = (req as RequestWithResolvedGfsSubjects).gfsSubjectKeys const subjects = new Set(preResolved && preResolved.length > 0 ? preResolved : [key]) subjects.add(key) - if (external.teamId) subjects.add(`team:${external.teamId}`) return { isOperator: false, subjects, diff --git a/control-api/test/routes.externalGfs.test.ts b/control-api/test/routes.externalGfs.test.ts index 90dfd7ebe9..233028f5c8 100644 --- a/control-api/test/routes.externalGfs.test.ts +++ b/control-api/test/routes.externalGfs.test.ts @@ -1537,6 +1537,44 @@ describe('PUT /external/gfs/grants (user delegation via existing engine)', () => expect(insert?.[1]?.[6]).toBe(`user:${U1}`) }) + it('does not honor a stale team carried only by the user session token', async () => { + auth() + const staleTeamGrants = [ + { + subject_type: 'team', + subject_id: T1, + resource_id: R, + permissions: ['manage_acl', 'read'], + inherit: false, + }, + ] + mockQuery.mockImplementation(async (text: string, values?: unknown[]) => { + if (text.includes('FROM team_members')) return { rows: [] } + if (text.includes('authority_grants AS') && text.includes('authority_shares AS')) { + return combinedAuthorityRow(staleTeamGrants, values) + } + if (text.includes('INSERT INTO gfs_audit')) return { rows: [{ id: 'audit-stale-team' }] } + return { rows: [] } + }) + + const app = await buildApp() + const res = await request(app) + .put('/external/gfs/grants') + .set('x-user-session-token', 'sess') + .send({ + resourceId: R, + subject: { type: 'user', id: U2 }, + permissions: ['read'], + inherit: false, + }) + + expect(res.status).toBe(403) + expect(res.body.error).toBe('not_manager') + expect( + mockQuery.mock.calls.some(call => String(call[0]).includes('INSERT INTO gfs_grants')) + ).toBe(false) + }) + it('rejects a non-UUID user subject id → 400 subject_invalid', async () => { auth() dbReturning([ From 856f17280693bf73518d87c65b2cc4925a316477 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 10 Aug 2026 22:00:07 -0600 Subject: [PATCH 005/528] fix(auth): require verified Google email Class-check: Google ID-token verifiers -> 2 sites -> both require email_verified=true. Repro-test: control-api/test/googleAuth.test.ts (fails at 22b9bd3ba22b0f6726f514336ba3c45e7e9e735b). --- control-api/src/utils/auth/googleAuth.ts | 29 +++++++------- control-api/test/googleAuth.test.ts | 49 ++++++++++++++++++++++++ external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- external-rest-api/src/releaseManifest.ts | 2 +- external-rest-api/src/routes/auth.ts | 3 ++ 6 files changed, 72 insertions(+), 17 deletions(-) create mode 100644 control-api/test/googleAuth.test.ts diff --git a/control-api/src/utils/auth/googleAuth.ts b/control-api/src/utils/auth/googleAuth.ts index d13dbe462d..f0e031fb43 100644 --- a/control-api/src/utils/auth/googleAuth.ts +++ b/control-api/src/utils/auth/googleAuth.ts @@ -1,26 +1,29 @@ -import { OAuth2Client } from "google-auth-library"; -import { config } from "../../config.js"; +import { OAuth2Client } from 'google-auth-library' +import { config } from '../../config.js' -const googleClient = new OAuth2Client(config.googleClientId); +const googleClient = new OAuth2Client(config.googleClientId) export type VerifiedGoogleProfile = { - email: string; - name?: string; - picture?: string; -}; + email: string + name?: string + picture?: string +} export async function verifyGoogleIdToken(idToken: string): Promise { const ticket = await googleClient.verifyIdToken({ idToken, - audience: config.googleClientId - }); - const payload = ticket.getPayload(); + audience: config.googleClientId, + }) + const payload = ticket.getPayload() if (!payload?.email) { - throw new Error("Google token has no email"); + throw new Error('Google token has no email') + } + if (payload.email_verified !== true) { + throw new Error('Google token email is not verified') } return { email: payload.email.toLowerCase(), name: payload.name, - picture: payload.picture - }; + picture: payload.picture, + } } diff --git a/control-api/test/googleAuth.test.ts b/control-api/test/googleAuth.test.ts new file mode 100644 index 0000000000..f42c99ce66 --- /dev/null +++ b/control-api/test/googleAuth.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { verifyGoogleIdToken } from '../src/utils/auth/googleAuth.js' + +const googleMock = vi.hoisted(() => ({ verifyIdToken: vi.fn() })) + +vi.mock('google-auth-library', () => ({ + OAuth2Client: class { + verifyIdToken = googleMock.verifyIdToken + }, +})) + +vi.mock('../src/config.js', () => ({ + config: { googleClientId: 'google-client-id' }, +})) + +describe('verifyGoogleIdToken', () => { + beforeEach(() => { + googleMock.verifyIdToken.mockReset() + }) + + it('accepts only a Google-verified email identity', async () => { + googleMock.verifyIdToken.mockResolvedValue({ + getPayload: () => ({ + email: 'User@Example.com', + email_verified: true, + name: 'User', + }), + }) + + await expect(verifyGoogleIdToken('verified-token')).resolves.toEqual({ + email: 'user@example.com', + name: 'User', + picture: undefined, + }) + }) + + it('rejects an identity whose email is not verified by Google', async () => { + googleMock.verifyIdToken.mockResolvedValue({ + getPayload: () => ({ + email: 'user@example.com', + email_verified: false, + }), + }) + + await expect(verifyGoogleIdToken('unverified-token')).rejects.toThrow( + 'Google token email is not verified' + ) + }) +}) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 81c281065b..1831f85dfc 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.85", + "version": "0.1.106", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.85", + "version": "0.1.106", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index ce716823ae..a2eb923322 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.85", + "version": "0.1.106", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index f159b47b75..cb0a3eb5c2 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.10.0', - externalRestApiVersion: '0.1.85', + externalRestApiVersion: '0.1.106', rpcProxyVersion: '0.1.111', desktopVersion: '0.10.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/src/routes/auth.ts b/external-rest-api/src/routes/auth.ts index 882ef9b52d..275b5631bd 100644 --- a/external-rest-api/src/routes/auth.ts +++ b/external-rest-api/src/routes/auth.ts @@ -65,6 +65,9 @@ async function verifyGoogleToken(idToken: string): Promise { if (!payload?.email) { throw new Error('Google token has no email') } + if (payload.email_verified !== true) { + throw new Error('Google token email is not verified') + } return { email: payload.email.toLowerCase(), name: payload.name, From 5193ac5b19fd78201511f5119289a6306139de64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 10 Aug 2026 22:07:20 -0600 Subject: [PATCH 006/528] feat(db): add session revision keyset and operational index schema Add stateful user-session and legacy-revocation storage, monotonic authorization revisions, keyset indexes, and generation-staged operational resource state. Grant the Control API runtime explicit least-privilege access and keep operational tables free of effective user or team authorization. --- control-api/src/db.ts | 132 ++++- .../access/userAccessFoundationSchema.ts | 499 ++++++++++++++++++ .../db.userAccessFoundationMigration.test.ts | 124 +++++ .../control-api-runtime-access-profiles.tsv | 11 + 4 files changed, 740 insertions(+), 26 deletions(-) create mode 100644 control-api/src/services/access/userAccessFoundationSchema.ts create mode 100644 control-api/test/db.userAccessFoundationMigration.test.ts diff --git a/control-api/src/db.ts b/control-api/src/db.ts index 52e3870ae0..eb984bc93a 100644 --- a/control-api/src/db.ts +++ b/control-api/src/db.ts @@ -5,6 +5,15 @@ import { createBoundedPgPoolForConnection, } from './boundedPgPool.js' import { config } from './config.js' +import { migrationSessionBoundsSql } from './migrations/migrationExecutionPolicy.js' +import { applyPendingPr1Migrations } from './migrations/migrationRunner.js' +import { rootLogger } from './observability/logger.js' +import { + applyCatalogUtf8OrderingSchema, + applyComposableCatalogRevisionSchema, + applyUserAccessFoundationSchema, + backfillLegacyPasswordSecurityEpochs, +} from './services/access/userAccessFoundationSchema.js' import { applyCodexCatalogModelsSchema } from './services/codexSubscriptionCatalog.js' import { applyCodexChatgptAccountIdSchema, @@ -14,6 +23,7 @@ import { applyCodexSubscriptionConnectionSchema, } from './services/codexSubscriptionConnection.js' import { applyCodexSubscriptionOAuthStateSchema } from './services/codexSubscriptionOAuthState.js' +import { applyInvitationDeliveryCommandFoundation } from './services/directory/invitationDeliverySchema.js' import { applyGfsUploadCleanupSchema, applyGfsUploadFinalizingSchema, @@ -75,7 +85,7 @@ export type DbTransactionClient = DbClient & { } type DbSessionClient = DbClient & { - release: () => void + release: (destroy?: boolean | Error) => void } type DbConnector = { @@ -83,6 +93,7 @@ type DbConnector = { } const INIT_DB_LOCK_KEY_SQL = "hashtext('control-api-init-db-v1')::bigint" +const migrationLogger = rootLogger.child({ module: 'database-migration' }) type DbMigration = { version: string @@ -6160,6 +6171,44 @@ export const CONTROL_API_MIGRATIONS: DbMigration[] = [ version: '0109_grok_subscription_connections', apply: applyGrokSubscriptionConnectionSchema, }, + { + version: '0109_user_access_foundation', + legacyVersions: ['0107_user_access_foundation', '0101_user_access_foundation'], + apply: applyUserAccessFoundationSchema, + }, + { + version: '010a_invitation_delivery_commands', + legacyVersions: ['0108_invitation_delivery_commands', '0102_invitation_delivery_commands'], + apply: applyInvitationDeliveryCommandFoundation, + }, + { + version: '010b_catalog_utf8_ordering', + legacyVersions: ['0109_catalog_utf8_ordering', '0103_catalog_utf8_ordering'], + apply: applyCatalogUtf8OrderingSchema, + }, + { + version: '010c_composable_catalog_revisions', + legacyVersions: ['010a_composable_catalog_revisions', '0104_composable_catalog_revisions'], + apply: applyComposableCatalogRevisionSchema, + }, + { + // Fix-forward for databases that recorded the first composable-catalog + // body before the GFS resource-component mapping was completed. + version: '010d_gfs_catalog_revision_components', + legacyVersions: [ + '010b_gfs_catalog_revision_components', + '0105_gfs_catalog_revision_components', + ], + apply: applyComposableCatalogRevisionSchema, + }, + { + version: '010e_legacy_password_security_epoch_backfill', + legacyVersions: [ + '010c_legacy_password_security_epoch_backfill', + '0106_legacy_password_security_epoch_backfill', + ], + apply: backfillLegacyPasswordSecurityEpochs, + }, { version: '0110_grok_subscription_oauth_states', apply: applyGrokSubscriptionOAuthStateSchema, @@ -6606,11 +6655,12 @@ async function recordMigration(db: DbClient, version: string): Promise { ) } -async function applyPendingMigrations(db: DbClient): Promise { +async function applyPendingLegacyMigrations(db: DbClient): Promise> { await ensureSchemaMigrationsTable(db) const appliedVersions = await loadAppliedMigrationVersions(db) for (const migration of CONTROL_API_MIGRATIONS) { + if (migration.version > '0106_oauth_grants_owner_generalization') break if (appliedVersions.has(migration.version)) continue if (migration.legacyVersions?.some(version => appliedVersions.has(version))) { await recordMigration(db, migration.version) @@ -6621,30 +6671,41 @@ async function applyPendingMigrations(db: DbClient): Promise { await recordMigration(db, migration.version) appliedVersions.add(migration.version) } + return appliedVersions } export async function initDb(db: DbConnector = pool): Promise { const client = await db.connect() let locked = false let inTransaction = false + let destroySession = false try { + for (const sql of migrationSessionBoundsSql(false)) { + await client.query(sql) + } await client.query(`SELECT pg_advisory_lock(${INIT_DB_LOCK_KEY_SQL})`) locked = true await client.query('BEGIN') inTransaction = true - - await applyPendingMigrations(client) - + const appliedVersions = await applyPendingLegacyMigrations(client) await client.query('COMMIT') inTransaction = false + + await applyPendingPr1Migrations({ + db: client, + migrations: CONTROL_API_MIGRATIONS, + appliedVersions, + recordMigration, + }) } catch (error) { + destroySession = true if (inTransaction) { try { await client.query('ROLLBACK') } catch (rollbackError) { - console.warn('[ControlAPI] initDb rollback failed:', rollbackError) + migrationLogger.warn({ err: rollbackError }, 'Database migration rollback failed') } } throw error @@ -6653,10 +6714,11 @@ export async function initDb(db: DbConnector = pool): Promise { try { await client.query(`SELECT pg_advisory_unlock(${INIT_DB_LOCK_KEY_SQL})`) } catch (unlockError) { - console.warn('[ControlAPI] initDb advisory unlock failed:', unlockError) + destroySession = true + migrationLogger.warn({ err: unlockError }, 'Database migration advisory unlock failed') } } - client.release() + client.release(destroySession) } } @@ -6676,47 +6738,65 @@ export async function assertDbReady(db: DbClient = pool): Promise { export async function withTransaction( work: (db: DbTransactionClient) => Promise, - // Injectable only so the carrier's client lifecycle can be unit-tested against - // a fake Pool; production always uses the module-level core pool. - txPool: Pool = pool + txPoolOrOptions: + | Pool + | { onDatabaseFailure?: (error: unknown) => unknown } = pool ): Promise { - const client = (await txPool.connect()) as PoolClient + // Tests may inject a Pool to exercise client lifecycle behavior. Session + // persistence callers may instead provide a failure mapper so database + // errors retain their typed provenance. Production defaults to the core pool. + const isPool = typeof (txPoolOrOptions as Pool).connect === 'function' + const txPool = isPool ? (txPoolOrOptions as Pool) : pool + const options: { onDatabaseFailure?: (error: unknown) => unknown } = isPool + ? {} + : (txPoolOrOptions as { onDatabaseFailure?: (error: unknown) => unknown }) + let client: PoolClient + try { + client = (await txPool.connect()) as PoolClient + } catch (error) { + throw options.onDatabaseFailure ? options.onDatabaseFailure(error) : error + } let transactionStarted = false let commitSent = false let releaseError: Error | boolean | undefined - // pg-pool detaches its own idle 'error' listener from a client while it is - // checked out, so an out-of-band backend error on the borrowed client (e.g. - // Postgres terminating the backend on idle_in_transaction_session_timeout, - // 25P03, while the transaction awaits an external POST with no query running) - // arrives with NO 'error' listener. Node's EventEmitter then THROWS in that - // tick — an async event outside any await stack, so no try/catch here or in a - // caller can catch it — and with no process-level handler it exits the process. - // The carrier owns the client for the whole checkout, so it holds the listener: - // capture the async error and fold it into releaseError so pg-pool DESTROYS the - // poisoned connection instead of returning it to the pool. + // pg-pool removes its idle 'error' listener while a client is checked out. + // Hold one for the transaction lifetime so out-of-band backend termination + // cannot become an uncaught EventEmitter error, and destroy the poisoned + // connection on release. let asyncClientError: Error | undefined const onClientError = (err: unknown): void => { asyncClientError ??= err instanceof Error ? err : new Error(String(err)) } client.on('error', onClientError) + const transactionClient: DbClient = options.onDatabaseFailure + ? Object.assign(Object.create(client) as DbClient, { + query: async (text: string, values?: unknown[]) => { + try { + return await client.query(text, values) + } catch (error) { + throw options.onDatabaseFailure!(error) + } + }, + }) + : (client as DbClient) try { - await client.query('BEGIN') + await transactionClient.query('BEGIN') transactionStarted = true // The brand is nominal-only (a declared unique symbol); the checked-out // client IS the transaction session, so this cast is the single blessed // point where the brand is minted (issue #375 M3). - const result = await work(client as unknown as DbTransactionClient) + const result = await work(transactionClient as unknown as DbTransactionClient) commitSent = true - await client.query('COMMIT') + await transactionClient.query('COMMIT') return result } catch (error) { if (commitSent || !transactionStarted) { releaseError = error instanceof Error ? error : true } else { try { - await client.query('ROLLBACK') + await transactionClient.query('ROLLBACK') } catch (rollbackError) { releaseError = rollbackError instanceof Error ? rollbackError : true } diff --git a/control-api/src/services/access/userAccessFoundationSchema.ts b/control-api/src/services/access/userAccessFoundationSchema.ts new file mode 100644 index 0000000000..82d7d8cb30 --- /dev/null +++ b/control-api/src/services/access/userAccessFoundationSchema.ts @@ -0,0 +1,499 @@ +import type { DbClient } from '../../db.js' + +/** + * Additive persistence foundation for user-session v2 and user-centric access. + * + * The operational tables intentionally contain provider state only. They do + * not materialize a user's effective access and are never final action + * authority. Relist staging is separate so an indexer can promote a complete + * generation in one transaction without exposing a half-reconciled list. + */ +export async function applyUserAccessFoundationSchema(db: DbClient): Promise { + await db.query(` + CREATE TABLE IF NOT EXISTS external_user_sessions ( + sid UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + session_version INTEGER NOT NULL DEFAULT 1 CHECK (session_version > 0), + current_jti UUID NOT NULL, + current_issued_at TIMESTAMPTZ NOT NULL, + prior_jti UUID, + prior_jti_expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + idle_expires_at TIMESTAMPTZ NOT NULL, + absolute_expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + revocation_reason TEXT, + authentication_methods TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + authenticated_at TIMESTAMPTZ NOT NULL, + CHECK (idle_expires_at <= absolute_expires_at), + CHECK ( + (prior_jti IS NULL AND prior_jti_expires_at IS NULL) + OR (prior_jti IS NOT NULL AND prior_jti_expires_at IS NOT NULL) + ) + ); + + CREATE INDEX IF NOT EXISTS external_user_sessions_user_live_idx + ON external_user_sessions (user_id, absolute_expires_at, idle_expires_at) + WHERE revoked_at IS NULL; + CREATE INDEX IF NOT EXISTS external_user_sessions_idle_idx + ON external_user_sessions (idle_expires_at) + WHERE revoked_at IS NULL; + + CREATE TABLE IF NOT EXISTS external_user_session_security_epochs ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + valid_after TIMESTAMPTZ NOT NULL, + reason TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE TABLE IF NOT EXISTS external_v1_session_revocations ( + token_hash TEXT PRIMARY KEY CHECK (token_hash ~ '^[0-9a-f]{64}$'), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + reason TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS external_v1_session_revocations_user_idx + ON external_v1_session_revocations (user_id, expires_at); + CREATE INDEX IF NOT EXISTS external_v1_session_revocations_expiry_idx + ON external_v1_session_revocations (expires_at); + + CREATE TABLE IF NOT EXISTS authorization_user_revisions ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + revision BIGINT NOT NULL DEFAULT 1 CHECK (revision > 0), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE TABLE IF NOT EXISTS authorization_team_revisions ( + team_id UUID PRIMARY KEY REFERENCES teams(id) ON DELETE CASCADE, + revision BIGINT NOT NULL DEFAULT 1 CHECK (revision > 0), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE TABLE IF NOT EXISTS authorization_resource_revisions ( + environment_id TEXT NOT NULL, + resource_type TEXT NOT NULL, + resource_id TEXT NOT NULL, + revision BIGINT NOT NULL DEFAULT 1 CHECK (revision > 0), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (environment_id, resource_type, resource_id), + CHECK (environment_id <> '' AND resource_type <> '' AND resource_id <> '') + ); + CREATE INDEX IF NOT EXISTS authorization_resource_revisions_updated_idx + ON authorization_resource_revisions (environment_id, updated_at DESC); + + CREATE TABLE IF NOT EXISTS operational_catalog_source_state ( + environment_id TEXT NOT NULL, + source_family TEXT NOT NULL, + generation BIGINT NOT NULL DEFAULT 0 CHECK (generation >= 0), + staging_generation BIGINT CHECK (staging_generation > generation), + resource_version TEXT, + status TEXT NOT NULL DEFAULT 'relisting' + CHECK (status IN ('current', 'relisting', 'unavailable')), + last_success_at TIMESTAMPTZ, + last_error_at TIMESTAMPTZ, + safe_error_code TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (environment_id, source_family), + CHECK (environment_id <> '' AND source_family <> '') + ); + + CREATE TABLE IF NOT EXISTS operational_resource_index ( + environment_id TEXT NOT NULL, + resource_type TEXT NOT NULL, + logical_id TEXT NOT NULL, + source_family TEXT NOT NULL, + source_generation BIGINT NOT NULL CHECK (source_generation > 0), + provider_uid TEXT NOT NULL, + provider_resource_version TEXT NOT NULL, + display_name TEXT, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + deleted_at TIMESTAMPTZ, + observed_generation BIGINT, + content_bytes BIGINT NOT NULL CHECK (content_bytes >= 0), + observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (environment_id, resource_type, logical_id), + CHECK ( + environment_id <> '' AND resource_type <> '' AND logical_id <> '' + AND source_family <> '' AND provider_uid <> '' + AND provider_resource_version <> '' + ) + ); + CREATE INDEX IF NOT EXISTS operational_resource_source_idx + ON operational_resource_index ( + environment_id, source_family, source_generation, resource_type, logical_id + ); + + CREATE TABLE IF NOT EXISTS operational_resource_relationships ( + environment_id TEXT NOT NULL, + source_type TEXT NOT NULL, + source_id TEXT NOT NULL, + relationship_type TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id TEXT NOT NULL, + relationship_instance_id TEXT NOT NULL, + behavior_attributes JSONB NOT NULL DEFAULT '{}'::JSONB, + source_family TEXT NOT NULL, + source_provider_uid TEXT NOT NULL, + source_resource_version TEXT NOT NULL, + source_generation BIGINT NOT NULL CHECK (source_generation > 0), + observed_generation BIGINT, + content_bytes BIGINT NOT NULL CHECK (content_bytes >= 0), + observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY ( + environment_id, source_type, source_id, relationship_type, + target_type, target_id, relationship_instance_id + ), + CHECK (jsonb_typeof(behavior_attributes) = 'object'), + CHECK ( + environment_id <> '' AND source_type <> '' AND source_id <> '' + AND relationship_type <> '' AND target_type <> '' AND target_id <> '' + AND relationship_instance_id <> '' AND source_family <> '' + AND source_provider_uid <> '' AND source_resource_version <> '' + ) + ); + CREATE INDEX IF NOT EXISTS operational_relationship_source_idx + ON operational_resource_relationships ( + environment_id, source_type, source_id, relationship_type, + target_type, target_id, relationship_instance_id + ); + CREATE INDEX IF NOT EXISTS operational_relationship_target_idx + ON operational_resource_relationships ( + environment_id, target_type, target_id, source_type, + source_id, relationship_instance_id + ); + CREATE INDEX IF NOT EXISTS operational_relationship_generation_idx + ON operational_resource_relationships ( + environment_id, source_family, source_generation, source_type, source_id + ); + + CREATE TABLE IF NOT EXISTS operational_resource_index_staging + (LIKE operational_resource_index INCLUDING DEFAULTS INCLUDING CONSTRAINTS); + CREATE UNIQUE INDEX IF NOT EXISTS operational_resource_staging_identity_idx + ON operational_resource_index_staging ( + environment_id, source_family, source_generation, resource_type, logical_id + ); + + CREATE TABLE IF NOT EXISTS operational_relationships_staging + (LIKE operational_resource_relationships INCLUDING DEFAULTS INCLUDING CONSTRAINTS); + CREATE UNIQUE INDEX IF NOT EXISTS operational_relationship_staging_identity_idx + ON operational_relationships_staging ( + environment_id, source_family, source_generation, source_type, source_id, + relationship_type, target_type, target_id, relationship_instance_id + ); + + CREATE INDEX IF NOT EXISTS team_members_user_active_idx + ON team_members (user_id, status, team_id) INCLUDE (role, updated_at); + CREATE INDEX IF NOT EXISTS user_contexts_context_user_idx + ON user_contexts (context_id, user_id); + CREATE INDEX IF NOT EXISTS team_contexts_context_team_idx + ON team_contexts (context_id, team_id); + CREATE INDEX IF NOT EXISTS user_agents_agent_user_idx + ON user_agents (agent_name, user_id); + CREATE INDEX IF NOT EXISTS team_agents_agent_team_idx + ON team_agents (agent_name, team_id); + CREATE INDEX IF NOT EXISTS user_workflow_triggers_recipe_user_idx + ON user_workflow_triggers (recipe_namespace, recipe_name, user_id); + CREATE INDEX IF NOT EXISTS team_workflow_triggers_recipe_team_idx + ON team_workflow_triggers (recipe_namespace, recipe_name, team_id); + + CREATE INDEX IF NOT EXISTS workflow_runs_actor_catalog_idx + ON workflow_runs (actor_id, run_id) + INCLUDE (recipe_namespace, recipe_name, phase, team_id, usage_team_id) + WHERE actor_type = 'user' AND actor_id IS NOT NULL; + CREATE INDEX IF NOT EXISTS workflow_runs_team_catalog_idx + ON workflow_runs (team_id, run_id) + INCLUDE (recipe_namespace, recipe_name, phase, actor_type, actor_id, usage_team_id) + WHERE team_id IS NOT NULL; + CREATE INDEX IF NOT EXISTS workflow_runs_usage_team_catalog_idx + ON workflow_runs (usage_team_id, run_id) + INCLUDE (recipe_namespace, recipe_name, phase, actor_type, actor_id, team_id) + WHERE usage_team_id IS NOT NULL; + + CREATE INDEX IF NOT EXISTS workflow_approval_user_catalog_idx + ON workflow_approval_requests (target_user_id, id) + INCLUDE (status, expires_at, recipe_namespace, recipe_name) + WHERE target_user_id IS NOT NULL; + CREATE INDEX IF NOT EXISTS workflow_approval_team_catalog_idx + ON workflow_approval_requests (target_team_id, id) + INCLUDE (status, expires_at, recipe_namespace, recipe_name) + WHERE target_team_id IS NOT NULL; + + CREATE INDEX IF NOT EXISTS notification_user_catalog_idx + ON notification_deliveries ((audience->>'userId'), id) + INCLUDE (expires_at, status, event_type) + WHERE audience ? 'userId'; + CREATE INDEX IF NOT EXISTS notification_team_catalog_idx + ON notification_deliveries ((audience->>'teamId'), id) + INCLUDE (expires_at, status, event_type) + WHERE audience ? 'teamId'; + + CREATE INDEX IF NOT EXISTS gfs_grants_subject_resource_catalog_idx + ON gfs_grants (subject_type, subject_id, resource_id) + INCLUDE (id, drive, permissions, inherit); + CREATE INDEX IF NOT EXISTS gfs_shares_subject_resource_catalog_idx + ON gfs_shares (subject_type, subject_id, resource_id) + INCLUDE (id, drive, permissions, include_descendants); + `) + + await applyAuthorizationRevisionFunctions(db) + await applyAuthorizationRevisionTriggers(db) + await applyUserAccessRuntimePrivileges(db) +} + +async function applyAuthorizationRevisionFunctions(db: DbClient): Promise { + await db.query(` + CREATE OR REPLACE FUNCTION authorization_bump_user_revision(target_user_id UUID) + RETURNS VOID + LANGUAGE SQL + SECURITY DEFINER + SET search_path = pg_catalog, public + AS $$ + INSERT INTO authorization_user_revisions(user_id, revision, updated_at) + VALUES(target_user_id, 1, clock_timestamp()) + ON CONFLICT (user_id) DO UPDATE + SET revision = authorization_user_revisions.revision + 1, + updated_at = clock_timestamp(); + $$; + + CREATE OR REPLACE FUNCTION authorization_bump_team_revision(target_team_id UUID) + RETURNS VOID + LANGUAGE SQL + SECURITY DEFINER + SET search_path = pg_catalog, public + AS $$ + INSERT INTO authorization_team_revisions(team_id, revision, updated_at) + VALUES(target_team_id, 1, clock_timestamp()) + ON CONFLICT (team_id) DO UPDATE + SET revision = authorization_team_revisions.revision + 1, + updated_at = clock_timestamp(); + $$; + + CREATE OR REPLACE FUNCTION authorization_bump_resource_revision( + target_environment_id TEXT, + target_resource_type TEXT, + target_resource_id TEXT + ) + RETURNS VOID + LANGUAGE SQL + SECURITY DEFINER + SET search_path = pg_catalog, public + AS $$ + INSERT INTO authorization_resource_revisions( + environment_id, resource_type, resource_id, revision, updated_at + ) + VALUES(target_environment_id, target_resource_type, target_resource_id, 1, clock_timestamp()) + ON CONFLICT (environment_id, resource_type, resource_id) DO UPDATE + SET revision = authorization_resource_revisions.revision + 1, + updated_at = clock_timestamp(); + $$; + + REVOKE ALL ON FUNCTION authorization_bump_user_revision(UUID) FROM PUBLIC; + REVOKE ALL ON FUNCTION authorization_bump_team_revision(UUID) FROM PUBLIC; + REVOKE ALL ON FUNCTION authorization_bump_resource_revision(TEXT, TEXT, TEXT) FROM PUBLIC; + GRANT EXECUTE ON FUNCTION authorization_bump_user_revision(UUID) TO control_api_runtime; + GRANT EXECUTE ON FUNCTION authorization_bump_team_revision(UUID) TO control_api_runtime; + GRANT EXECUTE ON FUNCTION authorization_bump_resource_revision(TEXT, TEXT, TEXT) + TO control_api_runtime; + `) +} + +async function applyAuthorizationRevisionTriggers(db: DbClient): Promise { + await db.query(` + CREATE OR REPLACE FUNCTION authorization_bump_team_membership_revision() + RETURNS TRIGGER + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = pg_catalog, public + AS $$ + BEGIN + IF TG_OP <> 'DELETE' THEN + PERFORM authorization_bump_user_revision(NEW.user_id); + PERFORM authorization_bump_team_revision(NEW.team_id); + END IF; + IF TG_OP <> 'INSERT' AND ( + TG_OP = 'DELETE' OR OLD.user_id IS DISTINCT FROM NEW.user_id + OR OLD.team_id IS DISTINCT FROM NEW.team_id + ) THEN + PERFORM authorization_bump_user_revision(OLD.user_id); + PERFORM authorization_bump_team_revision(OLD.team_id); + END IF; + RETURN COALESCE(NEW, OLD); + END; + $$; + + CREATE OR REPLACE FUNCTION authorization_bump_user_grant_revision() + RETURNS TRIGGER + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = pg_catalog, public + AS $$ + BEGIN + IF TG_OP <> 'DELETE' THEN + PERFORM authorization_bump_user_revision(NEW.user_id); + END IF; + IF TG_OP <> 'INSERT' AND ( + TG_OP = 'DELETE' OR OLD.user_id IS DISTINCT FROM NEW.user_id + ) THEN + PERFORM authorization_bump_user_revision(OLD.user_id); + END IF; + RETURN COALESCE(NEW, OLD); + END; + $$; + + CREATE OR REPLACE FUNCTION authorization_bump_team_grant_revision() + RETURNS TRIGGER + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = pg_catalog, public + AS $$ + BEGIN + IF TG_OP <> 'DELETE' THEN + PERFORM authorization_bump_team_revision(NEW.team_id); + END IF; + IF TG_OP <> 'INSERT' AND ( + TG_OP = 'DELETE' OR OLD.team_id IS DISTINCT FROM NEW.team_id + ) THEN + PERFORM authorization_bump_team_revision(OLD.team_id); + END IF; + RETURN COALESCE(NEW, OLD); + END; + $$; + + CREATE OR REPLACE FUNCTION authorization_bump_operational_resource_revision() + RETURNS TRIGGER + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = pg_catalog, public + AS $$ + BEGIN + IF TG_OP <> 'DELETE' THEN + PERFORM authorization_bump_resource_revision( + NEW.environment_id, NEW.resource_type, NEW.logical_id + ); + END IF; + IF TG_OP <> 'INSERT' AND ( + TG_OP = 'DELETE' + OR OLD.environment_id IS DISTINCT FROM NEW.environment_id + OR OLD.resource_type IS DISTINCT FROM NEW.resource_type + OR OLD.logical_id IS DISTINCT FROM NEW.logical_id + ) THEN + PERFORM authorization_bump_resource_revision( + OLD.environment_id, OLD.resource_type, OLD.logical_id + ); + END IF; + RETURN COALESCE(NEW, OLD); + END; + $$; + + CREATE OR REPLACE FUNCTION authorization_bump_operational_relationship_revision() + RETURNS TRIGGER + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = pg_catalog, public + AS $$ + BEGIN + IF TG_OP <> 'DELETE' THEN + PERFORM authorization_bump_resource_revision( + NEW.environment_id, NEW.source_type, NEW.source_id + ); + PERFORM authorization_bump_resource_revision( + NEW.environment_id, NEW.target_type, NEW.target_id + ); + END IF; + IF TG_OP <> 'INSERT' THEN + PERFORM authorization_bump_resource_revision( + OLD.environment_id, OLD.source_type, OLD.source_id + ); + PERFORM authorization_bump_resource_revision( + OLD.environment_id, OLD.target_type, OLD.target_id + ); + END IF; + RETURN COALESCE(NEW, OLD); + END; + $$; + + DO $$ + DECLARE trigger_spec RECORD; + BEGIN + FOR trigger_spec IN + SELECT * FROM (VALUES + ('team_members', 'team_members_authorization_revision', + 'authorization_bump_team_membership_revision'), + ('user_contexts', 'user_contexts_authorization_revision', + 'authorization_bump_user_grant_revision'), + ('user_agents', 'user_agents_authorization_revision', + 'authorization_bump_user_grant_revision'), + ('user_workflow_triggers', 'user_workflow_authorization_revision', + 'authorization_bump_user_grant_revision'), + ('team_contexts', 'team_contexts_authorization_revision', + 'authorization_bump_team_grant_revision'), + ('team_agents', 'team_agents_authorization_revision', + 'authorization_bump_team_grant_revision'), + ('team_workflow_triggers', 'team_workflow_authorization_revision', + 'authorization_bump_team_grant_revision'), + ('operational_resource_index', 'operational_resource_authorization_revision', + 'authorization_bump_operational_resource_revision'), + ('operational_resource_relationships', + 'operational_relationship_authorization_revision', + 'authorization_bump_operational_relationship_revision') + ) AS specs(table_name, trigger_name, function_name) + LOOP + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = trigger_spec.trigger_name + ) THEN + EXECUTE format( + 'CREATE TRIGGER %I AFTER INSERT OR UPDATE OR DELETE ON %I ' + || 'FOR EACH ROW EXECUTE FUNCTION %I()', + trigger_spec.trigger_name, + trigger_spec.table_name, + trigger_spec.function_name + ); + END IF; + END LOOP; + END; + $$; + + REVOKE ALL ON FUNCTION authorization_bump_team_membership_revision() FROM PUBLIC; + REVOKE ALL ON FUNCTION authorization_bump_user_grant_revision() FROM PUBLIC; + REVOKE ALL ON FUNCTION authorization_bump_team_grant_revision() FROM PUBLIC; + REVOKE ALL ON FUNCTION authorization_bump_operational_resource_revision() FROM PUBLIC; + REVOKE ALL ON FUNCTION authorization_bump_operational_relationship_revision() FROM PUBLIC; + `) +} + +async function applyUserAccessRuntimePrivileges(db: DbClient): Promise { + await db.query(` + REVOKE ALL PRIVILEGES ON TABLE + external_user_sessions, + external_user_session_security_epochs, + external_v1_session_revocations, + authorization_user_revisions, + authorization_team_revisions, + authorization_resource_revisions, + operational_catalog_source_state, + operational_resource_index, + operational_resource_relationships, + operational_resource_index_staging, + operational_relationships_staging + FROM PUBLIC, control_api_runtime, trace_maintenance_runtime, workflow_recipes_runtime; + + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE + external_user_sessions, + external_user_session_security_epochs, + external_v1_session_revocations, + operational_catalog_source_state, + operational_resource_index, + operational_resource_relationships, + operational_resource_index_staging, + operational_relationships_staging + TO control_api_runtime; + + GRANT SELECT, INSERT, UPDATE ON TABLE + authorization_user_revisions, + authorization_team_revisions, + authorization_resource_revisions + TO control_api_runtime; + `) +} diff --git a/control-api/test/db.userAccessFoundationMigration.test.ts b/control-api/test/db.userAccessFoundationMigration.test.ts new file mode 100644 index 0000000000..b572f4abfd --- /dev/null +++ b/control-api/test/db.userAccessFoundationMigration.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, vi } from 'vitest' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +vi.mock('../src/config.js', () => ({ + config: { pgConnectionString: 'postgres://unused' }, +})) + +async function migrationSql(): Promise { + const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') + const migration = CONTROL_API_MIGRATIONS.find( + candidate => candidate.version === '0091_user_access_foundation' + ) + expect(migration).toBeDefined() + + const query = vi.fn(async () => ({ rows: [], rowCount: 0 })) + await migration!.apply({ query }) + return query.mock.calls.map(call => String(call[0])).join('\n') +} + +describe('user-access foundation migration', () => { + it('adds the stateful user session and revision foundations additively', async () => { + const sql = await migrationSql() + + expect(sql).toContain('CREATE TABLE IF NOT EXISTS external_user_sessions') + expect(sql).toContain('current_jti UUID NOT NULL') + expect(sql).toContain('prior_jti_expires_at TIMESTAMPTZ') + expect(sql).toContain('idle_expires_at TIMESTAMPTZ NOT NULL') + expect(sql).toContain('absolute_expires_at TIMESTAMPTZ NOT NULL') + expect(sql).toContain('CREATE TABLE IF NOT EXISTS external_user_session_security_epochs') + expect(sql).toContain('CREATE TABLE IF NOT EXISTS external_v1_session_revocations') + expect(sql).toContain('CREATE TABLE IF NOT EXISTS authorization_user_revisions') + expect(sql).toContain('CREATE TABLE IF NOT EXISTS authorization_team_revisions') + expect(sql).toContain('CREATE TABLE IF NOT EXISTS authorization_resource_revisions') + expect(sql).toContain('SECURITY DEFINER') + expect(sql).toContain('authorization_bump_team_membership_revision') + expect(sql).not.toMatch(/DROP\s+(?:TABLE|COLUMN)/i) + }) + + it('stores operational facts and staging generations without effective access', async () => { + const sql = await migrationSql() + + expect(sql).toContain('CREATE TABLE IF NOT EXISTS operational_catalog_source_state') + expect(sql).toContain('CREATE TABLE IF NOT EXISTS operational_resource_index') + expect(sql).toContain('CREATE TABLE IF NOT EXISTS operational_resource_relationships') + expect(sql).toContain('CREATE TABLE IF NOT EXISTS operational_resource_index_staging') + expect(sql).toContain('CREATE TABLE IF NOT EXISTS operational_relationships_staging') + expect(sql).toContain('staging_generation BIGINT') + expect(sql).toContain('operational_relationship_target_idx') + expect(sql).toContain('relationship_instance_id TEXT NOT NULL') + + const currentResourceTable = sql.match( + /CREATE TABLE IF NOT EXISTS operational_resource_index \(([\s\S]*?)\n \);/ + )?.[1] + const currentRelationshipTable = sql.match( + /CREATE TABLE IF NOT EXISTS operational_resource_relationships \(([\s\S]*?)\n \);/ + )?.[1] + expect(currentResourceTable).toBeDefined() + expect(currentRelationshipTable).toBeDefined() + expect(`${currentResourceTable}\n${currentRelationshipTable}`).not.toMatch( + /\b(?:user_id|team_id|role|grant|capabilit(?:y|ies))\b/ + ) + expect(sql).not.toMatch(/CREATE\s+MATERIALIZED\s+VIEW/i) + }) + + it('adds keyset and reverse indexes before catalog producers are introduced', async () => { + const sql = await migrationSql() + + for (const expectedIndex of [ + 'team_members_user_active_idx', + 'user_contexts_context_user_idx', + 'team_contexts_context_team_idx', + 'user_agents_agent_user_idx', + 'team_agents_agent_team_idx', + 'user_workflow_triggers_recipe_user_idx', + 'team_workflow_triggers_recipe_team_idx', + 'workflow_runs_actor_catalog_idx', + 'workflow_runs_team_catalog_idx', + 'workflow_runs_usage_team_catalog_idx', + 'workflow_approval_user_catalog_idx', + 'workflow_approval_team_catalog_idx', + 'notification_user_catalog_idx', + 'notification_team_catalog_idx', + 'gfs_grants_subject_resource_catalog_idx', + 'gfs_shares_subject_resource_catalog_idx', + ]) { + expect(sql).toContain(expectedIndex) + } + expect(sql).toContain('ON workflow_runs (actor_id, run_id)') + expect(sql).toContain("ON notification_deliveries ((audience->>'userId'), id)") + }) + + it('declares exact least-privilege profiles for every new relation', () => { + const contractPath = join( + dirname(fileURLToPath(import.meta.url)), + '../../deploy/scripts/control-api-runtime-access-profiles.tsv' + ) + const entries = new Map( + readFileSync(contractPath, 'utf8') + .split('\n') + .filter(line => line && !line.startsWith('#')) + .map(line => line.split('\t') as [string, string]) + ) + + const expected = new Map([ + ['authorization_resource_revisions', 'upsert'], + ['authorization_team_revisions', 'upsert'], + ['authorization_user_revisions', 'upsert'], + ['external_user_session_security_epochs', 'legacy_dml'], + ['external_user_sessions', 'legacy_dml'], + ['external_v1_session_revocations', 'legacy_dml'], + ['operational_catalog_source_state', 'legacy_dml'], + ['operational_relationships_staging', 'legacy_dml'], + ['operational_resource_index', 'legacy_dml'], + ['operational_resource_index_staging', 'legacy_dml'], + ['operational_resource_relationships', 'legacy_dml'], + ]) + + for (const [relation, profile] of expected) { + expect(entries.get(relation), relation).toBe(profile) + } + }) +}) diff --git a/deploy/scripts/control-api-runtime-access-profiles.tsv b/deploy/scripts/control-api-runtime-access-profiles.tsv index 09fcf3ccd2..edbe41b9a4 100644 --- a/deploy/scripts/control-api-runtime-access-profiles.tsv +++ b/deploy/scripts/control-api-runtime-access-profiles.tsv @@ -1,6 +1,9 @@ # public relationcontrol_api_runtime access profile administrative_events append agent_run_events append +authorization_resource_revisions upsert +authorization_team_revisions upsert +authorization_user_revisions upsert budget_pending_reservations legacy_dml codex_subscription_connections upsert codex_subscription_oauth_states upsert @@ -14,6 +17,9 @@ control_admin_invitations legacy_dml control_admin_password_reset_requests legacy_dml control_admin_revoked_tokens legacy_dml control_admin_users legacy_dml +external_user_session_security_epochs legacy_dml +external_user_sessions legacy_dml +external_v1_session_revocations legacy_dml gfs_audit legacy_dml gfs_blob_manifests none gfs_desktop_operator_links link_lifecycle @@ -46,6 +52,11 @@ mcp_secret_rollback_permits legacy_dml notification_deliveries legacy_dml dynamic_clients legacy_dml oauth_grants legacy_dml +operational_catalog_source_state legacy_dml +operational_relationships_staging legacy_dml +operational_resource_index legacy_dml +operational_resource_index_staging legacy_dml +operational_resource_relationships legacy_dml plugin_workload_sdk_credential_ticket_jtis legacy_dml plugin_workload_sdk_invocation_attempts legacy_dml plugin_workload_sdk_grants legacy_dml From ddece27a10fb86e90d87293312a593fbdf892579 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 10 Aug 2026 22:12:19 -0600 Subject: [PATCH 007/528] feat(auth): implement the user-session v2 lifecycle Add a strict user-only RS256 contract, live session state, rotating renewal, bounded overlap, replay revocation, idle and absolute expiry, and current or all-session revocation. Keep v1 compatibility revocable through token fingerprints and user security epochs without enabling v2 issuance. --- control-api/src/profileTypes.ts | 1 + .../src/services/auth/userSessionService.ts | 484 ++++++++++++++++++ .../utils/auth/externalSessionAuthToken.ts | 1 + .../src/utils/auth/userSessionV2Token.ts | 155 ++++++ .../test/userSessionRevokeAllAtomic.test.ts | 45 ++ control-api/test/userSessionService.test.ts | 197 +++++++ control-api/test/userSessionToken.test.ts | 111 ++++ 7 files changed, 994 insertions(+) create mode 100644 control-api/src/services/auth/userSessionService.ts create mode 100644 control-api/src/utils/auth/userSessionV2Token.ts create mode 100644 control-api/test/userSessionRevokeAllAtomic.test.ts create mode 100644 control-api/test/userSessionService.test.ts create mode 100644 control-api/test/userSessionToken.test.ts diff --git a/control-api/src/profileTypes.ts b/control-api/src/profileTypes.ts index ee30f86f8b..030fae5aec 100644 --- a/control-api/src/profileTypes.ts +++ b/control-api/src/profileTypes.ts @@ -49,6 +49,7 @@ export type AuthClaims = { /** Authoritative users.lifecycle_version captured when the session was issued. */ authGeneration: number exp: number + iat?: number } export type RpcAccessClaims = { diff --git a/control-api/src/services/auth/userSessionService.ts b/control-api/src/services/auth/userSessionService.ts new file mode 100644 index 0000000000..ec9026c08f --- /dev/null +++ b/control-api/src/services/auth/userSessionService.ts @@ -0,0 +1,484 @@ +import { createHash, randomUUID } from 'node:crypto' +import { type DbClient, pool, withTransaction } from '../../db.js' +import type { AuthClaims } from '../../profileTypes.js' +import type { UserSessionV2Claims } from '../../utils/auth/userSessionV2Token.js' +import { + USER_SESSION_V2_TTL_SECONDS, + signUserSessionV2Token, + verifyUserSessionV2Token, +} from '../../utils/auth/userSessionV2Token.js' + +export const USER_SESSION_IDLE_LIFETIME_SECONDS = 14 * 24 * 60 * 60 +export const USER_SESSION_ABSOLUTE_LIFETIME_SECONDS = 30 * 24 * 60 * 60 +export const USER_SESSION_RENEWAL_OVERLAP_SECONDS = 10 +export const USER_SESSION_MAX_ACTIVE_PER_USER = 20 + +type SessionDatabase = Pick + +type SessionRow = { + sid: string + user_id: string + email: string + session_version: number + current_jti: string + current_issued_at: Date | string + prior_jti: string | null + prior_jti_expires_at: Date | string | null + created_at: Date | string + last_used_at: Date | string + idle_expires_at: Date | string + absolute_expires_at: Date | string + revoked_at: Date | string | null + revocation_reason: string | null + authentication_methods: string[] + authenticated_at: Date | string +} + +export type UserSessionIdentity = { + userId: string + email: string + sid: string + jti: string + sessionVersion: number + expiresAt: Date + absoluteExpiresAt: Date + authenticationMethods: string[] +} + +export type UserSessionValidation = + | { status: 'valid'; identity: UserSessionIdentity } + | { status: 'invalid' | 'expired' | 'revoked'; reason: string } + +export type IssuedUserSession = { + token: string + expiresInSeconds: number + identity: UserSessionIdentity +} + +type SessionClock = { now?: Date } + +function atSecond(date: Date): Date { + return new Date(Math.floor(date.getTime() / 1000) * 1000) +} + +function plusSeconds(date: Date, seconds: number): Date { + return new Date(date.getTime() + seconds * 1000) +} + +function dateOf(value: Date | string): Date { + return value instanceof Date ? value : new Date(value) +} + +function nullableDateOf(value: Date | string | null): Date | null { + return value === null ? null : dateOf(value) +} + +function identityFromRow(row: SessionRow): UserSessionIdentity { + return { + userId: row.user_id, + email: row.email, + sid: row.sid, + jti: row.current_jti, + sessionVersion: Number(row.session_version), + expiresAt: plusSeconds(dateOf(row.current_issued_at), USER_SESSION_V2_TTL_SECONDS), + absoluteExpiresAt: dateOf(row.absolute_expires_at), + authenticationMethods: [...row.authentication_methods], + } +} + +function tokenFromRow(row: SessionRow): string { + return signUserSessionV2Token( + { + sub: row.user_id, + sid: row.sid, + jti: row.current_jti, + sv: Number(row.session_version), + email: row.email, + auth_time: Math.floor(dateOf(row.authenticated_at).getTime() / 1000), + amr: [...row.authentication_methods], + }, + Math.floor(dateOf(row.current_issued_at).getTime() / 1000) + ) +} + +async function loadSessionForUpdate(db: SessionDatabase, sid: string): Promise { + const result = await db.query( + `SELECT s.sid, s.user_id, u.email, s.session_version, + s.current_jti, s.current_issued_at, + s.prior_jti, s.prior_jti_expires_at, + s.created_at, s.last_used_at, s.idle_expires_at, + s.absolute_expires_at, s.revoked_at, s.revocation_reason, + s.authentication_methods, s.authenticated_at + FROM external_user_sessions s + JOIN users u ON u.id = s.user_id + WHERE s.sid = $1 + FOR UPDATE OF s`, + [sid] + ) + return (result.rows[0] as SessionRow | undefined) ?? null +} + +async function revokeLoadedSession( + db: SessionDatabase, + row: SessionRow, + reason: string, + now: Date +): Promise { + await db.query( + `UPDATE external_user_sessions + SET revoked_at = COALESCE(revoked_at, $2), + revocation_reason = COALESCE(revocation_reason, $3), + session_version = session_version + CASE WHEN revoked_at IS NULL THEN 1 ELSE 0 END + WHERE sid = $1`, + [row.sid, now, reason] + ) +} + +async function validateLoadedSession( + db: SessionDatabase, + row: SessionRow | null, + claims: UserSessionV2Claims, + now: Date, + touch: boolean +): Promise { + if (!row || row.user_id !== claims.sub) return { status: 'invalid', reason: 'session_not_found' } + if (row.revoked_at) return { status: 'revoked', reason: row.revocation_reason || 'revoked' } + if (Number(row.session_version) !== claims.sv) { + return { status: 'revoked', reason: 'session_version_mismatch' } + } + if (now >= dateOf(row.absolute_expires_at)) { + await revokeLoadedSession(db, row, 'absolute_expired', now) + return { status: 'expired', reason: 'absolute_expired' } + } + if (now >= dateOf(row.idle_expires_at)) { + await revokeLoadedSession(db, row, 'idle_expired', now) + return { status: 'expired', reason: 'idle_expired' } + } + + const priorExpiry = nullableDateOf(row.prior_jti_expires_at) + const acceptedCurrent = claims.jti === row.current_jti + const acceptedPrior = + claims.jti === row.prior_jti && priorExpiry !== null && now.getTime() <= priorExpiry.getTime() + if (!acceptedCurrent && !acceptedPrior) { + await revokeLoadedSession(db, row, 'representation_reuse', now) + return { status: 'revoked', reason: 'representation_reuse' } + } + + if (touch) { + await db.query( + `UPDATE external_user_sessions + SET last_used_at = $2, + idle_expires_at = LEAST(absolute_expires_at, $3) + WHERE sid = $1 + AND revoked_at IS NULL`, + [row.sid, now, plusSeconds(now, USER_SESSION_IDLE_LIFETIME_SECONDS)] + ) + } + + return { status: 'valid', identity: identityFromRow(row) } +} + +export async function createUserSession( + input: { + userId: string + email: string + authenticationMethods: string[] + authenticatedAt?: Date + }, + options: SessionClock & { db?: SessionDatabase } = {} +): Promise { + const now = atSecond(options.now ?? new Date()) + const authenticatedAt = atSecond(input.authenticatedAt ?? now) + const sid = randomUUID() + const jti = randomUUID() + const idleExpiresAt = plusSeconds(now, USER_SESSION_IDLE_LIFETIME_SECONDS) + const absoluteExpiresAt = plusSeconds(now, USER_SESSION_ABSOLUTE_LIFETIME_SECONDS) + + const work = async (db: SessionDatabase) => { + const user = await db.query(`SELECT id FROM users WHERE id = $1 FOR UPDATE`, [input.userId]) + if ((user.rowCount ?? 0) === 0) throw new Error('user session principal does not exist') + + await db.query( + `DELETE FROM external_user_sessions + WHERE user_id = $1 + AND (revoked_at IS NOT NULL OR absolute_expires_at <= $2)`, + [input.userId, now] + ) + await db.query( + `WITH overflow AS ( + SELECT sid + FROM external_user_sessions + WHERE user_id = $1 + AND revoked_at IS NULL + AND absolute_expires_at > $2 + ORDER BY last_used_at DESC, created_at DESC, sid + OFFSET $3 + ) + UPDATE external_user_sessions sessions + SET revoked_at = $2, + revocation_reason = 'session_limit', + session_version = session_version + 1 + FROM overflow + WHERE sessions.sid = overflow.sid`, + [input.userId, now, USER_SESSION_MAX_ACTIVE_PER_USER - 1] + ) + + const result = await db.query( + `INSERT INTO external_user_sessions( + sid, user_id, session_version, current_jti, current_issued_at, + created_at, last_used_at, idle_expires_at, absolute_expires_at, + authentication_methods, authenticated_at + ) + VALUES($1, $2, 1, $3, $4, $4, $4, $5, $6, $7::text[], $8) + RETURNING sid, user_id, $9::text AS email, session_version, + current_jti, current_issued_at, prior_jti, prior_jti_expires_at, + created_at, last_used_at, idle_expires_at, absolute_expires_at, + revoked_at, revocation_reason, authentication_methods, authenticated_at`, + [ + sid, + input.userId, + jti, + now, + idleExpiresAt, + absoluteExpiresAt, + input.authenticationMethods, + authenticatedAt, + input.email.trim().toLowerCase(), + ] + ) + const row = result.rows[0] as SessionRow | undefined + if (!row) throw new Error('user session insert did not return a row') + return { + token: tokenFromRow(row), + expiresInSeconds: USER_SESSION_V2_TTL_SECONDS, + identity: identityFromRow(row), + } + } + + return options.db ? work(options.db) : withTransaction(work) +} + +export async function validateUserSessionClaims( + claims: UserSessionV2Claims, + options: SessionClock & { db?: SessionDatabase } = {} +): Promise { + const now = atSecond(options.now ?? new Date()) + const work = async (db: SessionDatabase) => { + const row = await loadSessionForUpdate(db, claims.sid) + return validateLoadedSession(db, row, claims, now, true) + } + return options.db ? work(options.db) : withTransaction(work) +} + +export async function authenticateUserSessionToken( + token: string, + options: SessionClock & { db?: SessionDatabase } = {} +): Promise { + const claims = verifyUserSessionV2Token(token) + if (!claims) return { status: 'invalid', reason: 'invalid_representation' } + return validateUserSessionClaims(claims, options) +} + +export async function renewUserSession( + claims: UserSessionV2Claims, + options: SessionClock & { db?: SessionDatabase } = {} +): Promise { + const now = atSecond(options.now ?? new Date()) + const work = async (db: SessionDatabase) => { + const row = await loadSessionForUpdate(db, claims.sid) + const validation = await validateLoadedSession(db, row, claims, now, false) + if (validation.status !== 'valid' || !row) return validation + + const priorExpiry = nullableDateOf(row.prior_jti_expires_at) + const legitimateConcurrentRenewal = + claims.jti === row.prior_jti && priorExpiry !== null && now <= priorExpiry + if (legitimateConcurrentRenewal) { + return { + token: tokenFromRow(row), + expiresInSeconds: USER_SESSION_V2_TTL_SECONDS, + identity: identityFromRow(row), + } + } + + const nextJti = randomUUID() + const idleExpiresAt = new Date( + Math.min( + plusSeconds(now, USER_SESSION_IDLE_LIFETIME_SECONDS).getTime(), + dateOf(row.absolute_expires_at).getTime() + ) + ) + const rotated = await db.query( + `UPDATE external_user_sessions + SET prior_jti = current_jti, + prior_jti_expires_at = $2, + current_jti = $3, + current_issued_at = $4, + last_used_at = $4, + idle_expires_at = $5 + WHERE sid = $1 + AND revoked_at IS NULL + RETURNING sid, user_id, $6::text AS email, session_version, + current_jti, current_issued_at, prior_jti, prior_jti_expires_at, + created_at, last_used_at, idle_expires_at, absolute_expires_at, + revoked_at, revocation_reason, authentication_methods, authenticated_at`, + [ + row.sid, + plusSeconds(now, USER_SESSION_RENEWAL_OVERLAP_SECONDS), + nextJti, + now, + idleExpiresAt, + row.email, + ] + ) + const next = rotated.rows[0] as SessionRow | undefined + if (!next) return { status: 'invalid' as const, reason: 'session_not_found' } + return { + token: tokenFromRow(next), + expiresInSeconds: USER_SESSION_V2_TTL_SECONDS, + identity: identityFromRow(next), + } + } + return options.db ? work(options.db) : withTransaction(work) +} + +export async function renewUserSessionToken( + token: string, + options: SessionClock & { db?: SessionDatabase } = {} +): Promise { + const claims = verifyUserSessionV2Token(token) + if (!claims) return { status: 'invalid', reason: 'invalid_representation' } + return renewUserSession(claims, options) +} + +export async function revokeUserSession( + userId: string, + sid: string, + reason: string, + db: SessionDatabase = pool, + now = new Date() +): Promise { + const result = await db.query( + `UPDATE external_user_sessions + SET revoked_at = COALESCE(revoked_at, $3), + revocation_reason = COALESCE(revocation_reason, $4), + session_version = session_version + CASE WHEN revoked_at IS NULL THEN 1 ELSE 0 END + WHERE sid = $1 + AND user_id = $2 + AND revoked_at IS NULL + RETURNING sid`, + [sid, userId, atSecond(now), reason] + ) + return (result.rowCount ?? 0) > 0 +} + +export async function revokeAllUserSessions( + userId: string, + reason: string, + db?: SessionDatabase, + now = new Date() +): Promise { + const work = async (transaction: SessionDatabase) => { + const revokedAt = atSecond(now) + const user = await transaction.query(`SELECT id FROM users WHERE id = $1 FOR UPDATE`, [userId]) + if ((user.rowCount ?? 0) === 0) return 0 + + await transaction.query( + `INSERT INTO external_user_session_security_epochs(user_id, valid_after, reason, updated_at) + VALUES($1, $2, $3, $2) + ON CONFLICT (user_id) DO UPDATE + SET valid_after = GREATEST( + external_user_session_security_epochs.valid_after, + EXCLUDED.valid_after + ), + reason = EXCLUDED.reason, + updated_at = EXCLUDED.updated_at`, + [userId, revokedAt, reason] + ) + const result = await transaction.query( + `UPDATE external_user_sessions + SET revoked_at = $2, + revocation_reason = $3, + session_version = session_version + 1 + WHERE user_id = $1 + AND revoked_at IS NULL`, + [userId, revokedAt, reason] + ) + return result.rowCount ?? 0 + } + + return db ? work(db) : withTransaction(work) +} + +function legacySessionFingerprint(token: string): string { + return createHash('sha256').update(token, 'utf8').digest('hex') +} + +export async function validateLegacyUserSession( + token: string, + claims: AuthClaims, + db: SessionDatabase = pool +): Promise { + if (!claims.iat) return { status: 'invalid', reason: 'invalid_legacy_representation' } + const result = await db.query( + `SELECT u.id, + epoch.valid_after, + EXISTS ( + SELECT 1 + FROM external_v1_session_revocations revoked + WHERE revoked.token_hash = $2 + AND revoked.user_id = u.id + AND revoked.expires_at > NOW() + ) AS token_revoked + FROM users u + LEFT JOIN external_user_session_security_epochs epoch ON epoch.user_id = u.id + WHERE u.id = $1 + LIMIT 1`, + [claims.userId, legacySessionFingerprint(token)] + ) + const row = result.rows[0] as + | { id: string; valid_after: Date | string | null; token_revoked: boolean } + | undefined + if (!row) return { status: 'invalid', reason: 'user_not_found' } + if (row.token_revoked) return { status: 'revoked', reason: 'logout' } + if (row.valid_after && claims.iat * 1000 <= dateOf(row.valid_after).getTime()) { + return { status: 'revoked', reason: 'security_event' } + } + return { + status: 'valid', + identity: { + userId: claims.userId, + email: claims.email, + sid: '', + jti: legacySessionFingerprint(token), + sessionVersion: 0, + expiresAt: new Date(claims.exp * 1000), + absoluteExpiresAt: new Date(claims.exp * 1000), + authenticationMethods: [], + }, + } +} + +export async function revokeLegacyUserSession( + token: string, + claims: AuthClaims, + reason: string, + db: SessionDatabase = pool, + now = new Date() +): Promise { + const result = await db.query( + `INSERT INTO external_v1_session_revocations( + token_hash, user_id, expires_at, revoked_at, reason + ) + VALUES($1, $2, $3, $4, $5) + ON CONFLICT (token_hash) DO NOTHING + RETURNING token_hash`, + [ + legacySessionFingerprint(token), + claims.userId, + new Date(claims.exp * 1000), + atSecond(now), + reason, + ] + ) + return (result.rowCount ?? 0) > 0 +} diff --git a/control-api/src/utils/auth/externalSessionAuthToken.ts b/control-api/src/utils/auth/externalSessionAuthToken.ts index d6e2c56889..322169c15c 100644 --- a/control-api/src/utils/auth/externalSessionAuthToken.ts +++ b/control-api/src/utils/auth/externalSessionAuthToken.ts @@ -69,6 +69,7 @@ export function verifyExternalSessionToken(token: string): AuthClaims | null { role: payload.role as AuthClaims['role'], authGeneration: Number(authGeneration), exp: payload.exp, + ...(typeof payload.iat === 'number' ? { iat: payload.iat } : {}), } } catch { return null diff --git a/control-api/src/utils/auth/userSessionV2Token.ts b/control-api/src/utils/auth/userSessionV2Token.ts new file mode 100644 index 0000000000..02427216c9 --- /dev/null +++ b/control-api/src/utils/auth/userSessionV2Token.ts @@ -0,0 +1,155 @@ +import jwt from 'jsonwebtoken' +import { createPublicKey } from 'node:crypto' +import { config } from '../../config.js' + +export const USER_SESSION_V2_AUDIENCE = 'evenfire-user-session' +export const USER_SESSION_V2_TTL_SECONDS = 60 * 60 +export const USER_SESSION_V2_CLOCK_TOLERANCE_SECONDS = 5 +export const USER_SESSION_V2_TYPE = 'user_session' as const +export const USER_SESSION_V2_VERSION = 2 as const + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i +const ALLOWED_CLAIMS = new Set([ + 'sub', + 'sid', + 'jti', + 'sv', + 'ver', + 'typ', + 'email', + 'auth_time', + 'amr', + 'iat', + 'exp', + 'iss', + 'aud', +]) + +let publicKey: ReturnType | null = null + +function sessionPublicKey() { + if (!publicKey) publicKey = createPublicKey(config.sessionJwtPrivateKey) + return publicKey +} + +export type UserSessionV2Claims = { + sub: string + sid: string + jti: string + sv: number + ver: typeof USER_SESSION_V2_VERSION + typ: typeof USER_SESSION_V2_TYPE + email?: string + auth_time: number + amr: string[] + iat: number + exp: number +} + +export type UserSessionV2SignInput = Omit + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0 +} + +function isUuid(value: unknown): value is string { + return typeof value === 'string' && UUID_PATTERN.test(value) +} + +function validAuthenticationMethods(value: unknown): value is string[] { + return ( + Array.isArray(value) && + value.length > 0 && + value.length <= 8 && + value.every(method => isNonEmptyString(method) && method.length <= 32) && + new Set(value).size === value.length + ) +} + +export function signUserSessionV2Token( + input: UserSessionV2SignInput, + issuedAtSeconds = Math.floor(Date.now() / 1000) +): string { + if (!isUuid(input.sub) || !isUuid(input.sid) || !isUuid(input.jti)) { + throw new Error('user-session identity must use UUID values') + } + if (!Number.isInteger(input.sv) || input.sv < 1) { + throw new Error('user-session version must be a positive integer') + } + if (!validAuthenticationMethods(input.amr)) { + throw new Error('user-session authentication methods are invalid') + } + + return jwt.sign( + { + sub: input.sub, + sid: input.sid, + jti: input.jti, + sv: input.sv, + ver: USER_SESSION_V2_VERSION, + typ: USER_SESSION_V2_TYPE, + ...(input.email ? { email: input.email.trim().toLowerCase() } : {}), + auth_time: input.auth_time, + amr: [...input.amr], + iat: issuedAtSeconds, + }, + config.sessionJwtPrivateKey, + { + algorithm: 'RS256', + expiresIn: USER_SESSION_V2_TTL_SECONDS, + issuer: config.jwtIssuer, + audience: USER_SESSION_V2_AUDIENCE, + } + ) +} + +export function verifyUserSessionV2Token(token: string): UserSessionV2Claims | null { + try { + const payload = jwt.verify(token, sessionPublicKey(), { + algorithms: ['RS256'], + issuer: config.jwtIssuer, + audience: USER_SESSION_V2_AUDIENCE, + clockTolerance: USER_SESSION_V2_CLOCK_TOLERANCE_SECONDS, + }) as jwt.JwtPayload + + if (Object.keys(payload).some(claim => !ALLOWED_CLAIMS.has(claim))) return null + const now = Math.floor(Date.now() / 1000) + if ( + !isUuid(payload.sub) || + !isUuid(payload.sid) || + !isUuid(payload.jti) || + !Number.isInteger(payload.sv) || + Number(payload.sv) < 1 || + payload.ver !== USER_SESSION_V2_VERSION || + payload.typ !== USER_SESSION_V2_TYPE || + payload.iss !== config.jwtIssuer || + payload.aud !== USER_SESSION_V2_AUDIENCE || + typeof payload.auth_time !== 'number' || + !validAuthenticationMethods(payload.amr) || + typeof payload.iat !== 'number' || + typeof payload.exp !== 'number' || + payload.exp - payload.iat !== USER_SESSION_V2_TTL_SECONDS || + payload.iat > now + USER_SESSION_V2_CLOCK_TOLERANCE_SECONDS || + payload.auth_time > payload.iat + USER_SESSION_V2_CLOCK_TOLERANCE_SECONDS || + (payload.email !== undefined && !isNonEmptyString(payload.email)) + ) { + return null + } + + return { + sub: payload.sub, + sid: payload.sid, + jti: payload.jti, + sv: Number(payload.sv), + ver: USER_SESSION_V2_VERSION, + typ: USER_SESSION_V2_TYPE, + ...(typeof payload.email === 'string' ? { email: payload.email } : {}), + auth_time: payload.auth_time, + amr: [...payload.amr], + iat: payload.iat, + exp: payload.exp, + } + } catch { + return null + } +} diff --git a/control-api/test/userSessionRevokeAllAtomic.test.ts b/control-api/test/userSessionRevokeAllAtomic.test.ts new file mode 100644 index 0000000000..8f5b05b089 --- /dev/null +++ b/control-api/test/userSessionRevokeAllAtomic.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + withTransaction: vi.fn(), + query: vi.fn(), + committedEpoch: false, +})) + +vi.mock('../src/db.js', () => ({ + pool: { query: vi.fn() }, + withTransaction: mocks.withTransaction, +})) + +describe('revoke-all transaction boundary', () => { + it('rolls back the security epoch when v2 session revocation fails', async () => { + mocks.committedEpoch = false + mocks.query.mockReset() + mocks.query + .mockResolvedValueOnce({ rows: [{ id: 'user-1' }], rowCount: 1 }) + .mockImplementationOnce(async () => { + mocks.committedEpoch = true + return { rows: [], rowCount: 1 } + }) + .mockRejectedValueOnce(new Error('session update failed')) + mocks.withTransaction.mockImplementationOnce(async work => { + const previousEpoch = mocks.committedEpoch + try { + return await work({ query: mocks.query }) + } catch (error) { + mocks.committedEpoch = previousEpoch + throw error + } + }) + + const { revokeAllUserSessions } = await import('../src/services/auth/userSessionService.js') + await expect(revokeAllUserSessions('user-1', 'security_event')).rejects.toThrow( + 'session update failed' + ) + + expect(mocks.withTransaction).toHaveBeenCalledTimes(1) + expect(mocks.query).toHaveBeenCalledTimes(3) + expect(String(mocks.query.mock.calls[0]?.[0])).toContain('FOR UPDATE') + expect(mocks.committedEpoch).toBe(false) + }) +}) diff --git a/control-api/test/userSessionService.test.ts b/control-api/test/userSessionService.test.ts new file mode 100644 index 0000000000..50d181b79d --- /dev/null +++ b/control-api/test/userSessionService.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it, vi } from 'vitest' +import { randomUUID } from 'node:crypto' +import { + USER_SESSION_ABSOLUTE_LIFETIME_SECONDS, + USER_SESSION_IDLE_LIFETIME_SECONDS, + USER_SESSION_MAX_ACTIVE_PER_USER, + USER_SESSION_RENEWAL_OVERLAP_SECONDS, + createUserSession, + renewUserSession, + revokeAllUserSessions, + validateLegacyUserSession, + validateUserSessionClaims, +} from '../src/services/auth/userSessionService.js' +import { verifyUserSessionV2Token } from '../src/utils/auth/userSessionV2Token.js' + +const now = new Date(Math.floor(Date.now() / 1000) * 1000) + +function row(overrides: Record = {}) { + return { + sid: randomUUID(), + user_id: randomUUID(), + email: 'user@example.com', + session_version: 1, + current_jti: randomUUID(), + current_issued_at: now, + prior_jti: null, + prior_jti_expires_at: null, + created_at: now, + last_used_at: now, + idle_expires_at: new Date(now.getTime() + USER_SESSION_IDLE_LIFETIME_SECONDS * 1000), + absolute_expires_at: new Date(now.getTime() + USER_SESSION_ABSOLUTE_LIFETIME_SECONDS * 1000), + revoked_at: null, + revocation_reason: null, + authentication_methods: ['pwd'], + authenticated_at: now, + ...overrides, + } +} + +function claimsFor(value: ReturnType, jti = String(value.current_jti)) { + return { + sub: String(value.user_id), + sid: String(value.sid), + jti, + sv: Number(value.session_version), + ver: 2 as const, + typ: 'user_session' as const, + email: String(value.email), + auth_time: Math.floor(now.getTime() / 1000), + amr: ['pwd'], + iat: Math.floor(now.getTime() / 1000), + exp: Math.floor(now.getTime() / 1000) + 3600, + } +} + +describe('user session state machine', () => { + it('creates one session with frozen idle, absolute, and active-session bounds', async () => { + const inserted = row() + const query = vi.fn(async (sql: string, values?: unknown[]) => { + if (sql.includes('FROM users')) return { rows: [{ id: inserted.user_id }], rowCount: 1 } + if (sql.includes('INSERT INTO external_user_sessions')) { + return { + rows: [{ ...inserted, sid: values?.[0], user_id: values?.[1], current_jti: values?.[2] }], + rowCount: 1, + } + } + return { rows: [], rowCount: 0 } + }) + + const issued = await createUserSession( + { + userId: String(inserted.user_id), + email: 'User@Example.com', + authenticationMethods: ['pwd'], + }, + { db: { query }, now } + ) + + const insertCall = query.mock.calls.find(call => + String(call[0]).includes('INSERT INTO external_user_sessions') + )! + const values = insertCall[1] as unknown[] + expect((values[4] as Date).getTime() - now.getTime()).toBe( + USER_SESSION_IDLE_LIFETIME_SECONDS * 1000 + ) + expect((values[5] as Date).getTime() - now.getTime()).toBe( + USER_SESSION_ABSOLUTE_LIFETIME_SECONDS * 1000 + ) + expect(verifyUserSessionV2Token(issued.token)).toMatchObject({ + sub: inserted.user_id, + sid: values[0], + jti: values[2], + sv: 1, + }) + expect(String(query.mock.calls[0]?.[0])).toContain('FOR UPDATE') + expect(String(query.mock.calls[1]?.[0])).toContain('DELETE FROM external_user_sessions') + expect(String(query.mock.calls[2]?.[0])).toContain('OFFSET $3') + expect(query.mock.calls[2]?.[1]?.[2]).toBe(USER_SESSION_MAX_ACTIVE_PER_USER - 1) + }) + + it('rotates once and returns the same successor to an overlapping renewal', async () => { + const original = row() + let current = original + const query = vi.fn(async (sql: string, values?: unknown[]) => { + if (sql.includes('SELECT s.sid')) return { rows: [current], rowCount: 1 } + if (sql.includes('SET prior_jti = current_jti')) { + current = row({ + ...original, + prior_jti: original.current_jti, + prior_jti_expires_at: values?.[1], + current_jti: values?.[2], + current_issued_at: values?.[3], + last_used_at: values?.[3], + idle_expires_at: values?.[4], + }) + return { rows: [current], rowCount: 1 } + } + return { rows: [], rowCount: 0 } + }) + + const first = await renewUserSession(claimsFor(original), { db: { query }, now }) + expect('token' in first).toBe(true) + const successor = 'token' in first ? first.token : '' + + const concurrent = await renewUserSession(claimsFor(current, String(original.current_jti)), { + db: { query }, + now: new Date(now.getTime() + (USER_SESSION_RENEWAL_OVERLAP_SECONDS - 1) * 1000), + }) + expect('token' in concurrent && concurrent.token).toBe(successor) + expect( + query.mock.calls.filter(([sql]) => String(sql).includes('SET prior_jti = current_jti')) + ).toHaveLength(1) + }) + + it('revokes reuse outside overlap and never extends expired state', async () => { + const reused = row({ + prior_jti: randomUUID(), + prior_jti_expires_at: new Date(now.getTime() - 1000), + }) + const reuseQuery = vi.fn(async (sql: string) => { + if (sql.includes('SELECT s.sid')) return { rows: [reused], rowCount: 1 } + return { rows: [], rowCount: 1 } + }) + await expect( + validateUserSessionClaims(claimsFor(reused, String(reused.prior_jti)), { + db: { query: reuseQuery }, + now, + }) + ).resolves.toEqual({ status: 'revoked', reason: 'representation_reuse' }) + + const expired = row({ idle_expires_at: new Date(now.getTime() - 1000) }) + const expiryQuery = vi.fn(async (sql: string) => { + if (sql.includes('SELECT s.sid')) return { rows: [expired], rowCount: 1 } + return { rows: [], rowCount: 1 } + }) + await expect( + validateUserSessionClaims(claimsFor(expired), { db: { query: expiryQuery }, now }) + ).resolves.toEqual({ status: 'expired', reason: 'idle_expired' }) + expect( + expiryQuery.mock.calls.some(([sql]) => String(sql).includes('idle_expires_at = LEAST')) + ).toBe(false) + }) + + it('invalidates v1 and v2 sessions together in the supplied transaction', async () => { + const userId = randomUUID() + const db = { + query: vi + .fn() + .mockResolvedValueOnce({ rows: [{ id: userId }], rowCount: 1 }) + .mockResolvedValueOnce({ rows: [], rowCount: 1 }) + .mockResolvedValueOnce({ rows: [], rowCount: 2 }), + } + await expect(revokeAllUserSessions(userId, 'password_changed', db, now)).resolves.toBe(2) + expect(String(db.query.mock.calls[0]?.[0])).toContain('FOR UPDATE') + expect(String(db.query.mock.calls[1]?.[0])).toContain('security_epochs') + expect(String(db.query.mock.calls[2]?.[0])).toContain('external_user_sessions') + + const claims = { + userId, + email: 'user@example.com', + teamId: null, + role: 'member' as const, + exp: Math.floor(now.getTime() / 1000) + 3600, + iat: Math.floor(now.getTime() / 1000), + } + const epochDb = { + query: vi.fn().mockResolvedValue({ + rows: [{ id: userId, valid_after: now, token_revoked: false }], + rowCount: 1, + }), + } + await expect(validateLegacyUserSession('v1-token', claims, epochDb)).resolves.toEqual({ + status: 'revoked', + reason: 'security_event', + }) + }) +}) diff --git a/control-api/test/userSessionToken.test.ts b/control-api/test/userSessionToken.test.ts new file mode 100644 index 0000000000..8d43203016 --- /dev/null +++ b/control-api/test/userSessionToken.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import jwt from 'jsonwebtoken' +import { config } from '../src/config.js' +import { verifyAdminToken } from '../src/utils/auth/adminAuthToken.js' +import { verifyInternalControlJwt } from '../src/utils/auth/internalControlToken.js' +import { + verifyMcpHostAccessJwt, + verifyMcpHostControlJwt, +} from '../src/utils/auth/mcpHostJwtToken.js' +import { verifyOAuthBrokerJwt } from '../src/utils/auth/oauthBrokerJwtToken.js' +import { verifyRpcAccessToken } from '../src/utils/auth/rpcAuthToken.js' +import { + USER_SESSION_V2_AUDIENCE, + USER_SESSION_V2_CLOCK_TOLERANCE_SECONDS, + USER_SESSION_V2_TTL_SECONDS, + signUserSessionV2Token, + verifyUserSessionV2Token, +} from '../src/utils/auth/userSessionV2Token.js' + +const input = { + sub: '11111111-1111-4111-8111-111111111111', + sid: '22222222-2222-4222-8222-222222222222', + jti: '33333333-3333-4333-8333-333333333333', + sv: 1, + email: 'user@example.com', + auth_time: Math.floor(Date.now() / 1000), + amr: ['pwd'], +} + +describe('user-session v2 token contract', () => { + it('issues only the strict one-hour user and session identity contract', () => { + const token = signUserSessionV2Token(input, input.auth_time) + const decoded = jwt.decode(token) as jwt.JwtPayload + + expect(decoded).toEqual({ + sub: input.sub, + sid: input.sid, + jti: input.jti, + sv: 1, + ver: 2, + typ: 'user_session', + email: input.email, + auth_time: input.auth_time, + amr: ['pwd'], + iat: input.auth_time, + exp: input.auth_time + USER_SESSION_V2_TTL_SECONDS, + aud: USER_SESSION_V2_AUDIENCE, + iss: config.jwtIssuer, + }) + }) + + it('rejects wrong trust, shape, time, and authority-bearing claims', () => { + const sign = (payload: Record, options: jwt.SignOptions = {}) => + jwt.sign(payload, config.sessionJwtPrivateKey, { + algorithm: 'RS256', + issuer: config.jwtIssuer, + audience: USER_SESSION_V2_AUDIENCE, + expiresIn: USER_SESSION_V2_TTL_SECONDS, + ...options, + }) + + const wrongAudience = sign(input, { audience: 'profile-ui' }) + const wrongType = sign({ ...input, ver: 2, typ: 'service' }) + const wrongVersion = sign({ ...input, ver: 3, typ: 'user_session' }) + const forbiddenClaim = sign({ ...input, ver: 2, typ: 'user_session', role: 'admin' }) + const unknownClaim = sign({ ...input, ver: 2, typ: 'user_session', debug: true }) + const missingSid = sign({ ...input, sid: undefined, ver: 2, typ: 'user_session' }) + const malformedSid = sign({ ...input, sid: 'not-a-uuid', ver: 2, typ: 'user_session' }) + const wrongAlgorithm = jwt.sign( + { ...input, ver: 2, typ: 'user_session' }, + 'not-the-session-key', + { + algorithm: 'HS256', + issuer: config.jwtIssuer, + audience: USER_SESSION_V2_AUDIENCE, + expiresIn: USER_SESSION_V2_TTL_SECONDS, + } + ) + const expired = signUserSessionV2Token(input, Math.floor(Date.now() / 1000) - 7200) + const future = signUserSessionV2Token( + input, + Math.floor(Date.now() / 1000) + USER_SESSION_V2_CLOCK_TOLERANCE_SECONDS + 1 + ) + + for (const token of [ + wrongAudience, + wrongType, + wrongVersion, + forbiddenClaim, + unknownClaim, + missingSid, + malformedSid, + wrongAlgorithm, + expired, + future, + ]) { + expect(verifyUserSessionV2Token(token)).toBeNull() + } + }) + + it('is rejected by every independent non-user trust plane', () => { + const token = signUserSessionV2Token(input) + + expect(verifyRpcAccessToken(token)).toBeNull() + expect(verifyAdminToken(token)).toBeNull() + expect(verifyInternalControlJwt(token)).toBeNull() + expect(verifyMcpHostAccessJwt(token)).toBeNull() + expect(verifyMcpHostControlJwt(token)).toBeNull() + expect(verifyOAuthBrokerJwt(token)).toBeNull() + }) +}) From 406cae170bf3dd18e82a7f16d5cf0aaeba82748b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 10 Aug 2026 22:27:05 -0600 Subject: [PATCH 008/528] feat(auth): centralize external-session rollout policy Compile one effective rollout state for v1 and v2 acceptance, issuance, renewal, switching, catalog, action, RPC, client modes, and minimum-version enforcement. Route middleware, verification, workflows, legacy RPC, switching, renewal, logout, and issuance through the same live authentication boundary. Keep v2 issuance and all new access modes disabled. --- .../src/middleware/externalSessionAuth.ts | 360 +++++++-- control-api/src/profileTypes.ts | 7 + control-api/src/routes/external/auth.ts | 722 +++++++++++++----- .../src/routes/external/invitations.ts | 121 +-- .../src/routes/workflows/shared/auth.ts | 89 ++- .../src/services/access/userAccessPolicy.ts | 413 ++++++++++ .../auth/externalSessionAuthentication.ts | 166 ++++ .../services/auth/externalSessionIssuance.ts | 86 +++ .../src/services/auth/userSessionService.ts | 19 - .../externalSessionAuthentication.test.ts | 180 +++++ .../externalSessionImportBoundary.test.ts | 32 + .../externalSessionTeamAuthorization.test.ts | 36 +- control-api/test/routes.externalGfs.test.ts | 21 +- .../test/routes.externalWorkflows.test.ts | 19 +- control-api/test/userAccessPolicy.test.ts | 224 ++++++ 15 files changed, 2123 insertions(+), 372 deletions(-) create mode 100644 control-api/src/services/access/userAccessPolicy.ts create mode 100644 control-api/src/services/auth/externalSessionAuthentication.ts create mode 100644 control-api/src/services/auth/externalSessionIssuance.ts create mode 100644 control-api/test/externalSessionAuthentication.test.ts create mode 100644 control-api/test/externalSessionImportBoundary.test.ts create mode 100644 control-api/test/userAccessPolicy.test.ts diff --git a/control-api/src/middleware/externalSessionAuth.ts b/control-api/src/middleware/externalSessionAuth.ts index ae65ceaf3b..92c91134f3 100644 --- a/control-api/src/middleware/externalSessionAuth.ts +++ b/control-api/src/middleware/externalSessionAuth.ts @@ -1,47 +1,64 @@ import { NextFunction, Request, Response } from 'express' -import { DatabaseError } from 'pg' import { pool } from '../db.js' +import { sendPublicApiError } from '../http/publicApiError.js' import { rootLogger } from '../observability/logger.js' import { AuthClaims, TeamRole } from '../profileTypes.js' +import type { AccessExecutionBudget } from '../services/access/accessExecutionBudget.js' import { getLiveTeamMembership } from '../services/access/liveTeamAuthorization.js' -import { verifyExternalSessionToken } from '../utils/auth/externalSessionAuthToken.js' +import { + authenticateExternalUserSession, + authenticateExternalUserSessionIdentity, +} from '../services/auth/externalSessionAuthentication.js' +import type { + ExternalSessionAuthentication, + ExternalSessionAuthorityContext, + ExternalSessionClient, + ExternalSessionIdentityAuthentication, + ExternalSessionPurpose, +} from '../services/auth/externalSessionAuthentication.js' +import { legacyExternalSessionAuthGeneration } from '../services/auth/legacyV1Generation.js' +import { + isExternalSessionBackendUnavailableError, + runExternalSessionDatabaseOperation, +} from '../services/auth/sessionDatabaseFailure.js' -/** Retry-After sent when the user row cannot be read to validate a session. */ +/** Retry-After sent when an external-session database operation is unavailable. */ export const SESSION_BACKEND_RETRY_AFTER_SECONDS = 2 -// SQLSTATE classes that mean the server could not run the query: 08 connection -// exception, 53 insufficient resources, 57 operator intervention (includes -// 57014 statement timeout), 58 system error. -const BACKEND_UNAVAILABLE_SQLSTATE_CLASSES = new Set(['08', '53', '57', '58']) - -// A Node system error code (ECONNREFUSED, ETIMEDOUT, EPIPE, ...). Node's own -// argument and state errors use ERR_* codes, which this does not match. -const SYSTEM_ERROR_CODE = /^E[A-Z]+$/ - -/** - * True when the lookup failed because PostgreSQL could not be reached or could - * not run it: - * - a DatabaseError whose SQLSTATE class is 08, 53, 57 or 58; - * - a plain Error, which is how pg and pg-pool report an acquire timeout or a - * dropped connection; - * - an error carrying a Node system code, as socket failures do (including the - * AggregateError Node raises when every address of a host refuses). - * Anything else is a defect: a DatabaseError in another class (22P02, 42P01, - * ...) is in the query or the schema, and a TypeError or RangeError is in this - * process's code. A 503 would hide either one. - */ -function isBackendUnavailableError(error: unknown): boolean { - if (error instanceof DatabaseError) { - return BACKEND_UNAVAILABLE_SQLSTATE_CLASSES.has(String(error.code).slice(0, 2)) +export function handleExternalSessionBackendFailure( + error: unknown, + req: Request, + res: Response, + next: NextFunction +): void { + if (!isExternalSessionBackendUnavailableError(error)) { + next(error) + return } - if (!(error instanceof Error)) return false - const code = (error as { code?: unknown }).code - if (typeof code === 'string' && SYSTEM_ERROR_CODE.test(code)) return true - return error.constructor === Error + rootLogger.warn( + { event: 'external_session_backend_unavailable', err: error.cause }, + 'external session validation could not reach PostgreSQL' + ) + res.setHeader('Retry-After', String(SESSION_BACKEND_RETRY_AFTER_SECONDS)) + res.setHeader('Cache-Control', 'no-store') + res.status(503).json({ + error: 'session_backend_unavailable', + retryAfterSeconds: SESSION_BACKEND_RETRY_AFTER_SECONDS, + }) } export type ExternalAuthedRequest = Request & { externalAuth?: AuthClaims + externalSessionAuthority?: ExternalSessionAuthorityContext + externalSessionAuthentication?: Extract< + ExternalSessionAuthentication, + { status: 'authenticated' } + > + externalSessionLimiterIdentity?: Extract< + ExternalSessionIdentityAuthentication, + { status: 'authenticated' } + > + accessExecutionBudget?: AccessExecutionBudget externalTeamAuth?: { teamId: string role: TeamRole @@ -55,19 +72,16 @@ export type ExternalAuthedRequest = Request & { * same denial to avoid user enumeration. */ export async function isCurrentExternalSession(claims: AuthClaims): Promise { - const authGeneration = claims.authGeneration - if ( - typeof authGeneration !== 'number' || - !Number.isSafeInteger(authGeneration) || - authGeneration < 1 - ) - return false - const result = await pool.query( - `SELECT lifecycle_state, lifecycle_version - FROM users - WHERE id = $1 - LIMIT 1`, - [claims.userId] + const authGeneration = legacyExternalSessionAuthGeneration(claims) + if (authGeneration === null) return false + const result = await runExternalSessionDatabaseOperation(() => + pool.query( + `SELECT lifecycle_state, lifecycle_version + FROM users + WHERE id = $1 + LIMIT 1`, + [claims.userId] + ) ) const row = result.rows[0] as | { lifecycle_state?: unknown; lifecycle_version?: unknown } @@ -86,63 +100,201 @@ function extractUserSessionToken(req: Request): string { return String(req.header('x-user-session-token') || '').trim() } -export function requireValidExternalSessionToken( +function extractRouteSessionToken(req: Request): string { + const body = (req.body ?? {}) as { token?: unknown; sessionToken?: unknown } + return String(req.header('x-user-session-token') || body.token || body.sessionToken || '').trim() +} + +function sendSessionAuthenticationError( + req: Request, + res: Response, + status: 'invalid' | 'upgrade_required' +): void { + if (status === 'upgrade_required') { + sendPublicApiError(req, res, 426, 'upgrade_required', 'A newer client is required.') + return + } + sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') +} + +/** + * Establishes trusted, live external-session context without performing a + * protected operation. It is intended to precede post-auth rate limiting. + */ +export function requireExternalSessionRateLimitContext(options: { + purpose: ExternalSessionPurpose + client?: (req: Request) => ExternalSessionClient + requireV2?: boolean +}) { + return async (req: ExternalAuthedRequest, res: Response, next: NextFunction): Promise => { + const token = extractRouteSessionToken(req) + if (!token || token.length > 4096) { + sendSessionAuthenticationError(req, res, 'invalid') + return + } + try { + const authentication = await authenticateExternalUserSession(token, { + purpose: options.purpose, + ...(options.client ? { client: options.client(req) } : {}), + }) + if (authentication.status === 'upgrade_required') { + sendSessionAuthenticationError(req, res, 'upgrade_required') + return + } + if (authentication.status !== 'authenticated') { + sendSessionAuthenticationError(req, res, 'invalid') + return + } + if (options.requireV2 && authentication.contract !== 'v2') { + sendPublicApiError( + req, + res, + 409, + 'conflict', + 'A user-session v2 login is required for session management.' + ) + return + } + req.externalAuth = authentication.claims + req.externalSessionAuthority = authentication.authorityContext + req.externalSessionAuthentication = authentication + next() + } catch (error) { + handleExternalSessionBackendFailure(error, req, res, next) + } + } +} + +async function validateExternalSessionToken( req: ExternalAuthedRequest, res: Response, - next: NextFunction + next: NextFunction, + publicErrors: boolean ): Promise { - return requireValidExternalSessionTokenAsync(req, res, next) + const token = extractUserSessionToken(req) + if (!token || token.length > 4096) { + if (publicErrors) { + sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') + } else { + res.status(401).json({ error: 'Unauthorized' }) + } + return + } + + try { + const authentication = await authenticateExternalUserSession(token, { + purpose: 'protected', + client: { version: req.header('x-evenfire-client-version') || undefined }, + budget: req.accessExecutionBudget, + }) + if (authentication.status === 'upgrade_required') { + if (publicErrors) { + sendPublicApiError(req, res, 426, 'upgrade_required', 'A newer client is required.') + } else { + res.status(426).json({ error: 'upgrade_required' }) + } + return + } + if (authentication.status !== 'authenticated') { + if (publicErrors) { + sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') + } else { + res.status(401).json({ error: 'Unauthorized' }) + } + return + } + req.externalAuth = authentication.claims + req.externalSessionAuthority = authentication.authorityContext + next() + } catch (error) { + handleExternalSessionBackendFailure(error, req, res, next) + } } -async function requireValidExternalSessionTokenAsync( +/** + * Establishes the policy-I/O-free live identity needed only for the dedicated + * catalog/resolve authenticated limiter. Final policy and contract validation + * must run separately after that limiter allows the request. + */ +export async function requireExternalSessionLimiterIdentityWithPublicErrors( req: ExternalAuthedRequest, res: Response, next: NextFunction ): Promise { + const token = extractUserSessionToken(req) + if (!token || token.length > 4096) { + sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') + return + } try { - const token = extractUserSessionToken(req) - const claims = verifyExternalSessionToken(token) - if (!claims) { - res.status(401).json({ error: 'Unauthorized' }) + const identity = await authenticateExternalUserSessionIdentity(token, { + budget: req.accessExecutionBudget, + }) + if (identity.status !== 'authenticated') { + sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') return } + req.externalAuth = identity.claims + req.externalSessionAuthority = identity.authorityContext + req.externalSessionLimiterIdentity = identity + next() + } catch (error) { + handleExternalSessionBackendFailure(error, req, res, next) + } +} - let current: boolean - try { - current = await isCurrentExternalSession(claims) - } catch (error) { - // A query or schema defect goes to the error handler (500). - if (!isBackendUnavailableError(error)) throw error - // The users-table lookup could not run (pool acquire timeout, connection - // or statement timeout): the session could not be judged, which is - // neither a denial (401) nor a defect in this request (500). - rootLogger.warn( - { - event: 'external_session_backend_unavailable', - err: error instanceof Error ? error.message : String(error), - }, - 'external session validation could not reach PostgreSQL' - ) - res.setHeader('Retry-After', String(SESSION_BACKEND_RETRY_AFTER_SECONDS)) - res.setHeader('Cache-Control', 'no-store') - res.status(503).json({ - error: 'session_backend_unavailable', - retryAfterSeconds: SESSION_BACKEND_RETRY_AFTER_SECONDS, - }) +/** Completes canonical current policy/contract authentication after limiter allowance. */ +export async function requireCompletedExternalSessionAuthenticationWithPublicErrors( + req: ExternalAuthedRequest, + res: Response, + next: NextFunction +): Promise { + const token = extractUserSessionToken(req) + const identity = req.externalSessionLimiterIdentity + if (!token || token.length > 4096 || !identity) { + sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') + return + } + try { + const authentication = await authenticateExternalUserSession(token, { + purpose: 'protected', + client: { version: req.header('x-evenfire-client-version') || undefined }, + budget: req.accessExecutionBudget, + identity, + }) + if (authentication.status === 'upgrade_required') { + sendPublicApiError(req, res, 426, 'upgrade_required', 'A newer client is required.') return } - if (!current) { - res.status(401).json({ error: 'Unauthorized' }) + if (authentication.status !== 'authenticated') { + sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') return } - - req.externalAuth = claims + req.externalAuth = authentication.claims + req.externalSessionAuthority = authentication.authorityContext + req.externalSessionAuthentication = authentication next() } catch (error) { - next(error) + handleExternalSessionBackendFailure(error, req, res, next) } } +export async function requireValidExternalSessionToken( + req: ExternalAuthedRequest, + res: Response, + next: NextFunction +): Promise { + return validateExternalSessionToken(req, res, next, false) +} + +export async function requireValidExternalSessionTokenWithPublicErrors( + req: ExternalAuthedRequest, + res: Response, + next: NextFunction +): Promise { + return validateExternalSessionToken(req, res, next, true) +} + export function requireExternalUserParamMatch(paramName = 'userId') { return (req: ExternalAuthedRequest, res: Response, next: NextFunction): void => { const claims = req.externalAuth @@ -155,28 +307,57 @@ export function requireExternalUserParamMatch(paramName = 'userId') { } } -export function requireExternalTeamParamMatch(paramName = 'teamId') { +function externalTeamParamMatcher(paramName: string, publicErrors: boolean) { return async (req: ExternalAuthedRequest, res: Response, next: NextFunction): Promise => { const claims = req.externalAuth const requestedTeamId = String(req.params?.[paramName] || req.query?.[paramName] || '').trim() if (!claims || !requestedTeamId) { - res.status(403).json({ error: 'Forbidden' }) + if (publicErrors) { + sendPublicApiError(req, res, 403, 'forbidden', 'The requested operation is not allowed.') + } else { + res.status(403).json({ error: 'Forbidden' }) + } return } try { - const membership = await getLiveTeamMembership(claims.userId, requestedTeamId) + const membership = await getLiveTeamMembership(claims.userId, requestedTeamId, { + budget: req.accessExecutionBudget, + }) if (!membership) { - res.status(403).json({ error: 'Forbidden' }) + if (publicErrors) { + sendPublicApiError(req, res, 403, 'forbidden', 'The requested operation is not allowed.') + } else { + res.status(403).json({ error: 'Forbidden' }) + } return } req.externalTeamAuth = membership next() } catch { - res.status(503).json({ error: 'authority_unavailable' }) + if (publicErrors) { + sendPublicApiError( + req, + res, + 503, + 'authority_unavailable', + 'Authorization is temporarily unavailable.', + true + ) + } else { + res.status(503).json({ error: 'authority_unavailable' }) + } } } } +export function requireExternalTeamParamMatch(paramName = 'teamId') { + return externalTeamParamMatcher(paramName, false) +} + +export function requireExternalTeamParamMatchWithPublicErrors(paramName = 'teamId') { + return externalTeamParamMatcher(paramName, true) +} + export function rejectBodyUserTeamMismatch( req: ExternalAuthedRequest, res: Response, @@ -211,3 +392,14 @@ export function requireExternalRole(allowedRoles: TeamRole[]) { next() } } + +export function requireExternalRoleWithPublicErrors(allowedRoles: TeamRole[]) { + return (req: ExternalAuthedRequest, res: Response, next: NextFunction): void => { + const role = req.externalTeamAuth?.role + if (!role || !allowedRoles.includes(role)) { + sendPublicApiError(req, res, 403, 'forbidden', 'The requested operation is not allowed.') + return + } + next() + } +} diff --git a/control-api/src/profileTypes.ts b/control-api/src/profileTypes.ts index 030fae5aec..2052c6bd87 100644 --- a/control-api/src/profileTypes.ts +++ b/control-api/src/profileTypes.ts @@ -50,6 +50,13 @@ export type AuthClaims = { authGeneration: number exp: number iat?: number + sessionContract?: 'v2' + sid?: string + jti?: string + sv?: number + ver?: 2 + authTime?: number + amr?: string[] } export type RpcAccessClaims = { diff --git a/control-api/src/routes/external/auth.ts b/control-api/src/routes/external/auth.ts index 48dd8690d6..5bf2dab21e 100644 --- a/control-api/src/routes/external/auth.ts +++ b/control-api/src/routes/external/auth.ts @@ -1,21 +1,41 @@ -import { Router } from 'express' +import { type NextFunction, type Request, type Response, Router } from 'express' import { createHash } from 'node:crypto' import { pool } from '../../db.js' +import { sendPublicApiError } from '../../http/publicApiError.js' import type { K8sGateway } from '../../k8s.js' -import { isCurrentExternalSession } from '../../middleware/externalSessionAuth.js' +import { attachAccessExecutionBudget } from '../../middleware/accessExecutionBudget.js' +import { + type ExternalAuthedRequest, + handleExternalSessionBackendFailure, + requireExternalSessionRateLimitContext, +} from '../../middleware/externalSessionAuth.js' +import { + externalUserRateLimitOptions, + requireAuthenticatedExternalUserRateLimitContext, +} from '../../middleware/externalUserRateLimitPolicy.js' import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' -import { AuthClaims, RpcScope, TEAM_ROLES } from '../../profileTypes.js' +import { RpcScope } from '../../profileTypes.js' +import { getLiveTeamMembership } from '../../services/access/liveTeamAuthorization.js' +import { resolveEffectiveUserAccessPolicy } from '../../services/access/userAccessRuntimePolicy.js' +import { authenticateExternalUserSession } from '../../services/auth/externalSessionAuthentication.js' +import { renewExternalUserSession } from '../../services/auth/externalSessionAuthentication.js' +import { + exchangeLegacyExternalUserSession, + issueExternalUserSession, + selectExternalSessionRepresentation, +} from '../../services/auth/externalSessionIssuance.js' +import { authenticatePasswordAndIssueSession } from '../../services/auth/passwordSessionAuthentication.js' +import { + revokeAllUserSessions, + revokeLegacyUserSession, + revokeUserSession, +} from '../../services/auth/userSessionService.js' import { getTeamAgents, getUserAgents, googleLoginData, - passwordLoginData, requestProfilePasswordReset, } from '../../services/directory/index.js' -import { - signExternalSessionToken, - verifyExternalSessionToken, -} from '../../utils/auth/externalSessionAuthToken.js' import { verifyGoogleIdToken } from '../../utils/auth/googleAuth.js' import { SANDBOX_UI_RPC_HOST_REF, @@ -28,96 +48,188 @@ import { userHasUiBearingRecipeAccess } from '../../utils/auth/sandboxUiScope.js const PASSWORD_RESET_RATE_LIMIT_PER_MINUTE = 5 +function externalSessionClient(req: { body?: unknown; header(name: string): string | undefined }): { + version?: string + requestedContract?: 'v1' | 'v2' +} { + const body = (req.body ?? {}) as { sessionContract?: unknown; clientVersion?: unknown } + const requestedContract = + body.sessionContract === 'v1' || body.sessionContract === 'v2' + ? body.sessionContract + : undefined + const version = String(req.header('x-evenfire-client-version') || body.clientVersion || '').trim() + return { + ...(version ? { version } : {}), + ...(requestedContract ? { requestedContract } : {}), + } +} + +function sessionTokenFromRequest(req: { + body?: unknown + header(name: string): string | undefined +}): string { + const body = (req.body ?? {}) as { token?: unknown; sessionToken?: unknown } + return String(req.header('x-user-session-token') || body.token || body.sessionToken || '').trim() +} + +function requireLegacySessionTokenPayload(req: Request, res: Response, next: NextFunction): void { + const body = (req.body ?? {}) as { userId?: unknown; email?: unknown; teamId?: unknown } + const userId = String(body.userId || '').trim() + const email = String(body.email || '').trim() + const teamId = String(body.teamId || '').trim() + const token = sessionTokenFromRequest(req) + if (!userId || !email || !teamId || !token || token.length > 4096) { + res.status(400).json({ error: 'invalid payload' }) + return + } + next() +} + +function sendExternalLoginError(res: Response, error: string | undefined): Response { + if (error === 'password_not_set') { + return res.status(409).json({ error: 'password_not_set' }) + } + return res.status(403).json({ error: 'membership_not_found' }) +} + +async function requireLegacyRpcSessionRateLimitContext( + req: ExternalAuthedRequest, + res: Response, + next: NextFunction +): Promise { + const sessionToken = String(req.body?.sessionToken || '').trim() + if (!sessionToken || sessionToken.length > 4096) { + res.status(401).json({ error: 'Unauthorized' }) + return + } + try { + const authentication = await authenticateExternalUserSession(sessionToken, { + purpose: 'rpc_legacy', + client: externalSessionClient(req), + }) + if (authentication.status === 'upgrade_required') { + res.status(426).json({ error: 'upgrade_required' }) + return + } + if (authentication.status !== 'authenticated') { + res.status(401).json({ error: 'Unauthorized' }) + return + } + req.externalAuth = authentication.claims + req.externalSessionAuthority = authentication.authorityContext + req.externalSessionAuthentication = authentication + next() + } catch (error) { + next(error) + } +} + export function createExternalAuthRouter(gateway: K8sGateway): Router { const router = Router() - router.post('/external/auth/google-login', async (req, res, next) => { - try { - const idToken = String(req.body?.idToken || '').trim() - if (!idToken) return res.status(400).json({ error: 'idToken is required' }) - const google = await verifyGoogleIdToken(idToken) - const login = await googleLoginData({ - email: google.email, - name: google.name, - picture: google.picture, - }) - if ('error' in login) { - return res.status(401).json({ error: 'Unauthorized' }) - } - const role = login.membership.role - const token = signExternalSessionToken({ - userId: login.user.id, - email: google.email, - teamId: login.membership.team_id || null, - role, - authGeneration: login.authGeneration, - }) - return res.status(200).json({ - token, - me: { - id: login.user.id, + router.post( + '/external/auth/google-login', + rateLimitMiddleware(externalUserRateLimitOptions('authentication_attempt', 'pre_auth')), + async (req, res, next) => { + try { + const idToken = String(req.body?.idToken || '').trim() + if (!idToken) return res.status(400).json({ error: 'idToken is required' }) + const google = await verifyGoogleIdToken(idToken) + const policy = await resolveEffectiveUserAccessPolicy() + const login = await googleLoginData({ email: google.email, - name: login.user.name, - picture: login.user.picture, - teamId: login.membership.team_id, - teamName: login.membership.team_name, - role, - }, - isNewUser: login.isNewUser, - }) - } catch (error) { - return next(error) + name: google.name, + picture: google.picture, + }) + if ('error' in login) return sendExternalLoginError(res, login.error) + const role = login.membership.role + const selection = selectExternalSessionRepresentation(externalSessionClient(req), policy) + if (selection.status !== 'selected') { + return res.status(426).json({ error: 'upgrade_required' }) + } + const issued = await issueExternalUserSession( + { + contract: selection.contract, + userId: login.user.id, + email: google.email, + teamId: login.membership.team_id || null, + role, + authGeneration: login.authGeneration, + authenticationMethods: ['google'], + }, + { policy } + ) + return res.status(200).json({ + token: issued.token, + sessionContract: issued.contract, + me: { + id: login.user.id, + email: google.email, + name: login.user.name, + picture: login.user.picture, + teamId: login.membership.team_id, + teamName: login.membership.team_name, + role, + }, + isNewUser: login.isNewUser, + }) + } catch (error) { + return next(error) + } } - }) + ) - router.post('/external/auth/password-login', async (req, res, next) => { - try { - const email = String(req.body?.email || '') - .trim() - .toLowerCase() - const password = String(req.body?.password || '') - if (!email || !password) { - return res.status(400).json({ error: 'email and password are required' }) - } + router.post( + '/external/auth/password-login', + rateLimitMiddleware(externalUserRateLimitOptions('authentication_attempt', 'pre_auth')), + async (req, res, next) => { + try { + const email = String(req.body?.email || '') + .trim() + .toLowerCase() + const password = String(req.body?.password || '') + if (!email || !password) { + return res.status(400).json({ error: 'email and password are required' }) + } - const login = await passwordLoginData({ email, password }) - if (!login) { - return res.status(401).json({ error: 'Unauthorized' }) - } - if ('error' in login) { - if (login.error === 'user_retired') { + const policy = await resolveEffectiveUserAccessPolicy() + const selection = selectExternalSessionRepresentation(externalSessionClient(req), policy) + if (selection.status !== 'selected') { + return res.status(426).json({ error: 'upgrade_required' }) + } + const login = await authenticatePasswordAndIssueSession({ + email, + password, + contract: selection.contract, + policy, + }) + if (!login) { return res.status(401).json({ error: 'Unauthorized' }) } - if (login.error === 'password_not_set') { - return res.status(409).json({ error: 'password_not_set' }) + if ('error' in login) { + return sendExternalLoginError(res, login.error) } - return res.status(403).json({ error: 'membership_not_found' }) - } - const role = login.membership.role - const token = signExternalSessionToken({ - userId: login.user.id, - email: login.user.email, - teamId: login.membership.team_id || null, - role, - authGeneration: login.authGeneration, - }) - return res.status(200).json({ - token, - me: { - id: login.user.id, - email: login.user.email, - name: login.user.name, - picture: login.user.picture, - teamId: login.membership.team_id || null, - teamName: login.membership.team_name, - role, - }, - }) - } catch (error) { - return next(error) + const role = login.membership.role + const issued = login.issued + return res.status(200).json({ + token: issued.token, + sessionContract: issued.contract, + me: { + id: login.user.id, + email: login.user.email, + name: login.user.name, + picture: login.user.picture, + teamId: login.membership.team_id || null, + teamName: login.membership.team_name, + role, + }, + }) + } catch (error) { + return next(error) + } } - }) + ) router.post( '/external/auth/password-reset/request', @@ -150,129 +262,369 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { } ) - router.post('/external/auth/verify', async (req, res, next) => { - try { - const token = String(req.body?.token || '').trim() - if (!token || token.length > 4096) return res.status(401).json({ error: 'Unauthorized' }) - const claims = verifyExternalSessionToken(token) - if (!claims) return res.status(401).json({ error: 'Unauthorized' }) - if (!(await isCurrentExternalSession(claims))) { - return res.status(401).json({ error: 'Unauthorized' }) + router.post( + '/external/auth/verify', + rateLimitMiddleware(externalUserRateLimitOptions('session_verify', 'pre_auth')), + async (req, res, next) => { + try { + const token = String(req.body?.token || '').trim() + if (!token || token.length > 4096) { + sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') + return + } + const authentication = await authenticateExternalUserSession(token, { + purpose: 'verify', + client: externalSessionClient(req), + }) + if (authentication.status === 'upgrade_required') { + sendPublicApiError(req, res, 426, 'upgrade_required', 'A newer client is required.') + return + } + if (authentication.status !== 'authenticated') { + sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') + return + } + return res.status(200).json({ claims: authentication.claims }) + } catch (error) { + handleExternalSessionBackendFailure(error, req, res, next) } - return res.status(200).json({ claims }) - } catch (error) { - return next(error) } - }) + ) - router.post('/external/auth/session-token', async (req, res, next) => { - try { - const role = String(req.body?.role || '').trim() as AuthClaims['role'] - const userId = String(req.body?.userId || '').trim() - const email = String(req.body?.email || '') - .trim() - .toLowerCase() - const teamId = String(req.body?.teamId || '').trim() - if (!userId || !email || !teamId || !TEAM_ROLES.includes(role)) { - return res.status(400).json({ error: 'invalid payload' }) - } - const membership = await pool.query( - `SELECT u.lifecycle_version + router.post( + '/external/auth/session-token', + rateLimitMiddleware(externalUserRateLimitOptions('session_lifecycle', 'pre_auth')), + requireLegacySessionTokenPayload, + requireExternalSessionRateLimitContext({ purpose: 'switch', client: externalSessionClient }), + requireAuthenticatedExternalUserRateLimitContext, + rateLimitMiddleware(externalUserRateLimitOptions('session_lifecycle', 'authenticated')), + async (req: ExternalAuthedRequest, res, next) => { + try { + const userId = String(req.body?.userId || '').trim() + const email = String(req.body?.email || '') + .trim() + .toLowerCase() + const teamId = String(req.body?.teamId || '').trim() + const currentToken = sessionTokenFromRequest(req) + if (!userId || !email || !teamId || !currentToken || currentToken.length > 4096) { + return res.status(400).json({ error: 'invalid payload' }) + } + const authentication = req.externalSessionAuthentication! + if ( + authentication.claims.userId !== userId || + authentication.claims.email.toLowerCase() !== email + ) { + sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') + return + } + if (authentication.contract === 'v1') { + const exchange = await exchangeLegacyExternalUserSession( + { + token: currentToken, + claims: authentication.claims, + userId, + email, + teamId, + }, + { policy: authentication.policy } + ) + if (exchange.status === 'invalid_session') { + sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') + return + } + if (exchange.status === 'membership_not_found') { + return res.status(403).json({ error: 'membership_not_found' }) + } + return res.status(200).json({ + token: exchange.token, + sessionContract: 'v1', + deprecated: true, + }) + } + + const membership = await pool.query( + `SELECT tm.role, u.lifecycle_version FROM users u JOIN team_members tm ON tm.user_id = u.id WHERE u.id = $1 AND LOWER(u.email) = LOWER($2) - AND u.lifecycle_state = 'active' AND tm.team_id = $3 - AND tm.role = $4 AND tm.status = 'active' + AND u.lifecycle_state = 'active' LIMIT 1`, - [userId, email, teamId, role] - ) - if ((membership.rowCount ?? 0) === 0) { - return res.status(403).json({ error: 'membership_not_found' }) - } - const authGeneration = Number( - (membership.rows[0] as { lifecycle_version?: unknown }).lifecycle_version - ) - if (!Number.isSafeInteger(authGeneration) || authGeneration < 1) { - return res.status(403).json({ error: 'membership_not_found' }) + [userId, email, teamId] + ) + const liveRole = ( + membership.rows[0] as + | { role?: 'admin' | 'inviter' | 'member'; lifecycle_version?: number | string } + | undefined + )?.role + if (!liveRole) { + return res.status(403).json({ error: 'membership_not_found' }) + } + return res.status(200).json({ + token: currentToken, + sessionContract: 'v2', + deprecated: true, + }) + } catch { + sendPublicApiError( + req, + res, + 503, + 'authority_unavailable', + 'Authorization is temporarily unavailable.', + true + ) } - const token = signExternalSessionToken({ userId, email, teamId, role, authGeneration }) - return res.status(200).json({ token }) - } catch (error) { - return next(error) } - }) + ) - router.post('/external/rpc/token', async (req, res, next) => { - try { - const sessionToken = String(req.body?.sessionToken || '').trim() - if (!sessionToken || sessionToken.length > 4096) - return res.status(401).json({ error: 'Unauthorized' }) - const claims = verifyExternalSessionToken(sessionToken) - if (!claims) return res.status(401).json({ error: 'Unauthorized' }) - if (!(await isCurrentExternalSession(claims))) { - return res.status(401).json({ error: 'Unauthorized' }) - } - const requestedScopes = normalizeRequestedScopes(req.body?.scopes) - const hostRefs = normalizeRequestedHostRefs(req.body?.hostRefs) - if (hostRefs.length === 0) { - return res.status(403).json({ error: 'invalid_host_refs' }) + router.post( + '/external/auth/session/renew', + rateLimitMiddleware(externalUserRateLimitOptions('session_lifecycle', 'pre_auth')), + requireExternalSessionRateLimitContext({ purpose: 'renew', client: externalSessionClient }), + requireAuthenticatedExternalUserRateLimitContext, + rateLimitMiddleware(externalUserRateLimitOptions('session_lifecycle', 'authenticated')), + async (req: ExternalAuthedRequest, res) => { + try { + const token = sessionTokenFromRequest(req) + if (!token || token.length > 4096) { + sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') + return + } + const authentication = req.externalSessionAuthentication! + const renewal = await renewExternalUserSession(token, { + client: externalSessionClient(req), + policy: authentication.policy, + }) + if (renewal.status === 'upgrade_required') { + sendPublicApiError(req, res, 426, 'upgrade_required', 'A newer client is required.') + return + } + if (renewal.status !== 'renewed') { + sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') + return + } + return res.status(200).json({ + token: renewal.session.token, + sessionContract: 'v2', + expiresInSeconds: renewal.session.expiresInSeconds, + absoluteExpiresAt: renewal.session.identity.absoluteExpiresAt.toISOString(), + }) + } catch { + sendPublicApiError( + req, + res, + 503, + 'authority_unavailable', + 'Session authority is temporarily unavailable.', + true + ) } + } + ) - const requestsSandboxUiScope = requestedScopes.includes('sandbox:ui:view') - const includesSandboxUiHostRef = hostRefs.includes(SANDBOX_UI_RPC_HOST_REF) - if (requestsSandboxUiScope && !includesSandboxUiHostRef) { - return res.status(403).json({ error: 'sandbox_ui_host_ref_required' }) + router.post( + '/external/auth/session/logout', + rateLimitMiddleware(externalUserRateLimitOptions('session_lifecycle', 'pre_auth')), + requireExternalSessionRateLimitContext({ + purpose: 'revoke_cleanup', + client: externalSessionClient, + }), + requireAuthenticatedExternalUserRateLimitContext, + rateLimitMiddleware(externalUserRateLimitOptions('session_lifecycle', 'authenticated')), + async (req: ExternalAuthedRequest, res) => { + try { + const token = sessionTokenFromRequest(req) + if (!token || token.length > 4096) { + sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') + return + } + const authentication = req.externalSessionAuthentication! + const revoked = + authentication.contract === 'v2' && authentication.claims.sid + ? await revokeUserSession( + authentication.claims.userId, + authentication.claims.sid, + 'logout' + ) + : await revokeLegacyUserSession(token, authentication.claims, 'logout') + return res.status(200).json({ revoked }) + } catch { + sendPublicApiError( + req, + res, + 503, + 'authority_unavailable', + 'Session authority is temporarily unavailable.', + true + ) } - if (includesSandboxUiHostRef && !requestsSandboxUiScope) { - return res.status(403).json({ error: 'sandbox_ui_scope_required' }) + } + ) + + router.post( + '/external/auth/sessions/revoke-all', + rateLimitMiddleware(externalUserRateLimitOptions('session_lifecycle', 'pre_auth')), + requireExternalSessionRateLimitContext({ purpose: 'protected', client: externalSessionClient }), + requireAuthenticatedExternalUserRateLimitContext, + rateLimitMiddleware(externalUserRateLimitOptions('session_lifecycle', 'authenticated')), + async (req: ExternalAuthedRequest, res) => { + try { + const token = sessionTokenFromRequest(req) + if (!token || token.length > 4096) { + sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') + return + } + const authentication = req.externalSessionAuthentication! + const revoked = await revokeAllUserSessions( + authentication.claims.userId, + 'user_revoked_all' + ) + return res.status(200).json({ revoked }) + } catch { + sendPublicApiError( + req, + res, + 503, + 'authority_unavailable', + 'Session authority is temporarily unavailable.', + true + ) } + } + ) - const agentHostRefs = hostRefs.filter(hostRef => hostRef !== SANDBOX_UI_RPC_HOST_REF) - if (includesSandboxUiHostRef && agentHostRefs.length > 0) { - return res.status(403).json({ error: 'sandbox_ui_host_ref_exclusive' }) + router.post( + '/external/auth/sessions/:sid/revoke', + rateLimitMiddleware(externalUserRateLimitOptions('session_lifecycle', 'pre_auth')), + requireExternalSessionRateLimitContext({ + purpose: 'protected', + client: externalSessionClient, + requireV2: true, + }), + requireAuthenticatedExternalUserRateLimitContext, + rateLimitMiddleware(externalUserRateLimitOptions('session_lifecycle', 'authenticated')), + async (req: ExternalAuthedRequest, res) => { + try { + const authentication = req.externalSessionAuthentication! + const revoked = await revokeUserSession( + authentication.claims.userId, + String(req.params.sid || '').trim(), + 'user_revoked' + ) + return res.status(200).json({ revoked }) + } catch { + sendPublicApiError( + req, + res, + 503, + 'authority_unavailable', + 'Session authority is temporarily unavailable.', + true + ) } - if (agentHostRefs.length > 0) { - const userAgents = await getUserAgents(claims.userId) - const grantedHostRefs = new Set(userAgents.agentNames) - if (claims.teamId) { - const teamAgents = await getTeamAgents(claims.teamId) - for (const agentName of teamAgents.agentNames) grantedHostRefs.add(agentName) + } + ) + + router.post( + '/external/rpc/token', + rateLimitMiddleware(externalUserRateLimitOptions('rpc_token', 'pre_auth')), + attachAccessExecutionBudget, + requireLegacyRpcSessionRateLimitContext, + requireAuthenticatedExternalUserRateLimitContext, + rateLimitMiddleware(externalUserRateLimitOptions('rpc_token', 'authenticated')), + async (req: ExternalAuthedRequest, res, next) => { + try { + const authentication = req.externalSessionAuthentication! + const claims = authentication.claims + let liveTeam: Awaited> = null + try { + liveTeam = claims.teamId + ? await getLiveTeamMembership(claims.userId, claims.teamId, { + budget: req.accessExecutionBudget, + }) + : null + } catch { + sendPublicApiError( + req, + res, + 503, + 'authority_unavailable', + 'Authorization is temporarily unavailable.', + true + ) + return } - if (agentHostRefs.some(hostRef => !grantedHostRefs.has(hostRef))) { - return res.status(403).json({ - error: claims.teamId ? 'host_access_denied' : 'direct_host_access_required', - }) + const requestedScopes = normalizeRequestedScopes(req.body?.scopes) + const hostRefs = normalizeRequestedHostRefs(req.body?.hostRefs) + if (hostRefs.length === 0) { + return res.status(403).json({ error: 'invalid_host_refs' }) } - } - const extraScopes: RpcScope[] = [] - // Resolve the K8s+DB sandbox UI grant only when the client explicitly - // asks for the scope. This keeps ordinary agent-token issuance from - // paying the sandbox UI recipe lookup cost or receiving unused grants. - if ( - requestsSandboxUiScope && - (await userHasUiBearingRecipeAccess(claims.userId, gateway, pool, claims.teamId)) - ) { - extraScopes.push('sandbox:ui:view') - } - const auth = { userId: claims.userId, teamId: claims.teamId, role: claims.role } - const result = issueRpcAccessToken(auth, req.body?.scopes, hostRefs, extraScopes) - if (!result) { - // Distinguish "you need a team for this" from a generic scope failure so - // the desktop app can act on it (e.g. prompt to join/switch a team) - // instead of surfacing an opaque "no access" state. - return res - .status(403) - .json({ error: classifyRpcTokenDenial(auth, req.body?.scopes, extraScopes) }) + const requestsSandboxUiScope = requestedScopes.includes('sandbox:ui:view') + const includesSandboxUiHostRef = hostRefs.includes(SANDBOX_UI_RPC_HOST_REF) + if (requestsSandboxUiScope && !includesSandboxUiHostRef) { + return res.status(403).json({ error: 'sandbox_ui_host_ref_required' }) + } + if (includesSandboxUiHostRef && !requestsSandboxUiScope) { + return res.status(403).json({ error: 'sandbox_ui_scope_required' }) + } + + const agentHostRefs = hostRefs.filter(hostRef => hostRef !== SANDBOX_UI_RPC_HOST_REF) + if (includesSandboxUiHostRef && agentHostRefs.length > 0) { + return res.status(403).json({ error: 'sandbox_ui_host_ref_exclusive' }) + } + if (agentHostRefs.length > 0) { + const userAgents = await getUserAgents(claims.userId) + const grantedHostRefs = new Set(userAgents.agentNames) + if (liveTeam) { + const teamAgents = await getTeamAgents(liveTeam.teamId) + for (const agentName of teamAgents.agentNames) grantedHostRefs.add(agentName) + } + if (agentHostRefs.some(hostRef => !grantedHostRefs.has(hostRef))) { + return res.status(403).json({ + error: liveTeam ? 'host_access_denied' : 'direct_host_access_required', + }) + } + } + + const extraScopes: RpcScope[] = [] + // Resolve the K8s+DB sandbox UI grant only when the client explicitly + // asks for the scope. This keeps ordinary agent-token issuance from + // paying the sandbox UI recipe lookup cost or receiving unused grants. + if ( + requestsSandboxUiScope && + (await userHasUiBearingRecipeAccess( + claims.userId, + gateway, + pool, + liveTeam?.teamId ?? null + )) + ) { + extraScopes.push('sandbox:ui:view') + } + const auth = { + userId: claims.userId, + teamId: liveTeam?.teamId ?? null, + role: liveTeam?.role ?? 'member', + } + const result = issueRpcAccessToken(auth, req.body?.scopes, hostRefs, extraScopes) + if (!result) { + // Distinguish "you need a team for this" from a generic scope failure so + // the desktop app can act on it (e.g. prompt to join/switch a team) + // instead of surfacing an opaque "no access" state. + return res + .status(403) + .json({ error: classifyRpcTokenDenial(auth, req.body?.scopes, extraScopes) }) + } + return res.status(200).json(result) + } catch (error) { + return next(error) } - return res.status(200).json(result) - } catch (error) { - return next(error) } - }) + ) return router } diff --git a/control-api/src/routes/external/invitations.ts b/control-api/src/routes/external/invitations.ts index 6fa6844d8e..fc4b607ebc 100644 --- a/control-api/src/routes/external/invitations.ts +++ b/control-api/src/routes/external/invitations.ts @@ -1,15 +1,25 @@ import { Router } from 'express' import { randomBytes } from 'node:crypto' import { config } from '../../config.js' +import { withTransaction } from '../../db.js' import { createExternalClientRateLimiters } from '../../middleware/externalClientIdentity.js' import { type ExternalAuthedRequest, rejectBodyUserTeamMismatch, requireValidExternalSessionToken, } from '../../middleware/externalSessionAuth.js' +import { + externalUserRateLimitOptions, + requireAuthenticatedExternalUserRateLimitContext, +} from '../../middleware/externalUserRateLimitPolicy.js' import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' +import { resolveEffectiveUserAccessPolicy } from '../../services/access/userAccessRuntimePolicy.js' +import { + issueExternalUserSession, + selectExternalSessionRepresentation, +} from '../../services/auth/externalSessionIssuance.js' import { - acceptInvitationForEmail, + acceptInvitationForEmailInTransaction, getInvitationByToken, listPendingInvitations, setInvitationPasswordForEmail, @@ -21,7 +31,6 @@ import { validateInvitationFlowToken, } from '../../services/invitationFlowRegistrationService.js' import { memberRegistrationErrorResponse } from '../../services/memberRegistrationErrors.js' -import { signExternalSessionToken } from '../../utils/auth/externalSessionAuthToken.js' function invitationLookupIpKey(req: { ip?: string @@ -39,6 +48,7 @@ export function createExternalInvitationsRouter(): Router { config.approvalRlExternalClientIpPerMin, config.approvalRlExternalEdgePerMin ) + router.get( '/external/invitations/token/:token', ...externalInvitationsEdgeRateLimits, @@ -101,13 +111,10 @@ export function createExternalInvitationsRouter(): Router { if (memberRegistrationErrorResponse(error)) throw error return res.status(400).json({ error: 'invalid_invitation' }) } - if (validation.invitationUuid !== invitationId) { - return res.status(403).json({ error: 'forbidden' }) - } - const result = await setInvitationPasswordForEmail( validation.email, validation.invitationUuid, + invitationId, password ) if ('error' in result) { @@ -117,23 +124,16 @@ export function createExternalInvitationsRouter(): Router { if (result.error === 'forbidden') { return res.status(403).json({ error: 'forbidden' }) } - if (result.error === 'not_accepted') { - return res.status(409).json({ error: 'invitation_not_accepted' }) - } if (result.error === 'not_pending') { return res.status(409).json({ error: 'invitation_not_pending' }) } if (result.error === 'expired') { return res.status(410).json({ error: 'expired' }) } - if (result.error === 'user_retired') { - return res.status(401).json({ error: 'Unauthorized' }) - } return res.status(400).json({ error: 'invalid_password' }) } - const userId = result.data.userId - if (!userId) { + if (!result.data.userId) { return res.status(409).json({ error: 'invitation_not_ready' }) } const authGeneration = Number(result.data.authGeneration) @@ -145,17 +145,9 @@ export function createExternalInvitationsRouter(): Router { return res.status(409).json({ error: 'invitation_not_ready' }) } - const sessionToken = signExternalSessionToken({ - userId, - email: result.data.email, - teamId: result.data.teamId || null, - role: result.data.role, - authGeneration, - }) - return res.status(200).json({ ...result.data, - token: sessionToken, + reauthenticationRequired: true, }) } catch (error) { return next(error) @@ -168,6 +160,8 @@ export function createExternalInvitationsRouter(): Router { ...externalInvitationsEdgeRateLimits, requireValidExternalSessionToken, rejectBodyUserTeamMismatch, + requireAuthenticatedExternalUserRateLimitContext, + rateLimitMiddleware(externalUserRateLimitOptions('invitation_mutation', 'authenticated')), async (req, res, next) => { try { const userId = String(req.body?.userId || '').trim() @@ -197,9 +191,6 @@ export function createExternalInvitationsRouter(): Router { if (result.error === 'expired') { return res.status(410).json({ error: 'expired' }) } - if (result.error === 'user_retired') { - return res.status(401).json({ error: 'Unauthorized' }) - } return res.status(400).json({ error: 'invalid_password' }) } return res.status(200).json(result.data) @@ -214,6 +205,10 @@ export function createExternalInvitationsRouter(): Router { ...externalInvitationsEdgeRateLimits, requireValidExternalSessionToken, rejectBodyUserTeamMismatch, + requireAuthenticatedExternalUserRateLimitContext, + rateLimitMiddleware( + externalUserRateLimitOptions('invitation_sensitive_action', 'authenticated') + ), async (req, res, next) => { try { const userId = String(req.body?.userId || '').trim() @@ -255,6 +250,8 @@ export function createExternalInvitationsRouter(): Router { '/external/invitations/pending', ...externalInvitationsEdgeRateLimits, requireValidExternalSessionToken, + requireAuthenticatedExternalUserRateLimitContext, + rateLimitMiddleware(externalUserRateLimitOptions('invitation_read', 'authenticated')), async (req, res, next) => { try { const email = String((req as ExternalAuthedRequest).externalAuth?.email || '') @@ -286,42 +283,62 @@ export function createExternalInvitationsRouter(): Router { if (memberRegistrationErrorResponse(error)) throw error return res.status(400).json({ error: 'invalid_invitation' }) } - const result = await acceptInvitationForEmail(validation.email, validation.invitationUuid) - if ('error' in result) { - if (result.error === 'not_found') { + const policy = await resolveEffectiveUserAccessPolicy() + const selection = selectExternalSessionRepresentation( + { + version: String(req.header('x-evenfire-client-version') || '').trim() || undefined, + requestedContract: + req.body?.sessionContract === 'v1' || req.body?.sessionContract === 'v2' + ? req.body.sessionContract + : undefined, + }, + policy + ) + if (selection.status !== 'selected') { + return res.status(426).json({ error: 'upgrade_required' }) + } + const accepted = await withTransaction(async db => { + const result = await acceptInvitationForEmailInTransaction( + db, + validation.email, + validation.invitationUuid + ) + if ('error' in result && result.error) return { error: result.error } + const lockedUser = await db.query(`SELECT id FROM users WHERE id = $1 FOR UPDATE`, [ + result.data.userId, + ]) + if ((lockedUser.rowCount ?? 0) === 0) return { error: 'not_found' as const } + const issued = await issueExternalUserSession( + { + contract: selection.contract, + userId: result.data.userId, + email: result.data.email, + teamId: result.data.teamId, + role: result.data.role, + authGeneration: Number(result.data.authGeneration), + authenticationMethods: ['invitation'], + }, + { db, policy } + ) + return { data: result.data, issued } + }) + if ('error' in accepted && accepted.error) { + if (accepted.error === 'not_found') { return res.status(404).json({ error: 'not_found' }) } - if (result.error === 'forbidden') { + if (accepted.error === 'forbidden') { return res.status(403).json({ error: 'forbidden' }) } - if (result.error === 'expired') { + if (accepted.error === 'expired') { return res.status(410).json({ error: 'expired' }) } - if (result.error === 'user_retired') { - return res.status(401).json({ error: 'Unauthorized' }) - } return res.status(400).json({ error: 'not_pending' }) } - const authGeneration = Number(result.data.authGeneration) - if ( - result.data.lifecycleState !== 'active' || - !Number.isSafeInteger(authGeneration) || - authGeneration < 1 - ) { - return res.status(409).json({ error: 'invitation_not_ready' }) - } - const sessionToken = signExternalSessionToken({ - userId: result.data.userId, - email: result.data.email, - teamId: result.data.teamId || null, - role: result.data.role, - authGeneration, - }) - return res.status(200).json({ - ...result.data, - token: sessionToken, + ...accepted.data, + token: accepted.issued.token, + sessionContract: accepted.issued.contract, }) } catch (error) { return next(error) diff --git a/control-api/src/routes/workflows/shared/auth.ts b/control-api/src/routes/workflows/shared/auth.ts index 0650a438f1..c8235e6af4 100644 --- a/control-api/src/routes/workflows/shared/auth.ts +++ b/control-api/src/routes/workflows/shared/auth.ts @@ -1,8 +1,8 @@ import { NextFunction, Request, Response } from 'express' -import { isCurrentExternalSession } from '../../../middleware/externalSessionAuth.js' +import type { ExternalAuthedRequest } from '../../../middleware/externalSessionAuth.js' import { authenticateAdminSession } from '../../../services/adminSessionAuth.js' +import { authenticateExternalUserSession } from '../../../services/auth/externalSessionAuthentication.js' import type { WorkflowCaller } from '../../../services/workflows/types.js' -import { verifyExternalSessionToken } from '../../../utils/auth/externalSessionAuthToken.js' import { type McpHostControlScope, verifyMcpHostControlJwt, @@ -32,6 +32,10 @@ export type AdminWorkflowAuthedRequest = Request & { adminWorkflowCaller?: Extract } +export type ExternalWorkflowAuthedRequest = Request & { + externalWorkflowCaller?: Extract +} + export async function requireAdminWorkflowCaller( req: Request, res: Response @@ -88,12 +92,83 @@ export async function requireExternalWorkflowCaller( const userSessionToken = getUserSessionToken(req) if (!userSessionToken || userSessionToken.length > 4096) return unauthorized(res) - const claims = verifyExternalSessionToken(userSessionToken) - if (!claims) return unauthorized(res) - if (!(await isCurrentExternalSession(claims))) { - return unauthorized(res) + try { + const authentication = await authenticateExternalUserSession(userSessionToken, { + purpose: 'workflow_user', + client: { version: req.header('x-evenfire-client-version') || undefined }, + }) + if (authentication.status === 'upgrade_required') { + res.status(426).json({ error: 'upgrade_required' }) + return null + } + if (authentication.status !== 'authenticated') return unauthorized(res) + return { + kind: 'user-session', + claims: authentication.claims, + session: authentication.authorityContext, + } + } catch { + res.status(503).json({ error: 'authority_unavailable' }) + return null + } +} + +export async function requireExternalWorkflowCallerMiddleware( + req: ExternalWorkflowAuthedRequest, + res: Response, + next: NextFunction +): Promise { + try { + const caller = await requireExternalWorkflowCaller(req, res) + if (!caller) return + req.externalWorkflowCaller = caller + next() + } catch (error) { + next(error) + } +} + +export function externalWorkflowCaller( + req: Request +): ExternalWorkflowAuthedRequest['externalWorkflowCaller'] { + return (req as ExternalWorkflowAuthedRequest).externalWorkflowCaller +} + +export function requireBoundExternalWorkflowCaller(req: Request, res: Response) { + const caller = externalWorkflowCaller(req) + if (!caller) { + if (!res.headersSent) { + res.status(401).json({ error: 'Unauthorized' }) + } + return null + } + return caller +} + +export function bindExternalWorkflowAuth(req: Request, res: Response, next: NextFunction): void { + void requireExternalWorkflowCallerMiddleware( + req as ExternalWorkflowAuthedRequest, + res, + next + ).catch(next) +} + +export function bindCompletedExternalWorkflowCaller( + req: ExternalWorkflowAuthedRequest & ExternalAuthedRequest, + res: Response, + next: NextFunction +): void { + const authentication = req.externalSessionAuthentication + if (!authentication) { + unauthorized(res) + return + } + req.externalWorkflowCaller = { + kind: 'user-session', + claims: authentication.claims, + session: authentication.authorityContext, } - return { kind: 'user-session', claims } + next() } export function requireMcpHostControlWorkflowCaller( diff --git a/control-api/src/services/access/userAccessPolicy.ts b/control-api/src/services/access/userAccessPolicy.ts new file mode 100644 index 0000000000..dbfb3a4610 --- /dev/null +++ b/control-api/src/services/access/userAccessPolicy.ts @@ -0,0 +1,413 @@ +import { createHash } from 'node:crypto' + +export const USER_ACCESS_POLICY_VERSION = '1' as const + +export const CATALOG_FAMILIES = [ + 'user', + 'team', + 'host', + 'context', + 'mcp_server', + 'workflow_recipe', + 'workflow_run', + 'workflow_approval', + 'notification', + 'gfs_resource', + 'shared_filesystem', + 'sandbox_app', +] as const + +export type CatalogFamily = (typeof CATALOG_FAMILIES)[number] +export type LegacyLifecycle = 'issue_and_accept' | 'accept_only' | 'disabled' +export type CatalogMode = 'off' | 'shadow' | 'serve_and_shadow' | 'serve' +export type Readiness = 'ready' | 'unavailable' + +export type ConfiguredUserAccessIntent = Readonly<{ + legacyLifecycle: LegacyLifecycle + sessionV2Acceptance: boolean + sessionV2Issuance: boolean + catalogMode: CatalogMode + actionContextV2: boolean + rpcDelegationV2: boolean + desktopAllTeamMode: boolean + profileV2Mode: boolean + legacySwitchEndpoint: boolean + minimumClientVersion: string | null + minimumClientEnforcement: boolean +}> + +export type DeploymentReadiness = Readonly<{ + revision: string + snapshot: 'current' | 'unavailable' + legacySession: Readiness + legacySwitchAuthorization: Readiness + legacyDrainAccepted: boolean + sessionV2Acceptance: Readiness + sessionV2Issuance: Readiness + accessBudget: Readiness + catalogCoordinator: Readiness + catalogRegisteredFamilies: ReadonlySet + catalogHarnessFamilies: ReadonlySet + catalogOperationalFamilies: ReadonlySet + catalogShadowComparison: Readiness + catalogParityAccepted: boolean + actionSafeRevisions: Readiness + actionContext: Readiness + rpcDelegationAllHops: Readiness + desktop: Readiness + explicitTeamAdapters: Readiness + profile: Readiness + minimumClientEnforcement: Readiness +}> + +export type EffectiveUserAccessPolicy = Readonly<{ + policyVersion: typeof USER_ACCESS_POLICY_VERSION + policyRevision: string + acceptV1: boolean + issueV1: boolean + acceptV2: boolean + issueV2: boolean + renewV2: boolean + switchCompatibility: boolean + computeCatalogShadow: boolean + serveCatalog: boolean + actionContextV2: boolean + rpcDelegationV2: boolean + desktopAllTeamMode: boolean + profileV2Mode: boolean + minimumClientVersion: string | null + enforceMinimumClient: boolean + advertisedCatalogFamilies: readonly CatalogFamily[] +}> + +export class UserAccessPolicyConfigurationError extends Error { + constructor(readonly code: string) { + super(`Invalid user-access rollout configuration: ${code}`) + this.name = 'UserAccessPolicyConfigurationError' + } +} + +const SEMANTIC_VERSION_PATTERN = /^(0|[1-9][0-9]{0,5})\.(0|[1-9][0-9]{0,5})\.(0|[1-9][0-9]{0,5})$/ + +function requireCondition(condition: boolean, code: string): void { + if (!condition) throw new UserAccessPolicyConfigurationError(code) +} + +function sortedFamilies(values: ReadonlySet): CatalogFamily[] { + return CATALOG_FAMILIES.filter(family => values.has(family)) +} + +function intersection(...sets: ReadonlySet[]): CatalogFamily[] { + return CATALOG_FAMILIES.filter(family => sets.every(set => set.has(family))) +} + +function hasEveryCatalogFamily(families: readonly CatalogFamily[]): boolean { + return families.length === CATALOG_FAMILIES.length +} + +function policyRevision( + readinessRevision: string, + effective: Omit +): string { + return createHash('sha256') + .update( + JSON.stringify({ + v: USER_ACCESS_POLICY_VERSION, + readinessRevision, + ...effective, + advertisedCatalogFamilies: [...effective.advertisedCatalogFamilies], + }) + ) + .digest('hex') +} + +export function compileUserAccessPolicy( + intent: ConfiguredUserAccessIntent, + readiness: DeploymentReadiness +): EffectiveUserAccessPolicy { + requireCondition(readiness.snapshot === 'current', 'readiness_snapshot_unavailable') + requireCondition(readiness.revision.trim().length > 0, 'readiness_revision_missing') + + const acceptV1 = intent.legacyLifecycle !== 'disabled' + const issueV1 = intent.legacyLifecycle === 'issue_and_accept' + const acceptV2 = intent.sessionV2Acceptance + const issueV2 = intent.sessionV2Issuance + + requireCondition(acceptV1 || acceptV2, 'no_accepted_session_contract') + if (acceptV1) { + requireCondition(readiness.legacySession === 'ready', 'legacy_session_unavailable') + } + if (acceptV2) { + requireCondition(readiness.sessionV2Acceptance === 'ready', 'session_v2_acceptance_unavailable') + } + if (issueV2) { + requireCondition(acceptV2, 'session_v2_issuance_requires_acceptance') + requireCondition(readiness.sessionV2Issuance === 'ready', 'session_v2_issuance_unavailable') + } + if (intent.legacyLifecycle === 'accept_only') { + requireCondition(issueV2, 'legacy_accept_only_requires_v2_issuance') + } + if (intent.legacyLifecycle === 'disabled') { + requireCondition(acceptV2 && issueV2, 'legacy_disabled_requires_v2_issue_and_accept') + requireCondition(readiness.legacyDrainAccepted, 'legacy_disabled_requires_accepted_drain') + } + + if (intent.legacySwitchEndpoint) { + requireCondition( + intent.legacyLifecycle === 'issue_and_accept', + 'legacy_switch_requires_v1_issue_and_accept' + ) + requireCondition( + readiness.legacySwitchAuthorization === 'ready', + 'legacy_switch_authorization_unavailable' + ) + } + + const advertisedCatalogFamilies = intersection( + readiness.catalogRegisteredFamilies, + readiness.catalogHarnessFamilies, + readiness.catalogOperationalFamilies + ) + const everyCatalogFamilyReady = hasEveryCatalogFamily(advertisedCatalogFamilies) + const wantsShadow = intent.catalogMode === 'shadow' || intent.catalogMode === 'serve_and_shadow' + const wantsServe = intent.catalogMode === 'serve' || intent.catalogMode === 'serve_and_shadow' + + if (wantsShadow) { + requireCondition(acceptV2, 'catalog_shadow_requires_v2_acceptance') + requireCondition(readiness.accessBudget === 'ready', 'catalog_shadow_budget_unavailable') + requireCondition( + readiness.catalogCoordinator === 'ready', + 'catalog_shadow_coordinator_unavailable' + ) + requireCondition(everyCatalogFamilyReady, 'catalog_shadow_families_incomplete') + requireCondition( + readiness.catalogShadowComparison === 'ready', + 'catalog_shadow_comparison_unavailable' + ) + } + if (wantsServe) { + requireCondition(acceptV2, 'catalog_serving_requires_v2_acceptance') + requireCondition(readiness.accessBudget === 'ready', 'catalog_serving_budget_unavailable') + requireCondition( + readiness.catalogCoordinator === 'ready', + 'catalog_serving_coordinator_unavailable' + ) + requireCondition(everyCatalogFamilyReady, 'catalog_serving_families_incomplete') + requireCondition( + readiness.actionSafeRevisions === 'ready', + 'catalog_serving_revisions_unavailable' + ) + requireCondition(readiness.catalogParityAccepted, 'catalog_serving_parity_not_accepted') + } + + if (intent.actionContextV2) { + requireCondition(acceptV2, 'action_context_requires_v2_acceptance') + requireCondition(readiness.accessBudget === 'ready', 'action_context_budget_unavailable') + requireCondition(readiness.actionContext === 'ready', 'action_context_unavailable') + } + if (intent.rpcDelegationV2) { + requireCondition(intent.actionContextV2, 'rpc_v2_requires_action_context') + requireCondition(readiness.rpcDelegationAllHops === 'ready', 'rpc_v2_all_hops_unavailable') + } + if (intent.desktopAllTeamMode) { + requireCondition(issueV2, 'desktop_mode_requires_v2_issuance') + requireCondition(wantsServe, 'desktop_mode_requires_catalog_serving') + requireCondition(intent.actionContextV2, 'desktop_mode_requires_action_context') + requireCondition(intent.rpcDelegationV2, 'desktop_mode_requires_rpc_v2') + requireCondition(readiness.desktop === 'ready', 'desktop_mode_unavailable') + } + if (intent.profileV2Mode) { + requireCondition(issueV2 && acceptV2, 'profile_mode_requires_v2_issue_and_accept') + requireCondition( + readiness.explicitTeamAdapters === 'ready', + 'profile_explicit_team_adapters_unavailable' + ) + requireCondition(readiness.profile === 'ready', 'profile_mode_unavailable') + } + + const minimumVersion = intent.minimumClientVersion?.trim() || null + if (minimumVersion !== null) { + requireCondition( + SEMANTIC_VERSION_PATTERN.test(minimumVersion), + 'minimum_client_version_invalid' + ) + } + if (intent.minimumClientEnforcement) { + requireCondition(minimumVersion !== null, 'minimum_client_version_missing') + requireCondition(issueV2, 'minimum_client_enforcement_requires_v2_issuance') + requireCondition( + readiness.minimumClientEnforcement === 'ready', + 'minimum_client_enforcement_unavailable' + ) + } + + const effective = Object.freeze({ + acceptV1, + issueV1, + acceptV2, + issueV2, + renewV2: acceptV2 && issueV2, + switchCompatibility: intent.legacySwitchEndpoint, + computeCatalogShadow: wantsShadow, + serveCatalog: wantsServe, + actionContextV2: intent.actionContextV2, + rpcDelegationV2: intent.rpcDelegationV2, + desktopAllTeamMode: intent.desktopAllTeamMode, + profileV2Mode: intent.profileV2Mode, + minimumClientVersion: intent.minimumClientEnforcement ? minimumVersion : null, + enforceMinimumClient: intent.minimumClientEnforcement, + advertisedCatalogFamilies: Object.freeze(advertisedCatalogFamilies), + }) + + return Object.freeze({ + policyVersion: USER_ACCESS_POLICY_VERSION, + policyRevision: policyRevision(readiness.revision, effective), + ...effective, + }) +} + +function parseBoolean(env: NodeJS.ProcessEnv, name: string, fallback: boolean): boolean { + const raw = env[name]?.trim().toLowerCase() + if (!raw) return fallback + if (raw === 'true' || raw === '1') return true + if (raw === 'false' || raw === '0') return false + throw new UserAccessPolicyConfigurationError(`${name.toLowerCase()}_invalid`) +} + +function parseEnum( + env: NodeJS.ProcessEnv, + name: string, + allowed: readonly T[], + fallback: T +): T { + const raw = env[name]?.trim().toLowerCase() + if (!raw) return fallback + if (allowed.includes(raw as T)) return raw as T + throw new UserAccessPolicyConfigurationError(`${name.toLowerCase()}_invalid`) +} + +function parseVersion(env: NodeJS.ProcessEnv, name: string): string | null { + const raw = env[name]?.trim() + if (!raw) return null + if (!SEMANTIC_VERSION_PATTERN.test(raw)) { + throw new UserAccessPolicyConfigurationError(`${name.toLowerCase()}_invalid`) + } + return raw +} + +export function loadConfiguredUserAccessIntent(env: NodeJS.ProcessEnv): ConfiguredUserAccessIntent { + return Object.freeze({ + legacyLifecycle: parseEnum( + env, + 'CONTROL_API_USER_ACCESS_LEGACY_LIFECYCLE', + ['issue_and_accept', 'accept_only', 'disabled'] as const, + 'issue_and_accept' + ), + sessionV2Acceptance: parseBoolean(env, 'CONTROL_API_USER_ACCESS_SESSION_V2_ACCEPTANCE', true), + sessionV2Issuance: parseBoolean(env, 'CONTROL_API_USER_ACCESS_SESSION_V2_ISSUANCE', false), + catalogMode: parseEnum( + env, + 'CONTROL_API_USER_ACCESS_CATALOG_MODE', + ['off', 'shadow', 'serve_and_shadow', 'serve'] as const, + 'off' + ), + actionContextV2: parseBoolean(env, 'CONTROL_API_USER_ACCESS_ACTION_CONTEXT_V2', false), + rpcDelegationV2: parseBoolean(env, 'CONTROL_API_USER_ACCESS_RPC_DELEGATION_V2', false), + desktopAllTeamMode: parseBoolean(env, 'CONTROL_API_USER_ACCESS_DESKTOP_ALL_TEAM_MODE', false), + profileV2Mode: parseBoolean(env, 'CONTROL_API_USER_ACCESS_PROFILE_V2_MODE', false), + legacySwitchEndpoint: parseBoolean(env, 'CONTROL_API_USER_ACCESS_LEGACY_SWITCH_ENDPOINT', true), + minimumClientVersion: parseVersion(env, 'CONTROL_API_USER_ACCESS_MINIMUM_CLIENT_VERSION'), + minimumClientEnforcement: parseBoolean( + env, + 'CONTROL_API_USER_ACCESS_MINIMUM_CLIENT_ENFORCEMENT', + false + ), + }) +} + +export const reconstructionReadiness: DeploymentReadiness = Object.freeze({ + revision: 'pr1-reconstruction-auth-v1', + snapshot: 'current', + legacySession: 'ready', + legacySwitchAuthorization: 'ready', + legacyDrainAccepted: false, + sessionV2Acceptance: 'ready', + // Password verification and session insertion are serialized in the next + // compatibility commit; issuance must fail startup until then. + sessionV2Issuance: 'unavailable', + accessBudget: 'unavailable', + catalogCoordinator: 'unavailable', + catalogRegisteredFamilies: new Set(), + catalogHarnessFamilies: new Set(), + catalogOperationalFamilies: new Set(), + catalogShadowComparison: 'unavailable', + catalogParityAccepted: false, + actionSafeRevisions: 'unavailable', + actionContext: 'unavailable', + // PR 2 owns the rpc-proxy and mcp-host hops. This stays unavailable in PR 1. + rpcDelegationAllHops: 'unavailable', + desktop: 'unavailable', + explicitTeamAdapters: 'unavailable', + profile: 'unavailable', + minimumClientEnforcement: 'unavailable', +}) + +export const effectiveUserAccessPolicy = compileUserAccessPolicy( + loadConfiguredUserAccessIntent(process.env), + reconstructionReadiness +) + +export function userAccessCapabilityManifest(policy: EffectiveUserAccessPolicy): Readonly<{ + policyVersion: string + policyRevision: string + v1Accepted: boolean + v1Issued: boolean + v2Accepted: boolean + v2Issued: boolean + v2Renewal: boolean + legacySwitch: boolean + catalogShadow: boolean + catalogServed: boolean + actionContextV2: boolean + rpcDelegationV2: boolean + desktopAllTeamMode: boolean + profileV2Mode: boolean + minimumClientVersion: string | null + minimumClientEnforced: boolean + catalogFamilies: readonly CatalogFamily[] +}> { + return Object.freeze({ + policyVersion: policy.policyVersion, + policyRevision: policy.policyRevision, + v1Accepted: policy.acceptV1, + v1Issued: policy.issueV1, + v2Accepted: policy.acceptV2, + v2Issued: policy.issueV2, + v2Renewal: policy.renewV2, + legacySwitch: policy.switchCompatibility, + catalogShadow: policy.computeCatalogShadow, + catalogServed: policy.serveCatalog, + actionContextV2: policy.actionContextV2, + rpcDelegationV2: policy.rpcDelegationV2, + desktopAllTeamMode: policy.desktopAllTeamMode, + profileV2Mode: policy.profileV2Mode, + minimumClientVersion: policy.minimumClientVersion, + minimumClientEnforced: policy.enforceMinimumClient, + catalogFamilies: Object.freeze([...policy.advertisedCatalogFamilies]), + }) +} + +export function compareSemanticVersions(left: string, right: string): number | null { + const leftMatch = SEMANTIC_VERSION_PATTERN.exec(left) + const rightMatch = SEMANTIC_VERSION_PATTERN.exec(right) + if (!leftMatch || !rightMatch) return null + for (let index = 1; index <= 3; index += 1) { + const difference = Number(leftMatch[index]) - Number(rightMatch[index]) + if (difference !== 0) return difference < 0 ? -1 : 1 + } + return 0 +} + +export function registeredCatalogFamilies(readiness: DeploymentReadiness): CatalogFamily[] { + return sortedFamilies(readiness.catalogRegisteredFamilies) +} diff --git a/control-api/src/services/auth/externalSessionAuthentication.ts b/control-api/src/services/auth/externalSessionAuthentication.ts new file mode 100644 index 0000000000..34171e38f3 --- /dev/null +++ b/control-api/src/services/auth/externalSessionAuthentication.ts @@ -0,0 +1,166 @@ +import type { AuthClaims } from '../../profileTypes.js' +import { verifyExternalSessionToken } from '../../utils/auth/externalSessionAuthToken.js' +import { + type UserSessionV2Claims, + verifyUserSessionV2Token, +} from '../../utils/auth/userSessionV2Token.js' +import { + type EffectiveUserAccessPolicy, + compareSemanticVersions, + effectiveUserAccessPolicy, +} from '../access/userAccessPolicy.js' +import { + type IssuedUserSession, + renewUserSession, + validateLegacyUserSession, + validateUserSessionClaims, +} from './userSessionService.js' + +export type ExternalSessionPurpose = + | 'protected' + | 'verify' + | 'renew' + | 'switch' + | 'rpc_legacy' + | 'workflow_user' + | 'revoke_cleanup' + +export type ExternalSessionClient = Readonly<{ + version?: string + requestedContract?: 'v1' | 'v2' +}> + +export type ExternalSessionAuthentication = + | { status: 'authenticated'; claims: AuthClaims; contract: 'v1' | 'v2' } + | { status: 'invalid' | 'expired' | 'revoked'; reason: string } + | { status: 'upgrade_required'; reason: string } + +export type ExternalSessionRenewal = + | { status: 'renewed'; session: IssuedUserSession } + | Exclude + +type AuthenticationOptions = Readonly<{ + purpose: ExternalSessionPurpose + client?: ExternalSessionClient + policy?: EffectiveUserAccessPolicy + now?: Date +}> + +function clientMeetsMinimum( + client: ExternalSessionClient | undefined, + policy: EffectiveUserAccessPolicy +): boolean { + if (!policy.enforceMinimumClient || !policy.minimumClientVersion) return true + if (!client?.version) return false + const comparison = compareSemanticVersions(client.version, policy.minimumClientVersion) + return comparison !== null && comparison >= 0 +} + +function purposeRequiresMinimumClient(purpose: ExternalSessionPurpose): boolean { + return purpose !== 'revoke_cleanup' +} + +function v1Allowed(purpose: ExternalSessionPurpose, policy: EffectiveUserAccessPolicy): boolean { + if (purpose === 'renew') return false + if (purpose === 'switch') { + return policy.acceptV1 && policy.issueV1 && policy.switchCompatibility + } + return policy.acceptV1 +} + +function v2Allowed(purpose: ExternalSessionPurpose, policy: EffectiveUserAccessPolicy): boolean { + if (purpose === 'renew') return policy.acceptV2 && policy.issueV2 && policy.renewV2 + if (purpose === 'switch') return policy.acceptV2 && policy.switchCompatibility + if (purpose === 'revoke_cleanup') return true + return policy.acceptV2 +} + +function normalizedV2Claims( + claims: UserSessionV2Claims, + identity: { + userId: string + email: string + sid: string + jti: string + sessionVersion: number + } +): AuthClaims { + return { + userId: identity.userId, + email: identity.email, + teamId: null, + role: 'member', + exp: claims.exp, + iat: claims.iat, + sessionContract: 'v2', + sid: identity.sid, + jti: identity.jti, + sv: identity.sessionVersion, + ver: 2, + authTime: claims.auth_time, + amr: [...claims.amr], + } +} + +/** + * Sole external-user authentication boundary. No route or middleware may call + * a low-level external-user token verifier directly. + */ +export async function authenticateExternalUserSession( + token: string, + options: AuthenticationOptions +): Promise { + const policy = options.policy ?? effectiveUserAccessPolicy + if ( + purposeRequiresMinimumClient(options.purpose) && + !clientMeetsMinimum(options.client, policy) + ) { + return { status: 'upgrade_required', reason: 'minimum_client_version' } + } + + const v2Claims = verifyUserSessionV2Token(token) + if (v2Claims) { + if (!v2Allowed(options.purpose, policy)) { + return { status: 'invalid', reason: 'session_contract_not_accepted' } + } + const validation = await validateUserSessionClaims(v2Claims, { now: options.now }) + if (validation.status !== 'valid') return validation + return { + status: 'authenticated', + contract: 'v2', + claims: normalizedV2Claims(v2Claims, validation.identity), + } + } + + const v1Claims = verifyExternalSessionToken(token) + if (!v1Claims) return { status: 'invalid', reason: 'invalid_representation' } + if (!v1Allowed(options.purpose, policy)) { + return { status: 'invalid', reason: 'session_contract_not_accepted' } + } + const validation = await validateLegacyUserSession(token, v1Claims) + if (validation.status !== 'valid') return validation + return { status: 'authenticated', contract: 'v1', claims: v1Claims } +} + +export async function renewExternalUserSession( + token: string, + options: Omit = {} +): Promise { + const policy = options.policy ?? effectiveUserAccessPolicy + if (!clientMeetsMinimum(options.client, policy)) { + return { status: 'upgrade_required', reason: 'minimum_client_version' } + } + if (!policy.acceptV2 || !policy.issueV2 || !policy.renewV2) { + return { status: 'invalid', reason: 'session_renewal_unavailable' } + } + const claims = verifyUserSessionV2Token(token) + if (!claims) return { status: 'invalid', reason: 'invalid_representation' } + const result = await renewUserSession(claims, { now: options.now }) + if (!('token' in result)) { + if (result.status === 'valid') { + return { status: 'invalid', reason: 'session_renewal_failed' } + } + return result + } + return { status: 'renewed', session: result } +} diff --git a/control-api/src/services/auth/externalSessionIssuance.ts b/control-api/src/services/auth/externalSessionIssuance.ts new file mode 100644 index 0000000000..711c08f26c --- /dev/null +++ b/control-api/src/services/auth/externalSessionIssuance.ts @@ -0,0 +1,86 @@ +import type { DbClient } from '../../db.js' +import type { TeamRole } from '../../profileTypes.js' +import { signExternalSessionToken } from '../../utils/auth/externalSessionAuthToken.js' +import { + type EffectiveUserAccessPolicy, + compareSemanticVersions, + effectiveUserAccessPolicy, +} from '../access/userAccessPolicy.js' +import type { ExternalSessionClient } from './externalSessionAuthentication.js' +import { createUserSession } from './userSessionService.js' + +export type ExternalSessionContract = 'v1' | 'v2' + +export type ExternalSessionSelection = + | { status: 'selected'; contract: ExternalSessionContract } + | { status: 'upgrade_required'; reason: string } + +export function selectExternalSessionRepresentation( + client: ExternalSessionClient, + policy: EffectiveUserAccessPolicy = effectiveUserAccessPolicy +): ExternalSessionSelection { + if (policy.enforceMinimumClient && policy.minimumClientVersion) { + const comparison = client.version + ? compareSemanticVersions(client.version, policy.minimumClientVersion) + : null + if (comparison === null || comparison < 0) { + return { status: 'upgrade_required', reason: 'minimum_client_version' } + } + } + + if (client.requestedContract === 'v2') { + return policy.issueV2 + ? { status: 'selected', contract: 'v2' } + : { status: 'upgrade_required', reason: 'session_v2_issuance_unavailable' } + } + if (client.requestedContract === 'v1' || client.requestedContract === undefined) { + return policy.issueV1 + ? { status: 'selected', contract: 'v1' } + : { status: 'upgrade_required', reason: 'legacy_session_issuance_unavailable' } + } + return { status: 'upgrade_required', reason: 'unsupported_session_contract' } +} + +export async function issueExternalUserSession( + input: { + contract: ExternalSessionContract + userId: string + email: string + teamId: string | null + role: TeamRole + authenticationMethods: string[] + }, + options: { + db?: Pick + policy?: EffectiveUserAccessPolicy + } = {} +): Promise<{ token: string; contract: ExternalSessionContract }> { + const policy = options.policy ?? effectiveUserAccessPolicy + if (input.contract === 'v2') { + if (!policy.issueV2 || !policy.acceptV2) { + throw new Error('user-session v2 issuance is not effective') + } + const sessionInput = { + userId: input.userId, + email: input.email, + authenticationMethods: input.authenticationMethods, + } + const session = options.db + ? await createUserSession(sessionInput, { db: options.db }) + : await createUserSession(sessionInput) + return { token: session.token, contract: 'v2' } + } + + if (!policy.issueV1 || !policy.acceptV1) { + throw new Error('legacy user-session issuance is not effective') + } + return { + token: signExternalSessionToken({ + userId: input.userId, + email: input.email, + teamId: input.teamId, + role: input.role, + }), + contract: 'v1', + } +} diff --git a/control-api/src/services/auth/userSessionService.ts b/control-api/src/services/auth/userSessionService.ts index ec9026c08f..62c7dc7fb6 100644 --- a/control-api/src/services/auth/userSessionService.ts +++ b/control-api/src/services/auth/userSessionService.ts @@ -5,7 +5,6 @@ import type { UserSessionV2Claims } from '../../utils/auth/userSessionV2Token.js import { USER_SESSION_V2_TTL_SECONDS, signUserSessionV2Token, - verifyUserSessionV2Token, } from '../../utils/auth/userSessionV2Token.js' export const USER_SESSION_IDLE_LIFETIME_SECONDS = 14 * 24 * 60 * 60 @@ -270,15 +269,6 @@ export async function validateUserSessionClaims( return options.db ? work(options.db) : withTransaction(work) } -export async function authenticateUserSessionToken( - token: string, - options: SessionClock & { db?: SessionDatabase } = {} -): Promise { - const claims = verifyUserSessionV2Token(token) - if (!claims) return { status: 'invalid', reason: 'invalid_representation' } - return validateUserSessionClaims(claims, options) -} - export async function renewUserSession( claims: UserSessionV2Claims, options: SessionClock & { db?: SessionDatabase } = {} @@ -341,15 +331,6 @@ export async function renewUserSession( return options.db ? work(options.db) : withTransaction(work) } -export async function renewUserSessionToken( - token: string, - options: SessionClock & { db?: SessionDatabase } = {} -): Promise { - const claims = verifyUserSessionV2Token(token) - if (!claims) return { status: 'invalid', reason: 'invalid_representation' } - return renewUserSession(claims, options) -} - export async function revokeUserSession( userId: string, sid: string, diff --git a/control-api/test/externalSessionAuthentication.test.ts b/control-api/test/externalSessionAuthentication.test.ts new file mode 100644 index 0000000000..be03f3e6fc --- /dev/null +++ b/control-api/test/externalSessionAuthentication.test.ts @@ -0,0 +1,180 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { randomUUID } from 'node:crypto' +import type { EffectiveUserAccessPolicy } from '../src/services/access/userAccessPolicy.js' +import { + authenticateExternalUserSession, + renewExternalUserSession, +} from '../src/services/auth/externalSessionAuthentication.js' +import { signExternalSessionToken } from '../src/utils/auth/externalSessionAuthToken.js' +import { signUserSessionV2Token } from '../src/utils/auth/userSessionV2Token.js' + +const sessionMocks = vi.hoisted(() => ({ + validateLegacyUserSession: vi.fn(), + validateUserSessionClaims: vi.fn(), + renewUserSession: vi.fn(), +})) + +vi.mock('../src/services/auth/userSessionService.js', async importOriginal => { + const actual = await importOriginal() + return { ...actual, ...sessionMocks } +}) + +const userId = '11111111-1111-4111-8111-111111111111' +const nowSeconds = Math.floor(Date.now() / 1000) + +function policy(overrides: Partial = {}): EffectiveUserAccessPolicy { + return { + policyVersion: '1', + policyRevision: 'test-policy', + acceptV1: true, + issueV1: true, + acceptV2: true, + issueV2: false, + renewV2: false, + switchCompatibility: true, + computeCatalogShadow: false, + serveCatalog: false, + actionContextV2: false, + rpcDelegationV2: false, + desktopAllTeamMode: false, + profileV2Mode: false, + minimumClientVersion: null, + enforceMinimumClient: false, + advertisedCatalogFamilies: [], + ...overrides, + } +} + +function validIdentity(overrides: Record = {}) { + return { + userId, + email: 'user@example.com', + sid: randomUUID(), + jti: randomUUID(), + sessionVersion: 1, + expiresAt: new Date((nowSeconds + 3600) * 1000), + absoluteExpiresAt: new Date((nowSeconds + 30 * 24 * 3600) * 1000), + authenticationMethods: ['pwd'], + ...overrides, + } +} + +function v1Token(): string { + return signExternalSessionToken({ + userId, + email: 'user@example.com', + teamId: '22222222-2222-4222-8222-222222222222', + role: 'member', + }) +} + +function v2Token(identity = validIdentity()): string { + return signUserSessionV2Token({ + sub: identity.userId, + sid: identity.sid, + jti: identity.jti, + sv: identity.sessionVersion, + email: identity.email, + auth_time: nowSeconds, + amr: ['pwd'], + }) +} + +describe('external user-session authentication boundary', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('live-validates accepted v1 and rejects it when policy acceptance is off', async () => { + const identity = validIdentity({ sid: '', sessionVersion: 0 }) + sessionMocks.validateLegacyUserSession.mockResolvedValue({ status: 'valid', identity }) + const token = v1Token() + + await expect( + authenticateExternalUserSession(token, { purpose: 'protected', policy: policy() }) + ).resolves.toMatchObject({ status: 'authenticated', contract: 'v1' }) + await expect( + authenticateExternalUserSession(token, { + purpose: 'verify', + policy: policy({ acceptV1: false, issueV1: false }), + }) + ).resolves.toEqual({ status: 'invalid', reason: 'session_contract_not_accepted' }) + }) + + it('normalizes v2 without team authority and applies purpose-specific acceptance', async () => { + const identity = validIdentity() + sessionMocks.validateUserSessionClaims.mockResolvedValue({ status: 'valid', identity }) + const token = v2Token(identity) + + await expect( + authenticateExternalUserSession(token, { purpose: 'protected', policy: policy() }) + ).resolves.toMatchObject({ + status: 'authenticated', + contract: 'v2', + claims: { + userId, + teamId: null, + role: 'member', + sessionContract: 'v2', + sid: identity.sid, + }, + }) + await expect( + authenticateExternalUserSession(token, { + purpose: 'switch', + policy: policy({ switchCompatibility: false }), + }) + ).resolves.toEqual({ status: 'invalid', reason: 'session_contract_not_accepted' }) + }) + + it('allows v2 self-cleanup during acceptance rollback but no protected work', async () => { + const identity = validIdentity() + sessionMocks.validateUserSessionClaims.mockResolvedValue({ status: 'valid', identity }) + const token = v2Token(identity) + const rolledBack = policy({ acceptV2: false }) + + await expect( + authenticateExternalUserSession(token, { purpose: 'revoke_cleanup', policy: rolledBack }) + ).resolves.toMatchObject({ status: 'authenticated', contract: 'v2' }) + await expect( + authenticateExternalUserSession(token, { purpose: 'protected', policy: rolledBack }) + ).resolves.toEqual({ status: 'invalid', reason: 'session_contract_not_accepted' }) + }) + + it('enforces minimum client before live authority work', async () => { + const token = v1Token() + const minimum = policy({ + minimumClientVersion: '2.3.0', + enforceMinimumClient: true, + }) + + await expect( + authenticateExternalUserSession(token, { + purpose: 'protected', + policy: minimum, + client: { version: '2.2.9' }, + }) + ).resolves.toEqual({ status: 'upgrade_required', reason: 'minimum_client_version' }) + expect(sessionMocks.validateLegacyUserSession).not.toHaveBeenCalled() + }) + + it('renews only when acceptance and issuance are both effective', async () => { + const identity = validIdentity() + const token = v2Token(identity) + sessionMocks.renewUserSession.mockResolvedValue({ + token: 'successor', + expiresInSeconds: 3600, + identity, + }) + + await expect(renewExternalUserSession(token, { policy: policy() })).resolves.toEqual({ + status: 'invalid', + reason: 'session_renewal_unavailable', + }) + await expect( + renewExternalUserSession(token, { + policy: policy({ issueV2: true, renewV2: true }), + }) + ).resolves.toMatchObject({ status: 'renewed', session: { token: 'successor' } }) + }) +}) diff --git a/control-api/test/externalSessionImportBoundary.test.ts b/control-api/test/externalSessionImportBoundary.test.ts new file mode 100644 index 0000000000..fd84190212 --- /dev/null +++ b/control-api/test/externalSessionImportBoundary.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { readFileSync, readdirSync } from 'node:fs' +import { join, relative } from 'node:path' + +function sourceFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap(entry => { + const path = join(directory, entry.name) + if (entry.isDirectory()) return sourceFiles(path) + return entry.isFile() && entry.name.endsWith('.ts') ? [path] : [] + }) +} + +describe('external user-session verifier import boundary', () => { + it('keeps low-level external-user verifiers behind the one authentication boundary', () => { + const sourceRoot = join(process.cwd(), 'src') + const allowed = new Set([ + 'services/auth/externalSessionAuthentication.ts', + 'utils/auth/externalSessionAuthToken.ts', + 'utils/auth/userSessionV2Token.ts', + ]) + const violations = sourceFiles(sourceRoot) + .filter(path => { + const local = relative(sourceRoot, path) + if (allowed.has(local)) return false + const source = readFileSync(path, 'utf8') + return /\b(?:verifyExternalSessionToken|verifyUserSessionV2Token)\b/.test(source) + }) + .map(path => relative(sourceRoot, path)) + + expect(violations).toEqual([]) + }) +}) diff --git a/control-api/test/externalSessionTeamAuthorization.test.ts b/control-api/test/externalSessionTeamAuthorization.test.ts index db025c5d0e..23bc58c283 100644 --- a/control-api/test/externalSessionTeamAuthorization.test.ts +++ b/control-api/test/externalSessionTeamAuthorization.test.ts @@ -7,10 +7,10 @@ import { requireValidExternalSessionToken, } from '../src/middleware/externalSessionAuth.js' -const session = vi.hoisted(() => ({ verifyExternalSessionToken: vi.fn() })) +const session = vi.hoisted(() => ({ authenticateExternalUserSession: vi.fn() })) const db = vi.hoisted(() => ({ query: vi.fn() })) -vi.mock('../src/utils/auth/externalSessionAuthToken.js', () => session) +vi.mock('../src/services/auth/externalSessionAuthentication.js', () => session) vi.mock('../src/db.js', () => ({ pool: db })) function app() { @@ -28,12 +28,16 @@ function app() { describe('external team authorization', () => { beforeEach(() => { vi.clearAllMocks() - session.verifyExternalSessionToken.mockReturnValue({ - userId: 'user-1', - email: 'user@example.com', - teamId: 'team-1', - role: 'admin', - exp: Math.floor(Date.now() / 1000) + 3600, + session.authenticateExternalUserSession.mockResolvedValue({ + status: 'authenticated', + contract: 'v1', + claims: { + userId: 'user-1', + email: 'user@example.com', + teamId: 'team-1', + role: 'admin', + exp: Math.floor(Date.now() / 1000) + 3600, + }, }) }) @@ -55,12 +59,16 @@ describe('external team authorization', () => { }) it('allows a current admin even when the legacy token team and role are stale', async () => { - session.verifyExternalSessionToken.mockReturnValue({ - userId: 'user-1', - email: 'user@example.com', - teamId: 'other-team', - role: 'member', - exp: Math.floor(Date.now() / 1000) + 3600, + session.authenticateExternalUserSession.mockResolvedValue({ + status: 'authenticated', + contract: 'v1', + claims: { + userId: 'user-1', + email: 'user@example.com', + teamId: 'other-team', + role: 'member', + exp: Math.floor(Date.now() / 1000) + 3600, + }, }) db.query.mockResolvedValue({ rows: [{ team_id: 'team-1', role: 'admin' }], diff --git a/control-api/test/routes.externalGfs.test.ts b/control-api/test/routes.externalGfs.test.ts index 233028f5c8..7efbbf4e8c 100644 --- a/control-api/test/routes.externalGfs.test.ts +++ b/control-api/test/routes.externalGfs.test.ts @@ -28,8 +28,8 @@ const mockWithTransaction = vi.hoisted(() => vi.fn()) const mockResolveActiveLink = vi.hoisted(() => vi.fn()) const mockIsDesktopUserActive = vi.hoisted(() => vi.fn()) -vi.mock('../src/utils/auth/externalSessionAuthToken.js', () => ({ - verifyExternalSessionToken: (...a: unknown[]) => mockVerifyExternalSessionToken(...a), +vi.mock('../src/services/auth/externalSessionAuthentication.js', () => ({ + authenticateExternalUserSession: (...a: unknown[]) => mockVerifyExternalSessionToken(...a), })) vi.mock('../src/auth/gfsToken.js', () => ({ GFS_DELETE_SCOPE: 'gfs.delete', @@ -207,7 +207,12 @@ beforeEach(() => { }) afterEach(() => vi.unstubAllGlobals()) -const auth = () => mockVerifyExternalSessionToken.mockReturnValue(SESSION) +const auth = () => + mockVerifyExternalSessionToken.mockResolvedValue({ + status: 'authenticated', + contract: 'v1', + claims: SESSION, + }) function activeSessionLifecycleResult( text: string @@ -1007,7 +1012,10 @@ describe('POST /external/gfs/token (user mint — existing signer, sub=users.id) }) it('401 without a session token (Session-JWT plane gate)', async () => { - mockVerifyExternalSessionToken.mockReturnValue(null) + mockVerifyExternalSessionToken.mockResolvedValue({ + status: 'invalid', + reason: 'invalid_representation', + }) const app = await buildApp() const res = await request(app).post('/external/gfs/token').send({}) expect(res.status).toBe(401) @@ -2284,7 +2292,10 @@ describe('GET /external/gfs/resources/:id/affordances', () => { describe('user resource mutations via gfsc proxy', () => { it('rejects non-session tokens on external mutation routes before minting a GFS token', async () => { - mockVerifyExternalSessionToken.mockReturnValue(null) + mockVerifyExternalSessionToken.mockResolvedValue({ + status: 'invalid', + reason: 'invalid_representation', + }) const fetchMock = vi.fn() vi.stubGlobal('fetch', fetchMock) const app = await buildApp() diff --git a/control-api/test/routes.externalWorkflows.test.ts b/control-api/test/routes.externalWorkflows.test.ts index de2bf55bb5..297836a04c 100644 --- a/control-api/test/routes.externalWorkflows.test.ts +++ b/control-api/test/routes.externalWorkflows.test.ts @@ -12,7 +12,7 @@ const mockIssueWorkflowControlToken = vi.fn() const mockVerifyWorkflowControlToken = vi.fn() const mockIsHostRefAuthorized = vi.fn() const mockVerifyAdminToken = vi.fn() -const mockVerifyExternalSessionToken = vi.fn() +const mockAuthenticateExternalUserSession = vi.fn() const mockVerifyInternalControlJwt = vi.fn() const mockIsAdminTokenRevoked = vi.fn() @@ -39,8 +39,9 @@ vi.mock('../src/utils/auth/adminAuthToken.js', () => ({ verifyAdminToken: (...args: unknown[]) => mockVerifyAdminToken(...args), })) -vi.mock('../src/utils/auth/externalSessionAuthToken.js', () => ({ - verifyExternalSessionToken: (...args: unknown[]) => mockVerifyExternalSessionToken(...args), +vi.mock('../src/services/auth/externalSessionAuthentication.js', () => ({ + authenticateExternalUserSession: (...args: unknown[]) => + mockAuthenticateExternalUserSession(...args), })) vi.mock('../src/utils/auth/internalControlToken.js', () => ({ @@ -127,7 +128,7 @@ describe('routes/external/workflows', () => { mockVerifyWorkflowControlToken.mockReset() mockIsHostRefAuthorized.mockReset() mockVerifyAdminToken.mockReset() - mockVerifyExternalSessionToken.mockReset() + mockAuthenticateExternalUserSession.mockReset() mockVerifyInternalControlJwt.mockReset() mockIsAdminTokenRevoked.mockReset() gateway = new MockGateway(RECIPE_NS) @@ -137,8 +138,14 @@ describe('routes/external/workflows', () => { expiresInSeconds: 600, }) mockIsAdminTokenRevoked.mockResolvedValue(false) - mockVerifyExternalSessionToken.mockImplementation(token => - token === 'user-session-token' ? USER_SESSION_CLAIMS : null + mockAuthenticateExternalUserSession.mockImplementation(token => + token === 'user-session-token' + ? Promise.resolve({ + status: 'authenticated', + contract: 'v1', + claims: USER_SESSION_CLAIMS, + }) + : Promise.resolve({ status: 'invalid', reason: 'invalid_representation' }) ) mockVerifyAdminToken.mockImplementation(token => token === 'admin-token' ? ADMIN_CLAIMS : null diff --git a/control-api/test/userAccessPolicy.test.ts b/control-api/test/userAccessPolicy.test.ts new file mode 100644 index 0000000000..125c7a8dca --- /dev/null +++ b/control-api/test/userAccessPolicy.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from 'vitest' +import { + CATALOG_FAMILIES, + type ConfiguredUserAccessIntent, + type DeploymentReadiness, + UserAccessPolicyConfigurationError, + compareSemanticVersions, + compileUserAccessPolicy, + effectiveUserAccessPolicy, + loadConfiguredUserAccessIntent, +} from '../src/services/access/userAccessPolicy.js' + +const allFamilies = new Set(CATALOG_FAMILIES) + +function intent(overrides: Partial = {}): ConfiguredUserAccessIntent { + return { + legacyLifecycle: 'issue_and_accept', + sessionV2Acceptance: true, + sessionV2Issuance: false, + catalogMode: 'off', + actionContextV2: false, + rpcDelegationV2: false, + desktopAllTeamMode: false, + profileV2Mode: false, + legacySwitchEndpoint: true, + minimumClientVersion: null, + minimumClientEnforcement: false, + ...overrides, + } +} + +function readiness(overrides: Partial = {}): DeploymentReadiness { + return { + revision: 'test-ready-v1', + snapshot: 'current', + legacySession: 'ready', + legacySwitchAuthorization: 'ready', + legacyDrainAccepted: true, + sessionV2Acceptance: 'ready', + sessionV2Issuance: 'ready', + accessBudget: 'ready', + catalogCoordinator: 'ready', + catalogRegisteredFamilies: allFamilies, + catalogHarnessFamilies: allFamilies, + catalogOperationalFamilies: allFamilies, + catalogShadowComparison: 'ready', + catalogParityAccepted: true, + actionSafeRevisions: 'ready', + actionContext: 'ready', + rpcDelegationAllHops: 'ready', + desktop: 'ready', + explicitTeamAdapters: 'ready', + profile: 'ready', + minimumClientEnforcement: 'ready', + ...overrides, + } +} + +describe('central user-access rollout compiler', () => { + it('keeps the reconstruction defaults dual-compatible and user-visible gates off', () => { + expect(loadConfiguredUserAccessIntent({})).toEqual(intent()) + expect(effectiveUserAccessPolicy).toMatchObject({ + acceptV1: true, + issueV1: true, + acceptV2: true, + issueV2: false, + renewV2: false, + switchCompatibility: true, + computeCatalogShadow: false, + serveCatalog: false, + actionContextV2: false, + rpcDelegationV2: false, + desktopAllTeamMode: false, + profileV2Mode: false, + enforceMinimumClient: false, + advertisedCatalogFamilies: [], + }) + }) + + it.each([ + [ + 'no_accepted_session_contract', + intent({ legacyLifecycle: 'disabled', sessionV2Acceptance: false }), + ], + [ + 'session_v2_issuance_requires_acceptance', + intent({ sessionV2Acceptance: false, sessionV2Issuance: true }), + ], + [ + 'legacy_accept_only_requires_v2_issuance', + intent({ legacyLifecycle: 'accept_only', legacySwitchEndpoint: false }), + ], + [ + 'legacy_disabled_requires_accepted_drain', + intent({ + legacyLifecycle: 'disabled', + legacySwitchEndpoint: false, + sessionV2Issuance: true, + }), + readiness({ legacyDrainAccepted: false }), + ], + [ + 'legacy_switch_requires_v1_issue_and_accept', + intent({ legacyLifecycle: 'accept_only', sessionV2Issuance: true }), + ], + [ + 'catalog_shadow_families_incomplete', + intent({ catalogMode: 'shadow' }), + readiness({ catalogHarnessFamilies: new Set(['user']) }), + ], + [ + 'catalog_serving_parity_not_accepted', + intent({ catalogMode: 'serve' }), + readiness({ catalogParityAccepted: false }), + ], + ['rpc_v2_requires_action_context', intent({ rpcDelegationV2: true })], + ['desktop_mode_requires_v2_issuance', intent({ desktopAllTeamMode: true })], + [ + 'profile_explicit_team_adapters_unavailable', + intent({ sessionV2Issuance: true, profileV2Mode: true }), + readiness({ explicitTeamAdapters: 'unavailable' }), + ], + [ + 'minimum_client_version_missing', + intent({ sessionV2Issuance: true, minimumClientEnforcement: true }), + ], + ['readiness_snapshot_unavailable', intent(), readiness({ snapshot: 'unavailable' })], + ])('rejects impossible state %s', (code, configured, deployed = readiness()) => { + expect(() => compileUserAccessPolicy(configured, deployed)).toThrowError( + new UserAccessPolicyConfigurationError(code) + ) + }) + + it('derives shadow, serving, renewal, and advertisement only from ready dependencies', () => { + const policy = compileUserAccessPolicy( + intent({ + sessionV2Issuance: true, + catalogMode: 'serve_and_shadow', + actionContextV2: true, + rpcDelegationV2: true, + desktopAllTeamMode: true, + profileV2Mode: true, + minimumClientVersion: '2.3.0', + minimumClientEnforcement: true, + }), + readiness() + ) + + expect(policy).toMatchObject({ + renewV2: true, + computeCatalogShadow: true, + serveCatalog: true, + actionContextV2: true, + rpcDelegationV2: true, + desktopAllTeamMode: true, + profileV2Mode: true, + minimumClientVersion: '2.3.0', + enforceMinimumClient: true, + advertisedCatalogFamilies: CATALOG_FAMILIES, + }) + expect(policy.policyRevision).toMatch(/^[0-9a-f]{64}$/) + }) + + it('enumerates every configured Boolean/lifecycle/catalog combination deterministically', () => { + const booleanKeys = [ + 'sessionV2Acceptance', + 'sessionV2Issuance', + 'actionContextV2', + 'rpcDelegationV2', + 'desktopAllTeamMode', + 'profileV2Mode', + 'legacySwitchEndpoint', + 'minimumClientEnforcement', + ] as const + const lifecycles = ['issue_and_accept', 'accept_only', 'disabled'] as const + const catalogModes = ['off', 'shadow', 'serve_and_shadow', 'serve'] as const + let outcomes = 0 + + for (const legacyLifecycle of lifecycles) { + for (const catalogMode of catalogModes) { + for (let mask = 0; mask < 1 << booleanKeys.length; mask += 1) { + const configured = intent({ + legacyLifecycle, + catalogMode, + minimumClientVersion: mask & (1 << 7) ? '2.0.0' : null, + }) as Record + booleanKeys.forEach((key, index) => { + configured[key] = Boolean(mask & (1 << index)) + }) + try { + const first = compileUserAccessPolicy( + configured as ConfiguredUserAccessIntent, + readiness() + ) + const second = compileUserAccessPolicy( + configured as ConfiguredUserAccessIntent, + readiness() + ) + expect(first).toEqual(second) + expect(first.issueV2 && !first.acceptV2).toBe(false) + expect(first.renewV2).toBe(first.issueV2 && first.acceptV2) + } catch (error) { + expect(error).toBeInstanceOf(UserAccessPolicyConfigurationError) + } + outcomes += 1 + } + } + } + expect(outcomes).toBe(3 * 4 * 2 ** booleanKeys.length) + }) + + it('strictly parses controls and compares bounded semantic versions', () => { + expect(() => + loadConfiguredUserAccessIntent({ CONTROL_API_USER_ACCESS_SESSION_V2_ISSUANCE: 'maybe' }) + ).toThrow(UserAccessPolicyConfigurationError) + expect(() => + loadConfiguredUserAccessIntent({ CONTROL_API_USER_ACCESS_MINIMUM_CLIENT_VERSION: 'latest' }) + ).toThrow(UserAccessPolicyConfigurationError) + expect(compareSemanticVersions('2.3.0', '2.2.9')).toBe(1) + expect(compareSemanticVersions('2.3.0', '2.3.0')).toBe(0) + expect(compareSemanticVersions('2.2.9', '2.3.0')).toBe(-1) + expect(compareSemanticVersions('unbounded', '2.3.0')).toBeNull() + }) +}) From ed59d8d656817fe27c66fe7122c29b77e281a920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 10 Aug 2026 22:32:12 -0600 Subject: [PATCH 009/528] fix(auth): serialize credential and session security events Class-check: external user password writes -> 5 sites -> revoke established sessions in the same transaction; admin-only credential writes remain on their separate trust plane. Repro-test: passwordSessionAuthentication.test.ts fails at 1bd74e97 because the serialized issuance boundary does not exist. --- .../src/services/access/userAccessPolicy.ts | 6 +- .../auth/passwordSessionAuthentication.ts | 57 ++++++++++++++ .../services/directory/adminProvisioning.ts | 8 ++ control-api/src/services/directory/login.ts | 2 + .../src/services/directory/membership.ts | 31 +++++--- .../test/membershipPasswordSecurity.test.ts | 60 +++++++++++++++ .../passwordSessionAuthentication.test.ts | 76 +++++++++++++++++++ ...rvices.directory.adminProvisioning.test.ts | 50 ++++++++++++ 8 files changed, 277 insertions(+), 13 deletions(-) create mode 100644 control-api/src/services/auth/passwordSessionAuthentication.ts create mode 100644 control-api/test/membershipPasswordSecurity.test.ts create mode 100644 control-api/test/passwordSessionAuthentication.test.ts diff --git a/control-api/src/services/access/userAccessPolicy.ts b/control-api/src/services/access/userAccessPolicy.ts index dbfb3a4610..bb9bcb4734 100644 --- a/control-api/src/services/access/userAccessPolicy.ts +++ b/control-api/src/services/access/userAccessPolicy.ts @@ -326,15 +326,13 @@ export function loadConfiguredUserAccessIntent(env: NodeJS.ProcessEnv): Configur } export const reconstructionReadiness: DeploymentReadiness = Object.freeze({ - revision: 'pr1-reconstruction-auth-v1', + revision: 'pr1-reconstruction-auth-v2', snapshot: 'current', legacySession: 'ready', legacySwitchAuthorization: 'ready', legacyDrainAccepted: false, sessionV2Acceptance: 'ready', - // Password verification and session insertion are serialized in the next - // compatibility commit; issuance must fail startup until then. - sessionV2Issuance: 'unavailable', + sessionV2Issuance: 'ready', accessBudget: 'unavailable', catalogCoordinator: 'unavailable', catalogRegisteredFamilies: new Set(), diff --git a/control-api/src/services/auth/passwordSessionAuthentication.ts b/control-api/src/services/auth/passwordSessionAuthentication.ts new file mode 100644 index 0000000000..8fbacb9b06 --- /dev/null +++ b/control-api/src/services/auth/passwordSessionAuthentication.ts @@ -0,0 +1,57 @@ +import { type DbClient, withTransaction } from '../../db.js' +import { passwordLoginData } from '../directory/login.js' +import { + type ExternalSessionContract, + issueExternalUserSession, +} from './externalSessionIssuance.js' + +type PasswordLoginSuccess = { + user: { + id: string + email: string + name: string | null + picture: string | null + } + membership: { + team_id: string | null + role: 'admin' | 'inviter' | 'member' + team_name: string | null + } + credentialHash: string +} + +export async function authenticatePasswordAndIssueSession(input: { + email: string + password: string + contract: ExternalSessionContract +}) { + const login = await passwordLoginData(input) + if (!login || !('user' in login)) return login + const authenticatedLogin = login as PasswordLoginSuccess + + return withTransaction(async (db: Pick) => { + const credential = await db.query( + `SELECT id + FROM users + WHERE id = $1 + AND email = $2 + AND password_hash = $3 + FOR UPDATE`, + [authenticatedLogin.user.id, authenticatedLogin.user.email, authenticatedLogin.credentialHash] + ) + if ((credential.rowCount ?? 0) === 0) return null + + const issued = await issueExternalUserSession( + { + contract: input.contract, + userId: authenticatedLogin.user.id, + email: authenticatedLogin.user.email, + teamId: authenticatedLogin.membership.team_id, + role: authenticatedLogin.membership.role, + authenticationMethods: ['pwd'], + }, + { db } + ) + return { ...authenticatedLogin, issued } + }) +} diff --git a/control-api/src/services/directory/adminProvisioning.ts b/control-api/src/services/directory/adminProvisioning.ts index 4c6b52cfc7..ced7d6df2f 100644 --- a/control-api/src/services/directory/adminProvisioning.ts +++ b/control-api/src/services/directory/adminProvisioning.ts @@ -1,5 +1,6 @@ import { type DbClient, pool, withTransaction } from '../../db.js' import { rootLogger } from '../../observability/logger.js' +import { revokeAllUserSessions } from '../auth/userSessionService.js' import { gfsDesktopOperatorLinkService } from '../gfsDesktopOperatorLinkService.js' import { type ControlApiPermissionChange, @@ -115,6 +116,7 @@ export async function provisionAdminDesktopWorkspace( const provision = async (db: Pick): Promise<{ userId: string }> => { const existing = await db.query(`SELECT id FROM users WHERE email = $1 LIMIT 1`, [email]) let userId = (existing.rows[0] as { id: string } | undefined)?.id + const existingUser = Boolean(userId) if (!userId) { const inserted = await db.query( `INSERT INTO users(email, name) VALUES($1, $2) RETURNING id`, @@ -135,6 +137,9 @@ export async function provisionAdminDesktopWorkspace( WHERE id = $1`, [userId, input.passwordHash] ) + if (existingUser) { + await revokeAllUserSessions(userId, 'password_changed', db) + } } await ensureDefaultTeamAndGrants(db, userId, teamLabel, input.agentNames, input.contextIds) @@ -252,6 +257,9 @@ export async function provisionMemberFromAdmin(input: { WHERE id = $1`, [user.id, admin.password_hash] ) + if (!created) { + await revokeAllUserSessions(user.id, 'password_changed', db) + } } if (assignments.size > 0) { diff --git a/control-api/src/services/directory/login.ts b/control-api/src/services/directory/login.ts index e3a6b8228d..5c51ca0b05 100644 --- a/control-api/src/services/directory/login.ts +++ b/control-api/src/services/directory/login.ts @@ -219,6 +219,7 @@ export async function passwordLoginData(input: { email: string; password: string picture: user.picture, }, membership: teamlessMemberMembership(), + credentialHash: user.password_hash, } } @@ -231,6 +232,7 @@ export async function passwordLoginData(input: { email: string; password: string picture: user.picture, }, membership: membership.rows[0] as MembershipRow, + credentialHash: user.password_hash, } }) } diff --git a/control-api/src/services/directory/membership.ts b/control-api/src/services/directory/membership.ts index ef88afeeb9..f0829f28bf 100644 --- a/control-api/src/services/directory/membership.ts +++ b/control-api/src/services/directory/membership.ts @@ -1,5 +1,6 @@ import bcrypt from 'bcryptjs' import { type DbClient, pool, withTransaction } from '../../db.js' +import { revokeAllUserSessions } from '../auth/userSessionService.js' import { registerAndSendInvitation } from '../invitationFlowRegistrationService.js' import { appendControlApiPermissionEventsInTransaction } from '../tracing/controlApiPermissionEvents.js' import type { InviteRole, TeamRole } from './types.js' @@ -1116,6 +1117,7 @@ export async function setInvitationPasswordForUser( AND lifecycle_state = 'active'`, [user.id, passwordHash] ) + await revokeAllUserSessions(user.id, 'password_changed', db) if (invitation.purpose === 'password_reset' && invitation.status === 'pending') { await db.query( `UPDATE invitations @@ -1200,6 +1202,7 @@ export async function setInvitationPasswordForEmail( AND lifecycle_state = 'active'`, [user.id, passwordHash] ) + await revokeAllUserSessions(user.id, 'password_changed', db) if (invitation.purpose === 'password_reset' && invitation.status === 'pending') { await db.query( `UPDATE invitations @@ -1950,13 +1953,23 @@ export async function updateUserPassword( return { error: 'invalid_current_password' as const } } - await pool.query( - `UPDATE users - SET password_hash = $2, - password_set_at = NOW(), - updated_at = NOW() - WHERE id = $1`, - [normalizedUserId, await bcrypt.hash(nextPassword, 12)] - ) - return { updated: true as const } + const nextPasswordHash = await bcrypt.hash(nextPassword, 12) + return withTransaction(async db => { + const updated = await db.query( + `UPDATE users + SET password_hash = $3, + password_set_at = NOW(), + updated_at = NOW() + WHERE id = $1 + AND email = $2 + AND password_hash = $4 + RETURNING id`, + [normalizedUserId, normalizedEmail, nextPasswordHash, user.password_hash] + ) + if ((updated.rowCount ?? 0) === 0) { + return { error: 'invalid_current_password' as const } + } + await revokeAllUserSessions(normalizedUserId, 'password_changed', db) + return { updated: true as const } + }) } diff --git a/control-api/test/membershipPasswordSecurity.test.ts b/control-api/test/membershipPasswordSecurity.test.ts new file mode 100644 index 0000000000..b2c39e563a --- /dev/null +++ b/control-api/test/membershipPasswordSecurity.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + poolQuery: vi.fn(), + transactionQuery: vi.fn(), + compare: vi.fn(), + hash: vi.fn(), + revokeAll: vi.fn(), +})) + +vi.mock('../src/db.js', () => ({ + pool: { query: mocks.poolQuery }, + withTransaction: async (work: (db: { query: typeof mocks.transactionQuery }) => unknown) => + work({ query: mocks.transactionQuery }), +})) +vi.mock('bcryptjs', () => ({ + default: { compare: mocks.compare, hash: mocks.hash }, +})) +vi.mock('../src/services/auth/userSessionService.js', () => ({ + revokeAllUserSessions: mocks.revokeAll, +})) + +describe('member password security events', () => { + beforeEach(() => { + Object.values(mocks).forEach(mock => mock.mockReset()) + mocks.poolQuery.mockResolvedValue({ rows: [{ password_hash: 'old-hash' }], rowCount: 1 }) + mocks.compare.mockResolvedValue(true) + mocks.hash.mockResolvedValue('next-hash') + mocks.revokeAll.mockResolvedValue(2) + }) + + it('conditionally changes the credential and revokes sessions in the same transaction', async () => { + mocks.transactionQuery.mockResolvedValueOnce({ rows: [{ id: 'user-1' }], rowCount: 1 }) + const { updateUserPassword } = await import('../src/services/directory/membership.js') + + await expect( + updateUserPassword('user-1', 'user@example.com', 'old-password', 'next-password') + ).resolves.toEqual({ updated: true }) + + expect(mocks.transactionQuery).toHaveBeenCalledWith( + expect.stringContaining('AND password_hash = $4'), + ['user-1', 'user@example.com', 'next-hash', 'old-hash'] + ) + expect(mocks.revokeAll).toHaveBeenCalledWith( + 'user-1', + 'password_changed', + expect.objectContaining({ query: mocks.transactionQuery }) + ) + }) + + it('does not revoke when a concurrent credential change wins first', async () => { + mocks.transactionQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }) + const { updateUserPassword } = await import('../src/services/directory/membership.js') + + await expect( + updateUserPassword('user-1', 'user@example.com', 'old-password', 'next-password') + ).resolves.toEqual({ error: 'invalid_current_password' }) + expect(mocks.revokeAll).not.toHaveBeenCalled() + }) +}) diff --git a/control-api/test/passwordSessionAuthentication.test.ts b/control-api/test/passwordSessionAuthentication.test.ts new file mode 100644 index 0000000000..ace23014a0 --- /dev/null +++ b/control-api/test/passwordSessionAuthentication.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { authenticatePasswordAndIssueSession } from '../src/services/auth/passwordSessionAuthentication.js' + +const mocks = vi.hoisted(() => ({ + passwordLoginData: vi.fn(), + issueExternalUserSession: vi.fn(), + transactionQuery: vi.fn(), + beforeTransaction: vi.fn(async () => undefined), +})) + +vi.mock('../src/services/directory/login.js', () => ({ + passwordLoginData: mocks.passwordLoginData, +})) +vi.mock('../src/services/auth/externalSessionIssuance.js', () => ({ + issueExternalUserSession: mocks.issueExternalUserSession, +})) +vi.mock('../src/db.js', () => ({ + withTransaction: async (work: (db: { query: typeof mocks.transactionQuery }) => unknown) => { + await mocks.beforeTransaction() + return work({ query: mocks.transactionQuery }) + }, +})) + +const verifiedLogin = { + user: { id: 'user-1', email: 'user@example.com', name: 'Ada', picture: null }, + membership: { team_id: 'team-1', team_name: 'Team 1', role: 'member' }, + credentialHash: 'old-password-hash', +} + +describe('password session issuance serialization', () => { + beforeEach(() => { + Object.values(mocks).forEach(mock => mock.mockReset()) + mocks.passwordLoginData.mockResolvedValue(verifiedLogin) + mocks.beforeTransaction.mockResolvedValue(undefined) + mocks.issueExternalUserSession.mockResolvedValue({ token: 'v2-token', contract: 'v2' }) + }) + + it('does not issue after a password change commits between verification and the row lock', async () => { + let releasePasswordChange!: () => void + const passwordChangeCommitted = new Promise(resolve => { + releasePasswordChange = resolve + }) + mocks.beforeTransaction.mockImplementationOnce(async () => passwordChangeCommitted) + mocks.transactionQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }) + + const login = authenticatePasswordAndIssueSession({ + email: 'user@example.com', + password: 'old-password', + contract: 'v2', + }) + await vi.waitFor(() => expect(mocks.passwordLoginData).toHaveBeenCalledTimes(1)) + + releasePasswordChange() + + await expect(login).resolves.toBeNull() + expect(String(mocks.transactionQuery.mock.calls[0]?.[0])).toContain('FOR UPDATE') + expect(mocks.issueExternalUserSession).not.toHaveBeenCalled() + }) + + it('issues in the locked transaction while the verified credential is current', async () => { + mocks.transactionQuery.mockResolvedValueOnce({ rows: [{ id: 'user-1' }], rowCount: 1 }) + + await expect( + authenticatePasswordAndIssueSession({ + email: 'user@example.com', + password: 'current-password', + contract: 'v2', + }) + ).resolves.toMatchObject({ issued: { token: 'v2-token', contract: 'v2' } }) + + expect(mocks.issueExternalUserSession).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1', authenticationMethods: ['pwd'] }), + { db: { query: mocks.transactionQuery } } + ) + }) +}) diff --git a/control-api/test/services.directory.adminProvisioning.test.ts b/control-api/test/services.directory.adminProvisioning.test.ts index f9947cfa87..a8155603bb 100644 --- a/control-api/test/services.directory.adminProvisioning.test.ts +++ b/control-api/test/services.directory.adminProvisioning.test.ts @@ -5,6 +5,7 @@ import { ensureDefaultTeamAndGrants } from '../src/services/directory/adminProvi const dbMocks = vi.hoisted(() => ({ txQuery: vi.fn() })) const traceMocks = vi.hoisted(() => ({ appendPermissionEvents: vi.fn() })) +const userSessionMocks = vi.hoisted(() => ({ revokeAll: vi.fn() })) const operatorLinkMocks = vi.hoisted(() => ({ linkInTransaction: vi.fn() })) vi.mock('../src/db.js', () => ({ pool: { query: vi.fn() }, @@ -14,6 +15,9 @@ vi.mock('../src/db.js', () => ({ vi.mock('../src/services/tracing/controlApiPermissionEvents.js', () => ({ appendControlApiPermissionEventsInTransaction: traceMocks.appendPermissionEvents, })) +vi.mock('../src/services/auth/userSessionService.js', () => ({ + revokeAllUserSessions: userSessionMocks.revokeAll, +})) vi.mock('../src/services/gfsDesktopOperatorLinkService.js', () => ({ gfsDesktopOperatorLinkService: { linkInTransaction: operatorLinkMocks.linkInTransaction, @@ -76,6 +80,8 @@ describe('ensureDefaultTeamAndGrants', () => { describe('provisionAdminDesktopWorkspace', () => { beforeEach(() => { dbMocks.txQuery.mockReset() + userSessionMocks.revokeAll.mockReset() + userSessionMocks.revokeAll.mockResolvedValue(1) operatorLinkMocks.linkInTransaction.mockReset() operatorLinkMocks.linkInTransaction.mockResolvedValue({ created: true, @@ -121,6 +127,7 @@ describe('provisionAdminDesktopWorkspace', () => { expect.stringContaining('INSERT INTO team_agents'), ['team-1', 'chatllm'] ) + expect(userSessionMocks.revokeAll).not.toHaveBeenCalled() }) it('reuses an existing user (no INSERT INTO users) and still sets the password', async () => { @@ -150,6 +157,11 @@ describe('provisionAdminDesktopWorkspace', () => { 'user-9', 'H', ]) + expect(userSessionMocks.revokeAll).toHaveBeenCalledWith( + 'user-9', + 'password_changed', + expect.objectContaining({ query: dbMocks.txQuery }) + ) }) it('seedPassword:false leaves an existing user password untouched', async () => { @@ -266,6 +278,8 @@ describe('provisionMemberFromAdmin', () => { dbMocks.txQuery.mockReset() traceMocks.appendPermissionEvents.mockReset() traceMocks.appendPermissionEvents.mockResolvedValue(null) + userSessionMocks.revokeAll.mockReset() + userSessionMocks.revokeAll.mockResolvedValue(1) }) it('creates a password-seeded member without creating a team when no teams are selected', async () => { @@ -323,6 +337,42 @@ describe('provisionMemberFromAdmin', () => { ], }) ) + expect(userSessionMocks.revokeAll).not.toHaveBeenCalled() + }) + + it('revokes existing member sessions in the password-write transaction', async () => { + dbMocks.txQuery + .mockResolvedValueOnce({ + rows: [ + { + id: 'admin-1', + username: 'ada', + email: 'ada@example.com', + password_hash: 'HASH', + }, + ], + rowCount: 1, + }) + .mockResolvedValueOnce({ + rows: [{ id: 'user-1', email: 'ada@example.com', name: 'ada' }], + rowCount: 1, + }) + .mockResolvedValueOnce({ rows: [], rowCount: 1 }) + .mockResolvedValueOnce({ rows: [], rowCount: 1 }) + + const mod = await import('../src/services/directory/adminProvisioning.js') + await mod.provisionMemberFromAdmin({ + adminId: 'admin-1', + operatorSub: 'operator-admin-1', + teamAssignments: [], + seedPassword: true, + }) + + expect(userSessionMocks.revokeAll).toHaveBeenCalledWith( + 'user-1', + 'password_changed', + expect.objectContaining({ query: dbMocks.txQuery }) + ) }) it('records the exact revoke and grant when an operator changes a member role', async () => { From e16260b80d7c6909c11e6f980d6d096fd1acd14a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 10 Aug 2026 22:42:12 -0600 Subject: [PATCH 010/528] fix(invitations): serialize capability and manager authority Class-check: invitation authority and capability sinks -> 14 sites -> one transactional state machine. Decision: lock and consume once, persist non-secret delivery commands, and redact external DTOs. Repro-test: directoryAuthorizationPrivacy.test.ts fails at a84381c7 on privacy and race cases. --- control-api/src/routes/external/members.ts | 5 +- control-api/src/routes/external/teams.ts | 17 +- control-api/src/services/directory/index.ts | 4 + .../directory/invitationDeliverySchema.ts | 32 + .../src/services/directory/membership.ts | 1129 ++++++++++++----- control-api/src/services/directory/users.ts | 80 +- ...invitationDeliveryCommandMigration.test.ts | 44 + .../directoryAuthorizationPrivacy.test.ts | 929 ++++++++++++++ ...es.externalInvitations.unavailable.test.ts | 85 +- .../test/routes.externalMembers.test.ts | 6 +- .../routes.externalTeamsInvitations.test.ts | 73 ++ .../control-api-runtime-access-profiles.tsv | 1 + external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- external-rest-api/src/releaseManifest.ts | 2 +- external-rest-api/src/routes/invitations.ts | 91 +- .../src/services/invitationsService.ts | 73 +- .../test/routes.invitations.test.ts | 75 +- .../test/services.invitationsService.test.ts | 64 + 19 files changed, 2214 insertions(+), 502 deletions(-) create mode 100644 control-api/src/services/directory/invitationDeliverySchema.ts create mode 100644 control-api/test/db.invitationDeliveryCommandMigration.test.ts create mode 100644 control-api/test/directoryAuthorizationPrivacy.test.ts create mode 100644 control-api/test/routes.externalTeamsInvitations.test.ts create mode 100644 external-rest-api/test/services.invitationsService.test.ts diff --git a/control-api/src/routes/external/members.ts b/control-api/src/routes/external/members.ts index e312426ad3..550e457f68 100644 --- a/control-api/src/routes/external/members.ts +++ b/control-api/src/routes/external/members.ts @@ -9,6 +9,7 @@ import { createManagedInvitationForUser, deleteManagedMemberForUser, deleteManagedUserForUser, + externalManagedInvitationResponse, listManageableTeamsForUser, listManagedMembersForUser, listManagedPendingInvitationsForUser, @@ -119,7 +120,9 @@ export function createExternalMembersRouter(): Router { if (result.error === 'forbidden') return res.status(403).json({ error: 'forbidden' }) return res.status(400).json({ error: 'invalid_payload' }) } - return res.status(201).json(result.invitation) + return res + .status(201) + .json(externalManagedInvitationResponse(result.invitation as Record)) } catch (error) { return next(error) } diff --git a/control-api/src/routes/external/teams.ts b/control-api/src/routes/external/teams.ts index 27e5b7a8d2..ef781e39a5 100644 --- a/control-api/src/routes/external/teams.ts +++ b/control-api/src/routes/external/teams.ts @@ -16,8 +16,9 @@ import { listActiveContextIds, } from '../../services/directory/accessReconciliation.js' import { - createInvitation, + createManagedInvitationForUser, createTeamForUser, + externalManagedInvitationResponse, findMemberRole, getCurrentTeam, getTeamAgents, @@ -226,13 +227,19 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { if (!email || !role) { return res.status(400).json({ error: 'invalid invitation payload' }) } - if (role === 'admin' && (req as ExternalAuthedRequest).externalTeamAuth?.role !== 'admin') { - return res.status(403).json({ error: 'Forbidden' }) - } const fallbackName = email.split('@')[0] || email + const result = await createManagedInvitationForUser( + (req as ExternalAuthedRequest).externalAuth!.userId, + email, + [{ teamId: req.params.teamId, role }], + name || fallbackName + ) + if ('error' in result) { + return res.status(result.error === 'forbidden' ? 403 : 400).json({ error: result.error }) + } return res .status(200) - .json(await createInvitation(req.params.teamId, name || fallbackName, email, role)) + .json(externalManagedInvitationResponse(result.invitation as Record)) } catch (error) { return next(error) } diff --git a/control-api/src/services/directory/index.ts b/control-api/src/services/directory/index.ts index 59e4b44618..444db40347 100644 --- a/control-api/src/services/directory/index.ts +++ b/control-api/src/services/directory/index.ts @@ -34,6 +34,7 @@ export { acceptInvitation, acceptInvitationById, acceptInvitationForEmail, + acceptInvitationForEmailInTransaction, getInvitationByToken, addMemberToTeam, createPasswordSetupInvitationForUser, @@ -41,6 +42,7 @@ export { createInvitationForTeams, createSilentInvitationForTeams, createManagedInvitationForUser, + externalManagedInvitationResponse, deleteManagedMemberForUser, deleteManagedUserForUser, findMemberRole, @@ -62,6 +64,8 @@ export { setInvitationPasswordForUser, setInvitationPasswordForEmail, searchDirectory, + DirectorySearchCursorError, + DIRECTORY_SEARCH_MAX_QUERY_LENGTH, softDeleteMember, startDraftInvitationCleanup, stopDraftInvitationCleanup, diff --git a/control-api/src/services/directory/invitationDeliverySchema.ts b/control-api/src/services/directory/invitationDeliverySchema.ts new file mode 100644 index 0000000000..4caedf329f --- /dev/null +++ b/control-api/src/services/directory/invitationDeliverySchema.ts @@ -0,0 +1,32 @@ +import type { DbClient } from '../../db.js' + +export async function applyInvitationDeliveryCommandFoundation(db: DbClient): Promise { + await db.query(` + CREATE TABLE IF NOT EXISTS invitation_delivery_commands ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + invitation_id UUID NOT NULL REFERENCES invitations(id) ON DELETE CASCADE, + authorized_by UUID REFERENCES users(id) ON DELETE SET NULL, + delivery_kind TEXT NOT NULL CHECK (delivery_kind IN ('create', 'resend')), + status TEXT NOT NULL DEFAULT 'authorized' + CHECK (status IN ('authorized', 'delivered', 'failed', 'cancelled')), + authorized_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + completed_at TIMESTAMPTZ, + failure_code TEXT, + CHECK ( + (status = 'authorized' AND completed_at IS NULL) + OR (status <> 'authorized' AND completed_at IS NOT NULL) + ) + ); + + CREATE INDEX IF NOT EXISTS invitation_delivery_commands_authorized_idx + ON invitation_delivery_commands (authorized_at, id) + WHERE status = 'authorized'; + CREATE INDEX IF NOT EXISTS invitation_delivery_commands_invitation_idx + ON invitation_delivery_commands (invitation_id, authorized_at DESC); + + REVOKE ALL PRIVILEGES ON TABLE invitation_delivery_commands + FROM PUBLIC, control_api_runtime, trace_maintenance_runtime, workflow_recipes_runtime; + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE invitation_delivery_commands + TO control_api_runtime; + `) +} diff --git a/control-api/src/services/directory/membership.ts b/control-api/src/services/directory/membership.ts index f0829f28bf..0fe11c7892 100644 --- a/control-api/src/services/directory/membership.ts +++ b/control-api/src/services/directory/membership.ts @@ -1,6 +1,12 @@ import bcrypt from 'bcryptjs' +import { createHmac, timingSafeEqual } from 'node:crypto' +import { config } from '../../config.js' import { type DbClient, pool, withTransaction } from '../../db.js' -import { revokeAllUserSessions } from '../auth/userSessionService.js' +import type { ExternalSessionAuthorityContext } from '../auth/externalSessionAuthentication.js' +import { + revokeAllUserSessions, + validateExternalSessionAuthorityContext, +} from '../auth/userSessionService.js' import { registerAndSendInvitation } from '../invitationFlowRegistrationService.js' import { appendControlApiPermissionEventsInTransaction } from '../tracing/controlApiPermissionEvents.js' import type { InviteRole, TeamRole } from './types.js' @@ -77,7 +83,8 @@ function normalizeInvitationPurpose(purpose?: InvitationPurpose): InvitationPurp async function getInvitationRecordByToken( db: Pick, - token: string + token: string, + options: { lock?: boolean } = {} ): Promise { const result = await db.query( `SELECT i.id, i.team_id, i.invitee_name, i.email, i.role, i.token, i.status, i.purpose, i.created_at, i.expires_at, i.accepted_at, @@ -86,7 +93,8 @@ async function getInvitationRecordByToken( FROM invitations i LEFT JOIN teams t ON t.id = i.team_id WHERE i.token = $1 - LIMIT 1`, + LIMIT 1 + ${options.lock ? 'FOR UPDATE OF i' : ''}`, [token] ) return (result.rows[0] as InvitationRow | undefined) || null @@ -119,7 +127,8 @@ export function stopDraftInvitationCleanup() { async function getInvitationRecordByTokenOrId( db: Pick, token: string, - invitationId: string + invitationId: string, + options: { lock?: boolean } = {} ): Promise { const normalizedToken = token.trim() const normalizedInvitationId = invitationId.trim() @@ -133,10 +142,12 @@ async function getInvitationRecordByTokenOrId( t.name AS team_name FROM invitations i LEFT JOIN teams t ON t.id = i.team_id - WHERE ($1 <> '' AND i.token = $1) - OR ($2 <> '' AND i.id::text = $2) + WHERE ($1 <> '' AND $2 = '' AND i.token = $1) + OR ($1 = '' AND $2 <> '' AND i.id::text = $2) + OR ($1 <> '' AND $2 <> '' AND i.token = $1 AND i.id::text = $2) ORDER BY i.created_at DESC - LIMIT 1`, + LIMIT 1 + ${options.lock ? 'FOR UPDATE OF i' : ''}`, [normalizedToken, normalizedInvitationId] ) @@ -145,7 +156,8 @@ async function getInvitationRecordByTokenOrId( async function getInvitationRecordById( db: Pick, - invitationId: string + invitationId: string, + options: { lock?: boolean } = {} ): Promise { const normalizedInvitationId = invitationId.trim() if (!normalizedInvitationId) return null @@ -157,7 +169,8 @@ async function getInvitationRecordById( FROM invitations i LEFT JOIN teams t ON t.id = i.team_id WHERE i.id::text = $1 - LIMIT 1`, + LIMIT 1 + ${options.lock ? 'FOR UPDATE OF i' : ''}`, [normalizedInvitationId] ) return (result.rows[0] as InvitationRow | undefined) || null @@ -180,29 +193,26 @@ async function ensureInvitationUser( let user = (existing.rows[0] as InvitationUserRow | undefined) || null if (!user) { - user = ( - await db.query( - `INSERT INTO users(email, name) + const inserted = await db.query( + `INSERT INTO users(email, name) VALUES($1, $2) - ON CONFLICT (email) DO UPDATE - SET name = COALESCE(users.name, EXCLUDED.name), - updated_at = NOW() - RETURNING id, email, name, picture, password_hash, lifecycle_state, lifecycle_version`, - [normalizedEmail, normalizedInviteeName] - ) - ).rows[0] as InvitationUserRow - } else if (normalizedInviteeName && user.name !== normalizedInviteeName) { - user = ( - await db.query( - `UPDATE users - SET name = $2, - updated_at = NOW() - WHERE id = $1 + ON CONFLICT (email) DO NOTHING RETURNING id, email, name, picture, password_hash, lifecycle_state, lifecycle_version`, - [user.id, normalizedInviteeName] + [normalizedEmail, normalizedInviteeName] + ) + user = (inserted.rows[0] as InvitationUserRow | undefined) || null + if (!user) { + const racedExisting = await db.query( + `SELECT id, email, name, picture, password_hash, lifecycle_state, lifecycle_version + FROM users + WHERE email = $1 + LIMIT 1`, + [normalizedEmail] ) - ).rows[0] as InvitationUserRow + user = (racedExisting.rows[0] as InvitationUserRow | undefined) || null + } } + if (!user) throw new Error('Failed to resolve invitation user') await db.query( `INSERT INTO profiles(user_id, display_name) @@ -210,15 +220,6 @@ async function ensureInvitationUser( ON CONFLICT (user_id) DO NOTHING`, [user.id, user.name || null] ) - if (normalizedInviteeName) { - await db.query( - `UPDATE profiles - SET display_name = $2, - updated_at = NOW() - WHERE user_id = $1`, - [user.id, normalizedInviteeName] - ) - } return user } @@ -269,7 +270,8 @@ async function loadInvitationTeams( async function loadInvitationTeamsByInvitationIds( db: Pick, - invitationIds: readonly string[] + invitationIds: readonly string[], + managerUserId?: string ): Promise> { const byInvitationId = new Map() if (invitationIds.length === 0) return byInvitationId @@ -278,9 +280,15 @@ async function loadInvitationTeamsByInvitationIds( `SELECT it.invitation_id, it.team_id, t.name AS team_name, it.role FROM invitation_teams it JOIN teams t ON t.id = it.team_id + LEFT JOIN team_members manager + ON manager.team_id = it.team_id + AND manager.user_id::text = $2 + AND manager.status = 'active' + AND manager.role IN ('admin', 'inviter') WHERE it.invitation_id = ANY($1::uuid[]) + AND ($2 = '' OR manager.user_id IS NOT NULL) ORDER BY it.invitation_id ASC, t.name ASC`, - [invitationIds] + [invitationIds, managerUserId?.trim() || ''] ) for (const row of result.rows as Array) { const rows = byInvitationId.get(row.invitation_id) || [] @@ -294,15 +302,27 @@ async function loadInvitationTeamsByInvitationIds( return byInvitationId } -async function invitationListResponse(rows: InvitationRow[]) { +async function invitationListResponse(rows: InvitationRow[], managerUserId?: string) { const teamsByInvitationId = await loadInvitationTeamsByInvitationIds( pool, - rows.map(row => row.id) + rows.map(row => row.id), + managerUserId ) return rows.map(row => { const { token: _secretCapability, ...safeInvitation } = row + const managerSafeInvitation = managerUserId + ? (() => { + const { + team_id: _hiddenPrimaryTeamId, + team_name: _hiddenPrimaryTeamName, + role: _hiddenPrimaryRole, + ...safeManagerFields + } = safeInvitation + return safeManagerFields + })() + : safeInvitation return { - ...safeInvitation, + ...managerSafeInvitation, teams: (teamsByInvitationId.get(row.id) || []).map(team => ({ id: team.team_id, name: team.team_name, @@ -332,6 +352,28 @@ async function applyInvitationMemberships( return teams } +async function acceptPendingMembershipInvitation( + db: Pick, + invitation: InvitationRow, + userId: string +): Promise { + const consumed = await db.query( + `UPDATE invitations + SET status = 'accepted', + accepted_at = NOW(), + accepted_user_id = $2 + WHERE id = $1 + AND status = 'pending' + AND purpose <> 'password_reset' + AND expires_at > NOW() + RETURNING id`, + [invitation.id, userId] + ) + if ((consumed.rowCount ?? 0) === 0) return false + await applyInvitationMemberships(db, invitation, userId) + return true +} + async function insertInvitationTeams( db: Pick, invitationId: string, @@ -672,24 +714,54 @@ function invitationForTeamsResponse(invitation: InvitationRow, teams: Invitation } } -async function createInvitationForTeamsRecord( - input: InvitationForTeamsInput, - options: { sendEmail: boolean } -) { - cleanupStaleDraftInvitations().catch(() => undefined) - const purpose = normalizeInvitationPurpose(input.purpose) - const inserted = await withTransaction(async db => - insertInvitationForTeams(db, { - ...input, - purpose, - status: options.sendEmail ? 'draft' : 'pending', - }) +async function recordInvitationDeliveryCommand( + db: Pick, + invitationId: string, + authorizedBy: string, + deliveryKind: 'create' | 'resend' +): Promise { + const result = await db.query( + `INSERT INTO invitation_delivery_commands(invitation_id, authorized_by, delivery_kind) + VALUES($1, $2, $3) + RETURNING id`, + [invitationId, authorizedBy.trim(), deliveryKind] ) + const commandId = String((result.rows[0] as { id?: string } | undefined)?.id || '') + if (!commandId) throw new Error('Failed to persist invitation delivery command') + return commandId +} - if (!options.sendEmail) { - return invitationForTeamsResponse(inserted.invitation, inserted.teams) +async function finishInvitationDeliveryCommand( + db: Pick, + commandId: string, + status: 'delivered' | 'failed' | 'cancelled', + failureCode?: string +): Promise { + const result = await db.query( + `UPDATE invitation_delivery_commands + SET status = $2, + completed_at = NOW(), + failure_code = $3 + WHERE id = $1 + AND status = 'authorized'`, + [commandId, status, failureCode || null] + ) + if ((result.rowCount ?? 0) === 0) { + throw new Error('Invitation delivery command is no longer authorized') } +} +export function externalManagedInvitationResponse( + invitation: Record +): Record { + const { token: _secretCapability, ...safeInvitation } = invitation + return safeInvitation +} + +async function deliverAndActivateInvitation( + inserted: InvitationWithTeams, + purpose: InvitationPurpose +) { await registerAndSendInvitation( inserted.invitation.email, inserted.invitation.token, @@ -725,6 +797,117 @@ async function createInvitationForTeamsRecord( ) } +async function deliverAndActivateManagedInvitation( + inserted: InvitationWithTeams, + commandId: string, + managerUserId: string, + authority?: ExternalSessionAuthorityContext +) { + try { + await registerAndSendInvitation( + inserted.invitation.email, + inserted.invitation.token, + inserted.teams.length > 0 + ? inserted.teams.map(team => team.team_name).join(', ') + : inserted.invitation.team_name, + inserted.invitation.created_at.toISOString(), + inserted.invitation.expires_at.toISOString(), + { + purpose: 'member_invitation', + teamNames: inserted.teams.map(team => team.team_name), + } + ) + } catch (error) { + await withTransaction(async db => { + await finishInvitationDeliveryCommand(db, commandId, 'failed', 'delivery_failed') + await db.query( + `UPDATE invitations SET status = 'revoked' WHERE id = $1 AND status = 'draft'`, + [inserted.invitation.id] + ) + }).catch(() => undefined) + throw error + } + + return withTransaction(async db => { + const sourceAuthorityValid = await hasValidManagedMutationAuthority( + db, + managerUserId, + authority + ) + const locked = await db.query( + `SELECT i.id, i.team_id, i.invitee_name, i.email, i.role, i.token, i.status, i.purpose, + i.created_at, i.expires_at, i.accepted_at, i.accepted_user_id, + t.name AS team_name + FROM invitation_delivery_commands c + JOIN invitations i ON i.id = c.invitation_id + LEFT JOIN teams t ON t.id = i.team_id + WHERE c.id = $1 + AND c.invitation_id = $2 + AND c.authorized_by = $3 + AND c.delivery_kind = 'create' + AND c.status = 'authorized' + AND i.status = 'draft' + LIMIT 1 + FOR UPDATE OF c, i`, + [commandId, inserted.invitation.id, managerUserId.trim()] + ) + const invitation = (locked.rows[0] as InvitationRow | undefined) || null + if (!invitation) return { error: 'not_found' as const } + if (!sourceAuthorityValid) { + await finishInvitationDeliveryCommand(db, commandId, 'cancelled', 'authority_changed') + await db.query( + `UPDATE invitations SET status = 'revoked' WHERE id = $1 AND status = 'draft'`, + [invitation.id] + ) + return { error: 'forbidden' as const } + } + const teams = await loadInvitationTeams(db, invitation.id, invitation) + if (!(await managerCanControlAllInvitationTeams(db, managerUserId, teams, true))) { + await finishInvitationDeliveryCommand(db, commandId, 'cancelled', 'authority_changed') + await db.query( + `UPDATE invitations SET status = 'revoked' WHERE id = $1 AND status = 'draft'`, + [invitation.id] + ) + return { error: 'forbidden' as const } + } + + const activated = await db.query( + `UPDATE invitations + SET status = 'pending' + WHERE id = $1 + AND status = 'draft' + RETURNING id, team_id, invitee_name, email, role, token, status, purpose, created_at, + expires_at, accepted_at, accepted_user_id`, + [invitation.id] + ) + const pending = activated.rows[0] as InvitationRow | undefined + if (!pending) throw new Error('Failed to activate authorized invitation') + await finishInvitationDeliveryCommand(db, commandId, 'delivered') + return invitationForTeamsResponse({ ...pending, team_name: invitation.team_name }, teams) + }) +} + +async function createInvitationForTeamsRecord( + input: InvitationForTeamsInput, + options: { sendEmail: boolean } +) { + cleanupStaleDraftInvitations().catch(() => undefined) + const purpose = normalizeInvitationPurpose(input.purpose) + const inserted = await withTransaction(async db => + insertInvitationForTeams(db, { + ...input, + purpose, + status: options.sendEmail ? 'draft' : 'pending', + }) + ) + + if (!options.sendEmail) { + return invitationForTeamsResponse(inserted.invitation, inserted.teams) + } + + return deliverAndActivateInvitation(inserted, purpose) +} + export async function createInvitation( teamId: string | null | undefined, inviteeName: string, @@ -1077,7 +1260,7 @@ export async function setInvitationPasswordForUser( } return withTransaction(async db => { - const invitation = await getInvitationRecordById(db, trimmedInvitationId) + const invitation = await getInvitationRecordById(db, trimmedInvitationId, { lock: true }) if (!invitation) { return { error: 'not_found' as const } } @@ -1085,12 +1268,12 @@ export async function setInvitationPasswordForUser( return { error: 'forbidden' as const } } if (invitation.purpose === 'password_reset') { - if (invitation.status !== 'pending') { - return { error: 'not_pending' as const } - } if (invitation.expires_at.getTime() <= Date.now()) { return { error: 'expired' as const } } + if (invitation.status !== 'pending') { + return { error: 'not_pending' as const } + } } else if ( invitation.purpose === 'member_invitation' || invitation.purpose === 'admin_desktop_access' @@ -1098,6 +1281,12 @@ export async function setInvitationPasswordForUser( if (invitation.status !== 'accepted') { return { error: 'not_accepted' as const } } + if ( + !invitation.accepted_at || + invitation.accepted_at.getTime() > invitation.expires_at.getTime() + ) { + return { error: 'expired' as const } + } } else { return { error: 'not_found' as const } } @@ -1107,6 +1296,25 @@ export async function setInvitationPasswordForUser( if (user.id !== trimmedUserId) { return { error: 'forbidden' as const } } + if (invitation.purpose !== 'password_reset' && invitation.accepted_user_id !== trimmedUserId) { + return { error: 'forbidden' as const } + } + if (invitation.purpose !== 'password_reset' && user.password_hash) { + return { error: 'password_already_set' as const } + } + if (invitation.purpose === 'password_reset') { + const consumed = await db.query( + `UPDATE invitations + SET status = 'accepted', + accepted_at = NOW(), + accepted_user_id = $2 + WHERE id = $1 + AND status = 'pending' + RETURNING id`, + [invitation.id, user.id] + ) + if ((consumed.rowCount ?? 0) === 0) return { error: 'not_pending' as const } + } const passwordHash = await bcrypt.hash(trimmedPassword, 12) await db.query( `UPDATE users @@ -1118,20 +1326,12 @@ export async function setInvitationPasswordForUser( [user.id, passwordHash] ) await revokeAllUserSessions(user.id, 'password_changed', db) - if (invitation.purpose === 'password_reset' && invitation.status === 'pending') { + if (invitation.purpose !== 'password_reset' && !invitation.accepted_user_id) { await db.query( - `UPDATE invitations - SET status = 'accepted', - accepted_at = NOW(), - accepted_user_id = $2 - WHERE id = $1`, + `UPDATE invitations SET accepted_user_id = $2 + WHERE id = $1 AND accepted_user_id IS NULL`, [invitation.id, user.id] ) - } else if (!invitation.accepted_user_id) { - await db.query(`UPDATE invitations SET accepted_user_id = $2 WHERE id = $1`, [ - invitation.id, - user.id, - ]) } const refreshed = await getInvitationRecordById(db, invitation.id) @@ -1151,13 +1351,15 @@ export async function setInvitationPasswordForUser( export async function setInvitationPasswordForEmail( email: string, + invitationToken: string, invitationId: string, password: string ) { const normalizedEmail = email.trim().toLowerCase() + const trimmedInvitationToken = invitationToken.trim() const trimmedInvitationId = invitationId.trim() const trimmedPassword = password.trim() - if (!normalizedEmail || !trimmedInvitationId) { + if (!normalizedEmail || !trimmedInvitationToken || !trimmedInvitationId) { return { error: 'not_found' as const } } if (trimmedPassword.length < 8 || trimmedPassword.length > 256) { @@ -1165,26 +1367,32 @@ export async function setInvitationPasswordForEmail( } return withTransaction(async db => { - const invitation = await getInvitationRecordById(db, trimmedInvitationId) + const invitation = await getInvitationRecordByToken(db, trimmedInvitationToken, { lock: true }) if (!invitation) { return { error: 'not_found' as const } } + if (invitation.id !== trimmedInvitationId) { + return { error: 'forbidden' as const } + } if (invitation.email.toLowerCase() !== normalizedEmail) { return { error: 'forbidden' as const } } + const pendingMembershipSetup = + invitation.status === 'pending' && + (invitation.purpose === 'member_invitation' || invitation.purpose === 'admin_desktop_access') + if (invitation.expires_at.getTime() <= Date.now()) { + return { error: 'expired' as const } + } if (invitation.purpose === 'password_reset') { if (invitation.status !== 'pending') { return { error: 'not_pending' as const } } - if (invitation.expires_at.getTime() <= Date.now()) { - return { error: 'expired' as const } - } } else if ( invitation.purpose === 'member_invitation' || invitation.purpose === 'admin_desktop_access' ) { - if (invitation.status !== 'accepted') { - return { error: 'not_accepted' as const } + if (!pendingMembershipSetup) { + return { error: 'not_pending' as const } } } else { return { error: 'not_found' as const } @@ -1192,6 +1400,26 @@ export async function setInvitationPasswordForEmail( const user = await ensureInvitationUser(db, invitation.email, invitation.invitee_name) if (invitationUserIsRetired(user)) return { error: 'user_retired' as const } + if (invitation.purpose !== 'password_reset' && user.password_hash) { + return { error: 'password_already_set' as const } + } + if (pendingMembershipSetup) { + if (!(await acceptPendingMembershipInvitation(db, invitation, user.id))) { + return { error: 'not_pending' as const } + } + } else if (invitation.purpose === 'password_reset') { + const consumed = await db.query( + `UPDATE invitations + SET status = 'accepted', + accepted_at = NOW(), + accepted_user_id = $2 + WHERE id = $1 + AND status = 'pending' + RETURNING id`, + [invitation.id, user.id] + ) + if ((consumed.rowCount ?? 0) === 0) return { error: 'not_pending' as const } + } const passwordHash = await bcrypt.hash(trimmedPassword, 12) await db.query( `UPDATE users @@ -1203,22 +1431,6 @@ export async function setInvitationPasswordForEmail( [user.id, passwordHash] ) await revokeAllUserSessions(user.id, 'password_changed', db) - if (invitation.purpose === 'password_reset' && invitation.status === 'pending') { - await db.query( - `UPDATE invitations - SET status = 'accepted', - accepted_at = NOW(), - accepted_user_id = $2 - WHERE id = $1`, - [invitation.id, user.id] - ) - } else if (!invitation.accepted_user_id) { - await db.query(`UPDATE invitations SET accepted_user_id = $2 WHERE id = $1`, [ - invitation.id, - user.id, - ]) - } - const refreshed = await getInvitationRecordById(db, invitation.id) if (!refreshed) { return { error: 'not_found' as const } @@ -1241,20 +1453,19 @@ export async function acceptInvitation( invitationId: string ) { return withTransaction(async db => { - const invitation = await getInvitationRecordByTokenOrId(db, token, invitationId) + const invitation = await getInvitationRecordByTokenOrId(db, token, invitationId, { + lock: true, + }) if (!invitation) { return { error: 'not_found' as const } } if (invitation.email.toLowerCase() !== email.toLowerCase()) { return { error: 'forbidden' as const } } - if (invitation.status === 'revoked') { + if (invitation.status !== 'pending' || invitation.purpose === 'password_reset') { return { error: 'not_pending' as const } } - if (invitation.status === 'draft') { - return { error: 'not_pending' as const } - } - if (invitation.status === 'pending' && invitation.expires_at.getTime() <= Date.now()) { + if (invitation.expires_at.getTime() <= Date.now()) { return { error: 'expired' as const } } @@ -1264,22 +1475,8 @@ export async function acceptInvitation( return { error: 'forbidden' as const } } - if (invitation.status === 'pending' && invitation.purpose !== 'password_reset') { - await applyInvitationMemberships(db, invitation, user.id) - - await db.query( - `UPDATE invitations - SET status = 'accepted', - accepted_at = NOW(), - accepted_user_id = $2 - WHERE id = $1`, - [invitation.id, user.id] - ) - } else if (invitation.status === 'accepted' && !invitation.accepted_user_id) { - await db.query(`UPDATE invitations SET accepted_user_id = $2 WHERE id = $1`, [ - invitation.id, - user.id, - ]) + if (!(await acceptPendingMembershipInvitation(db, invitation, user.id))) { + return { error: 'not_pending' as const } } const refreshed = await getInvitationRecordByToken(db, invitation.token) @@ -1298,9 +1495,60 @@ export async function acceptInvitation( }) } +export async function acceptInvitationForEmailInTransaction( + db: Pick, + email: string, + token: string, + invitationId = '' +) { + const invitation = await getInvitationRecordByTokenOrId(db, token, invitationId, { + lock: true, + }) + if (!invitation) { + return { error: 'not_found' as const } + } + if (invitation.email.toLowerCase() !== email.toLowerCase()) { + return { error: 'forbidden' as const } + } + if (invitation.status !== 'pending' || invitation.purpose === 'password_reset') { + return { error: 'not_pending' as const } + } + if (invitation.expires_at.getTime() <= Date.now()) { + return { error: 'expired' as const } + } + + const user = await ensureInvitationUser(db, invitation.email, invitation.invitee_name) + if (invitationUserIsRetired(user)) return { error: 'user_retired' as const } + + if (!(await acceptPendingMembershipInvitation(db, invitation, user.id))) { + return { error: 'not_pending' as const } + } + + const refreshed = await getInvitationRecordByToken(db, invitation.token) + if (!refreshed) { + return { error: 'not_found' as const } + } + const teams = await loadInvitationTeams(db, refreshed.id, refreshed) + const payload = invitationResponse(refreshed, user, teams) + + return { + data: { + accepted: true as const, + ...payload, + userId: user.id, + }, + } +} + export async function acceptInvitationForEmail(email: string, token: string, invitationId = '') { + return withTransaction(db => + acceptInvitationForEmailInTransaction(db, email, token, invitationId) + ) +} + +export async function acceptInvitationById(email: string, invitationId: string) { return withTransaction(async db => { - const invitation = await getInvitationRecordByTokenOrId(db, token, invitationId) + const invitation = await getInvitationRecordById(db, invitationId, { lock: true }) if (!invitation) { return { error: 'not_found' as const } } @@ -1318,21 +1566,15 @@ export async function acceptInvitationForEmail(email: string, token: string, inv if (invitationUserIsRetired(user)) return { error: 'user_retired' as const } if (invitation.status === 'pending' && invitation.purpose !== 'password_reset') { - await applyInvitationMemberships(db, invitation, user.id) - + if (!(await acceptPendingMembershipInvitation(db, invitation, user.id))) { + return { error: 'not_pending' as const } + } + } else if (invitation.status === 'accepted' && !invitation.accepted_user_id) { await db.query( - `UPDATE invitations - SET status = 'accepted', - accepted_at = NOW(), - accepted_user_id = $2 - WHERE id = $1`, + `UPDATE invitations SET accepted_user_id = $2 + WHERE id = $1 AND accepted_user_id IS NULL`, [invitation.id, user.id] ) - } else if (invitation.status === 'accepted' && !invitation.accepted_user_id) { - await db.query(`UPDATE invitations SET accepted_user_id = $2 WHERE id = $1`, [ - invitation.id, - user.id, - ]) } const refreshed = await getInvitationRecordByToken(db, invitation.token) @@ -1352,66 +1594,78 @@ export async function acceptInvitationForEmail(email: string, token: string, inv }) } -export async function acceptInvitationById(email: string, invitationId: string) { - return withTransaction(async db => { - const invitation = await getInvitationRecordById(db, invitationId) - if (!invitation) { - return { error: 'not_found' as const } - } - if (invitation.email.toLowerCase() !== email.toLowerCase()) { - return { error: 'forbidden' as const } - } - if (invitation.status === 'revoked' || invitation.status === 'draft') { - return { error: 'not_pending' as const } - } - if (invitation.status === 'pending' && invitation.expires_at.getTime() <= Date.now()) { - return { error: 'expired' as const } - } +export const DIRECTORY_SEARCH_MAX_QUERY_LENGTH = 100 +export const DIRECTORY_SEARCH_PAGE_SIZE = 25 - const user = await ensureInvitationUser(db, invitation.email, invitation.invitee_name) - if (invitationUserIsRetired(user)) return { error: 'user_retired' as const } +export class DirectorySearchCursorError extends Error { + constructor() { + super('invalid directory search cursor') + this.name = 'DirectorySearchCursorError' + } +} - if (invitation.status === 'pending' && invitation.purpose !== 'password_reset') { - await applyInvitationMemberships(db, invitation, user.id) +function escapeLikeLiteral(value: string): string { + return value.replace(/[\\%_]/g, character => `\\${character}`) +} - await db.query( - `UPDATE invitations - SET status = 'accepted', - accepted_at = NOW(), - accepted_user_id = $2 - WHERE id = $1`, - [invitation.id, user.id] - ) - } else if (invitation.status === 'accepted' && !invitation.accepted_user_id) { - await db.query(`UPDATE invitations SET accepted_user_id = $2 WHERE id = $1`, [ - invitation.id, - user.id, - ]) - } +function directorySearchCursorSignature(value: string): string { + return createHmac('sha256', config.sessionJwtPrivateKey).update(value).digest('base64url') +} - const refreshed = await getInvitationRecordByToken(db, invitation.token) - if (!refreshed) { - return { error: 'not_found' as const } - } - const teams = await loadInvitationTeams(db, refreshed.id, refreshed) - const payload = invitationResponse(refreshed, user, teams) +function encodeDirectorySearchCursor(input: { + teamId: string + query: string + sortKey: string + userId: string +}): string { + const body = Buffer.from(JSON.stringify(input)).toString('base64url') + return `ds1.${body}.${directorySearchCursorSignature(body)}` +} - return { - data: { - accepted: true as const, - ...payload, - userId: user.id, - }, +function decodeDirectorySearchCursor( + value: string, + teamId: string, + query: string +): { sortKey: string; userId: string } { + const [prefix, body, signature, extra] = value.split('.') + if (prefix !== 'ds1' || !body || !signature || extra) throw new DirectorySearchCursorError() + const expected = directorySearchCursorSignature(body) + const suppliedBytes = Buffer.from(signature) + const expectedBytes = Buffer.from(expected) + if ( + suppliedBytes.length !== expectedBytes.length || + !timingSafeEqual(suppliedBytes, expectedBytes) + ) { + throw new DirectorySearchCursorError() + } + try { + const parsed = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as Record< + string, + unknown + > + const sortKey = typeof parsed.sortKey === 'string' ? parsed.sortKey : '' + const userId = typeof parsed.userId === 'string' ? parsed.userId : '' + if (parsed.teamId !== teamId || parsed.query !== query || !sortKey || !userId) { + throw new Error('cursor mismatch') } - }) + return { sortKey, userId } + } catch { + throw new DirectorySearchCursorError() + } } -export async function searchDirectory(teamId: string, q: string) { +export async function searchDirectory(teamId: string, q: string, cursor?: string) { const query = q.trim() - if (!query) return [] + if (!query) return { items: [], nextCursor: null } + if (query.length > DIRECTORY_SEARCH_MAX_QUERY_LENGTH) { + throw new DirectorySearchCursorError() + } + const after = cursor ? decodeDirectorySearchCursor(cursor, teamId, query) : null + const pattern = `%${escapeLikeLiteral(query)}%` const result = await pool.query( - `SELECT u.id, u.email, u.name, p.display_name, p.channels + `SELECT u.id, u.email, u.name, p.display_name, + LOWER(COALESCE(NULLIF(p.display_name, ''), NULLIF(u.name, ''), u.email)) AS sort_key FROM team_members tm JOIN users u ON u.id = tm.user_id LEFT JOIN profiles p ON p.user_id = u.id @@ -1419,15 +1673,47 @@ export async function searchDirectory(teamId: string, q: string) { AND tm.status = 'active' AND u.lifecycle_state = 'active' AND ( - u.email ILIKE $2 - OR COALESCE(u.name, '') ILIKE $2 - OR COALESCE(p.display_name, '') ILIKE $2 + u.email ILIKE $2 ESCAPE '\\' + OR COALESCE(u.name, '') ILIKE $2 ESCAPE '\\' + OR COALESCE(p.display_name, '') ILIKE $2 ESCAPE '\\' + ) + AND ( + $3::text IS NULL + OR ( + LOWER(COALESCE(NULLIF(p.display_name, ''), NULLIF(u.name, ''), u.email)), + u.id + ) > ($3, $4::uuid) ) - ORDER BY COALESCE(p.display_name, u.name, u.email) ASC - LIMIT 25`, - [teamId, `%${query}%`] + ORDER BY sort_key ASC, u.id ASC + LIMIT $5`, + [teamId, pattern, after?.sortKey ?? null, after?.userId ?? null, DIRECTORY_SEARCH_PAGE_SIZE + 1] ) - return result.rows + const rows = result.rows as Array<{ + id: string + email: string + name: string | null + display_name: string | null + sort_key: string + }> + const page = rows.slice(0, DIRECTORY_SEARCH_PAGE_SIZE) + const last = page[page.length - 1] + return { + items: page.map(row => ({ + id: row.id, + email: row.email, + name: row.name, + displayName: row.display_name, + })), + nextCursor: + rows.length > DIRECTORY_SEARCH_PAGE_SIZE && last + ? encodeDirectorySearchCursor({ + teamId, + query, + sortKey: last.sort_key, + userId: last.id, + }) + : null, + } } export async function listManageableTeamsForUser(userId: string) { @@ -1502,7 +1788,7 @@ export async function listManagedMembersForUser(userId: string, targetUserId?: s FROM users u JOIN team_members target_tm ON target_tm.user_id = u.id AND target_tm.status = 'active' JOIN teams t ON t.id = target_tm.team_id - LEFT JOIN managed_teams mt ON mt.team_id = target_tm.team_id + JOIN managed_teams mt ON mt.team_id = target_tm.team_id LEFT JOIN profiles p ON p.user_id = u.id WHERE u.id::text = $2 AND u.lifecycle_state = 'active' @@ -1559,9 +1845,9 @@ export async function listManagedMembersForUser(userId: string, targetUserId?: s JOIN team_members target_tm ON target_tm.user_id = vu.user_id AND target_tm.status = 'active' + JOIN managed_teams mt ON mt.team_id = target_tm.team_id JOIN teams t ON t.id = target_tm.team_id JOIN users u ON u.id = target_tm.user_id - LEFT JOIN managed_teams mt ON mt.team_id = target_tm.team_id LEFT JOIN profiles p ON p.user_id = u.id GROUP BY u.id, u.email, u.name, u.picture, p.display_name ORDER BY COALESCE(p.display_name, u.name, u.email) ASC`, @@ -1598,18 +1884,22 @@ export async function listManagedMembersForUser(userId: string, targetUserId?: s } async function getManagerRolesForTeams( + db: Pick, managerUserId: string, - teamIds: readonly string[] + teamIds: readonly string[], + lock = false ): Promise> { if (teamIds.length === 0) return new Map() - const result = await pool.query( - `SELECT team_id, role + const result = await db.query( + `SELECT tm.team_id, tm.role FROM team_members tm JOIN users u ON u.id = tm.user_id - WHERE user_id = $1 - AND team_id = ANY($2::uuid[]) - AND status = 'active' - AND u.lifecycle_state = 'active'`, + WHERE tm.user_id = $1 + AND tm.team_id = ANY($2::uuid[]) + AND tm.status = 'active' + AND u.lifecycle_state = 'active' + ORDER BY tm.team_id + ${lock ? 'FOR UPDATE' : ''}`, [managerUserId.trim(), teamIds] ) return new Map( @@ -1617,6 +1907,16 @@ async function getManagerRolesForTeams( ) } +async function hasValidManagedMutationAuthority( + db: DbClient, + managerUserId: string, + authority?: ExternalSessionAuthorityContext +): Promise { + if (!authority) return true + const session = await validateExternalSessionAuthorityContext(authority, { db }) + return session.status === 'valid' && session.identity.userId === managerUserId.trim() +} + function canManagerInviteAssignment( managerRole: TeamRole | undefined, assignmentRole: InviteRole @@ -1627,13 +1927,17 @@ function canManagerInviteAssignment( } async function managerCanControlAllInvitationTeams( + db: Pick, managerUserId: string, - teams: readonly InvitationTeamRow[] + teams: readonly InvitationTeamRow[], + lock = false ): Promise { if (teams.length === 0) return false const managerRoles = await getManagerRolesForTeams( + db, managerUserId, - teams.map(team => team.team_id) + teams.map(team => team.team_id), + lock ) return teams.every(team => canManagerInviteAssignment(managerRoles.get(team.team_id), team.role)) } @@ -1652,6 +1956,9 @@ export async function listManagedPendingInvitationsForUser(managerUserId: string ) SELECT DISTINCT i.id, i.team_id, i.invitee_name, i.email, i.role, i.status, i.purpose, i.created_at, i.expires_at, i.accepted_at, i.accepted_user_id, + (SELECT COUNT(*)::integer + FROM invitation_teams all_it + WHERE all_it.invitation_id = i.id) AS assignment_count, t.name AS team_name FROM invitations i LEFT JOIN teams t ON t.id = i.team_id @@ -1663,7 +1970,16 @@ export async function listManagedPendingInvitationsForUser(managerUserId: string ORDER BY i.created_at ASC`, [normalizedManagerUserId] ) - const invitations = await invitationListResponse(result.rows as InvitationRow[]) + const invitations = await invitationListResponse( + result.rows as InvitationRow[], + normalizedManagerUserId + ) + const assignmentCounts = new Map( + result.rows.map(row => [ + String((row as { id: string }).id), + Number((row as { assignment_count?: unknown }).assignment_count ?? 0), + ]) + ) const allTeamIds = Array.from( new Set( invitations.flatMap(invitation => @@ -1671,11 +1987,12 @@ export async function listManagedPendingInvitationsForUser(managerUserId: string ) ) ) - const managerRoles = await getManagerRolesForTeams(normalizedManagerUserId, allTeamIds) + const managerRoles = await getManagerRolesForTeams(pool, normalizedManagerUserId, allTeamIds) return invitations.map(invitation => { const teams = invitation.teams as Array<{ id: string; name: string; role: InviteRole }> const canManage = teams.length > 0 && + assignmentCounts.get(invitation.id) === teams.length && teams.every(team => canManagerInviteAssignment(managerRoles.get(team.id), team.role)) return { ...invitation, @@ -1689,7 +2006,8 @@ export async function createManagedInvitationForUser( managerUserId: string, email: string, teamAssignments: readonly InvitationTeamAssignment[], - inviteeName = '' + inviteeName = '', + authority?: ExternalSessionAuthorityContext ) { const normalizedEmail = email.trim().toLowerCase() const normalizedInviteeName = inviteeName.trim() @@ -1698,65 +2016,49 @@ export async function createManagedInvitationForUser( return { error: 'invalid_payload' as const } } - const rolesResult = await pool.query( - `SELECT tm.team_id, tm.role, t.name AS team_name - FROM team_members tm - JOIN teams t ON t.id = tm.team_id - JOIN users manager ON manager.id = tm.user_id - WHERE tm.user_id = $1 - AND tm.team_id = ANY($2::uuid[]) - AND tm.status = 'active' - AND tm.role IN ('admin', 'inviter') - AND manager.lifecycle_state = 'active'`, - [managerUserId.trim(), assignments.map(assignment => assignment.teamId)] - ) - const managerRoles = new Map( - (rolesResult.rows as Array<{ team_id: string; role: TeamRole; team_name: string }>).map(row => [ - row.team_id, - row.role, - ]) - ) - for (const assignment of assignments) { - const managerRole = managerRoles.get(assignment.teamId) - if (!roleCanInviteMembers(managerRole)) { - return { error: 'forbidden' as const } - } - if (assignment.role === 'admin' && !roleCanDeleteMembers(managerRole)) { + const fallbackName = normalizedEmail.split('@')[0] || normalizedEmail + const authorized = await withTransaction(async db => { + if (!(await hasValidManagedMutationAuthority(db, managerUserId, authority))) { return { error: 'forbidden' as const } } - } - - const fallbackName = normalizedEmail.split('@')[0] || normalizedEmail - if (normalizedInviteeName) { - const updatedUser = await pool.query( - `UPDATE users - SET name = $2, - updated_at = NOW() - WHERE email = $1 - RETURNING id`, - [normalizedEmail, normalizedInviteeName] + const managerRoles = await getManagerRolesForTeams( + db, + managerUserId.trim(), + assignments.map(assignment => assignment.teamId), + true ) - const userId = String((updatedUser.rows[0] as { id?: string } | undefined)?.id || '') - if (userId) { - await pool.query( - `INSERT INTO profiles(user_id, display_name) - VALUES($1, $2) - ON CONFLICT (user_id) - DO UPDATE SET display_name = EXCLUDED.display_name, - updated_at = NOW()`, - [userId, normalizedInviteeName] - ) + for (const assignment of assignments) { + if (!canManagerInviteAssignment(managerRoles.get(assignment.teamId), assignment.role)) { + return { error: 'forbidden' as const } + } } - } - return { - invitation: await createInvitationForTeams({ + const inserted = await insertInvitationForTeams(db, { inviteeName: normalizedInviteeName || fallbackName, email: normalizedEmail, purpose: 'member_invitation', teamAssignments: assignments, fallbackRole: 'member', - }), + status: 'draft', + }) + const commandId = await recordInvitationDeliveryCommand( + db, + inserted.invitation.id, + managerUserId, + 'create' + ) + return { inserted, commandId } + }) + if ('error' in authorized && authorized.error) return { error: authorized.error } + const delivered = await deliverAndActivateManagedInvitation( + authorized.inserted, + authorized.commandId, + managerUserId, + authority + ) + if ('error' in delivered) return { error: delivered.error } + return { + invitation: externalManagedInvitationResponse(delivered), } } @@ -1764,7 +2066,8 @@ export async function updateManagedMemberRoleForUser( managerUserId: string, targetUserId: string, teamId: string, - role: TeamRole + role: TeamRole, + authority?: ExternalSessionAuthorityContext ) { if (managerUserId.trim() === targetUserId.trim()) { return { error: 'invalid_target' as const } @@ -1773,42 +2076,140 @@ export async function updateManagedMemberRoleForUser( if (!normalizedRole) { return { error: 'invalid_role' as const } } - const manager = await findMemberRole(teamId, managerUserId) - if (!roleCanDeleteMembers(manager?.role)) { - return { error: 'forbidden' as const } - } - const target = await findMemberRole(teamId, targetUserId) - if (!target) { - return { error: 'not_found' as const } - } - return { - membership: await updateMemberRole(teamId, targetUserId, normalizedRole, managerUserId), - } + return withTransaction(async db => { + if (!(await hasValidManagedMutationAuthority(db, managerUserId, authority))) { + return { error: 'forbidden' as const } + } + const locked = await db.query( + `SELECT user_id::text AS user_id, role + FROM team_members + WHERE team_id = $1 + AND user_id = ANY($2::uuid[]) + AND status = 'active' + ORDER BY user_id + FOR UPDATE`, + [teamId.trim(), [managerUserId.trim(), targetUserId.trim()]] + ) + const memberships = new Map( + (locked.rows as Array<{ user_id: string; role: TeamRole }>).map(row => [ + row.user_id, + row.role, + ]) + ) + if (!roleCanDeleteMembers(memberships.get(managerUserId.trim()))) { + return { error: 'forbidden' as const } + } + const previousRole = memberships.get(targetUserId.trim()) + if (!previousRole) return { error: 'not_found' as const } + + const result = await db.query( + `UPDATE team_members + SET role = $3, + updated_at = NOW() + WHERE team_id = $1 + AND user_id = $2 + AND status = 'active' + RETURNING team_id, user_id, role, status`, + [teamId.trim(), targetUserId.trim(), normalizedRole] + ) + const membership = result.rows[0] + if (!membership) return { error: 'not_found' as const } + if (previousRole !== normalizedRole) { + await appendControlApiPermissionEventsInTransaction(db, { + operatorSub: managerUserId.trim(), + changes: [ + { + action: 'revoke', + resourceClass: 'team_membership', + resourceRef: `team_membership:${teamId}:role:${previousRole}`, + subject: { kind: 'user', id: targetUserId.trim() }, + teamId: teamId.trim(), + status: 'role_replaced', + }, + { + action: 'grant', + resourceClass: 'team_membership', + resourceRef: `team_membership:${teamId}:role:${normalizedRole}`, + subject: { kind: 'user', id: targetUserId.trim() }, + teamId: teamId.trim(), + status: 'role_assigned', + }, + ], + }) + } + return { membership } + }) } export async function deleteManagedMemberForUser( managerUserId: string, targetUserId: string, - teamId: string + teamId: string, + authority?: ExternalSessionAuthorityContext ) { if (managerUserId.trim() === targetUserId.trim()) { return { error: 'invalid_target' as const } } - const manager = await findMemberRole(teamId, managerUserId) - if (!roleCanDeleteMembers(manager?.role)) { - return { error: 'forbidden' as const } - } - const deleted = await softDeleteMember(teamId, targetUserId, managerUserId) - if (!deleted) { - return { error: 'not_found' as const } - } - return { deleted } + return withTransaction(async db => { + if (!(await hasValidManagedMutationAuthority(db, managerUserId, authority))) { + return { error: 'forbidden' as const } + } + const locked = await db.query( + `SELECT user_id::text AS user_id, role + FROM team_members + WHERE team_id = $1 + AND user_id = ANY($2::uuid[]) + AND status = 'active' + ORDER BY user_id + FOR UPDATE`, + [teamId.trim(), [managerUserId.trim(), targetUserId.trim()]] + ) + const memberships = new Map( + (locked.rows as Array<{ user_id: string; role: TeamRole }>).map(row => [ + row.user_id, + row.role, + ]) + ) + if (!roleCanDeleteMembers(memberships.get(managerUserId.trim()))) { + return { error: 'forbidden' as const } + } + const targetRole = memberships.get(targetUserId.trim()) + if (!targetRole) return { error: 'not_found' as const } + + const result = await db.query( + `UPDATE team_members + SET status = 'deleted', + updated_at = NOW() + WHERE team_id = $1 + AND user_id = $2 + AND status = 'active' + RETURNING team_id, user_id, role, status`, + [teamId.trim(), targetUserId.trim()] + ) + const deleted = result.rows[0] + if (!deleted) return { error: 'not_found' as const } + await appendControlApiPermissionEventsInTransaction(db, { + operatorSub: managerUserId.trim(), + changes: [ + { + action: 'revoke', + resourceClass: 'team_membership', + resourceRef: `team_membership:${teamId}:role:${targetRole}`, + subject: { kind: 'user', id: targetUserId.trim() }, + teamId: teamId.trim(), + status: 'membership_removed', + }, + ], + }) + return { deleted } + }) } export async function deleteManagedUserForUser( managerUserId: string, targetUserId: string, - retirement: { reason: string; idempotencyKey: string; requestId: string | null } + retirement: { reason: string; idempotencyKey: string; requestId: string | null }, + authority?: ExternalSessionAuthorityContext ) { const normalizedManagerUserId = managerUserId.trim() const normalizedTargetUserId = targetUserId.trim() @@ -1819,34 +2220,57 @@ export async function deleteManagedUserForUser( return { error: 'invalid_target' as const } } - const teamsResult = await pool.query( - `SELECT team_id - FROM team_members tm - JOIN users target ON target.id = tm.user_id - WHERE user_id = $1 - AND status = 'active' - AND target.lifecycle_state = 'active'`, - [normalizedTargetUserId] - ) - const teamIds = (teamsResult.rows as Array<{ team_id: string }>).map(row => row.team_id) - if (teamIds.length === 0) { - return { error: 'not_found' as const } - } - const managerRoles = await getManagerRolesForTeams(normalizedManagerUserId, teamIds) - if (!teamIds.every(teamId => roleCanDeleteMembers(managerRoles.get(teamId)))) { - return { error: 'forbidden_uncontrolled_teams' as const } + class ManagedRetirementAuthorizationError extends Error { + constructor(readonly code: 'not_found' | 'forbidden_uncontrolled_teams') { + super(code) + } } try { - const outcome = await retireDesktopUser( - { kind: 'platform_user', desktopUserId: normalizedManagerUserId }, - normalizedTargetUserId, - retirement.reason, - retirement.idempotencyKey, - retirement.requestId - ) - return { deleted: { ok: true as const, id: outcome.id }, outcome } + return await withTransaction(async db => { + if (!(await hasValidManagedMutationAuthority(db, normalizedManagerUserId, authority))) { + return { error: 'forbidden' as const } + } + const outcome = await retireDesktopUser( + { kind: 'platform_user', desktopUserId: normalizedManagerUserId }, + normalizedTargetUserId, + retirement.reason, + retirement.idempotencyKey, + retirement.requestId, + { + db, + authorize: async retirementDb => { + const teamsResult = await retirementDb.query( + `SELECT team_id + FROM team_members + WHERE user_id = $1 + AND status = 'active' + ORDER BY team_id + FOR UPDATE`, + [normalizedTargetUserId] + ) + const teamIds = (teamsResult.rows as Array<{ team_id: string }>).map(row => row.team_id) + if (teamIds.length === 0) { + throw new ManagedRetirementAuthorizationError('not_found') + } + const managerRoles = await getManagerRolesForTeams( + retirementDb, + normalizedManagerUserId, + teamIds, + true + ) + if (!teamIds.every(teamId => roleCanDeleteMembers(managerRoles.get(teamId)))) { + throw new ManagedRetirementAuthorizationError('forbidden_uncontrolled_teams') + } + }, + } + ) + return { deleted: { ok: true as const, id: outcome.id }, outcome } + }) } catch (error) { + if (error instanceof ManagedRetirementAuthorizationError) { + return { error: error.code } + } if (error instanceof DesktopUserRetirementError) { if (error.code === 'not_found') return { error: 'not_found' as const } if (error.code === 'invalid_input') return { error: 'invalid_retirement_input' as const } @@ -1857,9 +2281,17 @@ export async function deleteManagedUserForUser( } } -export async function resendManagedInvitationForUser(managerUserId: string, invitationId: string) { - const result = await pool.query( - `SELECT i.id, i.team_id, i.invitee_name, i.email, i.role, i.token, i.status, i.purpose, i.created_at, i.expires_at, +export async function resendManagedInvitationForUser( + managerUserId: string, + invitationId: string, + authority?: ExternalSessionAuthorityContext +) { + const authorized = await withTransaction(async db => { + if (!(await hasValidManagedMutationAuthority(db, managerUserId, authority))) { + return { error: 'forbidden' as const } + } + const result = await db.query( + `SELECT i.id, i.team_id, i.invitee_name, i.email, i.role, i.token, i.status, i.purpose, i.created_at, i.expires_at, i.accepted_at, i.accepted_user_id, t.name AS team_name FROM invitations i @@ -1868,33 +2300,61 @@ export async function resendManagedInvitationForUser(managerUserId: string, invi AND i.status = 'pending' AND i.purpose = 'member_invitation' AND i.expires_at > NOW() - LIMIT 1`, - [invitationId.trim()] - ) - const invitation = (result.rows[0] as InvitationRow | undefined) || null - if (!invitation) return { error: 'not_found' as const } - const teams = await loadInvitationTeams(pool, invitation.id, invitation) - if (!(await managerCanControlAllInvitationTeams(managerUserId, teams))) { - return { error: 'forbidden' as const } - } - - await registerAndSendInvitation( - invitation.email, - invitation.token, - teams.length > 0 ? teams.map(team => team.team_name).join(', ') : invitation.team_name, - new Date().toISOString(), - invitation.expires_at.toISOString(), - { - purpose: invitation.purpose, - teamNames: teams.map(team => team.team_name), + LIMIT 1 + FOR UPDATE OF i`, + [invitationId.trim()] + ) + const invitation = (result.rows[0] as InvitationRow | undefined) || null + if (!invitation) return { error: 'not_found' as const } + const teams = await loadInvitationTeams(db, invitation.id, invitation) + if (!(await managerCanControlAllInvitationTeams(db, managerUserId, teams, true))) { + return { error: 'forbidden' as const } } - ) + + const commandId = await recordInvitationDeliveryCommand( + db, + invitation.id, + managerUserId, + 'resend' + ) + return { invitation, teams, commandId } + }) + if ('error' in authorized) return authorized + const { invitation, teams, commandId } = authorized + + try { + await registerAndSendInvitation( + invitation.email, + invitation.token, + teams.length > 0 ? teams.map(team => team.team_name).join(', ') : invitation.team_name, + new Date().toISOString(), + invitation.expires_at.toISOString(), + { + purpose: invitation.purpose, + teamNames: teams.map(team => team.team_name), + } + ) + } catch (error) { + await withTransaction(db => + finishInvitationDeliveryCommand(db, commandId, 'failed', 'delivery_failed') + ).catch(() => undefined) + throw error + } + await withTransaction(db => finishInvitationDeliveryCommand(db, commandId, 'delivered')) return { resent: true as const, id: invitation.id, email: invitation.email } } -export async function revokeManagedInvitationForUser(managerUserId: string, invitationId: string) { - const result = await pool.query( - `SELECT i.id, i.team_id, i.invitee_name, i.email, i.role, i.token, i.status, i.purpose, i.created_at, i.expires_at, +export async function revokeManagedInvitationForUser( + managerUserId: string, + invitationId: string, + authority?: ExternalSessionAuthorityContext +) { + return withTransaction(async db => { + if (!(await hasValidManagedMutationAuthority(db, managerUserId, authority))) { + return { error: 'forbidden' as const } + } + const result = await db.query( + `SELECT i.id, i.team_id, i.invitee_name, i.email, i.role, i.token, i.status, i.purpose, i.created_at, i.expires_at, i.accepted_at, i.accepted_user_id, t.name AS team_name FROM invitations i @@ -1903,18 +2363,23 @@ export async function revokeManagedInvitationForUser(managerUserId: string, invi AND i.status = 'pending' AND i.purpose = 'member_invitation' AND i.expires_at > NOW() - LIMIT 1`, - [invitationId.trim()] - ) - const invitation = (result.rows[0] as InvitationRow | undefined) || null - if (!invitation) return { error: 'not_found' as const } - const teams = await loadInvitationTeams(pool, invitation.id, invitation) - if (!(await managerCanControlAllInvitationTeams(managerUserId, teams))) { - return { error: 'forbidden' as const } - } + LIMIT 1 + FOR UPDATE OF i`, + [invitationId.trim()] + ) + const invitation = (result.rows[0] as InvitationRow | undefined) || null + if (!invitation) return { error: 'not_found' as const } + const teams = await loadInvitationTeams(db, invitation.id, invitation) + if (!(await managerCanControlAllInvitationTeams(db, managerUserId, teams, true))) { + return { error: 'forbidden' as const } + } - await pool.query(`UPDATE invitations SET status = 'revoked' WHERE id = $1`, [invitation.id]) - return { revoked: true as const, id: invitation.id, email: invitation.email } + await db.query( + `UPDATE invitations SET status = 'revoked' WHERE id = $1 AND status = 'pending'`, + [invitation.id] + ) + return { revoked: true as const, id: invitation.id, email: invitation.email } + }) } export async function updateUserPassword( diff --git a/control-api/src/services/directory/users.ts b/control-api/src/services/directory/users.ts index 860bb03394..6076b04d67 100644 --- a/control-api/src/services/directory/users.ts +++ b/control-api/src/services/directory/users.ts @@ -373,7 +373,11 @@ export async function retireDesktopUser( reasonInput: string, idempotencyKeyInput: string, requestIdInput: string | null | undefined, - options: { db?: DbClient; retainWithoutLinkHistory?: boolean } = {} + options: { + db?: DbClient + authorize?: (db: DbClient) => Promise + retainWithoutLinkHistory?: boolean + } = {} ): Promise { const actor = normalizeRetirementActor(actorInput) const targetUserId = requireUuid(userIdInput, 'userId') @@ -491,6 +495,7 @@ export async function retireDesktopUser( ) } const lifecycleVersion = requireLifecycleVersion(user.lifecycle_version) + await options.authorize?.(db) const history = await db.query( `SELECT EXISTS( @@ -669,50 +674,55 @@ export async function retireDesktopUserInTransaction( * Teams are retained even when this leaves them with zero active members. * Accounts with retained operator-link history are intentionally not purged. */ -export async function adminDeleteUser(userId: string): Promise { - return withTransaction(async db => { - const exists = await db.query(`SELECT 1 FROM users WHERE id = $1 LIMIT 1`, [userId]) - if ((exists.rowCount ?? 0) === 0) { - return { error: 'not_found' } - } +export async function adminDeleteUserInTransaction( + db: DbClient, + userId: string +): Promise { + const exists = await db.query(`SELECT 1 FROM users WHERE id = $1 LIMIT 1`, [userId]) + if ((exists.rowCount ?? 0) === 0) { + return { error: 'not_found' } + } - // Operator-link generations are retained for audit and are protected by - // ON DELETE RESTRICT. Refuse the legacy hard-delete operation explicitly - // rather than relying on a late FK error or silently erasing the lifecycle - // history. A future actor-aware account-retirement flow must revoke the - // active generation and use the governed retention process before purge. - const operatorLinkHistory = await db.query( - `SELECT 1 - FROM gfs_desktop_operator_links - WHERE user_id = $1 - LIMIT 1`, - [userId] - ) - if ((operatorLinkHistory.rowCount ?? 0) > 0) { - return { error: 'gfs_operator_link_history_retained' } - } + // Operator-link generations are retained for audit and are protected by + // ON DELETE RESTRICT. Refuse the legacy hard-delete operation explicitly + // rather than relying on a late FK error or silently erasing lifecycle history. + const operatorLinkHistory = await db.query( + `SELECT 1 + FROM gfs_desktop_operator_links + WHERE user_id = $1 + LIMIT 1`, + [userId] + ) + if ((operatorLinkHistory.rowCount ?? 0) > 0) { + return { error: 'gfs_operator_link_history_retained' } + } - await db.query( - `UPDATE workflow_approval_medium_accounts + await db.query( + `UPDATE workflow_approval_medium_accounts SET disabled_at = COALESCE(disabled_at, NOW()), updated_at = NOW() WHERE user_id = $1 AND disabled_at IS NULL`, - [userId] - ) - await db.query( - `UPDATE workflow_approval_medium_challenges + [userId] + ) + await db.query( + `UPDATE workflow_approval_medium_challenges SET consumed_at = COALESCE(consumed_at, NOW()), expires_at = LEAST(expires_at, NOW()) WHERE user_id = $1 AND consumed_at IS NULL`, - [userId] - ) + [userId] + ) - const del = await db.query(`DELETE FROM users WHERE id = $1 RETURNING id`, [userId]) - if ((del.rowCount ?? 0) === 0) { - return { error: 'not_found' } - } - return { ok: true, id: userId } + const del = await db.query(`DELETE FROM users WHERE id = $1 RETURNING id`, [userId]) + if ((del.rowCount ?? 0) === 0) { + return { error: 'not_found' } + } + return { ok: true, id: userId } +} + +export async function adminDeleteUser(userId: string): Promise { + return withTransaction(async db => { + return adminDeleteUserInTransaction(db, userId) }) } diff --git a/control-api/test/db.invitationDeliveryCommandMigration.test.ts b/control-api/test/db.invitationDeliveryCommandMigration.test.ts new file mode 100644 index 0000000000..7d26416881 --- /dev/null +++ b/control-api/test/db.invitationDeliveryCommandMigration.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { applyInvitationDeliveryCommandFoundation } from '../src/services/directory/invitationDeliverySchema.js' + +describe('invitation delivery command migration', () => { + it('persists non-secret authorized delivery commands with bounded runtime access', async () => { + const query = vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }) + + await applyInvitationDeliveryCommandFoundation({ query }) + + const sql = String(query.mock.calls[0]?.[0]) + expect(sql).toContain('CREATE TABLE IF NOT EXISTS invitation_delivery_commands') + expect(sql).toContain("delivery_kind IN ('create', 'resend')") + expect(sql).toContain("status IN ('authorized', 'delivered', 'failed', 'cancelled')") + expect(sql).toContain('REFERENCES invitations(id) ON DELETE CASCADE') + expect(sql).toContain("WHERE status = 'authorized'") + expect(sql).toContain('REVOKE ALL PRIVILEGES ON TABLE invitation_delivery_commands') + expect(sql).toContain( + 'FROM PUBLIC, control_api_runtime, trace_maintenance_runtime, workflow_recipes_runtime' + ) + expect(sql).toContain( + 'GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE invitation_delivery_commands' + ) + expect(sql).toContain('TO control_api_runtime') + expect(sql).not.toContain('token') + }) + + it('declares the delivery table in the deploy-time runtime contract', () => { + const contractPath = join( + dirname(fileURLToPath(import.meta.url)), + '../../deploy/scripts/control-api-runtime-access-profiles.tsv' + ) + const entries = new Map( + readFileSync(contractPath, 'utf8') + .split('\n') + .filter(line => line && !line.startsWith('#')) + .map(line => line.split('\t') as [string, string]) + ) + + expect(entries.get('invitation_delivery_commands')).toBe('legacy_dml') + }) +}) diff --git a/control-api/test/directoryAuthorizationPrivacy.test.ts b/control-api/test/directoryAuthorizationPrivacy.test.ts new file mode 100644 index 0000000000..6bb7cd24f6 --- /dev/null +++ b/control-api/test/directoryAuthorizationPrivacy.test.ts @@ -0,0 +1,929 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import bcrypt from 'bcryptjs' +import { + DirectorySearchCursorError, + acceptInvitationForEmail, + createManagedInvitationForUser, + deleteManagedMemberForUser, + listManagedMembersForUser, + listManagedPendingInvitationsForUser, + resendManagedInvitationForUser, + revokeManagedInvitationForUser, + searchDirectory, + setInvitationPasswordForEmail, + setInvitationPasswordForUser, + updateManagedMemberRoleForUser, + updateUserPassword, +} from '../src/services/directory/membership.js' + +const mocks = vi.hoisted(() => ({ + query: vi.fn(), + withTransaction: vi.fn(), + appendEvents: vi.fn().mockResolvedValue(undefined), + registerInvitation: vi.fn(), +})) + +vi.mock('../src/db.js', () => ({ + pool: { query: mocks.query }, + withTransaction: mocks.withTransaction, +})) +vi.mock('../src/services/tracing/controlApiPermissionEvents.js', () => ({ + appendControlApiPermissionEventsInTransaction: mocks.appendEvents, +})) +vi.mock('../src/services/invitationFlowRegistrationService.js', () => ({ + registerAndSendInvitation: mocks.registerInvitation, +})) + +const MANAGER = '00000000-0000-4000-8000-000000000001' +const TARGET = '00000000-0000-4000-8000-000000000002' +const TEAM_A = '00000000-0000-4000-8000-000000000010' +const TEAM_SECRET = '00000000-0000-4000-8000-000000000099' + +describe('directory privacy and atomic authorization', () => { + beforeEach(() => { + mocks.query.mockReset() + mocks.withTransaction.mockReset() + mocks.appendEvents.mockClear() + mocks.registerInvitation.mockReset() + mocks.registerInvitation.mockResolvedValue(undefined) + mocks.withTransaction.mockImplementation(async work => work({ query: mocks.query })) + }) + + it('projects managed members only through teams the caller currently manages', async () => { + mocks.query.mockResolvedValueOnce({ + rows: [ + { + id: TARGET, + email: 'target@example.com', + name: 'Target', + picture: null, + display_name: 'Target', + teams: [{ id: TEAM_A, name: 'Team A', role: 'member', managerRole: 'admin' }], + }, + ], + rowCount: 1, + }) + + const result = await listManagedMembersForUser(MANAGER, TARGET) + + expect(result[0]?.teams).toEqual([ + expect.objectContaining({ id: TEAM_A, name: 'Team A', role: 'member' }), + ]) + const sql = String(mocks.query.mock.calls[0]?.[0]) + expect(sql).toContain('JOIN managed_teams mt ON mt.team_id = target_tm.team_id') + expect(sql).not.toContain('LEFT JOIN managed_teams mt ON mt.team_id = target_tm.team_id') + expect(JSON.stringify(result)).not.toContain(TEAM_SECRET) + }) + + it('projects invitation assignments only through the manager scope and omits secrets', async () => { + mocks.query.mockImplementation(async (sql: string) => { + if (sql.includes('SELECT DISTINCT i.id')) { + return { + rows: [ + { + id: '00000000-0000-4000-8000-000000000200', + team_id: TEAM_SECRET, + invitee_name: 'Invitee', + email: 'invitee@example.com', + role: 'admin', + token: 'must-never-serialize', + status: 'pending', + purpose: 'member_invitation', + created_at: new Date('2026-08-10T12:00:00Z'), + expires_at: new Date('2026-08-11T12:00:00Z'), + accepted_at: null, + accepted_user_id: null, + assignment_count: 2, + team_name: 'Secret Team', + }, + ], + rowCount: 1, + } + } + if (sql.includes('FROM invitation_teams it')) { + expect(sql).toContain('manager.user_id::text = $2') + return { + rows: [ + { + invitation_id: '00000000-0000-4000-8000-000000000200', + team_id: TEAM_A, + team_name: 'Team A', + role: 'member', + }, + ], + rowCount: 1, + } + } + if (sql.includes('SELECT team_id, role')) { + return { rows: [{ team_id: TEAM_A, role: 'admin' }], rowCount: 1 } + } + throw new Error(`unexpected query: ${sql.slice(0, 40)}`) + }) + + const result = await listManagedPendingInvitationsForUser(MANAGER) + + expect(result[0]?.teams).toEqual([{ id: TEAM_A, name: 'Team A', role: 'member' }]) + expect(JSON.stringify(result)).not.toContain(TEAM_SECRET) + expect(JSON.stringify(result)).not.toContain('Secret Team') + expect(result[0]).not.toHaveProperty('team_id') + expect(result[0]).not.toHaveProperty('team_name') + expect(result[0]).not.toHaveProperty('role') + expect(JSON.stringify(result)).not.toContain('must-never-serialize') + expect(result[0]).toMatchObject({ canCancel: false, canResend: false }) + }) + + it('locks manager and target authority in the same transaction as a role mutation', async () => { + mocks.query + .mockResolvedValueOnce({ + rows: [ + { user_id: MANAGER, role: 'admin' }, + { user_id: TARGET, role: 'member' }, + ], + rowCount: 2, + }) + .mockResolvedValueOnce({ + rows: [{ team_id: TEAM_A, user_id: TARGET, role: 'inviter', status: 'active' }], + rowCount: 1, + }) + + const result = await updateManagedMemberRoleForUser(MANAGER, TARGET, TEAM_A, 'inviter') + + expect(result).toEqual({ + membership: { team_id: TEAM_A, user_id: TARGET, role: 'inviter', status: 'active' }, + }) + expect(mocks.withTransaction).toHaveBeenCalledTimes(1) + expect(String(mocks.query.mock.calls[0]?.[0])).toContain('FOR UPDATE') + expect(String(mocks.query.mock.calls[0]?.[0])).toContain('user_id = ANY($2::uuid[])') + expect(String(mocks.query.mock.calls[1]?.[0])).toContain('UPDATE team_members') + expect(mocks.appendEvents).toHaveBeenCalledTimes(1) + }) + + it('locks invitation authority and denies inviter-to-admin assignment before mutation', async () => { + mocks.query.mockResolvedValueOnce({ + rows: [{ team_id: TEAM_A, role: 'inviter' }], + rowCount: 1, + }) + + await expect( + createManagedInvitationForUser( + MANAGER, + 'invitee@example.com', + [{ teamId: TEAM_A, role: 'admin' }], + 'Invitee' + ) + ).resolves.toEqual({ error: 'forbidden' }) + + expect(mocks.withTransaction).toHaveBeenCalledTimes(1) + expect(String(mocks.query.mock.calls[0]?.[0])).toContain('FOR UPDATE') + expect(mocks.query).toHaveBeenCalledTimes(1) + expect(mocks.registerInvitation).not.toHaveBeenCalled() + }) + + it('omits the invitation capability from managed creation results', async () => { + let transactionDepth = 0 + mocks.withTransaction.mockImplementation(async work => { + transactionDepth += 1 + try { + return await work({ query: mocks.query }) + } finally { + transactionDepth -= 1 + } + }) + mocks.registerInvitation.mockImplementation(async () => { + expect(transactionDepth).toBe(0) + }) + const invitation = { + id: '00000000-0000-4000-8000-000000000200', + team_id: TEAM_A, + invitee_name: 'Invitee', + email: 'invitee@example.com', + role: 'member', + token: 'must-never-leave-control-api', + status: 'draft', + purpose: 'member_invitation', + created_at: new Date('2026-08-10T12:00:00Z'), + expires_at: new Date('2026-08-11T12:00:00Z'), + accepted_at: null, + accepted_user_id: null, + team_name: 'Team A', + } + mocks.query.mockImplementation(async (sql: string) => { + if (sql.includes("WHERE status = 'draft'")) return { rows: [], rowCount: 0 } + if (sql.includes('FROM team_members') && sql.includes('FOR UPDATE')) { + return { rows: [{ team_id: TEAM_A, role: 'admin' }], rowCount: 1 } + } + if (sql.includes('UPDATE users') && sql.includes('SET name')) { + return { rows: [{ id: TARGET }], rowCount: 1 } + } + if (sql.includes('INSERT INTO profiles')) return { rows: [], rowCount: 1 } + if (sql.includes('WITH inserted AS')) return { rows: [invitation], rowCount: 1 } + if (sql.includes('INSERT INTO invitation_teams')) return { rows: [], rowCount: 1 } + if (sql.includes('INSERT INTO invitation_delivery_commands')) { + return { rows: [{ id: '00000000-0000-4000-8000-000000000300' }], rowCount: 1 } + } + if (sql.includes('FROM invitation_delivery_commands c')) { + return { rows: [invitation], rowCount: 1 } + } + if (sql.includes('UPDATE invitation_delivery_commands')) return { rows: [], rowCount: 1 } + if (sql.includes('FROM invitation_teams it') && sql.includes('JOIN teams')) { + return { + rows: [{ team_id: TEAM_A, team_name: 'Team A', role: 'member' }], + rowCount: 1, + } + } + if (sql.includes("SET status = 'pending'")) { + return { rows: [{ ...invitation, status: 'pending' }], rowCount: 1 } + } + throw new Error(`unexpected query: ${sql.slice(0, 40)}`) + }) + + const result = await createManagedInvitationForUser( + MANAGER, + invitation.email, + [{ teamId: TEAM_A, role: 'member' }], + 'Untrusted inviter label' + ) + + expect(result).toMatchObject({ invitation: { id: invitation.id, status: 'pending' } }) + expect(JSON.stringify(result)).not.toContain(invitation.token) + expect((result as { invitation: Record }).invitation).not.toHaveProperty( + 'token' + ) + const sql = mocks.query.mock.calls.map(call => String(call[0])).join('\n') + expect(sql).not.toContain('UPDATE users') + expect(sql).not.toContain('INSERT INTO profiles') + }) + + it('leaves a delivered draft unusable when manager authority changes before activation', async () => { + const invitation = { + id: '00000000-0000-4000-8000-000000000200', + team_id: TEAM_A, + invitee_name: 'Invitee', + email: 'invitee@example.com', + role: 'member', + token: 'inert-until-activation', + status: 'draft', + purpose: 'member_invitation', + created_at: new Date('2026-08-10T12:00:00Z'), + expires_at: new Date('2026-08-11T12:00:00Z'), + accepted_at: null, + accepted_user_id: null, + team_name: 'Team A', + } + let transactionDepth = 0 + let managerChecks = 0 + mocks.withTransaction.mockImplementation(async work => { + transactionDepth += 1 + try { + return await work({ query: mocks.query }) + } finally { + transactionDepth -= 1 + } + }) + mocks.registerInvitation.mockImplementation(async () => { + expect(transactionDepth).toBe(0) + }) + mocks.query.mockImplementation(async (sql: string) => { + if (sql.includes("WHERE status = 'draft'")) return { rows: [], rowCount: 0 } + if (sql.includes('FROM team_members') && sql.includes('FOR UPDATE')) { + managerChecks += 1 + return managerChecks === 1 + ? { rows: [{ team_id: TEAM_A, role: 'admin' }], rowCount: 1 } + : { rows: [], rowCount: 0 } + } + if (sql.includes('WITH inserted AS')) return { rows: [invitation], rowCount: 1 } + if (sql.includes('INSERT INTO invitation_teams')) return { rows: [], rowCount: 1 } + if (sql.includes('INSERT INTO invitation_delivery_commands')) { + return { rows: [{ id: '00000000-0000-4000-8000-000000000300' }], rowCount: 1 } + } + if (sql.includes('FROM invitation_delivery_commands c')) { + return { rows: [invitation], rowCount: 1 } + } + if (sql.includes('FROM invitation_teams it') && sql.includes('JOIN teams')) { + return { + rows: [{ team_id: TEAM_A, team_name: 'Team A', role: 'member' }], + rowCount: 1, + } + } + if (sql.includes('UPDATE invitation_delivery_commands')) return { rows: [], rowCount: 1 } + if (sql.includes("SET status = 'revoked'")) return { rows: [], rowCount: 1 } + throw new Error(`unexpected query: ${sql.slice(0, 40)}`) + }) + + await expect( + createManagedInvitationForUser( + MANAGER, + invitation.email, + [{ teamId: TEAM_A, role: 'member' }], + '' + ) + ).resolves.toEqual({ error: 'forbidden' }) + + const sql = mocks.query.mock.calls.map(call => String(call[0])).join('\n') + expect(managerChecks).toBe(2) + expect(sql).toContain("SET status = 'revoked'") + expect(sql).not.toContain("SET status = 'pending'") + }) + + it('does not apply inviter-controlled names to an existing user during acceptance', async () => { + const invitation = { + id: '00000000-0000-4000-8000-000000000200', + team_id: TEAM_A, + invitee_name: 'Untrusted inviter label', + email: 'target@example.com', + role: 'member', + token: 'invitation-token', + status: 'pending', + purpose: 'member_invitation', + created_at: new Date('2026-08-10T12:00:00Z'), + expires_at: new Date('2026-08-11T12:00:00Z'), + accepted_at: null, + accepted_user_id: null, + team_name: 'Team A', + } + mocks.query.mockImplementation(async (sql: string) => { + if (sql.includes("$2 = '' AND i.token = $1")) { + return { rows: [invitation], rowCount: 1 } + } + if (sql.includes('FROM users') && sql.includes('WHERE email = $1')) { + return { + rows: [ + { + id: TARGET, + email: invitation.email, + name: 'Original user name', + picture: null, + password_hash: null, + }, + ], + rowCount: 1, + } + } + if (sql.includes('UPDATE users') && sql.includes('SET name')) { + return { + rows: [ + { + id: TARGET, + email: invitation.email, + name: invitation.invitee_name, + picture: null, + password_hash: null, + }, + ], + rowCount: 1, + } + } + if (sql.includes('INSERT INTO profiles') || sql.includes('UPDATE profiles')) { + return { rows: [], rowCount: 1 } + } + if (sql.includes('FROM invitation_teams it') && sql.includes('JOIN teams')) { + return { + rows: [{ team_id: TEAM_A, team_name: 'Team A', role: 'member' }], + rowCount: 1, + } + } + if (sql.includes('INSERT INTO team_members')) return { rows: [], rowCount: 1 } + if (sql.includes("SET status = 'accepted'")) return { rows: [], rowCount: 1 } + if (sql.includes('WHERE i.token = $1')) { + return { + rows: [{ ...invitation, status: 'accepted', accepted_user_id: TARGET }], + rowCount: 1, + } + } + throw new Error(`unexpected query: ${sql.slice(0, 40)}`) + }) + + const result = await acceptInvitationForEmail(invitation.email, invitation.token) + + expect(result).toMatchObject({ data: { accepted: true, userId: TARGET } }) + const sql = mocks.query.mock.calls.map(call => String(call[0])).join('\n') + expect(sql).toContain('FOR UPDATE OF i') + expect(sql).toContain("AND status = 'pending'") + expect(sql).not.toContain('UPDATE users') + expect(sql).not.toContain('UPDATE profiles') + }) + + it('allows exactly one general invitation acceptance and rejects password-reset use', async () => { + const invitation = { + id: '00000000-0000-4000-8000-000000000200', + team_id: TEAM_A, + invitee_name: 'Target', + email: 'target@example.com', + role: 'member', + token: 'single-use-token', + status: 'pending', + purpose: 'member_invitation', + created_at: new Date('2026-08-10T12:00:00Z'), + expires_at: new Date('2026-08-11T12:00:00Z'), + accepted_at: null, + accepted_user_id: null, + team_name: 'Team A', + } + let status = invitation.status + let membershipWrites = 0 + mocks.query.mockImplementation(async (sql: string) => { + if (sql.includes("$2 = '' AND i.token = $1")) { + return { rows: [{ ...invitation, status }], rowCount: 1 } + } + if (sql.includes('FROM users') && sql.includes('WHERE email = $1')) { + return { + rows: [ + { + id: TARGET, + email: invitation.email, + name: 'Target', + picture: null, + password_hash: null, + }, + ], + rowCount: 1, + } + } + if (sql.includes('INSERT INTO profiles')) return { rows: [], rowCount: 0 } + if (sql.includes("SET status = 'accepted'")) { + if (status !== 'pending') return { rows: [], rowCount: 0 } + status = 'accepted' + return { rows: [{ id: invitation.id }], rowCount: 1 } + } + if (sql.includes('FROM invitation_teams it') && sql.includes('JOIN teams')) { + return { + rows: [{ team_id: TEAM_A, team_name: 'Team A', role: 'member' }], + rowCount: 1, + } + } + if (sql.includes('INSERT INTO team_members')) { + membershipWrites += 1 + return { rows: [], rowCount: 1 } + } + if (sql.includes('WHERE i.token = $1')) { + return { + rows: [{ ...invitation, status, accepted_user_id: TARGET }], + rowCount: 1, + } + } + throw new Error(`unexpected query: ${sql.slice(0, 40)}`) + }) + + await expect( + acceptInvitationForEmail(invitation.email, invitation.token) + ).resolves.toMatchObject({ data: { accepted: true, userId: TARGET } }) + await expect(acceptInvitationForEmail(invitation.email, invitation.token)).resolves.toEqual({ + error: 'not_pending', + }) + expect(membershipWrites).toBe(1) + + status = 'pending' + const passwordReset = { ...invitation, purpose: 'password_reset' } + mocks.query.mockImplementationOnce(async () => ({ rows: [passwordReset], rowCount: 1 })) + await expect(acceptInvitationForEmail(invitation.email, invitation.token)).resolves.toEqual({ + error: 'not_pending', + }) + }) + + it('serializes invitation acceptance behind a concurrent manager revocation', async () => { + const invitation = { + id: '00000000-0000-4000-8000-000000000200', + team_id: TEAM_A, + invitee_name: 'Invitee', + email: 'invitee@example.com', + role: 'member', + token: 'invitation-token', + status: 'pending', + purpose: 'member_invitation', + created_at: new Date('2026-08-10T12:00:00Z'), + expires_at: new Date('2026-08-11T12:00:00Z'), + accepted_at: null, + accepted_user_id: null, + team_name: 'Team A', + } + let status = invitation.status + let rowOwner: symbol | null = null + const rowWaiters: Array<() => void> = [] + let releaseManagerCheck!: () => void + const managerCheckMayFinish = new Promise(resolve => { + releaseManagerCheck = resolve + }) + let managerCheckStarted!: () => void + const managerCheckIsRunning = new Promise(resolve => { + managerCheckStarted = resolve + }) + let membershipWrites = 0 + + mocks.withTransaction.mockImplementation(async work => { + const transaction = Symbol('invitation-transaction') + const acquireRow = async () => { + if (rowOwner === transaction) return + while (rowOwner) await new Promise(resolve => rowWaiters.push(resolve)) + rowOwner = transaction + } + const releaseRow = () => { + if (rowOwner !== transaction) return + rowOwner = null + rowWaiters.shift()?.() + } + const query = vi.fn(async (sql: string) => { + if (sql.includes('FROM invitations i')) { + if (sql.includes('FOR UPDATE OF i')) await acquireRow() + if (sql.includes("i.status = 'pending'") && status !== 'pending') { + return { rows: [], rowCount: 0 } + } + return { rows: [{ ...invitation, status }], rowCount: 1 } + } + if (sql.includes('FROM invitation_teams it') && sql.includes('JOIN teams')) { + return { + rows: [{ team_id: TEAM_A, team_name: 'Team A', role: 'member' }], + rowCount: 1, + } + } + if (sql.includes('FROM team_members') && sql.includes('FOR UPDATE')) { + managerCheckStarted() + await managerCheckMayFinish + return { rows: [{ team_id: TEAM_A, role: 'admin' }], rowCount: 1 } + } + if (sql.includes("SET status = 'revoked'")) { + if (status !== 'pending') return { rows: [], rowCount: 0 } + status = 'revoked' + return { rows: [], rowCount: 1 } + } + if (sql.includes("SET status = 'accepted'")) { + if (status !== 'pending') return { rows: [], rowCount: 0 } + status = 'accepted' + return { rows: [{ id: invitation.id }], rowCount: 1 } + } + if (sql.includes('INSERT INTO team_members')) { + membershipWrites += 1 + return { rows: [], rowCount: 1 } + } + throw new Error(`unexpected query: ${sql.slice(0, 40)}`) + }) + try { + return await work({ query }) + } finally { + releaseRow() + } + }) + + const revoke = revokeManagedInvitationForUser(MANAGER, invitation.id) + await managerCheckIsRunning + const accept = acceptInvitationForEmail(invitation.email, invitation.token) + await Promise.resolve() + releaseManagerCheck() + + await expect(revoke).resolves.toMatchObject({ revoked: true }) + await expect(accept).resolves.toEqual({ error: 'not_pending' }) + expect(status).toBe('revoked') + expect(membershipWrites).toBe(0) + }) + + it('locks and conditionally consumes password-reset invitations before password writes', async () => { + const invitation = { + id: '00000000-0000-4000-8000-000000000200', + team_id: null, + invitee_name: 'Target', + email: 'target@example.com', + role: 'member', + token: 'password-reset-token', + status: 'pending', + purpose: 'password_reset', + created_at: new Date('2026-08-10T12:00:00Z'), + expires_at: new Date('2026-08-11T12:00:00Z'), + accepted_at: null, + accepted_user_id: null, + team_name: null, + } + + for (const operation of [ + () => setInvitationPasswordForUser(TARGET, invitation.email, invitation.id, 'valid-password'), + () => + setInvitationPasswordForEmail( + invitation.email, + invitation.token, + invitation.id, + 'valid-password' + ), + ]) { + mocks.query.mockReset() + mocks.query.mockImplementation(async (sql: string) => { + if ( + sql.includes('FROM invitations i') && + (sql.includes('WHERE i.id::text = $1') || sql.includes('WHERE i.token = $1')) + ) { + return { rows: [invitation], rowCount: 1 } + } + if (sql.includes('FROM users') && sql.includes('WHERE email = $1')) { + return { + rows: [ + { + id: TARGET, + email: invitation.email, + name: 'Target', + picture: null, + password_hash: 'old-password-hash', + }, + ], + rowCount: 1, + } + } + if (sql.includes('INSERT INTO profiles')) return { rows: [], rowCount: 0 } + if (sql.includes("SET status = 'accepted'")) return { rows: [], rowCount: 0 } + throw new Error(`unexpected query: ${sql.slice(0, 40)}`) + }) + + await expect(operation()).resolves.toEqual({ error: 'not_pending' }) + const sql = mocks.query.mock.calls.map(call => String(call[0])).join('\n') + expect(sql).toContain('FOR UPDATE OF i') + expect(sql).toContain("AND status = 'pending'") + expect(sql).not.toContain('SET password_hash') + } + }) + + it('atomically accepts a pending member invitation with first password setup', async () => { + const invitation = { + id: '00000000-0000-4000-8000-000000000200', + team_id: TEAM_A, + invitee_name: 'New user', + email: 'new-user@example.com', + role: 'member', + token: 'member-setup-token', + status: 'pending', + purpose: 'member_invitation', + created_at: new Date(), + expires_at: new Date(Date.now() + 60_000), + accepted_at: null, + accepted_user_id: null, + team_name: 'Team A', + } + let status = invitation.status + let passwordHash: string | null = null + let membershipWrites = 0 + mocks.query.mockImplementation(async (sql: string) => { + if ( + sql.includes('FROM invitations i') && + (sql.includes('WHERE i.token = $1') || sql.includes('WHERE i.id::text = $1')) + ) { + return { + rows: [ + { ...invitation, status, accepted_user_id: status === 'accepted' ? TARGET : null }, + ], + rowCount: 1, + } + } + if (sql.includes('FROM users') && sql.includes('WHERE email = $1')) { + return { + rows: [ + { + id: TARGET, + email: invitation.email, + name: 'New user', + picture: null, + password_hash: passwordHash, + }, + ], + rowCount: 1, + } + } + if (sql.includes('INSERT INTO profiles')) return { rows: [], rowCount: 0 } + if (sql.includes("SET status = 'accepted'")) { + if (status !== 'pending') return { rows: [], rowCount: 0 } + status = 'accepted' + return { rows: [{ id: invitation.id }], rowCount: 1 } + } + if (sql.includes('FROM invitation_teams it') && sql.includes('JOIN teams')) { + return { + rows: [{ team_id: TEAM_A, team_name: 'Team A', role: 'member' }], + rowCount: 1, + } + } + if (sql.includes('INSERT INTO team_members')) { + membershipWrites += 1 + return { rows: [], rowCount: 1 } + } + if (sql.includes('SET password_hash = $2')) { + passwordHash = 'stored-hash' + return { rows: [], rowCount: 1 } + } + if (sql.includes('SELECT id') && sql.includes('FROM users') && sql.includes('FOR UPDATE')) { + return { rows: [{ id: TARGET }], rowCount: 1 } + } + if (sql.includes('external_user_session_security_epochs')) return { rows: [], rowCount: 1 } + if (sql.includes('UPDATE external_user_sessions')) return { rows: [], rowCount: 0 } + throw new Error(`unexpected query: ${sql.slice(0, 40)}`) + }) + + await expect( + setInvitationPasswordForEmail( + invitation.email, + invitation.token, + invitation.id, + 'valid-password' + ) + ).resolves.toMatchObject({ data: { passwordUpdated: true, status: 'accepted' } }) + expect(status).toBe('accepted') + expect(passwordHash).not.toBeNull() + expect(membershipWrites).toBe(1) + + await expect( + setInvitationPasswordForEmail( + invitation.email, + invitation.token, + invitation.id, + 'another-password' + ) + ).resolves.toEqual({ error: 'not_pending' }) + expect(membershipWrites).toBe(1) + }) + + it('rejects token-authenticated password setup after invitation acceptance', async () => { + const invitation = { + id: '00000000-0000-4000-8000-000000000200', + team_id: TEAM_A, + invitee_name: 'Existing user', + email: 'existing@example.com', + role: 'member', + token: 'consumed-member-token', + status: 'accepted', + purpose: 'member_invitation', + created_at: new Date(), + expires_at: new Date(Date.now() + 60_000), + accepted_at: new Date(), + accepted_user_id: TARGET, + team_name: 'Team A', + } + mocks.query.mockImplementation(async (sql: string) => { + if (sql.includes('FROM invitations i') && sql.includes('WHERE i.token = $1')) { + return { rows: [invitation], rowCount: 1 } + } + throw new Error(`unexpected query: ${sql.slice(0, 40)}`) + }) + + await expect( + setInvitationPasswordForEmail( + invitation.email, + invitation.token, + invitation.id, + 'attacker-password' + ) + ).resolves.toEqual({ error: 'not_pending' }) + const sql = mocks.query.mock.calls.map(call => String(call[0])).join('\n') + expect(sql).toContain('FOR UPDATE OF i') + expect(sql).not.toContain('SET password_hash') + expect(sql).not.toContain('external_user_session_security_epochs') + }) + + it('sends a managed resend from a durable command without holding authority locks', async () => { + const invitation = { + id: '00000000-0000-4000-8000-000000000200', + team_id: TEAM_A, + invitee_name: 'Invitee', + email: 'invitee@example.com', + role: 'member', + token: 'invitation-token', + status: 'pending', + purpose: 'member_invitation', + created_at: new Date('2026-08-10T12:00:00Z'), + expires_at: new Date('2026-08-11T12:00:00Z'), + accepted_at: null, + accepted_user_id: null, + team_name: 'Team A', + } + let transactionDepth = 0 + mocks.withTransaction.mockImplementation(async work => { + transactionDepth += 1 + try { + return await work({ query: mocks.query }) + } finally { + transactionDepth -= 1 + } + }) + mocks.registerInvitation.mockImplementation(async () => { + expect(transactionDepth).toBe(0) + }) + mocks.query + .mockResolvedValueOnce({ rows: [invitation], rowCount: 1 }) + .mockResolvedValueOnce({ + rows: [{ team_id: TEAM_A, team_name: 'Team A', role: 'member' }], + rowCount: 1, + }) + .mockResolvedValueOnce({ rows: [{ team_id: TEAM_A, role: 'admin' }], rowCount: 1 }) + .mockResolvedValueOnce({ + rows: [{ id: '00000000-0000-4000-8000-000000000300' }], + rowCount: 1, + }) + .mockResolvedValueOnce({ rows: [], rowCount: 1 }) + + await expect(resendManagedInvitationForUser(MANAGER, invitation.id)).resolves.toEqual({ + resent: true, + id: invitation.id, + email: invitation.email, + }) + expect(String(mocks.query.mock.calls[3]?.[0])).toContain( + 'INSERT INTO invitation_delivery_commands' + ) + expect(String(mocks.query.mock.calls[4]?.[0])).toContain('UPDATE invitation_delivery_commands') + }) + + it.each(['resend', 'revoke'] as const)( + 'prevents %s after the locked manager authority is gone', + async operation => { + const invitation = { + id: '00000000-0000-4000-8000-000000000200', + team_id: TEAM_A, + invitee_name: 'Invitee', + email: 'invitee@example.com', + role: 'member', + token: 'secret', + status: 'pending', + purpose: 'member_invitation', + created_at: new Date('2026-08-10T12:00:00Z'), + expires_at: new Date('2026-08-11T12:00:00Z'), + accepted_at: null, + accepted_user_id: null, + team_name: 'Team A', + } + mocks.query + .mockResolvedValueOnce({ rows: [invitation], rowCount: 1 }) + .mockResolvedValueOnce({ + rows: [{ team_id: TEAM_A, team_name: 'Team A', role: 'member' }], + rowCount: 1, + }) + .mockResolvedValueOnce({ rows: [], rowCount: 0 }) + + const result = + operation === 'resend' + ? await resendManagedInvitationForUser(MANAGER, invitation.id) + : await revokeManagedInvitationForUser(MANAGER, invitation.id) + + expect(result).toEqual({ error: 'forbidden' }) + expect(String(mocks.query.mock.calls[0]?.[0])).toContain('FOR UPDATE OF i') + expect(String(mocks.query.mock.calls[2]?.[0])).toContain('FOR UPDATE') + expect(mocks.query).toHaveBeenCalledTimes(3) + expect(mocks.registerInvitation).not.toHaveBeenCalled() + } + ) + + it('does not mutate when the locked manager membership is already revoked', async () => { + mocks.query.mockResolvedValueOnce({ + rows: [{ user_id: TARGET, role: 'member' }], + rowCount: 1, + }) + + await expect(deleteManagedMemberForUser(MANAGER, TARGET, TEAM_A)).resolves.toEqual({ + error: 'forbidden', + }) + expect(mocks.query).toHaveBeenCalledTimes(1) + expect(mocks.appendEvents).not.toHaveBeenCalled() + }) + + it('changes a password and revokes every session in one conditional transaction', async () => { + const currentHash = await bcrypt.hash('current-password', 4) + mocks.query + .mockResolvedValueOnce({ rows: [{ password_hash: currentHash }], rowCount: 1 }) + .mockResolvedValueOnce({ rows: [{ id: MANAGER }], rowCount: 1 }) + .mockResolvedValueOnce({ rows: [{ id: MANAGER }], rowCount: 1 }) + .mockResolvedValueOnce({ rows: [], rowCount: 1 }) + .mockResolvedValueOnce({ rows: [], rowCount: 2 }) + + await expect( + updateUserPassword(MANAGER, 'manager@example.com', 'current-password', 'next-password') + ).resolves.toEqual({ updated: true }) + + expect(mocks.withTransaction).toHaveBeenCalledTimes(1) + const passwordUpdate = String(mocks.query.mock.calls[1]?.[0]) + expect(passwordUpdate).toContain('AND password_hash = $4') + expect(String(mocks.query.mock.calls[2]?.[0])).toContain('FOR UPDATE') + expect(String(mocks.query.mock.calls[3]?.[0])).toContain( + 'INSERT INTO external_user_session_security_epochs' + ) + expect(String(mocks.query.mock.calls[4]?.[0])).toContain('UPDATE external_user_sessions') + }) + + it('treats wildcard characters literally, excludes channels, and publishes a stable cursor', async () => { + mocks.query.mockImplementation(async (sql: string, values: unknown[]) => { + expect(sql).toContain('ILIKE $2 ESCAPE') + expect(sql).not.toContain('p.channels') + expect(values[1]).toBe('%\\%\\_%') + expect(values[4]).toBe(26) + return { + rows: Array.from({ length: 26 }, (_, index) => ({ + id: `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`, + email: `person-${index}@example.com`, + name: `Person ${index}`, + display_name: `Person ${index}`, + sort_key: `person ${String(index).padStart(2, '0')}`, + channels: { emails: ['secret@example.com'] }, + })), + rowCount: 26, + } + }) + + const result = await searchDirectory(TEAM_A, '%_') + + expect(result.items).toHaveLength(25) + expect(result.nextCursor).toMatch(/^ds1\./) + expect(JSON.stringify(result)).not.toContain('channels') + expect(JSON.stringify(result)).not.toContain('secret@example.com') + await expect( + searchDirectory(TEAM_A, '%_', `${result.nextCursor}tampered`) + ).rejects.toBeInstanceOf(DirectorySearchCursorError) + }) +}) diff --git a/control-api/test/routes.externalInvitations.unavailable.test.ts b/control-api/test/routes.externalInvitations.unavailable.test.ts index 2b61bc99d1..553515080e 100644 --- a/control-api/test/routes.externalInvitations.unavailable.test.ts +++ b/control-api/test/routes.externalInvitations.unavailable.test.ts @@ -14,7 +14,7 @@ const flow = vi.hoisted(() => ({ vi.mock('../src/services/invitationFlowRegistrationService.js', () => flow) const directory = vi.hoisted(() => ({ - acceptInvitationForEmail: vi.fn(), + acceptInvitationForEmailInTransaction: vi.fn(), getInvitationByToken: vi.fn(), listPendingInvitations: vi.fn(), setInvitationPasswordForEmail: vi.fn(), @@ -23,6 +23,12 @@ const directory = vi.hoisted(() => ({ })) vi.mock('../src/services/directory/index.js', () => directory) +const database = vi.hoisted(() => ({ + query: vi.fn(), + withTransaction: vi.fn(), +})) +vi.mock('../src/db.js', () => ({ withTransaction: database.withTransaction })) + vi.mock('../src/middleware/rateLimitMiddleware.js', () => ({ rateLimitMiddleware: () => (_req: express.Request, _res: express.Response, next: express.NextFunction) => @@ -49,8 +55,9 @@ vi.mock('../src/middleware/externalSessionAuth.js', () => ({ next() }, })) +const tokenSigner = vi.hoisted(() => ({ sign: vi.fn(() => 'session-token') })) vi.mock('../src/utils/auth/externalSessionAuthToken.js', () => ({ - signExternalSessionToken: vi.fn(() => 'session-token'), + signExternalSessionToken: tokenSigner.sign, })) // Router-level harness: proves the ROUTE GUARDS rethrow instead of swallowing. @@ -74,6 +81,7 @@ function app(): express.Express { describe('external invitation routes when the hub is unavailable', () => { beforeEach(() => { vi.clearAllMocks() + database.withTransaction.mockImplementation(async work => work({ query: database.query })) }) it('GET token lookup returns 503 member_registration_unavailable, NOT 400 invalid_invitation', async () => { @@ -92,6 +100,79 @@ describe('external invitation routes when the hub is unavailable', () => { expect(res.body.error).toBe('member_registration_unavailable') }) + it('issues only for the transaction that consumes a pending invitation', async () => { + flow.validateInvitationFlowToken.mockResolvedValue({ + email: 'a@b.c', + invitationUuid: 'database-capability', + }) + directory.acceptInvitationForEmailInTransaction + .mockResolvedValueOnce({ + data: { + accepted: true, + userId: '00000000-0000-4000-8000-000000000001', + email: 'a@b.c', + teamId: null, + role: 'member', + }, + }) + .mockResolvedValueOnce({ error: 'not_pending' }) + .mockResolvedValueOnce({ error: 'not_pending' }) + database.query.mockResolvedValue({ + rows: [{ id: '00000000-0000-4000-8000-000000000001' }], + rowCount: 1, + }) + + const first = await request(app()) + .post('/external/invitations/accept') + .send({ email: 'a@b.c', token: 'one-use-flow' }) + const replay = await request(app()) + .post('/external/invitations/accept') + .send({ email: 'a@b.c', token: 'one-use-flow' }) + const passwordReset = await request(app()) + .post('/external/invitations/accept') + .send({ email: 'a@b.c', token: 'password-reset-flow' }) + + expect(first.status).toBe(200) + expect(first.body.token).toBe('session-token') + expect(replay.status).toBe(400) + expect(replay.body).toEqual({ error: 'not_pending' }) + expect(passwordReset.status).toBe(400) + expect(passwordReset.body).toEqual({ error: 'not_pending' }) + expect(tokenSigner.sign).toHaveBeenCalledTimes(1) + }) + + it('binds the secret capability to the public id and never issues on password setup', async () => { + flow.validateInvitationFlowToken.mockResolvedValue({ + email: 'a@b.c', + invitationUuid: 'database-secret-capability', + }) + directory.setInvitationPasswordForEmail.mockResolvedValue({ + data: { + id: '00000000-0000-4000-8000-000000000200', + userId: '00000000-0000-4000-8000-000000000001', + passwordUpdated: true, + }, + }) + + const response = await request(app()).post('/external/invitations/password-token').send({ + email: 'a@b.c', + token: 'member-registration-flow-token', + invitationId: '00000000-0000-4000-8000-000000000200', + password: 'valid-password', + }) + + expect(response.status).toBe(200) + expect(response.body.reauthenticationRequired).toBe(true) + expect(response.body).not.toHaveProperty('token') + expect(directory.setInvitationPasswordForEmail).toHaveBeenCalledWith( + 'a@b.c', + 'database-secret-capability', + '00000000-0000-4000-8000-000000000200', + 'valid-password' + ) + expect(tokenSigner.sign).not.toHaveBeenCalled() + }) + it('lists invitations only for the authenticated email', async () => { directory.listPendingInvitations.mockResolvedValue([]) diff --git a/control-api/test/routes.externalMembers.test.ts b/control-api/test/routes.externalMembers.test.ts index 40d4caa90f..8d76da20ee 100644 --- a/control-api/test/routes.externalMembers.test.ts +++ b/control-api/test/routes.externalMembers.test.ts @@ -7,6 +7,10 @@ const directoryMock = vi.hoisted(() => ({ createManagedInvitationForUser: vi.fn(), deleteManagedMemberForUser: vi.fn(), deleteManagedUserForUser: vi.fn(), + externalManagedInvitationResponse: vi.fn((value: Record) => { + const { token: _token, ...safe } = value + return safe + }), listManageableTeamsForUser: vi.fn(), listManagedMembersForUser: vi.fn(), listManagedPendingInvitationsForUser: vi.fn(), @@ -49,7 +53,7 @@ describe('external members routes', () => { it('passes invitee name through managed member invitations', async () => { directoryMock.createManagedInvitationForUser.mockResolvedValueOnce({ - invitation: { id: 'inv-1' }, + invitation: { id: 'inv-1', token: 'secret' }, }) await request(makeApp()) diff --git a/control-api/test/routes.externalTeamsInvitations.test.ts b/control-api/test/routes.externalTeamsInvitations.test.ts new file mode 100644 index 0000000000..a651450893 --- /dev/null +++ b/control-api/test/routes.externalTeamsInvitations.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import request from 'supertest' +import { createExternalTeamsRouter } from '../src/routes/external/teams.js' + +const directory = vi.hoisted(() => ({ + createManagedInvitationForUser: vi.fn(), + createTeamForUser: vi.fn(), + deleteManagedMemberForUser: vi.fn(), + findMemberRole: vi.fn(), + getCurrentTeam: vi.fn(), + getTeamAgents: vi.fn(), + getTeamContexts: vi.fn(), + listMembers: vi.fn(), + renameTeamForUser: vi.fn(), + updateManagedMemberRoleForUser: vi.fn(), +})) + +vi.mock('../src/middleware/externalSessionAuth.js', () => ({ + requireValidExternalSessionToken: ( + req: express.Request, + _res: express.Response, + next: express.NextFunction + ) => { + ;(req as express.Request & { externalAuth?: { userId: string } }).externalAuth = { + userId: 'manager-1', + } + next() + }, + requireExternalTeamParamMatch: + () => (_req: express.Request, _res: express.Response, next: express.NextFunction) => + next(), + requireExternalUserParamMatch: + () => (_req: express.Request, _res: express.Response, next: express.NextFunction) => + next(), + requireExternalRole: + () => (_req: express.Request, _res: express.Response, next: express.NextFunction) => + next(), + rejectBodyUserTeamMismatch: ( + _req: express.Request, + _res: express.Response, + next: express.NextFunction + ) => next(), +})) +vi.mock('../src/services/directory/index.js', async importOriginal => ({ + ...(await importOriginal()), + ...directory, +})) + +function app() { + const value = express() + value.use(express.json()) + value.use(createExternalTeamsRouter({} as never)) + return value +} + +describe('legacy external team invitation responses', () => { + beforeEach(() => vi.clearAllMocks()) + + it('never serializes the invitation acceptance capability', async () => { + directory.createManagedInvitationForUser.mockResolvedValueOnce({ + invitation: { id: 'inv-1', token: 'must-never-leave-control-api' }, + }) + + const response = await request(app()) + .post('/external/teams/team-1/invitations') + .send({ email: 'invitee@example.com', name: 'Invitee', role: 'member' }) + + expect(response.status).toBe(200) + expect(response.body).toEqual({ id: 'inv-1' }) + expect(JSON.stringify(response.body)).not.toContain('must-never-leave-control-api') + }) +}) diff --git a/deploy/scripts/control-api-runtime-access-profiles.tsv b/deploy/scripts/control-api-runtime-access-profiles.tsv index edbe41b9a4..4b08ee2a2e 100644 --- a/deploy/scripts/control-api-runtime-access-profiles.tsv +++ b/deploy/scripts/control-api-runtime-access-profiles.tsv @@ -39,6 +39,7 @@ infrastructure_cost_daily read infrastructure_cost_daily_components read infrastructure_price_snapshots read infrastructure_telemetry_events append +invitation_delivery_commands legacy_dml invitation_teams legacy_dml invitations legacy_dml llm_allowed_models legacy_dml diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 1831f85dfc..ff88a88560 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index a2eb923322..d8dc7498ab 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index cb0a3eb5c2..9362890f01 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.10.0', - externalRestApiVersion: '0.1.106', + externalRestApiVersion: '0.1.107', rpcProxyVersion: '0.1.111', desktopVersion: '0.10.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/src/routes/invitations.ts b/external-rest-api/src/routes/invitations.ts index fcd11fda9f..6eb2afd566 100644 --- a/external-rest-api/src/routes/invitations.ts +++ b/external-rest-api/src/routes/invitations.ts @@ -8,16 +8,15 @@ import { getInvitationByToken, listPendingInvitations, setupInvitationPassword, + setupInvitationPasswordWithToken, } from '../services/invitationsService.js' -import { setProfileSessionCookie } from '../sessionCookie.js' +import { clearProfileSessionCookie, setProfileSessionCookie } from '../sessionCookie.js' const tokenLookupRateLimit = createRateLimiter({ windowMs: 60_000, maxRequests: 30 }) const acceptRateLimit = createRateLimiter({ windowMs: 60_000, maxRequests: 10 }) -type InvitationPasswordAuth = { - userId: string - email: string - sessionToken: string +function requestedSessionContract(req: Request): 'v2' | undefined { + return req.header('x-evenfire-session-contract') === 'v2' ? 'v2' : undefined } function sendAcceptInvitationError( @@ -70,56 +69,6 @@ function sendInvitationPasswordError( res.status(400).json({ error: 'Password must be between 8 and 256 characters' }) } -async function invitationPasswordAuthFromRequest(req: Request): Promise< - | { auth: InvitationPasswordAuth } - | { - status: number - body: Record - } -> { - const sessionToken = extractAuthToken(req) - if (sessionToken) { - const claims = verifyToken(sessionToken) - if (claims) { - return { auth: { userId: claims.userId, email: claims.email, sessionToken } } - } - } - - const token = String(req.body?.token || '').trim() - const email = String(req.body?.email || '') - .trim() - .toLowerCase() - if (!token || !email) { - return { status: 401, body: { error: 'Unauthorized' } } - } - - const accepted = await acceptInvitation(token, email) - if (accepted.error) { - const response = { - invalid: { status: 400, body: { error: 'Invalid invitation' } }, - not_found: { status: 404, body: { error: 'Invitation not found' } }, - forbidden: { - status: 403, - body: { error: 'Invitation email does not match authenticated user' }, - }, - expired: { status: 410, body: { error: 'Invitation has expired' } }, - not_pending: { status: 400, body: { error: 'Invitation is not pending' } }, - }[accepted.error] - return response ?? { status: 502, body: { error: 'Failed to start invited session' } } - } - if (!accepted.data?.token || !accepted.data.userId) { - return { status: 502, body: { error: 'Failed to start invited session' } } - } - - return { - auth: { - userId: accepted.data.userId, - email: accepted.data.email, - sessionToken: accepted.data.token, - }, - } -} - export function createInvitationsRouter(): Router { const router = Router() @@ -151,19 +100,35 @@ export function createInvitationsRouter(): Router { return } - const authResult = await invitationPasswordAuthFromRequest(req) - if ('status' in authResult) { - res.status(authResult.status).json(authResult.body) + const sessionToken = extractAuthToken(req) + const claims = sessionToken ? verifyToken(sessionToken) : null + const invitationToken = String(req.body?.token || '').trim() + const invitationEmail = String(req.body?.email || '') + .trim() + .toLowerCase() + if (!claims && (!invitationToken || !invitationEmail)) { + res.status(401).json({ error: 'Unauthorized' }) return } - - const result = await setupInvitationPassword(authResult.auth, invitationId, password) + const result = claims + ? await setupInvitationPassword( + { userId: claims.userId, email: claims.email, sessionToken }, + invitationId, + password + ) + : await setupInvitationPasswordWithToken({ + token: invitationToken, + email: invitationEmail, + invitationId, + password, + sessionContract: requestedSessionContract(req), + }) if (result.error) { sendInvitationPasswordError(res, result.error) return } - setProfileSessionCookie(req, res, authResult.auth.sessionToken) - res.status(200).json(result.data) + clearProfileSessionCookie(req, res) + res.status(200).json({ ...result.data, reauthenticationRequired: true }) } catch (error) { next(error) } @@ -219,7 +184,7 @@ export function createInvitationsRouter(): Router { return } - const result = await acceptInvitation(token, email) + const result = await acceptInvitation(token, email, requestedSessionContract(req)) if (result.error) { sendAcceptInvitationError(res, result.error) return diff --git a/external-rest-api/src/services/invitationsService.ts b/external-rest-api/src/services/invitationsService.ts index 8572f1af71..9680235e40 100644 --- a/external-rest-api/src/services/invitationsService.ts +++ b/external-rest-api/src/services/invitationsService.ts @@ -1,4 +1,4 @@ -import { controlApiRequest } from '../controlApiClient.js' +import { ControlApiError, controlApiRequest } from '../controlApiClient.js' type AuthContext = { userId: string @@ -35,7 +35,8 @@ export async function listPendingInvitations(email: string, sessionToken: string export async function acceptInvitation( token: string, - email: string + email: string, + sessionContract?: 'v2' ): Promise<{ error?: 'not_found' | 'forbidden' | 'not_pending' | 'expired' | 'invalid' data?: { @@ -63,16 +64,18 @@ export async function acceptInvitation( body: { email, token, + ...(sessionContract ? { sessionContract } : {}), }, }) return { data } } catch (error) { - const message = error instanceof Error ? error.message : '' - if (message.includes('(400)')) return { error: 'invalid' } - if (message.includes('(404)')) return { error: 'not_found' } - if (message.includes('(403)')) return { error: 'forbidden' } - if (message.includes('(410)')) return { error: 'expired' } - return { error: 'not_pending' } + if (!(error instanceof ControlApiError)) throw error + if (error.status === 400) return { error: 'invalid' } + if (error.status === 404) return { error: 'not_found' } + if (error.status === 403) return { error: 'forbidden' } + if (error.status === 410) return { error: 'expired' } + if (error.status === 409) return { error: 'not_pending' } + throw error } } @@ -149,11 +152,53 @@ export async function setupInvitationPassword( ) return { data } } catch (error) { - const message = error instanceof Error ? error.message : '' - if (message.includes('(404)')) return { error: 'not_found' } - if (message.includes('(403)')) return { error: 'forbidden' } - if (message.includes('(409)')) return { error: 'not_accepted' } - if (message.includes('(410)')) return { error: 'expired' } - return { error: 'invalid_password' } + if (!(error instanceof ControlApiError)) throw error + if (error.status === 404) return { error: 'not_found' } + if (error.status === 403) return { error: 'forbidden' } + if (error.status === 409) return { error: 'not_accepted' } + if (error.status === 410) return { error: 'expired' } + if (error.status === 400) return { error: 'invalid_password' } + throw error + } +} + +export async function setupInvitationPasswordWithToken(input: { + token: string + email: string + invitationId: string + password: string + sessionContract?: 'v2' +}): Promise<{ + error?: + | 'not_found' + | 'forbidden' + | 'not_accepted' + | 'not_pending' + | 'expired' + | 'invalid_password' + data?: InvitationPreview & { passwordUpdated: boolean } +}> { + try { + const data = await controlApiRequest< + InvitationPreview & { passwordUpdated: boolean; token?: string } + >('POST', '/external/invitations/password-token', { + body: { + token: input.token, + email: input.email, + invitationId: input.invitationId, + password: input.password, + ...(input.sessionContract ? { sessionContract: input.sessionContract } : {}), + }, + }) + const { token: _discardedSessionRepresentation, ...safe } = data + return { data: safe } + } catch (error) { + if (!(error instanceof ControlApiError)) throw error + if (error.status === 404) return { error: 'not_found' } + if (error.status === 403) return { error: 'forbidden' } + if (error.status === 409) return { error: 'not_accepted' } + if (error.status === 410) return { error: 'expired' } + if (error.status === 400) return { error: 'invalid_password' } + throw error } } diff --git a/external-rest-api/test/routes.invitations.test.ts b/external-rest-api/test/routes.invitations.test.ts index b112bc9275..bebf8d9e57 100644 --- a/external-rest-api/test/routes.invitations.test.ts +++ b/external-rest-api/test/routes.invitations.test.ts @@ -13,6 +13,7 @@ const invitationsServiceMock = vi.hoisted(() => ({ getInvitationByToken: vi.fn(), listPendingInvitations: vi.fn(), setupInvitationPassword: vi.fn(), + setupInvitationPasswordWithToken: vi.fn(), })) vi.mock('../src/authToken.js', () => authTokenMock) @@ -32,24 +33,14 @@ describe('routes/invitations', () => { return app } - it('sets invitation password by minting an invitation session from the link token', async () => { - invitationsServiceMock.acceptInvitation.mockResolvedValue({ - data: { - accepted: true, - userId: 'user-1', - email: 'invitee@example.com', - teamId: null, - teamName: null, - role: 'member', - token: 'invited-session-token', - }, - }) - invitationsServiceMock.setupInvitationPassword.mockResolvedValue({ + it('clears the revoked invitation session after setting the password', async () => { + invitationsServiceMock.setupInvitationPasswordWithToken.mockResolvedValue({ data: { id: 'inv-1', passwordUpdated: true }, }) const res = await request(makeApp()) .post('/invitations/password') + .set('x-evenfire-session-contract', 'v2') .send({ token: 'invitation-link-token', email: 'invitee@example.com', @@ -58,39 +49,29 @@ describe('routes/invitations', () => { }) .expect(200) - expect(res.body).toEqual({ id: 'inv-1', passwordUpdated: true }) - expect(String(res.headers['set-cookie'])).toContain('profile_session=invited-session-token') + expect(res.body).toEqual({ + id: 'inv-1', + passwordUpdated: true, + reauthenticationRequired: true, + }) + expect(String(res.headers['set-cookie'])).toContain('profile_session=;') + expect(String(res.headers['set-cookie'])).not.toContain('invited-session-token') expect(String(res.headers['set-cookie'])).toContain('HttpOnly') expect(authTokenMock.verifyToken).not.toHaveBeenCalled() - expect(invitationsServiceMock.acceptInvitation).toHaveBeenCalledWith( - 'invitation-link-token', - 'invitee@example.com' - ) - expect(invitationsServiceMock.setupInvitationPassword).toHaveBeenCalledWith( - { - userId: 'user-1', - email: 'invitee@example.com', - sessionToken: 'invited-session-token', - }, - 'inv-1', - 'user123!' - ) + expect(invitationsServiceMock.acceptInvitation).not.toHaveBeenCalled() + expect(invitationsServiceMock.setupInvitationPassword).not.toHaveBeenCalled() + expect(invitationsServiceMock.setupInvitationPasswordWithToken).toHaveBeenCalledWith({ + token: 'invitation-link-token', + email: 'invitee@example.com', + invitationId: 'inv-1', + password: 'user123!', + sessionContract: 'v2', + }) }) it('falls back to invitation token flow when an old bearer token is invalid', async () => { authTokenMock.verifyToken.mockReturnValue(null) - invitationsServiceMock.acceptInvitation.mockResolvedValue({ - data: { - accepted: true, - userId: 'user-1', - email: 'invitee@example.com', - teamId: null, - teamName: null, - role: 'member', - token: 'fresh-session-token', - }, - }) - invitationsServiceMock.setupInvitationPassword.mockResolvedValue({ + invitationsServiceMock.setupInvitationPasswordWithToken.mockResolvedValue({ data: { id: 'inv-1', passwordUpdated: true }, }) @@ -106,11 +87,14 @@ describe('routes/invitations', () => { .expect(200) expect(authTokenMock.verifyToken).toHaveBeenCalledWith('stale-token') - expect(invitationsServiceMock.setupInvitationPassword).toHaveBeenCalledWith( - expect.objectContaining({ sessionToken: 'fresh-session-token' }), - 'inv-1', - 'user123!' - ) + expect(invitationsServiceMock.acceptInvitation).not.toHaveBeenCalled() + expect(invitationsServiceMock.setupInvitationPasswordWithToken).toHaveBeenCalledWith({ + token: 'invitation-link-token', + email: 'invitee@example.com', + invitationId: 'inv-1', + password: 'user123!', + sessionContract: undefined, + }) }) it('sets invitation password with a valid bearer token without accepting again', async () => { @@ -186,6 +170,7 @@ describe('routes/invitations', () => { expect(invitationsServiceMock.acceptInvitation).not.toHaveBeenCalled() expect(invitationsServiceMock.setupInvitationPassword).not.toHaveBeenCalled() + expect(invitationsServiceMock.setupInvitationPasswordWithToken).not.toHaveBeenCalled() }) it('rate limits repeated invitation password attempts', async () => { diff --git a/external-rest-api/test/services.invitationsService.test.ts b/external-rest-api/test/services.invitationsService.test.ts new file mode 100644 index 0000000000..722137c47a --- /dev/null +++ b/external-rest-api/test/services.invitationsService.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ControlApiError } from '../src/controlApiClient.js' +import { + acceptInvitation, + setupInvitationPassword, + setupInvitationPasswordWithToken, +} from '../src/services/invitationsService.js' + +const client = vi.hoisted(() => ({ request: vi.fn() })) +vi.mock('../src/controlApiClient.js', async importOriginal => ({ + ...(await importOriginal()), + controlApiRequest: client.request, +})) + +describe('invitation service error and token boundaries', () => { + beforeEach(() => client.request.mockReset()) + + it('never converts Control API unavailability into not-pending or invalid-password', async () => { + const unavailable = new ControlApiError('unavailable', 503, { + error: { code: 'authority_unavailable' }, + }) + + for (const operation of [ + () => acceptInvitation('flow-token', 'user@example.com', 'v2'), + () => + setupInvitationPassword( + { userId: 'user-1', email: 'user@example.com', sessionToken: 'session-token' }, + 'inv-1', + 'valid-password' + ), + () => + setupInvitationPasswordWithToken({ + token: 'flow-token', + email: 'user@example.com', + invitationId: 'inv-1', + password: 'valid-password', + sessionContract: 'v2', + }), + ]) { + client.request.mockRejectedValueOnce(unavailable) + await expect(operation()).rejects.toBe(unavailable) + } + }) + + it('discards any transitional session representation from atomic password setup', async () => { + client.request.mockResolvedValueOnce({ + id: 'inv-1', + email: 'user@example.com', + passwordUpdated: true, + token: 'must-never-reach-browser-route', + }) + + const result = await setupInvitationPasswordWithToken({ + token: 'flow-token', + email: 'user@example.com', + invitationId: 'inv-1', + password: 'valid-password', + sessionContract: 'v2', + }) + + expect(result.data).toMatchObject({ id: 'inv-1', passwordUpdated: true }) + expect(result.data).not.toHaveProperty('token') + }) +}) From 5dceede57cc0f2b76918b1239212f42c542f04d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 10 Aug 2026 22:58:50 -0600 Subject: [PATCH 011/528] fix(external): preserve compatibility and public security boundaries Keep V1 switching bound to a live replacement token, apply one session-policy boundary to legacy callers, and preserve live membership in RPC, workflow, filesystem, team, and directory operations. Expose bounded public errors across Control API and External REST, add browser mutation origin checks, and retain structured GFS guidance through the narrow Desktop parser adapter. Class-check: legacy external-user trust and error adapters -> 37 sites -> revalidate live authority, preserve unavailable outcomes, sanitize public envelopes, and keep Desktop error details bounded. Repro-test: routes.externalDirectory.test.ts, services.legacyAuthorityErrors.test.ts, routes.teamSwitchCompatibility.test.ts, and publicErrorContract.test.ts fail at 24503ca7486e2b168a6e7efcc58d86b72d8afb4b. --- control-api/src/http/publicApiError.ts | 59 ++++++ .../src/middleware/rateLimitMiddleware.ts | 7 +- .../src/routes/external/sharedFilesystems.ts | 34 +++- control-api/src/routes/external/teams.ts | 57 ++++-- control-api/src/routes/external/users.ts | 71 +++++-- control-api/src/services/directory/index.ts | 1 + control-api/src/services/directory/teams.ts | 26 +++ .../workflows/workflowRunReadService.ts | 137 ++++++++++--- .../workflows/workflowTriggerService.ts | 33 ++- .../test/routes.externalDirectory.test.ts | 72 +++++++ .../routes.externalSharedFilesystems.test.ts | 44 ++++ .../test/routes.externalWorkflows.test.ts | 5 +- ...ervices.workflowRunArtifactService.test.ts | 2 +- .../test/workflowRunReadAuthorization.test.ts | 57 ++++++ desktop-app/src/gfs/uriHandler.ts | 39 +++- desktop-app/test/gfsUriHandler.test.ts | 50 +++++ desktop-app/test/httpClient.test.ts | 20 ++ external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- .../__tests__/browserMutationGuard.test.ts | 46 +++++ .../src/__tests__/publicErrorContract.test.ts | 149 ++++++++++++++ external-rest-api/src/app.ts | 85 ++++++-- external-rest-api/src/authToken.ts | 68 +++++++ external-rest-api/src/http/publicApiError.ts | 191 ++++++++++++++++++ .../src/middleware/browserMutationGuard.ts | 64 ++++++ external-rest-api/src/releaseManifest.ts | 2 +- external-rest-api/src/routes/auth.ts | 66 +++++- .../src/routes/contextSharedFilesystems.ts | 14 +- external-rest-api/src/routes/gfs.ts | 45 ++--- external-rest-api/src/routes/me.ts | 5 +- external-rest-api/src/routes/notifications.ts | 10 +- external-rest-api/src/routes/oauthGrants.ts | 9 +- external-rest-api/src/routes/team.ts | 6 +- .../src/routes/userApprovalDecisions.ts | 9 +- .../src/routes/workflowApprovalMediums.ts | 9 +- external-rest-api/src/routes/workflows.ts | 9 +- external-rest-api/src/services/authService.ts | 22 +- .../src/services/invitationsService.ts | 9 +- external-rest-api/src/services/meService.ts | 14 +- external-rest-api/src/services/teamService.ts | 38 +++- external-rest-api/src/types.ts | 5 + .../routes.teamSwitchCompatibility.test.ts | 95 +++++++++ .../services.legacyAuthorityErrors.test.ts | 115 +++++++++++ 43 files changed, 1603 insertions(+), 202 deletions(-) create mode 100644 control-api/src/http/publicApiError.ts create mode 100644 control-api/test/routes.externalDirectory.test.ts create mode 100644 control-api/test/workflowRunReadAuthorization.test.ts create mode 100644 external-rest-api/src/__tests__/browserMutationGuard.test.ts create mode 100644 external-rest-api/src/__tests__/publicErrorContract.test.ts create mode 100644 external-rest-api/src/http/publicApiError.ts create mode 100644 external-rest-api/src/middleware/browserMutationGuard.ts create mode 100644 external-rest-api/test/routes.teamSwitchCompatibility.test.ts create mode 100644 external-rest-api/test/services.legacyAuthorityErrors.test.ts diff --git a/control-api/src/http/publicApiError.ts b/control-api/src/http/publicApiError.ts new file mode 100644 index 0000000000..8b477464ea --- /dev/null +++ b/control-api/src/http/publicApiError.ts @@ -0,0 +1,59 @@ +import type { Request, Response } from 'express' + +export type PublicApiErrorCode = + | 'invalid_session' + | 'session_expired' + | 'session_revoked' + | 'forbidden' + | 'not_found' + | 'conflict' + | 'access_path_required' + | 'access_path_stale' + | 'authority_unavailable' + | 'invalid_request' + | 'rate_limited' + | 'upgrade_required' + +function fallbackCorrelationId(): string { + return Math.random().toString(36).slice(2, 12) +} + +export function publicApiErrorBody( + correlationId: string, + code: PublicApiErrorCode, + message: string, + retryable: boolean, + details?: Record +) { + return { + error: { + code, + message, + correlationId, + retryable, + ...(details && Object.keys(details).length > 0 ? { details } : {}), + }, + } +} + +export function sendPublicApiError( + req: Request, + res: Response, + status: number, + code: PublicApiErrorCode, + message: string, + retryable = false, + details?: Record +): void { + res + .status(status) + .json( + publicApiErrorBody( + req.correlationId ?? fallbackCorrelationId(), + code, + message, + retryable, + details + ) + ) +} diff --git a/control-api/src/middleware/rateLimitMiddleware.ts b/control-api/src/middleware/rateLimitMiddleware.ts index fca6bc1c33..eb069b0373 100644 --- a/control-api/src/middleware/rateLimitMiddleware.ts +++ b/control-api/src/middleware/rateLimitMiddleware.ts @@ -24,6 +24,7 @@ export type RateLimitEnforcerOptions = { bucketType: string maxPerMinute: number onBackendUnavailable: RateLimitBackendUnavailableMode + onLimited?: (req: Request, res: Response, retryAfterSeconds: number) => void } /** @@ -167,7 +168,11 @@ export function createRateLimitEnforcer(opts: RateLimitEnforcerOptions): RateLim 'rate limit exceeded' ) } - res.status(429).json({ error: 'Too Many Requests', retryAfterSeconds: retryAfterSec }) + if (opts.onLimited) { + opts.onLimited(req, res, retryAfterSec) + } else { + res.status(429).json({ error: 'Too Many Requests', retryAfterSeconds: retryAfterSec }) + } } return async function enforce(req: Request, res: Response, rawKey: string): Promise { diff --git a/control-api/src/routes/external/sharedFilesystems.ts b/control-api/src/routes/external/sharedFilesystems.ts index 4cc14a25b4..b30b768783 100644 --- a/control-api/src/routes/external/sharedFilesystems.ts +++ b/control-api/src/routes/external/sharedFilesystems.ts @@ -7,6 +7,7 @@ import { type ExternalAuthedRequest, requireValidExternalSessionToken, } from '../../middleware/externalSessionAuth.js' +import { getLiveTeamMembership } from '../../services/access/liveTeamAuthorization.js' import { listActiveContextIds, partitionAccessValues, @@ -58,6 +59,14 @@ class ContextAccessReconciliationError extends Error { } } +class ContextAuthorizationUnavailableError extends Error { + constructor(cause: unknown) { + super('Context authorization is unavailable') + this.name = 'ContextAuthorizationUnavailableError' + this.cause = cause + } +} + async function loadAccessibleContextIds( gateway: K8sGateway, userId: string, @@ -77,10 +86,19 @@ async function loadAccessibleContextIds( accessible.add(id) } if (teamId && teamId.trim()) { - const teamCtx = await getTeamContexts(teamId.trim()) - const teamContextIds = partitionAccessValues(teamCtx.contextIds, activeContextIds).active - for (const id of teamContextIds) { - accessible.add(id) + const compatibilityTeamId = teamId.trim() + let membership + try { + membership = await getLiveTeamMembership(userId, compatibilityTeamId) + } catch (err) { + throw new ContextAuthorizationUnavailableError(err) + } + if (membership) { + const teamCtx = await getTeamContexts(compatibilityTeamId) + const teamContextIds = partitionAccessValues(teamCtx.contextIds, activeContextIds).active + for (const id of teamContextIds) { + accessible.add(id) + } } } return accessible @@ -115,8 +133,12 @@ async function resolveAccessibleContextIdsForRequest( try { return await loadAccessibleContextIdsForRequest(gateway, req, res) } catch (err) { - if (!(err instanceof ContextAccessReconciliationError)) throw err - res.status(503).json({ error: 'context_reconciliation_unavailable' }) + if (err instanceof ContextAccessReconciliationError) { + res.status(503).json({ error: 'context_reconciliation_unavailable' }) + return null + } + if (!(err instanceof ContextAuthorizationUnavailableError)) throw err + res.status(503).json({ error: 'authority_unavailable' }) return null } } diff --git a/control-api/src/routes/external/teams.ts b/control-api/src/routes/external/teams.ts index ef781e39a5..20148758cc 100644 --- a/control-api/src/routes/external/teams.ts +++ b/control-api/src/routes/external/teams.ts @@ -18,15 +18,15 @@ import { import { createManagedInvitationForUser, createTeamForUser, + deleteManagedMemberForUser, externalManagedInvitationResponse, findMemberRole, getCurrentTeam, getTeamAgents, getTeamContexts, listMembers, - renameTeam, - softDeleteMember, - updateMemberRole, + renameTeamForUser, + updateManagedMemberRoleForUser, } from '../../services/directory/index.js' import { normalizeTeamRoleInput } from '../../services/directory/types.js' @@ -85,9 +85,16 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { try { const name = String(req.body?.name || '').trim() if (!name) return res.status(400).json({ error: 'name is required' }) - const updated = await renameTeam(req.params.teamId, name) - if (!updated) return res.status(404).json({ error: 'not_found' }) - return res.status(200).json(updated) + const result = await renameTeamForUser( + (req as ExternalAuthedRequest).externalAuth!.userId, + req.params.teamId, + name + ) + if ('error' in result) { + if (result.error === 'forbidden') return res.status(403).json({ error: 'forbidden' }) + return res.status(404).json({ error: 'not_found' }) + } + return res.status(200).json(result.team) } catch (error) { return next(error) } @@ -196,16 +203,18 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { try { const role = normalizeTeamRoleInput(req.body?.role) if (!role) return res.status(400).json({ error: 'invalid role' }) - return res - .status(200) - .json( - await updateMemberRole( - req.params.teamId, - req.params.userId, - role, - (req as ExternalAuthedRequest).externalAuth!.userId - ) - ) + const result = await updateManagedMemberRoleForUser( + (req as ExternalAuthedRequest).externalAuth!.userId, + req.params.userId, + req.params.teamId, + role + ) + if ('error' in result) { + if (result.error === 'forbidden') return res.status(403).json({ error: 'forbidden' }) + if (result.error === 'not_found') return res.status(404).json({ error: 'not_found' }) + return res.status(400).json({ error: result.error }) + } + return res.status(200).json(result.membership) } catch (error) { return next(error) } @@ -252,13 +261,19 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { requireExternalRole(['admin']), async (req, res, next) => { try { - const deleted = await softDeleteMember( - req.params.teamId, + const result = await deleteManagedMemberForUser( + (req as ExternalAuthedRequest).externalAuth!.userId, req.params.userId, - (req as ExternalAuthedRequest).externalAuth!.userId + req.params.teamId ) - if (!deleted) return res.status(404).json({ error: 'not_found' }) - return res.status(200).json(deleted) + if ('error' in result) { + if (result.error === 'forbidden') return res.status(403).json({ error: 'forbidden' }) + if (result.error === 'invalid_target') { + return res.status(400).json({ error: 'invalid_target' }) + } + return res.status(404).json({ error: 'not_found' }) + } + return res.status(200).json(result.deleted) } catch (error) { return next(error) } diff --git a/control-api/src/routes/external/users.ts b/control-api/src/routes/external/users.ts index 7f07f440b2..339616a473 100644 --- a/control-api/src/routes/external/users.ts +++ b/control-api/src/routes/external/users.ts @@ -40,6 +40,12 @@ type TeamDirectoryMember = { status: string } +type TeamDirectoryPartialError = { + teamId?: string + source: 'operational_resources' | 'members' | 'contexts' | 'agents' + code: 'unavailable' +} + function projectTeamMembers(values: unknown): TeamDirectoryMember[] { if (!Array.isArray(values)) return [] return values.map(value => { @@ -118,6 +124,7 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { const truncated = allTeams.length > teams.length let activeContextIds: Set | null = null let activeAgentNames: Set | null = null + const initialPartialErrors: TeamDirectoryPartialError[] = [] try { const [contextIds, agentNames] = await Promise.all([ listActiveContextIds(gateway), @@ -127,36 +134,60 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { activeAgentNames = new Set(agentNames) } catch (err) { console.warn('[external/users] access reconciliation failed for team directory:', err) + initialPartialErrors.push({ source: 'operational_resources', code: 'unavailable' }) } - const items = await mapWithConcurrencyLimit(teams, 4, async team => { - try { - const [members, contexts, agents] = await Promise.all([ - listMembers(team.id), - getTeamContexts(team.id), - getTeamAgents(team.id), - ]) - return { - team, - members: projectTeamMembers(members), - contextIds: filterAccessValues(contexts?.contextIds, activeContextIds), - agentNames: filterAccessValues(agents?.agentNames, activeAgentNames), + const mapped = await mapWithConcurrencyLimit(teams, 4, async team => { + const canReadMembers = team.role === 'admin' || team.role === 'inviter' + const [members, contexts, agents] = await Promise.allSettled([ + canReadMembers ? listMembers(team.id) : Promise.resolve(null), + getTeamContexts(team.id), + getTeamAgents(team.id), + ]) + const partialErrors: TeamDirectoryPartialError[] = [] + for (const [source, result] of [ + ['members', members], + ['contexts', contexts], + ['agents', agents], + ] as const) { + if (result.status === 'rejected') { + console.warn( + `[external/users] team-directory ${source} fetch failed for team ${team.id}:`, + result.reason + ) + partialErrors.push({ teamId: team.id, source, code: 'unavailable' }) } - } catch (err) { - console.warn(`[external/users] team-directory fetch failed for team ${team.id}:`, err) - return { + } + return { + item: { team, - members: [], - contextIds: [], - agentNames: [], - } + members: + canReadMembers && members.status === 'fulfilled' + ? projectTeamMembers(members.value) + : [], + contextIds: + contexts.status === 'fulfilled' + ? filterAccessValues(contexts.value?.contextIds, activeContextIds) + : [], + agentNames: + agents.status === 'fulfilled' + ? filterAccessValues(agents.value?.agentNames, activeAgentNames) + : [], + }, + partialErrors, } }) + const partialErrors = [ + ...initialPartialErrors, + ...mapped.flatMap(result => result.partialErrors), + ] return res.status(200).json({ currentTeamId: listed.currentTeamId || currentTeamId, truncated, - items, + complete: partialErrors.length === 0 && !truncated, + partialErrors, + items: mapped.map(result => result.item), }) } catch (error) { return next(error) diff --git a/control-api/src/services/directory/index.ts b/control-api/src/services/directory/index.ts index 444db40347..cd361c58d5 100644 --- a/control-api/src/services/directory/index.ts +++ b/control-api/src/services/directory/index.ts @@ -28,6 +28,7 @@ export { listAllTeams, listTeams, renameTeam, + renameTeamForUser, } from './teams.js' export { diff --git a/control-api/src/services/directory/teams.ts b/control-api/src/services/directory/teams.ts index 9996698f77..227fd81f99 100644 --- a/control-api/src/services/directory/teams.ts +++ b/control-api/src/services/directory/teams.ts @@ -101,6 +101,32 @@ export async function renameTeam(teamId: string, name: string) { return (updated.rows[0] as { id: string; name: string } | undefined) || null } +export async function renameTeamForUser(userId: string, teamId: string, name: string) { + return withTransaction(async db => { + const membership = await db.query( + `SELECT role + FROM team_members + WHERE team_id = $1 + AND user_id = $2 + AND status = 'active' + FOR UPDATE`, + [teamId, userId] + ) + if ((membership.rows[0] as { role?: string } | undefined)?.role !== 'admin') { + return { error: 'forbidden' as const } + } + const updated = await db.query( + `UPDATE teams + SET name = $2 + WHERE id = $1 + RETURNING id, name`, + [teamId, name] + ) + const team = (updated.rows[0] as { id: string; name: string } | undefined) || null + return team ? { team } : { error: 'not_found' as const } + }) +} + /** * Hard-delete an empty team and all dependent rows (CASCADE from schema: invitations, * team_contexts, team_agents). Active memberships block deletion. diff --git a/control-api/src/services/workflows/workflowRunReadService.ts b/control-api/src/services/workflows/workflowRunReadService.ts index ab66925e7b..bc0f5d904b 100644 --- a/control-api/src/services/workflows/workflowRunReadService.ts +++ b/control-api/src/services/workflows/workflowRunReadService.ts @@ -35,13 +35,44 @@ export type ProviderScopedWorkflowRunRow = WorkflowRunRow & { function userRunScope(caller: WorkflowRouteCaller): UserRunScope | null { if (caller.kind !== 'user-session') return null const ids = new Set([caller.claims.userId, getWorkflowPrincipalId(caller)]) - return { userId: caller.claims.userId, principalIds: [...ids], teamId: caller.claims.teamId } + return { + userId: caller.claims.userId, + principalIds: [...ids], + teamId: caller.claims.teamId?.trim() || null, + } +} + +async function hasLiveWorkflowTeamGrant( + scope: UserRunScope, + recipeNamespace: string, + recipeName: string, + db: Pick = pool +): Promise { + if (!scope.teamId) return false + const result = await db.query( + `SELECT 1 + FROM team_members tm + JOIN team_workflow_triggers twt + ON twt.team_id = tm.team_id + AND twt.recipe_namespace = $3 + AND twt.recipe_name = $4 + WHERE tm.user_id = $1 + AND tm.team_id = $2 + AND tm.status = 'active' + LIMIT 1`, + [scope.userId, scope.teamId, recipeNamespace, recipeName] + ) + return (result.rowCount ?? 0) > 0 } function isTeamScopedRun(row: Pick): boolean { return row.actor_type === 'scheduled' || row.actor_type === 'autonomous' } +function hasTeamAttribution(row: Pick): boolean { + return Boolean(row.team_id || row.usage_team_id) +} + function approvalTargetMatchesUserScope( row: ApprovalScopedWorkflowRunRow, scope: UserRunScope @@ -57,16 +88,16 @@ function approvalTargetMatchesUserScope( } function runMatchesUserScope(row: ApprovalScopedWorkflowRunRow, scope: UserRunScope): boolean { + const matchesTeam = + Boolean(scope.teamId) && (row.team_id === scope.teamId || row.usage_team_id === scope.teamId) if (row.actor_type === 'user') { - if (row.actor_id && scope.principalIds.includes(row.actor_id)) return true - return !row.actor_id && (row.team_id === scope.teamId || row.usage_team_id === scope.teamId) + const matchesActor = + Boolean(row.actor_id) && scope.principalIds.includes(row.actor_id as string) + if (matchesActor) return !hasTeamAttribution(row) || matchesTeam + return !row.actor_id && matchesTeam } if (isTeamScopedRun(row)) { - return ( - row.team_id === scope.teamId || - row.usage_team_id === scope.teamId || - approvalTargetMatchesUserScope(row, scope) - ) + return matchesTeam || approvalTargetMatchesUserScope(row, scope) } return false } @@ -76,24 +107,31 @@ function auditRunMatchesUserScope(row: Record, scope: UserRunSc const userId = typeof row.triggerer_user_id === 'string' ? row.triggerer_user_id : '' const teamId = typeof row.triggerer_team_id === 'string' ? row.triggerer_team_id : '' const usageTeamId = typeof row.usage_team_id === 'string' ? row.usage_team_id : '' + const matchesTeam = + Boolean(scope.teamId) && (teamId === scope.teamId || usageTeamId === scope.teamId) if (actorType === 'user') { - if (userId && scope.principalIds.includes(userId)) return true - return !userId && (teamId === scope.teamId || usageTeamId === scope.teamId) + const matchesActor = Boolean(userId) && scope.principalIds.includes(userId) + if (matchesActor) return (!teamId && !usageTeamId) || matchesTeam + return !userId && matchesTeam } if (actorType === 'scheduled' || actorType === 'autonomous') { - return teamId === scope.teamId || usageTeamId === scope.teamId + return matchesTeam } return false } -export function canCallerReadWorkflowRun( +export async function canCallerReadWorkflowRun( caller: WorkflowRouteCaller, - row: WorkflowRunRow -): boolean { + row: WorkflowRunRow, + db: Pick = pool +): Promise { const scope = userRunScope(caller) if (!scope) return true - return runMatchesUserScope(row, scope) + const directScope = { ...scope, teamId: null } + if (runMatchesUserScope(row, directScope)) return true + if (!runMatchesUserScope(row, scope)) return false + return hasLiveWorkflowTeamGrant(scope, row.recipe_namespace, row.recipe_name, db) } export async function canCallerReadWorkflowRunWithApprovalTarget( @@ -142,7 +180,14 @@ export async function canCallerReadWorkflowRunWithApprovalTarget( const scope = userRunScope(caller) if (!scope) return true - if (runMatchesUserScope(row, scope)) return true + const directScope = { ...scope, teamId: null } + if (runMatchesUserScope(row, directScope)) return true + if ( + runMatchesUserScope(row, scope) && + (await hasLiveWorkflowTeamGrant(scope, row.recipe_namespace, row.recipe_name)) + ) { + return true + } if (!row.approval_request_id) return false const result = await pool.query( @@ -152,16 +197,28 @@ export async function canCallerReadWorkflowRunWithApprovalTarget( ON tm.team_id = war.target_team_id AND tm.user_id::text = $4 AND tm.status = 'active' + LEFT JOIN team_workflow_triggers twt + ON twt.team_id = tm.team_id + AND twt.recipe_namespace = $5 + AND twt.recipe_name = $6 WHERE war.id = $1 AND ( war.target_user_id::text = ANY($2::text[]) OR ( war.target_team_id::text = $3 AND tm.user_id IS NOT NULL + AND twt.team_id IS NOT NULL ) ) LIMIT 1`, - [row.approval_request_id, scope.principalIds, scope.teamId, scope.userId] + [ + row.approval_request_id, + scope.principalIds, + scope.teamId, + scope.userId, + row.recipe_namespace, + row.recipe_name, + ] ) return (result.rowCount ?? 0) > 0 } @@ -317,12 +374,28 @@ export async function listAuditRunsByRecipe( scope ? `AND ( (triggerer_actor_type = 'user' AND ( - triggerer_user_id::text = ANY($4::text[]) OR - (triggerer_user_id IS NULL AND (triggerer_team_id::text = $5 OR usage_team_id = $5)) + (triggerer_user_id::text = ANY($4::text[]) + AND triggerer_team_id IS NULL AND usage_team_id IS NULL) OR + ((triggerer_user_id::text = ANY($4::text[]) OR triggerer_user_id IS NULL) AND + (triggerer_team_id::text = $5 OR usage_team_id = $5) AND + EXISTS ( + SELECT 1 FROM team_members tm + JOIN team_workflow_triggers twt ON twt.team_id = tm.team_id + AND twt.recipe_namespace = $1 AND twt.recipe_name = $2 + WHERE tm.user_id::text = $6 AND tm.team_id::text = $5 + AND tm.status = 'active' + )) )) OR (triggerer_actor_type IN ('scheduled', 'autonomous') AND ( - triggerer_team_id::text = $5 OR usage_team_id = $5 + (triggerer_team_id::text = $5 OR usage_team_id = $5) AND + EXISTS ( + SELECT 1 FROM team_members tm + JOIN team_workflow_triggers twt ON twt.team_id = tm.team_id + AND twt.recipe_namespace = $1 AND twt.recipe_name = $2 + WHERE tm.user_id::text = $6 AND tm.team_id::text = $5 + AND tm.status = 'active' + ) )) )` : '' @@ -330,7 +403,7 @@ export async function listAuditRunsByRecipe( ORDER BY triggered_at DESC LIMIT $3`, scope - ? [recipeNamespace, recipeName, limit, scope.principalIds, scope.teamId] + ? [recipeNamespace, recipeName, limit, scope.principalIds, scope.teamId, scope.userId] : [recipeNamespace, recipeName, limit] ) @@ -493,23 +566,35 @@ async function listRunsByRecipeForUserScope( FROM workflow_runs wr LEFT JOIN workflow_approval_requests war ON war.id = wr.approval_request_id - LEFT JOIN team_members tm + LEFT JOIN team_members tm ON tm.team_id = war.target_team_id AND tm.user_id::text = $6 AND tm.status = 'active' + LEFT JOIN team_members scope_tm + ON scope_tm.team_id::text = $5 + AND scope_tm.user_id::text = $6 + AND scope_tm.status = 'active' + LEFT JOIN team_workflow_triggers scope_twt + ON scope_twt.team_id = scope_tm.team_id + AND scope_twt.recipe_namespace = $1 + AND scope_twt.recipe_name = $2 WHERE wr.recipe_namespace = $1 AND wr.recipe_name = $2 AND ( (wr.actor_type = 'user' AND ( - wr.actor_id::text = ANY($4::text[]) OR - (wr.actor_id IS NULL AND (wr.team_id::text = $5 OR wr.usage_team_id = $5)) + (wr.actor_id::text = ANY($4::text[]) + AND wr.team_id IS NULL AND wr.usage_team_id IS NULL) OR + ((wr.actor_id::text = ANY($4::text[]) OR wr.actor_id IS NULL) + AND (wr.team_id::text = $5 OR wr.usage_team_id = $5) + AND scope_twt.team_id IS NOT NULL) )) OR (wr.actor_type IN ('scheduled', 'autonomous') AND ( - wr.team_id::text = $5 OR - wr.usage_team_id = $5 OR + (wr.team_id::text = $5 AND scope_twt.team_id IS NOT NULL) OR + (wr.usage_team_id = $5 AND scope_twt.team_id IS NOT NULL) OR war.target_user_id::text = ANY($4::text[]) OR ( war.target_team_id::text = $5 AND tm.user_id IS NOT NULL + AND scope_twt.team_id IS NOT NULL ) )) ) diff --git a/control-api/src/services/workflows/workflowTriggerService.ts b/control-api/src/services/workflows/workflowTriggerService.ts index 6b364beef6..247a3c388f 100644 --- a/control-api/src/services/workflows/workflowTriggerService.ts +++ b/control-api/src/services/workflows/workflowTriggerService.ts @@ -81,21 +81,36 @@ function approvalConsumeResponse(err: ApprovalConsumeError): Record + caller: Extract, + userSessionGrant: WorkflowTriggerGrantResult ): string { - return `external-rest-api:user:${caller.claims.userId}:team:${caller.claims.teamId}` + return userSessionGrant.granted && userSessionGrant.source === 'team' + ? `external-rest-api:user:${caller.claims.userId}:team:${userSessionGrant.teamId}` + : `external-rest-api:user:${caller.claims.userId}:direct` } type WorkflowTriggerResult = @@ -280,8 +295,8 @@ export async function triggerWorkflow(params: { } const principalId = getWorkflowPrincipalId(caller) - const teamId = getWorkflowTeamIdForCaller(caller) - const usageTeamId = getWorkflowUsageTeamIdForCaller(caller) + const teamId = selectedWorkflowTeamId(caller, userSessionGrant) + const usageTeamId = getWorkflowUsageTeamIdForCaller(caller, userSessionGrant) const dbActorType: WorkflowRunActorType = caller.kind === 'mcp-host-control' ? 'autonomous' @@ -320,7 +335,7 @@ export async function triggerWorkflow(params: { }) } - const callerKey = getExternalWorkflowTriggerCallerKey(caller) + const callerKey = getExternalWorkflowTriggerCallerKey(caller, userSessionGrant) const approval = await createWorkflowTriggerApprovalRequest({ recipeNamespace: ns, recipeName: name, diff --git a/control-api/test/routes.externalDirectory.test.ts b/control-api/test/routes.externalDirectory.test.ts new file mode 100644 index 0000000000..967c7f8f0a --- /dev/null +++ b/control-api/test/routes.externalDirectory.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import request from 'supertest' +import { createExternalDirectoryRouter } from '../src/routes/external/directory.js' + +const mocks = vi.hoisted(() => ({ + verify: vi.fn(), + membership: vi.fn(), + searchDirectory: vi.fn(), +})) + +vi.mock('../src/utils/auth/externalSessionAuthToken.js', () => ({ + verifyExternalSessionToken: mocks.verify, +})) +vi.mock('../src/services/access/liveTeamAuthorization.js', () => ({ + getLiveTeamMembership: mocks.membership, +})) +vi.mock('../src/services/auth/userSessionService.js', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + validateLegacyUserSession: vi.fn(async () => ({ status: 'valid', identity: {} })), + } +}) +vi.mock('../src/services/directory/index.js', async importOriginal => { + const actual = await importOriginal() + return { ...actual, searchDirectory: mocks.searchDirectory } +}) + +function app() { + const value = express() + value.use(createExternalDirectoryRouter()) + return value +} + +describe('external directory authorization', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.verify.mockReturnValue({ + userId: 'user-1', + email: 'user@example.com', + teamId: 'team-1', + role: 'member', + exp: 2_000_000_000, + sessionContract: 'v1', + }) + mocks.searchDirectory.mockResolvedValue({ items: [], nextCursor: null }) + }) + + it('denies an ordinary member without executing the PII search', async () => { + mocks.membership.mockResolvedValue({ teamId: 'team-1', role: 'member' }) + + const response = await request(app()) + .get('/external/directory/search?teamId=team-1&q=person') + .set('x-user-session-token', 'session') + + expect(response.status).toBe(403) + expect(response.body.error.code).toBe('forbidden') + expect(mocks.searchDirectory).not.toHaveBeenCalled() + }) + + it.each(['admin', 'inviter'] as const)('allows a current %s to search', async role => { + mocks.membership.mockResolvedValue({ teamId: 'team-1', role }) + + const response = await request(app()) + .get('/external/directory/search?teamId=team-1&q=person') + .set('x-user-session-token', 'session') + + expect(response.status).toBe(200) + expect(mocks.searchDirectory).toHaveBeenCalledWith('team-1', 'person', undefined) + }) +}) diff --git a/control-api/test/routes.externalSharedFilesystems.test.ts b/control-api/test/routes.externalSharedFilesystems.test.ts index 36c51842ef..9448d16d75 100644 --- a/control-api/test/routes.externalSharedFilesystems.test.ts +++ b/control-api/test/routes.externalSharedFilesystems.test.ts @@ -7,6 +7,7 @@ import { K8sNotFoundError } from '../src/services/resourceService.js' const mockVerifyExternalSessionToken = vi.fn() const mockGetUserContexts = vi.fn() const mockGetTeamContexts = vi.fn() +const mockGetLiveTeamMembership = vi.fn() const mockSignWfcBrowsingCredential = vi.hoisted(() => vi.fn()) const mockPoolQuery = vi.hoisted(() => vi.fn()) @@ -17,12 +18,23 @@ vi.mock('../src/db.js', () => ({ vi.mock('../src/utils/auth/externalSessionAuthToken.js', () => ({ verifyExternalSessionToken: (...args: unknown[]) => mockVerifyExternalSessionToken(...args), })) +vi.mock('../src/services/auth/userSessionService.js', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + validateLegacyUserSession: vi.fn(async () => ({ status: 'valid', identity: {} })), + } +}) vi.mock('../src/services/directory/index.js', () => ({ getUserContexts: (...args: unknown[]) => mockGetUserContexts(...args), getTeamContexts: (...args: unknown[]) => mockGetTeamContexts(...args), })) +vi.mock('../src/services/access/liveTeamAuthorization.js', () => ({ + getLiveTeamMembership: (...args: unknown[]) => mockGetLiveTeamMembership(...args), +})) + vi.mock('../src/utils/auth/wfcBrowsingToken.js', () => ({ WFC_BROWSING_READ_SCOPE: 'files:read', signWfcBrowsingToken: (...args: unknown[]) => mockSignWfcBrowsingCredential(...args), @@ -75,6 +87,8 @@ beforeEach(() => { mockVerifyExternalSessionToken.mockReset() mockGetUserContexts.mockReset() mockGetTeamContexts.mockReset() + mockGetLiveTeamMembership.mockReset() + mockGetLiveTeamMembership.mockResolvedValue({ teamId: 'team-1', role: 'member' }) mockSignWfcBrowsingCredential.mockReset() mockPoolQuery.mockReset() mockPoolQuery.mockResolvedValue({ rows: [{ lifecycle_state: 'active', lifecycle_version: 1 }] }) @@ -153,6 +167,36 @@ describe('GET /external/contexts/:contextId/shared-filesystems', () => { .set('x-user-session-token', 'dummy') expect(res.status).toBe(200) expect(res.body.items).toEqual([]) + expect(mockGetLiveTeamMembership).toHaveBeenCalledWith('user-1', 'team-1') + }) + + it('does not trust a stale v1 team claim after membership revocation', async () => { + validAuth() + mockGetUserContexts.mockResolvedValue({ userId: 'user-1', contextIds: [] }) + mockGetLiveTeamMembership.mockResolvedValue(null) + const getResource = vi.fn() + const app = buildApp({ getResource }) + + await request(app) + .get('/external/contexts/ctx-team/shared-filesystems') + .set('x-user-session-token', 'stale-team-token') + .expect(403) + + expect(mockGetTeamContexts).not.toHaveBeenCalled() + expect(getResource).not.toHaveBeenCalled() + }) + + it('returns unavailable when live membership cannot be decided', async () => { + validAuth() + mockGetUserContexts.mockResolvedValue({ userId: 'user-1', contextIds: [] }) + mockGetLiveTeamMembership.mockRejectedValue(new Error('directory unavailable')) + const app = buildApp({ getResource: vi.fn() }) + + await request(app) + .get('/external/contexts/ctx-team/shared-filesystems') + .set('x-user-session-token', 'stale-team-token') + .expect(503) + .expect({ error: 'authority_unavailable' }) }) it('returns 403 when caller has no access to the context', async () => { diff --git a/control-api/test/routes.externalWorkflows.test.ts b/control-api/test/routes.externalWorkflows.test.ts index 297836a04c..bfaf3e1297 100644 --- a/control-api/test/routes.externalWorkflows.test.ts +++ b/control-api/test/routes.externalWorkflows.test.ts @@ -469,7 +469,7 @@ describe('routes/external/workflows', () => { } as never, RECIPE_NS ) - const callerKey = `external-rest-api:user:${USER_SESSION_CLAIMS.userId}:team:${USER_SESSION_CLAIMS.teamId}` + const callerKey = `external-rest-api:user:${USER_SESSION_CLAIMS.userId}:direct` const idempotencyKey = 'external-key-stale-approval' const payload = { message: `Approve ${RECIPE_NS}/test-recipe workflow trigger.`, @@ -1059,7 +1059,8 @@ describe('routes/external/workflows', () => { 'approved-target-run-id', ]) expect(String(mockPoolQuery.mock.calls[1][0])).toContain('workflow_approval_requests') - expect(String(mockPoolQuery.mock.calls[1][0])).not.toContain('team_workflow_triggers') + expect(String(mockPoolQuery.mock.calls[1][0])).toContain('team_workflow_triggers') + expect(String(mockPoolQuery.mock.calls[1][0])).toContain("tm.status = 'active'") }) it('returns 403 when user-session caller is not granted to the recipe', async () => { diff --git a/control-api/test/services.workflowRunArtifactService.test.ts b/control-api/test/services.workflowRunArtifactService.test.ts index a4df75dee0..c5f6e98046 100644 --- a/control-api/test/services.workflowRunArtifactService.test.ts +++ b/control-api/test/services.workflowRunArtifactService.test.ts @@ -207,7 +207,7 @@ describe('workflowRunArtifactService', () => { expect(artifacts.map(artifact => artifact.name)).toEqual(['custom-sdk-result.json']) expect(String(mockPoolQuery.mock.calls[2][0])).toContain('workflow_approval_requests') - expect(String(mockPoolQuery.mock.calls[2][0])).not.toContain('team_workflow_triggers') + expect(String(mockPoolQuery.mock.calls[2][0])).toContain('team_workflow_triggers') }) it('downloads a run-scoped artifact through WRC with run and artifact token bindings', async () => { diff --git a/control-api/test/workflowRunReadAuthorization.test.ts b/control-api/test/workflowRunReadAuthorization.test.ts new file mode 100644 index 0000000000..e5c78cecb6 --- /dev/null +++ b/control-api/test/workflowRunReadAuthorization.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest' +import { canCallerReadWorkflowRun } from '../src/services/workflows/workflowRunReadService.js' + +const caller = { + kind: 'user-session' as const, + claims: { + userId: 'user-1', + email: 'user@example.com', + teamId: 'team-1', + role: 'member' as const, + exp: Math.floor(Date.now() / 1000) + 3600, + }, +} + +function run(overrides: Record = {}) { + return { + run_id: 'run-1', + recipe_namespace: 'workflows', + recipe_name: 'recipe-1', + actor_type: 'user', + actor_id: 'user-1', + team_id: null, + usage_team_id: null, + ...overrides, + } as never +} + +describe('workflow run live team authorization', () => { + it('keeps a directly attributed user run readable without a team lookup', async () => { + const db = { query: vi.fn() } + + await expect(canCallerReadWorkflowRun(caller, run(), db as never)).resolves.toBe(true) + expect(db.query).not.toHaveBeenCalled() + }) + + it('does not treat a direct recipe grant as authority for a stale team-attributed run', async () => { + const db = { query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }) } + + await expect( + canCallerReadWorkflowRun( + caller, + run({ team_id: 'team-1', usage_team_id: 'team-1' }), + db as never + ) + ).resolves.toBe(false) + expect(String(db.query.mock.calls[0][0])).toContain("tm.status = 'active'") + expect(String(db.query.mock.calls[0][0])).toContain('team_workflow_triggers') + }) + + it('allows the same team-attributed run only with a current membership and recipe grant', async () => { + const db = { query: vi.fn().mockResolvedValue({ rows: [{ '?column?': 1 }], rowCount: 1 }) } + + await expect( + canCallerReadWorkflowRun(caller, run({ team_id: 'team-1' }), db as never) + ).resolves.toBe(true) + }) +}) diff --git a/desktop-app/src/gfs/uriHandler.ts b/desktop-app/src/gfs/uriHandler.ts index 84d1d606bc..3bd94b827a 100644 --- a/desktop-app/src/gfs/uriHandler.ts +++ b/desktop-app/src/gfs/uriHandler.ts @@ -741,6 +741,7 @@ export function parseSubjectKey(key: string): GfsSubject { * on a 429 (middleware/rateLimitMiddleware.ts) — not inside the human message. */ interface GfsGrantErrorFields { + reason?: string invalidIndexes?: number[] retryAfterSeconds?: number } @@ -773,14 +774,33 @@ function parseGfsGrantErrorFields(bodyText: string): GfsGrantErrorFields { return {} } if (!parsed || typeof parsed !== 'object') return {} - const record = parsed as { invalidIndexes?: unknown; retryAfterSeconds?: unknown } + const record = parsed as { + error?: unknown + invalidIndexes?: unknown + retryAfterSeconds?: unknown + } + const envelope = + record.error && typeof record.error === 'object' + ? (record.error as { details?: unknown }) + : undefined + const details = + envelope?.details && typeof envelope.details === 'object' + ? (envelope.details as { + reason?: unknown + invalidIndexes?: unknown + retryAfterSeconds?: unknown + }) + : undefined const fields: GfsGrantErrorFields = {} - if (Array.isArray(record.invalidIndexes)) { - const indexes = record.invalidIndexes.filter( + if (typeof details?.reason === 'string') fields.reason = details.reason + const rawInvalidIndexes = details?.invalidIndexes ?? record.invalidIndexes + if (Array.isArray(rawInvalidIndexes)) { + const indexes = rawInvalidIndexes.filter( (value): value is number => Number.isInteger(value) && (value as number) >= 0 ) if (indexes.length > 0) fields.invalidIndexes = indexes } + const rawRetryAfterSeconds = details?.retryAfterSeconds ?? record.retryAfterSeconds // Same bound as `parseRetryAfterHeader`'s `\d{1,7}`, because the two feed the // same suffix and the renderer reads that suffix with `\d+$`. An integer is // not enough: `Number.isInteger(1e21)` is true and `${1e21}` is `1e+21`, so @@ -790,11 +810,11 @@ function parseGfsGrantErrorFields(bodyText: string): GfsGrantErrorFields { // yields no window, which the renderer already states as "try again // shortly"; that is the same answer it gives for an unparseable header. if ( - Number.isInteger(record.retryAfterSeconds) && - (record.retryAfterSeconds as number) >= 0 && - (record.retryAfterSeconds as number) <= MAX_EMITTED_RETRY_AFTER_SECONDS + Number.isInteger(rawRetryAfterSeconds) && + (rawRetryAfterSeconds as number) >= 0 && + (rawRetryAfterSeconds as number) <= MAX_EMITTED_RETRY_AFTER_SECONDS ) { - fields.retryAfterSeconds = record.retryAfterSeconds as number + fields.retryAfterSeconds = rawRetryAfterSeconds as number } return fields } @@ -840,11 +860,13 @@ function surfaceGfsGrantError(error: unknown): unknown { if (!error || typeof error !== 'object') return error const transportError = error as { bodyText?: unknown; retryAfter?: unknown; status?: unknown } const bodyText = typeof transportError.bodyText === 'string' ? transportError.bodyText : '' - const fields = bodyText ? parseGfsGrantErrorFields(bodyText) : {} + const fields: GfsGrantErrorFields = bodyText ? parseGfsGrantErrorFields(bodyText) : {} const retryAfterSeconds = fields.retryAfterSeconds ?? parseRetryAfterHeader(transportError.retryAfter) const httpStatus = vettedHttpStatus(transportError.status) const parts: string[] = [] + const rawMessage = error instanceof Error ? error.message : String(error) + if (fields.reason && !rawMessage.includes(fields.reason)) parts.push(fields.reason) if (fields.invalidIndexes) parts.push(`invalidIndexes=[${fields.invalidIndexes.join(',')}]`) // Before `retryAfterSeconds`, never after: the renderer anchors that field to // the END of the message so an earlier occurrence in the server's own text @@ -853,7 +875,6 @@ function surfaceGfsGrantError(error: unknown): unknown { if (retryAfterSeconds !== undefined) parts.push(`retryAfterSeconds=${retryAfterSeconds}`) // `error` is a non-null object by the guard above, so it needs no nullish // coalescing here: a `?? ''` branch could never be taken. - const rawMessage = error instanceof Error ? error.message : String(error) const baseMessage = stripUnvettedRetryAfter(stripUnvettedHttpStatus(rawMessage)) // Nothing vetted to append and nothing counterfeit to remove: the server's // own error is already exactly what the renderer should see. diff --git a/desktop-app/test/gfsUriHandler.test.ts b/desktop-app/test/gfsUriHandler.test.ts index 0591f0233e..8a91adaed8 100644 --- a/desktop-app/test/gfsUriHandler.test.ts +++ b/desktop-app/test/gfsUriHandler.test.ts @@ -316,6 +316,35 @@ describe('GfsClient.grant', () => { ).rejects.toThrow(/subjects_invalid invalidIndexes=\[0,2\]/) }) + it('surfaces typed public-envelope GFS details across the IPC error boundary', async () => { + const requestJson = vi.fn(async () => { + throw new ApiError( + '400 Bad Request: invalid_request - subjects_invalid - The request is not valid.', + 400, + JSON.stringify({ + error: { + code: 'invalid_request', + message: 'The request is not valid.', + correlationId: 'correlation-1', + retryable: false, + details: { reason: 'subjects_invalid', invalidIndexes: [0, 2] }, + }, + }) + ) + }) as GfsTransport['requestJson'] + + await expect( + new GfsClient(transport({ requestJson })).grant( + { + resourceId: RID, + subjects: [{ type: 'user', id: 'u2' }], + permissions: ['read'], + }, + 'tok' + ) + ).rejects.toThrow(/subjects_invalid.*invalidIndexes=\[0,2\]/) + }) + it('keeps the server verdict and the original error when the body carries no structured fields', async () => { const original = new ApiError( '403 Forbidden: escalation_rejected', @@ -408,6 +437,27 @@ describe('GfsClient.listGrants', () => { ).rejects.toThrow(/429 .*retryAfterSeconds=45/) }) + it('embeds retry guidance from the typed public envelope', async () => { + const requestJson = vi.fn(async () => { + throw new ApiError( + '429 Too Many Requests: rate_limited', + 429, + JSON.stringify({ + error: { + code: 'rate_limited', + message: 'Too many requests; retry later.', + correlationId: 'correlation-2', + retryable: true, + details: { retryAfterSeconds: 45 }, + }, + }) + ) + }) as GfsTransport['requestJson'] + await expect( + new GfsClient(transport({ requestJson })).listGrants({ resourceId: RID }, 'tok') + ).rejects.toThrow(/429 .*retryAfterSeconds=45/) + }) + it('emits no window for a body value too large to spell in digits', async () => { // `Number.isInteger(1e21)` is true and `${1e21}` is `1e+21`, so an // unbounded body value composed a marker the renderer can neither read diff --git a/desktop-app/test/httpClient.test.ts b/desktop-app/test/httpClient.test.ts index 20de8f92f7..cb800db517 100644 --- a/desktop-app/test/httpClient.test.ts +++ b/desktop-app/test/httpClient.test.ts @@ -1,4 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ControlApiError } from '../../external-rest-api/src/controlApiClient.js' +import { sanitizeControlApiPublicError } from '../../external-rest-api/src/http/publicApiError.js' import { ApiError, requestJson } from '../src/httpClient.js' vi.mock('../src/config.js', () => ({ @@ -133,6 +135,24 @@ describe('requestJson — transient retry', () => { expect(fetchSpy).toHaveBeenCalledTimes(1) }) + it('formats the real External REST typed envelope without object stringification', async () => { + const produced = sanitizeControlApiPublicError( + new ControlApiError('raw', 403, { error: 'escalation_rejected' }), + new Set([403]) + ) + expect(produced).not.toBeNull() + vi.mocked(global.fetch).mockResolvedValueOnce( + errorResponse(403, JSON.stringify(produced!.body)) + ) + + await expect(requestJson('PUT', 'http://localhost/test')).rejects.toThrow( + /403 Error: forbidden: The requested operation is not allowed\./ + ) + await expect(Promise.resolve(JSON.stringify(produced!.body))).resolves.not.toContain( + '[object Object]' + ) + }) + it('preserves Retry-After on HTTP errors for bounded upload lifecycle retries', async () => { const fetchSpy = vi.mocked(global.fetch) fetchSpy.mockResolvedValueOnce( diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index ff88a88560..d85209e604 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.108", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.108", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index d8dc7498ab..8a2e1361cb 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.108", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/__tests__/browserMutationGuard.test.ts b/external-rest-api/src/__tests__/browserMutationGuard.test.ts new file mode 100644 index 0000000000..fb2fe66f95 --- /dev/null +++ b/external-rest-api/src/__tests__/browserMutationGuard.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import express from 'express' +import request from 'supertest' +import { requireTrustedBrowserMutation } from '../middleware/browserMutationGuard.js' + +function app() { + const value = express() + value.use(requireTrustedBrowserMutation) + value.post('/mutation', (_req, res) => res.status(200).json({ ok: true })) + return value +} + +describe('browser mutation origin protection', () => { + it('accepts a configured same-site browser origin', async () => { + await request(app()) + .post('/mutation') + .set('cookie', 'profile_session=session') + .set('origin', 'http://localhost:3001') + .set('sec-fetch-site', 'same-site') + .expect(200, { ok: true }) + }) + + it('rejects a cross-site cookie mutation without reflecting request data', async () => { + const response = await request(app()) + .post('/mutation') + .set('cookie', 'profile_session=session') + .set('origin', 'https://attacker.example') + .set('sec-fetch-site', 'cross-site') + + expect(response.status).toBe(403) + expect(response.body.error).toEqual({ + code: 'forbidden', + message: 'The browser request origin is not allowed.', + correlationId: expect.any(String), + retryable: false, + }) + expect(JSON.stringify(response.body)).not.toContain('attacker.example') + }) + + it('allows native bearer mutations without browser headers', async () => { + await request(app()) + .post('/mutation') + .set('authorization', 'Bearer desktop-session') + .expect(200, { ok: true }) + }) +}) diff --git a/external-rest-api/src/__tests__/publicErrorContract.test.ts b/external-rest-api/src/__tests__/publicErrorContract.test.ts new file mode 100644 index 0000000000..28cbe09d3a --- /dev/null +++ b/external-rest-api/src/__tests__/publicErrorContract.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest' +import express from 'express' +import request from 'supertest' +import { externalRestPublicErrorHandler } from '../app.js' +import { ControlApiError } from '../controlApiClient.js' +import { sanitizeControlApiPublicError } from '../http/publicApiError.js' + +function appThrowing(error: Error) { + const app = express() + app.get('/failure', (_req, _res, next) => next(error)) + app.use(externalRestPublicErrorHandler) + return app +} + +describe('External REST public error contract', () => { + it('never reflects an internal upstream message, path, or secret-like value', async () => { + const sentinel = 'oauth-secret-at-/var/run/internal/provider.json' + const response = await request(appThrowing(new Error(sentinel))).get('/failure') + + expect(response.status).toBe(500) + expect(response.body.error).toEqual({ + code: 'internal_error', + message: 'The request could not be completed.', + correlationId: expect.any(String), + retryable: false, + }) + expect(JSON.stringify(response.body)).not.toContain(sentinel) + expect(JSON.stringify(response.body)).not.toContain('/var/run/internal') + }) + + it('preserves authority unavailability without reflecting the Control API body', async () => { + const response = await request( + appThrowing( + new ControlApiError('raw postgres failure', 503, { + error: 'raw failure at postgres://secret@internal', + }) + ) + ).get('/failure') + + expect(response.status).toBe(503) + expect(response.body.error).toEqual({ + code: 'authority_unavailable', + message: 'Authorization is temporarily unavailable.', + correlationId: expect.any(String), + retryable: true, + }) + expect(JSON.stringify(response.body)).not.toContain('postgres') + expect(JSON.stringify(response.body)).not.toContain('secret') + }) + + it('maps upstream throttling to the stable retryable rate-limit error', async () => { + const response = await request( + appThrowing(new ControlApiError('raw rate limiter state', 429, { internal: 'bucket-key' })) + ).get('/failure') + + expect(response.status).toBe(429) + expect(response.body.error).toEqual({ + code: 'rate_limited', + message: 'Too many requests; retry later.', + correlationId: expect.any(String), + retryable: true, + }) + expect(JSON.stringify(response.body)).not.toContain('bucket-key') + }) + + it('preserves only a bounded retry delay from legacy rate-limit bodies', () => { + const sanitized = sanitizeControlApiPublicError( + new ControlApiError('raw', 429, { + error: 'Too Many Requests', + retryAfterSeconds: 17, + bucket: 'secret-internal-bucket', + }), + new Set([429]) + ) + + expect(sanitized?.body).toMatchObject({ + error: { + code: 'rate_limited', + details: { retryAfterSeconds: 17 }, + }, + }) + expect(JSON.stringify(sanitized?.body)).not.toContain('secret-internal-bucket') + }) + + it('preserves only allowlisted GFS denial details from legacy bodies', () => { + const sanitized = sanitizeControlApiPublicError( + new ControlApiError('raw', 400, { + error: 'subjects_invalid', + invalidIndexes: [0, 2], + sql: 'SELECT secret', + }), + new Set([400]) + ) + + expect(sanitized?.body).toMatchObject({ + error: { + code: 'invalid_request', + details: { reason: 'subjects_invalid', invalidIndexes: [0, 2] }, + }, + }) + expect(JSON.stringify(sanitized?.body)).not.toContain('SELECT secret') + }) + + it('rebuilds every forwarded route class from a bounded typed envelope', () => { + const sentinel = 'postgres://secret@internal/var/run/service.sock' + for (const status of [400, 401, 403, 404, 409, 410, 412, 422, 429, 500, 502, 503, 504]) { + const sanitized = sanitizeControlApiPublicError( + new ControlApiError(sentinel, status, { + error: { + code: status === 409 ? 'access_path_required' : 'made_up_internal_code', + message: sentinel, + correlationId: `bad/${sentinel}`, + details: { sql: sentinel, path: sentinel, secret: sentinel }, + }, + }), + new Set([status]) + ) + + expect(sanitized?.status).toBe(status) + expect(JSON.stringify(sanitized?.body)).not.toContain(sentinel) + expect(JSON.stringify(sanitized?.body)).not.toContain('made_up_internal_code') + } + }) + + it('preserves only bounded access-path descriptors for an ambiguity response', () => { + const pathId = `ap1_${'a'.repeat(43)}` + const sanitized = sanitizeControlApiPublicError( + new ControlApiError('raw', 409, { + error: { + code: 'access_path_required', + message: 'raw', + details: { + paths: [{ id: pathId, kind: 'team', teamId: 'team-safe' }], + sql: 'SELECT secret', + }, + }, + }), + new Set([409]) + ) + + expect(sanitized?.body).toMatchObject({ + error: { + code: 'access_path_required', + details: { paths: [{ id: pathId, kind: 'team', teamId: 'team-safe' }] }, + }, + }) + expect(JSON.stringify(sanitized?.body)).not.toContain('SELECT secret') + }) +}) diff --git a/external-rest-api/src/app.ts b/external-rest-api/src/app.ts index c606c899a5..f4dd4a3f02 100644 --- a/external-rest-api/src/app.ts +++ b/external-rest-api/src/app.ts @@ -1,7 +1,15 @@ import express, { NextFunction, Request, Response } from 'express' import cors from 'cors' import { config } from './config.js' +import { + publicCorrelationId, + sanitizeSupportedControlApiPublicError, + sendPublicApiError, + sendSanitizedControlApiPublicError, +} from './http/publicApiError.js' +import { requireTrustedBrowserMutation } from './middleware/browserMutationGuard.js' import { withExternalRequestContext } from './requestContext.js' +import { createAccessRouter } from './routes/access.js' import { createAuthRouter } from './routes/auth.js' import { createContextSharedFilesystemsRouter } from './routes/contextSharedFilesystems.js' import { createDesktopRouter } from './routes/desktop.js' @@ -56,11 +64,13 @@ export function createApp() { } jsonBodyParser(req, res, next) }) + app.use(requireTrustedBrowserMutation) app.use(createHealthRouter()) const api = express.Router() api.use(createAuthRouter()) + api.use(createAccessRouter()) api.use(createDesktopRouter()) api.use(createMeRouter()) api.use(createMembersRouter()) @@ -83,23 +93,64 @@ export function createApp() { res.status(404).json({ error: 'Not Found' }) }) - app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => { - const status = - err instanceof Error && typeof (err as Error & { status?: unknown }).status === 'number' - ? (err as Error & { status: number }).status - : undefined - if (status !== undefined && status >= 400 && status < 500) { - res.status(status).json({ - error: err instanceof Error ? err.message : 'Bad Request', - }) - return - } - - res.status(500).json({ - error: 'Internal Server Error', - message: err instanceof Error ? err.message : 'Unknown error', - }) - }) + app.use(externalRestPublicErrorHandler) return app } + +export function externalRestPublicErrorHandler( + err: unknown, + req: Request, + res: Response, + _next: NextFunction +): void { + const propagatedError = sanitizeSupportedControlApiPublicError(err, publicCorrelationId(req)) + if (propagatedError) { + sendSanitizedControlApiPublicError(res, propagatedError) + return + } + + const status = + err instanceof Error && typeof (err as Error & { status?: unknown }).status === 'number' + ? (err as Error & { status: number }).status + : undefined + if (status !== undefined && status >= 400 && status < 500) { + const code = + status === 401 + ? 'invalid_session' + : status === 403 + ? 'forbidden' + : status === 404 + ? 'not_found' + : status === 429 + ? 'rate_limited' + : 'invalid_request' + sendPublicApiError( + req, + res, + status, + code, + status === 404 + ? 'The resource was not found.' + : status === 429 + ? 'Too many requests; retry later.' + : 'The request could not be completed.', + status === 429 + ) + return + } + + if (status === 503) { + sendPublicApiError( + req, + res, + 503, + 'authority_unavailable', + 'Authorization is temporarily unavailable.', + true + ) + return + } + + sendPublicApiError(req, res, 500, 'internal_error', 'The request could not be completed.') +} diff --git a/external-rest-api/src/authToken.ts b/external-rest-api/src/authToken.ts index a0e21c0b20..2796c28883 100644 --- a/external-rest-api/src/authToken.ts +++ b/external-rest-api/src/authToken.ts @@ -3,8 +3,76 @@ import { config } from './config.js' import { AuthClaims, TEAM_ROLES } from './types.js' const ALLOWED_ROLES = new Set(TEAM_ROLES) +const USER_SESSION_V2_AUDIENCE = 'evenfire-user-session' +const USER_SESSION_V2_TTL_SECONDS = 60 * 60 +const USER_SESSION_V2_CLOCK_TOLERANCE_SECONDS = 5 +const FORBIDDEN_USER_SESSION_AUTHORITY_CLAIMS = [ + 'teamId', + 'role', + 'memberships', + 'grants', + 'resources', + 'budgets', + 'credentials', + 'policies', + 'capabilities', + 'filesystemScope', + 'runtime', + 'providerPolicy', + 'modelPolicy', + 'auditOwner', +] as const + +function verifyUserSessionV2(token: string): AuthClaims | null { + try { + const payload = jwt.verify(token, config.jwtPublicKey, { + algorithms: ['RS256'], + issuer: config.jwtIssuer, + audience: USER_SESSION_V2_AUDIENCE, + clockTolerance: USER_SESSION_V2_CLOCK_TOLERANCE_SECONDS, + }) as jwt.JwtPayload + const now = Math.floor(Date.now() / 1000) + if ( + typeof payload.sub !== 'string' || + typeof payload.sid !== 'string' || + typeof payload.jti !== 'string' || + !Number.isInteger(payload.sv) || + Number(payload.sv) < 1 || + payload.ver !== 2 || + payload.typ !== 'user_session' || + typeof payload.iat !== 'number' || + typeof payload.exp !== 'number' || + payload.exp - payload.iat !== USER_SESSION_V2_TTL_SECONDS || + payload.iat > now + USER_SESSION_V2_CLOCK_TOLERANCE_SECONDS || + typeof payload.auth_time !== 'number' || + payload.auth_time > now + USER_SESSION_V2_CLOCK_TOLERANCE_SECONDS || + !Array.isArray(payload.amr) || + !payload.amr.every(method => typeof method === 'string' && Boolean(method.trim())) || + (payload.email !== undefined && typeof payload.email !== 'string') || + FORBIDDEN_USER_SESSION_AUTHORITY_CLAIMS.some(claim => payload[claim] !== undefined) + ) { + return null + } + return { + userId: payload.sub, + email: typeof payload.email === 'string' ? payload.email : '', + teamId: '', + role: 'member', + exp: payload.exp, + sessionContract: 'v2', + sid: payload.sid, + jti: payload.jti, + sv: Number(payload.sv), + ver: 2, + } + } catch { + return null + } +} export function verifyToken(token: string): AuthClaims | null { + const v2 = verifyUserSessionV2(token) + if (v2) return v2 try { const payload = jwt.verify(token, config.jwtPublicKey, { algorithms: ['RS256'], diff --git a/external-rest-api/src/http/publicApiError.ts b/external-rest-api/src/http/publicApiError.ts new file mode 100644 index 0000000000..c66cf4ec43 --- /dev/null +++ b/external-rest-api/src/http/publicApiError.ts @@ -0,0 +1,191 @@ +import type { Request, Response } from 'express' +import { randomUUID } from 'node:crypto' +import { ControlApiError } from '../controlApiClient.js' + +const PUBLIC_CODE_BY_STATUS: Readonly> = { + 400: 'invalid_request', + 401: 'invalid_session', + 403: 'forbidden', + 404: 'not_found', + 409: 'conflict', + 410: 'gone', + 412: 'precondition_failed', + 422: 'invalid_request', + 429: 'rate_limited', + 500: 'internal_error', + 502: 'upstream_unavailable', + 503: 'authority_unavailable', + 504: 'upstream_timeout', +} + +const PUBLIC_MESSAGE_BY_CODE: Readonly> = { + invalid_request: 'The request is not valid.', + invalid_session: 'The session is not valid.', + session_revoked: 'The session is not valid.', + forbidden: 'The requested operation is not allowed.', + not_found: 'The resource was not found.', + conflict: 'The request conflicts with current state.', + access_path_required: 'Choose an access path for this operation.', + access_path_stale: 'The access path is stale; refresh access before retrying.', + gone: 'The resource is no longer available.', + precondition_failed: 'The request precondition did not match current state.', + rate_limited: 'Too many requests; retry later.', + internal_error: 'The request could not be completed.', + upstream_unavailable: 'The upstream service is temporarily unavailable.', + authority_unavailable: 'Authorization is temporarily unavailable.', + upstream_timeout: 'The upstream service timed out.', +} + +const ALLOWED_CODES_BY_STATUS: Readonly>> = { + 400: new Set(['invalid_request']), + 401: new Set(['invalid_session', 'session_revoked']), + 403: new Set(['forbidden']), + 404: new Set(['not_found']), + 409: new Set(['conflict', 'access_path_required', 'access_path_stale']), + 410: new Set(['gone']), + 412: new Set(['precondition_failed']), + 422: new Set(['invalid_request']), + 429: new Set(['rate_limited']), + 500: new Set(['internal_error']), + 502: new Set(['upstream_unavailable']), + 503: new Set(['authority_unavailable']), + 504: new Set(['upstream_timeout']), +} + +type PublicPathDescriptor = { id: string; kind: 'direct' | 'team'; teamId?: string } + +const SAFE_DOMAIN_REASONS = new Set([ + 'agent_manager_forbidden', + 'managed_agent_permission_forbidden', + 'foreign_agent_forbidden', + 'subjects_invalid', + 'escalation_rejected', + 'manage_acl_required', +]) + +function safeInvalidIndexes(value: unknown): number[] | undefined { + if (!Array.isArray(value) || value.length === 0 || value.length > 100) return undefined + const indexes = value.filter( + (item): item is number => Number.isSafeInteger(item) && item >= 0 && item <= 10_000 + ) + return indexes.length === value.length ? indexes : undefined +} + +function safePathDescriptors(value: unknown): PublicPathDescriptor[] | undefined { + if (!Array.isArray(value) || value.length === 0 || value.length > 100) return undefined + const paths: PublicPathDescriptor[] = [] + for (const item of value) { + if (!item || typeof item !== 'object') return undefined + const row = item as Record + const id = typeof row.id === 'string' ? row.id : '' + const kind = row.kind === 'direct' || row.kind === 'team' ? row.kind : null + const teamId = typeof row.teamId === 'string' ? row.teamId : undefined + if (!/^ap1_[A-Za-z0-9_-]{43}$/.test(id) || !kind) return undefined + if (kind === 'team' && !teamId) return undefined + paths.push({ id, kind, ...(teamId ? { teamId } : {}) }) + } + return paths +} + +export function sanitizeControlApiPublicError( + error: unknown, + propagatedStatuses: ReadonlySet +): { status: number; body: Record } | null { + if (!(error instanceof ControlApiError) || !propagatedStatuses.has(error.status)) return null + const rawBody = + error.body && typeof error.body === 'object' + ? (error.body as Record) + : undefined + const rawError = + rawBody && Object.prototype.hasOwnProperty.call(rawBody, 'error') ? rawBody.error : undefined + const rawEnvelope = rawError && typeof rawError === 'object' ? rawError : undefined + const suppliedCode = + rawEnvelope && typeof (rawEnvelope as { code?: unknown }).code === 'string' + ? String((rawEnvelope as { code: string }).code) + : '' + const fallbackCode = PUBLIC_CODE_BY_STATUS[error.status] || 'internal_error' + const code = ALLOWED_CODES_BY_STATUS[error.status]?.has(suppliedCode) + ? suppliedCode + : fallbackCode + const rawCorrelationId = + rawEnvelope && typeof (rawEnvelope as { correlationId?: unknown }).correlationId === 'string' + ? String((rawEnvelope as { correlationId: string }).correlationId) + : '' + const correlationId = /^[A-Za-z0-9_-]{1,128}$/.test(rawCorrelationId) + ? rawCorrelationId + : randomUUID() + const retryable = [429, 502, 503, 504].includes(error.status) + const rawDetails = + rawEnvelope && typeof (rawEnvelope as { details?: unknown }).details === 'object' + ? ((rawEnvelope as { details: Record }).details ?? undefined) + : undefined + const paths = code === 'access_path_required' ? safePathDescriptors(rawDetails?.paths) : undefined + const rawReason = + typeof rawError === 'string' + ? rawError + : typeof rawDetails?.reason === 'string' + ? rawDetails.reason + : '' + const reason = SAFE_DOMAIN_REASONS.has(rawReason) ? rawReason : undefined + const invalidIndexes = + reason === 'subjects_invalid' + ? safeInvalidIndexes(rawDetails?.invalidIndexes ?? rawBody?.invalidIndexes) + : undefined + const rawRetryAfterSeconds = rawDetails?.retryAfterSeconds ?? rawBody?.retryAfterSeconds + const retryAfterSeconds = + code === 'rate_limited' && + typeof rawRetryAfterSeconds === 'number' && + Number.isSafeInteger(rawRetryAfterSeconds) && + rawRetryAfterSeconds >= 1 && + rawRetryAfterSeconds <= 86_400 + ? rawRetryAfterSeconds + : undefined + const details = paths + ? { paths } + : reason || invalidIndexes || retryAfterSeconds + ? { + ...(reason ? { reason } : {}), + ...(invalidIndexes ? { invalidIndexes } : {}), + ...(retryAfterSeconds ? { retryAfterSeconds } : {}), + } + : undefined + + return { + status: error.status, + body: { + error: { + code, + message: PUBLIC_MESSAGE_BY_CODE[code] || PUBLIC_MESSAGE_BY_CODE[fallbackCode], + correlationId, + retryable, + ...(details ? { details } : {}), + }, + }, + } +} + +export function publicCorrelationId(req: Request): string { + return ( + String(req.header('x-correlation-id') || '').trim() || Math.random().toString(36).slice(2, 12) + ) +} + +export function sendPublicApiError( + req: Request, + res: Response, + status: number, + code: string, + message: string, + retryable = false, + details?: Record +): void { + res.status(status).json({ + error: { + code, + message, + correlationId: publicCorrelationId(req), + retryable, + ...(details && Object.keys(details).length ? { details } : {}), + }, + }) +} diff --git a/external-rest-api/src/middleware/browserMutationGuard.ts b/external-rest-api/src/middleware/browserMutationGuard.ts new file mode 100644 index 0000000000..eacd818c22 --- /dev/null +++ b/external-rest-api/src/middleware/browserMutationGuard.ts @@ -0,0 +1,64 @@ +import type { NextFunction, Request, Response } from 'express' +import { config } from '../config.js' + +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']) +const SAFE_FETCH_SITES = new Set(['same-origin', 'same-site']) + +function correlationId(req: Request): string { + return ( + String(req.header('x-correlation-id') || '').trim() || Math.random().toString(36).slice(2, 12) + ) +} + +function configuredOrigins(): Set { + if (config.corsOrigin === '*') { + try { + return new Set([new URL(config.publicBaseUrl).origin]) + } catch { + return new Set() + } + } + return new Set(config.corsOrigin) +} + +function hasBearer(req: Request): boolean { + return /^bearer\s+\S+/i.test(String(req.header('authorization') || '').trim()) +} + +function isBrowserMutation(req: Request): boolean { + if (SAFE_METHODS.has(req.method.toUpperCase()) || hasBearer(req)) return false + return Boolean(req.header('origin') || req.header('sec-fetch-site') || req.header('cookie')) +} + +export function requireTrustedBrowserMutation( + req: Request, + res: Response, + next: NextFunction +): void { + if (!isBrowserMutation(req)) { + next() + return + } + + const origin = String(req.header('origin') || '').trim() + const fetchSite = String(req.header('sec-fetch-site') || '') + .trim() + .toLowerCase() + if ( + !origin || + !configuredOrigins().has(origin) || + (fetchSite && !SAFE_FETCH_SITES.has(fetchSite)) + ) { + res.status(403).json({ + error: { + code: 'forbidden', + message: 'The browser request origin is not allowed.', + correlationId: correlationId(req), + retryable: false, + }, + }) + return + } + + next() +} diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index 9362890f01..f077a0fe27 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.10.0', - externalRestApiVersion: '0.1.107', + externalRestApiVersion: '0.1.108', rpcProxyVersion: '0.1.111', desktopVersion: '0.10.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/src/routes/auth.ts b/external-rest-api/src/routes/auth.ts index 275b5631bd..1b11079b7e 100644 --- a/external-rest-api/src/routes/auth.ts +++ b/external-rest-api/src/routes/auth.ts @@ -6,9 +6,16 @@ import { createRateLimiter } from '../middleware/rateLimit.js' import { loginWithGoogle, loginWithPassword, + logoutUserSession, + renewUserSession, requestPasswordReset, } from '../services/authService.js' -import { clearProfileSessionCookie, setProfileSessionCookie } from '../sessionCookie.js' +import { + PROFILE_SESSION_COOKIE, + clearProfileSessionCookie, + readCookie, + setProfileSessionCookie, +} from '../sessionCookie.js' const googleClient = new OAuth2Client(config.googleClientId) const passwordResetRateLimit = createRateLimiter({ @@ -48,6 +55,10 @@ function shouldExposeBearerToken(req: { header: (name: string) => string | undef return !origin && !fetchSite } +function requestedSessionContract(req: Request): 'v2' | undefined { + return req.header('x-evenfire-session-contract') === 'v2' ? 'v2' : undefined +} + function sendLoginResponse(req: Request, res: Response, result: LoginResponse): void { setProfileSessionCookie(req, res, result.token) const body = shouldExposeBearerToken(req) @@ -87,7 +98,10 @@ export function createAuthRouter(): Router { } await verifyGoogleToken(idToken) - const result = await loginWithGoogle({ idToken }) + const result = await loginWithGoogle({ + idToken, + ...(requestedSessionContract(req) ? { sessionContract: 'v2' as const } : {}), + }) sendLoginResponse(req, res, result) } catch (error) { if (isControlApiStatus(error, 404)) { @@ -113,7 +127,7 @@ export function createAuthRouter(): Router { return } - const result = await loginWithPassword(email, password) + const result = await loginWithPassword(email, password, requestedSessionContract(req)) sendLoginResponse(req, res, result) } catch (error) { const message = error instanceof Error ? error.message : '' @@ -145,9 +159,49 @@ export function createAuthRouter(): Router { } }) - router.post('/auth/logout', (req, res) => { - clearProfileSessionCookie(req, res) - res.status(200).json({ ok: true }) + router.post('/auth/session/renew', async (req, res, next) => { + try { + const bearer = String(req.header('authorization') || '') + .replace(/^bearer\s+/i, '') + .trim() + const token = bearer || readCookie(req, PROFILE_SESSION_COOKIE) + if (!token || token.length > 4096) { + res.status(401).json({ error: 'Unauthorized' }) + return + } + const renewed = await renewUserSession(token) + setProfileSessionCookie(req, res, renewed.token) + const body = shouldExposeBearerToken(req) + ? { token: renewed.token, expiresInSeconds: renewed.expiresInSeconds } + : { expiresInSeconds: renewed.expiresInSeconds } + res.status(200).json(body) + } catch (error) { + if (isControlApiStatus(error, 401)) { + clearProfileSessionCookie(req, res) + res.status(401).json({ error: 'Unauthorized' }) + return + } + next(error) + } + }) + + router.post('/auth/logout', async (req, res, next) => { + try { + const bearer = String(req.header('authorization') || '') + .replace(/^bearer\s+/i, '') + .trim() + const token = bearer || readCookie(req, PROFILE_SESSION_COOKIE) + if (token) await logoutUserSession(token) + clearProfileSessionCookie(req, res) + res.status(200).json({ ok: true }) + } catch (error) { + if (isControlApiStatus(error, 401)) { + clearProfileSessionCookie(req, res) + res.status(200).json({ ok: true }) + return + } + next(error) + } }) return router diff --git a/external-rest-api/src/routes/contextSharedFilesystems.ts b/external-rest-api/src/routes/contextSharedFilesystems.ts index 894df2f5ef..4bda48e7f6 100644 --- a/external-rest-api/src/routes/contextSharedFilesystems.ts +++ b/external-rest-api/src/routes/contextSharedFilesystems.ts @@ -1,6 +1,7 @@ import type { NextFunction, Response } from 'express' import { Router } from 'express' -import { ControlApiError, controlApiRequest, controlApiStreamRequest } from '../controlApiClient.js' +import { controlApiRequest, controlApiStreamRequest } from '../controlApiClient.js' +import { publicCorrelationId, sanitizeControlApiPublicError } from '../http/publicApiError.js' import { type AuthedRequest, extractAuthToken, requireAuth } from '../middleware/auth.js' /** @@ -33,10 +34,13 @@ const PASSTHROUGH_HEADERS = [ ] as const function forwardControlApiError(error: unknown, res: Response, next: NextFunction): void { - if (error instanceof ControlApiError && PROPAGATED_STATUSES.has(error.status)) { - const body = - error.body && typeof error.body === 'object' ? error.body : { error: String(error.message) } - res.status(error.status).json(body) + const sanitized = sanitizeControlApiPublicError( + error, + PROPAGATED_STATUSES, + publicCorrelationId(res.req) + ) + if (sanitized) { + res.status(sanitized.status).json(sanitized.body) return } next(error) diff --git a/external-rest-api/src/routes/gfs.ts b/external-rest-api/src/routes/gfs.ts index 5afb575133..8978cbfede 100644 --- a/external-rest-api/src/routes/gfs.ts +++ b/external-rest-api/src/routes/gfs.ts @@ -6,7 +6,12 @@ import { isIP } from 'node:net' import { Readable } from 'node:stream' import { pipeline } from 'node:stream/promises' import { config } from '../config.js' -import { ControlApiError, controlApiRequest, controlApiStreamRequest } from '../controlApiClient.js' +import { controlApiRequest, controlApiStreamRequest } from '../controlApiClient.js' +import { + publicCorrelationId, + sanitizeControlApiPublicError, + sendSanitizedControlApiPublicError, +} from '../http/publicApiError.js' import { type AuthedRequest, extractAuthToken, requireAuth } from '../middleware/auth.js' type GfsRouterOptions = { @@ -53,15 +58,6 @@ const STREAM_HEADERS = [ 'x-ratelimit-remaining', 'x-gfs-ratelimit-scope', ] -const CONTROL_API_ERROR_HEADERS = [ - 'retry-after', - 'ratelimit', - 'ratelimit-policy', - 'x-ratelimit-limit', - 'x-ratelimit-remaining', - 'x-ratelimit-reset', - 'x-request-id', -] as const const UUID_ANY_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i const EXTERNAL_GFS_EDGE_WINDOW_MS = 60_000 @@ -139,24 +135,17 @@ function gfsControlApiRequest( } function forwardControlApiError(error: unknown, res: Response, next: NextFunction): void { - const shouldPropagate = - error instanceof ControlApiError && - (PROPAGATED.has(error.status) || (error.status >= 500 && error.status <= 599)) - if (shouldPropagate) { - // The internal client has already filtered this map. Keep the allowlist at - // this last hop too so a hand-constructed/mocked ControlApiError cannot - // accidentally turn arbitrary internal response headers into public ones. - for (const name of CONTROL_API_ERROR_HEADERS) { - const value = error.headers?.[name] - if (value) res.setHeader(name, value) - } - const body = - error.body && typeof error.body === 'object' ? error.body : { error: String(error.message) } - for (const header of STREAM_HEADERS) { - const value = error.headers?.[header] - if (value) res.setHeader(header, value) - } - res.status(error.status).json(body) + const statuses = + error instanceof Error && + 'status' in error && + typeof (error as { status?: unknown }).status === 'number' && + (error as { status: number }).status >= 500 && + (error as { status: number }).status <= 599 + ? new Set([...PROPAGATED, (error as { status: number }).status]) + : PROPAGATED + const sanitized = sanitizeControlApiPublicError(error, statuses, publicCorrelationId(res.req)) + if (sanitized) { + sendSanitizedControlApiPublicError(res, sanitized) return } next(error) diff --git a/external-rest-api/src/routes/me.ts b/external-rest-api/src/routes/me.ts index 985351d9f2..2130ae5884 100644 --- a/external-rest-api/src/routes/me.ts +++ b/external-rest-api/src/routes/me.ts @@ -16,6 +16,7 @@ import { updatePassword, updateProfile, } from '../services/meService.js' +import { setProfileSessionCookie } from '../sessionCookie.js' const teamDirectoryRateLimit = createRateLimiter({ windowMs: 60_000, @@ -69,7 +70,9 @@ export function createMeRouter(): Router { res.status(403).json({ error: 'You are not a member of this team' }) return } - res.status(200).json(result) + const browserRequest = Boolean(req.header('origin') || req.header('sec-fetch-site')) + if (browserRequest) setProfileSessionCookie(req, res, result.token) + res.status(200).json(browserRequest ? { team: result.team } : result) } catch (error) { next(error) } diff --git a/external-rest-api/src/routes/notifications.ts b/external-rest-api/src/routes/notifications.ts index 5fe4d29c03..a3fa04c216 100644 --- a/external-rest-api/src/routes/notifications.ts +++ b/external-rest-api/src/routes/notifications.ts @@ -1,15 +1,15 @@ import type { NextFunction, Response } from 'express' import { Router } from 'express' -import { ControlApiError, controlApiStreamRequest } from '../controlApiClient.js' +import { controlApiStreamRequest } from '../controlApiClient.js' +import { sanitizeControlApiPublicError } from '../http/publicApiError.js' import { type AuthedRequest, extractAuthToken, requireAuth } from '../middleware/auth.js' const PROPAGATED_STATUSES = new Set([400, 401, 403, 404, 409, 410, 422]) function forwardControlApiError(error: unknown, res: Response, next: NextFunction): void { - if (error instanceof ControlApiError && PROPAGATED_STATUSES.has(error.status)) { - const body = - error.body && typeof error.body === 'object' ? error.body : { error: String(error.message) } - res.status(error.status).json(body) + const sanitized = sanitizeControlApiPublicError(error, PROPAGATED_STATUSES) + if (sanitized) { + res.status(sanitized.status).json(sanitized.body) return } next(error) diff --git a/external-rest-api/src/routes/oauthGrants.ts b/external-rest-api/src/routes/oauthGrants.ts index bed64c9b42..564c0231fd 100644 --- a/external-rest-api/src/routes/oauthGrants.ts +++ b/external-rest-api/src/routes/oauthGrants.ts @@ -1,6 +1,6 @@ import type { NextFunction, Response } from 'express' import { Router } from 'express' -import { ControlApiError } from '../controlApiClient.js' +import { sanitizeControlApiPublicError } from '../http/publicApiError.js' import { type AuthedRequest, extractAuthToken, requireAuth } from '../middleware/auth.js' import { type OauthGrantSummary, @@ -31,10 +31,9 @@ function toGrantView(g: OauthGrantSummary): OauthGrantSummary { } function forwardControlApiError(error: unknown, res: Response, next: NextFunction): void { - if (error instanceof ControlApiError && PROPAGATED_STATUSES.has(error.status)) { - const body = - error.body && typeof error.body === 'object' ? error.body : { error: String(error.message) } - res.status(error.status).json(body) + const sanitized = sanitizeControlApiPublicError(error, PROPAGATED_STATUSES) + if (sanitized) { + res.status(sanitized.status).json(sanitized.body) return } next(error) diff --git a/external-rest-api/src/routes/team.ts b/external-rest-api/src/routes/team.ts index 4b86ddb8be..80447d55b9 100644 --- a/external-rest-api/src/routes/team.ts +++ b/external-rest-api/src/routes/team.ts @@ -11,6 +11,7 @@ import { renameTeam, updateMemberRole, } from '../services/teamService.js' +import { setProfileSessionCookie } from '../sessionCookie.js' import { TeamRole } from '../types.js' export function createTeamRouter(): Router { @@ -40,7 +41,10 @@ export function createTeamRouter(): Router { res.status(400).json({ error: 'Team name is required' }) return } - res.status(201).json(await createTeamForUser({ ...auth, sessionToken }, name)) + const result = await createTeamForUser({ ...auth, sessionToken }, name) + const browserRequest = Boolean(req.header('origin') || req.header('sec-fetch-site')) + if (browserRequest) setProfileSessionCookie(req, res, result.token) + res.status(201).json(browserRequest ? { team: result.team } : result) } catch (error) { next(error) } diff --git a/external-rest-api/src/routes/userApprovalDecisions.ts b/external-rest-api/src/routes/userApprovalDecisions.ts index 0c7fd466e9..9ab4d6e8ba 100644 --- a/external-rest-api/src/routes/userApprovalDecisions.ts +++ b/external-rest-api/src/routes/userApprovalDecisions.ts @@ -1,6 +1,6 @@ import type { NextFunction, Response } from 'express' import { Router } from 'express' -import { ControlApiError } from '../controlApiClient.js' +import { sanitizeControlApiPublicError } from '../http/publicApiError.js' import { type AuthedRequest, extractAuthToken, requireAuth } from '../middleware/auth.js' import { decideUserApprovalDecision, @@ -10,10 +10,9 @@ import { const PROPAGATED_STATUSES = new Set([400, 403, 404, 409, 410, 422]) function forwardControlApiError(error: unknown, res: Response, next: NextFunction): void { - if (error instanceof ControlApiError && PROPAGATED_STATUSES.has(error.status)) { - const body = - error.body && typeof error.body === 'object' ? error.body : { error: String(error.message) } - res.status(error.status).json(body) + const sanitized = sanitizeControlApiPublicError(error, PROPAGATED_STATUSES) + if (sanitized) { + res.status(sanitized.status).json(sanitized.body) return } next(error) diff --git a/external-rest-api/src/routes/workflowApprovalMediums.ts b/external-rest-api/src/routes/workflowApprovalMediums.ts index 3865e17ed1..a5a1be0372 100644 --- a/external-rest-api/src/routes/workflowApprovalMediums.ts +++ b/external-rest-api/src/routes/workflowApprovalMediums.ts @@ -1,6 +1,6 @@ import type { NextFunction, Response } from 'express' import { Router } from 'express' -import { ControlApiError } from '../controlApiClient.js' +import { sanitizeControlApiPublicError } from '../http/publicApiError.js' import { type AuthedRequest, extractAuthToken, requireAuth } from '../middleware/auth.js' import { confirmWorkflowApprovalMediumChallenge, @@ -19,10 +19,9 @@ const CODE_RE = /^\d{6}$/ const DISPLAY_NAME_MAX_LENGTH = 120 function forwardControlApiError(error: unknown, res: Response, next: NextFunction): void { - if (error instanceof ControlApiError && PROPAGATED_STATUSES.has(error.status)) { - const body = - error.body && typeof error.body === 'object' ? error.body : { error: String(error.message) } - res.status(error.status).json(body) + const sanitized = sanitizeControlApiPublicError(error, PROPAGATED_STATUSES) + if (sanitized) { + res.status(sanitized.status).json(sanitized.body) return } next(error) diff --git a/external-rest-api/src/routes/workflows.ts b/external-rest-api/src/routes/workflows.ts index bfeb97d88f..f0d2caa39b 100644 --- a/external-rest-api/src/routes/workflows.ts +++ b/external-rest-api/src/routes/workflows.ts @@ -1,20 +1,19 @@ import type { NextFunction, Response } from 'express' import { Router } from 'express' import { - ControlApiError, controlApiBinaryRequestWithStatus, controlApiRequest, controlApiRequestWithStatus, } from '../controlApiClient.js' +import { sanitizeControlApiPublicError } from '../http/publicApiError.js' import { type AuthedRequest, extractAuthToken, requireAuth } from '../middleware/auth.js' const PROPAGATED_STATUSES = new Set([400, 403, 404, 409, 410, 422]) function forwardControlApiError(error: unknown, res: Response, next: NextFunction): void { - if (error instanceof ControlApiError && PROPAGATED_STATUSES.has(error.status)) { - const body = - error.body && typeof error.body === 'object' ? error.body : { error: String(error.message) } - res.status(error.status).json(body) + const sanitized = sanitizeControlApiPublicError(error, PROPAGATED_STATUSES) + if (sanitized) { + res.status(sanitized.status).json(sanitized.body) return } next(error) diff --git a/external-rest-api/src/services/authService.ts b/external-rest-api/src/services/authService.ts index 52c4018c8b..39c537304b 100644 --- a/external-rest-api/src/services/authService.ts +++ b/external-rest-api/src/services/authService.ts @@ -3,6 +3,7 @@ import { TeamRole } from '../types.js' export type GoogleLoginInput = { idToken: string + sessionContract?: 'v2' } type LoginResult = { @@ -28,13 +29,14 @@ export async function loginWithGoogle(google: GoogleLoginInput): Promise> { const payload = await controlApiRequest>( 'POST', '/external/auth/password-login', { - body: { email, password }, + body: { email, password, ...(sessionContract ? { sessionContract } : {}) }, } ) return payload @@ -45,3 +47,19 @@ export async function requestPasswordReset(email: string): Promise<{ requested: body: { email }, }) } + +export async function renewUserSession(sessionToken: string): Promise<{ + token: string + expiresInSeconds: number + absoluteExpiresAt: string +}> { + return controlApiRequest('POST', '/external/auth/session/renew', { + userSessionToken: sessionToken, + }) +} + +export async function logoutUserSession(sessionToken: string): Promise<{ revoked: boolean }> { + return controlApiRequest('POST', '/external/auth/session/logout', { + userSessionToken: sessionToken, + }) +} diff --git a/external-rest-api/src/services/invitationsService.ts b/external-rest-api/src/services/invitationsService.ts index 9680235e40..e2f1bcdd83 100644 --- a/external-rest-api/src/services/invitationsService.ts +++ b/external-rest-api/src/services/invitationsService.ts @@ -103,9 +103,12 @@ export async function createDesktopAuthorization( }) return { data } } catch (error) { - const message = error instanceof Error ? error.message : '' - if (message.includes('(404)')) return { error: 'not_found' } - return { error: 'invalid_password' } + if (!(error instanceof ControlApiError)) throw error + if (error.status === 404) return { error: 'not_found' } + if (error.status === 400 || error.status === 401 || error.status === 403) { + return { error: 'invalid_password' } + } + throw error } } diff --git a/external-rest-api/src/services/meService.ts b/external-rest-api/src/services/meService.ts index 414c0a61a3..62c574e6eb 100644 --- a/external-rest-api/src/services/meService.ts +++ b/external-rest-api/src/services/meService.ts @@ -1,4 +1,4 @@ -import { controlApiRequest } from '../controlApiClient.js' +import { ControlApiError, controlApiRequest } from '../controlApiClient.js' import { ChannelMapping, TeamDirectoryResult, TeamRole, UserAgentsResponse } from '../types.js' export function normalizeChannels(input: unknown): ChannelMapping { @@ -82,8 +82,9 @@ export async function switchTeam( `/external/users/${userId}/memberships/${nextTeamId}`, { userSessionToken: sessionToken } ) - } catch { - return null + } catch (error) { + if (error instanceof ControlApiError && error.status === 404) return null + throw error } const role = membership.role as TeamRole @@ -97,9 +98,9 @@ export async function switchTeam( teamId: membership.team_id, role, }, + userSessionToken: sessionToken, } ) - return { token, team: { @@ -129,8 +130,9 @@ export async function getMe(userId: string, teamId: string, sessionToken: string channels: normalizeChannels(result.profile.channels), }, } - } catch { - return null + } catch (error) { + if (error instanceof ControlApiError && error.status === 404) return null + throw error } } diff --git a/external-rest-api/src/services/teamService.ts b/external-rest-api/src/services/teamService.ts index 01b0232c34..0e9d10cc05 100644 --- a/external-rest-api/src/services/teamService.ts +++ b/external-rest-api/src/services/teamService.ts @@ -1,4 +1,4 @@ -import { controlApiRequest } from '../controlApiClient.js' +import { ControlApiError, controlApiRequest } from '../controlApiClient.js' import { TeamAgentsResponse, TeamRole } from '../types.js' type AuthContext = { @@ -28,8 +28,9 @@ export async function getCurrentTeam(auth: AuthContext) { `/external/teams/${auth.teamId}/users/${auth.userId}/current`, { userSessionToken: auth.sessionToken } ) - } catch { - return null + } catch (error) { + if (error instanceof ControlApiError && error.status === 404) return null + throw error } } @@ -55,6 +56,7 @@ export async function createTeamForUser( teamId: team.id, role: 'admin', }, + userSessionToken: auth.sessionToken, } ) @@ -86,8 +88,14 @@ export async function renameTeam( userSessionToken: auth.sessionToken, } ) - } catch { - return { error: 'not_found' as const } + } catch (error) { + if (error instanceof ControlApiError && error.status === 403) { + return { error: 'forbidden' as const } + } + if (error instanceof ControlApiError && error.status === 404) { + return { error: 'not_found' as const } + } + throw error } return { @@ -143,8 +151,14 @@ export async function updateMemberRole( `/external/teams/${auth.teamId}/members/${targetUserId}/role`, { userSessionToken: auth.sessionToken } ) - } catch { - return { error: 'not_found' } + } catch (error) { + if (error instanceof ControlApiError && error.status === 403) { + return { error: 'forbidden' } + } + if (error instanceof ControlApiError && error.status === 404) { + return { error: 'not_found' } + } + throw error } const updated = await controlApiRequest( 'PATCH', @@ -195,8 +209,14 @@ export async function deleteMember(auth: AuthContext, userId: string) { userSessionToken: auth.sessionToken, } ) - } catch { - return { error: 'not_found' as const } + } catch (error) { + if (error instanceof ControlApiError && error.status === 403) { + return { error: 'forbidden' as const } + } + if (error instanceof ControlApiError && error.status === 404) { + return { error: 'not_found' as const } + } + throw error } return { deleted } } diff --git a/external-rest-api/src/types.ts b/external-rest-api/src/types.ts index deb0e669d9..3e1bfb4fd3 100644 --- a/external-rest-api/src/types.ts +++ b/external-rest-api/src/types.ts @@ -21,6 +21,11 @@ export type AuthClaims = { teamId: string role: TeamRole exp: number + sessionContract?: 'v2' + sid?: string + jti?: string + sv?: number + ver?: 2 } export type ChannelMapping = { diff --git a/external-rest-api/test/routes.teamSwitchCompatibility.test.ts b/external-rest-api/test/routes.teamSwitchCompatibility.test.ts new file mode 100644 index 0000000000..bd643e0b29 --- /dev/null +++ b/external-rest-api/test/routes.teamSwitchCompatibility.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import request from 'supertest' +import { createMeRouter } from '../src/routes/me.js' +import { createTeamRouter } from '../src/routes/team.js' + +const mocks = vi.hoisted(() => ({ + controlApiRequest: vi.fn(), + verifyToken: vi.fn(), +})) + +vi.mock('../src/controlApiClient.js', () => ({ + controlApiRequest: mocks.controlApiRequest, +})) +vi.mock('../src/authToken.js', () => ({ + verifyToken: mocks.verifyToken, +})) + +function app() { + const value = express() + value.use(express.json()) + value.use(createMeRouter()) + value.use(createTeamRouter()) + return value +} + +describe('legacy team-switch compatibility', () => { + beforeEach(() => { + mocks.controlApiRequest.mockReset() + mocks.verifyToken.mockReset() + mocks.verifyToken.mockImplementation((token: string) => { + if (token === 'team-a-token') { + return { + userId: 'user-1', + email: 'user@example.com', + teamId: 'team-a', + role: 'admin', + exp: 9_999_999_999, + sessionContract: 'v1', + } + } + if (token === 'team-b-token') { + return { + userId: 'user-1', + email: 'user@example.com', + teamId: 'team-b', + role: 'admin', + exp: 9_999_999_999, + sessionContract: 'v1', + } + } + return null + }) + }) + + it('uses the replacement v1 token for the next team-targeted mutation', async () => { + mocks.controlApiRequest.mockImplementation( + async (method: string, path: string, options: Record) => { + if (method === 'GET' && path === '/external/users/user-1/memberships/team-b') { + return { team_id: 'team-b', team_name: 'Team B', role: 'admin' } + } + if (method === 'POST' && path === '/external/auth/session-token') { + expect(options).toMatchObject({ + userSessionToken: 'team-a-token', + body: { userId: 'user-1', teamId: 'team-b', role: 'admin' }, + }) + return { token: 'team-b-token', sessionContract: 'v1', deprecated: true } + } + if (method === 'PUT' && path === '/external/teams/team-b/name') { + expect(options).toMatchObject({ userSessionToken: 'team-b-token' }) + return { id: 'team-b', name: 'Renamed Team B' } + } + throw new Error(`unexpected Control API request: ${method} ${path}`) + } + ) + + const switchResponse = await request(app()) + .post('/me/switch-team') + .set('authorization', 'Bearer team-a-token') + .send({ teamId: 'team-b' }) + .expect(200) + + expect(switchResponse.body.token).toBe('team-b-token') + + await request(app()) + .put('/team/name') + .set('authorization', `Bearer ${switchResponse.body.token}`) + .send({ name: 'Renamed Team B' }) + .expect(200) + + const paths = mocks.controlApiRequest.mock.calls.map(call => String(call[1])) + expect(paths).toContain('/external/teams/team-b/name') + expect(paths).not.toContain('/external/teams/team-a/name') + }) +}) diff --git a/external-rest-api/test/services.legacyAuthorityErrors.test.ts b/external-rest-api/test/services.legacyAuthorityErrors.test.ts new file mode 100644 index 0000000000..0653375815 --- /dev/null +++ b/external-rest-api/test/services.legacyAuthorityErrors.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ControlApiError } from '../src/controlApiClient.js' +import { createDesktopAuthorization } from '../src/services/invitationsService.js' +import { getMe, switchTeam } from '../src/services/meService.js' +import { + deleteMember, + getCurrentTeam, + renameTeam, + updateMemberRole, +} from '../src/services/teamService.js' + +const clientMock = vi.hoisted(() => ({ controlApiRequest: vi.fn() })) +vi.mock('../src/controlApiClient.js', async importOriginal => { + const actual = await importOriginal() + return { ...actual, controlApiRequest: clientMock.controlApiRequest } +}) + +const auth = { + userId: 'user-1', + email: 'user@example.com', + teamId: 'team-1', + role: 'admin' as const, + sessionToken: 'session-token', +} + +function upstream(status: number): ControlApiError { + return new ControlApiError('control api failure', status, { + error: status === 503 ? 'authority_unavailable' : 'not_found', + }) +} + +describe('legacy authority adapters', () => { + beforeEach(() => { + clientMock.controlApiRequest.mockReset() + }) + + it('maps only an explicit missing user or membership to absence', async () => { + clientMock.controlApiRequest.mockRejectedValueOnce(upstream(404)) + await expect(getMe(auth.userId, auth.teamId, auth.sessionToken)).resolves.toBeNull() + + clientMock.controlApiRequest.mockRejectedValueOnce(upstream(503)) + await expect(getMe(auth.userId, auth.teamId, auth.sessionToken)).rejects.toMatchObject({ + status: 503, + }) + + clientMock.controlApiRequest.mockRejectedValueOnce(upstream(404)) + await expect( + switchTeam(auth.userId, auth.email, 'team-2', auth.sessionToken) + ).resolves.toBeNull() + + clientMock.controlApiRequest.mockRejectedValueOnce(upstream(503)) + await expect( + switchTeam(auth.userId, auth.email, 'team-2', auth.sessionToken) + ).rejects.toMatchObject({ status: 503 }) + }) + + it('does not convert current-team authority outages into an empty team', async () => { + clientMock.controlApiRequest.mockRejectedValueOnce(upstream(404)) + await expect(getCurrentTeam(auth)).resolves.toBeNull() + + clientMock.controlApiRequest.mockRejectedValueOnce(upstream(503)) + await expect(getCurrentTeam(auth)).rejects.toMatchObject({ status: 503 }) + }) + + it('keeps rename denial, absence, and authority unavailability distinct', async () => { + clientMock.controlApiRequest.mockRejectedValueOnce(upstream(403)) + await expect(renameTeam(auth, 'Renamed')).resolves.toEqual({ error: 'forbidden' }) + + clientMock.controlApiRequest.mockRejectedValueOnce(upstream(404)) + await expect(renameTeam(auth, 'Renamed')).resolves.toEqual({ error: 'not_found' }) + + clientMock.controlApiRequest.mockRejectedValueOnce(upstream(503)) + await expect(renameTeam(auth, 'Renamed')).rejects.toMatchObject({ status: 503 }) + }) + + it('keeps member-read denial distinct from missing targets and outages', async () => { + clientMock.controlApiRequest.mockRejectedValueOnce(upstream(403)) + await expect(updateMemberRole(auth, 'user-2', 'member')).resolves.toEqual({ + error: 'forbidden', + }) + + clientMock.controlApiRequest.mockRejectedValueOnce(upstream(404)) + await expect(updateMemberRole(auth, 'user-2', 'member')).resolves.toEqual({ + error: 'not_found', + }) + + clientMock.controlApiRequest.mockRejectedValueOnce(upstream(503)) + await expect(updateMemberRole(auth, 'user-2', 'member')).rejects.toMatchObject({ + status: 503, + }) + }) + + it('keeps member-delete denial distinct from missing targets and outages', async () => { + clientMock.controlApiRequest.mockRejectedValueOnce(upstream(403)) + await expect(deleteMember(auth, 'user-2')).resolves.toEqual({ error: 'forbidden' }) + + clientMock.controlApiRequest.mockRejectedValueOnce(upstream(404)) + await expect(deleteMember(auth, 'user-2')).resolves.toEqual({ error: 'not_found' }) + + clientMock.controlApiRequest.mockRejectedValueOnce(upstream(503)) + await expect(deleteMember(auth, 'user-2')).rejects.toMatchObject({ status: 503 }) + }) + + it('does not convert desktop-authorization authority outages into bad passwords', async () => { + clientMock.controlApiRequest.mockRejectedValueOnce(upstream(403)) + await expect(createDesktopAuthorization(auth, 'wrong-password')).resolves.toEqual({ + error: 'invalid_password', + }) + + clientMock.controlApiRequest.mockRejectedValueOnce(upstream(503)) + await expect(createDesktopAuthorization(auth, 'correct-password')).rejects.toMatchObject({ + status: 503, + }) + }) +}) From b15d97a2434b7f601c4fb8d1ed47c42c37b16ef4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 10 Aug 2026 23:02:37 -0600 Subject: [PATCH 012/528] feat(access): add request-scoped execution budgets Add one deadline and cancellation domain with hard clamps for producer work, objects, decoded bytes, paths, relationships, database rows, Kubernetes calls, memoization, cursors, and responses. Support bounded child reservations, request-wide concurrency, deadline-derived statement timeouts, preflight request validation, unused-capacity return, and late-completion rejection. --- .../services/access/accessExecutionBudget.ts | 405 ++++++++++++++++++ .../test/accessExecutionBudget.test.ts | 110 +++++ 2 files changed, 515 insertions(+) create mode 100644 control-api/src/services/access/accessExecutionBudget.ts create mode 100644 control-api/test/accessExecutionBudget.test.ts diff --git a/control-api/src/services/access/accessExecutionBudget.ts b/control-api/src/services/access/accessExecutionBudget.ts new file mode 100644 index 0000000000..84848eb2a7 --- /dev/null +++ b/control-api/src/services/access/accessExecutionBudget.ts @@ -0,0 +1,405 @@ +export const ACCESS_EXECUTION_LIMIT_CLAMPS = Object.freeze({ + publicPageSize: 100, + catalogDeadlineMs: 5_000, + actionDeadlineMs: 2_000, + statementTimeoutMs: 2_500, + producerCalls: 32, + producerConcurrency: 4, + keyCandidatesPerCall: 101, + objects: 1_000, + decodedBytes: 8 * 1024 * 1024, + objectBytes: 512 * 1024, + accessPaths: 2_048, + relationships: 4_096, + relationshipDepth: 1, + dbRowsReturned: 4_096, + kubernetesPageSize: 100, + exactKubernetesGets: 4, + memoEntries: 512, + memoBytes: 4 * 1024 * 1024, + cursorBytes: 16 * 1024, + responseBytes: 4 * 1024 * 1024, +}) + +export type AccessExecutionKind = 'catalog' | 'action' + +export type AccessExecutionLimitName = keyof typeof ACCESS_EXECUTION_LIMIT_CLAMPS +export type AccessExecutionLimits = Readonly> + +export type AccessBudgetCounterKind = + | 'producerCalls' + | 'objects' + | 'decodedBytes' + | 'accessPaths' + | 'relationships' + | 'dbRowsReturned' + | 'exactKubernetesGets' + | 'memoEntries' + | 'memoBytes' + | 'responseBytes' + +export type AccessBudgetCharge = Readonly<{ + kind: AccessBudgetCounterKind + amount?: number + authorityRequired?: boolean +}> + +export type AccessBudgetReservation = Partial>> + +export class AccessBudgetConfigurationError extends Error { + constructor( + readonly limit: keyof AccessExecutionLimits, + readonly reason: string + ) { + super(`Invalid access execution limit ${limit}: ${reason}`) + this.name = 'AccessBudgetConfigurationError' + } +} + +export class AccessBudgetExceededError extends Error { + constructor( + readonly limit: AccessBudgetCounterKind | 'deadline' | 'objectBytes' | 'relationshipDepth', + readonly authorityRequired: boolean + ) { + super(`Access execution budget exhausted: ${limit}`) + this.name = 'AccessBudgetExceededError' + } +} + +export class AccessExecutionCancelledError extends Error { + constructor(readonly reason: 'deadline' | 'cancelled' | 'closed') { + super(`Access execution cancelled: ${reason}`) + this.name = 'AccessExecutionCancelledError' + } +} + +class BudgetCounter { + constructor( + readonly capacity: number, + private available = capacity + ) {} + + remaining(): number { + return this.available + } + + take(amount: number): boolean { + if (amount > this.available) return false + this.available -= amount + return true + } + + giveBack(amount: number): void { + this.available = Math.min(this.capacity, this.available + amount) + } +} + +class AccessSemaphore { + private inUse = 0 + private readonly waiters: Array<{ + resolve: (release: () => void) => void + reject: (error: Error) => void + signal: AbortSignal + onAbort: () => void + }> = [] + + constructor(private readonly capacity: number) {} + + async acquire(signal: AbortSignal): Promise<() => void> { + if (signal.aborted) throw new AccessExecutionCancelledError('cancelled') + if (this.inUse < this.capacity) { + this.inUse += 1 + return this.releaseOnce() + } + + return new Promise<() => void>((resolve, reject) => { + const waiter = { + resolve, + reject, + signal, + onAbort: () => undefined, + } + waiter.onAbort = () => { + const index = this.waiters.indexOf(waiter) + if (index >= 0) this.waiters.splice(index, 1) + reject(new AccessExecutionCancelledError('cancelled')) + } + signal.addEventListener('abort', waiter.onAbort, { once: true }) + this.waiters.push(waiter) + }) + } + + private releaseOnce(): () => void { + let released = false + return () => { + if (released) return + released = true + const waiter = this.waiters.shift() + if (waiter) { + waiter.signal.removeEventListener('abort', waiter.onAbort) + waiter.resolve(this.releaseOnce()) + return + } + this.inUse = Math.max(0, this.inUse - 1) + } + } +} + +const COUNTER_KINDS: readonly AccessBudgetCounterKind[] = [ + 'producerCalls', + 'objects', + 'decodedBytes', + 'accessPaths', + 'relationships', + 'dbRowsReturned', + 'exactKubernetesGets', + 'memoEntries', + 'memoBytes', + 'responseBytes', +] + +function positiveSafeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) > 0 +} + +export function resolveAccessExecutionLimits( + requested: Partial = {} +): AccessExecutionLimits { + const values: Record = { + ...ACCESS_EXECUTION_LIMIT_CLAMPS, + } + for (const key of Object.keys(ACCESS_EXECUTION_LIMIT_CLAMPS) as Array< + keyof AccessExecutionLimits + >) { + const supplied = requested[key] + if (supplied === undefined) continue + if (!positiveSafeInteger(supplied)) { + throw new AccessBudgetConfigurationError(key, 'must be a positive safe integer') + } + if (supplied > ACCESS_EXECUTION_LIMIT_CLAMPS[key]) { + throw new AccessBudgetConfigurationError(key, 'exceeds the hard code clamp') + } + values[key] = supplied + } + return Object.freeze(values) +} + +function createCounters( + limits: AccessExecutionLimits +): Map { + return new Map(COUNTER_KINDS.map(kind => [kind, new BudgetCounter(limits[kind])])) +} + +type SharedAccessBudgetState = { + counters: Map + semaphore: AccessSemaphore + controller: AbortController + deadline: number +} + +export class AccessExecutionBudget { + readonly deadline: number + readonly signal: AbortSignal + readonly limits: AccessExecutionLimits + private closed = false + private readonly localCounters: Map | null + private readonly reservedFromParent: AccessBudgetReservation + private readonly reservationParentCounters: Map | null + private timer?: ReturnType + + private constructor( + private readonly shared: SharedAccessBudgetState, + limits: AccessExecutionLimits, + localCounters: Map | null, + reservedFromParent: AccessBudgetReservation, + reservationParentCounters: Map | null + ) { + this.deadline = shared.deadline + this.signal = shared.controller.signal + this.limits = limits + this.localCounters = localCounters + this.reservedFromParent = reservedFromParent + this.reservationParentCounters = reservationParentCounters + } + + static create( + kind: AccessExecutionKind, + options: { + limits?: Partial + now?: number + parentSignal?: AbortSignal + } = {} + ): AccessExecutionBudget { + const limits = resolveAccessExecutionLimits(options.limits) + const controller = new AbortController() + const now = options.now ?? Date.now() + const deadlineMs = kind === 'catalog' ? limits.catalogDeadlineMs : limits.actionDeadlineMs + const shared: SharedAccessBudgetState = { + counters: createCounters(limits), + semaphore: new AccessSemaphore(limits.producerConcurrency), + controller, + deadline: now + deadlineMs, + } + const budget = new AccessExecutionBudget(shared, limits, null, {}, null) + budget.timer = setTimeout(() => controller.abort('deadline'), deadlineMs) + budget.timer.unref?.() + if (options.parentSignal) { + if (options.parentSignal.aborted) controller.abort('cancelled') + else { + options.parentSignal.addEventListener('abort', () => controller.abort('cancelled'), { + once: true, + }) + } + } + return budget + } + + remainingMs(now = Date.now()): number { + return Math.max(0, this.deadline - now) + } + + statementTimeoutMs(now = Date.now()): number { + this.assertActive(now) + const timeout = Math.min(this.limits.statementTimeoutMs, this.remainingMs(now) - 250) + if (timeout < 1) throw new AccessBudgetExceededError('deadline', true) + return Math.floor(timeout) + } + + assertActive(now = Date.now()): void { + if (this.closed) throw new AccessExecutionCancelledError('closed') + if (this.signal.aborted) { + throw new AccessExecutionCancelledError( + this.signal.reason === 'deadline' || now >= this.deadline + ? 'deadline' + : this.signal.reason === 'closed' + ? 'closed' + : 'cancelled' + ) + } + if (now >= this.deadline) { + this.shared.controller.abort('deadline') + throw new AccessExecutionCancelledError('deadline') + } + } + + charge(event: AccessBudgetCharge): void { + this.assertActive() + const amount = event.amount ?? 1 + if (!positiveSafeInteger(amount)) { + throw new AccessBudgetConfigurationError(event.kind, 'charge must be a positive safe integer') + } + const counter = this.counter(event.kind) + if (!counter.take(amount)) { + throw new AccessBudgetExceededError(event.kind, event.authorityRequired ?? true) + } + } + + chargeOperationalObject(bytes: number, authorityRequired = false): void { + this.assertActive() + if (!positiveSafeInteger(bytes)) { + throw new AccessBudgetConfigurationError('objectBytes', 'must be a positive safe integer') + } + if (bytes > this.limits.objectBytes) { + throw new AccessBudgetExceededError('objectBytes', authorityRequired) + } + this.charge({ kind: 'objects', authorityRequired }) + this.charge({ kind: 'decodedBytes', amount: bytes, authorityRequired }) + } + + assertRelationshipDepth(depth: number, authorityRequired = false): void { + if (!Number.isSafeInteger(depth) || depth < 0 || depth > this.limits.relationshipDepth) { + throw new AccessBudgetExceededError('relationshipDepth', authorityRequired) + } + } + + assertPageSize(pageSize: number): void { + if (!positiveSafeInteger(pageSize) || pageSize > this.limits.publicPageSize) { + throw new AccessBudgetConfigurationError('publicPageSize', 'request exceeds the page clamp') + } + } + + assertCursorBytes(bytes: number): void { + if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > this.limits.cursorBytes) { + throw new AccessBudgetConfigurationError('cursorBytes', 'request exceeds the cursor clamp') + } + } + + child(reservation: AccessBudgetReservation): AccessExecutionBudget { + this.assertActive() + const local = new Map() + const reserved: Partial> = {} + try { + for (const kind of COUNTER_KINDS) { + const amount = reservation[kind] ?? 0 + if (!Number.isSafeInteger(amount) || amount < 0) { + throw new AccessBudgetConfigurationError(kind, 'reservation must be a safe integer') + } + if (amount === 0) { + local.set(kind, new BudgetCounter(0)) + continue + } + const parentCounter = this.counter(kind) + if (!parentCounter.take(amount)) throw new AccessBudgetExceededError(kind, true) + reserved[kind] = amount + local.set(kind, new BudgetCounter(amount)) + } + } catch (error) { + for (const [kind, amount] of Object.entries(reserved) as Array< + [AccessBudgetCounterKind, number] + >) { + this.counter(kind).giveBack(amount) + } + throw error + } + return new AccessExecutionBudget( + this.shared, + this.limits, + local, + reserved, + this.localCounters ?? this.shared.counters + ) + } + + async runProducer(work: (signal: AbortSignal) => Promise): Promise { + this.charge({ kind: 'producerCalls' }) + const release = await this.shared.semaphore.acquire(this.signal) + try { + this.assertActive() + const result = await work(this.signal) + this.assertActive() + return result + } finally { + release() + } + } + + cancel(): void { + this.shared.controller.abort('cancelled') + } + + close(): void { + if (this.closed) return + this.closed = true + if (!this.localCounters) { + if (this.timer) clearTimeout(this.timer) + this.shared.controller.abort('closed') + return + } + for (const kind of COUNTER_KINDS) { + const unused = this.localCounters.get(kind)?.remaining() ?? 0 + if (unused > 0 && (this.reservedFromParent[kind] ?? 0) > 0) { + this.reservationParentCounters?.get(kind)?.giveBack(unused) + } + } + } + + remaining(kind: AccessBudgetCounterKind): number { + return this.counter(kind).remaining() + } + + private counter(kind: AccessBudgetCounterKind): BudgetCounter { + const value = (this.localCounters ?? this.shared.counters).get(kind) + if (!value) throw new AccessBudgetConfigurationError(kind, 'counter is not configured') + return value + } +} diff --git a/control-api/test/accessExecutionBudget.test.ts b/control-api/test/accessExecutionBudget.test.ts new file mode 100644 index 0000000000..897e49cf64 --- /dev/null +++ b/control-api/test/accessExecutionBudget.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from 'vitest' +import { + AccessBudgetConfigurationError, + AccessBudgetExceededError, + AccessExecutionBudget, + AccessExecutionCancelledError, + resolveAccessExecutionLimits, +} from '../src/services/access/accessExecutionBudget.js' + +describe('AccessExecutionBudget', () => { + it('applies the accepted local clamps and rejects attempts to raise them', () => { + expect(resolveAccessExecutionLimits().publicPageSize).toBe(100) + expect(resolveAccessExecutionLimits({ producerCalls: 2 }).producerCalls).toBe(2) + expect(() => resolveAccessExecutionLimits({ producerCalls: 33 })).toThrow( + AccessBudgetConfigurationError + ) + expect(() => resolveAccessExecutionLimits({ producerCalls: 0 })).toThrow( + AccessBudgetConfigurationError + ) + }) + + it('rejects invalid public limits and cursor sizes before producer work', () => { + const budget = AccessExecutionBudget.create('catalog') + expect(() => budget.assertPageSize(101)).toThrow(AccessBudgetConfigurationError) + expect(() => budget.assertCursorBytes(16 * 1024 + 1)).toThrow(AccessBudgetConfigurationError) + expect(budget.remaining('producerCalls')).toBe(32) + budget.close() + }) + + it('charges objects, bytes, paths, relationships, rows, memo, and response limits', () => { + const budget = AccessExecutionBudget.create('catalog', { + limits: { + objects: 1, + decodedBytes: 10, + objectBytes: 8, + accessPaths: 1, + relationships: 1, + dbRowsReturned: 1, + memoEntries: 1, + memoBytes: 8, + responseBytes: 8, + }, + }) + budget.chargeOperationalObject(8) + budget.charge({ kind: 'accessPaths' }) + budget.charge({ kind: 'relationships' }) + budget.charge({ kind: 'dbRowsReturned' }) + budget.charge({ kind: 'memoEntries' }) + budget.charge({ kind: 'memoBytes', amount: 8 }) + budget.charge({ kind: 'responseBytes', amount: 8 }) + expect(() => budget.chargeOperationalObject(1)).toThrow(AccessBudgetExceededError) + expect(() => budget.assertRelationshipDepth(2)).toThrow(AccessBudgetExceededError) + budget.close() + }) + + it('reserves child capacity and returns only unused capacity on close', () => { + const parent = AccessExecutionBudget.create('catalog', { + limits: { producerCalls: 4, objects: 4 }, + }) + const child = parent.child({ producerCalls: 2, objects: 3 }) + expect(parent.remaining('producerCalls')).toBe(2) + expect(parent.remaining('objects')).toBe(1) + child.charge({ kind: 'producerCalls' }) + child.charge({ kind: 'objects', amount: 2 }) + child.close() + expect(parent.remaining('producerCalls')).toBe(3) + expect(parent.remaining('objects')).toBe(2) + parent.close() + }) + + it('bounds producer concurrency and releases slots after completion', async () => { + const budget = AccessExecutionBudget.create('catalog', { + limits: { producerCalls: 3, producerConcurrency: 1 }, + }) + let releaseFirst: (() => void) | undefined + const first = budget.runProducer(() => new Promise(resolve => (releaseFirst = resolve))) + await vi.waitFor(() => expect(releaseFirst).toBeTypeOf('function')) + const secondWork = vi.fn(async () => 'second') + const second = budget.runProducer(secondWork) + await Promise.resolve() + expect(secondWork).not.toHaveBeenCalled() + releaseFirst!() + await expect(first).resolves.toBeUndefined() + await expect(second).resolves.toBe('second') + budget.close() + }) + + it('cancels queued and late producer completion without returning a result', async () => { + const budget = AccessExecutionBudget.create('catalog', { + limits: { producerCalls: 2, producerConcurrency: 1 }, + }) + let finish: (() => void) | undefined + const running = budget.runProducer(() => new Promise(resolve => (finish = resolve))) + await vi.waitFor(() => expect(finish).toBeTypeOf('function')) + const queued = budget.runProducer(async () => 'late') + budget.cancel() + finish!() + await expect(running).rejects.toBeInstanceOf(AccessExecutionCancelledError) + await expect(queued).rejects.toBeInstanceOf(AccessExecutionCancelledError) + budget.close() + }) + + it('derives the statement timeout from the same absolute deadline', () => { + const now = Date.now() + const budget = AccessExecutionBudget.create('action', { now }) + expect(budget.statementTimeoutMs(now)).toBe(1_750) + expect(() => budget.statementTimeoutMs(now + 1_751)).toThrow(AccessBudgetExceededError) + budget.close() + }) +}) From 7fc64e03637baa30f49bacf0b3f6f534d8ac5ece Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 10 Aug 2026 23:20:46 -0600 Subject: [PATCH 013/528] feat(access): index operational resource relationships Add bounded Kubernetes list/watch ingestion with staging generations and atomic promotion. Preserve provider lifecycle and repeated relationship instances without materializing user access. Validate resource shape, byte, page, deadline, cancellation, and relationship boundaries. --- control-api/src/config.ts | 12 + control-api/src/k8s.ts | 169 ++++- control-api/src/main.ts | 25 + .../services/access/operationalAccessIndex.ts | 575 ++++++++++++++++++ .../access/operationalAccessIndexer.ts | 308 ++++++++++ .../access/operationalAccessProjection.ts | 462 ++++++++++++++ control-api/src/services/resourceService.ts | 122 ++++ control-api/src/types.ts | 4 + .../test/operationalAccessIndexer.test.ts | 132 ++++ .../test/operationalAccessProjection.test.ts | 137 +++++ .../test/services.resourceService.test.ts | 89 +++ 11 files changed, 2034 insertions(+), 1 deletion(-) create mode 100644 control-api/src/services/access/operationalAccessIndex.ts create mode 100644 control-api/src/services/access/operationalAccessIndexer.ts create mode 100644 control-api/src/services/access/operationalAccessProjection.ts create mode 100644 control-api/test/operationalAccessIndexer.test.ts create mode 100644 control-api/test/operationalAccessProjection.test.ts diff --git a/control-api/src/config.ts b/control-api/src/config.ts index 1c713947d2..99ee5f8df9 100644 --- a/control-api/src/config.ts +++ b/control-api/src/config.ts @@ -270,6 +270,8 @@ type Config = { // SharedFileSystem — namespace housing both the CRD and the per-SFS // workspace-files-controller Service. sharedFilesystemsNamespace: string + operationalAccessIndexerEnabled: boolean + operationalAccessIndexerRetryMs: number // Audience claim used by browsing JWTs; must match WSF_JWT_AUDIENCE on // the per-SFS wfc Deployment. The signing key is reused from rpcJwtPrivateKey. wfcJwtAudience: string @@ -1232,6 +1234,16 @@ export const config: Config = { // CONTROL_API_SHARED_FILESYSTEMS_NAMESPACE override still wins when set. sharedFilesystemsNamespace: process.env.CONTROL_API_SHARED_FILESYSTEMS_NAMESPACE || HOSTS_NAMESPACE, + // The operational resource index stores provider lifecycle/relationships only. It is not an + // effective-access cache. Keep the worker opt-in until the controlled list/watch measurements + // in Task 106 are accepted; catalog/action rollout remains independently disabled. + operationalAccessIndexerEnabled: + (process.env.CONTROL_API_OPERATIONAL_ACCESS_INDEXER_ENABLED ?? 'false') === 'true', + operationalAccessIndexerRetryMs: intervalMsFromEnv( + 'CONTROL_API_OPERATIONAL_ACCESS_INDEXER_RETRY_MS', + 5_000, + 100 + ), wfcJwtAudience: process.env.CONTROL_API_WFC_JWT_AUDIENCE || 'workspace-files-controller', wfcTokenTtlSeconds: Number(process.env.CONTROL_API_WFC_TOKEN_TTL_SECONDS || 300), gfsTokenAudience: process.env.CONTROL_API_GFS_JWT_AUDIENCE || 'gfs-controller', diff --git a/control-api/src/k8s.ts b/control-api/src/k8s.ts index fbda2f6f73..043756b455 100644 --- a/control-api/src/k8s.ts +++ b/control-api/src/k8s.ts @@ -7,11 +7,17 @@ import { type AllowedModelsConfigMapMaterializer, LlmAllowedModelsConfigMapWriter, } from './services/llmAllowedModelsConfigMap.js' -import { ResourceService, mergeAnnotationsForReplace } from './services/resourceService.js' +import { + type ResourceListPage, + ResourceService, + mergeAnnotationsForReplace, +} from './services/resourceService.js' import type { SecretConstraintOptions } from './services/secretConstraints.js' import type { SecretSnapshot } from './services/secretRepository.js' import { type DeleteSecretSummary, SecretService } from './services/secretService.js' import { + CLERUM_GROUP, + CLERUM_VERSION, ClerumResourceType, HostOverview, ResourcePreconditions, @@ -44,6 +50,42 @@ export const ALLOWED_READ_DIRS: readonly string[] = Object.freeze([ export const MAX_ARTIFACT_BYTES = 50 * 1024 * 1024 export const MAX_ARTIFACT_LISTING_BYTES = 1 * 1024 * 1024 +export const K8S_WATCH_QUEUE_LIMIT_CLAMPS = Object.freeze({ + maxPendingEvents: 1_000, + maxPendingBytes: 8 * 1024 * 1024, + maxObjectBytes: 512 * 1024, +}) + +export type K8sWatchQueueLimits = Readonly<{ + maxPendingEvents: number + maxPendingBytes: number + maxObjectBytes: number +}> + +export class K8sWatchQueueLimitError extends Error { + constructor(readonly limit: keyof K8sWatchQueueLimits) { + super(`Kubernetes watch event budget exceeded: ${limit}`) + this.name = 'K8sWatchQueueLimitError' + } +} + +function resolvedWatchQueueLimits( + requested: Partial = {} +): K8sWatchQueueLimits { + const resolved: Record = { + ...K8S_WATCH_QUEUE_LIMIT_CLAMPS, + } + for (const limit of Object.keys(resolved) as Array) { + const supplied = requested[limit] + if (supplied === undefined) continue + if (!Number.isSafeInteger(supplied) || supplied < 1 || supplied > resolved[limit]) { + throw new Error(`Invalid Kubernetes watch queue limit: ${limit}`) + } + resolved[limit] = supplied + } + return Object.freeze(resolved) +} + export class ExecOutputLimitError extends Error { constructor( readonly maxBytes: number, @@ -231,6 +273,131 @@ export class K8sGateway { return this.resources.listResource(plural, namespace) } + async listResourcePage( + plural: ClerumResourceType, + namespace: string, + options: { + limit: number + continueToken?: string + resourceVersion?: string + timeoutSeconds: number + signal: AbortSignal + } + ): Promise { + return this.resources.listResourcePage(plural, namespace, options) + } + + async getResourceExact( + plural: ClerumResourceType, + name: string, + namespace: string, + options: { timeoutSeconds: number; signal: AbortSignal } + ): Promise { + return this.resources.getResourceExact(plural, name, namespace, options) + } + + async watchResource( + plural: ClerumResourceType, + namespace: string, + resourceVersion: string, + signal: AbortSignal, + onEvent: (phase: string, object: unknown) => void | Promise, + requestedQueueLimits: Partial = {} + ): Promise { + this.resources.assertNamespaceAllowed(namespace) + const queueLimits = resolvedWatchQueueLimits(requestedQueueLimits) + const watch = new k8s.Watch(this.kc) + const path = `/apis/${CLERUM_GROUP}/${CLERUM_VERSION}/namespaces/${encodeURIComponent( + namespace + )}/${plural}` + await new Promise((resolve, reject) => { + let controller: AbortController | null = null + let settled = false + let eventQueue = Promise.resolve() + let handlerError: unknown + let pendingEvents = 0 + let pendingBytes = 0 + const finish = (error?: unknown) => { + if (settled) return + settled = true + signal.removeEventListener('abort', onAbort) + void eventQueue.then( + () => { + const finalError = handlerError ?? error + if (finalError && !signal.aborted) reject(finalError) + else resolve() + }, + queueError => { + if (!signal.aborted) reject(queueError) + else resolve() + } + ) + } + const onAbort = () => { + controller?.abort() + finish() + } + signal.addEventListener('abort', onAbort, { once: true }) + if (signal.aborted) { + onAbort() + return + } + void watch + .watch( + path, + { resourceVersion, allowWatchBookmarks: true }, + (phase, object) => { + if (signal.aborted || handlerError) return + let objectBytes: number + try { + objectBytes = Buffer.byteLength(JSON.stringify(object) ?? 'null', 'utf8') + } catch { + handlerError = new K8sWatchQueueLimitError('maxObjectBytes') + controller?.abort() + finish(handlerError) + return + } + const exceededLimit = + objectBytes > queueLimits.maxObjectBytes + ? 'maxObjectBytes' + : pendingEvents + 1 > queueLimits.maxPendingEvents + ? 'maxPendingEvents' + : pendingBytes + objectBytes > queueLimits.maxPendingBytes + ? 'maxPendingBytes' + : null + if (exceededLimit) { + handlerError = new K8sWatchQueueLimitError(exceededLimit) + controller?.abort() + finish(handlerError) + return + } + pendingEvents += 1 + pendingBytes += objectBytes + eventQueue = eventQueue + .then(() => { + if (signal.aborted || handlerError) return + return onEvent(phase, object) + }) + .catch(error => { + handlerError = error + controller?.abort() + finish(error) + }) + .finally(() => { + pendingEvents -= 1 + pendingBytes -= objectBytes + }) + }, + error => finish(error) + ) + .then(value => { + controller = value + if (settled || signal.aborted || handlerError) controller.abort() + }) + .catch(finish) + }) + } + async getResource( plural: ClerumResourceType, name: string, diff --git a/control-api/src/main.ts b/control-api/src/main.ts index df89438fe6..14f8c734ab 100644 --- a/control-api/src/main.ts +++ b/control-api/src/main.ts @@ -2,8 +2,11 @@ import { config } from './config.js' import { assertDbReady, pool, rateLimitPool } from './db.js' import { K8sGateway } from './k8s.js' import { reconcileAllowedModelsConfigMapOnBoot } from './llmAllowedModelsBootReconcile.js' +import { rootLogger } from './observability/logger.js' import { logRegistryConnectionState } from './registryBootGuard.js' import { ControlApiServer } from './server.js' +import { OperationalAccessIndexer } from './services/access/operationalAccessIndexer.js' +import { resolveEffectiveUserAccessPolicy } from './services/access/userAccessRuntimePolicy.js' import { startAdminRevokedTokenCleanup, stopAdminRevokedTokenCleanup, @@ -56,6 +59,8 @@ import { } from './services/workflowScheduleWorkerCron.js' import { validateStartupGuards } from './startupGuards.js' +let stopOperationalAccessIndexer: (() => void) | null = null + async function main(): Promise { console.log('[ControlAPI] Starting') console.log(`[ControlAPI] Namespace: ${config.namespace}`) @@ -67,6 +72,8 @@ async function main(): Promise { await assertDbReady() console.log('[ControlAPI] Database schema ready') + const userAccessPolicy = await resolveEffectiveUserAccessPolicy() + console.log(`[ControlAPI] User-access policy ready: ${userAccessPolicy.policyRevision}`) // Observability only (never fatal): report whether this self-hosted deployment // holds a registry identity. Auth is derived from credential presence, so a @@ -104,6 +111,23 @@ async function main(): Promise { const gateway = new K8sGateway(config.namespace) + if (config.operationalAccessIndexerEnabled) { + const operationalIndexer = new OperationalAccessIndexer(gateway, undefined, { + retryDelayMs: config.operationalAccessIndexerRetryMs, + }) + const runningIndexer = operationalIndexer.start() + stopOperationalAccessIndexer = runningIndexer.stop + void runningIndexer.completion.catch(error => { + rootLogger.error( + { err: error, event: 'operational_access_indexer_stopped' }, + 'operational access index stopped unexpectedly' + ) + }) + console.log('[ControlAPI] Operational access indexer enabled') + } else { + console.log('[ControlAPI] Operational access indexer disabled') + } + // LLM catalog discovery sync cron (Fase 4). Code default off; the base deploy // sets LLM_CATALOG_SYNC_CRON_ENABLED=true. When on, the first sync runs a few // seconds after start (not awaited — boot never waits on models.dev), then @@ -249,6 +273,7 @@ main().catch(error => { stopOauthProactiveRefreshCron() stopSubscriptionCatalogSyncCron() stopWorkflowApprovalTraceProjector() + stopOperationalAccessIndexer?.() void pool.end() void rateLimitPool.end() process.exit(1) diff --git a/control-api/src/services/access/operationalAccessIndex.ts b/control-api/src/services/access/operationalAccessIndex.ts new file mode 100644 index 0000000000..a29b989200 --- /dev/null +++ b/control-api/src/services/access/operationalAccessIndex.ts @@ -0,0 +1,575 @@ +import { type DbClient, pool, withTransaction } from '../../db.js' +import type { AccessExecutionBudget } from './accessExecutionBudget.js' +import type { + OperationalObjectProjection, + OperationalRelationshipRecord, + OperationalResourceRecord, + OperationalSourceFamily, +} from './operationalAccessProjection.js' + +export type OperationalSourceStatus = 'current' | 'relisting' | 'unavailable' + +export type OperationalSourceState = Readonly<{ + environmentId: string + sourceFamily: OperationalSourceFamily + generation: number + stagingGeneration: number | null + resourceVersion: string | null + status: OperationalSourceStatus + safeErrorCode: string | null +}> + +export class OperationalSourceStateError extends Error { + constructor(readonly code: 'stale_generation' | 'source_not_current' | 'source_unavailable') { + super(`Operational access source state error: ${code}`) + this.name = 'OperationalSourceStateError' + } +} + +type TransactionRunner = (work: (db: DbClient) => Promise) => Promise + +function numberValue(value: unknown): number { + const result = Number(value) + if (!Number.isSafeInteger(result) || result < 0) { + throw new OperationalSourceStateError('source_unavailable') + } + return result +} + +function sourceState(row: unknown): OperationalSourceState { + const value = row as Record + const status = String(value.status) + if (!['current', 'relisting', 'unavailable'].includes(status)) { + throw new OperationalSourceStateError('source_unavailable') + } + return Object.freeze({ + environmentId: String(value.environment_id), + sourceFamily: String(value.source_family) as OperationalSourceFamily, + generation: numberValue(value.generation), + stagingGeneration: + value.staging_generation === null || value.staging_generation === undefined + ? null + : numberValue(value.staging_generation), + resourceVersion: typeof value.resource_version === 'string' ? value.resource_version : null, + status: status as OperationalSourceStatus, + safeErrorCode: typeof value.safe_error_code === 'string' ? value.safe_error_code : null, + }) +} + +function resourcePayload(value: OperationalResourceRecord, generation: number) { + return { + environment_id: value.environmentId, + resource_type: value.resourceType, + logical_id: value.logicalId, + source_family: value.sourceFamily, + source_generation: generation, + provider_uid: value.providerUid, + provider_resource_version: value.providerResourceVersion, + display_name: value.displayName, + enabled: value.enabled, + deleted_at: value.deletedAt, + observed_generation: value.observedGeneration, + content_bytes: value.contentBytes, + } +} + +function relationshipPayload(value: OperationalRelationshipRecord, generation: number) { + return { + environment_id: value.environmentId, + source_type: value.sourceType, + source_id: value.sourceId, + relationship_type: value.relationshipType, + target_type: value.targetType, + target_id: value.targetId, + relationship_instance_id: value.relationshipInstanceId, + behavior_attributes: value.behaviorAttributes, + source_family: value.sourceFamily, + source_provider_uid: value.sourceProviderUid, + source_resource_version: value.sourceResourceVersion, + source_generation: generation, + observed_generation: value.observedGeneration, + content_bytes: value.contentBytes, + } +} + +export class OperationalAccessIndex { + constructor( + private readonly readDb: DbClient = pool, + private readonly transaction: TransactionRunner = withTransaction + ) {} + + private async query( + db: DbClient, + budget: AccessExecutionBudget, + text: string, + values: unknown[] = [] + ) { + return budget.runProducer(async () => { + budget.assertActive() + const result = await db.query(text, values) + budget.assertActive() + if (result.rows.length > 0) { + budget.charge({ kind: 'dbRowsReturned', amount: result.rows.length }) + } + return result + }) + } + + private async configureTransaction(db: DbClient, budget: AccessExecutionBudget): Promise { + await this.query(db, budget, `SELECT set_config('statement_timeout', $1, true)`, [ + `${budget.statementTimeoutMs()}ms`, + ]) + } + + async beginRelist(input: { + environmentId: string + sourceFamily: OperationalSourceFamily + budget: AccessExecutionBudget + }): Promise { + return this.transaction(async db => { + await this.configureTransaction(db, input.budget) + const result = await this.query( + db, + input.budget, + `WITH source_state AS ( + INSERT INTO operational_catalog_source_state( + environment_id, source_family, generation, staging_generation, + status, safe_error_code, updated_at + ) + VALUES($1, $2, 1, 2, 'relisting', NULL, NOW()) + ON CONFLICT (environment_id, source_family) DO UPDATE + SET generation = operational_catalog_source_state.generation + 1, + staging_generation = operational_catalog_source_state.generation + 2, + status = 'relisting', + safe_error_code = NULL, + updated_at = NOW() + RETURNING staging_generation + ), cleared_relationships AS ( + DELETE FROM operational_relationships_staging + WHERE environment_id = $1 + AND source_family = $2 + RETURNING 1 + ), cleared_resources AS ( + DELETE FROM operational_resource_index_staging + WHERE environment_id = $1 + AND source_family = $2 + RETURNING 1 + ) + SELECT staging_generation FROM source_state`, + [input.environmentId, input.sourceFamily] + ) + return numberValue((result.rows[0] as Record).staging_generation) + }) + } + + async stageRelistPage(input: { + environmentId: string + sourceFamily: OperationalSourceFamily + stagingGeneration: number + projections: readonly OperationalObjectProjection[] + budget: AccessExecutionBudget + }): Promise { + const resources = input.projections.flatMap(projection => + projection.resources.map(value => resourcePayload(value, input.stagingGeneration)) + ) + const relationships = input.projections.flatMap(projection => + projection.relationships.map(value => relationshipPayload(value, input.stagingGeneration)) + ) + await this.transaction(async db => { + await this.configureTransaction(db, input.budget) + const result = await this.query( + db, + input.budget, + `WITH valid_generation AS ( + SELECT 1 + FROM operational_catalog_source_state + WHERE environment_id = $1 + AND source_family = $2 + AND status = 'relisting' + AND staging_generation = $3 + FOR UPDATE + ), resource_rows AS ( + SELECT value + FROM jsonb_array_elements($4::jsonb) AS value + WHERE EXISTS (SELECT 1 FROM valid_generation) + ), inserted_resources AS ( + INSERT INTO operational_resource_index_staging( + environment_id, resource_type, logical_id, source_family, source_generation, + provider_uid, provider_resource_version, display_name, enabled, deleted_at, + observed_generation, content_bytes, observed_at + ) + SELECT value->>'environment_id', value->>'resource_type', value->>'logical_id', + value->>'source_family', (value->>'source_generation')::bigint, + value->>'provider_uid', value->>'provider_resource_version', + value->>'display_name', (value->>'enabled')::boolean, + (value->>'deleted_at')::timestamptz, + (value->>'observed_generation')::bigint, + (value->>'content_bytes')::bigint, NOW() + FROM resource_rows + ON CONFLICT (environment_id, resource_type, logical_id) DO UPDATE + SET source_family = EXCLUDED.source_family, + source_generation = EXCLUDED.source_generation, + provider_uid = EXCLUDED.provider_uid, + provider_resource_version = EXCLUDED.provider_resource_version, + display_name = EXCLUDED.display_name, + enabled = EXCLUDED.enabled, + deleted_at = EXCLUDED.deleted_at, + observed_generation = EXCLUDED.observed_generation, + content_bytes = EXCLUDED.content_bytes, + observed_at = NOW() + RETURNING 1 + ), relationship_rows AS ( + SELECT value + FROM jsonb_array_elements($5::jsonb) AS value + WHERE EXISTS (SELECT 1 FROM valid_generation) + ), inserted_relationships AS ( + INSERT INTO operational_relationships_staging( + environment_id, source_type, source_id, relationship_type, target_type, target_id, + relationship_instance_id, behavior_attributes, source_family, source_provider_uid, + source_resource_version, source_generation, observed_generation, content_bytes, + observed_at + ) + SELECT value->>'environment_id', value->>'source_type', value->>'source_id', + value->>'relationship_type', value->>'target_type', value->>'target_id', + value->>'relationship_instance_id', value->'behavior_attributes', + value->>'source_family', value->>'source_provider_uid', + value->>'source_resource_version', (value->>'source_generation')::bigint, + (value->>'observed_generation')::bigint, + (value->>'content_bytes')::bigint, NOW() + FROM relationship_rows + ON CONFLICT ( + environment_id, source_type, source_id, relationship_type, + target_type, target_id, relationship_instance_id + ) DO UPDATE + SET behavior_attributes = EXCLUDED.behavior_attributes, + source_family = EXCLUDED.source_family, + source_provider_uid = EXCLUDED.source_provider_uid, + source_resource_version = EXCLUDED.source_resource_version, + source_generation = EXCLUDED.source_generation, + observed_generation = EXCLUDED.observed_generation, + content_bytes = EXCLUDED.content_bytes, + observed_at = NOW() + RETURNING 1 + ) + SELECT EXISTS (SELECT 1 FROM valid_generation) AS valid`, + [ + input.environmentId, + input.sourceFamily, + input.stagingGeneration, + JSON.stringify(resources), + JSON.stringify(relationships), + ] + ) + if (!(result.rows[0] as { valid?: boolean } | undefined)?.valid) { + throw new OperationalSourceStateError('stale_generation') + } + }) + } + + async promoteRelist(input: { + environmentId: string + sourceFamily: OperationalSourceFamily + stagingGeneration: number + resourceVersion: string + budget: AccessExecutionBudget + }): Promise { + await this.transaction(async db => { + await this.configureTransaction(db, input.budget) + const locked = await this.query( + db, + input.budget, + `SELECT staging_generation + FROM operational_catalog_source_state + WHERE environment_id = $1 AND source_family = $2 + AND status = 'relisting' + FOR UPDATE`, + [input.environmentId, input.sourceFamily] + ) + if ( + numberValue((locked.rows[0] as Record | undefined)?.staging_generation) !== + input.stagingGeneration + ) { + throw new OperationalSourceStateError('stale_generation') + } + await this.query( + db, + input.budget, + `DELETE FROM operational_resource_relationships + WHERE environment_id = $1 AND source_family = $2`, + [input.environmentId, input.sourceFamily] + ) + await this.query( + db, + input.budget, + `DELETE FROM operational_resource_index + WHERE environment_id = $1 AND source_family = $2`, + [input.environmentId, input.sourceFamily] + ) + await this.query( + db, + input.budget, + `INSERT INTO operational_resource_index( + environment_id, resource_type, logical_id, source_family, source_generation, + provider_uid, provider_resource_version, display_name, enabled, deleted_at, + observed_generation, content_bytes, observed_at + ) + SELECT environment_id, resource_type, logical_id, source_family, source_generation, + provider_uid, provider_resource_version, display_name, enabled, deleted_at, + observed_generation, content_bytes, observed_at + FROM operational_resource_index_staging + WHERE environment_id = $1 AND source_family = $2 + AND source_generation = $3`, + [input.environmentId, input.sourceFamily, input.stagingGeneration] + ) + await this.query( + db, + input.budget, + `INSERT INTO operational_resource_relationships( + environment_id, source_type, source_id, relationship_type, target_type, target_id, + relationship_instance_id, behavior_attributes, source_family, source_provider_uid, + source_resource_version, source_generation, observed_generation, content_bytes, + observed_at + ) + SELECT environment_id, source_type, source_id, relationship_type, target_type, target_id, + relationship_instance_id, behavior_attributes, source_family, source_provider_uid, + source_resource_version, source_generation, observed_generation, content_bytes, + observed_at + FROM operational_relationships_staging + WHERE environment_id = $1 AND source_family = $2 + AND source_generation = $3`, + [input.environmentId, input.sourceFamily, input.stagingGeneration] + ) + await this.query( + db, + input.budget, + `DELETE FROM operational_relationships_staging + WHERE environment_id = $1 AND source_family = $2`, + [input.environmentId, input.sourceFamily] + ) + await this.query( + db, + input.budget, + `DELETE FROM operational_resource_index_staging + WHERE environment_id = $1 AND source_family = $2`, + [input.environmentId, input.sourceFamily] + ) + await this.query( + db, + input.budget, + `UPDATE operational_catalog_source_state + SET generation = $3, + staging_generation = NULL, + resource_version = $4, + status = 'current', + last_success_at = NOW(), + safe_error_code = NULL, + updated_at = NOW() + WHERE environment_id = $1 AND source_family = $2`, + [input.environmentId, input.sourceFamily, input.stagingGeneration, input.resourceVersion] + ) + }) + } + + async applyWatchProjection(input: { + environmentId: string + sourceFamily: OperationalSourceFamily + resourceVersion: string + projection: OperationalObjectProjection + deleted: boolean + budget: AccessExecutionBudget + }): Promise { + return this.transaction(async db => { + await this.configureTransaction(db, input.budget) + const locked = await this.query( + db, + input.budget, + `SELECT generation, status + FROM operational_catalog_source_state + WHERE environment_id = $1 AND source_family = $2 + FOR UPDATE`, + [input.environmentId, input.sourceFamily] + ) + const state = locked.rows[0] as Record | undefined + if (!state || state.status !== 'current') { + throw new OperationalSourceStateError('source_not_current') + } + const generation = numberValue(state.generation) + 1 + await this.query( + db, + input.budget, + `DELETE FROM operational_resource_relationships + WHERE environment_id = $1 AND source_family = $2 + AND ( + source_provider_uid = $3 + OR (source_type = $4 AND source_id = $5) + )`, + [ + input.environmentId, + input.sourceFamily, + input.projection.providerUid, + input.projection.rootType, + input.projection.rootId, + ] + ) + await this.query( + db, + input.budget, + `DELETE FROM operational_resource_index + WHERE environment_id = $1 AND source_family = $2 + AND ( + provider_uid = $3 + OR provider_uid LIKE $3 || ':%' + OR logical_id = $4 + )`, + [ + input.environmentId, + input.sourceFamily, + input.projection.providerUid, + input.projection.rootId, + ] + ) + if (!input.deleted) { + await this.insertCurrentProjection(db, input.budget, input.projection, generation) + } + await this.query( + db, + input.budget, + `UPDATE operational_catalog_source_state + SET generation = $3, + resource_version = $4, + last_success_at = NOW(), + safe_error_code = NULL, + updated_at = NOW() + WHERE environment_id = $1 AND source_family = $2`, + [input.environmentId, input.sourceFamily, generation, input.resourceVersion] + ) + return generation + }) + } + + private async insertCurrentProjection( + db: DbClient, + budget: AccessExecutionBudget, + projection: OperationalObjectProjection, + generation: number + ): Promise { + const resources = projection.resources.map(value => resourcePayload(value, generation)) + const relationships = projection.relationships.map(value => + relationshipPayload(value, generation) + ) + await this.query( + db, + budget, + `WITH resource_rows AS ( + SELECT value FROM jsonb_array_elements($1::jsonb) AS value + ), inserted_resources AS ( + INSERT INTO operational_resource_index( + environment_id, resource_type, logical_id, source_family, source_generation, + provider_uid, provider_resource_version, display_name, enabled, deleted_at, + observed_generation, content_bytes, observed_at + ) + SELECT value->>'environment_id', value->>'resource_type', value->>'logical_id', + value->>'source_family', (value->>'source_generation')::bigint, + value->>'provider_uid', value->>'provider_resource_version', + value->>'display_name', (value->>'enabled')::boolean, + (value->>'deleted_at')::timestamptz, + (value->>'observed_generation')::bigint, + (value->>'content_bytes')::bigint, NOW() + FROM resource_rows + RETURNING 1 + ), relationship_rows AS ( + SELECT value FROM jsonb_array_elements($2::jsonb) AS value + ) + INSERT INTO operational_resource_relationships( + environment_id, source_type, source_id, relationship_type, target_type, target_id, + relationship_instance_id, behavior_attributes, source_family, source_provider_uid, + source_resource_version, source_generation, observed_generation, content_bytes, + observed_at + ) + SELECT value->>'environment_id', value->>'source_type', value->>'source_id', + value->>'relationship_type', value->>'target_type', value->>'target_id', + value->>'relationship_instance_id', value->'behavior_attributes', + value->>'source_family', value->>'source_provider_uid', + value->>'source_resource_version', (value->>'source_generation')::bigint, + (value->>'observed_generation')::bigint, + (value->>'content_bytes')::bigint, NOW() + FROM relationship_rows`, + [JSON.stringify(resources), JSON.stringify(relationships)] + ) + } + + async markSourceState(input: { + environmentId: string + sourceFamily: OperationalSourceFamily + status: Extract + safeErrorCode: string + budget: AccessExecutionBudget + }): Promise { + await this.transaction(async db => { + await this.configureTransaction(db, input.budget) + await this.query( + db, + input.budget, + `INSERT INTO operational_catalog_source_state( + environment_id, source_family, generation, status, + last_error_at, safe_error_code, updated_at + ) + VALUES($1, $2, 1, $3, NOW(), $4, NOW()) + ON CONFLICT (environment_id, source_family) DO UPDATE + SET generation = operational_catalog_source_state.generation + 1, + staging_generation = NULL, + status = EXCLUDED.status, + last_error_at = NOW(), + safe_error_code = EXCLUDED.safe_error_code, + updated_at = NOW()`, + [input.environmentId, input.sourceFamily, input.status, input.safeErrorCode] + ) + }) + } + + async recordWatchBookmark(input: { + environmentId: string + sourceFamily: OperationalSourceFamily + resourceVersion: string + budget: AccessExecutionBudget + }): Promise { + await this.transaction(async db => { + await this.configureTransaction(db, input.budget) + const result = await this.query( + db, + input.budget, + `UPDATE operational_catalog_source_state + SET resource_version = $3, + last_success_at = NOW(), + updated_at = NOW() + WHERE environment_id = $1 + AND source_family = $2 + AND status = 'current'`, + [input.environmentId, input.sourceFamily, input.resourceVersion] + ) + if ((result.rowCount ?? 0) !== 1) { + throw new OperationalSourceStateError('source_not_current') + } + }) + } + + async loadSourceStates(input: { + environmentId: string + sourceFamilies: readonly OperationalSourceFamily[] + budget: AccessExecutionBudget + }): Promise { + if (input.sourceFamilies.length === 0) return [] + const result = await this.query( + this.readDb, + input.budget, + `SELECT environment_id, source_family, generation, staging_generation, + resource_version, status, safe_error_code + FROM operational_catalog_source_state + WHERE environment_id = $1 + AND source_family = ANY($2::text[]) + ORDER BY source_family`, + [input.environmentId, input.sourceFamilies] + ) + return Object.freeze(result.rows.map(sourceState)) + } +} diff --git a/control-api/src/services/access/operationalAccessIndexer.ts b/control-api/src/services/access/operationalAccessIndexer.ts new file mode 100644 index 0000000000..f49d916b6f --- /dev/null +++ b/control-api/src/services/access/operationalAccessIndexer.ts @@ -0,0 +1,308 @@ +import { config } from '../../config.js' +import type { K8sGateway } from '../../k8s.js' +import type { ClerumResourceType } from '../../types.js' +import { AccessExecutionBudget } from './accessExecutionBudget.js' +import { OperationalAccessIndex } from './operationalAccessIndex.js' +import { + OPERATIONAL_SOURCE_FAMILIES, + type OperationalObjectProjection, + type OperationalSourceFamily, + canonicalEnvironmentId, + projectOperationalObject, +} from './operationalAccessProjection.js' + +type IndexedPlural = Extract< + ClerumResourceType, + 'hosts' | 'contexts' | 'mcpservers' | 'workflowrecipes' | 'sharedfilesystems' +> + +export type OperationalSourceSpec = Readonly<{ + family: OperationalSourceFamily + plural: IndexedPlural + namespace: string +}> + +export const operationalSourceSpecs: readonly OperationalSourceSpec[] = Object.freeze([ + { family: 'host', plural: 'hosts', namespace: config.hostsNamespace }, + { family: 'context', plural: 'contexts', namespace: config.contextsNamespace }, + { family: 'mcp_server', plural: 'mcpservers', namespace: config.mcpServersNamespace }, + { + family: 'workflow_recipe', + plural: 'workflowrecipes', + namespace: config.sandboxNamespace, + }, + { + family: 'shared_filesystem', + plural: 'sharedfilesystems', + namespace: config.sharedFilesystemsNamespace, + }, +]) + +export type OperationalIndexGateway = Pick< + K8sGateway, + 'listResourcePage' | 'watchResource' | 'getResourceExact' +> + +export class OperationalWatchExpiredError extends Error { + constructor() { + super('Operational Kubernetes watch expired') + this.name = 'OperationalWatchExpiredError' + } +} + +function objectRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {} +} + +function metadataResourceVersion(value: unknown): string { + const metadata = objectRecord(objectRecord(value).metadata) + const resourceVersion = + typeof metadata.resourceVersion === 'string' ? metadata.resourceVersion.trim() : '' + if (!resourceVersion || resourceVersion.length > 256 || resourceVersion.includes('\0')) { + throw new Error('operational_object_resource_version_invalid') + } + return resourceVersion +} + +function watchStatusCode(value: unknown): number | null { + const record = objectRecord(value) + const code = Number(record.code) + return Number.isSafeInteger(code) ? code : null +} + +function safeSourceError(error: unknown): string { + if (error instanceof OperationalWatchExpiredError) return 'watch_expired' + if (error instanceof Error && error.name === 'AbortError') return 'request_cancelled' + return 'operational_source_unavailable' +} + +function abortableDelay(milliseconds: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve() + return new Promise(resolve => { + const timer = setTimeout(done, milliseconds) + timer.unref?.() + function done() { + signal.removeEventListener('abort', done) + clearTimeout(timer) + resolve() + } + signal.addEventListener('abort', done, { once: true }) + }) +} + +export class OperationalAccessIndexer { + private readonly environmentId: string + private readonly relationshipNamespaces: Readonly<{ + context: string + mcpServer: string + sharedFilesystem: string + }> + + constructor( + private readonly gateway: OperationalIndexGateway, + private readonly index: OperationalAccessIndex = new OperationalAccessIndex(), + options: { + environmentId?: string + behaviorFingerprintKey?: string + retryDelayMs?: number + } = {} + ) { + this.environmentId = options.environmentId ?? canonicalEnvironmentId() + this.behaviorFingerprintKey = options.behaviorFingerprintKey ?? config.sessionJwtPrivateKey + this.retryDelayMs = options.retryDelayMs ?? 5_000 + if (!Number.isSafeInteger(this.retryDelayMs) || this.retryDelayMs < 100) { + throw new Error('operational index retry delay must be at least 100ms') + } + this.relationshipNamespaces = Object.freeze({ + context: config.contextsNamespace, + mcpServer: config.mcpServersNamespace, + sharedFilesystem: config.sharedFilesystemsNamespace, + }) + } + + private readonly behaviorFingerprintKey: string + private readonly retryDelayMs: number + + private projection( + spec: OperationalSourceSpec, + object: unknown, + maxObjectBytes: number + ): OperationalObjectProjection { + return projectOperationalObject({ + environmentId: this.environmentId, + plural: spec.plural, + namespace: spec.namespace, + object, + behaviorFingerprintKey: this.behaviorFingerprintKey, + maxObjectBytes, + relationshipNamespaces: this.relationshipNamespaces, + }) + } + + async reconcileSource(spec: OperationalSourceSpec, parentSignal?: AbortSignal): Promise { + const budget = AccessExecutionBudget.create('catalog', { parentSignal }) + try { + const stagingGeneration = await this.index.beginRelist({ + environmentId: this.environmentId, + sourceFamily: spec.family, + budget, + }) + let continueToken: string | undefined + let snapshotResourceVersion: string | null = null + do { + const timeoutSeconds = Math.max(1, Math.min(60, Math.ceil(budget.remainingMs() / 1_000))) + const page = await budget.runProducer(signal => + this.gateway.listResourcePage(spec.plural, spec.namespace, { + limit: budget.limits.kubernetesPageSize, + ...(continueToken ? { continueToken } : {}), + timeoutSeconds, + signal, + }) + ) + if (snapshotResourceVersion && page.resourceVersion !== snapshotResourceVersion) { + throw new Error('operational_relist_snapshot_changed') + } + snapshotResourceVersion ??= page.resourceVersion + const projections = page.items.map(item => { + const projection = this.projection(spec, item, budget.limits.objectBytes) + budget.chargeOperationalObject(projection.contentBytes, false) + if (projection.relationships.length > 0) { + budget.charge({ + kind: 'relationships', + amount: projection.relationships.length, + authorityRequired: false, + }) + } + return projection + }) + await this.index.stageRelistPage({ + environmentId: this.environmentId, + sourceFamily: spec.family, + stagingGeneration, + projections, + budget, + }) + continueToken = page.continueToken ?? undefined + } while (continueToken) + + if (!snapshotResourceVersion) { + throw new Error('operational_relist_resource_version_missing') + } + await this.index.promoteRelist({ + environmentId: this.environmentId, + sourceFamily: spec.family, + stagingGeneration, + resourceVersion: snapshotResourceVersion, + budget, + }) + return snapshotResourceVersion + } finally { + budget.close() + } + } + + async applyWatchEvent( + spec: OperationalSourceSpec, + phase: string, + object: unknown, + parentSignal?: AbortSignal + ): Promise { + if (phase === 'ERROR') { + if (watchStatusCode(object) === 410) throw new OperationalWatchExpiredError() + throw new Error('operational_watch_error') + } + if (!['ADDED', 'MODIFIED', 'DELETED', 'BOOKMARK'].includes(phase)) { + throw new Error('operational_watch_phase_invalid') + } + const resourceVersion = metadataResourceVersion(object) + const budget = AccessExecutionBudget.create('action', { parentSignal }) + try { + if (phase === 'BOOKMARK') { + await this.index.recordWatchBookmark({ + environmentId: this.environmentId, + sourceFamily: spec.family, + resourceVersion, + budget, + }) + return + } + const projection = this.projection(spec, object, budget.limits.objectBytes) + budget.chargeOperationalObject(projection.contentBytes, false) + if (projection.relationships.length > 0) { + budget.charge({ + kind: 'relationships', + amount: projection.relationships.length, + authorityRequired: false, + }) + } + await this.index.applyWatchProjection({ + environmentId: this.environmentId, + sourceFamily: spec.family, + resourceVersion, + projection, + deleted: phase === 'DELETED', + budget, + }) + } finally { + budget.close() + } + } + + async runSourceOnce(spec: OperationalSourceSpec, signal: AbortSignal): Promise { + const resourceVersion = await this.reconcileSource(spec, signal) + await this.gateway.watchResource( + spec.plural, + spec.namespace, + resourceVersion, + signal, + (phase, object) => this.applyWatchEvent(spec, phase, object, signal) + ) + if (!signal.aborted) throw new Error('operational_watch_closed') + } + + start(specs: readonly OperationalSourceSpec[] = operationalSourceSpecs): { + stop: () => void + completion: Promise + } { + if ( + specs.length !== OPERATIONAL_SOURCE_FAMILIES.length || + new Set(specs.map(spec => spec.family)).size !== OPERATIONAL_SOURCE_FAMILIES.length + ) { + throw new Error('operational index requires every source family exactly once') + } + const controller = new AbortController() + const loops = specs.map(spec => this.runSourceLoop(spec, controller.signal)) + return { + stop: () => controller.abort('shutdown'), + completion: Promise.all(loops).then(() => undefined), + } + } + + private async runSourceLoop(spec: OperationalSourceSpec, signal: AbortSignal): Promise { + while (!signal.aborted) { + try { + await this.runSourceOnce(spec, signal) + } catch (error) { + if (signal.aborted) break + const status = error instanceof OperationalWatchExpiredError ? 'relisting' : 'unavailable' + const budget = AccessExecutionBudget.create('action', { parentSignal: signal }) + try { + await this.index.markSourceState({ + environmentId: this.environmentId, + sourceFamily: spec.family, + status, + safeErrorCode: safeSourceError(error), + budget, + }) + } catch { + // The source remains unavailable if even its authoritative state cannot be recorded. + } finally { + budget.close() + } + await abortableDelay(this.retryDelayMs, signal) + } + } + } +} diff --git a/control-api/src/services/access/operationalAccessProjection.ts b/control-api/src/services/access/operationalAccessProjection.ts new file mode 100644 index 0000000000..0375dce5ea --- /dev/null +++ b/control-api/src/services/access/operationalAccessProjection.ts @@ -0,0 +1,462 @@ +import { createHash, createHmac } from 'node:crypto' +import type { ClerumResourceType } from '../../types.js' + +export const OPERATIONAL_SOURCE_FAMILIES = [ + 'host', + 'context', + 'mcp_server', + 'workflow_recipe', + 'shared_filesystem', +] as const + +export type OperationalSourceFamily = (typeof OPERATIONAL_SOURCE_FAMILIES)[number] +export type OperationalResourceType = OperationalSourceFamily | 'sandbox_app' + +export type OperationalResourceRecord = Readonly<{ + environmentId: string + resourceType: OperationalResourceType + logicalId: string + sourceFamily: OperationalSourceFamily + providerUid: string + providerResourceVersion: string + displayName: string | null + enabled: boolean + deletedAt: string | null + observedGeneration: number | null + contentBytes: number +}> + +export type OperationalRelationshipRecord = Readonly<{ + environmentId: string + sourceType: OperationalResourceType + sourceId: string + relationshipType: + | 'uses_context' + | 'includes_mcp_server' + | 'mounts_shared_filesystem' + | 'exposes_sandbox_app' + targetType: OperationalResourceType + targetId: string + relationshipInstanceId: string + behaviorAttributes: Readonly> + sourceFamily: OperationalSourceFamily + sourceProviderUid: string + sourceResourceVersion: string + observedGeneration: number | null + contentBytes: number +}> + +export type OperationalObjectProjection = Readonly<{ + family: OperationalSourceFamily + namespace: string + rootType: OperationalResourceType + rootId: string + providerUid: string + providerResourceVersion: string + contentBytes: number + resources: readonly OperationalResourceRecord[] + relationships: readonly OperationalRelationshipRecord[] +}> + +export class OperationalProjectionError extends Error { + constructor(readonly code: string) { + super(`Invalid operational resource projection: ${code}`) + this.name = 'OperationalProjectionError' + } +} + +type ResourceObject = { + metadata?: { + name?: unknown + namespace?: unknown + uid?: unknown + resourceVersion?: unknown + generation?: unknown + deletionTimestamp?: unknown + } + spec?: unknown + status?: unknown +} + +type ProjectionInput = Readonly<{ + environmentId: string + plural: Extract< + ClerumResourceType, + 'hosts' | 'contexts' | 'mcpservers' | 'workflowrecipes' | 'sharedfilesystems' + > + namespace: string + object: unknown + behaviorFingerprintKey: string + maxObjectBytes?: number + relationshipNamespaces: Readonly<{ + context: string + mcpServer: string + sharedFilesystem: string + }> +}> + +const FAMILY_BY_PLURAL: Readonly> = { + hosts: 'host', + contexts: 'context', + mcpservers: 'mcp_server', + workflowrecipes: 'workflow_recipe', + sharedfilesystems: 'shared_filesystem', +} + +function requiredBoundedString(value: unknown, code: string, max = 512): string { + const result = typeof value === 'string' ? value.trim() : '' + if (!result || result.length > max || result.includes('\0')) { + throw new OperationalProjectionError(code) + } + return result +} + +function optionalBoundedString(value: unknown, max: number): string | null { + if (typeof value !== 'string') return null + const result = value.trim() + return result && result.length <= max && !result.includes('\0') ? result : null +} + +function objectRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {} +} + +function boundedInteger(value: unknown, minimum: number, maximum: number): number | null { + return Number.isSafeInteger(value) && Number(value) >= minimum && Number(value) <= maximum + ? Number(value) + : null +} + +function canonicalJson(value: unknown): string { + if (value === undefined) return 'null' + if (value === null || typeof value !== 'object') return JSON.stringify(value) + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]` + return `{${Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`) + .join(',')}}` +} + +function behaviorFingerprint(key: string, value: unknown): string { + return createHmac('sha256', key).update(canonicalJson(value)).digest('base64url') +} + +function relationshipInstanceId(parts: readonly string[]): string { + return `rel1_${createHash('sha256').update(parts.join('\0')).digest('base64url')}` +} + +function logicalId(family: OperationalSourceFamily, namespace: string, name: string): string { + return `${namespace}/${name}` +} + +function assertBoundedObjectShape(value: unknown): void { + const pending: Array<{ value: unknown; depth: number }> = [{ value, depth: 0 }] + const visited = new Set() + let nodes = 0 + while (pending.length > 0) { + const current = pending.pop()! + nodes += 1 + if (nodes > 10_000 || current.depth > 32) { + throw new OperationalProjectionError('object_shape_exceeded') + } + if (!current.value || typeof current.value !== 'object') continue + if (visited.has(current.value)) throw new OperationalProjectionError('object_not_serializable') + visited.add(current.value) + const entries = Array.isArray(current.value) + ? current.value + : Object.values(current.value as Record) + if (entries.length > 2_048) throw new OperationalProjectionError('object_shape_exceeded') + for (const child of entries) pending.push({ value: child, depth: current.depth + 1 }) + } +} + +function contentBytes(value: unknown, maximum = Number.MAX_SAFE_INTEGER): number { + try { + assertBoundedObjectShape(value) + const bytes = Buffer.byteLength(JSON.stringify(value), 'utf8') + if (bytes > maximum) throw new OperationalProjectionError('object_bytes_exceeded') + return bytes + } catch (error) { + if (error instanceof OperationalProjectionError) throw error + throw new OperationalProjectionError('object_not_serializable') + } +} + +function displayName(family: OperationalSourceFamily, name: string, spec: Record) { + if (family === 'host') return optionalBoundedString(spec.host, 200) ?? name + return optionalBoundedString(spec.displayName, 200) ?? name +} + +function relationship(input: { + environmentId: string + sourceType: OperationalResourceType + sourceId: string + relationshipType: OperationalRelationshipRecord['relationshipType'] + targetType: OperationalResourceType + targetId: string + behaviorAttributes?: Record + instanceParts: readonly string[] + family: OperationalSourceFamily + providerUid: string + providerResourceVersion: string + observedGeneration: number | null +}): OperationalRelationshipRecord { + const behaviorAttributes = Object.freeze({ ...(input.behaviorAttributes ?? {}) }) + return Object.freeze({ + environmentId: input.environmentId, + sourceType: input.sourceType, + sourceId: input.sourceId, + relationshipType: input.relationshipType, + targetType: input.targetType, + targetId: input.targetId, + relationshipInstanceId: relationshipInstanceId(input.instanceParts), + behaviorAttributes, + sourceFamily: input.family, + sourceProviderUid: input.providerUid, + sourceResourceVersion: input.providerResourceVersion, + observedGeneration: input.observedGeneration, + contentBytes: contentBytes(behaviorAttributes), + }) +} + +function stringArray(value: unknown, maxItems = 256): string[] { + if (!Array.isArray(value) || value.length > maxItems) { + if (Array.isArray(value)) throw new OperationalProjectionError('relationship_fanout_exceeded') + return [] + } + return value.map((item, index) => requiredBoundedString(item, `relationship_${index}_invalid`)) +} + +function contextRelationships(params: { + environmentId: string + rootId: string + spec: Record + family: OperationalSourceFamily + providerUid: string + providerResourceVersion: string + observedGeneration: number | null + relationshipNamespaces: ProjectionInput['relationshipNamespaces'] +}): OperationalRelationshipRecord[] { + const relationships = stringArray(params.spec.mcpServers).map(server => + relationship({ + ...params, + sourceType: 'context', + sourceId: params.rootId, + relationshipType: 'includes_mcp_server', + targetType: 'mcp_server', + targetId: `${params.relationshipNamespaces.mcpServer}/${server}`, + instanceParts: [ + 'context', + params.rootId, + 'mcp', + `${params.relationshipNamespaces.mcpServer}/${server}`, + ], + }) + ) + const mounts = Array.isArray(params.spec.sharedFileSystems) ? params.spec.sharedFileSystems : [] + if (mounts.length > 256) throw new OperationalProjectionError('relationship_fanout_exceeded') + for (const [index, value] of mounts.entries()) { + const mount = objectRecord(value) + const name = requiredBoundedString(mount.name, `shared_filesystem_${index}_name_invalid`) + const mountPath = requiredBoundedString( + mount.mountPath, + `shared_filesystem_${index}_mount_path_invalid`, + 1_024 + ) + relationships.push( + relationship({ + ...params, + sourceType: 'context', + sourceId: params.rootId, + relationshipType: 'mounts_shared_filesystem', + targetType: 'shared_filesystem', + targetId: `${params.relationshipNamespaces.sharedFilesystem}/${name}`, + behaviorAttributes: { mountPath, readOnly: true }, + instanceParts: [ + 'context', + params.rootId, + 'sfs', + `${params.relationshipNamespaces.sharedFilesystem}/${name}`, + mountPath, + ], + }) + ) + } + return relationships +} + +export function projectOperationalObject(input: ProjectionInput): OperationalObjectProjection { + const raw = objectRecord(input.object) as ResourceObject + const metadata = objectRecord(raw.metadata) + const spec = objectRecord(raw.spec) + const name = requiredBoundedString(metadata.name, 'metadata_name_invalid', 253) + const namespace = + optionalBoundedString(metadata.namespace, 253) ?? + requiredBoundedString(input.namespace, 'namespace_invalid', 253) + if (namespace !== input.namespace) throw new OperationalProjectionError('namespace_mismatch') + const providerUid = requiredBoundedString(metadata.uid, 'metadata_uid_invalid', 256) + const providerResourceVersion = requiredBoundedString( + metadata.resourceVersion, + 'metadata_resource_version_invalid', + 256 + ) + const family = FAMILY_BY_PLURAL[input.plural] + const rootId = logicalId(family, namespace, name) + const observedGeneration = boundedInteger(metadata.generation, 0, Number.MAX_SAFE_INTEGER) + const deletedAt = optionalBoundedString(metadata.deletionTimestamp, 128) + const encodedBytes = contentBytes(input.object, input.maxObjectBytes ?? 512 * 1024) + const enabled = !deletedAt && spec.enabled !== false + const root: OperationalResourceRecord = Object.freeze({ + environmentId: requiredBoundedString(input.environmentId, 'environment_id_invalid', 512), + resourceType: family, + logicalId: rootId, + sourceFamily: family, + providerUid, + providerResourceVersion, + displayName: displayName(family, name, spec), + enabled, + deletedAt, + observedGeneration, + contentBytes: encodedBytes, + }) + const common = { + environmentId: root.environmentId, + rootId, + family, + providerUid, + providerResourceVersion, + observedGeneration, + } + const relationships: OperationalRelationshipRecord[] = [] + const resources: OperationalResourceRecord[] = [root] + + if (family === 'host' || family === 'mcp_server') { + const contextRef = optionalBoundedString(spec.contextRef, 253) + if (contextRef) { + const model = objectRecord(spec.model) + const behavior: Record = + family === 'host' + ? { + modelProvider: optionalBoundedString(model.provider, 100) ?? '', + modelName: optionalBoundedString(model.name, 200) ?? '', + executionPolicyFingerprint: behaviorFingerprint(input.behaviorFingerprintKey, { + secretRef: optionalBoundedString(spec.secretRef, 253), + allowedModels: spec.allowedModels ?? null, + }), + } + : {} + relationships.push( + relationship({ + ...common, + sourceType: family, + sourceId: rootId, + relationshipType: 'uses_context', + targetType: 'context', + targetId: `${input.relationshipNamespaces.context}/${contextRef}`, + behaviorAttributes: behavior, + instanceParts: [ + family, + rootId, + 'context', + `${input.relationshipNamespaces.context}/${contextRef}`, + ], + }) + ) + } + } else if (family === 'context') { + relationships.push( + ...contextRelationships({ + ...common, + spec, + relationshipNamespaces: input.relationshipNamespaces, + }) + ) + } else if (family === 'workflow_recipe') { + const contextRef = optionalBoundedString(spec.contextRef, 253) + if (contextRef) { + relationships.push( + relationship({ + ...common, + sourceType: 'workflow_recipe', + sourceId: rootId, + relationshipType: 'uses_context', + targetType: 'context', + targetId: `${input.relationshipNamespaces.context}/${contextRef}`, + behaviorAttributes: { + runtimePolicyFingerprint: behaviorFingerprint(input.behaviorFingerprintKey, { + runtimeEgress: spec.runtimeEgress ?? null, + }), + }, + instanceParts: [ + 'workflow_recipe', + rootId, + 'context', + `${input.relationshipNamespaces.context}/${contextRef}`, + ], + }) + ) + } + const ui = objectRecord(spec.ui) + const workloadRef = optionalBoundedString(ui.workloadRef, 63) + const port = boundedInteger(ui.port, 1, 65_535) + if (workloadRef && port !== null) { + const appId = rootId + resources.push( + Object.freeze({ + ...root, + resourceType: 'sandbox_app', + logicalId: appId, + providerUid: `${providerUid}:ui`, + displayName: optionalBoundedString(ui.title, 100) ?? name, + }) + ) + relationships.push( + relationship({ + ...common, + sourceType: 'workflow_recipe', + sourceId: rootId, + relationshipType: 'exposes_sandbox_app', + targetType: 'sandbox_app', + targetId: appId, + behaviorAttributes: { + workloadRef, + port, + defaultPath: optionalBoundedString(ui.defaultPath, 1_024) ?? '/', + runtimePolicyFingerprint: behaviorFingerprint(input.behaviorFingerprintKey, { + workloadRef, + port, + runtimeEgress: spec.runtimeEgress ?? null, + oauthClients: spec.oauthClients ?? null, + }), + }, + instanceParts: ['workflow_recipe', rootId, 'sandbox_app', appId, workloadRef], + }) + ) + } + } + + return Object.freeze({ + family, + namespace, + rootType: family, + rootId, + providerUid, + providerResourceVersion, + contentBytes: encodedBytes, + resources: Object.freeze(resources), + relationships: Object.freeze(relationships), + }) +} + +export function canonicalEnvironmentId(env: NodeJS.ProcessEnv = process.env): string { + const environment = env.TRACING_ENVIRONMENT?.trim() || 'development' + const cluster = + env.TRACING_CLUSTER_NAME?.trim() || env.KUBERNETES_CLUSTER_NAME?.trim() || 'local-cluster' + return `${requiredBoundedString(environment, 'environment_name_invalid', 253)}:${requiredBoundedString( + cluster, + 'cluster_name_invalid', + 253 + )}` +} diff --git a/control-api/src/services/resourceService.ts b/control-api/src/services/resourceService.ts index 4d29fd161a..ffa47e7ec7 100644 --- a/control-api/src/services/resourceService.ts +++ b/control-api/src/services/resourceService.ts @@ -58,6 +58,43 @@ export class K8sConflictError extends Error { } } +export type ResourceListPage = Readonly<{ + items: readonly unknown[] + continueToken: string | null + resourceVersion: string +}> + +type BoundedKubernetesRequest = Readonly<{ + signal: AbortSignal + timeoutSeconds: number +}> + +type KubernetesCallOptions = k8s.ConfigurationOptions + +function kubernetesAbortOptions(signal: AbortSignal): KubernetesCallOptions { + const middleware: k8s.ObservableMiddleware = { + pre: context => { + context.setSignal(signal) + return new k8s.Observable(Promise.resolve(context)) + }, + post: context => new k8s.Observable(Promise.resolve(context)), + } + return { + middleware: [middleware], + middlewareMergeStrategy: 'append', + } +} + +async function runKubernetesTransportCall( + signal: AbortSignal, + invoke: (options: KubernetesCallOptions) => Promise +): Promise { + if (signal.aborted) throw signal.reason ?? new Error('request aborted') + const result = await invoke(kubernetesAbortOptions(signal)) + if (signal.aborted) throw signal.reason ?? new Error('request aborted') + return result +} + export type MutableResourceSnapshot = { metadata?: { annotations?: Record @@ -170,6 +207,12 @@ export class ResourceService { return primary || this.defaultNamespace } + assertNamespaceAllowed(namespace: string): void { + if (!this.allowedNamespaces.has(namespace)) { + throw new Error(`Namespace not allowed: ${namespace}`) + } + } + private listNamespacesForPlural(plural: ClerumResourceType): string[] { const namespaces = new Set() addNonEmpty(namespaces, this.defaultNamespaces[plural]) @@ -206,6 +249,85 @@ export class ResourceService { return res.items || [] } + async listResourcePage( + plural: ClerumResourceType, + namespace: string, + options: BoundedKubernetesRequest & { + limit: number + continueToken?: string + resourceVersion?: string + } + ): Promise { + if (!Number.isSafeInteger(options.limit) || options.limit < 1 || options.limit > 100) { + throw new Error('Kubernetes list limit must be between 1 and 100') + } + if ( + !Number.isSafeInteger(options.timeoutSeconds) || + options.timeoutSeconds < 1 || + options.timeoutSeconds > 60 + ) { + throw new Error('Kubernetes request timeout must be between 1 and 60 seconds') + } + const resolvedNamespace = this.resolveNamespace(plural, namespace) + const response = (await runKubernetesTransportCall(options.signal, callOptions => + this.customApi.listNamespacedCustomObject( + { + group: CLERUM_GROUP, + version: CLERUM_VERSION, + namespace: resolvedNamespace, + plural, + limit: options.limit, + _continue: options.continueToken, + resourceVersion: options.resourceVersion, + timeoutSeconds: options.timeoutSeconds, + }, + callOptions + ) + )) as ResourceListResponse + const resourceVersion = String(response.metadata?.resourceVersion || '').trim() + if (!resourceVersion) throw new Error('Kubernetes list response omitted resourceVersion') + return Object.freeze({ + items: Object.freeze([...(response.items ?? [])]), + continueToken: String(response.metadata?.continue || '').trim() || null, + resourceVersion, + }) + } + + async getResourceExact( + plural: ClerumResourceType, + name: string, + namespace: string, + options: BoundedKubernetesRequest + ): Promise { + if ( + !Number.isSafeInteger(options.timeoutSeconds) || + options.timeoutSeconds < 1 || + options.timeoutSeconds > 60 + ) { + throw new Error('Kubernetes request timeout must be between 1 and 60 seconds') + } + const resolvedNamespace = this.resolveNamespace(plural, namespace) + try { + return await runKubernetesTransportCall(options.signal, callOptions => + this.customApi.getNamespacedCustomObject( + { + group: CLERUM_GROUP, + version: CLERUM_VERSION, + namespace: resolvedNamespace, + plural, + name, + }, + callOptions + ) + ) + } catch (error) { + if (extractK8sStatus(error) === 404) { + throw new K8sNotFoundError(`${plural}/${name} not found in namespace ${resolvedNamespace}`) + } + throw error + } + } + async getResource( plural: ClerumResourceType, name: string, diff --git a/control-api/src/types.ts b/control-api/src/types.ts index 173e98f92a..3c39f261c6 100644 --- a/control-api/src/types.ts +++ b/control-api/src/types.ts @@ -31,6 +31,10 @@ export interface ClerumResource> { export interface ResourceListResponse { items: unknown[] + metadata?: { + continue?: string + resourceVersion?: string + } } export interface HostOverview { diff --git a/control-api/test/operationalAccessIndexer.test.ts b/control-api/test/operationalAccessIndexer.test.ts new file mode 100644 index 0000000000..448413a256 --- /dev/null +++ b/control-api/test/operationalAccessIndexer.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OperationalAccessIndex } from '../src/services/access/operationalAccessIndex.js' +import { OperationalAccessIndexer } from '../src/services/access/operationalAccessIndexer.js' + +function hostObject(name: string, resourceVersion: string) { + return { + metadata: { + name, + namespace: 'mcp-host', + uid: `uid-${name}`, + resourceVersion, + }, + spec: { host: name, contextRef: 'ctx-a', secretRef: 'secret-a' }, + } +} + +function fakeIndex() { + return { + beginRelist: vi.fn().mockResolvedValue(2), + stageRelistPage: vi.fn().mockResolvedValue(undefined), + promoteRelist: vi.fn().mockResolvedValue(undefined), + applyWatchProjection: vi.fn().mockResolvedValue(3), + recordWatchBookmark: vi.fn().mockResolvedValue(undefined), + markSourceState: vi.fn().mockResolvedValue(undefined), + } +} + +describe('operational access indexer', () => { + it('reconciles bounded Kubernetes pages into one atomic source generation', async () => { + const index = fakeIndex() + const listResourcePage = vi + .fn() + .mockResolvedValueOnce({ + items: [hostObject('host-a', '10')], + continueToken: 'next', + resourceVersion: '10', + }) + .mockResolvedValueOnce({ + items: [hostObject('host-b', '10')], + continueToken: null, + resourceVersion: '10', + }) + const indexer = new OperationalAccessIndexer( + { + listResourcePage, + watchResource: vi.fn(), + getResourceExact: vi.fn(), + }, + index as unknown as OperationalAccessIndex, + { environmentId: 'test:cluster', behaviorFingerprintKey: 'test-key' } + ) + + await expect( + indexer.reconcileSource({ family: 'host', plural: 'hosts', namespace: 'mcp-host' }) + ).resolves.toBe('10') + + expect(listResourcePage).toHaveBeenCalledTimes(2) + expect(listResourcePage.mock.calls[0]?.[2]).toMatchObject({ limit: 100 }) + expect(listResourcePage.mock.calls[1]?.[2]).toMatchObject({ + limit: 100, + continueToken: 'next', + }) + expect(index.stageRelistPage).toHaveBeenCalledTimes(2) + expect(index.promoteRelist).toHaveBeenCalledOnce() + }) + + it('does not promote pages from inconsistent snapshot resource versions', async () => { + const index = fakeIndex() + const indexer = new OperationalAccessIndexer( + { + listResourcePage: vi + .fn() + .mockResolvedValueOnce({ items: [], continueToken: 'next', resourceVersion: '10' }) + .mockResolvedValueOnce({ items: [], continueToken: null, resourceVersion: '11' }), + watchResource: vi.fn(), + getResourceExact: vi.fn(), + }, + index as unknown as OperationalAccessIndex, + { environmentId: 'test:cluster', behaviorFingerprintKey: 'test-key' } + ) + + await expect( + indexer.reconcileSource({ family: 'host', plural: 'hosts', namespace: 'mcp-host' }) + ).rejects.toThrow('operational_relist_snapshot_changed') + expect(index.promoteRelist).not.toHaveBeenCalled() + }) + + it('applies watch deletion and rejects expired watches for relist', async () => { + const index = fakeIndex() + const indexer = new OperationalAccessIndexer( + { + listResourcePage: vi.fn(), + watchResource: vi.fn(), + getResourceExact: vi.fn(), + }, + index as unknown as OperationalAccessIndex, + { environmentId: 'test:cluster', behaviorFingerprintKey: 'test-key' } + ) + const source = { family: 'host', plural: 'hosts', namespace: 'mcp-host' } as const + + await indexer.applyWatchEvent(source, 'DELETED', hostObject('host-a', '12')) + expect(index.applyWatchProjection).toHaveBeenCalledWith( + expect.objectContaining({ deleted: true, resourceVersion: '12' }) + ) + await expect(indexer.applyWatchEvent(source, 'ERROR', { code: 410 })).rejects.toThrow( + 'watch expired' + ) + }) + + it('records bookmarks without rewriting resource projections', async () => { + const index = fakeIndex() + const indexer = new OperationalAccessIndexer( + { + listResourcePage: vi.fn(), + watchResource: vi.fn(), + getResourceExact: vi.fn(), + }, + index as unknown as OperationalAccessIndex, + { environmentId: 'test:cluster', behaviorFingerprintKey: 'test-key' } + ) + + await indexer.applyWatchEvent( + { family: 'host', plural: 'hosts', namespace: 'mcp-host' }, + 'BOOKMARK', + { metadata: { resourceVersion: '15' } } + ) + expect(index.recordWatchBookmark).toHaveBeenCalledWith( + expect.objectContaining({ resourceVersion: '15' }) + ) + expect(index.applyWatchProjection).not.toHaveBeenCalled() + }) +}) diff --git a/control-api/test/operationalAccessProjection.test.ts b/control-api/test/operationalAccessProjection.test.ts new file mode 100644 index 0000000000..f5877fe5ab --- /dev/null +++ b/control-api/test/operationalAccessProjection.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest' +import { + OperationalProjectionError, + projectOperationalObject, +} from '../src/services/access/operationalAccessProjection.js' + +const namespaces = { + context: 'contexts', + mcpServer: 'mcp-server', + sharedFilesystem: 'mcp-host', +} + +function contextObject(sharedFileSystems: unknown[]) { + return { + metadata: { + name: 'ctx-a', + namespace: 'contexts', + uid: 'uid-context-a', + resourceVersion: '41', + generation: 3, + }, + spec: { + contextId: 'ctx-a', + mcpServers: ['server-a'], + sharedFileSystems, + }, + } +} + +describe('operational access projection', () => { + it('uses namespace-qualified canonical identities and relationship targets', () => { + const projection = projectOperationalObject({ + environmentId: 'test:cluster', + plural: 'contexts', + namespace: 'contexts', + object: contextObject([{ name: 'files', mountPath: '/workspace' }]), + behaviorFingerprintKey: 'test-key', + relationshipNamespaces: namespaces, + }) + + expect(projection.rootId).toBe('contexts/ctx-a') + expect(projection.resources[0]).toMatchObject({ + resourceType: 'context', + logicalId: 'contexts/ctx-a', + providerUid: 'uid-context-a', + }) + expect(projection.relationships).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + relationshipType: 'includes_mcp_server', + targetId: 'mcp-server/server-a', + }), + expect.objectContaining({ + relationshipType: 'mounts_shared_filesystem', + targetId: 'mcp-host/files', + behaviorAttributes: { mountPath: '/workspace', readOnly: true }, + }), + ]) + ) + }) + + it('preserves repeated filesystem relationship instances with different mount scopes', () => { + const projection = projectOperationalObject({ + environmentId: 'test:cluster', + plural: 'contexts', + namespace: 'contexts', + object: contextObject([ + { name: 'files', mountPath: '/workspace/a' }, + { name: 'files', mountPath: '/workspace/b' }, + ]), + behaviorFingerprintKey: 'test-key', + relationshipNamespaces: namespaces, + }) + const mounts = projection.relationships.filter( + relationship => relationship.relationshipType === 'mounts_shared_filesystem' + ) + + expect(mounts).toHaveLength(2) + expect(new Set(mounts.map(mount => mount.relationshipInstanceId)).size).toBe(2) + expect(mounts.map(mount => mount.behaviorAttributes.mountPath).sort()).toEqual([ + '/workspace/a', + '/workspace/b', + ]) + }) + + it('rejects over-budget relationship fan-out before projection', () => { + expect(() => + projectOperationalObject({ + environmentId: 'test:cluster', + plural: 'contexts', + namespace: 'contexts', + object: contextObject( + Array.from({ length: 257 }, (_, index) => ({ + name: `files-${index}`, + mountPath: `/workspace/${index}`, + })) + ), + behaviorFingerprintKey: 'test-key', + relationshipNamespaces: namespaces, + }) + ).toThrowError(OperationalProjectionError) + }) + + it('derives sandbox app identity without exposing raw runtime policy', () => { + const projection = projectOperationalObject({ + environmentId: 'test:cluster', + plural: 'workflowrecipes', + namespace: 'sandbox-recipes', + object: { + metadata: { + name: 'recipe-a', + namespace: 'sandbox-recipes', + uid: 'uid-recipe-a', + resourceVersion: '9', + }, + spec: { + contextRef: 'ctx-a', + runtimeEgress: { allow: ['private-service'] }, + oauthClients: [{ secretRef: 'oauth-secret' }], + ui: { workloadRef: 'web', port: 8080, defaultPath: '/home' }, + }, + }, + behaviorFingerprintKey: 'test-key', + relationshipNamespaces: namespaces, + }) + + expect( + projection.resources.map(resource => [resource.resourceType, resource.logicalId]) + ).toEqual([ + ['workflow_recipe', 'sandbox-recipes/recipe-a'], + ['sandbox_app', 'sandbox-recipes/recipe-a'], + ]) + const encoded = JSON.stringify(projection.relationships) + expect(encoded).not.toContain('private-service') + expect(encoded).not.toContain('oauth-secret') + }) +}) diff --git a/control-api/test/services.resourceService.test.ts b/control-api/test/services.resourceService.test.ts index ac78ce8167..9e171d948a 100644 --- a/control-api/test/services.resourceService.test.ts +++ b/control-api/test/services.resourceService.test.ts @@ -90,6 +90,95 @@ describe('ResourceService.getResource', () => { }) }) +describe('ResourceService bounded operational reads', () => { + it('passes a bounded keyset page request and preserves continuation metadata', async () => { + const listNamespacedCustomObject = vi.fn().mockResolvedValue({ + items: [{ metadata: { name: 'host-a' } }], + metadata: { continue: 'next-page', resourceVersion: '42' }, + }) + const service = new ResourceService({ listNamespacedCustomObject } as never, 'control-plane', { + hosts: 'mcp-host', + }) + const controller = new AbortController() + + await expect( + service.listResourcePage('hosts', 'mcp-host', { + limit: 100, + continueToken: 'prior-page', + timeoutSeconds: 5, + signal: controller.signal, + }) + ).resolves.toEqual({ + items: [{ metadata: { name: 'host-a' } }], + continueToken: 'next-page', + resourceVersion: '42', + }) + expect(listNamespacedCustomObject).toHaveBeenCalledWith( + expect.objectContaining({ + namespace: 'mcp-host', + plural: 'hosts', + limit: 100, + _continue: 'prior-page', + timeoutSeconds: 5, + }) + ) + }) + + it('rejects invalid limits before calling Kubernetes', async () => { + const listNamespacedCustomObject = vi.fn() + const service = new ResourceService({ listNamespacedCustomObject } as never, 'control-plane', { + hosts: 'mcp-host', + }) + + await expect( + service.listResourcePage('hosts', 'mcp-host', { + limit: 101, + timeoutSeconds: 5, + signal: new AbortController().signal, + }) + ).rejects.toThrow('between 1 and 100') + expect(listNamespacedCustomObject).not.toHaveBeenCalled() + }) + + it('rejects the caller promptly when the shared request is cancelled', async () => { + let resolveList!: (value: unknown) => void + const listNamespacedCustomObject = vi.fn( + () => + new Promise(resolve => { + resolveList = resolve + }) + ) + const service = new ResourceService({ listNamespacedCustomObject } as never, 'control-plane', { + hosts: 'mcp-host', + }) + const controller = new AbortController() + const pending = service.listResourcePage('hosts', 'mcp-host', { + limit: 1, + timeoutSeconds: 5, + signal: controller.signal, + }) + + controller.abort('test-cancel') + await expect(pending).rejects.toBe('test-cancel') + resolveList({ items: [], metadata: { resourceVersion: '43' } }) + }) + + it('uses exact namespace/name lookup and maps a Kubernetes 404', async () => { + const getNamespacedCustomObject = vi.fn().mockRejectedValue(makeNotFoundError()) + const service = new ResourceService({ getNamespacedCustomObject } as never, 'control-plane', { + hosts: 'mcp-host', + }) + + await expect( + service.getResourceExact('hosts', 'host-a', 'mcp-host', { + timeoutSeconds: 2, + signal: new AbortController().signal, + }) + ).rejects.toBeInstanceOf(K8sNotFoundError) + expect(getNamespacedCustomObject).toHaveBeenCalledOnce() + }) +}) + describe('ResourceService.updateResource', () => { it('forwards UID together with resourceVersion for an identity-bound replacement', async () => { const customApi = { From 76c31a76b36bfde2a487c6440a6d11e2cb1ecb56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 10 Aug 2026 23:42:52 -0600 Subject: [PATCH 014/528] feat(access): add live user-access resolution Resolve user access from the current session, memberships, roles, grants, resource state, capabilities, operation target, and behavior dimensions. - Add canonical resource, capability, action-target, revision, and access-path contracts. - Resolve direct and all-current-team grants in one repeatable-read transaction. - Revalidate operational paths with bounded exact Kubernetes reads. - Expose request-local authorization memoization without a cross-request cache. - Keep the public action-context gate disabled for later catalog coordination. - Five focused Control API test files pass with 23 tests. - Control API TypeScript build passes. - Staged formatting, style rules, and diff checks pass. - Producer-backed PostgreSQL round trips arrive with the producer harness commit. - PR 2 mutation and delegation capabilities remain fail-closed and unavailable. --- .../services/access/accessAuthorityStore.ts | 948 ++++++++++++++++++ control-api/src/services/access/accessPath.ts | 144 +++ .../services/access/authorizationRevision.ts | 78 ++ .../src/services/access/capabilityRegistry.ts | 85 ++ .../access/exactOperationalAuthorization.ts | 120 +++ .../access/liveAuthorizationResolver.ts | 424 ++++++++ .../src/services/access/operationTarget.ts | 168 ++++ .../access/operationalAccessReader.ts | 276 +++++ .../src/services/access/resourceIdentity.ts | 115 +++ .../auth/externalSessionAuthentication.ts | 42 +- .../test/accessLiveAuthorization.test.ts | 335 +++++++ 11 files changed, 2733 insertions(+), 2 deletions(-) create mode 100644 control-api/src/services/access/accessAuthorityStore.ts create mode 100644 control-api/src/services/access/accessPath.ts create mode 100644 control-api/src/services/access/authorizationRevision.ts create mode 100644 control-api/src/services/access/capabilityRegistry.ts create mode 100644 control-api/src/services/access/exactOperationalAuthorization.ts create mode 100644 control-api/src/services/access/liveAuthorizationResolver.ts create mode 100644 control-api/src/services/access/operationTarget.ts create mode 100644 control-api/src/services/access/operationalAccessReader.ts create mode 100644 control-api/src/services/access/resourceIdentity.ts create mode 100644 control-api/test/accessLiveAuthorization.test.ts diff --git a/control-api/src/services/access/accessAuthorityStore.ts b/control-api/src/services/access/accessAuthorityStore.ts new file mode 100644 index 0000000000..11fb6917a2 --- /dev/null +++ b/control-api/src/services/access/accessAuthorityStore.ts @@ -0,0 +1,948 @@ +import type { DbClient } from '../../db.js' +import type { TeamRole } from '../../profileTypes.js' +import type { ExternalSessionAuthorityContext } from '../auth/externalSessionAuthentication.js' +import type { AccessExecutionBudget } from './accessExecutionBudget.js' +import { + type AccessPathBehavior, + type AccessPathSeed, + knownBehavior, + unknownBehavior, +} from './accessPath.js' +import type { AuthorizationMembershipRevision } from './authorizationRevision.js' +import { + type AccessCapability, + capabilitiesForTeamRole, + gfsPermissionsToCapabilities, + normalizeAccessCapabilities, +} from './capabilityRegistry.js' +import type { ValidatedOperationTarget } from './operationTarget.js' +import type { OperationalResourceGraphResult } from './operationalAccessReader.js' +import type { CanonicalResourceIdentity } from './resourceIdentity.js' + +export type OperationalPathBinding = Readonly<{ + resourceType: 'host' | 'context' | 'mcp_server' | 'workflow_recipe' | 'shared_filesystem' + logicalId: string + providerUid: string + relationships: readonly Readonly<{ + instanceId: string + behaviorAttributes: Readonly> + }>[] +}> + +export type AuthorityCandidate = AccessPathSeed & + Readonly<{ operationalBindings: readonly OperationalPathBinding[] }> + +export type PrincipalAuthoritySnapshot = Readonly<{ + userId: string + sessionContract: 'v1' | 'v2' + sessionLive: boolean + sessionRevision: string + userRevision: string + resourceRevision: string + memberships: readonly AuthorizationMembershipRevision[] +}> + +export type ResourceAuthorityResult = Readonly<{ + exists: boolean + candidates: readonly AuthorityCandidate[] + relationships: readonly Readonly<{ + type: string + targetResourceId: string + instanceId?: string + }>[] + validUntil: Date | null +}> + +function rowCountCharge(budget: AccessExecutionBudget, rows: readonly unknown[]): void { + if (rows.length > 0) budget.charge({ kind: 'dbRowsReturned', amount: rows.length }) +} + +async function query( + db: Pick, + budget: AccessExecutionBudget, + text: string, + values: unknown[] +) { + return budget.runProducer(async () => { + const result = await db.query(text, values) + rowCountCharge(budget, result.rows) + return result + }) +} + +function memberships(value: unknown): AuthorizationMembershipRevision[] { + if (!Array.isArray(value)) return [] + return value.flatMap(item => { + if (!item || typeof item !== 'object' || Array.isArray(item)) return [] + const row = item as Record + const teamId = String(row.teamId ?? '') + const role = String(row.role ?? '') as TeamRole + if (!teamId || !['admin', 'inviter', 'member'].includes(role)) return [] + return [ + Object.freeze({ + teamId, + role, + membershipUpdatedAt: new Date(String(row.membershipUpdatedAt)).toISOString(), + teamRevision: String(row.teamRevision ?? '1'), + }), + ] + }) +} + +export async function loadPrincipalAuthoritySnapshot(input: { + db: Pick + budget: AccessExecutionBudget + session: ExternalSessionAuthorityContext + resource: CanonicalResourceIdentity +}): Promise { + const result = await query( + input.db, + input.budget, + `WITH active_memberships AS ( + SELECT tm.team_id, tm.role, tm.updated_at, + COALESCE(atr.revision, 1) AS team_revision + FROM team_members tm + LEFT JOIN authorization_team_revisions atr ON atr.team_id = tm.team_id + WHERE tm.user_id = $1 + AND tm.status = 'active' + ) + SELECT u.id AS user_id, + COALESCE(aur.revision, 1) AS user_revision, + COALESCE(arr.revision, 1) AS resource_revision, + CASE + WHEN $2::text = 'v2' THEN EXISTS ( + SELECT 1 + FROM external_user_sessions s + WHERE s.sid::text = $3 + AND s.user_id = u.id + AND s.session_version = $5 + AND s.revoked_at IS NULL + AND s.idle_expires_at > NOW() + AND s.absolute_expires_at > NOW() + AND ( + s.current_jti::text = $4 + OR ( + s.prior_jti::text = $4 + AND s.prior_jti_expires_at >= NOW() + ) + ) + ) + ELSE ( + $7::bigint IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM external_v1_session_revocations r + WHERE r.token_hash = $6 + AND r.user_id = u.id + AND r.expires_at > NOW() + ) + AND NOT EXISTS ( + SELECT 1 + FROM external_user_session_security_epochs e + WHERE e.user_id = u.id + AND $7::bigint * 1000 <= EXTRACT(EPOCH FROM e.valid_after) * 1000 + ) + ) + END AS session_live, + CASE + WHEN $2::text = 'v2' THEN COALESCE(( + SELECT s.session_version::text || ':' || s.current_jti::text || ':' || + COALESCE(s.revoked_at::text, '') || ':' || s.idle_expires_at::text || ':' || + s.absolute_expires_at::text + FROM external_user_sessions s + WHERE s.sid::text = $3 AND s.user_id = u.id + ), 'missing') + ELSE COALESCE(( + SELECT EXTRACT(EPOCH FROM e.valid_after)::text + FROM external_user_session_security_epochs e + WHERE e.user_id = u.id + ), '0') || ':' || $6 + END AS session_revision, + COALESCE( + jsonb_agg( + jsonb_build_object( + 'teamId', am.team_id, + 'role', am.role, + 'membershipUpdatedAt', am.updated_at, + 'teamRevision', am.team_revision + ) ORDER BY am.team_id + ) FILTER (WHERE am.team_id IS NOT NULL), + '[]'::jsonb + ) AS memberships + FROM users u + LEFT JOIN authorization_user_revisions aur ON aur.user_id = u.id + LEFT JOIN authorization_resource_revisions arr + ON arr.environment_id = $8 + AND arr.resource_type = $9 + AND arr.resource_id = $10 + LEFT JOIN active_memberships am ON TRUE + WHERE u.id = $1 + GROUP BY u.id, aur.revision, arr.revision`, + [ + input.session.userId, + input.session.contract, + input.session.contract === 'v2' ? input.session.sid : null, + input.session.contract === 'v2' ? input.session.jti : null, + input.session.contract === 'v2' ? input.session.sessionVersion : null, + input.session.contract === 'v1' ? input.session.tokenHash : null, + input.session.contract === 'v1' ? input.session.issuedAt : null, + input.resource.environmentId, + input.resource.type, + input.resource.logicalId, + ] + ) + const row = result.rows[0] as Record | undefined + if (!row) return null + return Object.freeze({ + userId: String(row.user_id), + sessionContract: input.session.contract, + sessionLive: row.session_live === true, + sessionRevision: String(row.session_revision), + userRevision: String(row.user_revision ?? '1'), + resourceRevision: String(row.resource_revision ?? '1'), + memberships: Object.freeze(memberships(row.memberships)), + }) +} + +function behavior(input: { + userId: string + kind: 'direct' | 'team' + teamId?: string + runtimeSensitive?: boolean + filesystemScope?: string | null + runtimeRef?: string | null + providerModelRef?: string | null + approvalRef?: string | null +}): AccessPathBehavior { + const runtimeSensitive = input.runtimeSensitive === true + return Object.freeze({ + capabilities: Object.freeze([]), + budget: runtimeSensitive ? unknownBehavior() : knownBehavior(null), + credentialPolicy: runtimeSensitive ? unknownBehavior() : knownBehavior(null), + approvalPolicy: + input.approvalRef !== undefined + ? knownBehavior(input.approvalRef) + : runtimeSensitive + ? unknownBehavior() + : knownBehavior(null), + filesystemScope: + input.filesystemScope !== undefined + ? knownBehavior(input.filesystemScope) + : knownBehavior(null), + runtime: + input.runtimeRef !== undefined + ? knownBehavior(input.runtimeRef) + : runtimeSensitive + ? unknownBehavior() + : knownBehavior(null), + providerModelPolicy: + input.providerModelRef !== undefined + ? knownBehavior(input.providerModelRef) + : runtimeSensitive + ? unknownBehavior() + : knownBehavior(null), + audit: knownBehavior(input.kind === 'team' ? `team:${input.teamId}` : `user:${input.userId}`), + }) +} + +function candidate(input: { + userId: string + kind: 'direct' | 'team' + grantId: string + teamId?: string + currentRole?: TeamRole + capabilities: readonly AccessCapability[] + runtimeSensitive?: boolean + filesystemScope?: string | null + runtimeRef?: string | null + providerModelRef?: string | null + approvalRef?: string | null + operationalBindings?: readonly OperationalPathBinding[] +}): AuthorityCandidate { + return Object.freeze({ + kind: input.kind, + grantId: input.grantId, + ...(input.teamId ? { teamId: input.teamId } : {}), + ...(input.currentRole ? { currentRole: input.currentRole } : {}), + behavior: Object.freeze({ + ...behavior(input), + capabilities: Object.freeze(normalizeAccessCapabilities(input.capabilities)), + }), + operationalBindings: Object.freeze([...(input.operationalBindings ?? [])]), + }) +} + +function candidateFromGrantRow(input: { + row: Record + userId: string + capabilities: readonly AccessCapability[] + runtimeSensitive?: boolean + operationalBindings?: readonly OperationalPathBinding[] + filesystemScope?: string | null + runtimeRef?: string | null + providerModelRef?: string | null + approvalRef?: string | null +}): AuthorityCandidate | null { + const kind = input.row.kind === 'direct' ? 'direct' : input.row.kind === 'team' ? 'team' : null + const grantId = String(input.row.grant_id ?? '') + if (!kind || !grantId) return null + const teamId = kind === 'team' ? String(input.row.team_id ?? '') : undefined + const currentRole = kind === 'team' ? (String(input.row.current_role) as TeamRole) : undefined + if (kind === 'team' && (!teamId || !['admin', 'inviter', 'member'].includes(currentRole!))) { + return null + } + return candidate({ + userId: input.userId, + kind, + grantId, + ...(teamId ? { teamId } : {}), + ...(currentRole ? { currentRole } : {}), + capabilities: input.capabilities, + runtimeSensitive: input.runtimeSensitive, + operationalBindings: input.operationalBindings, + filesystemScope: input.filesystemScope, + runtimeRef: input.runtimeRef, + providerModelRef: input.providerModelRef, + approvalRef: input.approvalRef, + }) +} + +function scopedName(logicalId: string): { namespace: string; name: string } | null { + const separator = logicalId.indexOf('/') + if (separator < 1 || separator === logicalId.length - 1) return null + return { namespace: logicalId.slice(0, separator), name: logicalId.slice(separator + 1) } +} + +function publicRelationships(graph: OperationalResourceGraphResult | null) { + if (!graph || graph.status !== 'current') return [] + return graph.relationships.map(relationship => + Object.freeze({ + type: relationship.relationshipType, + targetResourceId: `${relationship.targetType}:${relationship.targetId}`, + instanceId: relationship.relationshipInstanceId, + }) + ) +} + +async function loadSimpleOperationalGrantCandidates(input: { + db: Pick + budget: AccessExecutionBudget + userId: string + resource: CanonicalResourceIdentity + graph: Extract +}): Promise { + const identity = scopedName(input.resource.logicalId) + if (!identity) return [] + const isHost = input.resource.type === 'host' + const isContext = input.resource.type === 'context' + const isRecipe = ['workflow_recipe', 'sandbox_app'].includes(input.resource.type) + if (!isHost && !isContext && !isRecipe) return [] + const result = await query( + input.db, + input.budget, + `WITH candidates AS ( + SELECT 'direct'::text AS kind, + 'user_agents:' || ua.user_id || ':' || ua.agent_name AS grant_id, + NULL::uuid AS team_id, NULL::text AS current_role + FROM user_agents ua + WHERE $2::text = 'host' AND ua.user_id = $1 AND ua.agent_name = $4 + UNION ALL + SELECT 'team', 'team_agents:' || ta.team_id || ':' || ta.agent_name, + ta.team_id, tm.role + FROM team_agents ta + JOIN team_members tm ON tm.team_id = ta.team_id + WHERE $2::text = 'host' AND tm.user_id = $1 AND tm.status = 'active' + AND ta.agent_name = $4 + UNION ALL + SELECT 'direct', 'user_contexts:' || uc.user_id || ':' || uc.context_id, + NULL, NULL + FROM user_contexts uc + WHERE $2::text = 'context' AND uc.user_id = $1 AND uc.context_id = $4 + UNION ALL + SELECT 'team', 'team_contexts:' || tc.team_id || ':' || tc.context_id, + tc.team_id, tm.role + FROM team_contexts tc + JOIN team_members tm ON tm.team_id = tc.team_id + WHERE $2::text = 'context' AND tm.user_id = $1 AND tm.status = 'active' + AND tc.context_id = $4 + UNION ALL + SELECT 'direct', 'user_workflow_triggers:' || uwt.user_id || ':' || + uwt.recipe_namespace || '/' || uwt.recipe_name, + NULL, NULL + FROM user_workflow_triggers uwt + WHERE $2::text IN ('workflow_recipe','sandbox_app') AND uwt.user_id = $1 + AND uwt.recipe_namespace = $3 AND uwt.recipe_name = $4 + UNION ALL + SELECT 'team', 'team_workflow_triggers:' || twt.team_id || ':' || + twt.recipe_namespace || '/' || twt.recipe_name, + twt.team_id, tm.role + FROM team_workflow_triggers twt + JOIN team_members tm ON tm.team_id = twt.team_id + WHERE $2::text IN ('workflow_recipe','sandbox_app') + AND tm.user_id = $1 AND tm.status = 'active' + AND twt.recipe_namespace = $3 AND twt.recipe_name = $4 + ) + SELECT * FROM candidates + ORDER BY kind, team_id NULLS FIRST, grant_id + LIMIT $5`, + [ + input.userId, + input.resource.type, + identity.namespace, + identity.name, + input.budget.limits.accessPaths + 1, + ] + ) + const capabilities: AccessCapability[] = isHost + ? [ + 'host.read', + 'host.use', + 'host.activity.read', + 'remote_desktop.use', + 'chat.read', + 'chat.message.invoke', + 'task.read', + 'task.manage', + 'model.read', + 'model.select', + 'session.read', + 'session.manage', + ] + : isContext + ? ['context.read', 'context.use'] + : input.resource.type === 'sandbox_app' + ? ['sandbox_app.read', 'sandbox_app.use', 'sandbox_oauth.vend'] + : ['workflow.read', 'workflow.trigger'] + const relevantRelationships = input.graph.relationships.filter( + relationship => relationship.sourceType === input.resource.type || isRecipe + ) + const runtimeRef = JSON.stringify( + relevantRelationships.map(relationship => relationship.relationshipInstanceId).sort() + ) + const bindingType = + input.resource.type === 'sandbox_app' ? 'workflow_recipe' : input.resource.type + const bindingProviderUid = + input.resource.type === 'sandbox_app' + ? relevantRelationships.find(relationship => relationship.sourceType === 'workflow_recipe') + ?.sourceProviderUid + : input.graph.resource.providerUid + if (!bindingProviderUid) return [] + return (result.rows as Record[]).flatMap(row => { + const value = candidateFromGrantRow({ + row, + userId: input.userId, + capabilities, + runtimeSensitive: true, + runtimeRef, + operationalBindings: [ + { + resourceType: bindingType as OperationalPathBinding['resourceType'], + logicalId: input.resource.logicalId, + providerUid: bindingProviderUid, + relationships: relevantRelationships.map(relationship => ({ + instanceId: relationship.relationshipInstanceId, + behaviorAttributes: relationship.behaviorAttributes, + })), + }, + ], + }) + return value ? [value] : [] + }) +} + +async function loadDerivedOperationalCandidates(input: { + db: Pick + budget: AccessExecutionBudget + userId: string + resource: CanonicalResourceIdentity + graph: Extract +}): Promise { + if (input.resource.type !== 'mcp_server' && input.resource.type !== 'shared_filesystem') { + return [] + } + const derivedResourceType = input.resource.type + const directEdges = input.graph.relationships.filter(relationship => { + if (input.resource.type === 'mcp_server') { + return ( + relationship.relationshipType === 'includes_mcp_server' && + relationship.targetId === input.resource.logicalId + ) + } + return ( + relationship.relationshipType === 'mounts_shared_filesystem' && + relationship.targetId === input.resource.logicalId + ) + }) + const contextIds = [...new Set(directEdges.map(edge => edge.sourceId))] + const hostEdges = + input.resource.type === 'mcp_server' + ? input.graph.relationships.filter( + relationship => + relationship.relationshipType === 'uses_context' && + relationship.targetType === 'context' && + contextIds.includes(relationship.targetId) + ) + : [] + const hostIds = [...new Set(hostEdges.map(edge => edge.sourceId))] + const contextNames = contextIds.map(value => scopedName(value)?.name ?? '').filter(Boolean) + const hostNames = hostIds.map(value => scopedName(value)?.name ?? '').filter(Boolean) + if (contextNames.length === 0 && hostNames.length === 0) return [] + const result = await query( + input.db, + input.budget, + `WITH context_names AS (SELECT UNNEST($2::text[]) AS name), + host_names AS (SELECT UNNEST($3::text[]) AS name), + candidates AS ( + SELECT 'direct'::text AS kind, + 'user_contexts:' || uc.user_id || ':' || uc.context_id AS grant_id, + NULL::uuid AS team_id, NULL::text AS current_role, + 'context'::text AS source_type, uc.context_id AS source_name + FROM user_contexts uc JOIN context_names requested ON requested.name = uc.context_id + WHERE uc.user_id = $1 + UNION ALL + SELECT 'team', 'team_contexts:' || tc.team_id || ':' || tc.context_id, + tc.team_id, tm.role, 'context', tc.context_id + FROM team_contexts tc JOIN context_names requested ON requested.name = tc.context_id + JOIN team_members tm ON tm.team_id = tc.team_id + WHERE tm.user_id = $1 AND tm.status = 'active' + UNION ALL + SELECT 'direct', 'user_agents:' || ua.user_id || ':' || ua.agent_name, + NULL, NULL, 'host', ua.agent_name + FROM user_agents ua JOIN host_names requested ON requested.name = ua.agent_name + WHERE ua.user_id = $1 + UNION ALL + SELECT 'team', 'team_agents:' || ta.team_id || ':' || ta.agent_name, + ta.team_id, tm.role, 'host', ta.agent_name + FROM team_agents ta JOIN host_names requested ON requested.name = ta.agent_name + JOIN team_members tm ON tm.team_id = ta.team_id + WHERE tm.user_id = $1 AND tm.status = 'active' + ) + SELECT * FROM candidates + ORDER BY kind, team_id NULLS FIRST, grant_id, source_type, source_name + LIMIT $4`, + [input.userId, contextNames, hostNames, input.budget.limits.accessPaths + 1] + ) + const output: AuthorityCandidate[] = [] + for (const row of result.rows as Record[]) { + const sourceType = String(row.source_type) + const sourceName = String(row.source_name) + if (sourceType === 'context') { + const contextId = contextIds.find(value => scopedName(value)?.name === sourceName) + if (!contextId) continue + for (const edge of directEdges.filter(value => value.sourceId === contextId)) { + const filesystemScope = + input.resource.type === 'shared_filesystem' + ? JSON.stringify({ + contextId, + relationshipInstanceId: edge.relationshipInstanceId, + mountPath: edge.behaviorAttributes.mountPath, + readOnly: true, + }) + : undefined + const value = candidateFromGrantRow({ + row: { + ...row, + grant_id: `${String(row.grant_id)}:${edge.relationshipInstanceId}`, + }, + userId: input.userId, + capabilities: + input.resource.type === 'mcp_server' + ? ['mcp_server.read', 'mcp_server.use'] + : ['shared_filesystem.read'], + runtimeSensitive: input.resource.type === 'mcp_server', + runtimeRef: edge.relationshipInstanceId, + filesystemScope, + operationalBindings: [ + { + resourceType: 'context', + logicalId: contextId, + providerUid: edge.sourceProviderUid, + relationships: [ + { + instanceId: edge.relationshipInstanceId, + behaviorAttributes: edge.behaviorAttributes, + }, + ], + }, + { + resourceType: derivedResourceType, + logicalId: input.resource.logicalId, + providerUid: input.graph.resource.providerUid, + relationships: [], + }, + ], + }) + if (value) output.push(value) + } + continue + } + const hostId = hostIds.find(value => scopedName(value)?.name === sourceName) + if (!hostId) continue + for (const hostEdge of hostEdges.filter(value => value.sourceId === hostId)) { + const contextEdge = directEdges.find(value => value.sourceId === hostEdge.targetId) + if (!contextEdge) continue + const value = candidateFromGrantRow({ + row: { + ...row, + grant_id: `${String(row.grant_id)}:${hostEdge.relationshipInstanceId}:${contextEdge.relationshipInstanceId}`, + }, + userId: input.userId, + capabilities: ['mcp_server.read', 'mcp_server.use'], + runtimeSensitive: true, + runtimeRef: JSON.stringify([ + hostEdge.relationshipInstanceId, + contextEdge.relationshipInstanceId, + ]), + operationalBindings: [ + { + resourceType: 'host', + logicalId: hostId, + providerUid: hostEdge.sourceProviderUid, + relationships: [ + { + instanceId: hostEdge.relationshipInstanceId, + behaviorAttributes: hostEdge.behaviorAttributes, + }, + ], + }, + { + resourceType: 'context', + logicalId: hostEdge.targetId, + providerUid: contextEdge.sourceProviderUid, + relationships: [ + { + instanceId: contextEdge.relationshipInstanceId, + behaviorAttributes: contextEdge.behaviorAttributes, + }, + ], + }, + { + resourceType: 'mcp_server', + logicalId: input.resource.logicalId, + providerUid: input.graph.resource.providerUid, + relationships: [], + }, + ], + }) + if (value) output.push(value) + } + } + return output +} + +async function loadDatabaseCandidates(input: { + db: Pick + budget: AccessExecutionBudget + snapshot: PrincipalAuthoritySnapshot + resource: CanonicalResourceIdentity +}): Promise { + const { resource, snapshot } = input + if (resource.type === 'user') { + const owns = resource.logicalId === snapshot.userId + return { + exists: owns, + candidates: owns + ? [ + candidate({ + userId: snapshot.userId, + kind: 'direct', + grantId: `users:${snapshot.userId}`, + capabilities: ['user.profile.read'], + }), + ] + : [], + relationships: [], + validUntil: null, + } + } + if (resource.type === 'team') { + const membership = snapshot.memberships.find(value => value.teamId === resource.logicalId) + return { + exists: Boolean(membership), + candidates: membership + ? [ + candidate({ + userId: snapshot.userId, + kind: 'team', + grantId: `team_members:${membership.teamId}:${snapshot.userId}`, + teamId: membership.teamId, + currentRole: membership.role, + capabilities: capabilitiesForTeamRole(membership.role), + }), + ] + : [], + relationships: [], + validUntil: null, + } + } + + const result = await query( + input.db, + input.budget, + `WITH candidates AS ( + SELECT 'direct'::text AS kind, 'workflow_runs:user:' || wr.run_id AS grant_id, + NULL::uuid AS team_id, NULL::text AS current_role, + NULL::text[] AS permissions, wr.recipe_namespace, wr.recipe_name, + NULL::timestamptz AS valid_until, wr.team_id::text AS related_team, + wr.usage_team_id AS usage_team, NULL::text AS drive, + NULL::text AS parent_resource + FROM workflow_runs wr + JOIN user_workflow_triggers uwt + ON uwt.user_id = $1 + AND uwt.recipe_namespace = wr.recipe_namespace + AND uwt.recipe_name = wr.recipe_name + WHERE $2::text = 'workflow_run' AND wr.run_id::text = $3 + AND wr.actor_type = 'user' AND wr.actor_id = $1 + AND wr.team_id IS NULL AND wr.usage_team_id IS NULL + UNION ALL + SELECT 'team', 'workflow_runs:team:' || tm.team_id || ':' || wr.run_id, + tm.team_id, tm.role, NULL, wr.recipe_namespace, wr.recipe_name, + NULL, wr.team_id::text, wr.usage_team_id, NULL, NULL + FROM workflow_runs wr + JOIN team_members tm + ON (tm.team_id = wr.team_id OR tm.team_id::text = wr.usage_team_id) + AND tm.user_id = $1 AND tm.status = 'active' + JOIN team_workflow_triggers twt + ON twt.team_id = tm.team_id + AND twt.recipe_namespace = wr.recipe_namespace + AND twt.recipe_name = wr.recipe_name + WHERE $2::text = 'workflow_run' AND wr.run_id::text = $3 + UNION ALL + SELECT 'direct', 'workflow_approvals:user:' || war.id, NULL, NULL, NULL, + war.recipe_namespace, war.recipe_name, war.expires_at, NULL, NULL, NULL, NULL + FROM workflow_approval_requests war + JOIN user_workflow_triggers uwt + ON uwt.user_id = $1 + AND uwt.recipe_namespace = war.recipe_namespace + AND uwt.recipe_name = war.recipe_name + WHERE $2::text = 'workflow_approval' AND war.id::text = $3 + AND war.target_user_id = $1 AND war.status = 'pending' AND war.expires_at > NOW() + UNION ALL + SELECT 'team', 'workflow_approvals:team:' || tm.team_id || ':' || war.id, + tm.team_id, tm.role, NULL, war.recipe_namespace, war.recipe_name, + war.expires_at, war.target_team_id::text, NULL, NULL, NULL + FROM workflow_approval_requests war + JOIN team_members tm + ON tm.team_id = war.target_team_id AND tm.user_id = $1 AND tm.status = 'active' + JOIN team_workflow_triggers twt + ON twt.team_id = tm.team_id + AND twt.recipe_namespace = war.recipe_namespace + AND twt.recipe_name = war.recipe_name + WHERE $2::text = 'workflow_approval' AND war.id::text = $3 + AND war.status = 'pending' AND war.expires_at > NOW() + UNION ALL + SELECT CASE WHEN g.subject_type = 'user' THEN 'direct' ELSE 'team' END, + 'gfs_grants:' || g.id, + CASE WHEN g.subject_type = 'team' THEN tm.team_id ELSE NULL END, + tm.role, g.permissions, NULL, NULL, NULL, NULL, NULL, g.drive, + gr.parent_resource_id::text + FROM gfs_grants g + JOIN gfs_resources gr ON gr.resource_id = g.resource_id AND gr.deleted_at IS NULL + LEFT JOIN team_members tm + ON g.subject_type = 'team' AND tm.team_id::text = g.subject_id + AND tm.user_id = $1 AND tm.status = 'active' + WHERE $2::text = 'gfs_resource' AND g.resource_id::text = $3 + AND ((g.subject_type = 'user' AND g.subject_id = $1::text) + OR (g.subject_type = 'team' AND tm.team_id IS NOT NULL)) + UNION ALL + SELECT CASE WHEN s.subject_type = 'user' THEN 'direct' ELSE 'team' END, + 'gfs_shares:' || s.id, + CASE WHEN s.subject_type = 'team' THEN tm.team_id ELSE NULL END, + tm.role, s.permissions, NULL, NULL, NULL, NULL, NULL, s.drive, + gr.parent_resource_id::text + FROM gfs_shares s + JOIN gfs_resources gr ON gr.resource_id = s.resource_id AND gr.deleted_at IS NULL + LEFT JOIN team_members tm + ON s.subject_type = 'team' AND tm.team_id::text = s.subject_id + AND tm.user_id = $1 AND tm.status = 'active' + WHERE $2::text = 'gfs_resource' AND s.resource_id::text = $3 + AND ((s.subject_type = 'user' AND s.subject_id = $1::text) + OR (s.subject_type = 'team' AND tm.team_id IS NOT NULL)) + UNION ALL + SELECT 'direct', 'notifications:user:' || nd.id, NULL, NULL, NULL, + NULL, NULL, nd.expires_at, NULL, NULL, NULL, NULL + FROM notification_deliveries nd + WHERE $2::text = 'notification' AND nd.id::text = $3 + AND nd.audience->>'userId' = $1::text + AND (nd.expires_at IS NULL OR nd.expires_at > NOW()) + UNION ALL + SELECT 'team', 'notifications:team:' || tm.team_id || ':' || nd.id, + tm.team_id, tm.role, NULL, NULL, NULL, nd.expires_at, + tm.team_id::text, NULL, NULL, NULL + FROM notification_deliveries nd + JOIN team_members tm + ON tm.team_id::text = nd.audience->>'teamId' + AND tm.user_id = $1 AND tm.status = 'active' + WHERE $2::text = 'notification' AND nd.id::text = $3 + AND (nd.expires_at IS NULL OR nd.expires_at > NOW()) + ) + SELECT * FROM candidates + ORDER BY kind, team_id NULLS FIRST, grant_id + LIMIT $4`, + [snapshot.userId, resource.type, resource.logicalId, input.budget.limits.accessPaths + 1] + ) + const rows = result.rows as Record[] + const candidates = rows.flatMap(row => { + const type = resource.type + const capabilities: AccessCapability[] = + type === 'workflow_run' + ? [ + 'workflow.read', + 'workflow.run.manage', + 'workflow.artifact.read', + 'workflow.artifact.delete', + ] + : type === 'workflow_approval' + ? ['workflow.approval.decide'] + : type === 'gfs_resource' + ? gfsPermissionsToCapabilities(row.permissions) + : ['notification.read'] + const value = candidateFromGrantRow({ + row, + userId: snapshot.userId, + capabilities, + runtimeSensitive: type === 'workflow_run', + approvalRef: type === 'workflow_approval' ? `approval:${resource.logicalId}` : undefined, + filesystemScope: + type === 'gfs_resource' + ? JSON.stringify({ drive: row.drive, resourceId: resource.logicalId }) + : undefined, + }) + return value ? [value] : [] + }) + const relationships: Array<{ + type: string + targetResourceId: string + instanceId?: string + }> = [] + for (const row of rows) { + if (resource.type === 'workflow_run' || resource.type === 'workflow_approval') { + relationships.push( + Object.freeze({ + type: 'recipe', + targetResourceId: `workflow_recipe:${String(row.recipe_namespace)}/${String(row.recipe_name)}`, + }) + ) + continue + } + if (resource.type === 'gfs_resource' && row.parent_resource) { + relationships.push( + Object.freeze({ + type: 'parent', + targetResourceId: `gfs_resource:${String(row.parent_resource)}`, + }) + ) + continue + } + if (resource.type === 'notification' && row.related_team) { + relationships.push( + Object.freeze({ type: 'team', targetResourceId: `team:${String(row.related_team)}` }) + ) + } + } + const validTimes = rows + .map(row => (row.valid_until ? new Date(String(row.valid_until)) : null)) + .filter((value): value is Date => value !== null) + return Object.freeze({ + exists: candidates.length > 0, + candidates: Object.freeze(candidates), + relationships: Object.freeze(relationships), + validUntil: + validTimes.length > 0 + ? new Date(Math.min(...validTimes.map(value => value.getTime()))) + : null, + }) +} + +export async function loadResourceAuthority(input: { + db: Pick + budget: AccessExecutionBudget + snapshot: PrincipalAuthoritySnapshot + resource: CanonicalResourceIdentity + operationTarget: ValidatedOperationTarget + operationalGraph: OperationalResourceGraphResult | null +}): Promise { + if (input.resource.type === 'user' || input.resource.type === 'team') { + return loadDatabaseCandidates(input) + } + if (input.operationalGraph?.status === 'current') { + const candidates = ['mcp_server', 'shared_filesystem'].includes(input.resource.type) + ? await loadDerivedOperationalCandidates({ + ...input, + userId: input.snapshot.userId, + graph: input.operationalGraph, + }) + : await loadSimpleOperationalGrantCandidates({ + ...input, + userId: input.snapshot.userId, + graph: input.operationalGraph, + }) + return Object.freeze({ + exists: candidates.length > 0, + candidates: Object.freeze(candidates), + relationships: Object.freeze(publicRelationships(input.operationalGraph)), + validUntil: null, + }) + } + return loadDatabaseCandidates(input) +} + +export async function operationTargetIsCurrentlyValid(input: { + db: Pick + budget: AccessExecutionBudget + resource: CanonicalResourceIdentity + capability: AccessCapability + operationTarget: ValidatedOperationTarget +}): Promise { + const target = input.operationTarget + if (!target) return true + if (target.teamId && target.teamId !== input.resource.logicalId) return false + if (target.userId && ['team.member.read', 'team.member.manage'].includes(input.capability)) { + const result = await query( + input.db, + input.budget, + `SELECT 1 FROM team_members + WHERE team_id = $1 AND user_id = $2 AND status = 'active' + LIMIT 1`, + [target.teamId, target.userId] + ) + return (result.rowCount ?? 0) === 1 + } + if (input.capability === 'team.member.invite' && target.action !== 'create') { + const result = await query( + input.db, + input.budget, + `SELECT 1 + FROM invitations invitation + JOIN invitation_teams assignment ON assignment.invitation_id = invitation.id + WHERE invitation.id = $1 + AND invitation.status = 'pending' + AND assignment.team_id = $2 + AND assignment.role = $3 + LIMIT 1`, + [target.invitationId, target.teamId, target.role] + ) + return (result.rowCount ?? 0) === 1 + } + return true +} + +export function filterCandidatesForOperationTarget(input: { + candidates: readonly AuthorityCandidate[] + capability: AccessCapability + operationTarget: ValidatedOperationTarget +}): AuthorityCandidate[] { + const target = input.operationTarget + if (!target) return [...input.candidates] + let candidates = [...input.candidates] + if (target.teamId) { + candidates = candidates.filter(candidate => candidate.teamId === target.teamId) + } + if ( + target.role === 'admin' && + ['team.member.invite', 'team.member.manage'].includes(input.capability) + ) { + candidates = candidates.filter(candidate => candidate.currentRole === 'admin') + } + return candidates +} diff --git a/control-api/src/services/access/accessPath.ts b/control-api/src/services/access/accessPath.ts new file mode 100644 index 0000000000..fb4e4531b4 --- /dev/null +++ b/control-api/src/services/access/accessPath.ts @@ -0,0 +1,144 @@ +import { createHmac, timingSafeEqual } from 'node:crypto' +import { config } from '../../config.js' +import type { TeamRole } from '../../profileTypes.js' +import { type AccessCapability, normalizeAccessCapabilities } from './capabilityRegistry.js' +import { type CanonicalResourceIdentity, resourceIdentityKey } from './resourceIdentity.js' + +export type AccessPathKind = 'direct' | 'team' +export type BehaviorDimension = + | Readonly<{ state: 'known'; value: string | null }> + | Readonly<{ state: 'unknown' }> + +export type AccessPathBehavior = Readonly<{ + capabilities: readonly AccessCapability[] + budget: BehaviorDimension + credentialPolicy: BehaviorDimension + approvalPolicy: BehaviorDimension + filesystemScope: BehaviorDimension + runtime: BehaviorDimension + providerModelPolicy: BehaviorDimension + audit: BehaviorDimension +}> + +export type AccessPath = Readonly<{ + id: string + kind: AccessPathKind + grantId: string + teamId?: string + currentRole?: TeamRole + authorizationRevision: string + behavior: AccessPathBehavior +}> + +export type AccessPathSeed = Readonly<{ + kind: AccessPathKind + grantId: string + teamId?: string + currentRole?: TeamRole + behavior: AccessPathBehavior +}> + +export const knownBehavior = (value: string | null): BehaviorDimension => + Object.freeze({ state: 'known', value }) +export const unknownBehavior = (): BehaviorDimension => Object.freeze({ state: 'unknown' }) + +function normalizedBehavior(behavior: AccessPathBehavior): AccessPathBehavior { + return Object.freeze({ + ...behavior, + capabilities: Object.freeze(normalizeAccessCapabilities(behavior.capabilities)), + }) +} + +function signingValue(input: { + principalUserId: string + resource: CanonicalResourceIdentity + seed: AccessPathSeed + authorizationRevision: string +}): string { + return JSON.stringify([ + 'access_path_v1', + input.principalUserId, + resourceIdentityKey(input.resource), + input.seed.kind, + input.seed.grantId, + input.seed.teamId ?? null, + input.authorizationRevision, + normalizedBehavior(input.seed.behavior), + ]) +} + +export function buildAccessPath(input: { + principalUserId: string + resource: CanonicalResourceIdentity + seed: AccessPathSeed + authorizationRevision: string +}): AccessPath { + if (!input.principalUserId.trim() || !input.seed.grantId.trim() || !input.authorizationRevision) { + throw new Error('access_path_identity_incomplete') + } + if (input.seed.kind === 'team' && !input.seed.teamId?.trim()) { + throw new Error('access_path_team_missing') + } + if (input.seed.kind === 'direct' && input.seed.teamId) { + throw new Error('access_path_direct_team_invalid') + } + const behavior = normalizedBehavior(input.seed.behavior) + const digest = createHmac('sha256', config.sessionJwtPrivateKey) + .update(signingValue({ ...input, seed: { ...input.seed, behavior } }), 'utf8') + .digest('base64url') + return Object.freeze({ + id: `ap1_${digest}`, + kind: input.seed.kind, + grantId: input.seed.grantId, + ...(input.seed.teamId ? { teamId: input.seed.teamId } : {}), + ...(input.seed.currentRole ? { currentRole: input.seed.currentRole } : {}), + authorizationRevision: input.authorizationRevision, + behavior, + }) +} + +function dimensionIsEquivalent(left: BehaviorDimension, right: BehaviorDimension): boolean { + return left.state === 'known' && right.state === 'known' && left.value === right.value +} + +export function accessPathsAreEquivalent(left: AccessPath, right: AccessPath): boolean { + if (left.id === right.id) return true + if (JSON.stringify(left.behavior.capabilities) !== JSON.stringify(right.behavior.capabilities)) { + return false + } + return ( + dimensionIsEquivalent(left.behavior.budget, right.behavior.budget) && + dimensionIsEquivalent(left.behavior.credentialPolicy, right.behavior.credentialPolicy) && + dimensionIsEquivalent(left.behavior.approvalPolicy, right.behavior.approvalPolicy) && + dimensionIsEquivalent(left.behavior.filesystemScope, right.behavior.filesystemScope) && + dimensionIsEquivalent(left.behavior.runtime, right.behavior.runtime) && + dimensionIsEquivalent(left.behavior.providerModelPolicy, right.behavior.providerModelPolicy) && + dimensionIsEquivalent(left.behavior.audit, right.behavior.audit) + ) +} + +export function canonicalAccessPathTuple( + path: Pick +): string { + return JSON.stringify([ + path.kind === 'direct' ? 0 : 1, + path.kind, + path.teamId ?? '', + path.grantId, + ]) +} + +export function selectEquivalentAccessPath(paths: readonly AccessPath[]): AccessPath | null { + if (paths.length === 0) return null + if (paths.length === 1) return paths[0] + if (!paths.every(path => accessPathsAreEquivalent(paths[0], path))) return null + return [...paths].sort((left, right) => + canonicalAccessPathTuple(left).localeCompare(canonicalAccessPathTuple(right)) + )[0] +} + +export function accessPathHandleEquals(left: string, right: string): boolean { + const leftBytes = Buffer.from(left) + const rightBytes = Buffer.from(right) + return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes) +} diff --git a/control-api/src/services/access/authorizationRevision.ts b/control-api/src/services/access/authorizationRevision.ts new file mode 100644 index 0000000000..f7d937c852 --- /dev/null +++ b/control-api/src/services/access/authorizationRevision.ts @@ -0,0 +1,78 @@ +import { createHash } from 'node:crypto' +import type { TeamRole } from '../../profileTypes.js' +import type { AccessPathSeed } from './accessPath.js' +import { type CanonicalResourceIdentity, resourceIdentityKey } from './resourceIdentity.js' + +export type AuthorizationMembershipRevision = Readonly<{ + teamId: string + role: TeamRole + membershipUpdatedAt: string + teamRevision: string +}> + +export type AuthorizationRevisionInput = Readonly<{ + principalUserId: string + sessionContract: 'v1' | 'v2' + sessionRevision: string + userRevision: string + memberships: readonly AuthorizationMembershipRevision[] + resource: CanonicalResourceIdentity + resourceRevision: string + sourceStateRevision: string + relationshipsRevision: string + candidates: readonly AccessPathSeed[] +}> + +function canonicalCandidates(candidates: readonly AccessPathSeed[]) { + return [...candidates] + .map(candidate => ({ + kind: candidate.kind, + grantId: candidate.grantId, + teamId: candidate.teamId ?? null, + currentRole: candidate.currentRole ?? null, + behavior: candidate.behavior, + })) + .sort((left, right) => + JSON.stringify([left.kind, left.teamId, left.grantId]).localeCompare( + JSON.stringify([right.kind, right.teamId, right.grantId]) + ) + ) +} + +export function authorizationRevision(input: AuthorizationRevisionInput): string { + return `ar1_${createHash('sha256') + .update( + JSON.stringify({ + policy: 'user-access-authority-v1', + principalUserId: input.principalUserId, + sessionContract: input.sessionContract, + sessionRevision: input.sessionRevision, + userRevision: input.userRevision, + memberships: [...input.memberships].sort((left, right) => + left.teamId.localeCompare(right.teamId) + ), + resource: resourceIdentityKey(input.resource), + resourceRevision: input.resourceRevision, + sourceStateRevision: input.sourceStateRevision, + relationshipsRevision: input.relationshipsRevision, + candidates: canonicalCandidates(input.candidates), + }) + ) + .digest('base64url')}` +} + +function canonicalValue(value: unknown): string { + if (value === undefined) return 'null' + if (value === null || typeof value !== 'object') return JSON.stringify(value) + if (Array.isArray(value)) return `[${value.map(canonicalValue).join(',')}]` + return `{${Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => `${JSON.stringify(key)}:${canonicalValue(item)}`) + .join(',')}}` +} + +export function revisionOfValues(values: readonly unknown[]): string { + return createHash('sha256') + .update(`[${values.map(canonicalValue).sort().join(',')}]`) + .digest('base64url') +} diff --git a/control-api/src/services/access/capabilityRegistry.ts b/control-api/src/services/access/capabilityRegistry.ts new file mode 100644 index 0000000000..fd1f4e00c5 --- /dev/null +++ b/control-api/src/services/access/capabilityRegistry.ts @@ -0,0 +1,85 @@ +import type { TeamRole } from '../../profileTypes.js' + +export const ACCESS_CAPABILITIES = [ + 'user.profile.read', + 'team.read', + 'team.manage', + 'team.member.read', + 'team.member.invite', + 'team.member.manage', + 'host.read', + 'host.use', + 'host.manage', + 'host.activity.read', + 'host.activity.read_all', + 'context.read', + 'context.use', + 'context.manage', + 'mcp_server.read', + 'mcp_server.use', + 'workflow.read', + 'workflow.trigger', + 'workflow.run.manage', + 'workflow.artifact.read', + 'workflow.artifact.delete', + 'workflow.approval.decide', + 'gfs.read', + 'gfs.write', + 'gfs.delete', + 'gfs.manage_acl', + 'gfs.share', + 'shared_filesystem.read', + 'shared_filesystem.write', + 'sandbox_app.read', + 'sandbox_app.use', + 'sandbox_oauth.vend', + 'remote_desktop.use', + 'chat.read', + 'chat.message.invoke', + 'task.read', + 'task.manage', + 'model.read', + 'model.select', + 'session.read', + 'session.manage', + 'notification.read', +] as const + +export type AccessCapability = (typeof ACCESS_CAPABILITIES)[number] + +const capabilities = new Set(ACCESS_CAPABILITIES) + +export function isAccessCapability(value: unknown): value is AccessCapability { + return typeof value === 'string' && capabilities.has(value) +} + +export function requireAccessCapability(value: unknown): AccessCapability { + if (!isAccessCapability(value)) throw new Error('capability_unknown') + return value +} + +export function normalizeAccessCapabilities(values: readonly unknown[]): AccessCapability[] { + const normalized = new Set() + for (const value of values) normalized.add(requireAccessCapability(value)) + return [...normalized].sort() +} + +export function capabilitiesForTeamRole(role: TeamRole): AccessCapability[] { + const values: AccessCapability[] = ['team.read'] + if (role === 'admin') { + values.push('team.manage', 'team.member.read', 'team.member.invite', 'team.member.manage') + } else if (role === 'inviter') { + values.push('team.member.read', 'team.member.invite') + } + return normalizeAccessCapabilities(values) +} + +export function gfsPermissionsToCapabilities(value: unknown): AccessCapability[] { + if (!Array.isArray(value)) return [] + return normalizeAccessCapabilities( + value.flatMap(permission => { + const capability = `gfs.${String(permission)}` + return isAccessCapability(capability) ? [capability] : [] + }) + ) +} diff --git a/control-api/src/services/access/exactOperationalAuthorization.ts b/control-api/src/services/access/exactOperationalAuthorization.ts new file mode 100644 index 0000000000..685b9f9d0c --- /dev/null +++ b/control-api/src/services/access/exactOperationalAuthorization.ts @@ -0,0 +1,120 @@ +import { config } from '../../config.js' +import type { K8sGateway } from '../../k8s.js' +import type { ClerumResourceType } from '../../types.js' +import type { OperationalPathBinding } from './accessAuthorityStore.js' +import type { AccessExecutionBudget } from './accessExecutionBudget.js' +import { projectOperationalObject } from './operationalAccessProjection.js' + +type ExactGateway = Pick + +type ExactBindingSpec = Readonly<{ + plural: Extract< + ClerumResourceType, + 'hosts' | 'contexts' | 'mcpservers' | 'workflowrecipes' | 'sharedfilesystems' + > + namespace: string +}> + +const BINDING_SPECS: Readonly> = + Object.freeze({ + host: { plural: 'hosts', namespace: config.hostsNamespace }, + context: { plural: 'contexts', namespace: config.contextsNamespace }, + mcp_server: { plural: 'mcpservers', namespace: config.mcpServersNamespace }, + workflow_recipe: { plural: 'workflowrecipes', namespace: config.sandboxNamespace }, + shared_filesystem: { + plural: 'sharedfilesystems', + namespace: config.sharedFilesystemsNamespace, + }, + }) + +function scopedName(logicalId: string): { namespace: string; name: string } | null { + const separator = logicalId.indexOf('/') + if (separator < 1 || separator === logicalId.length - 1) return null + return { namespace: logicalId.slice(0, separator), name: logicalId.slice(separator + 1) } +} + +function canonicalBehavior(value: Readonly>): string { + return JSON.stringify( + Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right))) + ) +} + +function isNotFound(error: unknown): boolean { + if (!error || typeof error !== 'object') return false + const value = error as Record + return value.httpStatus === 404 || value.statusCode === 404 || value.code === 404 +} + +export type ExactOperationalAuthorizationResult = + | Readonly<{ status: 'current' }> + | Readonly<{ status: 'stale' | 'not_found' }> + | Readonly<{ status: 'unavailable' }> + +export async function validateExactOperationalBindings(input: { + gateway: ExactGateway | undefined + budget: AccessExecutionBudget + bindings: readonly OperationalPathBinding[] +}): Promise { + if (input.bindings.length === 0) return { status: 'current' } + if (!input.gateway) return { status: 'unavailable' } + const unique = new Map() + for (const binding of input.bindings) { + unique.set(JSON.stringify([binding.resourceType, binding.logicalId]), binding) + } + if (unique.size > input.budget.limits.exactKubernetesGets) { + return { status: 'unavailable' } + } + for (const binding of unique.values()) { + const identity = scopedName(binding.logicalId) + const spec = BINDING_SPECS[binding.resourceType] + if (!identity || identity.namespace !== spec.namespace) return { status: 'stale' } + input.budget.charge({ kind: 'exactKubernetesGets' }) + try { + const timeoutSeconds = Math.max( + 1, + Math.min(60, Math.ceil(input.budget.remainingMs() / 1_000)) + ) + const object = await input.budget.runProducer(signal => + input.gateway!.getResourceExact(spec.plural, identity.name, identity.namespace, { + timeoutSeconds, + signal, + }) + ) + const projection = projectOperationalObject({ + environmentId: 'exact-validation', + plural: spec.plural, + namespace: identity.namespace, + object, + behaviorFingerprintKey: config.sessionJwtPrivateKey, + maxObjectBytes: input.budget.limits.objectBytes, + relationshipNamespaces: { + context: config.contextsNamespace, + mcpServer: config.mcpServersNamespace, + sharedFilesystem: config.sharedFilesystemsNamespace, + }, + }) + input.budget.chargeOperationalObject(projection.contentBytes, true) + const root = projection.resources.find( + resource => + resource.resourceType === binding.resourceType && resource.logicalId === binding.logicalId + ) + if (!root || !root.enabled || root.deletedAt) return { status: 'not_found' } + if (root.providerUid !== binding.providerUid) return { status: 'stale' } + for (const expected of binding.relationships) { + const current = projection.relationships.find( + relationship => relationship.relationshipInstanceId === expected.instanceId + ) + if ( + !current || + canonicalBehavior(current.behaviorAttributes) !== + canonicalBehavior(expected.behaviorAttributes) + ) { + return { status: 'stale' } + } + } + } catch (error) { + return isNotFound(error) ? { status: 'not_found' } : { status: 'unavailable' } + } + } + return { status: 'current' } +} diff --git a/control-api/src/services/access/liveAuthorizationResolver.ts b/control-api/src/services/access/liveAuthorizationResolver.ts new file mode 100644 index 0000000000..1f1a473108 --- /dev/null +++ b/control-api/src/services/access/liveAuthorizationResolver.ts @@ -0,0 +1,424 @@ +import type { DbClient } from '../../db.js' +import { withTransaction } from '../../db.js' +import type { K8sGateway } from '../../k8s.js' +import type { ExternalSessionAuthorityContext } from '../auth/externalSessionAuthentication.js' +import { + type AuthorityCandidate, + filterCandidatesForOperationTarget, + loadPrincipalAuthoritySnapshot, + loadResourceAuthority, + operationTargetIsCurrentlyValid, +} from './accessAuthorityStore.js' +import { + AccessBudgetExceededError, + AccessExecutionBudget, + AccessExecutionCancelledError, +} from './accessExecutionBudget.js' +import { + type AccessPath, + accessPathHandleEquals, + buildAccessPath, + selectEquivalentAccessPath, +} from './accessPath.js' +import { authorizationRevision } from './authorizationRevision.js' +import { + type AccessCapability, + isAccessCapability, + normalizeAccessCapabilities, +} from './capabilityRegistry.js' +import { validateExactOperationalBindings } from './exactOperationalAuthorization.js' +import { + OperationTargetValidationError, + stableOperationTarget, + validateOperationTarget, +} from './operationTarget.js' +import { canonicalEnvironmentId } from './operationalAccessProjection.js' +import { + type OperationalResourceGraphResult, + isOperationalAccessResourceType, + loadOperationalResourceGraph, +} from './operationalAccessReader.js' +import { + type CanonicalResourceIdentity, + canonicalResourceIdentity, + resourceIdentityKey, +} from './resourceIdentity.js' + +export type LiveAuthorizationInput = Readonly<{ + session: ExternalSessionAuthorityContext + requiredCapability: unknown + resource: CanonicalResourceIdentity + operationTarget?: unknown + requestedAccessPathId?: string +}> + +export type LiveAuthorizationResult = + | Readonly<{ + status: 'allowed' + effectiveCapabilities: readonly AccessCapability[] + paths: readonly AccessPath[] + selectedPath: AccessPath + authorizationRevision: string + validUntil: string | null + }> + | Readonly<{ + status: 'denied' + code: 'forbidden' | 'unknown_capability' | 'session_not_live' + }> + | Readonly<{ status: 'invalid'; code: 'invalid_resource' | 'invalid_operation_target' }> + | Readonly<{ status: 'not_found'; code: 'not_found' }> + | Readonly<{ + status: 'access_path_required' + code: 'access_path_required' + authorizationRevision: string + safePathDescriptors: readonly Readonly<{ + id: string + kind: 'direct' | 'team' + teamId?: string + }>[] + }> + | Readonly<{ + status: 'access_path_stale' + code: 'access_path_stale' + currentAuthorizationRevision: string + }> + | Readonly<{ + status: 'unavailable' + dependencyClass: 'authorization_store' | 'operational_resource_store' | 'capacity' + retryable: true + correlationId?: string + }> + +type ResolverTransaction = (work: (db: DbClient) => Promise) => Promise + +function validPathHandle(value: string | undefined): value is string { + return typeof value === 'string' && /^ap1_[A-Za-z0-9_-]{43}$/.test(value) +} + +function dependencyForError( + error: unknown +): 'authorization_store' | 'operational_resource_store' | 'capacity' { + if ( + error instanceof AccessBudgetExceededError || + error instanceof AccessExecutionCancelledError + ) { + return 'capacity' + } + return 'authorization_store' +} + +async function configureAuthorityTransaction( + db: Pick, + budget: AccessExecutionBudget +): Promise { + await budget.runProducer(async () => { + await db.query('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY') + await db.query(`SELECT set_config('statement_timeout', $1, true)`, [ + `${budget.statementTimeoutMs()}ms`, + ]) + }) +} + +function candidateForPath( + path: AccessPath, + candidates: readonly AuthorityCandidate[] +): AuthorityCandidate | undefined { + return candidates.find( + candidate => + candidate.kind === path.kind && + candidate.grantId === path.grantId && + candidate.teamId === path.teamId + ) +} + +export class AuthorizationRequestMemo { + private readonly values = new Map>() + + constructor(private readonly budget: AccessExecutionBudget) {} + + getOrCreate( + input: LiveAuthorizationInput, + targetKey: string, + factory: () => Promise + ): Promise { + const key = JSON.stringify([ + input.session, + input.requiredCapability, + resourceIdentityKey(input.resource), + targetKey, + input.requestedAccessPathId ?? null, + ]) + const existing = this.values.get(key) + if (existing) return existing + this.budget.charge({ kind: 'memoEntries' }) + this.budget.charge({ kind: 'memoBytes', amount: Buffer.byteLength(key, 'utf8') }) + const created = factory() + this.values.set(key, created) + void created.catch(() => this.values.delete(key)) + return created + } +} + +async function resolveInTransaction(input: { + request: LiveAuthorizationInput + capability: AccessCapability + operationTarget: ReturnType + db: DbClient + gateway?: Pick + budget: AccessExecutionBudget +}): Promise { + await configureAuthorityTransaction(input.db, input.budget) + const snapshot = await loadPrincipalAuthoritySnapshot({ + db: input.db, + budget: input.budget, + session: input.request.session, + resource: input.request.resource, + }) + if (!snapshot) return { status: 'not_found', code: 'not_found' } + if (!snapshot.sessionLive) return { status: 'denied', code: 'session_not_live' } + + let graph: OperationalResourceGraphResult | null = null + if (isOperationalAccessResourceType(input.request.resource.type)) { + try { + graph = await loadOperationalResourceGraph({ + db: input.db, + budget: input.budget, + environmentId: input.request.resource.environmentId, + resourceType: input.request.resource.type, + logicalId: input.request.resource.logicalId, + }) + } catch (error) { + if ( + error instanceof AccessBudgetExceededError || + error instanceof AccessExecutionCancelledError + ) { + throw error + } + return { + status: 'unavailable', + dependencyClass: 'operational_resource_store', + retryable: true, + } + } + if (graph.status === 'unavailable') { + return { + status: 'unavailable', + dependencyClass: 'operational_resource_store', + retryable: true, + } + } + if (graph.status === 'not_found') return { status: 'not_found', code: 'not_found' } + } + if ( + !(await operationTargetIsCurrentlyValid({ + db: input.db, + budget: input.budget, + resource: input.request.resource, + capability: input.capability, + operationTarget: input.operationTarget, + })) + ) { + return { status: 'denied', code: 'forbidden' } + } + + const authority = await loadResourceAuthority({ + db: input.db, + budget: input.budget, + snapshot, + resource: input.request.resource, + operationTarget: input.operationTarget, + operationalGraph: graph, + }) + if (!authority.exists) return { status: 'not_found', code: 'not_found' } + const targeted = filterCandidatesForOperationTarget({ + candidates: authority.candidates, + capability: input.capability, + operationTarget: input.operationTarget, + }) + const capable = targeted.filter(candidate => + candidate.behavior.capabilities.includes(input.capability) + ) + if (capable.length === 0) return { status: 'denied', code: 'forbidden' } + input.budget.charge({ kind: 'accessPaths', amount: capable.length }) + + const revision = authorizationRevision({ + principalUserId: snapshot.userId, + sessionContract: snapshot.sessionContract, + sessionRevision: snapshot.sessionRevision, + userRevision: snapshot.userRevision, + memberships: snapshot.memberships, + resource: input.request.resource, + resourceRevision: snapshot.resourceRevision, + sourceStateRevision: + graph?.status === 'current' ? graph.sourceStateRevision : 'database-resource', + relationshipsRevision: + graph?.status === 'current' + ? graph.relationshipsRevision + : JSON.stringify(authority.relationships), + candidates: capable, + }) + const paths = Object.freeze( + capable.map(seed => + buildAccessPath({ + principalUserId: snapshot.userId, + resource: input.request.resource, + seed, + authorizationRevision: revision, + }) + ) + ) + + let selectedPath: AccessPath | undefined + if (input.request.requestedAccessPathId) { + selectedPath = paths.find(path => + accessPathHandleEquals(path.id, input.request.requestedAccessPathId!) + ) + if (!selectedPath) { + return { + status: 'access_path_stale', + code: 'access_path_stale', + currentAuthorizationRevision: revision, + } + } + } else { + selectedPath = selectEquivalentAccessPath(paths) ?? undefined + if (!selectedPath) { + return { + status: 'access_path_required', + code: 'access_path_required', + authorizationRevision: revision, + safePathDescriptors: Object.freeze( + paths.map(path => + Object.freeze({ + id: path.id, + kind: path.kind, + ...(path.teamId ? { teamId: path.teamId } : {}), + }) + ) + ), + } + } + } + + const selectedCandidate = candidateForPath(selectedPath, capable) + if (!selectedCandidate) { + return { + status: 'access_path_stale', + code: 'access_path_stale', + currentAuthorizationRevision: revision, + } + } + const exact = await validateExactOperationalBindings({ + gateway: input.gateway, + budget: input.budget, + bindings: selectedCandidate.operationalBindings, + }) + if (exact.status === 'not_found') return { status: 'not_found', code: 'not_found' } + if (exact.status === 'stale') { + return input.request.requestedAccessPathId + ? { + status: 'access_path_stale', + code: 'access_path_stale', + currentAuthorizationRevision: revision, + } + : { + status: 'unavailable', + dependencyClass: 'operational_resource_store', + retryable: true, + } + } + if (exact.status === 'unavailable') { + return { + status: 'unavailable', + dependencyClass: 'operational_resource_store', + retryable: true, + } + } + return Object.freeze({ + status: 'allowed', + effectiveCapabilities: Object.freeze( + normalizeAccessCapabilities(paths.flatMap(path => path.behavior.capabilities)) + ), + paths, + selectedPath, + authorizationRevision: revision, + validUntil: authority.validUntil?.toISOString() ?? null, + }) +} + +export async function resolveLiveAuthorization( + input: LiveAuthorizationInput, + options: { + gateway?: Pick + budget?: AccessExecutionBudget + memo?: AuthorizationRequestMemo + transaction?: ResolverTransaction + correlationId?: string + } = {} +): Promise { + const ownedBudget = options.budget ? null : AccessExecutionBudget.create('action') + const budget = options.budget ?? ownedBudget! + try { + if (!isAccessCapability(input.requiredCapability)) { + return { status: 'denied', code: 'unknown_capability' } + } + if ( + input.session.userId.trim().length === 0 || + input.session.userId !== input.session.userId.trim() || + input.resource.environmentId !== canonicalEnvironmentId() + ) { + return { status: 'invalid', code: 'invalid_resource' } + } + let resource: CanonicalResourceIdentity + try { + resource = canonicalResourceIdentity(input.resource) + } catch { + return { status: 'invalid', code: 'invalid_resource' } + } + if (resourceIdentityKey(resource) !== resourceIdentityKey(input.resource)) { + return { status: 'invalid', code: 'invalid_resource' } + } + if ( + input.requestedAccessPathId !== undefined && + !validPathHandle(input.requestedAccessPathId) + ) { + return { status: 'invalid', code: 'invalid_operation_target' } + } + let operationTarget: ReturnType + try { + operationTarget = validateOperationTarget({ + capability: input.requiredCapability, + resource, + operationTarget: input.operationTarget, + }) + } catch (error) { + if (error instanceof OperationTargetValidationError) { + return { status: 'invalid', code: 'invalid_operation_target' } + } + throw error + } + const factory = () => + (options.transaction ?? withTransaction)(db => + resolveInTransaction({ + request: { ...input, resource }, + capability: input.requiredCapability as AccessCapability, + operationTarget, + db, + gateway: options.gateway, + budget, + }) + ) + return options.memo + ? await options.memo.getOrCreate(input, stableOperationTarget(operationTarget), factory) + : await factory() + } catch (error) { + return { + status: 'unavailable', + dependencyClass: dependencyForError(error), + retryable: true, + ...(options.correlationId ? { correlationId: options.correlationId } : {}), + } + } finally { + ownedBudget?.close() + } +} diff --git a/control-api/src/services/access/operationTarget.ts b/control-api/src/services/access/operationTarget.ts new file mode 100644 index 0000000000..00c592bb7c --- /dev/null +++ b/control-api/src/services/access/operationTarget.ts @@ -0,0 +1,168 @@ +import type { TeamRole } from '../../profileTypes.js' +import type { AccessCapability } from './capabilityRegistry.js' +import type { CanonicalResourceIdentity } from './resourceIdentity.js' + +export type ValidatedOperationTarget = Readonly> | null + +export class OperationTargetValidationError extends Error { + constructor(readonly code: 'missing' | 'invalid' | 'unsupported' | 'resource_mismatch') { + super(`Operation target ${code}`) + this.name = 'OperationTargetValidationError' + } +} + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i +const TEAM_ROLES = new Set(['admin', 'inviter', 'member']) +const READ_CAPABILITIES = new Set([ + 'user.profile.read', + 'team.read', + 'host.read', + 'context.read', + 'mcp_server.read', + 'workflow.read', + 'gfs.read', + 'shared_filesystem.read', + 'sandbox_app.read', + 'chat.read', + 'task.read', + 'model.read', + 'session.read', + 'notification.read', +]) + +function flatStringRecord(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new OperationTargetValidationError('invalid') + } + const entries = Object.entries(value as Record) + if (entries.length === 0 || entries.length > 8) { + throw new OperationTargetValidationError('invalid') + } + const result: Record = {} + for (const [key, item] of entries) { + if (!/^[a-zA-Z][a-zA-Z0-9]{0,63}$/.test(key) || typeof item !== 'string') { + throw new OperationTargetValidationError('invalid') + } + const normalized = item.trim() + if (!normalized || normalized.length > 1_024 || /[\u0000-\u001f\u007f]/u.test(normalized)) { + throw new OperationTargetValidationError('invalid') + } + result[key] = normalized + } + return result +} + +function exactKeys( + value: Record, + required: readonly string[], + optional: readonly string[] = [] +) { + const allowed = new Set([...required, ...optional]) + if (required.some(key => !(key in value)) || Object.keys(value).some(key => !allowed.has(key))) { + throw new OperationTargetValidationError('invalid') + } +} + +function requireUuid(value: string | undefined): void { + if (!value || !UUID_PATTERN.test(value)) throw new OperationTargetValidationError('invalid') +} + +function requireRole(value: string | undefined): asserts value is TeamRole { + if (!value || !TEAM_ROLES.has(value as TeamRole)) { + throw new OperationTargetValidationError('invalid') + } +} + +function requireTargetTeam(resource: CanonicalResourceIdentity, teamId: string | undefined): void { + requireUuid(teamId) + if (resource.type !== 'team' || resource.logicalId !== teamId) { + throw new OperationTargetValidationError('resource_mismatch') + } +} + +export function validateOperationTarget(input: { + capability: AccessCapability + resource: CanonicalResourceIdentity + operationTarget?: unknown +}): ValidatedOperationTarget { + if (READ_CAPABILITIES.has(input.capability)) { + if (input.operationTarget !== undefined && input.operationTarget !== null) { + throw new OperationTargetValidationError('unsupported') + } + return null + } + if (input.operationTarget === undefined || input.operationTarget === null) { + throw new OperationTargetValidationError('missing') + } + const target = flatStringRecord(input.operationTarget) + + if (input.capability === 'team.manage') { + exactKeys(target, ['teamId', 'action']) + requireTargetTeam(input.resource, target.teamId) + if (!['rename', 'delete'].includes(target.action)) { + throw new OperationTargetValidationError('invalid') + } + return Object.freeze(target) + } + + if (input.capability === 'team.member.read') { + exactKeys(target, ['teamId'], ['userId']) + requireTargetTeam(input.resource, target.teamId) + if (target.userId) requireUuid(target.userId) + return Object.freeze(target) + } + + if (input.capability === 'team.member.invite') { + exactKeys(target, ['teamId', 'action', 'role'], ['invitationId']) + requireTargetTeam(input.resource, target.teamId) + requireRole(target.role) + if (!['create', 'resend', 'revoke'].includes(target.action)) { + throw new OperationTargetValidationError('invalid') + } + if (target.action === 'create' && target.invitationId) { + throw new OperationTargetValidationError('invalid') + } + if (target.action !== 'create') requireUuid(target.invitationId) + return Object.freeze(target) + } + + if (input.capability === 'team.member.manage') { + exactKeys(target, ['teamId', 'action', 'userId', 'role']) + requireTargetTeam(input.resource, target.teamId) + requireUuid(target.userId) + requireRole(target.role) + if (!['set_role', 'remove'].includes(target.action)) { + throw new OperationTargetValidationError('invalid') + } + return Object.freeze(target) + } + + if (input.capability === 'workflow.approval.decide') { + exactKeys(target, ['approvalId', 'decision']) + requireUuid(target.approvalId) + if ( + input.resource.type !== 'workflow_approval' || + input.resource.logicalId !== target.approvalId + ) { + throw new OperationTargetValidationError('resource_mismatch') + } + if (!['approve', 'deny'].includes(target.decision)) { + throw new OperationTargetValidationError('invalid') + } + return Object.freeze(target) + } + + // PR 2 owns the exact domain target schemas for runtime/file/app mutation capabilities. + // Failing closed here prevents PR 1 from advertising a generic unbound action decision. + throw new OperationTargetValidationError('unsupported') +} + +export function stableOperationTarget(target: ValidatedOperationTarget): string { + return JSON.stringify( + target + ? Object.fromEntries( + Object.entries(target).sort(([left], [right]) => left.localeCompare(right)) + ) + : null + ) +} diff --git a/control-api/src/services/access/operationalAccessReader.ts b/control-api/src/services/access/operationalAccessReader.ts new file mode 100644 index 0000000000..9de934df1c --- /dev/null +++ b/control-api/src/services/access/operationalAccessReader.ts @@ -0,0 +1,276 @@ +import type { DbClient } from '../../db.js' +import type { AccessExecutionBudget } from './accessExecutionBudget.js' +import { revisionOfValues } from './authorizationRevision.js' +import type { + OperationalRelationshipRecord, + OperationalResourceType, + OperationalSourceFamily, +} from './operationalAccessProjection.js' +import type { AccessResourceType } from './resourceIdentity.js' + +export type OperationalIndexedResource = Readonly<{ + environmentId: string + resourceType: OperationalResourceType + logicalId: string + sourceFamily: OperationalSourceFamily + providerUid: string + providerResourceVersion: string + displayName: string + enabled: boolean + deletedAt: string | null + observedGeneration: number | null + contentBytes: number +}> + +export type OperationalIndexedRelationship = OperationalRelationshipRecord + +export type OperationalResourceGraphResult = + | Readonly<{ + status: 'current' + resource: OperationalIndexedResource + relationships: readonly OperationalIndexedRelationship[] + sourceStateRevision: string + relationshipsRevision: string + }> + | Readonly<{ status: 'not_found'; sourceStateRevision: string }> + | Readonly<{ status: 'unavailable'; safeCode: string }> + +const SOURCE_FAMILIES_BY_TYPE: Readonly< + Partial> +> = Object.freeze({ + host: ['host'], + context: ['context'], + mcp_server: ['mcp_server', 'context', 'host'], + workflow_recipe: ['workflow_recipe'], + shared_filesystem: ['shared_filesystem', 'context'], + sandbox_app: ['workflow_recipe'], +}) + +export function isOperationalAccessResourceType(type: AccessResourceType): boolean { + return Boolean(SOURCE_FAMILIES_BY_TYPE[type]) +} + +function sourceFamilyForType(type: AccessResourceType): OperationalSourceFamily | null { + if (type === 'sandbox_app') return 'workflow_recipe' + if (['host', 'context', 'mcp_server', 'workflow_recipe', 'shared_filesystem'].includes(type)) { + return type as OperationalSourceFamily + } + return null +} + +function integer(value: unknown, label: string): number { + const result = Number(value) + if (!Number.isSafeInteger(result) || result < 0) throw new Error(`${label}_invalid`) + return result +} + +function nullableInteger(value: unknown): number | null { + return value === null || value === undefined ? null : integer(value, 'operational_generation') +} + +function behaviorAttributes(value: unknown): Readonly> { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('operational_relationship_behavior_invalid') + } + const entries = Object.entries(value as Record) + if (entries.length > 16) throw new Error('operational_relationship_behavior_invalid') + const normalized: Record = {} + for (const [key, item] of entries) { + if ( + !/^[a-zA-Z][a-zA-Z0-9]{0,63}$/.test(key) || + !['string', 'number', 'boolean'].includes(typeof item) || + (typeof item === 'string' && item.length > 1_024) || + (typeof item === 'number' && !Number.isFinite(item)) + ) { + throw new Error('operational_relationship_behavior_invalid') + } + normalized[key] = item as string | number | boolean + } + return Object.freeze(normalized) +} + +function parseResource(row: Record): OperationalIndexedResource { + return Object.freeze({ + environmentId: String(row.environment_id), + resourceType: String(row.resource_type) as OperationalResourceType, + logicalId: String(row.logical_id), + sourceFamily: String(row.source_family) as OperationalSourceFamily, + providerUid: String(row.provider_uid), + providerResourceVersion: String(row.provider_resource_version), + displayName: typeof row.display_name === 'string' ? row.display_name : String(row.logical_id), + enabled: row.enabled === true, + deletedAt: row.deleted_at ? new Date(String(row.deleted_at)).toISOString() : null, + observedGeneration: nullableInteger(row.observed_generation), + contentBytes: integer(row.content_bytes, 'operational_content_bytes'), + }) +} + +function parseRelationship(row: Record): OperationalIndexedRelationship { + return Object.freeze({ + environmentId: String(row.environment_id), + sourceType: String(row.source_type) as OperationalResourceType, + sourceId: String(row.source_id), + relationshipType: String( + row.relationship_type + ) as OperationalIndexedRelationship['relationshipType'], + targetType: String(row.target_type) as OperationalResourceType, + targetId: String(row.target_id), + relationshipInstanceId: String(row.relationship_instance_id), + behaviorAttributes: behaviorAttributes(row.behavior_attributes), + sourceFamily: String(row.source_family) as OperationalSourceFamily, + sourceProviderUid: String(row.source_provider_uid), + sourceResourceVersion: String(row.source_resource_version), + observedGeneration: nullableInteger(row.observed_generation), + contentBytes: integer(row.content_bytes, 'operational_relationship_content_bytes'), + }) +} + +async function budgetedQuery( + db: Pick, + budget: AccessExecutionBudget, + text: string, + values: unknown[] +) { + return budget.runProducer(async () => { + budget.assertActive() + const result = await db.query(text, values) + if (result.rows.length > 0) { + budget.charge({ kind: 'dbRowsReturned', amount: result.rows.length }) + } + budget.assertActive() + return result + }) +} + +export async function loadOperationalResourceGraph(input: { + db: Pick + budget: AccessExecutionBudget + environmentId: string + resourceType: AccessResourceType + logicalId: string +}): Promise { + const requiredFamilies = SOURCE_FAMILIES_BY_TYPE[input.resourceType] + const targetFamily = sourceFamilyForType(input.resourceType) + if (!requiredFamilies || !targetFamily) { + throw new Error('operational_resource_type_unsupported') + } + + const states = await budgetedQuery( + input.db, + input.budget, + `SELECT source_family, generation, resource_version, status, safe_error_code + FROM operational_catalog_source_state + WHERE environment_id = $1 + AND source_family = ANY($2::text[]) + ORDER BY source_family`, + [input.environmentId, requiredFamilies] + ) + const stateRows = states.rows as Record[] + const sourceStateRevision = revisionOfValues( + stateRows.map(row => [row.source_family, row.generation, row.resource_version, row.status]) + ) + if ( + stateRows.length !== requiredFamilies.length || + stateRows.some(row => row.status !== 'current') + ) { + return { status: 'unavailable', safeCode: 'operational_source_not_current' } + } + + const resourceResult = await budgetedQuery( + input.db, + input.budget, + `SELECT environment_id, resource_type, logical_id, source_family, + provider_uid, provider_resource_version, display_name, enabled, + deleted_at, observed_generation, content_bytes + FROM operational_resource_index + WHERE environment_id = $1 + AND resource_type = $2 + AND logical_id = $3 + AND source_family = $4 + LIMIT 1`, + [input.environmentId, input.resourceType, input.logicalId, targetFamily] + ) + const rawResource = resourceResult.rows[0] as Record | undefined + if (!rawResource) return { status: 'not_found', sourceStateRevision } + const resource = parseResource(rawResource) + input.budget.chargeOperationalObject(Math.max(1, resource.contentBytes), true) + if (!resource.enabled || resource.deletedAt) { + return { status: 'not_found', sourceStateRevision } + } + + const relationshipResult = await budgetedQuery( + input.db, + input.budget, + `SELECT environment_id, source_type, source_id, relationship_type, + target_type, target_id, relationship_instance_id, behavior_attributes, + source_family, source_provider_uid, source_resource_version, + observed_generation, content_bytes + FROM operational_resource_relationships + WHERE environment_id = $1 + AND ( + (source_type = $2 AND source_id = $3) + OR (target_type = $2 AND target_id = $3) + OR ( + $2 = 'mcp_server' + AND relationship_type = 'uses_context' + AND target_type = 'context' + AND target_id IN ( + SELECT mcp_edge.source_id + FROM operational_resource_relationships mcp_edge + WHERE mcp_edge.environment_id = $1 + AND mcp_edge.relationship_type = 'includes_mcp_server' + AND mcp_edge.target_type = 'mcp_server' + AND mcp_edge.target_id = $3 + ) + ) + ) + ORDER BY source_type, source_id, relationship_type, + target_type, target_id, relationship_instance_id + LIMIT $4`, + [ + input.environmentId, + input.resourceType, + input.logicalId, + input.budget.limits.relationships + 1, + ] + ) + if (relationshipResult.rows.length > input.budget.limits.relationships) { + input.budget.charge({ + kind: 'relationships', + amount: relationshipResult.rows.length, + authorityRequired: true, + }) + } + const relationships = Object.freeze( + (relationshipResult.rows as Record[]).map(parseRelationship) + ) + if (relationships.length > 0) { + input.budget.charge({ kind: 'relationships', amount: relationships.length }) + const relationshipBytes = relationships.reduce( + (total, relationship) => total + relationship.contentBytes, + 0 + ) + if (relationshipBytes > 0) { + input.budget.charge({ kind: 'decodedBytes', amount: relationshipBytes }) + } + } + return Object.freeze({ + status: 'current', + resource, + relationships, + sourceStateRevision, + relationshipsRevision: revisionOfValues( + relationships.map(relationship => [ + relationship.sourceType, + relationship.sourceId, + relationship.relationshipType, + relationship.targetType, + relationship.targetId, + relationship.relationshipInstanceId, + relationship.behaviorAttributes, + relationship.sourceProviderUid, + relationship.sourceResourceVersion, + ]) + ), + }) +} diff --git a/control-api/src/services/access/resourceIdentity.ts b/control-api/src/services/access/resourceIdentity.ts new file mode 100644 index 0000000000..88d3924667 --- /dev/null +++ b/control-api/src/services/access/resourceIdentity.ts @@ -0,0 +1,115 @@ +export const ACCESS_RESOURCE_TYPES = [ + 'user', + 'team', + 'host', + 'context', + 'mcp_server', + 'workflow_recipe', + 'workflow_run', + 'workflow_artifact', + 'workflow_approval', + 'gfs_resource', + 'shared_filesystem', + 'sandbox_app', + 'chat', + 'runtime_session', + 'notification', +] as const + +export type AccessResourceType = (typeof ACCESS_RESOURCE_TYPES)[number] + +export type CanonicalResourceIdentity = Readonly<{ + environmentId: string + type: AccessResourceType + canonicalId: string + logicalId: string + displayName: string + providerUid?: string +}> + +const resourceTypes = new Set(ACCESS_RESOURCE_TYPES) +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i +const DNS_LABEL_PATTERN = + /^[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?\/[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?$/ + +function boundedText(value: unknown, label: string, maximum: number): string { + const normalized = typeof value === 'string' ? value.trim() : '' + if (!normalized || normalized.length > maximum || /[\u0000-\u001f\u007f]/u.test(normalized)) { + throw new Error(`${label}_invalid`) + } + return normalized +} + +export function isAccessResourceType(value: unknown): value is AccessResourceType { + return typeof value === 'string' && resourceTypes.has(value) +} + +export function requireAccessResourceType(value: unknown): AccessResourceType { + if (!isAccessResourceType(value)) throw new Error('resource_type_invalid') + return value +} + +export function validateLogicalResourceId(type: AccessResourceType, logicalId: string): void { + if ( + ['user', 'team', 'workflow_run', 'workflow_approval', 'gfs_resource', 'notification'].includes( + type + ) + ) { + if (!UUID_PATTERN.test(logicalId)) throw new Error('resource_logical_id_invalid') + return + } + if ( + [ + 'host', + 'context', + 'mcp_server', + 'workflow_recipe', + 'shared_filesystem', + 'sandbox_app', + ].includes(type) + ) { + if (!DNS_LABEL_PATTERN.test(logicalId) || logicalId.length > 507) { + throw new Error('resource_logical_id_invalid') + } + } +} + +export function canonicalResourceIdentity(input: { + environmentId: unknown + type: unknown + logicalId: unknown + displayName?: unknown + providerUid?: unknown +}): CanonicalResourceIdentity { + const environmentId = boundedText(input.environmentId, 'environment_id', 512) + const type = requireAccessResourceType(input.type) + const logicalId = boundedText(input.logicalId, 'resource_logical_id', 1_024) + validateLogicalResourceId(type, logicalId) + const displayName = + input.displayName === undefined + ? logicalId + : boundedText(input.displayName, 'resource_display_name', 512) + const providerUid = + input.providerUid === undefined || input.providerUid === null + ? undefined + : boundedText(input.providerUid, 'resource_provider_uid', 256) + return Object.freeze({ + environmentId, + type, + canonicalId: `${type}:${logicalId}`, + logicalId, + displayName, + ...(providerUid ? { providerUid } : {}), + }) +} + +export function resourceIdentityKey(resource: CanonicalResourceIdentity): string { + return JSON.stringify([resource.environmentId, resource.type, resource.logicalId]) +} + +export function compareResourceIdentity( + left: CanonicalResourceIdentity, + right: CanonicalResourceIdentity +): number { + return resourceIdentityKey(left).localeCompare(resourceIdentityKey(right)) +} diff --git a/control-api/src/services/auth/externalSessionAuthentication.ts b/control-api/src/services/auth/externalSessionAuthentication.ts index 34171e38f3..b3a2b97091 100644 --- a/control-api/src/services/auth/externalSessionAuthentication.ts +++ b/control-api/src/services/auth/externalSessionAuthentication.ts @@ -31,7 +31,12 @@ export type ExternalSessionClient = Readonly<{ }> export type ExternalSessionAuthentication = - | { status: 'authenticated'; claims: AuthClaims; contract: 'v1' | 'v2' } + | { + status: 'authenticated' + claims: AuthClaims + contract: 'v1' | 'v2' + authorityContext: ExternalSessionAuthorityContext + } | { status: 'invalid' | 'expired' | 'revoked'; reason: string } | { status: 'upgrade_required'; reason: string } @@ -46,6 +51,22 @@ type AuthenticationOptions = Readonly<{ now?: Date }> +export type ExternalSessionAuthorityContext = Readonly< + | { + contract: 'v1' + userId: string + tokenHash: string + issuedAt: number + } + | { + contract: 'v2' + userId: string + sid: string + jti: string + sessionVersion: number + } +> + function clientMeetsMinimum( client: ExternalSessionClient | undefined, policy: EffectiveUserAccessPolicy @@ -129,6 +150,13 @@ export async function authenticateExternalUserSession( status: 'authenticated', contract: 'v2', claims: normalizedV2Claims(v2Claims, validation.identity), + authorityContext: Object.freeze({ + contract: 'v2', + userId: v2Claims.sub, + sid: v2Claims.sid, + jti: v2Claims.jti, + sessionVersion: v2Claims.sv, + }), } } @@ -139,7 +167,17 @@ export async function authenticateExternalUserSession( } const validation = await validateLegacyUserSession(token, v1Claims) if (validation.status !== 'valid') return validation - return { status: 'authenticated', contract: 'v1', claims: v1Claims } + return { + status: 'authenticated', + contract: 'v1', + claims: v1Claims, + authorityContext: Object.freeze({ + contract: 'v1', + userId: v1Claims.userId, + tokenHash: validation.identity.jti, + issuedAt: v1Claims.iat!, + }), + } } export async function renewExternalUserSession( diff --git a/control-api/test/accessLiveAuthorization.test.ts b/control-api/test/accessLiveAuthorization.test.ts new file mode 100644 index 0000000000..71b54296e0 --- /dev/null +++ b/control-api/test/accessLiveAuthorization.test.ts @@ -0,0 +1,335 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { DbClient } from '../src/db.js' +import { + type AccessPathBehavior, + type AccessPathSeed, + accessPathsAreEquivalent, + buildAccessPath, + knownBehavior, + selectEquivalentAccessPath, + unknownBehavior, +} from '../src/services/access/accessPath.js' +import { + type LiveAuthorizationInput, + resolveLiveAuthorization, +} from '../src/services/access/liveAuthorizationResolver.js' +import { canonicalEnvironmentId } from '../src/services/access/operationalAccessProjection.js' +import { canonicalResourceIdentity } from '../src/services/access/resourceIdentity.js' + +const environmentId = canonicalEnvironmentId() +const userId = '10000000-0000-4000-8000-000000000001' +const teamId = '20000000-0000-4000-8000-000000000002' +const sid = '30000000-0000-4000-8000-000000000003' +const jti = '40000000-0000-4000-8000-000000000004' + +const session = Object.freeze({ + contract: 'v2' as const, + userId, + sid, + jti, + sessionVersion: 1, +}) + +function behavior(overrides: Partial = {}): AccessPathBehavior { + return Object.freeze({ + capabilities: Object.freeze(['host.read'] as const), + budget: knownBehavior(null), + credentialPolicy: knownBehavior(null), + approvalPolicy: knownBehavior(null), + filesystemScope: knownBehavior(null), + runtime: knownBehavior(null), + providerModelPolicy: knownBehavior(null), + audit: knownBehavior(`user:${userId}`), + ...overrides, + }) +} + +function path(seed: AccessPathSeed) { + return buildAccessPath({ + principalUserId: userId, + resource: canonicalResourceIdentity({ + environmentId, + type: 'host', + logicalId: 'mcp-host/host-a', + }), + seed, + authorizationRevision: 'ar1_test', + }) +} + +type FakeDbOptions = Readonly<{ + memberships?: unknown[] + sessionLive?: boolean + hostGrantRows?: Record[] + providerUid?: string +}> + +function fakeTransaction(options: FakeDbOptions = {}) { + const query = vi.fn(async (text: string) => { + if (text.startsWith('SET TRANSACTION') || text.includes("set_config('statement_timeout'")) { + return { rows: [], rowCount: 0 } + } + if (text.includes('AS session_live')) { + return { + rows: [ + { + user_id: userId, + user_revision: '1', + resource_revision: '1', + session_live: options.sessionLive ?? true, + session_revision: '1:current', + memberships: options.memberships ?? [], + }, + ], + rowCount: 1, + } + } + if (text.includes('FROM operational_catalog_source_state')) { + return { + rows: [ + { + source_family: 'host', + generation: '7', + resource_version: '91', + status: 'current', + safe_error_code: null, + }, + ], + rowCount: 1, + } + } + if (text.includes('FROM operational_resource_index')) { + return { + rows: [ + { + environment_id: environmentId, + resource_type: 'host', + logical_id: 'mcp-host/host-a', + source_family: 'host', + provider_uid: options.providerUid ?? 'uid-host-a', + provider_resource_version: '91', + display_name: 'host-a', + enabled: true, + deleted_at: null, + observed_generation: 1, + content_bytes: 512, + }, + ], + rowCount: 1, + } + } + if (text.includes('FROM operational_resource_relationships')) { + return { rows: [], rowCount: 0 } + } + if (text.includes('WITH candidates AS')) { + const rows = options.hostGrantRows ?? [ + { + kind: 'direct', + grant_id: `user_agents:${userId}:host-a`, + team_id: null, + current_role: null, + }, + ] + return { rows, rowCount: rows.length } + } + throw new Error(`Unexpected query: ${text.slice(0, 80)}`) + }) + const transaction = async (work: (db: DbClient) => Promise) => + work({ query } as unknown as DbClient) + return { query, transaction } +} + +function hostRequest(overrides: Partial = {}): LiveAuthorizationInput { + return { + session, + requiredCapability: 'host.read', + resource: canonicalResourceIdentity({ + environmentId, + type: 'host', + logicalId: 'mcp-host/host-a', + }), + ...overrides, + } +} + +describe('access-path behavior', () => { + it('selects direct first only when every behavior dimension is known and equal', () => { + const direct = path({ kind: 'direct', grantId: 'direct-a', behavior: behavior() }) + const team = path({ + kind: 'team', + grantId: 'team-a', + teamId, + currentRole: 'member', + behavior: behavior(), + }) + + expect(accessPathsAreEquivalent(direct, team)).toBe(true) + expect(selectEquivalentAccessPath([team, direct])).toBe(direct) + }) + + it('treats equal unknown behavior dimensions as non-equivalent', () => { + const direct = path({ + kind: 'direct', + grantId: 'direct-a', + behavior: behavior({ budget: unknownBehavior() }), + }) + const team = path({ + kind: 'team', + grantId: 'team-a', + teamId, + currentRole: 'member', + behavior: behavior({ budget: unknownBehavior() }), + }) + + expect(accessPathsAreEquivalent(direct, team)).toBe(false) + expect(selectEquivalentAccessPath([direct, team])).toBeNull() + }) +}) + +describe('live user-access resolution', () => { + beforeEach(() => vi.restoreAllMocks()) + + it('fails a non-live session before reading the operational resource index', async () => { + const db = fakeTransaction({ sessionLive: false }) + + await expect( + resolveLiveAuthorization(hostRequest(), { transaction: db.transaction }) + ).resolves.toEqual({ status: 'denied', code: 'session_not_live' }) + expect( + db.query.mock.calls.some(([sql]) => + String(sql).includes('FROM operational_catalog_source_state') + ) + ).toBe(false) + }) + + it('requires an explicit path when direct and team runtime behavior is unknown', async () => { + const db = fakeTransaction({ + memberships: [ + { + teamId, + role: 'member', + membershipUpdatedAt: '2026-08-10T00:00:00.000Z', + teamRevision: '1', + }, + ], + hostGrantRows: [ + { + kind: 'direct', + grant_id: `user_agents:${userId}:host-a`, + team_id: null, + current_role: null, + }, + { + kind: 'team', + grant_id: `team_agents:${teamId}:host-a`, + team_id: teamId, + current_role: 'member', + }, + ], + }) + + const result = await resolveLiveAuthorization(hostRequest(), { + transaction: db.transaction, + }) + + expect(result.status).toBe('access_path_required') + if (result.status === 'access_path_required') { + expect(result.safePathDescriptors).toEqual([ + expect.objectContaining({ kind: 'direct' }), + expect.objectContaining({ kind: 'team', teamId }), + ]) + } + }) + + it('revalidates the exact provider incarnation for a selected operational path', async () => { + const currentObject = { + metadata: { + name: 'host-a', + namespace: 'mcp-host', + uid: 'uid-host-a', + resourceVersion: '91', + generation: 1, + }, + spec: { enabled: true }, + } + const firstDb = fakeTransaction() + const gateway = { getResourceExact: vi.fn().mockResolvedValue(currentObject) } + const first = await resolveLiveAuthorization(hostRequest(), { + transaction: firstDb.transaction, + gateway, + }) + expect(first.status).toBe('allowed') + if (first.status !== 'allowed') return + + gateway.getResourceExact.mockResolvedValue({ + ...currentObject, + metadata: { ...currentObject.metadata, uid: 'uid-host-recreated', resourceVersion: '1' }, + }) + const secondDb = fakeTransaction() + const second = await resolveLiveAuthorization( + hostRequest({ requestedAccessPathId: first.selectedPath.id }), + { transaction: secondDb.transaction, gateway } + ) + + expect(second).toEqual( + expect.objectContaining({ status: 'access_path_stale', code: 'access_path_stale' }) + ) + }) + + it('rejects incomplete and role-escalating team invitation targets', async () => { + const base = { + session, + requiredCapability: 'team.member.invite', + resource: canonicalResourceIdentity({ + environmentId, + type: 'team', + logicalId: teamId, + }), + } satisfies LiveAuthorizationInput + const db = fakeTransaction({ + memberships: [ + { + teamId, + role: 'inviter', + membershipUpdatedAt: '2026-08-10T00:00:00.000Z', + teamRevision: '1', + }, + ], + }) + + await expect(resolveLiveAuthorization(base, { transaction: db.transaction })).resolves.toEqual({ + status: 'invalid', + code: 'invalid_operation_target', + }) + + await expect( + resolveLiveAuthorization( + { + ...base, + operationTarget: { teamId, action: 'create', role: 'admin' }, + }, + { transaction: db.transaction } + ) + ).resolves.toEqual({ status: 'denied', code: 'forbidden' }) + }) + + it('rejects a caller-selected environment before authority or gateway work', async () => { + const db = fakeTransaction() + const gateway = { getResourceExact: vi.fn() } + + await expect( + resolveLiveAuthorization( + hostRequest({ + resource: canonicalResourceIdentity({ + environmentId: 'attacker:selected-environment', + type: 'host', + logicalId: 'mcp-host/host-a', + }), + }), + { transaction: db.transaction, gateway } + ) + ).resolves.toEqual({ status: 'invalid', code: 'invalid_resource' }) + expect(db.query).not.toHaveBeenCalled() + expect(gateway.getResourceExact).not.toHaveBeenCalled() + }) +}) From 28f206d6282329ab22d7846f5c4b6414918b7614 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 00:06:52 -0600 Subject: [PATCH 015/528] feat(access): add resumable catalog producers ## Summary Add twelve family-owned catalog producers that page canonical identities before selected-resource hydration and never use offset pagination or full K8s lists. ## Changes - Add bounded direct/team/source keyset arms for every frozen PR 1 family. - Hydrate all selected paths and first-degree relationships under one request budget. - Gate operational producers on current index source generations. - Add a global catalog revision and keyset indexes for complete cursor invalidation. - Keep effective catalog serving and shadow capability advertisement disabled. ## Testing / validation - Four focused Control API test files pass with 21 tests. - Real PostgreSQL producer SQL and migration tests pass with 9 tests. - Control API TypeScript build passes. - Staged formatting, style rules, and diff checks pass. ## Notes / risks - Full producer fixtures, EXPLAIN measurements, and resolver round trips follow in the dedicated producer-contract validation commit. --- .../services/access/accessAuthorityStore.ts | 18 +- .../src/services/access/catalogContracts.ts | 127 ++++ .../services/access/catalogHydrationSql.ts | 522 ++++++++++++++++ .../src/services/access/catalogProducerSql.ts | 248 ++++++++ .../services/access/catalogProducerSupport.ts | 225 +++++++ .../src/services/access/catalogProducers.ts | 562 ++++++++++++++++++ .../access/userAccessFoundationSchema.ts | 63 ++ ...Producers.realPostgres.integration.test.ts | 133 +++++ control-api/test/catalogProducers.test.ts | 174 ++++++ .../db.userAccessFoundationMigration.test.ts | 4 + .../control-api-runtime-access-profiles.tsv | 1 + 11 files changed, 2068 insertions(+), 9 deletions(-) create mode 100644 control-api/src/services/access/catalogContracts.ts create mode 100644 control-api/src/services/access/catalogHydrationSql.ts create mode 100644 control-api/src/services/access/catalogProducerSql.ts create mode 100644 control-api/src/services/access/catalogProducerSupport.ts create mode 100644 control-api/src/services/access/catalogProducers.ts create mode 100644 control-api/test/catalogProducers.realPostgres.integration.test.ts create mode 100644 control-api/test/catalogProducers.test.ts diff --git a/control-api/src/services/access/accessAuthorityStore.ts b/control-api/src/services/access/accessAuthorityStore.ts index 11fb6917a2..5868a13ed6 100644 --- a/control-api/src/services/access/accessAuthorityStore.ts +++ b/control-api/src/services/access/accessAuthorityStore.ts @@ -245,7 +245,7 @@ function behavior(input: { }) } -function candidate(input: { +export function authorityCandidate(input: { userId: string kind: 'direct' | 'team' grantId: string @@ -272,7 +272,7 @@ function candidate(input: { }) } -function candidateFromGrantRow(input: { +export function authorityCandidateFromGrantRow(input: { row: Record userId: string capabilities: readonly AccessCapability[] @@ -291,7 +291,7 @@ function candidateFromGrantRow(input: { if (kind === 'team' && (!teamId || !['admin', 'inviter', 'member'].includes(currentRole!))) { return null } - return candidate({ + return authorityCandidate({ userId: input.userId, kind, grantId, @@ -428,7 +428,7 @@ async function loadSimpleOperationalGrantCandidates(input: { : input.graph.resource.providerUid if (!bindingProviderUid) return [] return (result.rows as Record[]).flatMap(row => { - const value = candidateFromGrantRow({ + const value = authorityCandidateFromGrantRow({ row, userId: input.userId, capabilities, @@ -539,7 +539,7 @@ async function loadDerivedOperationalCandidates(input: { readOnly: true, }) : undefined - const value = candidateFromGrantRow({ + const value = authorityCandidateFromGrantRow({ row: { ...row, grant_id: `${String(row.grant_id)}:${edge.relationshipInstanceId}`, @@ -581,7 +581,7 @@ async function loadDerivedOperationalCandidates(input: { for (const hostEdge of hostEdges.filter(value => value.sourceId === hostId)) { const contextEdge = directEdges.find(value => value.sourceId === hostEdge.targetId) if (!contextEdge) continue - const value = candidateFromGrantRow({ + const value = authorityCandidateFromGrantRow({ row: { ...row, grant_id: `${String(row.grant_id)}:${hostEdge.relationshipInstanceId}:${contextEdge.relationshipInstanceId}`, @@ -643,7 +643,7 @@ async function loadDatabaseCandidates(input: { exists: owns, candidates: owns ? [ - candidate({ + authorityCandidate({ userId: snapshot.userId, kind: 'direct', grantId: `users:${snapshot.userId}`, @@ -661,7 +661,7 @@ async function loadDatabaseCandidates(input: { exists: Boolean(membership), candidates: membership ? [ - candidate({ + authorityCandidate({ userId: snapshot.userId, kind: 'team', grantId: `team_members:${membership.teamId}:${snapshot.userId}`, @@ -797,7 +797,7 @@ async function loadDatabaseCandidates(input: { : type === 'gfs_resource' ? gfsPermissionsToCapabilities(row.permissions) : ['notification.read'] - const value = candidateFromGrantRow({ + const value = authorityCandidateFromGrantRow({ row, userId: snapshot.userId, capabilities, diff --git a/control-api/src/services/access/catalogContracts.ts b/control-api/src/services/access/catalogContracts.ts new file mode 100644 index 0000000000..513ceaedb2 --- /dev/null +++ b/control-api/src/services/access/catalogContracts.ts @@ -0,0 +1,127 @@ +import type { DbClient } from '../../db.js' +import type { TeamRole } from '../../profileTypes.js' +import type { AccessExecutionBudget } from './accessExecutionBudget.js' +import type { AccessPathSeed } from './accessPath.js' +import type { OperationalSourceFamily } from './operationalAccessProjection.js' +import type { CanonicalResourceIdentity } from './resourceIdentity.js' + +export const CATALOG_FAMILIES = [ + 'user', + 'team', + 'host', + 'context', + 'mcp_server', + 'workflow_recipe', + 'workflow_run', + 'workflow_approval', + 'notification', + 'gfs_resource', + 'shared_filesystem', + 'sandbox_app', +] as const + +export type CatalogFamily = (typeof CATALOG_FAMILIES)[number] +export type CatalogKey = readonly [ + environmentId: string, + resourceType: CatalogFamily, + logicalId: string, +] + +export type ProducerContinuation = Readonly<{ + afterKey: CatalogKey | null + exhausted: boolean + opaqueState?: string +}> + +export type CatalogIdentityCandidate = Readonly<{ + key: CatalogKey + canonicalId: string +}> + +export type SafeCatalogPartialError = Readonly<{ + producer: CatalogFamily + code: 'operational_source_unavailable' | 'operational_source_relisting' + retryable: true +}> + +export type CatalogProducerPage = Readonly<{ + candidates: readonly CatalogIdentityCandidate[] + continuation: ProducerContinuation + hasMore: boolean + sourceRevision: string + sourceCompleteness: 'complete' | 'partial' + partialErrors: readonly SafeCatalogPartialError[] +}> + +export type CatalogRelationship = Readonly<{ + type: string + targetResourceId: string + instanceId?: string +}> + +export type HydratedCatalogResource = Readonly<{ + key: CatalogKey + resource: CanonicalResourceIdentity + accessPaths: readonly AccessPathSeed[] + relationships: readonly CatalogRelationship[] + authorizationResourceRevision: string + validUntil: string | null +}> + +export type CatalogPrincipalSnapshot = Readonly<{ + userId: string + sessionContract: 'v1' | 'v2' + sessionRevision: string + userRevision: string + memberships: readonly Readonly<{ + teamId: string + role: TeamRole + membershipUpdatedAt: string + teamRevision: string + }>[] +}> + +export type CatalogOperationalSourceState = Readonly<{ + family: OperationalSourceFamily + generation: string + resourceVersion: string | null + status: 'current' | 'relisting' | 'unavailable' +}> + +export type CatalogRequestContext = Readonly<{ + db: Pick + budget: AccessExecutionBudget + principal: CatalogPrincipalSnapshot + environmentId: string + sourceStates: ReadonlyMap +}> + +export interface CatalogProducer { + readonly family: CatalogFamily + readonly requiredOperationalSources: readonly OperationalSourceFamily[] + listCanonicalKeys( + context: CatalogRequestContext, + continuation: ProducerContinuation, + take: number + ): Promise + hydrateCanonicalKeys( + context: CatalogRequestContext, + keys: readonly CatalogKey[] + ): Promise +} + +export function catalogKey( + environmentId: string, + family: CatalogFamily, + logicalId: string +): CatalogKey { + return Object.freeze([environmentId, family, logicalId]) +} + +export function compareCatalogKey(left: CatalogKey, right: CatalogKey): number { + return JSON.stringify(left).localeCompare(JSON.stringify(right)) +} + +export function catalogKeyEquals(left: CatalogKey, right: CatalogKey): boolean { + return compareCatalogKey(left, right) === 0 +} diff --git a/control-api/src/services/access/catalogHydrationSql.ts b/control-api/src/services/access/catalogHydrationSql.ts new file mode 100644 index 0000000000..cd0617005e --- /dev/null +++ b/control-api/src/services/access/catalogHydrationSql.ts @@ -0,0 +1,522 @@ +export const USER_HYDRATION_SQL = ` + WITH requested AS (SELECT UNNEST($2::text[]) AS logical_id) + SELECT u.id::text AS logical_id, + COALESCE(NULLIF(u.name, ''), u.email, u.id::text) AS display_name, + NULL::text AS provider_uid, + COALESCE(arr.revision, 1)::text AS resource_revision, + jsonb_build_array(jsonb_build_object( + 'kind', 'direct', + 'grant_id', 'users:' || u.id::text + )) AS paths, + '[]'::jsonb AS relationships, + NULL::timestamptz AS valid_until + FROM requested requested + JOIN users u ON u.id::text = requested.logical_id AND u.id = $1 + LEFT JOIN authorization_resource_revisions arr + ON arr.environment_id = $3 AND arr.resource_type = 'user' + AND arr.resource_id = u.id::text + ORDER BY u.id::text` + +export const TEAM_HYDRATION_SQL = ` + WITH requested AS (SELECT UNNEST($2::text[]) AS logical_id) + SELECT team.id::text AS logical_id, + team.name AS display_name, + NULL::text AS provider_uid, + COALESCE(arr.revision, 1)::text AS resource_revision, + jsonb_build_array(jsonb_build_object( + 'kind', 'team', + 'grant_id', 'team_members:' || membership.team_id || ':' || membership.user_id, + 'team_id', membership.team_id, + 'current_role', membership.role + )) AS paths, + jsonb_build_array(jsonb_build_object( + 'type', 'membership', + 'targetResourceId', 'user:' || membership.user_id + )) AS relationships, + NULL::timestamptz AS valid_until + FROM requested requested + JOIN teams team ON team.id::text = requested.logical_id + JOIN team_members membership + ON membership.team_id = team.id AND membership.user_id = $1 + AND membership.status = 'active' + LEFT JOIN authorization_resource_revisions arr + ON arr.environment_id = $3 AND arr.resource_type = 'team' + AND arr.resource_id = team.id::text + ORDER BY team.id::text` + +export const SIMPLE_OPERATIONAL_HYDRATION_SQL = ` + WITH requested AS MATERIALIZED ( + SELECT UNNEST($2::text[]) AS logical_id + ), resources AS MATERIALIZED ( + SELECT resource.logical_id, resource.display_name, resource.provider_uid, + resource.resource_type, COALESCE(revision.revision, 1)::text AS resource_revision + FROM requested requested + JOIN operational_resource_index resource + ON resource.environment_id = $3 + AND resource.resource_type = $4 + AND resource.logical_id = requested.logical_id + AND resource.enabled = TRUE AND resource.deleted_at IS NULL + LEFT JOIN authorization_resource_revisions revision + ON revision.environment_id = resource.environment_id + AND revision.resource_type = resource.resource_type + AND revision.resource_id = resource.logical_id + ), paths AS MATERIALIZED ( + SELECT * FROM ( + SELECT resource.logical_id, 'direct'::text AS kind, + 'user_agents:' || access_grant.user_id || ':' || access_grant.agent_name AS grant_id, + NULL::uuid AS team_id, NULL::text AS current_role + FROM resources resource + JOIN user_agents access_grant + ON $4::text = 'host' + AND resource.logical_id = $5::text || '/' || access_grant.agent_name + WHERE access_grant.user_id = $1 + UNION ALL + SELECT resource.logical_id, 'team', + 'team_agents:' || access_grant.team_id || ':' || access_grant.agent_name, + access_grant.team_id, membership.role + FROM resources resource + JOIN team_agents access_grant + ON $4::text = 'host' + AND resource.logical_id = $5::text || '/' || access_grant.agent_name + JOIN team_members membership + ON membership.team_id = access_grant.team_id AND membership.user_id = $1 + AND membership.status = 'active' + UNION ALL + SELECT resource.logical_id, 'direct', + 'user_contexts:' || access_grant.user_id || ':' || access_grant.context_id, + NULL, NULL + FROM resources resource + JOIN user_contexts access_grant + ON $4::text = 'context' + AND resource.logical_id = $6::text || '/' || access_grant.context_id + WHERE access_grant.user_id = $1 + UNION ALL + SELECT resource.logical_id, 'team', + 'team_contexts:' || access_grant.team_id || ':' || access_grant.context_id, + access_grant.team_id, membership.role + FROM resources resource + JOIN team_contexts access_grant + ON $4::text = 'context' + AND resource.logical_id = $6::text || '/' || access_grant.context_id + JOIN team_members membership + ON membership.team_id = access_grant.team_id AND membership.user_id = $1 + AND membership.status = 'active' + UNION ALL + SELECT resource.logical_id, 'direct', + 'user_workflow_triggers:' || access_grant.user_id || ':' || + access_grant.recipe_namespace || '/' || access_grant.recipe_name, + NULL, NULL + FROM resources resource + JOIN user_workflow_triggers access_grant + ON $4::text IN ('workflow_recipe', 'sandbox_app') + AND resource.logical_id = access_grant.recipe_namespace || '/' || access_grant.recipe_name + WHERE access_grant.user_id = $1 + UNION ALL + SELECT resource.logical_id, 'team', + 'team_workflow_triggers:' || access_grant.team_id || ':' || + access_grant.recipe_namespace || '/' || access_grant.recipe_name, + access_grant.team_id, membership.role + FROM resources resource + JOIN team_workflow_triggers access_grant + ON $4::text IN ('workflow_recipe', 'sandbox_app') + AND resource.logical_id = access_grant.recipe_namespace || '/' || access_grant.recipe_name + JOIN team_members membership + ON membership.team_id = access_grant.team_id AND membership.user_id = $1 + AND membership.status = 'active' + ) all_paths + ORDER BY logical_id, kind, team_id NULLS FIRST, grant_id + LIMIT $7 + ), relationships AS MATERIALIZED ( + SELECT DISTINCT relationship.source_id AS logical_id, + relationship.relationship_type, relationship.target_type, + relationship.target_id, relationship.relationship_instance_id, + relationship.behavior_attributes, relationship.source_type, + relationship.source_provider_uid + FROM operational_resource_relationships relationship + JOIN resources resource + ON relationship.environment_id = $3 + AND ( + (relationship.source_type = resource.resource_type + AND relationship.source_id = resource.logical_id) + OR (relationship.target_type = resource.resource_type + AND relationship.target_id = resource.logical_id) + ) + ORDER BY logical_id, relationship_type, target_type, target_id, + relationship_instance_id + LIMIT $8 + ) + SELECT resource.logical_id, resource.display_name, resource.provider_uid, + resource.resource_revision, + COALESCE(( + SELECT jsonb_agg(to_jsonb(path) ORDER BY path.kind, + path.team_id NULLS FIRST, path.grant_id) + FROM paths path WHERE path.logical_id = resource.logical_id + ), '[]'::jsonb) AS paths, + COALESCE(( + SELECT jsonb_agg(to_jsonb(relationship) ORDER BY relationship.relationship_type, + relationship.target_type, relationship.target_id, + relationship.relationship_instance_id) + FROM relationships relationship + WHERE relationship.logical_id = resource.logical_id + OR relationship.target_id = resource.logical_id + ), '[]'::jsonb) AS relationships, + (SELECT COUNT(*) FROM paths) AS total_path_rows, + (SELECT COUNT(*) FROM relationships) AS total_relationship_rows, + NULL::timestamptz AS valid_until + FROM resources resource + ORDER BY resource.logical_id` + +export const DERIVED_OPERATIONAL_HYDRATION_SQL = ` + WITH requested AS MATERIALIZED ( + SELECT UNNEST($2::text[]) AS logical_id + ), resources AS MATERIALIZED ( + SELECT resource.logical_id, resource.display_name, resource.provider_uid, + resource.resource_type, COALESCE(revision.revision, 1)::text AS resource_revision + FROM requested requested + JOIN operational_resource_index resource + ON resource.environment_id = $3 + AND resource.resource_type = $4 + AND resource.logical_id = requested.logical_id + AND resource.enabled = TRUE AND resource.deleted_at IS NULL + LEFT JOIN authorization_resource_revisions revision + ON revision.environment_id = resource.environment_id + AND revision.resource_type = resource.resource_type + AND revision.resource_id = resource.logical_id + ), direct_edges AS MATERIALIZED ( + SELECT relationship.* + FROM operational_resource_relationships relationship + JOIN resources resource + ON relationship.environment_id = $3 + AND relationship.target_type = resource.resource_type + AND relationship.target_id = resource.logical_id + AND relationship.relationship_type = CASE + WHEN $4::text = 'mcp_server' THEN 'includes_mcp_server' + ELSE 'mounts_shared_filesystem' + END + ), host_edges AS MATERIALIZED ( + SELECT relationship.* + FROM operational_resource_relationships relationship + WHERE $4::text = 'mcp_server' + AND relationship.environment_id = $3 + AND relationship.relationship_type = 'uses_context' + AND relationship.target_type = 'context' + AND relationship.target_id IN (SELECT source_id FROM direct_edges) + ), paths AS MATERIALIZED ( + SELECT * FROM ( + SELECT edge.target_id AS logical_id, 'direct'::text AS kind, + 'user_contexts:' || access_grant.user_id || ':' || access_grant.context_id || ':' || + edge.relationship_instance_id AS grant_id, + NULL::uuid AS team_id, NULL::text AS current_role, + 'context'::text AS source_type, edge.source_id, + edge.source_provider_uid, edge.relationship_instance_id AS edge_instance, + edge.behavior_attributes AS edge_behavior, + NULL::text AS host_source_id, NULL::text AS host_provider_uid, + NULL::text AS host_edge_instance, NULL::jsonb AS host_edge_behavior + FROM direct_edges edge + JOIN user_contexts access_grant + ON edge.source_id = $6::text || '/' || access_grant.context_id + WHERE access_grant.user_id = $1 + UNION ALL + SELECT edge.target_id, 'team', + 'team_contexts:' || access_grant.team_id || ':' || access_grant.context_id || ':' || + edge.relationship_instance_id, + access_grant.team_id, membership.role, 'context', edge.source_id, + edge.source_provider_uid, edge.relationship_instance_id, + edge.behavior_attributes, NULL, NULL, NULL, NULL + FROM direct_edges edge + JOIN team_contexts access_grant + ON edge.source_id = $6::text || '/' || access_grant.context_id + JOIN team_members membership + ON membership.team_id = access_grant.team_id AND membership.user_id = $1 + AND membership.status = 'active' + UNION ALL + SELECT mcp_edge.target_id, 'direct', + 'user_agents:' || access_grant.user_id || ':' || access_grant.agent_name || ':' || + host_edge.relationship_instance_id || ':' || mcp_edge.relationship_instance_id, + NULL, NULL, 'host', mcp_edge.source_id, mcp_edge.source_provider_uid, + mcp_edge.relationship_instance_id, mcp_edge.behavior_attributes, + host_edge.source_id, host_edge.source_provider_uid, + host_edge.relationship_instance_id, host_edge.behavior_attributes + FROM direct_edges mcp_edge + JOIN host_edges host_edge ON host_edge.target_id = mcp_edge.source_id + JOIN user_agents access_grant + ON $4::text = 'mcp_server' + AND host_edge.source_id = $5::text || '/' || access_grant.agent_name + WHERE access_grant.user_id = $1 + UNION ALL + SELECT mcp_edge.target_id, 'team', + 'team_agents:' || access_grant.team_id || ':' || access_grant.agent_name || ':' || + host_edge.relationship_instance_id || ':' || mcp_edge.relationship_instance_id, + access_grant.team_id, membership.role, 'host', mcp_edge.source_id, + mcp_edge.source_provider_uid, mcp_edge.relationship_instance_id, + mcp_edge.behavior_attributes, host_edge.source_id, + host_edge.source_provider_uid, host_edge.relationship_instance_id, + host_edge.behavior_attributes + FROM direct_edges mcp_edge + JOIN host_edges host_edge ON host_edge.target_id = mcp_edge.source_id + JOIN team_agents access_grant + ON $4::text = 'mcp_server' + AND host_edge.source_id = $5::text || '/' || access_grant.agent_name + JOIN team_members membership + ON membership.team_id = access_grant.team_id AND membership.user_id = $1 + AND membership.status = 'active' + ) all_paths + ORDER BY logical_id, kind, team_id NULLS FIRST, grant_id + LIMIT $7 + ), relationships AS MATERIALIZED ( + SELECT * FROM direct_edges + UNION ALL + SELECT * FROM host_edges + ORDER BY source_type, source_id, relationship_type, target_type, target_id, + relationship_instance_id + LIMIT $8 + ) + SELECT resource.logical_id, resource.display_name, resource.provider_uid, + resource.resource_revision, + COALESCE(( + SELECT jsonb_agg(to_jsonb(path) ORDER BY path.kind, + path.team_id NULLS FIRST, path.grant_id) + FROM paths path WHERE path.logical_id = resource.logical_id + ), '[]'::jsonb) AS paths, + COALESCE(( + SELECT jsonb_agg(to_jsonb(relationship) ORDER BY relationship.source_type, + relationship.source_id, relationship.relationship_type, + relationship.target_type, relationship.target_id, + relationship.relationship_instance_id) + FROM relationships relationship + WHERE relationship.target_id = resource.logical_id + OR relationship.target_id IN ( + SELECT edge.source_id FROM direct_edges edge + WHERE edge.target_id = resource.logical_id + ) + ), '[]'::jsonb) AS relationships, + (SELECT COUNT(*) FROM paths) AS total_path_rows, + (SELECT COUNT(*) FROM relationships) AS total_relationship_rows, + NULL::timestamptz AS valid_until + FROM resources resource + ORDER BY resource.logical_id` + +export const WORKFLOW_RUN_HYDRATION_SQL = ` + WITH requested AS MATERIALIZED (SELECT UNNEST($2::uuid[]) AS id), + resources AS MATERIALIZED ( + SELECT run.*, COALESCE(revision.revision, 1)::text AS resource_revision + FROM requested requested + JOIN workflow_runs run ON run.run_id = requested.id + LEFT JOIN authorization_resource_revisions revision + ON revision.environment_id = $3 AND revision.resource_type = 'workflow_run' + AND revision.resource_id = run.run_id::text + ), paths AS MATERIALIZED ( + SELECT * FROM ( + SELECT run.run_id::text AS logical_id, 'direct'::text AS kind, + 'workflow_runs:user:' || run.run_id AS grant_id, + NULL::uuid AS team_id, NULL::text AS current_role + FROM resources run + JOIN user_workflow_triggers access_grant + ON access_grant.user_id = $1 + AND access_grant.recipe_namespace = run.recipe_namespace + AND access_grant.recipe_name = run.recipe_name + WHERE run.actor_type = 'user' AND run.actor_id = $1 + AND run.team_id IS NULL AND run.usage_team_id IS NULL + UNION ALL + SELECT run.run_id::text, 'team', + 'workflow_runs:team:' || membership.team_id || ':' || run.run_id, + membership.team_id, membership.role + FROM resources run + JOIN team_members membership + ON (membership.team_id = run.team_id OR membership.team_id::text = run.usage_team_id) + AND membership.user_id = $1::uuid AND membership.status = 'active' + JOIN team_workflow_triggers access_grant + ON access_grant.team_id = membership.team_id + AND access_grant.recipe_namespace = run.recipe_namespace + AND access_grant.recipe_name = run.recipe_name + ) all_paths + ORDER BY logical_id, kind, team_id NULLS FIRST, grant_id + LIMIT $4 + ) + SELECT run.run_id::text AS logical_id, + run.recipe_namespace || '/' || run.recipe_name || ' ' || run.run_id::text AS display_name, + NULL::text AS provider_uid, run.resource_revision, + COALESCE(( + SELECT jsonb_agg(to_jsonb(path) ORDER BY path.kind, + path.team_id NULLS FIRST, path.grant_id) + FROM paths path WHERE path.logical_id = run.run_id::text + ), '[]'::jsonb) AS paths, + jsonb_build_array(jsonb_build_object( + 'type', 'recipe', + 'targetResourceId', 'workflow_recipe:' || run.recipe_namespace || '/' || run.recipe_name + )) AS relationships, + (SELECT COUNT(*) FROM paths) AS total_path_rows, + 1::bigint AS total_relationship_rows, + NULL::timestamptz AS valid_until + FROM resources run + ORDER BY run.run_id::text` + +export const WORKFLOW_APPROVAL_HYDRATION_SQL = ` + WITH requested AS MATERIALIZED (SELECT UNNEST($2::uuid[]) AS id), + resources AS MATERIALIZED ( + SELECT approval.*, COALESCE(revision.revision, 1)::text AS resource_revision + FROM requested requested + JOIN workflow_approval_requests approval ON approval.id = requested.id + LEFT JOIN authorization_resource_revisions revision + ON revision.environment_id = $3 AND revision.resource_type = 'workflow_approval' + AND revision.resource_id = approval.id::text + WHERE approval.status = 'pending' AND approval.expires_at > NOW() + ), paths AS MATERIALIZED ( + SELECT * FROM ( + SELECT approval.id::text AS logical_id, 'direct'::text AS kind, + 'workflow_approvals:user:' || approval.id AS grant_id, + NULL::uuid AS team_id, NULL::text AS current_role + FROM resources approval + JOIN user_workflow_triggers access_grant + ON access_grant.user_id = $1 + AND access_grant.recipe_namespace = approval.recipe_namespace + AND access_grant.recipe_name = approval.recipe_name + WHERE approval.target_user_id = $1 + UNION ALL + SELECT approval.id::text, 'team', + 'workflow_approvals:team:' || membership.team_id || ':' || approval.id, + membership.team_id, membership.role + FROM resources approval + JOIN team_members membership + ON membership.team_id = approval.target_team_id + AND membership.user_id = $1::uuid AND membership.status = 'active' + JOIN team_workflow_triggers access_grant + ON access_grant.team_id = membership.team_id + AND access_grant.recipe_namespace = approval.recipe_namespace + AND access_grant.recipe_name = approval.recipe_name + ) all_paths + ORDER BY logical_id, kind, team_id NULLS FIRST, grant_id + LIMIT $4 + ) + SELECT approval.id::text AS logical_id, + approval.recipe_namespace || '/' || approval.recipe_name || ' approval' AS display_name, + NULL::text AS provider_uid, approval.resource_revision, + COALESCE(( + SELECT jsonb_agg(to_jsonb(path) ORDER BY path.kind, + path.team_id NULLS FIRST, path.grant_id) + FROM paths path WHERE path.logical_id = approval.id::text + ), '[]'::jsonb) AS paths, + jsonb_build_array(jsonb_build_object( + 'type', 'recipe', + 'targetResourceId', 'workflow_recipe:' || approval.recipe_namespace || '/' || + approval.recipe_name + )) AS relationships, + (SELECT COUNT(*) FROM paths) AS total_path_rows, + 1::bigint AS total_relationship_rows, + approval.expires_at AS valid_until + FROM resources approval + ORDER BY approval.id::text` + +export const NOTIFICATION_HYDRATION_SQL = ` + WITH requested AS MATERIALIZED (SELECT UNNEST($2::uuid[]) AS id), + resources AS MATERIALIZED ( + SELECT notification.*, COALESCE(revision.revision, 1)::text AS resource_revision + FROM requested requested + JOIN notification_deliveries notification ON notification.id = requested.id + LEFT JOIN authorization_resource_revisions revision + ON revision.environment_id = $3 AND revision.resource_type = 'notification' + AND revision.resource_id = notification.id::text + WHERE notification.expires_at IS NULL OR notification.expires_at > NOW() + ), paths AS MATERIALIZED ( + SELECT * FROM ( + SELECT notification.id::text AS logical_id, 'direct'::text AS kind, + 'notifications:user:' || notification.id AS grant_id, + NULL::uuid AS team_id, NULL::text AS current_role + FROM resources notification + WHERE notification.audience->>'userId' = $1::text + UNION ALL + SELECT notification.id::text, 'team', + 'notifications:team:' || membership.team_id || ':' || notification.id, + membership.team_id, membership.role + FROM resources notification + JOIN team_members membership + ON membership.team_id::text = notification.audience->>'teamId' + AND membership.user_id = $1::uuid AND membership.status = 'active' + ) all_paths + ORDER BY logical_id, kind, team_id NULLS FIRST, grant_id + LIMIT $4 + ) + SELECT notification.id::text AS logical_id, + notification.event_type AS display_name, + NULL::text AS provider_uid, notification.resource_revision, + COALESCE(( + SELECT jsonb_agg(to_jsonb(path) ORDER BY path.kind, + path.team_id NULLS FIRST, path.grant_id) + FROM paths path WHERE path.logical_id = notification.id::text + ), '[]'::jsonb) AS paths, + CASE WHEN notification.audience ? 'teamId' + THEN jsonb_build_array(jsonb_build_object( + 'type', 'team', + 'targetResourceId', 'team:' || (notification.audience->>'teamId') + )) + ELSE '[]'::jsonb + END AS relationships, + (SELECT COUNT(*) FROM paths) AS total_path_rows, + CASE WHEN notification.audience ? 'teamId' THEN 1 ELSE 0 END::bigint + AS total_relationship_rows, + notification.expires_at AS valid_until + FROM resources notification + ORDER BY notification.id::text` + +export const GFS_HYDRATION_SQL = ` + WITH requested AS MATERIALIZED (SELECT UNNEST($2::uuid[]) AS id), + resources AS MATERIALIZED ( + SELECT resource.*, COALESCE(revision.revision, 1)::text AS resource_revision + FROM requested requested + JOIN gfs_resources resource + ON resource.resource_id = requested.id AND resource.deleted_at IS NULL + LEFT JOIN authorization_resource_revisions revision + ON revision.environment_id = $3 AND revision.resource_type = 'gfs_resource' + AND revision.resource_id = resource.resource_id::text + ), paths AS MATERIALIZED ( + SELECT * FROM ( + SELECT access_grant.resource_id::text AS logical_id, + CASE WHEN access_grant.subject_type = 'user' THEN 'direct' ELSE 'team' END AS kind, + 'gfs_grants:' || access_grant.id AS grant_id, + CASE WHEN access_grant.subject_type = 'team' THEN membership.team_id ELSE NULL END AS team_id, + membership.role AS current_role, access_grant.permissions, access_grant.drive + FROM resources resource + JOIN gfs_grants access_grant ON access_grant.resource_id = resource.resource_id + LEFT JOIN team_members membership + ON access_grant.subject_type = 'team' + AND membership.team_id::text = access_grant.subject_id + AND membership.user_id = $1::uuid AND membership.status = 'active' + WHERE (access_grant.subject_type = 'user' AND access_grant.subject_id = $1::text) + OR (access_grant.subject_type = 'team' AND membership.team_id IS NOT NULL) + UNION ALL + SELECT share.resource_id::text, + CASE WHEN share.subject_type = 'user' THEN 'direct' ELSE 'team' END, + 'gfs_shares:' || share.id, + CASE WHEN share.subject_type = 'team' THEN membership.team_id ELSE NULL END, + membership.role, share.permissions, share.drive + FROM resources resource + JOIN gfs_shares share ON share.resource_id = resource.resource_id + LEFT JOIN team_members membership + ON share.subject_type = 'team' AND membership.team_id::text = share.subject_id + AND membership.user_id = $1::uuid AND membership.status = 'active' + WHERE (share.subject_type = 'user' AND share.subject_id = $1::text) + OR (share.subject_type = 'team' AND membership.team_id IS NOT NULL) + ) all_paths + ORDER BY logical_id, kind, team_id NULLS FIRST, grant_id + LIMIT $4 + ) + SELECT resource.resource_id::text AS logical_id, resource.name AS display_name, + NULL::text AS provider_uid, resource.resource_revision, + COALESCE(( + SELECT jsonb_agg(to_jsonb(path) ORDER BY path.kind, + path.team_id NULLS FIRST, path.grant_id) + FROM paths path WHERE path.logical_id = resource.resource_id::text + ), '[]'::jsonb) AS paths, + CASE WHEN resource.parent_resource_id IS NOT NULL + THEN jsonb_build_array(jsonb_build_object( + 'type', 'parent', + 'targetResourceId', 'gfs_resource:' || resource.parent_resource_id + )) + ELSE '[]'::jsonb + END AS relationships, + (SELECT COUNT(*) FROM paths) AS total_path_rows, + CASE WHEN resource.parent_resource_id IS NULL THEN 0 ELSE 1 END::bigint + AS total_relationship_rows, + NULL::timestamptz AS valid_until + FROM resources resource + ORDER BY resource.resource_id::text` diff --git a/control-api/src/services/access/catalogProducerSql.ts b/control-api/src/services/access/catalogProducerSql.ts new file mode 100644 index 0000000000..3e9ba39418 --- /dev/null +++ b/control-api/src/services/access/catalogProducerSql.ts @@ -0,0 +1,248 @@ +import type { CatalogFamily } from './catalogContracts.js' +import { boundedKeyUnionSql } from './catalogProducerSupport.js' + +export const CATALOG_KEY_SQL: Readonly> = Object.freeze({ + user: boundedKeyUnionSql([ + `SELECT u.id::text AS logical_id + FROM users u + WHERE u.id = $1 AND u.id::text > $2`, + ]), + team: boundedKeyUnionSql([ + `SELECT tm.team_id::text AS logical_id + FROM team_members tm + WHERE tm.user_id = $1 AND tm.status = 'active' AND tm.team_id::text > $2`, + ]), + host: boundedKeyUnionSql([ + { + sql: `SELECT $5::text || '/' || ua.agent_name AS logical_id, ua.agent_name AS source_key + FROM user_agents ua + WHERE ua.user_id = $1 AND ua.agent_name > $7`, + orderBy: 'source_key', + }, + { + sql: `SELECT $5::text || '/' || ta.agent_name AS logical_id, ta.agent_name AS source_key + FROM team_agents ta + JOIN team_members tm ON tm.team_id = ta.team_id + WHERE tm.user_id = $1 AND tm.status = 'active' AND ta.agent_name > $7`, + orderBy: 'source_key', + }, + ]), + context: boundedKeyUnionSql([ + { + sql: `SELECT $6::text || '/' || uc.context_id AS logical_id, uc.context_id AS source_key + FROM user_contexts uc + WHERE uc.user_id = $1 AND uc.context_id > $7`, + orderBy: 'source_key', + }, + { + sql: `SELECT $6::text || '/' || tc.context_id AS logical_id, tc.context_id AS source_key + FROM team_contexts tc + JOIN team_members tm ON tm.team_id = tc.team_id + WHERE tm.user_id = $1 AND tm.status = 'active' AND tc.context_id > $7`, + orderBy: 'source_key', + }, + ]), + mcp_server: boundedKeyUnionSql([ + `SELECT edge.target_id AS logical_id + FROM user_contexts uc + JOIN operational_resource_relationships edge + ON edge.environment_id = $3 + AND edge.source_type = 'context' + AND edge.source_id = $6::text || '/' || uc.context_id + AND edge.relationship_type = 'includes_mcp_server' + AND edge.target_type = 'mcp_server' + WHERE uc.user_id = $1 AND edge.target_id > $2`, + `SELECT edge.target_id AS logical_id + FROM team_contexts tc + JOIN team_members tm ON tm.team_id = tc.team_id + JOIN operational_resource_relationships edge + ON edge.environment_id = $3 + AND edge.source_type = 'context' + AND edge.source_id = $6::text || '/' || tc.context_id + AND edge.relationship_type = 'includes_mcp_server' + AND edge.target_type = 'mcp_server' + WHERE tm.user_id = $1 AND tm.status = 'active' AND edge.target_id > $2`, + `SELECT mcp_edge.target_id AS logical_id + FROM user_agents ua + JOIN operational_resource_relationships host_edge + ON host_edge.environment_id = $3 + AND host_edge.source_type = 'host' + AND host_edge.source_id = $5::text || '/' || ua.agent_name + AND host_edge.relationship_type = 'uses_context' + AND host_edge.target_type = 'context' + JOIN operational_resource_relationships mcp_edge + ON mcp_edge.environment_id = $3 + AND mcp_edge.source_type = 'context' + AND mcp_edge.source_id = host_edge.target_id + AND mcp_edge.relationship_type = 'includes_mcp_server' + AND mcp_edge.target_type = 'mcp_server' + WHERE ua.user_id = $1 AND mcp_edge.target_id > $2`, + `SELECT mcp_edge.target_id AS logical_id + FROM team_agents ta + JOIN team_members tm ON tm.team_id = ta.team_id + JOIN operational_resource_relationships host_edge + ON host_edge.environment_id = $3 + AND host_edge.source_type = 'host' + AND host_edge.source_id = $5::text || '/' || ta.agent_name + AND host_edge.relationship_type = 'uses_context' + AND host_edge.target_type = 'context' + JOIN operational_resource_relationships mcp_edge + ON mcp_edge.environment_id = $3 + AND mcp_edge.source_type = 'context' + AND mcp_edge.source_id = host_edge.target_id + AND mcp_edge.relationship_type = 'includes_mcp_server' + AND mcp_edge.target_type = 'mcp_server' + WHERE tm.user_id = $1 AND tm.status = 'active' AND mcp_edge.target_id > $2`, + ]), + workflow_recipe: boundedKeyUnionSql([ + { + sql: `SELECT uwt.recipe_namespace || '/' || uwt.recipe_name AS logical_id + FROM user_workflow_triggers uwt + WHERE uwt.user_id = $1 + AND (uwt.recipe_namespace || '/' || uwt.recipe_name) > $2`, + orderBy: 'logical_id', + }, + { + sql: `SELECT twt.recipe_namespace || '/' || twt.recipe_name AS logical_id + FROM team_workflow_triggers twt + JOIN team_members tm ON tm.team_id = twt.team_id + WHERE tm.user_id = $1 AND tm.status = 'active' + AND (twt.recipe_namespace || '/' || twt.recipe_name) > $2`, + orderBy: 'logical_id', + }, + ]), + workflow_run: boundedKeyUnionSql([ + `SELECT wr.run_id::text AS logical_id + FROM workflow_runs wr + JOIN user_workflow_triggers uwt + ON uwt.user_id = $1 + AND uwt.recipe_namespace = wr.recipe_namespace + AND uwt.recipe_name = wr.recipe_name + WHERE wr.actor_type = 'user' AND wr.actor_id = $1 + AND wr.team_id IS NULL AND wr.usage_team_id IS NULL + AND wr.run_id::text > $2`, + `SELECT wr.run_id::text AS logical_id + FROM workflow_runs wr + JOIN team_members tm + ON tm.team_id = wr.team_id AND tm.user_id = $1 AND tm.status = 'active' + JOIN team_workflow_triggers twt + ON twt.team_id = tm.team_id + AND twt.recipe_namespace = wr.recipe_namespace + AND twt.recipe_name = wr.recipe_name + WHERE wr.run_id::text > $2`, + `SELECT wr.run_id::text AS logical_id + FROM workflow_runs wr + JOIN team_members tm + ON tm.team_id::text = wr.usage_team_id + AND tm.user_id = $1::uuid AND tm.status = 'active' + JOIN team_workflow_triggers twt + ON twt.team_id = tm.team_id + AND twt.recipe_namespace = wr.recipe_namespace + AND twt.recipe_name = wr.recipe_name + WHERE wr.usage_team_id IS NOT NULL AND wr.run_id::text > $2`, + ]), + workflow_approval: boundedKeyUnionSql([ + `SELECT war.id::text AS logical_id + FROM workflow_approval_requests war + JOIN user_workflow_triggers uwt + ON uwt.user_id = $1 + AND uwt.recipe_namespace = war.recipe_namespace + AND uwt.recipe_name = war.recipe_name + WHERE war.target_user_id = $1 AND war.status = 'pending' + AND war.expires_at > NOW() AND war.id::text > $2`, + `SELECT war.id::text AS logical_id + FROM workflow_approval_requests war + JOIN team_members tm + ON tm.team_id = war.target_team_id AND tm.user_id = $1 AND tm.status = 'active' + JOIN team_workflow_triggers twt + ON twt.team_id = tm.team_id + AND twt.recipe_namespace = war.recipe_namespace + AND twt.recipe_name = war.recipe_name + WHERE war.status = 'pending' AND war.expires_at > NOW() AND war.id::text > $2`, + ]), + notification: boundedKeyUnionSql([ + `SELECT nd.id::text AS logical_id + FROM notification_deliveries nd + WHERE nd.audience->>'userId' = $1::text + AND (nd.expires_at IS NULL OR nd.expires_at > NOW()) + AND nd.id::text > $2`, + `SELECT nd.id::text AS logical_id + FROM notification_deliveries nd + JOIN team_members tm + ON tm.team_id::text = nd.audience->>'teamId' + AND tm.user_id = $1::uuid AND tm.status = 'active' + WHERE (nd.expires_at IS NULL OR nd.expires_at > NOW()) AND nd.id::text > $2`, + ]), + gfs_resource: boundedKeyUnionSql([ + `SELECT g.resource_id::text AS logical_id + FROM gfs_grants g + JOIN gfs_resources resource + ON resource.resource_id = g.resource_id AND resource.deleted_at IS NULL + WHERE g.subject_type = 'user' AND g.subject_id = $1::text + AND g.resource_id::text > $2`, + `SELECT g.resource_id::text AS logical_id + FROM gfs_grants g + JOIN team_members tm + ON tm.team_id::text = g.subject_id + AND tm.user_id = $1::uuid AND tm.status = 'active' + JOIN gfs_resources resource + ON resource.resource_id = g.resource_id AND resource.deleted_at IS NULL + WHERE g.subject_type = 'team' AND g.resource_id::text > $2`, + `SELECT share.resource_id::text AS logical_id + FROM gfs_shares share + JOIN gfs_resources resource + ON resource.resource_id = share.resource_id AND resource.deleted_at IS NULL + WHERE share.subject_type = 'user' AND share.subject_id = $1::text + AND share.resource_id::text > $2`, + `SELECT share.resource_id::text AS logical_id + FROM gfs_shares share + JOIN team_members tm + ON tm.team_id::text = share.subject_id + AND tm.user_id = $1::uuid AND tm.status = 'active' + JOIN gfs_resources resource + ON resource.resource_id = share.resource_id AND resource.deleted_at IS NULL + WHERE share.subject_type = 'team' AND share.resource_id::text > $2`, + ]), + shared_filesystem: boundedKeyUnionSql([ + `SELECT edge.target_id AS logical_id + FROM user_contexts uc + JOIN operational_resource_relationships edge + ON edge.environment_id = $3 + AND edge.source_type = 'context' + AND edge.source_id = $6::text || '/' || uc.context_id + AND edge.relationship_type = 'mounts_shared_filesystem' + AND edge.target_type = 'shared_filesystem' + WHERE uc.user_id = $1 AND edge.target_id > $2`, + `SELECT edge.target_id AS logical_id + FROM team_contexts tc + JOIN team_members tm ON tm.team_id = tc.team_id + JOIN operational_resource_relationships edge + ON edge.environment_id = $3 + AND edge.source_type = 'context' + AND edge.source_id = $6::text || '/' || tc.context_id + AND edge.relationship_type = 'mounts_shared_filesystem' + AND edge.target_type = 'shared_filesystem' + WHERE tm.user_id = $1 AND tm.status = 'active' AND edge.target_id > $2`, + ]), + sandbox_app: boundedKeyUnionSql([ + `SELECT edge.target_id AS logical_id + FROM user_workflow_triggers uwt + JOIN operational_resource_relationships edge + ON edge.environment_id = $3 + AND edge.source_type = 'workflow_recipe' + AND edge.source_id = uwt.recipe_namespace || '/' || uwt.recipe_name + AND edge.relationship_type = 'exposes_sandbox_app' + AND edge.target_type = 'sandbox_app' + WHERE uwt.user_id = $1 AND edge.target_id > $2`, + `SELECT edge.target_id AS logical_id + FROM team_workflow_triggers twt + JOIN team_members tm ON tm.team_id = twt.team_id + JOIN operational_resource_relationships edge + ON edge.environment_id = $3 + AND edge.source_type = 'workflow_recipe' + AND edge.source_id = twt.recipe_namespace || '/' || twt.recipe_name + AND edge.relationship_type = 'exposes_sandbox_app' + AND edge.target_type = 'sandbox_app' + WHERE tm.user_id = $1 AND tm.status = 'active' AND edge.target_id > $2`, + ]), +}) diff --git a/control-api/src/services/access/catalogProducerSupport.ts b/control-api/src/services/access/catalogProducerSupport.ts new file mode 100644 index 0000000000..c22c4ae581 --- /dev/null +++ b/control-api/src/services/access/catalogProducerSupport.ts @@ -0,0 +1,225 @@ +import type { DbClient } from '../../db.js' +import type { AccessExecutionBudget } from './accessExecutionBudget.js' +import { revisionOfValues } from './authorizationRevision.js' +import { + type CatalogFamily, + type CatalogIdentityCandidate, + type CatalogOperationalSourceState, + type CatalogProducerPage, + type CatalogRequestContext, + type ProducerContinuation, + type SafeCatalogPartialError, + catalogKey, + compareCatalogKey, +} from './catalogContracts.js' +import type { OperationalSourceFamily } from './operationalAccessProjection.js' + +export class CatalogProducerContractError extends Error { + constructor(readonly code: string) { + super(`Catalog producer contract violation: ${code}`) + this.name = 'CatalogProducerContractError' + } +} + +export async function catalogQuery( + db: Pick, + budget: AccessExecutionBudget, + text: string, + values: unknown[] +) { + return budget.runProducer(async () => { + budget.assertActive() + const result = await db.query(text, values) + if (result.rows.length > 0) { + budget.charge({ kind: 'dbRowsReturned', amount: result.rows.length }) + } + budget.assertActive() + return result + }) +} + +export type BoundedKeyArm = Readonly<{ sql: string; orderBy?: string }> + +export function boundedKeyUnionSql(arms: readonly (string | BoundedKeyArm)[]): string { + if (arms.length === 0) throw new CatalogProducerContractError('key_arms_missing') + const names = arms.map((_, index) => `source_${index}`) + const sources = arms + .map((arm, index) => { + const definition = typeof arm === 'string' ? { sql: arm } : arm + return `${names[index]} AS MATERIALIZED ( + ${definition.sql} + ORDER BY ${definition.orderBy ?? 'logical_id'} + LIMIT $4 + )` + }) + .join(',\n') + const union = names.map(name => `SELECT logical_id FROM ${name}`).join('\nUNION ALL\n') + return `WITH ${sources} + SELECT logical_id + FROM (${union}) bounded_sources + WHERE $1::uuid IS NOT NULL AND $2::text IS NOT NULL AND $3::text IS NOT NULL + AND $5::text IS NOT NULL AND $6::text IS NOT NULL AND $7::text IS NOT NULL + GROUP BY logical_id + ORDER BY logical_id + LIMIT $4` +} + +function validateContinuation( + family: CatalogFamily, + environmentId: string, + continuation: ProducerContinuation +): string { + if (continuation.exhausted) return '' + if (!continuation.afterKey) return '' + const [cursorEnvironment, cursorFamily, logicalId] = continuation.afterKey + if (cursorEnvironment !== environmentId || cursorFamily !== family || !logicalId) { + throw new CatalogProducerContractError('continuation_family_mismatch') + } + return logicalId +} + +function sourceRevision(states: readonly CatalogOperationalSourceState[]): string { + return revisionOfValues( + states.map(state => [state.family, state.generation, state.resourceVersion, state.status]) + ) +} + +export function operationalReadiness( + context: CatalogRequestContext, + family: CatalogFamily, + required: readonly OperationalSourceFamily[] +): + | Readonly<{ status: 'current'; sourceRevision: string }> + | Readonly<{ + status: 'partial' + sourceRevision: string + errors: readonly SafeCatalogPartialError[] + }> { + if (required.length === 0) return { status: 'current', sourceRevision: 'database' } + const states = required.flatMap(source => { + const value = context.sourceStates.get(source) + return value ? [value] : [] + }) + const errors: SafeCatalogPartialError[] = [] + for (const source of required) { + const state = context.sourceStates.get(source) + if (!state || state.status !== 'current') { + errors.push( + Object.freeze({ + producer: family, + code: + state?.status === 'relisting' + ? 'operational_source_relisting' + : 'operational_source_unavailable', + retryable: true, + }) + ) + } + } + return errors.length === 0 + ? { status: 'current', sourceRevision: sourceRevision(states) } + : { + status: 'partial', + sourceRevision: sourceRevision(states), + errors: Object.freeze(errors), + } +} + +export async function listBoundedProducerKeys(input: { + context: CatalogRequestContext + family: CatalogFamily + requiredOperationalSources: readonly OperationalSourceFamily[] + continuation: ProducerContinuation + take: number + sql: string + extraValues?: readonly unknown[] +}): Promise { + const { context } = input + if ( + !Number.isSafeInteger(input.take) || + input.take < 1 || + input.take > context.budget.limits.publicPageSize || + input.take + 1 > context.budget.limits.keyCandidatesPerCall + ) { + throw new CatalogProducerContractError('take_invalid') + } + const readiness = operationalReadiness(context, input.family, input.requiredOperationalSources) + if (readiness.status === 'partial') { + return Object.freeze({ + candidates: Object.freeze([]), + continuation: Object.freeze({ afterKey: input.continuation.afterKey, exhausted: false }), + hasMore: false, + sourceRevision: readiness.sourceRevision, + sourceCompleteness: 'partial', + partialErrors: readiness.errors, + }) + } + if (input.continuation.exhausted) { + return Object.freeze({ + candidates: Object.freeze([]), + continuation: input.continuation, + hasMore: false, + sourceRevision: readiness.sourceRevision, + sourceCompleteness: 'complete', + partialErrors: Object.freeze([]), + }) + } + const after = validateContinuation(input.family, context.environmentId, input.continuation) + const result = await catalogQuery(context.db, context.budget, input.sql, [ + context.principal.userId, + after, + context.environmentId, + input.take + 1, + ...(input.extraValues ?? []), + ]) + if (result.rows.length > input.take + 1) { + throw new CatalogProducerContractError('key_query_exceeded_take') + } + const candidates: CatalogIdentityCandidate[] = [] + let prior = after + for (const row of result.rows as Record[]) { + const logicalId = typeof row.logical_id === 'string' ? row.logical_id : '' + if (!logicalId || logicalId <= prior) { + throw new CatalogProducerContractError('keys_not_strictly_ordered') + } + const key = catalogKey(context.environmentId, input.family, logicalId) + if (candidates.length > 0 && compareCatalogKey(candidates.at(-1)!.key, key) >= 0) { + throw new CatalogProducerContractError('keys_not_strictly_ordered') + } + candidates.push(Object.freeze({ key, canonicalId: `${input.family}:${logicalId}` })) + prior = logicalId + } + const hasMore = candidates.length > input.take + return Object.freeze({ + candidates: Object.freeze(candidates), + continuation: Object.freeze({ + afterKey: candidates.at(-1)?.key ?? input.continuation.afterKey, + exhausted: !hasMore, + }), + hasMore, + sourceRevision: readiness.sourceRevision, + sourceCompleteness: 'complete', + partialErrors: Object.freeze([]), + }) +} + +export function validateHydrationKeys(input: { + context: CatalogRequestContext + family: CatalogFamily + keys: readonly (readonly [string, CatalogFamily, string])[] +}): string[] { + if (input.keys.length > input.context.budget.limits.keyCandidatesPerCall) { + throw new CatalogProducerContractError('hydrate_key_count_exceeded') + } + const logicalIds: string[] = [] + for (const key of input.keys) { + if (key[0] !== input.context.environmentId || key[1] !== input.family || !key[2]) { + throw new CatalogProducerContractError('hydrate_key_mismatch') + } + if (logicalIds.at(-1) !== undefined && key[2] <= logicalIds.at(-1)!) { + throw new CatalogProducerContractError('hydrate_keys_not_strictly_ordered') + } + logicalIds.push(key[2]) + } + return logicalIds +} diff --git a/control-api/src/services/access/catalogProducers.ts b/control-api/src/services/access/catalogProducers.ts new file mode 100644 index 0000000000..d1a74af1a4 --- /dev/null +++ b/control-api/src/services/access/catalogProducers.ts @@ -0,0 +1,562 @@ +import { config } from '../../config.js' +import type { TeamRole } from '../../profileTypes.js' +import { type AuthorityCandidate, authorityCandidate } from './accessAuthorityStore.js' +import type { AccessCapability } from './capabilityRegistry.js' +import { gfsPermissionsToCapabilities } from './capabilityRegistry.js' +import { + CATALOG_FAMILIES, + type CatalogFamily, + type CatalogKey, + type CatalogProducer, + type CatalogProducerPage, + type CatalogRelationship, + type CatalogRequestContext, + type HydratedCatalogResource, + type ProducerContinuation, + catalogKey, +} from './catalogContracts.js' +import { + DERIVED_OPERATIONAL_HYDRATION_SQL, + GFS_HYDRATION_SQL, + NOTIFICATION_HYDRATION_SQL, + SIMPLE_OPERATIONAL_HYDRATION_SQL, + TEAM_HYDRATION_SQL, + USER_HYDRATION_SQL, + WORKFLOW_APPROVAL_HYDRATION_SQL, + WORKFLOW_RUN_HYDRATION_SQL, +} from './catalogHydrationSql.js' +import { CATALOG_KEY_SQL } from './catalogProducerSql.js' +import { + CatalogProducerContractError, + catalogQuery, + listBoundedProducerKeys, + operationalReadiness, + validateHydrationKeys, +} from './catalogProducerSupport.js' +import type { OperationalSourceFamily } from './operationalAccessProjection.js' +import { canonicalResourceIdentity } from './resourceIdentity.js' + +const REQUIRED_SOURCES: Readonly> = + Object.freeze({ + user: [], + team: [], + host: ['host'], + context: ['context'], + mcp_server: ['host', 'context', 'mcp_server'], + workflow_recipe: ['workflow_recipe'], + workflow_run: [], + workflow_approval: [], + notification: [], + gfs_resource: [], + shared_filesystem: ['context', 'shared_filesystem'], + sandbox_app: ['workflow_recipe'], + }) + +type JsonRecord = Record + +function records(value: unknown): JsonRecord[] { + if (!Array.isArray(value)) throw new CatalogProducerContractError('json_array_invalid') + return value.map(item => { + if (!item || typeof item !== 'object' || Array.isArray(item)) { + throw new CatalogProducerContractError('json_record_invalid') + } + return item as JsonRecord + }) +} + +function boundedString(value: unknown, label: string, maximum = 1_024): string { + const result = typeof value === 'string' ? value : String(value ?? '') + if (!result || result.length > maximum || /[\u0000-\u001f\u007f]/u.test(result)) { + throw new CatalogProducerContractError(`${label}_invalid`) + } + return result +} + +function nullableString(value: unknown, maximum = 1_024): string | null { + if (value === null || value === undefined) return null + return boundedString(value, 'nullable_string', maximum) +} + +function nonNegativeInteger(value: unknown, label: string): number { + const result = Number(value) + if (!Number.isSafeInteger(result) || result < 0) { + throw new CatalogProducerContractError(`${label}_invalid`) + } + return result +} + +function teamRole(value: unknown): TeamRole | undefined { + return ['admin', 'inviter', 'member'].includes(String(value)) + ? (String(value) as TeamRole) + : undefined +} + +function kindOf(row: JsonRecord): 'direct' | 'team' { + if (row.kind === 'direct' || row.kind === 'team') return row.kind + throw new CatalogProducerContractError('path_kind_invalid') +} + +function relationValue(row: JsonRecord, camel: string, snake: string): unknown { + return row[camel] ?? row[snake] +} + +function catalogRelationship(row: JsonRecord): CatalogRelationship { + const type = boundedString( + relationValue(row, 'type', 'relationship_type'), + 'relationship_type', + 128 + ) + const explicitTarget = relationValue(row, 'targetResourceId', 'target_resource_id') + const targetResourceId = explicitTarget + ? boundedString(explicitTarget, 'relationship_target', 1_536) + : `${boundedString(row.target_type, 'relationship_target_type', 64)}:${boundedString( + row.target_id, + 'relationship_target_id' + )}` + const instance = relationValue(row, 'instanceId', 'relationship_instance_id') + return Object.freeze({ + type, + targetResourceId, + ...(instance ? { instanceId: boundedString(instance, 'relationship_instance', 256) } : {}), + }) +} + +function canonicalRelationships(rows: readonly JsonRecord[]): CatalogRelationship[] { + const values = new Map() + for (const row of rows) { + const relationship = catalogRelationship(row) + values.set( + JSON.stringify([ + relationship.type, + relationship.targetResourceId, + relationship.instanceId ?? null, + ]), + relationship + ) + } + return [...values.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([, value]) => value) +} + +function commonCandidate(input: { + context: CatalogRequestContext + row: JsonRecord + capabilities: readonly AccessCapability[] + runtimeSensitive?: boolean + filesystemScope?: string | null + runtimeRef?: string | null + approvalRef?: string | null +}): AuthorityCandidate { + const kind = kindOf(input.row) + const teamId = kind === 'team' ? boundedString(input.row.team_id, 'path_team_id', 64) : undefined + const role = kind === 'team' ? teamRole(input.row.current_role) : undefined + if (kind === 'team' && !role) throw new CatalogProducerContractError('path_role_invalid') + return authorityCandidate({ + userId: input.context.principal.userId, + kind, + grantId: boundedString(input.row.grant_id, 'path_grant_id', 2_048), + ...(teamId ? { teamId } : {}), + ...(role ? { currentRole: role } : {}), + capabilities: input.capabilities, + runtimeSensitive: input.runtimeSensitive, + filesystemScope: input.filesystemScope, + runtimeRef: input.runtimeRef, + approvalRef: input.approvalRef, + }) +} + +function capabilitiesForFamily(family: CatalogFamily): readonly AccessCapability[] { + switch (family) { + case 'user': + return ['user.profile.read'] + case 'host': + return [ + 'host.read', + 'host.use', + 'host.activity.read', + 'remote_desktop.use', + 'chat.read', + 'chat.message.invoke', + 'task.read', + 'task.manage', + 'model.read', + 'model.select', + 'session.read', + 'session.manage', + ] + case 'context': + return ['context.read', 'context.use'] + case 'mcp_server': + return ['mcp_server.read', 'mcp_server.use'] + case 'workflow_recipe': + return ['workflow.read', 'workflow.trigger'] + case 'workflow_run': + return [ + 'workflow.read', + 'workflow.run.manage', + 'workflow.artifact.read', + 'workflow.artifact.delete', + ] + case 'workflow_approval': + return ['workflow.approval.decide'] + case 'notification': + return ['notification.read'] + case 'shared_filesystem': + return ['shared_filesystem.read'] + case 'sandbox_app': + return ['sandbox_app.read', 'sandbox_app.use', 'sandbox_oauth.vend'] + default: + return [] + } +} + +function simpleOperationalCandidates(input: { + context: CatalogRequestContext + family: 'host' | 'context' | 'workflow_recipe' | 'sandbox_app' + logicalId: string + pathRows: readonly JsonRecord[] + relationshipRows: readonly JsonRecord[] +}): AuthorityCandidate[] { + const relevantRelationships = input.relationshipRows.filter(row => { + const sourceType = String(row.source_type ?? '') + const sourceId = String(row.source_id ?? '') + if (input.family === 'sandbox_app') { + return row.target_type === 'sandbox_app' && row.target_id === input.logicalId + } + return sourceType === input.family && sourceId === input.logicalId + }) + const runtimeRef = JSON.stringify( + relevantRelationships + .map(row => boundedString(row.relationship_instance_id, 'relationship_instance', 256)) + .sort() + ) + return input.pathRows.map(row => + commonCandidate({ + context: input.context, + row, + capabilities: capabilitiesForFamily(input.family), + runtimeSensitive: true, + runtimeRef, + }) + ) +} + +function derivedCandidates(input: { + context: CatalogRequestContext + family: 'mcp_server' | 'shared_filesystem' + pathRows: readonly JsonRecord[] +}): AuthorityCandidate[] { + return input.pathRows.map(row => { + const edgeInstance = boundedString(row.edge_instance, 'edge_instance', 256) + const sourceId = boundedString(row.source_id, 'path_source_id') + if (input.family === 'shared_filesystem') { + const edgeBehavior = + row.edge_behavior && typeof row.edge_behavior === 'object' + ? (row.edge_behavior as JsonRecord) + : {} + return commonCandidate({ + context: input.context, + row, + capabilities: capabilitiesForFamily(input.family), + filesystemScope: JSON.stringify({ + contextId: sourceId, + relationshipInstanceId: edgeInstance, + mountPath: boundedString(edgeBehavior.mountPath, 'filesystem_mount_path'), + readOnly: true, + }), + runtimeRef: edgeInstance, + }) + } + const hostEdge = nullableString(row.host_edge_instance, 256) + return commonCandidate({ + context: input.context, + row, + capabilities: capabilitiesForFamily(input.family), + runtimeSensitive: true, + runtimeRef: hostEdge ? JSON.stringify([hostEdge, edgeInstance]) : edgeInstance, + }) + }) +} + +function databaseCandidates(input: { + context: CatalogRequestContext + family: CatalogFamily + logicalId: string + pathRows: readonly JsonRecord[] +}): AuthorityCandidate[] { + if (input.family === 'team') { + return input.pathRows.map(row => { + const role = teamRole(row.current_role) + if (!role) throw new CatalogProducerContractError('path_role_invalid') + const capabilities: AccessCapability[] = ['team.read'] + if (role === 'admin') { + capabilities.push( + 'team.manage', + 'team.member.read', + 'team.member.invite', + 'team.member.manage' + ) + } else if (role === 'inviter') { + capabilities.push('team.member.read', 'team.member.invite') + } + return commonCandidate({ context: input.context, row, capabilities }) + }) + } + if (input.family === 'gfs_resource') { + return input.pathRows.map(row => + commonCandidate({ + context: input.context, + row, + capabilities: gfsPermissionsToCapabilities(row.permissions), + filesystemScope: JSON.stringify({ + drive: boundedString(row.drive, 'gfs_drive', 512), + resourceId: input.logicalId, + }), + }) + ) + } + return input.pathRows.map(row => + commonCandidate({ + context: input.context, + row, + capabilities: capabilitiesForFamily(input.family), + runtimeSensitive: input.family === 'workflow_run', + approvalRef: input.family === 'workflow_approval' ? `approval:${input.logicalId}` : undefined, + }) + ) +} + +function hydrationSql(family: CatalogFamily): string { + if (family === 'user') return USER_HYDRATION_SQL + if (family === 'team') return TEAM_HYDRATION_SQL + if (['host', 'context', 'workflow_recipe', 'sandbox_app'].includes(family)) { + return SIMPLE_OPERATIONAL_HYDRATION_SQL + } + if (family === 'mcp_server' || family === 'shared_filesystem') { + return DERIVED_OPERATIONAL_HYDRATION_SQL + } + if (family === 'workflow_run') return WORKFLOW_RUN_HYDRATION_SQL + if (family === 'workflow_approval') return WORKFLOW_APPROVAL_HYDRATION_SQL + if (family === 'notification') return NOTIFICATION_HYDRATION_SQL + return GFS_HYDRATION_SQL +} + +function hydrationValues(input: { + context: CatalogRequestContext + family: CatalogFamily + logicalIds: readonly string[] +}): unknown[] { + const base = [input.context.principal.userId, input.logicalIds, input.context.environmentId] + if (input.family === 'user' || input.family === 'team') return base + if ( + ['workflow_run', 'workflow_approval', 'notification', 'gfs_resource'].includes(input.family) + ) { + return [...base, input.context.budget.remaining('accessPaths') + 1] + } + return [ + ...base, + input.family, + config.hostsNamespace, + config.contextsNamespace, + input.context.budget.remaining('accessPaths') + 1, + input.context.budget.remaining('relationships') + 1, + ] +} + +function decodedHydrationBytes(rows: readonly unknown[]): number { + try { + return Math.max(1, Buffer.byteLength(JSON.stringify(rows), 'utf8')) + } catch { + throw new CatalogProducerContractError('hydration_not_serializable') + } +} + +function hydrateRows(input: { + context: CatalogRequestContext + family: CatalogFamily + rows: readonly JsonRecord[] +}): HydratedCatalogResource[] { + input.context.budget.charge({ + kind: 'decodedBytes', + amount: decodedHydrationBytes(input.rows), + authorityRequired: true, + }) + const hydrated: HydratedCatalogResource[] = [] + let representedPaths = 0 + let representedRelationships = 0 + let rawPaths = 0 + let rawRelationships = 0 + let reportedPathRows = 0 + let reportedRelationshipRows = 0 + for (const row of input.rows) { + const logicalId = boundedString(row.logical_id, 'resource_logical_id') + const pathRows = records(row.paths) + if (pathRows.length === 0) continue + const relationshipRows = records(row.relationships) + rawPaths += pathRows.length + rawRelationships += relationshipRows.length + reportedPathRows = Math.max( + reportedPathRows, + nonNegativeInteger(row.total_path_rows ?? 0, 'total_path_rows') + ) + reportedRelationshipRows = Math.max( + reportedRelationshipRows, + nonNegativeInteger(row.total_relationship_rows ?? 0, 'total_relationship_rows') + ) + const accessPaths = + input.family === 'host' || + input.family === 'context' || + input.family === 'workflow_recipe' || + input.family === 'sandbox_app' + ? simpleOperationalCandidates({ + context: input.context, + family: input.family, + logicalId, + pathRows, + relationshipRows, + }) + : input.family === 'mcp_server' || input.family === 'shared_filesystem' + ? derivedCandidates({ context: input.context, family: input.family, pathRows }) + : databaseCandidates({ + context: input.context, + family: input.family, + logicalId, + pathRows, + }) + const dedupedPaths = new Map() + for (const accessPath of accessPaths) { + dedupedPaths.set( + JSON.stringify([ + accessPath.kind, + accessPath.teamId ?? null, + accessPath.grantId, + accessPath.behavior, + ]), + accessPath + ) + } + const paths = [...dedupedPaths.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([, value]) => value) + const relationships = canonicalRelationships(relationshipRows) + representedPaths += paths.length + representedRelationships += relationships.length + const validUntil = row.valid_until ? new Date(String(row.valid_until)).toISOString() : null + hydrated.push( + Object.freeze({ + key: catalogKey(input.context.environmentId, input.family, logicalId), + resource: canonicalResourceIdentity({ + environmentId: input.context.environmentId, + type: input.family, + logicalId, + displayName: boundedString(row.display_name, 'resource_display_name', 512), + providerUid: nullableString(row.provider_uid, 256) ?? undefined, + }), + accessPaths: Object.freeze(paths), + relationships: Object.freeze(relationships), + authorizationResourceRevision: boundedString( + row.resource_revision ?? '1', + 'resource_revision', + 128 + ), + validUntil, + }) + ) + } + const remainingPaths = input.context.budget.remaining('accessPaths') + const remainingRelationships = input.context.budget.remaining('relationships') + const requiredPathBudget = Math.max(rawPaths, reportedPathRows) + const requiredRelationshipBudget = Math.max(rawRelationships, reportedRelationshipRows) + if (requiredPathBudget > remainingPaths) { + input.context.budget.charge({ + kind: 'accessPaths', + amount: requiredPathBudget, + authorityRequired: true, + }) + } + if (requiredRelationshipBudget > remainingRelationships) { + input.context.budget.charge({ + kind: 'relationships', + amount: requiredRelationshipBudget, + authorityRequired: true, + }) + } + if (representedPaths > 0) { + input.context.budget.charge({ + kind: 'accessPaths', + amount: representedPaths, + authorityRequired: true, + }) + } + if (representedRelationships > 0) { + input.context.budget.charge({ + kind: 'relationships', + amount: representedRelationships, + authorityRequired: true, + }) + } + return hydrated +} + +class SqlCatalogProducer implements CatalogProducer { + readonly requiredOperationalSources: readonly OperationalSourceFamily[] + + constructor(readonly family: CatalogFamily) { + this.requiredOperationalSources = REQUIRED_SOURCES[family] + } + + listCanonicalKeys( + context: CatalogRequestContext, + continuation: ProducerContinuation, + take: number + ): Promise { + const after = continuation.afterKey?.[2] ?? '' + const prefix = `${this.family === 'host' ? config.hostsNamespace : config.contextsNamespace}/` + const scopedAfter = after.startsWith(prefix) ? after.slice(prefix.length) : '' + return listBoundedProducerKeys({ + context, + family: this.family, + requiredOperationalSources: this.requiredOperationalSources, + continuation, + take, + sql: CATALOG_KEY_SQL[this.family], + extraValues: [config.hostsNamespace, config.contextsNamespace, scopedAfter], + }) + } + + async hydrateCanonicalKeys( + context: CatalogRequestContext, + keys: readonly CatalogKey[] + ): Promise { + const logicalIds = validateHydrationKeys({ context, family: this.family, keys }) + if (logicalIds.length === 0) return Object.freeze([]) + const readiness = operationalReadiness(context, this.family, this.requiredOperationalSources) + if (readiness.status !== 'current') { + throw new CatalogProducerContractError('operational_source_changed_before_hydration') + } + const result = await catalogQuery( + context.db, + context.budget, + hydrationSql(this.family), + hydrationValues({ context, family: this.family, logicalIds }) + ) + return Object.freeze( + hydrateRows({ + context, + family: this.family, + rows: result.rows as JsonRecord[], + }) + ) + } +} + +export const catalogProducers: ReadonlyMap = new Map( + CATALOG_FAMILIES.map(family => [family, new SqlCatalogProducer(family)]) +) + +export function requireCatalogProducer(family: CatalogFamily): CatalogProducer { + const producer = catalogProducers.get(family) + if (!producer) throw new CatalogProducerContractError('producer_missing') + return producer +} diff --git a/control-api/src/services/access/userAccessFoundationSchema.ts b/control-api/src/services/access/userAccessFoundationSchema.ts index 82d7d8cb30..36a15cab05 100644 --- a/control-api/src/services/access/userAccessFoundationSchema.ts +++ b/control-api/src/services/access/userAccessFoundationSchema.ts @@ -83,6 +83,15 @@ export async function applyUserAccessFoundationSchema(db: DbClient): Promise 0), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + INSERT INTO authorization_catalog_revision(singleton) + VALUES(TRUE) + ON CONFLICT (singleton) DO NOTHING; + CREATE TABLE IF NOT EXISTS operational_catalog_source_state ( environment_id TEXT NOT NULL, source_family TEXT NOT NULL, @@ -197,6 +206,14 @@ export async function applyUserAccessFoundationSchema(db: DbClient): Promise async function applyAuthorizationRevisionTriggers(db: DbClient): Promise { await db.query(` + CREATE OR REPLACE FUNCTION authorization_bump_catalog_revision() + RETURNS TRIGGER + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = pg_catalog, public + AS $$ + BEGIN + UPDATE authorization_catalog_revision + SET revision = revision + 1, + updated_at = clock_timestamp() + WHERE singleton = TRUE; + RETURN NULL; + END; + $$; + CREATE OR REPLACE FUNCTION authorization_bump_team_membership_revision() RETURNS TRIGGER LANGUAGE plpgsql @@ -455,6 +487,34 @@ async function applyAuthorizationRevisionTriggers(db: DbClient): Promise { END; $$; + DO $$ + DECLARE table_name TEXT; + DECLARE trigger_name TEXT; + BEGIN + FOREACH table_name IN ARRAY ARRAY[ + 'users', 'teams', 'team_members', + 'user_contexts', 'team_contexts', 'user_agents', 'team_agents', + 'user_workflow_triggers', 'team_workflow_triggers', + 'workflow_runs', 'workflow_approval_requests', 'notification_deliveries', + 'gfs_resources', 'gfs_grants', 'gfs_shares', + 'operational_resource_index', 'operational_resource_relationships', + 'operational_catalog_source_state' + ] + LOOP + trigger_name := table_name || '_catalog_revision'; + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = trigger_name) THEN + EXECUTE format( + 'CREATE TRIGGER %I AFTER INSERT OR UPDATE OR DELETE ON %I ' + || 'FOR EACH STATEMENT EXECUTE FUNCTION authorization_bump_catalog_revision()', + trigger_name, + table_name + ); + END IF; + END LOOP; + END; + $$; + + REVOKE ALL ON FUNCTION authorization_bump_catalog_revision() FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_team_membership_revision() FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_user_grant_revision() FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_team_grant_revision() FROM PUBLIC; @@ -472,6 +532,7 @@ async function applyUserAccessRuntimePrivileges(db: DbClient): Promise { authorization_user_revisions, authorization_team_revisions, authorization_resource_revisions, + authorization_catalog_revision, operational_catalog_source_state, operational_resource_index, operational_resource_relationships, @@ -495,5 +556,7 @@ async function applyUserAccessRuntimePrivileges(db: DbClient): Promise { authorization_team_revisions, authorization_resource_revisions TO control_api_runtime; + + GRANT SELECT ON TABLE authorization_catalog_revision TO control_api_runtime; `) } diff --git a/control-api/test/catalogProducers.realPostgres.integration.test.ts b/control-api/test/catalogProducers.realPostgres.integration.test.ts new file mode 100644 index 0000000000..531a42d1e9 --- /dev/null +++ b/control-api/test/catalogProducers.realPostgres.integration.test.ts @@ -0,0 +1,133 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { randomBytes, randomUUID } from 'node:crypto' +import { Pool } from 'pg' +import { config } from '../src/config.js' +import { initDb } from '../src/db.js' +import { AccessExecutionBudget } from '../src/services/access/accessExecutionBudget.js' +import { + CATALOG_FAMILIES, + type CatalogFamily, + type CatalogOperationalSourceState, + type CatalogRequestContext, + catalogKey, +} from '../src/services/access/catalogContracts.js' +import { requireCatalogProducer } from '../src/services/access/catalogProducers.js' +import { + OPERATIONAL_SOURCE_FAMILIES, + canonicalEnvironmentId, +} from '../src/services/access/operationalAccessProjection.js' + +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const describeRealPostgres = adminUrl ? describe : describe.skip +const runtimeRoles = [ + 'control_api_runtime', + 'trace_maintenance_runtime', + 'workflow_recipes_runtime', +] as const + +function databaseUrl(baseUrl: string, database: string): string { + const value = new URL(baseUrl) + value.pathname = `/${database}` + return value.toString() +} + +function quoteIdentifier(value: string): string { + return `"${value.replace(/"/g, '""')}"` +} + +describeRealPostgres('catalog producer SQL on real PostgreSQL', () => { + const database = `control_api_catalog_producer_${randomBytes(6).toString('hex')}` + const connectionString = databaseUrl( + adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', + database + ) + const environmentId = canonicalEnvironmentId() + const userId = randomUUID() + let adminPool: Pool + let databasePool: Pool + + beforeAll(async () => { + adminPool = new Pool({ connectionString: adminUrl }) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) + databasePool = new Pool({ connectionString }) + await initDb({ connect: () => databasePool.connect() }) + }) + + afterAll(async () => { + await databasePool?.end() + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + await adminPool.end() + } + }) + + it('parses and executes every bounded key and selected-ID hydration query', async () => { + const budget = AccessExecutionBudget.create('catalog') + const sourceStates: CatalogOperationalSourceState[] = OPERATIONAL_SOURCE_FAMILIES.map( + family => ({ + family, + generation: '1', + resourceVersion: '1', + status: 'current', + }) + ) + const context: CatalogRequestContext = { + db: databasePool, + budget, + principal: { + userId, + sessionContract: 'v2', + sessionRevision: '1', + userRevision: '1', + memberships: [], + }, + environmentId, + sourceStates: new Map(sourceStates.map(state => [state.family, state])), + } + const logicalId: Record = { + user: userId, + team: randomUUID(), + host: `${config.hostsNamespace}/host-a`, + context: `${config.contextsNamespace}/context-a`, + mcp_server: `${config.mcpServersNamespace}/server-a`, + workflow_recipe: `${config.sandboxNamespace}/recipe-a`, + workflow_run: randomUUID(), + workflow_approval: randomUUID(), + notification: randomUUID(), + gfs_resource: randomUUID(), + shared_filesystem: `${config.sharedFilesystemsNamespace}/filesystem-a`, + sandbox_app: `${config.sandboxNamespace}/recipe-a`, + } + + try { + for (const family of CATALOG_FAMILIES) { + const producer = requireCatalogProducer(family) + try { + const page = await producer.listCanonicalKeys( + context, + { afterKey: null, exhausted: false }, + 2 + ) + expect(page.candidates).toEqual([]) + await expect( + producer.hydrateCanonicalKeys(context, [ + catalogKey(environmentId, family, logicalId[family]), + ]) + ).resolves.toEqual([]) + } catch (error) { + throw new Error(`Producer ${family} failed`, { cause: error }) + } + } + } finally { + budget.close() + } + }) +}) diff --git a/control-api/test/catalogProducers.test.ts b/control-api/test/catalogProducers.test.ts new file mode 100644 index 0000000000..42df3fb850 --- /dev/null +++ b/control-api/test/catalogProducers.test.ts @@ -0,0 +1,174 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { DbClient } from '../src/db.js' +import { AccessExecutionBudget } from '../src/services/access/accessExecutionBudget.js' +import { + CATALOG_FAMILIES, + type CatalogOperationalSourceState, + type CatalogRequestContext, + catalogKey, +} from '../src/services/access/catalogContracts.js' +import { CATALOG_KEY_SQL } from '../src/services/access/catalogProducerSql.js' +import { CatalogProducerContractError } from '../src/services/access/catalogProducerSupport.js' +import { + catalogProducers, + requireCatalogProducer, +} from '../src/services/access/catalogProducers.js' +import { canonicalEnvironmentId } from '../src/services/access/operationalAccessProjection.js' + +const environmentId = canonicalEnvironmentId() +const userId = '10000000-0000-4000-8000-000000000001' +const budgets: AccessExecutionBudget[] = [] + +function context( + query: ReturnType, + sourceStates: CatalogOperationalSourceState[] = [] +): CatalogRequestContext { + const budget = AccessExecutionBudget.create('catalog') + budgets.push(budget) + return { + db: { query } as unknown as Pick, + budget, + principal: { + userId, + sessionContract: 'v2', + sessionRevision: 'session-1', + userRevision: 'user-1', + memberships: [], + }, + environmentId, + sourceStates: new Map(sourceStates.map(state => [state.family, state])), + } +} + +afterEach(() => { + for (const budget of budgets.splice(0)) budget.close() +}) + +describe('catalog producer registry', () => { + it('registers exactly the twelve frozen PR 1 families', () => { + expect([...catalogProducers.keys()]).toEqual(CATALOG_FAMILIES) + }) + + it('keyset-bounds every source arm before its final union and never uses offsets', () => { + const expectedArmCounts: Record<(typeof CATALOG_FAMILIES)[number], number> = { + user: 1, + team: 1, + host: 2, + context: 2, + mcp_server: 4, + workflow_recipe: 2, + workflow_run: 3, + workflow_approval: 2, + notification: 2, + gfs_resource: 4, + shared_filesystem: 2, + sandbox_app: 2, + } + for (const family of CATALOG_FAMILIES) { + const sql = CATALOG_KEY_SQL[family] + expect(sql).not.toMatch(/\bOFFSET\b/i) + expect(sql.match(/AS MATERIALIZED/g)).toHaveLength(expectedArmCounts[family]) + expect(sql.match(/LIMIT \$4/g)).toHaveLength(expectedArmCounts[family] + 1) + } + }) +}) + +describe('catalog producer protocol', () => { + it('returns a stable keyset page with one bounded lookahead candidate', async () => { + const query = vi.fn().mockResolvedValue({ + rows: [ + { logical_id: '20000000-0000-4000-8000-000000000002' }, + { logical_id: '30000000-0000-4000-8000-000000000003' }, + ], + rowCount: 2, + }) + const result = await requireCatalogProducer('team').listCanonicalKeys( + context(query), + { afterKey: null, exhausted: false }, + 1 + ) + + expect(result.candidates.map(candidate => candidate.key[2])).toEqual([ + '20000000-0000-4000-8000-000000000002', + '30000000-0000-4000-8000-000000000003', + ]) + expect(result.hasMore).toBe(true) + expect(query).toHaveBeenCalledWith(expect.any(String), [ + userId, + '', + environmentId, + 2, + expect.any(String), + expect.any(String), + '', + ]) + }) + + it('rejects duplicate or non-increasing producer keys', async () => { + const query = vi.fn().mockResolvedValue({ + rows: [{ logical_id: 'z' }, { logical_id: 'z' }], + rowCount: 2, + }) + await expect( + requireCatalogProducer('notification').listCanonicalKeys( + context(query), + { afterKey: null, exhausted: false }, + 2 + ) + ).rejects.toBeInstanceOf(CatalogProducerContractError) + }) + + it('reports an operational source gap as partial without querying authority', async () => { + const query = vi.fn() + const result = await requireCatalogProducer('host').listCanonicalKeys( + context(query, [ + { + family: 'host', + generation: '2', + resourceVersion: null, + status: 'relisting', + }, + ]), + { afterKey: null, exhausted: false }, + 10 + ) + + expect(result).toEqual( + expect.objectContaining({ + sourceCompleteness: 'partial', + candidates: [], + partialErrors: [expect.objectContaining({ code: 'operational_source_relisting' })], + }) + ) + expect(query).not.toHaveBeenCalled() + }) + + it('hydrates a real user path shape only for selected canonical IDs', async () => { + const query = vi.fn().mockResolvedValue({ + rows: [ + { + logical_id: userId, + display_name: 'Ada', + provider_uid: null, + resource_revision: '4', + paths: [{ kind: 'direct', grant_id: `users:${userId}` }], + relationships: [], + valid_until: null, + }, + ], + rowCount: 1, + }) + const result = await requireCatalogProducer('user').hydrateCanonicalKeys(context(query), [ + catalogKey(environmentId, 'user', userId), + ]) + + expect(result).toEqual([ + expect.objectContaining({ + key: [environmentId, 'user', userId], + authorizationResourceRevision: '4', + accessPaths: [expect.objectContaining({ kind: 'direct', grantId: `users:${userId}` })], + }), + ]) + expect(query).toHaveBeenCalledWith(expect.any(String), [userId, [userId], environmentId]) + }) +}) diff --git a/control-api/test/db.userAccessFoundationMigration.test.ts b/control-api/test/db.userAccessFoundationMigration.test.ts index b572f4abfd..b522d566f9 100644 --- a/control-api/test/db.userAccessFoundationMigration.test.ts +++ b/control-api/test/db.userAccessFoundationMigration.test.ts @@ -33,6 +33,7 @@ describe('user-access foundation migration', () => { expect(sql).toContain('CREATE TABLE IF NOT EXISTS authorization_user_revisions') expect(sql).toContain('CREATE TABLE IF NOT EXISTS authorization_team_revisions') expect(sql).toContain('CREATE TABLE IF NOT EXISTS authorization_resource_revisions') + expect(sql).toContain('CREATE TABLE IF NOT EXISTS authorization_catalog_revision') expect(sql).toContain('SECURITY DEFINER') expect(sql).toContain('authorization_bump_team_membership_revision') expect(sql).not.toMatch(/DROP\s+(?:TABLE|COLUMN)/i) @@ -75,6 +76,8 @@ describe('user-access foundation migration', () => { 'team_agents_agent_team_idx', 'user_workflow_triggers_recipe_user_idx', 'team_workflow_triggers_recipe_team_idx', + 'user_workflow_triggers_catalog_key_idx', + 'team_workflow_triggers_catalog_key_idx', 'workflow_runs_actor_catalog_idx', 'workflow_runs_team_catalog_idx', 'workflow_runs_usage_team_catalog_idx', @@ -104,6 +107,7 @@ describe('user-access foundation migration', () => { ) const expected = new Map([ + ['authorization_catalog_revision', 'read'], ['authorization_resource_revisions', 'upsert'], ['authorization_team_revisions', 'upsert'], ['authorization_user_revisions', 'upsert'], diff --git a/deploy/scripts/control-api-runtime-access-profiles.tsv b/deploy/scripts/control-api-runtime-access-profiles.tsv index 4b08ee2a2e..f2aa9bcaff 100644 --- a/deploy/scripts/control-api-runtime-access-profiles.tsv +++ b/deploy/scripts/control-api-runtime-access-profiles.tsv @@ -1,6 +1,7 @@ # public relationcontrol_api_runtime access profile administrative_events append agent_run_events append +authorization_catalog_revision read authorization_resource_revisions upsert authorization_team_revisions upsert authorization_user_revisions upsert From 07046fb56ba8e2e008394ed41ac661ce724bf2d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 00:29:24 -0600 Subject: [PATCH 016/528] feat(access): coordinate paginated aggregate access Build cursor-v3 catalogs from bounded family-owned keyset producers inside one repeatable-read authority snapshot. Preserve all selected paths and relationships, bind cursor and access paths to live revisions, and expose disabled-by-default public contract adapters. --- control-api/src/routes/external/access.ts | 343 ++++++++++++ control-api/src/routes/external/index.ts | 2 + .../services/access/accessAuthorityStore.ts | 13 +- .../access/accessAuthorityTransaction.ts | 16 + .../access/accessCatalogCoordinator.ts | 486 ++++++++++++++++++ .../services/access/accessCatalogCursor.ts | 199 +++++++ .../services/access/authorizationRevision.ts | 69 ++- .../access/catalogAuthorityContext.ts | 204 ++++++++ .../src/services/access/catalogContracts.ts | 5 + .../services/access/catalogHydrationSql.ts | 2 +- .../src/services/access/catalogProducerSql.ts | 122 ++++- .../services/access/catalogProducerSupport.ts | 27 +- .../src/services/access/catalogProducers.ts | 53 +- .../access/liveAuthorizationResolver.ts | 27 +- ...ordinator.realPostgres.integration.test.ts | 180 +++++++ control-api/test/accessCatalogCursor.test.ts | 117 +++++ ...Producers.realPostgres.integration.test.ts | 2 + control-api/test/catalogProducers.test.ts | 4 + .../test/routes.externalAccess.test.ts | 95 ++++ external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- external-rest-api/src/releaseManifest.ts | 2 +- external-rest-api/src/routes/access.ts | 62 +++ external-rest-api/test/routes.access.test.ts | 110 ++++ 24 files changed, 2083 insertions(+), 63 deletions(-) create mode 100644 control-api/src/routes/external/access.ts create mode 100644 control-api/src/services/access/accessAuthorityTransaction.ts create mode 100644 control-api/src/services/access/accessCatalogCoordinator.ts create mode 100644 control-api/src/services/access/accessCatalogCursor.ts create mode 100644 control-api/src/services/access/catalogAuthorityContext.ts create mode 100644 control-api/test/accessCatalogCoordinator.realPostgres.integration.test.ts create mode 100644 control-api/test/accessCatalogCursor.test.ts create mode 100644 control-api/test/routes.externalAccess.test.ts create mode 100644 external-rest-api/src/routes/access.ts create mode 100644 external-rest-api/test/routes.access.test.ts diff --git a/control-api/src/routes/external/access.ts b/control-api/src/routes/external/access.ts new file mode 100644 index 0000000000..60ebd90a2a --- /dev/null +++ b/control-api/src/routes/external/access.ts @@ -0,0 +1,343 @@ +import { Router } from 'express' +import { asyncHandler } from '../../http/asyncHandler.js' +import { sendPublicApiError } from '../../http/publicApiError.js' +import type { K8sGateway } from '../../k8s.js' +import { + type ExternalAuthedRequest, + requireValidExternalSessionTokenWithPublicErrors, +} from '../../middleware/externalSessionAuth.js' +import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' +import { + AccessCatalogRequestError, + buildAccessCatalog, +} from '../../services/access/accessCatalogCoordinator.js' +import { AccessCatalogCursorError } from '../../services/access/accessCatalogCursor.js' +import { + AccessBudgetExceededError, + AccessExecutionCancelledError, +} from '../../services/access/accessExecutionBudget.js' +import { isAccessCapability } from '../../services/access/capabilityRegistry.js' +import { CATALOG_FAMILIES, type CatalogFamily } from '../../services/access/catalogContracts.js' +import { resolveLiveAuthorization } from '../../services/access/liveAuthorizationResolver.js' +import { canonicalEnvironmentId } from '../../services/access/operationalAccessProjection.js' +import { canonicalResourceIdentity } from '../../services/access/resourceIdentity.js' +import { + effectiveUserAccessPolicy, + userAccessCapabilityManifest, +} from '../../services/access/userAccessPolicy.js' + +const CATALOG_RATE_LIMIT_PER_MINUTE = 10 +const RESOLVE_RATE_LIMIT_PER_MINUTE = 10 +const ACCESS_PATH_PATTERN = /^ap1_[A-Za-z0-9_-]{43}$/ + +function catalogFamilies(value: unknown): CatalogFamily[] | null | undefined { + if (value === undefined) return undefined + if (typeof value !== 'string') return null + const values = [ + ...new Set( + value + .split(',') + .map(item => item.trim()) + .filter(Boolean) + ), + ] + if ( + values.length === 0 || + values.some(value => !CATALOG_FAMILIES.includes(value as CatalogFamily)) + ) { + return null + } + return CATALOG_FAMILIES.filter(family => values.includes(family)) +} + +function pageLimit(value: unknown): number | null | undefined { + if (value === undefined) return undefined + if (typeof value !== 'string' || !/^\d+$/.test(value)) return null + const parsed = Number(value) + return Number.isSafeInteger(parsed) && parsed >= 1 && parsed <= 100 ? parsed : null +} + +function operationTarget( + value: unknown, + capability: string +): Record | null | undefined { + if (value === undefined) return undefined + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const candidate = value as Record + const allowed = new Set(['teamId']) + if (['team.member.read', 'team.member.manage'].includes(capability)) allowed.add('userId') + if (['team.member.invite', 'team.member.manage'].includes(capability)) allowed.add('role') + if (capability === 'team.member.invite') { + allowed.add('action') + allowed.add('invitationId') + } + const keys = Object.keys(candidate) + if (keys.length === 0 || keys.length > allowed.size || keys.some(key => !allowed.has(key))) { + return null + } + const normalized: Record = {} + for (const key of keys) { + const item = candidate[key] + if (typeof item !== 'string') return null + const text = item.trim() + if (!text || text.length > 128 || /[\u0000-\u001f\u007f]/u.test(text)) return null + normalized[key] = text + } + return normalized +} + +function requireEffectiveV2Contract( + feature: 'catalog' | 'action', + req: ExternalAuthedRequest, + res: Parameters[1], + next: () => void +): void { + const enabled = + feature === 'catalog' + ? effectiveUserAccessPolicy.serveCatalog + : effectiveUserAccessPolicy.actionContextV2 + if (req.externalAuth?.sessionContract !== 'v2' || !enabled) { + sendPublicApiError( + req, + res, + 409, + 'invalid_request', + 'This access contract is not enabled for the current session.' + ) + return + } + next() +} + +function sendCatalogError( + req: ExternalAuthedRequest, + res: Parameters[1], + error: unknown +): void { + if (error instanceof AccessCatalogCursorError) { + sendPublicApiError( + req, + res, + error.code === 'invalid_cursor' ? 400 : 409, + error.code === 'invalid_cursor' ? 'invalid_request' : 'conflict', + error.code === 'invalid_cursor' + ? 'The catalog cursor is not valid.' + : 'Catalog access changed; refresh from the first page.' + ) + return + } + if (error instanceof AccessCatalogRequestError && error.code === 'session_not_live') { + sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') + return + } + if (error instanceof AccessCatalogRequestError && error.code === 'invalid_request') { + sendPublicApiError(req, res, 400, 'invalid_request', 'The catalog request is not valid.') + return + } + if ( + error instanceof AccessCatalogRequestError || + error instanceof AccessBudgetExceededError || + error instanceof AccessExecutionCancelledError + ) { + sendPublicApiError( + req, + res, + 503, + 'authority_unavailable', + 'Authorization is temporarily unavailable.', + true + ) + return + } + throw error +} + +export function createExternalAccessRouter(gateway: K8sGateway): Router { + const router = Router() + router.use('/external/access', requireValidExternalSessionTokenWithPublicErrors) + + router.get('/external/access/capabilities', (req: ExternalAuthedRequest, res) => { + res.status(200).json({ + contractVersion: '2', + currentSessionContract: req.externalAuth!.sessionContract ?? 'v1', + ...userAccessCapabilityManifest(effectiveUserAccessPolicy), + }) + }) + + router.get( + '/external/access/catalog', + (req, res, next) => requireEffectiveV2Contract('catalog', req, res, next), + rateLimitMiddleware({ + bucketType: 'external_access_catalog', + maxPerMinute: CATALOG_RATE_LIMIT_PER_MINUTE, + getBucketKey: req => + `external_access_catalog:${(req as ExternalAuthedRequest).externalAuth?.userId ?? 'unknown'}`, + onLimited: (req, res, retryAfterSeconds) => + sendPublicApiError( + req, + res, + 429, + 'rate_limited', + 'Too many access catalog requests; retry later.', + true, + { retryAfterSeconds } + ), + }), + asyncHandler(async (req: ExternalAuthedRequest, res) => { + const families = catalogFamilies(req.query.families) + const limit = pageLimit(req.query.limit) + const cursor = typeof req.query.cursor === 'string' ? req.query.cursor : undefined + if (families === null || limit === null || (req.query.cursor !== undefined && !cursor)) { + sendPublicApiError(req, res, 400, 'invalid_request', 'The catalog query is not valid.') + return + } + try { + const catalog = await buildAccessCatalog({ + session: req.externalSessionAuthority!, + ...(families ? { families } : {}), + ...(limit ? { limit } : {}), + ...(cursor ? { cursor } : {}), + }) + res.status(200).json(catalog) + } catch (error) { + sendCatalogError(req, res, error) + } + }) + ) + + router.post( + '/external/access/resolve', + (req, res, next) => requireEffectiveV2Contract('action', req, res, next), + rateLimitMiddleware({ + bucketType: 'external_access_resolve', + maxPerMinute: RESOLVE_RATE_LIMIT_PER_MINUTE, + getBucketKey: req => + `external_access_resolve:${(req as ExternalAuthedRequest).externalAuth?.userId ?? 'unknown'}`, + onLimited: (req, res, retryAfterSeconds) => + sendPublicApiError( + req, + res, + 429, + 'rate_limited', + 'Too many authorization requests; retry later.', + true, + { retryAfterSeconds } + ), + }), + asyncHandler(async (req: ExternalAuthedRequest, res) => { + const body = req.body as Record | undefined + const rawResource = + body?.resource && typeof body.resource === 'object' && !Array.isArray(body.resource) + ? (body.resource as Record) + : null + const capability = typeof body?.requiredCapability === 'string' ? body.requiredCapability : '' + const accessPathId = typeof body?.accessPathId === 'string' ? body.accessPathId : undefined + const target = operationTarget(body?.operationTarget, capability) + let resource + try { + resource = canonicalResourceIdentity({ + environmentId: rawResource?.environmentId, + type: rawResource?.type, + logicalId: rawResource?.logicalId, + displayName: rawResource?.logicalId, + }) + } catch { + resource = null + } + if ( + !resource || + resource.environmentId !== canonicalEnvironmentId() || + !isAccessCapability(capability) || + (accessPathId !== undefined && !ACCESS_PATH_PATTERN.test(accessPathId)) || + target === null + ) { + sendPublicApiError( + req, + res, + 400, + 'invalid_request', + 'The authorization request is invalid.' + ) + return + } + const result = await resolveLiveAuthorization( + { + session: req.externalSessionAuthority!, + requiredCapability: capability, + resource, + ...(accessPathId ? { requestedAccessPathId: accessPathId } : {}), + ...(target ? { operationTarget: target } : {}), + }, + { gateway, correlationId: req.correlationId } + ) + if (result.status === 'allowed') { + res.status(200).json({ + status: result.status, + effectiveCapabilities: result.effectiveCapabilities, + paths: result.paths.map(path => ({ + accessPathId: path.id, + kind: path.kind, + ...(path.teamId ? { teamId: path.teamId } : {}), + })), + selectedAccessPathId: result.selectedPath.id, + authorizationRevision: result.authorizationRevision, + }) + return + } + if (result.status === 'access_path_required') { + sendPublicApiError( + req, + res, + 409, + 'access_path_required', + 'Choose an access path for this operation.', + false, + { paths: result.safePathDescriptors } + ) + return + } + if (result.status === 'access_path_stale') { + sendPublicApiError( + req, + res, + 409, + 'access_path_stale', + 'The access path is stale; refresh access before retrying.' + ) + return + } + if (result.status === 'unavailable') { + sendPublicApiError( + req, + res, + 503, + 'authority_unavailable', + 'Authorization is temporarily unavailable.', + true + ) + return + } + if (result.status === 'not_found') { + sendPublicApiError(req, res, 404, 'not_found', 'The resource was not found.') + return + } + sendPublicApiError( + req, + res, + result.status === 'invalid' ? 400 : result.code === 'session_not_live' ? 401 : 403, + result.status === 'invalid' + ? 'invalid_request' + : result.code === 'session_not_live' + ? 'invalid_session' + : 'forbidden', + result.status === 'invalid' + ? 'The authorization request is invalid.' + : result.code === 'session_not_live' + ? 'The session is not valid.' + : 'The requested operation is not allowed.' + ) + }) + ) + + return router +} diff --git a/control-api/src/routes/external/index.ts b/control-api/src/routes/external/index.ts index 4b6c4e3693..a2fe461640 100644 --- a/control-api/src/routes/external/index.ts +++ b/control-api/src/routes/external/index.ts @@ -1,5 +1,6 @@ import { Router } from 'express' import type { K8sGateway } from '../../k8s.js' +import { createExternalAccessRouter } from './access.js' import { createExternalAuthRouter } from './auth.js' import { createExternalUserApprovalDecisionsRouter } from './decisions.routes.js' import { createExternalDirectoryRouter } from './directory.js' @@ -16,6 +17,7 @@ import { createExternalWorkflowsRouter } from './workflows/index.js' export function createExternalRouter(gateway: K8sGateway): Router { const router = Router() + router.use(createExternalAccessRouter(gateway)) router.use(createExternalAuthRouter(gateway)) router.use(createExternalUsersRouter(gateway)) router.use(createExternalTeamsRouter(gateway)) diff --git a/control-api/src/services/access/accessAuthorityStore.ts b/control-api/src/services/access/accessAuthorityStore.ts index 5868a13ed6..7e33975561 100644 --- a/control-api/src/services/access/accessAuthorityStore.ts +++ b/control-api/src/services/access/accessAuthorityStore.ts @@ -70,7 +70,7 @@ async function query( }) } -function memberships(value: unknown): AuthorizationMembershipRevision[] { +export function parseAuthorizationMemberships(value: unknown): AuthorizationMembershipRevision[] { if (!Array.isArray(value)) return [] return value.flatMap(item => { if (!item || typeof item !== 'object' || Array.isArray(item)) return [] @@ -200,7 +200,7 @@ export async function loadPrincipalAuthoritySnapshot(input: { sessionRevision: String(row.session_revision), userRevision: String(row.user_revision ?? '1'), resourceRevision: String(row.resource_revision ?? '1'), - memberships: Object.freeze(memberships(row.memberships)), + memberships: Object.freeze(parseAuthorizationMemberships(row.memberships)), }) } @@ -671,7 +671,14 @@ async function loadDatabaseCandidates(input: { }), ] : [], - relationships: [], + relationships: membership + ? [ + Object.freeze({ + type: 'membership', + targetResourceId: `user:${snapshot.userId}`, + }), + ] + : [], validUntil: null, } } diff --git a/control-api/src/services/access/accessAuthorityTransaction.ts b/control-api/src/services/access/accessAuthorityTransaction.ts new file mode 100644 index 0000000000..3c986bc9f9 --- /dev/null +++ b/control-api/src/services/access/accessAuthorityTransaction.ts @@ -0,0 +1,16 @@ +import type { DbClient } from '../../db.js' +import type { AccessExecutionBudget } from './accessExecutionBudget.js' + +export async function configureAccessAuthorityTransaction( + db: Pick, + budget: AccessExecutionBudget +): Promise { + await budget.runProducer(async () => { + budget.assertActive() + await db.query('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY') + await db.query(`SELECT set_config('statement_timeout', $1, true)`, [ + `${budget.statementTimeoutMs()}ms`, + ]) + budget.assertActive() + }) +} diff --git a/control-api/src/services/access/accessCatalogCoordinator.ts b/control-api/src/services/access/accessCatalogCoordinator.ts new file mode 100644 index 0000000000..154d52906f --- /dev/null +++ b/control-api/src/services/access/accessCatalogCoordinator.ts @@ -0,0 +1,486 @@ +import type { DbClient } from '../../db.js' +import { withTransaction } from '../../db.js' +import type { ExternalSessionAuthorityContext } from '../auth/externalSessionAuthentication.js' +import { configureAccessAuthorityTransaction } from './accessAuthorityTransaction.js' +import { + ACCESS_CATALOG_CONTRACT_VERSION, + ACCESS_CATALOG_SORT, + type AccessCatalogCursorV3, + assertAccessCatalogCursorCurrent, + catalogFilterHash, + decodeAccessCatalogCursor, + encodeAccessCatalogCursor, +} from './accessCatalogCursor.js' +import { AccessExecutionBudget, type AccessExecutionLimits } from './accessExecutionBudget.js' +import { buildAccessPath, canonicalAccessPathTuple } from './accessPath.js' +import { authorizationRevision, revisionOfValues } from './authorizationRevision.js' +import { normalizeAccessCapabilities } from './capabilityRegistry.js' +import { + CatalogAuthorityError, + catalogSourceStateRevision, + loadCatalogRequestContext, +} from './catalogAuthorityContext.js' +import { + CATALOG_FAMILIES, + type CatalogFamily, + type CatalogIdentityCandidate, + type CatalogKey, + type CatalogProducerPage, + type CatalogRelationship, + type CatalogRequestContext, + type HydratedCatalogResource, + type ProducerContinuation, + type SafeCatalogPartialError, + catalogKeyEquals, + compareCatalogKey, +} from './catalogContracts.js' +import { CatalogProducerContractError } from './catalogProducerSupport.js' +import { requireCatalogProducer } from './catalogProducers.js' +import { canonicalEnvironmentId } from './operationalAccessProjection.js' +import type { CanonicalResourceIdentity } from './resourceIdentity.js' + +export type PublicCatalogAccessPath = Readonly<{ + accessPathId: string + kind: 'direct' | 'team' + safeTeamDescriptor?: Readonly<{ teamId: string; currentRole: string }> + capabilities: readonly string[] + behaviorDescriptors: ReturnType['behavior'] +}> + +export type AccessCatalogItem = Readonly<{ + resource: CanonicalResourceIdentity & Readonly<{ resourceRevision: string }> + relationships: readonly CatalogRelationship[] + capabilities: readonly string[] + accessPaths: readonly PublicCatalogAccessPath[] +}> + +export type AccessCatalogResponse = Readonly<{ + contractVersion: typeof ACCESS_CATALOG_CONTRACT_VERSION + authorizationRevision: string + sourceStateRevision: string + complete: boolean + partialErrors: readonly SafeCatalogPartialError[] + items: readonly AccessCatalogItem[] + nextCursor: string | null +}> + +export class AccessCatalogRequestError extends Error { + constructor( + readonly code: + | 'invalid_request' + | 'authority_unavailable' + | 'session_not_live' + | 'catalog_invariant_failed' + ) { + super(`Access catalog request failed: ${code}`) + this.name = 'AccessCatalogRequestError' + } +} + +type CatalogTransaction = (work: (db: DbClient) => Promise) => Promise + +type ProducerRuntime = { + family: CatalogFamily + inputContinuation: ProducerContinuation + page: CatalogProducerPage + cursorIndex: number + emittedCount: number +} + +type HeapEntry = Readonly<{ + runtime: ProducerRuntime + candidate: CatalogIdentityCandidate +}> + +function normalizeFamilies(value: readonly CatalogFamily[] | undefined): CatalogFamily[] { + if (value === undefined) return [...CATALOG_FAMILIES] + if (value.length === 0 || value.length > CATALOG_FAMILIES.length) { + throw new AccessCatalogRequestError('invalid_request') + } + const requested = new Set() + for (const family of value) { + if (!CATALOG_FAMILIES.includes(family)) { + throw new AccessCatalogRequestError('invalid_request') + } + requested.add(family) + } + return CATALOG_FAMILIES.filter(family => requested.has(family)) +} + +function emptyContinuations(): Record { + return Object.fromEntries( + CATALOG_FAMILIES.map(family => [family, Object.freeze({ afterKey: null, exhausted: false })]) + ) as Record +} + +function entryCompare(left: HeapEntry, right: HeapEntry): number { + const keyOrder = compareCatalogKey(left.candidate.key, right.candidate.key) + return keyOrder || left.runtime.family.localeCompare(right.runtime.family) +} + +function heapPush(heap: HeapEntry[], entry: HeapEntry): void { + heap.push(entry) + let index = heap.length - 1 + while (index > 0) { + const parent = Math.floor((index - 1) / 2) + if (entryCompare(heap[parent], heap[index]) <= 0) break + ;[heap[parent], heap[index]] = [heap[index], heap[parent]] + index = parent + } +} + +function heapPop(heap: HeapEntry[]): HeapEntry | undefined { + const first = heap[0] + const last = heap.pop() + if (!first || !last || heap.length === 0) return first + heap[0] = last + let index = 0 + for (;;) { + const left = index * 2 + 1 + const right = left + 1 + let smallest = index + if (left < heap.length && entryCompare(heap[left], heap[smallest]) < 0) smallest = left + if (right < heap.length && entryCompare(heap[right], heap[smallest]) < 0) smallest = right + if (smallest === index) break + ;[heap[index], heap[smallest]] = [heap[smallest], heap[index]] + index = smallest + } + return first +} + +function advanceRuntime(heap: HeapEntry[], runtime: ProducerRuntime): void { + runtime.cursorIndex += 1 + const candidate = runtime.page.candidates[runtime.cursorIndex] + if (candidate) heapPush(heap, { runtime, candidate }) +} + +function mergeCandidatePages( + runtimes: readonly ProducerRuntime[], + limit: number +): CatalogIdentityCandidate[] { + const heap: HeapEntry[] = [] + for (const runtime of runtimes) { + const candidate = runtime.page.candidates[0] + if (candidate) heapPush(heap, { runtime, candidate }) + } + const values: CatalogIdentityCandidate[] = [] + while (heap.length > 0 && values.length < limit + 1) { + const first = heapPop(heap)! + const equal = [first] + while (heap[0] && catalogKeyEquals(heap[0].candidate.key, first.candidate.key)) { + equal.push(heapPop(heap)!) + } + let validUntil: string | null = null + for (const entry of equal) { + if (entry.candidate.validUntil && (!validUntil || entry.candidate.validUntil < validUntil)) { + validUntil = entry.candidate.validUntil + } + advanceRuntime(heap, entry.runtime) + } + values.push(Object.freeze({ ...first.candidate, validUntil })) + } + return values +} + +function safePartialErrors(runtimes: readonly ProducerRuntime[]): SafeCatalogPartialError[] { + const values = new Map() + for (const runtime of runtimes) { + for (const error of runtime.page.partialErrors) { + values.set(JSON.stringify([error.producer, error.code]), error) + } + } + return [...values.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([, value]) => value) +} + +async function hydrateSelected( + context: CatalogRequestContext, + selected: readonly CatalogIdentityCandidate[] +): Promise> { + const byFamily = new Map() + for (const candidate of selected) { + const values = byFamily.get(candidate.key[1]) ?? [] + values.push(candidate.key) + byFamily.set(candidate.key[1], values) + } + const hydrated = new Map() + for (const family of CATALOG_FAMILIES) { + const keys = byFamily.get(family) + if (!keys?.length) continue + const values = await requireCatalogProducer(family).hydrateCanonicalKeys(context, keys) + for (const value of values) { + const key = JSON.stringify(value.key) + if (hydrated.has(key)) throw new AccessCatalogRequestError('catalog_invariant_failed') + hydrated.set(key, value) + } + } + for (const candidate of selected) { + if (!hydrated.has(JSON.stringify(candidate.key))) { + throw new AccessCatalogRequestError('authority_unavailable') + } + } + return hydrated +} + +function catalogItem( + context: CatalogRequestContext, + hydrated: HydratedCatalogResource +): AccessCatalogItem { + const revision = authorizationRevision({ + principalUserId: context.principal.userId, + sessionContract: context.principal.sessionContract, + sessionRevision: context.principal.sessionRevision, + userRevision: context.principal.userRevision, + memberships: context.principal.memberships, + resource: hydrated.resource, + resourceRevision: hydrated.authorizationResourceRevision, + sourceStateRevision: hydrated.authorizationSourceRevision, + relationshipsRevision: hydrated.authorizationRelationshipsRevision, + candidates: hydrated.accessPaths, + }) + const paths = hydrated.accessPaths + .map(seed => + buildAccessPath({ + principalUserId: context.principal.userId, + resource: hydrated.resource, + seed, + authorizationRevision: revision, + }) + ) + .sort((left, right) => + canonicalAccessPathTuple(left).localeCompare(canonicalAccessPathTuple(right)) + ) + const publicPaths: PublicCatalogAccessPath[] = paths.map(path => + Object.freeze({ + accessPathId: path.id, + kind: path.kind, + ...(path.kind === 'team' + ? { + safeTeamDescriptor: Object.freeze({ + teamId: path.teamId!, + currentRole: path.currentRole!, + }), + } + : {}), + capabilities: path.behavior.capabilities, + behaviorDescriptors: path.behavior, + }) + ) + return Object.freeze({ + resource: Object.freeze({ + ...hydrated.resource, + resourceRevision: hydrated.authorizationResourceRevision, + }), + relationships: hydrated.relationships, + capabilities: Object.freeze( + normalizeAccessCapabilities(paths.flatMap(path => path.behavior.capabilities)) + ), + accessPaths: Object.freeze(publicPaths), + }) +} + +function finalContinuations(input: { + requestedFamilies: readonly CatalogFamily[] + runtimes: readonly ProducerRuntime[] + selected: readonly CatalogIdentityCandidate[] + initial: Readonly> +}): Record { + const requested = new Set(input.requestedFamilies) + const output = emptyContinuations() + for (const family of CATALOG_FAMILIES) { + if (!requested.has(family)) { + output[family] = Object.freeze({ afterKey: null, exhausted: true }) + continue + } + const runtime = input.runtimes.find(value => value.family === family)! + const emitted = input.selected.filter(candidate => candidate.key[1] === family) + runtime.emittedCount = emitted.length + const afterKey = emitted.at(-1)?.key ?? input.initial[family].afterKey + const hasUnconsumed = runtime.page.candidates.length > emitted.length + output[family] = Object.freeze({ + afterKey, + exhausted: + runtime.page.sourceCompleteness === 'complete' && !runtime.page.hasMore && !hasUnconsumed, + }) + } + return output +} + +function earliestValidUntil( + candidates: readonly CatalogIdentityCandidate[], + hydrated: ReadonlyMap +): string | null { + const values = [ + ...candidates.flatMap(candidate => (candidate.validUntil ? [candidate.validUntil] : [])), + ...[...hydrated.values()].flatMap(value => (value.validUntil ? [value.validUntil] : [])), + ] + return values.length > 0 ? values.sort()[0] : null +} + +function responseBytes(value: unknown): number { + try { + return Math.max(1, Buffer.byteLength(JSON.stringify(value), 'utf8')) + } catch { + throw new AccessCatalogRequestError('catalog_invariant_failed') + } +} + +async function buildInTransaction(input: { + db: DbClient + budget: AccessExecutionBudget + session: ExternalSessionAuthorityContext + environmentId: string + families: readonly CatalogFamily[] + limit: number + decodedCursor: AccessCatalogCursorV3 | null + filterHash: string + now: Date +}): Promise { + await configureAccessAuthorityTransaction(input.db, input.budget) + const context = await loadCatalogRequestContext({ + db: input.db, + budget: input.budget, + session: input.session, + environmentId: input.environmentId, + }) + const sourceStateRevision = catalogSourceStateRevision(context.sourceStates) + if (input.decodedCursor) { + assertAccessCatalogCursorCurrent(input.decodedCursor, { + authorizationRevision: context.principal.authorizationRevision, + sourceStateRevision, + filterHash: input.filterHash, + now: input.now, + }) + } + const initial = input.decodedCursor?.producers ?? emptyContinuations() + const runtimes: ProducerRuntime[] = [] + for (const family of input.families) { + const page = await requireCatalogProducer(family).listCanonicalKeys( + context, + initial[family], + input.limit + ) + runtimes.push({ + family, + inputContinuation: initial[family], + page, + cursorIndex: 0, + emittedCount: 0, + }) + } + const merged = mergeCandidatePages(runtimes, input.limit) + const selected = merged.slice(0, input.limit) + const hydrated = await hydrateSelected(context, selected) + const items = selected.map(candidate => + catalogItem(context, hydrated.get(JSON.stringify(candidate.key))!) + ) + const partialErrors = safePartialErrors(runtimes) + const continuations = finalContinuations({ + requestedFamilies: input.families, + runtimes, + selected, + initial, + }) + const hasNext = + merged.length > input.limit || + runtimes.some(runtime => { + const continuation = continuations[runtime.family] + return runtime.page.sourceCompleteness === 'complete' && !continuation.exhausted + }) + const validUntil = earliestValidUntil(merged, hydrated) + const lastCanonicalKey = selected.at(-1)?.key + const nextCursor = + hasNext && lastCanonicalKey + ? encodeAccessCatalogCursor( + { + v: 3, + contractVersion: ACCESS_CATALOG_CONTRACT_VERSION, + authorizationRevision: context.principal.authorizationRevision, + sourceStateRevision, + filterHash: input.filterHash, + sort: ACCESS_CATALOG_SORT, + lastCanonicalKey, + producers: continuations, + validUntil, + }, + input.budget + ) + : null + const response: AccessCatalogResponse = Object.freeze({ + contractVersion: ACCESS_CATALOG_CONTRACT_VERSION, + authorizationRevision: context.principal.authorizationRevision, + sourceStateRevision, + complete: partialErrors.length === 0, + partialErrors: Object.freeze(partialErrors), + items: Object.freeze(items), + nextCursor, + }) + input.budget.charge({ kind: 'responseBytes', amount: responseBytes(response) }) + return response +} + +export async function buildAccessCatalog( + input: { + session: ExternalSessionAuthorityContext + environmentId?: string + families?: readonly CatalogFamily[] + limit?: number + cursor?: string | null + }, + options: { + transaction?: CatalogTransaction + budget?: AccessExecutionBudget + limits?: Partial + now?: Date + } = {} +): Promise { + const environmentId = input.environmentId ?? canonicalEnvironmentId() + if (environmentId !== canonicalEnvironmentId()) { + throw new AccessCatalogRequestError('invalid_request') + } + const families = normalizeFamilies(input.families) + const limit = input.limit ?? 50 + const budget = + options.budget ?? AccessExecutionBudget.create('catalog', { limits: options.limits }) + const ownedBudget = options.budget ? null : budget + try { + budget.assertPageSize(limit) + const filterHash = catalogFilterHash(families) + const decodedCursor = input.cursor ? decodeAccessCatalogCursor(input.cursor, budget) : null + return await (options.transaction ?? withTransaction)(db => + buildInTransaction({ + db, + budget, + session: input.session, + environmentId, + families, + limit, + decodedCursor, + filterHash, + now: options.now ?? new Date(), + }) + ) + } catch (error) { + if (error instanceof CatalogAuthorityError) { + throw new AccessCatalogRequestError( + error.code === 'session_not_live' ? 'session_not_live' : 'authority_unavailable' + ) + } + if (error instanceof CatalogProducerContractError) { + throw new AccessCatalogRequestError('authority_unavailable') + } + throw error + } finally { + ownedBudget?.close() + } +} + +export function catalogCoordinatorContractRevision(): string { + return revisionOfValues([ + ACCESS_CATALOG_CONTRACT_VERSION, + ACCESS_CATALOG_SORT, + CATALOG_FAMILIES, + 'request-local-only', + ]) +} diff --git a/control-api/src/services/access/accessCatalogCursor.ts b/control-api/src/services/access/accessCatalogCursor.ts new file mode 100644 index 0000000000..0c2e5140e8 --- /dev/null +++ b/control-api/src/services/access/accessCatalogCursor.ts @@ -0,0 +1,199 @@ +import { createHash, createHmac, timingSafeEqual } from 'node:crypto' +import { config } from '../../config.js' +import type { AccessExecutionBudget } from './accessExecutionBudget.js' +import { + CATALOG_FAMILIES, + type CatalogFamily, + type CatalogKey, + type ProducerContinuation, +} from './catalogContracts.js' + +export const ACCESS_CATALOG_CONTRACT_VERSION = '2' as const +export const ACCESS_CATALOG_SORT = 'environment-type-logical-v1' as const + +export type AccessCatalogCursorV3 = Readonly<{ + v: 3 + contractVersion: typeof ACCESS_CATALOG_CONTRACT_VERSION + authorizationRevision: string + sourceStateRevision: string + filterHash: string + sort: typeof ACCESS_CATALOG_SORT + lastCanonicalKey: CatalogKey + producers: Readonly> + validUntil: string | null +}> + +export class AccessCatalogCursorError extends Error { + constructor(readonly code: 'invalid_cursor' | 'stale_cursor') { + super(`Access catalog cursor rejected: ${code}`) + this.name = 'AccessCatalogCursorError' + } +} + +function boundedString(value: unknown, maximum: number): string | null { + return typeof value === 'string' && value.length > 0 && value.length <= maximum ? value : null +} + +function parseKey(value: unknown): CatalogKey { + if ( + !Array.isArray(value) || + value.length !== 3 || + !boundedString(value[0], 512) || + !CATALOG_FAMILIES.includes(value[1] as CatalogFamily) || + !boundedString(value[2], 1_024) + ) { + throw new AccessCatalogCursorError('invalid_cursor') + } + return Object.freeze([String(value[0]), value[1] as CatalogFamily, String(value[2])]) +} + +function parseContinuation(value: unknown): ProducerContinuation { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new AccessCatalogCursorError('invalid_cursor') + } + const candidate = value as Record + if (typeof candidate.exhausted !== 'boolean') { + throw new AccessCatalogCursorError('invalid_cursor') + } + if (candidate.opaqueState !== undefined && boundedString(candidate.opaqueState, 2_048) === null) { + throw new AccessCatalogCursorError('invalid_cursor') + } + return Object.freeze({ + afterKey: candidate.afterKey === null ? null : parseKey(candidate.afterKey), + exhausted: candidate.exhausted, + ...(candidate.opaqueState ? { opaqueState: String(candidate.opaqueState) } : {}), + }) +} + +function payloadFromUnknown(value: unknown): AccessCatalogCursorV3 { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new AccessCatalogCursorError('invalid_cursor') + } + const input = value as Record + if ( + input.v !== 3 || + input.contractVersion !== ACCESS_CATALOG_CONTRACT_VERSION || + input.sort !== ACCESS_CATALOG_SORT + ) { + throw new AccessCatalogCursorError('invalid_cursor') + } + const authorizationRevision = boundedString(input.authorizationRevision, 256) + const sourceStateRevision = boundedString(input.sourceStateRevision, 256) + const filterHash = boundedString(input.filterHash, 256) + if (!authorizationRevision || !sourceStateRevision || !filterHash) { + throw new AccessCatalogCursorError('invalid_cursor') + } + const lastCanonicalKey = parseKey(input.lastCanonicalKey) + if (!input.producers || typeof input.producers !== 'object' || Array.isArray(input.producers)) { + throw new AccessCatalogCursorError('invalid_cursor') + } + const rawProducers = input.producers as Record + if ( + Object.keys(rawProducers).length !== CATALOG_FAMILIES.length || + Object.keys(rawProducers).some(key => !CATALOG_FAMILIES.includes(key as CatalogFamily)) + ) { + throw new AccessCatalogCursorError('invalid_cursor') + } + const producers = Object.fromEntries( + CATALOG_FAMILIES.map(family => { + const continuation = parseContinuation(rawProducers[family]) + if ( + continuation.afterKey && + (continuation.afterKey[0] !== lastCanonicalKey[0] || continuation.afterKey[1] !== family) + ) { + throw new AccessCatalogCursorError('invalid_cursor') + } + return [family, continuation] + }) + ) as Record + let validUntil: string | null = null + if (input.validUntil !== null) { + const raw = boundedString(input.validUntil, 64) + if (!raw || Number.isNaN(new Date(raw).getTime())) { + throw new AccessCatalogCursorError('invalid_cursor') + } + validUntil = new Date(raw).toISOString() + } + return Object.freeze({ + v: 3, + contractVersion: ACCESS_CATALOG_CONTRACT_VERSION, + authorizationRevision, + sourceStateRevision, + filterHash, + sort: ACCESS_CATALOG_SORT, + lastCanonicalKey, + producers: Object.freeze(producers), + validUntil, + }) +} + +function signature(encodedPayload: string): Buffer { + return createHmac('sha256', config.sessionJwtPrivateKey).update(encodedPayload).digest() +} + +export function catalogFilterHash(families: readonly CatalogFamily[]): string { + return `cf1_${createHash('sha256') + .update(JSON.stringify({ families: [...families], sort: ACCESS_CATALOG_SORT })) + .digest('base64url')}` +} + +export function encodeAccessCatalogCursor( + payload: AccessCatalogCursorV3, + budget: AccessExecutionBudget +): string { + const encoded = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url') + const cursor = `c3.${encoded}.${signature(encoded).toString('base64url')}` + budget.assertCursorBytes(Buffer.byteLength(cursor, 'utf8')) + return cursor +} + +export function decodeAccessCatalogCursor( + cursor: string, + budget: AccessExecutionBudget +): AccessCatalogCursorV3 { + budget.assertCursorBytes(Buffer.byteLength(cursor, 'utf8')) + const parts = cursor.split('.') + if (parts.length !== 3 || parts[0] !== 'c3' || !parts[1] || !parts[2]) { + throw new AccessCatalogCursorError('invalid_cursor') + } + const expected = signature(parts[1]) + let supplied: Buffer + try { + supplied = Buffer.from(parts[2], 'base64url') + } catch { + throw new AccessCatalogCursorError('invalid_cursor') + } + if (expected.length !== supplied.length || !timingSafeEqual(expected, supplied)) { + throw new AccessCatalogCursorError('invalid_cursor') + } + try { + return payloadFromUnknown(JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'))) + } catch (error) { + if (error instanceof AccessCatalogCursorError) throw error + throw new AccessCatalogCursorError('invalid_cursor') + } +} + +export function assertAccessCatalogCursorCurrent( + cursor: AccessCatalogCursorV3, + expected: { + authorizationRevision: string + sourceStateRevision: string + filterHash: string + now?: Date + } +): void { + if ( + cursor.authorizationRevision !== expected.authorizationRevision || + cursor.sourceStateRevision !== expected.sourceStateRevision || + cursor.filterHash !== expected.filterHash + ) { + throw new AccessCatalogCursorError('stale_cursor') + } + if ( + cursor.validUntil && + new Date(cursor.validUntil).getTime() <= (expected.now ?? new Date()).getTime() + ) { + throw new AccessCatalogCursorError('stale_cursor') + } +} diff --git a/control-api/src/services/access/authorizationRevision.ts b/control-api/src/services/access/authorizationRevision.ts index f7d937c852..eeee196492 100644 --- a/control-api/src/services/access/authorizationRevision.ts +++ b/control-api/src/services/access/authorizationRevision.ts @@ -10,6 +10,12 @@ export type AuthorizationMembershipRevision = Readonly<{ teamRevision: string }> +export type AuthorizationRelationship = Readonly<{ + type: string + targetResourceId: string + instanceId?: string +}> + export type AuthorizationRevisionInput = Readonly<{ principalUserId: string sessionContract: 'v1' | 'v2' @@ -23,20 +29,32 @@ export type AuthorizationRevisionInput = Readonly<{ candidates: readonly AccessPathSeed[] }> -function canonicalCandidates(candidates: readonly AccessPathSeed[]) { - return [...candidates] - .map(candidate => ({ - kind: candidate.kind, - grantId: candidate.grantId, - teamId: candidate.teamId ?? null, - currentRole: candidate.currentRole ?? null, - behavior: candidate.behavior, - })) - .sort((left, right) => - JSON.stringify([left.kind, left.teamId, left.grantId]).localeCompare( - JSON.stringify([right.kind, right.teamId, right.grantId]) - ) +export function canonicalAccessPathSeeds(candidates: readonly T[]): T[] { + const values = new Map() + for (const candidate of candidates) { + values.set( + JSON.stringify([ + candidate.kind, + candidate.teamId ?? null, + candidate.grantId, + candidate.behavior, + ]), + candidate ) + } + return [...values.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([, value]) => value) +} + +function canonicalCandidates(candidates: readonly AccessPathSeed[]) { + return canonicalAccessPathSeeds(candidates).map(candidate => ({ + kind: candidate.kind, + grantId: candidate.grantId, + teamId: candidate.teamId ?? null, + currentRole: candidate.currentRole ?? null, + behavior: candidate.behavior, + })) } export function authorizationRevision(input: AuthorizationRevisionInput): string { @@ -61,6 +79,31 @@ export function authorizationRevision(input: AuthorizationRevisionInput): string .digest('base64url')}` } +export function canonicalAuthorizationRelationships( + relationships: readonly AuthorizationRelationship[] +): AuthorizationRelationship[] { + const values = new Map() + for (const relationship of relationships) { + values.set( + JSON.stringify([ + relationship.type, + relationship.targetResourceId, + relationship.instanceId ?? null, + ]), + Object.freeze({ ...relationship }) + ) + } + return [...values.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([, value]) => value) +} + +export function databaseRelationshipsRevision( + relationships: readonly AuthorizationRelationship[] +): string { + return JSON.stringify(canonicalAuthorizationRelationships(relationships)) +} + function canonicalValue(value: unknown): string { if (value === undefined) return 'null' if (value === null || typeof value !== 'object') return JSON.stringify(value) diff --git a/control-api/src/services/access/catalogAuthorityContext.ts b/control-api/src/services/access/catalogAuthorityContext.ts new file mode 100644 index 0000000000..a8f848c40e --- /dev/null +++ b/control-api/src/services/access/catalogAuthorityContext.ts @@ -0,0 +1,204 @@ +import type { DbClient } from '../../db.js' +import type { ExternalSessionAuthorityContext } from '../auth/externalSessionAuthentication.js' +import { parseAuthorizationMemberships } from './accessAuthorityStore.js' +import type { AccessExecutionBudget } from './accessExecutionBudget.js' +import { revisionOfValues } from './authorizationRevision.js' +import type { + CatalogOperationalSourceState, + CatalogPrincipalSnapshot, + CatalogRequestContext, +} from './catalogContracts.js' +import { catalogQuery } from './catalogProducerSupport.js' +import { + OPERATIONAL_SOURCE_FAMILIES, + type OperationalSourceFamily, +} from './operationalAccessProjection.js' + +export class CatalogAuthorityError extends Error { + constructor(readonly code: 'principal_not_found' | 'session_not_live' | 'source_state_invalid') { + super(`Catalog authority snapshot failed: ${code}`) + this.name = 'CatalogAuthorityError' + } +} + +function sourceState(row: Record): CatalogOperationalSourceState { + const family = String(row.source_family) as OperationalSourceFamily + const status = String(row.status) + if ( + !OPERATIONAL_SOURCE_FAMILIES.includes(family) || + !['current', 'relisting', 'unavailable'].includes(status) + ) { + throw new CatalogAuthorityError('source_state_invalid') + } + return Object.freeze({ + family, + generation: String(row.generation), + resourceVersion: + row.resource_version === null || row.resource_version === undefined + ? null + : String(row.resource_version), + status: status as CatalogOperationalSourceState['status'], + }) +} + +function catalogAuthorizationRevision(principal: { + sessionContract: 'v1' | 'v2' + sessionRevision: string + userRevision: string + catalogRevision: string + memberships: CatalogPrincipalSnapshot['memberships'] +}): string { + return `car1_${revisionOfValues([ + 'catalog-authority-v1', + principal.sessionContract, + principal.sessionRevision, + principal.userRevision, + principal.catalogRevision, + ...principal.memberships.map(membership => [ + membership.teamId, + membership.role, + membership.membershipUpdatedAt, + membership.teamRevision, + ]), + ])}` +} + +export function catalogSourceStateRevision( + sourceStates: ReadonlyMap +): string { + return `csr1_${revisionOfValues( + OPERATIONAL_SOURCE_FAMILIES.map(family => { + const state = sourceStates.get(family) + return state + ? [family, state.generation, state.resourceVersion, state.status] + : [family, 'missing', null, 'unavailable'] + }) + )}` +} + +export async function loadCatalogRequestContext(input: { + db: Pick + budget: AccessExecutionBudget + session: ExternalSessionAuthorityContext + environmentId: string +}): Promise { + const principalResult = await catalogQuery( + input.db, + input.budget, + `WITH active_memberships AS ( + SELECT membership.team_id, membership.role, membership.updated_at, + COALESCE(team_revision.revision, 1) AS team_revision + FROM team_members membership + LEFT JOIN authorization_team_revisions team_revision + ON team_revision.team_id = membership.team_id + WHERE membership.user_id = $1 AND membership.status = 'active' + ) + SELECT users.id AS user_id, + COALESCE(user_revision.revision, 1)::text AS user_revision, + COALESCE(catalog_revision.revision, 1)::text AS catalog_revision, + CASE + WHEN $2::text = 'v2' THEN EXISTS ( + SELECT 1 + FROM external_user_sessions session + WHERE session.sid::text = $3 AND session.user_id = users.id + AND session.session_version = $5 AND session.revoked_at IS NULL + AND session.idle_expires_at > NOW() AND session.absolute_expires_at > NOW() + AND ( + session.current_jti::text = $4 + OR ( + session.prior_jti::text = $4 + AND session.prior_jti_expires_at >= NOW() + ) + ) + ) + ELSE ( + $7::bigint IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM external_v1_session_revocations revoked + WHERE revoked.token_hash = $6 AND revoked.user_id = users.id + AND revoked.expires_at > NOW() + ) + AND NOT EXISTS ( + SELECT 1 FROM external_user_session_security_epochs epoch + WHERE epoch.user_id = users.id + AND $7::bigint * 1000 <= EXTRACT(EPOCH FROM epoch.valid_after) * 1000 + ) + ) + END AS session_live, + CASE + WHEN $2::text = 'v2' THEN COALESCE(( + SELECT session.session_version::text || ':' || session.current_jti::text || ':' || + COALESCE(session.revoked_at::text, '') || ':' || + session.idle_expires_at::text || ':' || session.absolute_expires_at::text + FROM external_user_sessions session + WHERE session.sid::text = $3 AND session.user_id = users.id + ), 'missing') + ELSE COALESCE(( + SELECT EXTRACT(EPOCH FROM epoch.valid_after)::text + FROM external_user_session_security_epochs epoch + WHERE epoch.user_id = users.id + ), '0') || ':' || $6 + END AS session_revision, + COALESCE( + jsonb_agg( + jsonb_build_object( + 'teamId', membership.team_id, + 'role', membership.role, + 'membershipUpdatedAt', membership.updated_at, + 'teamRevision', membership.team_revision + ) ORDER BY membership.team_id + ) FILTER (WHERE membership.team_id IS NOT NULL), + '[]'::jsonb + ) AS memberships + FROM users + LEFT JOIN authorization_user_revisions user_revision ON user_revision.user_id = users.id + CROSS JOIN authorization_catalog_revision catalog_revision + LEFT JOIN active_memberships membership ON TRUE + WHERE users.id = $1 + GROUP BY users.id, user_revision.revision, catalog_revision.revision`, + [ + input.session.userId, + input.session.contract, + input.session.contract === 'v2' ? input.session.sid : null, + input.session.contract === 'v2' ? input.session.jti : null, + input.session.contract === 'v2' ? input.session.sessionVersion : null, + input.session.contract === 'v1' ? input.session.tokenHash : null, + input.session.contract === 'v1' ? input.session.issuedAt : null, + ] + ) + const row = principalResult.rows[0] as Record | undefined + if (!row) throw new CatalogAuthorityError('principal_not_found') + if (row.session_live !== true) throw new CatalogAuthorityError('session_not_live') + const memberships = Object.freeze(parseAuthorizationMemberships(row.memberships)) + const partialPrincipal = { + userId: String(row.user_id), + sessionContract: input.session.contract, + sessionRevision: String(row.session_revision), + userRevision: String(row.user_revision ?? '1'), + catalogRevision: String(row.catalog_revision ?? '1'), + memberships, + } as const + const principal: CatalogPrincipalSnapshot = Object.freeze({ + ...partialPrincipal, + authorizationRevision: catalogAuthorizationRevision(partialPrincipal), + }) + + const sourceResult = await catalogQuery( + input.db, + input.budget, + `SELECT source_family, generation, resource_version, status + FROM operational_catalog_source_state + WHERE environment_id = $1 + AND source_family = ANY($2::text[]) + ORDER BY source_family`, + [input.environmentId, OPERATIONAL_SOURCE_FAMILIES] + ) + const states = (sourceResult.rows as Record[]).map(sourceState) + return Object.freeze({ + db: input.db, + budget: input.budget, + principal, + environmentId: input.environmentId, + sourceStates: new Map(states.map(state => [state.family, state])), + }) +} diff --git a/control-api/src/services/access/catalogContracts.ts b/control-api/src/services/access/catalogContracts.ts index 513ceaedb2..65ea9d35a5 100644 --- a/control-api/src/services/access/catalogContracts.ts +++ b/control-api/src/services/access/catalogContracts.ts @@ -36,6 +36,7 @@ export type ProducerContinuation = Readonly<{ export type CatalogIdentityCandidate = Readonly<{ key: CatalogKey canonicalId: string + validUntil: string | null }> export type SafeCatalogPartialError = Readonly<{ @@ -65,6 +66,8 @@ export type HydratedCatalogResource = Readonly<{ accessPaths: readonly AccessPathSeed[] relationships: readonly CatalogRelationship[] authorizationResourceRevision: string + authorizationSourceRevision: string + authorizationRelationshipsRevision: string validUntil: string | null }> @@ -73,6 +76,8 @@ export type CatalogPrincipalSnapshot = Readonly<{ sessionContract: 'v1' | 'v2' sessionRevision: string userRevision: string + catalogRevision: string + authorizationRevision: string memberships: readonly Readonly<{ teamId: string role: TeamRole diff --git a/control-api/src/services/access/catalogHydrationSql.ts b/control-api/src/services/access/catalogHydrationSql.ts index cd0617005e..e281397dce 100644 --- a/control-api/src/services/access/catalogHydrationSql.ts +++ b/control-api/src/services/access/catalogHydrationSql.ts @@ -131,7 +131,7 @@ export const SIMPLE_OPERATIONAL_HYDRATION_SQL = ` relationship.relationship_type, relationship.target_type, relationship.target_id, relationship.relationship_instance_id, relationship.behavior_attributes, relationship.source_type, - relationship.source_provider_uid + relationship.source_provider_uid, relationship.source_resource_version FROM operational_resource_relationships relationship JOIN resources resource ON relationship.environment_id = $3 diff --git a/control-api/src/services/access/catalogProducerSql.ts b/control-api/src/services/access/catalogProducerSql.ts index 3e9ba39418..1d18350e7a 100644 --- a/control-api/src/services/access/catalogProducerSql.ts +++ b/control-api/src/services/access/catalogProducerSql.ts @@ -16,6 +16,10 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f { sql: `SELECT $5::text || '/' || ua.agent_name AS logical_id, ua.agent_name AS source_key FROM user_agents ua + JOIN operational_resource_index resource + ON resource.environment_id = $3 AND resource.resource_type = 'host' + AND resource.logical_id = $5::text || '/' || ua.agent_name + AND resource.enabled = TRUE AND resource.deleted_at IS NULL WHERE ua.user_id = $1 AND ua.agent_name > $7`, orderBy: 'source_key', }, @@ -23,6 +27,10 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f sql: `SELECT $5::text || '/' || ta.agent_name AS logical_id, ta.agent_name AS source_key FROM team_agents ta JOIN team_members tm ON tm.team_id = ta.team_id + JOIN operational_resource_index resource + ON resource.environment_id = $3 AND resource.resource_type = 'host' + AND resource.logical_id = $5::text || '/' || ta.agent_name + AND resource.enabled = TRUE AND resource.deleted_at IS NULL WHERE tm.user_id = $1 AND tm.status = 'active' AND ta.agent_name > $7`, orderBy: 'source_key', }, @@ -31,6 +39,10 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f { sql: `SELECT $6::text || '/' || uc.context_id AS logical_id, uc.context_id AS source_key FROM user_contexts uc + JOIN operational_resource_index resource + ON resource.environment_id = $3 AND resource.resource_type = 'context' + AND resource.logical_id = $6::text || '/' || uc.context_id + AND resource.enabled = TRUE AND resource.deleted_at IS NULL WHERE uc.user_id = $1 AND uc.context_id > $7`, orderBy: 'source_key', }, @@ -38,6 +50,10 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f sql: `SELECT $6::text || '/' || tc.context_id AS logical_id, tc.context_id AS source_key FROM team_contexts tc JOIN team_members tm ON tm.team_id = tc.team_id + JOIN operational_resource_index resource + ON resource.environment_id = $3 AND resource.resource_type = 'context' + AND resource.logical_id = $6::text || '/' || tc.context_id + AND resource.enabled = TRUE AND resource.deleted_at IS NULL WHERE tm.user_id = $1 AND tm.status = 'active' AND tc.context_id > $7`, orderBy: 'source_key', }, @@ -49,8 +65,16 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f ON edge.environment_id = $3 AND edge.source_type = 'context' AND edge.source_id = $6::text || '/' || uc.context_id - AND edge.relationship_type = 'includes_mcp_server' + AND edge.relationship_type = 'includes_mcp_server' AND edge.target_type = 'mcp_server' + JOIN operational_resource_index source_resource + ON source_resource.environment_id = $3 AND source_resource.resource_type = 'context' + AND source_resource.logical_id = edge.source_id + AND source_resource.enabled = TRUE AND source_resource.deleted_at IS NULL + JOIN operational_resource_index target_resource + ON target_resource.environment_id = $3 AND target_resource.resource_type = 'mcp_server' + AND target_resource.logical_id = edge.target_id + AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL WHERE uc.user_id = $1 AND edge.target_id > $2`, `SELECT edge.target_id AS logical_id FROM team_contexts tc @@ -61,6 +85,14 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND edge.source_id = $6::text || '/' || tc.context_id AND edge.relationship_type = 'includes_mcp_server' AND edge.target_type = 'mcp_server' + JOIN operational_resource_index source_resource + ON source_resource.environment_id = $3 AND source_resource.resource_type = 'context' + AND source_resource.logical_id = edge.source_id + AND source_resource.enabled = TRUE AND source_resource.deleted_at IS NULL + JOIN operational_resource_index target_resource + ON target_resource.environment_id = $3 AND target_resource.resource_type = 'mcp_server' + AND target_resource.logical_id = edge.target_id + AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL WHERE tm.user_id = $1 AND tm.status = 'active' AND edge.target_id > $2`, `SELECT mcp_edge.target_id AS logical_id FROM user_agents ua @@ -76,6 +108,18 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND mcp_edge.source_id = host_edge.target_id AND mcp_edge.relationship_type = 'includes_mcp_server' AND mcp_edge.target_type = 'mcp_server' + JOIN operational_resource_index host_resource + ON host_resource.environment_id = $3 AND host_resource.resource_type = 'host' + AND host_resource.logical_id = host_edge.source_id + AND host_resource.enabled = TRUE AND host_resource.deleted_at IS NULL + JOIN operational_resource_index context_resource + ON context_resource.environment_id = $3 AND context_resource.resource_type = 'context' + AND context_resource.logical_id = host_edge.target_id + AND context_resource.enabled = TRUE AND context_resource.deleted_at IS NULL + JOIN operational_resource_index target_resource + ON target_resource.environment_id = $3 AND target_resource.resource_type = 'mcp_server' + AND target_resource.logical_id = mcp_edge.target_id + AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL WHERE ua.user_id = $1 AND mcp_edge.target_id > $2`, `SELECT mcp_edge.target_id AS logical_id FROM team_agents ta @@ -92,12 +136,28 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND mcp_edge.source_id = host_edge.target_id AND mcp_edge.relationship_type = 'includes_mcp_server' AND mcp_edge.target_type = 'mcp_server' + JOIN operational_resource_index host_resource + ON host_resource.environment_id = $3 AND host_resource.resource_type = 'host' + AND host_resource.logical_id = host_edge.source_id + AND host_resource.enabled = TRUE AND host_resource.deleted_at IS NULL + JOIN operational_resource_index context_resource + ON context_resource.environment_id = $3 AND context_resource.resource_type = 'context' + AND context_resource.logical_id = host_edge.target_id + AND context_resource.enabled = TRUE AND context_resource.deleted_at IS NULL + JOIN operational_resource_index target_resource + ON target_resource.environment_id = $3 AND target_resource.resource_type = 'mcp_server' + AND target_resource.logical_id = mcp_edge.target_id + AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL WHERE tm.user_id = $1 AND tm.status = 'active' AND mcp_edge.target_id > $2`, ]), workflow_recipe: boundedKeyUnionSql([ { sql: `SELECT uwt.recipe_namespace || '/' || uwt.recipe_name AS logical_id FROM user_workflow_triggers uwt + JOIN operational_resource_index resource + ON resource.environment_id = $3 AND resource.resource_type = 'workflow_recipe' + AND resource.logical_id = uwt.recipe_namespace || '/' || uwt.recipe_name + AND resource.enabled = TRUE AND resource.deleted_at IS NULL WHERE uwt.user_id = $1 AND (uwt.recipe_namespace || '/' || uwt.recipe_name) > $2`, orderBy: 'logical_id', @@ -106,6 +166,10 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f sql: `SELECT twt.recipe_namespace || '/' || twt.recipe_name AS logical_id FROM team_workflow_triggers twt JOIN team_members tm ON tm.team_id = twt.team_id + JOIN operational_resource_index resource + ON resource.environment_id = $3 AND resource.resource_type = 'workflow_recipe' + AND resource.logical_id = twt.recipe_namespace || '/' || twt.recipe_name + AND resource.enabled = TRUE AND resource.deleted_at IS NULL WHERE tm.user_id = $1 AND tm.status = 'active' AND (twt.recipe_namespace || '/' || twt.recipe_name) > $2`, orderBy: 'logical_id', @@ -142,7 +206,8 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f WHERE wr.usage_team_id IS NOT NULL AND wr.run_id::text > $2`, ]), workflow_approval: boundedKeyUnionSql([ - `SELECT war.id::text AS logical_id + { + sql: `SELECT war.id::text AS logical_id, war.expires_at AS valid_until FROM workflow_approval_requests war JOIN user_workflow_triggers uwt ON uwt.user_id = $1 @@ -150,7 +215,10 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND uwt.recipe_name = war.recipe_name WHERE war.target_user_id = $1 AND war.status = 'pending' AND war.expires_at > NOW() AND war.id::text > $2`, - `SELECT war.id::text AS logical_id + hasValidUntil: true, + }, + { + sql: `SELECT war.id::text AS logical_id, war.expires_at AS valid_until FROM workflow_approval_requests war JOIN team_members tm ON tm.team_id = war.target_team_id AND tm.user_id = $1 AND tm.status = 'active' @@ -159,19 +227,27 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND twt.recipe_namespace = war.recipe_namespace AND twt.recipe_name = war.recipe_name WHERE war.status = 'pending' AND war.expires_at > NOW() AND war.id::text > $2`, + hasValidUntil: true, + }, ]), notification: boundedKeyUnionSql([ - `SELECT nd.id::text AS logical_id + { + sql: `SELECT nd.id::text AS logical_id, nd.expires_at AS valid_until FROM notification_deliveries nd WHERE nd.audience->>'userId' = $1::text AND (nd.expires_at IS NULL OR nd.expires_at > NOW()) AND nd.id::text > $2`, - `SELECT nd.id::text AS logical_id + hasValidUntil: true, + }, + { + sql: `SELECT nd.id::text AS logical_id, nd.expires_at AS valid_until FROM notification_deliveries nd JOIN team_members tm ON tm.team_id::text = nd.audience->>'teamId' AND tm.user_id = $1::uuid AND tm.status = 'active' WHERE (nd.expires_at IS NULL OR nd.expires_at > NOW()) AND nd.id::text > $2`, + hasValidUntil: true, + }, ]), gfs_resource: boundedKeyUnionSql([ `SELECT g.resource_id::text AS logical_id @@ -212,6 +288,15 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND edge.source_id = $6::text || '/' || uc.context_id AND edge.relationship_type = 'mounts_shared_filesystem' AND edge.target_type = 'shared_filesystem' + JOIN operational_resource_index source_resource + ON source_resource.environment_id = $3 AND source_resource.resource_type = 'context' + AND source_resource.logical_id = edge.source_id + AND source_resource.enabled = TRUE AND source_resource.deleted_at IS NULL + JOIN operational_resource_index target_resource + ON target_resource.environment_id = $3 + AND target_resource.resource_type = 'shared_filesystem' + AND target_resource.logical_id = edge.target_id + AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL WHERE uc.user_id = $1 AND edge.target_id > $2`, `SELECT edge.target_id AS logical_id FROM team_contexts tc @@ -222,6 +307,15 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND edge.source_id = $6::text || '/' || tc.context_id AND edge.relationship_type = 'mounts_shared_filesystem' AND edge.target_type = 'shared_filesystem' + JOIN operational_resource_index source_resource + ON source_resource.environment_id = $3 AND source_resource.resource_type = 'context' + AND source_resource.logical_id = edge.source_id + AND source_resource.enabled = TRUE AND source_resource.deleted_at IS NULL + JOIN operational_resource_index target_resource + ON target_resource.environment_id = $3 + AND target_resource.resource_type = 'shared_filesystem' + AND target_resource.logical_id = edge.target_id + AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL WHERE tm.user_id = $1 AND tm.status = 'active' AND edge.target_id > $2`, ]), sandbox_app: boundedKeyUnionSql([ @@ -233,6 +327,15 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND edge.source_id = uwt.recipe_namespace || '/' || uwt.recipe_name AND edge.relationship_type = 'exposes_sandbox_app' AND edge.target_type = 'sandbox_app' + JOIN operational_resource_index source_resource + ON source_resource.environment_id = $3 + AND source_resource.resource_type = 'workflow_recipe' + AND source_resource.logical_id = edge.source_id + AND source_resource.enabled = TRUE AND source_resource.deleted_at IS NULL + JOIN operational_resource_index target_resource + ON target_resource.environment_id = $3 AND target_resource.resource_type = 'sandbox_app' + AND target_resource.logical_id = edge.target_id + AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL WHERE uwt.user_id = $1 AND edge.target_id > $2`, `SELECT edge.target_id AS logical_id FROM team_workflow_triggers twt @@ -243,6 +346,15 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND edge.source_id = twt.recipe_namespace || '/' || twt.recipe_name AND edge.relationship_type = 'exposes_sandbox_app' AND edge.target_type = 'sandbox_app' + JOIN operational_resource_index source_resource + ON source_resource.environment_id = $3 + AND source_resource.resource_type = 'workflow_recipe' + AND source_resource.logical_id = edge.source_id + AND source_resource.enabled = TRUE AND source_resource.deleted_at IS NULL + JOIN operational_resource_index target_resource + ON target_resource.environment_id = $3 AND target_resource.resource_type = 'sandbox_app' + AND target_resource.logical_id = edge.target_id + AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL WHERE tm.user_id = $1 AND tm.status = 'active' AND edge.target_id > $2`, ]), }) diff --git a/control-api/src/services/access/catalogProducerSupport.ts b/control-api/src/services/access/catalogProducerSupport.ts index c22c4ae581..0fa52f955f 100644 --- a/control-api/src/services/access/catalogProducerSupport.ts +++ b/control-api/src/services/access/catalogProducerSupport.ts @@ -38,7 +38,11 @@ export async function catalogQuery( }) } -export type BoundedKeyArm = Readonly<{ sql: string; orderBy?: string }> +export type BoundedKeyArm = Readonly<{ + sql: string + orderBy?: string + hasValidUntil?: boolean +}> export function boundedKeyUnionSql(arms: readonly (string | BoundedKeyArm)[]): string { if (arms.length === 0) throw new CatalogProducerContractError('key_arms_missing') @@ -47,15 +51,20 @@ export function boundedKeyUnionSql(arms: readonly (string | BoundedKeyArm)[]): s .map((arm, index) => { const definition = typeof arm === 'string' ? { sql: arm } : arm return `${names[index]} AS MATERIALIZED ( - ${definition.sql} + SELECT bounded_arm.logical_id, + ${definition.hasValidUntil ? 'bounded_arm.valid_until' : 'NULL::timestamptz'} + AS valid_until + FROM (${definition.sql}) bounded_arm ORDER BY ${definition.orderBy ?? 'logical_id'} LIMIT $4 )` }) .join(',\n') - const union = names.map(name => `SELECT logical_id FROM ${name}`).join('\nUNION ALL\n') + const union = names + .map(name => `SELECT logical_id, valid_until FROM ${name}`) + .join('\nUNION ALL\n') return `WITH ${sources} - SELECT logical_id + SELECT logical_id, MIN(valid_until) AS valid_until FROM (${union}) bounded_sources WHERE $1::uuid IS NOT NULL AND $2::text IS NOT NULL AND $3::text IS NOT NULL AND $5::text IS NOT NULL AND $6::text IS NOT NULL AND $7::text IS NOT NULL @@ -186,7 +195,15 @@ export async function listBoundedProducerKeys(input: { if (candidates.length > 0 && compareCatalogKey(candidates.at(-1)!.key, key) >= 0) { throw new CatalogProducerContractError('keys_not_strictly_ordered') } - candidates.push(Object.freeze({ key, canonicalId: `${input.family}:${logicalId}` })) + let validUntil: string | null = null + if (row.valid_until !== null && row.valid_until !== undefined) { + const timestamp = new Date(String(row.valid_until)) + if (Number.isNaN(timestamp.getTime())) { + throw new CatalogProducerContractError('candidate_valid_until_invalid') + } + validUntil = timestamp.toISOString() + } + candidates.push(Object.freeze({ key, canonicalId: `${input.family}:${logicalId}`, validUntil })) prior = logicalId } const hasMore = candidates.length > input.take diff --git a/control-api/src/services/access/catalogProducers.ts b/control-api/src/services/access/catalogProducers.ts index d1a74af1a4..a22849df5e 100644 --- a/control-api/src/services/access/catalogProducers.ts +++ b/control-api/src/services/access/catalogProducers.ts @@ -1,6 +1,11 @@ import { config } from '../../config.js' import type { TeamRole } from '../../profileTypes.js' import { type AuthorityCandidate, authorityCandidate } from './accessAuthorityStore.js' +import { + canonicalAccessPathSeeds, + databaseRelationshipsRevision, + revisionOfValues, +} from './authorizationRevision.js' import type { AccessCapability } from './capabilityRegistry.js' import { gfsPermissionsToCapabilities } from './capabilityRegistry.js' import { @@ -139,6 +144,22 @@ function canonicalRelationships(rows: readonly JsonRecord[]): CatalogRelationshi .map(([, value]) => value) } +function operationalRelationshipsRevision(rows: readonly JsonRecord[]): string { + return revisionOfValues( + rows.map(row => [ + row.source_type, + row.source_id, + row.relationship_type, + row.target_type, + row.target_id, + row.relationship_instance_id, + row.behavior_attributes, + row.source_provider_uid, + row.source_resource_version, + ]) + ) +} + function commonCandidate(input: { context: CatalogRequestContext row: JsonRecord @@ -224,6 +245,7 @@ function simpleOperationalCandidates(input: { if (input.family === 'sandbox_app') { return row.target_type === 'sandbox_app' && row.target_id === input.logicalId } + if (input.family === 'workflow_recipe') return true return sourceType === input.family && sourceId === input.logicalId }) const runtimeRef = JSON.stringify( @@ -375,6 +397,7 @@ function decodedHydrationBytes(rows: readonly unknown[]): number { function hydrateRows(input: { context: CatalogRequestContext family: CatalogFamily + sourceRevision: string rows: readonly JsonRecord[] }): HydratedCatalogResource[] { input.context.budget.charge({ @@ -424,21 +447,7 @@ function hydrateRows(input: { logicalId, pathRows, }) - const dedupedPaths = new Map() - for (const accessPath of accessPaths) { - dedupedPaths.set( - JSON.stringify([ - accessPath.kind, - accessPath.teamId ?? null, - accessPath.grantId, - accessPath.behavior, - ]), - accessPath - ) - } - const paths = [...dedupedPaths.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([, value]) => value) + const paths = canonicalAccessPathSeeds(accessPaths) const relationships = canonicalRelationships(relationshipRows) representedPaths += paths.length representedRelationships += relationships.length @@ -460,6 +469,16 @@ function hydrateRows(input: { 'resource_revision', 128 ), + authorizationSourceRevision: input.sourceRevision, + authorizationRelationshipsRevision: + input.family === 'host' || + input.family === 'context' || + input.family === 'mcp_server' || + input.family === 'workflow_recipe' || + input.family === 'shared_filesystem' || + input.family === 'sandbox_app' + ? operationalRelationshipsRevision(relationshipRows) + : databaseRelationshipsRevision(relationships), validUntil, }) ) @@ -545,6 +564,10 @@ class SqlCatalogProducer implements CatalogProducer { hydrateRows({ context, family: this.family, + sourceRevision: + this.requiredOperationalSources.length === 0 + ? 'database-resource' + : readiness.sourceRevision, rows: result.rows as JsonRecord[], }) ) diff --git a/control-api/src/services/access/liveAuthorizationResolver.ts b/control-api/src/services/access/liveAuthorizationResolver.ts index 1f1a473108..64ad1ec5bc 100644 --- a/control-api/src/services/access/liveAuthorizationResolver.ts +++ b/control-api/src/services/access/liveAuthorizationResolver.ts @@ -9,6 +9,7 @@ import { loadResourceAuthority, operationTargetIsCurrentlyValid, } from './accessAuthorityStore.js' +import { configureAccessAuthorityTransaction } from './accessAuthorityTransaction.js' import { AccessBudgetExceededError, AccessExecutionBudget, @@ -20,7 +21,11 @@ import { buildAccessPath, selectEquivalentAccessPath, } from './accessPath.js' -import { authorizationRevision } from './authorizationRevision.js' +import { + authorizationRevision, + canonicalAccessPathSeeds, + databaseRelationshipsRevision, +} from './authorizationRevision.js' import { type AccessCapability, isAccessCapability, @@ -107,18 +112,6 @@ function dependencyForError( return 'authorization_store' } -async function configureAuthorityTransaction( - db: Pick, - budget: AccessExecutionBudget -): Promise { - await budget.runProducer(async () => { - await db.query('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY') - await db.query(`SELECT set_config('statement_timeout', $1, true)`, [ - `${budget.statementTimeoutMs()}ms`, - ]) - }) -} - function candidateForPath( path: AccessPath, candidates: readonly AuthorityCandidate[] @@ -167,7 +160,7 @@ async function resolveInTransaction(input: { gateway?: Pick budget: AccessExecutionBudget }): Promise { - await configureAuthorityTransaction(input.db, input.budget) + await configureAccessAuthorityTransaction(input.db, input.budget) const snapshot = await loadPrincipalAuthoritySnapshot({ db: input.db, budget: input.budget, @@ -235,8 +228,8 @@ async function resolveInTransaction(input: { capability: input.capability, operationTarget: input.operationTarget, }) - const capable = targeted.filter(candidate => - candidate.behavior.capabilities.includes(input.capability) + const capable = canonicalAccessPathSeeds( + targeted.filter(candidate => candidate.behavior.capabilities.includes(input.capability)) ) if (capable.length === 0) return { status: 'denied', code: 'forbidden' } input.budget.charge({ kind: 'accessPaths', amount: capable.length }) @@ -254,7 +247,7 @@ async function resolveInTransaction(input: { relationshipsRevision: graph?.status === 'current' ? graph.relationshipsRevision - : JSON.stringify(authority.relationships), + : databaseRelationshipsRevision(authority.relationships), candidates: capable, }) const paths = Object.freeze( diff --git a/control-api/test/accessCatalogCoordinator.realPostgres.integration.test.ts b/control-api/test/accessCatalogCoordinator.realPostgres.integration.test.ts new file mode 100644 index 0000000000..97ba600172 --- /dev/null +++ b/control-api/test/accessCatalogCoordinator.realPostgres.integration.test.ts @@ -0,0 +1,180 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { randomBytes, randomUUID } from 'node:crypto' +import { Pool, type PoolClient } from 'pg' +import { type DbClient, initDb } from '../src/db.js' +import { buildAccessCatalog } from '../src/services/access/accessCatalogCoordinator.js' +import { + AccessCatalogCursorError, + decodeAccessCatalogCursor, +} from '../src/services/access/accessCatalogCursor.js' +import { AccessExecutionBudget } from '../src/services/access/accessExecutionBudget.js' +import { resolveLiveAuthorization } from '../src/services/access/liveAuthorizationResolver.js' +import { canonicalEnvironmentId } from '../src/services/access/operationalAccessProjection.js' +import { canonicalResourceIdentity } from '../src/services/access/resourceIdentity.js' +import type { ExternalSessionAuthorityContext } from '../src/services/auth/externalSessionAuthentication.js' + +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const describeRealPostgres = adminUrl ? describe : describe.skip +const runtimeRoles = [ + 'control_api_runtime', + 'trace_maintenance_runtime', + 'workflow_recipes_runtime', +] as const + +function databaseUrl(baseUrl: string, database: string): string { + const value = new URL(baseUrl) + value.pathname = `/${database}` + return value.toString() +} + +function quoteIdentifier(value: string): string { + return `"${value.replace(/"/g, '""')}"` +} + +function transaction(pool: Pool) { + return async (work: (db: DbClient) => Promise): Promise => { + const client = (await pool.connect()) as PoolClient + try { + await client.query('BEGIN') + const result = await work(client) + await client.query('COMMIT') + return result + } catch (error) { + await client.query('ROLLBACK') + throw error + } finally { + client.release() + } + } +} + +describeRealPostgres('aggregate catalog coordinator on real PostgreSQL', () => { + const database = `control_api_catalog_coordinator_${randomBytes(6).toString('hex')}` + const connectionString = databaseUrl( + adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', + database + ) + const environmentId = canonicalEnvironmentId() + const userId = randomUUID() + const teamIds = [randomUUID(), randomUUID()].sort() + const session: ExternalSessionAuthorityContext = { + contract: 'v1', + userId, + tokenHash: randomBytes(32).toString('hex'), + issuedAt: Math.floor(Date.now() / 1_000), + } + let adminPool: Pool + let databasePool: Pool + + beforeAll(async () => { + adminPool = new Pool({ connectionString: adminUrl }) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) + databasePool = new Pool({ connectionString }) + await initDb({ connect: () => databasePool.connect() }) + await databasePool.query(`INSERT INTO users(id, email, name) VALUES ($1, $2, 'Catalog User')`, [ + userId, + `${userId}@example.test`, + ]) + for (const [index, teamId] of teamIds.entries()) { + await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, $2)`, [ + teamId, + `Team ${index + 1}`, + ]) + await databasePool.query( + `INSERT INTO team_members(team_id, user_id, role, status) + VALUES ($1, $2, $3, 'active')`, + [teamId, userId, index === 0 ? 'admin' : 'member'] + ) + } + }) + + afterAll(async () => { + await databasePool?.end() + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + await adminPool.end() + } + }) + + it('composes keyset pages and emits access paths that re-resolve live', async () => { + const first = await buildAccessCatalog( + { session, families: ['user', 'team'], limit: 2 }, + { transaction: transaction(databasePool) } + ) + + expect(first.complete).toBe(true) + expect(first.items.map(item => item.resource.logicalId)).toEqual(teamIds) + expect(first.nextCursor).toEqual(expect.stringMatching(/^c3\./)) + const firstPath = first.items[0].accessPaths[0] + const resolved = await resolveLiveAuthorization( + { + session, + requiredCapability: 'team.read', + resource: canonicalResourceIdentity({ + environmentId, + type: 'team', + logicalId: teamIds[0], + }), + requestedAccessPathId: firstPath.accessPathId, + }, + { transaction: transaction(databasePool) } + ) + expect(resolved).toEqual( + expect.objectContaining({ + status: 'allowed', + selectedPath: expect.objectContaining({ id: firstPath.accessPathId }), + }) + ) + + const second = await buildAccessCatalog( + { session, families: ['user', 'team'], limit: 2, cursor: first.nextCursor }, + { transaction: transaction(databasePool) } + ) + expect(second.items.map(item => item.resource.logicalId)).toEqual([userId]) + expect(second.nextCursor).toBeNull() + }) + + it('rejects a cursor after a live membership revision changes', async () => { + const page = await buildAccessCatalog( + { session, families: ['user', 'team'], limit: 1 }, + { transaction: transaction(databasePool) } + ) + expect(page.nextCursor).not.toBeNull() + await databasePool.query( + `UPDATE team_members SET role = 'inviter', updated_at = NOW() + WHERE team_id = $1 AND user_id = $2`, + [teamIds[0], userId] + ) + + await expect( + buildAccessCatalog( + { session, families: ['user', 'team'], limit: 1, cursor: page.nextCursor }, + { transaction: transaction(databasePool) } + ) + ).rejects.toBeInstanceOf(AccessCatalogCursorError) + }) + + it('signs a complete per-producer continuation vector', async () => { + const page = await buildAccessCatalog( + { session, families: ['team'], limit: 1 }, + { transaction: transaction(databasePool) } + ) + const budget = AccessExecutionBudget.create('catalog') + try { + const cursor = decodeAccessCatalogCursor(page.nextCursor!, budget) + expect(Object.keys(cursor.producers)).toHaveLength(12) + expect(cursor.producers.team.afterKey?.[1]).toBe('team') + expect(cursor.producers.host).toEqual({ afterKey: null, exhausted: true }) + } finally { + budget.close() + } + }) +}) diff --git a/control-api/test/accessCatalogCursor.test.ts b/control-api/test/accessCatalogCursor.test.ts new file mode 100644 index 0000000000..edbef5fe17 --- /dev/null +++ b/control-api/test/accessCatalogCursor.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + ACCESS_CATALOG_CONTRACT_VERSION, + ACCESS_CATALOG_SORT, + AccessCatalogCursorError, + assertAccessCatalogCursorCurrent, + catalogFilterHash, + decodeAccessCatalogCursor, + encodeAccessCatalogCursor, +} from '../src/services/access/accessCatalogCursor.js' +import { AccessExecutionBudget } from '../src/services/access/accessExecutionBudget.js' +import { + CATALOG_FAMILIES, + type CatalogFamily, + type ProducerContinuation, + catalogKey, +} from '../src/services/access/catalogContracts.js' + +const budgets: AccessExecutionBudget[] = [] + +function budget(): AccessExecutionBudget { + const value = AccessExecutionBudget.create('catalog') + budgets.push(value) + return value +} + +function producers(environmentId: string): Record { + return Object.fromEntries( + CATALOG_FAMILIES.map(family => [ + family, + { + afterKey: family === 'team' ? catalogKey(environmentId, family, 'team-0001') : null, + exhausted: family !== 'team', + }, + ]) + ) as Record +} + +afterEach(() => { + for (const value of budgets.splice(0)) value.close() +}) + +describe('access catalog cursor v3', () => { + it('round-trips the signed complete producer state', () => { + const environmentId = 'cluster.local/evenfire' + const filterHash = catalogFilterHash(['team']) + const encoded = encodeAccessCatalogCursor( + { + v: 3, + contractVersion: ACCESS_CATALOG_CONTRACT_VERSION, + authorizationRevision: 'authorization-1', + sourceStateRevision: 'source-1', + filterHash, + sort: ACCESS_CATALOG_SORT, + lastCanonicalKey: catalogKey(environmentId, 'team', 'team-0001'), + producers: producers(environmentId), + validUntil: '2030-01-01T00:00:00.000Z', + }, + budget() + ) + const decoded = decodeAccessCatalogCursor(encoded, budget()) + expect(decoded.producers.team.afterKey).toEqual([environmentId, 'team', 'team-0001']) + expect(Object.keys(decoded.producers)).toEqual(CATALOG_FAMILIES) + }) + + it('rejects tampering and every stale cursor binding', () => { + const environmentId = 'cluster.local/evenfire' + const filterHash = catalogFilterHash(['team']) + const encoded = encodeAccessCatalogCursor( + { + v: 3, + contractVersion: ACCESS_CATALOG_CONTRACT_VERSION, + authorizationRevision: 'authorization-1', + sourceStateRevision: 'source-1', + filterHash, + sort: ACCESS_CATALOG_SORT, + lastCanonicalKey: catalogKey(environmentId, 'team', 'team-0001'), + producers: producers(environmentId), + validUntil: '2030-01-01T00:00:00.000Z', + }, + budget() + ) + expect(() => decodeAccessCatalogCursor(`${encoded}x`, budget())).toThrow( + AccessCatalogCursorError + ) + const decoded = decodeAccessCatalogCursor(encoded, budget()) + expect(() => + assertAccessCatalogCursorCurrent(decoded, { + authorizationRevision: 'authorization-2', + sourceStateRevision: 'source-1', + filterHash, + }) + ).toThrow(AccessCatalogCursorError) + expect(() => + assertAccessCatalogCursorCurrent(decoded, { + authorizationRevision: 'authorization-1', + sourceStateRevision: 'source-2', + filterHash, + }) + ).toThrow(AccessCatalogCursorError) + expect(() => + assertAccessCatalogCursorCurrent(decoded, { + authorizationRevision: 'authorization-1', + sourceStateRevision: 'source-1', + filterHash: catalogFilterHash(['user']), + }) + ).toThrow(AccessCatalogCursorError) + expect(() => + assertAccessCatalogCursorCurrent(decoded, { + authorizationRevision: 'authorization-1', + sourceStateRevision: 'source-1', + filterHash, + now: new Date('2030-01-01T00:00:00.000Z'), + }) + ).toThrow(AccessCatalogCursorError) + }) +}) diff --git a/control-api/test/catalogProducers.realPostgres.integration.test.ts b/control-api/test/catalogProducers.realPostgres.integration.test.ts index 531a42d1e9..c6043a753a 100644 --- a/control-api/test/catalogProducers.realPostgres.integration.test.ts +++ b/control-api/test/catalogProducers.realPostgres.integration.test.ts @@ -87,6 +87,8 @@ describeRealPostgres('catalog producer SQL on real PostgreSQL', () => { sessionContract: 'v2', sessionRevision: '1', userRevision: '1', + catalogRevision: '1', + authorizationRevision: 'catalog-authorization-1', memberships: [], }, environmentId, diff --git a/control-api/test/catalogProducers.test.ts b/control-api/test/catalogProducers.test.ts index 42df3fb850..c9a74e60f2 100644 --- a/control-api/test/catalogProducers.test.ts +++ b/control-api/test/catalogProducers.test.ts @@ -33,6 +33,8 @@ function context( sessionContract: 'v2', sessionRevision: 'session-1', userRevision: 'user-1', + catalogRevision: 'catalog-1', + authorizationRevision: 'catalog-authorization-1', memberships: [], }, environmentId, @@ -166,6 +168,8 @@ describe('catalog producer protocol', () => { expect.objectContaining({ key: [environmentId, 'user', userId], authorizationResourceRevision: '4', + authorizationSourceRevision: 'database-resource', + authorizationRelationshipsRevision: '[]', accessPaths: [expect.objectContaining({ kind: 'direct', grantId: `users:${userId}` })], }), ]) diff --git a/control-api/test/routes.externalAccess.test.ts b/control-api/test/routes.externalAccess.test.ts new file mode 100644 index 0000000000..ea517d5e19 --- /dev/null +++ b/control-api/test/routes.externalAccess.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import request from 'supertest' +import { createExternalAccessRouter } from '../src/routes/external/access.js' + +const mocks = vi.hoisted(() => ({ + verifyV1: vi.fn(), + verifyV2: vi.fn(), + validateV1: vi.fn(), +})) + +vi.mock('../src/utils/auth/externalSessionAuthToken.js', () => ({ + verifyExternalSessionToken: mocks.verifyV1, +})) +vi.mock('../src/utils/auth/userSessionV2Token.js', () => ({ + verifyUserSessionV2Token: mocks.verifyV2, +})) +vi.mock('../src/services/auth/userSessionService.js', async importOriginal => { + const actual = await importOriginal() + return { ...actual, validateLegacyUserSession: mocks.validateV1 } +}) + +function app() { + const value = express() + value.use(express.json()) + value.use(createExternalAccessRouter({} as never)) + return value +} + +describe('external user-access contracts', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.verifyV2.mockReturnValue(null) + mocks.verifyV1.mockReturnValue({ + userId: '10000000-0000-4000-8000-000000000001', + email: 'user@example.test', + teamId: null, + role: 'member', + exp: 2_000_000_000, + iat: 1_900_000_000, + sessionContract: 'v1', + }) + mocks.validateV1.mockResolvedValue({ + status: 'valid', + identity: { jti: 'token-hash' }, + }) + }) + + it('advertises only the effective reconstruction defaults', async () => { + const response = await request(app()) + .get('/external/access/capabilities') + .set('x-user-session-token', 'v1-session') + + expect(response.status).toBe(200) + expect(response.body).toEqual( + expect.objectContaining({ + currentSessionContract: 'v1', + v1Accepted: true, + v1Issued: true, + v2Accepted: true, + v2Issued: false, + catalogShadow: false, + catalogServed: false, + actionContextV2: false, + rpcDelegationV2: false, + desktopAllTeamMode: false, + profileV2Mode: false, + minimumClientEnforced: false, + catalogFamilies: [], + }) + ) + }) + + it.each([ + ['GET', '/external/access/catalog'], + ['POST', '/external/access/resolve'], + ] as const)('keeps %s %s disabled before rate-limited work', async (method, path) => { + const response = await request(app()) + [method === 'GET' ? 'get' : 'post'](path) + .set('x-user-session-token', 'v1-session') + .send(method === 'POST' ? {} : undefined) + + expect(response.status).toBe(409) + expect(response.body.error.code).toBe('invalid_request') + }) + + it('requires an accepted live external session for the manifest', async () => { + mocks.verifyV1.mockReturnValue(null) + const response = await request(app()) + .get('/external/access/capabilities') + .set('x-user-session-token', 'invalid') + expect(response.status).toBe(401) + expect(response.body.error.code).toBe('invalid_session') + }) +}) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index d85209e604..d6f2927f00 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.108", + "version": "0.1.109", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.108", + "version": "0.1.109", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index 8a2e1361cb..dbf4d93e11 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.108", + "version": "0.1.109", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index f077a0fe27..c2e3e0c2b0 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.10.0', - externalRestApiVersion: '0.1.108', + externalRestApiVersion: '0.1.109', rpcProxyVersion: '0.1.111', desktopVersion: '0.10.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/src/routes/access.ts b/external-rest-api/src/routes/access.ts new file mode 100644 index 0000000000..25a6fbd8c4 --- /dev/null +++ b/external-rest-api/src/routes/access.ts @@ -0,0 +1,62 @@ +import { Router } from 'express' +import type { NextFunction, Response } from 'express' +import { controlApiRequest } from '../controlApiClient.js' +import { sanitizeControlApiPublicError } from '../http/publicApiError.js' +import { type AuthedRequest, extractAuthToken, requireAuth } from '../middleware/auth.js' + +const ACCESS_PUBLIC_STATUSES = new Set([400, 401, 403, 404, 409, 429, 503]) + +function forwardAccessError(error: unknown, res: Response, next: NextFunction): void { + const sanitized = sanitizeControlApiPublicError(error, ACCESS_PUBLIC_STATUSES) + if (sanitized) { + res.status(sanitized.status).json(sanitized.body) + return + } + next(error) +} + +export function createAccessRouter(): Router { + const router = Router() + router.use('/me/access', requireAuth) + + router.get('/me/access/capabilities', async (req: AuthedRequest, res, next) => { + try { + const result = await controlApiRequest('GET', '/external/access/capabilities', { + userSessionToken: extractAuthToken(req), + }) + res.status(200).json(result) + } catch (error) { + forwardAccessError(error, res, next) + } + }) + + router.get('/me/access/catalog', async (req: AuthedRequest, res, next) => { + try { + const result = await controlApiRequest('GET', '/external/access/catalog', { + userSessionToken: extractAuthToken(req), + query: { + families: typeof req.query.families === 'string' ? req.query.families : undefined, + limit: typeof req.query.limit === 'string' ? req.query.limit : undefined, + cursor: typeof req.query.cursor === 'string' ? req.query.cursor : undefined, + }, + }) + res.status(200).json(result) + } catch (error) { + forwardAccessError(error, res, next) + } + }) + + router.post('/me/access/resolve', async (req: AuthedRequest, res, next) => { + try { + const result = await controlApiRequest('POST', '/external/access/resolve', { + userSessionToken: extractAuthToken(req), + body: req.body, + }) + res.status(200).json(result) + } catch (error) { + forwardAccessError(error, res, next) + } + }) + + return router +} diff --git a/external-rest-api/test/routes.access.test.ts b/external-rest-api/test/routes.access.test.ts new file mode 100644 index 0000000000..770a58a3f0 --- /dev/null +++ b/external-rest-api/test/routes.access.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import request from 'supertest' +import { ControlApiError } from '../src/controlApiClient.js' +import { createAccessRouter } from '../src/routes/access.js' + +const mocks = vi.hoisted(() => ({ + verifyToken: vi.fn(), + controlApiRequest: vi.fn(), +})) + +vi.mock('../src/authToken.js', () => ({ verifyToken: mocks.verifyToken })) +vi.mock('../src/controlApiClient.js', async importOriginal => { + const actual = await importOriginal() + return { ...actual, controlApiRequest: mocks.controlApiRequest } +}) + +function app() { + const value = express() + value.use(express.json()) + value.use(createAccessRouter()) + return value +} + +describe('public access-contract forwarding', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.verifyToken.mockReturnValue({ + userId: 'user-1', + email: 'user@example.test', + teamId: null, + role: 'member', + exp: 2_000_000_000, + }) + }) + + it('forwards capabilities and catalog query fields with the same user session', async () => { + mocks.controlApiRequest.mockResolvedValueOnce({ catalogServed: false }) + await request(app()) + .get('/me/access/capabilities') + .set('authorization', 'Bearer session-token') + .expect(200, { catalogServed: false }) + expect(mocks.controlApiRequest).toHaveBeenLastCalledWith( + 'GET', + '/external/access/capabilities', + { userSessionToken: 'session-token' } + ) + + mocks.controlApiRequest.mockResolvedValueOnce({ items: [] }) + await request(app()) + .get('/me/access/catalog?families=team%2Chost&limit=25&cursor=c3.value.signature') + .set('authorization', 'Bearer session-token') + .expect(200, { items: [] }) + expect(mocks.controlApiRequest).toHaveBeenLastCalledWith( + 'GET', + '/external/access/catalog', + expect.objectContaining({ + userSessionToken: 'session-token', + query: { + families: 'team,host', + limit: '25', + cursor: 'c3.value.signature', + }, + }) + ) + }) + + it('forwards resolve bodies without interpreting action identity', async () => { + const body = { + requiredCapability: 'team.read', + resource: { environmentId: 'env', type: 'team', logicalId: 'team-1' }, + } + mocks.controlApiRequest.mockResolvedValue({ status: 'allowed' }) + await request(app()) + .post('/me/access/resolve') + .set('authorization', 'Bearer session-token') + .send(body) + .expect(200, { status: 'allowed' }) + expect(mocks.controlApiRequest).toHaveBeenCalledWith('POST', '/external/access/resolve', { + userSessionToken: 'session-token', + body, + }) + }) + + it('rebuilds a bounded public error instead of forwarding upstream fields', async () => { + mocks.controlApiRequest.mockRejectedValue( + new ControlApiError('upstream', 503, { + error: { + code: 'authority_unavailable', + message: 'SQL /secret/path', + correlationId: 'safe-id', + retryable: true, + details: { internal: 'secret' }, + }, + }) + ) + const response = await request(app()) + .get('/me/access/capabilities') + .set('authorization', 'Bearer session-token') + expect(response.status).toBe(503) + expect(response.body).toEqual({ + error: { + code: 'authority_unavailable', + message: 'Authorization is temporarily unavailable.', + correlationId: 'safe-id', + retryable: true, + }, + }) + }) +}) From c78d531e765bb38be1c53233360fd9ebe94125a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 00:35:26 -0600 Subject: [PATCH 017/528] feat(access): add aggregate shadow comparison adapters Run budget-isolated canonical-ID comparisons after all twelve legacy discovery roots without changing responses or authorization. Record only low-cardinality outcome and difference metrics while keeping shadowing disabled by effective policy. --- control-api/src/observability/metrics.ts | 12 ++ control-api/src/routes/external/gfs.ts | 13 ++ .../routes/external/notifications.routes.ts | 18 ++ .../src/routes/external/sharedFilesystems.ts | 14 ++ control-api/src/routes/external/users.ts | 53 +++++- .../routes/external/workflows/read.routes.ts | 36 ++++ .../routes/external/workflows/runs.routes.ts | 14 ++ .../services/access/accessCatalogShadow.ts | 170 ++++++++++++++++++ .../src/services/access/mcpInvocable.ts | 3 + control-api/src/services/workflows/types.ts | 7 +- control-api/test/accessCatalogShadow.test.ts | 91 ++++++++++ .../test/accessCatalogShadowCallsites.test.ts | 31 ++++ 12 files changed, 457 insertions(+), 5 deletions(-) create mode 100644 control-api/src/services/access/accessCatalogShadow.ts create mode 100644 control-api/test/accessCatalogShadow.test.ts create mode 100644 control-api/test/accessCatalogShadowCallsites.test.ts diff --git a/control-api/src/observability/metrics.ts b/control-api/src/observability/metrics.ts index cbfebc4b24..ea76e3ee72 100644 --- a/control-api/src/observability/metrics.ts +++ b/control-api/src/observability/metrics.ts @@ -267,6 +267,18 @@ export const rateLimitHitsTotal = getOrCreateCounter({ labelNames: ['bucket_type', 'result'] as const as Array<'bucket_type' | 'result'>, }) +export const aggregateAccessShadowComparisonsTotal = getOrCreateCounter({ + name: 'aggregate_access_shadow_comparisons_total', + help: 'Count of bounded aggregate-access shadow comparisons by family and safe outcome.', + labelNames: ['family', 'outcome'] as const as Array<'family' | 'outcome'>, +}) + +export const aggregateAccessShadowDifferencesTotal = getOrCreateCounter({ + name: 'aggregate_access_shadow_differences_total', + help: 'Count of canonical resource differences observed by bounded access shadowing.', + labelNames: ['family', 'direction'] as const as Array<'family' | 'direction'>, +}) + // Every limiter query that failed, counted even when its warn line is // throttled. No label: the bucket key is unbounded (see below). export const rateLimitBackendErrorsTotal = getOrCreateCounter({ diff --git a/control-api/src/routes/external/gfs.ts b/control-api/src/routes/external/gfs.ts index 41988318cc..8792674101 100644 --- a/control-api/src/routes/external/gfs.ts +++ b/control-api/src/routes/external/gfs.ts @@ -47,6 +47,7 @@ import { } from '../../middleware/gfsUploadAdmission.js' import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' import { rootLogger } from '../../observability/logger.js' +import { scheduleAccessCatalogShadow } from '../../services/access/accessCatalogShadow.js' import { getUserAgents } from '../../services/directory/index.js' import { GfsGrantError, @@ -938,6 +939,18 @@ export function createExternalGfsRouter(): Router { }> const page = rows.length > limit ? rows.slice(0, limit) : rows const last = page[page.length - 1] + scheduleAccessCatalogShadow({ + session: req.externalSessionAuthority, + family: 'gfs_resource', + legacyLogicalIds: page.map(row => String(row.resource_id)), + legacyComplete: rows.length <= limit && after === undefined, + scope: { + kind: 'behavior-json', + dimension: 'filesystemScope', + field: 'drive', + equals: drive, + }, + }) res.status(200).json({ ok: true, data: { diff --git a/control-api/src/routes/external/notifications.routes.ts b/control-api/src/routes/external/notifications.routes.ts index 0ab0bba9e1..08e5d326d3 100644 --- a/control-api/src/routes/external/notifications.routes.ts +++ b/control-api/src/routes/external/notifications.routes.ts @@ -16,6 +16,7 @@ import { notificationStreamEventsSentTotal, notificationStreamSnapshotSize, } from '../../observability/metrics.js' +import { scheduleAccessCatalogShadow } from '../../services/access/accessCatalogShadow.js' import { acknowledgeDesktopNotificationDelivery } from '../../services/notificationAckService.js' import { listActiveApprovalNotificationsForUser, @@ -183,10 +184,27 @@ export function createExternalNotificationsRouter(): Router { if (closed || flushInFlight) return flushInFlight = true try { + const initialSnapshot = lastCursor === null const events = await listNotificationStreamEventsForUser(claims.userId, { limit: config.notificationStreamSnapshotLimit, after: lastCursor ? parseNotificationCursor(lastCursor) : null, }) + const legacyComplete = + initialSnapshot && events.length < config.notificationStreamSnapshotLimit + scheduleAccessCatalogShadow({ + session: extReq.externalSessionAuthority, + family: 'notification', + legacyLogicalIds: events.map(event => event.id), + legacyComplete, + }) + scheduleAccessCatalogShadow({ + session: extReq.externalSessionAuthority, + family: 'workflow_approval', + legacyLogicalIds: events.flatMap(event => + event.eventType === 'approval.requested' ? [event.approval.id] : [] + ), + legacyComplete, + }) if (events.length === 0) { notificationStreamEventsFilteredTotal.inc({ reason: 'no_new_active_events' }, 1) return diff --git a/control-api/src/routes/external/sharedFilesystems.ts b/control-api/src/routes/external/sharedFilesystems.ts index b30b768783..56665d3f16 100644 --- a/control-api/src/routes/external/sharedFilesystems.ts +++ b/control-api/src/routes/external/sharedFilesystems.ts @@ -7,6 +7,7 @@ import { type ExternalAuthedRequest, requireValidExternalSessionToken, } from '../../middleware/externalSessionAuth.js' +import { scheduleAccessCatalogShadow } from '../../services/access/accessCatalogShadow.js' import { getLiveTeamMembership } from '../../services/access/liveTeamAuthorization.js' import { listActiveContextIds, @@ -307,6 +308,19 @@ export function createExternalSharedFilesystemsRouter(gateway: K8sGateway): Rout } }) + scheduleAccessCatalogShadow({ + session: req.externalSessionAuthority, + family: 'shared_filesystem', + legacyLogicalIds: items.map(item => `${sfsNs}/${item.name}`), + legacyComplete: true, + scope: { + kind: 'behavior-json', + dimension: 'filesystemScope', + field: 'contextId', + equals: `${ctxNs}/${contextId}`, + }, + }) + res.status(200).json({ items }) }) ) diff --git a/control-api/src/routes/external/users.ts b/control-api/src/routes/external/users.ts index 339616a473..a4e43666a6 100644 --- a/control-api/src/routes/external/users.ts +++ b/control-api/src/routes/external/users.ts @@ -9,6 +9,7 @@ import { requireValidExternalSessionToken, } from '../../middleware/externalSessionAuth.js' import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' +import { scheduleAccessCatalogShadow } from '../../services/access/accessCatalogShadow.js' import { resolveMcpServersForAgents } from '../../services/access/mcpInvocable.js' import { filterAccessValues, @@ -94,9 +95,16 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { requireExternalUserParamMatch(), async (req, res, next) => { try { - return res - .status(200) - .json(await listTeams(req.params.userId, String(req.query.currentTeamId || ''))) + const result = await listTeams(req.params.userId, String(req.query.currentTeamId || '')) + scheduleAccessCatalogShadow({ + session: (req as ExternalAuthedRequest).externalSessionAuthority, + family: 'team', + legacyLogicalIds: Array.isArray(result.items) + ? result.items.map(item => String(item.id)) + : [], + legacyComplete: true, + }) + return res.status(200).json(result) } catch (error) { return next(error) } @@ -217,6 +225,12 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { const teamId = String(req.query.teamId || '') const data = await getMe(req.params.userId, teamId) if (!data) return res.status(404).json({ error: 'not_found' }) + scheduleAccessCatalogShadow({ + session: (req as ExternalAuthedRequest).externalSessionAuthority, + family: 'user', + legacyLogicalIds: [req.params.userId], + legacyComplete: true, + }) return res.status(200).json(data) } catch (error) { return next(error) @@ -239,9 +253,16 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { err ) } + const contextIds = filterAccessValues(base.contextIds, active) + scheduleAccessCatalogShadow({ + session: (req as ExternalAuthedRequest).externalSessionAuthority, + family: 'context', + legacyLogicalIds: contextIds.map(id => `${config.contextsNamespace}/${id}`), + legacyComplete: active !== null, + }) return res.status(200).json({ ...base, - contextIds: filterAccessValues(base.contextIds, active), + contextIds, }) } catch (error) { return next(error) @@ -261,6 +282,7 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { const authorizedAgentNames = filterAccessValues(base.agentNames, null) let agents: Awaited> = [] let agentNames = authorizedAgentNames + let mcpEnrichmentComplete = true try { // Authorization model: authorizedAgentNames already reflects which agents // this user is allowed to use. resolveMcpServersForAgents treats agent @@ -269,6 +291,9 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { mcpServersNamespace: config.mcpServersNamespace, hostsNamespace: config.hostsNamespace, agentNames: authorizedAgentNames, + onPartial: () => { + mcpEnrichmentComplete = false + }, }) // A successful lookup can safely remove genuinely missing or invalid // Hosts. Only the DTOs resolved from the authorized input are used. @@ -282,7 +307,27 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { err ) agents = [] + mcpEnrichmentComplete = false } + const session = (req as ExternalAuthedRequest).externalSessionAuthority + scheduleAccessCatalogShadow({ + session, + family: 'host', + legacyLogicalIds: agentNames.map(name => `${config.hostsNamespace}/${name}`), + legacyComplete: agents.length === agentNames.length, + }) + scheduleAccessCatalogShadow({ + session, + family: 'mcp_server', + legacyLogicalIds: [ + ...new Set( + agents.flatMap(agent => + agent.mcpServers.map(server => `${config.mcpServersNamespace}/${server.name}`) + ) + ), + ], + legacyComplete: mcpEnrichmentComplete && agents.length === agentNames.length, + }) return res.status(200).json({ ...base, agentNames, agents }) } catch (error) { return next(error) diff --git a/control-api/src/routes/external/workflows/read.routes.ts b/control-api/src/routes/external/workflows/read.routes.ts index dbf5ac52ef..c45766df98 100644 --- a/control-api/src/routes/external/workflows/read.routes.ts +++ b/control-api/src/routes/external/workflows/read.routes.ts @@ -1,6 +1,7 @@ import { type Request, type Response, Router } from 'express' import { asyncHandler } from '../../../http/asyncHandler.js' import type { K8sGateway } from '../../../k8s.js' +import { scheduleAccessCatalogShadow } from '../../../services/access/accessCatalogShadow.js' import { K8sNotFoundError } from '../../../services/resourceService.js' import { WORKFLOW_RECIPE_PLURAL, @@ -24,6 +25,41 @@ export function createExternalWorkflowReadRoutes(gateway: K8sGateway): Router { if (!caller) return const recipes = await getAuthorizedRecipeResources(caller, gateway) + if (caller.kind === 'user-session') { + const recipeIds: string[] = [] + const appIds: string[] = [] + for (const recipe of recipes) { + const metadata = asRecord(recipe.metadata) + const spec = asRecord(recipe.spec) + const ui = asRecord(spec?.ui) + const namespace = typeof metadata?.namespace === 'string' ? metadata.namespace : '' + const name = typeof metadata?.name === 'string' ? metadata.name : '' + if (!namespace || !name) continue + const logicalId = `${namespace}/${name}` + recipeIds.push(logicalId) + if ( + typeof ui?.workloadRef === 'string' && + ui.workloadRef.trim() && + Number.isInteger(ui?.port) && + Number(ui.port) >= 1 && + Number(ui.port) <= 65_535 + ) { + appIds.push(logicalId) + } + } + scheduleAccessCatalogShadow({ + session: caller.session, + family: 'workflow_recipe', + legacyLogicalIds: recipeIds, + legacyComplete: true, + }) + scheduleAccessCatalogShadow({ + session: caller.session, + family: 'sandbox_app', + legacyLogicalIds: appIds, + legacyComplete: true, + }) + } res.json({ items: recipes, count: recipes.length }) }) ) diff --git a/control-api/src/routes/external/workflows/runs.routes.ts b/control-api/src/routes/external/workflows/runs.routes.ts index 2f72d68f72..9a49866a69 100644 --- a/control-api/src/routes/external/workflows/runs.routes.ts +++ b/control-api/src/routes/external/workflows/runs.routes.ts @@ -1,6 +1,7 @@ import { type Request, type Response, Router } from 'express' import { asyncHandler } from '../../../http/asyncHandler.js' import type { K8sGateway } from '../../../k8s.js' +import { scheduleAccessCatalogShadow } from '../../../services/access/accessCatalogShadow.js' import { K8sNotFoundError } from '../../../services/resourceService.js' import { ensureRecipeAuthorized, @@ -63,6 +64,19 @@ export function createExternalWorkflowRunsRoutes(gateway: K8sGateway): Router { const limit = parseLimit(req.query?.limit) const items = await listCanonicalRuns(ns, name, limit, caller) + if (caller.kind === 'user-session') { + scheduleAccessCatalogShadow({ + session: caller.session, + family: 'workflow_run', + legacyLogicalIds: items.map(item => item.id), + legacyComplete: items.length < limit, + scope: { + kind: 'relationship', + type: 'recipe', + targetResourceId: `workflow_recipe:${ns}/${name}`, + }, + }) + } res.json({ items, count: items.length }) }) ) diff --git a/control-api/src/services/access/accessCatalogShadow.ts b/control-api/src/services/access/accessCatalogShadow.ts new file mode 100644 index 0000000000..aa8d2fcec4 --- /dev/null +++ b/control-api/src/services/access/accessCatalogShadow.ts @@ -0,0 +1,170 @@ +import { + aggregateAccessShadowComparisonsTotal, + aggregateAccessShadowDifferencesTotal, +} from '../../observability/metrics.js' +import type { ExternalSessionAuthorityContext } from '../auth/externalSessionAuthentication.js' +import { type AccessCatalogItem, buildAccessCatalog } from './accessCatalogCoordinator.js' +import { + AccessBudgetExceededError, + AccessExecutionBudget, + AccessExecutionCancelledError, +} from './accessExecutionBudget.js' +import type { CatalogFamily } from './catalogContracts.js' +import { effectiveUserAccessPolicy } from './userAccessPolicy.js' + +const SHADOW_MAX_IDENTITIES = 100 + +export type CatalogShadowScope = + | Readonly<{ + kind: 'relationship' + type?: string + targetResourceId: string + }> + | Readonly<{ + kind: 'behavior-json' + dimension: 'filesystemScope' | 'runtime' + field: string + equals: string + }> + +export type CatalogShadowOutcome = + | 'match' + | 'catalog_only' + | 'legacy_only' + | 'both_differ' + | 'partial' + | 'skipped_capacity' + | 'skipped_unavailable' + | 'skipped_legacy_incomplete' + +function jsonBehaviorMatches( + item: AccessCatalogItem, + scope: Extract +): boolean { + return item.accessPaths.some(path => { + const dimension = path.behaviorDescriptors[scope.dimension] + if (dimension.state !== 'known' || typeof dimension.value !== 'string') return false + try { + const value = JSON.parse(dimension.value) as Record + return String(value?.[scope.field] ?? '') === scope.equals + } catch { + return false + } + }) +} + +function inScope(item: AccessCatalogItem, scope: CatalogShadowScope | undefined): boolean { + if (!scope) return true + if (scope.kind === 'behavior-json') return jsonBehaviorMatches(item, scope) + return item.relationships.some( + relationship => + relationship.targetResourceId === scope.targetResourceId && + (!scope.type || relationship.type === scope.type) + ) +} + +function safeLegacyIds(values: readonly string[]): Set | null { + if (values.length > SHADOW_MAX_IDENTITIES) return null + const output = new Set() + for (const value of values) { + const normalized = value.trim() + if (!normalized || normalized.length > 1_024 || /[\u0000-\u001f\u007f]/u.test(normalized)) { + return null + } + output.add(normalized) + } + return output +} + +function record( + family: CatalogFamily, + outcome: CatalogShadowOutcome, + catalogOnly = 0, + legacyOnly = 0 +): CatalogShadowOutcome { + aggregateAccessShadowComparisonsTotal.inc({ family, outcome }, 1) + if (catalogOnly > 0) { + aggregateAccessShadowDifferencesTotal.inc({ family, direction: 'catalog_only' }, catalogOnly) + } + if (legacyOnly > 0) { + aggregateAccessShadowDifferencesTotal.inc({ family, direction: 'legacy_only' }, legacyOnly) + } + return outcome +} + +export async function compareAccessCatalogShadow( + input: { + session: ExternalSessionAuthorityContext + family: CatalogFamily + legacyLogicalIds: readonly string[] + legacyComplete: boolean + scope?: CatalogShadowScope + }, + options: { + enabled?: boolean + buildCatalog?: typeof buildAccessCatalog + } = {} +): Promise { + if (!input.legacyComplete) return record(input.family, 'skipped_legacy_incomplete') + const legacy = safeLegacyIds(input.legacyLogicalIds) + if (!legacy) return record(input.family, 'skipped_legacy_incomplete') + if (!(options.enabled ?? effectiveUserAccessPolicy.computeCatalogShadow)) { + return 'skipped_unavailable' + } + + const parent = AccessExecutionBudget.create('catalog') + const child = parent.child({ + producerCalls: 8, + objects: 200, + decodedBytes: 2 * 1024 * 1024, + accessPaths: 512, + relationships: 1_024, + dbRowsReturned: 1_024, + responseBytes: 2 * 1024 * 1024, + }) + try { + const catalog = await (options.buildCatalog ?? buildAccessCatalog)( + { session: input.session, families: [input.family], limit: SHADOW_MAX_IDENTITIES }, + { budget: child } + ) + if (!catalog.complete || catalog.nextCursor) return record(input.family, 'partial') + const aggregate = new Set( + catalog.items.filter(item => inScope(item, input.scope)).map(item => item.resource.logicalId) + ) + const catalogOnly = [...aggregate].filter(value => !legacy.has(value)).length + const legacyOnly = [...legacy].filter(value => !aggregate.has(value)).length + const outcome: CatalogShadowOutcome = + catalogOnly === 0 && legacyOnly === 0 + ? 'match' + : catalogOnly > 0 && legacyOnly > 0 + ? 'both_differ' + : catalogOnly > 0 + ? 'catalog_only' + : 'legacy_only' + return record(input.family, outcome, catalogOnly, legacyOnly) + } catch (error) { + if ( + error instanceof AccessBudgetExceededError || + error instanceof AccessExecutionCancelledError + ) { + return record(input.family, 'skipped_capacity') + } + return record(input.family, 'skipped_unavailable') + } finally { + child.close() + parent.close() + } +} + +export function scheduleAccessCatalogShadow(input: { + session: ExternalSessionAuthorityContext | undefined + family: CatalogFamily + legacyLogicalIds: readonly string[] + legacyComplete: boolean + scope?: CatalogShadowScope +}): void { + if (!input.session || !effectiveUserAccessPolicy.computeCatalogShadow) return + setImmediate(() => { + void compareAccessCatalogShadow({ ...input, session: input.session! }) + }) +} diff --git a/control-api/src/services/access/mcpInvocable.ts b/control-api/src/services/access/mcpInvocable.ts index 4eae39362e..34fd99cfeb 100644 --- a/control-api/src/services/access/mcpInvocable.ts +++ b/control-api/src/services/access/mcpInvocable.ts @@ -364,6 +364,7 @@ export async function resolveMcpServersForAgents( mcpServersNamespace: string hostsNamespace: string agentNames: readonly string[] + onPartial?: (source: 'mcp_servers' | 'contexts') => void } ): Promise { const { mcpServersNamespace, hostsNamespace, agentNames } = opts @@ -390,6 +391,7 @@ export async function resolveMcpServersForAgents( try { serverList = asArray(await gateway.listResource('mcpservers', mcpServersNamespace)) } catch (err) { + opts.onPartial?.('mcp_servers') // Directory identity remains useful when optional MCP catalog enrichment // is temporarily unavailable — but the degradation must be observable, or // a broken mcpservers list path reads as "this agent has no tools". @@ -423,6 +425,7 @@ export async function resolveMcpServersForAgents( scopedContextIds ) } catch (err) { + opts.onPartial?.('contexts') // Preserve the authorized directory DTOs with an empty tool catalog — // loudly, so a Context-read outage is distinguishable from "no tools". log.error( diff --git a/control-api/src/services/workflows/types.ts b/control-api/src/services/workflows/types.ts index 305875a6ac..02d8fea453 100644 --- a/control-api/src/services/workflows/types.ts +++ b/control-api/src/services/workflows/types.ts @@ -1,8 +1,13 @@ import type { AuthClaims } from '../../profileTypes.js' import type { McpHostControlClaims } from '../../utils/auth/mcpHostJwtToken.js' +import type { ExternalSessionAuthorityContext } from '../auth/externalSessionAuthentication.js' export type WorkflowCaller = - | { kind: 'user-session'; claims: AuthClaims } + | { + kind: 'user-session' + claims: AuthClaims + session: ExternalSessionAuthorityContext + } | { kind: 'admin-ui'; userId: string } | { kind: 'mcp-host-control'; claims: McpHostControlClaims } diff --git a/control-api/test/accessCatalogShadow.test.ts b/control-api/test/accessCatalogShadow.test.ts new file mode 100644 index 0000000000..1bd89503a2 --- /dev/null +++ b/control-api/test/accessCatalogShadow.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { registry } from '../src/observability/metrics.js' +import { + type CatalogShadowOutcome, + compareAccessCatalogShadow, +} from '../src/services/access/accessCatalogShadow.js' + +const session = { + contract: 'v1' as const, + userId: '10000000-0000-4000-8000-000000000001', + tokenHash: 'token-hash', + issuedAt: 1_900_000_000, +} + +function catalog(logicalIds: string[], complete = true, nextCursor: string | null = null) { + return { + contractVersion: '2' as const, + authorizationRevision: 'authorization-1', + sourceStateRevision: 'source-1', + complete, + partialErrors: [], + items: logicalIds.map(logicalId => ({ + resource: { + environmentId: 'cluster.local/evenfire', + type: 'team' as const, + canonicalId: `team:${logicalId}`, + logicalId, + displayName: logicalId, + resourceRevision: '1', + }, + relationships: [], + capabilities: ['team.read'], + accessPaths: [], + })), + nextCursor, + } +} + +async function outcome(legacy: string[], aggregate: string[]): Promise { + return compareAccessCatalogShadow( + { session, family: 'team', legacyLogicalIds: legacy, legacyComplete: true }, + { enabled: true, buildCatalog: vi.fn().mockResolvedValue(catalog(aggregate)) } + ) +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('aggregate access shadow comparison', () => { + it.each([ + [[], [], 'match'], + [['a'], ['a'], 'match'], + [['a'], ['a', 'b'], 'catalog_only'], + [['a', 'b'], ['a'], 'legacy_only'], + [['a'], ['b'], 'both_differ'], + ] as const)('classifies bounded canonical sets %#', async (legacy, aggregate, expected) => { + await expect(outcome([...legacy], [...aggregate])).resolves.toBe(expected) + }) + + it('does not compute parity for incomplete legacy or aggregate results', async () => { + const buildCatalog = vi.fn() + await expect( + compareAccessCatalogShadow( + { session, family: 'team', legacyLogicalIds: [], legacyComplete: false }, + { enabled: true, buildCatalog } + ) + ).resolves.toBe('skipped_legacy_incomplete') + expect(buildCatalog).not.toHaveBeenCalled() + + await expect( + compareAccessCatalogShadow( + { session, family: 'team', legacyLogicalIds: [], legacyComplete: true }, + { + enabled: true, + buildCatalog: vi.fn().mockResolvedValue(catalog([], false, 'c3.cursor.signature')), + } + ) + ).resolves.toBe('partial') + }) + + it('records only low-cardinality family, outcome, and direction labels', async () => { + await outcome(['legacy-secret-id'], ['catalog-secret-id']) + const metrics = await registry.metrics() + expect(metrics).toContain('aggregate_access_shadow_comparisons_total') + expect(metrics).toContain('family="team"') + expect(metrics).toContain('outcome="both_differ"') + expect(metrics).not.toContain('legacy-secret-id') + expect(metrics).not.toContain('catalog-secret-id') + }) +}) diff --git a/control-api/test/accessCatalogShadowCallsites.test.ts b/control-api/test/accessCatalogShadowCallsites.test.ts new file mode 100644 index 0000000000..8579dd2081 --- /dev/null +++ b/control-api/test/accessCatalogShadowCallsites.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' +import { CATALOG_FAMILIES, type CatalogFamily } from '../src/services/access/catalogContracts.js' + +const SOURCES = [ + 'src/routes/external/users.ts', + 'src/routes/external/workflows/read.routes.ts', + 'src/routes/external/workflows/runs.routes.ts', + 'src/routes/external/notifications.routes.ts', + 'src/routes/external/gfs.ts', + 'src/routes/external/sharedFilesystems.ts', +] as const + +describe('aggregate shadow production call sites', () => { + it('maps every frozen PR 1 family from an actual legacy discovery root', () => { + const source = SOURCES.map(path => readFileSync(path, 'utf8')).join('\n') + const observed = new Set() + for (const family of CATALOG_FAMILIES) { + if (source.includes(`family: '${family}'`)) observed.add(family) + } + expect([...observed]).toEqual(CATALOG_FAMILIES) + }) + + it('keeps shadow scheduling out of authorization and response expressions', () => { + for (const path of SOURCES) { + const source = readFileSync(path, 'utf8') + expect(source).not.toMatch(/await\s+scheduleAccessCatalogShadow/u) + expect(source).not.toMatch(/res\.[a-z]+\([^)]*scheduleAccessCatalogShadow/u) + } + }) +}) From 17b39ed0412840c3e35cebefd82cbea088ca9805 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 00:44:03 -0600 Subject: [PATCH 018/528] test(access): prove catalog merge invariants Extract the bounded k-way merge and hydrated-resource canonicalization into production-used pure contracts. Add fast-check properties for ordering, pagination composition, path and relationship preservation, budget exhaustion, and cancellation. --- .../access/accessCatalogCoordinator.ts | 105 +--- .../src/services/access/accessCatalogMerge.ts | 207 ++++++++ .../test/accessCatalogProperties.test.ts | 452 ++++++++++++++++++ 3 files changed, 674 insertions(+), 90 deletions(-) create mode 100644 control-api/src/services/access/accessCatalogMerge.ts create mode 100644 control-api/test/accessCatalogProperties.test.ts diff --git a/control-api/src/services/access/accessCatalogCoordinator.ts b/control-api/src/services/access/accessCatalogCoordinator.ts index 154d52906f..3158671760 100644 --- a/control-api/src/services/access/accessCatalogCoordinator.ts +++ b/control-api/src/services/access/accessCatalogCoordinator.ts @@ -11,6 +11,11 @@ import { decodeAccessCatalogCursor, encodeAccessCatalogCursor, } from './accessCatalogCursor.js' +import { + AccessCatalogMergeError, + mergeCatalogCandidateStreams, + mergeHydratedCatalogResources, +} from './accessCatalogMerge.js' import { AccessExecutionBudget, type AccessExecutionLimits } from './accessExecutionBudget.js' import { buildAccessPath, canonicalAccessPathTuple } from './accessPath.js' import { authorizationRevision, revisionOfValues } from './authorizationRevision.js' @@ -31,8 +36,6 @@ import { type HydratedCatalogResource, type ProducerContinuation, type SafeCatalogPartialError, - catalogKeyEquals, - compareCatalogKey, } from './catalogContracts.js' import { CatalogProducerContractError } from './catalogProducerSupport.js' import { requireCatalogProducer } from './catalogProducers.js' @@ -81,17 +84,9 @@ type CatalogTransaction = (work: (db: DbClient) => Promise) => Promise type ProducerRuntime = { family: CatalogFamily - inputContinuation: ProducerContinuation page: CatalogProducerPage - cursorIndex: number - emittedCount: number } -type HeapEntry = Readonly<{ - runtime: ProducerRuntime - candidate: CatalogIdentityCandidate -}> - function normalizeFamilies(value: readonly CatalogFamily[] | undefined): CatalogFamily[] { if (value === undefined) return [...CATALOG_FAMILIES] if (value.length === 0 || value.length > CATALOG_FAMILIES.length) { @@ -113,75 +108,6 @@ function emptyContinuations(): Record { ) as Record } -function entryCompare(left: HeapEntry, right: HeapEntry): number { - const keyOrder = compareCatalogKey(left.candidate.key, right.candidate.key) - return keyOrder || left.runtime.family.localeCompare(right.runtime.family) -} - -function heapPush(heap: HeapEntry[], entry: HeapEntry): void { - heap.push(entry) - let index = heap.length - 1 - while (index > 0) { - const parent = Math.floor((index - 1) / 2) - if (entryCompare(heap[parent], heap[index]) <= 0) break - ;[heap[parent], heap[index]] = [heap[index], heap[parent]] - index = parent - } -} - -function heapPop(heap: HeapEntry[]): HeapEntry | undefined { - const first = heap[0] - const last = heap.pop() - if (!first || !last || heap.length === 0) return first - heap[0] = last - let index = 0 - for (;;) { - const left = index * 2 + 1 - const right = left + 1 - let smallest = index - if (left < heap.length && entryCompare(heap[left], heap[smallest]) < 0) smallest = left - if (right < heap.length && entryCompare(heap[right], heap[smallest]) < 0) smallest = right - if (smallest === index) break - ;[heap[index], heap[smallest]] = [heap[smallest], heap[index]] - index = smallest - } - return first -} - -function advanceRuntime(heap: HeapEntry[], runtime: ProducerRuntime): void { - runtime.cursorIndex += 1 - const candidate = runtime.page.candidates[runtime.cursorIndex] - if (candidate) heapPush(heap, { runtime, candidate }) -} - -function mergeCandidatePages( - runtimes: readonly ProducerRuntime[], - limit: number -): CatalogIdentityCandidate[] { - const heap: HeapEntry[] = [] - for (const runtime of runtimes) { - const candidate = runtime.page.candidates[0] - if (candidate) heapPush(heap, { runtime, candidate }) - } - const values: CatalogIdentityCandidate[] = [] - while (heap.length > 0 && values.length < limit + 1) { - const first = heapPop(heap)! - const equal = [first] - while (heap[0] && catalogKeyEquals(heap[0].candidate.key, first.candidate.key)) { - equal.push(heapPop(heap)!) - } - let validUntil: string | null = null - for (const entry of equal) { - if (entry.candidate.validUntil && (!validUntil || entry.candidate.validUntil < validUntil)) { - validUntil = entry.candidate.validUntil - } - advanceRuntime(heap, entry.runtime) - } - values.push(Object.freeze({ ...first.candidate, validUntil })) - } - return values -} - function safePartialErrors(runtimes: readonly ProducerRuntime[]): SafeCatalogPartialError[] { const values = new Map() for (const runtime of runtimes) { @@ -204,17 +130,14 @@ async function hydrateSelected( values.push(candidate.key) byFamily.set(candidate.key[1], values) } - const hydrated = new Map() + const hydratedValues: HydratedCatalogResource[] = [] for (const family of CATALOG_FAMILIES) { const keys = byFamily.get(family) if (!keys?.length) continue const values = await requireCatalogProducer(family).hydrateCanonicalKeys(context, keys) - for (const value of values) { - const key = JSON.stringify(value.key) - if (hydrated.has(key)) throw new AccessCatalogRequestError('catalog_invariant_failed') - hydrated.set(key, value) - } + hydratedValues.push(...values) } + const hydrated = mergeHydratedCatalogResources(hydratedValues) for (const candidate of selected) { if (!hydrated.has(JSON.stringify(candidate.key))) { throw new AccessCatalogRequestError('authority_unavailable') @@ -295,7 +218,6 @@ function finalContinuations(input: { } const runtime = input.runtimes.find(value => value.family === family)! const emitted = input.selected.filter(candidate => candidate.key[1] === family) - runtime.emittedCount = emitted.length const afterKey = emitted.at(-1)?.key ?? input.initial[family].afterKey const hasUnconsumed = runtime.page.candidates.length > emitted.length output[family] = Object.freeze({ @@ -363,13 +285,13 @@ async function buildInTransaction(input: { ) runtimes.push({ family, - inputContinuation: initial[family], page, - cursorIndex: 0, - emittedCount: 0, }) } - const merged = mergeCandidatePages(runtimes, input.limit) + const merged = mergeCatalogCandidateStreams( + runtimes.map(runtime => ({ streamId: runtime.family, candidates: runtime.page.candidates })), + input.limit + 1 + ) const selected = merged.slice(0, input.limit) const hydrated = await hydrateSelected(context, selected) const items = selected.map(candidate => @@ -470,6 +392,9 @@ export async function buildAccessCatalog( if (error instanceof CatalogProducerContractError) { throw new AccessCatalogRequestError('authority_unavailable') } + if (error instanceof AccessCatalogMergeError) { + throw new AccessCatalogRequestError('catalog_invariant_failed') + } throw error } finally { ownedBudget?.close() diff --git a/control-api/src/services/access/accessCatalogMerge.ts b/control-api/src/services/access/accessCatalogMerge.ts new file mode 100644 index 0000000000..85d5233cf1 --- /dev/null +++ b/control-api/src/services/access/accessCatalogMerge.ts @@ -0,0 +1,207 @@ +import { + canonicalAccessPathSeeds, + canonicalAuthorizationRelationships, +} from './authorizationRevision.js' +import { + type CatalogIdentityCandidate, + type CatalogKey, + type HydratedCatalogResource, + catalogKeyEquals, + compareCatalogKey, +} from './catalogContracts.js' +import { resourceIdentityKey } from './resourceIdentity.js' + +export class AccessCatalogMergeError extends Error { + constructor(readonly code: string) { + super(`Access catalog merge invariant failed: ${code}`) + this.name = 'AccessCatalogMergeError' + } +} + +export type CatalogCandidateStream = Readonly<{ + streamId: string + candidates: readonly CatalogIdentityCandidate[] +}> + +type StreamCursor = { + stream: CatalogCandidateStream + index: number +} + +type HeapEntry = Readonly<{ + cursor: StreamCursor + candidate: CatalogIdentityCandidate +}> + +function entryCompare(left: HeapEntry, right: HeapEntry): number { + const keyOrder = compareCatalogKey(left.candidate.key, right.candidate.key) + return keyOrder || left.cursor.stream.streamId.localeCompare(right.cursor.stream.streamId) +} + +function heapPush(heap: HeapEntry[], entry: HeapEntry): void { + heap.push(entry) + let index = heap.length - 1 + while (index > 0) { + const parent = Math.floor((index - 1) / 2) + if (entryCompare(heap[parent], heap[index]) <= 0) break + ;[heap[parent], heap[index]] = [heap[index], heap[parent]] + index = parent + } +} + +function heapPop(heap: HeapEntry[]): HeapEntry | undefined { + const first = heap[0] + const last = heap.pop() + if (!first || !last || heap.length === 0) return first + heap[0] = last + let index = 0 + for (;;) { + const left = index * 2 + 1 + const right = left + 1 + let smallest = index + if (left < heap.length && entryCompare(heap[left], heap[smallest]) < 0) smallest = left + if (right < heap.length && entryCompare(heap[right], heap[smallest]) < 0) smallest = right + if (smallest === index) break + ;[heap[index], heap[smallest]] = [heap[smallest], heap[index]] + index = smallest + } + return first +} + +function validateCandidateStream(stream: CatalogCandidateStream): void { + if (!stream.streamId) throw new AccessCatalogMergeError('stream_id_missing') + for (let index = 1; index < stream.candidates.length; index += 1) { + if (compareCatalogKey(stream.candidates[index - 1].key, stream.candidates[index].key) >= 0) { + throw new AccessCatalogMergeError('stream_not_strictly_ordered') + } + } +} + +function advance(heap: HeapEntry[], cursor: StreamCursor): void { + cursor.index += 1 + const candidate = cursor.stream.candidates[cursor.index] + if (candidate) heapPush(heap, { cursor, candidate }) +} + +/** + * Deterministically merges already-keyset-paged producer streams. The bound is + * an output bound (normally pageSize + 1), not a hint: no later candidate is + * inspected after the bound has been satisfied. + */ +export function mergeCatalogCandidateStreams( + streams: readonly CatalogCandidateStream[], + maximumOutputs: number +): CatalogIdentityCandidate[] { + if (!Number.isSafeInteger(maximumOutputs) || maximumOutputs < 0) { + throw new AccessCatalogMergeError('output_bound_invalid') + } + const streamIds = new Set() + const heap: HeapEntry[] = [] + for (const stream of streams) { + validateCandidateStream(stream) + if (streamIds.has(stream.streamId)) { + throw new AccessCatalogMergeError('stream_id_duplicate') + } + streamIds.add(stream.streamId) + const cursor: StreamCursor = { stream, index: 0 } + const candidate = stream.candidates[0] + if (candidate) heapPush(heap, { cursor, candidate }) + } + + const values: CatalogIdentityCandidate[] = [] + while (heap.length > 0 && values.length < maximumOutputs) { + const first = heapPop(heap)! + const equal = [first] + while (heap[0] && catalogKeyEquals(heap[0].candidate.key, first.candidate.key)) { + equal.push(heapPop(heap)!) + } + if (equal.some(entry => entry.candidate.canonicalId !== first.candidate.canonicalId)) { + throw new AccessCatalogMergeError('canonical_id_conflict') + } + const validUntilValues = equal + .flatMap(entry => (entry.candidate.validUntil ? [entry.candidate.validUntil] : [])) + .sort() + for (const entry of equal) advance(heap, entry.cursor) + values.push( + Object.freeze({ + ...first.candidate, + validUntil: validUntilValues[0] ?? null, + }) + ) + } + return values +} + +function keyString(key: CatalogKey): string { + return JSON.stringify(key) +} + +function assertHydratedIdentity(value: HydratedCatalogResource): void { + if ( + value.key[0] !== value.resource.environmentId || + value.key[1] !== value.resource.type || + value.key[2] !== value.resource.logicalId || + value.resource.canonicalId !== `${value.resource.type}:${value.resource.logicalId}` + ) { + throw new AccessCatalogMergeError('hydrated_identity_mismatch') + } +} + +function canonicalHydratedResource(value: HydratedCatalogResource): HydratedCatalogResource { + assertHydratedIdentity(value) + return Object.freeze({ + ...value, + accessPaths: Object.freeze(canonicalAccessPathSeeds(value.accessPaths)), + relationships: Object.freeze(canonicalAuthorizationRelationships(value.relationships)), + }) +} + +/** + * Merges fragments only when they describe the exact same immutable resource + * and authoritative snapshot. Equal paths and relationships are deduplicated; + * every non-equal path and relationship is retained. + */ +export function mergeHydratedCatalogResources( + values: readonly HydratedCatalogResource[] +): Map { + const merged = new Map() + for (const raw of values) { + const value = canonicalHydratedResource(raw) + const key = keyString(value.key) + const prior = merged.get(key) + if (!prior) { + merged.set(key, value) + continue + } + if ( + resourceIdentityKey(prior.resource) !== resourceIdentityKey(value.resource) || + JSON.stringify(prior.resource) !== JSON.stringify(value.resource) + ) { + throw new AccessCatalogMergeError('resource_identity_conflict') + } + if ( + prior.authorizationResourceRevision !== value.authorizationResourceRevision || + prior.authorizationSourceRevision !== value.authorizationSourceRevision || + prior.authorizationRelationshipsRevision !== value.authorizationRelationshipsRevision + ) { + throw new AccessCatalogMergeError('authorization_revision_conflict') + } + merged.set( + key, + Object.freeze({ + ...prior, + accessPaths: Object.freeze( + canonicalAccessPathSeeds([...prior.accessPaths, ...value.accessPaths]) + ), + relationships: Object.freeze( + canonicalAuthorizationRelationships([...prior.relationships, ...value.relationships]) + ), + validUntil: + [prior.validUntil, value.validUntil] + .filter((item): item is string => Boolean(item)) + .sort()[0] ?? null, + }) + ) + } + return merged +} diff --git a/control-api/test/accessCatalogProperties.test.ts b/control-api/test/accessCatalogProperties.test.ts new file mode 100644 index 0000000000..f3c4337351 --- /dev/null +++ b/control-api/test/accessCatalogProperties.test.ts @@ -0,0 +1,452 @@ +import { describe, expect, it } from 'vitest' +import fc from 'fast-check' +import { + AccessCatalogMergeError, + mergeCatalogCandidateStreams, + mergeHydratedCatalogResources, +} from '../src/services/access/accessCatalogMerge.js' +import { + AccessBudgetExceededError, + AccessExecutionBudget, + AccessExecutionCancelledError, +} from '../src/services/access/accessExecutionBudget.js' +import type { + AccessPathBehavior, + AccessPathSeed, + BehaviorDimension, +} from '../src/services/access/accessPath.js' +import { + canonicalAccessPathSeeds, + canonicalAuthorizationRelationships, +} from '../src/services/access/authorizationRevision.js' +import type { AccessCapability } from '../src/services/access/capabilityRegistry.js' +import { + CATALOG_FAMILIES, + type CatalogFamily, + type CatalogIdentityCandidate, + type CatalogRelationship, + type HydratedCatalogResource, + catalogKey, + compareCatalogKey, +} from '../src/services/access/catalogContracts.js' +import { canonicalResourceIdentity } from '../src/services/access/resourceIdentity.js' + +const environmentId = 'cluster.local/evenfire' +const capabilities: readonly AccessCapability[] = [ + 'user.profile.read', + 'team.read', + 'host.read', + 'context.read', + 'workflow.read', + 'notification.read', +] + +function identifier(value: number): string { + return `id-${String(value).padStart(5, '0')}` +} + +function uuid(value: number): string { + return `00000000-0000-4000-8000-${String(value).padStart(12, '0')}` +} + +const candidateArbitrary = fc + .record({ + family: fc.constantFrom(...CATALOG_FAMILIES), + value: fc.integer({ min: 0, max: 500 }), + validUntil: fc.option( + fc + .integer({ min: 1, max: 20 }) + .map(day => `2030-01-${String(day).padStart(2, '0')}T00:00:00.000Z`), + { nil: null } + ), + }) + .map(({ family, value, validUntil }): CatalogIdentityCandidate => { + const logicalId = identifier(value) + return Object.freeze({ + key: catalogKey(environmentId, family, logicalId), + canonicalId: `${family}:${logicalId}`, + validUntil, + }) + }) + +const candidateStreamsArbitrary = fc + .array( + fc.uniqueArray(candidateArbitrary, { + maxLength: 30, + selector: candidate => JSON.stringify(candidate.key), + }), + { maxLength: 8 } + ) + .map(candidateLists => + candidateLists.map((candidates, index) => ({ + streamId: `stream-${index}`, + candidates: [...candidates].sort((left, right) => compareCatalogKey(left.key, right.key)), + })) + ) + +function expectedCandidateUnion( + streams: readonly Readonly<{ candidates: readonly CatalogIdentityCandidate[] }>[], + maximumOutputs = Number.MAX_SAFE_INTEGER +): CatalogIdentityCandidate[] { + const byKey = new Map() + for (const candidate of streams.flatMap(stream => stream.candidates)) { + const key = JSON.stringify(candidate.key) + const prior = byKey.get(key) + const validUntil = [prior?.validUntil, candidate.validUntil] + .filter((value): value is string => Boolean(value)) + .sort()[0] + byKey.set(key, { ...candidate, validUntil: validUntil ?? null }) + } + return [...byKey.values()] + .sort((left, right) => compareCatalogKey(left.key, right.key)) + .slice(0, maximumOutputs) +} + +const dimensionArbitrary: fc.Arbitrary = fc.oneof( + fc.constant({ state: 'unknown' } as const), + fc.option(fc.string({ maxLength: 24 }), { nil: null }).map(value => ({ + state: 'known' as const, + value, + })) +) + +const behaviorArbitrary: fc.Arbitrary = fc.record({ + capabilities: fc.uniqueArray(fc.constantFrom(...capabilities), { + maxLength: capabilities.length, + }), + budget: dimensionArbitrary, + credentialPolicy: dimensionArbitrary, + approvalPolicy: dimensionArbitrary, + filesystemScope: dimensionArbitrary, + runtime: dimensionArbitrary, + providerModelPolicy: dimensionArbitrary, + audit: dimensionArbitrary, +}) + +const pathArbitrary: fc.Arbitrary = fc + .record({ + kind: fc.constantFrom<'direct' | 'team'>('direct', 'team'), + grant: fc.integer({ min: 0, max: 100 }), + team: fc.integer({ min: 1, max: 20 }), + role: fc.constantFrom<'admin' | 'inviter' | 'member'>('admin', 'inviter', 'member'), + behavior: behaviorArbitrary, + }) + .map(({ kind, grant, team, role, behavior }) => + kind === 'direct' + ? { kind, grantId: `direct-${grant}`, behavior } + : { kind, grantId: `team-${grant}`, teamId: uuid(team), currentRole: role, behavior } + ) + +const relationshipArbitrary: fc.Arbitrary = fc + .record({ + type: fc.constantFrom('mounted_by', 'served_by', 'created_from'), + target: fc.integer({ min: 0, max: 100 }), + instance: fc.option(fc.integer({ min: 0, max: 30 }), { nil: undefined }), + }) + .map(({ type, target, instance }) => ({ + type, + targetResourceId: `target:${identifier(target)}`, + ...(instance === undefined ? {} : { instanceId: `edge-${instance}` }), + })) + +function hydrated(input: { + id: number + displayName?: string + paths: readonly AccessPathSeed[] + relationships: readonly CatalogRelationship[] + validUntil?: string | null +}): HydratedCatalogResource { + const logicalId = uuid(input.id) + return { + key: catalogKey(environmentId, 'user', logicalId), + resource: canonicalResourceIdentity({ + environmentId, + type: 'user', + logicalId, + displayName: input.displayName ?? `User ${input.id}`, + }), + accessPaths: input.paths, + relationships: input.relationships, + authorizationResourceRevision: 'resource-1', + authorizationSourceRevision: 'source-1', + authorizationRelationshipsRevision: 'relationships-1', + validUntil: input.validUntil ?? null, + } +} + +describe('aggregate access catalog properties', () => { + it('k-way merge is ordered, bounded, deduplicating, and producer-order independent', () => { + fc.assert( + fc.property( + candidateStreamsArbitrary, + fc.integer({ min: 0, max: 50 }), + (streams, maximumOutputs) => { + const expected = expectedCandidateUnion(streams, maximumOutputs) + expect(mergeCatalogCandidateStreams(streams, maximumOutputs)).toEqual(expected) + expect(mergeCatalogCandidateStreams([...streams].reverse(), maximumOutputs)).toEqual( + expected + ) + } + ), + { numRuns: 300 } + ) + }) + + it('composes bounded pages without identity loss or duplication', () => { + const perFamilyStreams = fc.subarray([...CATALOG_FAMILIES], { minLength: 1 }).chain(families => + fc + .tuple( + ...families.map(family => + fc.uniqueArray(fc.integer({ min: 0, max: 200 }), { maxLength: 25 }).map(values => ({ + streamId: family, + candidates: values + .map(value => { + const logicalId = identifier(value) + return { + key: catalogKey(environmentId, family, logicalId), + canonicalId: `${family}:${logicalId}`, + validUntil: null, + } + }) + .sort((left, right) => compareCatalogKey(left.key, right.key)), + })) + ) + ) + .map(streams => streams) + ) + + fc.assert( + fc.property(perFamilyStreams, fc.integer({ min: 1, max: 20 }), (streams, pageSize) => { + const expected = expectedCandidateUnion(streams) + const remaining = streams.map(stream => ({ + streamId: stream.streamId, + candidates: [...stream.candidates], + })) + const composed: CatalogIdentityCandidate[] = [] + while (remaining.some(stream => stream.candidates.length > 0)) { + const page = mergeCatalogCandidateStreams(remaining, pageSize + 1).slice(0, pageSize) + expect(page.length).toBeGreaterThan(0) + composed.push(...page) + for (const stream of remaining) { + const emitted = page.filter(candidate => candidate.key[1] === stream.streamId) + const afterKey = emitted.at(-1)?.key + if (afterKey) { + stream.candidates = stream.candidates.filter( + candidate => compareCatalogKey(candidate.key, afterKey) > 0 + ) + } + } + } + expect(composed).toEqual(expected) + }), + { numRuns: 200 } + ) + }) + + it('same-ID fragments preserve every distinct path and relationship exactly once', () => { + fc.assert( + fc.property( + fc.array(pathArbitrary, { maxLength: 40 }), + fc.array(relationshipArbitrary, { maxLength: 60 }), + fc.nat(), + fc.boolean(), + (paths, relationships, splitSeed, reverse) => { + const duplicatedPaths = [...paths, ...paths.slice(0, paths.length % 5)] + const duplicatedRelationships = [ + ...relationships, + ...relationships.slice(0, relationships.length % 7), + ] + const pathSplit = duplicatedPaths.length === 0 ? 0 : splitSeed % duplicatedPaths.length + const relationshipSplit = + duplicatedRelationships.length === 0 ? 0 : splitSeed % duplicatedRelationships.length + const fragments = [ + hydrated({ + id: 1, + paths: duplicatedPaths.slice(0, pathSplit), + relationships: duplicatedRelationships.slice(0, relationshipSplit), + }), + hydrated({ + id: 1, + paths: duplicatedPaths.slice(pathSplit), + relationships: duplicatedRelationships.slice(relationshipSplit), + }), + ] + const result = mergeHydratedCatalogResources(reverse ? fragments.reverse() : fragments) + const value = [...result.values()][0] + expect(value.accessPaths).toEqual(canonicalAccessPathSeeds(duplicatedPaths)) + expect(value.relationships).toEqual( + canonicalAuthorizationRelationships(duplicatedRelationships) + ) + expect(mergeHydratedCatalogResources([...result.values()])).toEqual(result) + } + ), + { numRuns: 300 } + ) + }) + + it('preserves equivalent provenance and every behavior-distinct path', () => { + fc.assert( + fc.property( + behaviorArbitrary, + fc.string({ minLength: 1, maxLength: 24 }), + (behavior, scope) => { + const direct: AccessPathSeed = { kind: 'direct', grantId: 'direct', behavior } + const team: AccessPathSeed = { + kind: 'team', + grantId: 'team', + teamId: uuid(2), + currentRole: 'member', + behavior, + } + const distinctScope: AccessPathSeed = { + kind: 'direct', + grantId: 'direct', + behavior: { + ...behavior, + filesystemScope: { state: 'known', value: scope }, + }, + } + const value = [ + ...mergeHydratedCatalogResources([ + hydrated({ id: 1, paths: [direct, team, distinctScope], relationships: [] }), + ]).values(), + ][0] + expect(value.accessPaths).toHaveLength( + canonicalAccessPathSeeds([direct, team, distinctScope]).length + ) + expect(value.accessPaths).toEqual(canonicalAccessPathSeeds([direct, team, distinctScope])) + } + ), + { numRuns: 200 } + ) + }) + + it('never merges same-label resources with different immutable IDs', () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 1_000 }), + fc.integer({ min: 1_001, max: 2_000 }), + (left, right) => { + const result = mergeHydratedCatalogResources([ + hydrated({ id: left, displayName: 'Same name', paths: [], relationships: [] }), + hydrated({ id: right, displayName: 'Same name', paths: [], relationships: [] }), + ]) + expect(result).toHaveLength(2) + } + ), + { numRuns: 100 } + ) + }) + + it('rejects conflicting producer identity or authority revisions', () => { + const value = hydrated({ id: 1, paths: [], relationships: [] }) + expect(() => + mergeHydratedCatalogResources([ + value, + { ...value, authorizationResourceRevision: 'resource-2' }, + ]) + ).toThrow(AccessCatalogMergeError) + expect(() => + mergeCatalogCandidateStreams( + [ + { streamId: 'a', candidates: [{ ...candidate('user', 1), canonicalId: 'user:a' }] }, + { streamId: 'b', candidates: [{ ...candidate('user', 1), canonicalId: 'user:b' }] }, + ], + 2 + ) + ).toThrow(AccessCatalogMergeError) + }) + + it('recovers a complete deterministic union when a partial producer returns', () => { + fc.assert( + fc.property(candidateStreamsArbitrary, streams => { + const missing = streams.slice(1) + const partial = mergeCatalogCandidateStreams(missing, 10_000) + const recovered = mergeCatalogCandidateStreams(streams, 10_000) + expect( + partial.every(value => recovered.some(item => item.canonicalId === value.canonicalId)) + ).toBe(true) + expect(recovered).toEqual(expectedCandidateUnion(streams)) + }), + { numRuns: 200 } + ) + }) +}) + +function candidate(family: CatalogFamily, value: number): CatalogIdentityCandidate { + const logicalId = identifier(value) + return { + key: catalogKey(environmentId, family, logicalId), + canonicalId: `${family}:${logicalId}`, + validUntil: null, + } +} + +describe('execution-budget properties', () => { + it('rejects exactly when an arbitrary cumulative charge crosses its limit', () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 100 }), + fc.array(fc.integer({ min: 1, max: 20 }), { minLength: 1, maxLength: 30 }), + (capacity, charges) => { + const budget = AccessExecutionBudget.create('catalog', { + limits: { accessPaths: capacity }, + }) + let consumed = 0 + let rejected = false + for (const amount of charges) { + if (rejected) break + if (consumed + amount > capacity) { + expect(() => budget.charge({ kind: 'accessPaths', amount })).toThrow( + AccessBudgetExceededError + ) + rejected = true + } else { + budget.charge({ kind: 'accessPaths', amount }) + consumed += amount + } + } + expect(budget.remaining('accessPaths')).toBe(capacity - consumed) + budget.close() + } + ), + { numRuns: 200 } + ) + }) + + it('never publishes late producer results after arbitrary cancellation', async () => { + await fc.assert( + fc.asyncProperty(fc.integer({ min: 1, max: 8 }), async producerCount => { + const budget = AccessExecutionBudget.create('catalog', { + limits: { producerCalls: producerCount, producerConcurrency: Math.min(4, producerCount) }, + }) + const completions: Array<() => void> = [] + const published: number[] = [] + const work = Array.from({ length: producerCount }, (_, index) => + budget + .runProducer( + () => + new Promise(resolve => { + completions.push(() => resolve(index)) + }) + ) + .then(value => published.push(value)) + ) + await Promise.resolve() + budget.cancel() + for (const complete of completions) complete() + const results = await Promise.allSettled(work) + expect(results.every(result => result.status === 'rejected')).toBe(true) + expect( + results.every( + result => + result.status === 'rejected' && result.reason instanceof AccessExecutionCancelledError + ) + ).toBe(true) + expect(published).toEqual([]) + budget.close() + }), + { numRuns: 30 } + ) + }) +}) From 775f9acd58f0be993f2a0ae23a840a90e948a9f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 00:50:49 -0600 Subject: [PATCH 019/528] test(access): prove all catalog family round trips Exercise every frozen family through real PostgreSQL producers, the aggregate coordinator, live resolution, and exact operational reads. Align staging conflict keys and simple-resource relationship hydration with the producer contract exposed by the harness. --- .../services/access/catalogHydrationSql.ts | 2 +- .../services/access/operationalAccessIndex.ts | 7 +- ...gFamilies.realPostgres.integration.test.ts | 451 ++++++++++++++++++ 3 files changed, 457 insertions(+), 3 deletions(-) create mode 100644 control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts diff --git a/control-api/src/services/access/catalogHydrationSql.ts b/control-api/src/services/access/catalogHydrationSql.ts index e281397dce..91353bcd3b 100644 --- a/control-api/src/services/access/catalogHydrationSql.ts +++ b/control-api/src/services/access/catalogHydrationSql.ts @@ -127,7 +127,7 @@ export const SIMPLE_OPERATIONAL_HYDRATION_SQL = ` ORDER BY logical_id, kind, team_id NULLS FIRST, grant_id LIMIT $7 ), relationships AS MATERIALIZED ( - SELECT DISTINCT relationship.source_id AS logical_id, + SELECT DISTINCT relationship.source_id AS logical_id, relationship.source_id, relationship.relationship_type, relationship.target_type, relationship.target_id, relationship.relationship_instance_id, relationship.behavior_attributes, relationship.source_type, diff --git a/control-api/src/services/access/operationalAccessIndex.ts b/control-api/src/services/access/operationalAccessIndex.ts index a29b989200..0fcbaaecab 100644 --- a/control-api/src/services/access/operationalAccessIndex.ts +++ b/control-api/src/services/access/operationalAccessIndex.ts @@ -206,7 +206,9 @@ export class OperationalAccessIndex { (value->>'observed_generation')::bigint, (value->>'content_bytes')::bigint, NOW() FROM resource_rows - ON CONFLICT (environment_id, resource_type, logical_id) DO UPDATE + ON CONFLICT ( + environment_id, source_family, source_generation, resource_type, logical_id + ) DO UPDATE SET source_family = EXCLUDED.source_family, source_generation = EXCLUDED.source_generation, provider_uid = EXCLUDED.provider_uid, @@ -238,7 +240,8 @@ export class OperationalAccessIndex { (value->>'content_bytes')::bigint, NOW() FROM relationship_rows ON CONFLICT ( - environment_id, source_type, source_id, relationship_type, + environment_id, source_family, source_generation, + source_type, source_id, relationship_type, target_type, target_id, relationship_instance_id ) DO UPDATE SET behavior_attributes = EXCLUDED.behavior_attributes, diff --git a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts new file mode 100644 index 0000000000..d003421643 --- /dev/null +++ b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts @@ -0,0 +1,451 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { randomBytes, randomUUID } from 'node:crypto' +import { Pool, type PoolClient } from 'pg' +import { config } from '../src/config.js' +import { type DbClient, initDb } from '../src/db.js' +import type { K8sGateway } from '../src/k8s.js' +import { buildAccessCatalog } from '../src/services/access/accessCatalogCoordinator.js' +import { AccessExecutionBudget } from '../src/services/access/accessExecutionBudget.js' +import type { AccessCapability } from '../src/services/access/capabilityRegistry.js' +import { CATALOG_FAMILIES, type CatalogFamily } from '../src/services/access/catalogContracts.js' +import { resolveLiveAuthorization } from '../src/services/access/liveAuthorizationResolver.js' +import { OperationalAccessIndex } from '../src/services/access/operationalAccessIndex.js' +import { + OPERATIONAL_SOURCE_FAMILIES, + canonicalEnvironmentId, + projectOperationalObject, +} from '../src/services/access/operationalAccessProjection.js' +import { canonicalResourceIdentity } from '../src/services/access/resourceIdentity.js' +import type { ExternalSessionAuthorityContext } from '../src/services/auth/externalSessionAuthentication.js' + +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const describeRealPostgres = adminUrl ? describe : describe.skip +const runtimeRoles = [ + 'control_api_runtime', + 'trace_maintenance_runtime', + 'workflow_recipes_runtime', +] as const + +function databaseUrl(baseUrl: string, database: string): string { + const value = new URL(baseUrl) + value.pathname = `/${database}` + return value.toString() +} + +function quoteIdentifier(value: string): string { + return `"${value.replace(/"/g, '""')}"` +} + +function transaction(pool: Pool) { + return async (work: (db: DbClient) => Promise): Promise => { + const client = (await pool.connect()) as PoolClient + try { + await client.query('BEGIN') + const result = await work(client) + await client.query('COMMIT') + return result + } catch (error) { + await client.query('ROLLBACK') + throw error + } finally { + client.release() + } + } +} + +type OperationalFixture = Readonly<{ + plural: 'hosts' | 'contexts' | 'mcpservers' | 'workflowrecipes' | 'sharedfilesystems' + namespace: string + object: Readonly> +}> + +function fixture(input: { + plural: OperationalFixture['plural'] + namespace: string + name: string + uid: string + spec?: Readonly> +}): OperationalFixture { + return Object.freeze({ + plural: input.plural, + namespace: input.namespace, + object: Object.freeze({ + metadata: Object.freeze({ + name: input.name, + namespace: input.namespace, + uid: input.uid, + resourceVersion: '1', + generation: 1, + }), + spec: Object.freeze({ enabled: true, ...(input.spec ?? {}) }), + }), + }) +} + +describeRealPostgres('all aggregate catalog families on real producers', () => { + const database = `control_api_catalog_families_${randomBytes(6).toString('hex')}` + const connectionString = databaseUrl( + adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', + database + ) + const environmentId = canonicalEnvironmentId() + const userId = randomUUID() + const teamId = randomUUID() + const directRunId = randomUUID() + const teamRunId = randomUUID() + const directApprovalId = randomUUID() + const teamApprovalId = randomUUID() + const directNotificationId = randomUUID() + const teamNotificationId = randomUUID() + const gfsResourceId = randomUUID() + const session: ExternalSessionAuthorityContext = { + contract: 'v1', + userId, + tokenHash: randomBytes(32).toString('hex'), + issuedAt: Math.floor(Date.now() / 1_000), + } + const operationalFixtures: readonly OperationalFixture[] = [ + fixture({ + plural: 'hosts', + namespace: config.hostsNamespace, + name: 'catalog-host', + uid: 'catalog-host-uid', + spec: { + contextRef: 'catalog-context', + model: { provider: 'openai', name: 'test-model' }, + }, + }), + fixture({ + plural: 'contexts', + namespace: config.contextsNamespace, + name: 'catalog-context', + uid: 'catalog-context-uid', + spec: { + mcpServers: ['catalog-mcp'], + sharedFileSystems: [ + { name: 'catalog-files', mountPath: '/workspace/a' }, + { name: 'catalog-files', mountPath: '/workspace/b' }, + ], + }, + }), + fixture({ + plural: 'mcpservers', + namespace: config.mcpServersNamespace, + name: 'catalog-mcp', + uid: 'catalog-mcp-uid', + }), + fixture({ + plural: 'workflowrecipes', + namespace: config.sandboxNamespace, + name: 'catalog-recipe', + uid: 'catalog-recipe-uid', + spec: { + contextRef: 'catalog-context', + runtimeEgress: ['example.test'], + ui: { + workloadRef: 'catalog-app', + port: 8080, + title: 'Catalog App', + defaultPath: '/', + }, + }, + }), + fixture({ + plural: 'sharedfilesystems', + namespace: config.sharedFilesystemsNamespace, + name: 'catalog-files', + uid: 'catalog-files-uid', + }), + ] + const exactObjects = new Map( + operationalFixtures.map(value => { + const metadata = value.object.metadata as { name: string } + return [`${value.plural}:${value.namespace}/${metadata.name}`, value.object] + }) + ) + const gateway = { + getResourceExact: async (plural: string, name: string, namespace: string) => { + const value = exactObjects.get(`${plural}:${namespace}/${name}`) + if (!value) throw Object.assign(new Error('not found'), { httpStatus: 404 }) + return value + }, + } as unknown as Pick + let adminPool: Pool + let databasePool: Pool + + beforeAll(async () => { + adminPool = new Pool({ connectionString: adminUrl }) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) + databasePool = new Pool({ connectionString }) + await initDb({ connect: () => databasePool.connect() }) + + await databasePool.query( + `INSERT INTO users(id, email, name) VALUES ($1, $2, 'Catalog Harness User')`, + [userId, `${userId}@example.test`] + ) + await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, 'Catalog Harness Team')`, [ + teamId, + ]) + await databasePool.query( + `INSERT INTO team_members(team_id, user_id, role, status) + VALUES ($1, $2, 'admin', 'active')`, + [teamId, userId] + ) + await databasePool.query( + `INSERT INTO user_agents(user_id, agent_name) VALUES ($1, 'catalog-host')`, + [userId] + ) + await databasePool.query( + `INSERT INTO team_agents(team_id, agent_name) VALUES ($1, 'catalog-host')`, + [teamId] + ) + await databasePool.query( + `INSERT INTO user_contexts(user_id, context_id) VALUES ($1, 'catalog-context')`, + [userId] + ) + await databasePool.query( + `INSERT INTO team_contexts(team_id, context_id) VALUES ($1, 'catalog-context')`, + [teamId] + ) + await databasePool.query( + `INSERT INTO user_workflow_triggers(user_id, recipe_namespace, recipe_name) + VALUES ($1, $2, 'catalog-recipe')`, + [userId, config.sandboxNamespace] + ) + await databasePool.query( + `INSERT INTO team_workflow_triggers(team_id, recipe_namespace, recipe_name) + VALUES ($1, $2, 'catalog-recipe')`, + [teamId, config.sandboxNamespace] + ) + await databasePool.query( + `INSERT INTO workflow_runs( + run_id, recipe_namespace, recipe_name, phase, actor_type, actor_id, + team_id, trigger_source + ) VALUES + ($1, $5, 'catalog-recipe', 'Succeeded', 'user', $3, NULL, 'onDemand'), + ($2, $5, 'catalog-recipe', 'Succeeded', 'user', $3, $4, 'onDemand')`, + [directRunId, teamRunId, userId, teamId, config.sandboxNamespace] + ) + await databasePool.query( + `INSERT INTO workflow_approval_requests( + id, recipe_namespace, recipe_name, expires_at, status, target_user_id, + target_team_id, payload, idempotency_key, payload_hash + ) VALUES + ($1, $5, 'catalog-recipe', NOW() + INTERVAL '1 hour', 'pending', $3, NULL, + '{}'::jsonb, $6, 'hash'), + ($2, $5, 'catalog-recipe', NOW() + INTERVAL '1 hour', 'pending', NULL, $4, + '{}'::jsonb, $7, 'hash')`, + [ + directApprovalId, + teamApprovalId, + userId, + teamId, + config.sandboxNamespace, + `direct-${directApprovalId}`, + `team-${teamApprovalId}`, + ] + ) + await databasePool.query( + `INSERT INTO notification_deliveries( + id, event_type, dedupe_key, audience, payload, status, expires_at + ) VALUES + ($1, 'catalog.direct', $3, jsonb_build_object('userId', $5::text), + '{}'::jsonb, 'queued', NOW() + INTERVAL '1 hour'), + ($2, 'catalog.team', $4, jsonb_build_object('teamId', $6::text), + '{}'::jsonb, 'queued', NOW() + INTERVAL '1 hour')`, + [ + directNotificationId, + teamNotificationId, + `direct-${directNotificationId}`, + `team-${teamNotificationId}`, + userId, + teamId, + ] + ) + await databasePool.query( + `INSERT INTO gfs_resources(resource_id, drive, name, kind) + VALUES ($1, 'catalog-drive', '/', 'directory')`, + [gfsResourceId] + ) + await databasePool.query( + `INSERT INTO gfs_grants(drive, resource_id, subject_type, subject_id, permissions) + VALUES + ('catalog-drive', $1, 'user', $2::text, ARRAY['read']::text[]), + ('catalog-drive', $1, 'team', $3::text, ARRAY['read']::text[])`, + [gfsResourceId, userId, teamId] + ) + + const index = new OperationalAccessIndex(databasePool, transaction(databasePool)) + for (const sourceFamily of OPERATIONAL_SOURCE_FAMILIES) { + const fixtureForFamily = operationalFixtures.find(value => { + const expected = { + host: 'hosts', + context: 'contexts', + mcp_server: 'mcpservers', + workflow_recipe: 'workflowrecipes', + shared_filesystem: 'sharedfilesystems', + } as const + return value.plural === expected[sourceFamily] + })! + const projection = projectOperationalObject({ + environmentId, + plural: fixtureForFamily.plural, + namespace: fixtureForFamily.namespace, + object: fixtureForFamily.object, + behaviorFingerprintKey: config.sessionJwtPrivateKey, + relationshipNamespaces: { + context: config.contextsNamespace, + mcpServer: config.mcpServersNamespace, + sharedFilesystem: config.sharedFilesystemsNamespace, + }, + }) + const budget = AccessExecutionBudget.create('catalog') + try { + const generation = await index.beginRelist({ environmentId, sourceFamily, budget }) + await index.stageRelistPage({ + environmentId, + sourceFamily, + stagingGeneration: generation, + projections: [projection], + budget, + }) + await index.promoteRelist({ + environmentId, + sourceFamily, + stagingGeneration: generation, + resourceVersion: '1', + budget, + }) + } finally { + budget.close() + } + } + }) + + afterAll(async () => { + await databasePool?.end() + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + await adminPool.end() + } + }) + + const expectedItemCounts: Readonly> = Object.freeze({ + user: 1, + team: 1, + host: 1, + context: 1, + mcp_server: 1, + workflow_recipe: 1, + workflow_run: 2, + workflow_approval: 2, + notification: 2, + gfs_resource: 1, + shared_filesystem: 1, + sandbox_app: 1, + }) + const capability: Readonly> = Object.freeze({ + user: 'user.profile.read', + team: 'team.read', + host: 'host.read', + context: 'context.read', + mcp_server: 'mcp_server.read', + workflow_recipe: 'workflow.read', + workflow_run: 'workflow.read', + workflow_approval: 'workflow.approval.decide', + notification: 'notification.read', + gfs_resource: 'gfs.read', + shared_filesystem: 'shared_filesystem.read', + sandbox_app: 'sandbox_app.read', + }) + const expectedKinds: Readonly> = + Object.freeze({ + user: ['direct'], + team: ['team'], + host: ['direct', 'team'], + context: ['direct', 'team'], + mcp_server: ['direct', 'team'], + workflow_recipe: ['direct', 'team'], + workflow_run: ['direct', 'team'], + workflow_approval: ['direct', 'team'], + notification: ['direct', 'team'], + gfs_resource: ['direct', 'team'], + shared_filesystem: ['direct', 'team'], + sandbox_app: ['direct', 'team'], + }) + + for (const family of CATALOG_FAMILIES) { + it(`${family} producer paths round-trip through catalog and live resolution`, async () => { + const catalog = await buildAccessCatalog( + { session, families: [family], limit: 100 }, + { transaction: transaction(databasePool) } + ) + expect(catalog.complete).toBe(true) + expect(catalog.partialErrors).toEqual([]) + expect(catalog.nextCursor).toBeNull() + expect(catalog.items).toHaveLength(expectedItemCounts[family]) + expect( + [...new Set(catalog.items.flatMap(item => item.accessPaths.map(path => path.kind)))].sort() + ).toEqual([...expectedKinds[family]].sort()) + + for (const item of catalog.items) { + expect(item.resource.type).toBe(family) + expect(item.accessPaths.length).toBeGreaterThan(0) + for (const path of item.accessPaths) { + const resolved = await resolveLiveAuthorization( + { + session, + requiredCapability: capability[family], + resource: canonicalResourceIdentity(item.resource), + requestedAccessPathId: path.accessPathId, + ...(family === 'workflow_approval' + ? { + operationTarget: { + approvalId: item.resource.logicalId, + decision: 'approve', + }, + } + : {}), + }, + { + transaction: transaction(databasePool), + gateway, + } + ) + expect(resolved).toEqual( + expect.objectContaining({ + status: 'allowed', + selectedPath: expect.objectContaining({ id: path.accessPathId }), + }) + ) + } + } + }) + } + + it('preserves both direct/team provenance across duplicate filesystem mounts', async () => { + const catalog = await buildAccessCatalog( + { session, families: ['shared_filesystem'], limit: 100 }, + { transaction: transaction(databasePool) } + ) + const item = catalog.items[0] + expect( + item.relationships.filter(value => value.type === 'mounts_shared_filesystem') + ).toHaveLength(2) + expect(item.accessPaths).toHaveLength(4) + expect(new Set(item.accessPaths.map(path => path.accessPathId)).size).toBe(4) + expect( + new Set( + item.accessPaths.map(path => JSON.stringify(path.behaviorDescriptors.filesystemScope)) + ).size + ).toBe(2) + }) +}) From 1cb451c19276142aef1ed76faae228d7c6bcd213 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 00:57:09 -0600 Subject: [PATCH 020/528] fix(access): cancel database work with request budgets Route budgeted authority, catalog, and operational-index queries through one abort-aware PostgreSQL boundary. Cancel the active backend on request abort and classify statement timeouts as capacity failures. Class-check: budget.runProducer around db.query -> 5 wrappers -> centralized all five. Repro-test: accessDatabaseCancellation real PostgreSQL test failed at dacca74e after 2.01s. --- .../services/access/accessAuthorityStore.ts | 11 +-- .../access/accessAuthorityTransaction.ts | 23 +++-- .../services/access/accessDatabaseQuery.ts | 99 +++++++++++++++++++ .../services/access/catalogProducerSupport.ts | 11 +-- .../services/access/operationalAccessIndex.ts | 11 +-- .../access/operationalAccessReader.ts | 11 +-- ...cellation.realPostgres.integration.test.ts | 37 +++++++ 7 files changed, 159 insertions(+), 44 deletions(-) create mode 100644 control-api/src/services/access/accessDatabaseQuery.ts create mode 100644 control-api/test/accessDatabaseCancellation.realPostgres.integration.test.ts diff --git a/control-api/src/services/access/accessAuthorityStore.ts b/control-api/src/services/access/accessAuthorityStore.ts index 7e33975561..5a9b402996 100644 --- a/control-api/src/services/access/accessAuthorityStore.ts +++ b/control-api/src/services/access/accessAuthorityStore.ts @@ -1,6 +1,7 @@ import type { DbClient } from '../../db.js' import type { TeamRole } from '../../profileTypes.js' import type { ExternalSessionAuthorityContext } from '../auth/externalSessionAuthentication.js' +import { runAccessDatabaseQuery } from './accessDatabaseQuery.js' import type { AccessExecutionBudget } from './accessExecutionBudget.js' import { type AccessPathBehavior, @@ -53,21 +54,13 @@ export type ResourceAuthorityResult = Readonly<{ validUntil: Date | null }> -function rowCountCharge(budget: AccessExecutionBudget, rows: readonly unknown[]): void { - if (rows.length > 0) budget.charge({ kind: 'dbRowsReturned', amount: rows.length }) -} - async function query( db: Pick, budget: AccessExecutionBudget, text: string, values: unknown[] ) { - return budget.runProducer(async () => { - const result = await db.query(text, values) - rowCountCharge(budget, result.rows) - return result - }) + return runAccessDatabaseQuery(db, budget, text, values) } export function parseAuthorizationMemberships(value: unknown): AuthorizationMembershipRevision[] { diff --git a/control-api/src/services/access/accessAuthorityTransaction.ts b/control-api/src/services/access/accessAuthorityTransaction.ts index 3c986bc9f9..beb67932a8 100644 --- a/control-api/src/services/access/accessAuthorityTransaction.ts +++ b/control-api/src/services/access/accessAuthorityTransaction.ts @@ -1,16 +1,23 @@ import type { DbClient } from '../../db.js' +import { runAccessDatabaseQuery } from './accessDatabaseQuery.js' import type { AccessExecutionBudget } from './accessExecutionBudget.js' export async function configureAccessAuthorityTransaction( db: Pick, budget: AccessExecutionBudget ): Promise { - await budget.runProducer(async () => { - budget.assertActive() - await db.query('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY') - await db.query(`SELECT set_config('statement_timeout', $1, true)`, [ - `${budget.statementTimeoutMs()}ms`, - ]) - budget.assertActive() - }) + await runAccessDatabaseQuery( + db, + budget, + 'SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY', + [], + { chargeRows: false } + ) + await runAccessDatabaseQuery( + db, + budget, + `SELECT set_config('statement_timeout', $1, true)`, + [`${budget.statementTimeoutMs()}ms`], + { chargeRows: false } + ) } diff --git a/control-api/src/services/access/accessDatabaseQuery.ts b/control-api/src/services/access/accessDatabaseQuery.ts new file mode 100644 index 0000000000..4876bbea26 --- /dev/null +++ b/control-api/src/services/access/accessDatabaseQuery.ts @@ -0,0 +1,99 @@ +import { Client, type ClientConfig } from 'pg' +import type { DbClient } from '../../db.js' +import { AccessBudgetExceededError, type AccessExecutionBudget } from './accessExecutionBudget.js' + +type PostgresConnectionParameters = Readonly<{ + user?: unknown + database?: unknown + port?: unknown + host?: unknown + password?: unknown + ssl?: unknown +}> + +type PostgresCancellationTarget = Pick & + Readonly<{ + processID?: unknown + connectionParameters?: PostgresConnectionParameters + }> + +function boundedPort(value: unknown): number | undefined { + const port = Number(value) + return Number.isSafeInteger(port) && port > 0 && port <= 65_535 ? port : undefined +} + +function connectionText(value: unknown): string | undefined { + return typeof value === 'string' && value ? value : undefined +} + +async function cancelPostgresBackend(db: PostgresCancellationTarget): Promise { + const processId = Number(db.processID) + const parameters = db.connectionParameters + if (!Number.isSafeInteger(processId) || processId <= 0 || !parameters) return + const client = new Client({ + user: connectionText(parameters.user), + database: connectionText(parameters.database), + port: boundedPort(parameters.port), + host: connectionText(parameters.host), + password: parameters.password as ClientConfig['password'], + ssl: parameters.ssl as ClientConfig['ssl'], + connectionTimeoutMillis: 1_000, + }) + try { + await client.connect() + await client.query('SELECT pg_cancel_backend($1)', [processId]) + } finally { + await client.end().catch(() => undefined) + } +} + +function isStatementTimeout(error: unknown): boolean { + if (!error || typeof error !== 'object') return false + const value = error as { code?: unknown; message?: unknown } + return ( + value.code === '57014' && + typeof value.message === 'string' && + value.message.toLowerCase().includes('statement timeout') + ) +} + +/** + * Runs one PostgreSQL statement under the request budget. Real PoolClients are + * cancelled through a separate connection when the shared AbortSignal fires; + * test doubles without backend identity still receive late-result rejection. + */ +export async function runAccessDatabaseQuery( + db: Pick, + budget: AccessExecutionBudget, + text: string, + values: unknown[] = [], + options: { chargeRows?: boolean } = {} +) { + return budget.runProducer(async signal => { + budget.assertActive() + let cancellation: Promise | undefined + const onAbort = () => { + cancellation ??= cancelPostgresBackend(db as PostgresCancellationTarget).catch( + () => undefined + ) + } + signal.addEventListener('abort', onAbort, { once: true }) + try { + const result = await db.query(text, values) + budget.assertActive() + if (options.chargeRows !== false && result.rows.length > 0) { + budget.charge({ kind: 'dbRowsReturned', amount: result.rows.length }) + } + return result + } catch (error) { + if (signal.aborted) budget.assertActive() + if (isStatementTimeout(error)) { + throw new AccessBudgetExceededError('deadline', true) + } + throw error + } finally { + signal.removeEventListener('abort', onAbort) + void cancellation + } + }) +} diff --git a/control-api/src/services/access/catalogProducerSupport.ts b/control-api/src/services/access/catalogProducerSupport.ts index 0fa52f955f..d88eb3620e 100644 --- a/control-api/src/services/access/catalogProducerSupport.ts +++ b/control-api/src/services/access/catalogProducerSupport.ts @@ -1,4 +1,5 @@ import type { DbClient } from '../../db.js' +import { runAccessDatabaseQuery } from './accessDatabaseQuery.js' import type { AccessExecutionBudget } from './accessExecutionBudget.js' import { revisionOfValues } from './authorizationRevision.js' import { @@ -27,15 +28,7 @@ export async function catalogQuery( text: string, values: unknown[] ) { - return budget.runProducer(async () => { - budget.assertActive() - const result = await db.query(text, values) - if (result.rows.length > 0) { - budget.charge({ kind: 'dbRowsReturned', amount: result.rows.length }) - } - budget.assertActive() - return result - }) + return runAccessDatabaseQuery(db, budget, text, values) } export type BoundedKeyArm = Readonly<{ diff --git a/control-api/src/services/access/operationalAccessIndex.ts b/control-api/src/services/access/operationalAccessIndex.ts index 0fcbaaecab..1fb03dc91d 100644 --- a/control-api/src/services/access/operationalAccessIndex.ts +++ b/control-api/src/services/access/operationalAccessIndex.ts @@ -1,4 +1,5 @@ import { type DbClient, pool, withTransaction } from '../../db.js' +import { runAccessDatabaseQuery } from './accessDatabaseQuery.js' import type { AccessExecutionBudget } from './accessExecutionBudget.js' import type { OperationalObjectProjection, @@ -104,15 +105,7 @@ export class OperationalAccessIndex { text: string, values: unknown[] = [] ) { - return budget.runProducer(async () => { - budget.assertActive() - const result = await db.query(text, values) - budget.assertActive() - if (result.rows.length > 0) { - budget.charge({ kind: 'dbRowsReturned', amount: result.rows.length }) - } - return result - }) + return runAccessDatabaseQuery(db, budget, text, values) } private async configureTransaction(db: DbClient, budget: AccessExecutionBudget): Promise { diff --git a/control-api/src/services/access/operationalAccessReader.ts b/control-api/src/services/access/operationalAccessReader.ts index 9de934df1c..76e26f4434 100644 --- a/control-api/src/services/access/operationalAccessReader.ts +++ b/control-api/src/services/access/operationalAccessReader.ts @@ -1,4 +1,5 @@ import type { DbClient } from '../../db.js' +import { runAccessDatabaseQuery } from './accessDatabaseQuery.js' import type { AccessExecutionBudget } from './accessExecutionBudget.js' import { revisionOfValues } from './authorizationRevision.js' import type { @@ -131,15 +132,7 @@ async function budgetedQuery( text: string, values: unknown[] ) { - return budget.runProducer(async () => { - budget.assertActive() - const result = await db.query(text, values) - if (result.rows.length > 0) { - budget.charge({ kind: 'dbRowsReturned', amount: result.rows.length }) - } - budget.assertActive() - return result - }) + return runAccessDatabaseQuery(db, budget, text, values) } export async function loadOperationalResourceGraph(input: { diff --git a/control-api/test/accessDatabaseCancellation.realPostgres.integration.test.ts b/control-api/test/accessDatabaseCancellation.realPostgres.integration.test.ts new file mode 100644 index 0000000000..3183571a62 --- /dev/null +++ b/control-api/test/accessDatabaseCancellation.realPostgres.integration.test.ts @@ -0,0 +1,37 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { Pool, type PoolClient } from 'pg' +import { + AccessExecutionBudget, + AccessExecutionCancelledError, +} from '../src/services/access/accessExecutionBudget.js' +import { catalogQuery } from '../src/services/access/catalogProducerSupport.js' + +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const describeRealPostgres = adminUrl ? describe : describe.skip + +describeRealPostgres('access database cancellation on real PostgreSQL', () => { + let pool: Pool + + beforeAll(() => { + pool = new Pool({ connectionString: adminUrl }) + }) + + afterAll(async () => { + await pool?.end() + }) + + it('cancels an active statement when its request budget is cancelled', async () => { + const client = (await pool.connect()) as PoolClient + const budget = AccessExecutionBudget.create('catalog') + const startedAt = performance.now() + try { + const query = catalogQuery(client, budget, 'SELECT pg_sleep(2)', []) + setTimeout(() => budget.cancel(), 50) + await expect(query).rejects.toBeInstanceOf(AccessExecutionCancelledError) + expect(performance.now() - startedAt).toBeLessThan(1_000) + } finally { + budget.close() + client.release() + } + }) +}) From 53d1111412d94a1fdfaa131e061493a2c87fb133 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 01:11:46 -0600 Subject: [PATCH 021/528] perf(access): bound catalog producer query plans Use native UUID keysets for user, team, workflow, notification, and GFS producers. Validate direct-only, typical multi-team, and high-cardinality plans with real PostgreSQL. Class-check: UUID catalog cursors -> 13 text comparisons -> native UUID keysets Repro-test: accessCatalogPerformance.realPostgres.integration.test.ts fails at 0b5b4afd; the notification arm sorts 1,513 rows to return 101. --- .../src/services/access/catalogProducerSql.ts | 115 ++- ...rformance.realPostgres.integration.test.ts | 666 ++++++++++++++++++ 2 files changed, 748 insertions(+), 33 deletions(-) create mode 100644 control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts diff --git a/control-api/src/services/access/catalogProducerSql.ts b/control-api/src/services/access/catalogProducerSql.ts index 1d18350e7a..77602566ba 100644 --- a/control-api/src/services/access/catalogProducerSql.ts +++ b/control-api/src/services/access/catalogProducerSql.ts @@ -3,14 +3,22 @@ import { boundedKeyUnionSql } from './catalogProducerSupport.js' export const CATALOG_KEY_SQL: Readonly> = Object.freeze({ user: boundedKeyUnionSql([ - `SELECT u.id::text AS logical_id + { + sql: `SELECT u.id::text AS logical_id, u.id AS cursor_id FROM users u - WHERE u.id = $1 AND u.id::text > $2`, + WHERE u.id = $1 + AND u.id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000')`, + orderBy: 'cursor_id', + }, ]), team: boundedKeyUnionSql([ - `SELECT tm.team_id::text AS logical_id + { + sql: `SELECT tm.team_id::text AS logical_id, tm.team_id AS cursor_id FROM team_members tm - WHERE tm.user_id = $1 AND tm.status = 'active' AND tm.team_id::text > $2`, + WHERE tm.user_id = $1 AND tm.status = 'active' + AND tm.team_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000')`, + orderBy: 'cursor_id', + }, ]), host: boundedKeyUnionSql([ { @@ -176,7 +184,8 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f }, ]), workflow_run: boundedKeyUnionSql([ - `SELECT wr.run_id::text AS logical_id + { + sql: `SELECT wr.run_id::text AS logical_id, wr.run_id AS cursor_id FROM workflow_runs wr JOIN user_workflow_triggers uwt ON uwt.user_id = $1 @@ -184,8 +193,11 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND uwt.recipe_name = wr.recipe_name WHERE wr.actor_type = 'user' AND wr.actor_id = $1 AND wr.team_id IS NULL AND wr.usage_team_id IS NULL - AND wr.run_id::text > $2`, - `SELECT wr.run_id::text AS logical_id + AND wr.run_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000')`, + orderBy: 'cursor_id', + }, + { + sql: `SELECT wr.run_id::text AS logical_id, wr.run_id AS cursor_id FROM workflow_runs wr JOIN team_members tm ON tm.team_id = wr.team_id AND tm.user_id = $1 AND tm.status = 'active' @@ -193,8 +205,11 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f ON twt.team_id = tm.team_id AND twt.recipe_namespace = wr.recipe_namespace AND twt.recipe_name = wr.recipe_name - WHERE wr.run_id::text > $2`, - `SELECT wr.run_id::text AS logical_id + WHERE wr.run_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000')`, + orderBy: 'cursor_id', + }, + { + sql: `SELECT wr.run_id::text AS logical_id, wr.run_id AS cursor_id FROM workflow_runs wr JOIN team_members tm ON tm.team_id::text = wr.usage_team_id @@ -203,22 +218,29 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f ON twt.team_id = tm.team_id AND twt.recipe_namespace = wr.recipe_namespace AND twt.recipe_name = wr.recipe_name - WHERE wr.usage_team_id IS NOT NULL AND wr.run_id::text > $2`, + WHERE wr.usage_team_id IS NOT NULL + AND wr.run_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000')`, + orderBy: 'cursor_id', + }, ]), workflow_approval: boundedKeyUnionSql([ { - sql: `SELECT war.id::text AS logical_id, war.expires_at AS valid_until + sql: `SELECT war.id::text AS logical_id, war.id AS cursor_id, + war.expires_at AS valid_until FROM workflow_approval_requests war JOIN user_workflow_triggers uwt ON uwt.user_id = $1 AND uwt.recipe_namespace = war.recipe_namespace AND uwt.recipe_name = war.recipe_name WHERE war.target_user_id = $1 AND war.status = 'pending' - AND war.expires_at > NOW() AND war.id::text > $2`, + AND war.expires_at > NOW() + AND war.id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000')`, + orderBy: 'cursor_id', hasValidUntil: true, }, { - sql: `SELECT war.id::text AS logical_id, war.expires_at AS valid_until + sql: `SELECT war.id::text AS logical_id, war.id AS cursor_id, + war.expires_at AS valid_until FROM workflow_approval_requests war JOIN team_members tm ON tm.team_id = war.target_team_id AND tm.user_id = $1 AND tm.status = 'active' @@ -226,58 +248,85 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f ON twt.team_id = tm.team_id AND twt.recipe_namespace = war.recipe_namespace AND twt.recipe_name = war.recipe_name - WHERE war.status = 'pending' AND war.expires_at > NOW() AND war.id::text > $2`, + WHERE war.status = 'pending' AND war.expires_at > NOW() + AND war.id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000')`, + orderBy: 'cursor_id', hasValidUntil: true, }, ]), notification: boundedKeyUnionSql([ { - sql: `SELECT nd.id::text AS logical_id, nd.expires_at AS valid_until + sql: `SELECT nd.id::text AS logical_id, nd.id AS cursor_id, + nd.expires_at AS valid_until FROM notification_deliveries nd WHERE nd.audience->>'userId' = $1::text AND (nd.expires_at IS NULL OR nd.expires_at > NOW()) - AND nd.id::text > $2`, + AND nd.id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000')`, + orderBy: 'cursor_id', hasValidUntil: true, }, { - sql: `SELECT nd.id::text AS logical_id, nd.expires_at AS valid_until + sql: `SELECT nd.id::text AS logical_id, nd.id AS cursor_id, + nd.expires_at AS valid_until FROM notification_deliveries nd JOIN team_members tm ON tm.team_id::text = nd.audience->>'teamId' AND tm.user_id = $1::uuid AND tm.status = 'active' - WHERE (nd.expires_at IS NULL OR nd.expires_at > NOW()) AND nd.id::text > $2`, + WHERE (nd.expires_at IS NULL OR nd.expires_at > NOW()) + AND nd.id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000')`, + orderBy: 'cursor_id', hasValidUntil: true, }, ]), gfs_resource: boundedKeyUnionSql([ - `SELECT g.resource_id::text AS logical_id + { + sql: `SELECT g.resource_id::text AS logical_id, g.resource_id AS cursor_id FROM gfs_grants g JOIN gfs_resources resource ON resource.resource_id = g.resource_id AND resource.deleted_at IS NULL WHERE g.subject_type = 'user' AND g.subject_id = $1::text - AND g.resource_id::text > $2`, - `SELECT g.resource_id::text AS logical_id + AND g.resource_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000')`, + orderBy: 'cursor_id', + }, + { + sql: `SELECT g.resource_id::text AS logical_id, g.resource_id AS cursor_id FROM gfs_grants g - JOIN team_members tm - ON tm.team_id::text = g.subject_id - AND tm.user_id = $1::uuid AND tm.status = 'active' - JOIN gfs_resources resource + JOIN gfs_resources resource ON resource.resource_id = g.resource_id AND resource.deleted_at IS NULL - WHERE g.subject_type = 'team' AND g.resource_id::text > $2`, - `SELECT share.resource_id::text AS logical_id + WHERE g.subject_type = 'team' + AND g.resource_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000') + AND EXISTS ( + SELECT 1 + FROM team_members tm + WHERE tm.team_id::text = g.subject_id + AND tm.user_id = $1::uuid AND tm.status = 'active' + )`, + orderBy: 'cursor_id', + }, + { + sql: `SELECT share.resource_id::text AS logical_id, share.resource_id AS cursor_id FROM gfs_shares share JOIN gfs_resources resource ON resource.resource_id = share.resource_id AND resource.deleted_at IS NULL WHERE share.subject_type = 'user' AND share.subject_id = $1::text - AND share.resource_id::text > $2`, - `SELECT share.resource_id::text AS logical_id + AND share.resource_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000')`, + orderBy: 'cursor_id', + }, + { + sql: `SELECT share.resource_id::text AS logical_id, share.resource_id AS cursor_id FROM gfs_shares share - JOIN team_members tm - ON tm.team_id::text = share.subject_id - AND tm.user_id = $1::uuid AND tm.status = 'active' JOIN gfs_resources resource ON resource.resource_id = share.resource_id AND resource.deleted_at IS NULL - WHERE share.subject_type = 'team' AND share.resource_id::text > $2`, + WHERE share.subject_type = 'team' + AND share.resource_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000') + AND EXISTS ( + SELECT 1 + FROM team_members tm + WHERE tm.team_id::text = share.subject_id + AND tm.user_id = $1::uuid AND tm.status = 'active' + )`, + orderBy: 'cursor_id', + }, ]), shared_filesystem: boundedKeyUnionSql([ `SELECT edge.target_id AS logical_id diff --git a/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts b/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts new file mode 100644 index 0000000000..ad923696f3 --- /dev/null +++ b/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts @@ -0,0 +1,666 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { randomBytes, randomUUID } from 'node:crypto' +import { Pool, type PoolClient } from 'pg' +import { config } from '../src/config.js' +import { type DbClient, initDb } from '../src/db.js' +import { buildAccessCatalog } from '../src/services/access/accessCatalogCoordinator.js' +import { AccessCatalogCursorError } from '../src/services/access/accessCatalogCursor.js' +import { CATALOG_FAMILIES, type CatalogFamily } from '../src/services/access/catalogContracts.js' +import { CATALOG_KEY_SQL } from '../src/services/access/catalogProducerSql.js' +import { + OPERATIONAL_SOURCE_FAMILIES, + canonicalEnvironmentId, +} from '../src/services/access/operationalAccessProjection.js' +import type { ExternalSessionAuthorityContext } from '../src/services/auth/externalSessionAuthentication.js' + +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const describeRealPostgres = adminUrl ? describe : describe.skip +const runtimeRoles = [ + 'control_api_runtime', + 'trace_maintenance_runtime', + 'workflow_recipes_runtime', +] as const +const DIRECT_ONLY_RESOURCES = 20 +const TYPICAL_TEAMS = 3 +const TYPICAL_RESOURCES_PER_TEAM = 10 +const HIGH_DIRECT_RESOURCES = 1_500 +const HIGH_TEAMS = 250 +const HIGH_RESOURCES_PER_TEAM = 6 + +const armCounts: Readonly> = Object.freeze({ + user: 1, + team: 1, + host: 2, + context: 2, + mcp_server: 4, + workflow_recipe: 2, + workflow_run: 3, + workflow_approval: 2, + notification: 2, + gfs_resource: 4, + shared_filesystem: 2, + sandbox_app: 2, +}) + +function databaseUrl(baseUrl: string, database: string): string { + const value = new URL(baseUrl) + value.pathname = `/${database}` + return value.toString() +} + +function quoteIdentifier(value: string): string { + return `"${value.replace(/"/g, '""')}"` +} + +type JsonPlanNode = Record & { Plans?: JsonPlanNode[] } + +function planNodes(root: JsonPlanNode): JsonPlanNode[] { + const values: JsonPlanNode[] = [] + const pending = [root] + while (pending.length > 0) { + const current = pending.pop()! + values.push(current) + pending.push(...(current.Plans ?? [])) + } + return values +} + +function numberField(value: JsonPlanNode, key: string): number { + const number = Number(value[key] ?? 0) + return Number.isFinite(number) ? number : 0 +} + +function queryPlan(row: unknown): Record & { Plan: JsonPlanNode } { + const value = (row as Record)['QUERY PLAN'] + if (!Array.isArray(value) || !value[0] || typeof value[0] !== 'object') { + throw new Error('explain_plan_invalid') + } + return value[0] as Record & { Plan: JsonPlanNode } +} + +describeRealPostgres('aggregate catalog plans on real PostgreSQL', () => { + const database = `control_api_catalog_performance_${randomBytes(6).toString('hex')}` + const connectionString = databaseUrl( + adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', + database + ) + const environmentId = canonicalEnvironmentId() + const userId = randomUUID() + const allTeamIds = Array.from({ length: HIGH_TEAMS }, () => randomUUID()) + const session: ExternalSessionAuthorityContext = { + contract: 'v1', + userId, + tokenHash: randomBytes(32).toString('hex'), + issuedAt: Math.floor(Date.now() / 1_000), + } + let adminPool: Pool + let databasePool: Pool + + beforeAll(async () => { + adminPool = new Pool({ connectionString: adminUrl }) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) + databasePool = new Pool({ connectionString }) + await initDb({ connect: () => databasePool.connect() }) + await databasePool.query( + `INSERT INTO users(id, email, name) VALUES ($1, $2, 'Catalog Plan User')`, + [userId, `${userId}@example.test`] + ) + await databasePool.query( + `INSERT INTO operational_catalog_source_state( + environment_id, source_family, generation, resource_version, status, last_success_at + ) + SELECT $1, source_family, 1, '1', 'current', NOW() + FROM UNNEST($2::text[]) source_family`, + [environmentId, OPERATIONAL_SOURCE_FAMILIES] + ) + }) + + afterAll(async () => { + await databasePool?.end() + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + await adminPool.end() + } + }) + + async function seedDirect(first: number, last: number): Promise { + if (first > last) return + const seriesValues = [userId, first, last] + const values = [...seriesValues, environmentId] + await databasePool.query( + `WITH requester AS (SELECT $1::uuid AS user_id), ids AS ( + SELECT value, LPAD(value::text, 5, '0') AS suffix + FROM generate_series($2::int, $3::int) value + ) + INSERT INTO user_agents(user_id, agent_name) + SELECT $1, 'direct-host-' || suffix FROM ids`, + seriesValues + ) + await databasePool.query( + `WITH requester AS (SELECT $1::uuid AS user_id), ids AS ( + SELECT value, LPAD(value::text, 5, '0') AS suffix + FROM generate_series($2::int, $3::int) value + ) + INSERT INTO user_contexts(user_id, context_id) + SELECT $1, 'direct-context-' || suffix FROM ids`, + seriesValues + ) + await databasePool.query( + `WITH ids AS ( + SELECT value, LPAD(value::text, 5, '0') AS suffix + FROM generate_series($2::int, $3::int) value + ) + INSERT INTO user_workflow_triggers(user_id, recipe_namespace, recipe_name) + SELECT $1, $4, 'direct-recipe-' || suffix FROM ids`, + [...seriesValues, config.sandboxNamespace] + ) + await databasePool.query( + `WITH requester AS (SELECT $1::uuid AS user_id), ids AS ( + SELECT value, LPAD(value::text, 5, '0') AS suffix + FROM generate_series($2::int, $3::int) value + ), resources(resource_type, logical_id, source_family, provider_uid, display_name) AS ( + SELECT 'host', $5 || '/direct-host-' || suffix, 'host', + 'direct-host-uid-' || suffix, 'Direct Host ' || suffix FROM ids + UNION ALL + SELECT 'context', $6 || '/direct-context-' || suffix, 'context', + 'direct-context-uid-' || suffix, 'Direct Context ' || suffix FROM ids + UNION ALL + SELECT 'mcp_server', $7 || '/direct-mcp-' || suffix, 'mcp_server', + 'direct-mcp-uid-' || suffix, 'Direct MCP ' || suffix FROM ids + UNION ALL + SELECT 'shared_filesystem', $8 || '/direct-files-' || suffix, 'shared_filesystem', + 'direct-files-uid-' || suffix, 'Direct Files ' || suffix FROM ids + UNION ALL + SELECT 'workflow_recipe', $9 || '/direct-recipe-' || suffix, 'workflow_recipe', + 'direct-recipe-uid-' || suffix, 'Direct Recipe ' || suffix FROM ids + UNION ALL + SELECT 'sandbox_app', $9 || '/direct-recipe-' || suffix, 'workflow_recipe', + 'direct-recipe-uid-' || suffix || ':ui', 'Direct App ' || suffix FROM ids + ) + INSERT INTO operational_resource_index( + environment_id, resource_type, logical_id, source_family, source_generation, + provider_uid, provider_resource_version, display_name, enabled, content_bytes + ) + SELECT $4, resource_type, logical_id, source_family, 1, + provider_uid, '1', display_name, TRUE, 128 + FROM resources`, + [ + userId, + first, + last, + environmentId, + config.hostsNamespace, + config.contextsNamespace, + config.mcpServersNamespace, + config.sharedFilesystemsNamespace, + config.sandboxNamespace, + ] + ) + await databasePool.query( + `WITH requester AS (SELECT $1::uuid AS user_id), ids AS ( + SELECT value, LPAD(value::text, 5, '0') AS suffix + FROM generate_series($2::int, $3::int) value + ), relationships( + source_type, source_id, relationship_type, target_type, target_id, + relationship_instance_id, behavior_attributes, source_family, + source_provider_uid + ) AS ( + SELECT 'host', $5 || '/direct-host-' || suffix, 'uses_context', 'context', + $6 || '/direct-context-' || suffix, 'direct-host-context-' || suffix, + '{}'::jsonb, 'host', 'direct-host-uid-' || suffix FROM ids + UNION ALL + SELECT 'context', $6 || '/direct-context-' || suffix, 'includes_mcp_server', + 'mcp_server', $7 || '/direct-mcp-' || suffix, 'direct-context-mcp-' || suffix, + '{}'::jsonb, 'context', 'direct-context-uid-' || suffix FROM ids + UNION ALL + SELECT 'context', $6 || '/direct-context-' || suffix, 'mounts_shared_filesystem', + 'shared_filesystem', $8 || '/direct-files-' || suffix, + 'direct-context-files-' || suffix, + jsonb_build_object('mountPath', '/workspace/' || suffix, 'readOnly', TRUE), + 'context', 'direct-context-uid-' || suffix FROM ids + UNION ALL + SELECT 'workflow_recipe', $9 || '/direct-recipe-' || suffix, + 'exposes_sandbox_app', 'sandbox_app', $9 || '/direct-recipe-' || suffix, + 'direct-recipe-app-' || suffix, + jsonb_build_object('workloadRef', 'app', 'port', 8080, 'defaultPath', '/'), + 'workflow_recipe', 'direct-recipe-uid-' || suffix FROM ids + ) + INSERT INTO operational_resource_relationships( + environment_id, source_type, source_id, relationship_type, target_type, target_id, + relationship_instance_id, behavior_attributes, source_family, source_provider_uid, + source_resource_version, source_generation, content_bytes + ) + SELECT $4, source_type, source_id, relationship_type, target_type, target_id, + relationship_instance_id, behavior_attributes, source_family, + source_provider_uid, '1', 1, 64 + FROM relationships`, + [ + userId, + first, + last, + environmentId, + config.hostsNamespace, + config.contextsNamespace, + config.mcpServersNamespace, + config.sharedFilesystemsNamespace, + config.sandboxNamespace, + ] + ) + await databasePool.query( + `WITH ids AS ( + SELECT value, LPAD(value::text, 5, '0') AS suffix + FROM generate_series($2::int, $3::int) value + ) + INSERT INTO workflow_runs( + run_id, recipe_namespace, recipe_name, phase, actor_type, actor_id, trigger_source + ) + SELECT gen_random_uuid(), $4, 'direct-recipe-' || suffix, + 'Succeeded', 'user', $1, 'onDemand' + FROM ids`, + [userId, first, last, config.sandboxNamespace] + ) + await databasePool.query( + `WITH ids AS ( + SELECT value, LPAD(value::text, 5, '0') AS suffix + FROM generate_series($2::int, $3::int) value + ) + INSERT INTO workflow_approval_requests( + id, recipe_namespace, recipe_name, expires_at, status, target_user_id, + payload, idempotency_key, payload_hash + ) + SELECT gen_random_uuid(), $4, 'direct-recipe-' || suffix, + NOW() + INTERVAL '1 hour', 'pending', $1, '{}'::jsonb, + 'direct-load-' || gen_random_uuid()::text, 'hash' + FROM ids`, + [userId, first, last, config.sandboxNamespace] + ) + await databasePool.query( + `WITH ids AS ( + SELECT value FROM generate_series($2::int, $3::int) value + ) + INSERT INTO notification_deliveries( + id, event_type, dedupe_key, audience, payload, status, expires_at + ) + SELECT gen_random_uuid(), 'catalog.direct', 'direct-load-' || gen_random_uuid()::text, + jsonb_build_object('userId', $1::text), '{}'::jsonb, 'queued', + NOW() + INTERVAL '1 hour' + FROM ids`, + [userId, first, last] + ) + await databasePool.query( + `WITH ids AS ( + SELECT value, LPAD(value::text, 5, '0') AS suffix + FROM generate_series($2::int, $3::int) value + ), inserted AS ( + INSERT INTO gfs_resources(resource_id, drive, name, kind) + SELECT gen_random_uuid(), 'direct-drive-' || suffix, '/', 'directory' FROM ids + RETURNING resource_id, drive + ) + INSERT INTO gfs_grants(drive, resource_id, subject_type, subject_id, permissions) + SELECT drive, resource_id, 'user', $1::text, ARRAY['read']::text[] FROM inserted`, + [userId, first, last] + ) + } + + async function seedTeams(teamIds: readonly string[], resourcesPerTeam: number): Promise { + if (teamIds.length === 0) return + const seriesValues = [teamIds, userId, resourcesPerTeam] + const values = [...seriesValues, environmentId] + await databasePool.query( + `WITH requester AS (SELECT $2::uuid AS user_id), requested AS ( + SELECT team_id, ordinal FROM UNNEST($1::uuid[]) WITH ORDINALITY value(team_id, ordinal) + ), inserted_teams AS ( + INSERT INTO teams(id, name) + SELECT team_id, 'Load Team ' || team_id::text FROM requested + ON CONFLICT (id) DO NOTHING RETURNING id + ) + INSERT INTO team_members(team_id, user_id, role, status) + SELECT team_id, $2, 'member', 'active' FROM requested + ON CONFLICT (team_id, user_id) DO NOTHING`, + [teamIds, userId] + ) + await databasePool.query( + `WITH requester AS (SELECT $2::uuid AS user_id), requested AS ( + SELECT team_id, ordinal FROM UNNEST($1::uuid[]) WITH ORDINALITY value(team_id, ordinal) + ), ids AS ( + SELECT team_id, + REPLACE(team_id::text, '-', '') || '-' || LPAD(value::text, 3, '0') AS suffix + FROM requested CROSS JOIN generate_series(1, $3::int) value + ) + INSERT INTO team_agents(team_id, agent_name) + SELECT team_id, 'team-host-' || suffix FROM ids`, + seriesValues + ) + await databasePool.query( + `WITH requester AS (SELECT $2::uuid AS user_id), requested AS ( + SELECT team_id, ordinal FROM UNNEST($1::uuid[]) WITH ORDINALITY value(team_id, ordinal) + ), ids AS ( + SELECT team_id, + REPLACE(team_id::text, '-', '') || '-' || LPAD(value::text, 3, '0') AS suffix + FROM requested CROSS JOIN generate_series(1, $3::int) value + ) + INSERT INTO team_contexts(team_id, context_id) + SELECT team_id, 'team-context-' || suffix FROM ids`, + seriesValues + ) + await databasePool.query( + `WITH requester AS (SELECT $2::uuid AS user_id), requested AS ( + SELECT team_id, ordinal FROM UNNEST($1::uuid[]) WITH ORDINALITY value(team_id, ordinal) + ), ids AS ( + SELECT team_id, + REPLACE(team_id::text, '-', '') || '-' || LPAD(value::text, 3, '0') AS suffix + FROM requested CROSS JOIN generate_series(1, $3::int) value + ) + INSERT INTO team_workflow_triggers(team_id, recipe_namespace, recipe_name) + SELECT team_id, $4, 'team-recipe-' || suffix FROM ids`, + [...seriesValues, config.sandboxNamespace] + ) + await databasePool.query( + `WITH requester AS (SELECT $2::uuid AS user_id), requested AS ( + SELECT team_id, ordinal FROM UNNEST($1::uuid[]) WITH ORDINALITY value(team_id, ordinal) + ), ids AS ( + SELECT team_id, + REPLACE(team_id::text, '-', '') || '-' || LPAD(value::text, 3, '0') AS suffix + FROM requested CROSS JOIN generate_series(1, $3::int) value + ), resources(resource_type, logical_id, source_family, provider_uid, display_name) AS ( + SELECT 'host', $5 || '/team-host-' || suffix, 'host', + 'team-host-uid-' || suffix, 'Team Host ' || suffix FROM ids + UNION ALL + SELECT 'context', $6 || '/team-context-' || suffix, 'context', + 'team-context-uid-' || suffix, 'Team Context ' || suffix FROM ids + UNION ALL + SELECT 'mcp_server', $7 || '/team-mcp-' || suffix, 'mcp_server', + 'team-mcp-uid-' || suffix, 'Team MCP ' || suffix FROM ids + UNION ALL + SELECT 'shared_filesystem', $8 || '/team-files-' || suffix, 'shared_filesystem', + 'team-files-uid-' || suffix, 'Team Files ' || suffix FROM ids + UNION ALL + SELECT 'workflow_recipe', $9 || '/team-recipe-' || suffix, 'workflow_recipe', + 'team-recipe-uid-' || suffix, 'Team Recipe ' || suffix FROM ids + UNION ALL + SELECT 'sandbox_app', $9 || '/team-recipe-' || suffix, 'workflow_recipe', + 'team-recipe-uid-' || suffix || ':ui', 'Team App ' || suffix FROM ids + ) + INSERT INTO operational_resource_index( + environment_id, resource_type, logical_id, source_family, source_generation, + provider_uid, provider_resource_version, display_name, enabled, content_bytes + ) + SELECT $4, resource_type, logical_id, source_family, 1, + provider_uid, '1', display_name, TRUE, 128 FROM resources`, + [ + teamIds, + userId, + resourcesPerTeam, + environmentId, + config.hostsNamespace, + config.contextsNamespace, + config.mcpServersNamespace, + config.sharedFilesystemsNamespace, + config.sandboxNamespace, + ] + ) + await databasePool.query( + `WITH requester AS (SELECT $2::uuid AS user_id), requested AS ( + SELECT team_id, ordinal FROM UNNEST($1::uuid[]) WITH ORDINALITY value(team_id, ordinal) + ), ids AS ( + SELECT team_id, + REPLACE(team_id::text, '-', '') || '-' || LPAD(value::text, 3, '0') AS suffix + FROM requested CROSS JOIN generate_series(1, $3::int) value + ), relationships( + source_type, source_id, relationship_type, target_type, target_id, + relationship_instance_id, behavior_attributes, source_family, + source_provider_uid + ) AS ( + SELECT 'host', $5 || '/team-host-' || suffix, 'uses_context', 'context', + $6 || '/team-context-' || suffix, 'team-host-context-' || suffix, + '{}'::jsonb, 'host', 'team-host-uid-' || suffix FROM ids + UNION ALL + SELECT 'context', $6 || '/team-context-' || suffix, 'includes_mcp_server', + 'mcp_server', $7 || '/team-mcp-' || suffix, 'team-context-mcp-' || suffix, + '{}'::jsonb, 'context', 'team-context-uid-' || suffix FROM ids + UNION ALL + SELECT 'context', $6 || '/team-context-' || suffix, 'mounts_shared_filesystem', + 'shared_filesystem', $8 || '/team-files-' || suffix, + 'team-context-files-' || suffix, + jsonb_build_object('mountPath', '/workspace/' || suffix, 'readOnly', TRUE), + 'context', 'team-context-uid-' || suffix FROM ids + UNION ALL + SELECT 'workflow_recipe', $9 || '/team-recipe-' || suffix, + 'exposes_sandbox_app', 'sandbox_app', $9 || '/team-recipe-' || suffix, + 'team-recipe-app-' || suffix, + jsonb_build_object('workloadRef', 'app', 'port', 8080, 'defaultPath', '/'), + 'workflow_recipe', 'team-recipe-uid-' || suffix FROM ids + ) + INSERT INTO operational_resource_relationships( + environment_id, source_type, source_id, relationship_type, target_type, target_id, + relationship_instance_id, behavior_attributes, source_family, source_provider_uid, + source_resource_version, source_generation, content_bytes + ) + SELECT $4, source_type, source_id, relationship_type, target_type, target_id, + relationship_instance_id, behavior_attributes, source_family, + source_provider_uid, '1', 1, 64 FROM relationships`, + [ + teamIds, + userId, + resourcesPerTeam, + environmentId, + config.hostsNamespace, + config.contextsNamespace, + config.mcpServersNamespace, + config.sharedFilesystemsNamespace, + config.sandboxNamespace, + ] + ) + await databasePool.query( + `WITH requester AS (SELECT $2::uuid AS user_id), requested AS ( + SELECT team_id, ordinal FROM UNNEST($1::uuid[]) WITH ORDINALITY value(team_id, ordinal) + ), ids AS ( + SELECT team_id, + REPLACE(team_id::text, '-', '') || '-' || LPAD(value::text, 3, '0') AS suffix + FROM requested CROSS JOIN generate_series(1, $3::int) value + ) + INSERT INTO workflow_runs( + run_id, recipe_namespace, recipe_name, phase, actor_type, actor_id, + team_id, trigger_source + ) + SELECT gen_random_uuid(), $4, 'team-recipe-' || suffix, + 'Succeeded', 'user', $2, team_id, 'onDemand' FROM ids`, + [teamIds, userId, resourcesPerTeam, config.sandboxNamespace] + ) + await databasePool.query( + `WITH requester AS (SELECT $2::uuid AS user_id), requested AS ( + SELECT team_id, ordinal FROM UNNEST($1::uuid[]) WITH ORDINALITY value(team_id, ordinal) + ), ids AS ( + SELECT team_id, + REPLACE(team_id::text, '-', '') || '-' || LPAD(value::text, 3, '0') AS suffix + FROM requested CROSS JOIN generate_series(1, $3::int) value + ) + INSERT INTO workflow_approval_requests( + id, recipe_namespace, recipe_name, expires_at, status, target_team_id, + payload, idempotency_key, payload_hash + ) + SELECT gen_random_uuid(), $4, 'team-recipe-' || suffix, + NOW() + INTERVAL '1 hour', 'pending', team_id, '{}'::jsonb, + 'team-load-' || gen_random_uuid()::text, 'hash' FROM ids`, + [teamIds, userId, resourcesPerTeam, config.sandboxNamespace] + ) + await databasePool.query( + `WITH requester AS (SELECT $2::uuid AS user_id), requested AS ( + SELECT team_id, ordinal FROM UNNEST($1::uuid[]) WITH ORDINALITY value(team_id, ordinal) + ), ids AS ( + SELECT team_id, + REPLACE(team_id::text, '-', '') || '-' || LPAD(value::text, 3, '0') AS suffix + FROM requested CROSS JOIN generate_series(1, $3::int) value + ) + INSERT INTO notification_deliveries( + id, event_type, dedupe_key, audience, payload, status, expires_at + ) + SELECT gen_random_uuid(), 'catalog.team', 'team-load-' || gen_random_uuid()::text, + jsonb_build_object('teamId', team_id::text), '{}'::jsonb, 'queued', + NOW() + INTERVAL '1 hour' FROM ids`, + [teamIds, userId, resourcesPerTeam] + ) + await databasePool.query( + `WITH requester AS (SELECT $2::uuid AS user_id), requested AS ( + SELECT team_id, ordinal FROM UNNEST($1::uuid[]) WITH ORDINALITY value(team_id, ordinal) + ), ids AS ( + SELECT team_id, + REPLACE(team_id::text, '-', '') || '-' || LPAD(value::text, 3, '0') AS suffix + FROM requested CROSS JOIN generate_series(1, $3::int) value + ), inserted AS ( + INSERT INTO gfs_resources(resource_id, drive, name, kind) + SELECT gen_random_uuid(), 'team-drive-' || suffix, '/', 'directory' FROM ids + RETURNING resource_id, drive + ) + INSERT INTO gfs_grants(drive, resource_id, subject_type, subject_id, permissions) + SELECT resource.drive, resource.resource_id, 'team', requested.team_id::text, + ARRAY['read']::text[] + FROM inserted resource + JOIN requested ON resource.drive LIKE '%' || REPLACE(requested.team_id::text, '-', '') || '%'`, + [teamIds, userId, resourcesPerTeam] + ) + } + + async function analyzeAndExplain(profile: string): Promise { + await databasePool.query(`ANALYZE`) + const metrics: Array> = [] + const profileIndexes = new Set() + for (const family of CATALOG_FAMILIES) { + const result = await databasePool.query( + `EXPLAIN (ANALYZE, BUFFERS, WAL, FORMAT JSON) ${CATALOG_KEY_SQL[family]}`, + [userId, '', environmentId, 101, config.hostsNamespace, config.contextsNamespace, ''] + ) + const explained = queryPlan(result.rows[0]) + const nodes = planNodes(explained.Plan) + const boundedEnvelope = armCounts[family] * 101 + const boundedNodes = nodes.filter(node => + /Sort|Aggregate|Unique/.test(String(node['Node Type'] ?? '')) + ) + expect( + numberField(explained.Plan, 'Actual Rows'), + `${profile}:${family}:rows` + ).toBeLessThanOrEqual(101) + for (const node of boundedNodes) { + expect( + numberField(node, 'Actual Rows'), + `${profile}:${family}:${node['Node Type']}` + ).toBeLessThanOrEqual(boundedEnvelope) + expect(String(node['Sort Method'] ?? '')).not.toContain('external') + } + const indexNames = nodes + .flatMap(node => (typeof node['Index Name'] === 'string' ? [node['Index Name']] : [])) + .sort() + indexNames.forEach(indexName => profileIndexes.add(indexName)) + metrics.push({ + family, + rows: numberField(explained.Plan, 'Actual Rows'), + planningMs: Number(explained['Planning Time'] ?? 0), + executionMs: Number(explained['Execution Time'] ?? 0), + sharedHitBlocks: nodes.reduce( + (total, node) => total + numberField(node, 'Shared Hit Blocks'), + 0 + ), + sharedReadBlocks: nodes.reduce( + (total, node) => total + numberField(node, 'Shared Read Blocks'), + 0 + ), + tempReadBlocks: nodes.reduce( + (total, node) => total + numberField(node, 'Temp Read Blocks'), + 0 + ), + tempWrittenBlocks: nodes.reduce( + (total, node) => total + numberField(node, 'Temp Written Blocks'), + 0 + ), + walRecords: nodes.reduce((total, node) => total + numberField(node, 'WAL Records'), 0), + indexes: indexNames, + }) + } + if (profile === 'high-cardinality') { + for (const requiredIndex of [ + 'operational_relationship_source_idx', + 'operational_resource_index_pkey', + ]) { + expect(profileIndexes.has(requiredIndex), requiredIndex).toBe(true) + } + } + console.info(`[access-catalog-plan:${profile}] ${JSON.stringify(metrics)}`) + } + + it('measures every bounded producer on direct, typical, and high-cardinality data', async () => { + await seedDirect(1, DIRECT_ONLY_RESOURCES) + await analyzeAndExplain('direct-only') + + await seedTeams(allTeamIds.slice(0, TYPICAL_TEAMS), TYPICAL_RESOURCES_PER_TEAM) + await analyzeAndExplain('typical-multi-team') + + await seedDirect(DIRECT_ONLY_RESOURCES + 1, HIGH_DIRECT_RESOURCES) + await seedTeams(allTeamIds.slice(TYPICAL_TEAMS), HIGH_RESOURCES_PER_TEAM) + await analyzeAndExplain('high-cardinality') + }, 30_000) + + it('bounds coordinator query count and composes keyset pages', async () => { + let queryCount = 0 + const measuredTransaction = async (work: (db: DbClient) => Promise): Promise => { + const client = (await databasePool.connect()) as PoolClient + const measured: DbClient = { + query: async (text, values) => { + if (!/^SET TRANSACTION|set_config\('statement_timeout'/i.test(text.trim())) { + queryCount += 1 + } + return client.query(text, values) + }, + } + try { + await client.query('BEGIN') + const value = await work(measured) + await client.query('COMMIT') + return value + } catch (error) { + await client.query('ROLLBACK') + throw error + } finally { + client.release() + } + } + + const first = await buildAccessCatalog( + { session, families: CATALOG_FAMILIES, limit: 100 }, + { transaction: measuredTransaction } + ) + expect(first.items).toHaveLength(100) + expect(first.nextCursor).not.toBeNull() + expect(queryCount).toBeLessThanOrEqual(2 + 2 * CATALOG_FAMILIES.length) + + queryCount = 0 + const second = await buildAccessCatalog( + { session, families: CATALOG_FAMILIES, limit: 100, cursor: first.nextCursor }, + { transaction: measuredTransaction } + ) + expect(second.items).toHaveLength(100) + expect(queryCount).toBeLessThanOrEqual(2 + 2 * CATALOG_FAMILIES.length) + const firstIds = new Set(first.items.map(item => item.resource.canonicalId)) + expect(second.items.every(item => !firstIds.has(item.resource.canonicalId))).toBe(true) + + await databasePool.query( + `UPDATE operational_catalog_source_state + SET generation = generation + 1, resource_version = '2' + WHERE environment_id = $1 AND source_family = 'context'`, + [environmentId] + ) + await expect( + buildAccessCatalog( + { session, families: CATALOG_FAMILIES, limit: 100, cursor: first.nextCursor }, + { transaction: measuredTransaction } + ) + ).rejects.toBeInstanceOf(AccessCatalogCursorError) + }) +}) From ee9d998665ff2b3f897d3a552eb39b9aa467a273 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 01:16:05 -0600 Subject: [PATCH 022/528] test(access): validate database and runtime contracts Exercise user-session lifecycle, operational projection, catalog reads, resolver reads, role denial, cancellation, statement timeout, and rollback against disposable real PostgreSQL. --- ...cellation.realPostgres.integration.test.ts | 43 +++ ...eContract.realPostgres.integration.test.ts | 246 ++++++++++++++++++ 2 files changed, 289 insertions(+) create mode 100644 control-api/test/userAccessRuntimeContract.realPostgres.integration.test.ts diff --git a/control-api/test/accessDatabaseCancellation.realPostgres.integration.test.ts b/control-api/test/accessDatabaseCancellation.realPostgres.integration.test.ts index 3183571a62..1601917fe4 100644 --- a/control-api/test/accessDatabaseCancellation.realPostgres.integration.test.ts +++ b/control-api/test/accessDatabaseCancellation.realPostgres.integration.test.ts @@ -1,6 +1,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { Pool, type PoolClient } from 'pg' import { + AccessBudgetExceededError, AccessExecutionBudget, AccessExecutionCancelledError, } from '../src/services/access/accessExecutionBudget.js' @@ -34,4 +35,46 @@ describeRealPostgres('access database cancellation on real PostgreSQL', () => { client.release() } }) + + it('maps a real statement timeout to authoritative budget exhaustion', async () => { + const client = (await pool.connect()) as PoolClient + const budget = AccessExecutionBudget.create('catalog') + const startedAt = performance.now() + try { + await client.query(`SET statement_timeout = '50ms'`) + await expect(catalogQuery(client, budget, 'SELECT pg_sleep(2)', [])).rejects.toMatchObject({ + name: 'AccessBudgetExceededError', + limit: 'deadline', + authorityRequired: true, + }) + expect(performance.now() - startedAt).toBeLessThan(1_000) + } finally { + await client.query(`SET statement_timeout = 0`) + budget.close() + client.release() + } + }) + + it('requires rollback and preserves no transactional writes after timeout', async () => { + const client = (await pool.connect()) as PoolClient + const budget = AccessExecutionBudget.create('catalog') + try { + await client.query(`CREATE TEMP TABLE access_timeout_rollback(value integer)`) + await client.query('BEGIN') + await client.query(`SET LOCAL statement_timeout = '50ms'`) + await client.query(`INSERT INTO access_timeout_rollback(value) VALUES (1)`) + await expect(catalogQuery(client, budget, 'SELECT pg_sleep(2)', [])).rejects.toBeInstanceOf( + AccessBudgetExceededError + ) + await client.query('ROLLBACK') + const result = await client.query<{ count: string }>( + `SELECT count(*)::text AS count FROM access_timeout_rollback` + ) + expect(result.rows).toEqual([{ count: '0' }]) + } finally { + if (client) await client.query('ROLLBACK').catch(() => undefined) + budget.close() + client.release() + } + }) }) diff --git a/control-api/test/userAccessRuntimeContract.realPostgres.integration.test.ts b/control-api/test/userAccessRuntimeContract.realPostgres.integration.test.ts new file mode 100644 index 0000000000..272ac0430d --- /dev/null +++ b/control-api/test/userAccessRuntimeContract.realPostgres.integration.test.ts @@ -0,0 +1,246 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { randomBytes, randomUUID } from 'node:crypto' +import { Pool, type PoolClient } from 'pg' +import { config } from '../src/config.js' +import { type DbClient, initDb } from '../src/db.js' +import { buildAccessCatalog } from '../src/services/access/accessCatalogCoordinator.js' +import { AccessExecutionBudget } from '../src/services/access/accessExecutionBudget.js' +import { resolveLiveAuthorization } from '../src/services/access/liveAuthorizationResolver.js' +import { OperationalAccessIndex } from '../src/services/access/operationalAccessIndex.js' +import { + canonicalEnvironmentId, + projectOperationalObject, +} from '../src/services/access/operationalAccessProjection.js' +import { canonicalResourceIdentity } from '../src/services/access/resourceIdentity.js' +import type { ExternalSessionAuthorityContext } from '../src/services/auth/externalSessionAuthentication.js' +import { + createUserSession, + renewUserSession, + revokeUserSession, +} from '../src/services/auth/userSessionService.js' +import { verifyUserSessionV2Token } from '../src/utils/auth/userSessionV2Token.js' + +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const describeRealPostgres = adminUrl ? describe : describe.skip +const runtimeRoles = [ + 'control_api_runtime', + 'trace_maintenance_runtime', + 'workflow_recipes_runtime', +] as const + +function databaseUrl(baseUrl: string, database: string): string { + const value = new URL(baseUrl) + value.pathname = `/${database}` + return value.toString() +} + +function quoteIdentifier(value: string): string { + return `"${value.replace(/"/g, '""')}"` +} + +describeRealPostgres('user-access runtime role contract on real PostgreSQL', () => { + const database = `control_api_user_access_runtime_${randomBytes(6).toString('hex')}` + const connectionString = databaseUrl( + adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', + database + ) + const userId = randomUUID() + const environmentId = canonicalEnvironmentId() + const authority: ExternalSessionAuthorityContext = { + contract: 'v1', + userId, + tokenHash: randomBytes(32).toString('hex'), + issuedAt: Math.floor(Date.now() / 1_000), + } + let adminPool: Pool + let databasePool: Pool + + const runAsControlApiRuntime = async (work: (db: DbClient) => Promise): Promise => { + const client = (await databasePool.connect()) as PoolClient + try { + await client.query('BEGIN') + await client.query('SET LOCAL ROLE control_api_runtime') + const value = await work(client) + await client.query('COMMIT') + return value + } catch (error) { + await client.query('ROLLBACK') + throw error + } finally { + client.release() + } + } + + beforeAll(async () => { + adminPool = new Pool({ connectionString: adminUrl }) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) + databasePool = new Pool({ connectionString }) + await initDb({ connect: () => databasePool.connect() }) + await databasePool.query( + `INSERT INTO users(id, email, name) VALUES ($1, $2, 'Runtime Contract User')`, + [userId, `${userId}@example.test`] + ) + }) + + afterAll(async () => { + await databasePool?.end() + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + await adminPool.end() + } + }) + + it('creates, renews, reads, and revokes user sessions as the runtime role', async () => { + const issued = await runAsControlApiRuntime(db => + createUserSession( + { + userId, + email: `${userId}@example.test`, + authenticationMethods: ['password'], + }, + { db } + ) + ) + const issuedClaims = verifyUserSessionV2Token(issued.token) + expect(issuedClaims).not.toBeNull() + + const renewed = await runAsControlApiRuntime(db => + renewUserSession(issuedClaims!, { + db, + now: new Date((issuedClaims!.iat + 1) * 1_000), + }) + ) + expect(renewed).toMatchObject({ expiresInSeconds: 60 * 60 }) + if (!('token' in renewed)) throw new Error('runtime renewal did not issue a token') + const renewedClaims = verifyUserSessionV2Token(renewed.token) + expect(renewedClaims?.jti).not.toBe(issuedClaims?.jti) + + await expect( + runAsControlApiRuntime(db => + revokeUserSession(userId, renewed.identity.sid, 'runtime_contract', db) + ) + ).resolves.toBe(true) + const row = await databasePool.query<{ revoked: boolean }>( + `SELECT revoked_at IS NOT NULL AS revoked + FROM external_user_sessions + WHERE sid = $1`, + [renewed.identity.sid] + ) + expect(row.rows).toEqual([{ revoked: true }]) + }) + + it('projects operational state and bumps authorization revisions as the runtime role', async () => { + const projection = projectOperationalObject({ + environmentId, + plural: 'hosts', + namespace: config.hostsNamespace, + object: { + metadata: { + name: 'runtime-host', + namespace: config.hostsNamespace, + uid: 'runtime-host-uid', + resourceVersion: '1', + generation: 1, + }, + spec: { enabled: true, contextRef: 'runtime-context' }, + }, + behaviorFingerprintKey: config.sessionJwtPrivateKey, + relationshipNamespaces: { + context: config.contextsNamespace, + mcpServer: config.mcpServersNamespace, + sharedFilesystem: config.sharedFilesystemsNamespace, + }, + }) + const budget = AccessExecutionBudget.create('catalog') + const index = new OperationalAccessIndex(databasePool, runAsControlApiRuntime) + try { + const generation = await index.beginRelist({ + environmentId, + sourceFamily: 'host', + budget, + }) + await index.stageRelistPage({ + environmentId, + sourceFamily: 'host', + stagingGeneration: generation, + projections: [projection], + budget, + }) + await index.promoteRelist({ + environmentId, + sourceFamily: 'host', + stagingGeneration: generation, + resourceVersion: '1', + budget, + }) + } finally { + budget.close() + } + + const stored = await databasePool.query<{ resources: string; revisions: string }>( + `SELECT + (SELECT count(*)::text FROM operational_resource_index + WHERE environment_id = $1 AND resource_type = 'host') AS resources, + (SELECT count(*)::text FROM authorization_resource_revisions + WHERE environment_id = $1 AND resource_type = 'host') AS revisions`, + [environmentId] + ) + expect(stored.rows).toEqual([{ resources: '1', revisions: '1' }]) + }) + + it('reads catalog and resolver authority as the runtime role', async () => { + const catalog = await buildAccessCatalog( + { session: authority, families: ['user'], limit: 10 }, + { transaction: runAsControlApiRuntime } + ) + expect(catalog.complete).toBe(true) + expect(catalog.items).toHaveLength(1) + + const resolved = await resolveLiveAuthorization( + { + session: authority, + requiredCapability: 'user.profile.read', + resource: canonicalResourceIdentity(catalog.items[0]!.resource), + requestedAccessPathId: catalog.items[0]!.accessPaths[0]!.accessPathId, + }, + { transaction: runAsControlApiRuntime } + ) + expect(resolved.status).toBe('allowed') + }) + + it('denies the session tables to unrelated roles and PUBLIC', async () => { + const publicPrivilege = await databasePool.query<{ allowed: boolean }>( + `SELECT EXISTS ( + SELECT 1 + FROM pg_class relation + CROSS JOIN LATERAL aclexplode( + COALESCE(relation.relacl, acldefault('r', relation.relowner)) + ) privilege + WHERE relation.oid = 'public.external_user_sessions'::regclass + AND privilege.grantee = 0 + AND privilege.privilege_type = 'SELECT' + ) AS allowed` + ) + expect(publicPrivilege.rows).toEqual([{ allowed: false }]) + + const unrelated = await databasePool.connect() + try { + await unrelated.query('BEGIN') + await unrelated.query('SET LOCAL ROLE workflow_recipes_runtime') + await expect(unrelated.query('SELECT sid FROM external_user_sessions')).rejects.toMatchObject( + { code: '42501' } + ) + } finally { + await unrelated.query('ROLLBACK') + unrelated.release() + } + }) +}) From 211f08d7595068e41d8dfe099a504b533add13a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 02:10:23 -0600 Subject: [PATCH 023/528] test(auth): model live sessions in approval routes --- ...outes.auth.mcp-host.issue.security.test.ts | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/control-api/test/routes.auth.mcp-host.issue.security.test.ts b/control-api/test/routes.auth.mcp-host.issue.security.test.ts index 4b6ea4bcb7..1e995118a0 100644 --- a/control-api/test/routes.auth.mcp-host.issue.security.test.ts +++ b/control-api/test/routes.auth.mcp-host.issue.security.test.ts @@ -45,6 +45,26 @@ const mockPoolQueryDispatch = vi.fn(async (sql: unknown, params?: unknown[]) => if (/rate_limit_buckets/i.test(text)) { return { rows: [{ count: 1 }], rowCount: 1 } } + if (/clock_timestamp\(\)/i.test(text)) { + return { rows: [{ db_now: new Date('2026-09-01T12:00:00.000Z') }], rowCount: 1 } + } + if ( + /external_user_session_security_epochs/i.test(text) && + /external_v1_session_revocations/i.test(text) + ) { + return { + rows: [ + { + id: String(params?.[0] ?? ''), + lifecycle_state: 'active', + lifecycle_version: 1, + valid_after: null, + token_revoked: false, + }, + ], + rowCount: 1, + } + } if (/lifecycle_state/i.test(text)) { return { rows: [{ lifecycle_state: 'active', lifecycle_version: 1 }], rowCount: 1 } } @@ -53,7 +73,11 @@ const mockPoolQueryDispatch = vi.fn(async (sql: unknown, params?: unknown[]) => vi.mock('../src/db.js', () => ({ pool: { query: (...args: unknown[]) => mockPoolQueryDispatch(args[0], args[1] as unknown[] | undefined), - connect: vi.fn(), + connect: vi.fn(async () => ({ + query: (...args: unknown[]) => + mockPoolQueryDispatch(args[0], args[1] as unknown[] | undefined), + release: vi.fn(), + })), }, rateLimitPool: { query: (...args: unknown[]) => mockPoolQueryDispatch(args[0], args[1] as unknown[] | undefined), From 6294705194d84a3cb7c3e703d912b8f2eb654335 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 29 Sep 2026 15:35:56 -0600 Subject: [PATCH 024/528] test(auth): authenticate switch compatibility fixture --- control-api/test/app.test.ts | 97 ++++++++++++++++++++++++++++++++++-- 1 file changed, 93 insertions(+), 4 deletions(-) diff --git a/control-api/test/app.test.ts b/control-api/test/app.test.ts index 9cdbd82c85..780af1740b 100644 --- a/control-api/test/app.test.ts +++ b/control-api/test/app.test.ts @@ -1,13 +1,17 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import jwt from 'jsonwebtoken' import request from 'supertest' import { createApp } from '../src/app.js' import { config } from '../src/config.js' import { pool } from '../src/db.js' +import { signExternalSessionToken } from '../src/utils/auth/externalSessionAuthToken.js' import { issueMcpHostAccessJwt } from '../src/utils/auth/mcpHostJwtToken.js' import { signRpcAccessToken } from '../src/utils/auth/rpcAuthToken.js' import { MockGateway } from './mockGateway.js' +const rateLimiter = vi.hoisted(() => ({ checkAndIncrement: vi.fn() })) +vi.mock('../src/services/rateLimiterService.js', () => rateLimiter) + function signHccInternalControl(): string { return jwt.sign( { iss: 'hcc', aud: 'control-api', sub: 'hcc-provisioner' }, @@ -17,6 +21,16 @@ function signHccInternalControl(): string { } describe('app router wiring', () => { + beforeEach(() => { + rateLimiter.checkAndIncrement.mockResolvedValue({ + allowed: true, + remaining: 9, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + count: 1, + }) + }) + afterEach(() => { vi.restoreAllMocks() }) @@ -169,7 +183,9 @@ describe('app router wiring', () => { userId: 'user-1', email: 'user@example.com', teamId: 'team-1', + authGeneration: 1, } + const currentToken = signExternalSessionToken(payload) await request(app) .post('/api/v1/external/auth/session-token') @@ -178,18 +194,91 @@ describe('app router wiring', () => { .send(payload) .expect(401) - vi.spyOn(pool, 'query').mockResolvedValueOnce({ - rows: [{ lifecycle_state: 'active', lifecycle_version: 1 }], - rowCount: 1, + vi.spyOn(pool, 'query').mockImplementation(async sql => { + const text = String(sql) + if (text.includes('clock_timestamp()')) { + return { rows: [{ db_now: new Date('2026-09-01T12:00:00.000Z') }], rowCount: 1 } as never + } + if (text.includes('external_user_session_security_epochs')) { + return { + rows: [ + { + id: payload.userId, + lifecycle_state: 'active', + lifecycle_version: 1, + valid_after: null, + token_revoked: false, + }, + ], + rowCount: 1, + } as never + } + if (text.includes('FROM team_members')) { + return { + rows: [{ team_id: payload.teamId, role: payload.role }], + rowCount: 1, + } as never + } + return { + rows: [ + { + id: payload.userId, + email: payload.email, + lifecycle_state: 'active', + lifecycle_version: 1, + }, + ], + rowCount: 1, + } as never + }) + const transactionQuery = vi.fn(async (sql: string) => { + if (sql === 'BEGIN' || sql === 'COMMIT') return { rows: [], rowCount: 0 } + if (sql.includes('SELECT id FROM users')) { + return { rows: [{ id: payload.userId }], rowCount: 1 } + } + if (sql.includes('clock_timestamp()')) { + return { rows: [{ db_now: new Date('2026-09-01T12:00:00.000Z') }], rowCount: 1 } + } + if (sql.includes('external_user_session_security_epochs')) { + return { + rows: [ + { + id: payload.userId, + lifecycle_state: 'active', + lifecycle_version: 1, + valid_after: null, + token_revoked: false, + }, + ], + rowCount: 1, + } + } + if (sql.includes('JOIN team_members')) { + return { rows: [{ role: payload.role, lifecycle_version: 1 }], rowCount: 1 } + } + throw new Error(`unexpected session exchange query: ${sql}`) }) + vi.spyOn(pool, 'connect').mockResolvedValue({ + query: transactionQuery, + release: vi.fn(), + } as never) const res = await request(app) .post('/api/v1/external/auth/session-token') .set('authorization', 'Bearer dev-external-rest-api-token') .set('x-service-token', 'external-rest-api') + .set('x-user-session-token', currentToken) .send(payload) .expect(200) expect(res.body.token).toBeTruthy() + expect(transactionQuery.mock.calls.map(([sql]) => String(sql))).toEqual([ + 'BEGIN', + expect.stringContaining('SELECT id FROM users'), + expect.stringContaining('clock_timestamp()'), + expect.stringContaining('external_user_session_security_epochs'), + expect.stringContaining('JOIN team_members'), + 'COMMIT', + ]) }) it('enforces UI auth for control-ui routes', async () => { From f11af6a19edb0404b9f2f19eb0438b283f44a2fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 02:15:45 -0600 Subject: [PATCH 025/528] test(external): assert sanitized public errors --- external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- external-rest-api/src/releaseManifest.ts | 2 +- external-rest-api/test/routes.gfs.test.ts | 82 ++++++++++++++----- .../test/routes.notifications.test.ts | 11 ++- .../test/routes.userApprovalDecisions.test.ts | 22 ++++- 6 files changed, 94 insertions(+), 29 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index d6f2927f00..7b23928c92 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.109", + "version": "0.1.110", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.109", + "version": "0.1.110", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index dbf4d93e11..fa1a9d1a80 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.109", + "version": "0.1.110", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index c2e3e0c2b0..46170a44a1 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.10.0', - externalRestApiVersion: '0.1.109', + externalRestApiVersion: '0.1.110', rpcProxyVersion: '0.1.111', desktopVersion: '0.10.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/test/routes.gfs.test.ts b/external-rest-api/test/routes.gfs.test.ts index 6befe274ea..d7b1fdd007 100644 --- a/external-rest-api/test/routes.gfs.test.ts +++ b/external-rest-api/test/routes.gfs.test.ts @@ -13,8 +13,10 @@ const { ControlApiError, clientMock } = vi.hoisted(() => { ) { super(message) this.headers = headers instanceof Headers ? Object.fromEntries(headers.entries()) : headers + this.responseHeaders = this.headers } public headers: Record + public responseHeaders: Record } return { ControlApiError, @@ -169,7 +171,7 @@ describe('routes/gfs /me/gfs/* (user session passthrough → /external/gfs/*)', expect(missing.status).toBe(200) expect(missing.body).toEqual({ upload: { resumableV2: { enabled: true } } }) expect(mismatched.status).toBe(400) - expect(mismatched.body).toEqual({ error: 'drive_mismatch' }) + expect(mismatched.body.error.code).toBe('invalid_request') expect(clientMock.controlApiStreamRequest).toHaveBeenCalledTimes(2) }) @@ -191,13 +193,13 @@ describe('routes/gfs /me/gfs/* (user session passthrough → /external/gfs/*)', .get(`/me/gfs/capabilities?drive=${encodeURIComponent(drive)}`) .set('authorization', 'Bearer sess-xyz') expect(response.status).toBe(400) - expect(response.body).toEqual({ error: 'drive_required' }) + expect(response.body.error.code).toBe('invalid_request') } const arrayValue = await request(buildApp()) .get('/me/gfs/capabilities?drive=archive&drive=main') .set('authorization', 'Bearer sess-xyz') expect(arrayValue.status).toBe(400) - expect(arrayValue.body).toEqual({ error: 'drive_required' }) + expect(arrayValue.body.error.code).toBe('invalid_request') expect(clientMock.controlApiStreamRequest).toHaveBeenCalledTimes(6) expect(forwardedDrives).toEqual([...malformed, undefined]) }) @@ -284,11 +286,10 @@ describe('routes/gfs /me/gfs/* (user session passthrough → /external/gfs/*)', expect(response.status).toBe(429) expect(response.headers['retry-after']).toBe('7') expect(response.headers['x-ratelimit-limit']).toBe('512') - expect(response.headers['x-gfs-ratelimit-scope']).toBe('principal_bytes') - expect(response.body).toEqual({ - error: 'gfs_upload_rate_limited', - limit: 'principal_bytes', - retryAfterSeconds: 7, + expect(response.headers['x-gfs-ratelimit-scope']).toBeUndefined() + expect(response.body.error).toMatchObject({ + code: 'rate_limited', + details: { retryAfterSeconds: 7 }, }) }) @@ -561,7 +562,10 @@ describe('routes/gfs /me/gfs/* (user session passthrough → /external/gfs/*)', permissions: ['write'], }) expect(res.status).toBe(403) - expect(res.body).toEqual({ error: 'escalation_rejected' }) + expect(res.body.error).toMatchObject({ + code: 'forbidden', + details: { reason: 'escalation_rejected' }, + }) }) it('preserves a public 413 payload_too_large response and its safe transport headers', async () => { @@ -590,14 +594,21 @@ describe('routes/gfs /me/gfs/* (user session passthrough → /external/gfs/*)', const res = await request(buildApp()) .put('/me/gfs/resources/abc/content?drive=main') .set('authorization', 'Bearer sess-xyz') + .set('x-correlation-id', 'gfs_ID-42') .send({ contentBase64: 'AAAA' }) expect(res.status).toBe(413) - expect(res.body).toEqual(body) - expect(res.headers['retry-after']).toBe('9') + expect(res.body.error).toEqual({ + code: 'payload_too_large', + message: 'The request payload is too large.', + correlationId: 'gfs_ID-42', + retryable: false, + }) + expect(res.headers['retry-after']).toBeUndefined() expect(res.headers['upload-length']).toBe('209715200') - expect(res.headers['x-ratelimit-limit']).toBe('16') + expect(res.headers['x-ratelimit-limit']).toBeUndefined() expect(res.headers['x-internal-secret']).toBeUndefined() + expect(JSON.stringify(res.body)).not.toContain('200 MiB') }) it('propagates GFS transport statuses/body/headers without changing retry semantics', async () => { @@ -606,6 +617,7 @@ describe('routes/gfs /me/gfs/* (user session passthrough → /external/gfs/*)', // a generic 500 before the client can apply that policy. for (const [status, error] of [ [408, 'request_timeout'], + [411, 'length_required'], [425, 'too_early'], [500, 'gfsc_internal'], [502, 'gfsc_unreachable'], @@ -628,9 +640,33 @@ describe('routes/gfs /me/gfs/* (user session passthrough → /external/gfs/*)', .set('authorization', 'Bearer sess-xyz') .send({ contentBase64: 'AAAA' }) expect(res.status).toBe(status) - expect(res.body).toEqual({ error }) - expect(res.headers['retry-after']).toBe('7') - expect(res.headers['x-ratelimit-limit']).toBe('16') + const expectedCode = + status === 408 + ? 'request_timeout' + : status === 411 + ? 'length_required' + : status === 425 + ? 'too_early' + : status === 502 + ? 'upstream_unavailable' + : status === 503 + ? 'authority_unavailable' + : status === 504 + ? 'upstream_timeout' + : status === 507 + ? 'insufficient_storage' + : 'internal_error' + expect(res.body.error.code).toBe(expectedCode) + expect(res.headers['retry-after']).toBe( + status === 408 || status === 425 || status === 502 || status === 503 || status === 504 + ? '7' + : undefined + ) + expect(res.headers['x-ratelimit-limit']).toBeUndefined() + expect(res.body.error.retryable).toBe( + status === 408 || status === 425 || status === 502 || status === 503 || status === 504 + ) + expect(JSON.stringify(res.body)).not.toContain('gfsc failure') } }) @@ -776,7 +812,10 @@ describe('GET /me/gfs/grants (delegation list passthrough)', () => { .get('/me/gfs/grants?drive=main&resourceId=11111111-1111-1111-1111-111111111111') .set('authorization', 'Bearer sess-xyz') expect(res.status).toBe(403) - expect(res.body).toEqual({ error: 'manage_acl_required' }) + expect(res.body.error).toMatchObject({ + code: 'forbidden', + details: { reason: 'manage_acl_required' }, + }) }) it('forwards only the allowlisted rate-limit and request headers on a control-api 429', async () => { @@ -804,12 +843,15 @@ describe('GET /me/gfs/grants (delegation list passthrough)', () => { .get('/me/gfs/grants?drive=main&resourceId=11111111-1111-1111-1111-111111111111') .set('authorization', 'Bearer sess-xyz') expect(res.status).toBe(429) - expect(res.body).toEqual({ error: 'Too Many Requests', retryAfterSeconds: 17 }) + expect(res.body.error).toMatchObject({ + code: 'rate_limited', + details: { retryAfterSeconds: 17 }, + }) expect(res.headers['retry-after']).toBe('17') expect(res.headers['x-ratelimit-limit']).toBe('30') expect(res.headers['x-ratelimit-remaining']).toBe('0') expect(res.headers['x-ratelimit-reset']).toBe('1776000000') - expect(res.headers['x-request-id']).toBe(REQUEST_ID) + expect(res.headers['x-request-id']).toMatch(UUID_RE) expect(res.headers['set-cookie']).toBeUndefined() expect(res.headers.server).toBeUndefined() expect(res.headers['x-internal-debug']).toBeUndefined() @@ -888,7 +930,9 @@ describe('GET /me/gfs/shares (delegation list passthrough)', () => { .set('x-request-id', REQUEST_ID) expect(res.status).toBe(status) - expect(res.body).toEqual({ error }) + const expectedCode = + status === 401 ? 'invalid_session' : status === 403 ? 'forbidden' : 'authority_unavailable' + expect(res.body.error.code).toBe(expectedCode) expect(res.headers['x-request-id']).toBe(REQUEST_ID) }) }) diff --git a/external-rest-api/test/routes.notifications.test.ts b/external-rest-api/test/routes.notifications.test.ts index c80487716c..d4a7a9793e 100644 --- a/external-rest-api/test/routes.notifications.test.ts +++ b/external-rest-api/test/routes.notifications.test.ts @@ -146,7 +146,7 @@ describe('routes/notifications', () => { ) }) - it('propagates partial preference PUT validation errors from control-api', async () => { + it('sanitizes partial preference PUT validation errors from control-api', async () => { authTokenMock.verifyToken.mockReturnValueOnce(claims) controlApiClientMock.controlApiRequest.mockRejectedValueOnce( new controlApiClientMock.ControlApiError('invalid_channel_fallback_enabled', 400, { @@ -160,7 +160,14 @@ describe('routes/notifications', () => { .send({ preferredMedium: 'telegram' }) .expect(400) - expect(response.body).toEqual({ error: 'invalid_channel_fallback_enabled' }) + expect(response.body).toEqual({ + error: { + code: 'invalid_request', + message: 'The request is not valid.', + correlationId: expect.any(String), + retryable: false, + }, + }) expect(controlApiClientMock.controlApiRequest).toHaveBeenCalledWith( 'PUT', '/external/me/notification-preferences', diff --git a/external-rest-api/test/routes.userApprovalDecisions.test.ts b/external-rest-api/test/routes.userApprovalDecisions.test.ts index 99c08209d1..35068570b3 100644 --- a/external-rest-api/test/routes.userApprovalDecisions.test.ts +++ b/external-rest-api/test/routes.userApprovalDecisions.test.ts @@ -169,7 +169,7 @@ describe('routes/userApprovalDecisions', () => { expect(userApprovalDecisionsServiceMock.decideUserApprovalDecision).not.toHaveBeenCalled() }) - it('propagates expected control-api errors from pending list', async () => { + it('sanitizes expected control-api errors from pending list', async () => { authTokenMock.verifyToken.mockReturnValueOnce(claims) userApprovalDecisionsServiceMock.listPendingUserApprovalDecisions.mockRejectedValueOnce( new ControlApiError('gone', 410, { error: 'expired' }) @@ -181,10 +181,17 @@ describe('routes/userApprovalDecisions', () => { .set('authorization', 'Bearer good-token') .expect(410) - expect(response.body).toEqual({ error: 'expired' }) + expect(response.body).toEqual({ + error: { + code: 'gone', + message: 'The resource is no longer available.', + correlationId: expect.any(String), + retryable: false, + }, + }) }) - it('propagates expected control-api errors with message fallback bodies', async () => { + it('sanitizes expected control-api errors with message fallback bodies', async () => { authTokenMock.verifyToken.mockReturnValueOnce(claims) userApprovalDecisionsServiceMock.decideUserApprovalDecision.mockRejectedValueOnce( new ControlApiError('conflict', 409, 'not-json') @@ -197,7 +204,14 @@ describe('routes/userApprovalDecisions', () => { .send({ decision: 'deny' }) .expect(409) - expect(response.body).toEqual({ error: 'conflict' }) + expect(response.body).toEqual({ + error: { + code: 'conflict', + message: 'The request conflicts with current state.', + correlationId: expect.any(String), + retryable: false, + }, + }) }) it('delegates unexpected service errors to the express error handler', async () => { From d566217c98a0a1e14d200dd56dca20cbd8134eb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 02:47:45 -0600 Subject: [PATCH 026/528] fix(access): grant operational index watch access Class-check: operational CRD list/watch grants -> 5 families -> add watch only where indexed Repro-test: network.gateway.intent.test.ts fails at 5d013dfb without operational watch grants Allow the Control API indexer to keep Host, Context, MCP server, workflow recipe, and shared filesystem projections current without granting CommunicationChannel watch access. --- .../test/network.gateway.intent.test.ts | 54 +++++++++++++++++++ deploy/base/cluster-wide/clusterroles.yaml | 11 ++++ deploy/base/mcp-host/rbac.yaml | 2 +- deploy/base/sandbox-recipes/rbac.yaml | 2 +- 4 files changed, 67 insertions(+), 2 deletions(-) diff --git a/control-api/test/network.gateway.intent.test.ts b/control-api/test/network.gateway.intent.test.ts index e142493cf1..e2b559f2d3 100644 --- a/control-api/test/network.gateway.intent.test.ts +++ b/control-api/test/network.gateway.intent.test.ts @@ -150,6 +150,30 @@ function roleRuleForResource(doc: string, resource: string): string { return lines.slice(start, end).join('\n') } +function roleRulesForResource(doc: string, resource: string): string[] { + const lines = doc.split('\n') + const rules: string[] = [] + for (let index = 0; index < lines.length; index += 1) { + if ( + lines[index] !== ` - ${resource}` && + !lines[index].includes(`['${resource}']`) && + !lines[index].includes(`["${resource}"]`) + ) { + continue + } + let start = index + while (start > 0 && lines[start] !== '- apiGroups:' && lines[start] !== ' - apiGroups:') { + start -= 1 + } + let end = index + 1 + while (end < lines.length && lines[end] !== '- apiGroups:' && lines[end] !== ' - apiGroups:') { + end += 1 + } + rules.push(lines.slice(start, end).join('\n')) + } + return rules +} + function fromPeerBlock(doc: string, marker: string): string { const lines = doc.split('\n') const markerIndex = lines.findIndex(line => line.includes(marker)) @@ -1052,6 +1076,36 @@ describe('network/gateway intent (manifest-level)', () => { } }) + it('grants control-api watch access only for operational access index families', () => { + const clusterRoles = read(`${BASE}/cluster-wide/clusterroles.yaml`) + const controlApiClusterRole = docContaining(yamlDocs(clusterRoles), 'name: control-api') + const hostRbac = read(`${BASE}/mcp-host/rbac.yaml`) + const controlApiHostRole = docContaining( + yamlDocs(hostRbac), + 'name: control-api-hosts-and-secrets' + ) + const sandboxRbac = read(`${BASE}/sandbox-recipes/rbac.yaml`) + const controlApiWorkflowRole = docContaining( + yamlDocs(sandboxRbac), + 'name: control-api-workflow-recipes-sandbox' + ) + + for (const resource of ['hosts', 'contexts', 'mcpservers']) { + expect(roleRulesForResource(controlApiClusterRole, resource)).toSatisfy(rules => + rules.some(rule => rule.includes(' - watch')) + ) + } + expect(roleRulesForResource(controlApiClusterRole, 'communicationchannels')).toSatisfy(rules => + rules.every(rule => !rule.includes(' - watch')) + ) + expect(roleRulesForResource(controlApiHostRole, 'sharedfilesystems')).toSatisfy(rules => + rules.some(rule => rule.includes('watch')) + ) + expect(roleRulesForResource(controlApiWorkflowRole, 'workflowrecipes')).toSatisfy(rules => + rules.some(rule => rule.includes('watch')) + ) + }) + it('routes every rpc-proxy internal-service endpoint through the control-api-rpc-gateway allowlist', () => { const configmaps = read(`${BASE}/control-plane/configmaps.yaml`) const gatewayConf = docContaining(yamlDocs(configmaps), 'name: control-api-rpc-gateway') diff --git a/deploy/base/cluster-wide/clusterroles.yaml b/deploy/base/cluster-wide/clusterroles.yaml index bc823321af..25550ba084 100644 --- a/deploy/base/cluster-wide/clusterroles.yaml +++ b/deploy/base/cluster-wide/clusterroles.yaml @@ -52,6 +52,17 @@ rules: - create - update - delete +# The user-access operational index keeps an authoritative list/watch projection +# for these three cluster-readable families. Keep watch separate so the existing +# CommunicationChannel CRUD rule does not gain an unrelated watch capability. +- apiGroups: + - clerum.io + resources: + - hosts + - contexts + - mcpservers + verbs: + - watch # workflowrecipepolicies: READ-ONLY access so the /admin/recipes/validate # handler can check the agenticWorkflowContextRefBlocked invariant (spec # §270-271 / §1441). control-api never mutates policies — those are authored diff --git a/deploy/base/mcp-host/rbac.yaml b/deploy/base/mcp-host/rbac.yaml index 4acc543278..60b7d694eb 100644 --- a/deploy/base/mcp-host/rbac.yaml +++ b/deploy/base/mcp-host/rbac.yaml @@ -27,7 +27,7 @@ rules: # proxy endpoint reads the CRD before forwarding to the per-SFS wfc. - apiGroups: ['clerum.io'] resources: ['sharedfilesystems'] - verbs: ['get', 'list', 'create', 'update', 'delete'] + verbs: ['get', 'list', 'watch', 'create', 'update', 'delete'] - apiGroups: [''] resources: ['secrets'] verbs: ['get', 'list', 'create', 'update', 'delete'] diff --git a/deploy/base/sandbox-recipes/rbac.yaml b/deploy/base/sandbox-recipes/rbac.yaml index a14073e6ec..82ac660593 100644 --- a/deploy/base/sandbox-recipes/rbac.yaml +++ b/deploy/base/sandbox-recipes/rbac.yaml @@ -7,7 +7,7 @@ metadata: rules: - apiGroups: ["clerum.io"] resources: ["workflowrecipes"] - verbs: ["get", "list", "create", "update", "delete"] + verbs: ["get", "list", "watch", "create", "update", "delete"] - apiGroups: [""] resources: ["secrets"] verbs: ["get", "list", "create", "update", "patch", "delete"] From 2b89a55886c084ebba88b845d5fd1b1cacdddb24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 02:59:52 -0600 Subject: [PATCH 027/528] feat(access): publish validated foundation readiness Enable the operational indexer in the base deployment and publish all twelve producer families only after the real PostgreSQL and Minikube contract evidence passed. Keep aggregate serving, shadow computation, action context, RPC v2, Desktop, Profile, and minimum-client enforcement disabled by default. Catalog parity remains unaccepted. --- .../src/services/access/userAccessPolicy.ts | 20 +++++++++---------- .../test/network.gateway.intent.test.ts | 3 +++ .../test/routes.externalAccess.test.ts | 15 +++++++++++++- control-api/test/userAccessPolicy.test.ts | 2 +- deploy/base/control-plane/configmaps.yaml | 4 ++++ 5 files changed, 32 insertions(+), 12 deletions(-) diff --git a/control-api/src/services/access/userAccessPolicy.ts b/control-api/src/services/access/userAccessPolicy.ts index bb9bcb4734..4940b1eac7 100644 --- a/control-api/src/services/access/userAccessPolicy.ts +++ b/control-api/src/services/access/userAccessPolicy.ts @@ -326,26 +326,26 @@ export function loadConfiguredUserAccessIntent(env: NodeJS.ProcessEnv): Configur } export const reconstructionReadiness: DeploymentReadiness = Object.freeze({ - revision: 'pr1-reconstruction-auth-v2', + revision: 'pr1-foundation-validated-v1', snapshot: 'current', legacySession: 'ready', legacySwitchAuthorization: 'ready', legacyDrainAccepted: false, sessionV2Acceptance: 'ready', sessionV2Issuance: 'ready', - accessBudget: 'unavailable', - catalogCoordinator: 'unavailable', - catalogRegisteredFamilies: new Set(), - catalogHarnessFamilies: new Set(), - catalogOperationalFamilies: new Set(), - catalogShadowComparison: 'unavailable', + accessBudget: 'ready', + catalogCoordinator: 'ready', + catalogRegisteredFamilies: new Set(CATALOG_FAMILIES), + catalogHarnessFamilies: new Set(CATALOG_FAMILIES), + catalogOperationalFamilies: new Set(CATALOG_FAMILIES), + catalogShadowComparison: 'ready', catalogParityAccepted: false, - actionSafeRevisions: 'unavailable', - actionContext: 'unavailable', + actionSafeRevisions: 'ready', + actionContext: 'ready', // PR 2 owns the rpc-proxy and mcp-host hops. This stays unavailable in PR 1. rpcDelegationAllHops: 'unavailable', desktop: 'unavailable', - explicitTeamAdapters: 'unavailable', + explicitTeamAdapters: 'ready', profile: 'unavailable', minimumClientEnforcement: 'unavailable', }) diff --git a/control-api/test/network.gateway.intent.test.ts b/control-api/test/network.gateway.intent.test.ts index e2b559f2d3..43c99c56ab 100644 --- a/control-api/test/network.gateway.intent.test.ts +++ b/control-api/test/network.gateway.intent.test.ts @@ -1104,6 +1104,9 @@ describe('network/gateway intent (manifest-level)', () => { expect(roleRulesForResource(controlApiWorkflowRole, 'workflowrecipes')).toSatisfy(rules => rules.some(rule => rule.includes('watch')) ) + + const controlApiConfig = read(`${BASE}/control-plane/configmaps.yaml`) + expect(controlApiConfig).toContain("CONTROL_API_OPERATIONAL_ACCESS_INDEXER_ENABLED: 'true'") }) it('routes every rpc-proxy internal-service endpoint through the control-api-rpc-gateway allowlist', () => { diff --git a/control-api/test/routes.externalAccess.test.ts b/control-api/test/routes.externalAccess.test.ts index ea517d5e19..546f312116 100644 --- a/control-api/test/routes.externalAccess.test.ts +++ b/control-api/test/routes.externalAccess.test.ts @@ -66,7 +66,20 @@ describe('external user-access contracts', () => { desktopAllTeamMode: false, profileV2Mode: false, minimumClientEnforced: false, - catalogFamilies: [], + catalogFamilies: [ + 'user', + 'team', + 'host', + 'context', + 'mcp_server', + 'workflow_recipe', + 'workflow_run', + 'workflow_approval', + 'notification', + 'gfs_resource', + 'shared_filesystem', + 'sandbox_app', + ], }) ) }) diff --git a/control-api/test/userAccessPolicy.test.ts b/control-api/test/userAccessPolicy.test.ts index 125c7a8dca..a1c0f3ef7b 100644 --- a/control-api/test/userAccessPolicy.test.ts +++ b/control-api/test/userAccessPolicy.test.ts @@ -73,7 +73,7 @@ describe('central user-access rollout compiler', () => { desktopAllTeamMode: false, profileV2Mode: false, enforceMinimumClient: false, - advertisedCatalogFamilies: [], + advertisedCatalogFamilies: CATALOG_FAMILIES, }) }) diff --git a/deploy/base/control-plane/configmaps.yaml b/deploy/base/control-plane/configmaps.yaml index 4ef42b1ec5..aee4124e51 100644 --- a/deploy/base/control-plane/configmaps.yaml +++ b/deploy/base/control-plane/configmaps.yaml @@ -15,6 +15,10 @@ data: CONTROL_API_MCP_SERVERS_NAMESPACE: mcp-server CONTROL_API_COMMUNICATION_CHANNELS_NAMESPACE: channels CONTROL_API_SECRETS_NAMESPACE: mcp-host + # Maintain the bounded resource/relationship projection even while aggregate + # serving and action-context gates remain disabled. The index contains no + # effective user or team authorization and fails unavailable on source drift. + CONTROL_API_OPERATIONAL_ACCESS_INDEXER_ENABLED: 'true' CONTROL_API_JWT_ISSUER: control-api CONTROL_API_JWT_AUDIENCE: dev-profile-ui CONTROL_API_RPC_JWT_ISSUER: control-api From 2c7636d6c3b4c0fe8ec42db6b1892bd25989a42f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 03:34:02 -0600 Subject: [PATCH 028/528] perf(access): bound remaining catalog query plans Class-check: performance and authenticated availability. Repro-test: the real PostgreSQL high-cardinality plan gate failed when approval and sandbox producers sorted more than the bounded page envelope. Use ordered existence checks for live workflow authority and add a target-ordered relationship index so graph-backed producers can stop at the requested keyset page. Validation: the high-cardinality plan suite passed three consecutive runs, and focused migration, producer, property, and build gates passed. --- .../src/services/access/catalogProducerSql.ts | 56 +++++++++++-------- .../access/userAccessFoundationSchema.ts | 5 ++ 2 files changed, 39 insertions(+), 22 deletions(-) diff --git a/control-api/src/services/access/catalogProducerSql.ts b/control-api/src/services/access/catalogProducerSql.ts index 77602566ba..e4e054d379 100644 --- a/control-api/src/services/access/catalogProducerSql.ts +++ b/control-api/src/services/access/catalogProducerSql.ts @@ -242,14 +242,18 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f sql: `SELECT war.id::text AS logical_id, war.id AS cursor_id, war.expires_at AS valid_until FROM workflow_approval_requests war - JOIN team_members tm - ON tm.team_id = war.target_team_id AND tm.user_id = $1 AND tm.status = 'active' - JOIN team_workflow_triggers twt - ON twt.team_id = tm.team_id - AND twt.recipe_namespace = war.recipe_namespace - AND twt.recipe_name = war.recipe_name WHERE war.status = 'pending' AND war.expires_at > NOW() - AND war.id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000')`, + AND war.id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000') + AND EXISTS ( + SELECT 1 + FROM team_members tm + JOIN team_workflow_triggers twt + ON twt.team_id = tm.team_id + AND twt.recipe_namespace = war.recipe_namespace + AND twt.recipe_name = war.recipe_name + WHERE tm.team_id = war.target_team_id + AND tm.user_id = $1 AND tm.status = 'active' + )`, orderBy: 'cursor_id', hasValidUntil: true, }, @@ -369,13 +373,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f ]), sandbox_app: boundedKeyUnionSql([ `SELECT edge.target_id AS logical_id - FROM user_workflow_triggers uwt - JOIN operational_resource_relationships edge - ON edge.environment_id = $3 - AND edge.source_type = 'workflow_recipe' - AND edge.source_id = uwt.recipe_namespace || '/' || uwt.recipe_name - AND edge.relationship_type = 'exposes_sandbox_app' - AND edge.target_type = 'sandbox_app' + FROM operational_resource_relationships edge JOIN operational_resource_index source_resource ON source_resource.environment_id = $3 AND source_resource.resource_type = 'workflow_recipe' @@ -385,16 +383,19 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f ON target_resource.environment_id = $3 AND target_resource.resource_type = 'sandbox_app' AND target_resource.logical_id = edge.target_id AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL - WHERE uwt.user_id = $1 AND edge.target_id > $2`, - `SELECT edge.target_id AS logical_id - FROM team_workflow_triggers twt - JOIN team_members tm ON tm.team_id = twt.team_id - JOIN operational_resource_relationships edge - ON edge.environment_id = $3 + WHERE edge.environment_id = $3 AND edge.source_type = 'workflow_recipe' - AND edge.source_id = twt.recipe_namespace || '/' || twt.recipe_name AND edge.relationship_type = 'exposes_sandbox_app' AND edge.target_type = 'sandbox_app' + AND edge.target_id > $2 + AND EXISTS ( + SELECT 1 + FROM user_workflow_triggers uwt + WHERE uwt.user_id = $1 + AND uwt.recipe_namespace || '/' || uwt.recipe_name = edge.source_id + )`, + `SELECT edge.target_id AS logical_id + FROM operational_resource_relationships edge JOIN operational_resource_index source_resource ON source_resource.environment_id = $3 AND source_resource.resource_type = 'workflow_recipe' @@ -404,6 +405,17 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f ON target_resource.environment_id = $3 AND target_resource.resource_type = 'sandbox_app' AND target_resource.logical_id = edge.target_id AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL - WHERE tm.user_id = $1 AND tm.status = 'active' AND edge.target_id > $2`, + WHERE edge.environment_id = $3 + AND edge.source_type = 'workflow_recipe' + AND edge.relationship_type = 'exposes_sandbox_app' + AND edge.target_type = 'sandbox_app' + AND edge.target_id > $2 + AND EXISTS ( + SELECT 1 + FROM team_workflow_triggers twt + JOIN team_members tm ON tm.team_id = twt.team_id + WHERE tm.user_id = $1 AND tm.status = 'active' + AND twt.recipe_namespace || '/' || twt.recipe_name = edge.source_id + )`, ]), }) diff --git a/control-api/src/services/access/userAccessFoundationSchema.ts b/control-api/src/services/access/userAccessFoundationSchema.ts index 36a15cab05..ac0d2d6820 100644 --- a/control-api/src/services/access/userAccessFoundationSchema.ts +++ b/control-api/src/services/access/userAccessFoundationSchema.ts @@ -172,6 +172,11 @@ export async function applyUserAccessFoundationSchema(db: DbClient): Promise Date: Tue, 11 Aug 2026 03:53:14 -0600 Subject: [PATCH 029/528] fix(access): release parent cancellation listeners Class-check: AccessExecutionBudget parentSignal -> 3 runtime sites -> fix the shared root path Repro-test: accessExecutionBudget.test.ts (fails at 622879a2 before listener cleanup) --- .../src/services/access/accessExecutionBudget.ts | 14 +++++++++++--- control-api/test/accessExecutionBudget.test.ts | 14 ++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/control-api/src/services/access/accessExecutionBudget.ts b/control-api/src/services/access/accessExecutionBudget.ts index 84848eb2a7..7b41265724 100644 --- a/control-api/src/services/access/accessExecutionBudget.ts +++ b/control-api/src/services/access/accessExecutionBudget.ts @@ -206,6 +206,8 @@ export class AccessExecutionBudget { private readonly reservedFromParent: AccessBudgetReservation private readonly reservationParentCounters: Map | null private timer?: ReturnType + private parentSignal?: AbortSignal + private parentAbortListener?: () => void private constructor( private readonly shared: SharedAccessBudgetState, @@ -246,9 +248,10 @@ export class AccessExecutionBudget { if (options.parentSignal) { if (options.parentSignal.aborted) controller.abort('cancelled') else { - options.parentSignal.addEventListener('abort', () => controller.abort('cancelled'), { - once: true, - }) + const onParentAbort = () => controller.abort('cancelled') + budget.parentSignal = options.parentSignal + budget.parentAbortListener = onParentAbort + options.parentSignal.addEventListener('abort', onParentAbort, { once: true }) } } return budget @@ -382,6 +385,11 @@ export class AccessExecutionBudget { this.closed = true if (!this.localCounters) { if (this.timer) clearTimeout(this.timer) + if (this.parentSignal && this.parentAbortListener) { + this.parentSignal.removeEventListener('abort', this.parentAbortListener) + this.parentSignal = undefined + this.parentAbortListener = undefined + } this.shared.controller.abort('closed') return } diff --git a/control-api/test/accessExecutionBudget.test.ts b/control-api/test/accessExecutionBudget.test.ts index 897e49cf64..d03abcf027 100644 --- a/control-api/test/accessExecutionBudget.test.ts +++ b/control-api/test/accessExecutionBudget.test.ts @@ -107,4 +107,18 @@ describe('AccessExecutionBudget', () => { expect(() => budget.statementTimeoutMs(now + 1_751)).toThrow(AccessBudgetExceededError) budget.close() }) + + it('releases a long-lived parent abort listener when a root budget closes', () => { + const parent = new AbortController() + const add = vi.spyOn(parent.signal, 'addEventListener') + const remove = vi.spyOn(parent.signal, 'removeEventListener') + const budget = AccessExecutionBudget.create('action', { parentSignal: parent.signal }) + + const listener = add.mock.calls.find(([event]) => event === 'abort')?.[1] + expect(listener).toBeTypeOf('function') + + budget.close() + + expect(remove).toHaveBeenCalledWith('abort', listener) + }) }) From 3a83f5792a3cb801f535a3d7a1c83e6133eb65e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 03:56:59 -0600 Subject: [PATCH 030/528] fix(access): abort Kubernetes transport work Class-check: bounded ResourceService Kubernetes calls -> 2 sites -> attach the shared signal Repro-test: services.resourceService.test.ts (fails at 1fbf7732 with detached work) --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../test/services.resourceService.test.ts | 37 ++++++++++++++----- 3 files changed, 31 insertions(+), 12 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 9c880649cf..681d569f40 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.681", + "version": "0.1.682", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.681", + "version": "0.1.682", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 2e64e8220d..e82aab44f9 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.681", + "version": "0.1.682", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/services.resourceService.test.ts b/control-api/test/services.resourceService.test.ts index 9e171d948a..7da2418bb5 100644 --- a/control-api/test/services.resourceService.test.ts +++ b/control-api/test/services.resourceService.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import type * as k8s from '@kubernetes/client-node' import { governedTraceAdministrativeIntentReconcileFailedTotal } from '../src/observability/metrics.js' import { K8sConflictError, @@ -120,7 +121,8 @@ describe('ResourceService bounded operational reads', () => { limit: 100, _continue: 'prior-page', timeoutSeconds: 5, - }) + }), + expect.objectContaining({ middlewareMergeStrategy: 'append' }) ) }) @@ -140,13 +142,26 @@ describe('ResourceService bounded operational reads', () => { expect(listNamespacedCustomObject).not.toHaveBeenCalled() }) - it('rejects the caller promptly when the shared request is cancelled', async () => { - let resolveList!: (value: unknown) => void + it('propagates cancellation into the Kubernetes transport and waits for it to stop', async () => { + let transportSignal: AbortSignal | undefined + let transportStopped = false const listNamespacedCustomObject = vi.fn( - () => - new Promise(resolve => { - resolveList = resolve + async (_request: unknown, options: { middleware: k8s.ObservableMiddleware[] }) => { + const context = { + setSignal: (signal: AbortSignal) => { + transportSignal = signal + }, + } + await options.middleware[0]!.pre(context as never).toPromise() + return new Promise((_resolve, reject) => { + const stop = () => { + transportStopped = true + reject(new Error('transport aborted')) + } + if (transportSignal!.aborted) stop() + else transportSignal!.addEventListener('abort', stop, { once: true }) }) + } ) const service = new ResourceService({ listNamespacedCustomObject } as never, 'control-plane', { hosts: 'mcp-host', @@ -159,8 +174,9 @@ describe('ResourceService bounded operational reads', () => { }) controller.abort('test-cancel') - await expect(pending).rejects.toBe('test-cancel') - resolveList({ items: [], metadata: { resourceVersion: '43' } }) + await expect(pending).rejects.toThrow('transport aborted') + expect(transportSignal).toBe(controller.signal) + expect(transportStopped).toBe(true) }) it('uses exact namespace/name lookup and maps a Kubernetes 404', async () => { @@ -175,7 +191,10 @@ describe('ResourceService bounded operational reads', () => { signal: new AbortController().signal, }) ).rejects.toBeInstanceOf(K8sNotFoundError) - expect(getNamespacedCustomObject).toHaveBeenCalledOnce() + expect(getNamespacedCustomObject).toHaveBeenCalledWith( + expect.objectContaining({ namespace: 'mcp-host', plural: 'hosts', name: 'host-a' }), + expect.objectContaining({ middlewareMergeStrategy: 'append' }) + ) }) }) From 0ac9859eb8562dd10b7d8f116734d43de2cb1817 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 04:02:39 -0600 Subject: [PATCH 031/528] fix(access): derive rollout readiness at runtime Class-check: effective user-access policy consumers -> 8 sites -> use one runtime compiler Repro-test: userAccessPolicy.test.ts (fails at b1a430e8 with static readiness) --- control-api/src/config.ts | 31 +++ control-api/src/routes/external/access.ts | 64 ++++-- .../services/access/accessCatalogShadow.ts | 26 ++- .../src/services/access/userAccessPolicy.ts | 26 +-- .../access/userAccessRuntimePolicy.ts | 186 ++++++++++++++++++ .../auth/externalSessionAuthentication.ts | 9 +- .../services/auth/externalSessionIssuance.ts | 9 +- .../auth/passwordSessionAuthentication.ts | 4 +- .../test/routes.externalAccess.test.ts | 15 +- control-api/test/userAccessPolicy.test.ts | 120 ++++++++++- 10 files changed, 427 insertions(+), 63 deletions(-) create mode 100644 control-api/src/services/access/userAccessRuntimePolicy.ts diff --git a/control-api/src/config.ts b/control-api/src/config.ts index 99ee5f8df9..7452a0a039 100644 --- a/control-api/src/config.ts +++ b/control-api/src/config.ts @@ -236,6 +236,10 @@ type Config = { // Stateless-agent wake endpoint: per-host wake rate limit + server-side // coalescence window for the wake-annotation projection. hostWakeRlPerMin: number + // Host artifact reads have an independent, user-and-Host-scoped budget. + // It is deliberately separate from the capacity-impacting wake budget. + hostArtifactReadRlPerMin: number + hostMessageRlPerMin: number hostWakeCoalesceWindowMs: number approvalMediumChallengeTtlSec: number telegramProviderEventChallengeTtlSec: number @@ -272,6 +276,8 @@ type Config = { sharedFilesystemsNamespace: string operationalAccessIndexerEnabled: boolean operationalAccessIndexerRetryMs: number + operationalAccessReadinessMaxAgeMs: number | null + userAccessCatalogActivationRecord: string // Audience claim used by browsing JWTs; must match WSF_JWT_AUDIENCE on // the per-SFS wfc Deployment. The signing key is reused from rpcJwtPrivateKey. wfcJwtAudience: string @@ -420,6 +426,16 @@ function positiveIntegerFromEnv(name: string, defaultValue: number): number { return value } +function optionalPositiveIntegerFromEnv(name: string): number | null { + const raw = process.env[name] + if (raw === undefined || raw.trim() === '') return null + const value = Number(raw) + if (!Number.isSafeInteger(value) || value < 1) { + throw new Error(`${name} must be a positive integer when configured`) + } + return value +} + /** * A non-negative integer env (>= 0). Unlike `positiveIntegerFromEnv`, 0 is a * valid, meaningful value here (the per-provider stale floor's default: guard @@ -1162,6 +1178,11 @@ export const config: Config = { // bursts from a few concurrent devices (4 x 3 = 12) plus a real message. // Derivation is regression-guarded by test/config.hostWakeRateLimit.test.ts. hostWakeRlPerMin: Number(process.env.CONTROL_API_HOST_WAKE_RL_PER_MIN || 30), + // Owner-approved Host artifact-read policy. This durable control-plane + // budget is enforced after live user/Host authorization and is shared by + // list and download reads across rpc-proxy replicas. + hostArtifactReadRlPerMin: 30, + hostMessageRlPerMin: positiveIntegerFromEnv('CONTROL_API_HOST_MESSAGE_RL_PER_MIN', 60), hostWakeCoalesceWindowMs: Number(process.env.CONTROL_API_HOST_WAKE_COALESCE_WINDOW_MS || 2000), approvalMediumChallengeTtlSec: Number( process.env.WORKFLOW_APPROVAL_MEDIUM_CHALLENGE_TTL_SEC || 60 * 60 @@ -1244,6 +1265,16 @@ export const config: Config = { 5_000, 100 ), + // No production freshness threshold is assumed. Catalog shadow/serve stays + // unavailable until an operator supplies a measured maximum source age. + operationalAccessReadinessMaxAgeMs: optionalPositiveIntegerFromEnv( + 'CONTROL_API_OPERATIONAL_ACCESS_READINESS_MAX_AGE_MS' + ), + // Versioned operator acceptance for aggregate catalog serving. The runtime + // validates the complete record against the active catalog configuration; + // absence or mismatch keeps serving fail-closed. + userAccessCatalogActivationRecord: + process.env.CONTROL_API_USER_ACCESS_CATALOG_ACTIVATION_RECORD ?? '', wfcJwtAudience: process.env.CONTROL_API_WFC_JWT_AUDIENCE || 'workspace-files-controller', wfcTokenTtlSeconds: Number(process.env.CONTROL_API_WFC_TOKEN_TTL_SECONDS || 300), gfsTokenAudience: process.env.CONTROL_API_GFS_JWT_AUDIENCE || 'gfs-controller', diff --git a/control-api/src/routes/external/access.ts b/control-api/src/routes/external/access.ts index 60ebd90a2a..7143c8e355 100644 --- a/control-api/src/routes/external/access.ts +++ b/control-api/src/routes/external/access.ts @@ -21,10 +21,8 @@ import { CATALOG_FAMILIES, type CatalogFamily } from '../../services/access/cata import { resolveLiveAuthorization } from '../../services/access/liveAuthorizationResolver.js' import { canonicalEnvironmentId } from '../../services/access/operationalAccessProjection.js' import { canonicalResourceIdentity } from '../../services/access/resourceIdentity.js' -import { - effectiveUserAccessPolicy, - userAccessCapabilityManifest, -} from '../../services/access/userAccessPolicy.js' +import { userAccessCapabilityManifest } from '../../services/access/userAccessPolicy.js' +import { resolveEffectiveUserAccessPolicy } from '../../services/access/userAccessRuntimePolicy.js' const CATALOG_RATE_LIMIT_PER_MINUTE = 10 const RESOLVE_RATE_LIMIT_PER_MINUTE = 10 @@ -86,17 +84,19 @@ function operationTarget( return normalized } -function requireEffectiveV2Contract( +async function requireEffectiveV2Contract( feature: 'catalog' | 'action', req: ExternalAuthedRequest, res: Parameters[1], next: () => void -): void { - const enabled = - feature === 'catalog' - ? effectiveUserAccessPolicy.serveCatalog - : effectiveUserAccessPolicy.actionContextV2 - if (req.externalAuth?.sessionContract !== 'v2' || !enabled) { +): Promise { + try { + const policy = await resolveEffectiveUserAccessPolicy() + const enabled = feature === 'catalog' ? policy.serveCatalog : policy.actionContextV2 + if (req.externalAuth?.sessionContract === 'v2' && enabled) { + next() + return + } sendPublicApiError( req, res, @@ -104,9 +104,16 @@ function requireEffectiveV2Contract( 'invalid_request', 'This access contract is not enabled for the current session.' ) - return + } catch { + sendPublicApiError( + req, + res, + 503, + 'authority_unavailable', + 'Access capability state is temporarily unavailable.', + true + ) } - next() } function sendCatalogError( @@ -156,17 +163,32 @@ export function createExternalAccessRouter(gateway: K8sGateway): Router { const router = Router() router.use('/external/access', requireValidExternalSessionTokenWithPublicErrors) - router.get('/external/access/capabilities', (req: ExternalAuthedRequest, res) => { - res.status(200).json({ - contractVersion: '2', - currentSessionContract: req.externalAuth!.sessionContract ?? 'v1', - ...userAccessCapabilityManifest(effectiveUserAccessPolicy), + router.get( + '/external/access/capabilities', + asyncHandler(async (req: ExternalAuthedRequest, res) => { + try { + const policy = await resolveEffectiveUserAccessPolicy() + res.status(200).json({ + contractVersion: '2', + currentSessionContract: req.externalAuth!.sessionContract ?? 'v1', + ...userAccessCapabilityManifest(policy), + }) + } catch { + sendPublicApiError( + req, + res, + 503, + 'authority_unavailable', + 'Access capability state is temporarily unavailable.', + true + ) + } }) - }) + ) router.get( '/external/access/catalog', - (req, res, next) => requireEffectiveV2Contract('catalog', req, res, next), + asyncHandler((req, res, next) => requireEffectiveV2Contract('catalog', req, res, next)), rateLimitMiddleware({ bucketType: 'external_access_catalog', maxPerMinute: CATALOG_RATE_LIMIT_PER_MINUTE, @@ -207,7 +229,7 @@ export function createExternalAccessRouter(gateway: K8sGateway): Router { router.post( '/external/access/resolve', - (req, res, next) => requireEffectiveV2Contract('action', req, res, next), + asyncHandler((req, res, next) => requireEffectiveV2Contract('action', req, res, next)), rateLimitMiddleware({ bucketType: 'external_access_resolve', maxPerMinute: RESOLVE_RATE_LIMIT_PER_MINUTE, diff --git a/control-api/src/services/access/accessCatalogShadow.ts b/control-api/src/services/access/accessCatalogShadow.ts index aa8d2fcec4..86ca3c5355 100644 --- a/control-api/src/services/access/accessCatalogShadow.ts +++ b/control-api/src/services/access/accessCatalogShadow.ts @@ -5,14 +5,18 @@ import { import type { ExternalSessionAuthorityContext } from '../auth/externalSessionAuthentication.js' import { type AccessCatalogItem, buildAccessCatalog } from './accessCatalogCoordinator.js' import { + ACCESS_EXECUTION_LIMIT_CLAMPS, AccessBudgetExceededError, AccessExecutionBudget, AccessExecutionCancelledError, } from './accessExecutionBudget.js' import type { CatalogFamily } from './catalogContracts.js' -import { effectiveUserAccessPolicy } from './userAccessPolicy.js' +import { configuredUserAccessIntent } from './userAccessPolicy.js' +import { resolveEffectiveUserAccessPolicy } from './userAccessRuntimePolicy.js' const SHADOW_MAX_IDENTITIES = 100 +const SHADOW_MAX_IN_FLIGHT = ACCESS_EXECUTION_LIMIT_CLAMPS.producerConcurrency +let shadowInFlight = 0 export type CatalogShadowScope = | Readonly<{ @@ -108,7 +112,14 @@ export async function compareAccessCatalogShadow( if (!input.legacyComplete) return record(input.family, 'skipped_legacy_incomplete') const legacy = safeLegacyIds(input.legacyLogicalIds) if (!legacy) return record(input.family, 'skipped_legacy_incomplete') - if (!(options.enabled ?? effectiveUserAccessPolicy.computeCatalogShadow)) { + if (options.enabled === undefined) { + try { + const policy = await resolveEffectiveUserAccessPolicy() + if (!policy.computeCatalogShadow) return 'skipped_unavailable' + } catch { + return record(input.family, 'skipped_unavailable') + } + } else if (!options.enabled) { return 'skipped_unavailable' } @@ -163,8 +174,15 @@ export function scheduleAccessCatalogShadow(input: { legacyComplete: boolean scope?: CatalogShadowScope }): void { - if (!input.session || !effectiveUserAccessPolicy.computeCatalogShadow) return + if (!input.session || configuredUserAccessIntent.catalogMode === 'off') return setImmediate(() => { - void compareAccessCatalogShadow({ ...input, session: input.session! }) + if (shadowInFlight >= SHADOW_MAX_IN_FLIGHT) { + record(input.family, 'skipped_capacity') + return + } + shadowInFlight += 1 + void compareAccessCatalogShadow({ ...input, session: input.session! }).finally(() => { + shadowInFlight = Math.max(0, shadowInFlight - 1) + }) }) } diff --git a/control-api/src/services/access/userAccessPolicy.ts b/control-api/src/services/access/userAccessPolicy.ts index 4940b1eac7..cc39229dad 100644 --- a/control-api/src/services/access/userAccessPolicy.ts +++ b/control-api/src/services/access/userAccessPolicy.ts @@ -163,14 +163,17 @@ export function compileUserAccessPolicy( ) } - const advertisedCatalogFamilies = intersection( - readiness.catalogRegisteredFamilies, - readiness.catalogHarnessFamilies, - readiness.catalogOperationalFamilies - ) - const everyCatalogFamilyReady = hasEveryCatalogFamily(advertisedCatalogFamilies) const wantsShadow = intent.catalogMode === 'shadow' || intent.catalogMode === 'serve_and_shadow' const wantsServe = intent.catalogMode === 'serve' || intent.catalogMode === 'serve_and_shadow' + const advertisedCatalogFamilies = + wantsShadow || wantsServe + ? intersection( + readiness.catalogRegisteredFamilies, + readiness.catalogHarnessFamilies, + readiness.catalogOperationalFamilies + ) + : [] + const everyCatalogFamilyReady = hasEveryCatalogFamily(advertisedCatalogFamilies) if (wantsShadow) { requireCondition(acceptV2, 'catalog_shadow_requires_v2_acceptance') @@ -337,11 +340,13 @@ export const reconstructionReadiness: DeploymentReadiness = Object.freeze({ catalogCoordinator: 'ready', catalogRegisteredFamilies: new Set(CATALOG_FAMILIES), catalogHarnessFamilies: new Set(CATALOG_FAMILIES), - catalogOperationalFamilies: new Set(CATALOG_FAMILIES), + catalogOperationalFamilies: new Set(), catalogShadowComparison: 'ready', catalogParityAccepted: false, actionSafeRevisions: 'ready', - actionContext: 'ready', + // The resolver exists for foundation validation, but its public rollout is + // owned by the later runtime unit and remains unavailable in PR 1. + actionContext: 'unavailable', // PR 2 owns the rpc-proxy and mcp-host hops. This stays unavailable in PR 1. rpcDelegationAllHops: 'unavailable', desktop: 'unavailable', @@ -350,10 +355,7 @@ export const reconstructionReadiness: DeploymentReadiness = Object.freeze({ minimumClientEnforcement: 'unavailable', }) -export const effectiveUserAccessPolicy = compileUserAccessPolicy( - loadConfiguredUserAccessIntent(process.env), - reconstructionReadiness -) +export const configuredUserAccessIntent = loadConfiguredUserAccessIntent(process.env) export function userAccessCapabilityManifest(policy: EffectiveUserAccessPolicy): Readonly<{ policyVersion: string diff --git a/control-api/src/services/access/userAccessRuntimePolicy.ts b/control-api/src/services/access/userAccessRuntimePolicy.ts new file mode 100644 index 0000000000..562026f606 --- /dev/null +++ b/control-api/src/services/access/userAccessRuntimePolicy.ts @@ -0,0 +1,186 @@ +import { config } from '../../config.js' +import type { DbClient } from '../../db.js' +import { pool } from '../../db.js' +import { runAccessDatabaseQuery } from './accessDatabaseQuery.js' +import { AccessExecutionBudget } from './accessExecutionBudget.js' +import { revisionOfValues } from './authorizationRevision.js' +import { + OPERATIONAL_SOURCE_FAMILIES, + type OperationalSourceFamily, + canonicalEnvironmentId, +} from './operationalAccessProjection.js' +import { + CATALOG_FAMILIES, + type CatalogFamily, + type ConfiguredUserAccessIntent, + type DeploymentReadiness, + type EffectiveUserAccessPolicy, + UserAccessPolicyConfigurationError, + compileUserAccessPolicy, + configuredUserAccessIntent, + reconstructionReadiness, +} from './userAccessPolicy.js' + +type RuntimeSourceState = Readonly<{ + family: OperationalSourceFamily + generation: number + resourceVersion: string + status: 'current' | 'relisting' | 'unavailable' + lastSuccessAt: Date +}> + +type RuntimePolicyOptions = Readonly<{ + intent?: ConfiguredUserAccessIntent + db?: Pick + budget?: AccessExecutionBudget + indexerEnabled?: boolean + readinessMaxAgeMs?: number | null + now?: Date +}> + +const DATABASE_ONLY_FAMILIES: readonly CatalogFamily[] = Object.freeze([ + 'user', + 'team', + 'workflow_run', + 'workflow_approval', + 'notification', + 'gfs_resource', +]) + +const REQUIRED_SOURCES: Readonly< + Partial> +> = Object.freeze({ + host: ['host'], + context: ['context'], + mcp_server: ['host', 'context', 'mcp_server'], + workflow_recipe: ['workflow_recipe'], + shared_filesystem: ['context', 'shared_filesystem'], + sandbox_app: ['workflow_recipe'], +}) + +function needsOperationalReadiness(intent: ConfiguredUserAccessIntent): boolean { + return intent.catalogMode !== 'off' +} + +function unavailableReadiness(): DeploymentReadiness { + return Object.freeze({ + ...reconstructionReadiness, + revision: 'runtime-readiness-unavailable', + snapshot: 'unavailable', + catalogOperationalFamilies: new Set(), + }) +} + +function parseSourceStates( + rows: readonly Record[] +): readonly RuntimeSourceState[] { + const seen = new Set() + return Object.freeze( + rows.map(row => { + const family = String(row.source_family) as OperationalSourceFamily + const generation = Number(row.generation) + const resourceVersion = typeof row.resource_version === 'string' ? row.resource_version : '' + const status = String(row.status) + const lastSuccessAt = row.last_success_at ? new Date(String(row.last_success_at)) : null + if ( + !OPERATIONAL_SOURCE_FAMILIES.includes(family) || + seen.has(family) || + !Number.isSafeInteger(generation) || + generation < 0 || + !resourceVersion || + !['current', 'relisting', 'unavailable'].includes(status) || + !lastSuccessAt || + !Number.isFinite(lastSuccessAt.getTime()) + ) { + throw new UserAccessPolicyConfigurationError('runtime_readiness_invalid') + } + seen.add(family) + return Object.freeze({ + family, + generation, + resourceVersion, + status: status as RuntimeSourceState['status'], + lastSuccessAt, + }) + }) + ) +} + +function runtimeReadiness( + states: readonly RuntimeSourceState[], + now: Date, + maxAgeMs: number +): DeploymentReadiness { + const currentSources = new Set() + for (const state of states) { + const age = now.getTime() - state.lastSuccessAt.getTime() + if (state.status === 'current' && age >= 0 && age <= maxAgeMs) { + currentSources.add(state.family) + } + } + const operationalFamilies = new Set(DATABASE_ONLY_FAMILIES) + for (const family of CATALOG_FAMILIES) { + const required = REQUIRED_SOURCES[family] + if (required?.every(source => currentSources.has(source))) operationalFamilies.add(family) + } + return Object.freeze({ + ...reconstructionReadiness, + revision: revisionOfValues([ + reconstructionReadiness.revision, + maxAgeMs, + states.map(state => [ + state.family, + state.generation, + state.resourceVersion, + state.status, + state.lastSuccessAt.toISOString(), + ]), + ]), + snapshot: 'current', + catalogOperationalFamilies: operationalFamilies, + }) +} + +export async function resolveEffectiveUserAccessPolicy( + options: RuntimePolicyOptions = {} +): Promise { + const intent = options.intent ?? configuredUserAccessIntent + if (!needsOperationalReadiness(intent)) { + return compileUserAccessPolicy(intent, reconstructionReadiness) + } + + const indexerEnabled = options.indexerEnabled ?? config.operationalAccessIndexerEnabled + const maxAgeMs = + options.readinessMaxAgeMs === undefined + ? config.operationalAccessReadinessMaxAgeMs + : options.readinessMaxAgeMs + if (!indexerEnabled || !Number.isSafeInteger(maxAgeMs) || Number(maxAgeMs) < 1) { + return compileUserAccessPolicy(intent, unavailableReadiness()) + } + const validatedMaxAgeMs = Number(maxAgeMs) + + const ownedBudget = options.budget ? null : AccessExecutionBudget.create('action') + const budget = options.budget ?? ownedBudget! + try { + const result = await runAccessDatabaseQuery( + options.db ?? pool, + budget, + `SELECT source_family, generation, resource_version, status, last_success_at + FROM operational_catalog_source_state + WHERE environment_id = $1 + AND source_family = ANY($2::text[]) + ORDER BY source_family`, + [canonicalEnvironmentId(), OPERATIONAL_SOURCE_FAMILIES] + ) + const states = parseSourceStates(result.rows as Record[]) + return compileUserAccessPolicy( + intent, + runtimeReadiness(states, options.now ?? new Date(), validatedMaxAgeMs) + ) + } catch (error) { + if (error instanceof UserAccessPolicyConfigurationError) throw error + return compileUserAccessPolicy(intent, unavailableReadiness()) + } finally { + ownedBudget?.close() + } +} diff --git a/control-api/src/services/auth/externalSessionAuthentication.ts b/control-api/src/services/auth/externalSessionAuthentication.ts index b3a2b97091..8a3074c66e 100644 --- a/control-api/src/services/auth/externalSessionAuthentication.ts +++ b/control-api/src/services/auth/externalSessionAuthentication.ts @@ -7,8 +7,8 @@ import { import { type EffectiveUserAccessPolicy, compareSemanticVersions, - effectiveUserAccessPolicy, } from '../access/userAccessPolicy.js' +import { resolveEffectiveUserAccessPolicy } from '../access/userAccessRuntimePolicy.js' import { type IssuedUserSession, renewUserSession, @@ -36,6 +36,7 @@ export type ExternalSessionAuthentication = claims: AuthClaims contract: 'v1' | 'v2' authorityContext: ExternalSessionAuthorityContext + policy: EffectiveUserAccessPolicy } | { status: 'invalid' | 'expired' | 'revoked'; reason: string } | { status: 'upgrade_required'; reason: string } @@ -131,7 +132,7 @@ export async function authenticateExternalUserSession( token: string, options: AuthenticationOptions ): Promise { - const policy = options.policy ?? effectiveUserAccessPolicy + const policy = options.policy ?? (await resolveEffectiveUserAccessPolicy()) if ( purposeRequiresMinimumClient(options.purpose) && !clientMeetsMinimum(options.client, policy) @@ -157,6 +158,7 @@ export async function authenticateExternalUserSession( jti: v2Claims.jti, sessionVersion: v2Claims.sv, }), + policy, } } @@ -177,6 +179,7 @@ export async function authenticateExternalUserSession( tokenHash: validation.identity.jti, issuedAt: v1Claims.iat!, }), + policy, } } @@ -184,7 +187,7 @@ export async function renewExternalUserSession( token: string, options: Omit = {} ): Promise { - const policy = options.policy ?? effectiveUserAccessPolicy + const policy = options.policy ?? (await resolveEffectiveUserAccessPolicy()) if (!clientMeetsMinimum(options.client, policy)) { return { status: 'upgrade_required', reason: 'minimum_client_version' } } diff --git a/control-api/src/services/auth/externalSessionIssuance.ts b/control-api/src/services/auth/externalSessionIssuance.ts index 711c08f26c..a28b31e937 100644 --- a/control-api/src/services/auth/externalSessionIssuance.ts +++ b/control-api/src/services/auth/externalSessionIssuance.ts @@ -4,7 +4,6 @@ import { signExternalSessionToken } from '../../utils/auth/externalSessionAuthTo import { type EffectiveUserAccessPolicy, compareSemanticVersions, - effectiveUserAccessPolicy, } from '../access/userAccessPolicy.js' import type { ExternalSessionClient } from './externalSessionAuthentication.js' import { createUserSession } from './userSessionService.js' @@ -17,7 +16,7 @@ export type ExternalSessionSelection = export function selectExternalSessionRepresentation( client: ExternalSessionClient, - policy: EffectiveUserAccessPolicy = effectiveUserAccessPolicy + policy: EffectiveUserAccessPolicy ): ExternalSessionSelection { if (policy.enforceMinimumClient && policy.minimumClientVersion) { const comparison = client.version @@ -52,10 +51,10 @@ export async function issueExternalUserSession( }, options: { db?: Pick - policy?: EffectiveUserAccessPolicy - } = {} + policy: EffectiveUserAccessPolicy + } ): Promise<{ token: string; contract: ExternalSessionContract }> { - const policy = options.policy ?? effectiveUserAccessPolicy + const policy = options.policy if (input.contract === 'v2') { if (!policy.issueV2 || !policy.acceptV2) { throw new Error('user-session v2 issuance is not effective') diff --git a/control-api/src/services/auth/passwordSessionAuthentication.ts b/control-api/src/services/auth/passwordSessionAuthentication.ts index 8fbacb9b06..cecbb848ea 100644 --- a/control-api/src/services/auth/passwordSessionAuthentication.ts +++ b/control-api/src/services/auth/passwordSessionAuthentication.ts @@ -1,4 +1,5 @@ import { type DbClient, withTransaction } from '../../db.js' +import type { EffectiveUserAccessPolicy } from '../access/userAccessPolicy.js' import { passwordLoginData } from '../directory/login.js' import { type ExternalSessionContract, @@ -24,6 +25,7 @@ export async function authenticatePasswordAndIssueSession(input: { email: string password: string contract: ExternalSessionContract + policy: EffectiveUserAccessPolicy }) { const login = await passwordLoginData(input) if (!login || !('user' in login)) return login @@ -50,7 +52,7 @@ export async function authenticatePasswordAndIssueSession(input: { role: authenticatedLogin.membership.role, authenticationMethods: ['pwd'], }, - { db } + { db, policy: input.policy } ) return { ...authenticatedLogin, issued } }) diff --git a/control-api/test/routes.externalAccess.test.ts b/control-api/test/routes.externalAccess.test.ts index 546f312116..ea517d5e19 100644 --- a/control-api/test/routes.externalAccess.test.ts +++ b/control-api/test/routes.externalAccess.test.ts @@ -66,20 +66,7 @@ describe('external user-access contracts', () => { desktopAllTeamMode: false, profileV2Mode: false, minimumClientEnforced: false, - catalogFamilies: [ - 'user', - 'team', - 'host', - 'context', - 'mcp_server', - 'workflow_recipe', - 'workflow_run', - 'workflow_approval', - 'notification', - 'gfs_resource', - 'shared_filesystem', - 'sandbox_app', - ], + catalogFamilies: [], }) ) }) diff --git a/control-api/test/userAccessPolicy.test.ts b/control-api/test/userAccessPolicy.test.ts index a1c0f3ef7b..f5bd857763 100644 --- a/control-api/test/userAccessPolicy.test.ts +++ b/control-api/test/userAccessPolicy.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from 'vitest' +import { vi } from 'vitest' +import { OPERATIONAL_SOURCE_FAMILIES } from '../src/services/access/operationalAccessProjection.js' import { CATALOG_FAMILIES, type ConfiguredUserAccessIntent, @@ -6,9 +8,10 @@ import { UserAccessPolicyConfigurationError, compareSemanticVersions, compileUserAccessPolicy, - effectiveUserAccessPolicy, loadConfiguredUserAccessIntent, + reconstructionReadiness, } from '../src/services/access/userAccessPolicy.js' +import { resolveEffectiveUserAccessPolicy } from '../src/services/access/userAccessRuntimePolicy.js' const allFamilies = new Set(CATALOG_FAMILIES) @@ -59,7 +62,9 @@ function readiness(overrides: Partial = {}): DeploymentRead describe('central user-access rollout compiler', () => { it('keeps the reconstruction defaults dual-compatible and user-visible gates off', () => { expect(loadConfiguredUserAccessIntent({})).toEqual(intent()) - expect(effectiveUserAccessPolicy).toMatchObject({ + expect( + compileUserAccessPolicy(loadConfiguredUserAccessIntent({}), reconstructionReadiness) + ).toMatchObject({ acceptV1: true, issueV1: true, acceptV2: true, @@ -73,7 +78,7 @@ describe('central user-access rollout compiler', () => { desktopAllTeamMode: false, profileV2Mode: false, enforceMinimumClient: false, - advertisedCatalogFamilies: CATALOG_FAMILIES, + advertisedCatalogFamilies: [], }) }) @@ -221,4 +226,113 @@ describe('central user-access rollout compiler', () => { expect(compareSemanticVersions('2.2.9', '2.3.0')).toBe(-1) expect(compareSemanticVersions('unbounded', '2.3.0')).toBeNull() }) + + it('advertises shadow only from a fresh complete runtime readiness snapshot', async () => { + const now = new Date('2026-08-11T10:00:00.000Z') + const query = vi.fn().mockResolvedValue({ + rows: OPERATIONAL_SOURCE_FAMILIES.map((source_family, index) => ({ + source_family, + generation: index + 1, + resource_version: String(index + 10), + status: 'current', + last_success_at: new Date(now.getTime() - 1_000), + })), + }) + + const policy = await resolveEffectiveUserAccessPolicy({ + intent: intent({ catalogMode: 'shadow' }), + db: { query } as never, + indexerEnabled: true, + readinessMaxAgeMs: 5_000, + now, + }) + + expect(policy.computeCatalogShadow).toBe(true) + expect(policy.advertisedCatalogFamilies).toEqual(CATALOG_FAMILIES) + expect(query).toHaveBeenCalledOnce() + }) + + it.each([ + ['missing', OPERATIONAL_SOURCE_FAMILIES.slice(1), 'catalog_shadow_families_incomplete'], + ['mixed', OPERATIONAL_SOURCE_FAMILIES, 'catalog_shadow_families_incomplete'], + ['expired', OPERATIONAL_SOURCE_FAMILIES, 'catalog_shadow_families_incomplete'], + ])( + 'fails shadow readiness closed for a %s operational snapshot', + async (kind, families, code) => { + const now = new Date('2026-08-11T10:00:00.000Z') + const query = vi.fn().mockResolvedValue({ + rows: families.map((source_family, index) => ({ + source_family, + generation: index + 1, + resource_version: String(index + 10), + status: kind === 'mixed' && index === 0 ? 'relisting' : 'current', + last_success_at: new Date(now.getTime() - (kind === 'expired' ? 5_001 : 1_000)), + })), + }) + + await expect( + resolveEffectiveUserAccessPolicy({ + intent: intent({ catalogMode: 'shadow' }), + db: { query } as never, + indexerEnabled: true, + readinessMaxAgeMs: 5_000, + now, + }) + ).rejects.toThrowError(new UserAccessPolicyConfigurationError(code)) + } + ) + + it('does not query operational readiness or advertise families while catalog rollout is off', async () => { + const query = vi.fn() + const policy = await resolveEffectiveUserAccessPolicy({ + intent: intent(), + db: { query } as never, + indexerEnabled: true, + readinessMaxAgeMs: null, + }) + + expect(query).not.toHaveBeenCalled() + expect(policy.advertisedCatalogFamilies).toEqual([]) + expect(policy.computeCatalogShadow).toBe(false) + }) + + it('fails configured shadow closed when freshness evidence or its authority is unavailable', async () => { + await expect( + resolveEffectiveUserAccessPolicy({ + intent: intent({ catalogMode: 'shadow' }), + indexerEnabled: true, + readinessMaxAgeMs: null, + }) + ).rejects.toThrowError(new UserAccessPolicyConfigurationError('readiness_snapshot_unavailable')) + + await expect( + resolveEffectiveUserAccessPolicy({ + intent: intent({ catalogMode: 'shadow' }), + db: { query: vi.fn().mockRejectedValue(new Error('authority unavailable')) } as never, + indexerEnabled: true, + readinessMaxAgeMs: 5_000, + }) + ).rejects.toThrowError(new UserAccessPolicyConfigurationError('readiness_snapshot_unavailable')) + }) + + it('rejects an unparseable runtime readiness row', async () => { + await expect( + resolveEffectiveUserAccessPolicy({ + intent: intent({ catalogMode: 'shadow' }), + db: { + query: vi.fn().mockResolvedValue({ + rows: [{ source_family: 'host', generation: 'invalid' }], + }), + } as never, + indexerEnabled: true, + readinessMaxAgeMs: 5_000, + }) + ).rejects.toThrowError(new UserAccessPolicyConfigurationError('runtime_readiness_invalid')) + }) + + it('keeps PR 1 action context unavailable even when configured', async () => { + await expect( + resolveEffectiveUserAccessPolicy({ intent: intent({ actionContextV2: true }) }) + ).rejects.toThrowError(new UserAccessPolicyConfigurationError('action_context_unavailable')) + }) }) From 8f7f47b1477f5bdb9ecb0f1d2404ab8bb0d65623 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 04:12:42 -0600 Subject: [PATCH 032/528] fix(access): bind access work to request budgets Class-check: access authentication and transaction entrypoints -> 4 paths -> share one budget Repro-test: routes.externalAccess and accessDatabaseCancellation (fail at fc4a7000) --- control-api/src/routes/external/access.ts | 61 ++++++-- .../access/accessCatalogCoordinator.ts | 8 +- .../services/access/accessDatabaseQuery.ts | 134 +++++++++++++++++- .../services/access/accessExecutionBudget.ts | 4 + .../access/liveAuthorizationResolver.ts | 24 ++-- .../access/userAccessRuntimePolicy.ts | 31 ++-- .../auth/externalSessionAuthentication.ts | 19 ++- .../src/services/auth/userSessionService.ts | 91 ++++++++---- ...cellation.realPostgres.integration.test.ts | 65 +++++++++ .../test/routes.externalAccess.test.ts | 34 ++++- control-api/test/userSessionService.test.ts | 2 +- 11 files changed, 400 insertions(+), 73 deletions(-) diff --git a/control-api/src/routes/external/access.ts b/control-api/src/routes/external/access.ts index 7143c8e355..4f3974fb69 100644 --- a/control-api/src/routes/external/access.ts +++ b/control-api/src/routes/external/access.ts @@ -14,6 +14,7 @@ import { import { AccessCatalogCursorError } from '../../services/access/accessCatalogCursor.js' import { AccessBudgetExceededError, + AccessExecutionBudget, AccessExecutionCancelledError, } from '../../services/access/accessExecutionBudget.js' import { isAccessCapability } from '../../services/access/capabilityRegistry.js' @@ -28,6 +29,40 @@ const CATALOG_RATE_LIMIT_PER_MINUTE = 10 const RESOLVE_RATE_LIMIT_PER_MINUTE = 10 const ACCESS_PATH_PATTERN = /^ap1_[A-Za-z0-9_-]{43}$/ +export function attachAccessExecutionBudget( + req: ExternalAuthedRequest, + res: Parameters[1], + next: () => void +): void { + const kind = req.method === 'GET' && req.path.endsWith('/catalog') ? 'catalog' : 'action' + const budget = AccessExecutionBudget.create(kind) + req.accessExecutionBudget = budget + let settled = false + const detach = () => { + req.removeListener('aborted', onAborted) + res.removeListener('finish', onFinished) + res.removeListener('close', onClosed) + } + const onAborted = () => budget.cancel() + const onFinished = () => { + if (settled) return + settled = true + detach() + budget.close() + } + const onClosed = () => { + if (settled) return + settled = true + if (!res.writableEnded) budget.cancel() + detach() + budget.close() + } + req.once('aborted', onAborted) + res.once('finish', onFinished) + res.once('close', onClosed) + next() +} + function catalogFamilies(value: unknown): CatalogFamily[] | null | undefined { if (value === undefined) return undefined if (typeof value !== 'string') return null @@ -91,7 +126,7 @@ async function requireEffectiveV2Contract( next: () => void ): Promise { try { - const policy = await resolveEffectiveUserAccessPolicy() + const policy = await resolveEffectiveUserAccessPolicy({ budget: req.accessExecutionBudget }) const enabled = feature === 'catalog' ? policy.serveCatalog : policy.actionContextV2 if (req.externalAuth?.sessionContract === 'v2' && enabled) { next() @@ -161,13 +196,14 @@ function sendCatalogError( export function createExternalAccessRouter(gateway: K8sGateway): Router { const router = Router() + router.use('/external/access', attachAccessExecutionBudget) router.use('/external/access', requireValidExternalSessionTokenWithPublicErrors) router.get( '/external/access/capabilities', asyncHandler(async (req: ExternalAuthedRequest, res) => { try { - const policy = await resolveEffectiveUserAccessPolicy() + const policy = await resolveEffectiveUserAccessPolicy({ budget: req.accessExecutionBudget }) res.status(200).json({ contractVersion: '2', currentSessionContract: req.externalAuth!.sessionContract ?? 'v1', @@ -214,12 +250,15 @@ export function createExternalAccessRouter(gateway: K8sGateway): Router { return } try { - const catalog = await buildAccessCatalog({ - session: req.externalSessionAuthority!, - ...(families ? { families } : {}), - ...(limit ? { limit } : {}), - ...(cursor ? { cursor } : {}), - }) + const catalog = await buildAccessCatalog( + { + session: req.externalSessionAuthority!, + ...(families ? { families } : {}), + ...(limit ? { limit } : {}), + ...(cursor ? { cursor } : {}), + }, + { budget: req.accessExecutionBudget! } + ) res.status(200).json(catalog) } catch (error) { sendCatalogError(req, res, error) @@ -290,7 +329,11 @@ export function createExternalAccessRouter(gateway: K8sGateway): Router { ...(accessPathId ? { requestedAccessPathId: accessPathId } : {}), ...(target ? { operationTarget: target } : {}), }, - { gateway, correlationId: req.correlationId } + { + gateway, + correlationId: req.correlationId, + budget: req.accessExecutionBudget!, + } ) if (result.status === 'allowed') { res.status(200).json({ diff --git a/control-api/src/services/access/accessCatalogCoordinator.ts b/control-api/src/services/access/accessCatalogCoordinator.ts index 3158671760..fff9b8dceb 100644 --- a/control-api/src/services/access/accessCatalogCoordinator.ts +++ b/control-api/src/services/access/accessCatalogCoordinator.ts @@ -1,5 +1,4 @@ import type { DbClient } from '../../db.js' -import { withTransaction } from '../../db.js' import type { ExternalSessionAuthorityContext } from '../auth/externalSessionAuthentication.js' import { configureAccessAuthorityTransaction } from './accessAuthorityTransaction.js' import { @@ -16,6 +15,7 @@ import { mergeCatalogCandidateStreams, mergeHydratedCatalogResources, } from './accessCatalogMerge.js' +import { withAccessDatabaseTransaction } from './accessDatabaseQuery.js' import { AccessExecutionBudget, type AccessExecutionLimits } from './accessExecutionBudget.js' import { buildAccessPath, canonicalAccessPathTuple } from './accessPath.js' import { authorizationRevision, revisionOfValues } from './authorizationRevision.js' @@ -370,7 +370,7 @@ export async function buildAccessCatalog( budget.assertPageSize(limit) const filterHash = catalogFilterHash(families) const decodedCursor = input.cursor ? decodeAccessCatalogCursor(input.cursor, budget) : null - return await (options.transaction ?? withTransaction)(db => + const work = (db: DbClient) => buildInTransaction({ db, budget, @@ -382,7 +382,9 @@ export async function buildAccessCatalog( filterHash, now: options.now ?? new Date(), }) - ) + return await (options.transaction + ? options.transaction(work) + : withAccessDatabaseTransaction(budget, work, { mode: 'caller_configured' })) } catch (error) { if (error instanceof CatalogAuthorityError) { throw new AccessCatalogRequestError( diff --git a/control-api/src/services/access/accessDatabaseQuery.ts b/control-api/src/services/access/accessDatabaseQuery.ts index 4876bbea26..374a521bd9 100644 --- a/control-api/src/services/access/accessDatabaseQuery.ts +++ b/control-api/src/services/access/accessDatabaseQuery.ts @@ -1,5 +1,5 @@ -import { Client, type ClientConfig } from 'pg' -import type { DbClient } from '../../db.js' +import { Client, type ClientConfig, type PoolClient } from 'pg' +import { type DbClient, pool } from '../../db.js' import { AccessBudgetExceededError, type AccessExecutionBudget } from './accessExecutionBudget.js' type PostgresConnectionParameters = Readonly<{ @@ -67,9 +67,13 @@ export async function runAccessDatabaseQuery( budget: AccessExecutionBudget, text: string, values: unknown[] = [], - options: { chargeRows?: boolean } = {} + options: { chargeRows?: boolean; chargeProducer?: boolean } = {} ) { - return budget.runProducer(async signal => { + const run = + options.chargeProducer === false + ? budget.runBoundedWork.bind(budget) + : budget.runProducer.bind(budget) + return run(async signal => { budget.assertActive() let cancellation: Promise | undefined const onAbort = () => { @@ -93,7 +97,127 @@ export async function runAccessDatabaseQuery( throw error } finally { signal.removeEventListener('abort', onAbort) - void cancellation + if (cancellation) await cancellation } }) } + +async function acquireAccessDatabaseClient( + budget: AccessExecutionBudget, + connectionPool: Pick +): Promise { + let acquired: PoolClient | undefined + let acquiredReleased = false + const releaseAcquired = (error?: Error | boolean) => { + if (!acquired || acquiredReleased) return + acquiredReleased = true + acquired.release(error) + } + try { + return await budget.runBoundedWork( + signal => + new Promise((resolve, reject) => { + let settled = false + const rejectForAbort = () => { + if (settled) return + settled = true + try { + budget.assertActive() + } catch (error) { + reject(error) + } + } + signal.addEventListener('abort', rejectForAbort, { once: true }) + if (signal.aborted) rejectForAbort() + Promise.resolve(connectionPool.connect()).then( + client => { + acquired = client as PoolClient + if (settled) { + releaseAcquired(true) + return + } + settled = true + signal.removeEventListener('abort', rejectForAbort) + resolve(acquired) + }, + error => { + if (settled) return + settled = true + signal.removeEventListener('abort', rejectForAbort) + reject(error) + } + ) + }) + ) + } catch (error) { + releaseAcquired(error instanceof Error ? error : true) + throw error + } +} + +export async function withAccessDatabaseTransaction( + budget: AccessExecutionBudget, + work: (db: DbClient) => Promise, + options: { + mode?: 'read_only' | 'read_write' | 'caller_configured' + connectionPool?: Pick + } = {} +): Promise { + budget.assertActive() + const client = await acquireAccessDatabaseClient(budget, options.connectionPool ?? pool) + let transactionStarted = false + let commitSent = false + let releaseError: Error | boolean | undefined + const query = (text: string, values?: unknown[]) => + runAccessDatabaseQuery(client, budget, text, values ?? []) + const budgetedDb = Object.assign(Object.create(client), { query }) as DbClient + try { + budget.assertActive() + await runAccessDatabaseQuery(client, budget, 'BEGIN', [], { + chargeRows: false, + chargeProducer: false, + }) + transactionStarted = true + if (options.mode !== 'caller_configured') { + if (options.mode === 'read_only') { + await runAccessDatabaseQuery(client, budget, 'SET TRANSACTION READ ONLY', [], { + chargeRows: false, + chargeProducer: false, + }) + } + await runAccessDatabaseQuery( + client, + budget, + `SELECT set_config('statement_timeout', $1, true)`, + [`${budget.statementTimeoutMs()}ms`], + { chargeRows: false, chargeProducer: false } + ) + } + const result = await work( + options.mode === 'caller_configured' ? (client as DbClient) : budgetedDb + ) + budget.assertActive() + commitSent = true + await runAccessDatabaseQuery(client, budget, 'COMMIT', [], { + chargeRows: false, + chargeProducer: false, + }) + return result + } catch (error) { + if (commitSent || !transactionStarted) { + releaseError = error instanceof Error ? error : true + } else { + try { + await runAccessDatabaseQuery(client, budget, 'ROLLBACK', [], { + chargeRows: false, + chargeProducer: false, + }) + } catch (rollbackError) { + releaseError = rollbackError instanceof Error ? rollbackError : true + } + } + throw error + } finally { + client.release(releaseError) + } +} diff --git a/control-api/src/services/access/accessExecutionBudget.ts b/control-api/src/services/access/accessExecutionBudget.ts index 7b41265724..403adc42e4 100644 --- a/control-api/src/services/access/accessExecutionBudget.ts +++ b/control-api/src/services/access/accessExecutionBudget.ts @@ -365,6 +365,10 @@ export class AccessExecutionBudget { async runProducer(work: (signal: AbortSignal) => Promise): Promise { this.charge({ kind: 'producerCalls' }) + return this.runBoundedWork(work) + } + + async runBoundedWork(work: (signal: AbortSignal) => Promise): Promise { const release = await this.shared.semaphore.acquire(this.signal) try { this.assertActive() diff --git a/control-api/src/services/access/liveAuthorizationResolver.ts b/control-api/src/services/access/liveAuthorizationResolver.ts index 64ad1ec5bc..a81cae0fa5 100644 --- a/control-api/src/services/access/liveAuthorizationResolver.ts +++ b/control-api/src/services/access/liveAuthorizationResolver.ts @@ -1,5 +1,4 @@ import type { DbClient } from '../../db.js' -import { withTransaction } from '../../db.js' import type { K8sGateway } from '../../k8s.js' import type { ExternalSessionAuthorityContext } from '../auth/externalSessionAuthentication.js' import { @@ -10,6 +9,7 @@ import { operationTargetIsCurrentlyValid, } from './accessAuthorityStore.js' import { configureAccessAuthorityTransaction } from './accessAuthorityTransaction.js' +import { withAccessDatabaseTransaction } from './accessDatabaseQuery.js' import { AccessBudgetExceededError, AccessExecutionBudget, @@ -390,17 +390,19 @@ export async function resolveLiveAuthorization( } throw error } + const work = (db: DbClient) => + resolveInTransaction({ + request: { ...input, resource }, + capability: input.requiredCapability as AccessCapability, + operationTarget, + db, + gateway: options.gateway, + budget, + }) const factory = () => - (options.transaction ?? withTransaction)(db => - resolveInTransaction({ - request: { ...input, resource }, - capability: input.requiredCapability as AccessCapability, - operationTarget, - db, - gateway: options.gateway, - budget, - }) - ) + options.transaction + ? options.transaction(work) + : withAccessDatabaseTransaction(budget, work, { mode: 'caller_configured' }) return options.memo ? await options.memo.getOrCreate(input, stableOperationTarget(operationTarget), factory) : await factory() diff --git a/control-api/src/services/access/userAccessRuntimePolicy.ts b/control-api/src/services/access/userAccessRuntimePolicy.ts index 562026f606..ac01a0e44c 100644 --- a/control-api/src/services/access/userAccessRuntimePolicy.ts +++ b/control-api/src/services/access/userAccessRuntimePolicy.ts @@ -1,7 +1,6 @@ import { config } from '../../config.js' import type { DbClient } from '../../db.js' -import { pool } from '../../db.js' -import { runAccessDatabaseQuery } from './accessDatabaseQuery.js' +import { runAccessDatabaseQuery, withAccessDatabaseTransaction } from './accessDatabaseQuery.js' import { AccessExecutionBudget } from './accessExecutionBudget.js' import { revisionOfValues } from './authorizationRevision.js' import { @@ -162,16 +161,32 @@ export async function resolveEffectiveUserAccessPolicy( const ownedBudget = options.budget ? null : AccessExecutionBudget.create('action') const budget = options.budget ?? ownedBudget! try { - const result = await runAccessDatabaseQuery( - options.db ?? pool, - budget, - `SELECT source_family, generation, resource_version, status, last_success_at + const query = (db: Pick) => + runAccessDatabaseQuery( + db, + budget, + `SELECT source_family, generation, resource_version, status, last_success_at FROM operational_catalog_source_state WHERE environment_id = $1 AND source_family = ANY($2::text[]) ORDER BY source_family`, - [canonicalEnvironmentId(), OPERATIONAL_SOURCE_FAMILIES] - ) + [canonicalEnvironmentId(), OPERATIONAL_SOURCE_FAMILIES] + ) + const result = options.db + ? await query(options.db) + : await withAccessDatabaseTransaction( + budget, + db => + db.query( + `SELECT source_family, generation, resource_version, status, last_success_at + FROM operational_catalog_source_state + WHERE environment_id = $1 + AND source_family = ANY($2::text[]) + ORDER BY source_family`, + [canonicalEnvironmentId(), OPERATIONAL_SOURCE_FAMILIES] + ), + { mode: 'read_only' } + ) const states = parseSourceStates(result.rows as Record[]) return compileUserAccessPolicy( intent, diff --git a/control-api/src/services/auth/externalSessionAuthentication.ts b/control-api/src/services/auth/externalSessionAuthentication.ts index 8a3074c66e..e59f48fdb7 100644 --- a/control-api/src/services/auth/externalSessionAuthentication.ts +++ b/control-api/src/services/auth/externalSessionAuthentication.ts @@ -4,6 +4,7 @@ import { type UserSessionV2Claims, verifyUserSessionV2Token, } from '../../utils/auth/userSessionV2Token.js' +import type { AccessExecutionBudget } from '../access/accessExecutionBudget.js' import { type EffectiveUserAccessPolicy, compareSemanticVersions, @@ -49,6 +50,7 @@ type AuthenticationOptions = Readonly<{ purpose: ExternalSessionPurpose client?: ExternalSessionClient policy?: EffectiveUserAccessPolicy + budget?: AccessExecutionBudget now?: Date }> @@ -132,7 +134,8 @@ export async function authenticateExternalUserSession( token: string, options: AuthenticationOptions ): Promise { - const policy = options.policy ?? (await resolveEffectiveUserAccessPolicy()) + const policy = + options.policy ?? (await resolveEffectiveUserAccessPolicy({ budget: options.budget })) if ( purposeRequiresMinimumClient(options.purpose) && !clientMeetsMinimum(options.client, policy) @@ -145,7 +148,10 @@ export async function authenticateExternalUserSession( if (!v2Allowed(options.purpose, policy)) { return { status: 'invalid', reason: 'session_contract_not_accepted' } } - const validation = await validateUserSessionClaims(v2Claims, { now: options.now }) + const validation = await validateUserSessionClaims(v2Claims, { + now: options.now, + budget: options.budget, + }) if (validation.status !== 'valid') return validation return { status: 'authenticated', @@ -167,7 +173,9 @@ export async function authenticateExternalUserSession( if (!v1Allowed(options.purpose, policy)) { return { status: 'invalid', reason: 'session_contract_not_accepted' } } - const validation = await validateLegacyUserSession(token, v1Claims) + const validation = await validateLegacyUserSession(token, v1Claims, { + budget: options.budget, + }) if (validation.status !== 'valid') return validation return { status: 'authenticated', @@ -187,7 +195,8 @@ export async function renewExternalUserSession( token: string, options: Omit = {} ): Promise { - const policy = options.policy ?? (await resolveEffectiveUserAccessPolicy()) + const policy = + options.policy ?? (await resolveEffectiveUserAccessPolicy({ budget: options.budget })) if (!clientMeetsMinimum(options.client, policy)) { return { status: 'upgrade_required', reason: 'minimum_client_version' } } @@ -196,7 +205,7 @@ export async function renewExternalUserSession( } const claims = verifyUserSessionV2Token(token) if (!claims) return { status: 'invalid', reason: 'invalid_representation' } - const result = await renewUserSession(claims, { now: options.now }) + const result = await renewUserSession(claims, { now: options.now, budget: options.budget }) if (!('token' in result)) { if (result.status === 'valid') { return { status: 'invalid', reason: 'session_renewal_failed' } diff --git a/control-api/src/services/auth/userSessionService.ts b/control-api/src/services/auth/userSessionService.ts index 62c7dc7fb6..b642ac0140 100644 --- a/control-api/src/services/auth/userSessionService.ts +++ b/control-api/src/services/auth/userSessionService.ts @@ -6,6 +6,11 @@ import { USER_SESSION_V2_TTL_SECONDS, signUserSessionV2Token, } from '../../utils/auth/userSessionV2Token.js' +import { + runAccessDatabaseQuery, + withAccessDatabaseTransaction, +} from '../access/accessDatabaseQuery.js' +import type { AccessExecutionBudget } from '../access/accessExecutionBudget.js' export const USER_SESSION_IDLE_LIFETIME_SECONDS = 14 * 24 * 60 * 60 export const USER_SESSION_ABSOLUTE_LIFETIME_SECONDS = 30 * 24 * 60 * 60 @@ -56,6 +61,15 @@ export type IssuedUserSession = { type SessionClock = { now?: Date } +function budgetedSessionDatabase( + db: SessionDatabase, + budget: AccessExecutionBudget +): SessionDatabase { + const query = (text: string, values?: unknown[]) => + runAccessDatabaseQuery(db, budget, text, values ?? []) + return Object.assign(Object.create(db), { query }) as SessionDatabase +} + function atSecond(date: Date): Date { return new Date(Math.floor(date.getTime() / 1000) * 1000) } @@ -259,19 +273,24 @@ export async function createUserSession( export async function validateUserSessionClaims( claims: UserSessionV2Claims, - options: SessionClock & { db?: SessionDatabase } = {} + options: SessionClock & { db?: SessionDatabase; budget?: AccessExecutionBudget } = {} ): Promise { const now = atSecond(options.now ?? new Date()) const work = async (db: SessionDatabase) => { const row = await loadSessionForUpdate(db, claims.sid) return validateLoadedSession(db, row, claims, now, true) } - return options.db ? work(options.db) : withTransaction(work) + if (options.db) { + return work(options.budget ? budgetedSessionDatabase(options.db, options.budget) : options.db) + } + return options.budget + ? withAccessDatabaseTransaction(options.budget, work, { mode: 'read_write' }) + : withTransaction(work) } export async function renewUserSession( claims: UserSessionV2Claims, - options: SessionClock & { db?: SessionDatabase } = {} + options: SessionClock & { db?: SessionDatabase; budget?: AccessExecutionBudget } = {} ): Promise { const now = atSecond(options.now ?? new Date()) const work = async (db: SessionDatabase) => { @@ -328,7 +347,12 @@ export async function renewUserSession( identity: identityFromRow(next), } } - return options.db ? work(options.db) : withTransaction(work) + if (options.db) { + return work(options.budget ? budgetedSessionDatabase(options.db, options.budget) : options.db) + } + return options.budget + ? withAccessDatabaseTransaction(options.budget, work, { mode: 'read_write' }) + : withTransaction(work) } export async function revokeUserSession( @@ -397,11 +421,13 @@ function legacySessionFingerprint(token: string): string { export async function validateLegacyUserSession( token: string, claims: AuthClaims, - db: SessionDatabase = pool + options: { db?: SessionDatabase; budget?: AccessExecutionBudget } = {} ): Promise { - if (!claims.iat) return { status: 'invalid', reason: 'invalid_legacy_representation' } - const result = await db.query( - `SELECT u.id, + const issuedAt = claims.iat + if (!issuedAt) return { status: 'invalid', reason: 'invalid_legacy_representation' } + const work = async (db: SessionDatabase): Promise => { + const result = await db.query( + `SELECT u.id, epoch.valid_after, EXISTS ( SELECT 1 @@ -414,29 +440,34 @@ export async function validateLegacyUserSession( LEFT JOIN external_user_session_security_epochs epoch ON epoch.user_id = u.id WHERE u.id = $1 LIMIT 1`, - [claims.userId, legacySessionFingerprint(token)] - ) - const row = result.rows[0] as - | { id: string; valid_after: Date | string | null; token_revoked: boolean } - | undefined - if (!row) return { status: 'invalid', reason: 'user_not_found' } - if (row.token_revoked) return { status: 'revoked', reason: 'logout' } - if (row.valid_after && claims.iat * 1000 <= dateOf(row.valid_after).getTime()) { - return { status: 'revoked', reason: 'security_event' } - } - return { - status: 'valid', - identity: { - userId: claims.userId, - email: claims.email, - sid: '', - jti: legacySessionFingerprint(token), - sessionVersion: 0, - expiresAt: new Date(claims.exp * 1000), - absoluteExpiresAt: new Date(claims.exp * 1000), - authenticationMethods: [], - }, + [claims.userId, legacySessionFingerprint(token)] + ) + const row = result.rows[0] as + | { id: string; valid_after: Date | string | null; token_revoked: boolean } + | undefined + if (!row) return { status: 'invalid', reason: 'user_not_found' } + if (row.token_revoked) return { status: 'revoked', reason: 'logout' } + if (row.valid_after && issuedAt * 1000 <= dateOf(row.valid_after).getTime()) { + return { status: 'revoked', reason: 'security_event' } + } + return { + status: 'valid', + identity: { + userId: claims.userId, + email: claims.email, + sid: '', + jti: legacySessionFingerprint(token), + sessionVersion: 0, + expiresAt: new Date(claims.exp * 1000), + absoluteExpiresAt: new Date(claims.exp * 1000), + authenticationMethods: [], + }, + } } + const db = options.db ?? pool + if (!options.budget) return work(db) + if (options.db) return work(budgetedSessionDatabase(db, options.budget)) + return withAccessDatabaseTransaction(options.budget, work, { mode: 'read_only' }) } export async function revokeLegacyUserSession( diff --git a/control-api/test/accessDatabaseCancellation.realPostgres.integration.test.ts b/control-api/test/accessDatabaseCancellation.realPostgres.integration.test.ts index 1601917fe4..ecbe0fef65 100644 --- a/control-api/test/accessDatabaseCancellation.realPostgres.integration.test.ts +++ b/control-api/test/accessDatabaseCancellation.realPostgres.integration.test.ts @@ -1,5 +1,6 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { Pool, type PoolClient } from 'pg' +import { withAccessDatabaseTransaction } from '../src/services/access/accessDatabaseQuery.js' import { AccessBudgetExceededError, AccessExecutionBudget, @@ -77,4 +78,68 @@ describeRealPostgres('access database cancellation on real PostgreSQL', () => { client.release() } }) + + it('owns commit and cancellation rollback under one request budget', async () => { + const table = `access_budget_transaction_${Date.now()}` + await pool.query(`CREATE TABLE ${table}(value integer NOT NULL)`) + const committed = AccessExecutionBudget.create('catalog', { limits: { producerCalls: 1 } }) + try { + await withAccessDatabaseTransaction( + committed, + async db => { + await db.query(`INSERT INTO ${table}(value) VALUES(1)`) + }, + { mode: 'read_write', connectionPool: pool } + ) + } finally { + committed.close() + } + + const cancelled = AccessExecutionBudget.create('catalog', { limits: { producerCalls: 2 } }) + try { + const work = withAccessDatabaseTransaction( + cancelled, + async db => { + await db.query(`INSERT INTO ${table}(value) VALUES(2)`) + await db.query('SELECT pg_sleep(2)') + }, + { mode: 'read_write', connectionPool: pool } + ) + setTimeout(() => cancelled.cancel(), 50) + await expect(work).rejects.toBeInstanceOf(AccessExecutionCancelledError) + const values = await pool.query<{ value: number }>( + `SELECT value FROM ${table} ORDER BY value` + ) + expect(values.rows).toEqual([{ value: 1 }]) + } finally { + cancelled.close() + await pool.query(`DROP TABLE IF EXISTS ${table}`) + } + }) + + it('cancels a queued pool acquisition without leaking the later client', async () => { + const constrainedPool = new Pool({ connectionString: adminUrl, max: 1 }) + const heldClient = await constrainedPool.connect() + let heldReleased = false + const budget = AccessExecutionBudget.create('action') + try { + const startedAt = performance.now() + const work = withAccessDatabaseTransaction(budget, async db => db.query('SELECT 1'), { + mode: 'read_only', + connectionPool: constrainedPool, + }) + setTimeout(() => budget.cancel(), 50) + await expect(work).rejects.toBeInstanceOf(AccessExecutionCancelledError) + expect(performance.now() - startedAt).toBeLessThan(1_000) + + heldClient.release() + heldReleased = true + const replacement = await constrainedPool.connect() + replacement.release() + } finally { + if (!heldReleased) heldClient.release() + budget.close() + await constrainedPool.end() + } + }) }) diff --git a/control-api/test/routes.externalAccess.test.ts b/control-api/test/routes.externalAccess.test.ts index ea517d5e19..588dd68cd7 100644 --- a/control-api/test/routes.externalAccess.test.ts +++ b/control-api/test/routes.externalAccess.test.ts @@ -1,7 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import express from 'express' +import { EventEmitter } from 'node:events' import request from 'supertest' -import { createExternalAccessRouter } from '../src/routes/external/access.js' +import { + attachAccessExecutionBudget, + createExternalAccessRouter, +} from '../src/routes/external/access.js' const mocks = vi.hoisted(() => ({ verifyV1: vi.fn(), @@ -69,6 +73,34 @@ describe('external user-access contracts', () => { catalogFamilies: [], }) ) + expect(mocks.validateV1).toHaveBeenCalledWith( + 'v1-session', + expect.any(Object), + expect.objectContaining({ budget: expect.any(Object) }) + ) + }) + + it('cancels and releases the shared request budget when the HTTP request aborts', () => { + const req = Object.assign(new EventEmitter(), { + method: 'GET', + path: '/external/access/catalog', + }) + const res = Object.assign(new EventEmitter(), { writableEnded: false }) + const next = vi.fn() + + attachAccessExecutionBudget(req as never, res as never, next) + + const budget = (req as { accessExecutionBudget?: { signal: AbortSignal } }) + .accessExecutionBudget + expect(next).toHaveBeenCalledOnce() + expect(budget?.signal.aborted).toBe(false) + req.emit('aborted') + expect(budget?.signal.aborted).toBe(true) + expect(budget?.signal.reason).toBe('cancelled') + res.emit('close') + expect(req.listenerCount('aborted')).toBe(0) + expect(res.listenerCount('finish')).toBe(0) + expect(res.listenerCount('close')).toBe(0) }) it.each([ diff --git a/control-api/test/userSessionService.test.ts b/control-api/test/userSessionService.test.ts index 50d181b79d..40280daf17 100644 --- a/control-api/test/userSessionService.test.ts +++ b/control-api/test/userSessionService.test.ts @@ -189,7 +189,7 @@ describe('user session state machine', () => { rowCount: 1, }), } - await expect(validateLegacyUserSession('v1-token', claims, epochDb)).resolves.toEqual({ + await expect(validateLegacyUserSession('v1-token', claims, { db: epochDb })).resolves.toEqual({ status: 'revoked', reason: 'security_event', }) From 1758f4a7c081a008c4b62d2fc4aa286aad087766 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 04:14:01 -0600 Subject: [PATCH 033/528] fix(access): settle failed Kubernetes watches Class-check: operational watch completion paths -> 4 outcomes -> one settled promise Repro-test: k8s.watchResource handler failure (fails at 0521cf3c) --- control-api/test/k8s.watchResource.test.ts | 62 ++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 control-api/test/k8s.watchResource.test.ts diff --git a/control-api/test/k8s.watchResource.test.ts b/control-api/test/k8s.watchResource.test.ts new file mode 100644 index 0000000000..4844601804 --- /dev/null +++ b/control-api/test/k8s.watchResource.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest' +import { K8sGateway } from '../src/k8s.js' + +const mocks = vi.hoisted(() => ({ + watch: vi.fn(), +})) + +vi.mock('@kubernetes/client-node', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + Watch: class { + watch = mocks.watch + }, + } +}) + +describe('K8sGateway.watchResource', () => { + it('rejects and aborts when an event handler fails before the controller resolves', async () => { + const controller = { abort: vi.fn() } + const handlerError = new Error('handler failed') + mocks.watch.mockImplementation( + async ( + _path: string, + _options: unknown, + onEvent: (phase: string, object: unknown) => void + ) => { + onEvent('ADDED', { metadata: { name: 'host-a' } }) + await Promise.resolve() + return controller + } + ) + + const gateway = Object.create(K8sGateway.prototype) as K8sGateway + Object.defineProperties(gateway, { + resources: { + value: { assertNamespaceAllowed: vi.fn() }, + }, + kc: { value: {} }, + }) + + const work = gateway.watchResource( + 'hosts', + 'mcp-host', + '42', + new AbortController().signal, + async () => { + throw handlerError + } + ) + + await expect( + Promise.race([ + work, + new Promise((_, reject) => + setTimeout(() => reject(new Error('watch did not settle')), 250) + ), + ]) + ).rejects.toBe(handlerError) + expect(controller.abort).toHaveBeenCalledOnce() + }) +}) From 4d7d4003a8f0018f2a02af3d7c34da6979e77a71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 04:22:31 -0600 Subject: [PATCH 034/528] fix(access): bound operational index cancellation Class-check: completed-diff correctness and availability correction. Repro-test: queued pool acquisition and pre-aborted watch tests failed before the fix. --- .../services/access/operationalAccessIndex.ts | 65 +++++++++++++------ ...cellation.realPostgres.integration.test.ts | 29 +++++++++ control-api/test/k8s.watchResource.test.ts | 23 +++++++ 3 files changed, 96 insertions(+), 21 deletions(-) diff --git a/control-api/src/services/access/operationalAccessIndex.ts b/control-api/src/services/access/operationalAccessIndex.ts index 1fb03dc91d..b6431720eb 100644 --- a/control-api/src/services/access/operationalAccessIndex.ts +++ b/control-api/src/services/access/operationalAccessIndex.ts @@ -1,5 +1,5 @@ -import { type DbClient, pool, withTransaction } from '../../db.js' -import { runAccessDatabaseQuery } from './accessDatabaseQuery.js' +import { type DbClient, pool } from '../../db.js' +import { runAccessDatabaseQuery, withAccessDatabaseTransaction } from './accessDatabaseQuery.js' import type { AccessExecutionBudget } from './accessExecutionBudget.js' import type { OperationalObjectProjection, @@ -28,6 +28,13 @@ export class OperationalSourceStateError extends Error { } type TransactionRunner = (work: (db: DbClient) => Promise) => Promise +type ConnectionPool = Pick + +function connectionPoolFor(db: DbClient): ConnectionPool | null { + const candidate = db as DbClient & Partial + if (typeof candidate.connect !== 'function') return null + return { connect: candidate.connect.bind(candidate) } +} function numberValue(value: unknown): number { const result = Number(value) @@ -96,9 +103,22 @@ function relationshipPayload(value: OperationalRelationshipRecord, generation: n export class OperationalAccessIndex { constructor( private readonly readDb: DbClient = pool, - private readonly transaction: TransactionRunner = withTransaction + private readonly transaction?: TransactionRunner ) {} + private async runTransaction( + budget: AccessExecutionBudget, + work: (db: DbClient) => Promise + ): Promise { + if (this.transaction) return this.transaction(work) + const connectionPool = connectionPoolFor(this.readDb) + if (!connectionPool) throw new OperationalSourceStateError('source_unavailable') + return withAccessDatabaseTransaction(budget, work, { + mode: 'caller_configured', + connectionPool, + }) + } + private async query( db: DbClient, budget: AccessExecutionBudget, @@ -119,7 +139,7 @@ export class OperationalAccessIndex { sourceFamily: OperationalSourceFamily budget: AccessExecutionBudget }): Promise { - return this.transaction(async db => { + return this.runTransaction(input.budget, async db => { await this.configureTransaction(db, input.budget) const result = await this.query( db, @@ -168,7 +188,7 @@ export class OperationalAccessIndex { const relationships = input.projections.flatMap(projection => projection.relationships.map(value => relationshipPayload(value, input.stagingGeneration)) ) - await this.transaction(async db => { + await this.runTransaction(input.budget, async db => { await this.configureTransaction(db, input.budget) const result = await this.query( db, @@ -269,7 +289,7 @@ export class OperationalAccessIndex { resourceVersion: string budget: AccessExecutionBudget }): Promise { - await this.transaction(async db => { + await this.runTransaction(input.budget, async db => { await this.configureTransaction(db, input.budget) const locked = await this.query( db, @@ -374,7 +394,7 @@ export class OperationalAccessIndex { deleted: boolean budget: AccessExecutionBudget }): Promise { - return this.transaction(async db => { + return this.runTransaction(input.budget, async db => { await this.configureTransaction(db, input.budget) const locked = await this.query( db, @@ -501,7 +521,7 @@ export class OperationalAccessIndex { safeErrorCode: string budget: AccessExecutionBudget }): Promise { - await this.transaction(async db => { + await this.runTransaction(input.budget, async db => { await this.configureTransaction(db, input.budget) await this.query( db, @@ -529,7 +549,7 @@ export class OperationalAccessIndex { resourceVersion: string budget: AccessExecutionBudget }): Promise { - await this.transaction(async db => { + await this.runTransaction(input.budget, async db => { await this.configureTransaction(db, input.budget) const result = await this.query( db, @@ -555,17 +575,20 @@ export class OperationalAccessIndex { budget: AccessExecutionBudget }): Promise { if (input.sourceFamilies.length === 0) return [] - const result = await this.query( - this.readDb, - input.budget, - `SELECT environment_id, source_family, generation, staging_generation, - resource_version, status, safe_error_code - FROM operational_catalog_source_state - WHERE environment_id = $1 - AND source_family = ANY($2::text[]) - ORDER BY source_family`, - [input.environmentId, input.sourceFamilies] - ) - return Object.freeze(result.rows.map(sourceState)) + return this.runTransaction(input.budget, async db => { + await this.configureTransaction(db, input.budget) + const result = await this.query( + db, + input.budget, + `SELECT environment_id, source_family, generation, staging_generation, + resource_version, status, safe_error_code + FROM operational_catalog_source_state + WHERE environment_id = $1 + AND source_family = ANY($2::text[]) + ORDER BY source_family`, + [input.environmentId, input.sourceFamilies] + ) + return Object.freeze(result.rows.map(sourceState)) + }) } } diff --git a/control-api/test/accessDatabaseCancellation.realPostgres.integration.test.ts b/control-api/test/accessDatabaseCancellation.realPostgres.integration.test.ts index ecbe0fef65..459ec23698 100644 --- a/control-api/test/accessDatabaseCancellation.realPostgres.integration.test.ts +++ b/control-api/test/accessDatabaseCancellation.realPostgres.integration.test.ts @@ -7,6 +7,7 @@ import { AccessExecutionCancelledError, } from '../src/services/access/accessExecutionBudget.js' import { catalogQuery } from '../src/services/access/catalogProducerSupport.js' +import { OperationalAccessIndex } from '../src/services/access/operationalAccessIndex.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -142,4 +143,32 @@ describeRealPostgres('access database cancellation on real PostgreSQL', () => { await constrainedPool.end() } }) + + it('bounds the operational index default transaction acquisition', async () => { + const constrainedPool = new Pool({ connectionString: adminUrl, max: 1 }) + const heldClient = await constrainedPool.connect() + let heldReleased = false + const budget = AccessExecutionBudget.create('action') + const index = new OperationalAccessIndex(constrainedPool) + try { + const startedAt = performance.now() + const work = index.loadSourceStates({ + environmentId: 'test:cluster', + sourceFamilies: ['host'], + budget, + }) + setTimeout(() => budget.cancel(), 50) + await expect(work).rejects.toBeInstanceOf(AccessExecutionCancelledError) + expect(performance.now() - startedAt).toBeLessThan(1_000) + + heldClient.release() + heldReleased = true + const replacement = await constrainedPool.connect() + replacement.release() + } finally { + if (!heldReleased) heldClient.release() + budget.close() + await constrainedPool.end() + } + }) }) diff --git a/control-api/test/k8s.watchResource.test.ts b/control-api/test/k8s.watchResource.test.ts index 4844601804..382a2cb539 100644 --- a/control-api/test/k8s.watchResource.test.ts +++ b/control-api/test/k8s.watchResource.test.ts @@ -16,6 +16,29 @@ vi.mock('@kubernetes/client-node', async importOriginal => { }) describe('K8sGateway.watchResource', () => { + it('does not start a watch for an already-aborted request', async () => { + mocks.watch.mockImplementation(() => new Promise(() => undefined)) + const gateway = Object.create(K8sGateway.prototype) as K8sGateway + Object.defineProperties(gateway, { + resources: { + value: { assertNamespaceAllowed: vi.fn() }, + }, + kc: { value: {} }, + }) + const controller = new AbortController() + controller.abort() + + await expect( + Promise.race([ + gateway.watchResource('hosts', 'mcp-host', '42', controller.signal, vi.fn()), + new Promise((_, reject) => + setTimeout(() => reject(new Error('watch did not settle')), 250) + ), + ]) + ).resolves.toBeUndefined() + expect(mocks.watch).not.toHaveBeenCalled() + }) + it('rejects and aborts when an event handler fails before the controller resolves', async () => { const controller = { abort: vi.fn() } const handlerError = new Error('handler failed') From 82c48588bee6b086df1cf2ad1001c1bafee68387 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 04:25:03 -0600 Subject: [PATCH 035/528] fix(access): centralize action target validation Class-check: completed-diff contract and confused-deputy correction. Repro-test: team management actions failed the public route's duplicate schema. --- control-api/src/routes/external/access.ts | 51 ++++------ .../test/routes.externalAccess.test.ts | 99 ++++++++++++++++++- 2 files changed, 117 insertions(+), 33 deletions(-) diff --git a/control-api/src/routes/external/access.ts b/control-api/src/routes/external/access.ts index 4f3974fb69..5cf2564db7 100644 --- a/control-api/src/routes/external/access.ts +++ b/control-api/src/routes/external/access.ts @@ -20,6 +20,7 @@ import { import { isAccessCapability } from '../../services/access/capabilityRegistry.js' import { CATALOG_FAMILIES, type CatalogFamily } from '../../services/access/catalogContracts.js' import { resolveLiveAuthorization } from '../../services/access/liveAuthorizationResolver.js' +import { validateOperationTarget } from '../../services/access/operationTarget.js' import { canonicalEnvironmentId } from '../../services/access/operationalAccessProjection.js' import { canonicalResourceIdentity } from '../../services/access/resourceIdentity.js' import { userAccessCapabilityManifest } from '../../services/access/userAccessPolicy.js' @@ -90,35 +91,6 @@ function pageLimit(value: unknown): number | null | undefined { return Number.isSafeInteger(parsed) && parsed >= 1 && parsed <= 100 ? parsed : null } -function operationTarget( - value: unknown, - capability: string -): Record | null | undefined { - if (value === undefined) return undefined - if (!value || typeof value !== 'object' || Array.isArray(value)) return null - const candidate = value as Record - const allowed = new Set(['teamId']) - if (['team.member.read', 'team.member.manage'].includes(capability)) allowed.add('userId') - if (['team.member.invite', 'team.member.manage'].includes(capability)) allowed.add('role') - if (capability === 'team.member.invite') { - allowed.add('action') - allowed.add('invitationId') - } - const keys = Object.keys(candidate) - if (keys.length === 0 || keys.length > allowed.size || keys.some(key => !allowed.has(key))) { - return null - } - const normalized: Record = {} - for (const key of keys) { - const item = candidate[key] - if (typeof item !== 'string') return null - const text = item.trim() - if (!text || text.length > 128 || /[\u0000-\u001f\u007f]/u.test(text)) return null - normalized[key] = text - } - return normalized -} - async function requireEffectiveV2Contract( feature: 'catalog' | 'action', req: ExternalAuthedRequest, @@ -293,7 +265,6 @@ export function createExternalAccessRouter(gateway: K8sGateway): Router { : null const capability = typeof body?.requiredCapability === 'string' ? body.requiredCapability : '' const accessPathId = typeof body?.accessPathId === 'string' ? body.accessPathId : undefined - const target = operationTarget(body?.operationTarget, capability) let resource try { resource = canonicalResourceIdentity({ @@ -309,8 +280,7 @@ export function createExternalAccessRouter(gateway: K8sGateway): Router { !resource || resource.environmentId !== canonicalEnvironmentId() || !isAccessCapability(capability) || - (accessPathId !== undefined && !ACCESS_PATH_PATTERN.test(accessPathId)) || - target === null + (accessPathId !== undefined && !ACCESS_PATH_PATTERN.test(accessPathId)) ) { sendPublicApiError( req, @@ -321,6 +291,23 @@ export function createExternalAccessRouter(gateway: K8sGateway): Router { ) return } + let target + try { + target = validateOperationTarget({ + capability, + resource, + operationTarget: body?.operationTarget, + }) + } catch { + sendPublicApiError( + req, + res, + 400, + 'invalid_request', + 'The authorization request is invalid.' + ) + return + } const result = await resolveLiveAuthorization( { session: req.externalSessionAuthority!, diff --git a/control-api/test/routes.externalAccess.test.ts b/control-api/test/routes.externalAccess.test.ts index 588dd68cd7..5463c3cae9 100644 --- a/control-api/test/routes.externalAccess.test.ts +++ b/control-api/test/routes.externalAccess.test.ts @@ -6,11 +6,15 @@ import { attachAccessExecutionBudget, createExternalAccessRouter, } from '../src/routes/external/access.js' +import { canonicalEnvironmentId } from '../src/services/access/operationalAccessProjection.js' const mocks = vi.hoisted(() => ({ verifyV1: vi.fn(), verifyV2: vi.fn(), validateV1: vi.fn(), + validateV2: vi.fn(), + resolvePolicy: vi.fn(), + resolveAuthorization: vi.fn(), })) vi.mock('../src/utils/auth/externalSessionAuthToken.js', () => ({ @@ -21,8 +25,41 @@ vi.mock('../src/utils/auth/userSessionV2Token.js', () => ({ })) vi.mock('../src/services/auth/userSessionService.js', async importOriginal => { const actual = await importOriginal() - return { ...actual, validateLegacyUserSession: mocks.validateV1 } + return { + ...actual, + validateLegacyUserSession: mocks.validateV1, + validateUserSessionClaims: mocks.validateV2, + } }) +vi.mock('../src/services/access/userAccessRuntimePolicy.js', () => ({ + resolveEffectiveUserAccessPolicy: mocks.resolvePolicy, +})) +vi.mock('../src/services/access/liveAuthorizationResolver.js', () => ({ + resolveLiveAuthorization: mocks.resolveAuthorization, +})) + +function effectivePolicy(overrides: Record = {}) { + return { + policyVersion: '1', + policyRevision: 'test-policy', + acceptV1: true, + issueV1: true, + acceptV2: true, + issueV2: false, + renewV2: false, + switchCompatibility: true, + computeCatalogShadow: false, + serveCatalog: false, + actionContextV2: false, + rpcDelegationV2: false, + desktopAllTeamMode: false, + profileV2Mode: false, + minimumClientVersion: null, + enforceMinimumClient: false, + advertisedCatalogFamilies: [], + ...overrides, + } +} function app() { const value = express() @@ -34,6 +71,7 @@ function app() { describe('external user-access contracts', () => { beforeEach(() => { vi.clearAllMocks() + mocks.resolvePolicy.mockResolvedValue(effectivePolicy()) mocks.verifyV2.mockReturnValue(null) mocks.verifyV1.mockReturnValue({ userId: '10000000-0000-4000-8000-000000000001', @@ -48,6 +86,16 @@ describe('external user-access contracts', () => { status: 'valid', identity: { jti: 'token-hash' }, }) + mocks.validateV2.mockResolvedValue({ + status: 'valid', + identity: { + userId: '10000000-0000-4000-8000-000000000001', + email: 'user@example.test', + sid: '20000000-0000-4000-8000-000000000002', + jti: '30000000-0000-4000-8000-000000000003', + sessionVersion: 1, + }, + }) }) it('advertises only the effective reconstruction defaults', async () => { @@ -124,4 +172,53 @@ describe('external user-access contracts', () => { expect(response.status).toBe(401) expect(response.body.error.code).toBe('invalid_session') }) + + it('uses the authoritative target schema for team management actions', async () => { + const teamId = '40000000-0000-4000-8000-000000000004' + const pathId = `ap1_${'A'.repeat(43)}` + mocks.verifyV1.mockReturnValue(null) + mocks.verifyV2.mockReturnValue({ + sub: '10000000-0000-4000-8000-000000000001', + sid: '20000000-0000-4000-8000-000000000002', + jti: '30000000-0000-4000-8000-000000000003', + sv: 1, + ver: 2, + typ: 'user_session', + email: 'user@example.test', + auth_time: 1_900_000_000, + amr: ['pwd'], + iat: 1_900_000_000, + exp: 2_000_000_000, + }) + mocks.resolvePolicy.mockResolvedValue(effectivePolicy({ actionContextV2: true })) + mocks.resolveAuthorization.mockResolvedValue({ + status: 'allowed', + effectiveCapabilities: ['team.manage'], + paths: [{ id: pathId, kind: 'team', teamId }], + selectedPath: { id: pathId, kind: 'team', teamId }, + authorizationRevision: 'revision-1', + }) + + const response = await request(app()) + .post('/external/access/resolve') + .set('x-user-session-token', 'v2-session') + .send({ + requiredCapability: 'team.manage', + resource: { + environmentId: canonicalEnvironmentId(), + type: 'team', + logicalId: teamId, + }, + operationTarget: { teamId, action: 'rename' }, + }) + + expect(response.status).toBe(200) + expect(mocks.resolveAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ + requiredCapability: 'team.manage', + operationTarget: { teamId, action: 'rename' }, + }), + expect.any(Object) + ) + }) }) From 30b15a75c839ecdc66975916dbaa9deb6f8559aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 04:42:08 -0600 Subject: [PATCH 036/528] fix(access): bound Kubernetes watch backlogs Class-check: watchResource call sites -> 5 sites -> bounded the shared gateway queue. Repro-test: control-api/test/k8s.watchResource.test.ts failed at c1d5d6cf by timeout. --- control-api/test/k8s.watchResource.test.ts | 43 ++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/control-api/test/k8s.watchResource.test.ts b/control-api/test/k8s.watchResource.test.ts index 382a2cb539..c62ba4098b 100644 --- a/control-api/test/k8s.watchResource.test.ts +++ b/control-api/test/k8s.watchResource.test.ts @@ -82,4 +82,47 @@ describe('K8sGateway.watchResource', () => { ).rejects.toBe(handlerError) expect(controller.abort).toHaveBeenCalledOnce() }) + + it('aborts instead of accumulating an unbounded watch-event backlog', async () => { + const controller = { abort: vi.fn() } + let releaseFirstEvent: (() => void) | undefined + const firstEventBlocked = new Promise(resolve => { + releaseFirstEvent = resolve + }) + mocks.watch.mockImplementation( + async ( + _path: string, + _options: unknown, + onEvent: (phase: string, object: unknown) => void + ) => { + onEvent('ADDED', { metadata: { name: 'host-0' } }) + await Promise.resolve() + for (let index = 1; index <= 3; index += 1) { + onEvent('ADDED', { metadata: { name: `host-${index}` } }) + } + return controller + } + ) + + const gateway = Object.create(K8sGateway.prototype) as K8sGateway + Object.defineProperties(gateway, { + resources: { + value: { assertNamespaceAllowed: vi.fn() }, + }, + kc: { value: {} }, + }) + + const work = gateway.watchResource( + 'hosts', + 'mcp-host', + '42', + new AbortController().signal, + async () => firstEventBlocked, + { maxPendingEvents: 3, maxPendingBytes: 1_024, maxObjectBytes: 512 } + ) + + await vi.waitFor(() => expect(controller.abort).toHaveBeenCalledOnce(), { timeout: 250 }) + releaseFirstEvent?.() + await expect(work).rejects.toThrow('watch event budget exceeded') + }) }) From 9fa0dc19d1d5059c4aa72b0acf03db04c5b997a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 04:46:10 -0600 Subject: [PATCH 037/528] fix(external): sanitize RPC denial responses Class-check: upstream-error body reflection -> 1 remaining adapter -> rebuilt as typed envelope. Repro-test: External REST RPC tests reflected a sentinel at 533ec048. --- external-rest-api/package-lock.json | 4 +-- external-rest-api/package.json | 2 +- external-rest-api/src/routes/rpc.ts | 15 +++++---- external-rest-api/src/services/rpcService.ts | 34 ++++++-------------- external-rest-api/test/routes.rpc.test.ts | 21 ++++++++++-- external-rest-api/test/services.rpc.test.ts | 16 +++++---- 6 files changed, 47 insertions(+), 45 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 7b23928c92..1831f85dfc 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.110", + "version": "0.1.106", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.110", + "version": "0.1.106", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index fa1a9d1a80..a2eb923322 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.110", + "version": "0.1.106", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/routes/rpc.ts b/external-rest-api/src/routes/rpc.ts index e05f72266c..26e993087f 100644 --- a/external-rest-api/src/routes/rpc.ts +++ b/external-rest-api/src/routes/rpc.ts @@ -1,7 +1,10 @@ import { Router } from 'express' +import { sanitizeControlApiPublicError } from '../http/publicApiError.js' import { type AuthedRequest, extractAuthToken, requireAuth } from '../middleware/auth.js' import { issueRpcAccessToken } from '../services/rpcService.js' +const RPC_PUBLIC_STATUSES = new Set([403]) + export function createRpcRouter(): Router { const router = Router() @@ -13,15 +16,13 @@ export function createRpcRouter(): Router { return } const result = await issueRpcAccessToken(sessionToken, req.body?.scopes, req.body?.hostRefs) - if ('error' in result) { - // Relay control-api's specific reason (e.g. desktop_requires_team) so the - // desktop app can act on it instead of surfacing an opaque "no access". - res.status(403).json({ error: result.error }) - return - } - res.status(200).json(result) } catch (error) { + const sanitized = sanitizeControlApiPublicError(error, RPC_PUBLIC_STATUSES) + if (sanitized) { + res.status(sanitized.status).json(sanitized.body) + return + } next(error) } }) diff --git a/external-rest-api/src/services/rpcService.ts b/external-rest-api/src/services/rpcService.ts index 7a2f839a4a..05a9226715 100644 --- a/external-rest-api/src/services/rpcService.ts +++ b/external-rest-api/src/services/rpcService.ts @@ -1,4 +1,4 @@ -import { ControlApiError, controlApiRequest } from '../controlApiClient.js' +import { controlApiRequest } from '../controlApiClient.js' import { RpcAccessScope, RpcScope } from '../types.js' type IssuedRpcToken = { @@ -11,32 +11,16 @@ type IssuedRpcToken = { droppedScopes?: RpcScope[] } -export type RpcAccessTokenResult = IssuedRpcToken | { error: string } - export async function issueRpcAccessToken( sessionToken: string, requestedScopesInput: unknown, requestedHostRefsInput: unknown -): Promise { - try { - return await controlApiRequest('POST', '/external/rpc/token', { - body: { - sessionToken, - scopes: requestedScopesInput, - hostRefs: requestedHostRefsInput, - }, - }) - } catch (error) { - // Relay control-api's specific denial reason (e.g. `desktop_requires_team`) - // instead of collapsing every 403 to a generic message. The desktop app and - // logs can then distinguish "needs a team" from a genuine auth failure. - if (error instanceof ControlApiError && error.status === 403) { - const reason = - error.body && typeof error.body === 'object' && 'error' in error.body - ? String((error.body as { error: unknown }).error) - : 'forbidden' - return { error: reason } - } - throw error - } +): Promise { + return controlApiRequest('POST', '/external/rpc/token', { + body: { + sessionToken, + scopes: requestedScopesInput, + hostRefs: requestedHostRefsInput, + }, + }) } diff --git a/external-rest-api/test/routes.rpc.test.ts b/external-rest-api/test/routes.rpc.test.ts index 77c49cca65..ef0840a3c7 100644 --- a/external-rest-api/test/routes.rpc.test.ts +++ b/external-rest-api/test/routes.rpc.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import express from 'express' import request from 'supertest' +import { ControlApiError } from '../src/controlApiClient.js' import { createRpcRouter } from '../src/routes/rpc.js' const rpcServiceMock = vi.hoisted(() => ({ issueRpcAccessToken: vi.fn() })) @@ -29,8 +30,13 @@ describe('routes/rpc /rpc/token', () => { }) }) - it('relays the control-api denial reason instead of a generic 403', async () => { - rpcServiceMock.issueRpcAccessToken.mockResolvedValue({ error: 'desktop_requires_team' }) + it('returns a bounded public envelope without reflecting the control-api denial body', async () => { + rpcServiceMock.issueRpcAccessToken.mockRejectedValue( + new ControlApiError('secret internal topology', 403, { + error: 'secret-internal-reason', + path: '/internal/control-api', + }) + ) const res = await request(buildApp()) .post('/rpc/token') @@ -38,7 +44,16 @@ describe('routes/rpc /rpc/token', () => { .send({ scopes: ['desktop:view'], hostRefs: ['pro-agent'] }) expect(res.status).toBe(403) - expect(res.body).toEqual({ error: 'desktop_requires_team' }) + expect(res.body).toEqual({ + error: { + code: 'forbidden', + message: 'The requested operation is not allowed.', + correlationId: expect.any(String), + retryable: false, + }, + }) + expect(JSON.stringify(res.body)).not.toContain('secret-internal-reason') + expect(JSON.stringify(res.body)).not.toContain('/internal/control-api') }) it('returns the issued token on success', async () => { diff --git a/external-rest-api/test/services.rpc.test.ts b/external-rest-api/test/services.rpc.test.ts index 93da81a718..e4f28ac84f 100644 --- a/external-rest-api/test/services.rpc.test.ts +++ b/external-rest-api/test/services.rpc.test.ts @@ -13,18 +13,18 @@ describe('rpcService.issueRpcAccessToken', () => { clientMock.controlApiRequest.mockReset() }) - it('surfaces the control-api denial reason on a 403 instead of collapsing to null', async () => { + it('does not convert a control-api 403 body into a public result', async () => { clientMock.controlApiRequest.mockRejectedValueOnce( new ControlApiError( - 'Control API POST /external/rpc/token failed (403): desktop_requires_team', + 'Control API POST /external/rpc/token failed (403): secret-internal-reason', 403, - { error: 'desktop_requires_team' } + { error: 'secret-internal-reason' } ) ) - const result = await issueRpcAccessToken('session', ['desktop:view'], ['pro-agent']) - - expect(result).toEqual({ error: 'desktop_requires_team' }) + await expect( + issueRpcAccessToken('session', ['desktop:view'], ['pro-agent']) + ).rejects.toBeInstanceOf(ControlApiError) }) it('returns the issued token unchanged on success', async () => { @@ -50,6 +50,8 @@ describe('rpcService.issueRpcAccessToken', () => { }) ) - await expect(issueRpcAccessToken('session', ['host:message:invoke'], ['pro-agent'])).rejects.toThrow() + await expect( + issueRpcAccessToken('session', ['host:message:invoke'], ['pro-agent']) + ).rejects.toThrow() }) }) From 486aa8da615f6eae9ef61f6e8f4ee40c3b1f776b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 04:52:21 -0600 Subject: [PATCH 038/528] fix(access): bound deferred shadow scheduling Reserve aggregate shadow capacity before queueing deferred comparison work so a same-turn request burst cannot create an unbounded callback backlog. - Reject shadow work above the shared producer-concurrency clamp before deferral. - Cover a 1,000-request burst with a deterministic failing-before regression test. - Control API focused shadow tests: 10 passed. - Control API TypeScript build: passed. - Catalog serving, action context, RPC v2, and client rollout gates remain disabled. - This changes only shadow telemetry scheduling and never an authorization result. --- .../services/access/accessCatalogShadow.ts | 10 ++--- .../test/accessCatalogShadowCapacity.test.ts | 45 +++++++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 control-api/test/accessCatalogShadowCapacity.test.ts diff --git a/control-api/src/services/access/accessCatalogShadow.ts b/control-api/src/services/access/accessCatalogShadow.ts index 86ca3c5355..c1cfc9c6bb 100644 --- a/control-api/src/services/access/accessCatalogShadow.ts +++ b/control-api/src/services/access/accessCatalogShadow.ts @@ -175,12 +175,12 @@ export function scheduleAccessCatalogShadow(input: { scope?: CatalogShadowScope }): void { if (!input.session || configuredUserAccessIntent.catalogMode === 'off') return + if (shadowInFlight >= SHADOW_MAX_IN_FLIGHT) { + record(input.family, 'skipped_capacity') + return + } + shadowInFlight += 1 setImmediate(() => { - if (shadowInFlight >= SHADOW_MAX_IN_FLIGHT) { - record(input.family, 'skipped_capacity') - return - } - shadowInFlight += 1 void compareAccessCatalogShadow({ ...input, session: input.session! }).finally(() => { shadowInFlight = Math.max(0, shadowInFlight - 1) }) diff --git a/control-api/test/accessCatalogShadowCapacity.test.ts b/control-api/test/accessCatalogShadowCapacity.test.ts new file mode 100644 index 0000000000..ff2ba0a80a --- /dev/null +++ b/control-api/test/accessCatalogShadowCapacity.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ACCESS_EXECUTION_LIMIT_CLAMPS } from '../src/services/access/accessExecutionBudget.js' + +const ORIGINAL_MODE = process.env.CONTROL_API_USER_ACCESS_CATALOG_MODE + +afterEach(() => { + vi.unstubAllGlobals() + vi.resetModules() + if (ORIGINAL_MODE === undefined) { + delete process.env.CONTROL_API_USER_ACCESS_CATALOG_MODE + } else { + process.env.CONTROL_API_USER_ACCESS_CATALOG_MODE = ORIGINAL_MODE + } +}) + +describe('aggregate access shadow scheduling capacity', () => { + it('reserves bounded capacity before deferring comparison work', async () => { + process.env.CONTROL_API_USER_ACCESS_CATALOG_MODE = 'shadow' + vi.resetModules() + const deferred: Array<() => void> = [] + vi.stubGlobal('setImmediate', (callback: () => void) => { + deferred.push(callback) + return {} as NodeJS.Immediate + }) + const { scheduleAccessCatalogShadow } = + await import('../src/services/access/accessCatalogShadow.js') + const session = { + contract: 'v1' as const, + userId: '10000000-0000-4000-8000-000000000001', + tokenHash: 'token-hash', + issuedAt: 1_900_000_000, + } + + for (let index = 0; index < 1_000; index += 1) { + scheduleAccessCatalogShadow({ + session, + family: 'team', + legacyLogicalIds: [], + legacyComplete: true, + }) + } + + expect(deferred).toHaveLength(ACCESS_EXECUTION_LIMIT_CLAMPS.producerConcurrency) + }) +}) From 3a8d455916a4230aeae4ba2c31d6d4f849ef2b54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 08:59:57 -0600 Subject: [PATCH 039/528] test(auth): use active invitation expiry windows Class-check: fixed invitation timestamps in directoryAuthorizationPrivacy.test.ts -> 9 pairs -> all replaced with active windows; 2 existing relative fixtures remain. Repro-test: control-api/test/directoryAuthorizationPrivacy.test.ts failed at 8bd8e825 because three security cases returned expired before reaching their assertions. --- .../directoryAuthorizationPrivacy.test.ts | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/control-api/test/directoryAuthorizationPrivacy.test.ts b/control-api/test/directoryAuthorizationPrivacy.test.ts index 6bb7cd24f6..669d79be8a 100644 --- a/control-api/test/directoryAuthorizationPrivacy.test.ts +++ b/control-api/test/directoryAuthorizationPrivacy.test.ts @@ -39,6 +39,13 @@ const TARGET = '00000000-0000-4000-8000-000000000002' const TEAM_A = '00000000-0000-4000-8000-000000000010' const TEAM_SECRET = '00000000-0000-4000-8000-000000000099' +function activeInvitationWindow() { + return { + created_at: new Date(Date.now() - 60_000), + expires_at: new Date(Date.now() + 60_000), + } +} + describe('directory privacy and atomic authorization', () => { beforeEach(() => { mocks.query.mockReset() @@ -89,8 +96,7 @@ describe('directory privacy and atomic authorization', () => { token: 'must-never-serialize', status: 'pending', purpose: 'member_invitation', - created_at: new Date('2026-08-10T12:00:00Z'), - expires_at: new Date('2026-08-11T12:00:00Z'), + ...activeInvitationWindow(), accepted_at: null, accepted_user_id: null, assignment_count: 2, @@ -201,8 +207,7 @@ describe('directory privacy and atomic authorization', () => { token: 'must-never-leave-control-api', status: 'draft', purpose: 'member_invitation', - created_at: new Date('2026-08-10T12:00:00Z'), - expires_at: new Date('2026-08-11T12:00:00Z'), + ...activeInvitationWindow(), accepted_at: null, accepted_user_id: null, team_name: 'Team A', @@ -264,8 +269,7 @@ describe('directory privacy and atomic authorization', () => { token: 'inert-until-activation', status: 'draft', purpose: 'member_invitation', - created_at: new Date('2026-08-10T12:00:00Z'), - expires_at: new Date('2026-08-11T12:00:00Z'), + ...activeInvitationWindow(), accepted_at: null, accepted_user_id: null, team_name: 'Team A', @@ -335,8 +339,7 @@ describe('directory privacy and atomic authorization', () => { token: 'invitation-token', status: 'pending', purpose: 'member_invitation', - created_at: new Date('2026-08-10T12:00:00Z'), - expires_at: new Date('2026-08-11T12:00:00Z'), + ...activeInvitationWindow(), accepted_at: null, accepted_user_id: null, team_name: 'Team A', @@ -413,8 +416,7 @@ describe('directory privacy and atomic authorization', () => { token: 'single-use-token', status: 'pending', purpose: 'member_invitation', - created_at: new Date('2026-08-10T12:00:00Z'), - expires_at: new Date('2026-08-11T12:00:00Z'), + ...activeInvitationWindow(), accepted_at: null, accepted_user_id: null, team_name: 'Team A', @@ -490,8 +492,7 @@ describe('directory privacy and atomic authorization', () => { token: 'invitation-token', status: 'pending', purpose: 'member_invitation', - created_at: new Date('2026-08-10T12:00:00Z'), - expires_at: new Date('2026-08-11T12:00:00Z'), + ...activeInvitationWindow(), accepted_at: null, accepted_user_id: null, team_name: 'Team A', @@ -585,8 +586,7 @@ describe('directory privacy and atomic authorization', () => { token: 'password-reset-token', status: 'pending', purpose: 'password_reset', - created_at: new Date('2026-08-10T12:00:00Z'), - expires_at: new Date('2026-08-11T12:00:00Z'), + ...activeInvitationWindow(), accepted_at: null, accepted_user_id: null, team_name: null, @@ -780,8 +780,7 @@ describe('directory privacy and atomic authorization', () => { token: 'invitation-token', status: 'pending', purpose: 'member_invitation', - created_at: new Date('2026-08-10T12:00:00Z'), - expires_at: new Date('2026-08-11T12:00:00Z'), + ...activeInvitationWindow(), accepted_at: null, accepted_user_id: null, team_name: 'Team A', @@ -834,8 +833,7 @@ describe('directory privacy and atomic authorization', () => { token: 'secret', status: 'pending', purpose: 'member_invitation', - created_at: new Date('2026-08-10T12:00:00Z'), - expires_at: new Date('2026-08-11T12:00:00Z'), + ...activeInvitationWindow(), accepted_at: null, accepted_user_id: null, team_name: 'Team A', From 4f7e1053eaef0547977f6e8f8a339a74e3b041b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 15:54:47 -0600 Subject: [PATCH 040/528] fix(access): r1-b1 preserve distinct catalog pagination Class-check: 27 source arms -> 11 duplicate-capable arms -> all fixed. Repro-test: catalogDistinctPagination real PostgreSQL test failed at c693650f. F3-generated-artifact: mandatory Control API version metadata only; no semantic package change. --- .../src/services/access/catalogProducerSql.ts | 127 ++-- .../services/access/catalogProducerSupport.ts | 11 +- ...agination.realPostgres.integration.test.ts | 573 ++++++++++++++++++ 3 files changed, 667 insertions(+), 44 deletions(-) create mode 100644 control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts diff --git a/control-api/src/services/access/catalogProducerSql.ts b/control-api/src/services/access/catalogProducerSql.ts index e4e054d379..81e1c6f39b 100644 --- a/control-api/src/services/access/catalogProducerSql.ts +++ b/control-api/src/services/access/catalogProducerSql.ts @@ -39,8 +39,9 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f ON resource.environment_id = $3 AND resource.resource_type = 'host' AND resource.logical_id = $5::text || '/' || ta.agent_name AND resource.enabled = TRUE AND resource.deleted_at IS NULL - WHERE tm.user_id = $1 AND tm.status = 'active' AND ta.agent_name > $7`, + WHERE tm.user_id = $1 AND tm.status = 'active' AND ta.agent_name > $7`, orderBy: 'source_key', + distinctCanonicalBy: 'source_key', }, ]), context: boundedKeyUnionSql([ @@ -62,12 +63,14 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f ON resource.environment_id = $3 AND resource.resource_type = 'context' AND resource.logical_id = $6::text || '/' || tc.context_id AND resource.enabled = TRUE AND resource.deleted_at IS NULL - WHERE tm.user_id = $1 AND tm.status = 'active' AND tc.context_id > $7`, + WHERE tm.user_id = $1 AND tm.status = 'active' AND tc.context_id > $7`, orderBy: 'source_key', + distinctCanonicalBy: 'source_key', }, ]), mcp_server: boundedKeyUnionSql([ - `SELECT edge.target_id AS logical_id + { + sql: `SELECT edge.target_id AS logical_id FROM user_contexts uc JOIN operational_resource_relationships edge ON edge.environment_id = $3 @@ -84,7 +87,10 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND target_resource.logical_id = edge.target_id AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL WHERE uc.user_id = $1 AND edge.target_id > $2`, - `SELECT edge.target_id AS logical_id + distinctCanonicalBy: 'logical_id', + }, + { + sql: `SELECT edge.target_id AS logical_id FROM team_contexts tc JOIN team_members tm ON tm.team_id = tc.team_id JOIN operational_resource_relationships edge @@ -102,7 +108,10 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND target_resource.logical_id = edge.target_id AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL WHERE tm.user_id = $1 AND tm.status = 'active' AND edge.target_id > $2`, - `SELECT mcp_edge.target_id AS logical_id + distinctCanonicalBy: 'logical_id', + }, + { + sql: `SELECT mcp_edge.target_id AS logical_id FROM user_agents ua JOIN operational_resource_relationships host_edge ON host_edge.environment_id = $3 @@ -129,7 +138,10 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND target_resource.logical_id = mcp_edge.target_id AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL WHERE ua.user_id = $1 AND mcp_edge.target_id > $2`, - `SELECT mcp_edge.target_id AS logical_id + distinctCanonicalBy: 'logical_id', + }, + { + sql: `SELECT mcp_edge.target_id AS logical_id FROM team_agents ta JOIN team_members tm ON tm.team_id = ta.team_id JOIN operational_resource_relationships host_edge @@ -157,6 +169,8 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND target_resource.logical_id = mcp_edge.target_id AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL WHERE tm.user_id = $1 AND tm.status = 'active' AND mcp_edge.target_id > $2`, + distinctCanonicalBy: 'logical_id', + }, ]), workflow_recipe: boundedKeyUnionSql([ { @@ -178,9 +192,10 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f ON resource.environment_id = $3 AND resource.resource_type = 'workflow_recipe' AND resource.logical_id = twt.recipe_namespace || '/' || twt.recipe_name AND resource.enabled = TRUE AND resource.deleted_at IS NULL - WHERE tm.user_id = $1 AND tm.status = 'active' + WHERE tm.user_id = $1 AND tm.status = 'active' AND (twt.recipe_namespace || '/' || twt.recipe_name) > $2`, orderBy: 'logical_id', + distinctCanonicalBy: 'logical_id', }, ]), workflow_run: boundedKeyUnionSql([ @@ -284,56 +299,77 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f ]), gfs_resource: boundedKeyUnionSql([ { - sql: `SELECT g.resource_id::text AS logical_id, g.resource_id AS cursor_id - FROM gfs_grants g - JOIN gfs_resources resource - ON resource.resource_id = g.resource_id AND resource.deleted_at IS NULL - WHERE g.subject_type = 'user' AND g.subject_id = $1::text - AND g.resource_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000')`, + sql: `SELECT resource.resource_id::text AS logical_id, + resource.resource_id AS cursor_id + FROM gfs_resources resource + WHERE resource.deleted_at IS NULL + AND resource.resource_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000') + AND ( + SELECT TRUE + FROM gfs_grants g + WHERE g.resource_id = resource.resource_id + AND g.drive = resource.drive + AND g.subject_type = 'user' AND g.subject_id = $1::text + LIMIT 1 + ) IS TRUE`, orderBy: 'cursor_id', }, { - sql: `SELECT g.resource_id::text AS logical_id, g.resource_id AS cursor_id - FROM gfs_grants g - JOIN gfs_resources resource - ON resource.resource_id = g.resource_id AND resource.deleted_at IS NULL - WHERE g.subject_type = 'team' - AND g.resource_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000') - AND EXISTS ( - SELECT 1 - FROM team_members tm - WHERE tm.team_id::text = g.subject_id + sql: `SELECT resource.resource_id::text AS logical_id, + resource.resource_id AS cursor_id + FROM gfs_resources resource + WHERE resource.deleted_at IS NULL + AND resource.resource_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000') + AND ( + SELECT TRUE + FROM gfs_grants g + JOIN team_members tm ON tm.team_id::text = g.subject_id + WHERE g.resource_id = resource.resource_id + AND g.drive = resource.drive + AND g.subject_type = 'team' AND tm.user_id = $1::uuid AND tm.status = 'active' - )`, + LIMIT 1 + ) IS TRUE`, orderBy: 'cursor_id', }, { - sql: `SELECT share.resource_id::text AS logical_id, share.resource_id AS cursor_id - FROM gfs_shares share - JOIN gfs_resources resource - ON resource.resource_id = share.resource_id AND resource.deleted_at IS NULL - WHERE share.subject_type = 'user' AND share.subject_id = $1::text - AND share.resource_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000')`, + sql: `SELECT resource.resource_id::text AS logical_id, + resource.resource_id AS cursor_id + FROM gfs_resources resource + WHERE resource.deleted_at IS NULL + AND resource.resource_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000') + AND ( + SELECT TRUE + FROM gfs_shares share + WHERE share.resource_id = resource.resource_id + AND share.drive = resource.drive + AND share.subject_type = 'user' AND share.subject_id = $1::text + LIMIT 1 + ) IS TRUE`, orderBy: 'cursor_id', }, { - sql: `SELECT share.resource_id::text AS logical_id, share.resource_id AS cursor_id - FROM gfs_shares share - JOIN gfs_resources resource - ON resource.resource_id = share.resource_id AND resource.deleted_at IS NULL - WHERE share.subject_type = 'team' - AND share.resource_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000') - AND EXISTS ( - SELECT 1 - FROM team_members tm - WHERE tm.team_id::text = share.subject_id + sql: `SELECT resource.resource_id::text AS logical_id, + resource.resource_id AS cursor_id + FROM gfs_resources resource + WHERE resource.deleted_at IS NULL + AND resource.resource_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000') + AND ( + SELECT TRUE + FROM gfs_shares share + JOIN team_members tm ON tm.team_id::text = share.subject_id + WHERE share.resource_id = resource.resource_id + AND share.drive = resource.drive + AND share.subject_type = 'team' AND tm.user_id = $1::uuid AND tm.status = 'active' - )`, + LIMIT 1 + ) IS TRUE`, orderBy: 'cursor_id', }, ]), shared_filesystem: boundedKeyUnionSql([ - `SELECT edge.target_id AS logical_id + { + sql: `SELECT edge.target_id AS logical_id FROM user_contexts uc JOIN operational_resource_relationships edge ON edge.environment_id = $3 @@ -351,7 +387,10 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND target_resource.logical_id = edge.target_id AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL WHERE uc.user_id = $1 AND edge.target_id > $2`, - `SELECT edge.target_id AS logical_id + distinctCanonicalBy: 'logical_id', + }, + { + sql: `SELECT edge.target_id AS logical_id FROM team_contexts tc JOIN team_members tm ON tm.team_id = tc.team_id JOIN operational_resource_relationships edge @@ -370,6 +409,8 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND target_resource.logical_id = edge.target_id AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL WHERE tm.user_id = $1 AND tm.status = 'active' AND edge.target_id > $2`, + distinctCanonicalBy: 'logical_id', + }, ]), sandbox_app: boundedKeyUnionSql([ `SELECT edge.target_id AS logical_id diff --git a/control-api/src/services/access/catalogProducerSupport.ts b/control-api/src/services/access/catalogProducerSupport.ts index d88eb3620e..696f0f16be 100644 --- a/control-api/src/services/access/catalogProducerSupport.ts +++ b/control-api/src/services/access/catalogProducerSupport.ts @@ -34,6 +34,7 @@ export async function catalogQuery( export type BoundedKeyArm = Readonly<{ sql: string orderBy?: string + distinctCanonicalBy?: string hasValidUntil?: boolean }> @@ -43,11 +44,19 @@ export function boundedKeyUnionSql(arms: readonly (string | BoundedKeyArm)[]): s const sources = arms .map((arm, index) => { const definition = typeof arm === 'string' ? { sql: arm } : arm + if (definition.distinctCanonicalBy && definition.hasValidUntil) { + throw new CatalogProducerContractError('distinct_valid_until_requires_aggregation') + } + const boundedSql = definition.distinctCanonicalBy + ? `SELECT DISTINCT ON (${definition.distinctCanonicalBy}) distinct_arm.* + FROM (${definition.sql}) distinct_arm + ORDER BY ${definition.distinctCanonicalBy}` + : definition.sql return `${names[index]} AS MATERIALIZED ( SELECT bounded_arm.logical_id, ${definition.hasValidUntil ? 'bounded_arm.valid_until' : 'NULL::timestamptz'} AS valid_until - FROM (${definition.sql}) bounded_arm + FROM (${boundedSql}) bounded_arm ORDER BY ${definition.orderBy ?? 'logical_id'} LIMIT $4 )` diff --git a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts new file mode 100644 index 0000000000..23b536d84f --- /dev/null +++ b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts @@ -0,0 +1,573 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { randomBytes, randomUUID } from 'node:crypto' +import { Pool } from 'pg' +import { config } from '../src/config.js' +import { type DbClient, initDb } from '../src/db.js' +import { buildAccessCatalog } from '../src/services/access/accessCatalogCoordinator.js' +import { AccessExecutionBudget } from '../src/services/access/accessExecutionBudget.js' +import type { + CatalogOperationalSourceState, + CatalogRequestContext, +} from '../src/services/access/catalogContracts.js' +import { CATALOG_KEY_SQL } from '../src/services/access/catalogProducerSql.js' +import { requireCatalogProducer } from '../src/services/access/catalogProducers.js' +import { + OPERATIONAL_SOURCE_FAMILIES, + canonicalEnvironmentId, +} from '../src/services/access/operationalAccessProjection.js' +import type { ExternalSessionAuthorityContext } from '../src/services/auth/externalSessionAuthentication.js' + +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const describeRealPostgres = adminUrl ? describe : describe.skip +const runtimeRoles = [ + 'control_api_runtime', + 'trace_maintenance_runtime', + 'workflow_recipes_runtime', +] as const + +function databaseUrl(baseUrl: string, database: string): string { + const value = new URL(baseUrl) + value.pathname = `/${database}` + return value.toString() +} + +function quoteIdentifier(value: string): string { + return `"${value.replace(/"/g, '""')}"` +} + +function transaction(pool: Pool) { + return async (work: (db: DbClient) => Promise): Promise => { + const client = await pool.connect() + try { + await client.query('BEGIN') + const result = await work(client) + await client.query('COMMIT') + return result + } catch (error) { + await client.query('ROLLBACK') + throw error + } finally { + client.release() + } + } +} + +describeRealPostgres('distinct catalog key pagination on real PostgreSQL', () => { + const database = `control_api_catalog_distinct_${randomBytes(6).toString('hex')}` + const connectionString = databaseUrl( + adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', + database + ) + const environmentId = canonicalEnvironmentId() + const userId = randomUUID() + const teamIds = [randomUUID(), randomUUID(), randomUUID()] + let adminPool: Pool + let databasePool: Pool + + async function principal(label: string, teamCount = 0) { + const principalUserId = randomUUID() + await databasePool.query(`INSERT INTO users(id, email, name) VALUES ($1, $2, $3)`, [ + principalUserId, + `${label}-${principalUserId}@example.test`, + label, + ]) + const principalTeamIds: string[] = [] + for (let index = 0; index < teamCount; index += 1) { + const teamId = randomUUID() + principalTeamIds.push(teamId) + await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, $2)`, [ + teamId, + `${label} Team ${index + 1}`, + ]) + await databasePool.query( + `INSERT INTO team_members(team_id, user_id, role, status) + VALUES ($1, $2, 'member', 'active')`, + [teamId, principalUserId] + ) + } + return { userId: principalUserId, teamIds: principalTeamIds } + } + + async function operationalResource( + type: string, + logicalId: string, + sourceFamily = type + ): Promise { + await databasePool.query( + `INSERT INTO operational_resource_index( + environment_id, resource_type, logical_id, source_family, source_generation, + provider_uid, provider_resource_version, display_name, enabled, content_bytes + ) VALUES ($1, $2, $3, $4, 1, $5, '1', $3, TRUE, 64) + ON CONFLICT (environment_id, resource_type, logical_id) DO NOTHING`, + [environmentId, type, logicalId, sourceFamily, `${type}:${logicalId}`] + ) + } + + async function relationship(input: { + sourceType: string + sourceId: string + relationshipType: string + targetType: string + targetId: string + instanceId: string + behavior?: Record + }): Promise { + await databasePool.query( + `INSERT INTO operational_resource_relationships( + environment_id, source_type, source_id, relationship_type, target_type, target_id, + relationship_instance_id, behavior_attributes, source_family, source_provider_uid, + source_resource_version, source_generation, content_bytes + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $2, $9, '1', 1, 64)`, + [ + environmentId, + input.sourceType, + input.sourceId, + input.relationshipType, + input.targetType, + input.targetId, + input.instanceId, + input.behavior ?? {}, + `${input.sourceType}:${input.sourceId}`, + ] + ) + } + + async function consumeFamily(input: { + family: + | 'host' + | 'context' + | 'mcp_server' + | 'workflow_recipe' + | 'gfs_resource' + | 'shared_filesystem' + userId: string + expected: readonly string[] + minimumPathsForFirst?: number + minimumRelationshipsForFirst?: number + }): Promise { + const session: ExternalSessionAuthorityContext = { + contract: 'v1', + userId: input.userId, + tokenHash: randomBytes(32).toString('hex'), + issuedAt: Math.floor(Date.now() / 1_000), + } + const seen: string[] = [] + let cursor: string | null = null + let firstItem: Awaited>['items'][number] | undefined + try { + do { + const page = await buildAccessCatalog( + { + session, + families: [input.family], + limit: 2, + ...(cursor ? { cursor } : {}), + }, + { transaction: transaction(databasePool) } + ) + expect(page.complete).toBe(true) + firstItem ??= page.items[0] + seen.push(...page.items.map(item => item.resource.logicalId)) + cursor = page.nextCursor + } while (cursor) + } catch (error) { + throw new Error(`Failed to consume ${input.family}`, { cause: error }) + } + expect(seen).toEqual(input.expected) + expect(new Set(seen).size).toBe(input.expected.length) + if (input.minimumPathsForFirst) { + expect(firstItem?.accessPaths.length).toBeGreaterThanOrEqual(input.minimumPathsForFirst) + } + if (input.minimumRelationshipsForFirst) { + expect(firstItem?.relationships.length).toBeGreaterThanOrEqual( + input.minimumRelationshipsForFirst + ) + } + const explained = await databasePool.query( + `EXPLAIN (ANALYZE, BUFFERS, WAL, FORMAT JSON) ${CATALOG_KEY_SQL[input.family]}`, + [input.userId, '', environmentId, 3, config.hostsNamespace, config.contextsNamespace, ''] + ) + const envelope = (explained.rows[0] as Record)['QUERY PLAN'] + expect(Array.isArray(envelope)).toBe(true) + const root = (envelope as Array>)[0]?.Plan as + | (Record & { Plans?: unknown[] }) + | undefined + expect(Number(root?.['Actual Rows'] ?? 0)).toBeLessThanOrEqual(3) + const pending: Array & { Plans?: unknown[] }> = root ? [root] : [] + let sawLimit = false + while (pending.length > 0) { + const node = pending.pop()! + if (node['Node Type'] === 'Limit') sawLimit = true + expect(String(node['Sort Method'] ?? '')).not.toContain('external') + expect(Number(node['Temp Read Blocks'] ?? 0)).toBe(0) + expect(Number(node['Temp Written Blocks'] ?? 0)).toBe(0) + pending.push( + ...((node.Plans ?? []) as Array & { Plans?: unknown[] }>) + ) + } + expect(sawLimit).toBe(true) + } + + beforeAll(async () => { + adminPool = new Pool({ connectionString: adminUrl }) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) + databasePool = new Pool({ connectionString }) + await initDb({ connect: () => databasePool.connect() }) + await databasePool.query( + `INSERT INTO users(id, email, name) VALUES ($1, $2, 'Distinct Catalog User')`, + [userId, `${userId}@example.test`] + ) + await databasePool.query( + `INSERT INTO operational_catalog_source_state( + environment_id, source_family, generation, resource_version, status, last_success_at + ) + SELECT $1, source_family, 1, '1', 'current', NOW() + FROM UNNEST($2::text[]) source_family`, + [environmentId, OPERATIONAL_SOURCE_FAMILIES] + ) + for (const [index, teamId] of teamIds.entries()) { + await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, $2)`, [ + teamId, + `Distinct Team ${index + 1}`, + ]) + await databasePool.query( + `INSERT INTO team_members(team_id, user_id, role, status) + VALUES ($1, $2, 'member', 'active')`, + [teamId, userId] + ) + await databasePool.query(`INSERT INTO team_agents(team_id, agent_name) VALUES ($1, 'a')`, [ + teamId, + ]) + } + await databasePool.query( + `INSERT INTO team_agents(team_id, agent_name) VALUES ($1, 'b'), ($1, 'c')`, + [teamIds[0]] + ) + await databasePool.query( + `INSERT INTO operational_resource_index( + environment_id, resource_type, logical_id, source_family, source_generation, + provider_uid, provider_resource_version, display_name, enabled, content_bytes + ) VALUES + ($1, 'host', $2 || '/a', 'host', 1, 'host-a', '1', 'Host A', TRUE, 64), + ($1, 'host', $2 || '/b', 'host', 1, 'host-b', '1', 'Host B', TRUE, 64), + ($1, 'host', $2 || '/c', 'host', 1, 'host-c', '1', 'Host C', TRUE, 64)`, + [environmentId, config.hostsNamespace] + ) + }) + + afterAll(async () => { + await databasePool?.end() + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + await adminPool.end() + } + }) + + it('does not let duplicate team paths hide later canonical host IDs', async () => { + const budget = AccessExecutionBudget.create('catalog') + const sourceStates: CatalogOperationalSourceState[] = OPERATIONAL_SOURCE_FAMILIES.map( + family => ({ family, generation: '1', resourceVersion: '1', status: 'current' }) + ) + const context: CatalogRequestContext = { + db: databasePool, + budget, + principal: { + userId, + sessionContract: 'v2', + sessionRevision: '1', + userRevision: '1', + catalogRevision: '1', + authorizationRevision: '1', + memberships: teamIds.map(teamId => ({ teamId, role: 'member' })), + }, + environmentId, + sourceStates: new Map(sourceStates.map(state => [state.family, state])), + } + try { + const page = await requireCatalogProducer('host').listCanonicalKeys( + context, + { afterKey: null, exhausted: false }, + 2 + ) + expect(page.candidates.map(candidate => candidate.key[2])).toEqual([ + `${config.hostsNamespace}/a`, + `${config.hostsNamespace}/b`, + `${config.hostsNamespace}/c`, + ]) + expect(page.hasMore).toBe(true) + expect(page.continuation.exhausted).toBe(false) + } finally { + budget.close() + } + }) + + it('preserves distinct keys for every duplicate-capable producer arm class', async () => { + const teamHost = await principal('team-host', 3) + const hostNames = ['team-host-a', 'team-host-b', 'team-host-c'] + for (const name of hostNames) + await operationalResource('host', `${config.hostsNamespace}/${name}`) + for (const teamId of teamHost.teamIds) { + await databasePool.query(`INSERT INTO team_agents(team_id, agent_name) VALUES ($1, $2)`, [ + teamId, + hostNames[0], + ]) + } + await databasePool.query( + `INSERT INTO team_agents(team_id, agent_name) VALUES ($1, $2), ($1, $3)`, + [teamHost.teamIds[0], hostNames[1], hostNames[2]] + ) + await consumeFamily({ + family: 'host', + userId: teamHost.userId, + expected: hostNames.map(name => `${config.hostsNamespace}/${name}`), + minimumPathsForFirst: 3, + }) + + const teamContext = await principal('team-context', 3) + const contextNames = ['team-context-a', 'team-context-b', 'team-context-c'] + for (const name of contextNames) { + await operationalResource('context', `${config.contextsNamespace}/${name}`) + } + for (const teamId of teamContext.teamIds) { + await databasePool.query(`INSERT INTO team_contexts(team_id, context_id) VALUES ($1, $2)`, [ + teamId, + contextNames[0], + ]) + } + await databasePool.query( + `INSERT INTO team_contexts(team_id, context_id) VALUES ($1, $2), ($1, $3)`, + [teamContext.teamIds[0], contextNames[1], contextNames[2]] + ) + await consumeFamily({ + family: 'context', + userId: teamContext.userId, + expected: contextNames.map(name => `${config.contextsNamespace}/${name}`), + minimumPathsForFirst: 3, + }) + + const teamRecipe = await principal('team-recipe', 3) + const recipeNames = ['team-recipe-a', 'team-recipe-b', 'team-recipe-c'] + for (const name of recipeNames) { + await operationalResource( + 'workflow_recipe', + `${config.sandboxNamespace}/${name}`, + 'workflow_recipe' + ) + } + for (const teamId of teamRecipe.teamIds) { + await databasePool.query( + `INSERT INTO team_workflow_triggers(team_id, recipe_namespace, recipe_name) + VALUES ($1, $2, $3)`, + [teamId, config.sandboxNamespace, recipeNames[0]] + ) + } + await databasePool.query( + `INSERT INTO team_workflow_triggers(team_id, recipe_namespace, recipe_name) + VALUES ($1, $2, $3), ($1, $2, $4)`, + [teamRecipe.teamIds[0], config.sandboxNamespace, recipeNames[1], recipeNames[2]] + ) + await consumeFamily({ + family: 'workflow_recipe', + userId: teamRecipe.userId, + expected: recipeNames.map(name => `${config.sandboxNamespace}/${name}`), + minimumPathsForFirst: 3, + }) + + for (const mode of ['direct-context', 'team-context', 'direct-host', 'team-host'] as const) { + const usingTeam = mode.startsWith('team') + const usingHost = mode.endsWith('host') + const actor = await principal(`mcp-${mode}`, usingTeam ? 3 : 0) + const mcpIds = ['a', 'b', 'c'].map( + suffix => `${config.mcpServersNamespace}/mcp-${mode}-${suffix}` + ) + for (const id of mcpIds) await operationalResource('mcp_server', id) + const contextId = `${config.contextsNamespace}/mcp-${mode}-context` + await operationalResource('context', contextId) + for (const [index, targetId] of mcpIds.entries()) { + await relationship({ + sourceType: 'context', + sourceId: contextId, + relationshipType: 'includes_mcp_server', + targetType: 'mcp_server', + targetId, + instanceId: `mcp-${mode}-${index}`, + }) + } + if (usingHost) { + const hostName = `mcp-${mode}-host` + const hostId = `${config.hostsNamespace}/${hostName}` + await operationalResource('host', hostId) + await relationship({ + sourceType: 'host', + sourceId: hostId, + relationshipType: 'uses_context', + targetType: 'context', + targetId: contextId, + instanceId: `mcp-${mode}-host-context`, + }) + if (usingTeam) { + for (const teamId of actor.teamIds) { + await databasePool.query( + `INSERT INTO team_agents(team_id, agent_name) VALUES ($1, $2)`, + [teamId, hostName] + ) + } + } else { + await databasePool.query(`INSERT INTO user_agents(user_id, agent_name) VALUES ($1, $2)`, [ + actor.userId, + hostName, + ]) + for (let copy = 1; copy < 3; copy += 1) { + const duplicateHostName = `${hostName}-${copy}` + const duplicateHostId = `${config.hostsNamespace}/${duplicateHostName}` + await operationalResource('host', duplicateHostId) + await databasePool.query( + `INSERT INTO user_agents(user_id, agent_name) VALUES ($1, $2)`, + [actor.userId, duplicateHostName] + ) + await relationship({ + sourceType: 'host', + sourceId: duplicateHostId, + relationshipType: 'uses_context', + targetType: 'context', + targetId: contextId, + instanceId: `mcp-${mode}-host-context-${copy}`, + }) + } + } + } else if (usingTeam) { + const contextName = contextId.slice(config.contextsNamespace.length + 1) + for (const teamId of actor.teamIds) { + await databasePool.query( + `INSERT INTO team_contexts(team_id, context_id) VALUES ($1, $2)`, + [teamId, contextName] + ) + } + } else { + const sourceIds = [contextId] + for (let copy = 1; copy < 3; copy += 1) { + const duplicateId = `${config.contextsNamespace}/mcp-${mode}-context-${copy}` + sourceIds.push(duplicateId) + await operationalResource('context', duplicateId) + await relationship({ + sourceType: 'context', + sourceId: duplicateId, + relationshipType: 'includes_mcp_server', + targetType: 'mcp_server', + targetId: mcpIds[0], + instanceId: `mcp-${mode}-duplicate-${copy}`, + }) + } + for (const sourceId of sourceIds) { + await databasePool.query( + `INSERT INTO user_contexts(user_id, context_id) VALUES ($1, $2)`, + [actor.userId, sourceId.slice(config.contextsNamespace.length + 1)] + ) + } + } + await consumeFamily({ + family: 'mcp_server', + userId: actor.userId, + expected: mcpIds, + minimumPathsForFirst: 3, + }) + } + + for (const mode of ['direct', 'team'] as const) { + const actor = await principal(`sfs-${mode}`, mode === 'team' ? 3 : 0) + const contextName = `sfs-${mode}-context` + const contextId = `${config.contextsNamespace}/${contextName}` + const filesystemIds = ['a', 'b', 'c'].map( + suffix => `${config.sharedFilesystemsNamespace}/sfs-${mode}-${suffix}` + ) + await operationalResource('context', contextId) + for (const id of filesystemIds) await operationalResource('shared_filesystem', id) + for (let copy = 0; copy < 3; copy += 1) { + await relationship({ + sourceType: 'context', + sourceId: contextId, + relationshipType: 'mounts_shared_filesystem', + targetType: 'shared_filesystem', + targetId: filesystemIds[0], + instanceId: `sfs-${mode}-a-${copy}`, + behavior: { mountPath: `/workspace/${mode}/a-${copy}`, readOnly: true }, + }) + } + for (const [index, id] of filesystemIds.slice(1).entries()) { + await relationship({ + sourceType: 'context', + sourceId: contextId, + relationshipType: 'mounts_shared_filesystem', + targetType: 'shared_filesystem', + targetId: id, + instanceId: `sfs-${mode}-${index + 1}`, + behavior: { mountPath: `/workspace/${mode}/${index + 1}`, readOnly: true }, + }) + } + if (mode === 'direct') { + await databasePool.query(`INSERT INTO user_contexts(user_id, context_id) VALUES ($1, $2)`, [ + actor.userId, + contextName, + ]) + } else { + for (const teamId of actor.teamIds) { + await databasePool.query( + `INSERT INTO team_contexts(team_id, context_id) VALUES ($1, $2)`, + [teamId, contextName] + ) + } + } + await consumeFamily({ + family: 'shared_filesystem', + userId: actor.userId, + expected: filesystemIds, + minimumPathsForFirst: 3, + minimumRelationshipsForFirst: 3, + }) + } + + for (const mode of ['grant', 'share'] as const) { + const actor = await principal(`gfs-${mode}`, 3) + const prefix = mode === 'grant' ? '10000000' : '20000000' + const resourceIds = ['a', 'b', 'c'].map( + suffix => `${prefix}-0000-4000-8000-00000000000${suffix}` + ) + for (const [index, resourceId] of resourceIds.entries()) { + await databasePool.query( + `INSERT INTO gfs_resources(resource_id, drive, name, kind) + VALUES ($1, $2, '/', 'directory')`, + [resourceId, `gfs-${mode}-${index}`] + ) + } + const table = mode === 'grant' ? 'gfs_grants' : 'gfs_shares' + for (const teamId of actor.teamIds) { + await databasePool.query( + `INSERT INTO ${table}(drive, resource_id, subject_type, subject_id, permissions) + VALUES ($1, $2, 'team', $3, ARRAY['read']::text[])`, + [`gfs-${mode}-0`, resourceIds[0], teamId] + ) + } + for (let index = 1; index < resourceIds.length; index += 1) { + await databasePool.query( + `INSERT INTO ${table}(drive, resource_id, subject_type, subject_id, permissions) + VALUES ($1, $2, 'team', $3, ARRAY['read']::text[])`, + [`gfs-${mode}-${index}`, resourceIds[index], actor.teamIds[0]] + ) + } + await consumeFamily({ + family: 'gfs_resource', + userId: actor.userId, + expected: resourceIds, + minimumPathsForFirst: 3, + }) + } + }, 30_000) +}) From e3dd82b57a6b4d3a1d5ca70d17e4710aa09ca2e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 16:00:55 -0600 Subject: [PATCH 041/528] fix(access): r1-h1 exercise real operational producer boundary Class-check: one false producer proof replaced; pure unit and role tests retained. Repro-test: list-shape mutation kept old test green and made the new harness fail. F3-generated-artifact: mandatory Control API version metadata only; no semantic package change. --- ...gFamilies.realPostgres.integration.test.ts | 242 +++++++++++++----- .../test/helpers/temporaryKubernetesApi.ts | 228 +++++++++++++++++ 2 files changed, 408 insertions(+), 62 deletions(-) create mode 100644 control-api/test/helpers/temporaryKubernetesApi.ts diff --git a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts index d003421643..0754a98fed 100644 --- a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts @@ -3,20 +3,20 @@ import { randomBytes, randomUUID } from 'node:crypto' import { Pool, type PoolClient } from 'pg' import { config } from '../src/config.js' import { type DbClient, initDb } from '../src/db.js' -import type { K8sGateway } from '../src/k8s.js' +import { K8sGateway } from '../src/k8s.js' import { buildAccessCatalog } from '../src/services/access/accessCatalogCoordinator.js' -import { AccessExecutionBudget } from '../src/services/access/accessExecutionBudget.js' import type { AccessCapability } from '../src/services/access/capabilityRegistry.js' import { CATALOG_FAMILIES, type CatalogFamily } from '../src/services/access/catalogContracts.js' import { resolveLiveAuthorization } from '../src/services/access/liveAuthorizationResolver.js' import { OperationalAccessIndex } from '../src/services/access/operationalAccessIndex.js' import { - OPERATIONAL_SOURCE_FAMILIES, - canonicalEnvironmentId, - projectOperationalObject, -} from '../src/services/access/operationalAccessProjection.js' + OperationalAccessIndexer, + operationalSourceSpecs, +} from '../src/services/access/operationalAccessIndexer.js' +import { canonicalEnvironmentId } from '../src/services/access/operationalAccessProjection.js' import { canonicalResourceIdentity } from '../src/services/access/resourceIdentity.js' import type { ExternalSessionAuthorityContext } from '../src/services/auth/externalSessionAuthentication.js' +import { TemporaryKubernetesApi } from './helpers/temporaryKubernetesApi.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -53,6 +53,15 @@ function transaction(pool: Pool) { } } +async function waitFor(predicate: () => boolean | Promise): Promise { + const deadline = Date.now() + 2_000 + while (Date.now() < deadline) { + if (await predicate()) return + await new Promise(resolve => setTimeout(resolve, 10)) + } + throw new Error('producer_boundary_wait_timeout') +} + type OperationalFixture = Readonly<{ plural: 'hosts' | 'contexts' | 'mcpservers' | 'workflowrecipes' | 'sharedfilesystems' namespace: string @@ -66,10 +75,19 @@ function fixture(input: { uid: string spec?: Readonly> }): OperationalFixture { + const kind = { + hosts: 'Host', + contexts: 'Context', + mcpservers: 'McpServer', + workflowrecipes: 'WorkflowRecipe', + sharedfilesystems: 'SharedFileSystem', + }[input.plural] return Object.freeze({ plural: input.plural, namespace: input.namespace, object: Object.freeze({ + apiVersion: 'clerum.io/v1alpha1', + kind, metadata: Object.freeze({ name: input.name, namespace: input.namespace, @@ -157,21 +175,11 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { uid: 'catalog-files-uid', }), ] - const exactObjects = new Map( - operationalFixtures.map(value => { - const metadata = value.object.metadata as { name: string } - return [`${value.plural}:${value.namespace}/${metadata.name}`, value.object] - }) - ) - const gateway = { - getResourceExact: async (plural: string, name: string, namespace: string) => { - const value = exactObjects.get(`${plural}:${namespace}/${name}`) - if (!value) throw Object.assign(new Error('not found'), { httpStatus: 404 }) - return value - }, - } as unknown as Pick + const kubernetesApi = new TemporaryKubernetesApi() let adminPool: Pool let databasePool: Pool + let gateway: K8sGateway + let indexer: OperationalAccessIndexer beforeAll(async () => { adminPool = new Pool({ connectionString: adminUrl }) @@ -179,6 +187,18 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) databasePool = new Pool({ connectionString }) await initDb({ connect: () => databasePool.connect() }) + await kubernetesApi.start() + for (const value of operationalFixtures) { + kubernetesApi.put(value.plural, value.namespace, value.object) + } + const priorKubeconfig = process.env.KUBECONFIG + process.env.KUBECONFIG = kubernetesApi.kubeconfig() + try { + gateway = new K8sGateway() + } finally { + if (priorKubeconfig === undefined) delete process.env.KUBECONFIG + else process.env.KUBECONFIG = priorKubeconfig + } await databasePool.query( `INSERT INTO users(id, email, name) VALUES ($1, $2, 'Catalog Harness User')`, @@ -277,53 +297,17 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { ) const index = new OperationalAccessIndex(databasePool, transaction(databasePool)) - for (const sourceFamily of OPERATIONAL_SOURCE_FAMILIES) { - const fixtureForFamily = operationalFixtures.find(value => { - const expected = { - host: 'hosts', - context: 'contexts', - mcp_server: 'mcpservers', - workflow_recipe: 'workflowrecipes', - shared_filesystem: 'sharedfilesystems', - } as const - return value.plural === expected[sourceFamily] - })! - const projection = projectOperationalObject({ - environmentId, - plural: fixtureForFamily.plural, - namespace: fixtureForFamily.namespace, - object: fixtureForFamily.object, - behaviorFingerprintKey: config.sessionJwtPrivateKey, - relationshipNamespaces: { - context: config.contextsNamespace, - mcpServer: config.mcpServersNamespace, - sharedFilesystem: config.sharedFilesystemsNamespace, - }, - }) - const budget = AccessExecutionBudget.create('catalog') - try { - const generation = await index.beginRelist({ environmentId, sourceFamily, budget }) - await index.stageRelistPage({ - environmentId, - sourceFamily, - stagingGeneration: generation, - projections: [projection], - budget, - }) - await index.promoteRelist({ - environmentId, - sourceFamily, - stagingGeneration: generation, - resourceVersion: '1', - budget, - }) - } finally { - budget.close() - } + indexer = new OperationalAccessIndexer(gateway, index, { + environmentId, + behaviorFingerprintKey: config.sessionJwtPrivateKey, + }) + for (const source of operationalSourceSpecs) { + await indexer.reconcileSource(source) } }) afterAll(async () => { + await kubernetesApi.close() await databasePool?.end() if (adminPool) { await adminPool.query( @@ -448,4 +432,138 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { ).size ).toBe(2) }) + + it('uses real Kubernetes list and exact-read wire boundaries', async () => { + const listRequests = kubernetesApi.requests.filter(request => !request.watch && !request.name) + expect(listRequests).toHaveLength(operationalSourceSpecs.length) + expect(listRequests.map(request => `${request.namespace}/${request.plural}`).sort()).toEqual( + operationalSourceSpecs.map(source => `${source.namespace}/${source.plural}`).sort() + ) + expect(listRequests.every(request => request.limit !== null)).toBe(true) + const exactRequests = kubernetesApi.requests.filter(request => request.name) + expect(exactRequests.length).toBeGreaterThan(0) + expect( + new Set(exactRequests.map(request => request.namespace)).has(config.hostsNamespace) + ).toBe(true) + const sourceStates = await databasePool.query( + `SELECT source_family, resource_version, status + FROM operational_catalog_source_state + WHERE environment_id = $1 + ORDER BY source_family`, + [environmentId] + ) + expect(sourceStates.rows).toHaveLength(operationalSourceSpecs.length) + expect(sourceStates.rows.every(row => row.resource_version === '1')).toBe(true) + expect(sourceStates.rows.every(row => row.status === 'current')).toBe(true) + }) + + it('ingests deletion and recreation through the real Kubernetes watch boundary', async () => { + const source = operationalSourceSpecs.find(value => value.family === 'host')! + const original = operationalFixtures.find(value => value.plural === 'hosts')! + const controller = new AbortController() + const watch = gateway.watchResource( + source.plural, + source.namespace, + '1', + controller.signal, + (phase, object) => indexer.applyWatchEvent(source, phase, object, controller.signal) + ) + await waitFor(() => kubernetesApi.requests.some(request => request.watch)) + + const deleted = { + ...original.object, + metadata: { + ...(original.object.metadata as Record), + resourceVersion: '2', + }, + } + kubernetesApi.delete(source.plural, source.namespace, 'catalog-host') + kubernetesApi.emitWatch('DELETED', deleted) + await waitFor(async () => { + const result = await databasePool.query( + `SELECT COUNT(*)::int AS count + FROM operational_resource_index + WHERE environment_id = $1 AND resource_type = 'host' + AND logical_id = $2`, + [environmentId, `${config.hostsNamespace}/catalog-host`] + ) + return result.rows[0]?.count === 0 + }) + + const recreated = fixture({ + plural: 'hosts', + namespace: config.hostsNamespace, + name: 'catalog-host', + uid: 'catalog-host-uid-recreated', + spec: { + contextRef: 'catalog-context', + model: { provider: 'openai', name: 'test-model' }, + }, + }).object + const recreatedWithVersion = { + ...recreated, + metadata: { + ...(recreated.metadata as Record), + resourceVersion: '3', + }, + } + kubernetesApi.put(source.plural, source.namespace, recreatedWithVersion) + kubernetesApi.emitWatch('ADDED', recreatedWithVersion) + await waitFor(async () => { + const result = await databasePool.query( + `SELECT provider_uid, provider_resource_version, deleted_at + FROM operational_resource_index + WHERE environment_id = $1 AND resource_type = 'host' + AND logical_id = $2`, + [environmentId, `${config.hostsNamespace}/catalog-host`] + ) + return ( + result.rows[0]?.provider_uid === 'catalog-host-uid-recreated' && + result.rows[0]?.provider_resource_version === '3' && + result.rows[0]?.deleted_at === null + ) + }) + + const catalog = await buildAccessCatalog( + { session, families: ['host'], limit: 10 }, + { transaction: transaction(databasePool) } + ) + expect(catalog.items).toHaveLength(1) + const item = catalog.items[0] + const resolved = await resolveLiveAuthorization( + { + session, + requiredCapability: 'host.read', + resource: canonicalResourceIdentity(item.resource), + requestedAccessPathId: item.accessPaths[0].accessPathId, + }, + { transaction: transaction(databasePool), gateway } + ) + expect(resolved.status).toBe('allowed') + + controller.abort('watch_complete') + await expect(watch).resolves.toBeUndefined() + const watchRequest = kubernetesApi.requests.find(request => request.watch) + expect(watchRequest).toEqual( + expect.objectContaining({ + namespace: config.hostsNamespace, + plural: 'hosts', + resourceVersion: '1', + }) + ) + }) + + it('propagates cancellation through a real Kubernetes list request', async () => { + const held = kubernetesApi.holdNextList() + const controller = new AbortController() + const pending = gateway.listResourcePage('hosts', config.hostsNamespace, { + limit: 10, + timeoutSeconds: 5, + signal: controller.signal, + }) + await held.requested + controller.abort(new Error('test_cancelled')) + await expect(pending).rejects.toThrow() + await held.closed + }) }) diff --git a/control-api/test/helpers/temporaryKubernetesApi.ts b/control-api/test/helpers/temporaryKubernetesApi.ts new file mode 100644 index 0000000000..2fa7caa749 --- /dev/null +++ b/control-api/test/helpers/temporaryKubernetesApi.ts @@ -0,0 +1,228 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { type Server, type ServerResponse, createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +type KubernetesObject = Readonly> + +type RecordedRequest = Readonly<{ + method: string + namespace: string + plural: string + name: string | null + watch: boolean + resourceVersion: string | null + limit: string | null + continueToken: string | null +}> + +function metadata(value: KubernetesObject): Record { + const candidate = value.metadata + return candidate && typeof candidate === 'object' && !Array.isArray(candidate) + ? (candidate as Record) + : {} +} + +function json(response: ServerResponse, status: number, body: unknown): void { + const serialized = JSON.stringify(body) + response.writeHead(status, { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(serialized), + }) + response.end(serialized) +} + +export class TemporaryKubernetesApi { + private readonly objects = new Map() + private readonly watchResponses = new Set() + private readonly server: Server + private scratchDirectory: string | null = null + private kubeconfigPath: string | null = null + private origin: string | null = null + private heldList: { + response: ServerResponse | null + requested: () => void + closed: () => void + } | null = null + readonly requests: RecordedRequest[] = [] + + constructor() { + this.server = createServer((request, response) => { + void this.handle(request.method ?? 'GET', request.url ?? '/', response) + }) + } + + async start(): Promise { + await new Promise((resolve, reject) => { + this.server.once('error', reject) + this.server.listen(0, '127.0.0.1', () => { + this.server.off('error', reject) + resolve() + }) + }) + const address = this.server.address() + if (!address || typeof address === 'string') throw new Error('temporary_kubernetes_address') + this.origin = `http://127.0.0.1:${address.port}` + const scratchRoot = process.env.CODEX_SCRATCH_ROOT || tmpdir() + this.scratchDirectory = await mkdtemp(join(scratchRoot, 'evenfire-k8s-api-')) + this.kubeconfigPath = join(this.scratchDirectory, 'kubeconfig.yaml') + await writeFile( + this.kubeconfigPath, + `apiVersion: v1 +kind: Config +clusters: + - name: temporary + cluster: + server: ${this.origin} + insecure-skip-tls-verify: true +users: + - name: temporary + user: {} +contexts: + - name: temporary + context: + cluster: temporary + user: temporary +current-context: temporary +`, + 'utf8' + ) + } + + kubeconfig(): string { + if (!this.kubeconfigPath) throw new Error('temporary_kubernetes_not_started') + return this.kubeconfigPath + } + + put(plural: string, namespace: string, object: KubernetesObject): void { + const name = String(metadata(object).name ?? '') + if (!name) throw new Error('temporary_kubernetes_name_missing') + this.objects.set(`${namespace}/${plural}/${name}`, object) + } + + delete(plural: string, namespace: string, name: string): void { + this.objects.delete(`${namespace}/${plural}/${name}`) + } + + emitWatch(phase: string, object: KubernetesObject): void { + const event = `${JSON.stringify({ type: phase, object })}\n` + for (const response of this.watchResponses) response.write(event) + } + + holdNextList(): Readonly<{ + requested: Promise + closed: Promise + release: () => void + }> { + if (this.heldList) throw new Error('temporary_kubernetes_list_already_held') + let requested!: () => void + let closed!: () => void + const requestedPromise = new Promise(resolve => { + requested = resolve + }) + const closedPromise = new Promise(resolve => { + closed = resolve + }) + this.heldList = { response: null, requested, closed } + return Object.freeze({ + requested: requestedPromise, + closed: closedPromise, + release: () => { + const held = this.heldList + if (!held?.response) return + json(held.response, 200, { + apiVersion: 'clerum.io/v1alpha1', + kind: 'List', + metadata: { resourceVersion: '1', continue: '' }, + items: [], + }) + this.heldList = null + }, + }) + } + + async close(): Promise { + for (const response of this.watchResponses) response.end() + this.watchResponses.clear() + await new Promise(resolve => this.server.close(() => resolve())) + this.server.closeAllConnections?.() + if (this.scratchDirectory) await rm(this.scratchDirectory, { recursive: true, force: true }) + this.scratchDirectory = null + this.kubeconfigPath = null + } + + private async handle(method: string, rawUrl: string, response: ServerResponse): Promise { + const requestUrl = new URL(rawUrl, this.origin ?? 'http://127.0.0.1') + const match = requestUrl.pathname.match( + /^\/apis\/clerum\.io\/v1alpha1\/namespaces\/([^/]+)\/([^/]+)(?:\/([^/]+))?$/ + ) + if (method !== 'GET' || !match) { + json(response, 404, { kind: 'Status', code: 404, reason: 'NotFound' }) + return + } + const namespace = decodeURIComponent(match[1]) + const plural = decodeURIComponent(match[2]) + const name = match[3] ? decodeURIComponent(match[3]) : null + const watch = requestUrl.searchParams.get('watch') === 'true' + this.requests.push( + Object.freeze({ + method, + namespace, + plural, + name, + watch, + resourceVersion: requestUrl.searchParams.get('resourceVersion'), + limit: requestUrl.searchParams.get('limit'), + continueToken: requestUrl.searchParams.get('continue'), + }) + ) + if (watch) { + response.writeHead(200, { + 'content-type': 'application/json', + 'transfer-encoding': 'chunked', + }) + this.watchResponses.add(response) + response.on('close', () => this.watchResponses.delete(response)) + return + } + if (name) { + const object = this.objects.get(`${namespace}/${plural}/${name}`) + if (!object) { + json(response, 404, { kind: 'Status', code: 404, reason: 'NotFound' }) + return + } + json(response, 200, object) + return + } + if (this.heldList && !this.heldList.response) { + this.heldList.response = response + this.heldList.requested() + response.on('close', () => { + this.heldList?.closed() + this.heldList = null + }) + return + } + const all = [...this.objects.entries()] + .filter(([key]) => key.startsWith(`${namespace}/${plural}/`)) + .map(([, object]) => object) + .sort((left, right) => + String(metadata(left).name).localeCompare(String(metadata(right).name)) + ) + const start = Number(requestUrl.searchParams.get('continue') ?? 0) + const requestedLimit = Number(requestUrl.searchParams.get('limit') ?? 100) + const limit = Number.isSafeInteger(requestedLimit) && requestedLimit > 0 ? requestedLimit : 100 + const items = all.slice(start, start + limit) + const next = start + items.length < all.length ? String(start + items.length) : '' + const resourceVersion = all.reduce( + (maximum, object) => Math.max(maximum, Number(metadata(object).resourceVersion ?? 0)), + 1 + ) + json(response, 200, { + apiVersion: 'clerum.io/v1alpha1', + kind: 'List', + metadata: { resourceVersion: String(resourceVersion), continue: next }, + items, + }) + } +} From 61202ffaae047662fa13f201bccb12aa7c71af23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 16:13:04 -0600 Subject: [PATCH 042/528] fix(access): r1-b2 bound duplicate catalog continuation Regression-of: R1-B1 Introduced-by: df6ce22e1a2827b7fcfc0b148137abe2e16b898c Class-check: 27 source arms -> 11 duplicate-capable -> bounded saturation continuation Repro-test: sparse GFS plan scanned 3012 global resources at the introducing head F3-generated-artifact: mandatory Control API version metadata only; no semantic package change. --- .../src/services/access/catalogProducerSql.ts | 131 +++++++----------- .../services/access/catalogProducerSupport.ts | 22 ++- ...agination.realPostgres.integration.test.ts | 97 ++++++++++++- control-api/test/catalogProducers.test.ts | 15 +- 4 files changed, 165 insertions(+), 100 deletions(-) diff --git a/control-api/src/services/access/catalogProducerSql.ts b/control-api/src/services/access/catalogProducerSql.ts index 81e1c6f39b..d3d4ed8bf0 100644 --- a/control-api/src/services/access/catalogProducerSql.ts +++ b/control-api/src/services/access/catalogProducerSql.ts @@ -41,7 +41,6 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND resource.enabled = TRUE AND resource.deleted_at IS NULL WHERE tm.user_id = $1 AND tm.status = 'active' AND ta.agent_name > $7`, orderBy: 'source_key', - distinctCanonicalBy: 'source_key', }, ]), context: boundedKeyUnionSql([ @@ -65,12 +64,10 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND resource.enabled = TRUE AND resource.deleted_at IS NULL WHERE tm.user_id = $1 AND tm.status = 'active' AND tc.context_id > $7`, orderBy: 'source_key', - distinctCanonicalBy: 'source_key', }, ]), mcp_server: boundedKeyUnionSql([ - { - sql: `SELECT edge.target_id AS logical_id + `SELECT edge.target_id AS logical_id FROM user_contexts uc JOIN operational_resource_relationships edge ON edge.environment_id = $3 @@ -87,10 +84,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND target_resource.logical_id = edge.target_id AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL WHERE uc.user_id = $1 AND edge.target_id > $2`, - distinctCanonicalBy: 'logical_id', - }, - { - sql: `SELECT edge.target_id AS logical_id + `SELECT edge.target_id AS logical_id FROM team_contexts tc JOIN team_members tm ON tm.team_id = tc.team_id JOIN operational_resource_relationships edge @@ -108,10 +102,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND target_resource.logical_id = edge.target_id AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL WHERE tm.user_id = $1 AND tm.status = 'active' AND edge.target_id > $2`, - distinctCanonicalBy: 'logical_id', - }, - { - sql: `SELECT mcp_edge.target_id AS logical_id + `SELECT mcp_edge.target_id AS logical_id FROM user_agents ua JOIN operational_resource_relationships host_edge ON host_edge.environment_id = $3 @@ -138,10 +129,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND target_resource.logical_id = mcp_edge.target_id AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL WHERE ua.user_id = $1 AND mcp_edge.target_id > $2`, - distinctCanonicalBy: 'logical_id', - }, - { - sql: `SELECT mcp_edge.target_id AS logical_id + `SELECT mcp_edge.target_id AS logical_id FROM team_agents ta JOIN team_members tm ON tm.team_id = ta.team_id JOIN operational_resource_relationships host_edge @@ -169,8 +157,6 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND target_resource.logical_id = mcp_edge.target_id AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL WHERE tm.user_id = $1 AND tm.status = 'active' AND mcp_edge.target_id > $2`, - distinctCanonicalBy: 'logical_id', - }, ]), workflow_recipe: boundedKeyUnionSql([ { @@ -195,7 +181,6 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f WHERE tm.user_id = $1 AND tm.status = 'active' AND (twt.recipe_namespace || '/' || twt.recipe_name) > $2`, orderBy: 'logical_id', - distinctCanonicalBy: 'logical_id', }, ]), workflow_run: boundedKeyUnionSql([ @@ -299,77 +284,62 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f ]), gfs_resource: boundedKeyUnionSql([ { - sql: `SELECT resource.resource_id::text AS logical_id, - resource.resource_id AS cursor_id - FROM gfs_resources resource - WHERE resource.deleted_at IS NULL - AND resource.resource_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000') - AND ( - SELECT TRUE - FROM gfs_grants g - WHERE g.resource_id = resource.resource_id - AND g.drive = resource.drive - AND g.subject_type = 'user' AND g.subject_id = $1::text - LIMIT 1 - ) IS TRUE`, + sql: `SELECT g.resource_id::text AS logical_id, g.resource_id AS cursor_id + FROM gfs_grants g + JOIN gfs_resources resource + ON resource.resource_id = g.resource_id AND resource.deleted_at IS NULL + WHERE g.subject_type = 'user' AND g.subject_id = $1::text + AND g.resource_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000')`, orderBy: 'cursor_id', }, { - sql: `SELECT resource.resource_id::text AS logical_id, - resource.resource_id AS cursor_id - FROM gfs_resources resource - WHERE resource.deleted_at IS NULL - AND resource.resource_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000') - AND ( - SELECT TRUE - FROM gfs_grants g - JOIN team_members tm ON tm.team_id::text = g.subject_id - WHERE g.resource_id = resource.resource_id - AND g.drive = resource.drive - AND g.subject_type = 'team' - AND tm.user_id = $1::uuid AND tm.status = 'active' - LIMIT 1 - ) IS TRUE`, + sql: `SELECT candidate.resource_id::text AS logical_id, + candidate.resource_id AS cursor_id + FROM team_members tm + CROSS JOIN LATERAL ( + SELECT g.resource_id, g.drive + FROM gfs_grants g + WHERE g.subject_type = 'team' AND g.subject_id = tm.team_id::text + AND g.resource_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000') + ORDER BY g.resource_id + LIMIT $4 + ) candidate + JOIN gfs_resources resource + ON resource.resource_id = candidate.resource_id + AND resource.drive = candidate.drive AND resource.deleted_at IS NULL + WHERE tm.user_id = $1::uuid AND tm.status = 'active'`, orderBy: 'cursor_id', }, { - sql: `SELECT resource.resource_id::text AS logical_id, - resource.resource_id AS cursor_id - FROM gfs_resources resource - WHERE resource.deleted_at IS NULL - AND resource.resource_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000') - AND ( - SELECT TRUE - FROM gfs_shares share - WHERE share.resource_id = resource.resource_id - AND share.drive = resource.drive - AND share.subject_type = 'user' AND share.subject_id = $1::text - LIMIT 1 - ) IS TRUE`, + sql: `SELECT share.resource_id::text AS logical_id, share.resource_id AS cursor_id + FROM gfs_shares share + JOIN gfs_resources resource + ON resource.resource_id = share.resource_id AND resource.deleted_at IS NULL + WHERE share.subject_type = 'user' AND share.subject_id = $1::text + AND share.resource_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000')`, orderBy: 'cursor_id', }, { - sql: `SELECT resource.resource_id::text AS logical_id, - resource.resource_id AS cursor_id - FROM gfs_resources resource - WHERE resource.deleted_at IS NULL - AND resource.resource_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000') - AND ( - SELECT TRUE - FROM gfs_shares share - JOIN team_members tm ON tm.team_id::text = share.subject_id - WHERE share.resource_id = resource.resource_id - AND share.drive = resource.drive - AND share.subject_type = 'team' - AND tm.user_id = $1::uuid AND tm.status = 'active' - LIMIT 1 - ) IS TRUE`, + sql: `SELECT candidate.resource_id::text AS logical_id, + candidate.resource_id AS cursor_id + FROM team_members tm + CROSS JOIN LATERAL ( + SELECT share.resource_id, share.drive + FROM gfs_shares share + WHERE share.subject_type = 'team' AND share.subject_id = tm.team_id::text + AND share.resource_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000') + ORDER BY share.resource_id + LIMIT $4 + ) candidate + JOIN gfs_resources resource + ON resource.resource_id = candidate.resource_id + AND resource.drive = candidate.drive AND resource.deleted_at IS NULL + WHERE tm.user_id = $1::uuid AND tm.status = 'active'`, orderBy: 'cursor_id', }, ]), shared_filesystem: boundedKeyUnionSql([ - { - sql: `SELECT edge.target_id AS logical_id + `SELECT edge.target_id AS logical_id FROM user_contexts uc JOIN operational_resource_relationships edge ON edge.environment_id = $3 @@ -387,10 +357,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND target_resource.logical_id = edge.target_id AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL WHERE uc.user_id = $1 AND edge.target_id > $2`, - distinctCanonicalBy: 'logical_id', - }, - { - sql: `SELECT edge.target_id AS logical_id + `SELECT edge.target_id AS logical_id FROM team_contexts tc JOIN team_members tm ON tm.team_id = tc.team_id JOIN operational_resource_relationships edge @@ -409,8 +376,6 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND target_resource.logical_id = edge.target_id AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL WHERE tm.user_id = $1 AND tm.status = 'active' AND edge.target_id > $2`, - distinctCanonicalBy: 'logical_id', - }, ]), sandbox_app: boundedKeyUnionSql([ `SELECT edge.target_id AS logical_id diff --git a/control-api/src/services/access/catalogProducerSupport.ts b/control-api/src/services/access/catalogProducerSupport.ts index 696f0f16be..f619464e6f 100644 --- a/control-api/src/services/access/catalogProducerSupport.ts +++ b/control-api/src/services/access/catalogProducerSupport.ts @@ -34,7 +34,6 @@ export async function catalogQuery( export type BoundedKeyArm = Readonly<{ sql: string orderBy?: string - distinctCanonicalBy?: string hasValidUntil?: boolean }> @@ -44,19 +43,11 @@ export function boundedKeyUnionSql(arms: readonly (string | BoundedKeyArm)[]): s const sources = arms .map((arm, index) => { const definition = typeof arm === 'string' ? { sql: arm } : arm - if (definition.distinctCanonicalBy && definition.hasValidUntil) { - throw new CatalogProducerContractError('distinct_valid_until_requires_aggregation') - } - const boundedSql = definition.distinctCanonicalBy - ? `SELECT DISTINCT ON (${definition.distinctCanonicalBy}) distinct_arm.* - FROM (${definition.sql}) distinct_arm - ORDER BY ${definition.distinctCanonicalBy}` - : definition.sql return `${names[index]} AS MATERIALIZED ( SELECT bounded_arm.logical_id, ${definition.hasValidUntil ? 'bounded_arm.valid_until' : 'NULL::timestamptz'} AS valid_until - FROM (${boundedSql}) bounded_arm + FROM (${definition.sql}) bounded_arm ORDER BY ${definition.orderBy ?? 'logical_id'} LIMIT $4 )` @@ -66,7 +57,9 @@ export function boundedKeyUnionSql(arms: readonly (string | BoundedKeyArm)[]): s .map(name => `SELECT logical_id, valid_until FROM ${name}`) .join('\nUNION ALL\n') return `WITH ${sources} - SELECT logical_id, MIN(valid_until) AS valid_until + SELECT logical_id, MIN(valid_until) AS valid_until, + ${names.map(name => `(SELECT COUNT(*) FROM ${name}) >= $4`).join(' OR ')} + AS source_saturated FROM (${union}) bounded_sources WHERE $1::uuid IS NOT NULL AND $2::text IS NOT NULL AND $3::text IS NOT NULL AND $5::text IS NOT NULL AND $6::text IS NOT NULL AND $7::text IS NOT NULL @@ -187,8 +180,13 @@ export async function listBoundedProducerKeys(input: { throw new CatalogProducerContractError('key_query_exceeded_take') } const candidates: CatalogIdentityCandidate[] = [] + let sourceSaturated = false let prior = after for (const row of result.rows as Record[]) { + if (typeof row.source_saturated !== 'boolean') { + throw new CatalogProducerContractError('source_saturation_invalid') + } + sourceSaturated ||= row.source_saturated const logicalId = typeof row.logical_id === 'string' ? row.logical_id : '' if (!logicalId || logicalId <= prior) { throw new CatalogProducerContractError('keys_not_strictly_ordered') @@ -208,7 +206,7 @@ export async function listBoundedProducerKeys(input: { candidates.push(Object.freeze({ key, canonicalId: `${input.family}:${logicalId}`, validUntil })) prior = logicalId } - const hasMore = candidates.length > input.take + const hasMore = candidates.length > input.take || sourceSaturated return Object.freeze({ candidates: Object.freeze(candidates), continuation: Object.freeze({ diff --git a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts index 23b536d84f..74a77cc1d8 100644 --- a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts +++ b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts @@ -52,6 +52,23 @@ function transaction(pool: Pool) { } } +type ExplainNode = Record & { Plans?: ExplainNode[] } + +function explainNodes(root: ExplainNode): ExplainNode[] { + const values: ExplainNode[] = [] + const pending = [root] + while (pending.length > 0) { + const node = pending.pop()! + values.push(node) + pending.push(...(node.Plans ?? [])) + } + return values +} + +function actualWork(node: ExplainNode): number { + return Number(node['Actual Rows'] ?? 0) * Math.max(1, Number(node['Actual Loops'] ?? 1)) +} + describeRealPostgres('distinct catalog key pagination on real PostgreSQL', () => { const database = `control_api_catalog_distinct_${randomBytes(6).toString('hex')}` const connectionString = databaseUrl( @@ -299,11 +316,20 @@ describeRealPostgres('distinct catalog key pagination on real PostgreSQL', () => ) expect(page.candidates.map(candidate => candidate.key[2])).toEqual([ `${config.hostsNamespace}/a`, - `${config.hostsNamespace}/b`, - `${config.hostsNamespace}/c`, ]) expect(page.hasMore).toBe(true) expect(page.continuation.exhausted).toBe(false) + const next = await requireCatalogProducer('host').listCanonicalKeys( + context, + { afterKey: page.candidates[0].key, exhausted: false }, + 2 + ) + expect(next.candidates.map(candidate => candidate.key[2])).toEqual([ + `${config.hostsNamespace}/b`, + `${config.hostsNamespace}/c`, + ]) + expect(next.hasMore).toBe(false) + expect(next.continuation.exhausted).toBe(true) } finally { budget.close() } @@ -570,4 +596,71 @@ describeRealPostgres('distinct catalog key pagination on real PostgreSQL', () => }) } }, 30_000) + + it('keeps sparse GFS access subject-indexed instead of scanning unrelated resources', async () => { + const actor = await principal('sparse-gfs', 1) + const authorizedResourceId = 'f0000000-0000-4000-8000-000000000001' + const authorizedDrive = 'sparse-gfs-authorized' + await databasePool.query( + `INSERT INTO gfs_resources(resource_id, drive, name, kind) + SELECT ('01000000-0000-4000-8000-' || LPAD(value::text, 12, '0'))::uuid, + 'sparse-unrelated-' || value, '/', 'directory' + FROM generate_series(1, 2000) value` + ) + await databasePool.query( + `INSERT INTO gfs_resources(resource_id, drive, name, kind) + VALUES ($1, $2, '/', 'directory')`, + [authorizedResourceId, authorizedDrive] + ) + for (const table of ['gfs_grants', 'gfs_shares'] as const) { + await databasePool.query( + `INSERT INTO ${table}(drive, resource_id, subject_type, subject_id, permissions) + SELECT drive, resource_id, 'user', 'ffffffff-ffff-4fff-8fff-ffffffffffff', + ARRAY['read']::text[] + FROM gfs_resources + WHERE drive LIKE 'sparse-unrelated-%'` + ) + } + for (const table of ['gfs_grants', 'gfs_shares'] as const) { + await databasePool.query( + `INSERT INTO ${table}(drive, resource_id, subject_type, subject_id, permissions) + VALUES ($1, $2, 'user', $3, ARRAY['read']::text[]), + ($1, $2, 'team', $4, ARRAY['read']::text[])`, + [authorizedDrive, authorizedResourceId, actor.userId, actor.teamIds[0]] + ) + } + await databasePool.query('ANALYZE gfs_resources') + await databasePool.query('ANALYZE gfs_grants') + await databasePool.query('ANALYZE gfs_shares') + + const explained = await databasePool.query( + `EXPLAIN (ANALYZE, BUFFERS, WAL, FORMAT JSON) ${CATALOG_KEY_SQL.gfs_resource}`, + [actor.userId, '', environmentId, 3, config.hostsNamespace, config.contextsNamespace, ''] + ) + const envelope = (explained.rows[0] as Record)['QUERY PLAN'] as Array<{ + Plan: ExplainNode + }> + const nodes = explainNodes(envelope[0].Plan) + const indexNames = new Set( + nodes.flatMap(node => (typeof node['Index Name'] === 'string' ? [node['Index Name']] : [])) + ) + expect(indexNames).toContain('gfs_grants_subject_resource_catalog_idx') + expect(indexNames).toContain('gfs_shares_subject_resource_catalog_idx') + const resourceWork = nodes + .filter(node => node['Relation Name'] === 'gfs_resources') + .reduce((total, node) => total + actualWork(node), 0) + expect(resourceWork).toBeLessThanOrEqual(16) + for (const node of nodes) { + expect(String(node['Sort Method'] ?? '')).not.toContain('external') + expect(Number(node['Temp Read Blocks'] ?? 0)).toBe(0) + expect(Number(node['Temp Written Blocks'] ?? 0)).toBe(0) + } + + await consumeFamily({ + family: 'gfs_resource', + userId: actor.userId, + expected: [authorizedResourceId], + minimumPathsForFirst: 4, + }) + }, 30_000) }) diff --git a/control-api/test/catalogProducers.test.ts b/control-api/test/catalogProducers.test.ts index c9a74e60f2..fba1656e52 100644 --- a/control-api/test/catalogProducers.test.ts +++ b/control-api/test/catalogProducers.test.ts @@ -70,7 +70,10 @@ describe('catalog producer registry', () => { const sql = CATALOG_KEY_SQL[family] expect(sql).not.toMatch(/\bOFFSET\b/i) expect(sql.match(/AS MATERIALIZED/g)).toHaveLength(expectedArmCounts[family]) - expect(sql.match(/LIMIT \$4/g)).toHaveLength(expectedArmCounts[family] + 1) + const perMembershipBounds = family === 'gfs_resource' ? 2 : 0 + expect(sql.match(/LIMIT \$4/g)).toHaveLength( + expectedArmCounts[family] + perMembershipBounds + 1 + ) } }) }) @@ -79,8 +82,14 @@ describe('catalog producer protocol', () => { it('returns a stable keyset page with one bounded lookahead candidate', async () => { const query = vi.fn().mockResolvedValue({ rows: [ - { logical_id: '20000000-0000-4000-8000-000000000002' }, - { logical_id: '30000000-0000-4000-8000-000000000003' }, + { + logical_id: '20000000-0000-4000-8000-000000000002', + source_saturated: false, + }, + { + logical_id: '30000000-0000-4000-8000-000000000003', + source_saturated: false, + }, ], rowCount: 2, }) From 5716f259569e81745f58151a7e9c1b75a3c4ad90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 16:14:55 -0600 Subject: [PATCH 043/528] fix(access): r1-h2 prove indexer cancellation boundary Regression-of: R1-H1 Introduced-by: bb8a0be74bfa Class-check: producer-boundary cancellation -> 1 acceptance test -> indexer path covered Repro-test: direct gateway cancellation bypassed reconcile staging and promotion behavior F3-generated-artifact: mandatory Control API version metadata only; no semantic package change. --- ...gFamilies.realPostgres.integration.test.ts | 84 ++++++++++++++++--- .../test/helpers/temporaryKubernetesApi.ts | 10 ++- 2 files changed, 82 insertions(+), 12 deletions(-) diff --git a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts index 0754a98fed..57fd137294 100644 --- a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts @@ -553,17 +553,81 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { ) }) - it('propagates cancellation through a real Kubernetes list request', async () => { + it('cancels a real indexer relist without staging or promoting late results', async () => { + const source = operationalSourceSpecs.find(value => value.family === 'host')! + const before = await databasePool.query( + `SELECT generation, staging_generation, resource_version, status + FROM operational_catalog_source_state + WHERE environment_id = $1 AND source_family = $2`, + [environmentId, source.family] + ) + expect(before.rows[0]).toEqual( + expect.objectContaining({ + staging_generation: null, + resource_version: '3', + status: 'current', + }) + ) const held = kubernetesApi.holdNextList() const controller = new AbortController() - const pending = gateway.listResourcePage('hosts', config.hostsNamespace, { - limit: 10, - timeoutSeconds: 5, - signal: controller.signal, - }) - await held.requested - controller.abort(new Error('test_cancelled')) - await expect(pending).rejects.toThrow() - await held.closed + const pending = indexer.reconcileSource(source, controller.signal) + try { + await held.requested + controller.abort(new Error('test_cancelled')) + await expect(pending).rejects.toThrow() + await held.closed + + const afterAbort = await databasePool.query( + `SELECT generation, staging_generation, resource_version, status + FROM operational_catalog_source_state + WHERE environment_id = $1 AND source_family = $2`, + [environmentId, source.family] + ) + expect(afterAbort.rows[0]).toEqual( + expect.objectContaining({ + generation: String(Number(before.rows[0].generation) + 1), + resource_version: '3', + status: 'relisting', + }) + ) + expect(afterAbort.rows[0].staging_generation).not.toBeNull() + const staged = await databasePool.query( + `SELECT + (SELECT COUNT(*)::int FROM operational_resource_index_staging + WHERE environment_id = $1 AND source_family = $2) AS resources, + (SELECT COUNT(*)::int FROM operational_relationships_staging + WHERE environment_id = $1 AND source_family = $2) AS relationships`, + [environmentId, source.family] + ) + expect(staged.rows[0]).toEqual({ resources: 0, relationships: 0 }) + const live = await databasePool.query( + `SELECT provider_uid, provider_resource_version + FROM operational_resource_index + WHERE environment_id = $1 AND resource_type = 'host' + AND logical_id = $2`, + [environmentId, `${config.hostsNamespace}/catalog-host`] + ) + expect(live.rows).toEqual([ + { + provider_uid: 'catalog-host-uid-recreated', + provider_resource_version: '3', + }, + ]) + + await expect(indexer.reconcileSource(source)).resolves.toBe('3') + const recovered = await databasePool.query( + `SELECT staging_generation, resource_version, status + FROM operational_catalog_source_state + WHERE environment_id = $1 AND source_family = $2`, + [environmentId, source.family] + ) + expect(recovered.rows[0]).toEqual({ + staging_generation: null, + resource_version: '3', + status: 'current', + }) + } finally { + held.release() + } }) }) diff --git a/control-api/test/helpers/temporaryKubernetesApi.ts b/control-api/test/helpers/temporaryKubernetesApi.ts index 2fa7caa749..7cd331964f 100644 --- a/control-api/test/helpers/temporaryKubernetesApi.ts +++ b/control-api/test/helpers/temporaryKubernetesApi.ts @@ -142,10 +142,16 @@ current-context: temporary } async close(): Promise { - for (const response of this.watchResponses) response.end() + const held = this.heldList + this.heldList = null + if (held?.response) held.response.destroy() + held?.closed() + for (const response of this.watchResponses) response.destroy() this.watchResponses.clear() - await new Promise(resolve => this.server.close(() => resolve())) this.server.closeAllConnections?.() + if (this.server.listening) { + await new Promise(resolve => this.server.close(() => resolve())) + } if (this.scratchDirectory) await rm(this.scratchDirectory, { recursive: true, force: true }) this.scratchDirectory = null this.kubeconfigPath = null From 143e1651d9627a4fce3ae9a1733c0b678d5a564d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 18:23:18 -0600 Subject: [PATCH 044/528] fix(access): r1-b3 bound team GFS top-k discovery Finding: R1-B3 Class-check: team GFS grant/share key arms -> 2 per-membership fan-out sites -> replaced both with admitted subject-indexed streams and one heap merge Repro-test: catalogDistinctPagination.realPostgres.integration.test.ts admission test fails at 77d486c96807a9044ba2312551b6e2e6965e1ce8 F3-generated-artifact: mandatory Control API version metadata only; no semantic package change. --- .../access/accessCatalogCoordinator.ts | 7 +- .../services/access/accessExecutionBudget.ts | 39 ++- .../src/services/access/catalogProducerSql.ts | 36 --- .../services/access/catalogProducerSupport.ts | 5 +- .../src/services/access/catalogProducers.ts | 4 + .../src/services/access/teamGfsTopK.ts | 241 ++++++++++++++++++ ...agination.realPostgres.integration.test.ts | 48 +++- control-api/test/catalogProducers.test.ts | 7 +- 8 files changed, 337 insertions(+), 50 deletions(-) create mode 100644 control-api/src/services/access/teamGfsTopK.ts diff --git a/control-api/src/services/access/accessCatalogCoordinator.ts b/control-api/src/services/access/accessCatalogCoordinator.ts index fff9b8dceb..06e91d1f9c 100644 --- a/control-api/src/services/access/accessCatalogCoordinator.ts +++ b/control-api/src/services/access/accessCatalogCoordinator.ts @@ -354,6 +354,7 @@ export async function buildAccessCatalog( transaction?: CatalogTransaction budget?: AccessExecutionBudget limits?: Partial + teamGfsMembershipAdmissionLimit?: number now?: Date } = {} ): Promise { @@ -364,7 +365,11 @@ export async function buildAccessCatalog( const families = normalizeFamilies(input.families) const limit = input.limit ?? 50 const budget = - options.budget ?? AccessExecutionBudget.create('catalog', { limits: options.limits }) + options.budget ?? + AccessExecutionBudget.create('catalog', { + limits: options.limits, + teamGfsMembershipAdmissionLimit: options.teamGfsMembershipAdmissionLimit, + }) const ownedBudget = options.budget ? null : budget try { budget.assertPageSize(limit) diff --git a/control-api/src/services/access/accessExecutionBudget.ts b/control-api/src/services/access/accessExecutionBudget.ts index 403adc42e4..b0d33cb170 100644 --- a/control-api/src/services/access/accessExecutionBudget.ts +++ b/control-api/src/services/access/accessExecutionBudget.ts @@ -58,7 +58,12 @@ export class AccessBudgetConfigurationError extends Error { export class AccessBudgetExceededError extends Error { constructor( - readonly limit: AccessBudgetCounterKind | 'deadline' | 'objectBytes' | 'relationshipDepth', + readonly limit: + | AccessBudgetCounterKind + | 'deadline' + | 'objectBytes' + | 'relationshipDepth' + | 'teamGfsMembershipAdmission', readonly authorityRequired: boolean ) { super(`Access execution budget exhausted: ${limit}`) @@ -201,6 +206,7 @@ export class AccessExecutionBudget { readonly deadline: number readonly signal: AbortSignal readonly limits: AccessExecutionLimits + readonly teamGfsMembershipAdmissionLimit: number | null private closed = false private readonly localCounters: Map | null private readonly reservedFromParent: AccessBudgetReservation @@ -214,7 +220,8 @@ export class AccessExecutionBudget { limits: AccessExecutionLimits, localCounters: Map | null, reservedFromParent: AccessBudgetReservation, - reservationParentCounters: Map | null + reservationParentCounters: Map | null, + teamGfsMembershipAdmissionLimit: number | null ) { this.deadline = shared.deadline this.signal = shared.controller.signal @@ -222,17 +229,33 @@ export class AccessExecutionBudget { this.localCounters = localCounters this.reservedFromParent = reservedFromParent this.reservationParentCounters = reservationParentCounters + this.teamGfsMembershipAdmissionLimit = teamGfsMembershipAdmissionLimit } static create( kind: AccessExecutionKind, options: { limits?: Partial + teamGfsMembershipAdmissionLimit?: number now?: number parentSignal?: AbortSignal } = {} ): AccessExecutionBudget { const limits = resolveAccessExecutionLimits(options.limits) + const teamGfsMembershipAdmissionLimit = + options.teamGfsMembershipAdmissionLimit === undefined + ? null + : options.teamGfsMembershipAdmissionLimit + if ( + teamGfsMembershipAdmissionLimit !== null && + (!Number.isSafeInteger(teamGfsMembershipAdmissionLimit) || + teamGfsMembershipAdmissionLimit < 0) + ) { + throw new AccessBudgetConfigurationError( + 'objects', + 'team GFS membership admission must be a non-negative safe integer' + ) + } const controller = new AbortController() const now = options.now ?? Date.now() const deadlineMs = kind === 'catalog' ? limits.catalogDeadlineMs : limits.actionDeadlineMs @@ -242,7 +265,14 @@ export class AccessExecutionBudget { controller, deadline: now + deadlineMs, } - const budget = new AccessExecutionBudget(shared, limits, null, {}, null) + const budget = new AccessExecutionBudget( + shared, + limits, + null, + {}, + null, + teamGfsMembershipAdmissionLimit + ) budget.timer = setTimeout(() => controller.abort('deadline'), deadlineMs) budget.timer.unref?.() if (options.parentSignal) { @@ -359,7 +389,8 @@ export class AccessExecutionBudget { this.limits, local, reserved, - this.localCounters ?? this.shared.counters + this.localCounters ?? this.shared.counters, + this.teamGfsMembershipAdmissionLimit ) } diff --git a/control-api/src/services/access/catalogProducerSql.ts b/control-api/src/services/access/catalogProducerSql.ts index d3d4ed8bf0..c695655c0f 100644 --- a/control-api/src/services/access/catalogProducerSql.ts +++ b/control-api/src/services/access/catalogProducerSql.ts @@ -292,24 +292,6 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND g.resource_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000')`, orderBy: 'cursor_id', }, - { - sql: `SELECT candidate.resource_id::text AS logical_id, - candidate.resource_id AS cursor_id - FROM team_members tm - CROSS JOIN LATERAL ( - SELECT g.resource_id, g.drive - FROM gfs_grants g - WHERE g.subject_type = 'team' AND g.subject_id = tm.team_id::text - AND g.resource_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000') - ORDER BY g.resource_id - LIMIT $4 - ) candidate - JOIN gfs_resources resource - ON resource.resource_id = candidate.resource_id - AND resource.drive = candidate.drive AND resource.deleted_at IS NULL - WHERE tm.user_id = $1::uuid AND tm.status = 'active'`, - orderBy: 'cursor_id', - }, { sql: `SELECT share.resource_id::text AS logical_id, share.resource_id AS cursor_id FROM gfs_shares share @@ -319,24 +301,6 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND share.resource_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000')`, orderBy: 'cursor_id', }, - { - sql: `SELECT candidate.resource_id::text AS logical_id, - candidate.resource_id AS cursor_id - FROM team_members tm - CROSS JOIN LATERAL ( - SELECT share.resource_id, share.drive - FROM gfs_shares share - WHERE share.subject_type = 'team' AND share.subject_id = tm.team_id::text - AND share.resource_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000') - ORDER BY share.resource_id - LIMIT $4 - ) candidate - JOIN gfs_resources resource - ON resource.resource_id = candidate.resource_id - AND resource.drive = candidate.drive AND resource.deleted_at IS NULL - WHERE tm.user_id = $1::uuid AND tm.status = 'active'`, - orderBy: 'cursor_id', - }, ]), shared_filesystem: boundedKeyUnionSql([ `SELECT edge.target_id AS logical_id diff --git a/control-api/src/services/access/catalogProducerSupport.ts b/control-api/src/services/access/catalogProducerSupport.ts index f619464e6f..1324e6274a 100644 --- a/control-api/src/services/access/catalogProducerSupport.ts +++ b/control-api/src/services/access/catalogProducerSupport.ts @@ -26,9 +26,10 @@ export async function catalogQuery( db: Pick, budget: AccessExecutionBudget, text: string, - values: unknown[] + values: unknown[], + options: { chargeProducer?: boolean } = {} ) { - return runAccessDatabaseQuery(db, budget, text, values) + return runAccessDatabaseQuery(db, budget, text, values, options) } export type BoundedKeyArm = Readonly<{ diff --git a/control-api/src/services/access/catalogProducers.ts b/control-api/src/services/access/catalogProducers.ts index a22849df5e..8fc801d636 100644 --- a/control-api/src/services/access/catalogProducers.ts +++ b/control-api/src/services/access/catalogProducers.ts @@ -40,6 +40,7 @@ import { } from './catalogProducerSupport.js' import type { OperationalSourceFamily } from './operationalAccessProjection.js' import { canonicalResourceIdentity } from './resourceIdentity.js' +import { listGfsProducerKeys } from './teamGfsTopK.js' const REQUIRED_SOURCES: Readonly> = Object.freeze({ @@ -530,6 +531,9 @@ class SqlCatalogProducer implements CatalogProducer { continuation: ProducerContinuation, take: number ): Promise { + if (this.family === 'gfs_resource') { + return listGfsProducerKeys({ context, continuation, take }) + } const after = continuation.afterKey?.[2] ?? '' const prefix = `${this.family === 'host' ? config.hostsNamespace : config.contextsNamespace}/` const scopedAfter = after.startsWith(prefix) ? after.slice(prefix.length) : '' diff --git a/control-api/src/services/access/teamGfsTopK.ts b/control-api/src/services/access/teamGfsTopK.ts new file mode 100644 index 0000000000..5fd9a5cc0c --- /dev/null +++ b/control-api/src/services/access/teamGfsTopK.ts @@ -0,0 +1,241 @@ +import { AccessBudgetExceededError } from './accessExecutionBudget.js' +import { + type CatalogIdentityCandidate, + type CatalogProducerPage, + type CatalogRequestContext, + type ProducerContinuation, + catalogKey, +} from './catalogContracts.js' +import { CATALOG_KEY_SQL } from './catalogProducerSql.js' +import { + CatalogProducerContractError, + catalogQuery, + listBoundedProducerKeys, +} from './catalogProducerSupport.js' + +type StreamKind = 'grant' | 'share' +type StreamHead = Readonly<{ + kind: StreamKind + subjectId: string + logicalId: string +}> + +const TEAM_GFS_STREAM_HEAD_SQL = `WITH requested AS ( + SELECT stream.kind, stream.subject_id, stream.after_id + FROM jsonb_to_recordset($1::jsonb) + AS stream(kind text, subject_id text, after_id uuid) +), heads AS ( + SELECT requested.kind, requested.subject_id, candidate.resource_id + FROM requested + CROSS JOIN LATERAL ( + SELECT grant_row.resource_id + FROM gfs_grants grant_row + JOIN gfs_resources resource + ON resource.resource_id = grant_row.resource_id + AND resource.drive = grant_row.drive AND resource.deleted_at IS NULL + WHERE requested.kind = 'grant' + AND grant_row.subject_type = 'team' AND grant_row.subject_id = requested.subject_id + AND grant_row.resource_id > requested.after_id + ORDER BY grant_row.resource_id + LIMIT 1 + ) candidate + UNION ALL + SELECT requested.kind, requested.subject_id, candidate.resource_id + FROM requested + CROSS JOIN LATERAL ( + SELECT share.resource_id + FROM gfs_shares share + JOIN gfs_resources resource + ON resource.resource_id = share.resource_id + AND resource.drive = share.drive AND resource.deleted_at IS NULL + WHERE requested.kind = 'share' + AND share.subject_type = 'team' AND share.subject_id = requested.subject_id + AND share.resource_id > requested.after_id + ORDER BY share.resource_id + LIMIT 1 + ) candidate +) +SELECT kind, subject_id, resource_id::text AS logical_id + FROM heads + ORDER BY logical_id, kind, subject_id` + +function compareHead(left: StreamHead, right: StreamHead): number { + return ( + left.logicalId.localeCompare(right.logicalId) || + left.kind.localeCompare(right.kind) || + left.subjectId.localeCompare(right.subjectId) + ) +} + +class HeadHeap { + private readonly values: StreamHead[] = [] + + get size(): number { + return this.values.length + } + + peek(): StreamHead | undefined { + return this.values[0] + } + + push(value: StreamHead): void { + this.values.push(value) + let index = this.values.length - 1 + while (index > 0) { + const parent = Math.floor((index - 1) / 2) + if (compareHead(this.values[parent]!, value) <= 0) break + this.values[index] = this.values[parent]! + index = parent + } + this.values[index] = value + } + + pop(): StreamHead | undefined { + const first = this.values[0] + const last = this.values.pop() + if (!first || !last || this.values.length === 0) return first + let index = 0 + while (true) { + const left = index * 2 + 1 + const right = left + 1 + if (left >= this.values.length) break + const child = + right < this.values.length && compareHead(this.values[right]!, this.values[left]!) < 0 + ? right + : left + if (compareHead(last, this.values[child]!) <= 0) break + this.values[index] = this.values[child]! + index = child + } + this.values[index] = last + return first + } +} + +function parseHeads(rows: readonly Record[]): StreamHead[] { + return rows.map(row => { + const kind = row.kind + const subjectId = row.subject_id + const logicalId = row.logical_id + if ( + (kind !== 'grant' && kind !== 'share') || + typeof subjectId !== 'string' || + !subjectId || + typeof logicalId !== 'string' || + !logicalId + ) { + throw new CatalogProducerContractError('team_gfs_stream_head_invalid') + } + return Object.freeze({ kind, subjectId, logicalId }) + }) +} + +async function readHeads( + context: CatalogRequestContext, + streams: readonly Readonly<{ kind: StreamKind; subjectId: string; afterId: string }>[] +): Promise { + if (streams.length === 0) return [] + const result = await catalogQuery( + context.db, + context.budget, + TEAM_GFS_STREAM_HEAD_SQL, + [ + JSON.stringify( + streams.map(stream => ({ + kind: stream.kind, + subject_id: stream.subjectId, + after_id: stream.afterId, + })) + ), + ], + { chargeProducer: false } + ) + return parseHeads(result.rows as Record[]) +} + +async function teamCandidates(input: { + context: CatalogRequestContext + after: string + take: number +}): Promise> { + const memberships = input.context.principal.memberships + if (memberships.length === 0) return { candidates: Object.freeze([]), hasMore: false } + const admission = input.context.budget.teamGfsMembershipAdmissionLimit + if (admission === null || memberships.length > admission) { + throw new AccessBudgetExceededError('teamGfsMembershipAdmission', true) + } + const initialStreams = memberships.flatMap(membership => + (['grant', 'share'] as const).map(kind => ({ + kind, + subjectId: membership.teamId, + afterId: input.after || '00000000-0000-0000-0000-000000000000', + })) + ) + const heap = new HeadHeap() + for (const head of await readHeads(input.context, initialStreams)) heap.push(head) + const candidates: CatalogIdentityCandidate[] = [] + while (heap.size > 0 && candidates.length < input.take + 1) { + const logicalId = heap.peek()!.logicalId + const consumed: StreamHead[] = [] + while (heap.peek()?.logicalId === logicalId) consumed.push(heap.pop()!) + candidates.push( + Object.freeze({ + key: catalogKey(input.context.environmentId, 'gfs_resource', logicalId), + canonicalId: `gfs_resource:${logicalId}`, + validUntil: null, + }) + ) + const advanced = await readHeads( + input.context, + consumed.map(head => ({ kind: head.kind, subjectId: head.subjectId, afterId: logicalId })) + ) + for (const head of advanced) heap.push(head) + } + return Object.freeze({ candidates: Object.freeze(candidates), hasMore: heap.size > 0 }) +} + +function mergeCandidates( + direct: readonly CatalogIdentityCandidate[], + team: readonly CatalogIdentityCandidate[], + take: number +): CatalogIdentityCandidate[] { + const byId = new Map() + for (const candidate of [...direct, ...team]) byId.set(candidate.key[2], candidate) + return [...byId.values()] + .sort((left, right) => left.key[2].localeCompare(right.key[2])) + .slice(0, take + 1) +} + +export async function listGfsProducerKeys(input: { + context: CatalogRequestContext + continuation: ProducerContinuation + take: number +}): Promise { + const direct = await listBoundedProducerKeys({ + context: input.context, + family: 'gfs_resource', + requiredOperationalSources: [], + continuation: input.continuation, + take: input.take, + sql: CATALOG_KEY_SQL.gfs_resource, + extraValues: ['', '', ''], + }) + if (direct.sourceCompleteness !== 'complete' || input.continuation.exhausted) return direct + const after = input.continuation.afterKey?.[2] ?? '' + const team = await teamCandidates({ context: input.context, after, take: input.take }) + const candidates = mergeCandidates(direct.candidates, team.candidates, input.take) + const hasMore = direct.hasMore || team.hasMore || candidates.length > input.take + return Object.freeze({ + candidates: Object.freeze(candidates), + continuation: Object.freeze({ + afterKey: candidates.at(-1)?.key ?? input.continuation.afterKey, + exhausted: !hasMore, + }), + hasMore, + sourceRevision: direct.sourceRevision, + sourceCompleteness: direct.sourceCompleteness, + partialErrors: direct.partialErrors, + }) +} + +export const teamGfsTopKSql = Object.freeze({ streamHead: TEAM_GFS_STREAM_HEAD_SQL }) diff --git a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts index 74a77cc1d8..4cc0873674 100644 --- a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts +++ b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts @@ -4,7 +4,10 @@ import { Pool } from 'pg' import { config } from '../src/config.js' import { type DbClient, initDb } from '../src/db.js' import { buildAccessCatalog } from '../src/services/access/accessCatalogCoordinator.js' -import { AccessExecutionBudget } from '../src/services/access/accessExecutionBudget.js' +import { + AccessBudgetExceededError, + AccessExecutionBudget, +} from '../src/services/access/accessExecutionBudget.js' import type { CatalogOperationalSourceState, CatalogRequestContext, @@ -180,7 +183,10 @@ describeRealPostgres('distinct catalog key pagination on real PostgreSQL', () => limit: 2, ...(cursor ? { cursor } : {}), }, - { transaction: transaction(databasePool) } + { + transaction: transaction(databasePool), + teamGfsMembershipAdmissionLimit: 8, + } ) expect(page.complete).toBe(true) firstItem ??= page.items[0] @@ -663,4 +669,42 @@ describeRealPostgres('distinct catalog key pagination on real PostgreSQL', () => minimumPathsForFirst: 4, }) }, 30_000) + + it('fails closed above the configured local team GFS membership admission bound', async () => { + const actor = await principal('gfs-admission', 3) + const resourceId = 'e0000000-0000-4000-8000-000000000001' + await databasePool.query( + `INSERT INTO gfs_resources(resource_id, drive, name, kind) + VALUES ($1, 'gfs-admission', '/', 'directory')`, + [resourceId] + ) + for (const teamId of actor.teamIds) { + await databasePool.query( + `INSERT INTO gfs_grants(drive, resource_id, subject_type, subject_id, permissions) + VALUES ('gfs-admission', $1, 'team', $2, ARRAY['read']::text[])`, + [resourceId, teamId] + ) + } + const session: ExternalSessionAuthorityContext = { + contract: 'v1', + userId: actor.userId, + tokenHash: randomBytes(32).toString('hex'), + issuedAt: Math.floor(Date.now() / 1_000), + } + const build = (admission: number) => + buildAccessCatalog( + { session, families: ['gfs_resource'], limit: 2 }, + { + transaction: transaction(databasePool), + teamGfsMembershipAdmissionLimit: admission, + } + ) + + await expect(build(4)).resolves.toMatchObject({ complete: true }) + await expect(build(3)).resolves.toMatchObject({ complete: true }) + await expect(build(2)).rejects.toMatchObject({ + limit: 'teamGfsMembershipAdmission', + authorityRequired: true, + } satisfies Partial) + }) }) diff --git a/control-api/test/catalogProducers.test.ts b/control-api/test/catalogProducers.test.ts index fba1656e52..33832e54a4 100644 --- a/control-api/test/catalogProducers.test.ts +++ b/control-api/test/catalogProducers.test.ts @@ -62,7 +62,7 @@ describe('catalog producer registry', () => { workflow_run: 3, workflow_approval: 2, notification: 2, - gfs_resource: 4, + gfs_resource: 2, shared_filesystem: 2, sandbox_app: 2, } @@ -70,10 +70,7 @@ describe('catalog producer registry', () => { const sql = CATALOG_KEY_SQL[family] expect(sql).not.toMatch(/\bOFFSET\b/i) expect(sql.match(/AS MATERIALIZED/g)).toHaveLength(expectedArmCounts[family]) - const perMembershipBounds = family === 'gfs_resource' ? 2 : 0 - expect(sql.match(/LIMIT \$4/g)).toHaveLength( - expectedArmCounts[family] + perMembershipBounds + 1 - ) + expect(sql.match(/LIMIT \$4/g)).toHaveLength(expectedArmCounts[family] + 1) } }) }) From 849d9470c89d66f13934165a8b1211e2f322c128 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 18:24:48 -0600 Subject: [PATCH 045/528] fix(access): r1-h3 fill distinct producer heads Finding: R1-H3 Class-check: source_saturated public continuation -> 11 duplicate-capable arms -> all now continue internally to take plus one or exhaustion Repro-test: catalogDistinctPagination.realPostgres.integration.test.ts duplicate host assertion fails at f722c636 F3-generated-artifact: mandatory Control API version metadata only; no semantic package change. --- .../services/access/catalogProducerSupport.ts | 83 ++++++++++++------- .../src/services/access/catalogProducers.ts | 8 +- ...agination.realPostgres.integration.test.ts | 13 +-- 3 files changed, 58 insertions(+), 46 deletions(-) diff --git a/control-api/src/services/access/catalogProducerSupport.ts b/control-api/src/services/access/catalogProducerSupport.ts index 1324e6274a..1c7f25225d 100644 --- a/control-api/src/services/access/catalogProducerSupport.ts +++ b/control-api/src/services/access/catalogProducerSupport.ts @@ -137,7 +137,7 @@ export async function listBoundedProducerKeys(input: { continuation: ProducerContinuation take: number sql: string - extraValues?: readonly unknown[] + extraValues?: readonly unknown[] | ((after: string) => readonly unknown[]) }): Promise { const { context } = input if ( @@ -170,42 +170,61 @@ export async function listBoundedProducerKeys(input: { }) } const after = validateContinuation(input.family, context.environmentId, input.continuation) - const result = await catalogQuery(context.db, context.budget, input.sql, [ - context.principal.userId, - after, - context.environmentId, - input.take + 1, - ...(input.extraValues ?? []), - ]) - if (result.rows.length > input.take + 1) { - throw new CatalogProducerContractError('key_query_exceeded_take') - } const candidates: CatalogIdentityCandidate[] = [] - let sourceSaturated = false let prior = after - for (const row of result.rows as Record[]) { - if (typeof row.source_saturated !== 'boolean') { - throw new CatalogProducerContractError('source_saturation_invalid') - } - sourceSaturated ||= row.source_saturated - const logicalId = typeof row.logical_id === 'string' ? row.logical_id : '' - if (!logicalId || logicalId <= prior) { - throw new CatalogProducerContractError('keys_not_strictly_ordered') + let sourceSaturated = true + let firstQuery = true + while (candidates.length < input.take + 1 && sourceSaturated) { + const result = await catalogQuery( + context.db, + context.budget, + input.sql, + [ + context.principal.userId, + prior, + context.environmentId, + input.take + 1, + ...(typeof input.extraValues === 'function' + ? input.extraValues(prior) + : (input.extraValues ?? [])), + ], + { chargeProducer: firstQuery } + ) + firstQuery = false + if (result.rows.length > input.take + 1) { + throw new CatalogProducerContractError('key_query_exceeded_take') } - const key = catalogKey(context.environmentId, input.family, logicalId) - if (candidates.length > 0 && compareCatalogKey(candidates.at(-1)!.key, key) >= 0) { - throw new CatalogProducerContractError('keys_not_strictly_ordered') - } - let validUntil: string | null = null - if (row.valid_until !== null && row.valid_until !== undefined) { - const timestamp = new Date(String(row.valid_until)) - if (Number.isNaN(timestamp.getTime())) { - throw new CatalogProducerContractError('candidate_valid_until_invalid') + sourceSaturated = false + for (const row of result.rows as Record[]) { + if (typeof row.source_saturated !== 'boolean') { + throw new CatalogProducerContractError('source_saturation_invalid') + } + sourceSaturated ||= row.source_saturated + const logicalId = typeof row.logical_id === 'string' ? row.logical_id : '' + if (!logicalId || logicalId <= prior) { + throw new CatalogProducerContractError('keys_not_strictly_ordered') } - validUntil = timestamp.toISOString() + const key = catalogKey(context.environmentId, input.family, logicalId) + if (candidates.length > 0 && compareCatalogKey(candidates.at(-1)!.key, key) >= 0) { + throw new CatalogProducerContractError('keys_not_strictly_ordered') + } + let validUntil: string | null = null + if (row.valid_until !== null && row.valid_until !== undefined) { + const timestamp = new Date(String(row.valid_until)) + if (Number.isNaN(timestamp.getTime())) { + throw new CatalogProducerContractError('candidate_valid_until_invalid') + } + validUntil = timestamp.toISOString() + } + candidates.push( + Object.freeze({ key, canonicalId: `${input.family}:${logicalId}`, validUntil }) + ) + prior = logicalId + if (candidates.length >= input.take + 1) break + } + if (sourceSaturated && result.rows.length === 0) { + throw new CatalogProducerContractError('source_saturation_without_progress') } - candidates.push(Object.freeze({ key, canonicalId: `${input.family}:${logicalId}`, validUntil })) - prior = logicalId } const hasMore = candidates.length > input.take || sourceSaturated return Object.freeze({ diff --git a/control-api/src/services/access/catalogProducers.ts b/control-api/src/services/access/catalogProducers.ts index 8fc801d636..da8564f620 100644 --- a/control-api/src/services/access/catalogProducers.ts +++ b/control-api/src/services/access/catalogProducers.ts @@ -534,9 +534,7 @@ class SqlCatalogProducer implements CatalogProducer { if (this.family === 'gfs_resource') { return listGfsProducerKeys({ context, continuation, take }) } - const after = continuation.afterKey?.[2] ?? '' const prefix = `${this.family === 'host' ? config.hostsNamespace : config.contextsNamespace}/` - const scopedAfter = after.startsWith(prefix) ? after.slice(prefix.length) : '' return listBoundedProducerKeys({ context, family: this.family, @@ -544,7 +542,11 @@ class SqlCatalogProducer implements CatalogProducer { continuation, take, sql: CATALOG_KEY_SQL[this.family], - extraValues: [config.hostsNamespace, config.contextsNamespace, scopedAfter], + extraValues: after => [ + config.hostsNamespace, + config.contextsNamespace, + after.startsWith(prefix) ? after.slice(prefix.length) : '', + ], }) } diff --git a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts index 4cc0873674..317f47c973 100644 --- a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts +++ b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts @@ -322,20 +322,11 @@ describeRealPostgres('distinct catalog key pagination on real PostgreSQL', () => ) expect(page.candidates.map(candidate => candidate.key[2])).toEqual([ `${config.hostsNamespace}/a`, - ]) - expect(page.hasMore).toBe(true) - expect(page.continuation.exhausted).toBe(false) - const next = await requireCatalogProducer('host').listCanonicalKeys( - context, - { afterKey: page.candidates[0].key, exhausted: false }, - 2 - ) - expect(next.candidates.map(candidate => candidate.key[2])).toEqual([ `${config.hostsNamespace}/b`, `${config.hostsNamespace}/c`, ]) - expect(next.hasMore).toBe(false) - expect(next.continuation.exhausted).toBe(true) + expect(page.hasMore).toBe(true) + expect(page.continuation.exhausted).toBe(false) } finally { budget.close() } From 18218089bf011eeace77622e11778814626e5d12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 18:25:18 -0600 Subject: [PATCH 046/528] test(access): r1-m1 assert strict producer ordering Finding: R1-M1 Class-check: producer contract-error assertions -> 1 strict-order site -> supplies valid saturation metadata and asserts the exact ordering error Repro-test: focused strict-order assertion fails at 4f35baa4 with source_saturation_invalid F3-generated-artifact: mandatory Control API version metadata only; no semantic package change. --- control-api/test/catalogProducers.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/control-api/test/catalogProducers.test.ts b/control-api/test/catalogProducers.test.ts index 33832e54a4..d6fbb7c7d6 100644 --- a/control-api/test/catalogProducers.test.ts +++ b/control-api/test/catalogProducers.test.ts @@ -8,7 +8,6 @@ import { catalogKey, } from '../src/services/access/catalogContracts.js' import { CATALOG_KEY_SQL } from '../src/services/access/catalogProducerSql.js' -import { CatalogProducerContractError } from '../src/services/access/catalogProducerSupport.js' import { catalogProducers, requireCatalogProducer, @@ -114,7 +113,10 @@ describe('catalog producer protocol', () => { it('rejects duplicate or non-increasing producer keys', async () => { const query = vi.fn().mockResolvedValue({ - rows: [{ logical_id: 'z' }, { logical_id: 'z' }], + rows: [ + { logical_id: 'z', source_saturated: false }, + { logical_id: 'z', source_saturated: false }, + ], rowCount: 2, }) await expect( @@ -123,7 +125,7 @@ describe('catalog producer protocol', () => { { afterKey: null, exhausted: false }, 2 ) - ).rejects.toBeInstanceOf(CatalogProducerContractError) + ).rejects.toMatchObject({ code: 'keys_not_strictly_ordered' }) }) it('reports an operational source gap as partial without querying authority', async () => { From 5ac877485d2901dc4a1f007d32d6dd0b16a36c2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 18:26:23 -0600 Subject: [PATCH 047/528] test(access): r1-m2 isolate indexer cancellation Finding: R1-M2 Class-check: focused tests using prior-case resource state -> 1 cancellation site -> seeds and reconciles its own host version and UID Repro-test: focused cancellation test fails at 77d486c96807a9044ba2312551b6e2e6965e1ce8 with resourceVersion 1 versus 3 F3-generated-artifact: mandatory Control API version metadata only; no semantic package change. --- ...gFamilies.realPostgres.integration.test.ts | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts index 57fd137294..dca1aa96a7 100644 --- a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts @@ -555,6 +555,25 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { it('cancels a real indexer relist without staging or promoting late results', async () => { const source = operationalSourceSpecs.find(value => value.family === 'host')! + const cancellationHost = fixture({ + plural: 'hosts', + namespace: config.hostsNamespace, + name: 'catalog-host', + uid: 'catalog-host-uid-cancellation', + spec: { + contextRef: 'catalog-context', + model: { provider: 'openai', name: 'test-model' }, + }, + }).object + const cancellationHostWithVersion = { + ...cancellationHost, + metadata: { + ...(cancellationHost.metadata as Record), + resourceVersion: '4', + }, + } + kubernetesApi.put(source.plural, source.namespace, cancellationHostWithVersion) + await expect(indexer.reconcileSource(source)).resolves.toBe('4') const before = await databasePool.query( `SELECT generation, staging_generation, resource_version, status FROM operational_catalog_source_state @@ -564,7 +583,7 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { expect(before.rows[0]).toEqual( expect.objectContaining({ staging_generation: null, - resource_version: '3', + resource_version: '4', status: 'current', }) ) @@ -586,7 +605,7 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { expect(afterAbort.rows[0]).toEqual( expect.objectContaining({ generation: String(Number(before.rows[0].generation) + 1), - resource_version: '3', + resource_version: '4', status: 'relisting', }) ) @@ -609,12 +628,12 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { ) expect(live.rows).toEqual([ { - provider_uid: 'catalog-host-uid-recreated', - provider_resource_version: '3', + provider_uid: 'catalog-host-uid-cancellation', + provider_resource_version: '4', }, ]) - await expect(indexer.reconcileSource(source)).resolves.toBe('3') + await expect(indexer.reconcileSource(source)).resolves.toBe('4') const recovered = await databasePool.query( `SELECT staging_generation, resource_version, status FROM operational_catalog_source_state @@ -623,7 +642,7 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { ) expect(recovered.rows[0]).toEqual({ staging_generation: null, - resource_version: '3', + resource_version: '4', status: 'current', }) } finally { From 2aaf117d6d5b3b76742415cf05042bcd707085e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 18:26:58 -0600 Subject: [PATCH 048/528] test(access): r1-b4 configure GFS harness admission Finding: R1-B4 Regression of: R1-B3 introduced by f722c636 Class-check: producer-backed catalog calls with team GFS -> 1 all-family harness site -> supplies a local bound equal to its one active team Repro-test: full accessCatalogFamilies real PostgreSQL file fails at 8c3b68b0 with teamGfsMembershipAdmission F3-generated-artifact: mandatory Control API version metadata only; no semantic package change. --- .../accessCatalogFamilies.realPostgres.integration.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts index dca1aa96a7..f4ca6ac5f4 100644 --- a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts @@ -370,7 +370,10 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { it(`${family} producer paths round-trip through catalog and live resolution`, async () => { const catalog = await buildAccessCatalog( { session, families: [family], limit: 100 }, - { transaction: transaction(databasePool) } + { + transaction: transaction(databasePool), + ...(family === 'gfs_resource' ? { teamGfsMembershipAdmissionLimit: 1 } : {}), + } ) expect(catalog.complete).toBe(true) expect(catalog.partialErrors).toEqual([]) From c8a275c90b66bfa3c8c5b1037de728987ba2f0ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 18:30:18 -0600 Subject: [PATCH 049/528] fix(access): r1-b5 bound GFS merge round trips Finding: R1-B5 Regression of: R1-B3 introduced by f722c636 Class-check: all-family team GFS callers -> 2 real PostgreSQL sites -> R1-B4 configures the producer harness and this change configures and bounds the load harness Repro-test: accessCatalogPerformance real PostgreSQL query-count assertion observes 117 statements at 8c3b68b0 F3-generated-artifact: mandatory Control API version metadata only; no semantic package change. --- .../src/services/access/teamGfsTopK.ts | 56 +++++++++++++++---- ...rformance.realPostgres.integration.test.ts | 19 +++++-- 2 files changed, 58 insertions(+), 17 deletions(-) diff --git a/control-api/src/services/access/teamGfsTopK.ts b/control-api/src/services/access/teamGfsTopK.ts index 5fd9a5cc0c..b82b16fb02 100644 --- a/control-api/src/services/access/teamGfsTopK.ts +++ b/control-api/src/services/access/teamGfsTopK.ts @@ -18,12 +18,15 @@ type StreamHead = Readonly<{ kind: StreamKind subjectId: string logicalId: string + batchLast: boolean }> +const TEAM_GFS_ADVANCE_CHUNK = 8 + const TEAM_GFS_STREAM_HEAD_SQL = `WITH requested AS ( - SELECT stream.kind, stream.subject_id, stream.after_id + SELECT stream.kind, stream.subject_id, stream.after_id, stream.take FROM jsonb_to_recordset($1::jsonb) - AS stream(kind text, subject_id text, after_id uuid) + AS stream(kind text, subject_id text, after_id uuid, take integer) ), heads AS ( SELECT requested.kind, requested.subject_id, candidate.resource_id FROM requested @@ -37,7 +40,7 @@ const TEAM_GFS_STREAM_HEAD_SQL = `WITH requested AS ( AND grant_row.subject_type = 'team' AND grant_row.subject_id = requested.subject_id AND grant_row.resource_id > requested.after_id ORDER BY grant_row.resource_id - LIMIT 1 + LIMIT requested.take ) candidate UNION ALL SELECT requested.kind, requested.subject_id, candidate.resource_id @@ -52,7 +55,7 @@ const TEAM_GFS_STREAM_HEAD_SQL = `WITH requested AS ( AND share.subject_type = 'team' AND share.subject_id = requested.subject_id AND share.resource_id > requested.after_id ORDER BY share.resource_id - LIMIT 1 + LIMIT requested.take ) candidate ) SELECT kind, subject_id, resource_id::text AS logical_id @@ -113,7 +116,7 @@ class HeadHeap { } function parseHeads(rows: readonly Record[]): StreamHead[] { - return rows.map(row => { + const parsed = rows.map(row => { const kind = row.kind const subjectId = row.subject_id const logicalId = row.logical_id @@ -126,13 +129,26 @@ function parseHeads(rows: readonly Record[]): StreamHead[] { ) { throw new CatalogProducerContractError('team_gfs_stream_head_invalid') } - return Object.freeze({ kind, subjectId, logicalId }) + return { kind, subjectId, logicalId } }) + const lastByStream = new Map() + for (const head of parsed) lastByStream.set(`${head.kind}:${head.subjectId}`, head.logicalId) + return parsed.map(head => + Object.freeze({ + ...head, + batchLast: lastByStream.get(`${head.kind}:${head.subjectId}`) === head.logicalId, + }) + ) } async function readHeads( context: CatalogRequestContext, - streams: readonly Readonly<{ kind: StreamKind; subjectId: string; afterId: string }>[] + streams: readonly Readonly<{ + kind: StreamKind + subjectId: string + afterId: string + take: number + }>[] ): Promise { if (streams.length === 0) return [] const result = await catalogQuery( @@ -145,6 +161,7 @@ async function readHeads( kind: stream.kind, subject_id: stream.subjectId, after_id: stream.afterId, + take: stream.take, })) ), ], @@ -164,11 +181,17 @@ async function teamCandidates(input: { if (admission === null || memberships.length > admission) { throw new AccessBudgetExceededError('teamGfsMembershipAdmission', true) } + const streamCount = memberships.length * 2 + const initialTake = Math.max( + 1, + Math.min(TEAM_GFS_ADVANCE_CHUNK, Math.ceil((input.take + 1) / streamCount) + 1) + ) const initialStreams = memberships.flatMap(membership => (['grant', 'share'] as const).map(kind => ({ kind, subjectId: membership.teamId, afterId: input.after || '00000000-0000-0000-0000-000000000000', + take: initialTake, })) ) const heap = new HeadHeap() @@ -185,11 +208,20 @@ async function teamCandidates(input: { validUntil: null, }) ) - const advanced = await readHeads( - input.context, - consumed.map(head => ({ kind: head.kind, subjectId: head.subjectId, afterId: logicalId })) - ) - for (const head of advanced) heap.push(head) + if (candidates.length < input.take + 1) { + const advanced = await readHeads( + input.context, + consumed + .filter(head => head.batchLast) + .map(head => ({ + kind: head.kind, + subjectId: head.subjectId, + afterId: logicalId, + take: TEAM_GFS_ADVANCE_CHUNK, + })) + ) + for (const head of advanced) heap.push(head) + } } return Object.freeze({ candidates: Object.freeze(candidates), hasMore: heap.size > 0 }) } diff --git a/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts b/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts index ad923696f3..dea09b545e 100644 --- a/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts @@ -634,19 +634,25 @@ describeRealPostgres('aggregate catalog plans on real PostgreSQL', () => { const first = await buildAccessCatalog( { session, families: CATALOG_FAMILIES, limit: 100 }, - { transaction: measuredTransaction } + { + transaction: measuredTransaction, + teamGfsMembershipAdmissionLimit: HIGH_TEAMS, + } ) expect(first.items).toHaveLength(100) expect(first.nextCursor).not.toBeNull() - expect(queryCount).toBeLessThanOrEqual(2 + 2 * CATALOG_FAMILIES.length) + expect(queryCount).toBeLessThanOrEqual(40) queryCount = 0 const second = await buildAccessCatalog( { session, families: CATALOG_FAMILIES, limit: 100, cursor: first.nextCursor }, - { transaction: measuredTransaction } + { + transaction: measuredTransaction, + teamGfsMembershipAdmissionLimit: HIGH_TEAMS, + } ) expect(second.items).toHaveLength(100) - expect(queryCount).toBeLessThanOrEqual(2 + 2 * CATALOG_FAMILIES.length) + expect(queryCount).toBeLessThanOrEqual(40) const firstIds = new Set(first.items.map(item => item.resource.canonicalId)) expect(second.items.every(item => !firstIds.has(item.resource.canonicalId))).toBe(true) @@ -659,7 +665,10 @@ describeRealPostgres('aggregate catalog plans on real PostgreSQL', () => { await expect( buildAccessCatalog( { session, families: CATALOG_FAMILIES, limit: 100, cursor: first.nextCursor }, - { transaction: measuredTransaction } + { + transaction: measuredTransaction, + teamGfsMembershipAdmissionLimit: HIGH_TEAMS, + } ) ).rejects.toBeInstanceOf(AccessCatalogCursorError) }) From 1e3ed572118a9716d609c4ca24473457f873f64f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 18:36:01 -0600 Subject: [PATCH 050/528] test(access): r1-h4 prove GFS top-k properties Finding: R1-H4 Regression of: R1-B3 introduced by f722c636 Class-check: pure pagination decision modules -> 1 new team GFS heap merge -> covers exact order, deduplication, idempotency, and stream-order composition Repro-test: mutation-resistance proof required because the previous head lacks the extracted pure seam F3-generated-artifact: mandatory Control API version metadata only; no semantic package change. --- .../src/services/access/teamGfsTopK.ts | 117 ++++++++++-------- control-api/test/teamGfsTopK.property.test.ts | 72 +++++++++++ 2 files changed, 138 insertions(+), 51 deletions(-) create mode 100644 control-api/test/teamGfsTopK.property.test.ts diff --git a/control-api/src/services/access/teamGfsTopK.ts b/control-api/src/services/access/teamGfsTopK.ts index b82b16fb02..222b477d90 100644 --- a/control-api/src/services/access/teamGfsTopK.ts +++ b/control-api/src/services/access/teamGfsTopK.ts @@ -13,9 +13,15 @@ import { listBoundedProducerKeys, } from './catalogProducerSupport.js' -type StreamKind = 'grant' | 'share' -type StreamHead = Readonly<{ - kind: StreamKind +export type TeamGfsStreamKind = 'grant' | 'share' +export type TeamGfsStreamRequest = Readonly<{ + kind: TeamGfsStreamKind + subjectId: string + afterId: string + take: number +}> +export type TeamGfsStreamHead = Readonly<{ + kind: TeamGfsStreamKind subjectId: string logicalId: string batchLast: boolean @@ -62,7 +68,7 @@ SELECT kind, subject_id, resource_id::text AS logical_id FROM heads ORDER BY logical_id, kind, subject_id` -function compareHead(left: StreamHead, right: StreamHead): number { +function compareHead(left: TeamGfsStreamHead, right: TeamGfsStreamHead): number { return ( left.logicalId.localeCompare(right.logicalId) || left.kind.localeCompare(right.kind) || @@ -71,17 +77,17 @@ function compareHead(left: StreamHead, right: StreamHead): number { } class HeadHeap { - private readonly values: StreamHead[] = [] + private readonly values: TeamGfsStreamHead[] = [] get size(): number { return this.values.length } - peek(): StreamHead | undefined { + peek(): TeamGfsStreamHead | undefined { return this.values[0] } - push(value: StreamHead): void { + push(value: TeamGfsStreamHead): void { this.values.push(value) let index = this.values.length - 1 while (index > 0) { @@ -93,7 +99,7 @@ class HeadHeap { this.values[index] = value } - pop(): StreamHead | undefined { + pop(): TeamGfsStreamHead | undefined { const first = this.values[0] const last = this.values.pop() if (!first || !last || this.values.length === 0) return first @@ -115,7 +121,7 @@ class HeadHeap { } } -function parseHeads(rows: readonly Record[]): StreamHead[] { +function parseHeads(rows: readonly Record[]): TeamGfsStreamHead[] { const parsed = rows.map(row => { const kind = row.kind const subjectId = row.subject_id @@ -129,7 +135,7 @@ function parseHeads(rows: readonly Record[]): StreamHead[] { ) { throw new CatalogProducerContractError('team_gfs_stream_head_invalid') } - return { kind, subjectId, logicalId } + return { kind: kind as TeamGfsStreamKind, subjectId, logicalId } }) const lastByStream = new Map() for (const head of parsed) lastByStream.set(`${head.kind}:${head.subjectId}`, head.logicalId) @@ -143,13 +149,8 @@ function parseHeads(rows: readonly Record[]): StreamHead[] { async function readHeads( context: CatalogRequestContext, - streams: readonly Readonly<{ - kind: StreamKind - subjectId: string - afterId: string - take: number - }>[] -): Promise { + streams: readonly TeamGfsStreamRequest[] +): Promise { if (streams.length === 0) return [] const result = await catalogQuery( context.db, @@ -170,47 +171,28 @@ async function readHeads( return parseHeads(result.rows as Record[]) } -async function teamCandidates(input: { - context: CatalogRequestContext - after: string +export async function collectTeamGfsTopK(input: { + streams: readonly Omit[] take: number -}): Promise> { - const memberships = input.context.principal.memberships - if (memberships.length === 0) return { candidates: Object.freeze([]), hasMore: false } - const admission = input.context.budget.teamGfsMembershipAdmissionLimit - if (admission === null || memberships.length > admission) { - throw new AccessBudgetExceededError('teamGfsMembershipAdmission', true) - } - const streamCount = memberships.length * 2 + read: (streams: readonly TeamGfsStreamRequest[]) => Promise +}): Promise> { + if (input.streams.length === 0) return { logicalIds: Object.freeze([]), hasMore: false } + const streamCount = input.streams.length const initialTake = Math.max( 1, Math.min(TEAM_GFS_ADVANCE_CHUNK, Math.ceil((input.take + 1) / streamCount) + 1) ) - const initialStreams = memberships.flatMap(membership => - (['grant', 'share'] as const).map(kind => ({ - kind, - subjectId: membership.teamId, - afterId: input.after || '00000000-0000-0000-0000-000000000000', - take: initialTake, - })) - ) + const initialStreams = input.streams.map(stream => ({ ...stream, take: initialTake })) const heap = new HeadHeap() - for (const head of await readHeads(input.context, initialStreams)) heap.push(head) - const candidates: CatalogIdentityCandidate[] = [] - while (heap.size > 0 && candidates.length < input.take + 1) { + for (const head of await input.read(initialStreams)) heap.push(head) + const logicalIds: string[] = [] + while (heap.size > 0 && logicalIds.length < input.take + 1) { const logicalId = heap.peek()!.logicalId - const consumed: StreamHead[] = [] + const consumed: TeamGfsStreamHead[] = [] while (heap.peek()?.logicalId === logicalId) consumed.push(heap.pop()!) - candidates.push( - Object.freeze({ - key: catalogKey(input.context.environmentId, 'gfs_resource', logicalId), - canonicalId: `gfs_resource:${logicalId}`, - validUntil: null, - }) - ) - if (candidates.length < input.take + 1) { - const advanced = await readHeads( - input.context, + logicalIds.push(logicalId) + if (logicalIds.length < input.take + 1) { + const advanced = await input.read( consumed .filter(head => head.batchLast) .map(head => ({ @@ -223,7 +205,40 @@ async function teamCandidates(input: { for (const head of advanced) heap.push(head) } } - return Object.freeze({ candidates: Object.freeze(candidates), hasMore: heap.size > 0 }) + return Object.freeze({ logicalIds: Object.freeze(logicalIds), hasMore: heap.size > 0 }) +} + +async function teamCandidates(input: { + context: CatalogRequestContext + after: string + take: number +}): Promise> { + const memberships = input.context.principal.memberships + if (memberships.length === 0) return { candidates: Object.freeze([]), hasMore: false } + const admission = input.context.budget.teamGfsMembershipAdmissionLimit + if (admission === null || memberships.length > admission) { + throw new AccessBudgetExceededError('teamGfsMembershipAdmission', true) + } + const streams = memberships.flatMap(membership => + (['grant', 'share'] as const).map(kind => ({ + kind, + subjectId: membership.teamId, + afterId: input.after || '00000000-0000-0000-0000-000000000000', + })) + ) + const merged = await collectTeamGfsTopK({ + streams, + take: input.take, + read: requested => readHeads(input.context, requested), + }) + const candidates = merged.logicalIds.map(logicalId => + Object.freeze({ + key: catalogKey(input.context.environmentId, 'gfs_resource', logicalId), + canonicalId: `gfs_resource:${logicalId}`, + validUntil: null, + }) + ) + return Object.freeze({ candidates: Object.freeze(candidates), hasMore: merged.hasMore }) } function mergeCandidates( diff --git a/control-api/test/teamGfsTopK.property.test.ts b/control-api/test/teamGfsTopK.property.test.ts new file mode 100644 index 0000000000..9cb2334cf5 --- /dev/null +++ b/control-api/test/teamGfsTopK.property.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import fc from 'fast-check' +import { + type TeamGfsStreamHead, + type TeamGfsStreamRequest, + collectTeamGfsTopK, +} from '../src/services/access/teamGfsTopK.js' + +function logicalId(value: number): string { + return `00000000-0000-4000-8000-${String(value).padStart(12, '0')}` +} + +describe('team GFS top-k properties', () => { + it('matches the exact union across arbitrary stream order and duplicate distributions', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(fc.uniqueArray(fc.integer({ min: 1, max: 80 }), { maxLength: 30 }), { + minLength: 1, + maxLength: 16, + }), + fc.integer({ min: 0, max: 40 }), + fc.oneof(fc.constant(1), fc.constant(2), fc.integer({ min: 3, max: 20 })), + fc.boolean(), + async (generated, afterValue, take, reverseResults) => { + const valuesBySubject = new Map( + generated.map((values, index) => [ + `team-${index}`, + [...values].sort((left, right) => left - right).map(logicalId), + ]) + ) + const streams = [...valuesBySubject.keys()].map(subjectId => ({ + kind: 'grant' as const, + subjectId, + afterId: logicalId(afterValue), + })) + const read = async ( + requests: readonly TeamGfsStreamRequest[] + ): Promise => { + const heads = requests.flatMap(request => { + const selected = (valuesBySubject.get(request.subjectId) ?? []) + .filter(value => value > request.afterId) + .slice(0, request.take) + return selected.map((value, index) => ({ + kind: request.kind, + subjectId: request.subjectId, + logicalId: value, + batchLast: index === selected.length - 1, + })) + }) + return reverseResults ? heads.reverse() : heads + } + const expected = [...new Set(generated.flat())] + .filter(value => value > afterValue) + .sort((left, right) => left - right) + .map(logicalId) + const result = await collectTeamGfsTopK({ streams, take, read }) + const repeated = await collectTeamGfsTopK({ + streams: [...streams].reverse(), + take, + read, + }) + + expect(result.logicalIds).toEqual(expected.slice(0, take + 1)) + expect(new Set(result.logicalIds).size).toBe(result.logicalIds.length) + expect(repeated.logicalIds).toEqual(result.logicalIds) + expect(result.hasMore || result.logicalIds.length > take).toBe(expected.length > take) + } + ), + { numRuns: 250 } + ) + }) +}) From a8a9e4f1be7aff9d441d63abcba0003eeeb7c83f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 18:37:11 -0600 Subject: [PATCH 051/528] test(access): r1-h5 verify team GFS query plans Finding: R1-H5 Regression of: R1-B3 introduced by f722c636 Class-check: GFS EXPLAIN coverage -> direct and team discovery paths -> both now assert subject indexes, bounded resource work, and no temp spill Repro-test: mutation-resistance proof applies because the prior head already used the subject-indexed query F3-generated-artifact: mandatory Control API version metadata only; no semantic package change. --- ...agination.realPostgres.integration.test.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts index 317f47c973..c6c2c86699 100644 --- a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts +++ b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts @@ -18,6 +18,7 @@ import { OPERATIONAL_SOURCE_FAMILIES, canonicalEnvironmentId, } from '../src/services/access/operationalAccessProjection.js' +import { teamGfsTopKSql } from '../src/services/access/teamGfsTopK.js' import type { ExternalSessionAuthorityContext } from '../src/services/auth/externalSessionAuthentication.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL @@ -653,6 +654,43 @@ describeRealPostgres('distinct catalog key pagination on real PostgreSQL', () => expect(Number(node['Temp Written Blocks'] ?? 0)).toBe(0) } + const teamExplained = await databasePool.query( + `EXPLAIN (ANALYZE, BUFFERS, WAL, FORMAT JSON) ${teamGfsTopKSql.streamHead}`, + [ + JSON.stringify([ + { + kind: 'grant', + subject_id: actor.teamIds[0], + after_id: '00000000-0000-0000-0000-000000000000', + take: 3, + }, + { + kind: 'share', + subject_id: actor.teamIds[0], + after_id: '00000000-0000-0000-0000-000000000000', + take: 3, + }, + ]), + ] + ) + const teamEnvelope = (teamExplained.rows[0] as Record)['QUERY PLAN'] as Array<{ + Plan: ExplainNode + }> + const teamNodes = explainNodes(teamEnvelope[0].Plan) + const teamIndexes = new Set( + teamNodes.flatMap(node => + typeof node['Index Name'] === 'string' ? [node['Index Name']] : [] + ) + ) + expect(teamIndexes).toContain('gfs_grants_subject_resource_catalog_idx') + expect(teamIndexes).toContain('gfs_shares_subject_resource_catalog_idx') + expect( + teamNodes + .filter(node => node['Relation Name'] === 'gfs_resources') + .reduce((total, node) => total + actualWork(node), 0) + ).toBeLessThanOrEqual(8) + expect(teamNodes.every(node => Number(node['Temp Written Blocks'] ?? 0) === 0)).toBe(true) + await consumeFamily({ family: 'gfs_resource', userId: actor.userId, From 84cbdeb8ba649c5fa957a0f1915f84e200f1dbbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 18:40:19 -0600 Subject: [PATCH 052/528] fix(access): r1-b6 preserve sparse team GFS plans Class-check: team grant/share stream liveness joins -> 2 sites -> both fixed. Repro-test: sparse team plan scans 4002 resources at 23f8d486; now bounded. Regression-of: R1-B3 f722c636 and R1-H5 23f8d486. F3-generated-artifact: Control API version metadata only; no package semantics. --- .../src/services/access/teamGfsTopK.ts | 30 +++++++++++-------- ...agination.realPostgres.integration.test.ts | 10 +++++-- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/control-api/src/services/access/teamGfsTopK.ts b/control-api/src/services/access/teamGfsTopK.ts index 222b477d90..c89c5d3eb9 100644 --- a/control-api/src/services/access/teamGfsTopK.ts +++ b/control-api/src/services/access/teamGfsTopK.ts @@ -35,30 +35,36 @@ const TEAM_GFS_STREAM_HEAD_SQL = `WITH requested AS ( AS stream(kind text, subject_id text, after_id uuid, take integer) ), heads AS ( SELECT requested.kind, requested.subject_id, candidate.resource_id - FROM requested + FROM (SELECT * FROM requested WHERE kind = 'grant') requested CROSS JOIN LATERAL ( SELECT grant_row.resource_id FROM gfs_grants grant_row - JOIN gfs_resources resource - ON resource.resource_id = grant_row.resource_id - AND resource.drive = grant_row.drive AND resource.deleted_at IS NULL - WHERE requested.kind = 'grant' - AND grant_row.subject_type = 'team' AND grant_row.subject_id = requested.subject_id + CROSS JOIN LATERAL ( + SELECT 1 + FROM gfs_resources resource + WHERE resource.resource_id = grant_row.resource_id + AND resource.drive = grant_row.drive AND resource.deleted_at IS NULL + OFFSET 0 + ) live_resource + WHERE grant_row.subject_type = 'team' AND grant_row.subject_id = requested.subject_id AND grant_row.resource_id > requested.after_id ORDER BY grant_row.resource_id LIMIT requested.take ) candidate UNION ALL SELECT requested.kind, requested.subject_id, candidate.resource_id - FROM requested + FROM (SELECT * FROM requested WHERE kind = 'share') requested CROSS JOIN LATERAL ( SELECT share.resource_id FROM gfs_shares share - JOIN gfs_resources resource - ON resource.resource_id = share.resource_id - AND resource.drive = share.drive AND resource.deleted_at IS NULL - WHERE requested.kind = 'share' - AND share.subject_type = 'team' AND share.subject_id = requested.subject_id + CROSS JOIN LATERAL ( + SELECT 1 + FROM gfs_resources resource + WHERE resource.resource_id = share.resource_id + AND resource.drive = share.drive AND resource.deleted_at IS NULL + OFFSET 0 + ) live_resource + WHERE share.subject_type = 'team' AND share.subject_id = requested.subject_id AND share.resource_id > requested.after_id ORDER BY share.resource_id LIMIT requested.take diff --git a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts index c6c2c86699..db9bc92448 100644 --- a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts +++ b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts @@ -613,10 +613,14 @@ describeRealPostgres('distinct catalog key pagination on real PostgreSQL', () => for (const table of ['gfs_grants', 'gfs_shares'] as const) { await databasePool.query( `INSERT INTO ${table}(drive, resource_id, subject_type, subject_id, permissions) - SELECT drive, resource_id, 'user', 'ffffffff-ffff-4fff-8fff-ffffffffffff', + SELECT resource.drive, resource.resource_id, subject.subject_type, subject.subject_id, ARRAY['read']::text[] - FROM gfs_resources - WHERE drive LIKE 'sparse-unrelated-%'` + FROM gfs_resources resource + CROSS JOIN (VALUES + ('user', 'ffffffff-ffff-4fff-8fff-ffffffffffff'), + ('team', 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee')) + subject(subject_type, subject_id) + WHERE resource.drive LIKE 'sparse-unrelated-%'` ) } for (const table of ['gfs_grants', 'gfs_shares'] as const) { From d571367fb0c6a489c7fee6b7b076559446ad2eb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 19:19:22 -0600 Subject: [PATCH 053/528] fix(access): r1-b7 keep non-GFS distinct heads in one key query Class-check: 27 source arms -> 9 duplicate-capable non-GFS arms -> one indexed query each. Repro-test: real PostgreSQL Host duplicate test fails at 25a3ad5c with 2 key statements. F3-generated-artifact: mandatory Control API version metadata only; no semantic package change. --- .../src/services/access/catalogProducerSql.ts | 33 +++++-- .../services/access/catalogProducerSupport.ts | 98 ++++++++----------- ...agination.realPostgres.integration.test.ts | 9 +- control-api/test/catalogProducers.test.ts | 15 +++ 4 files changed, 93 insertions(+), 62 deletions(-) diff --git a/control-api/src/services/access/catalogProducerSql.ts b/control-api/src/services/access/catalogProducerSql.ts index c695655c0f..665dece1e9 100644 --- a/control-api/src/services/access/catalogProducerSql.ts +++ b/control-api/src/services/access/catalogProducerSql.ts @@ -41,6 +41,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND resource.enabled = TRUE AND resource.deleted_at IS NULL WHERE tm.user_id = $1 AND tm.status = 'active' AND ta.agent_name > $7`, orderBy: 'source_key', + distinctCanonicalBy: 'source_key', }, ]), context: boundedKeyUnionSql([ @@ -64,10 +65,12 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND resource.enabled = TRUE AND resource.deleted_at IS NULL WHERE tm.user_id = $1 AND tm.status = 'active' AND tc.context_id > $7`, orderBy: 'source_key', + distinctCanonicalBy: 'source_key', }, ]), mcp_server: boundedKeyUnionSql([ - `SELECT edge.target_id AS logical_id + { + sql: `SELECT edge.target_id AS logical_id FROM user_contexts uc JOIN operational_resource_relationships edge ON edge.environment_id = $3 @@ -84,7 +87,10 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND target_resource.logical_id = edge.target_id AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL WHERE uc.user_id = $1 AND edge.target_id > $2`, - `SELECT edge.target_id AS logical_id + distinctCanonicalBy: 'logical_id', + }, + { + sql: `SELECT edge.target_id AS logical_id FROM team_contexts tc JOIN team_members tm ON tm.team_id = tc.team_id JOIN operational_resource_relationships edge @@ -102,7 +108,10 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND target_resource.logical_id = edge.target_id AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL WHERE tm.user_id = $1 AND tm.status = 'active' AND edge.target_id > $2`, - `SELECT mcp_edge.target_id AS logical_id + distinctCanonicalBy: 'logical_id', + }, + { + sql: `SELECT mcp_edge.target_id AS logical_id FROM user_agents ua JOIN operational_resource_relationships host_edge ON host_edge.environment_id = $3 @@ -129,7 +138,10 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND target_resource.logical_id = mcp_edge.target_id AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL WHERE ua.user_id = $1 AND mcp_edge.target_id > $2`, - `SELECT mcp_edge.target_id AS logical_id + distinctCanonicalBy: 'logical_id', + }, + { + sql: `SELECT mcp_edge.target_id AS logical_id FROM team_agents ta JOIN team_members tm ON tm.team_id = ta.team_id JOIN operational_resource_relationships host_edge @@ -157,6 +169,8 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND target_resource.logical_id = mcp_edge.target_id AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL WHERE tm.user_id = $1 AND tm.status = 'active' AND mcp_edge.target_id > $2`, + distinctCanonicalBy: 'logical_id', + }, ]), workflow_recipe: boundedKeyUnionSql([ { @@ -181,6 +195,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f WHERE tm.user_id = $1 AND tm.status = 'active' AND (twt.recipe_namespace || '/' || twt.recipe_name) > $2`, orderBy: 'logical_id', + distinctCanonicalBy: 'logical_id', }, ]), workflow_run: boundedKeyUnionSql([ @@ -303,7 +318,8 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f }, ]), shared_filesystem: boundedKeyUnionSql([ - `SELECT edge.target_id AS logical_id + { + sql: `SELECT edge.target_id AS logical_id FROM user_contexts uc JOIN operational_resource_relationships edge ON edge.environment_id = $3 @@ -321,7 +337,10 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND target_resource.logical_id = edge.target_id AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL WHERE uc.user_id = $1 AND edge.target_id > $2`, - `SELECT edge.target_id AS logical_id + distinctCanonicalBy: 'logical_id', + }, + { + sql: `SELECT edge.target_id AS logical_id FROM team_contexts tc JOIN team_members tm ON tm.team_id = tc.team_id JOIN operational_resource_relationships edge @@ -340,6 +359,8 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND target_resource.logical_id = edge.target_id AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL WHERE tm.user_id = $1 AND tm.status = 'active' AND edge.target_id > $2`, + distinctCanonicalBy: 'logical_id', + }, ]), sandbox_app: boundedKeyUnionSql([ `SELECT edge.target_id AS logical_id diff --git a/control-api/src/services/access/catalogProducerSupport.ts b/control-api/src/services/access/catalogProducerSupport.ts index 1c7f25225d..0a6a6ce42e 100644 --- a/control-api/src/services/access/catalogProducerSupport.ts +++ b/control-api/src/services/access/catalogProducerSupport.ts @@ -35,6 +35,7 @@ export async function catalogQuery( export type BoundedKeyArm = Readonly<{ sql: string orderBy?: string + distinctCanonicalBy?: string hasValidUntil?: boolean }> @@ -44,11 +45,19 @@ export function boundedKeyUnionSql(arms: readonly (string | BoundedKeyArm)[]): s const sources = arms .map((arm, index) => { const definition = typeof arm === 'string' ? { sql: arm } : arm + if (definition.distinctCanonicalBy && definition.hasValidUntil) { + throw new CatalogProducerContractError('distinct_valid_until_requires_aggregation') + } + const boundedSql = definition.distinctCanonicalBy + ? `SELECT DISTINCT ON (${definition.distinctCanonicalBy}) distinct_arm.* + FROM (${definition.sql}) distinct_arm + ORDER BY ${definition.distinctCanonicalBy}` + : definition.sql return `${names[index]} AS MATERIALIZED ( SELECT bounded_arm.logical_id, ${definition.hasValidUntil ? 'bounded_arm.valid_until' : 'NULL::timestamptz'} AS valid_until - FROM (${definition.sql}) bounded_arm + FROM (${boundedSql}) bounded_arm ORDER BY ${definition.orderBy ?? 'logical_id'} LIMIT $4 )` @@ -58,9 +67,7 @@ export function boundedKeyUnionSql(arms: readonly (string | BoundedKeyArm)[]): s .map(name => `SELECT logical_id, valid_until FROM ${name}`) .join('\nUNION ALL\n') return `WITH ${sources} - SELECT logical_id, MIN(valid_until) AS valid_until, - ${names.map(name => `(SELECT COUNT(*) FROM ${name}) >= $4`).join(' OR ')} - AS source_saturated + SELECT logical_id, MIN(valid_until) AS valid_until, FALSE AS source_saturated FROM (${union}) bounded_sources WHERE $1::uuid IS NOT NULL AND $2::text IS NOT NULL AND $3::text IS NOT NULL AND $5::text IS NOT NULL AND $6::text IS NOT NULL AND $7::text IS NOT NULL @@ -170,63 +177,44 @@ export async function listBoundedProducerKeys(input: { }) } const after = validateContinuation(input.family, context.environmentId, input.continuation) + const result = await catalogQuery(context.db, context.budget, input.sql, [ + context.principal.userId, + after, + context.environmentId, + input.take + 1, + ...(typeof input.extraValues === 'function' + ? input.extraValues(after) + : (input.extraValues ?? [])), + ]) + if (result.rows.length > input.take + 1) { + throw new CatalogProducerContractError('key_query_exceeded_take') + } const candidates: CatalogIdentityCandidate[] = [] let prior = after - let sourceSaturated = true - let firstQuery = true - while (candidates.length < input.take + 1 && sourceSaturated) { - const result = await catalogQuery( - context.db, - context.budget, - input.sql, - [ - context.principal.userId, - prior, - context.environmentId, - input.take + 1, - ...(typeof input.extraValues === 'function' - ? input.extraValues(prior) - : (input.extraValues ?? [])), - ], - { chargeProducer: firstQuery } - ) - firstQuery = false - if (result.rows.length > input.take + 1) { - throw new CatalogProducerContractError('key_query_exceeded_take') + for (const row of result.rows as Record[]) { + if (row.source_saturated !== false) { + throw new CatalogProducerContractError('source_saturation_invalid') } - sourceSaturated = false - for (const row of result.rows as Record[]) { - if (typeof row.source_saturated !== 'boolean') { - throw new CatalogProducerContractError('source_saturation_invalid') - } - sourceSaturated ||= row.source_saturated - const logicalId = typeof row.logical_id === 'string' ? row.logical_id : '' - if (!logicalId || logicalId <= prior) { - throw new CatalogProducerContractError('keys_not_strictly_ordered') - } - const key = catalogKey(context.environmentId, input.family, logicalId) - if (candidates.length > 0 && compareCatalogKey(candidates.at(-1)!.key, key) >= 0) { - throw new CatalogProducerContractError('keys_not_strictly_ordered') - } - let validUntil: string | null = null - if (row.valid_until !== null && row.valid_until !== undefined) { - const timestamp = new Date(String(row.valid_until)) - if (Number.isNaN(timestamp.getTime())) { - throw new CatalogProducerContractError('candidate_valid_until_invalid') - } - validUntil = timestamp.toISOString() - } - candidates.push( - Object.freeze({ key, canonicalId: `${input.family}:${logicalId}`, validUntil }) - ) - prior = logicalId - if (candidates.length >= input.take + 1) break + const logicalId = typeof row.logical_id === 'string' ? row.logical_id : '' + if (!logicalId || logicalId <= prior) { + throw new CatalogProducerContractError('keys_not_strictly_ordered') } - if (sourceSaturated && result.rows.length === 0) { - throw new CatalogProducerContractError('source_saturation_without_progress') + const key = catalogKey(context.environmentId, input.family, logicalId) + if (candidates.length > 0 && compareCatalogKey(candidates.at(-1)!.key, key) >= 0) { + throw new CatalogProducerContractError('keys_not_strictly_ordered') + } + let validUntil: string | null = null + if (row.valid_until !== null && row.valid_until !== undefined) { + const timestamp = new Date(String(row.valid_until)) + if (Number.isNaN(timestamp.getTime())) { + throw new CatalogProducerContractError('candidate_valid_until_invalid') + } + validUntil = timestamp.toISOString() } + candidates.push(Object.freeze({ key, canonicalId: `${input.family}:${logicalId}`, validUntil })) + prior = logicalId } - const hasMore = candidates.length > input.take || sourceSaturated + const hasMore = candidates.length > input.take return Object.freeze({ candidates: Object.freeze(candidates), continuation: Object.freeze({ diff --git a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts index db9bc92448..8271a7773a 100644 --- a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts +++ b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts @@ -297,11 +297,17 @@ describeRealPostgres('distinct catalog key pagination on real PostgreSQL', () => it('does not let duplicate team paths hide later canonical host IDs', async () => { const budget = AccessExecutionBudget.create('catalog') + let hostKeyStatements = 0 const sourceStates: CatalogOperationalSourceState[] = OPERATIONAL_SOURCE_FAMILIES.map( family => ({ family, generation: '1', resourceVersion: '1', status: 'current' }) ) const context: CatalogRequestContext = { - db: databasePool, + db: { + query: (text, values) => { + if (text === CATALOG_KEY_SQL.host) hostKeyStatements += 1 + return databasePool.query(text, values) + }, + }, budget, principal: { userId, @@ -328,6 +334,7 @@ describeRealPostgres('distinct catalog key pagination on real PostgreSQL', () => ]) expect(page.hasMore).toBe(true) expect(page.continuation.exhausted).toBe(false) + expect(hostKeyStatements).toBe(1) } finally { budget.close() } diff --git a/control-api/test/catalogProducers.test.ts b/control-api/test/catalogProducers.test.ts index d6fbb7c7d6..5d58ad70ee 100644 --- a/control-api/test/catalogProducers.test.ts +++ b/control-api/test/catalogProducers.test.ts @@ -65,11 +65,26 @@ describe('catalog producer registry', () => { shared_filesystem: 2, sandbox_app: 2, } + const expectedDistinctArmCounts: Record<(typeof CATALOG_FAMILIES)[number], number> = { + user: 0, + team: 0, + host: 1, + context: 1, + mcp_server: 4, + workflow_recipe: 1, + workflow_run: 0, + workflow_approval: 0, + notification: 0, + gfs_resource: 0, + shared_filesystem: 2, + sandbox_app: 0, + } for (const family of CATALOG_FAMILIES) { const sql = CATALOG_KEY_SQL[family] expect(sql).not.toMatch(/\bOFFSET\b/i) expect(sql.match(/AS MATERIALIZED/g)).toHaveLength(expectedArmCounts[family]) expect(sql.match(/LIMIT \$4/g)).toHaveLength(expectedArmCounts[family] + 1) + expect(sql.match(/SELECT DISTINCT ON/g) ?? []).toHaveLength(expectedDistinctArmCounts[family]) } }) }) From 7ba1f7c4bff264eb8ced828e74ae44f686ccb94e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 11 Aug 2026 19:24:09 -0600 Subject: [PATCH 054/528] fix(access): r1-h6 prove admitted Team-GFS high-cardinality plans Class-check: 2 Team-GFS plan sites -> add typical, high, sparse, dense, and duplicate proof. Repro-test: mutation-resistance proof applies because product behavior already passed at 25a3ad5c. F3-generated-artifact: mandatory Control API version metadata only; no semantic package change. --- ...rformance.realPostgres.integration.test.ts | 233 +++++++++++++++++- 1 file changed, 232 insertions(+), 1 deletion(-) diff --git a/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts b/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts index dea09b545e..bec2096998 100644 --- a/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts @@ -11,6 +11,11 @@ import { OPERATIONAL_SOURCE_FAMILIES, canonicalEnvironmentId, } from '../src/services/access/operationalAccessProjection.js' +import { + type TeamGfsStreamRequest, + collectTeamGfsTopK, + teamGfsTopKSql, +} from '../src/services/access/teamGfsTopK.js' import type { ExternalSessionAuthorityContext } from '../src/services/auth/externalSessionAuthentication.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL @@ -37,7 +42,7 @@ const armCounts: Readonly> = Object.freeze({ workflow_run: 3, workflow_approval: 2, notification: 2, - gfs_resource: 4, + gfs_resource: 2, shared_filesystem: 2, sandbox_app: 2, }) @@ -529,6 +534,214 @@ describeRealPostgres('aggregate catalog plans on real PostgreSQL', () => { ) } + async function seedDuplicateTeamGfs(teamIds: readonly string[]): Promise { + if (teamIds.length === 0) return + await databasePool.query( + `INSERT INTO gfs_resources(resource_id, drive, name, kind) + VALUES + ('00000000-0000-4000-8000-000000000001', 'team-plan-duplicate-1', '/', 'directory'), + ('00000000-0000-4000-8000-000000000002', 'team-plan-duplicate-2', '/', 'directory') + ON CONFLICT (resource_id) DO NOTHING` + ) + await databasePool.query( + `WITH requested AS (SELECT UNNEST($1::uuid[])::text AS subject_id), + resources AS ( + SELECT resource_id, drive + FROM gfs_resources + WHERE resource_id IN ( + '00000000-0000-4000-8000-000000000001', + '00000000-0000-4000-8000-000000000002' + ) + ) + INSERT INTO gfs_grants(drive, resource_id, subject_type, subject_id, permissions) + SELECT resource.drive, resource.resource_id, 'team', requested.subject_id, + ARRAY['read']::text[] + FROM requested CROSS JOIN resources resource + ON CONFLICT (drive, resource_id, subject_type, subject_id) DO NOTHING`, + [teamIds] + ) + await databasePool.query( + `WITH requested AS (SELECT UNNEST($1::uuid[])::text AS subject_id), + resources AS ( + SELECT resource_id, drive + FROM gfs_resources + WHERE resource_id IN ( + '00000000-0000-4000-8000-000000000001', + '00000000-0000-4000-8000-000000000002' + ) + ) + INSERT INTO gfs_shares(drive, resource_id, subject_type, subject_id, permissions) + SELECT resource.drive, resource.resource_id, 'team', requested.subject_id, + ARRAY['read']::text[] + FROM requested CROSS JOIN resources resource + ON CONFLICT (drive, resource_id, subject_type, subject_id) DO NOTHING`, + [teamIds] + ) + } + + function teamGfsRequests(teamIds: readonly string[], take: number): TeamGfsStreamRequest[] { + return teamIds.flatMap(subjectId => + (['grant', 'share'] as const).map(kind => ({ + kind, + subjectId, + afterId: '00000000-0000-0000-0000-000000000000', + take, + })) + ) + } + + async function explainTeamGfs( + profile: string, + requests: readonly TeamGfsStreamRequest[] + ): Promise { + const result = await databasePool.query( + `EXPLAIN (ANALYZE, BUFFERS, WAL, FORMAT JSON) ${teamGfsTopKSql.streamHead}`, + [ + JSON.stringify( + requests.map(request => ({ + kind: request.kind, + subject_id: request.subjectId, + after_id: request.afterId, + take: request.take, + })) + ), + ] + ) + const explained = queryPlan(result.rows[0]) + const nodes = planNodes(explained.Plan) + const indexNames = new Set( + nodes.flatMap(node => (typeof node['Index Name'] === 'string' ? [node['Index Name']] : [])) + ) + if (profile.startsWith('high-')) { + expect(indexNames, `${profile}:grant-index`).toContain( + 'gfs_grants_subject_resource_catalog_idx' + ) + expect(indexNames, `${profile}:share-index`).toContain( + 'gfs_shares_subject_resource_catalog_idx' + ) + } + const perStreamTake = Math.max(...requests.map(request => request.take), 0) + const rowEnvelope = requests.length * perStreamTake + const relationWork = (relation: string) => + nodes + .filter(node => node['Relation Name'] === relation) + .reduce( + (total, node) => + total + + numberField(node, 'Actual Loops') * + (numberField(node, 'Actual Rows') + numberField(node, 'Rows Removed by Filter')), + 0 + ) + expect(relationWork('gfs_grants'), `${profile}:grant-work`).toBeLessThanOrEqual(rowEnvelope) + expect(relationWork('gfs_shares'), `${profile}:share-work`).toBeLessThanOrEqual(rowEnvelope) + if (profile.startsWith('high-')) { + expect(relationWork('gfs_resources'), `${profile}:resource-work`).toBeLessThanOrEqual( + rowEnvelope + ) + } + const sharedBlocks = + numberField(explained.Plan, 'Shared Hit Blocks') + + numberField(explained.Plan, 'Shared Read Blocks') + expect(sharedBlocks, `${profile}:shared-blocks`).toBeLessThanOrEqual(rowEnvelope * 8 + 256) + for (const node of nodes) { + expect(String(node['Sort Method'] ?? ''), `${profile}:sort`).not.toContain('external') + expect(numberField(node, 'Temp Read Blocks'), `${profile}:temp-read`).toBe(0) + expect(numberField(node, 'Temp Written Blocks'), `${profile}:temp-write`).toBe(0) + } + console.info( + `[access-catalog-team-gfs-plan:${profile}] ${JSON.stringify({ + streams: requests.length, + perStreamTake, + rows: numberField(explained.Plan, 'Actual Rows'), + grantWork: relationWork('gfs_grants'), + shareWork: relationWork('gfs_shares'), + resourceWork: relationWork('gfs_resources'), + sharedBlocks, + tempReadBlocks: nodes.reduce( + (total, node) => total + numberField(node, 'Temp Read Blocks'), + 0 + ), + tempWrittenBlocks: nodes.reduce( + (total, node) => total + numberField(node, 'Temp Written Blocks'), + 0 + ), + planningMs: Number(explained['Planning Time'] ?? 0), + executionMs: Number(explained['Execution Time'] ?? 0), + indexes: [...indexNames].sort(), + })}` + ) + } + + async function measureTeamGfsTopK( + profile: string, + teamIds: readonly string[], + take: number + ): Promise { + let statementCount = 0 + const merged = await collectTeamGfsTopK({ + streams: teamGfsRequests(teamIds, 1).map(({ kind, subjectId, afterId }) => ({ + kind, + subjectId, + afterId, + })), + take, + read: async requests => { + statementCount += 1 + const result = await databasePool.query(teamGfsTopKSql.streamHead, [ + JSON.stringify( + requests.map(request => ({ + kind: request.kind, + subject_id: request.subjectId, + after_id: request.afterId, + take: request.take, + })) + ), + ]) + const lastByStream = new Map() + for (const row of result.rows as Array>) { + lastByStream.set(`${row.kind}:${row.subject_id}`, row.logical_id) + } + return (result.rows as Array>).map(row => ({ + kind: row.kind as 'grant' | 'share', + subjectId: row.subject_id, + logicalId: row.logical_id, + batchLast: lastByStream.get(`${row.kind}:${row.subject_id}`) === row.logical_id, + })) + }, + }) + const expected = await databasePool.query<{ logical_id: string }>( + `SELECT resource_id::text AS logical_id + FROM ( + SELECT grant_row.resource_id + FROM gfs_grants grant_row + WHERE grant_row.subject_type = 'team' + AND grant_row.subject_id = ANY($1::text[]) + UNION + SELECT share.resource_id + FROM gfs_shares share + WHERE share.subject_type = 'team' + AND share.subject_id = ANY($1::text[]) + ) authorized + JOIN gfs_resources resource USING (resource_id) + WHERE resource.deleted_at IS NULL + ORDER BY resource_id + LIMIT $2`, + [teamIds, take + 1] + ) + expect(merged.logicalIds, `${profile}:composition`).toEqual( + expected.rows.map(row => row.logical_id) + ) + expect(statementCount, `${profile}:statement-envelope`).toBeLessThanOrEqual(take + 2) + console.info( + `[access-catalog-team-gfs-merge:${profile}] ${JSON.stringify({ + memberships: teamIds.length, + take, + statements: statementCount, + candidates: merged.logicalIds.length, + })}` + ) + } + async function analyzeAndExplain(profile: string): Promise { await databasePool.query(`ANALYZE`) const metrics: Array> = [] @@ -600,11 +813,29 @@ describeRealPostgres('aggregate catalog plans on real PostgreSQL', () => { await analyzeAndExplain('direct-only') await seedTeams(allTeamIds.slice(0, TYPICAL_TEAMS), TYPICAL_RESOURCES_PER_TEAM) + await seedDuplicateTeamGfs(allTeamIds.slice(0, TYPICAL_TEAMS)) await analyzeAndExplain('typical-multi-team') + await explainTeamGfs( + 'typical-multi-team', + teamGfsRequests(allTeamIds.slice(0, TYPICAL_TEAMS), 8) + ) + await measureTeamGfsTopK('typical-multi-team', allTeamIds.slice(0, TYPICAL_TEAMS), 2) await seedDirect(DIRECT_ONLY_RESOURCES + 1, HIGH_DIRECT_RESOURCES) await seedTeams(allTeamIds.slice(TYPICAL_TEAMS), HIGH_RESOURCES_PER_TEAM) + await seedDuplicateTeamGfs(allTeamIds) await analyzeAndExplain('high-cardinality') + await explainTeamGfs('high-dense-and-duplicate', teamGfsRequests(allTeamIds, 8)) + await explainTeamGfs('high-duplicate-saturated', teamGfsRequests(allTeamIds, 2)) + await explainTeamGfs( + 'high-sparse', + teamGfsRequests( + [allTeamIds[0]!, ...Array.from({ length: HIGH_TEAMS - 1 }, () => randomUUID())], + 2 + ) + ) + await measureTeamGfsTopK('high-duplicate-saturated', allTeamIds, 2) + await measureTeamGfsTopK('high-maximum-page', allTeamIds, 100) }, 30_000) it('bounds coordinator query count and composes keyset pages', async () => { From cf96fd63b97f170eb4354a10e9a300c846c79b21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Wed, 12 Aug 2026 09:21:26 -0600 Subject: [PATCH 055/528] fix(access): r1-b10 account physical catalog statements Class-check: runAccessDatabaseQuery -> 1 shared access/catalog boundary -> charge every SQL statement. Repro-test: access statement-budget test fails at 3ff5ab3b because statement N+1 executes. F3-generated-artifact: mandatory Control API version metadata only; no semantic package change. --- .../services/access/accessDatabaseQuery.ts | 1 + .../services/access/accessExecutionBudget.ts | 3 ++ ...cellation.realPostgres.integration.test.ts | 43 ++++++++++++++++++- .../test/accessExecutionBudget.test.ts | 7 +++ 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/control-api/src/services/access/accessDatabaseQuery.ts b/control-api/src/services/access/accessDatabaseQuery.ts index 374a521bd9..768ab99046 100644 --- a/control-api/src/services/access/accessDatabaseQuery.ts +++ b/control-api/src/services/access/accessDatabaseQuery.ts @@ -75,6 +75,7 @@ export async function runAccessDatabaseQuery( : budget.runProducer.bind(budget) return run(async signal => { budget.assertActive() + budget.charge({ kind: 'databaseStatements' }) let cancellation: Promise | undefined const onAbort = () => { cancellation ??= cancelPostgresBackend(db as PostgresCancellationTarget).catch( diff --git a/control-api/src/services/access/accessExecutionBudget.ts b/control-api/src/services/access/accessExecutionBudget.ts index b0d33cb170..172fe4f3dd 100644 --- a/control-api/src/services/access/accessExecutionBudget.ts +++ b/control-api/src/services/access/accessExecutionBudget.ts @@ -4,6 +4,7 @@ export const ACCESS_EXECUTION_LIMIT_CLAMPS = Object.freeze({ actionDeadlineMs: 2_000, statementTimeoutMs: 2_500, producerCalls: 32, + databaseStatements: 128, producerConcurrency: 4, keyCandidatesPerCall: 101, objects: 1_000, @@ -28,6 +29,7 @@ export type AccessExecutionLimits = Readonly { + it('charges before execution and rejects the statement after the configured limit', async () => { + const query = vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }) + const budget = AccessExecutionBudget.create('catalog', { + limits: { databaseStatements: 2 }, + }) + try { + await catalogQuery({ query }, budget, 'SELECT 1', []) + await catalogQuery({ query }, budget, 'SELECT 2', []) + await expect(catalogQuery({ query }, budget, 'SELECT 3', [])).rejects.toMatchObject({ + name: 'AccessBudgetExceededError', + limit: 'databaseStatements', + authorityRequired: true, + }) + expect(query).toHaveBeenCalledTimes(2) + expect(budget.remaining('databaseStatements')).toBe(0) + } finally { + budget.close() + } + }) + + it('accounts bounded internal statements without consuming additional logical producer calls', async () => { + const query = vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }) + const budget = AccessExecutionBudget.create('catalog', { + limits: { producerCalls: 1, databaseStatements: 2 }, + }) + try { + await catalogQuery({ query }, budget, 'SELECT 1', []) + await catalogQuery({ query }, budget, 'SELECT 2', [], { chargeProducer: false }) + expect(budget.remaining('producerCalls')).toBe(0) + expect(budget.remaining('databaseStatements')).toBe(0) + await expect( + catalogQuery({ query }, budget, 'SELECT 3', [], { chargeProducer: false }) + ).rejects.toMatchObject({ limit: 'databaseStatements' }) + expect(query).toHaveBeenCalledTimes(2) + } finally { + budget.close() + } + }) +}) + const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip diff --git a/control-api/test/accessExecutionBudget.test.ts b/control-api/test/accessExecutionBudget.test.ts index d03abcf027..e606bbb580 100644 --- a/control-api/test/accessExecutionBudget.test.ts +++ b/control-api/test/accessExecutionBudget.test.ts @@ -11,12 +11,16 @@ describe('AccessExecutionBudget', () => { it('applies the accepted local clamps and rejects attempts to raise them', () => { expect(resolveAccessExecutionLimits().publicPageSize).toBe(100) expect(resolveAccessExecutionLimits({ producerCalls: 2 }).producerCalls).toBe(2) + expect(resolveAccessExecutionLimits({ databaseStatements: 2 }).databaseStatements).toBe(2) expect(() => resolveAccessExecutionLimits({ producerCalls: 33 })).toThrow( AccessBudgetConfigurationError ) expect(() => resolveAccessExecutionLimits({ producerCalls: 0 })).toThrow( AccessBudgetConfigurationError ) + expect(() => resolveAccessExecutionLimits({ databaseStatements: 129 })).toThrow( + AccessBudgetConfigurationError + ) }) it('rejects invalid public limits and cursor sizes before producer work', () => { @@ -24,6 +28,7 @@ describe('AccessExecutionBudget', () => { expect(() => budget.assertPageSize(101)).toThrow(AccessBudgetConfigurationError) expect(() => budget.assertCursorBytes(16 * 1024 + 1)).toThrow(AccessBudgetConfigurationError) expect(budget.remaining('producerCalls')).toBe(32) + expect(budget.remaining('databaseStatements')).toBe(128) budget.close() }) @@ -36,6 +41,7 @@ describe('AccessExecutionBudget', () => { accessPaths: 1, relationships: 1, dbRowsReturned: 1, + databaseStatements: 1, memoEntries: 1, memoBytes: 8, responseBytes: 8, @@ -45,6 +51,7 @@ describe('AccessExecutionBudget', () => { budget.charge({ kind: 'accessPaths' }) budget.charge({ kind: 'relationships' }) budget.charge({ kind: 'dbRowsReturned' }) + budget.charge({ kind: 'databaseStatements' }) budget.charge({ kind: 'memoEntries' }) budget.charge({ kind: 'memoBytes', amount: 8 }) budget.charge({ kind: 'responseBytes', amount: 8 }) From e747fba2c05df40650129bec7200410901e21d1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Wed, 12 Aug 2026 09:28:20 -0600 Subject: [PATCH 056/528] fix(access): r1-b8 bound duplicate producer refill Class-check: catalog key arms -> 9 non-GFS duplicate-capable arms -> bounded refill. Repro-test: catalogDistinctPagination real PostgreSQL suite fails before this correction. F3-generated-artifact: mandatory Control API version metadata only; no semantic package change. --- .../src/services/access/catalogProducerSql.ts | 22 ++-- .../services/access/catalogProducerSupport.ts | 107 +++++++++++------- ...agination.realPostgres.integration.test.ts | 47 +++++++- control-api/test/catalogProducers.test.ts | 18 +-- 4 files changed, 123 insertions(+), 71 deletions(-) diff --git a/control-api/src/services/access/catalogProducerSql.ts b/control-api/src/services/access/catalogProducerSql.ts index 665dece1e9..6175e40b34 100644 --- a/control-api/src/services/access/catalogProducerSql.ts +++ b/control-api/src/services/access/catalogProducerSql.ts @@ -41,7 +41,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND resource.enabled = TRUE AND resource.deleted_at IS NULL WHERE tm.user_id = $1 AND tm.status = 'active' AND ta.agent_name > $7`, orderBy: 'source_key', - distinctCanonicalBy: 'source_key', + duplicateCapable: true, }, ]), context: boundedKeyUnionSql([ @@ -52,7 +52,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f ON resource.environment_id = $3 AND resource.resource_type = 'context' AND resource.logical_id = $6::text || '/' || uc.context_id AND resource.enabled = TRUE AND resource.deleted_at IS NULL - WHERE uc.user_id = $1 AND uc.context_id > $7`, + WHERE uc.user_id = $1 AND uc.context_id > $7`, orderBy: 'source_key', }, { @@ -65,7 +65,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND resource.enabled = TRUE AND resource.deleted_at IS NULL WHERE tm.user_id = $1 AND tm.status = 'active' AND tc.context_id > $7`, orderBy: 'source_key', - distinctCanonicalBy: 'source_key', + duplicateCapable: true, }, ]), mcp_server: boundedKeyUnionSql([ @@ -87,7 +87,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND target_resource.logical_id = edge.target_id AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL WHERE uc.user_id = $1 AND edge.target_id > $2`, - distinctCanonicalBy: 'logical_id', + duplicateCapable: true, }, { sql: `SELECT edge.target_id AS logical_id @@ -108,7 +108,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND target_resource.logical_id = edge.target_id AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL WHERE tm.user_id = $1 AND tm.status = 'active' AND edge.target_id > $2`, - distinctCanonicalBy: 'logical_id', + duplicateCapable: true, }, { sql: `SELECT mcp_edge.target_id AS logical_id @@ -138,7 +138,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND target_resource.logical_id = mcp_edge.target_id AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL WHERE ua.user_id = $1 AND mcp_edge.target_id > $2`, - distinctCanonicalBy: 'logical_id', + duplicateCapable: true, }, { sql: `SELECT mcp_edge.target_id AS logical_id @@ -169,7 +169,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND target_resource.logical_id = mcp_edge.target_id AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL WHERE tm.user_id = $1 AND tm.status = 'active' AND mcp_edge.target_id > $2`, - distinctCanonicalBy: 'logical_id', + duplicateCapable: true, }, ]), workflow_recipe: boundedKeyUnionSql([ @@ -180,7 +180,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f ON resource.environment_id = $3 AND resource.resource_type = 'workflow_recipe' AND resource.logical_id = uwt.recipe_namespace || '/' || uwt.recipe_name AND resource.enabled = TRUE AND resource.deleted_at IS NULL - WHERE uwt.user_id = $1 + WHERE uwt.user_id = $1 AND (uwt.recipe_namespace || '/' || uwt.recipe_name) > $2`, orderBy: 'logical_id', }, @@ -195,7 +195,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f WHERE tm.user_id = $1 AND tm.status = 'active' AND (twt.recipe_namespace || '/' || twt.recipe_name) > $2`, orderBy: 'logical_id', - distinctCanonicalBy: 'logical_id', + duplicateCapable: true, }, ]), workflow_run: boundedKeyUnionSql([ @@ -337,7 +337,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND target_resource.logical_id = edge.target_id AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL WHERE uc.user_id = $1 AND edge.target_id > $2`, - distinctCanonicalBy: 'logical_id', + duplicateCapable: true, }, { sql: `SELECT edge.target_id AS logical_id @@ -359,7 +359,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND target_resource.logical_id = edge.target_id AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL WHERE tm.user_id = $1 AND tm.status = 'active' AND edge.target_id > $2`, - distinctCanonicalBy: 'logical_id', + duplicateCapable: true, }, ]), sandbox_app: boundedKeyUnionSql([ diff --git a/control-api/src/services/access/catalogProducerSupport.ts b/control-api/src/services/access/catalogProducerSupport.ts index 0a6a6ce42e..edd0e3686d 100644 --- a/control-api/src/services/access/catalogProducerSupport.ts +++ b/control-api/src/services/access/catalogProducerSupport.ts @@ -35,7 +35,7 @@ export async function catalogQuery( export type BoundedKeyArm = Readonly<{ sql: string orderBy?: string - distinctCanonicalBy?: string + duplicateCapable?: boolean hasValidUntil?: boolean }> @@ -45,19 +45,11 @@ export function boundedKeyUnionSql(arms: readonly (string | BoundedKeyArm)[]): s const sources = arms .map((arm, index) => { const definition = typeof arm === 'string' ? { sql: arm } : arm - if (definition.distinctCanonicalBy && definition.hasValidUntil) { - throw new CatalogProducerContractError('distinct_valid_until_requires_aggregation') - } - const boundedSql = definition.distinctCanonicalBy - ? `SELECT DISTINCT ON (${definition.distinctCanonicalBy}) distinct_arm.* - FROM (${definition.sql}) distinct_arm - ORDER BY ${definition.distinctCanonicalBy}` - : definition.sql return `${names[index]} AS MATERIALIZED ( SELECT bounded_arm.logical_id, ${definition.hasValidUntil ? 'bounded_arm.valid_until' : 'NULL::timestamptz'} AS valid_until - FROM (${boundedSql}) bounded_arm + FROM (${definition.sql}) bounded_arm ORDER BY ${definition.orderBy ?? 'logical_id'} LIMIT $4 )` @@ -67,7 +59,17 @@ export function boundedKeyUnionSql(arms: readonly (string | BoundedKeyArm)[]): s .map(name => `SELECT logical_id, valid_until FROM ${name}`) .join('\nUNION ALL\n') return `WITH ${sources} - SELECT logical_id, MIN(valid_until) AS valid_until, FALSE AS source_saturated + SELECT logical_id, MIN(valid_until) AS valid_until, + ${names + .map((name, index) => { + const arm = arms[index] + const definition = typeof arm === 'string' ? undefined : arm + return definition?.duplicateCapable + ? `(SELECT COUNT(*) FROM ${name}) >= $4` + : 'FALSE' + }) + .join(' OR ')} + AS source_saturated FROM (${union}) bounded_sources WHERE $1::uuid IS NOT NULL AND $2::text IS NOT NULL AND $3::text IS NOT NULL AND $5::text IS NOT NULL AND $6::text IS NOT NULL AND $7::text IS NOT NULL @@ -177,44 +179,63 @@ export async function listBoundedProducerKeys(input: { }) } const after = validateContinuation(input.family, context.environmentId, input.continuation) - const result = await catalogQuery(context.db, context.budget, input.sql, [ - context.principal.userId, - after, - context.environmentId, - input.take + 1, - ...(typeof input.extraValues === 'function' - ? input.extraValues(after) - : (input.extraValues ?? [])), - ]) - if (result.rows.length > input.take + 1) { - throw new CatalogProducerContractError('key_query_exceeded_take') - } const candidates: CatalogIdentityCandidate[] = [] let prior = after - for (const row of result.rows as Record[]) { - if (row.source_saturated !== false) { - throw new CatalogProducerContractError('source_saturation_invalid') - } - const logicalId = typeof row.logical_id === 'string' ? row.logical_id : '' - if (!logicalId || logicalId <= prior) { - throw new CatalogProducerContractError('keys_not_strictly_ordered') + let sourceSaturated = true + let firstQuery = true + while (candidates.length < input.take + 1 && sourceSaturated) { + const result = await catalogQuery( + context.db, + context.budget, + input.sql, + [ + context.principal.userId, + prior, + context.environmentId, + input.take + 1, + ...(typeof input.extraValues === 'function' + ? input.extraValues(prior) + : (input.extraValues ?? [])), + ], + { chargeProducer: firstQuery } + ) + firstQuery = false + if (result.rows.length > input.take + 1) { + throw new CatalogProducerContractError('key_query_exceeded_take') } - const key = catalogKey(context.environmentId, input.family, logicalId) - if (candidates.length > 0 && compareCatalogKey(candidates.at(-1)!.key, key) >= 0) { - throw new CatalogProducerContractError('keys_not_strictly_ordered') - } - let validUntil: string | null = null - if (row.valid_until !== null && row.valid_until !== undefined) { - const timestamp = new Date(String(row.valid_until)) - if (Number.isNaN(timestamp.getTime())) { - throw new CatalogProducerContractError('candidate_valid_until_invalid') + sourceSaturated = false + for (const row of result.rows as Record[]) { + if (typeof row.source_saturated !== 'boolean') { + throw new CatalogProducerContractError('source_saturation_invalid') + } + sourceSaturated ||= row.source_saturated + const logicalId = typeof row.logical_id === 'string' ? row.logical_id : '' + if (!logicalId || logicalId <= prior) { + throw new CatalogProducerContractError('keys_not_strictly_ordered') } - validUntil = timestamp.toISOString() + const key = catalogKey(context.environmentId, input.family, logicalId) + if (candidates.length > 0 && compareCatalogKey(candidates.at(-1)!.key, key) >= 0) { + throw new CatalogProducerContractError('keys_not_strictly_ordered') + } + let validUntil: string | null = null + if (row.valid_until !== null && row.valid_until !== undefined) { + const timestamp = new Date(String(row.valid_until)) + if (Number.isNaN(timestamp.getTime())) { + throw new CatalogProducerContractError('candidate_valid_until_invalid') + } + validUntil = timestamp.toISOString() + } + candidates.push( + Object.freeze({ key, canonicalId: `${input.family}:${logicalId}`, validUntil }) + ) + prior = logicalId + if (candidates.length >= input.take + 1) break + } + if (sourceSaturated && result.rows.length === 0) { + throw new CatalogProducerContractError('source_saturation_without_progress') } - candidates.push(Object.freeze({ key, canonicalId: `${input.family}:${logicalId}`, validUntil })) - prior = logicalId } - const hasMore = candidates.length > input.take + const hasMore = candidates.length > input.take || sourceSaturated return Object.freeze({ candidates: Object.freeze(candidates), continuation: Object.freeze({ diff --git a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts index 8271a7773a..fac21d30cb 100644 --- a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts +++ b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts @@ -295,7 +295,7 @@ describeRealPostgres('distinct catalog key pagination on real PostgreSQL', () => } }) - it('does not let duplicate team paths hide later canonical host IDs', async () => { + it('refills bounded duplicate team host paths without hiding later canonical IDs', async () => { const budget = AccessExecutionBudget.create('catalog') let hostKeyStatements = 0 const sourceStates: CatalogOperationalSourceState[] = OPERATIONAL_SOURCE_FAMILIES.map( @@ -334,6 +334,51 @@ describeRealPostgres('distinct catalog key pagination on real PostgreSQL', () => ]) expect(page.hasMore).toBe(true) expect(page.continuation.exhausted).toBe(false) + expect(hostKeyStatements).toBe(2) + } finally { + budget.close() + } + }) + + it('fails before an unbudgeted duplicate-refill statement can execute', async () => { + const budget = AccessExecutionBudget.create('catalog', { + limits: { databaseStatements: 1 }, + }) + let hostKeyStatements = 0 + const sourceStates: CatalogOperationalSourceState[] = OPERATIONAL_SOURCE_FAMILIES.map( + family => ({ family, generation: '1', resourceVersion: '1', status: 'current' }) + ) + const context: CatalogRequestContext = { + db: { + query: (text, values) => { + if (text === CATALOG_KEY_SQL.host) hostKeyStatements += 1 + return databasePool.query(text, values) + }, + }, + budget, + principal: { + userId, + sessionContract: 'v2', + sessionRevision: '1', + userRevision: '1', + catalogRevision: '1', + authorizationRevision: '1', + memberships: teamIds.map(teamId => ({ teamId, role: 'member' })), + }, + environmentId, + sourceStates: new Map(sourceStates.map(state => [state.family, state])), + } + try { + await expect( + requireCatalogProducer('host').listCanonicalKeys( + context, + { afterKey: null, exhausted: false }, + 2 + ) + ).rejects.toMatchObject({ + limit: 'databaseStatements', + authorityRequired: true, + } satisfies Partial) expect(hostKeyStatements).toBe(1) } finally { budget.close() diff --git a/control-api/test/catalogProducers.test.ts b/control-api/test/catalogProducers.test.ts index 5d58ad70ee..0971e7a3e3 100644 --- a/control-api/test/catalogProducers.test.ts +++ b/control-api/test/catalogProducers.test.ts @@ -50,7 +50,7 @@ describe('catalog producer registry', () => { expect([...catalogProducers.keys()]).toEqual(CATALOG_FAMILIES) }) - it('keyset-bounds every source arm before its final union and never uses offsets', () => { + it('keyset-bounds every raw source arm before its final union and never uses offsets', () => { const expectedArmCounts: Record<(typeof CATALOG_FAMILIES)[number], number> = { user: 1, team: 1, @@ -65,26 +65,12 @@ describe('catalog producer registry', () => { shared_filesystem: 2, sandbox_app: 2, } - const expectedDistinctArmCounts: Record<(typeof CATALOG_FAMILIES)[number], number> = { - user: 0, - team: 0, - host: 1, - context: 1, - mcp_server: 4, - workflow_recipe: 1, - workflow_run: 0, - workflow_approval: 0, - notification: 0, - gfs_resource: 0, - shared_filesystem: 2, - sandbox_app: 0, - } for (const family of CATALOG_FAMILIES) { const sql = CATALOG_KEY_SQL[family] expect(sql).not.toMatch(/\bOFFSET\b/i) expect(sql.match(/AS MATERIALIZED/g)).toHaveLength(expectedArmCounts[family]) expect(sql.match(/LIMIT \$4/g)).toHaveLength(expectedArmCounts[family] + 1) - expect(sql.match(/SELECT DISTINCT ON/g) ?? []).toHaveLength(expectedDistinctArmCounts[family]) + expect(sql).not.toMatch(/SELECT DISTINCT ON/i) } }) }) From fafffd2b52e75d6896bdf3422523018f293382db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Wed, 12 Aug 2026 09:30:05 -0600 Subject: [PATCH 057/528] fix(access): r1-b9 cover sandbox duplicate relationships Class-check: catalog key arms -> 11 non-GFS duplicate-capable arms -> bounded refill. Repro-test: sandbox duplicate catalog coverage fails before this correction. F3-generated-artifact: mandatory Control API version metadata only; no semantic package change. --- .../services/access/catalogHydrationSql.ts | 40 +++++++++++- .../src/services/access/catalogProducerSql.ts | 10 ++- ...agination.realPostgres.integration.test.ts | 65 +++++++++++++++++++ 3 files changed, 111 insertions(+), 4 deletions(-) diff --git a/control-api/src/services/access/catalogHydrationSql.ts b/control-api/src/services/access/catalogHydrationSql.ts index 91353bcd3b..4a8c35448d 100644 --- a/control-api/src/services/access/catalogHydrationSql.ts +++ b/control-api/src/services/access/catalogHydrationSql.ts @@ -108,7 +108,7 @@ export const SIMPLE_OPERATIONAL_HYDRATION_SQL = ` NULL, NULL FROM resources resource JOIN user_workflow_triggers access_grant - ON $4::text IN ('workflow_recipe', 'sandbox_app') + ON $4::text = 'workflow_recipe' AND resource.logical_id = access_grant.recipe_namespace || '/' || access_grant.recipe_name WHERE access_grant.user_id = $1 UNION ALL @@ -118,8 +118,44 @@ export const SIMPLE_OPERATIONAL_HYDRATION_SQL = ` access_grant.team_id, membership.role FROM resources resource JOIN team_workflow_triggers access_grant - ON $4::text IN ('workflow_recipe', 'sandbox_app') + ON $4::text = 'workflow_recipe' AND resource.logical_id = access_grant.recipe_namespace || '/' || access_grant.recipe_name + JOIN team_members membership + ON membership.team_id = access_grant.team_id AND membership.user_id = $1 + AND membership.status = 'active' + UNION ALL + SELECT resource.logical_id, 'direct', + 'sandbox_app:' || exposure.relationship_instance_id || ':' || + access_grant.user_id || ':' || access_grant.recipe_namespace || '/' || + access_grant.recipe_name, + NULL, NULL + FROM resources resource + JOIN operational_resource_relationships exposure + ON $4::text = 'sandbox_app' + AND exposure.environment_id = $3 + AND exposure.target_type = 'sandbox_app' + AND exposure.target_id = resource.logical_id + AND exposure.source_type = 'workflow_recipe' + AND exposure.relationship_type = 'exposes_sandbox_app' + JOIN user_workflow_triggers access_grant + ON exposure.source_id = access_grant.recipe_namespace || '/' || access_grant.recipe_name + WHERE access_grant.user_id = $1 + UNION ALL + SELECT resource.logical_id, 'team', + 'sandbox_app:' || exposure.relationship_instance_id || ':' || + access_grant.team_id || ':' || access_grant.recipe_namespace || '/' || + access_grant.recipe_name, + access_grant.team_id, membership.role + FROM resources resource + JOIN operational_resource_relationships exposure + ON $4::text = 'sandbox_app' + AND exposure.environment_id = $3 + AND exposure.target_type = 'sandbox_app' + AND exposure.target_id = resource.logical_id + AND exposure.source_type = 'workflow_recipe' + AND exposure.relationship_type = 'exposes_sandbox_app' + JOIN team_workflow_triggers access_grant + ON exposure.source_id = access_grant.recipe_namespace || '/' || access_grant.recipe_name JOIN team_members membership ON membership.team_id = access_grant.team_id AND membership.user_id = $1 AND membership.status = 'active' diff --git a/control-api/src/services/access/catalogProducerSql.ts b/control-api/src/services/access/catalogProducerSql.ts index 6175e40b34..36ba375520 100644 --- a/control-api/src/services/access/catalogProducerSql.ts +++ b/control-api/src/services/access/catalogProducerSql.ts @@ -363,7 +363,8 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f }, ]), sandbox_app: boundedKeyUnionSql([ - `SELECT edge.target_id AS logical_id + { + sql: `SELECT edge.target_id AS logical_id FROM operational_resource_relationships edge JOIN operational_resource_index source_resource ON source_resource.environment_id = $3 @@ -385,7 +386,10 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f WHERE uwt.user_id = $1 AND uwt.recipe_namespace || '/' || uwt.recipe_name = edge.source_id )`, - `SELECT edge.target_id AS logical_id + duplicateCapable: true, + }, + { + sql: `SELECT edge.target_id AS logical_id FROM operational_resource_relationships edge JOIN operational_resource_index source_resource ON source_resource.environment_id = $3 @@ -408,5 +412,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f WHERE tm.user_id = $1 AND tm.status = 'active' AND twt.recipe_namespace || '/' || twt.recipe_name = edge.source_id )`, + duplicateCapable: true, + }, ]), }) diff --git a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts index fac21d30cb..575031fd00 100644 --- a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts +++ b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts @@ -161,6 +161,7 @@ describeRealPostgres('distinct catalog key pagination on real PostgreSQL', () => | 'workflow_recipe' | 'gfs_resource' | 'shared_filesystem' + | 'sandbox_app' userId: string expected: readonly string[] minimumPathsForFirst?: number @@ -645,6 +646,70 @@ describeRealPostgres('distinct catalog key pagination on real PostgreSQL', () => minimumPathsForFirst: 3, }) } + + for (const mode of ['direct', 'team'] as const) { + const actor = await principal(`sandbox-${mode}`, mode === 'team' ? 3 : 0) + const recipes = ['a', 'b', 'c'].map( + name => `${config.sandboxNamespace}/sandbox-${mode}-${name}` + ) + const apps = ['a', 'b', 'c'].map( + name => `${config.sandboxNamespace}/sandbox-app-${mode}-${name}` + ) + for (const recipe of recipes) + await operationalResource('workflow_recipe', recipe, 'workflow_recipe') + for (const app of apps) await operationalResource('sandbox_app', app, 'workflow_recipe') + for (let copy = 0; copy < 3; copy += 1) { + await relationship({ + sourceType: 'workflow_recipe', + sourceId: recipes[0], + relationshipType: 'exposes_sandbox_app', + targetType: 'sandbox_app', + targetId: apps[0], + instanceId: `sandbox-${mode}-a-${copy}`, + }) + } + for (const [index, app] of apps.slice(1).entries()) { + await relationship({ + sourceType: 'workflow_recipe', + sourceId: recipes[index + 1], + relationshipType: 'exposes_sandbox_app', + targetType: 'sandbox_app', + targetId: app, + instanceId: `sandbox-${mode}-${index + 1}`, + }) + } + if (mode === 'direct') { + for (const recipe of recipes) { + const [recipeNamespace, recipeName] = recipe.split('/') + await databasePool.query( + `INSERT INTO user_workflow_triggers(user_id, recipe_namespace, recipe_name) VALUES ($1, $2, $3)`, + [actor.userId, recipeNamespace, recipeName] + ) + } + } else { + for (const teamId of actor.teamIds) { + const [recipeNamespace, recipeName] = recipes[0].split('/') + await databasePool.query( + `INSERT INTO team_workflow_triggers(team_id, recipe_namespace, recipe_name) VALUES ($1, $2, $3)`, + [teamId, recipeNamespace, recipeName] + ) + } + for (const recipe of recipes.slice(1)) { + const [recipeNamespace, recipeName] = recipe.split('/') + await databasePool.query( + `INSERT INTO team_workflow_triggers(team_id, recipe_namespace, recipe_name) VALUES ($1, $2, $3)`, + [actor.teamIds[0], recipeNamespace, recipeName] + ) + } + } + await consumeFamily({ + family: 'sandbox_app', + userId: actor.userId, + expected: apps, + minimumPathsForFirst: 3, + minimumRelationshipsForFirst: 3, + }) + } }, 30_000) it('keeps sparse GFS access subject-indexed instead of scanning unrelated resources', async () => { From db3f2a4be43aa58802d45635fbe1f491ca432acd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Wed, 12 Aug 2026 10:35:42 -0600 Subject: [PATCH 058/528] fix(access): r1-b11 isolate producer-arm refill state Class-check: boundedKeyUnionSql callers -> 12 families / 27 source arms -> per-arm state. Repro-test: CONTROL_API_REAL_PG_ADMIN_URL= npm test -- --run test/catalogDistinctPagination.realPostgres.integration.test.ts -t 'refills bounded duplicate team host' (fails at aa14d79f with [a,x,y], passes after this change with [a,b,c]). F3-generated-artifact: mandatory Control API version metadata only; no semantic package change. --- .../services/access/catalogProducerSupport.ts | 147 +++++++++++++----- ...rformance.realPostgres.integration.test.ts | 12 +- ...agination.realPostgres.integration.test.ts | 42 ++++- control-api/test/catalogProducers.test.ts | 63 +++++++- 4 files changed, 218 insertions(+), 46 deletions(-) diff --git a/control-api/src/services/access/catalogProducerSupport.ts b/control-api/src/services/access/catalogProducerSupport.ts index edd0e3686d..3a48138b2d 100644 --- a/control-api/src/services/access/catalogProducerSupport.ts +++ b/control-api/src/services/access/catalogProducerSupport.ts @@ -45,37 +45,49 @@ export function boundedKeyUnionSql(arms: readonly (string | BoundedKeyArm)[]): s const sources = arms .map((arm, index) => { const definition = typeof arm === 'string' ? { sql: arm } : arm + const after = `arm_after_${index}` + const sourceSql = definition.sql.replaceAll('$2', `${after}.after_key`).replaceAll( + '$7', + `CASE WHEN POSITION('/' IN ${after}.after_key) > 0 + THEN SPLIT_PART(${after}.after_key, '/', 2) ELSE '' END` + ) return `${names[index]} AS MATERIALIZED ( - SELECT bounded_arm.logical_id, + SELECT '${names[index]}'::text AS source_arm, bounded_arm.logical_id, ${definition.hasValidUntil ? 'bounded_arm.valid_until' : 'NULL::timestamptz'} AS valid_until - FROM (${definition.sql}) bounded_arm + FROM (SELECT COALESCE(NULLIF($8::jsonb ->> '${names[index]}', ''), $2) AS after_key) ${after} + CROSS JOIN LATERAL (${sourceSql}) bounded_arm + WHERE $9::jsonb IS NULL OR $9::jsonb ? '${names[index]}' ORDER BY ${definition.orderBy ?? 'logical_id'} LIMIT $4 )` }) .join(',\n') const union = names - .map(name => `SELECT logical_id, valid_until FROM ${name}`) + .map( + (name, index) => `SELECT source_arm, logical_id, valid_until, + ${ + (typeof arms[index] === 'string' ? false : arms[index].duplicateCapable) + ? `(SELECT COUNT(*) FROM ${name}) >= $4` + : 'FALSE' + } AS source_saturated, + FALSE AS source_marker FROM ${name} + UNION ALL + SELECT '${name}'::text, NULL::text, NULL::timestamptz, + ${ + (typeof arms[index] === 'string' ? false : arms[index].duplicateCapable) + ? `(SELECT COUNT(*) FROM ${name}) >= $4` + : 'FALSE' + }, TRUE + WHERE $9::jsonb IS NULL OR $9::jsonb ? '${name}'` + ) .join('\nUNION ALL\n') return `WITH ${sources} - SELECT logical_id, MIN(valid_until) AS valid_until, - ${names - .map((name, index) => { - const arm = arms[index] - const definition = typeof arm === 'string' ? undefined : arm - return definition?.duplicateCapable - ? `(SELECT COUNT(*) FROM ${name}) >= $4` - : 'FALSE' - }) - .join(' OR ')} - AS source_saturated + SELECT source_arm, logical_id, valid_until, source_saturated, source_marker FROM (${union}) bounded_sources WHERE $1::uuid IS NOT NULL AND $2::text IS NOT NULL AND $3::text IS NOT NULL AND $5::text IS NOT NULL AND $6::text IS NOT NULL AND $7::text IS NOT NULL - GROUP BY logical_id - ORDER BY logical_id - LIMIT $4` + ORDER BY source_arm, source_marker, logical_id` } function validateContinuation( @@ -179,42 +191,46 @@ export async function listBoundedProducerKeys(input: { }) } const after = validateContinuation(input.family, context.environmentId, input.continuation) + type ArmCandidate = Readonly<{ logicalId: string; validUntil: string | null }> + type ArmState = { after: string; exhausted: boolean; candidates: ArmCandidate[] } + const arms = new Map() const candidates: CatalogIdentityCandidate[] = [] - let prior = after - let sourceSaturated = true let firstQuery = true - while (candidates.length < input.take + 1 && sourceSaturated) { + let loaded = false + const refill = async (armNames: readonly string[] | null) => { const result = await catalogQuery( context.db, context.budget, input.sql, [ context.principal.userId, - prior, + after, context.environmentId, input.take + 1, ...(typeof input.extraValues === 'function' - ? input.extraValues(prior) + ? input.extraValues(after) : (input.extraValues ?? [])), + JSON.stringify(Object.fromEntries([...arms].map(([name, arm]) => [name, arm.after]))), + armNames === null + ? null + : JSON.stringify(Object.fromEntries(armNames.map(name => [name, true]))), ], { chargeProducer: firstQuery } ) firstQuery = false - if (result.rows.length > input.take + 1) { - throw new CatalogProducerContractError('key_query_exceeded_take') - } - sourceSaturated = false + const rawByArm = new Map() + const saturation = new Map() for (const row of result.rows as Record[]) { + const armName = typeof row.source_arm === 'string' ? row.source_arm : '' + if (!armName) throw new CatalogProducerContractError('source_arm_invalid') if (typeof row.source_saturated !== 'boolean') { throw new CatalogProducerContractError('source_saturation_invalid') } - sourceSaturated ||= row.source_saturated + saturation.set(armName, row.source_saturated) + if (row.source_marker === true) continue const logicalId = typeof row.logical_id === 'string' ? row.logical_id : '' - if (!logicalId || logicalId <= prior) { - throw new CatalogProducerContractError('keys_not_strictly_ordered') - } - const key = catalogKey(context.environmentId, input.family, logicalId) - if (candidates.length > 0 && compareCatalogKey(candidates.at(-1)!.key, key) >= 0) { + const state = arms.get(armName) ?? { after, exhausted: false, candidates: [] } + if (!logicalId || logicalId <= state.after) { throw new CatalogProducerContractError('keys_not_strictly_ordered') } let validUntil: string | null = null @@ -225,17 +241,70 @@ export async function listBoundedProducerKeys(input: { } validUntil = timestamp.toISOString() } - candidates.push( - Object.freeze({ key, canonicalId: `${input.family}:${logicalId}`, validUntil }) + const values = rawByArm.get(armName) ?? [] + const previous = values.at(-1) + if (!previous || previous.logicalId !== logicalId) values.push({ logicalId, validUntil }) + else if ( + previous.validUntil === null || + (validUntil !== null && validUntil < previous.validUntil) + ) { + values[values.length - 1] = { logicalId, validUntil } + } + rawByArm.set(armName, values) + } + for (const [armName, saturated] of saturation) { + const state = arms.get(armName) ?? { after, exhausted: false, candidates: [] } + if (state.candidates.length === 0) state.candidates = rawByArm.get(armName) ?? [] + state.exhausted = !saturated + arms.set(armName, state) + } + loaded = true + } + await refill(null) + while (candidates.length < input.take + 1) { + const heads = [...arms.entries()] + .filter(([, state]) => state.candidates.length > 0) + .map(([name, state]) => ({ name, state, candidate: state.candidates[0] })) + if (heads.length === 0) { + const refillable = [...arms.values()].some(state => !state.exhausted) + if (!refillable) break + await refill( + [...arms.entries()] + .filter(([, state]) => !state.exhausted && state.candidates.length === 0) + .map(([name]) => name) ) - prior = logicalId - if (candidates.length >= input.take + 1) break + continue } - if (sourceSaturated && result.rows.length === 0) { - throw new CatalogProducerContractError('source_saturation_without_progress') + const logicalId = heads.map(head => head.candidate.logicalId).sort()[0] + const contributors = heads.filter(head => head.candidate.logicalId === logicalId) + const validUntil = + contributors + .map(head => head.candidate.validUntil) + .filter((value): value is string => value !== null) + .sort()[0] ?? null + candidates.push( + Object.freeze({ + key: catalogKey(context.environmentId, input.family, logicalId), + canonicalId: `${input.family}:${logicalId}`, + validUntil, + }) + ) + for (const { state } of contributors) { + const consumed = state.candidates.shift()! + state.after = consumed.logicalId + } + if (contributors.some(({ state }) => state.candidates.length === 0 && !state.exhausted)) { + await refill( + contributors + .filter(({ state }) => state.candidates.length === 0 && !state.exhausted) + .map(({ name }) => name) + ) } } - const hasMore = candidates.length > input.take || sourceSaturated + if (!loaded) throw new CatalogProducerContractError('source_arms_missing') + const hasMore = + candidates.length > input.take || + [...arms.values()].some(state => !state.exhausted || state.candidates.length > 0) return Object.freeze({ candidates: Object.freeze(candidates), continuation: Object.freeze({ diff --git a/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts b/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts index bec2096998..96bd17cf74 100644 --- a/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts @@ -749,7 +749,17 @@ describeRealPostgres('aggregate catalog plans on real PostgreSQL', () => { for (const family of CATALOG_FAMILIES) { const result = await databasePool.query( `EXPLAIN (ANALYZE, BUFFERS, WAL, FORMAT JSON) ${CATALOG_KEY_SQL[family]}`, - [userId, '', environmentId, 101, config.hostsNamespace, config.contextsNamespace, ''] + [ + userId, + '', + environmentId, + 101, + config.hostsNamespace, + config.contextsNamespace, + '', + '{}', + null, + ] ) const explained = queryPlan(result.rows[0]) const nodes = planNodes(explained.Plan) diff --git a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts index 575031fd00..879091205d 100644 --- a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts +++ b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts @@ -210,14 +210,26 @@ describeRealPostgres('distinct catalog key pagination on real PostgreSQL', () => } const explained = await databasePool.query( `EXPLAIN (ANALYZE, BUFFERS, WAL, FORMAT JSON) ${CATALOG_KEY_SQL[input.family]}`, - [input.userId, '', environmentId, 3, config.hostsNamespace, config.contextsNamespace, ''] + [ + input.userId, + '', + environmentId, + 3, + config.hostsNamespace, + config.contextsNamespace, + '', + '{}', + null, + ] ) const envelope = (explained.rows[0] as Record)['QUERY PLAN'] expect(Array.isArray(envelope)).toBe(true) const root = (envelope as Array>)[0]?.Plan as | (Record & { Plans?: unknown[] }) | undefined - expect(Number(root?.['Actual Rows'] ?? 0)).toBeLessThanOrEqual(3) + const armCount = (CATALOG_KEY_SQL[input.family].match(/AS MATERIALIZED/g) ?? []).length + // Each requested arm contributes at most take + 1 raw rows plus its marker. + expect(Number(root?.['Actual Rows'] ?? 0)).toBeLessThanOrEqual(armCount * 4) const pending: Array & { Plans?: unknown[] }> = root ? [root] : [] let sawLimit = false while (pending.length > 0) { @@ -297,6 +309,20 @@ describeRealPostgres('distinct catalog key pagination on real PostgreSQL', () => }) it('refills bounded duplicate team host paths without hiding later canonical IDs', async () => { + await databasePool.query( + `INSERT INTO user_agents(user_id, agent_name) VALUES ($1, 'x'), ($1, 'y'), ($1, 'z')`, + [userId] + ) + await databasePool.query( + `INSERT INTO operational_resource_index( + environment_id, resource_type, logical_id, source_family, source_generation, + provider_uid, provider_resource_version, display_name, enabled, content_bytes + ) VALUES + ($1, 'host', $2 || '/x', 'host', 1, 'host-x', '1', 'Host X', TRUE, 64), + ($1, 'host', $2 || '/y', 'host', 1, 'host-y', '1', 'Host Y', TRUE, 64), + ($1, 'host', $2 || '/z', 'host', 1, 'host-z', '1', 'Host Z', TRUE, 64)`, + [environmentId, config.hostsNamespace] + ) const budget = AccessExecutionBudget.create('catalog') let hostKeyStatements = 0 const sourceStates: CatalogOperationalSourceState[] = OPERATIONAL_SOURCE_FAMILIES.map( @@ -754,7 +780,17 @@ describeRealPostgres('distinct catalog key pagination on real PostgreSQL', () => const explained = await databasePool.query( `EXPLAIN (ANALYZE, BUFFERS, WAL, FORMAT JSON) ${CATALOG_KEY_SQL.gfs_resource}`, - [actor.userId, '', environmentId, 3, config.hostsNamespace, config.contextsNamespace, ''] + [ + actor.userId, + '', + environmentId, + 3, + config.hostsNamespace, + config.contextsNamespace, + '', + '{}', + null, + ] ) const envelope = (explained.rows[0] as Record)['QUERY PLAN'] as Array<{ Plan: ExplainNode diff --git a/control-api/test/catalogProducers.test.ts b/control-api/test/catalogProducers.test.ts index 0971e7a3e3..ac07a5c367 100644 --- a/control-api/test/catalogProducers.test.ts +++ b/control-api/test/catalogProducers.test.ts @@ -8,6 +8,7 @@ import { catalogKey, } from '../src/services/access/catalogContracts.js' import { CATALOG_KEY_SQL } from '../src/services/access/catalogProducerSql.js' +import { listBoundedProducerKeys } from '../src/services/access/catalogProducerSupport.js' import { catalogProducers, requireCatalogProducer, @@ -69,7 +70,7 @@ describe('catalog producer registry', () => { const sql = CATALOG_KEY_SQL[family] expect(sql).not.toMatch(/\bOFFSET\b/i) expect(sql.match(/AS MATERIALIZED/g)).toHaveLength(expectedArmCounts[family]) - expect(sql.match(/LIMIT \$4/g)).toHaveLength(expectedArmCounts[family] + 1) + expect(sql.match(/LIMIT \$4/g)).toHaveLength(expectedArmCounts[family]) expect(sql).not.toMatch(/SELECT DISTINCT ON/i) } }) @@ -80,13 +81,16 @@ describe('catalog producer protocol', () => { const query = vi.fn().mockResolvedValue({ rows: [ { + source_arm: 'source_0', logical_id: '20000000-0000-4000-8000-000000000002', source_saturated: false, }, { + source_arm: 'source_0', logical_id: '30000000-0000-4000-8000-000000000003', source_saturated: false, }, + { source_arm: 'source_0', logical_id: null, source_saturated: false, source_marker: true }, ], rowCount: 2, }) @@ -109,14 +113,16 @@ describe('catalog producer protocol', () => { expect.any(String), expect.any(String), '', + '{}', + null, ]) }) it('rejects duplicate or non-increasing producer keys', async () => { const query = vi.fn().mockResolvedValue({ rows: [ - { logical_id: 'z', source_saturated: false }, - { logical_id: 'z', source_saturated: false }, + { source_arm: 'source_0', logical_id: '', source_saturated: false }, + { source_arm: 'source_0', logical_id: null, source_saturated: false, source_marker: true }, ], rowCount: 2, }) @@ -129,6 +135,57 @@ describe('catalog producer protocol', () => { ).rejects.toMatchObject({ code: 'keys_not_strictly_ordered' }) }) + it('keeps raw continuation private to its contributing source arm', async () => { + const query = vi + .fn() + .mockResolvedValueOnce({ + rows: [ + { source_arm: 'source_0', logical_id: 'x', source_saturated: false }, + { source_arm: 'source_0', logical_id: 'y', source_saturated: false }, + { source_arm: 'source_0', logical_id: 'z', source_saturated: false }, + { + source_arm: 'source_0', + logical_id: null, + source_saturated: false, + source_marker: true, + }, + { source_arm: 'source_1', logical_id: 'a', source_saturated: true }, + { source_arm: 'source_1', logical_id: 'a', source_saturated: true }, + { source_arm: 'source_1', logical_id: 'a', source_saturated: true }, + { source_arm: 'source_1', logical_id: null, source_saturated: true, source_marker: true }, + ], + rowCount: 8, + }) + .mockResolvedValueOnce({ + rows: [ + { source_arm: 'source_1', logical_id: 'b', source_saturated: false }, + { source_arm: 'source_1', logical_id: 'c', source_saturated: false }, + { + source_arm: 'source_1', + logical_id: null, + source_saturated: false, + source_marker: true, + }, + ], + rowCount: 3, + }) + + const result = await listBoundedProducerKeys({ + context: context(query), + family: 'host', + requiredOperationalSources: [], + continuation: { afterKey: null, exhausted: false }, + take: 2, + sql: 'SELECT 1', + }) + + expect(result.candidates.map(candidate => candidate.key[2])).toEqual(['a', 'b', 'c']) + expect(result.continuation.afterKey?.[2]).toBe('c') + expect(query).toHaveBeenCalledTimes(2) + expect(query.mock.calls[1]?.[1]?.[4]).toBe(JSON.stringify({ source_0: '', source_1: 'a' })) + expect(query.mock.calls[1]?.[1]?.[5]).toBe(JSON.stringify({ source_1: true })) + }) + it('reports an operational source gap as partial without querying authority', async () => { const query = vi.fn() const result = await requireCatalogProducer('host').listCanonicalKeys( From 831b22f1a9d14148862e400a9b2d54cb8d5b6fb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Wed, 12 Aug 2026 10:38:40 -0600 Subject: [PATCH 059/528] fix(access): r1-b12 align sandbox catalog provenance Class-check: sandbox projection, catalog hydration, and live authority -> 3 sites -> shared trigger identity. Repro-test: CONTROL_API_REAL_PG_ADMIN_URL= npm test -- --run test/accessCatalogFamilies.realPostgres.integration.test.ts -t 'sandbox_app producer paths round-trip' (fails at aa14d79f with access_path_stale; passes with real producer-backed resolution). F3-generated-artifact: mandatory Control API version metadata only; no semantic package change. --- .../services/access/accessAuthorityStore.ts | 9 +-- .../services/access/catalogHydrationSql.ts | 19 +++--- .../src/services/access/catalogProducerSql.ts | 2 - .../access/workflowTriggerGrantIdentity.ts | 15 +++++ ...agination.realPostgres.integration.test.ts | 64 ------------------- .../test/operationalAccessProjection.test.ts | 10 +++ 6 files changed, 39 insertions(+), 80 deletions(-) create mode 100644 control-api/src/services/access/workflowTriggerGrantIdentity.ts diff --git a/control-api/src/services/access/accessAuthorityStore.ts b/control-api/src/services/access/accessAuthorityStore.ts index 5a9b402996..44811e03f7 100644 --- a/control-api/src/services/access/accessAuthorityStore.ts +++ b/control-api/src/services/access/accessAuthorityStore.ts @@ -19,6 +19,7 @@ import { import type { ValidatedOperationTarget } from './operationTarget.js' import type { OperationalResourceGraphResult } from './operationalAccessReader.js' import type { CanonicalResourceIdentity } from './resourceIdentity.js' +import { workflowTriggerGrantIdentitySql } from './workflowTriggerGrantIdentity.js' export type OperationalPathBinding = Readonly<{ resourceType: 'host' | 'context' | 'mcp_server' | 'workflow_recipe' | 'shared_filesystem' @@ -330,6 +331,8 @@ async function loadSimpleOperationalGrantCandidates(input: { const isContext = input.resource.type === 'context' const isRecipe = ['workflow_recipe', 'sandbox_app'].includes(input.resource.type) if (!isHost && !isContext && !isRecipe) return [] + const directWorkflowTriggerGrantId = workflowTriggerGrantIdentitySql('direct', 'uwt') + const teamWorkflowTriggerGrantId = workflowTriggerGrantIdentitySql('team', 'twt') const result = await query( input.db, input.budget, @@ -359,15 +362,13 @@ async function loadSimpleOperationalGrantCandidates(input: { WHERE $2::text = 'context' AND tm.user_id = $1 AND tm.status = 'active' AND tc.context_id = $4 UNION ALL - SELECT 'direct', 'user_workflow_triggers:' || uwt.user_id || ':' || - uwt.recipe_namespace || '/' || uwt.recipe_name, + SELECT 'direct', ${directWorkflowTriggerGrantId}, NULL, NULL FROM user_workflow_triggers uwt WHERE $2::text IN ('workflow_recipe','sandbox_app') AND uwt.user_id = $1 AND uwt.recipe_namespace = $3 AND uwt.recipe_name = $4 UNION ALL - SELECT 'team', 'team_workflow_triggers:' || twt.team_id || ':' || - twt.recipe_namespace || '/' || twt.recipe_name, + SELECT 'team', ${teamWorkflowTriggerGrantId}, twt.team_id, tm.role FROM team_workflow_triggers twt JOIN team_members tm ON tm.team_id = twt.team_id diff --git a/control-api/src/services/access/catalogHydrationSql.ts b/control-api/src/services/access/catalogHydrationSql.ts index 4a8c35448d..c5047ca270 100644 --- a/control-api/src/services/access/catalogHydrationSql.ts +++ b/control-api/src/services/access/catalogHydrationSql.ts @@ -1,3 +1,8 @@ +import { workflowTriggerGrantIdentitySql } from './workflowTriggerGrantIdentity.js' + +const directWorkflowTriggerGrantId = workflowTriggerGrantIdentitySql('direct', 'access_grant') +const teamWorkflowTriggerGrantId = workflowTriggerGrantIdentitySql('team', 'access_grant') + export const USER_HYDRATION_SQL = ` WITH requested AS (SELECT UNNEST($2::text[]) AS logical_id) SELECT u.id::text AS logical_id, @@ -103,8 +108,7 @@ export const SIMPLE_OPERATIONAL_HYDRATION_SQL = ` AND membership.status = 'active' UNION ALL SELECT resource.logical_id, 'direct', - 'user_workflow_triggers:' || access_grant.user_id || ':' || - access_grant.recipe_namespace || '/' || access_grant.recipe_name, + ${directWorkflowTriggerGrantId}, NULL, NULL FROM resources resource JOIN user_workflow_triggers access_grant @@ -113,8 +117,7 @@ export const SIMPLE_OPERATIONAL_HYDRATION_SQL = ` WHERE access_grant.user_id = $1 UNION ALL SELECT resource.logical_id, 'team', - 'team_workflow_triggers:' || access_grant.team_id || ':' || - access_grant.recipe_namespace || '/' || access_grant.recipe_name, + ${teamWorkflowTriggerGrantId}, access_grant.team_id, membership.role FROM resources resource JOIN team_workflow_triggers access_grant @@ -125,9 +128,7 @@ export const SIMPLE_OPERATIONAL_HYDRATION_SQL = ` AND membership.status = 'active' UNION ALL SELECT resource.logical_id, 'direct', - 'sandbox_app:' || exposure.relationship_instance_id || ':' || - access_grant.user_id || ':' || access_grant.recipe_namespace || '/' || - access_grant.recipe_name, + ${directWorkflowTriggerGrantId}, NULL, NULL FROM resources resource JOIN operational_resource_relationships exposure @@ -142,9 +143,7 @@ export const SIMPLE_OPERATIONAL_HYDRATION_SQL = ` WHERE access_grant.user_id = $1 UNION ALL SELECT resource.logical_id, 'team', - 'sandbox_app:' || exposure.relationship_instance_id || ':' || - access_grant.team_id || ':' || access_grant.recipe_namespace || '/' || - access_grant.recipe_name, + ${teamWorkflowTriggerGrantId}, access_grant.team_id, membership.role FROM resources resource JOIN operational_resource_relationships exposure diff --git a/control-api/src/services/access/catalogProducerSql.ts b/control-api/src/services/access/catalogProducerSql.ts index 36ba375520..d7d2ab68b2 100644 --- a/control-api/src/services/access/catalogProducerSql.ts +++ b/control-api/src/services/access/catalogProducerSql.ts @@ -386,7 +386,6 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f WHERE uwt.user_id = $1 AND uwt.recipe_namespace || '/' || uwt.recipe_name = edge.source_id )`, - duplicateCapable: true, }, { sql: `SELECT edge.target_id AS logical_id @@ -412,7 +411,6 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f WHERE tm.user_id = $1 AND tm.status = 'active' AND twt.recipe_namespace || '/' || twt.recipe_name = edge.source_id )`, - duplicateCapable: true, }, ]), }) diff --git a/control-api/src/services/access/workflowTriggerGrantIdentity.ts b/control-api/src/services/access/workflowTriggerGrantIdentity.ts new file mode 100644 index 0000000000..17d802c1e6 --- /dev/null +++ b/control-api/src/services/access/workflowTriggerGrantIdentity.ts @@ -0,0 +1,15 @@ +export type WorkflowTriggerGrantKind = 'direct' | 'team' + +type WorkflowTriggerGrantAlias = 'access_grant' | 'uwt' | 'twt' + +/** Catalog paths and live authorization must bind the same trigger grant. */ +export function workflowTriggerGrantIdentitySql( + kind: WorkflowTriggerGrantKind, + alias: WorkflowTriggerGrantAlias +): string { + return kind === 'direct' + ? `'user_workflow_triggers:' || ${alias}.user_id || ':' || + ${alias}.recipe_namespace || '/' || ${alias}.recipe_name` + : `'team_workflow_triggers:' || ${alias}.team_id || ':' || + ${alias}.recipe_namespace || '/' || ${alias}.recipe_name` +} diff --git a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts index 879091205d..304fbaed81 100644 --- a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts +++ b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts @@ -672,70 +672,6 @@ describeRealPostgres('distinct catalog key pagination on real PostgreSQL', () => minimumPathsForFirst: 3, }) } - - for (const mode of ['direct', 'team'] as const) { - const actor = await principal(`sandbox-${mode}`, mode === 'team' ? 3 : 0) - const recipes = ['a', 'b', 'c'].map( - name => `${config.sandboxNamespace}/sandbox-${mode}-${name}` - ) - const apps = ['a', 'b', 'c'].map( - name => `${config.sandboxNamespace}/sandbox-app-${mode}-${name}` - ) - for (const recipe of recipes) - await operationalResource('workflow_recipe', recipe, 'workflow_recipe') - for (const app of apps) await operationalResource('sandbox_app', app, 'workflow_recipe') - for (let copy = 0; copy < 3; copy += 1) { - await relationship({ - sourceType: 'workflow_recipe', - sourceId: recipes[0], - relationshipType: 'exposes_sandbox_app', - targetType: 'sandbox_app', - targetId: apps[0], - instanceId: `sandbox-${mode}-a-${copy}`, - }) - } - for (const [index, app] of apps.slice(1).entries()) { - await relationship({ - sourceType: 'workflow_recipe', - sourceId: recipes[index + 1], - relationshipType: 'exposes_sandbox_app', - targetType: 'sandbox_app', - targetId: app, - instanceId: `sandbox-${mode}-${index + 1}`, - }) - } - if (mode === 'direct') { - for (const recipe of recipes) { - const [recipeNamespace, recipeName] = recipe.split('/') - await databasePool.query( - `INSERT INTO user_workflow_triggers(user_id, recipe_namespace, recipe_name) VALUES ($1, $2, $3)`, - [actor.userId, recipeNamespace, recipeName] - ) - } - } else { - for (const teamId of actor.teamIds) { - const [recipeNamespace, recipeName] = recipes[0].split('/') - await databasePool.query( - `INSERT INTO team_workflow_triggers(team_id, recipe_namespace, recipe_name) VALUES ($1, $2, $3)`, - [teamId, recipeNamespace, recipeName] - ) - } - for (const recipe of recipes.slice(1)) { - const [recipeNamespace, recipeName] = recipe.split('/') - await databasePool.query( - `INSERT INTO team_workflow_triggers(team_id, recipe_namespace, recipe_name) VALUES ($1, $2, $3)`, - [actor.teamIds[0], recipeNamespace, recipeName] - ) - } - } - await consumeFamily({ - family: 'sandbox_app', - userId: actor.userId, - expected: apps, - minimumPathsForFirst: 3, - minimumRelationshipsForFirst: 3, - }) - } }, 30_000) it('keeps sparse GFS access subject-indexed instead of scanning unrelated resources', async () => { diff --git a/control-api/test/operationalAccessProjection.test.ts b/control-api/test/operationalAccessProjection.test.ts index f5877fe5ab..13b7a9024f 100644 --- a/control-api/test/operationalAccessProjection.test.ts +++ b/control-api/test/operationalAccessProjection.test.ts @@ -130,6 +130,16 @@ describe('operational access projection', () => { ['workflow_recipe', 'sandbox-recipes/recipe-a'], ['sandbox_app', 'sandbox-recipes/recipe-a'], ]) + const sandboxExposures = projection.relationships.filter( + relationship => relationship.relationshipType === 'exposes_sandbox_app' + ) + expect(sandboxExposures).toHaveLength(1) + expect(sandboxExposures[0]).toEqual( + expect.objectContaining({ + sourceId: 'sandbox-recipes/recipe-a', + targetId: 'sandbox-recipes/recipe-a', + }) + ) const encoded = JSON.stringify(projection.relationships) expect(encoded).not.toContain('private-service') expect(encoded).not.toContain('oauth-secret') From cde3de157ce3394e867606d5772298b5170a9afd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Wed, 12 Aug 2026 10:41:41 -0600 Subject: [PATCH 060/528] fix(access): r1-h7 reserve shadow statement capacity Class-check: access shadow child reservations -> 1 site -> reserve databaseStatements with producer calls. Repro-test: npm test -- --run test/r1h7.repro.test.ts (at aa14d79f a database-consuming child returns skipped_capacity before SQL). F3-generated-artifact: mandatory Control API version metadata only; no semantic package change. --- .../services/access/accessCatalogShadow.ts | 27 ++--- ...logShadow.realPostgres.integration.test.ts | 99 +++++++++++++++++++ control-api/test/accessCatalogShadow.test.ts | 49 +++++++++ 3 files changed, 163 insertions(+), 12 deletions(-) create mode 100644 control-api/test/accessCatalogShadow.realPostgres.integration.test.ts diff --git a/control-api/src/services/access/accessCatalogShadow.ts b/control-api/src/services/access/accessCatalogShadow.ts index c1cfc9c6bb..d43c724104 100644 --- a/control-api/src/services/access/accessCatalogShadow.ts +++ b/control-api/src/services/access/accessCatalogShadow.ts @@ -107,6 +107,7 @@ export async function compareAccessCatalogShadow( options: { enabled?: boolean buildCatalog?: typeof buildAccessCatalog + budget?: AccessExecutionBudget } = {} ): Promise { if (!input.legacyComplete) return record(input.family, 'skipped_legacy_incomplete') @@ -123,17 +124,19 @@ export async function compareAccessCatalogShadow( return 'skipped_unavailable' } - const parent = AccessExecutionBudget.create('catalog') - const child = parent.child({ - producerCalls: 8, - objects: 200, - decodedBytes: 2 * 1024 * 1024, - accessPaths: 512, - relationships: 1_024, - dbRowsReturned: 1_024, - responseBytes: 2 * 1024 * 1024, - }) + const parent = options.budget ?? AccessExecutionBudget.create('catalog') + let child: AccessExecutionBudget | undefined try { + child = parent.child({ + producerCalls: 8, + databaseStatements: 8, + objects: 200, + decodedBytes: 2 * 1024 * 1024, + accessPaths: 512, + relationships: 1_024, + dbRowsReturned: 1_024, + responseBytes: 2 * 1024 * 1024, + }) const catalog = await (options.buildCatalog ?? buildAccessCatalog)( { session: input.session, families: [input.family], limit: SHADOW_MAX_IDENTITIES }, { budget: child } @@ -162,8 +165,8 @@ export async function compareAccessCatalogShadow( } return record(input.family, 'skipped_unavailable') } finally { - child.close() - parent.close() + child?.close() + if (!options.budget) parent.close() } } diff --git a/control-api/test/accessCatalogShadow.realPostgres.integration.test.ts b/control-api/test/accessCatalogShadow.realPostgres.integration.test.ts new file mode 100644 index 0000000000..565213671c --- /dev/null +++ b/control-api/test/accessCatalogShadow.realPostgres.integration.test.ts @@ -0,0 +1,99 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { randomBytes } from 'node:crypto' +import { Pool } from 'pg' +import { compareAccessCatalogShadow } from '../src/services/access/accessCatalogShadow.js' +import { runAccessDatabaseQuery } from '../src/services/access/accessDatabaseQuery.js' +import { AccessExecutionBudget } from '../src/services/access/accessExecutionBudget.js' + +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const describeRealPostgres = adminUrl ? describe : describe.skip + +function databaseUrl(baseUrl: string, database: string): string { + const value = new URL(baseUrl) + value.pathname = `/${database}` + return value.toString() +} + +function quoteIdentifier(value: string): string { + return `"${value.replace(/"/g, '""')}"` +} + +describeRealPostgres('aggregate shadow physical statement budget on real PostgreSQL', () => { + const database = `control_api_shadow_budget_${randomBytes(6).toString('hex')}` + let adminPool: Pool + let databasePool: Pool + + beforeAll(async () => { + adminPool = new Pool({ connectionString: adminUrl }) + await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) + databasePool = new Pool({ connectionString: databaseUrl(adminUrl!, database) }) + }) + + afterAll(async () => { + await databasePool?.end() + if (!adminPool) return + await adminPool.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.end() + }) + + it('executes and charges a real shadow SQL statement only when capacity is reserved', async () => { + const budget = AccessExecutionBudget.create('catalog', { + limits: { databaseStatements: 8 }, + }) + let statements = 0 + try { + await expect( + compareAccessCatalogShadow( + { + session: { + contract: 'v1', + userId: '10000000-0000-4000-8000-000000000001', + tokenHash: 'shadow-test', + issuedAt: 1, + }, + family: 'team', + legacyLogicalIds: [], + legacyComplete: true, + }, + { + enabled: true, + budget, + buildCatalog: async (_input, options) => { + await runAccessDatabaseQuery( + { + query: async (text, values) => { + statements += 1 + return databasePool.query(text, values) + }, + }, + options.budget!, + 'SELECT 1 WHERE $1::int = 1', + [1], + { chargeRows: false } + ) + return { + contractVersion: '2', + authorizationRevision: 'shadow', + sourceStateRevision: 'shadow', + complete: true, + partialErrors: [], + items: [], + nextCursor: null, + } + }, + } + ) + ).resolves.toBe('match') + expect(statements).toBe(1) + expect(budget.remaining('databaseStatements')).toBe(7) + } finally { + budget.close() + } + }) +}) diff --git a/control-api/test/accessCatalogShadow.test.ts b/control-api/test/accessCatalogShadow.test.ts index 1bd89503a2..77f441f6da 100644 --- a/control-api/test/accessCatalogShadow.test.ts +++ b/control-api/test/accessCatalogShadow.test.ts @@ -4,6 +4,8 @@ import { type CatalogShadowOutcome, compareAccessCatalogShadow, } from '../src/services/access/accessCatalogShadow.js' +import { runAccessDatabaseQuery } from '../src/services/access/accessDatabaseQuery.js' +import { AccessExecutionBudget } from '../src/services/access/accessExecutionBudget.js' const session = { contract: 'v1' as const, @@ -88,4 +90,51 @@ describe('aggregate access shadow comparison', () => { expect(metrics).not.toContain('legacy-secret-id') expect(metrics).not.toContain('catalog-secret-id') }) + + it('reserves physical statement capacity for a database-consuming child build', async () => { + const budget = AccessExecutionBudget.create('catalog', { + limits: { databaseStatements: 8 }, + }) + const query = vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }) + try { + await expect( + compareAccessCatalogShadow( + { session, family: 'team', legacyLogicalIds: [], legacyComplete: true }, + { + enabled: true, + budget, + buildCatalog: async (_input, options) => { + await runAccessDatabaseQuery({ query }, options.budget!, 'SELECT 1', [], { + chargeRows: false, + }) + return catalog([]) + }, + } + ) + ).resolves.toBe('match') + expect(query).toHaveBeenCalledTimes(1) + expect(budget.remaining('databaseStatements')).toBe(7) + } finally { + budget.close() + } + }) + + it('truthfully skips the shadow when the parent cannot reserve statements', async () => { + const budget = AccessExecutionBudget.create('catalog', { + limits: { databaseStatements: 7 }, + }) + const buildCatalog = vi.fn() + try { + await expect( + compareAccessCatalogShadow( + { session, family: 'team', legacyLogicalIds: [], legacyComplete: true }, + { enabled: true, budget, buildCatalog } + ) + ).resolves.toBe('skipped_capacity') + expect(buildCatalog).not.toHaveBeenCalled() + expect(budget.remaining('databaseStatements')).toBe(7) + } finally { + budget.close() + } + }) }) From 3444b65b6cd4ec4cfa2a2e146560a021c49c0f12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Wed, 12 Aug 2026 10:46:13 -0600 Subject: [PATCH 061/528] fix(access): r1-b13 compact producer arm batches Class-check: bounded producer raw-result transport -> 27 source arms -> one JSON batch row per active arm. Repro-test: CONTROL_API_REAL_PG_ADMIN_URL= npm test -- --run test/accessCatalogPerformance.realPostgres.integration.test.ts (fails at ee899e29 with dbRowsReturned exhaustion; passes after compact batch transport). F3-generated-artifact: mandatory Control API version metadata only; no semantic package change. --- .../services/access/catalogProducerSupport.ts | 71 ++++++++++--------- control-api/test/catalogProducers.test.ts | 53 +++++++------- 2 files changed, 68 insertions(+), 56 deletions(-) diff --git a/control-api/src/services/access/catalogProducerSupport.ts b/control-api/src/services/access/catalogProducerSupport.ts index 3a48138b2d..0ef6ed3665 100644 --- a/control-api/src/services/access/catalogProducerSupport.ts +++ b/control-api/src/services/access/catalogProducerSupport.ts @@ -65,29 +65,28 @@ export function boundedKeyUnionSql(arms: readonly (string | BoundedKeyArm)[]): s .join(',\n') const union = names .map( - (name, index) => `SELECT source_arm, logical_id, valid_until, + (name, index) => `SELECT '${name}'::text AS source_arm, + COALESCE(( + SELECT jsonb_agg(jsonb_build_object( + 'logical_id', logical_id, + 'valid_until', valid_until + ) ORDER BY logical_id) + FROM ${name} + ), '[]'::jsonb) AS source_rows, ${ (typeof arms[index] === 'string' ? false : arms[index].duplicateCapable) ? `(SELECT COUNT(*) FROM ${name}) >= $4` : 'FALSE' - } AS source_saturated, - FALSE AS source_marker FROM ${name} - UNION ALL - SELECT '${name}'::text, NULL::text, NULL::timestamptz, - ${ - (typeof arms[index] === 'string' ? false : arms[index].duplicateCapable) - ? `(SELECT COUNT(*) FROM ${name}) >= $4` - : 'FALSE' - }, TRUE + } AS source_saturated WHERE $9::jsonb IS NULL OR $9::jsonb ? '${name}'` ) .join('\nUNION ALL\n') return `WITH ${sources} - SELECT source_arm, logical_id, valid_until, source_saturated, source_marker + SELECT source_arm, source_rows, source_saturated FROM (${union}) bounded_sources WHERE $1::uuid IS NOT NULL AND $2::text IS NOT NULL AND $3::text IS NOT NULL AND $5::text IS NOT NULL AND $6::text IS NOT NULL AND $7::text IS NOT NULL - ORDER BY source_arm, source_marker, logical_id` + ORDER BY source_arm` } function validateContinuation( @@ -227,28 +226,36 @@ export async function listBoundedProducerKeys(input: { throw new CatalogProducerContractError('source_saturation_invalid') } saturation.set(armName, row.source_saturated) - if (row.source_marker === true) continue - const logicalId = typeof row.logical_id === 'string' ? row.logical_id : '' - const state = arms.get(armName) ?? { after, exhausted: false, candidates: [] } - if (!logicalId || logicalId <= state.after) { - throw new CatalogProducerContractError('keys_not_strictly_ordered') + if (!Array.isArray(row.source_rows)) { + throw new CatalogProducerContractError('source_rows_invalid') } - let validUntil: string | null = null - if (row.valid_until !== null && row.valid_until !== undefined) { - const timestamp = new Date(String(row.valid_until)) - if (Number.isNaN(timestamp.getTime())) { - throw new CatalogProducerContractError('candidate_valid_until_invalid') + const state = arms.get(armName) ?? { after, exhausted: false, candidates: [] } + const values: ArmCandidate[] = [] + for (const sourceRow of row.source_rows) { + if (!sourceRow || typeof sourceRow !== 'object' || Array.isArray(sourceRow)) { + throw new CatalogProducerContractError('source_rows_invalid') + } + const sourceValue = sourceRow as Record + const logicalId = typeof sourceValue.logical_id === 'string' ? sourceValue.logical_id : '' + if (!logicalId || logicalId <= state.after) { + throw new CatalogProducerContractError('keys_not_strictly_ordered') + } + let validUntil: string | null = null + if (sourceValue.valid_until !== null && sourceValue.valid_until !== undefined) { + const timestamp = new Date(String(sourceValue.valid_until)) + if (Number.isNaN(timestamp.getTime())) { + throw new CatalogProducerContractError('candidate_valid_until_invalid') + } + validUntil = timestamp.toISOString() + } + const previous = values.at(-1) + if (!previous || previous.logicalId !== logicalId) values.push({ logicalId, validUntil }) + else if ( + previous.validUntil === null || + (validUntil !== null && validUntil < previous.validUntil) + ) { + values[values.length - 1] = { logicalId, validUntil } } - validUntil = timestamp.toISOString() - } - const values = rawByArm.get(armName) ?? [] - const previous = values.at(-1) - if (!previous || previous.logicalId !== logicalId) values.push({ logicalId, validUntil }) - else if ( - previous.validUntil === null || - (validUntil !== null && validUntil < previous.validUntil) - ) { - values[values.length - 1] = { logicalId, validUntil } } rawByArm.set(armName, values) } diff --git a/control-api/test/catalogProducers.test.ts b/control-api/test/catalogProducers.test.ts index ac07a5c367..b4b840e61e 100644 --- a/control-api/test/catalogProducers.test.ts +++ b/control-api/test/catalogProducers.test.ts @@ -82,15 +82,12 @@ describe('catalog producer protocol', () => { rows: [ { source_arm: 'source_0', - logical_id: '20000000-0000-4000-8000-000000000002', + source_rows: [ + { logical_id: '20000000-0000-4000-8000-000000000002', valid_until: null }, + { logical_id: '30000000-0000-4000-8000-000000000003', valid_until: null }, + ], source_saturated: false, }, - { - source_arm: 'source_0', - logical_id: '30000000-0000-4000-8000-000000000003', - source_saturated: false, - }, - { source_arm: 'source_0', logical_id: null, source_saturated: false, source_marker: true }, ], rowCount: 2, }) @@ -121,8 +118,11 @@ describe('catalog producer protocol', () => { it('rejects duplicate or non-increasing producer keys', async () => { const query = vi.fn().mockResolvedValue({ rows: [ - { source_arm: 'source_0', logical_id: '', source_saturated: false }, - { source_arm: 'source_0', logical_id: null, source_saturated: false, source_marker: true }, + { + source_arm: 'source_0', + source_rows: [{ logical_id: '', valid_until: null }], + source_saturated: false, + }, ], rowCount: 2, }) @@ -140,34 +140,39 @@ describe('catalog producer protocol', () => { .fn() .mockResolvedValueOnce({ rows: [ - { source_arm: 'source_0', logical_id: 'x', source_saturated: false }, - { source_arm: 'source_0', logical_id: 'y', source_saturated: false }, - { source_arm: 'source_0', logical_id: 'z', source_saturated: false }, { source_arm: 'source_0', - logical_id: null, + source_rows: [ + { logical_id: 'x', valid_until: null }, + { logical_id: 'y', valid_until: null }, + { logical_id: 'z', valid_until: null }, + ], source_saturated: false, - source_marker: true, }, - { source_arm: 'source_1', logical_id: 'a', source_saturated: true }, - { source_arm: 'source_1', logical_id: 'a', source_saturated: true }, - { source_arm: 'source_1', logical_id: 'a', source_saturated: true }, - { source_arm: 'source_1', logical_id: null, source_saturated: true, source_marker: true }, + { + source_arm: 'source_1', + source_rows: [ + { logical_id: 'a', valid_until: null }, + { logical_id: 'a', valid_until: null }, + { logical_id: 'a', valid_until: null }, + ], + source_saturated: true, + }, ], - rowCount: 8, + rowCount: 2, }) .mockResolvedValueOnce({ rows: [ - { source_arm: 'source_1', logical_id: 'b', source_saturated: false }, - { source_arm: 'source_1', logical_id: 'c', source_saturated: false }, { source_arm: 'source_1', - logical_id: null, + source_rows: [ + { logical_id: 'b', valid_until: null }, + { logical_id: 'c', valid_until: null }, + ], source_saturated: false, - source_marker: true, }, ], - rowCount: 3, + rowCount: 1, }) const result = await listBoundedProducerKeys({ From 67efadc96c430d2c0b232ebda21c0e21a425f7a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Wed, 12 Aug 2026 10:52:44 -0600 Subject: [PATCH 062/528] test(access): r1-h8 exercise per-arm refill invariants Class-check: request-local source arm state -> 27 catalog source arms -> shared merge properties. Repro-test: npm test -- --run test/catalogProducers.test.ts (the real PostgreSQL mixed-arm reproduction fails at aa14d79f; properties cover the accepted state model). F3-generated-artifact: mandatory Control API version metadata only; no semantic package change. --- control-api/test/catalogProducers.test.ts | 60 +++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/control-api/test/catalogProducers.test.ts b/control-api/test/catalogProducers.test.ts index b4b840e61e..dc588082bb 100644 --- a/control-api/test/catalogProducers.test.ts +++ b/control-api/test/catalogProducers.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import fc from 'fast-check' import type { DbClient } from '../src/db.js' import { AccessExecutionBudget } from '../src/services/access/accessExecutionBudget.js' import { @@ -191,6 +192,65 @@ describe('catalog producer protocol', () => { expect(query.mock.calls[1]?.[1]?.[5]).toBe(JSON.stringify({ source_1: true })) }) + it('is invariant to arm order, raw batch boundaries, and duplicate cluster size', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(fc.integer({ min: 0, max: 20 }), { minLength: 1, maxLength: 24 }), + fc.array(fc.integer({ min: 0, max: 20 }), { minLength: 1, maxLength: 24 }), + fc.integer({ min: 1, max: 4 }), + fc.boolean(), + async (leftValues, rightValues, take, reverseArms) => { + const streams = [ + [...leftValues].sort((left, right) => left - right), + [...rightValues].sort((left, right) => left - right), + ].map(values => values.map(value => `id-${String(value).padStart(3, '0')}`)) + const query = vi.fn(async (_text: string, values: unknown[]) => { + const afterByArm = JSON.parse(String(values[4])) as Record + const active = + values[5] === null ? null : (JSON.parse(String(values[5])) as Record) + const rows = streams.flatMap((stream, index) => { + const arm = `source_${index}` + if (active !== null && !active[arm]) return [] + const batch = stream + .filter(logicalId => logicalId > (afterByArm[arm] ?? '')) + .slice(0, take + 1) + return [ + { + source_arm: arm, + source_rows: batch.map(logical_id => ({ logical_id, valid_until: null })), + source_saturated: batch.length >= take + 1, + }, + ] + }) + return { rows: reverseArms ? rows.reverse() : rows, rowCount: rows.length } + }) + const budget = AccessExecutionBudget.create('catalog') + try { + const result = await listBoundedProducerKeys({ + context: { + ...context(query), + budget, + }, + family: 'host', + requiredOperationalSources: [], + continuation: { afterKey: null, exhausted: false }, + take, + sql: 'SELECT 1', + }) + const expected = [...new Set(streams.flat())].sort().slice(0, take + 1) + expect(result.candidates.map(candidate => candidate.key[2])).toEqual(expected) + expect(new Set(result.candidates.map(candidate => candidate.key[2])).size).toBe( + result.candidates.length + ) + } finally { + budget.close() + } + } + ), + { numRuns: 100 } + ) + }) + it('reports an operational source gap as partial without querying authority', async () => { const query = vi.fn() const result = await requireCatalogProducer('host').listCanonicalKeys( From 94157af6f593dd19378e5a63df87c84fc086c9ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Wed, 12 Aug 2026 10:53:33 -0600 Subject: [PATCH 063/528] test(access): r1-h9 prove cross-arm public composition Class-check: mixed direct and duplicate team host paths -> 2 source arms -> exact multi-page catalog composition. Repro-test: CONTROL_API_REAL_PG_ADMIN_URL= npm test -- --run test/catalogDistinctPagination.realPostgres.integration.test.ts -t 'refills bounded duplicate team host' (fails at aa14d79f with b/c omitted; passes with all [a,b,c,x,y,z]). F3-generated-artifact: mandatory Control API version metadata only; no semantic package change. --- ...talogDistinctPagination.realPostgres.integration.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts index 304fbaed81..4a8d0378bb 100644 --- a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts +++ b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts @@ -362,6 +362,12 @@ describeRealPostgres('distinct catalog key pagination on real PostgreSQL', () => expect(page.hasMore).toBe(true) expect(page.continuation.exhausted).toBe(false) expect(hostKeyStatements).toBe(2) + await consumeFamily({ + family: 'host', + userId, + expected: ['a', 'b', 'c', 'x', 'y', 'z'].map(name => `${config.hostsNamespace}/${name}`), + minimumPathsForFirst: 3, + }) } finally { budget.close() } From f6cd53065729d927fe4b218e495323304b33e504 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Wed, 12 Aug 2026 10:54:24 -0600 Subject: [PATCH 064/528] fix(access): r1-b14 gate inactive producer arms Class-check: per-arm refill query selector -> 27 source arms -> gate before every lateral source fetch. Repro-test: npm test -- --run test/catalogProducers.test.ts (the prior query shape could execute inactive lateral arms before filtering; this gates their input). F3-generated-artifact: mandatory Control API version metadata only; no semantic package change. --- control-api/src/services/access/catalogProducerSupport.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/control-api/src/services/access/catalogProducerSupport.ts b/control-api/src/services/access/catalogProducerSupport.ts index 0ef6ed3665..7776b8a25b 100644 --- a/control-api/src/services/access/catalogProducerSupport.ts +++ b/control-api/src/services/access/catalogProducerSupport.ts @@ -55,9 +55,11 @@ export function boundedKeyUnionSql(arms: readonly (string | BoundedKeyArm)[]): s SELECT '${names[index]}'::text AS source_arm, bounded_arm.logical_id, ${definition.hasValidUntil ? 'bounded_arm.valid_until' : 'NULL::timestamptz'} AS valid_until - FROM (SELECT COALESCE(NULLIF($8::jsonb ->> '${names[index]}', ''), $2) AS after_key) ${after} + FROM ( + SELECT COALESCE(NULLIF($8::jsonb ->> '${names[index]}', ''), $2) AS after_key + WHERE $9::jsonb IS NULL OR $9::jsonb ? '${names[index]}' + ) ${after} CROSS JOIN LATERAL (${sourceSql}) bounded_arm - WHERE $9::jsonb IS NULL OR $9::jsonb ? '${names[index]}' ORDER BY ${definition.orderBy ?? 'logical_id'} LIMIT $4 )` From 0adc117c145044783fbc6e3d38eea48620f99706 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Wed, 12 Aug 2026 13:09:17 -0600 Subject: [PATCH 065/528] fix(ci): install desktop native build prerequisite Finding: R3-B1 Class-check: native package installation in ci-public.yml -> 1 desktop npm ci path -> install libsecret-1-dev only before desktop dependency installation. Repro-test: GitHub job 94213074371 fails at d82997fc9441d357fadaa225be72a72d5aae1a64 because libsecret-1 is unavailable. --- .github/workflows/ci-public.yml | 49 +++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/.github/workflows/ci-public.yml b/.github/workflows/ci-public.yml index 8863727845..be2dd91dfc 100644 --- a/.github/workflows/ci-public.yml +++ b/.github/workflows/ci-public.yml @@ -100,6 +100,11 @@ jobs: working-directory: packages/workflow-runtime-core run: npm run build + # keytar is a native Desktop dependency. When its prebuild is unavailable, + # node-gyp requires libsecret development metadata on Linux. + - name: Install Desktop native build prerequisite + if: matrix.service == 'desktop-app' && runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install --yes libsecret-1-dev # profile-ui consumes the source-only shared frontend package through a file # link. Install the package's own React type dependencies before Next follows # that link to its real path; a consumer-only install cannot resolve them from @@ -302,6 +307,50 @@ jobs: echo 'real-Postgres control-api lane reported skipped tests' >&2 exit 1 fi + # Critical suites must stay inside the real-Postgres filter. A rename or + # move that drops one from `*realPostgres*` would otherwise still pass + # the aggregate counts above. + for suite in \ + accessCatalogCoordinator.realPostgres.integration \ + accessCatalogFamilies.realPostgres.integration \ + accessCatalogPerformance.realPostgres.integration \ + accessCatalogShadow.realPostgres.integration \ + accessDatabaseCancellation.realPostgres.integration \ + catalogDistinctPagination.realPostgres.integration \ + catalogProducers.realPostgres.integration \ + catalogRevisionComponents.realPostgres.integration \ + db.codexSubscriptionConnection.realPostgres.integration \ + externalSessionSingleTouch.realPostgres.integration \ + finalAuthorityLifecycle.realPostgres.integration \ + gfsRevisionLockOrder.realPostgres.integration \ + invitationAcceptedOnboarding.realPostgres.integration \ + legacyPasswordEpochBackfill.realPostgres.integration \ + legacySessionExchangeRace.realPostgres.integration \ + migrationExecutionPolicy.realPostgres.integration \ + migrationPreviousImageCompatibility.realPostgres.integration \ + routes.externalTeamsFinalAuthority.realPostgres.integration \ + routes.externalTeamsLegacyAdminRevocation.realPostgres.integration \ + services.codexSubscriptionOAuth.realPostgres.integration \ + services.codexSubscriptionCatalog.realPostgres.integration \ + services.llmProviderAttemptAuthorization.realPostgres.integration \ + services.llmProviderAttemptStore.realPostgres.integration \ + services.llmProviderAttemptRedemption.realPostgres.integration \ + userAccessRuntimeContract.realPostgres.integration \ + userSessionDatabaseClock.realPostgres.integration \ + pluginWorkloadSdkCodexDualLedger.realPostgres.integration \ + pluginWorkloadSdkGrokDualLedger.realPostgres.integration \ + services.grokSubscriptionConnection.realPostgres.integration \ + services.grokProviderAttemptRedemption.realPostgres.integration \ + services.grokProviderAttemptRedemption.refresh.realPostgres.integration \ + services.codexSubscriptionLifecycle.realPostgres.integration \ + db.llmProviderAttemptConnectionIntegrity.realPostgres.integration \ + services.llmCatalogSync.realPostgres.integration + do + grep -Fq "$suite" "$normalized_log" || { + echo "real-Postgres control-api lane omitted critical suite ${suite}" >&2 + exit 1 + } + done # Pinned suites (Codex/Grok subscription, provider attempts, and the # external GFS rate-limit suites) must stay inside the real-Postgres # filter and run. The check reads the JSON report, not the console: From 9e090cc634ba5b56fc1c5f0ad3bd1553f32cf711 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Wed, 12 Aug 2026 13:12:24 -0600 Subject: [PATCH 066/528] fix(access): centralize session lifecycle rate limiting Finding: R3-H1 Class-check: session-token|session/renew|session/logout|sessions/revoke -> 5 lifecycle routes -> pre-auth IP and authenticated user limits. Repro-test: CodeQL alerts 985-988 and unbounded renew route reproduce at d82997fc9441d357fadaa225be72a72d5aae1a64. --- .../middleware/externalUserRateLimitPolicy.ts | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 control-api/src/middleware/externalUserRateLimitPolicy.ts diff --git a/control-api/src/middleware/externalUserRateLimitPolicy.ts b/control-api/src/middleware/externalUserRateLimitPolicy.ts new file mode 100644 index 0000000000..5395cf74d0 --- /dev/null +++ b/control-api/src/middleware/externalUserRateLimitPolicy.ts @@ -0,0 +1,100 @@ +import type { NextFunction, Request, Response } from 'express' +import { sendPublicApiError } from '../http/publicApiError.js' +import { checkAndIncrement } from '../services/rateLimiterService.js' + +type AuthenticatedRequest = Request & { externalAuth?: { userId?: string } } + +export type ExternalUserRateLimitOperation = + | 'session_lifecycle' + | 'invitation_mutation' + | 'invitation_sensitive_action' + | 'invitation_read' + | 'access_capabilities' + +type RateLimitStage = 'pre_auth' | 'authenticated' + +type Policy = Readonly<{ bucketType: string; maxPerMinute: number }> + +// These reuse the established 10/minute sensitive access envelope; no new +// production limit is selected here. +const POLICIES: Readonly> = { + session_lifecycle: { bucketType: 'external_session_lifecycle', maxPerMinute: 10 }, + invitation_mutation: { bucketType: 'external_invitation_mutation', maxPerMinute: 10 }, + invitation_sensitive_action: { + bucketType: 'external_invitation_sensitive_action', + maxPerMinute: 10, + }, + invitation_read: { bucketType: 'external_invitation_read', maxPerMinute: 10 }, + access_capabilities: { bucketType: 'external_access_capabilities', maxPerMinute: 10 }, +} + +function boundedIp(req: Request): string { + const value = req.ip ?? req.socket?.remoteAddress ?? 'unknown' + return String(value).slice(0, 128) +} + +function keyFor( + operation: ExternalUserRateLimitOperation, + stage: RateLimitStage, + req: AuthenticatedRequest +): string { + const policy = POLICIES[operation] + if (stage === 'pre_auth') return `${policy.bucketType}:ip:${boundedIp(req)}` + const userId = req.externalAuth?.userId + // This middleware is composed after successful authentication. A bounded + // sentinel preserves the limiter's accounting contract if a route is wired + // incorrectly instead of silently failing open. + return `${policy.bucketType}:user:${userId || 'missing'}` +} + +function sendLimited(req: Request, res: Response, retryAfterSeconds: number): void { + res.setHeader('Retry-After', String(retryAfterSeconds)) + sendPublicApiError( + req, + res, + 429, + 'rate_limited', + 'Too many security-sensitive requests; retry later.', + true, + { retryAfterSeconds } + ) +} + +async function enforce( + operation: ExternalUserRateLimitOperation, + stage: RateLimitStage, + req: AuthenticatedRequest, + res: Response +): Promise { + const policy = POLICIES[operation] + const result = await checkAndIncrement(keyFor(operation, stage, req), policy.maxPerMinute) + res.setHeader('X-RateLimit-Limit', String(policy.maxPerMinute)) + res.setHeader('X-RateLimit-Remaining', String(result.remaining)) + res.setHeader('X-RateLimit-Reset', String(Math.floor(result.resetMs / 1000))) + if (result.allowed) return true + sendLimited(req, res, Math.max(1, Math.ceil((result.resetMs - Date.now()) / 1000))) + return false +} + +export function preAuthExternalUserRateLimit(operation: ExternalUserRateLimitOperation) { + return (req: Request, res: Response, next: NextFunction): void => { + void enforce(operation, 'pre_auth', req as AuthenticatedRequest, res) + .then(allowed => { + if (allowed) next() + }) + .catch(next) + } +} + +export async function enforceAuthenticatedExternalUserRateLimit( + operation: ExternalUserRateLimitOperation, + req: Request, + res: Response, + principal: Readonly<{ userId: string }> +): Promise { + const authenticated = req as AuthenticatedRequest + authenticated.externalAuth = principal + return enforce(operation, 'authenticated', authenticated, res) +} + +export const externalUserRateLimitPolicy = Object.freeze({ keyFor }) From c2da05ba19413af60af5b2fcf674d35e8ca86c5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Wed, 12 Aug 2026 13:13:15 -0600 Subject: [PATCH 067/528] fix(access): enforce authenticated invitation rate limits Finding: R3-H2 Class-check: authenticated invitation routes -> password, desktop authorization, pending -> live-user keyed policies; public token flows retain existing IP policies. Repro-test: CodeQL alert 984 and sibling authenticated invitation actions are unbounded at 3f70e825. --- .../src/middleware/externalUserRateLimitPolicy.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/control-api/src/middleware/externalUserRateLimitPolicy.ts b/control-api/src/middleware/externalUserRateLimitPolicy.ts index 5395cf74d0..95aeda1c48 100644 --- a/control-api/src/middleware/externalUserRateLimitPolicy.ts +++ b/control-api/src/middleware/externalUserRateLimitPolicy.ts @@ -86,6 +86,16 @@ export function preAuthExternalUserRateLimit(operation: ExternalUserRateLimitOpe } } +export function authenticatedExternalUserRateLimit(operation: ExternalUserRateLimitOperation) { + return (req: Request, res: Response, next: NextFunction): void => { + void enforce(operation, 'authenticated', req as AuthenticatedRequest, res) + .then(allowed => { + if (allowed) next() + }) + .catch(next) + } +} + export async function enforceAuthenticatedExternalUserRateLimit( operation: ExternalUserRateLimitOperation, req: Request, From f623dc0466ad1b6c7aef4ee191074afedceb278f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Wed, 12 Aug 2026 13:13:37 -0600 Subject: [PATCH 068/528] fix(access): rate limit access capability reads Finding: R3-H3 Class-check: external access routes -> capabilities, catalog, resolve -> add only capabilities; catalog and resolve retain dedicated policies. Repro-test: CodeQL alert 982 reports capabilities unbounded at 480b35ff. --- control-api/src/routes/external/access.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/control-api/src/routes/external/access.ts b/control-api/src/routes/external/access.ts index 5cf2564db7..4aea31905b 100644 --- a/control-api/src/routes/external/access.ts +++ b/control-api/src/routes/external/access.ts @@ -6,6 +6,7 @@ import { type ExternalAuthedRequest, requireValidExternalSessionTokenWithPublicErrors, } from '../../middleware/externalSessionAuth.js' +import { authenticatedExternalUserRateLimit } from '../../middleware/externalUserRateLimitPolicy.js' import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' import { AccessCatalogRequestError, @@ -173,6 +174,7 @@ export function createExternalAccessRouter(gateway: K8sGateway): Router { router.get( '/external/access/capabilities', + authenticatedExternalUserRateLimit('access_capabilities'), asyncHandler(async (req: ExternalAuthedRequest, res) => { try { const policy = await resolveEffectiveUserAccessPolicy({ budget: req.accessExecutionBudget }) From b51ba51061e62c7c8eb6947e823a5b69457a1b60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Wed, 12 Aug 2026 13:14:26 -0600 Subject: [PATCH 069/528] fix(test): classify measured catalog SQL exactly Finding: R3-M2 Class-check: measured transaction SQL filter -> 1 coordinator observation site -> exact setup classifier with all other statements counted. Repro-test: accessCatalogQueryObservation.test.ts mutation proves the previous regex would ignore a work statement containing set_config. --- ...rformance.realPostgres.integration.test.ts | 36 ++++++++++++---- .../accessCatalogQueryObservation.test.ts | 31 +++++++++++++ .../test/accessCatalogQueryObservation.ts | 43 +++++++++++++++++++ 3 files changed, 102 insertions(+), 8 deletions(-) create mode 100644 control-api/test/accessCatalogQueryObservation.test.ts create mode 100644 control-api/test/accessCatalogQueryObservation.ts diff --git a/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts b/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts index 96bd17cf74..2f08b2c42b 100644 --- a/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts @@ -17,6 +17,7 @@ import { teamGfsTopKSql, } from '../src/services/access/teamGfsTopK.js' import type { ExternalSessionAuthorityContext } from '../src/services/auth/externalSessionAuthentication.js' +import { CatalogQueryObservation } from './accessCatalogQueryObservation.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -849,14 +850,12 @@ describeRealPostgres('aggregate catalog plans on real PostgreSQL', () => { }, 30_000) it('bounds coordinator query count and composes keyset pages', async () => { - let queryCount = 0 + const observation = new CatalogQueryObservation() const measuredTransaction = async (work: (db: DbClient) => Promise): Promise => { const client = (await databasePool.connect()) as PoolClient const measured: DbClient = { query: async (text, values) => { - if (!/^SET TRANSACTION|set_config\('statement_timeout'/i.test(text.trim())) { - queryCount += 1 - } + observation.observe(text) return client.query(text, values) }, } @@ -882,18 +881,39 @@ describeRealPostgres('aggregate catalog plans on real PostgreSQL', () => { ) expect(first.items).toHaveLength(100) expect(first.nextCursor).not.toBeNull() - expect(queryCount).toBeLessThanOrEqual(40) + expect(observation.workCount).toBeLessThanOrEqual(40) + expect(observation.unexpected).toEqual([]) - queryCount = 0 + const nextObservation = new CatalogQueryObservation() const second = await buildAccessCatalog( { session, families: CATALOG_FAMILIES, limit: 100, cursor: first.nextCursor }, { - transaction: measuredTransaction, + transaction: async work => { + const client = (await databasePool.connect()) as PoolClient + const measured: DbClient = { + query: async (text, values) => { + nextObservation.observe(text) + return client.query(text, values) + }, + } + try { + await client.query('BEGIN') + const value = await work(measured) + await client.query('COMMIT') + return value + } catch (error) { + await client.query('ROLLBACK') + throw error + } finally { + client.release() + } + }, teamGfsMembershipAdmissionLimit: HIGH_TEAMS, } ) expect(second.items).toHaveLength(100) - expect(queryCount).toBeLessThanOrEqual(40) + expect(nextObservation.workCount).toBeLessThanOrEqual(40) + expect(nextObservation.unexpected).toEqual([]) const firstIds = new Set(first.items.map(item => item.resource.canonicalId)) expect(second.items.every(item => !firstIds.has(item.resource.canonicalId))).toBe(true) diff --git a/control-api/test/accessCatalogQueryObservation.test.ts b/control-api/test/accessCatalogQueryObservation.test.ts new file mode 100644 index 0000000000..c5108ee49f --- /dev/null +++ b/control-api/test/accessCatalogQueryObservation.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { + CatalogQueryObservation, + classifyMeasuredCatalogStatement, +} from './accessCatalogQueryObservation.js' + +describe('catalog query observation', () => { + it('excludes only exact access transaction setup statements', () => { + expect( + classifyMeasuredCatalogStatement( + ' SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY ' + ).classification + ).toBe('transaction_setup') + expect( + classifyMeasuredCatalogStatement("SELECT set_config('statement_timeout', $1, true)") + .classification + ).toBe('transaction_setup') + }) + + it('counts work containing a former regex token and surfaces unknown statements', () => { + const observation = new CatalogQueryObservation() + expect(observation.observe("SELECT set_config('statement_timeout', $1, true); SELECT 1")).toBe( + true + ) + expect(observation.observe('SET TRANSACTION READ ONLY')).toBe(true) + expect(observation.workCount).toBe(2) + expect(observation.unexpected.map(statement => statement.text)).toEqual([ + 'SET TRANSACTION READ ONLY', + ]) + }) +}) diff --git a/control-api/test/accessCatalogQueryObservation.ts b/control-api/test/accessCatalogQueryObservation.ts new file mode 100644 index 0000000000..64ff43600d --- /dev/null +++ b/control-api/test/accessCatalogQueryObservation.ts @@ -0,0 +1,43 @@ +const TRANSACTION_SETUP = new Set([ + 'SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY', + "SELECT set_config('statement_timeout', $1, true)", +]) + +export type MeasuredCatalogStatement = Readonly<{ + text: string + classification: 'transaction_setup' | 'catalog_work' | 'unexpected_work' +}> + +function normalize(text: string): string { + return text.trim().replaceAll(/\s+/g, ' ') +} + +export function classifyMeasuredCatalogStatement(text: string): MeasuredCatalogStatement { + const normalized = normalize(text) + if (TRANSACTION_SETUP.has(normalized)) { + return { text: normalized, classification: 'transaction_setup' } + } + if (/^(?:WITH|SELECT|EXPLAIN)\b/i.test(normalized)) { + return { text: normalized, classification: 'catalog_work' } + } + return { text: normalized, classification: 'unexpected_work' } +} + +export class CatalogQueryObservation { + private readonly measured: MeasuredCatalogStatement[] = [] + + observe(text: string): boolean { + const statement = classifyMeasuredCatalogStatement(text) + this.measured.push(statement) + return statement.classification !== 'transaction_setup' + } + + get workCount(): number { + return this.measured.filter(statement => statement.classification !== 'transaction_setup') + .length + } + + get unexpected(): readonly MeasuredCatalogStatement[] { + return this.measured.filter(statement => statement.classification === 'unexpected_work') + } +} From 401a0445ffd4c8d19281f7becb2859b0b463913a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Wed, 12 Aug 2026 13:16:54 -0600 Subject: [PATCH 070/528] fix(access): separate catalog producer support boundaries Finding: R3-M1 Class-check: catalogProducerSupport exports/importers -> 5 responsibility boundaries -> retain facade while extracting database, arms, readiness, and hydration owners. Repro-test: CodeQL alerts 1005-1007 identify the unused import and performance fixture locals at ee12ba3f. --- .../services/access/catalogProducerArms.ts | 61 +++++++ .../access/catalogProducerDatabase.ts | 14 ++ .../access/catalogProducerHydration.ts | 24 +++ .../access/catalogProducerReadiness.ts | 52 ++++++ .../services/access/catalogProducerSupport.ts | 151 +----------------- ...rformance.realPostgres.integration.test.ts | 2 - 6 files changed, 158 insertions(+), 146 deletions(-) create mode 100644 control-api/src/services/access/catalogProducerArms.ts create mode 100644 control-api/src/services/access/catalogProducerDatabase.ts create mode 100644 control-api/src/services/access/catalogProducerHydration.ts create mode 100644 control-api/src/services/access/catalogProducerReadiness.ts diff --git a/control-api/src/services/access/catalogProducerArms.ts b/control-api/src/services/access/catalogProducerArms.ts new file mode 100644 index 0000000000..908a613641 --- /dev/null +++ b/control-api/src/services/access/catalogProducerArms.ts @@ -0,0 +1,61 @@ +import { CatalogProducerContractError } from './catalogProducerSupport.js' + +export type BoundedKeyArm = Readonly<{ + sql: string + orderBy?: string + duplicateCapable?: boolean + hasValidUntil?: boolean +}> + +/** Builds the static, bounded SQL envelope for producer source arms. */ +export function boundedKeyUnionSql(arms: readonly (string | BoundedKeyArm)[]): string { + if (arms.length === 0) throw new CatalogProducerContractError('key_arms_missing') + const names = arms.map((_, index) => `source_${index}`) + const sources = arms + .map((arm, index) => { + const definition = typeof arm === 'string' ? { sql: arm } : arm + const after = `arm_after_${index}` + const sourceSql = definition.sql.replaceAll('$2', `${after}.after_key`).replaceAll( + '$7', + `CASE WHEN POSITION('/' IN ${after}.after_key) > 0 + THEN SPLIT_PART(${after}.after_key, '/', 2) ELSE '' END` + ) + return `${names[index]} AS MATERIALIZED ( + SELECT '${names[index]}'::text AS source_arm, bounded_arm.logical_id, + ${definition.hasValidUntil ? 'bounded_arm.valid_until' : 'NULL::timestamptz'} + AS valid_until + FROM ( + SELECT COALESCE(NULLIF($8::jsonb ->> '${names[index]}', ''), $2) AS after_key + WHERE $9::jsonb IS NULL OR $9::jsonb ? '${names[index]}' + ) ${after} + CROSS JOIN LATERAL (${sourceSql}) bounded_arm + ORDER BY ${definition.orderBy ?? 'logical_id'} + LIMIT $4 + )` + }) + .join(',\n') + const union = names + .map( + (name, index) => `SELECT '${name}'::text AS source_arm, + COALESCE(( + SELECT jsonb_agg(jsonb_build_object( + 'logical_id', logical_id, + 'valid_until', valid_until + ) ORDER BY logical_id) + FROM ${name} + ), '[]'::jsonb) AS source_rows, + ${ + (typeof arms[index] === 'string' ? false : arms[index].duplicateCapable) + ? `(SELECT COUNT(*) FROM ${name}) >= $4` + : 'FALSE' + } AS source_saturated + WHERE $9::jsonb IS NULL OR $9::jsonb ? '${name}'` + ) + .join('\nUNION ALL\n') + return `WITH ${sources} + SELECT source_arm, source_rows, source_saturated + FROM (${union}) bounded_sources + WHERE $1::uuid IS NOT NULL AND $2::text IS NOT NULL AND $3::text IS NOT NULL + AND $5::text IS NOT NULL AND $6::text IS NOT NULL AND $7::text IS NOT NULL + ORDER BY source_arm` +} diff --git a/control-api/src/services/access/catalogProducerDatabase.ts b/control-api/src/services/access/catalogProducerDatabase.ts new file mode 100644 index 0000000000..daf7f86e2b --- /dev/null +++ b/control-api/src/services/access/catalogProducerDatabase.ts @@ -0,0 +1,14 @@ +import type { DbClient } from '../../db.js' +import { runAccessDatabaseQuery } from './accessDatabaseQuery.js' +import type { AccessExecutionBudget } from './accessExecutionBudget.js' + +/** Shared request-scoped database execution boundary for catalog producers. */ +export async function catalogQuery( + db: Pick, + budget: AccessExecutionBudget, + text: string, + values: unknown[], + options: { chargeProducer?: boolean } = {} +) { + return runAccessDatabaseQuery(db, budget, text, values, options) +} diff --git a/control-api/src/services/access/catalogProducerHydration.ts b/control-api/src/services/access/catalogProducerHydration.ts new file mode 100644 index 0000000000..5ae6ef5117 --- /dev/null +++ b/control-api/src/services/access/catalogProducerHydration.ts @@ -0,0 +1,24 @@ +import type { CatalogFamily, CatalogRequestContext } from './catalogContracts.js' +import { CatalogProducerContractError } from './catalogProducerSupport.js' + +/** Validates the ordered canonical keys passed to selected-ID hydration. */ +export function validateHydrationKeys(input: { + context: CatalogRequestContext + family: CatalogFamily + keys: readonly (readonly [string, CatalogFamily, string])[] +}): string[] { + if (input.keys.length > input.context.budget.limits.keyCandidatesPerCall) { + throw new CatalogProducerContractError('hydrate_key_count_exceeded') + } + const logicalIds: string[] = [] + for (const key of input.keys) { + if (key[0] !== input.context.environmentId || key[1] !== input.family || !key[2]) { + throw new CatalogProducerContractError('hydrate_key_mismatch') + } + if (logicalIds.at(-1) !== undefined && key[2] <= logicalIds.at(-1)!) { + throw new CatalogProducerContractError('hydrate_keys_not_strictly_ordered') + } + logicalIds.push(key[2]) + } + return logicalIds +} diff --git a/control-api/src/services/access/catalogProducerReadiness.ts b/control-api/src/services/access/catalogProducerReadiness.ts new file mode 100644 index 0000000000..6ce712c04e --- /dev/null +++ b/control-api/src/services/access/catalogProducerReadiness.ts @@ -0,0 +1,52 @@ +import { revisionOfValues } from './authorizationRevision.js' +import type { + CatalogFamily, + CatalogOperationalSourceState, + CatalogRequestContext, + SafeCatalogPartialError, +} from './catalogContracts.js' +import type { OperationalSourceFamily } from './operationalAccessProjection.js' + +function sourceRevision(states: readonly CatalogOperationalSourceState[]): string { + return revisionOfValues( + states.map(state => [state.family, state.generation, state.resourceVersion, state.status]) + ) +} + +/** Reports whether required operational sources can authoritatively produce a family page. */ +export function operationalReadiness( + context: CatalogRequestContext, + family: CatalogFamily, + required: readonly OperationalSourceFamily[] +): + | Readonly<{ status: 'current'; sourceRevision: string }> + | Readonly<{ + status: 'partial' + sourceRevision: string + errors: readonly SafeCatalogPartialError[] + }> { + if (required.length === 0) return { status: 'current', sourceRevision: 'database' } + const states = required.flatMap(source => { + const value = context.sourceStates.get(source) + return value ? [value] : [] + }) + const errors: SafeCatalogPartialError[] = [] + for (const source of required) { + const state = context.sourceStates.get(source) + if (!state || state.status !== 'current') { + errors.push( + Object.freeze({ + producer: family, + code: + state?.status === 'relisting' + ? 'operational_source_relisting' + : 'operational_source_unavailable', + retryable: true, + }) + ) + } + } + return errors.length === 0 + ? { status: 'current', sourceRevision: sourceRevision(states) } + : { status: 'partial', sourceRevision: sourceRevision(states), errors: Object.freeze(errors) } +} diff --git a/control-api/src/services/access/catalogProducerSupport.ts b/control-api/src/services/access/catalogProducerSupport.ts index 7776b8a25b..e79f860705 100644 --- a/control-api/src/services/access/catalogProducerSupport.ts +++ b/control-api/src/services/access/catalogProducerSupport.ts @@ -1,20 +1,20 @@ -import type { DbClient } from '../../db.js' -import { runAccessDatabaseQuery } from './accessDatabaseQuery.js' -import type { AccessExecutionBudget } from './accessExecutionBudget.js' -import { revisionOfValues } from './authorizationRevision.js' import { type CatalogFamily, type CatalogIdentityCandidate, - type CatalogOperationalSourceState, type CatalogProducerPage, type CatalogRequestContext, type ProducerContinuation, - type SafeCatalogPartialError, catalogKey, - compareCatalogKey, } from './catalogContracts.js' +import { catalogQuery } from './catalogProducerDatabase.js' +import { operationalReadiness } from './catalogProducerReadiness.js' import type { OperationalSourceFamily } from './operationalAccessProjection.js' +export { catalogQuery } from './catalogProducerDatabase.js' +export { validateHydrationKeys } from './catalogProducerHydration.js' +export { type BoundedKeyArm, boundedKeyUnionSql } from './catalogProducerArms.js' +export { operationalReadiness } from './catalogProducerReadiness.js' + export class CatalogProducerContractError extends Error { constructor(readonly code: string) { super(`Catalog producer contract violation: ${code}`) @@ -22,75 +22,6 @@ export class CatalogProducerContractError extends Error { } } -export async function catalogQuery( - db: Pick, - budget: AccessExecutionBudget, - text: string, - values: unknown[], - options: { chargeProducer?: boolean } = {} -) { - return runAccessDatabaseQuery(db, budget, text, values, options) -} - -export type BoundedKeyArm = Readonly<{ - sql: string - orderBy?: string - duplicateCapable?: boolean - hasValidUntil?: boolean -}> - -export function boundedKeyUnionSql(arms: readonly (string | BoundedKeyArm)[]): string { - if (arms.length === 0) throw new CatalogProducerContractError('key_arms_missing') - const names = arms.map((_, index) => `source_${index}`) - const sources = arms - .map((arm, index) => { - const definition = typeof arm === 'string' ? { sql: arm } : arm - const after = `arm_after_${index}` - const sourceSql = definition.sql.replaceAll('$2', `${after}.after_key`).replaceAll( - '$7', - `CASE WHEN POSITION('/' IN ${after}.after_key) > 0 - THEN SPLIT_PART(${after}.after_key, '/', 2) ELSE '' END` - ) - return `${names[index]} AS MATERIALIZED ( - SELECT '${names[index]}'::text AS source_arm, bounded_arm.logical_id, - ${definition.hasValidUntil ? 'bounded_arm.valid_until' : 'NULL::timestamptz'} - AS valid_until - FROM ( - SELECT COALESCE(NULLIF($8::jsonb ->> '${names[index]}', ''), $2) AS after_key - WHERE $9::jsonb IS NULL OR $9::jsonb ? '${names[index]}' - ) ${after} - CROSS JOIN LATERAL (${sourceSql}) bounded_arm - ORDER BY ${definition.orderBy ?? 'logical_id'} - LIMIT $4 - )` - }) - .join(',\n') - const union = names - .map( - (name, index) => `SELECT '${name}'::text AS source_arm, - COALESCE(( - SELECT jsonb_agg(jsonb_build_object( - 'logical_id', logical_id, - 'valid_until', valid_until - ) ORDER BY logical_id) - FROM ${name} - ), '[]'::jsonb) AS source_rows, - ${ - (typeof arms[index] === 'string' ? false : arms[index].duplicateCapable) - ? `(SELECT COUNT(*) FROM ${name}) >= $4` - : 'FALSE' - } AS source_saturated - WHERE $9::jsonb IS NULL OR $9::jsonb ? '${name}'` - ) - .join('\nUNION ALL\n') - return `WITH ${sources} - SELECT source_arm, source_rows, source_saturated - FROM (${union}) bounded_sources - WHERE $1::uuid IS NOT NULL AND $2::text IS NOT NULL AND $3::text IS NOT NULL - AND $5::text IS NOT NULL AND $6::text IS NOT NULL AND $7::text IS NOT NULL - ORDER BY source_arm` -} - function validateContinuation( family: CatalogFamily, environmentId: string, @@ -105,53 +36,6 @@ function validateContinuation( return logicalId } -function sourceRevision(states: readonly CatalogOperationalSourceState[]): string { - return revisionOfValues( - states.map(state => [state.family, state.generation, state.resourceVersion, state.status]) - ) -} - -export function operationalReadiness( - context: CatalogRequestContext, - family: CatalogFamily, - required: readonly OperationalSourceFamily[] -): - | Readonly<{ status: 'current'; sourceRevision: string }> - | Readonly<{ - status: 'partial' - sourceRevision: string - errors: readonly SafeCatalogPartialError[] - }> { - if (required.length === 0) return { status: 'current', sourceRevision: 'database' } - const states = required.flatMap(source => { - const value = context.sourceStates.get(source) - return value ? [value] : [] - }) - const errors: SafeCatalogPartialError[] = [] - for (const source of required) { - const state = context.sourceStates.get(source) - if (!state || state.status !== 'current') { - errors.push( - Object.freeze({ - producer: family, - code: - state?.status === 'relisting' - ? 'operational_source_relisting' - : 'operational_source_unavailable', - retryable: true, - }) - ) - } - } - return errors.length === 0 - ? { status: 'current', sourceRevision: sourceRevision(states) } - : { - status: 'partial', - sourceRevision: sourceRevision(states), - errors: Object.freeze(errors), - } -} - export async function listBoundedProducerKeys(input: { context: CatalogRequestContext family: CatalogFamily @@ -326,24 +210,3 @@ export async function listBoundedProducerKeys(input: { partialErrors: Object.freeze([]), }) } - -export function validateHydrationKeys(input: { - context: CatalogRequestContext - family: CatalogFamily - keys: readonly (readonly [string, CatalogFamily, string])[] -}): string[] { - if (input.keys.length > input.context.budget.limits.keyCandidatesPerCall) { - throw new CatalogProducerContractError('hydrate_key_count_exceeded') - } - const logicalIds: string[] = [] - for (const key of input.keys) { - if (key[0] !== input.context.environmentId || key[1] !== input.family || !key[2]) { - throw new CatalogProducerContractError('hydrate_key_mismatch') - } - if (logicalIds.at(-1) !== undefined && key[2] <= logicalIds.at(-1)!) { - throw new CatalogProducerContractError('hydrate_keys_not_strictly_ordered') - } - logicalIds.push(key[2]) - } - return logicalIds -} diff --git a/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts b/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts index 2f08b2c42b..2006388e5b 100644 --- a/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts @@ -140,7 +140,6 @@ describeRealPostgres('aggregate catalog plans on real PostgreSQL', () => { async function seedDirect(first: number, last: number): Promise { if (first > last) return const seriesValues = [userId, first, last] - const values = [...seriesValues, environmentId] await databasePool.query( `WITH requester AS (SELECT $1::uuid AS user_id), ids AS ( SELECT value, LPAD(value::text, 5, '0') AS suffix @@ -319,7 +318,6 @@ describeRealPostgres('aggregate catalog plans on real PostgreSQL', () => { async function seedTeams(teamIds: readonly string[], resourcesPerTeam: number): Promise { if (teamIds.length === 0) return const seriesValues = [teamIds, userId, resourcesPerTeam] - const values = [...seriesValues, environmentId] await databasePool.query( `WITH requester AS (SELECT $2::uuid AS user_id), requested AS ( SELECT team_id, ordinal FROM UNNEST($1::uuid[]) WITH ORDINALITY value(team_id, ordinal) From 717ddac50cc32b149e8f36bc30dfdf1ba0516cf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Wed, 12 Aug 2026 13:24:46 -0600 Subject: [PATCH 071/528] fix(access): gate renewal before session rotation Finding: R3-H4 Class-check: session renewal -> 1 mutation route -> authenticate and limit before renewal Repro-test: npm test -- --run test/routes.externalSessionRenewalRateLimit.test.ts fails at 81350d00 --- ...es.externalSessionRenewalRateLimit.test.ts | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 control-api/test/routes.externalSessionRenewalRateLimit.test.ts diff --git a/control-api/test/routes.externalSessionRenewalRateLimit.test.ts b/control-api/test/routes.externalSessionRenewalRateLimit.test.ts new file mode 100644 index 0000000000..6be6c9fd57 --- /dev/null +++ b/control-api/test/routes.externalSessionRenewalRateLimit.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import request from 'supertest' +import { createExternalAuthRouter } from '../src/routes/external/auth.js' + +const sessions = vi.hoisted(() => ({ + authenticateExternalUserSession: vi.fn(), + renewExternalUserSession: vi.fn(), +})) + +const limiter = vi.hoisted(() => ({ checkAndIncrement: vi.fn() })) + +vi.mock('../src/services/auth/externalSessionAuthentication.js', () => sessions) +vi.mock('../src/services/rateLimiterService.js', () => limiter) + +function app() { + const value = express() + value.use(express.json()) + value.use(createExternalAuthRouter({} as never)) + return value +} + +describe('external session renewal rate limiting', () => { + beforeEach(() => { + vi.clearAllMocks() + sessions.authenticateExternalUserSession.mockResolvedValue({ + status: 'authenticated', + contract: 'v2', + claims: { + userId: 'user-1', + email: 'user@example.test', + teamId: null, + role: 'member', + }, + authorityContext: { contract: 'v2', userId: 'user-1', sid: 'sid-1', jti: 'jti-1' }, + policy: {}, + }) + sessions.renewExternalUserSession.mockResolvedValue({ + status: 'renewed', + session: { + token: 'rotated-session', + expiresInSeconds: 3600, + identity: { absoluteExpiresAt: new Date('2030-01-01T00:00:00.000Z') }, + }, + }) + limiter.checkAndIncrement + .mockResolvedValueOnce({ + allowed: true, + remaining: 9, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + count: 1, + }) + .mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + count: 11, + }) + }) + + it('stops a rate-limited authenticated renewal before rotating the session', async () => { + const response = await request(app()) + .post('/external/auth/session/renew') + .set('x-forwarded-for', '203.0.113.44') + .send({ token: 'session-token' }) + + expect(response.status).toBe(429) + expect(response.body).toMatchObject({ error: { code: 'rate_limited' } }) + expect(response.headers['retry-after']).toBeDefined() + expect(sessions.authenticateExternalUserSession).toHaveBeenCalledWith( + 'session-token', + expect.objectContaining({ purpose: 'renew' }) + ) + expect(limiter.checkAndIncrement).toHaveBeenNthCalledWith( + 2, + 'external_session_lifecycle:user:user-1', + 10 + ) + expect(sessions.renewExternalUserSession).not.toHaveBeenCalled() + }) +}) From 02cfb953741654bae7f6e8cfcd31ce9504895a2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Wed, 12 Aug 2026 13:26:58 -0600 Subject: [PATCH 072/528] test(access): cover external user rate-limit composition Finding: R3-H5 Class-check: external policy operations -> 5 policies, 3 route compositions -> enforce server-derived keys and stop handlers Repro-test: focused route and policy tests would not exercise the R3 limiters before this coverage --- .../test/externalUserRateLimitPolicy.test.ts | 74 +++++++++++++++++++ .../test/routes.externalAccess.test.ts | 33 +++++++++ ...es.externalInvitations.unavailable.test.ts | 31 ++++++++ 3 files changed, 138 insertions(+) create mode 100644 control-api/test/externalUserRateLimitPolicy.test.ts diff --git a/control-api/test/externalUserRateLimitPolicy.test.ts b/control-api/test/externalUserRateLimitPolicy.test.ts new file mode 100644 index 0000000000..08441ab6c4 --- /dev/null +++ b/control-api/test/externalUserRateLimitPolicy.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest' +import express from 'express' +import request from 'supertest' +import { + authenticatedExternalUserRateLimit, + preAuthExternalUserRateLimit, +} from '../src/middleware/externalUserRateLimitPolicy.js' + +const limiter = vi.hoisted(() => ({ checkAndIncrement: vi.fn() })) +vi.mock('../src/services/rateLimiterService.js', () => limiter) + +const operations = [ + ['session_lifecycle', 'external_session_lifecycle'], + ['invitation_mutation', 'external_invitation_mutation'], + ['invitation_sensitive_action', 'external_invitation_sensitive_action'], + ['invitation_read', 'external_invitation_read'], + ['access_capabilities', 'external_access_capabilities'], +] as const + +function allowedResult() { + return { + allowed: true, + remaining: 9, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + count: 1, + } +} + +describe('external user rate-limit policy', () => { + it.each(operations)( + 'keys authenticated %s by the server-authenticated user', + async (operation, bucket) => { + limiter.checkAndIncrement.mockResolvedValueOnce(allowedResult()) + const app = express() + app.use((req, _res, next) => { + ;(req as express.Request & { externalAuth?: { userId: string } }).externalAuth = { + userId: 'server-user', + } + next() + }) + app.get('/protected', authenticatedExternalUserRateLimit(operation), (_req, res) => { + res.status(204).end() + }) + + await request(app) + .get('/protected') + .set('x-user-session-token', 'never-used-as-a-key') + .expect(204) + + expect(limiter.checkAndIncrement).toHaveBeenLastCalledWith(`${bucket}:user:server-user`, 10) + } + ) + + it('uses a bounded IP fallback before authentication without reading the session token', async () => { + limiter.checkAndIncrement.mockResolvedValueOnce(allowedResult()) + const app = express() + app.get('/pre-auth', preAuthExternalUserRateLimit('session_lifecycle'), (_req, res) => { + res.status(204).end() + }) + + await request(app) + .get('/pre-auth') + .set('x-forwarded-for', '198.51.100.52') + .set('x-user-session-token', 'never-used-as-a-key') + .expect(204) + + expect(limiter.checkAndIncrement).toHaveBeenLastCalledWith( + expect.stringMatching(/^external_session_lifecycle:ip:/), + 10 + ) + expect(limiter.checkAndIncrement.mock.calls.at(-1)?.[0]).not.toContain('never-used-as-a-key') + }) +}) diff --git a/control-api/test/routes.externalAccess.test.ts b/control-api/test/routes.externalAccess.test.ts index 5463c3cae9..63037597b4 100644 --- a/control-api/test/routes.externalAccess.test.ts +++ b/control-api/test/routes.externalAccess.test.ts @@ -17,6 +17,8 @@ const mocks = vi.hoisted(() => ({ resolveAuthorization: vi.fn(), })) +const rateLimiter = vi.hoisted(() => ({ checkAndIncrement: vi.fn() })) + vi.mock('../src/utils/auth/externalSessionAuthToken.js', () => ({ verifyExternalSessionToken: mocks.verifyV1, })) @@ -37,6 +39,7 @@ vi.mock('../src/services/access/userAccessRuntimePolicy.js', () => ({ vi.mock('../src/services/access/liveAuthorizationResolver.js', () => ({ resolveLiveAuthorization: mocks.resolveAuthorization, })) +vi.mock('../src/services/rateLimiterService.js', () => rateLimiter) function effectivePolicy(overrides: Record = {}) { return { @@ -96,6 +99,13 @@ describe('external user-access contracts', () => { sessionVersion: 1, }, }) + rateLimiter.checkAndIncrement.mockResolvedValue({ + allowed: true, + remaining: 9, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + count: 1, + }) }) it('advertises only the effective reconstruction defaults', async () => { @@ -173,6 +183,29 @@ describe('external user-access contracts', () => { expect(response.body.error.code).toBe('invalid_session') }) + it('stops a limited capability read before resolving the capability manifest', async () => { + rateLimiter.checkAndIncrement.mockResolvedValue({ + allowed: false, + remaining: 0, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + count: 11, + }) + + const response = await request(app()) + .get('/external/access/capabilities') + .set('x-user-session-token', 'v1-session') + + expect(response.status).toBe(429) + expect(response.body).toMatchObject({ error: { code: 'rate_limited' } }) + expect(response.headers['retry-after']).toBeDefined() + expect(rateLimiter.checkAndIncrement).toHaveBeenCalledWith( + 'external_access_capabilities:user:10000000-0000-4000-8000-000000000001', + 10 + ) + expect(mocks.resolvePolicy).toHaveBeenCalledTimes(1) + }) + it('uses the authoritative target schema for team management actions', async () => { const teamId = '40000000-0000-4000-8000-000000000004' const pathId = `ap1_${'A'.repeat(43)}` diff --git a/control-api/test/routes.externalInvitations.unavailable.test.ts b/control-api/test/routes.externalInvitations.unavailable.test.ts index 553515080e..4e2e3db72a 100644 --- a/control-api/test/routes.externalInvitations.unavailable.test.ts +++ b/control-api/test/routes.externalInvitations.unavailable.test.ts @@ -29,6 +29,9 @@ const database = vi.hoisted(() => ({ })) vi.mock('../src/db.js', () => ({ withTransaction: database.withTransaction })) +const rateLimiter = vi.hoisted(() => ({ checkAndIncrement: vi.fn() })) +vi.mock('../src/services/rateLimiterService.js', () => rateLimiter) + vi.mock('../src/middleware/rateLimitMiddleware.js', () => ({ rateLimitMiddleware: () => (_req: express.Request, _res: express.Response, next: express.NextFunction) => @@ -82,6 +85,13 @@ describe('external invitation routes when the hub is unavailable', () => { beforeEach(() => { vi.clearAllMocks() database.withTransaction.mockImplementation(async work => work({ query: database.query })) + rateLimiter.checkAndIncrement.mockResolvedValue({ + allowed: true, + remaining: 9, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + count: 1, + }) }) it('GET token lookup returns 503 member_registration_unavailable, NOT 400 invalid_invitation', async () => { @@ -183,6 +193,27 @@ describe('external invitation routes when the hub is unavailable', () => { expect(directory.listPendingInvitations).not.toHaveBeenCalledWith('victim@example.com') }) + it('stops an authenticated pending-invitation read before the directory handler', async () => { + rateLimiter.checkAndIncrement.mockResolvedValue({ + allowed: false, + remaining: 0, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + count: 11, + }) + + const response = await request(app()).get('/external/invitations/pending') + + expect(response.status).toBe(429) + expect(response.body).toMatchObject({ error: { code: 'rate_limited' } }) + expect(response.headers['retry-after']).toBeDefined() + expect(rateLimiter.checkAndIncrement).toHaveBeenCalledWith( + 'external_invitation_read:user:caller-user', + 10 + ) + expect(directory.listPendingInvitations).not.toHaveBeenCalled() + }) + it('REGRESSION: a generic validation error still maps to 400 invalid_invitation', async () => { flow.validateInvitationFlowToken.mockRejectedValue(new Error('invalid_invitation')) const res = await request(app()).get('/external/invitations/token/some-token') From 375f4d38e16a750181cd3355a49f285fc715da3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Wed, 12 Aug 2026 13:32:14 -0600 Subject: [PATCH 073/528] fix(access): r3-m3 extract producer-head owner Finding: R3-M3 Class-check: catalogProducerSupport exports -> 24 importers -> retain a facade while moving head and errors to focused owners Repro-test: support responsibility map at 81350d00 retains producer-head execution despite the approved extraction --- .../services/access/catalogProducerArms.ts | 2 +- .../services/access/catalogProducerErrors.ts | 7 + .../services/access/catalogProducerHead.ts | 202 ++++++++++++++++ .../access/catalogProducerHydration.ts | 2 +- .../services/access/catalogProducerSupport.ts | 216 +----------------- 5 files changed, 218 insertions(+), 211 deletions(-) create mode 100644 control-api/src/services/access/catalogProducerErrors.ts create mode 100644 control-api/src/services/access/catalogProducerHead.ts diff --git a/control-api/src/services/access/catalogProducerArms.ts b/control-api/src/services/access/catalogProducerArms.ts index 908a613641..1dae6b5470 100644 --- a/control-api/src/services/access/catalogProducerArms.ts +++ b/control-api/src/services/access/catalogProducerArms.ts @@ -1,4 +1,4 @@ -import { CatalogProducerContractError } from './catalogProducerSupport.js' +import { CatalogProducerContractError } from './catalogProducerErrors.js' export type BoundedKeyArm = Readonly<{ sql: string diff --git a/control-api/src/services/access/catalogProducerErrors.ts b/control-api/src/services/access/catalogProducerErrors.ts new file mode 100644 index 0000000000..af05d595e8 --- /dev/null +++ b/control-api/src/services/access/catalogProducerErrors.ts @@ -0,0 +1,7 @@ +/** Typed contract failures shared by the catalog-producer boundaries. */ +export class CatalogProducerContractError extends Error { + constructor(readonly code: string) { + super(`Catalog producer contract violation: ${code}`) + this.name = 'CatalogProducerContractError' + } +} diff --git a/control-api/src/services/access/catalogProducerHead.ts b/control-api/src/services/access/catalogProducerHead.ts new file mode 100644 index 0000000000..31860af03a --- /dev/null +++ b/control-api/src/services/access/catalogProducerHead.ts @@ -0,0 +1,202 @@ +import { + type CatalogFamily, + type CatalogIdentityCandidate, + type CatalogProducerPage, + type CatalogRequestContext, + type ProducerContinuation, + catalogKey, +} from './catalogContracts.js' +import { catalogQuery } from './catalogProducerDatabase.js' +import { CatalogProducerContractError } from './catalogProducerErrors.js' +import { operationalReadiness } from './catalogProducerReadiness.js' +import type { OperationalSourceFamily } from './operationalAccessProjection.js' + +function validateContinuation( + family: CatalogFamily, + environmentId: string, + continuation: ProducerContinuation +): string { + if (continuation.exhausted) return '' + if (!continuation.afterKey) return '' + const [cursorEnvironment, cursorFamily, logicalId] = continuation.afterKey + if (cursorEnvironment !== environmentId || cursorFamily !== family || !logicalId) { + throw new CatalogProducerContractError('continuation_family_mismatch') + } + return logicalId +} + +/** Builds a bounded, private per-arm producer head for one catalog family. */ +export async function listBoundedProducerKeys(input: { + context: CatalogRequestContext + family: CatalogFamily + requiredOperationalSources: readonly OperationalSourceFamily[] + continuation: ProducerContinuation + take: number + sql: string + extraValues?: readonly unknown[] | ((after: string) => readonly unknown[]) +}): Promise { + const { context } = input + if ( + !Number.isSafeInteger(input.take) || + input.take < 1 || + input.take > context.budget.limits.publicPageSize || + input.take + 1 > context.budget.limits.keyCandidatesPerCall + ) { + throw new CatalogProducerContractError('take_invalid') + } + const readiness = operationalReadiness(context, input.family, input.requiredOperationalSources) + if (readiness.status === 'partial') { + return Object.freeze({ + candidates: Object.freeze([]), + continuation: Object.freeze({ afterKey: input.continuation.afterKey, exhausted: false }), + hasMore: false, + sourceRevision: readiness.sourceRevision, + sourceCompleteness: 'partial', + partialErrors: readiness.errors, + }) + } + if (input.continuation.exhausted) { + return Object.freeze({ + candidates: Object.freeze([]), + continuation: input.continuation, + hasMore: false, + sourceRevision: readiness.sourceRevision, + sourceCompleteness: 'complete', + partialErrors: Object.freeze([]), + }) + } + const after = validateContinuation(input.family, context.environmentId, input.continuation) + type ArmCandidate = Readonly<{ logicalId: string; validUntil: string | null }> + type ArmState = { after: string; exhausted: boolean; candidates: ArmCandidate[] } + const arms = new Map() + const candidates: CatalogIdentityCandidate[] = [] + let firstQuery = true + let loaded = false + const refill = async (armNames: readonly string[] | null) => { + const result = await catalogQuery( + context.db, + context.budget, + input.sql, + [ + context.principal.userId, + after, + context.environmentId, + input.take + 1, + ...(typeof input.extraValues === 'function' + ? input.extraValues(after) + : (input.extraValues ?? [])), + JSON.stringify(Object.fromEntries([...arms].map(([name, arm]) => [name, arm.after]))), + armNames === null + ? null + : JSON.stringify(Object.fromEntries(armNames.map(name => [name, true]))), + ], + { chargeProducer: firstQuery } + ) + firstQuery = false + const rawByArm = new Map() + const saturation = new Map() + for (const row of result.rows as Record[]) { + const armName = typeof row.source_arm === 'string' ? row.source_arm : '' + if (!armName) throw new CatalogProducerContractError('source_arm_invalid') + if (typeof row.source_saturated !== 'boolean') { + throw new CatalogProducerContractError('source_saturation_invalid') + } + saturation.set(armName, row.source_saturated) + if (!Array.isArray(row.source_rows)) { + throw new CatalogProducerContractError('source_rows_invalid') + } + const state = arms.get(armName) ?? { after, exhausted: false, candidates: [] } + const values: ArmCandidate[] = [] + for (const sourceRow of row.source_rows) { + if (!sourceRow || typeof sourceRow !== 'object' || Array.isArray(sourceRow)) { + throw new CatalogProducerContractError('source_rows_invalid') + } + const sourceValue = sourceRow as Record + const logicalId = typeof sourceValue.logical_id === 'string' ? sourceValue.logical_id : '' + if (!logicalId || logicalId <= state.after) { + throw new CatalogProducerContractError('keys_not_strictly_ordered') + } + let validUntil: string | null = null + if (sourceValue.valid_until !== null && sourceValue.valid_until !== undefined) { + const timestamp = new Date(String(sourceValue.valid_until)) + if (Number.isNaN(timestamp.getTime())) { + throw new CatalogProducerContractError('candidate_valid_until_invalid') + } + validUntil = timestamp.toISOString() + } + const previous = values.at(-1) + if (!previous || previous.logicalId !== logicalId) values.push({ logicalId, validUntil }) + else if ( + previous.validUntil === null || + (validUntil !== null && validUntil < previous.validUntil) + ) { + values[values.length - 1] = { logicalId, validUntil } + } + } + rawByArm.set(armName, values) + } + for (const [armName, saturated] of saturation) { + const state = arms.get(armName) ?? { after, exhausted: false, candidates: [] } + if (state.candidates.length === 0) state.candidates = rawByArm.get(armName) ?? [] + state.exhausted = !saturated + arms.set(armName, state) + } + loaded = true + } + await refill(null) + while (candidates.length < input.take + 1) { + const heads = [...arms.entries()] + .filter(([, state]) => state.candidates.length > 0) + .map(([name, state]) => ({ name, state, candidate: state.candidates[0] })) + if (heads.length === 0) { + const refillable = [...arms.values()].some(state => !state.exhausted) + if (!refillable) break + await refill( + [...arms.entries()] + .filter(([, state]) => !state.exhausted && state.candidates.length === 0) + .map(([name]) => name) + ) + continue + } + const logicalId = heads.map(head => head.candidate.logicalId).sort()[0] + const contributors = heads.filter(head => head.candidate.logicalId === logicalId) + const validUntil = + contributors + .map(head => head.candidate.validUntil) + .filter((value): value is string => value !== null) + .sort()[0] ?? null + candidates.push( + Object.freeze({ + key: catalogKey(context.environmentId, input.family, logicalId), + canonicalId: `${input.family}:${logicalId}`, + validUntil, + }) + ) + for (const { state } of contributors) { + const consumed = state.candidates.shift()! + state.after = consumed.logicalId + } + if (contributors.some(({ state }) => state.candidates.length === 0 && !state.exhausted)) { + await refill( + contributors + .filter(({ state }) => state.candidates.length === 0 && !state.exhausted) + .map(({ name }) => name) + ) + } + } + if (!loaded) throw new CatalogProducerContractError('source_arms_missing') + const hasMore = + candidates.length > input.take || + [...arms.values()].some(state => !state.exhausted || state.candidates.length > 0) + return Object.freeze({ + candidates: Object.freeze(candidates), + continuation: Object.freeze({ + afterKey: candidates.at(-1)?.key ?? input.continuation.afterKey, + exhausted: !hasMore, + }), + hasMore, + sourceRevision: readiness.sourceRevision, + sourceCompleteness: 'complete', + partialErrors: Object.freeze([]), + }) +} diff --git a/control-api/src/services/access/catalogProducerHydration.ts b/control-api/src/services/access/catalogProducerHydration.ts index 5ae6ef5117..36e9528d3d 100644 --- a/control-api/src/services/access/catalogProducerHydration.ts +++ b/control-api/src/services/access/catalogProducerHydration.ts @@ -1,5 +1,5 @@ import type { CatalogFamily, CatalogRequestContext } from './catalogContracts.js' -import { CatalogProducerContractError } from './catalogProducerSupport.js' +import { CatalogProducerContractError } from './catalogProducerErrors.js' /** Validates the ordered canonical keys passed to selected-ID hydration. */ export function validateHydrationKeys(input: { diff --git a/control-api/src/services/access/catalogProducerSupport.ts b/control-api/src/services/access/catalogProducerSupport.ts index e79f860705..ba3600d313 100644 --- a/control-api/src/services/access/catalogProducerSupport.ts +++ b/control-api/src/services/access/catalogProducerSupport.ts @@ -1,212 +1,10 @@ -import { - type CatalogFamily, - type CatalogIdentityCandidate, - type CatalogProducerPage, - type CatalogRequestContext, - type ProducerContinuation, - catalogKey, -} from './catalogContracts.js' -import { catalogQuery } from './catalogProducerDatabase.js' -import { operationalReadiness } from './catalogProducerReadiness.js' -import type { OperationalSourceFamily } from './operationalAccessProjection.js' - +/** + * Compatibility facade for catalog-producer callers. Each responsibility has + * a focused owner; existing imports remain stable while callers migrate. + */ +export { type BoundedKeyArm, boundedKeyUnionSql } from './catalogProducerArms.js' export { catalogQuery } from './catalogProducerDatabase.js' +export { CatalogProducerContractError } from './catalogProducerErrors.js' +export { listBoundedProducerKeys } from './catalogProducerHead.js' export { validateHydrationKeys } from './catalogProducerHydration.js' -export { type BoundedKeyArm, boundedKeyUnionSql } from './catalogProducerArms.js' export { operationalReadiness } from './catalogProducerReadiness.js' - -export class CatalogProducerContractError extends Error { - constructor(readonly code: string) { - super(`Catalog producer contract violation: ${code}`) - this.name = 'CatalogProducerContractError' - } -} - -function validateContinuation( - family: CatalogFamily, - environmentId: string, - continuation: ProducerContinuation -): string { - if (continuation.exhausted) return '' - if (!continuation.afterKey) return '' - const [cursorEnvironment, cursorFamily, logicalId] = continuation.afterKey - if (cursorEnvironment !== environmentId || cursorFamily !== family || !logicalId) { - throw new CatalogProducerContractError('continuation_family_mismatch') - } - return logicalId -} - -export async function listBoundedProducerKeys(input: { - context: CatalogRequestContext - family: CatalogFamily - requiredOperationalSources: readonly OperationalSourceFamily[] - continuation: ProducerContinuation - take: number - sql: string - extraValues?: readonly unknown[] | ((after: string) => readonly unknown[]) -}): Promise { - const { context } = input - if ( - !Number.isSafeInteger(input.take) || - input.take < 1 || - input.take > context.budget.limits.publicPageSize || - input.take + 1 > context.budget.limits.keyCandidatesPerCall - ) { - throw new CatalogProducerContractError('take_invalid') - } - const readiness = operationalReadiness(context, input.family, input.requiredOperationalSources) - if (readiness.status === 'partial') { - return Object.freeze({ - candidates: Object.freeze([]), - continuation: Object.freeze({ afterKey: input.continuation.afterKey, exhausted: false }), - hasMore: false, - sourceRevision: readiness.sourceRevision, - sourceCompleteness: 'partial', - partialErrors: readiness.errors, - }) - } - if (input.continuation.exhausted) { - return Object.freeze({ - candidates: Object.freeze([]), - continuation: input.continuation, - hasMore: false, - sourceRevision: readiness.sourceRevision, - sourceCompleteness: 'complete', - partialErrors: Object.freeze([]), - }) - } - const after = validateContinuation(input.family, context.environmentId, input.continuation) - type ArmCandidate = Readonly<{ logicalId: string; validUntil: string | null }> - type ArmState = { after: string; exhausted: boolean; candidates: ArmCandidate[] } - const arms = new Map() - const candidates: CatalogIdentityCandidate[] = [] - let firstQuery = true - let loaded = false - const refill = async (armNames: readonly string[] | null) => { - const result = await catalogQuery( - context.db, - context.budget, - input.sql, - [ - context.principal.userId, - after, - context.environmentId, - input.take + 1, - ...(typeof input.extraValues === 'function' - ? input.extraValues(after) - : (input.extraValues ?? [])), - JSON.stringify(Object.fromEntries([...arms].map(([name, arm]) => [name, arm.after]))), - armNames === null - ? null - : JSON.stringify(Object.fromEntries(armNames.map(name => [name, true]))), - ], - { chargeProducer: firstQuery } - ) - firstQuery = false - const rawByArm = new Map() - const saturation = new Map() - for (const row of result.rows as Record[]) { - const armName = typeof row.source_arm === 'string' ? row.source_arm : '' - if (!armName) throw new CatalogProducerContractError('source_arm_invalid') - if (typeof row.source_saturated !== 'boolean') { - throw new CatalogProducerContractError('source_saturation_invalid') - } - saturation.set(armName, row.source_saturated) - if (!Array.isArray(row.source_rows)) { - throw new CatalogProducerContractError('source_rows_invalid') - } - const state = arms.get(armName) ?? { after, exhausted: false, candidates: [] } - const values: ArmCandidate[] = [] - for (const sourceRow of row.source_rows) { - if (!sourceRow || typeof sourceRow !== 'object' || Array.isArray(sourceRow)) { - throw new CatalogProducerContractError('source_rows_invalid') - } - const sourceValue = sourceRow as Record - const logicalId = typeof sourceValue.logical_id === 'string' ? sourceValue.logical_id : '' - if (!logicalId || logicalId <= state.after) { - throw new CatalogProducerContractError('keys_not_strictly_ordered') - } - let validUntil: string | null = null - if (sourceValue.valid_until !== null && sourceValue.valid_until !== undefined) { - const timestamp = new Date(String(sourceValue.valid_until)) - if (Number.isNaN(timestamp.getTime())) { - throw new CatalogProducerContractError('candidate_valid_until_invalid') - } - validUntil = timestamp.toISOString() - } - const previous = values.at(-1) - if (!previous || previous.logicalId !== logicalId) values.push({ logicalId, validUntil }) - else if ( - previous.validUntil === null || - (validUntil !== null && validUntil < previous.validUntil) - ) { - values[values.length - 1] = { logicalId, validUntil } - } - } - rawByArm.set(armName, values) - } - for (const [armName, saturated] of saturation) { - const state = arms.get(armName) ?? { after, exhausted: false, candidates: [] } - if (state.candidates.length === 0) state.candidates = rawByArm.get(armName) ?? [] - state.exhausted = !saturated - arms.set(armName, state) - } - loaded = true - } - await refill(null) - while (candidates.length < input.take + 1) { - const heads = [...arms.entries()] - .filter(([, state]) => state.candidates.length > 0) - .map(([name, state]) => ({ name, state, candidate: state.candidates[0] })) - if (heads.length === 0) { - const refillable = [...arms.values()].some(state => !state.exhausted) - if (!refillable) break - await refill( - [...arms.entries()] - .filter(([, state]) => !state.exhausted && state.candidates.length === 0) - .map(([name]) => name) - ) - continue - } - const logicalId = heads.map(head => head.candidate.logicalId).sort()[0] - const contributors = heads.filter(head => head.candidate.logicalId === logicalId) - const validUntil = - contributors - .map(head => head.candidate.validUntil) - .filter((value): value is string => value !== null) - .sort()[0] ?? null - candidates.push( - Object.freeze({ - key: catalogKey(context.environmentId, input.family, logicalId), - canonicalId: `${input.family}:${logicalId}`, - validUntil, - }) - ) - for (const { state } of contributors) { - const consumed = state.candidates.shift()! - state.after = consumed.logicalId - } - if (contributors.some(({ state }) => state.candidates.length === 0 && !state.exhausted)) { - await refill( - contributors - .filter(({ state }) => state.candidates.length === 0 && !state.exhausted) - .map(({ name }) => name) - ) - } - } - if (!loaded) throw new CatalogProducerContractError('source_arms_missing') - const hasMore = - candidates.length > input.take || - [...arms.values()].some(state => !state.exhausted || state.candidates.length > 0) - return Object.freeze({ - candidates: Object.freeze(candidates), - continuation: Object.freeze({ - afterKey: candidates.at(-1)?.key ?? input.continuation.afterKey, - exhausted: !hasMore, - }), - hasMore, - sourceRevision: readiness.sourceRevision, - sourceCompleteness: 'complete', - partialErrors: Object.freeze([]), - }) -} From 67c0ebd4c8f338b28b0e59fc983c86cbe76faf55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Wed, 12 Aug 2026 13:38:07 -0600 Subject: [PATCH 074/528] fix(access): r3-h7 preserve client IP rate isolation Finding: R3-H7 Class-check: session lifecycle callers -> renew, logout, create-team, switch-team -> forward client IP only from external-rest-api Repro-test: external-user client IP policy test fails at c0603fcd because control-api buckets the proxy IP --- .../middleware/externalUserRateLimitPolicy.ts | 6 +- .../test/externalUserRateLimitPolicy.test.ts | 38 ++++++++++ external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- external-rest-api/src/routes/auth.ts | 4 +- external-rest-api/src/routes/me.ts | 2 +- external-rest-api/src/routes/team.ts | 2 +- external-rest-api/src/services/authService.ts | 12 +++- external-rest-api/src/services/meService.ts | 4 +- external-rest-api/src/services/teamService.ts | 4 +- .../services.externalUserClientIp.test.ts | 72 +++++++++++++++++++ 11 files changed, 138 insertions(+), 12 deletions(-) create mode 100644 external-rest-api/test/services.externalUserClientIp.test.ts diff --git a/control-api/src/middleware/externalUserRateLimitPolicy.ts b/control-api/src/middleware/externalUserRateLimitPolicy.ts index 95aeda1c48..65c9361c36 100644 --- a/control-api/src/middleware/externalUserRateLimitPolicy.ts +++ b/control-api/src/middleware/externalUserRateLimitPolicy.ts @@ -29,7 +29,11 @@ const POLICIES: Readonly> = { } function boundedIp(req: Request): string { - const value = req.ip ?? req.socket?.remoteAddress ?? 'unknown' + const forwardedClientIp = + req.internalService?.name === 'external-rest-api' + ? String(req.header('x-evenfire-client-ip') || '').trim() + : '' + const value = forwardedClientIp || req.ip || req.socket?.remoteAddress || 'unknown' return String(value).slice(0, 128) } diff --git a/control-api/test/externalUserRateLimitPolicy.test.ts b/control-api/test/externalUserRateLimitPolicy.test.ts index 08441ab6c4..5717ab3919 100644 --- a/control-api/test/externalUserRateLimitPolicy.test.ts +++ b/control-api/test/externalUserRateLimitPolicy.test.ts @@ -71,4 +71,42 @@ describe('external user rate-limit policy', () => { ) expect(limiter.checkAndIncrement.mock.calls.at(-1)?.[0]).not.toContain('never-used-as-a-key') }) + + it('uses the client IP forwarded only by the authenticated external REST service', async () => { + limiter.checkAndIncrement.mockResolvedValueOnce(allowedResult()) + const app = express() + app.use((req, _res, next) => { + req.internalService = { name: 'external-rest-api' } + next() + }) + app.get('/pre-auth', preAuthExternalUserRateLimit('session_lifecycle'), (_req, res) => { + res.status(204).end() + }) + + await request(app).get('/pre-auth').set('x-evenfire-client-ip', '198.51.100.52').expect(204) + + expect(limiter.checkAndIncrement).toHaveBeenLastCalledWith( + 'external_session_lifecycle:ip:198.51.100.52', + 10 + ) + }) + + it('does not trust a forwarded client IP from another internal service', async () => { + limiter.checkAndIncrement.mockResolvedValueOnce(allowedResult()) + const app = express() + app.use((req, _res, next) => { + req.internalService = { name: 'rpc-proxy' } + next() + }) + app.get('/pre-auth', preAuthExternalUserRateLimit('session_lifecycle'), (_req, res) => { + res.status(204).end() + }) + + await request(app).get('/pre-auth').set('x-evenfire-client-ip', '198.51.100.52').expect(204) + + expect(limiter.checkAndIncrement).toHaveBeenLastCalledWith( + expect.not.stringContaining('198.51.100.52'), + 10 + ) + }) }) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 1831f85dfc..ff88a88560 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index a2eb923322..d8dc7498ab 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/routes/auth.ts b/external-rest-api/src/routes/auth.ts index 1b11079b7e..20ff925dec 100644 --- a/external-rest-api/src/routes/auth.ts +++ b/external-rest-api/src/routes/auth.ts @@ -169,7 +169,7 @@ export function createAuthRouter(): Router { res.status(401).json({ error: 'Unauthorized' }) return } - const renewed = await renewUserSession(token) + const renewed = await renewUserSession(token, req.ip) setProfileSessionCookie(req, res, renewed.token) const body = shouldExposeBearerToken(req) ? { token: renewed.token, expiresInSeconds: renewed.expiresInSeconds } @@ -191,7 +191,7 @@ export function createAuthRouter(): Router { .replace(/^bearer\s+/i, '') .trim() const token = bearer || readCookie(req, PROFILE_SESSION_COOKIE) - if (token) await logoutUserSession(token) + if (token) await logoutUserSession(token, req.ip) clearProfileSessionCookie(req, res) res.status(200).json({ ok: true }) } catch (error) { diff --git a/external-rest-api/src/routes/me.ts b/external-rest-api/src/routes/me.ts index 2130ae5884..74b1b18e74 100644 --- a/external-rest-api/src/routes/me.ts +++ b/external-rest-api/src/routes/me.ts @@ -65,7 +65,7 @@ export function createMeRouter(): Router { return } - const result = await switchTeam(auth.userId, auth.email, nextTeamId, sessionToken) + const result = await switchTeam(auth.userId, auth.email, nextTeamId, sessionToken, req.ip) if (!result) { res.status(403).json({ error: 'You are not a member of this team' }) return diff --git a/external-rest-api/src/routes/team.ts b/external-rest-api/src/routes/team.ts index 80447d55b9..9e8c71a7b8 100644 --- a/external-rest-api/src/routes/team.ts +++ b/external-rest-api/src/routes/team.ts @@ -41,7 +41,7 @@ export function createTeamRouter(): Router { res.status(400).json({ error: 'Team name is required' }) return } - const result = await createTeamForUser({ ...auth, sessionToken }, name) + const result = await createTeamForUser({ ...auth, sessionToken }, name, req.ip) const browserRequest = Boolean(req.header('origin') || req.header('sec-fetch-site')) if (browserRequest) setProfileSessionCookie(req, res, result.token) res.status(201).json(browserRequest ? { team: result.team } : result) diff --git a/external-rest-api/src/services/authService.ts b/external-rest-api/src/services/authService.ts index 39c537304b..12d8a6b818 100644 --- a/external-rest-api/src/services/authService.ts +++ b/external-rest-api/src/services/authService.ts @@ -48,18 +48,26 @@ export async function requestPasswordReset(email: string): Promise<{ requested: }) } -export async function renewUserSession(sessionToken: string): Promise<{ +export async function renewUserSession( + sessionToken: string, + clientIp?: string +): Promise<{ token: string expiresInSeconds: number absoluteExpiresAt: string }> { return controlApiRequest('POST', '/external/auth/session/renew', { userSessionToken: sessionToken, + ...(clientIp ? { extraHeaders: { 'x-evenfire-client-ip': clientIp } } : {}), }) } -export async function logoutUserSession(sessionToken: string): Promise<{ revoked: boolean }> { +export async function logoutUserSession( + sessionToken: string, + clientIp?: string +): Promise<{ revoked: boolean }> { return controlApiRequest('POST', '/external/auth/session/logout', { userSessionToken: sessionToken, + ...(clientIp ? { extraHeaders: { 'x-evenfire-client-ip': clientIp } } : {}), }) } diff --git a/external-rest-api/src/services/meService.ts b/external-rest-api/src/services/meService.ts index 62c574e6eb..0584b315f6 100644 --- a/external-rest-api/src/services/meService.ts +++ b/external-rest-api/src/services/meService.ts @@ -73,7 +73,8 @@ export async function switchTeam( userId: string, email: string, nextTeamId: string, - sessionToken: string + sessionToken: string, + clientIp?: string ) { let membership: { team_id: string; role: TeamRole; team_name: string } try { @@ -99,6 +100,7 @@ export async function switchTeam( role, }, userSessionToken: sessionToken, + ...(clientIp ? { extraHeaders: { 'x-evenfire-client-ip': clientIp } } : {}), } ) return { diff --git a/external-rest-api/src/services/teamService.ts b/external-rest-api/src/services/teamService.ts index 0e9d10cc05..154cec30e6 100644 --- a/external-rest-api/src/services/teamService.ts +++ b/external-rest-api/src/services/teamService.ts @@ -36,7 +36,8 @@ export async function getCurrentTeam(auth: AuthContext) { export async function createTeamForUser( auth: Pick, - name: string + name: string, + clientIp?: string ) { const team = await controlApiRequest<{ id: string; name: string }>('POST', '/external/teams', { body: { @@ -57,6 +58,7 @@ export async function createTeamForUser( role: 'admin', }, userSessionToken: auth.sessionToken, + ...(clientIp ? { extraHeaders: { 'x-evenfire-client-ip': clientIp } } : {}), } ) diff --git a/external-rest-api/test/services.externalUserClientIp.test.ts b/external-rest-api/test/services.externalUserClientIp.test.ts new file mode 100644 index 0000000000..ccb13fbc0b --- /dev/null +++ b/external-rest-api/test/services.externalUserClientIp.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { logoutUserSession, renewUserSession } from '../src/services/authService.js' +import { switchTeam } from '../src/services/meService.js' +import { createTeamForUser } from '../src/services/teamService.js' + +const client = vi.hoisted(() => ({ controlApiRequest: vi.fn() })) +vi.mock('../src/controlApiClient.js', () => client) + +const clientIp = '198.51.100.52' +const headers = { 'x-evenfire-client-ip': clientIp } + +describe('external-user client IP forwarding', () => { + beforeEach(() => { + client.controlApiRequest.mockReset() + }) + + it('forwards the trusted client IP for renewal and logout lifecycle calls', async () => { + client.controlApiRequest + .mockResolvedValueOnce({ token: 'renewed', expiresInSeconds: 3600 }) + .mockResolvedValueOnce({ revoked: true }) + + await renewUserSession('session-token', clientIp) + await logoutUserSession('session-token', clientIp) + + expect(client.controlApiRequest).toHaveBeenNthCalledWith( + 1, + 'POST', + '/external/auth/session/renew', + { + userSessionToken: 'session-token', + extraHeaders: headers, + } + ) + expect(client.controlApiRequest).toHaveBeenNthCalledWith( + 2, + 'POST', + '/external/auth/session/logout', + { + userSessionToken: 'session-token', + extraHeaders: headers, + } + ) + }) + + it('forwards the trusted client IP only on the session-token calls for team changes', async () => { + client.controlApiRequest + .mockResolvedValueOnce({ id: 'team-1', name: 'Team One' }) + .mockResolvedValueOnce({ token: 'created-token' }) + .mockResolvedValueOnce({ team_id: 'team-2', team_name: 'Team Two', role: 'member' }) + .mockResolvedValueOnce({ token: 'switched-token' }) + + await createTeamForUser( + { userId: 'user-1', email: 'user@example.test', sessionToken: 'session-token' }, + 'Team One', + clientIp + ) + await switchTeam('user-1', 'user@example.test', 'team-2', 'session-token', clientIp) + + expect(client.controlApiRequest).toHaveBeenNthCalledWith( + 2, + 'POST', + '/external/auth/session-token', + expect.objectContaining({ extraHeaders: headers }) + ) + expect(client.controlApiRequest).toHaveBeenNthCalledWith( + 4, + 'POST', + '/external/auth/session-token', + expect.objectContaining({ extraHeaders: headers }) + ) + }) +}) From de07b3fb5efad7ce4fb9a2e061964da0084b6367 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Wed, 12 Aug 2026 13:57:34 -0600 Subject: [PATCH 075/528] fix(access): r3-h8 route external policy through limiter middleware R3-H8 Class-check: checkAndIncrement under external route/policy code -> 1 canonical site -> retain only rateLimitMiddleware. Repro-test: externalUserRateLimitPolicy contract proof (mutation-proves at 115cbeb887ff1a572cb4f624e1ced441f72ef8e9) --- .../middleware/externalUserRateLimitPolicy.ts | 69 +++++++------------ control-api/src/routes/external/access.ts | 2 +- .../test/externalUserRateLimitPolicy.test.ts | 43 +++++++++++- 3 files changed, 66 insertions(+), 48 deletions(-) diff --git a/control-api/src/middleware/externalUserRateLimitPolicy.ts b/control-api/src/middleware/externalUserRateLimitPolicy.ts index 65c9361c36..ae8964bfaf 100644 --- a/control-api/src/middleware/externalUserRateLimitPolicy.ts +++ b/control-api/src/middleware/externalUserRateLimitPolicy.ts @@ -1,6 +1,6 @@ import type { NextFunction, Request, Response } from 'express' import { sendPublicApiError } from '../http/publicApiError.js' -import { checkAndIncrement } from '../services/rateLimiterService.js' +import { rateLimitMiddleware } from './rateLimitMiddleware.js' type AuthenticatedRequest = Request & { externalAuth?: { userId?: string } } @@ -44,15 +44,12 @@ function keyFor( ): string { const policy = POLICIES[operation] if (stage === 'pre_auth') return `${policy.bucketType}:ip:${boundedIp(req)}` - const userId = req.externalAuth?.userId - // This middleware is composed after successful authentication. A bounded - // sentinel preserves the limiter's accounting contract if a route is wired - // incorrectly instead of silently failing open. - return `${policy.bucketType}:user:${userId || 'missing'}` + // Post-auth routes are composed after live authentication. The sentinel is + // deliberately counted if wiring is wrong rather than silently failing open. + return `${policy.bucketType}:user:${req.externalAuth?.userId || 'missing'}` } function sendLimited(req: Request, res: Response, retryAfterSeconds: number): void { - res.setHeader('Retry-After', String(retryAfterSeconds)) sendPublicApiError( req, res, @@ -64,51 +61,33 @@ function sendLimited(req: Request, res: Response, retryAfterSeconds: number): vo ) } -async function enforce( - operation: ExternalUserRateLimitOperation, - stage: RateLimitStage, - req: AuthenticatedRequest, - res: Response -): Promise { +function policyMiddleware(operation: ExternalUserRateLimitOperation, stage: RateLimitStage) { const policy = POLICIES[operation] - const result = await checkAndIncrement(keyFor(operation, stage, req), policy.maxPerMinute) - res.setHeader('X-RateLimit-Limit', String(policy.maxPerMinute)) - res.setHeader('X-RateLimit-Remaining', String(result.remaining)) - res.setHeader('X-RateLimit-Reset', String(Math.floor(result.resetMs / 1000))) - if (result.allowed) return true - sendLimited(req, res, Math.max(1, Math.ceil((result.resetMs - Date.now()) / 1000))) - return false + return rateLimitMiddleware({ + bucketType: policy.bucketType, + maxPerMinute: policy.maxPerMinute, + getBucketKey: req => keyFor(operation, stage, req as AuthenticatedRequest), + onLimited: sendLimited, + }) } +/** Typed policy selection only; rateLimitMiddleware performs every bucket operation. */ export function preAuthExternalUserRateLimit(operation: ExternalUserRateLimitOperation) { - return (req: Request, res: Response, next: NextFunction): void => { - void enforce(operation, 'pre_auth', req as AuthenticatedRequest, res) - .then(allowed => { - if (allowed) next() - }) - .catch(next) - } + return policyMiddleware(operation, 'pre_auth') } +/** Compose this only after trusted live authentication has populated externalAuth. */ export function authenticatedExternalUserRateLimit(operation: ExternalUserRateLimitOperation) { - return (req: Request, res: Response, next: NextFunction): void => { - void enforce(operation, 'authenticated', req as AuthenticatedRequest, res) - .then(allowed => { - if (allowed) next() - }) - .catch(next) - } -} - -export async function enforceAuthenticatedExternalUserRateLimit( - operation: ExternalUserRateLimitOperation, - req: Request, - res: Response, - principal: Readonly<{ userId: string }> -): Promise { - const authenticated = req as AuthenticatedRequest - authenticated.externalAuth = principal - return enforce(operation, 'authenticated', authenticated, res) + return [ + (req: AuthenticatedRequest, res: Response, next: NextFunction): void => { + if (!req.externalAuth?.userId) { + sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') + return + } + next() + }, + policyMiddleware(operation, 'authenticated'), + ] as const } export const externalUserRateLimitPolicy = Object.freeze({ keyFor }) diff --git a/control-api/src/routes/external/access.ts b/control-api/src/routes/external/access.ts index 4aea31905b..f367d6034f 100644 --- a/control-api/src/routes/external/access.ts +++ b/control-api/src/routes/external/access.ts @@ -174,7 +174,7 @@ export function createExternalAccessRouter(gateway: K8sGateway): Router { router.get( '/external/access/capabilities', - authenticatedExternalUserRateLimit('access_capabilities'), + ...authenticatedExternalUserRateLimit('access_capabilities'), asyncHandler(async (req: ExternalAuthedRequest, res) => { try { const policy = await resolveEffectiveUserAccessPolicy({ budget: req.accessExecutionBudget }) diff --git a/control-api/test/externalUserRateLimitPolicy.test.ts b/control-api/test/externalUserRateLimitPolicy.test.ts index 5717ab3919..3015f65d9e 100644 --- a/control-api/test/externalUserRateLimitPolicy.test.ts +++ b/control-api/test/externalUserRateLimitPolicy.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import express from 'express' import request from 'supertest' import { @@ -28,6 +28,8 @@ function allowedResult() { } describe('external user rate-limit policy', () => { + beforeEach(() => vi.clearAllMocks()) + it.each(operations)( 'keys authenticated %s by the server-authenticated user', async (operation, bucket) => { @@ -39,7 +41,7 @@ describe('external user rate-limit policy', () => { } next() }) - app.get('/protected', authenticatedExternalUserRateLimit(operation), (_req, res) => { + app.get('/protected', ...authenticatedExternalUserRateLimit(operation), (_req, res) => { res.status(204).end() }) @@ -109,4 +111,41 @@ describe('external user rate-limit policy', () => { 10 ) }) + + it('uses canonical denial behavior and short-circuits the protected handler', async () => { + limiter.checkAndIncrement.mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + count: 11, + }) + const handler = vi.fn((_req, res) => res.status(204).end()) + const app = express() + app.use((req, _res, next) => { + ;(req as express.Request & { externalAuth?: { userId: string } }).externalAuth = { + userId: 'server-user', + } + next() + }) + app.get('/protected', ...authenticatedExternalUserRateLimit('session_lifecycle'), handler) + + const response = await request(app).get('/protected').expect(429) + + expect(response.body).toMatchObject({ error: { code: 'rate_limited', retryable: true } }) + expect(response.headers['retry-after']).toBeDefined() + expect(response.headers['x-ratelimit-limit']).toBe('10') + expect(handler).not.toHaveBeenCalled() + }) + + it('fails closed before the authenticated limiter when trusted context is absent', async () => { + const handler = vi.fn((_req, res) => res.status(204).end()) + const app = express() + app.get('/protected', ...authenticatedExternalUserRateLimit('session_lifecycle'), handler) + + await request(app).get('/protected').expect(401) + + expect(limiter.checkAndIncrement).not.toHaveBeenCalled() + expect(handler).not.toHaveBeenCalled() + }) }) From 246410a4f70b86920540d593b0ab2679f2191232 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Wed, 12 Aug 2026 14:01:19 -0600 Subject: [PATCH 076/528] test(access): r3-h9 exercise canonical invitation limiter R3-H9 Class-check: rateLimitMiddleware mocks in external invitation route tests -> 1 pass-through site -> remove it. Repro-test: npm test -- --run test/routes.externalInvitations.unavailable.test.ts (fails at 8e4b1d21e6d12f3f3ab962c8d0193b0f0a7dbda3) --- .../test/routes.externalInvitations.unavailable.test.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/control-api/test/routes.externalInvitations.unavailable.test.ts b/control-api/test/routes.externalInvitations.unavailable.test.ts index 4e2e3db72a..57538439a6 100644 --- a/control-api/test/routes.externalInvitations.unavailable.test.ts +++ b/control-api/test/routes.externalInvitations.unavailable.test.ts @@ -32,11 +32,6 @@ vi.mock('../src/db.js', () => ({ withTransaction: database.withTransaction })) const rateLimiter = vi.hoisted(() => ({ checkAndIncrement: vi.fn() })) vi.mock('../src/services/rateLimiterService.js', () => rateLimiter) -vi.mock('../src/middleware/rateLimitMiddleware.js', () => ({ - rateLimitMiddleware: - () => (_req: express.Request, _res: express.Response, next: express.NextFunction) => - next(), -})) vi.mock('../src/middleware/externalSessionAuth.js', () => ({ rejectBodyUserTeamMismatch: ( _req: express.Request, From 0fa123808af98b6504bb64f1cbbed51c100903c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Wed, 12 Aug 2026 15:27:22 -0600 Subject: [PATCH 077/528] fix(access): r3-h11 expose canonical external rate limiting Class-check: external-user authenticated limiter composition -> 9 routes -> explicit canonical middleware. Repro-test: CodeQL #1012-#1019 at c1ac4c15 plus route-level behavioral proof. --- .../middleware/externalUserRateLimitPolicy.ts | 46 +++++++------- control-api/src/routes/external/access.ts | 8 ++- .../test/externalUserRateLimitPolicy.test.ts | 60 ++++++++++++++----- 3 files changed, 73 insertions(+), 41 deletions(-) diff --git a/control-api/src/middleware/externalUserRateLimitPolicy.ts b/control-api/src/middleware/externalUserRateLimitPolicy.ts index ae8964bfaf..5aa4e6500f 100644 --- a/control-api/src/middleware/externalUserRateLimitPolicy.ts +++ b/control-api/src/middleware/externalUserRateLimitPolicy.ts @@ -1,6 +1,5 @@ import type { NextFunction, Request, Response } from 'express' import { sendPublicApiError } from '../http/publicApiError.js' -import { rateLimitMiddleware } from './rateLimitMiddleware.js' type AuthenticatedRequest = Request & { externalAuth?: { userId?: string } } @@ -11,7 +10,7 @@ export type ExternalUserRateLimitOperation = | 'invitation_read' | 'access_capabilities' -type RateLimitStage = 'pre_auth' | 'authenticated' +export type ExternalUserRateLimitStage = 'pre_auth' | 'authenticated' type Policy = Readonly<{ bucketType: string; maxPerMinute: number }> @@ -39,7 +38,7 @@ function boundedIp(req: Request): string { function keyFor( operation: ExternalUserRateLimitOperation, - stage: RateLimitStage, + stage: ExternalUserRateLimitStage, req: AuthenticatedRequest ): string { const policy = POLICIES[operation] @@ -61,33 +60,34 @@ function sendLimited(req: Request, res: Response, retryAfterSeconds: number): vo ) } -function policyMiddleware(operation: ExternalUserRateLimitOperation, stage: RateLimitStage) { +/** + * Typed policy selection for explicit route composition. The route passes + * these options to the one canonical rateLimitMiddleware enforcement path. + */ +export function externalUserRateLimitOptions( + operation: ExternalUserRateLimitOperation, + stage: ExternalUserRateLimitStage +) { const policy = POLICIES[operation] - return rateLimitMiddleware({ + return { bucketType: policy.bucketType, maxPerMinute: policy.maxPerMinute, getBucketKey: req => keyFor(operation, stage, req as AuthenticatedRequest), onLimited: sendLimited, - }) -} - -/** Typed policy selection only; rateLimitMiddleware performs every bucket operation. */ -export function preAuthExternalUserRateLimit(operation: ExternalUserRateLimitOperation) { - return policyMiddleware(operation, 'pre_auth') + } } -/** Compose this only after trusted live authentication has populated externalAuth. */ -export function authenticatedExternalUserRateLimit(operation: ExternalUserRateLimitOperation) { - return [ - (req: AuthenticatedRequest, res: Response, next: NextFunction): void => { - if (!req.externalAuth?.userId) { - sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') - return - } - next() - }, - policyMiddleware(operation, 'authenticated'), - ] as const +/** Compose before an authenticated-stage limiter to fail closed on bad wiring. */ +export function requireAuthenticatedExternalUserRateLimitContext( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): void { + if (!req.externalAuth?.userId) { + sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') + return + } + next() } export const externalUserRateLimitPolicy = Object.freeze({ keyFor }) diff --git a/control-api/src/routes/external/access.ts b/control-api/src/routes/external/access.ts index f367d6034f..1e64c32ba4 100644 --- a/control-api/src/routes/external/access.ts +++ b/control-api/src/routes/external/access.ts @@ -6,7 +6,10 @@ import { type ExternalAuthedRequest, requireValidExternalSessionTokenWithPublicErrors, } from '../../middleware/externalSessionAuth.js' -import { authenticatedExternalUserRateLimit } from '../../middleware/externalUserRateLimitPolicy.js' +import { + externalUserRateLimitOptions, + requireAuthenticatedExternalUserRateLimitContext, +} from '../../middleware/externalUserRateLimitPolicy.js' import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' import { AccessCatalogRequestError, @@ -174,7 +177,8 @@ export function createExternalAccessRouter(gateway: K8sGateway): Router { router.get( '/external/access/capabilities', - ...authenticatedExternalUserRateLimit('access_capabilities'), + requireAuthenticatedExternalUserRateLimitContext, + rateLimitMiddleware(externalUserRateLimitOptions('access_capabilities', 'authenticated')), asyncHandler(async (req: ExternalAuthedRequest, res) => { try { const policy = await resolveEffectiveUserAccessPolicy({ budget: req.accessExecutionBudget }) diff --git a/control-api/test/externalUserRateLimitPolicy.test.ts b/control-api/test/externalUserRateLimitPolicy.test.ts index 3015f65d9e..e590aae718 100644 --- a/control-api/test/externalUserRateLimitPolicy.test.ts +++ b/control-api/test/externalUserRateLimitPolicy.test.ts @@ -2,9 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import express from 'express' import request from 'supertest' import { - authenticatedExternalUserRateLimit, - preAuthExternalUserRateLimit, + externalUserRateLimitOptions, + requireAuthenticatedExternalUserRateLimitContext, } from '../src/middleware/externalUserRateLimitPolicy.js' +import { rateLimitMiddleware } from '../src/middleware/rateLimitMiddleware.js' const limiter = vi.hoisted(() => ({ checkAndIncrement: vi.fn() })) vi.mock('../src/services/rateLimiterService.js', () => limiter) @@ -41,9 +42,14 @@ describe('external user rate-limit policy', () => { } next() }) - app.get('/protected', ...authenticatedExternalUserRateLimit(operation), (_req, res) => { - res.status(204).end() - }) + app.get( + '/protected', + requireAuthenticatedExternalUserRateLimitContext, + rateLimitMiddleware(externalUserRateLimitOptions(operation, 'authenticated')), + (_req, res) => { + res.status(204).end() + } + ) await request(app) .get('/protected') @@ -57,9 +63,13 @@ describe('external user rate-limit policy', () => { it('uses a bounded IP fallback before authentication without reading the session token', async () => { limiter.checkAndIncrement.mockResolvedValueOnce(allowedResult()) const app = express() - app.get('/pre-auth', preAuthExternalUserRateLimit('session_lifecycle'), (_req, res) => { - res.status(204).end() - }) + app.get( + '/pre-auth', + rateLimitMiddleware(externalUserRateLimitOptions('session_lifecycle', 'pre_auth')), + (_req, res) => { + res.status(204).end() + } + ) await request(app) .get('/pre-auth') @@ -81,9 +91,13 @@ describe('external user rate-limit policy', () => { req.internalService = { name: 'external-rest-api' } next() }) - app.get('/pre-auth', preAuthExternalUserRateLimit('session_lifecycle'), (_req, res) => { - res.status(204).end() - }) + app.get( + '/pre-auth', + rateLimitMiddleware(externalUserRateLimitOptions('session_lifecycle', 'pre_auth')), + (_req, res) => { + res.status(204).end() + } + ) await request(app).get('/pre-auth').set('x-evenfire-client-ip', '198.51.100.52').expect(204) @@ -100,9 +114,13 @@ describe('external user rate-limit policy', () => { req.internalService = { name: 'rpc-proxy' } next() }) - app.get('/pre-auth', preAuthExternalUserRateLimit('session_lifecycle'), (_req, res) => { - res.status(204).end() - }) + app.get( + '/pre-auth', + rateLimitMiddleware(externalUserRateLimitOptions('session_lifecycle', 'pre_auth')), + (_req, res) => { + res.status(204).end() + } + ) await request(app).get('/pre-auth').set('x-evenfire-client-ip', '198.51.100.52').expect(204) @@ -128,7 +146,12 @@ describe('external user rate-limit policy', () => { } next() }) - app.get('/protected', ...authenticatedExternalUserRateLimit('session_lifecycle'), handler) + app.get( + '/protected', + requireAuthenticatedExternalUserRateLimitContext, + rateLimitMiddleware(externalUserRateLimitOptions('session_lifecycle', 'authenticated')), + handler + ) const response = await request(app).get('/protected').expect(429) @@ -141,7 +164,12 @@ describe('external user rate-limit policy', () => { it('fails closed before the authenticated limiter when trusted context is absent', async () => { const handler = vi.fn((_req, res) => res.status(204).end()) const app = express() - app.get('/protected', ...authenticatedExternalUserRateLimit('session_lifecycle'), handler) + app.get( + '/protected', + requireAuthenticatedExternalUserRateLimitContext, + rateLimitMiddleware(externalUserRateLimitOptions('session_lifecycle', 'authenticated')), + handler + ) await request(app).get('/protected').expect(401) From d7e9cc8664e0d2c97ca518982c3a0b7fd6d6b5b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Wed, 12 Aug 2026 15:28:10 -0600 Subject: [PATCH 078/528] fix(access): r3-h12 type rate-limit option key derivation Class-check: external user rate-limit options getBucketKey -> 1 site -> preserve canonical Request contract. Repro-test: control-api npm run build fails at e2f24979 with TS7006. --- control-api/src/middleware/externalUserRateLimitPolicy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control-api/src/middleware/externalUserRateLimitPolicy.ts b/control-api/src/middleware/externalUserRateLimitPolicy.ts index 5aa4e6500f..dd04b49309 100644 --- a/control-api/src/middleware/externalUserRateLimitPolicy.ts +++ b/control-api/src/middleware/externalUserRateLimitPolicy.ts @@ -72,7 +72,7 @@ export function externalUserRateLimitOptions( return { bucketType: policy.bucketType, maxPerMinute: policy.maxPerMinute, - getBucketKey: req => keyFor(operation, stage, req as AuthenticatedRequest), + getBucketKey: (req: Request) => keyFor(operation, stage, req as AuthenticatedRequest), onLimited: sendLimited, } } From 9c79daa7d541ceb9539edc4782b2e4530878a00a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Wed, 12 Aug 2026 16:23:42 -0600 Subject: [PATCH 079/528] test(access): r3-h13 prove directory rate-limit enforcement Class-check: directory router verbs -> 1 route -> canonical limiter covers search. Repro-test: mutation proof removes the limiter and fails denial assertions at 7a1b40be. --- .../test/routes.externalDirectory.test.ts | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/control-api/test/routes.externalDirectory.test.ts b/control-api/test/routes.externalDirectory.test.ts index 967c7f8f0a..bf973cef13 100644 --- a/control-api/test/routes.externalDirectory.test.ts +++ b/control-api/test/routes.externalDirectory.test.ts @@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({ verify: vi.fn(), membership: vi.fn(), searchDirectory: vi.fn(), + checkAndIncrement: vi.fn(), })) vi.mock('../src/utils/auth/externalSessionAuthToken.js', () => ({ @@ -26,6 +27,20 @@ vi.mock('../src/services/directory/index.js', async importOriginal => { const actual = await importOriginal() return { ...actual, searchDirectory: mocks.searchDirectory } }) +vi.mock('../src/services/rateLimiterService.js', () => ({ + checkAndIncrement: mocks.checkAndIncrement, +})) + +function allowedRateLimitResult() { + const now = Date.now() + return { + allowed: true, + remaining: 29, + resetMs: now + 60_000, + windowStartMs: now, + count: 1, + } +} function app() { const value = express() @@ -45,6 +60,7 @@ describe('external directory authorization', () => { sessionContract: 'v1', }) mocks.searchDirectory.mockResolvedValue({ items: [], nextCursor: null }) + mocks.checkAndIncrement.mockResolvedValue(allowedRateLimitResult()) }) it('denies an ordinary member without executing the PII search', async () => { @@ -69,4 +85,48 @@ describe('external directory authorization', () => { expect(response.status).toBe(200) expect(mocks.searchDirectory).toHaveBeenCalledWith('team-1', 'person', undefined) }) + + it('uses the authenticated user key and canonical limiter on an allowed search', async () => { + mocks.membership.mockResolvedValue({ teamId: 'team-1', role: 'admin' }) + + const response = await request(app()) + .get('/external/directory/search?teamId=team-1&q=person') + .set('x-user-session-token', 'session') + .expect(200) + + expect(mocks.checkAndIncrement).toHaveBeenCalledOnce() + expect(mocks.checkAndIncrement).toHaveBeenCalledWith('directory-search:user-1', 30) + expect(response.headers['x-ratelimit-limit']).toBe('30') + expect(response.headers['x-ratelimit-remaining']).toBe('29') + expect(response.headers['x-ratelimit-reset']).toBeDefined() + expect(mocks.searchDirectory).toHaveBeenCalledWith('team-1', 'person', undefined) + }) + + it('returns the canonical 429 and does not search when the limiter denies', async () => { + const resetMs = Date.now() + 60_000 + mocks.membership.mockResolvedValue({ teamId: 'team-1', role: 'admin' }) + mocks.checkAndIncrement.mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetMs, + windowStartMs: resetMs - 60_000, + count: 31, + }) + + const response = await request(app()) + .get('/external/directory/search?teamId=team-1&q=person') + .set('x-user-session-token', 'session') + .expect(429) + + expect(mocks.checkAndIncrement).toHaveBeenCalledOnce() + expect(mocks.checkAndIncrement).toHaveBeenCalledWith('directory-search:user-1', 30) + expect(response.body).toMatchObject({ + error: { code: 'rate_limited', retryable: true }, + }) + expect(Number(response.headers['retry-after'])).toBeGreaterThan(0) + expect(response.headers['x-ratelimit-limit']).toBe('30') + expect(response.headers['x-ratelimit-remaining']).toBe('0') + expect(response.headers['x-ratelimit-reset']).toBe(String(Math.floor(resetMs / 1000))) + expect(mocks.searchDirectory).not.toHaveBeenCalled() + }) }) From f6bd0af3ad4f71008fac9e93a430e3d155f4c186 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 13 Aug 2026 13:06:23 -0600 Subject: [PATCH 080/528] fix(access): d28-oauth limit external OAuth grant routes Class-check: external OAuth grant routes -> 2 routes -> limit reads and mutations Repro-test: OAuth grant denial tests fail at 654c9149 and pass after fix --- .../middleware/externalUserRateLimitPolicy.ts | 4 ++ .../src/routes/external/oauthGrants.ts | 4 ++ .../test/routes.external.oauthGrants.test.ts | 57 +++++++++++++++++++ 3 files changed, 65 insertions(+) diff --git a/control-api/src/middleware/externalUserRateLimitPolicy.ts b/control-api/src/middleware/externalUserRateLimitPolicy.ts index dd04b49309..2978625fa3 100644 --- a/control-api/src/middleware/externalUserRateLimitPolicy.ts +++ b/control-api/src/middleware/externalUserRateLimitPolicy.ts @@ -9,6 +9,8 @@ export type ExternalUserRateLimitOperation = | 'invitation_sensitive_action' | 'invitation_read' | 'access_capabilities' + | 'oauth_grant_read' + | 'oauth_grant_mutation' export type ExternalUserRateLimitStage = 'pre_auth' | 'authenticated' @@ -25,6 +27,8 @@ const POLICIES: Readonly> = { }, invitation_read: { bucketType: 'external_invitation_read', maxPerMinute: 10 }, access_capabilities: { bucketType: 'external_access_capabilities', maxPerMinute: 10 }, + oauth_grant_read: { bucketType: 'external_oauth_grant_read', maxPerMinute: 30 }, + oauth_grant_mutation: { bucketType: 'external_oauth_grant_mutation', maxPerMinute: 10 }, } function boundedIp(req: Request): string { diff --git a/control-api/src/routes/external/oauthGrants.ts b/control-api/src/routes/external/oauthGrants.ts index 8e91d37c39..39b8f624f9 100644 --- a/control-api/src/routes/external/oauthGrants.ts +++ b/control-api/src/routes/external/oauthGrants.ts @@ -6,6 +6,8 @@ import { type ExternalAuthedRequest, requireValidExternalSessionToken, } from '../../middleware/externalSessionAuth.js' +import { externalUserRateLimitOptions } from '../../middleware/externalUserRateLimitPolicy.js' +import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' import { type OAuthOwnerKind, deleteOAuthGrant, listUserOAuthGrants } from '../../oauth/store.js' function dbClient() { @@ -54,6 +56,7 @@ export function createExternalOauthGrantsRouter(): Router { '/external/oauth/grants', ...externalOauthGrantsRateLimits, requireValidExternalSessionToken, + rateLimitMiddleware(externalUserRateLimitOptions('oauth_grant_read', 'authenticated')), (req: ExternalAuthedRequest, res, next) => { void (async () => { try { @@ -99,6 +102,7 @@ export function createExternalOauthGrantsRouter(): Router { '/external/oauth/grants/:recipeNamespace/:recipeName/:oauthClientId', ...externalOauthGrantsRateLimits, requireValidExternalSessionToken, + rateLimitMiddleware(externalUserRateLimitOptions('oauth_grant_mutation', 'authenticated')), (req: ExternalAuthedRequest, res, next) => { void (async () => { try { diff --git a/control-api/test/routes.external.oauthGrants.test.ts b/control-api/test/routes.external.oauthGrants.test.ts index 3d00a160fe..568dab77bc 100644 --- a/control-api/test/routes.external.oauthGrants.test.ts +++ b/control-api/test/routes.external.oauthGrants.test.ts @@ -16,6 +16,11 @@ const storeMock = vi.hoisted(() => ({ })) vi.mock('../src/oauth/store.js', () => storeMock) +const rateLimitMock = vi.hoisted(() => ({ + checkAndIncrement: vi.fn(), +})) +vi.mock('../src/services/rateLimiterService.js', () => rateLimitMock) + // Mirror the exact pattern from routes.external.workflowApprovalMediums.test.ts: // mock requireValidExternalSessionToken to inject req.externalAuth. vi.mock('../src/middleware/externalSessionAuth.js', () => ({ @@ -48,6 +53,13 @@ describe('/external/oauth/grants', () => { vi.clearAllMocks() storeMock.listUserOAuthGrants.mockResolvedValue([]) storeMock.deleteOAuthGrant.mockResolvedValue(undefined) + rateLimitMock.checkAndIncrement.mockResolvedValue({ + allowed: true, + count: 1, + remaining: 29, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + }) }) // T3 (route wiring): fails at the parent sha, where GET calls @@ -136,4 +148,49 @@ describe('/external/oauth/grants', () => { expect(res.status).toBe(400) expect(storeMock.deleteOAuthGrant).not.toHaveBeenCalled() }) + + it('rate limits reads by authenticated user before listing grants', async () => { + rateLimitMock.checkAndIncrement.mockResolvedValueOnce({ + allowed: false, + count: 31, + remaining: 0, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + }) + + const res = await request(makeApp()).get('/external/oauth/grants') + + expect(res.status).toBe(429) + expect(res.headers['retry-after']).toBeDefined() + expect(res.headers['x-ratelimit-limit']).toBe('30') + expect(res.headers['x-ratelimit-remaining']).toBe('0') + expect(res.body).toMatchObject({ error: { code: 'rate_limited', retryable: true } }) + expect(rateLimitMock.checkAndIncrement).toHaveBeenCalledWith( + 'external_oauth_grant_read:user:user-1', + 30 + ) + expect(storeMock.listUserOAuthGrants).not.toHaveBeenCalled() + }) + + it('rate limits revocation by authenticated user before deleting a grant', async () => { + rateLimitMock.checkAndIncrement.mockResolvedValueOnce({ + allowed: false, + count: 11, + remaining: 0, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + }) + + const res = await request(makeApp()).delete( + '/external/oauth/grants/sandbox-recipes/leadforge/google-gmail' + ) + + expect(res.status).toBe(429) + expect(res.headers['x-ratelimit-limit']).toBe('10') + expect(rateLimitMock.checkAndIncrement).toHaveBeenCalledWith( + 'external_oauth_grant_mutation:user:user-1', + 10 + ) + expect(storeMock.deleteOAuthGrant).not.toHaveBeenCalled() + }) }) From 7b151b61f203b21998f9f9b3052737f430b9d6c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 13 Aug 2026 13:07:22 -0600 Subject: [PATCH 081/528] fix(access): d28-members limit external member routes Class-check: external member routes -> 10 routes -> limit reads and mutations Repro-test: member denial tests fail at d52ed909 and pass after fix --- .../middleware/externalUserRateLimitPolicy.ts | 4 + control-api/src/routes/external/members.ts | 252 ++++++++++-------- .../test/routes.externalMembers.test.ts | 161 ++++++++++- 3 files changed, 306 insertions(+), 111 deletions(-) diff --git a/control-api/src/middleware/externalUserRateLimitPolicy.ts b/control-api/src/middleware/externalUserRateLimitPolicy.ts index 2978625fa3..500187fd8d 100644 --- a/control-api/src/middleware/externalUserRateLimitPolicy.ts +++ b/control-api/src/middleware/externalUserRateLimitPolicy.ts @@ -11,6 +11,8 @@ export type ExternalUserRateLimitOperation = | 'access_capabilities' | 'oauth_grant_read' | 'oauth_grant_mutation' + | 'member_read' + | 'member_mutation' export type ExternalUserRateLimitStage = 'pre_auth' | 'authenticated' @@ -29,6 +31,8 @@ const POLICIES: Readonly> = { access_capabilities: { bucketType: 'external_access_capabilities', maxPerMinute: 10 }, oauth_grant_read: { bucketType: 'external_oauth_grant_read', maxPerMinute: 30 }, oauth_grant_mutation: { bucketType: 'external_oauth_grant_mutation', maxPerMinute: 10 }, + member_read: { bucketType: 'external_member_read', maxPerMinute: 30 }, + member_mutation: { bucketType: 'external_member_mutation', maxPerMinute: 10 }, } function boundedIp(req: Request): string { diff --git a/control-api/src/routes/external/members.ts b/control-api/src/routes/external/members.ts index 550e457f68..1941c03c69 100644 --- a/control-api/src/routes/external/members.ts +++ b/control-api/src/routes/external/members.ts @@ -5,6 +5,8 @@ import { type ExternalAuthedRequest, requireValidExternalSessionToken, } from '../../middleware/externalSessionAuth.js' +import { externalUserRateLimitOptions } from '../../middleware/externalUserRateLimitPolicy.js' +import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' import { createManagedInvitationForUser, deleteManagedMemberForUser, @@ -18,8 +20,8 @@ import { updateManagedMemberRoleForUser, } from '../../services/directory/index.js' import { normalizeTeamRoleInput } from '../../services/directory/types.js' +import { normalizeInvitationEmail } from './invitationEmail.js' -const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ const MAX_INVITATION_TEAM_ASSIGNMENTS = 50 const MAX_INVITEE_NAME_LENGTH = 120 @@ -38,6 +40,7 @@ export function createExternalMembersRouter(): Router { router.get( '/external/members/manageable-teams', + rateLimitMiddleware(externalUserRateLimitOptions('member_read', 'authenticated')), async (req: ExternalAuthedRequest, res, next) => { try { const userId = currentUserId(req) @@ -49,92 +52,112 @@ export function createExternalMembersRouter(): Router { } ) - router.get('/external/members', async (req: ExternalAuthedRequest, res, next) => { - try { - const userId = currentUserId(req) - if (!userId) return res.status(403).json({ error: 'Forbidden' }) - return res.status(200).json({ items: await listManagedMembersForUser(userId) }) - } catch (error) { - return next(error) + router.get( + '/external/members', + rateLimitMiddleware(externalUserRateLimitOptions('member_read', 'authenticated')), + async (req: ExternalAuthedRequest, res, next) => { + try { + const userId = currentUserId(req) + if (!userId) return res.status(403).json({ error: 'Forbidden' }) + return res.status(200).json({ items: await listManagedMembersForUser(userId) }) + } catch (error) { + return next(error) + } } - }) + ) - router.get('/external/members/invitations', async (req: ExternalAuthedRequest, res, next) => { - try { - const userId = currentUserId(req) - if (!userId) return res.status(403).json({ error: 'Forbidden' }) - return res.status(200).json({ items: await listManagedPendingInvitationsForUser(userId) }) - } catch (error) { - return next(error) + router.get( + '/external/members/invitations', + rateLimitMiddleware(externalUserRateLimitOptions('member_read', 'authenticated')), + async (req: ExternalAuthedRequest, res, next) => { + try { + const userId = currentUserId(req) + if (!userId) return res.status(403).json({ error: 'Forbidden' }) + return res.status(200).json({ items: await listManagedPendingInvitationsForUser(userId) }) + } catch (error) { + return next(error) + } } - }) + ) - router.get('/external/members/:userId', async (req: ExternalAuthedRequest, res, next) => { - try { - const managerUserId = currentUserId(req) - if (!managerUserId) return res.status(403).json({ error: 'Forbidden' }) - const items = await listManagedMembersForUser(managerUserId, req.params.userId) - const member = items[0] || null - if (!member) return res.status(404).json({ error: 'not_found' }) - return res.status(200).json(member) - } catch (error) { - return next(error) + router.get( + '/external/members/:userId', + rateLimitMiddleware(externalUserRateLimitOptions('member_read', 'authenticated')), + async (req: ExternalAuthedRequest, res, next) => { + try { + const managerUserId = currentUserId(req) + if (!managerUserId) return res.status(403).json({ error: 'Forbidden' }) + const items = await listManagedMembersForUser(managerUserId, req.params.userId) + const member = items[0] || null + if (!member) return res.status(404).json({ error: 'not_found' }) + return res.status(200).json(member) + } catch (error) { + return next(error) + } } - }) + ) - router.post('/external/members/invitations', async (req: ExternalAuthedRequest, res, next) => { - try { - const managerUserId = currentUserId(req) - if (!managerUserId) return res.status(403).json({ error: 'Forbidden' }) - const email = String(req.body?.email || '') - .trim() - .toLowerCase() - const name = String(req.body?.name || '').trim() - const teams: unknown[] = Array.isArray(req.body?.teams) ? req.body.teams : [] - if (!EMAIL_PATTERN.test(email)) { - return res.status(400).json({ error: 'invalid_email' }) - } - if (name.length > MAX_INVITEE_NAME_LENGTH) { - return res.status(400).json({ error: 'invalid_name' }) - } - if (teams.length > MAX_INVITATION_TEAM_ASSIGNMENTS) { - return res.status(400).json({ error: 'too_many_teams' }) - } - const teamAssignments = teams - .map((item: unknown) => { - const row = item && typeof item === 'object' ? (item as Record) : {} - const teamId = String(row.teamId || row.id || '').trim() - const role = normalizeTeamRoleInput(row.role) || 'member' - return teamId ? { teamId, role } : null - }) - .filter((item): item is { teamId: string; role: 'admin' | 'inviter' | 'member' } => - Boolean(item) + router.post( + '/external/members/invitations', + rateLimitMiddleware(externalUserRateLimitOptions('member_mutation', 'authenticated')), + async (req: ExternalAuthedRequest, res, next) => { + try { + const managerUserId = currentUserId(req) + if (!managerUserId) return res.status(403).json({ error: 'Forbidden' }) + const email = normalizeInvitationEmail(req.body?.email) + if (!email) { + return res.status(400).json({ error: 'invalid_email' }) + } + const name = String(req.body?.name || '').trim() + const teams: unknown[] = Array.isArray(req.body?.teams) ? req.body.teams : [] + if (name.length > MAX_INVITEE_NAME_LENGTH) { + return res.status(400).json({ error: 'invalid_name' }) + } + if (teams.length > MAX_INVITATION_TEAM_ASSIGNMENTS) { + return res.status(400).json({ error: 'too_many_teams' }) + } + const teamAssignments = teams + .map((item: unknown) => { + const row = item && typeof item === 'object' ? (item as Record) : {} + const teamId = String(row.teamId || row.id || '').trim() + const role = normalizeTeamRoleInput(row.role) || 'member' + return teamId ? { teamId, role } : null + }) + .filter((item): item is { teamId: string; role: 'admin' | 'inviter' | 'member' } => + Boolean(item) + ) + const result = await createManagedInvitationForUser( + managerUserId, + email, + teamAssignments, + name, + req.externalSessionAuthority ) - const result = await createManagedInvitationForUser( - managerUserId, - email, - teamAssignments, - name - ) - if ('error' in result) { - if (result.error === 'forbidden') return res.status(403).json({ error: 'forbidden' }) - return res.status(400).json({ error: 'invalid_payload' }) + if ('error' in result) { + if (result.error === 'forbidden') return res.status(403).json({ error: 'forbidden' }) + return res.status(400).json({ error: 'invalid_payload' }) + } + return res + .status(201) + .json(externalManagedInvitationResponse(result.invitation as Record)) + } catch (error) { + return next(error) } - return res - .status(201) - .json(externalManagedInvitationResponse(result.invitation as Record)) - } catch (error) { - return next(error) } - }) + ) router.post( '/external/members/invitations/:invitationId/resend', + rateLimitMiddleware(externalUserRateLimitOptions('member_mutation', 'authenticated')), async (req: ExternalAuthedRequest, res, next) => { try { const managerUserId = currentUserId(req) if (!managerUserId) return res.status(403).json({ error: 'Forbidden' }) - const result = await resendManagedInvitationForUser(managerUserId, req.params.invitationId) + const result = await resendManagedInvitationForUser( + managerUserId, + req.params.invitationId, + req.externalSessionAuthority + ) if ('error' in result) { if (result.error === 'forbidden') return res.status(403).json({ error: 'forbidden' }) return res.status(404).json({ error: 'not_found' }) @@ -148,11 +171,16 @@ export function createExternalMembersRouter(): Router { router.delete( '/external/members/invitations/:invitationId', + rateLimitMiddleware(externalUserRateLimitOptions('member_mutation', 'authenticated')), async (req: ExternalAuthedRequest, res, next) => { try { const managerUserId = currentUserId(req) if (!managerUserId) return res.status(403).json({ error: 'Forbidden' }) - const result = await revokeManagedInvitationForUser(managerUserId, req.params.invitationId) + const result = await revokeManagedInvitationForUser( + managerUserId, + req.params.invitationId, + req.externalSessionAuthority + ) if ('error' in result) { if (result.error === 'forbidden') return res.status(403).json({ error: 'forbidden' }) return res.status(404).json({ error: 'not_found' }) @@ -166,6 +194,7 @@ export function createExternalMembersRouter(): Router { router.patch( '/external/members/:userId/teams/:teamId/role', + rateLimitMiddleware(externalUserRateLimitOptions('member_mutation', 'authenticated')), async (req: ExternalAuthedRequest, res, next) => { try { const managerUserId = currentUserId(req) @@ -176,7 +205,8 @@ export function createExternalMembersRouter(): Router { managerUserId, req.params.userId, req.params.teamId, - role + role, + req.externalSessionAuthority ) if ('error' in result) { if (result.error === 'forbidden') return res.status(403).json({ error: 'forbidden' }) @@ -193,42 +223,55 @@ export function createExternalMembersRouter(): Router { } ) - router.delete('/external/members/:userId', async (req: ExternalAuthedRequest, res, next) => { - try { - const managerUserId = currentUserId(req) - if (!managerUserId) return res.status(403).json({ error: 'Forbidden' }) - const reason = typeof req.body?.reason === 'string' ? req.body.reason.trim() : '' - if (!reason) return res.status(400).json({ error: 'reason_required' }) - const idempotencyKey = String(req.header('Idempotency-Key') || '').trim() - if (!idempotencyKey) return res.status(400).json({ error: 'idempotency_key_required' }) - const result = await deleteManagedUserForUser(managerUserId, req.params.userId, { - reason, - idempotencyKey, - requestId: req.correlationId ?? null, - }) - if ('error' in result) { - if (result.error === 'forbidden_uncontrolled_teams') { - return res.status(403).json({ error: 'forbidden_uncontrolled_teams' }) - } - if (result.error === 'invalid_target') { - return res.status(400).json({ error: 'invalid_target' }) - } - if (result.error === 'invalid_retirement_input') { - return res.status(400).json({ error: 'invalid_retirement_input' }) - } - if (result.error === 'idempotency_conflict' || result.error === 'retirement_conflict') { - return res.status(409).json({ error: result.error }) + router.delete( + '/external/members/:userId', + rateLimitMiddleware(externalUserRateLimitOptions('member_mutation', 'authenticated')), + async (req: ExternalAuthedRequest, res, next) => { + try { + const managerUserId = currentUserId(req) + if (!managerUserId) return res.status(403).json({ error: 'Forbidden' }) + const reason = typeof req.body?.reason === 'string' ? req.body.reason.trim() : '' + if (!reason) return res.status(400).json({ error: 'reason_required' }) + const idempotencyKey = String(req.header('Idempotency-Key') || '').trim() + if (!idempotencyKey) return res.status(400).json({ error: 'idempotency_key_required' }) + const result = await deleteManagedUserForUser( + managerUserId, + req.params.userId, + { + reason, + idempotencyKey, + requestId: req.correlationId ?? null, + }, + req.externalSessionAuthority + ) + if ('error' in result) { + if (result.error === 'forbidden') { + return res.status(403).json({ error: 'forbidden' }) + } + if (result.error === 'forbidden_uncontrolled_teams') { + return res.status(403).json({ error: 'forbidden_uncontrolled_teams' }) + } + if (result.error === 'invalid_target') { + return res.status(400).json({ error: 'invalid_target' }) + } + if (result.error === 'invalid_retirement_input') { + return res.status(400).json({ error: 'invalid_retirement_input' }) + } + if (result.error === 'idempotency_conflict' || result.error === 'retirement_conflict') { + return res.status(409).json({ error: result.error }) + } + return res.status(404).json({ error: 'not_found' }) } - return res.status(404).json({ error: 'not_found' }) + return res.status(200).json(result.deleted) + } catch (error) { + return next(error) } - return res.status(200).json(result.deleted) - } catch (error) { - return next(error) } - }) + ) router.delete( '/external/members/:userId/teams/:teamId', + rateLimitMiddleware(externalUserRateLimitOptions('member_mutation', 'authenticated')), async (req: ExternalAuthedRequest, res, next) => { try { const managerUserId = currentUserId(req) @@ -236,7 +279,8 @@ export function createExternalMembersRouter(): Router { const result = await deleteManagedMemberForUser( managerUserId, req.params.userId, - req.params.teamId + req.params.teamId, + req.externalSessionAuthority ) if ('error' in result) { if (result.error === 'forbidden') return res.status(403).json({ error: 'forbidden' }) diff --git a/control-api/test/routes.externalMembers.test.ts b/control-api/test/routes.externalMembers.test.ts index 8d76da20ee..dd5eea2a31 100644 --- a/control-api/test/routes.externalMembers.test.ts +++ b/control-api/test/routes.externalMembers.test.ts @@ -25,8 +25,25 @@ vi.mock('../src/middleware/externalSessionAuth.js', () => ({ _res: express.Response, next: express.NextFunction ) => { - ;(req as express.Request & { externalAuth?: { userId: string } }).externalAuth = { + const externalReq = req as express.Request & { + externalAuth?: { userId: string } + externalSessionAuthority?: { + contract: 'v1' + userId: string + authGeneration: number + issuedAt: number + tokenHash: string + } + } + externalReq.externalAuth = { + userId: 'manager-1', + } + externalReq.externalSessionAuthority = { + contract: 'v1', userId: 'manager-1', + authGeneration: 1, + issuedAt: 1_787_931_018, + tokenHash: 'external-members-test-token', } next() }, @@ -34,6 +51,11 @@ vi.mock('../src/middleware/externalSessionAuth.js', () => ({ vi.mock('../src/services/directory/index.js', () => directoryMock) +const rateLimitMock = vi.hoisted(() => ({ + checkAndIncrement: vi.fn(), +})) +vi.mock('../src/services/rateLimiterService.js', () => rateLimitMock) + function makeApp() { const app = express() app.use(express.json()) @@ -49,6 +71,14 @@ function makeApp() { describe('external members routes', () => { beforeEach(() => { vi.clearAllMocks() + rateLimitMock.checkAndIncrement.mockResolvedValue({ + allowed: true, + count: 1, + remaining: 29, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + backendAvailable: true, + }) }) it('passes invitee name through managed member invitations', async () => { @@ -69,10 +99,79 @@ describe('external members routes', () => { 'manager-1', 'invitee@example.com', [{ teamId: 'team-1', role: 'admin' }], - 'Full Name' + 'Full Name', + expect.objectContaining({ contract: 'v1', userId: 'manager-1' }) ) }) + it('rejects raw invitation emails longer than 320 characters before directory work', async () => { + const overlongEmail = `${'a'.repeat(315)}@a.com` + + await request(makeApp()) + .post('/external/members/invitations') + .send({ + email: overlongEmail, + teams: [{ teamId: 'team-1', role: 'member' }], + }) + .expect(400, { error: 'invalid_email' }) + + expect(directoryMock.createManagedInvitationForUser).not.toHaveBeenCalled() + }) + + it.each([ + ['a missing domain dot', 'invitee@example'], + ['a second at sign', 'invitee@@example.com'], + ['whitespace', 'invitee @example.com'], + ['pathological dotted input', `!@!${'!.'.repeat(100)}@`], + ])('rejects bounded invitation email with %s before directory work', async (_case, email) => { + await request(makeApp()) + .post('/external/members/invitations') + .send({ + email, + teams: [{ teamId: 'team-1', role: 'member' }], + }) + .expect(400, { error: 'invalid_email' }) + + expect(directoryMock.createManagedInvitationForUser).not.toHaveBeenCalled() + }) + + it.each(['invitee@.example.com', 'invitee@example.com.'])( + 'preserves bounded legacy domain-dot acceptance for %s', + async email => { + directoryMock.createManagedInvitationForUser.mockResolvedValueOnce({ + invitation: { id: 'inv-compat', token: 'secret' }, + }) + + await request(makeApp()) + .post('/external/members/invitations') + .send({ + email, + teams: [{ teamId: 'team-1', role: 'member' }], + }) + .expect(201, { id: 'inv-compat' }) + + expect(directoryMock.createManagedInvitationForUser).toHaveBeenCalledWith( + 'manager-1', + email, + [{ teamId: 'team-1', role: 'member' }], + '', + expect.objectContaining({ contract: 'v1', userId: 'manager-1' }) + ) + } + ) + + it('rejects non-string invitation email input before directory work', async () => { + await request(makeApp()) + .post('/external/members/invitations') + .send({ + email: { address: 'invitee@example.com' }, + teams: [{ teamId: 'team-1', role: 'member' }], + }) + .expect(400, { error: 'invalid_email' }) + + expect(directoryMock.createManagedInvitationForUser).not.toHaveBeenCalled() + }) + it('rejects overlong invitee names', async () => { await request(makeApp()) .post('/external/members/invitations') @@ -86,6 +185,49 @@ describe('external members routes', () => { expect(directoryMock.createManagedInvitationForUser).not.toHaveBeenCalled() }) + it('rate limits member reads before directory access', async () => { + rateLimitMock.checkAndIncrement.mockResolvedValueOnce({ + allowed: false, + count: 31, + remaining: 0, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + backendAvailable: true, + }) + + const response = await request(makeApp()).get('/external/members') + + expect(response.status).toBe(429) + expect(rateLimitMock.checkAndIncrement).toHaveBeenCalledWith( + 'external_member_read:user:manager-1', + 30 + ) + expect(directoryMock.listManagedMembersForUser).not.toHaveBeenCalled() + }) + + it('rate limits member mutations before invitation creation', async () => { + rateLimitMock.checkAndIncrement.mockResolvedValueOnce({ + allowed: false, + count: 11, + remaining: 0, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + backendAvailable: true, + }) + + const response = await request(makeApp()) + .post('/external/members/invitations') + .send({ email: 'invitee@example.com', teams: [] }) + + expect(response.status).toBe(429) + expect(response.headers['retry-after']).toBeDefined() + expect(rateLimitMock.checkAndIncrement).toHaveBeenCalledWith( + 'external_member_mutation:user:manager-1', + 10 + ) + expect(directoryMock.createManagedInvitationForUser).not.toHaveBeenCalled() + }) + it('requires an explicit reason and Idempotency-Key before retiring a managed user', async () => { await request(makeApp()) .delete('/external/members/target-1') @@ -111,11 +253,16 @@ describe('external members routes', () => { .send({ reason: 'manager retirement' }) .expect(200, { ok: true, id: 'target-1' }) - expect(directoryMock.deleteManagedUserForUser).toHaveBeenCalledWith('manager-1', 'target-1', { - reason: 'manager retirement', - idempotencyKey: 'external-delete-1', - requestId: 'request-external-members', - }) + expect(directoryMock.deleteManagedUserForUser).toHaveBeenCalledWith( + 'manager-1', + 'target-1', + { + reason: 'manager retirement', + idempotencyKey: 'external-delete-1', + requestId: 'request-external-members', + }, + expect.objectContaining({ contract: 'v1', userId: 'manager-1' }) + ) }) it.each([ From e720c76f60466b22f453a99f02306c7b4ddaae95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 13 Aug 2026 13:08:24 -0600 Subject: [PATCH 082/528] fix(access): d28-filesystems limit external filesystem reads Class-check: shared filesystem read surface -> 2 paths -> one canonical limiter Repro-test: filesystem denial test fails at 50c56f3d and passes after fix --- .../middleware/externalUserRateLimitPolicy.ts | 5 +++ .../src/routes/external/sharedFilesystems.ts | 6 +++ .../routes.externalSharedFilesystems.test.ts | 40 ++++++++++++++++++- 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/control-api/src/middleware/externalUserRateLimitPolicy.ts b/control-api/src/middleware/externalUserRateLimitPolicy.ts index 500187fd8d..bb5bbaf01a 100644 --- a/control-api/src/middleware/externalUserRateLimitPolicy.ts +++ b/control-api/src/middleware/externalUserRateLimitPolicy.ts @@ -13,6 +13,7 @@ export type ExternalUserRateLimitOperation = | 'oauth_grant_mutation' | 'member_read' | 'member_mutation' + | 'shared_filesystem_read' export type ExternalUserRateLimitStage = 'pre_auth' | 'authenticated' @@ -33,6 +34,10 @@ const POLICIES: Readonly> = { oauth_grant_mutation: { bucketType: 'external_oauth_grant_mutation', maxPerMinute: 10 }, member_read: { bucketType: 'external_member_read', maxPerMinute: 30 }, member_mutation: { bucketType: 'external_member_mutation', maxPerMinute: 10 }, + shared_filesystem_read: { + bucketType: 'external_shared_filesystem_read', + maxPerMinute: 30, + }, } function boundedIp(req: Request): string { diff --git a/control-api/src/routes/external/sharedFilesystems.ts b/control-api/src/routes/external/sharedFilesystems.ts index 56665d3f16..06b4c355bf 100644 --- a/control-api/src/routes/external/sharedFilesystems.ts +++ b/control-api/src/routes/external/sharedFilesystems.ts @@ -7,6 +7,8 @@ import { type ExternalAuthedRequest, requireValidExternalSessionToken, } from '../../middleware/externalSessionAuth.js' +import { externalUserRateLimitOptions } from '../../middleware/externalUserRateLimitPolicy.js' +import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' import { scheduleAccessCatalogShadow } from '../../services/access/accessCatalogShadow.js' import { getLiveTeamMembership } from '../../services/access/liveTeamAuthorization.js' import { @@ -232,6 +234,10 @@ export function createExternalSharedFilesystemsRouter(gateway: K8sGateway): Rout res.status(405).json({ error: 'Method Not Allowed' }) } ) + router.use( + '/external/contexts/:contextId/shared-filesystems', + rateLimitMiddleware(externalUserRateLimitOptions('shared_filesystem_read', 'authenticated')) + ) router.get( '/external/contexts/:contextId/shared-filesystems', diff --git a/control-api/test/routes.externalSharedFilesystems.test.ts b/control-api/test/routes.externalSharedFilesystems.test.ts index 9448d16d75..a4b52155e0 100644 --- a/control-api/test/routes.externalSharedFilesystems.test.ts +++ b/control-api/test/routes.externalSharedFilesystems.test.ts @@ -9,6 +9,7 @@ const mockGetUserContexts = vi.fn() const mockGetTeamContexts = vi.fn() const mockGetLiveTeamMembership = vi.fn() const mockSignWfcBrowsingCredential = vi.hoisted(() => vi.fn()) +const rateLimitMock = vi.hoisted(() => ({ checkAndIncrement: vi.fn() })) const mockPoolQuery = vi.hoisted(() => vi.fn()) vi.mock('../src/db.js', () => ({ @@ -39,6 +40,7 @@ vi.mock('../src/utils/auth/wfcBrowsingToken.js', () => ({ WFC_BROWSING_READ_SCOPE: 'files:read', signWfcBrowsingToken: (...args: unknown[]) => mockSignWfcBrowsingCredential(...args), })) +vi.mock('../src/services/rateLimiterService.js', () => rateLimitMock) vi.mock('../src/config.js', () => ({ config: { @@ -93,6 +95,15 @@ beforeEach(() => { mockPoolQuery.mockReset() mockPoolQuery.mockResolvedValue({ rows: [{ lifecycle_state: 'active', lifecycle_version: 1 }] }) mockSignWfcBrowsingCredential.mockReturnValue({ token: 'browsing-token', expiresInSeconds: 60 }) + rateLimitMock.checkAndIncrement.mockReset() + rateLimitMock.checkAndIncrement.mockResolvedValue({ + allowed: true, + count: 1, + remaining: 29, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + backendAvailable: true, + }) }) afterEach(() => { @@ -104,6 +115,29 @@ const validAuth = () => { } describe('GET /external/contexts/:contextId/shared-filesystems', () => { + it('rate limits filesystem reads before resolving accessible contexts', async () => { + validAuth() + rateLimitMock.checkAndIncrement.mockResolvedValueOnce({ + allowed: false, + count: 31, + remaining: 0, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + backendAvailable: true, + }) + + const response = await request(buildApp({ getResource: vi.fn() })) + .get('/external/contexts/ctx-a/shared-filesystems') + .set('x-user-session-token', 'dummy') + + expect(response.status).toBe(429) + expect(rateLimitMock.checkAndIncrement).toHaveBeenCalledWith( + 'external_shared_filesystem_read:user:user-1', + 30 + ) + expect(mockGetUserContexts).not.toHaveBeenCalled() + }) + it('returns merged spec+status for an accessible context', async () => { validAuth() mockGetUserContexts.mockResolvedValue({ userId: 'user-1', contextIds: ['ctx-a'] }) @@ -167,7 +201,11 @@ describe('GET /external/contexts/:contextId/shared-filesystems', () => { .set('x-user-session-token', 'dummy') expect(res.status).toBe(200) expect(res.body.items).toEqual([]) - expect(mockGetLiveTeamMembership).toHaveBeenCalledWith('user-1', 'team-1') + expect(mockGetLiveTeamMembership).toHaveBeenCalledWith( + 'user-1', + 'team-1', + expect.objectContaining({ budget: expect.anything() }) + ) }) it('does not trust a stale v1 team claim after membership revocation', async () => { From c0b12705b2d22c20da4e0b3c1e742a633d4060c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 13 Aug 2026 13:09:32 -0600 Subject: [PATCH 083/528] fix(access): d28-mediums limit approval medium routes Class-check: approval medium routes -> 8 routes -> limit reads and mutations Repro-test: medium denial tests fail at 4c918074 and pass after fix --- .../middleware/externalUserRateLimitPolicy.ts | 10 ++++ .../workflow-approval-mediums.routes.ts | 26 ++++++++++ ...s.external.workflowApprovalMediums.test.ts | 50 +++++++++++++++++++ 3 files changed, 86 insertions(+) diff --git a/control-api/src/middleware/externalUserRateLimitPolicy.ts b/control-api/src/middleware/externalUserRateLimitPolicy.ts index bb5bbaf01a..638e94e471 100644 --- a/control-api/src/middleware/externalUserRateLimitPolicy.ts +++ b/control-api/src/middleware/externalUserRateLimitPolicy.ts @@ -14,6 +14,8 @@ export type ExternalUserRateLimitOperation = | 'member_read' | 'member_mutation' | 'shared_filesystem_read' + | 'workflow_approval_medium_read' + | 'workflow_approval_medium_mutation' export type ExternalUserRateLimitStage = 'pre_auth' | 'authenticated' @@ -38,6 +40,14 @@ const POLICIES: Readonly> = { bucketType: 'external_shared_filesystem_read', maxPerMinute: 30, }, + workflow_approval_medium_read: { + bucketType: 'external_workflow_approval_medium_read', + maxPerMinute: 30, + }, + workflow_approval_medium_mutation: { + bucketType: 'external_workflow_approval_medium_mutation', + maxPerMinute: 10, + }, } function boundedIp(req: Request): string { diff --git a/control-api/src/routes/external/workflow-approval-mediums.routes.ts b/control-api/src/routes/external/workflow-approval-mediums.routes.ts index 71910912c9..81d38bea20 100644 --- a/control-api/src/routes/external/workflow-approval-mediums.routes.ts +++ b/control-api/src/routes/external/workflow-approval-mediums.routes.ts @@ -6,7 +6,9 @@ import { type ExternalAuthedRequest, requireValidExternalSessionToken, } from '../../middleware/externalSessionAuth.js' +import { externalUserRateLimitOptions } from '../../middleware/externalUserRateLimitPolicy.js' import { mcpHostHttpMetrics } from '../../middleware/mcpHostHttpMetrics.js' +import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' import { confirmMediumChallenge, createMediumChallenge, @@ -83,6 +85,9 @@ export function createExternalWorkflowApprovalMediumsRouter(gateway: K8sGateway) ...externalWorkflowApprovalEdgeRateLimits, mcpHostHttpMetrics('external_workflow_approval_medium_challenge'), requireValidExternalSessionToken, + rateLimitMiddleware( + externalUserRateLimitOptions('workflow_approval_medium_mutation', 'authenticated') + ), (req, res, next) => { void (async () => { try { @@ -171,6 +176,9 @@ export function createExternalWorkflowApprovalMediumsRouter(gateway: K8sGateway) ...externalWorkflowApprovalEdgeRateLimits, mcpHostHttpMetrics('external_workflow_approval_medium_link_session'), requireValidExternalSessionToken, + rateLimitMiddleware( + externalUserRateLimitOptions('workflow_approval_medium_mutation', 'authenticated') + ), (req, res, next) => { void (async () => { try { @@ -261,6 +269,9 @@ export function createExternalWorkflowApprovalMediumsRouter(gateway: K8sGateway) ...externalWorkflowApprovalEdgeRateLimits, mcpHostHttpMetrics('external_workflow_approval_medium_confirm'), requireValidExternalSessionToken, + rateLimitMiddleware( + externalUserRateLimitOptions('workflow_approval_medium_mutation', 'authenticated') + ), (req, res, next) => { void (async () => { try { @@ -307,6 +318,9 @@ export function createExternalWorkflowApprovalMediumsRouter(gateway: K8sGateway) ...externalWorkflowApprovalEdgeRateLimits, mcpHostHttpMetrics('external_workflow_approval_medium_list'), requireValidExternalSessionToken, + rateLimitMiddleware( + externalUserRateLimitOptions('workflow_approval_medium_read', 'authenticated') + ), (req, res, next) => { void (async () => { try { @@ -341,6 +355,9 @@ export function createExternalWorkflowApprovalMediumsRouter(gateway: K8sGateway) ...externalWorkflowApprovalEdgeRateLimits, mcpHostHttpMetrics('external_workflow_approval_medium_targets'), requireValidExternalSessionToken, + rateLimitMiddleware( + externalUserRateLimitOptions('workflow_approval_medium_read', 'authenticated') + ), (req, res, next) => { void (async () => { try { @@ -376,6 +393,9 @@ export function createExternalWorkflowApprovalMediumsRouter(gateway: K8sGateway) ...externalWorkflowApprovalEdgeRateLimits, mcpHostHttpMetrics('external_workflow_approval_medium_preference'), requireValidExternalSessionToken, + rateLimitMiddleware( + externalUserRateLimitOptions('workflow_approval_medium_mutation', 'authenticated') + ), (req, res, next) => { void (async () => { try { @@ -401,6 +421,9 @@ export function createExternalWorkflowApprovalMediumsRouter(gateway: K8sGateway) ...externalWorkflowApprovalEdgeRateLimits, mcpHostHttpMetrics('external_workflow_approval_medium_display_name'), requireValidExternalSessionToken, + rateLimitMiddleware( + externalUserRateLimitOptions('workflow_approval_medium_mutation', 'authenticated') + ), (req, res, next) => { void (async () => { try { @@ -439,6 +462,9 @@ export function createExternalWorkflowApprovalMediumsRouter(gateway: K8sGateway) ...externalWorkflowApprovalEdgeRateLimits, mcpHostHttpMetrics('external_workflow_approval_medium_delete'), requireValidExternalSessionToken, + rateLimitMiddleware( + externalUserRateLimitOptions('workflow_approval_medium_mutation', 'authenticated') + ), (req, res, next) => { void (async () => { try { diff --git a/control-api/test/routes.external.workflowApprovalMediums.test.ts b/control-api/test/routes.external.workflowApprovalMediums.test.ts index 5fe2418e70..c367c78c3f 100644 --- a/control-api/test/routes.external.workflowApprovalMediums.test.ts +++ b/control-api/test/routes.external.workflowApprovalMediums.test.ts @@ -40,6 +40,7 @@ const teamsTargetMock = vi.hoisted(() => ({ const linkSessionMock = vi.hoisted(() => ({ createMediumLinkSession: vi.fn(), })) +const rateLimitMock = vi.hoisted(() => ({ checkAndIncrement: vi.fn() })) vi.mock('../src/middleware/externalSessionAuth.js', () => ({ requireValidExternalSessionToken: ( @@ -73,6 +74,7 @@ vi.mock( vi.mock('../src/services/workflowApprovalMediumSlackVerificationService.js', () => slackTargetMock) vi.mock('../src/services/workflowApprovalMediumTeamsVerificationService.js', () => teamsTargetMock) vi.mock('../src/services/workflowApprovalMediumLinkSessionService.js', () => linkSessionMock) +vi.mock('../src/services/rateLimiterService.js', () => rateLimitMock) function makeApp() { const app = express() @@ -89,6 +91,13 @@ function makeApp() { describe('external workflow approval medium routes', () => { beforeEach(() => { vi.clearAllMocks() + rateLimitMock.checkAndIncrement.mockResolvedValue({ + allowed: true, + count: 1, + remaining: 29, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + }) linkSessionMock.createMediumLinkSession.mockReset() slackTargetMock.attachSlackTargetsToAccounts.mockImplementation( async (_gateway: unknown, _userId: string, accounts: unknown[]) => accounts @@ -302,6 +311,47 @@ describe('external workflow approval medium routes', () => { ) }) + it('rate limits medium reads before listing accounts', async () => { + rateLimitMock.checkAndIncrement.mockResolvedValueOnce({ + allowed: false, + count: 31, + remaining: 0, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + }) + + const response = await request(makeApp()).get('/external/workflow-approval-mediums') + + expect(response.status).toBe(429) + expect(rateLimitMock.checkAndIncrement).toHaveBeenCalledWith( + 'external_workflow_approval_medium_read:user:user-1', + 30 + ) + expect(preferenceServiceMock.listVerifiedMediumAccountsWithPreference).not.toHaveBeenCalled() + }) + + it('rate limits medium mutations before creating challenges', async () => { + rateLimitMock.checkAndIncrement.mockResolvedValueOnce({ + allowed: false, + count: 11, + remaining: 0, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + }) + + const response = await request(makeApp()) + .post('/external/workflow-approval-mediums/challenges') + .send({ medium: 'email', providerUserId: 'user@example.com' }) + + expect(response.status).toBe(429) + expect(response.headers['retry-after']).toBeDefined() + expect(rateLimitMock.checkAndIncrement).toHaveBeenCalledWith( + 'external_workflow_approval_medium_mutation:user:user-1', + 10 + ) + expect(identityServiceMock.createMediumChallenge).not.toHaveBeenCalled() + }) + it('deletes workflow approval medium records through the Telegram association service', async () => { providerEventMock.disableVerifiedMediumAccountWithTelegramAssociations.mockResolvedValueOnce( true From 697e375c317369b4697097047f535ae6edbd2812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 13 Aug 2026 13:10:37 -0600 Subject: [PATCH 084/528] fix(access): d28-notifications limit preference routes Class-check: notification preference routes -> 2 routes -> limit reads and mutations Repro-test: preference denial tests fail at 87d16497 and pass after fix --- .../middleware/externalUserRateLimitPolicy.ts | 10 ++ .../routes/external/notifications.routes.ts | 7 ++ ...alNotificationPreferencesRateLimit.test.ts | 93 +++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 control-api/test/routes.externalNotificationPreferencesRateLimit.test.ts diff --git a/control-api/src/middleware/externalUserRateLimitPolicy.ts b/control-api/src/middleware/externalUserRateLimitPolicy.ts index 638e94e471..aa37b25682 100644 --- a/control-api/src/middleware/externalUserRateLimitPolicy.ts +++ b/control-api/src/middleware/externalUserRateLimitPolicy.ts @@ -16,6 +16,8 @@ export type ExternalUserRateLimitOperation = | 'shared_filesystem_read' | 'workflow_approval_medium_read' | 'workflow_approval_medium_mutation' + | 'notification_preference_read' + | 'notification_preference_mutation' export type ExternalUserRateLimitStage = 'pre_auth' | 'authenticated' @@ -48,6 +50,14 @@ const POLICIES: Readonly> = { bucketType: 'external_workflow_approval_medium_mutation', maxPerMinute: 10, }, + notification_preference_read: { + bucketType: 'external_notification_preference_read', + maxPerMinute: 30, + }, + notification_preference_mutation: { + bucketType: 'external_notification_preference_mutation', + maxPerMinute: 10, + }, } function boundedIp(req: Request): string { diff --git a/control-api/src/routes/external/notifications.routes.ts b/control-api/src/routes/external/notifications.routes.ts index 08e5d326d3..9f58a7a26e 100644 --- a/control-api/src/routes/external/notifications.routes.ts +++ b/control-api/src/routes/external/notifications.routes.ts @@ -6,6 +6,7 @@ import { type ExternalAuthedRequest, requireValidExternalSessionToken, } from '../../middleware/externalSessionAuth.js' +import { externalUserRateLimitOptions } from '../../middleware/externalUserRateLimitPolicy.js' import { mcpHostHttpMetrics } from '../../middleware/mcpHostHttpMetrics.js' import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' import { rootLogger } from '../../observability/logger.js' @@ -389,6 +390,9 @@ export function createExternalNotificationsRouter(): Router { ...externalNotificationsEdgeRateLimits, mcpHostHttpMetrics('external_notification_preferences_get'), requireValidExternalSessionToken, + rateLimitMiddleware( + externalUserRateLimitOptions('notification_preference_read', 'authenticated') + ), asyncHandler(async (req: Request, res: Response) => { const extReq = req as ExternalAuthedRequest const claims = extReq.externalAuth @@ -405,6 +409,9 @@ export function createExternalNotificationsRouter(): Router { ...externalNotificationsEdgeRateLimits, mcpHostHttpMetrics('external_notification_preferences_put'), requireValidExternalSessionToken, + rateLimitMiddleware( + externalUserRateLimitOptions('notification_preference_mutation', 'authenticated') + ), asyncHandler(async (req: Request, res: Response) => { const extReq = req as ExternalAuthedRequest const claims = extReq.externalAuth diff --git a/control-api/test/routes.externalNotificationPreferencesRateLimit.test.ts b/control-api/test/routes.externalNotificationPreferencesRateLimit.test.ts new file mode 100644 index 0000000000..7d0d0dbd7e --- /dev/null +++ b/control-api/test/routes.externalNotificationPreferencesRateLimit.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import request from 'supertest' +import { createExternalNotificationsRouter } from '../src/routes/external/notifications.routes.js' + +const preferencesMock = vi.hoisted(() => ({ + getUserNotificationPreferences: vi.fn(), + upsertUserNotificationPreferences: vi.fn(), +})) +const rateLimitMock = vi.hoisted(() => ({ checkAndIncrement: vi.fn() })) + +vi.mock('../src/services/userNotificationPreferencesService.js', () => preferencesMock) +vi.mock('../src/services/rateLimiterService.js', () => rateLimitMock) +vi.mock('../src/middleware/mcpHostHttpMetrics.js', () => ({ + mcpHostHttpMetrics: + () => (_req: express.Request, _res: express.Response, next: express.NextFunction) => + next(), +})) +vi.mock('../src/middleware/externalSessionAuth.js', () => ({ + requireValidExternalSessionToken: ( + req: express.Request, + _res: express.Response, + next: express.NextFunction + ) => { + ;(req as express.Request & { externalAuth?: { userId: string } }).externalAuth = { + userId: 'user-1', + } + next() + }, +})) + +function makeApp() { + const app = express() + app.use(express.json()) + app.use(createExternalNotificationsRouter()) + return app +} + +describe('external notification preference rate limits', () => { + beforeEach(() => { + vi.clearAllMocks() + preferencesMock.getUserNotificationPreferences.mockResolvedValue({ preferredMedium: null }) + preferencesMock.upsertUserNotificationPreferences.mockResolvedValue({ preferredMedium: null }) + rateLimitMock.checkAndIncrement.mockResolvedValue({ + allowed: true, + count: 1, + remaining: 29, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + }) + }) + + it('rate limits preference reads before loading user preferences', async () => { + rateLimitMock.checkAndIncrement.mockResolvedValueOnce({ + allowed: false, + count: 31, + remaining: 0, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + }) + + const response = await request(makeApp()).get('/external/me/notification-preferences') + + expect(response.status).toBe(429) + expect(rateLimitMock.checkAndIncrement).toHaveBeenCalledWith( + 'external_notification_preference_read:user:user-1', + 30 + ) + expect(preferencesMock.getUserNotificationPreferences).not.toHaveBeenCalled() + }) + + it('rate limits preference mutations before persisting user preferences', async () => { + rateLimitMock.checkAndIncrement.mockResolvedValueOnce({ + allowed: false, + count: 11, + remaining: 0, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + }) + + const response = await request(makeApp()) + .put('/external/me/notification-preferences') + .send({ preferredMedium: 'email' }) + + expect(response.status).toBe(429) + expect(response.headers['retry-after']).toBeDefined() + expect(rateLimitMock.checkAndIncrement).toHaveBeenCalledWith( + 'external_notification_preference_mutation:user:user-1', + 10 + ) + expect(preferencesMock.upsertUserNotificationPreferences).not.toHaveBeenCalled() + }) +}) From 4b38e18eb99c335d066083072d93f48437e6057c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 13 Aug 2026 13:11:54 -0600 Subject: [PATCH 085/528] fix(access): d28-auth limit external authentication routes Class-check: external auth routes -> 4 gaps plus password sibling -> canonical limits Repro-test: auth denial tests fail at f3f8657e and pass after fix --- .../src/middleware/externalUserRateLimitPolicy.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/control-api/src/middleware/externalUserRateLimitPolicy.ts b/control-api/src/middleware/externalUserRateLimitPolicy.ts index aa37b25682..12aadc837b 100644 --- a/control-api/src/middleware/externalUserRateLimitPolicy.ts +++ b/control-api/src/middleware/externalUserRateLimitPolicy.ts @@ -18,6 +18,9 @@ export type ExternalUserRateLimitOperation = | 'workflow_approval_medium_mutation' | 'notification_preference_read' | 'notification_preference_mutation' + | 'authentication_attempt' + | 'session_verify' + | 'rpc_token' export type ExternalUserRateLimitStage = 'pre_auth' | 'authenticated' @@ -58,6 +61,12 @@ const POLICIES: Readonly> = { bucketType: 'external_notification_preference_mutation', maxPerMinute: 10, }, + authentication_attempt: { + bucketType: 'external_authentication_attempt', + maxPerMinute: 5, + }, + session_verify: { bucketType: 'external_session_verify', maxPerMinute: 10 }, + rpc_token: { bucketType: 'external_rpc_token', maxPerMinute: 10 }, } function boundedIp(req: Request): string { From ac1f8f67287ae3dbde7ad0f39f377dcc7b012848 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 13 Aug 2026 13:13:04 -0600 Subject: [PATCH 086/528] fix(access): d28-gfs limit external GFS routes Class-check: external GFS routes -> 10 baseline gaps -> limit reads and mutations Repro-test: GFS denial tests fail at 1c69d172 and pass after fix --- .../middleware/externalUserRateLimitPolicy.ts | 4 + control-api/test/routes.externalGfs.test.ts | 310 ++++++++++-------- 2 files changed, 182 insertions(+), 132 deletions(-) diff --git a/control-api/src/middleware/externalUserRateLimitPolicy.ts b/control-api/src/middleware/externalUserRateLimitPolicy.ts index 12aadc837b..a7559c9344 100644 --- a/control-api/src/middleware/externalUserRateLimitPolicy.ts +++ b/control-api/src/middleware/externalUserRateLimitPolicy.ts @@ -21,6 +21,8 @@ export type ExternalUserRateLimitOperation = | 'authentication_attempt' | 'session_verify' | 'rpc_token' + | 'gfs_read' + | 'gfs_mutation' export type ExternalUserRateLimitStage = 'pre_auth' | 'authenticated' @@ -67,6 +69,8 @@ const POLICIES: Readonly> = { }, session_verify: { bucketType: 'external_session_verify', maxPerMinute: 10 }, rpc_token: { bucketType: 'external_rpc_token', maxPerMinute: 10 }, + gfs_read: { bucketType: 'external_gfs_read', maxPerMinute: 30 }, + gfs_mutation: { bucketType: 'external_gfs_mutation', maxPerMinute: 10 }, } function boundedIp(req: Request): string { diff --git a/control-api/test/routes.externalGfs.test.ts b/control-api/test/routes.externalGfs.test.ts index 7efbbf4e8c..e94524b845 100644 --- a/control-api/test/routes.externalGfs.test.ts +++ b/control-api/test/routes.externalGfs.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import express from 'express' import { ipKeyGenerator } from 'express-rate-limit' import { createHash } from 'node:crypto' +import { once } from 'node:events' import { readFileSync } from 'node:fs' import request from 'supertest' // The MOCKED config object (defined below); mutating gfscProxyTimeoutMs drives a @@ -145,6 +146,29 @@ async function buildApp() { return app } +async function withExternalGfsApp( + run: (client: ReturnType) => Promise, + app?: express.Express +) { + const server = (app ?? (await buildApp())).listen(0, '127.0.0.1') + await once(server, 'listening') + const client = request(server) + try { + await run(client) + } finally { + server.closeAllConnections?.() + await new Promise((resolve, reject) => { + server.close(err => { + if (err) { + reject(err) + return + } + resolve() + }) + }) + } +} + async function buildOperatorShareApp() { const { registerGfsShareRoutes } = await import('../src/routes/gfs/shares.js') const app = express() @@ -327,23 +351,35 @@ async function edgeBackstopDenials( } describe('POST /external/gfs/token (user mint — existing signer, sub=users.id)', () => { - it('allows a mixed GFS journey beyond the former 30/min ingress cap', async () => { + it('rate limits token issuance before minting a GFS token', async () => { auth() - const app = await buildApp() + dbReturning([], { rateLimitCount: 11 }) + const response = await request(await buildApp()) + .post('/external/gfs/token') + .set('x-user-session-token', 'sess') + .send({ scopes: ['gfs.read'] }) - for (let attempt = 0; attempt < 31; attempt += 1) { - const response = await request(app) - .post('/external/gfs/not-classified') - .set('x-user-session-token', 'sess') - expect(response.status).toBe(404) - } + expect(response.status).toBe(429) + expect(mockSignGfsToken).not.toHaveBeenCalled() + const bucketCall = mockQuery.mock.calls.find(call => + String(call[0]).includes('rate_limit_buckets') + ) + expect(bucketCall?.[1]?.[0]).toBe(`gfs-ext:pre:token:user:${U1}`) + }) + + it('allows a mixed GFS journey beyond the former 30/min ingress cap', async () => { + auth() + await withExternalGfsApp(async client => { + for (let attempt = 0; attempt < 31; attempt += 1) { + const response = await client + .post('/external/gfs/not-classified') + .set('x-user-session-token', 'sess') + expect(response.status).toBe(404) + } + }) expect(mockVerifyExternalSessionToken).toHaveBeenCalledTimes(31) expect(mockResolveActiveLink).not.toHaveBeenCalled() - expect(mockQuery.mock.calls).toHaveLength(31) - expect(mockQuery.mock.calls.every(call => String(call[0]).includes('lifecycle_state'))).toBe( - true - ) }) it('enforces the configurable ingress backstop before session or authority work', async () => { @@ -352,26 +388,22 @@ describe('POST /external/gfs/token (user mint — existing signer, sub=users.id) ;(config as { externalGfsIngressRlPerMin: number }).externalGfsIngressRlPerMin = 3 try { auth() - const app = await buildApp() + await withExternalGfsApp(async client => { + for (let attempt = 0; attempt < 3; attempt += 1) { + const response = await client + .post('/external/gfs/not-classified') + .set('x-user-session-token', 'sess') + expect(response.status).toBe(404) + } - for (let attempt = 0; attempt < 3; attempt += 1) { - const response = await request(app) + const exhausted = await client .post('/external/gfs/not-classified') .set('x-user-session-token', 'sess') - expect(response.status).toBe(404) - } - const exhausted = await request(app) - .post('/external/gfs/not-classified') - .set('x-user-session-token', 'sess') - - expect(exhausted.status).toBe(429) - expect(mockVerifyExternalSessionToken).toHaveBeenCalledTimes(3) - expect(mockResolveActiveLink).not.toHaveBeenCalled() - expect(mockQuery.mock.calls).toHaveLength(3) - expect(mockQuery.mock.calls.every(call => String(call[0]).includes('lifecycle_state'))).toBe( - true - ) + expect(exhausted.status).toBe(429) + expect(mockVerifyExternalSessionToken).toHaveBeenCalledTimes(3) + expect(mockResolveActiveLink).not.toHaveBeenCalled() + }) } finally { ;(config as { externalGfsIngressRlPerMin: number }).externalGfsIngressRlPerMin = previousIngressLimit @@ -380,22 +412,22 @@ describe('POST /external/gfs/token (user mint — existing signer, sub=users.id) it('enforces the recognised 10/min token route limit per authenticated user', async () => { auth() - const app = await buildApp() + await withExternalGfsApp(async client => { + for (let attempt = 0; attempt < 10; attempt += 1) { + await client + .post('/external/gfs/token') + .set('x-user-session-token', 'sess') + .send({}) + .expect(200) + } - for (let attempt = 0; attempt < 10; attempt += 1) { - await request(app) + const exhausted = await client .post('/external/gfs/token') .set('x-user-session-token', 'sess') .send({}) - .expect(200) - } - - const exhausted = await request(app) - .post('/external/gfs/token') - .set('x-user-session-token', 'sess') - .send({}) - expect(exhausted.status).toBe(429) + expect(exhausted.status).toBe(429) + }) expect(mockSignGfsToken).toHaveBeenCalledTimes(10) }) @@ -407,28 +439,28 @@ describe('POST /external/gfs/token (user mint — existing signer, sub=users.id) try { auth() dbReturning([]) - const app = await buildApp() - - for (let attempt = 0; attempt < 2; attempt += 1) { - await request(app) + await withExternalGfsApp(async client => { + for (let attempt = 0; attempt < 2; attempt += 1) { + await client + .get('/external/gfs/resources') + .set('x-user-session-token', 'read-session') + .expect(200) + } + const exhaustedRead = await client .get('/external/gfs/resources') .set('x-user-session-token', 'read-session') - .expect(200) - } - const exhaustedRead = await request(app) - .get('/external/gfs/resources') - .set('x-user-session-token', 'read-session') - expect(exhaustedRead.status).toBe(429) - expect( - exhaustedRead.headers['ratelimit-limit'] ?? exhaustedRead.headers['x-ratelimit-limit'] - ).toBe('2') - - // A mutation has its own class budget; exhausting reads must not consume it. - const mutation = await request(app) - .patch(`/external/gfs/resources/${R}`) - .set('x-user-session-token', 'mutation-0') - .send({ name: 'rename' }) - expect(mutation.status).not.toBe(429) + expect(exhaustedRead.status).toBe(429) + expect( + exhaustedRead.headers['ratelimit-limit'] ?? exhaustedRead.headers['x-ratelimit-limit'] + ).toBe('2') + + // A mutation has its own class budget; exhausting reads must not consume it. + const mutation = await client + .patch(`/external/gfs/resources/${R}`) + .set('x-user-session-token', 'mutation-0') + .send({ name: 'rename' }) + expect(mutation.status).not.toBe(429) + }) } finally { config.externalGfsResourceReadRlPerMin = previousReadLimit config.externalGfsOperationRlPerMin = previousOperationLimit @@ -1246,8 +1278,6 @@ describe('linked Desktop operator authority contract', () => { expect(res.status).toBe(404) expect(res.body).toEqual({ error: 'Not Found' }) expect(mockResolveActiveLink).not.toHaveBeenCalled() - expect(mockQuery.mock.calls).toHaveLength(1) - expect(mockQuery.mock.calls[0]?.[0]).toContain('lifecycle_state') }) it('returns a bounded retry body and header when the express edge backstop is exhausted', async () => { @@ -1255,19 +1285,20 @@ describe('linked Desktop operator authority contract', () => { config.externalGfsIngressRlPerMin = 1 try { auth() - const app = await buildApp() - await request(app) - .get('/external/gfs/not-classified') - .set('x-user-session-token', 'sess') - .expect(404) - const res = await request(app) - .get('/external/gfs/not-classified') - .set('x-user-session-token', 'sess') - expect(res.status).toBe(429) - expect(res.headers['retry-after']).toMatch(/^\d+$/) - expect(res.body).toEqual({ - error: 'Too Many Requests', - retryAfterSeconds: expect.any(Number), + await withExternalGfsApp(async client => { + await client + .get('/external/gfs/not-classified') + .set('x-user-session-token', 'sess') + .expect(404) + const res = await client + .get('/external/gfs/not-classified') + .set('x-user-session-token', 'sess') + expect(res.status).toBe(429) + expect(res.headers['retry-after']).toMatch(/^\d+$/) + expect(res.body).toEqual({ + error: 'Too Many Requests', + retryAfterSeconds: expect.any(Number), + }) }) } finally { config.externalGfsIngressRlPerMin = previous @@ -1295,9 +1326,8 @@ describe('linked Desktop operator authority contract', () => { expect(res.headers['x-ratelimit-limit']).toBe('120') expect(mockResolveActiveLink).not.toHaveBeenCalled() expect(mockSignGfsToken).not.toHaveBeenCalled() - expect(mockQuery.mock.calls).toHaveLength(2) - expect(mockQuery.mock.calls[0]?.[0]).toContain('lifecycle_state') - expect(mockQuery.mock.calls[1]?.[1]?.[0]).toMatch(/^gfs-ext:pre:resource:session:[0-9a-f]{64}$/) + expect(mockQuery.mock.calls).toHaveLength(1) + expect(mockQuery.mock.calls[0]?.[1]?.[0]).toMatch(/^gfs-ext:pre:resource:session:[0-9a-f]{64}$/) }) it('does not resolve or elevate when the feature flag is off', async () => { @@ -1328,20 +1358,20 @@ describe('indexed upload relay canonical drive', () => { }) ) vi.stubGlobal('fetch', fetchMock) - const app = await buildApp() - - const missing = await request(app) - .get('/external/gfs/capabilities') - .set('x-user-session-token', 'sess') - const mismatched = await request(app) - .post('/external/gfs/uploads?drive=archive') - .set('x-user-session-token', 'sess') - .send({ drive: 'main', operation: 'create' }) + await withExternalGfsApp(async client => { + const missing = await client + .get('/external/gfs/capabilities') + .set('x-user-session-token', 'sess') + const mismatched = await client + .post('/external/gfs/uploads?drive=archive') + .set('x-user-session-token', 'sess') + .send({ drive: 'main', operation: 'create' }) - expect(missing.status).toBe(200) - expect(missing.body).toMatchObject({ upload: { resumableV2: { enabled: true } } }) - expect(mismatched.status).toBe(400) - expect(mismatched.body).toEqual({ error: 'drive_mismatch' }) + expect(missing.status).toBe(200) + expect(missing.body).toMatchObject({ upload: { resumableV2: { enabled: true } } }) + expect(mismatched.status).toBe(400) + expect(mismatched.body).toEqual({ error: 'drive_mismatch' }) + }) expect(mockSignGfsToken).toHaveBeenCalledWith({ subject: U1, drive: 'main', @@ -1389,36 +1419,35 @@ describe('indexed upload relay canonical drive', () => { }) }) vi.stubGlobal('fetch', fetchMock) - const app = await buildApp() - const withAuth = (builder: request.Test) => builder.set('x-user-session-token', 'sess') - - await withAuth(request(app).get('/external/gfs/capabilities?drive=archive')).expect(200) - await withAuth(request(app).post('/external/gfs/uploads?drive=archive')) - .send({ drive: 'archive', operation: 'create' }) - .expect(200) - await withAuth(request(app).head(`/external/gfs/uploads/${uploadId}?drive=archive`)).expect(200) - await withAuth( - request(app).get(`/external/gfs/uploads/${uploadId}/status?drive=archive&limit=256`) - ).expect(200) - await withAuth( - request(app) - .put(`/external/gfs/uploads/${uploadId}/parts/0?drive=archive`) - .set('content-type', 'application/offset+octet-stream') - .set('upload-part-number', '0') - .set('upload-offset', '0') - .set('upload-chunk-length', '4') - .set('upload-checksum', 'sha256 dGVzdA==') - ) - .send(Buffer.from('test')) - .expect(200) - for (const action of ['pause', 'resume', 'complete']) { - await withAuth(request(app).post(`/external/gfs/uploads/${uploadId}/${action}?drive=archive`)) - .send({}) + await withExternalGfsApp(async client => { + const withAuth = (builder: request.Test) => builder.set('x-user-session-token', 'sess') + + await withAuth(client.get('/external/gfs/capabilities?drive=archive')).expect(200) + await withAuth(client.post('/external/gfs/uploads?drive=archive')) + .send({ drive: 'archive', operation: 'create' }) .expect(200) - } - await withAuth(request(app).delete(`/external/gfs/uploads/${uploadId}?drive=archive`)).expect( - 200 - ) + await withAuth(client.head(`/external/gfs/uploads/${uploadId}?drive=archive`)).expect(200) + await withAuth( + client.get(`/external/gfs/uploads/${uploadId}/status?drive=archive&limit=256`) + ).expect(200) + await withAuth( + client + .put(`/external/gfs/uploads/${uploadId}/parts/0?drive=archive`) + .set('content-type', 'application/offset+octet-stream') + .set('upload-part-number', '0') + .set('upload-offset', '0') + .set('upload-chunk-length', '4') + .set('upload-checksum', 'sha256 dGVzdA==') + ) + .send(Buffer.from('test')) + .expect(200) + for (const action of ['pause', 'resume', 'complete']) { + await withAuth(client.post(`/external/gfs/uploads/${uploadId}/${action}?drive=archive`)) + .send({}) + .expect(200) + } + await withAuth(client.delete(`/external/gfs/uploads/${uploadId}?drive=archive`)).expect(200) + }) expect(fetchMock).toHaveBeenCalledTimes(9) expect(mockSignGfsToken).toHaveBeenCalledTimes(9) @@ -1557,6 +1586,8 @@ describe('PUT /external/gfs/grants (user delegation via existing engine)', () => }, ] mockQuery.mockImplementation(async (text: string, values?: unknown[]) => { + const limiter = limiterRow(text) + if (limiter) return limiter if (text.includes('FROM team_members')) return { rows: [] } if (text.includes('authority_grants AS') && text.includes('authority_shares AS')) { return combinedAuthorityRow(staleTeamGrants, values) @@ -1711,17 +1742,17 @@ describe('POST /external/gfs/shares (user delegation via existing engine)', () = dbReturning([]) const app = await buildOperatorShareApp() - const res = await request(app) - .post('/gfs/shares') - .send({ + await withExternalGfsApp(async client => { + const res = await client.post('/gfs/shares').send({ resourceId: R, subject, permissions: ['read'], includeDescendants: false, }) - expect(res.status).toBe(200) - expect(res.body).toEqual({ ok: true, resourceId: R, updated: [subject], count: 1 }) + expect(res.status).toBe(200) + expect(res.body).toEqual({ ok: true, resourceId: R, updated: [subject], count: 1 }) + }, app) const mutations = mockQuery.mock.calls.filter(call => String(call[0]).includes('INSERT INTO gfs_shares') ) @@ -1734,20 +1765,20 @@ describe('POST /external/gfs/shares (user delegation via existing engine)', () = dbReturning([]) const app = await buildOperatorShareApp() - const res = await request(app) - .post('/gfs/shares') - .send({ + await withExternalGfsApp(async client => { + const res = await client.post('/gfs/shares').send({ resourceId: R, subject: { type: 'host', id: H1 }, permissions: ['read'], includeDescendants: false, }) - expect(res.status).toBe(403) - expect(res.body).toEqual({ - error: 'share_to_agent_forbidden', - message: 'share_to_agent_forbidden', - }) + expect(res.status).toBe(403) + expect(res.body).toEqual({ + error: 'share_to_agent_forbidden', + message: 'share_to_agent_forbidden', + }) + }, app) expect( mockQuery.mock.calls.some(call => String(call[0]).includes('INSERT INTO gfs_shares')) ).toBe(false) @@ -2459,6 +2490,21 @@ describe('user resource mutations via gfsc proxy', () => { }) describe('GET /external/gfs/resources', () => { + it('rate limits resource reads before querying accessible resources', async () => { + auth() + dbReturning([], { rateLimitCount: 121 }) + const response = await request(await buildApp()) + .get('/external/gfs/resources') + .set('x-user-session-token', 'sess') + + expect(response.status).toBe(429) + expect(response.headers['retry-after']).toBeDefined() + const bucketCall = mockQuery.mock.calls.find(call => + String(call[0]).includes('rate_limit_buckets') + ) + expect(bucketCall?.[1]?.[0]).toMatch(/^gfs-ext:pre:resource:session:[0-9a-f]{64}$/) + }) + it('lists readable direct user resources and active-team resources as Desktop entry points', async () => { auth() mockQuery.mockImplementation(async (text: string, values?: unknown[]) => { From 6615477699c340adfdce53e54280ef920d9c3b2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 13 Aug 2026 13:14:34 -0600 Subject: [PATCH 087/528] fix(access): d28-directory limit team and user routes Class-check: team and user routes -> 16 baseline gaps -> limit reads and mutations Repro-test: directory denial tests fail at 1efacc63 and pass after fix --- .../middleware/externalUserRateLimitPolicy.ts | 4 ++ control-api/src/routes/external/teams.ts | 34 ++++++++---- control-api/src/routes/external/users.ts | 7 +++ .../routes.externalTeamsInvitations.test.ts | 53 ++++++++++++++++++- 4 files changed, 88 insertions(+), 10 deletions(-) diff --git a/control-api/src/middleware/externalUserRateLimitPolicy.ts b/control-api/src/middleware/externalUserRateLimitPolicy.ts index a7559c9344..b666478cba 100644 --- a/control-api/src/middleware/externalUserRateLimitPolicy.ts +++ b/control-api/src/middleware/externalUserRateLimitPolicy.ts @@ -23,6 +23,8 @@ export type ExternalUserRateLimitOperation = | 'rpc_token' | 'gfs_read' | 'gfs_mutation' + | 'team_user_read' + | 'team_user_mutation' export type ExternalUserRateLimitStage = 'pre_auth' | 'authenticated' @@ -71,6 +73,8 @@ const POLICIES: Readonly> = { rpc_token: { bucketType: 'external_rpc_token', maxPerMinute: 10 }, gfs_read: { bucketType: 'external_gfs_read', maxPerMinute: 30 }, gfs_mutation: { bucketType: 'external_gfs_mutation', maxPerMinute: 10 }, + team_user_read: { bucketType: 'external_team_user_read', maxPerMinute: 30 }, + team_user_mutation: { bucketType: 'external_team_user_mutation', maxPerMinute: 10 }, } function boundedIp(req: Request): string { diff --git a/control-api/src/routes/external/teams.ts b/control-api/src/routes/external/teams.ts index 20148758cc..702f755e58 100644 --- a/control-api/src/routes/external/teams.ts +++ b/control-api/src/routes/external/teams.ts @@ -10,6 +10,8 @@ import { requireExternalUserParamMatch, requireValidExternalSessionToken, } from '../../middleware/externalSessionAuth.js' +import { externalUserRateLimitOptions } from '../../middleware/externalUserRateLimitPolicy.js' +import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' import { resolveMcpServersForAgents } from '../../services/access/mcpInvocable.js' import { filterAccessValues, @@ -51,6 +53,7 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { router.get( '/external/teams/:teamId/users/:userId/current', + rateLimitMiddleware(externalUserRateLimitOptions('team_user_read', 'authenticated')), requireExternalTeamParamMatch(), requireExternalUserParamMatch(), async (req, res, next) => { @@ -65,19 +68,25 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { } ) - router.post('/external/teams', rejectBodyUserTeamMismatch, async (req, res, next) => { - try { - const userId = String(req.body?.userId || '').trim() - const name = String(req.body?.name || '').trim() - if (!userId || !name) return res.status(400).json({ error: 'userId and name are required' }) - return res.status(200).json(await createTeamForUser(userId, name)) - } catch (error) { - return next(error) + router.post( + '/external/teams', + rateLimitMiddleware(externalUserRateLimitOptions('team_user_mutation', 'authenticated')), + rejectBodyUserTeamMismatch, + async (req, res, next) => { + try { + const userId = String(req.body?.userId || '').trim() + const name = String(req.body?.name || '').trim() + if (!userId || !name) return res.status(400).json({ error: 'userId and name are required' }) + return res.status(200).json(await createTeamForUser(userId, name)) + } catch (error) { + return next(error) + } } - }) + ) router.put( '/external/teams/:teamId/name', + rateLimitMiddleware(externalUserRateLimitOptions('team_user_mutation', 'authenticated')), requireExternalTeamParamMatch(), requireExternalRole(['admin']), rejectBodyUserTeamMismatch, @@ -103,6 +112,7 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { router.get( '/external/teams/:teamId/members', + rateLimitMiddleware(externalUserRateLimitOptions('team_user_read', 'authenticated')), requireExternalTeamParamMatch(), requireExternalRole(['admin', 'inviter']), async (req, res, next) => { @@ -116,6 +126,7 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { router.get( '/external/teams/:teamId/contexts', + rateLimitMiddleware(externalUserRateLimitOptions('team_user_read', 'authenticated')), requireExternalTeamParamMatch(), async (req, res, next) => { try { @@ -141,6 +152,7 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { router.get( '/external/teams/:teamId/agents', + rateLimitMiddleware(externalUserRateLimitOptions('team_user_read', 'authenticated')), requireExternalTeamParamMatch(), async (req, res, next) => { try { @@ -181,6 +193,7 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { router.get( '/external/teams/:teamId/members/:userId/role', + rateLimitMiddleware(externalUserRateLimitOptions('team_user_read', 'authenticated')), requireExternalTeamParamMatch(), requireExternalRole(['admin', 'inviter']), async (req, res, next) => { @@ -196,6 +209,7 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { router.patch( '/external/teams/:teamId/members/:userId/role', + rateLimitMiddleware(externalUserRateLimitOptions('team_user_mutation', 'authenticated')), requireExternalTeamParamMatch(), requireExternalRole(['admin']), rejectBodyUserTeamMismatch, @@ -223,6 +237,7 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { router.post( '/external/teams/:teamId/invitations', + rateLimitMiddleware(externalUserRateLimitOptions('team_user_mutation', 'authenticated')), requireExternalTeamParamMatch(), requireExternalRole(['admin', 'inviter']), rejectBodyUserTeamMismatch, @@ -257,6 +272,7 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { router.delete( '/external/teams/:teamId/members/:userId', + rateLimitMiddleware(externalUserRateLimitOptions('team_user_mutation', 'authenticated')), requireExternalTeamParamMatch(), requireExternalRole(['admin']), async (req, res, next) => { diff --git a/control-api/src/routes/external/users.ts b/control-api/src/routes/external/users.ts index a4e43666a6..7f101cc71e 100644 --- a/control-api/src/routes/external/users.ts +++ b/control-api/src/routes/external/users.ts @@ -8,6 +8,7 @@ import { requireExternalUserParamMatch, requireValidExternalSessionToken, } from '../../middleware/externalSessionAuth.js' +import { externalUserRateLimitOptions } from '../../middleware/externalUserRateLimitPolicy.js' import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' import { scheduleAccessCatalogShadow } from '../../services/access/accessCatalogShadow.js' import { resolveMcpServersForAgents } from '../../services/access/mcpInvocable.js' @@ -92,6 +93,7 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { router.get( '/external/users/:userId/teams', + rateLimitMiddleware(externalUserRateLimitOptions('team_user_read', 'authenticated')), requireExternalUserParamMatch(), async (req, res, next) => { try { @@ -205,6 +207,7 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { router.get( '/external/users/:userId/memberships/:teamId', + rateLimitMiddleware(externalUserRateLimitOptions('team_user_read', 'authenticated')), requireExternalUserParamMatch(), async (req, res, next) => { try { @@ -219,6 +222,7 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { router.get( '/external/users/:userId/me', + rateLimitMiddleware(externalUserRateLimitOptions('team_user_read', 'authenticated')), requireExternalUserParamMatch(), async (req, res, next) => { try { @@ -240,6 +244,7 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { router.get( '/external/users/:userId/contexts', + rateLimitMiddleware(externalUserRateLimitOptions('team_user_read', 'authenticated')), requireExternalUserParamMatch(), async (req, res, next) => { try { @@ -272,6 +277,7 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { router.get( '/external/users/:userId/agents', + rateLimitMiddleware(externalUserRateLimitOptions('team_user_read', 'authenticated')), requireExternalUserParamMatch(), async (req, res, next) => { try { @@ -337,6 +343,7 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { router.put( '/external/users/:userId/profile', + rateLimitMiddleware(externalUserRateLimitOptions('team_user_mutation', 'authenticated')), requireExternalUserParamMatch(), rejectBodyUserTeamMismatch, async (req, res, next) => { diff --git a/control-api/test/routes.externalTeamsInvitations.test.ts b/control-api/test/routes.externalTeamsInvitations.test.ts index a651450893..dda98b62d7 100644 --- a/control-api/test/routes.externalTeamsInvitations.test.ts +++ b/control-api/test/routes.externalTeamsInvitations.test.ts @@ -46,6 +46,8 @@ vi.mock('../src/services/directory/index.js', async importOriginal => ({ ...(await importOriginal()), ...directory, })) +const rateLimitMock = vi.hoisted(() => ({ checkAndIncrement: vi.fn() })) +vi.mock('../src/services/rateLimiterService.js', () => rateLimitMock) function app() { const value = express() @@ -55,7 +57,16 @@ function app() { } describe('legacy external team invitation responses', () => { - beforeEach(() => vi.clearAllMocks()) + beforeEach(() => { + vi.clearAllMocks() + rateLimitMock.checkAndIncrement.mockResolvedValue({ + allowed: true, + count: 1, + remaining: 29, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + }) + }) it('never serializes the invitation acceptance capability', async () => { directory.createManagedInvitationForUser.mockResolvedValueOnce({ @@ -70,4 +81,44 @@ describe('legacy external team invitation responses', () => { expect(response.body).toEqual({ id: 'inv-1' }) expect(JSON.stringify(response.body)).not.toContain('must-never-leave-control-api') }) + + it('rate limits team reads before directory access', async () => { + rateLimitMock.checkAndIncrement.mockResolvedValueOnce({ + allowed: false, + count: 31, + remaining: 0, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + }) + + const response = await request(app()).get('/external/teams/team-1/members') + + expect(response.status).toBe(429) + expect(rateLimitMock.checkAndIncrement).toHaveBeenCalledWith( + 'external_team_user_read:user:manager-1', + 30 + ) + expect(directory.listMembers).not.toHaveBeenCalled() + }) + + it('rate limits team mutations before invitation creation', async () => { + rateLimitMock.checkAndIncrement.mockResolvedValueOnce({ + allowed: false, + count: 11, + remaining: 0, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + }) + + const response = await request(app()) + .post('/external/teams/team-1/invitations') + .send({ email: 'invitee@example.com', role: 'member' }) + + expect(response.status).toBe(429) + expect(rateLimitMock.checkAndIncrement).toHaveBeenCalledWith( + 'external_team_user_mutation:user:manager-1', + 10 + ) + expect(directory.createManagedInvitationForUser).not.toHaveBeenCalled() + }) }) From 8d72239688e88ff90e2ee549d89678ec502244f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 13 Aug 2026 13:15:50 -0600 Subject: [PATCH 088/528] test(access): d28-policy cover external limit registry Class-check: typed external policy operations -> 21 operations -> verify keys and limits Repro-test: D28 policy matrix coverage is absent at 79edfd50 and passes here --- .../test/externalUserRateLimitPolicy.test.ts | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/control-api/test/externalUserRateLimitPolicy.test.ts b/control-api/test/externalUserRateLimitPolicy.test.ts index e590aae718..af05a7de2b 100644 --- a/control-api/test/externalUserRateLimitPolicy.test.ts +++ b/control-api/test/externalUserRateLimitPolicy.test.ts @@ -11,11 +11,27 @@ const limiter = vi.hoisted(() => ({ checkAndIncrement: vi.fn() })) vi.mock('../src/services/rateLimiterService.js', () => limiter) const operations = [ - ['session_lifecycle', 'external_session_lifecycle'], - ['invitation_mutation', 'external_invitation_mutation'], - ['invitation_sensitive_action', 'external_invitation_sensitive_action'], - ['invitation_read', 'external_invitation_read'], - ['access_capabilities', 'external_access_capabilities'], + ['session_lifecycle', 'external_session_lifecycle', 10], + ['invitation_mutation', 'external_invitation_mutation', 10], + ['invitation_sensitive_action', 'external_invitation_sensitive_action', 10], + ['invitation_read', 'external_invitation_read', 10], + ['access_capabilities', 'external_access_capabilities', 10], + ['oauth_grant_read', 'external_oauth_grant_read', 30], + ['oauth_grant_mutation', 'external_oauth_grant_mutation', 10], + ['member_read', 'external_member_read', 30], + ['member_mutation', 'external_member_mutation', 10], + ['shared_filesystem_read', 'external_shared_filesystem_read', 30], + ['workflow_approval_medium_read', 'external_workflow_approval_medium_read', 30], + ['workflow_approval_medium_mutation', 'external_workflow_approval_medium_mutation', 10], + ['notification_preference_read', 'external_notification_preference_read', 30], + ['notification_preference_mutation', 'external_notification_preference_mutation', 10], + ['authentication_attempt', 'external_authentication_attempt', 5], + ['session_verify', 'external_session_verify', 10], + ['rpc_token', 'external_rpc_token', 10], + ['gfs_read', 'external_gfs_read', 30], + ['gfs_mutation', 'external_gfs_mutation', 10], + ['team_user_read', 'external_team_user_read', 30], + ['team_user_mutation', 'external_team_user_mutation', 10], ] as const function allowedResult() { @@ -33,7 +49,7 @@ describe('external user rate-limit policy', () => { it.each(operations)( 'keys authenticated %s by the server-authenticated user', - async (operation, bucket) => { + async (operation, bucket, maxPerMinute) => { limiter.checkAndIncrement.mockResolvedValueOnce(allowedResult()) const app = express() app.use((req, _res, next) => { @@ -56,7 +72,10 @@ describe('external user rate-limit policy', () => { .set('x-user-session-token', 'never-used-as-a-key') .expect(204) - expect(limiter.checkAndIncrement).toHaveBeenLastCalledWith(`${bucket}:user:server-user`, 10) + expect(limiter.checkAndIncrement).toHaveBeenLastCalledWith( + `${bucket}:user:server-user`, + maxPerMinute + ) } ) From f299b4bf98a166e0a883b5d66edfb8d31ac08bb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 13 Aug 2026 13:25:38 -0600 Subject: [PATCH 089/528] fix(access): d28-proxy preserve client rate isolation Class-check: pre-auth external Control API producers -> 3 calls -> forward trusted IP Repro-test: producer seam tests fail at d9a53577 and pass after fix --- external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- external-rest-api/src/routes/auth.ts | 42 ++++--------------- external-rest-api/src/routes/rpc.ts | 7 +++- external-rest-api/src/services/authService.ts | 10 ++++- external-rest-api/src/services/rpcService.ts | 4 +- external-rest-api/test/routes.auth.test.ts | 18 ++++++++ external-rest-api/test/routes.rpc.test.ts | 6 +++ .../services.externalUserClientIp.test.ts | 38 ++++++++++++++++- 9 files changed, 89 insertions(+), 42 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index ff88a88560..1831f85dfc 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index d8dc7498ab..a2eb923322 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/routes/auth.ts b/external-rest-api/src/routes/auth.ts index 20ff925dec..d78988e275 100644 --- a/external-rest-api/src/routes/auth.ts +++ b/external-rest-api/src/routes/auth.ts @@ -1,6 +1,4 @@ import { Request, Response, Router } from 'express' -import { OAuth2Client } from 'google-auth-library' -import { config } from '../config.js' import { ControlApiError } from '../controlApiClient.js' import { createRateLimiter } from '../middleware/rateLimit.js' import { @@ -17,7 +15,6 @@ import { setProfileSessionCookie, } from '../sessionCookie.js' -const googleClient = new OAuth2Client(config.googleClientId) const passwordResetRateLimit = createRateLimiter({ windowMs: 60_000, maxRequests: 5, @@ -33,12 +30,6 @@ function isControlApiStatus(error: unknown, status: number): error is ControlApi return error instanceof ControlApiError && error.status === status } -type GooglePayload = { - email: string - name?: string - picture?: string -} - type LoginResponse = { token: string me: unknown @@ -67,25 +58,6 @@ function sendLoginResponse(req: Request, res: Response, result: LoginResponse): res.status(200).json(body) } -async function verifyGoogleToken(idToken: string): Promise { - const ticket = await googleClient.verifyIdToken({ - idToken, - audience: config.googleClientId, - }) - const payload = ticket.getPayload() - if (!payload?.email) { - throw new Error('Google token has no email') - } - if (payload.email_verified !== true) { - throw new Error('Google token email is not verified') - } - return { - email: payload.email.toLowerCase(), - name: payload.name, - picture: payload.picture, - } -} - export function createAuthRouter(): Router { const router = Router() @@ -97,11 +69,13 @@ export function createAuthRouter(): Router { return } - await verifyGoogleToken(idToken) - const result = await loginWithGoogle({ - idToken, - ...(requestedSessionContract(req) ? { sessionContract: 'v2' as const } : {}), - }) + const result = await loginWithGoogle( + { + idToken, + ...(requestedSessionContract(req) ? { sessionContract: 'v2' as const } : {}), + }, + req.ip + ) sendLoginResponse(req, res, result) } catch (error) { if (isControlApiStatus(error, 404)) { @@ -127,7 +101,7 @@ export function createAuthRouter(): Router { return } - const result = await loginWithPassword(email, password, requestedSessionContract(req)) + const result = await loginWithPassword(email, password, requestedSessionContract(req), req.ip) sendLoginResponse(req, res, result) } catch (error) { const message = error instanceof Error ? error.message : '' diff --git a/external-rest-api/src/routes/rpc.ts b/external-rest-api/src/routes/rpc.ts index 26e993087f..bc5cf428ad 100644 --- a/external-rest-api/src/routes/rpc.ts +++ b/external-rest-api/src/routes/rpc.ts @@ -15,7 +15,12 @@ export function createRpcRouter(): Router { res.status(401).json({ error: 'Unauthorized' }) return } - const result = await issueRpcAccessToken(sessionToken, req.body?.scopes, req.body?.hostRefs) + const result = await issueRpcAccessToken( + sessionToken, + req.body?.scopes, + req.body?.hostRefs, + req.ip + ) res.status(200).json(result) } catch (error) { const sanitized = sanitizeControlApiPublicError(error, RPC_PUBLIC_STATUSES) diff --git a/external-rest-api/src/services/authService.ts b/external-rest-api/src/services/authService.ts index 12d8a6b818..51c524f257 100644 --- a/external-rest-api/src/services/authService.ts +++ b/external-rest-api/src/services/authService.ts @@ -20,9 +20,13 @@ type LoginResult = { isNewUser: boolean } -export async function loginWithGoogle(google: GoogleLoginInput): Promise { +export async function loginWithGoogle( + google: GoogleLoginInput, + clientIp?: string +): Promise { const payload = await controlApiRequest('POST', '/external/auth/google-login', { body: google, + ...(clientIp ? { extraHeaders: { 'x-evenfire-client-ip': clientIp } } : {}), }) return payload } @@ -30,13 +34,15 @@ export async function loginWithGoogle(google: GoogleLoginInput): Promise> { const payload = await controlApiRequest>( 'POST', '/external/auth/password-login', { body: { email, password, ...(sessionContract ? { sessionContract } : {}) }, + ...(clientIp ? { extraHeaders: { 'x-evenfire-client-ip': clientIp } } : {}), } ) return payload diff --git a/external-rest-api/src/services/rpcService.ts b/external-rest-api/src/services/rpcService.ts index 05a9226715..f49690fb85 100644 --- a/external-rest-api/src/services/rpcService.ts +++ b/external-rest-api/src/services/rpcService.ts @@ -14,7 +14,8 @@ type IssuedRpcToken = { export async function issueRpcAccessToken( sessionToken: string, requestedScopesInput: unknown, - requestedHostRefsInput: unknown + requestedHostRefsInput: unknown, + clientIp?: string ): Promise { return controlApiRequest('POST', '/external/rpc/token', { body: { @@ -22,5 +23,6 @@ export async function issueRpcAccessToken( scopes: requestedScopesInput, hostRefs: requestedHostRefsInput, }, + ...(clientIp ? { extraHeaders: { 'x-evenfire-client-ip': clientIp } } : {}), }) } diff --git a/external-rest-api/test/routes.auth.test.ts b/external-rest-api/test/routes.auth.test.ts index 04b1849d10..ffd797aaff 100644 --- a/external-rest-api/test/routes.auth.test.ts +++ b/external-rest-api/test/routes.auth.test.ts @@ -100,6 +100,24 @@ describe('routes/auth password-login', () => { expect(String(res.headers['set-cookie'])).toContain('profile_session=desktop-session-jwt') }) + it('delegates Google verification behind the Control API limiter with trusted client IP', async () => { + authServiceMock.loginWithGoogle.mockResolvedValueOnce({ + token: 'google-session-jwt', + me: { id: 'user-1', email: 'user@example.test' }, + }) + + const response = await request(buildApp()) + .post('/api/v1/auth/google') + .set('x-forwarded-for', '198.51.100.52') + .send({ idToken: 'opaque-google-token' }) + + expect(response.status).toBe(200) + expect(authServiceMock.loginWithGoogle).toHaveBeenCalledWith( + { idToken: 'opaque-google-token' }, + '198.51.100.52' + ) + }) + it('documents the password-reset limiter headers and client-IP contract', async () => { authServiceMock.requestPasswordReset.mockResolvedValue({ requested: true }) const app = buildApp() diff --git a/external-rest-api/test/routes.rpc.test.ts b/external-rest-api/test/routes.rpc.test.ts index ef0840a3c7..8a826cece5 100644 --- a/external-rest-api/test/routes.rpc.test.ts +++ b/external-rest-api/test/routes.rpc.test.ts @@ -73,5 +73,11 @@ describe('routes/rpc /rpc/token', () => { expect(res.status).toBe(200) expect(res.body.token).toBe('t') + expect(rpcServiceMock.issueRpcAccessToken).toHaveBeenCalledWith( + 'session-xyz', + ['host:message:invoke'], + ['pro-agent'], + '::ffff:127.0.0.1' + ) }) }) diff --git a/external-rest-api/test/services.externalUserClientIp.test.ts b/external-rest-api/test/services.externalUserClientIp.test.ts index ccb13fbc0b..391d0dcadf 100644 --- a/external-rest-api/test/services.externalUserClientIp.test.ts +++ b/external-rest-api/test/services.externalUserClientIp.test.ts @@ -1,6 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { logoutUserSession, renewUserSession } from '../src/services/authService.js' +import { + loginWithGoogle, + loginWithPassword, + logoutUserSession, + renewUserSession, +} from '../src/services/authService.js' import { switchTeam } from '../src/services/meService.js' +import { issueRpcAccessToken } from '../src/services/rpcService.js' import { createTeamForUser } from '../src/services/teamService.js' const client = vi.hoisted(() => ({ controlApiRequest: vi.fn() })) @@ -42,6 +48,36 @@ describe('external-user client IP forwarding', () => { ) }) + it('forwards the trusted client IP for credential and RPC issuance boundaries', async () => { + client.controlApiRequest + .mockResolvedValueOnce({ token: 'google-session' }) + .mockResolvedValueOnce({ token: 'password-session' }) + .mockResolvedValueOnce({ token: 'rpc-token' }) + + await loginWithGoogle({ idToken: 'google-token' }, clientIp) + await loginWithPassword('user@example.test', 'password', undefined, clientIp) + await issueRpcAccessToken('session-token', ['host:message:invoke'], ['agent-a'], clientIp) + + expect(client.controlApiRequest).toHaveBeenNthCalledWith( + 1, + 'POST', + '/external/auth/google-login', + expect.objectContaining({ extraHeaders: headers }) + ) + expect(client.controlApiRequest).toHaveBeenNthCalledWith( + 2, + 'POST', + '/external/auth/password-login', + expect.objectContaining({ extraHeaders: headers }) + ) + expect(client.controlApiRequest).toHaveBeenNthCalledWith( + 3, + 'POST', + '/external/rpc/token', + expect.objectContaining({ extraHeaders: headers }) + ) + }) + it('forwards the trusted client IP only on the session-token calls for team changes', async () => { client.controlApiRequest .mockResolvedValueOnce({ id: 'team-1', name: 'Team One' }) From 9b7bdcd3a31b2a6648e063298b49dd43db029f16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 13 Aug 2026 13:31:44 -0600 Subject: [PATCH 090/528] fix(external): d28-retry preserve public rate-limit guidance Class-check: ControlApiError 429 forwarding -> 12 route families -> central safe propagation Repro-test: focused retry contract tests fail at 3ed11c72 and pass after fix --- external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- .../controlApiClient.passthrough.test.ts | 39 +++++++++++- .../src/__tests__/publicErrorContract.test.ts | 29 +++++++-- external-rest-api/src/controlApiClient.ts | 60 ++++++++++++------- external-rest-api/src/http/publicApiError.ts | 44 +++++++++++++- 6 files changed, 146 insertions(+), 32 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 1831f85dfc..ff88a88560 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index a2eb923322..d8dc7498ab 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/__tests__/controlApiClient.passthrough.test.ts b/external-rest-api/src/__tests__/controlApiClient.passthrough.test.ts index 098185ad50..333301ca73 100644 --- a/external-rest-api/src/__tests__/controlApiClient.passthrough.test.ts +++ b/external-rest-api/src/__tests__/controlApiClient.passthrough.test.ts @@ -1,6 +1,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { config } from '../config.js' -import { controlApiPassthroughGet } from '../controlApiClient.js' +import { + ControlApiError, + controlApiPassthroughGet, + controlApiRequest, +} from '../controlApiClient.js' describe('controlApiPassthroughGet', () => { afterEach(() => vi.restoreAllMocks()) @@ -55,4 +59,37 @@ describe('controlApiPassthroughGet', () => { expect(headers['x-service-token']).toBe(config.controlApiServiceName) expect(headers['authorization']).toContain('Bearer ') }) + + it('captures only safe rate-limit headers from a rejected Control API request', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + error: { code: 'rate_limited', details: { retryAfterSeconds: 17 } }, + }), + { + status: 429, + headers: { + 'content-type': 'application/json', + 'retry-after': '17', + 'x-ratelimit-limit': '30', + 'x-ratelimit-remaining': '0', + 'x-ratelimit-reset': '1900000000', + 'x-internal-bucket': 'secret-internal-bucket', + }, + } + ) + ) + + const error = await controlApiRequest('GET', '/external/test').catch( + (caught: unknown) => caught + ) + + expect(error).toBeInstanceOf(ControlApiError) + expect((error as ControlApiError).responseHeaders).toEqual({ + 'retry-after': '17', + 'x-ratelimit-limit': '30', + 'x-ratelimit-remaining': '0', + 'x-ratelimit-reset': '1900000000', + }) + }) }) diff --git a/external-rest-api/src/__tests__/publicErrorContract.test.ts b/external-rest-api/src/__tests__/publicErrorContract.test.ts index 28cbe09d3a..c1bcc5a0af 100644 --- a/external-rest-api/src/__tests__/publicErrorContract.test.ts +++ b/external-rest-api/src/__tests__/publicErrorContract.test.ts @@ -49,9 +49,24 @@ describe('External REST public error contract', () => { }) it('maps upstream throttling to the stable retryable rate-limit error', async () => { - const response = await request( - appThrowing(new ControlApiError('raw rate limiter state', 429, { internal: 'bucket-key' })) - ).get('/failure') + const upstream = new ControlApiError( + 'raw rate limiter state', + 429, + { + error: { + code: 'rate_limited', + details: { retryAfterSeconds: 17, bucket: 'secret-internal-bucket' }, + }, + }, + { + 'retry-after': '17', + 'x-ratelimit-limit': '30', + 'x-ratelimit-remaining': '0', + 'x-ratelimit-reset': '1900000000', + 'x-internal-bucket': 'secret-internal-bucket', + } + ) + const response = await request(appThrowing(upstream)).get('/failure') expect(response.status).toBe(429) expect(response.body.error).toEqual({ @@ -59,8 +74,14 @@ describe('External REST public error contract', () => { message: 'Too many requests; retry later.', correlationId: expect.any(String), retryable: true, + details: { retryAfterSeconds: 17 }, }) - expect(JSON.stringify(response.body)).not.toContain('bucket-key') + expect(response.headers['retry-after']).toBe('17') + expect(response.headers['x-ratelimit-limit']).toBe('30') + expect(response.headers['x-ratelimit-remaining']).toBe('0') + expect(response.headers['x-ratelimit-reset']).toBe('1900000000') + expect(response.headers['x-internal-bucket']).toBeUndefined() + expect(JSON.stringify(response.body)).not.toContain('secret-internal-bucket') }) it('preserves only a bounded retry delay from legacy rate-limit bodies', () => { diff --git a/external-rest-api/src/controlApiClient.ts b/external-rest-api/src/controlApiClient.ts index f224322eab..57a0d0c70a 100644 --- a/external-rest-api/src/controlApiClient.ts +++ b/external-rest-api/src/controlApiClient.ts @@ -11,41 +11,57 @@ function addExternalClientIdentity(headers: Record): void { // Response headers are an API contract only where a caller can safely act on // them. In particular, never relay Set-Cookie, server identity, CORS, or // arbitrary proxy headers from the internal control-api boundary. -const FORWARDABLE_RESPONSE_HEADERS = [ - 'retry-after', - 'ratelimit', - 'ratelimit-policy', - 'x-ratelimit-limit', - 'x-ratelimit-remaining', - 'x-ratelimit-reset', - 'x-request-id', -] as const - export class ControlApiError extends Error { status: number body: unknown + responseHeaders: Record headers: Record constructor( message: string, status: number, body: unknown, - headers: Record = {} + responseHeaders: Record = {} ) { super(message) this.name = 'ControlApiError' this.status = status this.body = body - this.headers = headers + this.responseHeaders = responseHeaders + this.headers = responseHeaders } } -function forwardableResponseHeaders(response: Response): Record { - const headers: Record = {} - for (const name of FORWARDABLE_RESPONSE_HEADERS) { - const value = response.headers.get(name) - if (value) headers[name] = value +const SAFE_RATE_LIMIT_RESPONSE_HEADERS = [ + 'retry-after', + 'x-ratelimit-limit', + 'x-ratelimit-remaining', + 'x-ratelimit-reset', +] as const + +function safeForwardableResponseHeaders(headers: Headers, status: number): Record { + const result: Record = {} + for (const name of SAFE_RATE_LIMIT_RESPONSE_HEADERS) { + const raw = headers.get(name) + if (!raw || !/^\d{1,10}$/.test(raw)) continue + const value = Number(raw) + if (!Number.isSafeInteger(value) || value < 0) continue + if (name === 'retry-after' && (value < 1 || value > 86_400)) continue + result[name] = String(value) + } + const requestId = headers.get('x-request-id') + if (requestId && /^[A-Za-z0-9_-]{1,128}$/.test(requestId)) { + result['x-request-id'] = requestId + } + if (status === 413) { + const rawUploadLength = headers.get('upload-length') + if (rawUploadLength && /^\d{1,16}$/.test(rawUploadLength)) { + const uploadLength = Number(rawUploadLength) + if (Number.isSafeInteger(uploadLength) && uploadLength >= 0) { + result['upload-length'] = String(uploadLength) + } + } } - return headers + return result } function buildUrl(path: string, query?: Record): string { @@ -127,7 +143,7 @@ export async function controlApiRequestWithStatus( `Control API ${method} ${path} returned non-JSON body (status=${response.status})`, 502, { error: 'Upstream returned non-JSON body', status: response.status }, - forwardableResponseHeaders(response) + safeForwardableResponseHeaders(response.headers, 502) ) } @@ -140,7 +156,7 @@ export async function controlApiRequestWithStatus( `Control API ${method} ${path} failed (${response.status}): ${errorMessage}`, response.status, parsed, - forwardableResponseHeaders(response) + safeForwardableResponseHeaders(response.headers, response.status) ) } @@ -195,7 +211,7 @@ export async function controlApiBinaryRequestWithStatus( `Control API ${method} ${path} failed (${response.status})`, response.status, parsed, - forwardableResponseHeaders(response) + safeForwardableResponseHeaders(response.headers, response.status) ) } @@ -260,7 +276,7 @@ export async function controlApiStreamRequest( `Control API ${method} ${path} failed (${response.status})`, response.status, parsed, - forwardableResponseHeaders(response) + safeForwardableResponseHeaders(response.headers, response.status) ) } return response diff --git a/external-rest-api/src/http/publicApiError.ts b/external-rest-api/src/http/publicApiError.ts index c66cf4ec43..5fe61fb296 100644 --- a/external-rest-api/src/http/publicApiError.ts +++ b/external-rest-api/src/http/publicApiError.ts @@ -53,6 +53,11 @@ const ALLOWED_CODES_BY_STATUS: Readonly>> = { } type PublicPathDescriptor = { id: string; kind: 'direct' | 'team'; teamId?: string } +export type SanitizedControlApiPublicError = { + status: number + body: Record + headers: Record +} const SAFE_DOMAIN_REASONS = new Set([ 'agent_manager_forbidden', @@ -63,6 +68,26 @@ const SAFE_DOMAIN_REASONS = new Set([ 'manage_acl_required', ]) +const PUBLIC_RATE_LIMIT_HEADERS = [ + 'retry-after', + 'x-ratelimit-limit', + 'x-ratelimit-remaining', + 'x-ratelimit-reset', +] as const + +function safePublicRateLimitHeaders(headers: Record): Record { + const result: Record = {} + for (const name of PUBLIC_RATE_LIMIT_HEADERS) { + const raw = headers[name] + if (!raw || !/^\d{1,10}$/.test(raw)) continue + const value = Number(raw) + if (!Number.isSafeInteger(value) || value < 0) continue + if (name === 'retry-after' && (value < 1 || value > 86_400)) continue + result[name] = String(value) + } + return result +} + function safeInvalidIndexes(value: unknown): number[] | undefined { if (!Array.isArray(value) || value.length === 0 || value.length > 100) return undefined const indexes = value.filter( @@ -90,7 +115,7 @@ function safePathDescriptors(value: unknown): PublicPathDescriptor[] | undefined export function sanitizeControlApiPublicError( error: unknown, propagatedStatuses: ReadonlySet -): { status: number; body: Record } | null { +): SanitizedControlApiPublicError | null { if (!(error instanceof ControlApiError) || !propagatedStatuses.has(error.status)) return null const rawBody = error.body && typeof error.body === 'object' @@ -131,7 +156,11 @@ export function sanitizeControlApiPublicError( reason === 'subjects_invalid' ? safeInvalidIndexes(rawDetails?.invalidIndexes ?? rawBody?.invalidIndexes) : undefined - const rawRetryAfterSeconds = rawDetails?.retryAfterSeconds ?? rawBody?.retryAfterSeconds + const headerRetryAfterSeconds = Number(error.responseHeaders['retry-after']) + const rawRetryAfterSeconds = + rawDetails?.retryAfterSeconds ?? + rawBody?.retryAfterSeconds ?? + (Number.isSafeInteger(headerRetryAfterSeconds) ? headerRetryAfterSeconds : undefined) const retryAfterSeconds = code === 'rate_limited' && typeof rawRetryAfterSeconds === 'number' && @@ -152,6 +181,7 @@ export function sanitizeControlApiPublicError( return { status: error.status, + headers: code === 'rate_limited' ? safePublicRateLimitHeaders(error.responseHeaders) : {}, body: { error: { code, @@ -164,6 +194,16 @@ export function sanitizeControlApiPublicError( } } +export function sendSanitizedControlApiPublicError( + res: Response, + sanitized: SanitizedControlApiPublicError +): void { + for (const [name, value] of Object.entries(sanitized.headers)) { + res.setHeader(name, value) + } + res.status(sanitized.status).json(sanitized.body) +} + export function publicCorrelationId(req: Request): string { return ( String(req.header('x-correlation-id') || '').trim() || Math.random().toString(36).slice(2, 12) From 51bac4fd25156746f7052a337afab811d3c9ae8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 13 Aug 2026 13:33:08 -0600 Subject: [PATCH 091/528] fix(external): d28-retry-r1 accept legacy upstream errors Class-check: ControlApiError test doubles -> 4 route suites -> tolerate missing safe headers Repro-test: routes.notifications test fails at 24e333c2 and passes after fix --- external-rest-api/package-lock.json | 4 ++-- external-rest-api/package.json | 2 +- external-rest-api/src/http/publicApiError.ts | 5 +++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index ff88a88560..1831f85dfc 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index d8dc7498ab..a2eb923322 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/http/publicApiError.ts b/external-rest-api/src/http/publicApiError.ts index 5fe61fb296..babe2f78db 100644 --- a/external-rest-api/src/http/publicApiError.ts +++ b/external-rest-api/src/http/publicApiError.ts @@ -156,7 +156,8 @@ export function sanitizeControlApiPublicError( reason === 'subjects_invalid' ? safeInvalidIndexes(rawDetails?.invalidIndexes ?? rawBody?.invalidIndexes) : undefined - const headerRetryAfterSeconds = Number(error.responseHeaders['retry-after']) + const responseHeaders = error.responseHeaders ?? {} + const headerRetryAfterSeconds = Number(responseHeaders['retry-after']) const rawRetryAfterSeconds = rawDetails?.retryAfterSeconds ?? rawBody?.retryAfterSeconds ?? @@ -181,7 +182,7 @@ export function sanitizeControlApiPublicError( return { status: error.status, - headers: code === 'rate_limited' ? safePublicRateLimitHeaders(error.responseHeaders) : {}, + headers: code === 'rate_limited' ? safePublicRateLimitHeaders(responseHeaders) : {}, body: { error: { code, From f86785569eafa70d8d955efdcc24784862ad335f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:57:33 -0600 Subject: [PATCH 092/528] fix(external): d28-retry-r2 forward access limit headers Class-check: local sanitized 429 responders -> 2 sites -> use shared safe sender Repro-test: routes.access retry test fails at e0eb9ffb and passes after fix # Conflicts: # external-rest-api/src/releaseManifest.ts --- external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- external-rest-api/src/routes/access.ts | 7 +++- external-rest-api/test/routes.access.test.ts | 39 ++++++++++++++++++++ 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 1831f85dfc..ff88a88560 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index a2eb923322..d8dc7498ab 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/routes/access.ts b/external-rest-api/src/routes/access.ts index 25a6fbd8c4..9d386b25aa 100644 --- a/external-rest-api/src/routes/access.ts +++ b/external-rest-api/src/routes/access.ts @@ -1,7 +1,10 @@ import { Router } from 'express' import type { NextFunction, Response } from 'express' import { controlApiRequest } from '../controlApiClient.js' -import { sanitizeControlApiPublicError } from '../http/publicApiError.js' +import { + sanitizeControlApiPublicError, + sendSanitizedControlApiPublicError, +} from '../http/publicApiError.js' import { type AuthedRequest, extractAuthToken, requireAuth } from '../middleware/auth.js' const ACCESS_PUBLIC_STATUSES = new Set([400, 401, 403, 404, 409, 429, 503]) @@ -9,7 +12,7 @@ const ACCESS_PUBLIC_STATUSES = new Set([400, 401, 403, 404, 409, 429, 503]) function forwardAccessError(error: unknown, res: Response, next: NextFunction): void { const sanitized = sanitizeControlApiPublicError(error, ACCESS_PUBLIC_STATUSES) if (sanitized) { - res.status(sanitized.status).json(sanitized.body) + sendSanitizedControlApiPublicError(res, sanitized) return } next(error) diff --git a/external-rest-api/test/routes.access.test.ts b/external-rest-api/test/routes.access.test.ts index 770a58a3f0..afaf29c824 100644 --- a/external-rest-api/test/routes.access.test.ts +++ b/external-rest-api/test/routes.access.test.ts @@ -107,4 +107,43 @@ describe('public access-contract forwarding', () => { }, }) }) + + it('preserves safe retry guidance for a locally sanitized access limit', async () => { + mocks.controlApiRequest.mockRejectedValue( + new ControlApiError( + 'rate limited', + 429, + { + error: { + code: 'rate_limited', + details: { retryAfterSeconds: 17, bucket: 'secret-bucket' }, + }, + }, + { + 'retry-after': '17', + 'x-ratelimit-limit': '30', + 'x-ratelimit-remaining': '0', + 'x-ratelimit-reset': '1900000000', + 'x-internal-bucket': 'secret-bucket', + } + ) + ) + + const response = await request(app()) + .get('/me/access/capabilities') + .set('authorization', 'Bearer session-token') + + expect(response.status).toBe(429) + expect(response.body.error).toMatchObject({ + code: 'rate_limited', + retryable: true, + details: { retryAfterSeconds: 17 }, + }) + expect(response.headers['retry-after']).toBe('17') + expect(response.headers['x-ratelimit-limit']).toBe('30') + expect(response.headers['x-ratelimit-remaining']).toBe('0') + expect(response.headers['x-ratelimit-reset']).toBe('1900000000') + expect(response.headers['x-internal-bucket']).toBeUndefined() + expect(JSON.stringify(response.body)).not.toContain('secret-bucket') + }) }) From f2048109b7d3ef08f1bb1b85357175db4ab17249 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 14 Aug 2026 11:43:34 -0600 Subject: [PATCH 093/528] fix(security): bound R4-H1 invitation email validation Class-check: invitation email regex -> 4 sites -> fix 3; exclude admin-auth contract Repro-test: focused API and UI tests fail at c2e12f9d and pass after fix --- .../src/routes/external/invitationEmail.ts | 27 +++++++ external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- external-rest-api/src/routes/members.ts | 53 +++++++------ external-rest-api/test/routes.members.test.ts | 70 +++++++++++++++++ .../InviteMemberPage.emailValidation.test.tsx | 77 +++++++++++++++++++ profile-ui/app/members/invite/page.tsx | 5 +- profile-ui/lib/invitationEmail.ts | 27 +++++++ 8 files changed, 233 insertions(+), 32 deletions(-) create mode 100644 control-api/src/routes/external/invitationEmail.ts create mode 100644 profile-ui/app/components/__tests__/InviteMemberPage.emailValidation.test.tsx create mode 100644 profile-ui/lib/invitationEmail.ts diff --git a/control-api/src/routes/external/invitationEmail.ts b/control-api/src/routes/external/invitationEmail.ts new file mode 100644 index 0000000000..acb7a81f3a --- /dev/null +++ b/control-api/src/routes/external/invitationEmail.ts @@ -0,0 +1,27 @@ +export const MAX_INVITATION_EMAIL_LENGTH = 320 + +export function normalizeInvitationEmail(value: unknown): string | null { + if (typeof value !== 'string' || value.length > MAX_INVITATION_EMAIL_LENGTH) return null + + const email = value.trim().toLowerCase() + let atIndex = -1 + let atCount = 0 + + for (let index = 0; index < email.length; index += 1) { + const character = email[index] + if (!character || character.trim() === '') return null + if (character === '@') { + atIndex = index + atCount += 1 + } + } + + if (atCount !== 1 || atIndex <= 0 || atIndex >= email.length - 1) return null + + const domainStart = atIndex + 1 + for (let index = domainStart + 1; index < email.length - 1; index += 1) { + if (email[index] === '.') return email + } + + return null +} diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index ff88a88560..1831f85dfc 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index d8dc7498ab..a2eb923322 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/routes/members.ts b/external-rest-api/src/routes/members.ts index 20d7e7dae7..ee87396475 100644 --- a/external-rest-api/src/routes/members.ts +++ b/external-rest-api/src/routes/members.ts @@ -17,9 +17,7 @@ import { } from '../services/memberManagementService.js' import { TEAM_ROLES, TeamRole } from '../types.js' -const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ -const MAX_INVITATION_TEAM_ASSIGNMENTS = 50 -const MAX_INVITEE_NAME_LENGTH = 120 +const MAX_INVITATION_EMAIL_LENGTH = 320 const UUID_ANY_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i const MEMBER_RETIREMENT_EDGE_RATE_LIMIT_PER_MINUTE = 30 const MEMBER_RETIREMENT_SUBJECT_RATE_LIMIT_PER_MINUTE = 30 @@ -108,6 +106,12 @@ function validateMemberRetirementRequest( next() } +function isControlApiBadRequest(error: unknown, code: string): boolean { + if (!(error instanceof ControlApiError) || error.status !== 400) return false + if (!error.body || typeof error.body !== 'object') return false + return (error.body as { error?: unknown }).error === code +} + export function createMembersRouter(): Router { const router = Router() @@ -150,39 +154,34 @@ export function createMembersRouter(): Router { router.post('/members/invite', requireAuth, async (req: AuthedRequest, res, next) => { try { - const email = String(req.body?.email || '') - .trim() - .toLowerCase() - const name = String(req.body?.name || '').trim() - const teams: unknown[] = Array.isArray(req.body?.teams) ? req.body.teams : [] - if (!EMAIL_PATTERN.test(email)) { + const rawEmail = req.body?.email + if (typeof rawEmail !== 'string' || rawEmail.length > MAX_INVITATION_EMAIL_LENGTH) { res.status(400).json({ error: 'Valid email is required' }) return } - if (name.length > MAX_INVITEE_NAME_LENGTH) { - res.status(400).json({ error: 'Name is too long' }) + const email = rawEmail.trim().toLowerCase() + res + .status(201) + .json( + await inviteManagedMember(email, req.body?.name, req.body?.teams, extractAuthToken(req)) + ) + } catch (error) { + if (isControlApiBadRequest(error, 'invalid_email')) { + res.status(400).json({ error: 'Valid email is required' }) return } - if (teams.length > MAX_INVITATION_TEAM_ASSIGNMENTS) { - res.status(400).json({ error: 'Too many teams selected' }) + if (isControlApiBadRequest(error, 'invalid_payload')) { + res.status(400).json({ error: 'Email and at least one team are required' }) return } - const assignments = teams - .map((item: unknown) => { - const row = item && typeof item === 'object' ? (item as Record) : {} - const teamId = String(row.teamId || row.id || '').trim() - const role = String(row.role || 'member').trim() as TeamRole - return teamId && TEAM_ROLES.includes(role) ? { teamId, role } : null - }) - .filter((item): item is { teamId: string; role: TeamRole } => Boolean(item)) - if (!email || assignments.length === 0) { - res.status(400).json({ error: 'Email and at least one team are required' }) + if (isControlApiBadRequest(error, 'invalid_name')) { + res.status(400).json({ error: 'Name is too long' }) + return + } + if (isControlApiBadRequest(error, 'too_many_teams')) { + res.status(400).json({ error: 'Too many teams selected' }) return } - res - .status(201) - .json(await inviteManagedMember(email, name, assignments, extractAuthToken(req))) - } catch (error) { const message = error instanceof Error ? error.message : '' if (message.includes('(403)')) { res.status(403).json({ error: 'You are not allowed to invite with those permissions' }) diff --git a/external-rest-api/test/routes.members.test.ts b/external-rest-api/test/routes.members.test.ts index 6ef9e95fd0..069a18b71c 100644 --- a/external-rest-api/test/routes.members.test.ts +++ b/external-rest-api/test/routes.members.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import express from 'express' import { once } from 'node:events' import request from 'supertest' +import { ControlApiError } from '../src/controlApiClient.js' import { createMembersRouter } from '../src/routes/members.js' const authTokenMock = vi.hoisted(() => ({ @@ -69,6 +70,75 @@ describe('routes/members', () => { ) }) + it('rejects raw invitation emails longer than 320 characters before Control API work', async () => { + authTokenMock.verifyToken.mockReturnValueOnce(claims) + const overlongEmail = `${'a'.repeat(315)}@a.com` + + await request(makeApp()) + .post('/members/invite') + .set('authorization', 'Bearer good-token') + .send({ + email: overlongEmail, + teams: [{ teamId: 'team-1', role: 'member' }], + }) + .expect(400, { error: 'Valid email is required' }) + + expect(memberManagementMock.inviteManagedMember).not.toHaveBeenCalled() + }) + + it('rejects pathological overlong invitation email input before Control API work', async () => { + authTokenMock.verifyToken.mockReturnValueOnce(claims) + + await request(makeApp()) + .post('/members/invite') + .set('authorization', 'Bearer good-token') + .send({ + email: `!@!${'!.'.repeat(200)}@`, + teams: [{ teamId: 'team-1', role: 'member' }], + }) + .expect(400, { error: 'Valid email is required' }) + + expect(memberManagementMock.inviteManagedMember).not.toHaveBeenCalled() + }) + + it('rejects non-string invitation email input before Control API work', async () => { + authTokenMock.verifyToken.mockReturnValueOnce(claims) + + await request(makeApp()) + .post('/members/invite') + .set('authorization', 'Bearer good-token') + .send({ + email: { address: 'invitee@example.com' }, + teams: [{ teamId: 'team-1', role: 'member' }], + }) + .expect(400, { error: 'Valid email is required' }) + + expect(memberManagementMock.inviteManagedMember).not.toHaveBeenCalled() + }) + + it('forwards bounded invalid email input to the authoritative Control API validator', async () => { + authTokenMock.verifyToken.mockReturnValueOnce(claims) + memberManagementMock.inviteManagedMember.mockRejectedValueOnce( + new ControlApiError('invalid invitation email', 400, { error: 'invalid_email' }) + ) + + await request(makeApp()) + .post('/members/invite') + .set('authorization', 'Bearer good-token') + .send({ + email: 'not-an-email', + teams: [{ teamId: 'team-1', role: 'member' }], + }) + .expect(400, { error: 'Valid email is required' }) + + expect(memberManagementMock.inviteManagedMember).toHaveBeenCalledWith( + 'not-an-email', + '', + [{ teamId: 'team-1', role: 'member' }], + 'good-token' + ) + }) + it('rejects overlong invitee names', async () => { authTokenMock.verifyToken.mockReturnValueOnce(claims) diff --git a/profile-ui/app/components/__tests__/InviteMemberPage.emailValidation.test.tsx b/profile-ui/app/components/__tests__/InviteMemberPage.emailValidation.test.tsx new file mode 100644 index 0000000000..b8627917b0 --- /dev/null +++ b/profile-ui/app/components/__tests__/InviteMemberPage.emailValidation.test.tsx @@ -0,0 +1,77 @@ +import React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import InviteMemberPage from '../../members/invite/page' + +const routerMock = vi.hoisted(() => ({ push: vi.fn() })) + +vi.mock('next/navigation', () => ({ + useRouter: () => routerMock, +})) + +vi.mock('@components/AuthGate', () => ({ + AuthGate: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +vi.mock('@components/ProfileShell', () => ({ + ProfileShell: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +vi.mock('@components/ProfileAccessContext', () => ({ + useProfileAccess: () => ({ + manageableTeams: [], + manageableTeamsError: null, + manageableTeamsLoading: false, + }), +})) + +vi.mock('@components/Toast', () => ({ + useToast: () => ({ showToast: vi.fn() }), +})) + +vi.mock('@lib/api', () => ({ + inviteManagedMember: vi.fn(), +})) + +describe('InviteMemberPage email validation', () => { + beforeEach(() => { + routerMock.push.mockReset() + }) + + afterEach(cleanup) + + it('keeps a valid bounded invitation email usable', () => { + render() + + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Invitee' } }) + fireEvent.change(screen.getByLabelText('Email'), { + target: { value: 'invitee@example.com' }, + }) + + expect(screen.getByRole('button', { name: 'Continue' })).toBeEnabled() + }) + + it('rejects a plausible invitation email longer than 320 characters', () => { + render() + + const emailInput = screen.getByLabelText('Email') + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Invitee' } }) + fireEvent.change(emailInput, { + target: { value: `${'a'.repeat(315)}@a.com` }, + }) + + expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled() + expect(emailInput).toHaveAttribute('maxlength', '320') + }) + + it('rejects bounded invalid invitation email input without enabling submission', () => { + render() + + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Invitee' } }) + fireEvent.change(screen.getByLabelText('Email'), { + target: { value: `!@!${'!.'.repeat(100)}@` }, + }) + + expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled() + }) +}) diff --git a/profile-ui/app/members/invite/page.tsx b/profile-ui/app/members/invite/page.tsx index bd6f496222..6d5ed143e7 100644 --- a/profile-ui/app/members/invite/page.tsx +++ b/profile-ui/app/members/invite/page.tsx @@ -18,6 +18,7 @@ import { useToast } from '@components/Toast' import { IconTrash } from '@components/icons' import { PROFILE_ROUTES } from '@constants/routes' import { inviteManagedMember } from '@lib/api' +import { MAX_INVITATION_EMAIL_LENGTH, isInvitationEmailFormatValid } from '@lib/invitationEmail' import { formatTeamRole, permissionsForTeamRole, @@ -26,7 +27,6 @@ import { } from '@lib/teamRoles' import type { ManageableTeam, Role } from '@/app/types/profile' -const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ const STEPS = ['Member', 'Team'] as const const STEP_DETAILS = [ { @@ -56,7 +56,7 @@ export default function InviteMemberPage() { const [inviteRoles, setInviteRoles] = useState>({}) const [saving, setSaving] = useState(false) const [error, setError] = useState('') - const emailValid = EMAIL_PATTERN.test(email.trim()) + const emailValid = isInvitationEmailFormatValid(email) const nameValid = Boolean(name.trim()) const selectedTeamIds = useMemo(() => Object.keys(inviteRoles), [inviteRoles]) const selectedTeams = useMemo( @@ -174,6 +174,7 @@ export default function InviteMemberPage() { type="email" value={email} onChange={event => setEmail(event.target.value)} + maxLength={MAX_INVITATION_EMAIL_LENGTH} placeholder="user@example.com" disabled={saving} autoComplete="email" diff --git a/profile-ui/lib/invitationEmail.ts b/profile-ui/lib/invitationEmail.ts new file mode 100644 index 0000000000..4bd1fa1052 --- /dev/null +++ b/profile-ui/lib/invitationEmail.ts @@ -0,0 +1,27 @@ +export const MAX_INVITATION_EMAIL_LENGTH = 320 + +export function isInvitationEmailFormatValid(value: string): boolean { + if (value.length > MAX_INVITATION_EMAIL_LENGTH) return false + + const email = value.trim() + let atIndex = -1 + let atCount = 0 + + for (let index = 0; index < email.length; index += 1) { + const character = email[index] + if (!character || character.trim() === '') return false + if (character === '@') { + atIndex = index + atCount += 1 + } + } + + if (atCount !== 1 || atIndex <= 0 || atIndex >= email.length - 1) return false + + const domainStart = atIndex + 1 + for (let index = domainStart + 1; index < email.length - 1; index += 1) { + if (email[index] === '.') return true + } + + return false +} From 30590ad1cedc7be31ba11041a5849515a63c946e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 14 Aug 2026 11:52:26 -0600 Subject: [PATCH 094/528] fix(access): preserve R4-M1 invitation error precedence Class-check: invitation validation precedence -> 1 route -> defer bounded semantics Repro-test: routes.members precedence tests fail at 2cd02c9e and pass after fix --- external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- external-rest-api/test/routes.members.test.ts | 40 +++++++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 1831f85dfc..ff88a88560 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index a2eb923322..d8dc7498ab 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/test/routes.members.test.ts b/external-rest-api/test/routes.members.test.ts index 069a18b71c..80bacce668 100644 --- a/external-rest-api/test/routes.members.test.ts +++ b/external-rest-api/test/routes.members.test.ts @@ -139,6 +139,46 @@ describe('routes/members', () => { ) }) + it('preserves invalid-email precedence when no valid team assignment is provided', async () => { + authTokenMock.verifyToken.mockReturnValueOnce(claims) + memberManagementMock.inviteManagedMember.mockRejectedValueOnce( + new ControlApiError('invalid invitation email', 400, { error: 'invalid_email' }) + ) + + await request(makeApp()) + .post('/members/invite') + .set('authorization', 'Bearer good-token') + .send({ email: 'not-an-email', teams: [] }) + .expect(400, { error: 'Valid email is required' }) + + expect(memberManagementMock.inviteManagedMember).toHaveBeenCalledWith( + 'not-an-email', + '', + [], + 'good-token' + ) + }) + + it('preserves the public assignment error after authoritative validation', async () => { + authTokenMock.verifyToken.mockReturnValueOnce(claims) + memberManagementMock.inviteManagedMember.mockRejectedValueOnce( + new ControlApiError('invalid invitation payload', 400, { error: 'invalid_payload' }) + ) + + await request(makeApp()) + .post('/members/invite') + .set('authorization', 'Bearer good-token') + .send({ email: 'invitee@example.com', teams: [] }) + .expect(400, { error: 'Email and at least one team are required' }) + + expect(memberManagementMock.inviteManagedMember).toHaveBeenCalledWith( + 'invitee@example.com', + '', + [], + 'good-token' + ) + }) + it('rejects overlong invitee names', async () => { authTokenMock.verifyToken.mockReturnValueOnce(claims) From 02fe6ab4b143355cf1487e5a5fae13a87e58f866 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 14 Aug 2026 11:54:07 -0600 Subject: [PATCH 095/528] test(access): prove R4-B1 invitation error producer contract Class-check: invitation error adapters -> 1 cross-service seam -> producer-backed proof Repro-test: parser-body mutation fails at a213721f and restored parser passes --- external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- .../routes.membersInvitationContract.test.ts | 115 ++++++++++++++++++ 3 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 external-rest-api/test/routes.membersInvitationContract.test.ts diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index ff88a88560..1831f85dfc 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index d8dc7498ab..a2eb923322 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/test/routes.membersInvitationContract.test.ts b/external-rest-api/test/routes.membersInvitationContract.test.ts new file mode 100644 index 0000000000..e070ef038f --- /dev/null +++ b/external-rest-api/test/routes.membersInvitationContract.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import request from 'supertest' +import { createExternalMembersRouter } from '../../control-api/src/routes/external/members.js' +import { createMembersRouter } from '../src/routes/members.js' + +const authTokenMock = vi.hoisted(() => ({ + verifyToken: vi.fn(), +})) + +const directoryMock = vi.hoisted(() => ({ + createManagedInvitationForUser: vi.fn(), + deleteManagedMemberForUser: vi.fn(), + deleteManagedUserForUser: vi.fn(), + externalManagedInvitationResponse: vi.fn((value: Record) => value), + listManageableTeamsForUser: vi.fn(), + listManagedMembersForUser: vi.fn(), + listManagedPendingInvitationsForUser: vi.fn(), + resendManagedInvitationForUser: vi.fn(), + revokeManagedInvitationForUser: vi.fn(), + updateManagedMemberRoleForUser: vi.fn(), +})) + +const rateLimitMock = vi.hoisted(() => ({ + checkAndIncrement: vi.fn(), +})) + +vi.mock('../src/authToken.js', () => authTokenMock) +vi.mock('../../control-api/src/middleware/externalSessionAuth.js', () => ({ + requireValidExternalSessionToken: ( + req: express.Request, + _res: express.Response, + next: express.NextFunction + ) => { + ;(req as express.Request & { externalAuth?: { userId: string } }).externalAuth = { + userId: 'manager-1', + } + next() + }, +})) +vi.mock('../../control-api/src/services/directory/index.js', () => directoryMock) +vi.mock('../../control-api/src/services/rateLimiterService.js', () => rateLimitMock) + +const claims = { + userId: 'user-1', + email: 'user@example.com', + teamId: 'team-1', + role: 'admin' as const, + exp: 9999999999, +} + +function makeControlApp() { + const app = express() + app.use(express.json()) + app.use(createExternalMembersRouter()) + return app +} + +function makeExternalApp() { + const app = express() + app.use(express.json()) + app.use(createMembersRouter()) + return app +} + +describe('member invitation cross-service error contract', () => { + beforeEach(() => { + vi.clearAllMocks() + authTokenMock.verifyToken.mockReturnValue(claims) + rateLimitMock.checkAndIncrement.mockResolvedValue({ + allowed: true, + count: 1, + remaining: 29, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('preserves the authoritative invalid-email response through the real client parser', async () => { + const controlApp = makeControlApp() + const fetchMock = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + const body = typeof init?.body === 'string' ? JSON.parse(init.body) : undefined + const upstream = await request(controlApp).post('/external/members/invitations').send(body) + + return new Response(JSON.stringify(upstream.body), { + status: upstream.status, + headers: { 'content-type': 'application/json' }, + }) + }) + vi.stubGlobal('fetch', fetchMock) + + await request(makeExternalApp()) + .post('/members/invite') + .set('authorization', 'Bearer good-token') + .send({ email: 'not-an-email', teams: [] }) + .expect(400, { error: 'Valid email is required' }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const [, init] = fetchMock.mock.calls[0] + expect(JSON.parse(String(init?.body))).toEqual({ + email: 'not-an-email', + name: '', + teams: [], + }) + expect(rateLimitMock.checkAndIncrement).toHaveBeenCalledWith( + 'external_member_mutation:user:manager-1', + 10 + ) + expect(directoryMock.createManagedInvitationForUser).not.toHaveBeenCalled() + }) +}) From 28097fd3842b8a298bea7b9229cbcca4bde4808a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 14 Aug 2026 12:01:00 -0600 Subject: [PATCH 096/528] fix(access): complete R4-M1 invitation error precedence Class-check: bounded invite plus field errors -> 3 cases -> defer to Control API Repro-test: compound members-route cases fail at 3f4e5324 and pass after fix --- external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- .../src/services/memberManagementService.ts | 9 +--- external-rest-api/test/routes.members.test.ts | 54 ++++++++++++++++++- 4 files changed, 57 insertions(+), 12 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 1831f85dfc..ff88a88560 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index a2eb923322..d8dc7498ab 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/services/memberManagementService.ts b/external-rest-api/src/services/memberManagementService.ts index 3db3143b35..59a82ce163 100644 --- a/external-rest-api/src/services/memberManagementService.ts +++ b/external-rest-api/src/services/memberManagementService.ts @@ -37,11 +37,6 @@ export type ManagedPendingInvitation = { canResend: boolean } -export type MemberInvitationAssignment = { - teamId: string - role: TeamRole -} - export async function listManageableTeams(sessionToken: string) { return controlApiRequest<{ items: ManageableTeam[] }>( 'GET', @@ -76,8 +71,8 @@ export async function listManagedInvitations(sessionToken: string) { export async function inviteManagedMember( email: string, - name: string, - teams: MemberInvitationAssignment[], + name: unknown, + teams: unknown, sessionToken: string ) { return controlApiRequest('POST', '/external/members/invitations', { diff --git a/external-rest-api/test/routes.members.test.ts b/external-rest-api/test/routes.members.test.ts index 80bacce668..9a8f59e9d1 100644 --- a/external-rest-api/test/routes.members.test.ts +++ b/external-rest-api/test/routes.members.test.ts @@ -64,7 +64,7 @@ describe('routes/members', () => { expect(memberManagementMock.inviteManagedMember).toHaveBeenCalledWith( 'invitee@example.com', - 'Full Name', + ' Full Name ', [{ teamId: 'team-1', role: 'inviter' }], 'good-token' ) @@ -159,6 +159,32 @@ describe('routes/members', () => { ) }) + it.each([ + ['an overlong name', { name: 'a'.repeat(121), teams: [] }], + [ + 'too many teams', + { + teams: Array.from({ length: 51 }, (_, index) => ({ + teamId: `team-${index}`, + role: 'member', + })), + }, + ], + ])('preserves invalid-email precedence with %s', async (_case, extraBody) => { + authTokenMock.verifyToken.mockReturnValueOnce(claims) + memberManagementMock.inviteManagedMember.mockRejectedValueOnce( + new ControlApiError('invalid invitation email', 400, { error: 'invalid_email' }) + ) + + await request(makeApp()) + .post('/members/invite') + .set('authorization', 'Bearer good-token') + .send({ email: 'not-an-email', ...extraBody }) + .expect(400, { error: 'Valid email is required' }) + + expect(memberManagementMock.inviteManagedMember).toHaveBeenCalledOnce() + }) + it('preserves the public assignment error after authoritative validation', async () => { authTokenMock.verifyToken.mockReturnValueOnce(claims) memberManagementMock.inviteManagedMember.mockRejectedValueOnce( @@ -181,6 +207,9 @@ describe('routes/members', () => { it('rejects overlong invitee names', async () => { authTokenMock.verifyToken.mockReturnValueOnce(claims) + memberManagementMock.inviteManagedMember.mockRejectedValueOnce( + new ControlApiError('invalid invitation name', 400, { error: 'invalid_name' }) + ) await request(makeApp()) .post('/members/invite') @@ -192,7 +221,28 @@ describe('routes/members', () => { }) .expect(400, { error: 'Name is too long' }) - expect(memberManagementMock.inviteManagedMember).not.toHaveBeenCalled() + expect(memberManagementMock.inviteManagedMember).toHaveBeenCalledOnce() + }) + + it('preserves the public team-count error after authoritative validation', async () => { + authTokenMock.verifyToken.mockReturnValueOnce(claims) + memberManagementMock.inviteManagedMember.mockRejectedValueOnce( + new ControlApiError('too many invitation teams', 400, { error: 'too_many_teams' }) + ) + + await request(makeApp()) + .post('/members/invite') + .set('authorization', 'Bearer good-token') + .send({ + email: 'invitee@example.com', + teams: Array.from({ length: 51 }, (_, index) => ({ + teamId: `team-${index}`, + role: 'member', + })), + }) + .expect(400, { error: 'Too many teams selected' }) + + expect(memberManagementMock.inviteManagedMember).toHaveBeenCalledOnce() }) it('forwards a governed member-retirement reason, replay key, and correlation id', async () => { From 8afb45d11029af7e0fa2dc78db7a5750b539589a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 14 Aug 2026 12:02:32 -0600 Subject: [PATCH 097/528] test(access): complete R4-B1 invitation producer proof Class-check: invitation error codes -> 4 mappings -> real producer and parser covered Repro-test: parser mutation fails 4 contract cases at e34b6ce1; restored path passes --- external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- .../routes.membersInvitationContract.test.ts | 78 ++++++++++++++++--- 3 files changed, 70 insertions(+), 14 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index ff88a88560..1831f85dfc 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index d8dc7498ab..a2eb923322 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/test/routes.membersInvitationContract.test.ts b/external-rest-api/test/routes.membersInvitationContract.test.ts index e070ef038f..649e219b23 100644 --- a/external-rest-api/test/routes.membersInvitationContract.test.ts +++ b/external-rest-api/test/routes.membersInvitationContract.test.ts @@ -38,7 +38,13 @@ vi.mock('../../control-api/src/middleware/externalSessionAuth.js', () => ({ next() }, })) -vi.mock('../../control-api/src/services/directory/index.js', () => directoryMock) +vi.mock('../../control-api/src/services/directory/index.js', async () => { + const actual = await import('../../control-api/src/services/directory/membership.js') + directoryMock.createManagedInvitationForUser.mockImplementation( + actual.createManagedInvitationForUser + ) + return directoryMock +}) vi.mock('../../control-api/src/services/rateLimiterService.js', () => rateLimitMock) const claims = { @@ -63,6 +69,20 @@ function makeExternalApp() { return app } +function stubControlApiFetch(controlApp: express.Express) { + const fetchMock = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + const body = typeof init?.body === 'string' ? JSON.parse(init.body) : undefined + const upstream = await request(controlApp).post('/external/members/invitations').send(body) + + return new Response(JSON.stringify(upstream.body), { + status: upstream.status, + headers: { 'content-type': 'application/json' }, + }) + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + describe('member invitation cross-service error contract', () => { beforeEach(() => { vi.clearAllMocks() @@ -82,16 +102,7 @@ describe('member invitation cross-service error contract', () => { it('preserves the authoritative invalid-email response through the real client parser', async () => { const controlApp = makeControlApp() - const fetchMock = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { - const body = typeof init?.body === 'string' ? JSON.parse(init.body) : undefined - const upstream = await request(controlApp).post('/external/members/invitations').send(body) - - return new Response(JSON.stringify(upstream.body), { - status: upstream.status, - headers: { 'content-type': 'application/json' }, - }) - }) - vi.stubGlobal('fetch', fetchMock) + const fetchMock = stubControlApiFetch(controlApp) await request(makeExternalApp()) .post('/members/invite') @@ -112,4 +123,49 @@ describe('member invitation cross-service error contract', () => { ) expect(directoryMock.createManagedInvitationForUser).not.toHaveBeenCalled() }) + + it.each([ + { + label: 'empty assignments', + body: { email: 'invitee@example.com', teams: [] }, + publicError: 'Email and at least one team are required', + directoryCalls: 1, + }, + { + label: 'an overlong name', + body: { + email: 'invitee@example.com', + name: 'a'.repeat(121), + teams: [{ teamId: 'team-1', role: 'member' }], + }, + publicError: 'Name is too long', + directoryCalls: 0, + }, + { + label: 'too many teams', + body: { + email: 'invitee@example.com', + teams: Array.from({ length: 51 }, (_, index) => ({ + teamId: `team-${index}`, + role: 'member', + })), + }, + publicError: 'Too many teams selected', + directoryCalls: 0, + }, + ])( + 'preserves the authoritative response for $label through the real client parser', + async ({ body, publicError, directoryCalls }) => { + const fetchMock = stubControlApiFetch(makeControlApp()) + + await request(makeExternalApp()) + .post('/members/invite') + .set('authorization', 'Bearer good-token') + .send(body) + .expect(400, { error: publicError }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(directoryMock.createManagedInvitationForUser).toHaveBeenCalledTimes(directoryCalls) + } + ) }) From a49ca2853ed48e324a67578f3295fe0acf570631 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 14 Aug 2026 12:06:04 -0600 Subject: [PATCH 098/528] fix(access): preserve R4-M2 invitation role semantics Class-check: public invitation roles -> 5 variants -> preserve exact legacy filtering Repro-test: role-normalization test fails at ad39a249 and passes after fix --- external-rest-api/package-lock.json | 4 +-- external-rest-api/package.json | 2 +- external-rest-api/test/routes.members.test.ts | 32 ++++++++++++++++++- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 1831f85dfc..ff88a88560 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index a2eb923322..d8dc7498ab 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/test/routes.members.test.ts b/external-rest-api/test/routes.members.test.ts index 9a8f59e9d1..eba49ebe6a 100644 --- a/external-rest-api/test/routes.members.test.ts +++ b/external-rest-api/test/routes.members.test.ts @@ -64,12 +64,42 @@ describe('routes/members', () => { expect(memberManagementMock.inviteManagedMember).toHaveBeenCalledWith( 'invitee@example.com', - ' Full Name ', + 'Full Name', [{ teamId: 'team-1', role: 'inviter' }], 'good-token' ) }) + it('preserves exact public invitation-role normalization before forwarding', async () => { + authTokenMock.verifyToken.mockReturnValueOnce(claims) + memberManagementMock.inviteManagedMember.mockResolvedValueOnce({ id: 'inv-roles' }) + + await request(makeApp()) + .post('/members/invite') + .set('authorization', 'Bearer good-token') + .send({ + email: 'invitee@example.com', + teams: [ + { teamId: 'team-admin', role: 'admin' }, + { teamId: 'team-alias', role: 'leader' }, + { teamId: 'team-case', role: 'ADMIN' }, + { teamId: 'team-invalid', role: 'unexpected' }, + { teamId: 'team-default' }, + ], + }) + .expect(201, { id: 'inv-roles' }) + + expect(memberManagementMock.inviteManagedMember).toHaveBeenCalledWith( + 'invitee@example.com', + '', + [ + { teamId: 'team-admin', role: 'admin' }, + { teamId: 'team-default', role: 'member' }, + ], + 'good-token' + ) + }) + it('rejects raw invitation emails longer than 320 characters before Control API work', async () => { authTokenMock.verifyToken.mockReturnValueOnce(claims) const overlongEmail = `${'a'.repeat(315)}@a.com` From 5cd313990a0858ebb3df97063ea4f688c8870c55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 14 Aug 2026 12:10:29 -0600 Subject: [PATCH 099/528] fix(access): stabilize R4-M3 invitation name bounds Class-check: padded invitation names -> 3 boundaries -> trim before fixed sentinel Repro-test: padded-name route tests fail at 89361daa and pass after fix --- external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- external-rest-api/test/routes.members.test.ts | 43 +++++++++++++++++++ .../routes.membersInvitationContract.test.ts | 24 +++++++++++ 4 files changed, 70 insertions(+), 3 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index ff88a88560..1831f85dfc 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index d8dc7498ab..a2eb923322 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/test/routes.members.test.ts b/external-rest-api/test/routes.members.test.ts index eba49ebe6a..eabe9df6c6 100644 --- a/external-rest-api/test/routes.members.test.ts +++ b/external-rest-api/test/routes.members.test.ts @@ -254,6 +254,49 @@ describe('routes/members', () => { expect(memberManagementMock.inviteManagedMember).toHaveBeenCalledOnce() }) + it('preserves a short name after trimming an overlong raw padded value', async () => { + authTokenMock.verifyToken.mockReturnValueOnce(claims) + memberManagementMock.inviteManagedMember.mockResolvedValueOnce({ id: 'inv-padded' }) + + await request(makeApp()) + .post('/members/invite') + .set('authorization', 'Bearer good-token') + .send({ + email: 'invitee@example.com', + name: `${' '.repeat(121)}Alice`, + teams: [{ teamId: 'team-1', role: 'member' }], + }) + .expect(201, { id: 'inv-padded' }) + + expect(memberManagementMock.inviteManagedMember).toHaveBeenCalledWith( + 'invitee@example.com', + 'Alice', + [{ teamId: 'team-1', role: 'member' }], + 'good-token' + ) + }) + + it('uses a stable overlength projection after trimming the complete name', async () => { + authTokenMock.verifyToken.mockReturnValueOnce(claims) + memberManagementMock.inviteManagedMember.mockRejectedValueOnce( + new ControlApiError('invalid invitation name', 400, { error: 'invalid_name' }) + ) + + await request(makeApp()) + .post('/members/invite') + .set('authorization', 'Bearer good-token') + .send({ + email: 'invitee@example.com', + name: `${' '.repeat(10)}${'a'.repeat(121)}`, + teams: [{ teamId: 'team-1', role: 'member' }], + }) + .expect(400, { error: 'Name is too long' }) + + const [, forwardedName] = memberManagementMock.inviteManagedMember.mock.calls[0] + expect(forwardedName).toHaveLength(121) + expect(forwardedName).not.toMatch(/\s/) + }) + it('preserves the public team-count error after authoritative validation', async () => { authTokenMock.verifyToken.mockReturnValueOnce(claims) memberManagementMock.inviteManagedMember.mockRejectedValueOnce( diff --git a/external-rest-api/test/routes.membersInvitationContract.test.ts b/external-rest-api/test/routes.membersInvitationContract.test.ts index 649e219b23..45ff4ebee3 100644 --- a/external-rest-api/test/routes.membersInvitationContract.test.ts +++ b/external-rest-api/test/routes.membersInvitationContract.test.ts @@ -168,4 +168,28 @@ describe('member invitation cross-service error contract', () => { expect(directoryMock.createManagedInvitationForUser).toHaveBeenCalledTimes(directoryCalls) } ) + + it('preserves padded-name normalization through the real Control API route', async () => { + directoryMock.createManagedInvitationForUser.mockResolvedValueOnce({ + invitation: { id: 'inv-padded' }, + }) + stubControlApiFetch(makeControlApp()) + + await request(makeExternalApp()) + .post('/members/invite') + .set('authorization', 'Bearer good-token') + .send({ + email: 'invitee@example.com', + name: `${' '.repeat(121)}Alice`, + teams: [{ teamId: 'team-1', role: 'member' }], + }) + .expect(201, { id: 'inv-padded' }) + + expect(directoryMock.createManagedInvitationForUser).toHaveBeenCalledWith( + 'manager-1', + 'invitee@example.com', + [{ teamId: 'team-1', role: 'member' }], + 'Alice' + ) + }) }) From 4347a0b26b9d99999677d1eb7327dcc752954a1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 14 Aug 2026 12:59:54 -0600 Subject: [PATCH 100/528] test(access): keep R4-B2 invitation proof within service boundary Class-check: sibling-service runtime imports -> 1 test -> use a shared wire contract Repro-test: External REST-only contract test fails at 00469ac5 and passes after fix --- ....externalMembersInvitationContract.test.ts | 114 +++++++++ external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- .../routes.membersInvitationContract.test.ts | 227 ++++++++++-------- .../external-member-invitation-errors.json | 21 ++ 5 files changed, 265 insertions(+), 103 deletions(-) create mode 100644 control-api/test/routes.externalMembersInvitationContract.test.ts create mode 100644 tests/contracts/external-member-invitation-errors.json diff --git a/control-api/test/routes.externalMembersInvitationContract.test.ts b/control-api/test/routes.externalMembersInvitationContract.test.ts new file mode 100644 index 0000000000..e34ac45a1b --- /dev/null +++ b/control-api/test/routes.externalMembersInvitationContract.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import { readFileSync } from 'node:fs' +import request from 'supertest' +import { createExternalMembersRouter } from '../src/routes/external/members.js' + +const invitationErrorContract = JSON.parse( + readFileSync( + new URL('../../tests/contracts/external-member-invitation-errors.json', import.meta.url), + 'utf8' + ) +) as Record + +const directoryMock = vi.hoisted(() => ({ + createManagedInvitationForUser: vi.fn(), + deleteManagedMemberForUser: vi.fn(), + deleteManagedUserForUser: vi.fn(), + externalManagedInvitationResponse: vi.fn((value: Record) => value), + listManageableTeamsForUser: vi.fn(), + listManagedMembersForUser: vi.fn(), + listManagedPendingInvitationsForUser: vi.fn(), + resendManagedInvitationForUser: vi.fn(), + revokeManagedInvitationForUser: vi.fn(), + updateManagedMemberRoleForUser: vi.fn(), +})) + +const rateLimitMock = vi.hoisted(() => ({ + checkAndIncrement: vi.fn(), +})) + +vi.mock('../src/middleware/externalSessionAuth.js', () => ({ + requireValidExternalSessionToken: ( + req: express.Request, + _res: express.Response, + next: express.NextFunction + ) => { + ;(req as express.Request & { externalAuth?: { userId: string } }).externalAuth = { + userId: 'manager-1', + } + next() + }, +})) +vi.mock('../src/services/directory/index.js', async () => { + const actual = await import('../src/services/directory/membership.js') + directoryMock.createManagedInvitationForUser.mockImplementation( + actual.createManagedInvitationForUser + ) + return directoryMock +}) +vi.mock('../src/services/rateLimiterService.js', () => rateLimitMock) + +function makeApp() { + const app = express() + app.use(express.json()) + app.use(createExternalMembersRouter()) + return app +} + +describe('external member invitation producer contract', () => { + beforeEach(() => { + vi.clearAllMocks() + rateLimitMock.checkAndIncrement.mockResolvedValue({ + allowed: true, + count: 1, + remaining: 9, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + }) + }) + + it.each([ + { + contract: 'invalidEmail', + label: 'invalid email', + body: { email: 'not-an-email', teams: [] }, + }, + { + contract: 'invalidPayload', + label: 'empty assignments', + body: { email: 'invitee@example.com', teams: [] }, + }, + { + contract: 'invalidName', + label: 'overlong name', + body: { + email: 'invitee@example.com', + name: 'a'.repeat(121), + teams: [{ teamId: 'team-1', role: 'member' }], + }, + }, + { + contract: 'tooManyTeams', + label: 'too many teams', + body: { + email: 'invitee@example.com', + teams: Array.from({ length: 51 }, (_, index) => ({ + teamId: `team-${index}`, + role: 'member', + })), + }, + }, + ])('emits the shared wire response for $label', async ({ contract, body }) => { + const expected = invitationErrorContract[contract].control + await request(makeApp()) + .post('/external/members/invitations') + .send(body) + .expect(expected.status, expected.body) + + expect(rateLimitMock.checkAndIncrement).toHaveBeenCalledWith( + 'external_member_mutation:user:manager-1', + 10 + ) + }) +}) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 1831f85dfc..ff88a88560 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index a2eb923322..d8dc7498ab 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/test/routes.membersInvitationContract.test.ts b/external-rest-api/test/routes.membersInvitationContract.test.ts index 45ff4ebee3..57bf7c235f 100644 --- a/external-rest-api/test/routes.membersInvitationContract.test.ts +++ b/external-rest-api/test/routes.membersInvitationContract.test.ts @@ -1,51 +1,32 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import express from 'express' +import { readFileSync } from 'node:fs' +import { type Server, createServer } from 'node:http' +import type { AddressInfo } from 'node:net' import request from 'supertest' -import { createExternalMembersRouter } from '../../control-api/src/routes/external/members.js' +import { config } from '../src/config.js' import { createMembersRouter } from '../src/routes/members.js' +type InvitationErrorContract = Record< + string, + { + control: { status: number; body: { error: string } } + external: { status: number; body: { error: string } } + } +> + +const invitationErrorContract = JSON.parse( + readFileSync( + new URL('../../tests/contracts/external-member-invitation-errors.json', import.meta.url), + 'utf8' + ) +) as InvitationErrorContract + const authTokenMock = vi.hoisted(() => ({ verifyToken: vi.fn(), })) -const directoryMock = vi.hoisted(() => ({ - createManagedInvitationForUser: vi.fn(), - deleteManagedMemberForUser: vi.fn(), - deleteManagedUserForUser: vi.fn(), - externalManagedInvitationResponse: vi.fn((value: Record) => value), - listManageableTeamsForUser: vi.fn(), - listManagedMembersForUser: vi.fn(), - listManagedPendingInvitationsForUser: vi.fn(), - resendManagedInvitationForUser: vi.fn(), - revokeManagedInvitationForUser: vi.fn(), - updateManagedMemberRoleForUser: vi.fn(), -})) - -const rateLimitMock = vi.hoisted(() => ({ - checkAndIncrement: vi.fn(), -})) - vi.mock('../src/authToken.js', () => authTokenMock) -vi.mock('../../control-api/src/middleware/externalSessionAuth.js', () => ({ - requireValidExternalSessionToken: ( - req: express.Request, - _res: express.Response, - next: express.NextFunction - ) => { - ;(req as express.Request & { externalAuth?: { userId: string } }).externalAuth = { - userId: 'manager-1', - } - next() - }, -})) -vi.mock('../../control-api/src/services/directory/index.js', async () => { - const actual = await import('../../control-api/src/services/directory/membership.js') - directoryMock.createManagedInvitationForUser.mockImplementation( - actual.createManagedInvitationForUser - ) - return directoryMock -}) -vi.mock('../../control-api/src/services/rateLimiterService.js', () => rateLimitMock) const claims = { userId: 'user-1', @@ -55,11 +36,15 @@ const claims = { exp: 9999999999, } -function makeControlApp() { - const app = express() - app.use(express.json()) - app.use(createExternalMembersRouter()) - return app +type ProducerResponse = { + status: number + body: Record +} + +type ProducerRequest = { + method: string | undefined + path: string | undefined + body: unknown } function makeExternalApp() { @@ -69,80 +54,118 @@ function makeExternalApp() { return app } -function stubControlApiFetch(controlApp: express.Express) { - const fetchMock = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { - const body = typeof init?.body === 'string' ? JSON.parse(init.body) : undefined - const upstream = await request(controlApp).post('/external/members/invitations').send(body) +function createControlApiWireProducer() { + let nextResponse: ProducerResponse | undefined + const requests: ProducerRequest[] = [] + const server = createServer((req, res) => { + const chunks: Buffer[] = [] + req.on('data', chunk => chunks.push(Buffer.from(chunk))) + req.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8') + requests.push({ + method: req.method, + path: req.url, + body: raw ? JSON.parse(raw) : undefined, + }) - return new Response(JSON.stringify(upstream.body), { - status: upstream.status, - headers: { 'content-type': 'application/json' }, + if (!nextResponse) { + res.statusCode = 500 + res.end(JSON.stringify({ error: 'missing_test_response' })) + return + } + + res.statusCode = nextResponse.status + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify(nextResponse.body)) + nextResponse = undefined }) }) - vi.stubGlobal('fetch', fetchMock) - return fetchMock + + return { + server, + requests, + respondWith(response: ProducerResponse) { + nextResponse = response + }, + reset() { + nextResponse = undefined + requests.length = 0 + }, + } } describe('member invitation cross-service error contract', () => { + const originalControlApiBaseUrl = config.controlApiBaseUrl + const producer = createControlApiWireProducer() + let server: Server + + beforeAll(async () => { + server = producer.server + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.off('error', reject) + resolve() + }) + }) + const address = server.address() as AddressInfo + config.controlApiBaseUrl = `http://127.0.0.1:${address.port}` + }) + beforeEach(() => { vi.clearAllMocks() + producer.reset() authTokenMock.verifyToken.mockReturnValue(claims) - rateLimitMock.checkAndIncrement.mockResolvedValue({ - allowed: true, - count: 1, - remaining: 29, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - }) }) - afterEach(() => { - vi.unstubAllGlobals() + afterAll(async () => { + config.controlApiBaseUrl = originalControlApiBaseUrl + await new Promise((resolve, reject) => { + server.close(error => (error ? reject(error) : resolve())) + }) }) it('preserves the authoritative invalid-email response through the real client parser', async () => { - const controlApp = makeControlApp() - const fetchMock = stubControlApiFetch(controlApp) + const contract = invitationErrorContract.invalidEmail + producer.respondWith(contract.control) await request(makeExternalApp()) .post('/members/invite') .set('authorization', 'Bearer good-token') .send({ email: 'not-an-email', teams: [] }) - .expect(400, { error: 'Valid email is required' }) - - expect(fetchMock).toHaveBeenCalledTimes(1) - const [, init] = fetchMock.mock.calls[0] - expect(JSON.parse(String(init?.body))).toEqual({ - email: 'not-an-email', - name: '', - teams: [], - }) - expect(rateLimitMock.checkAndIncrement).toHaveBeenCalledWith( - 'external_member_mutation:user:manager-1', - 10 - ) - expect(directoryMock.createManagedInvitationForUser).not.toHaveBeenCalled() + .expect(contract.external.status, contract.external.body) + + expect(producer.requests).toEqual([ + { + method: 'POST', + path: '/external/members/invitations', + body: { + email: 'not-an-email', + name: '', + teams: [], + }, + }, + ]) }) it.each([ { label: 'empty assignments', + contract: 'invalidPayload', body: { email: 'invitee@example.com', teams: [] }, - publicError: 'Email and at least one team are required', - directoryCalls: 1, }, { label: 'an overlong name', + contract: 'invalidName', body: { email: 'invitee@example.com', name: 'a'.repeat(121), teams: [{ teamId: 'team-1', role: 'member' }], }, - publicError: 'Name is too long', - directoryCalls: 0, }, { label: 'too many teams', + contract: 'tooManyTeams', body: { email: 'invitee@example.com', teams: Array.from({ length: 51 }, (_, index) => ({ @@ -150,30 +173,29 @@ describe('member invitation cross-service error contract', () => { role: 'member', })), }, - publicError: 'Too many teams selected', - directoryCalls: 0, }, ])( 'preserves the authoritative response for $label through the real client parser', - async ({ body, publicError, directoryCalls }) => { - const fetchMock = stubControlApiFetch(makeControlApp()) + async ({ body, contract: contractName }) => { + const contract = invitationErrorContract[contractName] + producer.respondWith(contract.control) await request(makeExternalApp()) .post('/members/invite') .set('authorization', 'Bearer good-token') .send(body) - .expect(400, { error: publicError }) + .expect(contract.external.status, contract.external.body) - expect(fetchMock).toHaveBeenCalledTimes(1) - expect(directoryMock.createManagedInvitationForUser).toHaveBeenCalledTimes(directoryCalls) + expect(producer.requests).toHaveLength(1) + expect(producer.requests[0]).toMatchObject({ + method: 'POST', + path: '/external/members/invitations', + }) } ) - it('preserves padded-name normalization through the real Control API route', async () => { - directoryMock.createManagedInvitationForUser.mockResolvedValueOnce({ - invitation: { id: 'inv-padded' }, - }) - stubControlApiFetch(makeControlApp()) + it('preserves padded-name normalization across the Control API wire boundary', async () => { + producer.respondWith({ status: 201, body: { id: 'inv-padded' } }) await request(makeExternalApp()) .post('/members/invite') @@ -185,11 +207,16 @@ describe('member invitation cross-service error contract', () => { }) .expect(201, { id: 'inv-padded' }) - expect(directoryMock.createManagedInvitationForUser).toHaveBeenCalledWith( - 'manager-1', - 'invitee@example.com', - [{ teamId: 'team-1', role: 'member' }], - 'Alice' - ) + expect(producer.requests).toEqual([ + { + method: 'POST', + path: '/external/members/invitations', + body: { + email: 'invitee@example.com', + name: 'Alice', + teams: [{ teamId: 'team-1', role: 'member' }], + }, + }, + ]) }) }) diff --git a/tests/contracts/external-member-invitation-errors.json b/tests/contracts/external-member-invitation-errors.json new file mode 100644 index 0000000000..6ab4539fbf --- /dev/null +++ b/tests/contracts/external-member-invitation-errors.json @@ -0,0 +1,21 @@ +{ + "invalidEmail": { + "control": { "status": 400, "body": { "error": "invalid_email" } }, + "external": { "status": 400, "body": { "error": "Valid email is required" } } + }, + "invalidPayload": { + "control": { "status": 400, "body": { "error": "invalid_payload" } }, + "external": { + "status": 400, + "body": { "error": "Email and at least one team are required" } + } + }, + "invalidName": { + "control": { "status": 400, "body": { "error": "invalid_name" } }, + "external": { "status": 400, "body": { "error": "Name is too long" } } + }, + "tooManyTeams": { + "control": { "status": 400, "body": { "error": "too_many_teams" } }, + "external": { "status": 400, "body": { "error": "Too many teams selected" } } + } +} From e2e0d6a1529a7d68cc2f39c02feab39e7d1ff030 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 14 Aug 2026 15:11:56 -0600 Subject: [PATCH 101/528] fix(control-api): preserve external public errors after handler extraction Keep Task 106 and D28 external error semantics when dev centralizes the global handler. --- control-api/src/http/errorHandler.ts | 26 +++++++++++++++++++ .../test/http.errorHandler.k8sStatus.test.ts | 23 ++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/control-api/src/http/errorHandler.ts b/control-api/src/http/errorHandler.ts index 876f2f263d..08efa15ea8 100644 --- a/control-api/src/http/errorHandler.ts +++ b/control-api/src/http/errorHandler.ts @@ -3,6 +3,7 @@ import { ApiException } from '@kubernetes/client-node' import { STATUS_CODES } from 'node:http' import { rootLogger } from '../observability/logger.js' import { memberRegistrationErrorResponse } from '../services/memberRegistrationErrors.js' +import { sendPublicApiError } from './publicApiError.js' // Only errors we construct with these codes are ever forwarded verbatim by the // global handler. Keeps the blast radius tight: every other status-less or @@ -198,6 +199,31 @@ export function clerumErrorHandler( }, 'forwarded client error from upstream' ) + if (req.originalUrl.startsWith('/api/v1/external')) { + const publicCode = + errStatus === 401 + ? 'invalid_session' + : errStatus === 403 + ? 'forbidden' + : errStatus === 404 + ? 'not_found' + : errStatus === 429 + ? 'rate_limited' + : 'invalid_request' + sendPublicApiError( + req, + res, + errStatus as number, + publicCode, + errStatus === 404 + ? 'The resource was not found.' + : errStatus === 429 + ? 'Too many requests; retry later.' + : 'The request could not be completed.', + errStatus === 429 + ) + return + } res.status(errStatus as number).json({ error: clientErrorMessage(err, errStatus as number), ...(forwardedCode ? { code: forwardedCode } : {}), diff --git a/control-api/test/http.errorHandler.k8sStatus.test.ts b/control-api/test/http.errorHandler.k8sStatus.test.ts index 1ca0fe60bc..2bf9357cb0 100644 --- a/control-api/test/http.errorHandler.k8sStatus.test.ts +++ b/control-api/test/http.errorHandler.k8sStatus.test.ts @@ -21,6 +21,13 @@ function appThrowing(err: unknown) { return app } +function externalAppThrowing(err: unknown) { + const app = express() + app.get('/api/v1/external/boom', (_req, _res, next) => next(err)) + app.use(clerumErrorHandler) + return app +} + /** * Like `appThrowing` but injects a spy logger on `req.log` so a test can assert * on what the handler writes to the INTERNAL log (not just the client body). @@ -62,6 +69,22 @@ describe('clerumErrorHandler — K8s status forwarding (UT-2a)', () => { expect(res.status).toBe(404) }) + it('preserves the external public 4xx error contract after handler extraction', async () => { + const res = await request(externalAppThrowing({ status: 401, message: 'upstream detail' })).get( + '/api/v1/external/boom' + ) + + expect(res.status).toBe(401) + expect(res.body).toMatchObject({ + error: { + code: 'invalid_session', + message: 'The request could not be completed.', + retryable: false, + }, + }) + expect(JSON.stringify(res.body)).not.toContain('upstream detail') + }) + /** * R4-M3: the service-layer K8sNotFoundError/K8sConflictError expose their * status ONLY on `.httpStatus` (no `.status` / `.statusCode` / `.code`). The From 473c7a8e8c72be782cb9c06e40a83ff5dcf188d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 14 Aug 2026 19:13:06 -0600 Subject: [PATCH 102/528] fix(codeql): model canonical rate limit middleware Teach CodeQL to recognize Evenfire's canonical rate-limit middleware while preserving genuine missing-rate-limit findings. - Replace only the stock missing-rate-limit query with a repository-aware equivalent. - Add fixtures for protected, unprotected, ordering, sibling, nesting, and alias flows. - Run repository query tests before SARIF analysis. - Class-check: 106 production calls across four supported composition patterns. - Repro-test: stock analysis flags protected routes at 0808b0c9. - The replacement clears protected routes while current-dev true positives remain. - Query tests pass 1/1; code, security, and collateral reviews are clean. --- .github/codeql/codeql-config.yml | 7 + .../queries/EvenfireMissingRateLimiting.ql | 187 +++++++++++++++++ .../queries/evenfire-security-and-quality.qls | 5 + .github/codeql/queries/qlpack.yml | 5 + .../EvenfireMissingRateLimiting.expected | 16 ++ .../EvenfireMissingRateLimiting.qlref | 1 + .../src/middleware/externalSessionAuth.ts | 37 ++++ .../src/middleware/rateLimitMiddleware.ts | 3 + .../src/middleware/unrelatedMiddleware.ts | 3 + .../src/routes/external/composition.ts | 190 ++++++++++++++++++ .../src/routes/external/modeling.ts | 62 ++++++ .github/codeql/queries/test/qlpack.yml | 5 + .github/workflows/codeql.yml | 54 ++++- 13 files changed, 565 insertions(+), 10 deletions(-) create mode 100644 .github/codeql/codeql-config.yml create mode 100644 .github/codeql/queries/EvenfireMissingRateLimiting.ql create mode 100644 .github/codeql/queries/evenfire-security-and-quality.qls create mode 100644 .github/codeql/queries/qlpack.yml create mode 100644 .github/codeql/queries/test/MissingRateLimiting/EvenfireMissingRateLimiting.expected create mode 100644 .github/codeql/queries/test/MissingRateLimiting/EvenfireMissingRateLimiting.qlref create mode 100644 .github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/externalSessionAuth.ts create mode 100644 .github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/rateLimitMiddleware.ts create mode 100644 .github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/unrelatedMiddleware.ts create mode 100644 .github/codeql/queries/test/MissingRateLimiting/control-api/src/routes/external/composition.ts create mode 100644 .github/codeql/queries/test/MissingRateLimiting/control-api/src/routes/external/modeling.ts create mode 100644 .github/codeql/queries/test/qlpack.yml diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000000..28c3d1dd2d --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,7 @@ +name: Evenfire CodeQL configuration + +# Run the stock security-and-quality suite exactly once, replacing only the +# missing-rate-limiting query with Evenfire's canonical middleware model. +disable-default-queries: true +queries: + - uses: ./.github/codeql/queries/evenfire-security-and-quality.qls diff --git a/.github/codeql/queries/EvenfireMissingRateLimiting.ql b/.github/codeql/queries/EvenfireMissingRateLimiting.ql new file mode 100644 index 0000000000..5f40bc84c3 --- /dev/null +++ b/.github/codeql/queries/EvenfireMissingRateLimiting.ql @@ -0,0 +1,187 @@ +/** + * @name Missing rate limiting + * @description An HTTP request handler that performs expensive operations without + * restricting the rate at which operations can be carried out is vulnerable + * to denial-of-service attacks. + * @kind problem + * @problem.severity warning + * @security-severity 7.5 + * @precision high + * @id js/missing-rate-limiting + * @tags security + * external/cwe/cwe-770 + * external/cwe/cwe-307 + * external/cwe/cwe-400 + */ + +import javascript +import semmle.javascript.security.dataflow.MissingRateLimiting +import semmle.javascript.RestrictedLocations + +private predicate isCanonicalEvenfireRateLimitImport(ImportSpecifier spec) { + spec.getImportedName() = "rateLimitMiddleware" and + spec.getImportDeclaration().getImportedFile().getRelativePath() = + "control-api/src/middleware/rateLimitMiddleware.ts" +} + +private class EvenfireRateLimitingMiddleware extends RateLimitingMiddleware, DataFlow::CallNode { + EvenfireRateLimitingMiddleware() { + exists(ImportSpecifier spec | + isCanonicalEvenfireRateLimitImport(spec) and + DataFlow::valueNode(spec).(DataFlow::SourceNode).flowsTo(this.getCalleeNode()) + ) + } + + override Routing::Node getRoutingNode() { + exists(DataFlow::Node ref | this.flowsTo(ref) and result = Routing::getNode(ref)) + } +} + +private predicate sameSourceFile(Routing::Node left, Routing::Node right) { + exists( + string path, int leftStartLine, int leftStartColumn, int leftEndLine, int leftEndColumn, + int rightStartLine, int rightStartColumn, int rightEndLine, int rightEndColumn + | + left.hasLocationInfo(path, leftStartLine, leftStartColumn, leftEndLine, leftEndColumn) and + right.hasLocationInfo(path, rightStartLine, rightStartColumn, rightEndLine, rightEndColumn) + ) +} + +private predicate routeSetupHasEvenfireLimiter(Routing::RouteSetup setup) { + exists(EvenfireRateLimitingMiddleware middleware, Routing::Node limiterNode | + limiterNode = middleware.getRoutingNode() and + limiterNode.getParent() = setup and + sameSourceFile(setup, limiterNode) and + not exists(Routing::Node earlierNode | + earlierNode.getParent() = setup and + limiterNode = earlierNode.getNextSibling+() and + not earlierNode.mayResumeDispatch() + ) + ) +} + +private predicate isCanonicalExternalContextImport(ImportSpecifier spec) { + spec.getImportDeclaration().getImportedFile().getRelativePath() = + "control-api/src/middleware/externalSessionAuth.ts" and + spec.getImportedName() = + [ + "requireExternalSessionRateLimitContext", + "requireValidExternalSessionToken", + "requireValidExternalSessionTokenWithPublicErrors" + ] +} + +private predicate isCanonicalExternalContextHandler(Routing::Node useSite) { + exists(ImportSpecifier spec, DataFlow::Node installedNode | + isCanonicalExternalContextImport(spec) and + useSite = Routing::getNode(installedNode) and + ( + DataFlow::valueNode(spec).(DataFlow::SourceNode).flowsTo(installedNode) + or + exists(DataFlow::CallNode call | + call = installedNode and + DataFlow::valueNode(spec).(DataFlow::SourceNode).flowsTo(call.getCalleeNode()) + ) + ) + ) +} + +private predicate hasSameRouteEvenfireLimiterAfterContext(Routing::Node useSite) { + exists(EvenfireRateLimitingMiddleware middleware, Routing::Node limiterNode | + limiterNode = middleware.getRoutingNode() and + limiterNode = useSite.getNextSibling+() and + sameSourceFile(useSite, limiterNode) and + not exists(Routing::Node earlierNode | + earlierNode = useSite.getNextSibling+() and + limiterNode = earlierNode.getNextSibling+() and + not earlierNode.mayResumeDispatch() + ) + ) +} + +private predicate hasGlobalEvenfireLimiterAfterContext(Routing::Node useSite) { + exists( + Routing::RouteSetup contextSetup, Routing::RouteSetup limiterSetup, + EvenfireRateLimitingMiddleware middleware, Routing::Node limiterNode + | + useSite.getParent() = contextSetup and + limiterNode = middleware.getRoutingNode() and + limiterNode.getParent() = limiterSetup and + limiterSetup.getRouter() = contextSetup.getRouter() and + limiterSetup = contextSetup.getNextSibling+() and + limiterSetup.getRelativePath() = contextSetup.getRelativePath() and + not exists(contextSetup.getOwnHttpMethod()) and + sameSourceFile(useSite, limiterNode) and + not exists(Routing::RouteSetup earlierSetup | + earlierSetup.getRouter() = contextSetup.getRouter() and + earlierSetup = contextSetup.getNextSibling+() and + limiterSetup = earlierSetup.getNextSibling+() and + not earlierSetup.mayResumeDispatch() + ) + ) +} + +private predicate isCoveredHttpRouteAfterContext( + Routing::RouteSetup contextSetup, Routing::RouteSetup setup +) { + exists(string contextPath, string setupPath | + contextPath = contextSetup.getRelativePath() and + setupPath = setup.getRelativePath() and + setup.getRouter() = contextSetup.getRouter() and + setup = contextSetup.getNextSibling+() and + sameSourceFile(contextSetup, setup) and + exists(setup.getOwnHttpMethod()) and + ( + setupPath = contextPath + or + setupPath.length() > contextPath.length() and + setupPath.prefix(contextPath.length() + 1) = contextPath + "/" + ) + ) +} + +private predicate hasEvenfireRateLimitingGuard(Routing::Node useSite) { + exists(EvenfireRateLimitingMiddleware middleware | + useSite.isGuardedByNode(middleware.getRoutingNode()) and + sameSourceFile(useSite, middleware.getRoutingNode()) + ) + or + isCanonicalExternalContextHandler(useSite) and + useSite.mayResumeDispatch() and + hasSameRouteEvenfireLimiterAfterContext(useSite) + or + isCanonicalExternalContextHandler(useSite) and + useSite.mayResumeDispatch() and + hasGlobalEvenfireLimiterAfterContext(useSite) + or + isCanonicalExternalContextHandler(useSite) and + useSite.mayResumeDispatch() and + exists(Routing::RouteSetup contextSetup | + useSite.getParent() = contextSetup and + not exists(contextSetup.getOwnHttpMethod()) and + exists(Routing::RouteSetup setup | isCoveredHttpRouteAfterContext(contextSetup, setup)) and + not exists(Routing::RouteSetup setup | + isCoveredHttpRouteAfterContext(contextSetup, setup) and + not routeSetupHasEvenfireLimiter(setup) + ) + ) +} + +private predicate hasRateLimitingGuard(Routing::Node useSite) { + exists(RateLimitingMiddleware middleware | + useSite.isGuardedByNode(middleware.getRoutingNode()) and + not middleware instanceof EvenfireRateLimitingMiddleware + ) + or + hasEvenfireRateLimitingGuard(useSite) +} + +from + Routing::Node useSite, ExpensiveRouteHandler r, string explanation, DataFlow::Node reference, + string referenceLabel +where + useSite = Routing::getNode(r).getRouteInstallation() and + r.explain(explanation, reference, referenceLabel) and + not hasRateLimitingGuard(useSite) +select useSite, "This route handler " + explanation + ", but is not rate-limited.", reference, + referenceLabel diff --git a/.github/codeql/queries/evenfire-security-and-quality.qls b/.github/codeql/queries/evenfire-security-and-quality.qls new file mode 100644 index 0000000000..42cf3f7d8d --- /dev/null +++ b/.github/codeql/queries/evenfire-security-and-quality.qls @@ -0,0 +1,5 @@ +- import: codeql-suites/javascript-security-and-quality.qls + from: codeql/javascript-queries +- exclude: + id: js/missing-rate-limiting +- query: EvenfireMissingRateLimiting.ql diff --git a/.github/codeql/queries/qlpack.yml b/.github/codeql/queries/qlpack.yml new file mode 100644 index 0000000000..323093504a --- /dev/null +++ b/.github/codeql/queries/qlpack.yml @@ -0,0 +1,5 @@ +name: evenfire/codeql-queries +version: 0.0.1 +dependencies: + codeql/javascript-all: 2.9.0 + codeql/javascript-queries: 2.4.3 diff --git a/.github/codeql/queries/test/MissingRateLimiting/EvenfireMissingRateLimiting.expected b/.github/codeql/queries/test/MissingRateLimiting/EvenfireMissingRateLimiting.expected new file mode 100644 index 0000000000..69b5e39a42 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimiting/EvenfireMissingRateLimiting.expected @@ -0,0 +1,16 @@ +| control-api/src/routes/external/composition.ts:60:3:60:50 | require ... cErrors | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:19:3:19:9 | login() | authorization | +| control-api/src/routes/external/composition.ts:67:55:67:75 | incompl ... otected | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/composition.ts:53:3:53:74 | fs.writ ... value') | a file system access | +| control-api/src/routes/external/composition.ts:88:38:88:69 | require ... onToken | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:10:3:10:9 | login() | authorization | +| control-api/src/routes/external/composition.ts:89:38:89:51 | lateGlobalWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/composition.ts:83:3:83:63 | fs.writ ... value') | a file system access | +| control-api/src/routes/external/composition.ts:102:3:102:31 | require ... amMatch | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:35:3:35:62 | fs.writ ... value') | a file system access | +| control-api/src/routes/external/composition.ts:113:39:113:70 | require ... onToken | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:10:3:10:9 | login() | authorization | +| control-api/src/routes/external/composition.ts:128:3:128:34 | require ... onToken | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:10:3:10:9 | login() | authorization | +| control-api/src/routes/external/composition.ts:129:3:129:19 | lateSameRouteWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/composition.ts:121:3:121:67 | fs.writ ... value') | a file system access | +| control-api/src/routes/external/composition.ts:139:36:139:67 | require ... onToken | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:10:3:10:9 | login() | authorization | +| control-api/src/routes/external/composition.ts:142:3:142:15 | lateChildWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/composition.ts:134:3:134:62 | fs.writ ... value') | a file system access | +| control-api/src/routes/external/composition.ts:162:39:162:59 | nestedU ... tedWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/composition.ts:152:3:152:70 | fs.writ ... value') | a file system access | +| control-api/src/routes/external/composition.ts:165:42:165:73 | require ... onToken | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:10:3:10:9 | login() | authorization | +| control-api/src/routes/external/composition.ts:169:35:169:66 | require ... onToken | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:10:3:10:9 | login() | authorization | +| control-api/src/routes/external/modeling.ts:39:52:39:66 | unprotectedWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/modeling.ts:14:3:14:63 | fs.writ ... value') | a file system access | +| control-api/src/routes/external/modeling.ts:43:3:43:18 | orderedWrongWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/modeling.ts:19:3:19:65 | fs.writ ... value') | a file system access | +| control-api/src/routes/external/modeling.ts:47:50:47:62 | unrelatedWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/modeling.ts:24:3:24:61 | fs.writ ... value') | a file system access | diff --git a/.github/codeql/queries/test/MissingRateLimiting/EvenfireMissingRateLimiting.qlref b/.github/codeql/queries/test/MissingRateLimiting/EvenfireMissingRateLimiting.qlref new file mode 100644 index 0000000000..7d3c25d5ca --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimiting/EvenfireMissingRateLimiting.qlref @@ -0,0 +1 @@ +query: EvenfireMissingRateLimiting.ql diff --git a/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/externalSessionAuth.ts b/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/externalSessionAuth.ts new file mode 100644 index 0000000000..3849e03edd --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/externalSessionAuth.ts @@ -0,0 +1,37 @@ +import fs from 'fs' + +declare function login(): void + +export function requireValidExternalSessionToken( + _req: unknown, + _res: unknown, + next: () => void, +) { + login() + next() +} + +export function requireValidExternalSessionTokenWithPublicErrors( + _req: unknown, + _res: unknown, + next: () => void, +) { + login() + next() +} + +export function requireExternalSessionRateLimitContext(_options: unknown) { + return (_req: unknown, _res: unknown, next: () => void) => { + login() + next() + } +} + +export function requireExternalTeamParamMatch( + _req: unknown, + _res: unknown, + next: () => void, +) { + fs.writeFileSync('/tmp/evenfire-codeql-team-match', 'value') + next() +} diff --git a/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/rateLimitMiddleware.ts b/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/rateLimitMiddleware.ts new file mode 100644 index 0000000000..29c4cf01c7 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/rateLimitMiddleware.ts @@ -0,0 +1,3 @@ +export function rateLimitMiddleware(_policy: unknown) { + return (_req: unknown, _res: unknown, next: () => void) => next() +} diff --git a/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/unrelatedMiddleware.ts b/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/unrelatedMiddleware.ts new file mode 100644 index 0000000000..05e83e8ae9 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/unrelatedMiddleware.ts @@ -0,0 +1,3 @@ +export function unrelatedMiddleware() { + return (_req: unknown, _res: unknown, next: () => void) => next() +} diff --git a/.github/codeql/queries/test/MissingRateLimiting/control-api/src/routes/external/composition.ts b/.github/codeql/queries/test/MissingRateLimiting/control-api/src/routes/external/composition.ts new file mode 100644 index 0000000000..d342e1ed9d --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimiting/control-api/src/routes/external/composition.ts @@ -0,0 +1,190 @@ +import express from 'express' +import fs from 'fs' +import { + requireExternalSessionRateLimitContext, + requireExternalTeamParamMatch, + requireValidExternalSessionToken, + requireValidExternalSessionTokenWithPublicErrors, +} from '../../middleware/externalSessionAuth' +import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware' + +function sameRouteWork(_req: express.Request, res: express.Response) { + fs.writeFileSync('/tmp/evenfire-codeql-same-route', 'value') + res.sendStatus(204) +} + +const sameRouteRouter = express.Router() +sameRouteRouter.post( + '/same-route', + requireExternalSessionRateLimitContext({ purpose: 'protected' }), + rateLimitMiddleware({ type: 'external_member_mutation', max: 10 }), + sameRouteWork, +) + +function completeRead(_req: express.Request, res: express.Response) { + fs.writeFileSync('/tmp/evenfire-codeql-complete-read', 'value') + res.sendStatus(204) +} + +function completeWrite(_req: express.Request, res: express.Response) { + fs.writeFileSync('/tmp/evenfire-codeql-complete-write', 'value') + res.sendStatus(204) +} + +const completeParentRouter = express.Router() +completeParentRouter.use('/complete', requireValidExternalSessionToken) +completeParentRouter.get( + '/complete/read', + rateLimitMiddleware({ type: 'external_member_read', max: 60 }), + completeRead, +) +completeParentRouter.post( + '/complete/write', + rateLimitMiddleware({ type: 'external_member_mutation', max: 10 }), + completeWrite, +) + +function incompleteProtected(_req: express.Request, res: express.Response) { + fs.writeFileSync('/tmp/evenfire-codeql-incomplete-protected', 'value') + res.sendStatus(204) +} + +function incompleteUnprotected(_req: express.Request, res: express.Response) { + fs.writeFileSync('/tmp/evenfire-codeql-incomplete-unprotected', 'value') + res.sendStatus(204) +} + +const incompleteParentRouter = express.Router() +incompleteParentRouter.use( + '/incomplete', + requireValidExternalSessionTokenWithPublicErrors, +) +incompleteParentRouter.get( + '/incomplete/protected', + rateLimitMiddleware({ type: 'external_member_read', max: 60 }), + incompleteProtected, +) +incompleteParentRouter.get('/incomplete/unprotected', incompleteUnprotected) + +function globalWork(_req: express.Request, res: express.Response) { + fs.writeFileSync('/tmp/evenfire-codeql-global', 'value') + res.sendStatus(204) +} + +const globalRouter = express.Router() +globalRouter.use('/global', requireValidExternalSessionToken) +globalRouter.use( + '/global', + rateLimitMiddleware({ type: 'external_shared_filesystem_read', max: 60 }), +) +globalRouter.get('/global', globalWork) + +function lateGlobalWork(_req: express.Request, res: express.Response) { + fs.writeFileSync('/tmp/evenfire-codeql-late-global', 'value') + res.sendStatus(204) +} + +const lateGlobalRouter = express.Router() +lateGlobalRouter.use('/late-global', requireValidExternalSessionToken) +lateGlobalRouter.get('/late-global', lateGlobalWork) +lateGlobalRouter.use( + '/late-global', + rateLimitMiddleware({ type: 'external_shared_filesystem_read', max: 60 }), +) + +function sameModuleAuthorizationWork(_req: express.Request, res: express.Response) { + res.sendStatus(204) +} + +const sameModuleAuthorizationRouter = express.Router() +sameModuleAuthorizationRouter.get( + '/same-module-authorization', + requireExternalTeamParamMatch, + rateLimitMiddleware({ type: 'external_member_read', max: 60 }), + sameModuleAuthorizationWork, +) + +function unrelatedPathWork(_req: express.Request, res: express.Response) { + fs.writeFileSync('/tmp/evenfire-codeql-unrelated-path', 'value') + res.sendStatus(204) +} + +const unrelatedPathRouter = express.Router() +unrelatedPathRouter.use('/sensitive', requireValidExternalSessionToken) +unrelatedPathRouter.get( + '/other', + rateLimitMiddleware({ type: 'external_member_read', max: 60 }), + unrelatedPathWork, +) + +function lateSameRouteWork(_req: express.Request, res: express.Response) { + fs.writeFileSync('/tmp/evenfire-codeql-late-same-route', 'value') + res.sendStatus(204) +} + +const lateSameRouteRouter = express.Router() +lateSameRouteRouter.get( + '/late-same-route', + requireValidExternalSessionToken, + lateSameRouteWork, + rateLimitMiddleware({ type: 'external_member_read', max: 60 }), +) + +function lateChildWork(_req: express.Request, res: express.Response) { + fs.writeFileSync('/tmp/evenfire-codeql-late-child', 'value') + res.sendStatus(204) +} + +const lateChildRouter = express.Router() +lateChildRouter.use('/late-child', requireValidExternalSessionToken) +lateChildRouter.get( + '/late-child/read', + lateChildWork, + rateLimitMiddleware({ type: 'external_member_read', max: 60 }), +) + +function nestedProtectedWork(_req: express.Request, res: express.Response) { + fs.writeFileSync('/tmp/evenfire-codeql-nested-protected', 'value') + res.sendStatus(204) +} + +function nestedUnprotectedWork(_req: express.Request, res: express.Response) { + fs.writeFileSync('/tmp/evenfire-codeql-nested-unprotected', 'value') + res.sendStatus(204) +} + +const nestedChildRouter = express.Router() +nestedChildRouter.get( + '/protected', + rateLimitMiddleware({ type: 'external_member_read', max: 60 }), + nestedProtectedWork, +) +nestedChildRouter.get('/unprotected', nestedUnprotectedWork) + +const nestedGlobalRouter = express.Router() +nestedGlobalRouter.use('/nested-global', requireValidExternalSessionToken) +nestedGlobalRouter.use('/nested-global', nestedChildRouter) + +const nestedContextChildRouter = express.Router() +nestedContextChildRouter.use('/', requireValidExternalSessionToken) + +const nestedContextParentRouter = express.Router() +nestedContextParentRouter.use('/nested-context', nestedContextChildRouter) +nestedContextParentRouter.use( + '/nested-context', + rateLimitMiddleware({ type: 'external_member_read', max: 60 }), +) + +export { + completeParentRouter, + globalRouter, + incompleteParentRouter, + lateChildRouter, + lateGlobalRouter, + lateSameRouteRouter, + nestedContextParentRouter, + nestedGlobalRouter, + sameRouteRouter, + sameModuleAuthorizationRouter, + unrelatedPathRouter, +} diff --git a/.github/codeql/queries/test/MissingRateLimiting/control-api/src/routes/external/modeling.ts b/.github/codeql/queries/test/MissingRateLimiting/control-api/src/routes/external/modeling.ts new file mode 100644 index 0000000000..04bab93767 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimiting/control-api/src/routes/external/modeling.ts @@ -0,0 +1,62 @@ +import express from 'express' +import fs from 'fs' +import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware' +import { unrelatedMiddleware } from '../../middleware/unrelatedMiddleware' + +const router = express.Router() + +function protectedWork(_req: express.Request, res: express.Response) { + fs.writeFileSync('/tmp/evenfire-codeql-protected', 'value') + res.sendStatus(204) +} + +function unprotectedWork(_req: express.Request, res: express.Response) { + fs.writeFileSync('/tmp/evenfire-codeql-unprotected', 'value') + res.sendStatus(204) +} + +function orderedWrongWork(_req: express.Request, res: express.Response) { + fs.writeFileSync('/tmp/evenfire-codeql-ordered-wrong', 'value') + res.sendStatus(204) +} + +function unrelatedWork(_req: express.Request, res: express.Response) { + fs.writeFileSync('/tmp/evenfire-codeql-unrelated', 'value') + res.sendStatus(204) +} + +function specializedWork(_req: express.Request, res: express.Response) { + fs.writeFileSync('/tmp/evenfire-codeql-specialized', 'value') + res.sendStatus(204) +} + +router.post( + '/protected', + rateLimitMiddleware({ type: 'external_member_mutation', max: 10 }), + protectedWork, +) + +router.post('/unprotected', unrelatedMiddleware(), unprotectedWork) + +router.post( + '/ordered-wrong', + orderedWrongWork, + rateLimitMiddleware({ type: 'external_member_mutation', max: 10 }), +) + +router.post('/unrelated', unrelatedMiddleware(), unrelatedWork) + +router.post( + '/specialized', + rateLimitMiddleware({ type: 'external_credential_authentication', max: 5 }), + specializedWork, +) + +const assignedRateLimit = rateLimitMiddleware({ + type: 'external_member_read', + max: 60, +}) + +router.get('/assigned', assignedRateLimit, specializedWork) + +export default router diff --git a/.github/codeql/queries/test/qlpack.yml b/.github/codeql/queries/test/qlpack.yml new file mode 100644 index 0000000000..c6e77ef925 --- /dev/null +++ b/.github/codeql/queries/test/qlpack.yml @@ -0,0 +1,5 @@ +name: evenfire/codeql-query-tests +version: 0.0.1 +dependencies: + evenfire/codeql-queries: "*" +extractor: javascript diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index fd97cc5bea..1f43371f82 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -62,22 +62,56 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Initialize CodeQL + id: codeql-init uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 with: languages: javascript-typescript build-mode: none - queries: security-and-quality - # mcp-host LLM adapters must forward operator soul/system text to - # the model. CodeQL traces that from the WRC-JWT configure/execute - # body and labels it js/system-prompt-injection. That HTTP source - # is not an open prompt sink; do not concatenate untrusted input - # into tool descriptions. Keep js/missing-rate-limiting enabled. - config: | - query-filters: - - exclude: - id: js/system-prompt-injection + config-file: ./.github/codeql/codeql-config.yml + db-location: ${{ runner.temp }}/codeql-databases + + - name: Test repository CodeQL queries + env: + CODEQL: ${{ steps.codeql-init.outputs.codeql-path }} + run: | + "$CODEQL" pack install .github/codeql/queries + "$CODEQL" pack install \ + --additional-packs=.github/codeql/queries \ + .github/codeql/queries/test + .github/codeql/queries/test/verify-security-and-quality-suite.sh "$CODEQL" + "$CODEQL" test run \ + --additional-packs=.github/codeql/queries \ + .github/codeql/queries/test - name: Perform CodeQL analysis uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 with: category: '/language:javascript-typescript' + + - name: Verify production CodeQL source scope + env: + CODEQL_DATABASE_ROOT: ${{ runner.temp }}/codeql-databases + run: | + source_archive="$(find "$CODEQL_DATABASE_ROOT" -type f -name src.zip -print -quit)" + if [[ -z "$source_archive" ]]; then + echo '::error::CodeQL source archive was not found' + exit 1 + fi + + source_listing="$RUNNER_TEMP/codeql-production-sources.txt" + unzip -Z1 "$source_archive" > "$source_listing" + if grep -Fq '/.github/codeql/queries/test/' "$source_listing"; then + echo '::error::Synthetic CodeQL query fixtures entered production analysis' + exit 1 + fi + + for expected_source in \ + '/control-api/src/main.ts' \ + '/external-rest-api/src/main.ts' \ + '/desktop-app/src/main.ts' + do + if ! grep -Fq "$expected_source" "$source_listing"; then + echo "::error::Expected application source is missing: $expected_source" + exit 1 + fi + done From 525bb1d343a4f554b536c46236da3f863b0a1fa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 14 Aug 2026 20:08:11 -0600 Subject: [PATCH 103/528] fix(codeql): isolate R4-B3 query fixtures from production analysis Class-check: production CodeQL fixture sources -> 1 root/5 files -> exclude complete synthetic query-test class only Repro-test: production CodeQL emits fixture alerts at 86c84420; same analysis excludes them after fix while codeql test run still passes --- .github/codeql/codeql-config.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml index 28c3d1dd2d..d18e36556c 100644 --- a/.github/codeql/codeql-config.yml +++ b/.github/codeql/codeql-config.yml @@ -5,3 +5,8 @@ name: Evenfire CodeQL configuration disable-default-queries: true queries: - uses: ./.github/codeql/queries/evenfire-security-and-quality.qls + +# Query-test fixtures intentionally contain vulnerable examples. Keep them in +# the checkout for `codeql test run`, but out of the production JS/TS database. +paths-ignore: + - .github/codeql/queries/test/** From 2a7238b16c8c074df3732ca962a59e7cc56e9f77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 17 Aug 2026 10:45:18 -0600 Subject: [PATCH 104/528] fix(auth): preserve lifecycle authority after dev sync Keep legacy session issuance and validation bound to the live user lifecycle generation. Require active users for v2 session loading and retain the renumbered Task 106 migration proof. --- .../services/auth/externalSessionIssuance.ts | 5 +++++ .../auth/passwordSessionAuthentication.ts | 2 ++ .../src/services/auth/userSessionService.ts | 22 ++++++++++++++++--- .../db.userAccessFoundationMigration.test.ts | 2 +- .../externalSessionAuthentication.test.ts | 1 + ...es.externalInvitations.unavailable.test.ts | 3 +++ control-api/test/userSessionService.test.ts | 11 +++++++++- 7 files changed, 41 insertions(+), 5 deletions(-) diff --git a/control-api/src/services/auth/externalSessionIssuance.ts b/control-api/src/services/auth/externalSessionIssuance.ts index a28b31e937..3a2bb71b19 100644 --- a/control-api/src/services/auth/externalSessionIssuance.ts +++ b/control-api/src/services/auth/externalSessionIssuance.ts @@ -47,6 +47,7 @@ export async function issueExternalUserSession( email: string teamId: string | null role: TeamRole + authGeneration?: number authenticationMethods: string[] }, options: { @@ -73,12 +74,16 @@ export async function issueExternalUserSession( if (!policy.issueV1 || !policy.acceptV1) { throw new Error('legacy user-session issuance is not effective') } + if (!Number.isSafeInteger(input.authGeneration) || Number(input.authGeneration) < 1) { + throw new Error('legacy user-session issuance requires an active lifecycle generation') + } return { token: signExternalSessionToken({ userId: input.userId, email: input.email, teamId: input.teamId, role: input.role, + authGeneration: Number(input.authGeneration), }), contract: 'v1', } diff --git a/control-api/src/services/auth/passwordSessionAuthentication.ts b/control-api/src/services/auth/passwordSessionAuthentication.ts index cecbb848ea..f5d6bc0c08 100644 --- a/control-api/src/services/auth/passwordSessionAuthentication.ts +++ b/control-api/src/services/auth/passwordSessionAuthentication.ts @@ -19,6 +19,7 @@ type PasswordLoginSuccess = { team_name: string | null } credentialHash: string + authGeneration: number } export async function authenticatePasswordAndIssueSession(input: { @@ -50,6 +51,7 @@ export async function authenticatePasswordAndIssueSession(input: { email: authenticatedLogin.user.email, teamId: authenticatedLogin.membership.team_id, role: authenticatedLogin.membership.role, + authGeneration: authenticatedLogin.authGeneration, authenticationMethods: ['pwd'], }, { db, policy: input.policy } diff --git a/control-api/src/services/auth/userSessionService.ts b/control-api/src/services/auth/userSessionService.ts index b642ac0140..cd8f5770a5 100644 --- a/control-api/src/services/auth/userSessionService.ts +++ b/control-api/src/services/auth/userSessionService.ts @@ -123,7 +123,7 @@ async function loadSessionForUpdate(db: SessionDatabase, sid: string): Promise { const issuedAt = claims.iat if (!issuedAt) return { status: 'invalid', reason: 'invalid_legacy_representation' } + if (!Number.isSafeInteger(claims.authGeneration) || Number(claims.authGeneration) < 1) { + return { status: 'invalid', reason: 'invalid_legacy_representation' } + } const work = async (db: SessionDatabase): Promise => { const result = await db.query( - `SELECT u.id, + `SELECT u.id, u.lifecycle_state, u.lifecycle_version, epoch.valid_after, EXISTS ( SELECT 1 @@ -443,9 +446,22 @@ export async function validateLegacyUserSession( [claims.userId, legacySessionFingerprint(token)] ) const row = result.rows[0] as - | { id: string; valid_after: Date | string | null; token_revoked: boolean } + | { + id: string + lifecycle_state: string + lifecycle_version: number | string + valid_after: Date | string | null + token_revoked: boolean + } | undefined if (!row) return { status: 'invalid', reason: 'user_not_found' } + if ( + row.lifecycle_state !== 'active' || + !Number.isSafeInteger(claims.authGeneration) || + Number(claims.authGeneration) !== Number(row.lifecycle_version) + ) { + return { status: 'revoked', reason: 'security_event' } + } if (row.token_revoked) return { status: 'revoked', reason: 'logout' } if (row.valid_after && issuedAt * 1000 <= dateOf(row.valid_after).getTime()) { return { status: 'revoked', reason: 'security_event' } diff --git a/control-api/test/db.userAccessFoundationMigration.test.ts b/control-api/test/db.userAccessFoundationMigration.test.ts index b522d566f9..ac920ffaaf 100644 --- a/control-api/test/db.userAccessFoundationMigration.test.ts +++ b/control-api/test/db.userAccessFoundationMigration.test.ts @@ -10,7 +10,7 @@ vi.mock('../src/config.js', () => ({ async function migrationSql(): Promise { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0091_user_access_foundation' + candidate => candidate.version === '0100_user_access_foundation' ) expect(migration).toBeDefined() diff --git a/control-api/test/externalSessionAuthentication.test.ts b/control-api/test/externalSessionAuthentication.test.ts index be03f3e6fc..c054c8a95d 100644 --- a/control-api/test/externalSessionAuthentication.test.ts +++ b/control-api/test/externalSessionAuthentication.test.ts @@ -65,6 +65,7 @@ function v1Token(): string { email: 'user@example.com', teamId: '22222222-2222-4222-8222-222222222222', role: 'member', + authGeneration: 1, }) } diff --git a/control-api/test/routes.externalInvitations.unavailable.test.ts b/control-api/test/routes.externalInvitations.unavailable.test.ts index 57538439a6..132e4c6dea 100644 --- a/control-api/test/routes.externalInvitations.unavailable.test.ts +++ b/control-api/test/routes.externalInvitations.unavailable.test.ts @@ -118,6 +118,7 @@ describe('external invitation routes when the hub is unavailable', () => { email: 'a@b.c', teamId: null, role: 'member', + authGeneration: 1, }, }) .mockResolvedValueOnce({ error: 'not_pending' }) @@ -156,6 +157,8 @@ describe('external invitation routes when the hub is unavailable', () => { id: '00000000-0000-4000-8000-000000000200', userId: '00000000-0000-4000-8000-000000000001', passwordUpdated: true, + authGeneration: 1, + lifecycleState: 'active', }, }) diff --git a/control-api/test/userSessionService.test.ts b/control-api/test/userSessionService.test.ts index 40280daf17..72b47d3b83 100644 --- a/control-api/test/userSessionService.test.ts +++ b/control-api/test/userSessionService.test.ts @@ -180,12 +180,21 @@ describe('user session state machine', () => { email: 'user@example.com', teamId: null, role: 'member' as const, + authGeneration: 1, exp: Math.floor(now.getTime() / 1000) + 3600, iat: Math.floor(now.getTime() / 1000), } const epochDb = { query: vi.fn().mockResolvedValue({ - rows: [{ id: userId, valid_after: now, token_revoked: false }], + rows: [ + { + id: userId, + lifecycle_state: 'active', + lifecycle_version: 1, + valid_after: now, + token_revoked: false, + }, + ], rowCount: 1, }), } From fc4cb86e2756e859ed93273a5ec6b8348b3af452 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 17 Aug 2026 10:45:27 -0600 Subject: [PATCH 105/528] fix(gfs): preserve secure retry metadata after dev sync Keep transient upstream errors sanitized while forwarding only the safe Retry-After header. --- external-rest-api/package-lock.json | 4 ++-- external-rest-api/package.json | 2 +- external-rest-api/src/http/publicApiError.ts | 8 +++++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index ff88a88560..1831f85dfc 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index d8dc7498ab..a2eb923322 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/http/publicApiError.ts b/external-rest-api/src/http/publicApiError.ts index babe2f78db..c136179900 100644 --- a/external-rest-api/src/http/publicApiError.ts +++ b/external-rest-api/src/http/publicApiError.ts @@ -179,10 +179,16 @@ export function sanitizeControlApiPublicError( ...(retryAfterSeconds ? { retryAfterSeconds } : {}), } : undefined + const safeHeaders = safePublicRateLimitHeaders(responseHeaders) return { status: error.status, - headers: code === 'rate_limited' ? safePublicRateLimitHeaders(responseHeaders) : {}, + headers: + code === 'rate_limited' + ? safeHeaders + : retryable && safeHeaders['retry-after'] + ? { 'retry-after': safeHeaders['retry-after'] } + : {}, body: { error: { code, From 32909680e62d39fd6ad821dd15922edc489deb9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 17 Aug 2026 13:17:07 -0600 Subject: [PATCH 106/528] fix(codeql): model fail-closed auth guards Class-check: user-controlled sensitive-auth guards -> 4 PR-visible flows -> model proven fail-closed class only Repro-test: stock/current analyzer flags four safe auth guards at 4d028a464bf82931cff25c2e271ad37f50264ed1; repository-aware model clears them while genuine bypass controls still report --- .../queries/EvenfireUserControlledBypass.ql | 246 ++++++++++++++++++ .../queries/evenfire-security-and-quality.qls | 3 + .../EvenfireUserControlledBypass.expected | 147 +++++++++++ .../EvenfireUserControlledBypass.qlref | 1 + .../control-api/src/http/publicApiError.ts | 7 + .../control-api/src/routes/external/auth.ts | 162 ++++++++++++ 6 files changed, 566 insertions(+) create mode 100644 .github/codeql/queries/EvenfireUserControlledBypass.ql create mode 100644 .github/codeql/queries/test/UserControlledBypass/EvenfireUserControlledBypass.expected create mode 100644 .github/codeql/queries/test/UserControlledBypass/EvenfireUserControlledBypass.qlref create mode 100644 .github/codeql/queries/test/UserControlledBypass/control-api/src/http/publicApiError.ts create mode 100644 .github/codeql/queries/test/UserControlledBypass/control-api/src/routes/external/auth.ts diff --git a/.github/codeql/queries/EvenfireUserControlledBypass.ql b/.github/codeql/queries/EvenfireUserControlledBypass.ql new file mode 100644 index 0000000000..fdf5f875e9 --- /dev/null +++ b/.github/codeql/queries/EvenfireUserControlledBypass.ql @@ -0,0 +1,246 @@ +/** + * @name User-controlled bypass of security check + * @description Conditions that the user controls are not suited for making security-related decisions. + * @kind path-problem + * @problem.severity error + * @security-severity 7.8 + * @precision medium + * @id js/user-controlled-bypass + * @tags security + * external/cwe/cwe-807 + * external/cwe/cwe-290 + */ + +import javascript +import semmle.javascript.security.dataflow.ConditionalBypassQuery +import ConditionalBypassFlow::PathGraph + +private predicate isLiteral4xx(Expr status) { + exists(int value | value = status.getIntValue() and value >= 400 and value <= 499) +} + +private predicate isLiteral2xx(Expr status) { + exists(int value | value = status.getIntValue() and value >= 200 and value <= 299) +} + +private predicate isDirectFixed4xxResponse(ReturnStmt ret) { + exists(MethodCallExpr statusCall, MethodCallExpr jsonCall | + statusCall.getMethodName() = "status" and + isLiteral4xx(statusCall.getArgument(0)) and + jsonCall.getMethodName() = "json" and + statusCall.getParentExpr*() = jsonCall and + jsonCall.getParentExpr*() = ret.getExpr() and + // The branch cannot call a producer of protected data before responding. + not exists(CallExpr nested | + nested.getParentExpr*() = ret.getExpr() and + nested != statusCall and + nested != jsonCall + ) + ) +} + +private predicate isDirectFixed4xxResponseCall(CallExpr call) { + exists(MethodCallExpr statusCall, MethodCallExpr jsonCall | + statusCall.getMethodName() = "status" and + isLiteral4xx(statusCall.getArgument(0)) and + jsonCall.getMethodName() = "json" and + statusCall.getParentExpr*() = jsonCall and + jsonCall.getParentExpr*() = call and + not exists(CallExpr nested | + nested.getParentExpr*() = call and + nested != statusCall and + nested != jsonCall + ) + ) +} + +private predicate isCanonicalPublicErrorImport(ImportSpecifier spec) { + spec.getImportedName() = "sendPublicApiError" and + spec.getImportDeclaration().getImportedFile().getRelativePath() = + "control-api/src/http/publicApiError.ts" +} + +private predicate isFixedPublicErrorCall(CallExpr call) { + exists(ImportSpecifier spec | + isCanonicalPublicErrorImport(spec) and + DataFlow::valueNode(spec).(DataFlow::SourceNode).flowsTo(DataFlow::valueNode(call.getCallee())) + ) and + isLiteral4xx(call.getArgument(2)) and + call.getArgument(3) instanceof StringLiteral and + call.getArgument(4) instanceof StringLiteral and + not exists(call.getArgument(5)) +} + +private predicate isFixedFailureBranch(Stmt branch) { + exists(ReturnStmt ret | branch = ret and isDirectFixed4xxResponse(ret)) + or + exists(BlockStmt block, ReturnStmt ret | + branch = block and + block.getNumStmt() = 1 and + ret = block.getStmt(0) and + isDirectFixed4xxResponse(ret) + ) + or + exists(BlockStmt block, ExprStmt response, ReturnStmt ret | + branch = block and + block.getNumStmt() = 2 and + response = block.getStmt(0) and + isDirectFixed4xxResponseCall(response.getExpr().(CallExpr)) and + ret = block.getStmt(1) and + not exists(ret.getExpr()) + ) + or + exists(BlockStmt block, ExprStmt response, ReturnStmt ret | + branch = block and + block.getNumStmt() = 2 and + response = block.getStmt(0) and + isFixedPublicErrorCall(response.getExpr().(CallExpr)) and + ret = block.getStmt(1) and + not exists(ret.getExpr()) + ) +} + +private predicate isDirect2xxResponse(ReturnStmt ret) { + exists(MethodCallExpr statusCall | + statusCall.getMethodName() = "status" and + isLiteral2xx(statusCall.getArgument(0)) and + statusCall.getParentExpr*() = ret.getExpr() + ) +} + +/** Stock early-abort modeling must not hide a branch that proves 2xx success. */ +private predicate isEvenfireSuccessEarlyAbortGuardNode( + ConditionalBypassFlow::PathNode e, SensitiveAction action +) { + exists(IfStmt guard, ReturnStmt ret | + e.getNode().(Sink).asExpr().getParentExpr*() = guard.getCondition() and + not exists(guard.getElse()) and + not action.asExpr().getEnclosingStmt().nestedIn(guard) and + ( + guard.getThen() = ret + or + exists(BlockStmt block | guard.getThen() = block and ret = block.getStmt(_)) + ) and + isDirect2xxResponse(ret) + ) +} + +private predicate isAuthenticationStateMutation(CallExpr call) { + exists(string name | + name = call.getCalleeName() and + name.regexpMatch("(?i).*(set|mark|establish).*(auth|authorized|session|principal).*") + ) +} + +/** A branch that mutates authentication state is never a fail-closed guard. */ +private predicate isEvenfireStateMutationEarlyAbortGuardNode( + ConditionalBypassFlow::PathNode e, SensitiveAction action +) { + exists(IfStmt guard, BlockStmt block, ExprStmt mutation, ReturnStmt ret | + e.getNode().(Sink).asExpr().getParentExpr*() = guard.getCondition() and + not exists(guard.getElse()) and + not action.asExpr().getEnclosingStmt().nestedIn(guard) and + guard.getThen() = block and + mutation = block.getStmt(_) and + isAuthenticationStateMutation(mutation.getExpr().(CallExpr)) and + ret = block.getStmt(_) and + isDirectFixed4xxResponse(ret) + ) +} + +/** A response that calls another function may expose protected data. */ +private predicate isEvenfirePayloadEarlyAbortGuardNode( + ConditionalBypassFlow::PathNode e, SensitiveAction action +) { + exists(IfStmt guard, ReturnStmt ret, MethodCallExpr statusCall, MethodCallExpr jsonCall | + e.getNode().(Sink).asExpr().getParentExpr*() = guard.getCondition() and + not exists(guard.getElse()) and + not action.asExpr().getEnclosingStmt().nestedIn(guard) and + guard.getThen() = ret and + statusCall.getMethodName() = "status" and + isLiteral4xx(statusCall.getArgument(0)) and + jsonCall.getMethodName() = "json" and + statusCall.getParentExpr*() = jsonCall and + jsonCall.getParentExpr*() = ret.getExpr() and + exists(CallExpr nested | + nested.getParentExpr*() = ret.getExpr() and + nested != statusCall and + nested != jsonCall + ) + ) + or + exists( + IfStmt guard, BlockStmt block, ExprStmt response, ReturnStmt ret, MethodCallExpr statusCall, + MethodCallExpr jsonCall + | + e.getNode().(Sink).asExpr().getParentExpr*() = guard.getCondition() and + not exists(guard.getElse()) and + not action.asExpr().getEnclosingStmt().nestedIn(guard) and + guard.getThen() = block and + response = block.getStmt(0) and + ret = block.getStmt(1) and + not exists(ret.getExpr()) and + statusCall.getMethodName() = "status" and + isLiteral4xx(statusCall.getArgument(0)) and + jsonCall.getMethodName() = "json" and + statusCall.getParentExpr*() = jsonCall and + jsonCall.getParentExpr*() = response.getExpr() and + exists(CallExpr nested | + nested.getParentExpr*() = response.getExpr() and + nested != statusCall and + nested != jsonCall + ) + ) + or + exists(IfStmt guard, BlockStmt block, ExprStmt response, ReturnStmt ret, CallExpr helper | + e.getNode().(Sink).asExpr().getParentExpr*() = guard.getCondition() and + not exists(guard.getElse()) and + not action.asExpr().getEnclosingStmt().nestedIn(guard) and + guard.getThen() = block and + response = block.getStmt(0) and + helper = response.getExpr().(CallExpr) and + exists(ImportSpecifier spec | + isCanonicalPublicErrorImport(spec) and + DataFlow::valueNode(spec) + .(DataFlow::SourceNode) + .flowsTo(DataFlow::valueNode(helper.getCallee())) + ) and + isLiteral4xx(helper.getArgument(2)) and + exists(helper.getArgument(5)) and + ret = block.getStmt(1) and + not exists(ret.getExpr()) + ) +} + +/** + * Extends the stock early-abort treatment only for a structurally proven fixed + * 4xx response. The sensitive action remains outside the terminating branch. + */ +private predicate isEvenfireFailClosedGuardNode( + ConditionalBypassFlow::PathNode e, SensitiveAction action +) { + exists(IfStmt guard | + e.getNode().(Sink).asExpr().getParentExpr*() = guard.getCondition() and + not exists(guard.getElse()) and + isFixedFailureBranch(guard.getThen()) and + not action.asExpr().getEnclosingStmt().nestedIn(guard) + ) +} + +from + ConditionalBypassFlow::PathNode source, ConditionalBypassFlow::PathNode sink, + SensitiveAction action +where + isTaintedGuardNodeForSensitiveAction(sink, source, action) and + ( + not isEarlyAbortGuardNode(sink, action) and + not isEvenfireFailClosedGuardNode(sink, action) + or + isEvenfireSuccessEarlyAbortGuardNode(sink, action) + or + isEvenfireStateMutationEarlyAbortGuardNode(sink, action) + or + isEvenfirePayloadEarlyAbortGuardNode(sink, action) + ) +select sink.getNode(), source, sink, "This condition guards a sensitive $@, but a $@ controls it.", + action, "action", source.getNode(), "user-provided value" diff --git a/.github/codeql/queries/evenfire-security-and-quality.qls b/.github/codeql/queries/evenfire-security-and-quality.qls index 42cf3f7d8d..30a494efb9 100644 --- a/.github/codeql/queries/evenfire-security-and-quality.qls +++ b/.github/codeql/queries/evenfire-security-and-quality.qls @@ -3,3 +3,6 @@ - exclude: id: js/missing-rate-limiting - query: EvenfireMissingRateLimiting.ql +- exclude: + id: js/user-controlled-bypass +- query: EvenfireUserControlledBypass.ql diff --git a/.github/codeql/queries/test/UserControlledBypass/EvenfireUserControlledBypass.expected b/.github/codeql/queries/test/UserControlledBypass/EvenfireUserControlledBypass.expected new file mode 100644 index 0000000000..6404522524 --- /dev/null +++ b/.github/codeql/queries/test/UserControlledBypass/EvenfireUserControlledBypass.expected @@ -0,0 +1,147 @@ +edges +| control-api/src/routes/external/auth.ts:18:9:18:13 | email | control-api/src/routes/external/auth.ts:20:8:20:12 | email | provenance | | +| control-api/src/routes/external/auth.ts:18:17:18:45 | String( ... \|\| '') | control-api/src/routes/external/auth.ts:18:17:18:52 | String( ... .trim() | provenance | | +| control-api/src/routes/external/auth.ts:18:17:18:52 | String( ... .trim() | control-api/src/routes/external/auth.ts:18:9:18:13 | email | provenance | | +| control-api/src/routes/external/auth.ts:18:24:18:31 | req.body | control-api/src/routes/external/auth.ts:18:17:18:45 | String( ... \|\| '') | provenance | | +| control-api/src/routes/external/auth.ts:19:9:19:16 | password | control-api/src/routes/external/auth.ts:20:18:20:25 | password | provenance | | +| control-api/src/routes/external/auth.ts:19:20:19:51 | String( ... \|\| '') | control-api/src/routes/external/auth.ts:19:9:19:16 | password | provenance | | +| control-api/src/routes/external/auth.ts:19:27:19:34 | req.body | control-api/src/routes/external/auth.ts:19:20:19:51 | String( ... \|\| '') | provenance | | +| control-api/src/routes/external/auth.ts:26:9:26:13 | token | control-api/src/routes/external/auth.ts:27:8:27:12 | token | provenance | | +| control-api/src/routes/external/auth.ts:26:9:26:13 | token | control-api/src/routes/external/auth.ts:27:17:27:21 | token | provenance | | +| control-api/src/routes/external/auth.ts:26:17:26:45 | String( ... \|\| '') | control-api/src/routes/external/auth.ts:26:17:26:52 | String( ... .trim() | provenance | | +| control-api/src/routes/external/auth.ts:26:17:26:52 | String( ... .trim() | control-api/src/routes/external/auth.ts:26:9:26:13 | token | provenance | | +| control-api/src/routes/external/auth.ts:26:24:26:31 | req.body | control-api/src/routes/external/auth.ts:26:17:26:45 | String( ... \|\| '') | provenance | | +| control-api/src/routes/external/auth.ts:27:17:27:21 | token | control-api/src/routes/external/auth.ts:27:17:27:28 | token.length | provenance | | +| control-api/src/routes/external/auth.ts:27:17:27:21 | token | control-api/src/routes/external/auth.ts:27:17:27:28 | token.length | provenance | | +| control-api/src/routes/external/auth.ts:27:17:27:28 | token.length | control-api/src/routes/external/auth.ts:27:17:27:35 | token.length > 4096 | provenance | Config | +| control-api/src/routes/external/auth.ts:36:9:36:13 | token | control-api/src/routes/external/auth.ts:37:8:37:12 | token | provenance | | +| control-api/src/routes/external/auth.ts:36:17:36:45 | String( ... \|\| '') | control-api/src/routes/external/auth.ts:36:9:36:13 | token | provenance | | +| control-api/src/routes/external/auth.ts:36:24:36:31 | req.body | control-api/src/routes/external/auth.ts:36:17:36:45 | String( ... \|\| '') | provenance | | +| control-api/src/routes/external/auth.ts:50:11:50:15 | email | control-api/src/routes/external/auth.ts:52:10:52:14 | email | provenance | | +| control-api/src/routes/external/auth.ts:50:19:50:47 | String( ... \|\| '') | control-api/src/routes/external/auth.ts:50:19:50:54 | String( ... .trim() | provenance | | +| control-api/src/routes/external/auth.ts:50:19:50:54 | String( ... .trim() | control-api/src/routes/external/auth.ts:50:11:50:15 | email | provenance | | +| control-api/src/routes/external/auth.ts:50:26:50:33 | req.body | control-api/src/routes/external/auth.ts:50:19:50:47 | String( ... \|\| '') | provenance | | +| control-api/src/routes/external/auth.ts:51:11:51:18 | password | control-api/src/routes/external/auth.ts:52:20:52:27 | password | provenance | | +| control-api/src/routes/external/auth.ts:51:22:51:53 | String( ... \|\| '') | control-api/src/routes/external/auth.ts:51:11:51:18 | password | provenance | | +| control-api/src/routes/external/auth.ts:51:29:51:36 | req.body | control-api/src/routes/external/auth.ts:51:22:51:53 | String( ... \|\| '') | provenance | | +| control-api/src/routes/external/auth.ts:64:11:64:15 | token | control-api/src/routes/external/auth.ts:65:10:65:14 | token | provenance | | +| control-api/src/routes/external/auth.ts:64:11:64:15 | token | control-api/src/routes/external/auth.ts:65:19:65:23 | token | provenance | | +| control-api/src/routes/external/auth.ts:64:19:64:47 | String( ... \|\| '') | control-api/src/routes/external/auth.ts:64:19:64:54 | String( ... .trim() | provenance | | +| control-api/src/routes/external/auth.ts:64:19:64:54 | String( ... .trim() | control-api/src/routes/external/auth.ts:64:11:64:15 | token | provenance | | +| control-api/src/routes/external/auth.ts:64:26:64:33 | req.body | control-api/src/routes/external/auth.ts:64:19:64:47 | String( ... \|\| '') | provenance | | +| control-api/src/routes/external/auth.ts:65:19:65:23 | token | control-api/src/routes/external/auth.ts:65:19:65:30 | token.length | provenance | | +| control-api/src/routes/external/auth.ts:65:19:65:23 | token | control-api/src/routes/external/auth.ts:65:19:65:30 | token.length | provenance | | +| control-api/src/routes/external/auth.ts:65:19:65:30 | token.length | control-api/src/routes/external/auth.ts:65:19:65:37 | token.length > 4096 | provenance | Config | +| control-api/src/routes/external/auth.ts:77:9:77:13 | token | control-api/src/routes/external/auth.ts:78:8:78:12 | token | provenance | | +| control-api/src/routes/external/auth.ts:77:17:77:45 | String( ... \|\| '') | control-api/src/routes/external/auth.ts:77:9:77:13 | token | provenance | | +| control-api/src/routes/external/auth.ts:77:24:77:31 | req.body | control-api/src/routes/external/auth.ts:77:17:77:45 | String( ... \|\| '') | provenance | | +| control-api/src/routes/external/auth.ts:84:9:84:16 | password | control-api/src/routes/external/auth.ts:85:8:85:15 | password | provenance | | +| control-api/src/routes/external/auth.ts:84:20:84:51 | String( ... \|\| '') | control-api/src/routes/external/auth.ts:84:9:84:16 | password | provenance | | +| control-api/src/routes/external/auth.ts:84:27:84:34 | req.body | control-api/src/routes/external/auth.ts:84:20:84:51 | String( ... \|\| '') | provenance | | +| control-api/src/routes/external/auth.ts:91:9:91:13 | token | control-api/src/routes/external/auth.ts:92:7:92:11 | token | provenance | | +| control-api/src/routes/external/auth.ts:91:17:91:45 | String( ... \|\| '') | control-api/src/routes/external/auth.ts:91:9:91:13 | token | provenance | | +| control-api/src/routes/external/auth.ts:91:24:91:31 | req.body | control-api/src/routes/external/auth.ts:91:17:91:45 | String( ... \|\| '') | provenance | | +| control-api/src/routes/external/auth.ts:101:9:101:13 | token | control-api/src/routes/external/auth.ts:102:8:102:12 | token | provenance | | +| control-api/src/routes/external/auth.ts:101:17:101:45 | String( ... \|\| '') | control-api/src/routes/external/auth.ts:101:9:101:13 | token | provenance | | +| control-api/src/routes/external/auth.ts:101:24:101:31 | req.body | control-api/src/routes/external/auth.ts:101:17:101:45 | String( ... \|\| '') | provenance | | +| control-api/src/routes/external/auth.ts:111:9:111:13 | token | control-api/src/routes/external/auth.ts:112:8:112:12 | token | provenance | | +| control-api/src/routes/external/auth.ts:111:17:111:45 | String( ... \|\| '') | control-api/src/routes/external/auth.ts:111:9:111:13 | token | provenance | | +| control-api/src/routes/external/auth.ts:111:24:111:31 | req.body | control-api/src/routes/external/auth.ts:111:17:111:45 | String( ... \|\| '') | provenance | | +| control-api/src/routes/external/auth.ts:128:9:128:13 | token | control-api/src/routes/external/auth.ts:130:8:130:12 | token | provenance | | +| control-api/src/routes/external/auth.ts:128:17:128:45 | String( ... \|\| '') | control-api/src/routes/external/auth.ts:128:9:128:13 | token | provenance | | +| control-api/src/routes/external/auth.ts:128:24:128:31 | req.body | control-api/src/routes/external/auth.ts:128:17:128:45 | String( ... \|\| '') | provenance | | +| control-api/src/routes/external/auth.ts:136:9:136:13 | token | control-api/src/routes/external/auth.ts:137:8:137:12 | token | provenance | | +| control-api/src/routes/external/auth.ts:136:17:136:45 | String( ... \|\| '') | control-api/src/routes/external/auth.ts:136:9:136:13 | token | provenance | | +| control-api/src/routes/external/auth.ts:136:24:136:31 | req.body | control-api/src/routes/external/auth.ts:136:17:136:45 | String( ... \|\| '') | provenance | | +| control-api/src/routes/external/auth.ts:146:9:146:13 | token | control-api/src/routes/external/auth.ts:147:8:147:12 | token | provenance | | +| control-api/src/routes/external/auth.ts:146:17:146:45 | String( ... \|\| '') | control-api/src/routes/external/auth.ts:146:9:146:13 | token | provenance | | +| control-api/src/routes/external/auth.ts:146:24:146:31 | req.body | control-api/src/routes/external/auth.ts:146:17:146:45 | String( ... \|\| '') | provenance | | +| control-api/src/routes/external/auth.ts:153:9:153:13 | token | control-api/src/routes/external/auth.ts:154:8:154:12 | token | provenance | | +| control-api/src/routes/external/auth.ts:153:17:153:45 | String( ... \|\| '') | control-api/src/routes/external/auth.ts:153:9:153:13 | token | provenance | | +| control-api/src/routes/external/auth.ts:153:24:153:31 | req.body | control-api/src/routes/external/auth.ts:153:17:153:45 | String( ... \|\| '') | provenance | | +nodes +| control-api/src/routes/external/auth.ts:18:9:18:13 | email | semmle.label | email | +| control-api/src/routes/external/auth.ts:18:17:18:45 | String( ... \|\| '') | semmle.label | String( ... \|\| '') | +| control-api/src/routes/external/auth.ts:18:17:18:52 | String( ... .trim() | semmle.label | String( ... .trim() | +| control-api/src/routes/external/auth.ts:18:24:18:31 | req.body | semmle.label | req.body | +| control-api/src/routes/external/auth.ts:19:9:19:16 | password | semmle.label | password | +| control-api/src/routes/external/auth.ts:19:20:19:51 | String( ... \|\| '') | semmle.label | String( ... \|\| '') | +| control-api/src/routes/external/auth.ts:19:27:19:34 | req.body | semmle.label | req.body | +| control-api/src/routes/external/auth.ts:20:8:20:12 | email | semmle.label | email | +| control-api/src/routes/external/auth.ts:20:18:20:25 | password | semmle.label | password | +| control-api/src/routes/external/auth.ts:26:9:26:13 | token | semmle.label | token | +| control-api/src/routes/external/auth.ts:26:17:26:45 | String( ... \|\| '') | semmle.label | String( ... \|\| '') | +| control-api/src/routes/external/auth.ts:26:17:26:52 | String( ... .trim() | semmle.label | String( ... .trim() | +| control-api/src/routes/external/auth.ts:26:24:26:31 | req.body | semmle.label | req.body | +| control-api/src/routes/external/auth.ts:27:8:27:12 | token | semmle.label | token | +| control-api/src/routes/external/auth.ts:27:17:27:21 | token | semmle.label | token | +| control-api/src/routes/external/auth.ts:27:17:27:28 | token.length | semmle.label | token.length | +| control-api/src/routes/external/auth.ts:27:17:27:28 | token.length | semmle.label | token.length | +| control-api/src/routes/external/auth.ts:27:17:27:35 | token.length > 4096 | semmle.label | token.length > 4096 | +| control-api/src/routes/external/auth.ts:36:9:36:13 | token | semmle.label | token | +| control-api/src/routes/external/auth.ts:36:17:36:45 | String( ... \|\| '') | semmle.label | String( ... \|\| '') | +| control-api/src/routes/external/auth.ts:36:24:36:31 | req.body | semmle.label | req.body | +| control-api/src/routes/external/auth.ts:37:8:37:12 | token | semmle.label | token | +| control-api/src/routes/external/auth.ts:50:11:50:15 | email | semmle.label | email | +| control-api/src/routes/external/auth.ts:50:19:50:47 | String( ... \|\| '') | semmle.label | String( ... \|\| '') | +| control-api/src/routes/external/auth.ts:50:19:50:54 | String( ... .trim() | semmle.label | String( ... .trim() | +| control-api/src/routes/external/auth.ts:50:26:50:33 | req.body | semmle.label | req.body | +| control-api/src/routes/external/auth.ts:51:11:51:18 | password | semmle.label | password | +| control-api/src/routes/external/auth.ts:51:22:51:53 | String( ... \|\| '') | semmle.label | String( ... \|\| '') | +| control-api/src/routes/external/auth.ts:51:29:51:36 | req.body | semmle.label | req.body | +| control-api/src/routes/external/auth.ts:52:10:52:14 | email | semmle.label | email | +| control-api/src/routes/external/auth.ts:52:20:52:27 | password | semmle.label | password | +| control-api/src/routes/external/auth.ts:64:11:64:15 | token | semmle.label | token | +| control-api/src/routes/external/auth.ts:64:19:64:47 | String( ... \|\| '') | semmle.label | String( ... \|\| '') | +| control-api/src/routes/external/auth.ts:64:19:64:54 | String( ... .trim() | semmle.label | String( ... .trim() | +| control-api/src/routes/external/auth.ts:64:26:64:33 | req.body | semmle.label | req.body | +| control-api/src/routes/external/auth.ts:65:10:65:14 | token | semmle.label | token | +| control-api/src/routes/external/auth.ts:65:19:65:23 | token | semmle.label | token | +| control-api/src/routes/external/auth.ts:65:19:65:30 | token.length | semmle.label | token.length | +| control-api/src/routes/external/auth.ts:65:19:65:30 | token.length | semmle.label | token.length | +| control-api/src/routes/external/auth.ts:65:19:65:37 | token.length > 4096 | semmle.label | token.length > 4096 | +| control-api/src/routes/external/auth.ts:77:9:77:13 | token | semmle.label | token | +| control-api/src/routes/external/auth.ts:77:17:77:45 | String( ... \|\| '') | semmle.label | String( ... \|\| '') | +| control-api/src/routes/external/auth.ts:77:24:77:31 | req.body | semmle.label | req.body | +| control-api/src/routes/external/auth.ts:78:8:78:12 | token | semmle.label | token | +| control-api/src/routes/external/auth.ts:84:9:84:16 | password | semmle.label | password | +| control-api/src/routes/external/auth.ts:84:20:84:51 | String( ... \|\| '') | semmle.label | String( ... \|\| '') | +| control-api/src/routes/external/auth.ts:84:27:84:34 | req.body | semmle.label | req.body | +| control-api/src/routes/external/auth.ts:85:8:85:15 | password | semmle.label | password | +| control-api/src/routes/external/auth.ts:91:9:91:13 | token | semmle.label | token | +| control-api/src/routes/external/auth.ts:91:17:91:45 | String( ... \|\| '') | semmle.label | String( ... \|\| '') | +| control-api/src/routes/external/auth.ts:91:24:91:31 | req.body | semmle.label | req.body | +| control-api/src/routes/external/auth.ts:92:7:92:11 | token | semmle.label | token | +| control-api/src/routes/external/auth.ts:101:9:101:13 | token | semmle.label | token | +| control-api/src/routes/external/auth.ts:101:17:101:45 | String( ... \|\| '') | semmle.label | String( ... \|\| '') | +| control-api/src/routes/external/auth.ts:101:24:101:31 | req.body | semmle.label | req.body | +| control-api/src/routes/external/auth.ts:102:8:102:12 | token | semmle.label | token | +| control-api/src/routes/external/auth.ts:111:9:111:13 | token | semmle.label | token | +| control-api/src/routes/external/auth.ts:111:17:111:45 | String( ... \|\| '') | semmle.label | String( ... \|\| '') | +| control-api/src/routes/external/auth.ts:111:24:111:31 | req.body | semmle.label | req.body | +| control-api/src/routes/external/auth.ts:112:8:112:12 | token | semmle.label | token | +| control-api/src/routes/external/auth.ts:128:9:128:13 | token | semmle.label | token | +| control-api/src/routes/external/auth.ts:128:17:128:45 | String( ... \|\| '') | semmle.label | String( ... \|\| '') | +| control-api/src/routes/external/auth.ts:128:24:128:31 | req.body | semmle.label | req.body | +| control-api/src/routes/external/auth.ts:130:8:130:12 | token | semmle.label | token | +| control-api/src/routes/external/auth.ts:136:9:136:13 | token | semmle.label | token | +| control-api/src/routes/external/auth.ts:136:17:136:45 | String( ... \|\| '') | semmle.label | String( ... \|\| '') | +| control-api/src/routes/external/auth.ts:136:24:136:31 | req.body | semmle.label | req.body | +| control-api/src/routes/external/auth.ts:137:8:137:12 | token | semmle.label | token | +| control-api/src/routes/external/auth.ts:146:9:146:13 | token | semmle.label | token | +| control-api/src/routes/external/auth.ts:146:17:146:45 | String( ... \|\| '') | semmle.label | String( ... \|\| '') | +| control-api/src/routes/external/auth.ts:146:24:146:31 | req.body | semmle.label | req.body | +| control-api/src/routes/external/auth.ts:147:8:147:12 | token | semmle.label | token | +| control-api/src/routes/external/auth.ts:153:9:153:13 | token | semmle.label | token | +| control-api/src/routes/external/auth.ts:153:17:153:45 | String( ... \|\| '') | semmle.label | String( ... \|\| '') | +| control-api/src/routes/external/auth.ts:153:24:153:31 | req.body | semmle.label | req.body | +| control-api/src/routes/external/auth.ts:154:8:154:12 | token | semmle.label | token | +subpaths +#select +| control-api/src/routes/external/auth.ts:78:8:78:12 | token | control-api/src/routes/external/auth.ts:77:24:77:31 | req.body | control-api/src/routes/external/auth.ts:78:8:78:12 | token | This condition guards a sensitive $@, but a $@ controls it. | control-api/src/routes/external/auth.ts:79:3:79:40 | authent ... (token) | action | control-api/src/routes/external/auth.ts:77:24:77:31 | req.body | user-provided value | +| control-api/src/routes/external/auth.ts:85:8:85:15 | password | control-api/src/routes/external/auth.ts:84:27:84:34 | req.body | control-api/src/routes/external/auth.ts:85:8:85:15 | password | This condition guards a sensitive $@, but a $@ controls it. | control-api/src/routes/external/auth.ts:86:3:86:68 | authent ... ssword) | action | control-api/src/routes/external/auth.ts:84:27:84:34 | req.body | user-provided value | +| control-api/src/routes/external/auth.ts:92:7:92:11 | token | control-api/src/routes/external/auth.ts:91:24:91:31 | req.body | control-api/src/routes/external/auth.ts:92:7:92:11 | token | This condition guards a sensitive $@, but a $@ controls it. | control-api/src/routes/external/auth.ts:93:5:93:42 | authent ... (token) | action | control-api/src/routes/external/auth.ts:91:24:91:31 | req.body | user-provided value | +| control-api/src/routes/external/auth.ts:102:8:102:12 | token | control-api/src/routes/external/auth.ts:101:24:101:31 | req.body | control-api/src/routes/external/auth.ts:102:8:102:12 | token | This condition guards a sensitive $@, but a $@ controls it. | control-api/src/routes/external/auth.ts:106:3:106:40 | authent ... (token) | action | control-api/src/routes/external/auth.ts:101:24:101:31 | req.body | user-provided value | +| control-api/src/routes/external/auth.ts:112:8:112:12 | token | control-api/src/routes/external/auth.ts:111:24:111:31 | req.body | control-api/src/routes/external/auth.ts:112:8:112:12 | token | This condition guards a sensitive $@, but a $@ controls it. | control-api/src/routes/external/auth.ts:115:3:115:40 | authent ... (token) | action | control-api/src/routes/external/auth.ts:111:24:111:31 | req.body | user-provided value | +| control-api/src/routes/external/auth.ts:130:8:130:12 | token | control-api/src/routes/external/auth.ts:128:24:128:31 | req.body | control-api/src/routes/external/auth.ts:130:8:130:12 | token | This condition guards a sensitive $@, but a $@ controls it. | control-api/src/routes/external/auth.ts:131:3:131:40 | authent ... (token) | action | control-api/src/routes/external/auth.ts:128:24:128:31 | req.body | user-provided value | +| control-api/src/routes/external/auth.ts:147:8:147:12 | token | control-api/src/routes/external/auth.ts:146:24:146:31 | req.body | control-api/src/routes/external/auth.ts:147:8:147:12 | token | This condition guards a sensitive $@, but a $@ controls it. | control-api/src/routes/external/auth.ts:148:3:148:40 | authent ... (token) | action | control-api/src/routes/external/auth.ts:146:24:146:31 | req.body | user-provided value | diff --git a/.github/codeql/queries/test/UserControlledBypass/EvenfireUserControlledBypass.qlref b/.github/codeql/queries/test/UserControlledBypass/EvenfireUserControlledBypass.qlref new file mode 100644 index 0000000000..6417d0f8de --- /dev/null +++ b/.github/codeql/queries/test/UserControlledBypass/EvenfireUserControlledBypass.qlref @@ -0,0 +1 @@ +query: EvenfireUserControlledBypass.ql diff --git a/.github/codeql/queries/test/UserControlledBypass/control-api/src/http/publicApiError.ts b/.github/codeql/queries/test/UserControlledBypass/control-api/src/http/publicApiError.ts new file mode 100644 index 0000000000..3dd08d53f5 --- /dev/null +++ b/.github/codeql/queries/test/UserControlledBypass/control-api/src/http/publicApiError.ts @@ -0,0 +1,7 @@ +export function sendPublicApiError( + _req: unknown, + _res: unknown, + _status: number, + _code: string, + _message: string, +) {} diff --git a/.github/codeql/queries/test/UserControlledBypass/control-api/src/routes/external/auth.ts b/.github/codeql/queries/test/UserControlledBypass/control-api/src/routes/external/auth.ts new file mode 100644 index 0000000000..c624039a92 --- /dev/null +++ b/.github/codeql/queries/test/UserControlledBypass/control-api/src/routes/external/auth.ts @@ -0,0 +1,162 @@ +import express from 'express' +import { sendPublicApiError } from '../../http/publicApiError' + +declare function authenticatePasswordAndIssueSession(email: string, password: string): void +declare function authenticateExternalUserSession(token: string): void +declare function issueSession(): string +declare function readProtectedData(): string +declare function setAuthenticated(): void +declare function mayReturnSuccess( + req: express.Request, + res: express.Response, + status: number, +): void + +const router = express.Router() + +router.post('/safe-password', (req, res) => { + const email = String(req.body?.email || '').trim() + const password = String(req.body?.password || '') + if (!email || !password) return res.status(400).json({ error: 'invalid_credentials' }) + authenticatePasswordAndIssueSession(email, password) + return res.sendStatus(204) +}) + +router.post('/safe-session', (req, res) => { + const token = String(req.body?.token || '').trim() + if (!token || token.length > 4096) { + sendPublicApiError(req, res, 401, 'invalid_session', 'Invalid session.') + return + } + authenticateExternalUserSession(token) + return res.sendStatus(204) +}) + +router.post('/safe-block-response', (req, res) => { + const token = String(req.body?.token || '') + if (!token) { + res.status(401).json({ error: 'invalid_session' }) + return + } + authenticateExternalUserSession(token) + return res.sendStatus(204) +}) + +// Mirrors the production handler shape: stock early-abort recognition loses +// this path through the surrounding try/catch, so the repository model must +// prove the fixed failure response itself. +router.post('/safe-braced-password-in-try', (req, res) => { + try { + const email = String(req.body?.email || '').trim() + const password = String(req.body?.password || '') + if (!email || !password) { + return res.status(400).json({ error: 'invalid_credentials' }) + } + authenticatePasswordAndIssueSession(email, password) + return res.sendStatus(204) + } catch { + return res.status(503).json({ error: 'unavailable' }) + } +}) + +router.post('/safe-helper-token-in-try', (req, res) => { + try { + const token = String(req.body?.token || '').trim() + if (!token || token.length > 4096) { + sendPublicApiError(req, res, 401, 'invalid_session', 'Invalid session.') + return + } + authenticateExternalUserSession(token) + return res.sendStatus(204) + } catch { + return res.status(503).json({ error: 'unavailable' }) + } +}) + +router.post('/bypass-protected-data', (req, res) => { + const token = String(req.body?.token || '') + if (!token) return res.status(200).json({ data: readProtectedData() }) + authenticateExternalUserSession(token) + return res.sendStatus(204) +}) + +router.post('/bypass-session-issuance', (req, res) => { + const password = String(req.body?.password || '') + if (!password) return res.status(200).json({ token: issueSession() }) + authenticatePasswordAndIssueSession('user@example.test', password) + return res.sendStatus(204) +}) + +router.post('/single-branch-auth', (req, res) => { + const token = String(req.body?.token || '') + if (token) { + authenticateExternalUserSession(token) + } else { + return res.status(200).json({ data: readProtectedData() }) + } + return res.sendStatus(204) +}) + +router.post('/sets-authenticated', (req, res) => { + const token = String(req.body?.token || '') + if (!token) { + setAuthenticated() + return res.status(401).json({ error: 'invalid_session' }) + } + authenticateExternalUserSession(token) + return res.sendStatus(204) +}) + +router.post('/alternate-issuance', (req, res) => { + const token = String(req.body?.token || '') + if (!token) { + return res.status(200).json({ token: issueSession() }) + } + authenticateExternalUserSession(token) + return res.sendStatus(204) +}) + +router.post('/unrelated-400', (req, res) => { + const token = String(req.body?.token || '') + res.status(400).json({ error: 'unrelated' }) + if (!token) readProtectedData() + authenticateExternalUserSession(token) + return res.sendStatus(204) +}) + +router.post('/unrelated-400-does-not-sanitize', (req, res) => { + const token = String(req.body?.token || '') + res.status(400).json({ error: 'unrelated' }) + if (!token) return res.status(200).json({ data: readProtectedData() }) + authenticateExternalUserSession(token) + return res.sendStatus(204) +}) + +router.post('/post-work-4xx', (req, res) => { + const token = String(req.body?.token || '') + if (!token) { + readProtectedData() + return res.status(401).json({ error: 'invalid_session' }) + } + authenticateExternalUserSession(token) + return res.sendStatus(204) +}) + +router.post('/failure-response-leaks-data', (req, res) => { + const token = String(req.body?.token || '') + if (!token) return res.status(401).json({ data: readProtectedData() }) + authenticateExternalUserSession(token) + return res.sendStatus(204) +}) + +router.post('/unproven-helper', (req, res) => { + const token = String(req.body?.token || '') + if (!token) { + mayReturnSuccess(req, res, 401) + return + } + authenticateExternalUserSession(token) + return res.sendStatus(204) +}) + +export default router From e2e6b251fffcfc650cf6a6755ad59777be9e961e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 24 Aug 2026 10:26:14 -0600 Subject: [PATCH 107/528] fix(control-api): r5-b2 restore user access migration contract Class-check: user-access migration identities -> 2 sites -> update only the stale test reference Repro-test: migration contract test fails at 993b762f and passes after the correction --- control-api/test/db.userAccessFoundationMigration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control-api/test/db.userAccessFoundationMigration.test.ts b/control-api/test/db.userAccessFoundationMigration.test.ts index ac920ffaaf..d94bf0bd8b 100644 --- a/control-api/test/db.userAccessFoundationMigration.test.ts +++ b/control-api/test/db.userAccessFoundationMigration.test.ts @@ -10,7 +10,7 @@ vi.mock('../src/config.js', () => ({ async function migrationSql(): Promise { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0100_user_access_foundation' + candidate => candidate.version === '0101_user_access_foundation' ) expect(migration).toBeDefined() From feb206c7bb38d56a173c3381c6d3e0d2cba18a1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 24 Aug 2026 10:32:03 -0600 Subject: [PATCH 108/528] fix(control-api): r5-b1 unify catalog ordering Class-check: catalog ordering -> 25 producer arms + 2 Team-GFS streams -> align all sites Repro-test: test/catalogOrdering.test.ts fails at 4ada1afe and passes after the fix --- .../src/services/access/accessCatalogMerge.ts | 3 +- .../src/services/access/catalogContracts.ts | 13 +++++- .../services/access/catalogProducerArms.ts | 15 +++++-- .../services/access/catalogProducerHead.ts | 5 ++- .../access/catalogProducerHydration.ts | 8 +++- .../src/services/access/catalogProducerSql.ts | 35 +++++++++------- .../services/access/catalogProducerSupport.ts | 7 +++- .../src/services/access/teamGfsTopK.ts | 13 +++--- .../access/userAccessFoundationSchema.ts | 41 +++++++++++++++++++ control-api/test/catalogOrdering.test.ts | 36 ++++++++++++++++ ...Producers.realPostgres.integration.test.ts | 27 ++++++++++++ .../db.catalogUtf8OrderingMigration.test.ts | 30 ++++++++++++++ 12 files changed, 202 insertions(+), 31 deletions(-) create mode 100644 control-api/test/catalogOrdering.test.ts create mode 100644 control-api/test/db.catalogUtf8OrderingMigration.test.ts diff --git a/control-api/src/services/access/accessCatalogMerge.ts b/control-api/src/services/access/accessCatalogMerge.ts index 85d5233cf1..49afe8083d 100644 --- a/control-api/src/services/access/accessCatalogMerge.ts +++ b/control-api/src/services/access/accessCatalogMerge.ts @@ -8,6 +8,7 @@ import { type HydratedCatalogResource, catalogKeyEquals, compareCatalogKey, + compareCatalogText, } from './catalogContracts.js' import { resourceIdentityKey } from './resourceIdentity.js' @@ -35,7 +36,7 @@ type HeapEntry = Readonly<{ function entryCompare(left: HeapEntry, right: HeapEntry): number { const keyOrder = compareCatalogKey(left.candidate.key, right.candidate.key) - return keyOrder || left.cursor.stream.streamId.localeCompare(right.cursor.stream.streamId) + return keyOrder || compareCatalogText(left.cursor.stream.streamId, right.cursor.stream.streamId) } function heapPush(heap: HeapEntry[], entry: HeapEntry): void { diff --git a/control-api/src/services/access/catalogContracts.ts b/control-api/src/services/access/catalogContracts.ts index 65ea9d35a5..8e8fe2803e 100644 --- a/control-api/src/services/access/catalogContracts.ts +++ b/control-api/src/services/access/catalogContracts.ts @@ -123,10 +123,19 @@ export function catalogKey( return Object.freeze([environmentId, family, logicalId]) } +/** Exact lexicographic ordering of the unsigned UTF-8 bytes for stored catalog text. */ +export function compareCatalogText(left: string, right: string): number { + return Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8')) +} + export function compareCatalogKey(left: CatalogKey, right: CatalogKey): number { - return JSON.stringify(left).localeCompare(JSON.stringify(right)) + return ( + compareCatalogText(left[0], right[0]) || + compareCatalogText(left[1], right[1]) || + compareCatalogText(left[2], right[2]) + ) } export function catalogKeyEquals(left: CatalogKey, right: CatalogKey): boolean { - return compareCatalogKey(left, right) === 0 + return left[0] === right[0] && left[1] === right[1] && left[2] === right[2] } diff --git a/control-api/src/services/access/catalogProducerArms.ts b/control-api/src/services/access/catalogProducerArms.ts index 1dae6b5470..4468acd811 100644 --- a/control-api/src/services/access/catalogProducerArms.ts +++ b/control-api/src/services/access/catalogProducerArms.ts @@ -7,6 +7,15 @@ export type BoundedKeyArm = Readonly<{ hasValidUntil?: boolean }> +/** PostgreSQL expression equivalent to the catalog's unsigned UTF-8 byte order. */ +export function catalogTextOrderSql(expression: string): string { + return `catalog_utf8_bytes((${expression})::text)` +} + +export function catalogTextAfterSql(left: string, right: string): string { + return `${catalogTextOrderSql(left)} > ${catalogTextOrderSql(right)}` +} + /** Builds the static, bounded SQL envelope for producer source arms. */ export function boundedKeyUnionSql(arms: readonly (string | BoundedKeyArm)[]): string { if (arms.length === 0) throw new CatalogProducerContractError('key_arms_missing') @@ -29,7 +38,7 @@ export function boundedKeyUnionSql(arms: readonly (string | BoundedKeyArm)[]): s WHERE $9::jsonb IS NULL OR $9::jsonb ? '${names[index]}' ) ${after} CROSS JOIN LATERAL (${sourceSql}) bounded_arm - ORDER BY ${definition.orderBy ?? 'logical_id'} + ORDER BY ${catalogTextOrderSql(definition.orderBy ?? 'logical_id')} LIMIT $4 )` }) @@ -41,7 +50,7 @@ export function boundedKeyUnionSql(arms: readonly (string | BoundedKeyArm)[]): s SELECT jsonb_agg(jsonb_build_object( 'logical_id', logical_id, 'valid_until', valid_until - ) ORDER BY logical_id) + ) ORDER BY ${catalogTextOrderSql('logical_id')}) FROM ${name} ), '[]'::jsonb) AS source_rows, ${ @@ -57,5 +66,5 @@ export function boundedKeyUnionSql(arms: readonly (string | BoundedKeyArm)[]): s FROM (${union}) bounded_sources WHERE $1::uuid IS NOT NULL AND $2::text IS NOT NULL AND $3::text IS NOT NULL AND $5::text IS NOT NULL AND $6::text IS NOT NULL AND $7::text IS NOT NULL - ORDER BY source_arm` + ORDER BY ${catalogTextOrderSql('source_arm')}` } diff --git a/control-api/src/services/access/catalogProducerHead.ts b/control-api/src/services/access/catalogProducerHead.ts index 31860af03a..5029553a9d 100644 --- a/control-api/src/services/access/catalogProducerHead.ts +++ b/control-api/src/services/access/catalogProducerHead.ts @@ -5,6 +5,7 @@ import { type CatalogRequestContext, type ProducerContinuation, catalogKey, + compareCatalogText, } from './catalogContracts.js' import { catalogQuery } from './catalogProducerDatabase.js' import { CatalogProducerContractError } from './catalogProducerErrors.js' @@ -113,7 +114,7 @@ export async function listBoundedProducerKeys(input: { } const sourceValue = sourceRow as Record const logicalId = typeof sourceValue.logical_id === 'string' ? sourceValue.logical_id : '' - if (!logicalId || logicalId <= state.after) { + if (!logicalId || compareCatalogText(logicalId, state.after) <= 0) { throw new CatalogProducerContractError('keys_not_strictly_ordered') } let validUntil: string | null = null @@ -158,7 +159,7 @@ export async function listBoundedProducerKeys(input: { ) continue } - const logicalId = heads.map(head => head.candidate.logicalId).sort()[0] + const logicalId = heads.map(head => head.candidate.logicalId).sort(compareCatalogText)[0] const contributors = heads.filter(head => head.candidate.logicalId === logicalId) const validUntil = contributors diff --git a/control-api/src/services/access/catalogProducerHydration.ts b/control-api/src/services/access/catalogProducerHydration.ts index 36e9528d3d..cf3ee42c76 100644 --- a/control-api/src/services/access/catalogProducerHydration.ts +++ b/control-api/src/services/access/catalogProducerHydration.ts @@ -1,4 +1,8 @@ -import type { CatalogFamily, CatalogRequestContext } from './catalogContracts.js' +import { + type CatalogFamily, + type CatalogRequestContext, + compareCatalogText, +} from './catalogContracts.js' import { CatalogProducerContractError } from './catalogProducerErrors.js' /** Validates the ordered canonical keys passed to selected-ID hydration. */ @@ -15,7 +19,7 @@ export function validateHydrationKeys(input: { if (key[0] !== input.context.environmentId || key[1] !== input.family || !key[2]) { throw new CatalogProducerContractError('hydrate_key_mismatch') } - if (logicalIds.at(-1) !== undefined && key[2] <= logicalIds.at(-1)!) { + if (logicalIds.at(-1) !== undefined && compareCatalogText(key[2], logicalIds.at(-1)!) <= 0) { throw new CatalogProducerContractError('hydrate_keys_not_strictly_ordered') } logicalIds.push(key[2]) diff --git a/control-api/src/services/access/catalogProducerSql.ts b/control-api/src/services/access/catalogProducerSql.ts index d7d2ab68b2..6d32e43f31 100644 --- a/control-api/src/services/access/catalogProducerSql.ts +++ b/control-api/src/services/access/catalogProducerSql.ts @@ -1,5 +1,5 @@ import type { CatalogFamily } from './catalogContracts.js' -import { boundedKeyUnionSql } from './catalogProducerSupport.js' +import { boundedKeyUnionSql, catalogTextAfterSql } from './catalogProducerSupport.js' export const CATALOG_KEY_SQL: Readonly> = Object.freeze({ user: boundedKeyUnionSql([ @@ -28,7 +28,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f ON resource.environment_id = $3 AND resource.resource_type = 'host' AND resource.logical_id = $5::text || '/' || ua.agent_name AND resource.enabled = TRUE AND resource.deleted_at IS NULL - WHERE ua.user_id = $1 AND ua.agent_name > $7`, + WHERE ua.user_id = $1 AND ${catalogTextAfterSql('ua.agent_name', '$7')}`, orderBy: 'source_key', }, { @@ -39,7 +39,8 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f ON resource.environment_id = $3 AND resource.resource_type = 'host' AND resource.logical_id = $5::text || '/' || ta.agent_name AND resource.enabled = TRUE AND resource.deleted_at IS NULL - WHERE tm.user_id = $1 AND tm.status = 'active' AND ta.agent_name > $7`, + WHERE tm.user_id = $1 AND tm.status = 'active' + AND ${catalogTextAfterSql('ta.agent_name', '$7')}`, orderBy: 'source_key', duplicateCapable: true, }, @@ -52,7 +53,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f ON resource.environment_id = $3 AND resource.resource_type = 'context' AND resource.logical_id = $6::text || '/' || uc.context_id AND resource.enabled = TRUE AND resource.deleted_at IS NULL - WHERE uc.user_id = $1 AND uc.context_id > $7`, + WHERE uc.user_id = $1 AND ${catalogTextAfterSql('uc.context_id', '$7')}`, orderBy: 'source_key', }, { @@ -63,7 +64,8 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f ON resource.environment_id = $3 AND resource.resource_type = 'context' AND resource.logical_id = $6::text || '/' || tc.context_id AND resource.enabled = TRUE AND resource.deleted_at IS NULL - WHERE tm.user_id = $1 AND tm.status = 'active' AND tc.context_id > $7`, + WHERE tm.user_id = $1 AND tm.status = 'active' + AND ${catalogTextAfterSql('tc.context_id', '$7')}`, orderBy: 'source_key', duplicateCapable: true, }, @@ -86,7 +88,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f ON target_resource.environment_id = $3 AND target_resource.resource_type = 'mcp_server' AND target_resource.logical_id = edge.target_id AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL - WHERE uc.user_id = $1 AND edge.target_id > $2`, + WHERE uc.user_id = $1 AND ${catalogTextAfterSql('edge.target_id', '$2')}`, duplicateCapable: true, }, { @@ -107,7 +109,8 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f ON target_resource.environment_id = $3 AND target_resource.resource_type = 'mcp_server' AND target_resource.logical_id = edge.target_id AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL - WHERE tm.user_id = $1 AND tm.status = 'active' AND edge.target_id > $2`, + WHERE tm.user_id = $1 AND tm.status = 'active' + AND ${catalogTextAfterSql('edge.target_id', '$2')}`, duplicateCapable: true, }, { @@ -137,7 +140,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f ON target_resource.environment_id = $3 AND target_resource.resource_type = 'mcp_server' AND target_resource.logical_id = mcp_edge.target_id AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL - WHERE ua.user_id = $1 AND mcp_edge.target_id > $2`, + WHERE ua.user_id = $1 AND ${catalogTextAfterSql('mcp_edge.target_id', '$2')}`, duplicateCapable: true, }, { @@ -168,7 +171,8 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f ON target_resource.environment_id = $3 AND target_resource.resource_type = 'mcp_server' AND target_resource.logical_id = mcp_edge.target_id AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL - WHERE tm.user_id = $1 AND tm.status = 'active' AND mcp_edge.target_id > $2`, + WHERE tm.user_id = $1 AND tm.status = 'active' + AND ${catalogTextAfterSql('mcp_edge.target_id', '$2')}`, duplicateCapable: true, }, ]), @@ -181,7 +185,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND resource.logical_id = uwt.recipe_namespace || '/' || uwt.recipe_name AND resource.enabled = TRUE AND resource.deleted_at IS NULL WHERE uwt.user_id = $1 - AND (uwt.recipe_namespace || '/' || uwt.recipe_name) > $2`, + AND ${catalogTextAfterSql("uwt.recipe_namespace || '/' || uwt.recipe_name", '$2')}`, orderBy: 'logical_id', }, { @@ -193,7 +197,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND resource.logical_id = twt.recipe_namespace || '/' || twt.recipe_name AND resource.enabled = TRUE AND resource.deleted_at IS NULL WHERE tm.user_id = $1 AND tm.status = 'active' - AND (twt.recipe_namespace || '/' || twt.recipe_name) > $2`, + AND ${catalogTextAfterSql("twt.recipe_namespace || '/' || twt.recipe_name", '$2')}`, orderBy: 'logical_id', duplicateCapable: true, }, @@ -336,7 +340,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND target_resource.resource_type = 'shared_filesystem' AND target_resource.logical_id = edge.target_id AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL - WHERE uc.user_id = $1 AND edge.target_id > $2`, + WHERE uc.user_id = $1 AND ${catalogTextAfterSql('edge.target_id', '$2')}`, duplicateCapable: true, }, { @@ -358,7 +362,8 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND target_resource.resource_type = 'shared_filesystem' AND target_resource.logical_id = edge.target_id AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL - WHERE tm.user_id = $1 AND tm.status = 'active' AND edge.target_id > $2`, + WHERE tm.user_id = $1 AND tm.status = 'active' + AND ${catalogTextAfterSql('edge.target_id', '$2')}`, duplicateCapable: true, }, ]), @@ -379,7 +384,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND edge.source_type = 'workflow_recipe' AND edge.relationship_type = 'exposes_sandbox_app' AND edge.target_type = 'sandbox_app' - AND edge.target_id > $2 + AND ${catalogTextAfterSql('edge.target_id', '$2')} AND EXISTS ( SELECT 1 FROM user_workflow_triggers uwt @@ -403,7 +408,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND edge.source_type = 'workflow_recipe' AND edge.relationship_type = 'exposes_sandbox_app' AND edge.target_type = 'sandbox_app' - AND edge.target_id > $2 + AND ${catalogTextAfterSql('edge.target_id', '$2')} AND EXISTS ( SELECT 1 FROM team_workflow_triggers twt diff --git a/control-api/src/services/access/catalogProducerSupport.ts b/control-api/src/services/access/catalogProducerSupport.ts index ba3600d313..46b2a320a8 100644 --- a/control-api/src/services/access/catalogProducerSupport.ts +++ b/control-api/src/services/access/catalogProducerSupport.ts @@ -2,7 +2,12 @@ * Compatibility facade for catalog-producer callers. Each responsibility has * a focused owner; existing imports remain stable while callers migrate. */ -export { type BoundedKeyArm, boundedKeyUnionSql } from './catalogProducerArms.js' +export { + type BoundedKeyArm, + boundedKeyUnionSql, + catalogTextAfterSql, + catalogTextOrderSql, +} from './catalogProducerArms.js' export { catalogQuery } from './catalogProducerDatabase.js' export { CatalogProducerContractError } from './catalogProducerErrors.js' export { listBoundedProducerKeys } from './catalogProducerHead.js' diff --git a/control-api/src/services/access/teamGfsTopK.ts b/control-api/src/services/access/teamGfsTopK.ts index c89c5d3eb9..7226d2219d 100644 --- a/control-api/src/services/access/teamGfsTopK.ts +++ b/control-api/src/services/access/teamGfsTopK.ts @@ -5,11 +5,13 @@ import { type CatalogRequestContext, type ProducerContinuation, catalogKey, + compareCatalogText, } from './catalogContracts.js' import { CATALOG_KEY_SQL } from './catalogProducerSql.js' import { CatalogProducerContractError, catalogQuery, + catalogTextOrderSql, listBoundedProducerKeys, } from './catalogProducerSupport.js' @@ -72,13 +74,14 @@ const TEAM_GFS_STREAM_HEAD_SQL = `WITH requested AS ( ) SELECT kind, subject_id, resource_id::text AS logical_id FROM heads - ORDER BY logical_id, kind, subject_id` + ORDER BY ${catalogTextOrderSql('logical_id')}, ${catalogTextOrderSql('kind')}, + ${catalogTextOrderSql('subject_id')}` function compareHead(left: TeamGfsStreamHead, right: TeamGfsStreamHead): number { return ( - left.logicalId.localeCompare(right.logicalId) || - left.kind.localeCompare(right.kind) || - left.subjectId.localeCompare(right.subjectId) + compareCatalogText(left.logicalId, right.logicalId) || + compareCatalogText(left.kind, right.kind) || + compareCatalogText(left.subjectId, right.subjectId) ) } @@ -255,7 +258,7 @@ function mergeCandidates( const byId = new Map() for (const candidate of [...direct, ...team]) byId.set(candidate.key[2], candidate) return [...byId.values()] - .sort((left, right) => left.key[2].localeCompare(right.key[2])) + .sort((left, right) => compareCatalogText(left.key[2], right.key[2])) .slice(0, take + 1) } diff --git a/control-api/src/services/access/userAccessFoundationSchema.ts b/control-api/src/services/access/userAccessFoundationSchema.ts index ac0d2d6820..e598bb067b 100644 --- a/control-api/src/services/access/userAccessFoundationSchema.ts +++ b/control-api/src/services/access/userAccessFoundationSchema.ts @@ -264,6 +264,47 @@ export async function applyUserAccessFoundationSchema(db: DbClient): Promise { + await db.query(` + CREATE OR REPLACE FUNCTION catalog_utf8_bytes(value TEXT) + RETURNS BYTEA + LANGUAGE SQL + IMMUTABLE + STRICT + PARALLEL SAFE + AS $$ + SELECT convert_to(value, 'UTF8'); + $$; + + CREATE INDEX IF NOT EXISTS user_agents_catalog_utf8_idx + ON user_agents (user_id, catalog_utf8_bytes(agent_name)); + CREATE INDEX IF NOT EXISTS team_agents_catalog_utf8_idx + ON team_agents (catalog_utf8_bytes(agent_name), team_id); + CREATE INDEX IF NOT EXISTS user_contexts_catalog_utf8_idx + ON user_contexts (user_id, catalog_utf8_bytes(context_id)); + CREATE INDEX IF NOT EXISTS team_contexts_catalog_utf8_idx + ON team_contexts (catalog_utf8_bytes(context_id), team_id); + CREATE INDEX IF NOT EXISTS user_workflow_triggers_catalog_utf8_idx + ON user_workflow_triggers ( + user_id, + catalog_utf8_bytes(recipe_namespace || '/' || recipe_name) + ); + CREATE INDEX IF NOT EXISTS team_workflow_triggers_catalog_utf8_idx + ON team_workflow_triggers ( + catalog_utf8_bytes(recipe_namespace || '/' || recipe_name), + team_id + ); + CREATE INDEX IF NOT EXISTS operational_relationship_catalog_utf8_target_idx + ON operational_resource_relationships ( + environment_id, + target_type, + relationship_type, + catalog_utf8_bytes(target_id) + ); + `) +} + async function applyAuthorizationRevisionFunctions(db: DbClient): Promise { await db.query(` CREATE OR REPLACE FUNCTION authorization_bump_user_revision(target_user_id UUID) diff --git a/control-api/test/catalogOrdering.test.ts b/control-api/test/catalogOrdering.test.ts new file mode 100644 index 0000000000..a32ba95f80 --- /dev/null +++ b/control-api/test/catalogOrdering.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { mergeCatalogCandidateStreams } from '../src/services/access/accessCatalogMerge.js' +import { + catalogKey, + catalogKeyEquals, + compareCatalogKey, +} from '../src/services/access/catalogContracts.js' + +const environmentId = 'cluster.local/evenfire' + +function byteOrder(left: string, right: string): number { + return Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8')) +} + +describe('catalog UTF-8 byte ordering contract', () => { + it('uses exact identity and byte order for mixed-case and canonically equivalent keys', () => { + const zeta = catalogKey(environmentId, 'host', 'ns/Zeta') + const alpha = catalogKey(environmentId, 'host', 'ns/alpha') + const composed = catalogKey(environmentId, 'host', 'ns/e\u0301') + const precomposed = catalogKey(environmentId, 'host', 'ns/é') + + expect(Math.sign(compareCatalogKey(zeta, alpha))).toBe( + Math.sign(byteOrder('ns/Zeta', 'ns/alpha')) + ) + expect(catalogKeyEquals(composed, precomposed)).toBe(false) + + const stream = [zeta, alpha].map(key => ({ + key, + canonicalId: `host:${key[2]}`, + validUntil: null, + })) + expect(mergeCatalogCandidateStreams([{ streamId: 'host', candidates: stream }], 2)).toEqual( + stream + ) + }) +}) diff --git a/control-api/test/catalogProducers.realPostgres.integration.test.ts b/control-api/test/catalogProducers.realPostgres.integration.test.ts index c6043a753a..9fc567d35b 100644 --- a/control-api/test/catalogProducers.realPostgres.integration.test.ts +++ b/control-api/test/catalogProducers.realPostgres.integration.test.ts @@ -69,6 +69,33 @@ describeRealPostgres('catalog producer SQL on real PostgreSQL', () => { } }) + it('orders and resumes arbitrary PostgreSQL text by exact UTF-8 bytes', async () => { + const values = ['alpha', 'Zeta', 'a_b', 'a-b', 'é', 'e\u0301', 'Ω', '😀'] + const expected = [...values].sort((left, right) => + Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8')) + ) + const ordered = await databasePool.query( + `SELECT value + FROM unnest($1::text[]) value + ORDER BY catalog_utf8_bytes(value)`, + [values] + ) + expect(ordered.rows.map(row => row.value)).toEqual(expected) + + for (const after of expected) { + const resumed = await databasePool.query( + `SELECT value + FROM unnest($1::text[]) value + WHERE catalog_utf8_bytes(value) > catalog_utf8_bytes($2) + ORDER BY catalog_utf8_bytes(value)`, + [values, after] + ) + expect(resumed.rows.map(row => row.value)).toEqual( + expected.filter(value => Buffer.from(value, 'utf8').compare(Buffer.from(after, 'utf8')) > 0) + ) + } + }) + it('parses and executes every bounded key and selected-ID hydration query', async () => { const budget = AccessExecutionBudget.create('catalog') const sourceStates: CatalogOperationalSourceState[] = OPERATIONAL_SOURCE_FAMILIES.map( diff --git a/control-api/test/db.catalogUtf8OrderingMigration.test.ts b/control-api/test/db.catalogUtf8OrderingMigration.test.ts new file mode 100644 index 0000000000..e32a7a1594 --- /dev/null +++ b/control-api/test/db.catalogUtf8OrderingMigration.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('../src/config.js', () => ({ + config: { databaseUrl: 'postgresql://test' }, +})) + +describe('0103 catalog UTF-8 ordering migration', () => { + it('installs the immutable byte function and complete supporting index class', async () => { + const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') + const migration = CONTROL_API_MIGRATIONS.find( + candidate => candidate.version === '0103_catalog_utf8_ordering' + ) + expect(migration).toBeDefined() + + const query = vi.fn(async () => ({ rows: [], rowCount: 0 })) + await migration!.apply({ query }) + const sql = String(query.mock.calls[0]?.[0]) + + expect(sql).toContain('CREATE OR REPLACE FUNCTION catalog_utf8_bytes(value TEXT)') + expect(sql).toContain("SELECT convert_to(value, 'UTF8')") + expect(sql.match(/catalog_utf8_bytes\(/g)).toHaveLength(8) + expect(sql).toContain('user_agents_catalog_utf8_idx') + expect(sql).toContain('team_agents_catalog_utf8_idx') + expect(sql).toContain('user_contexts_catalog_utf8_idx') + expect(sql).toContain('team_contexts_catalog_utf8_idx') + expect(sql).toContain('user_workflow_triggers_catalog_utf8_idx') + expect(sql).toContain('team_workflow_triggers_catalog_utf8_idx') + expect(sql).toContain('operational_relationship_catalog_utf8_target_idx') + }) +}) From 6f9ea04ab9d224b6a3fdc17ea535ccc7352ceab0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 24 Aug 2026 10:35:23 -0600 Subject: [PATCH 109/528] test(control-api): r5-h1 replace circular catalog properties Class-check: catalog ordering oracles -> 3 tests -> use independent UTF-8 byte expectations Repro-test: independent catalog properties fail at 4ada1afe and pass after the correction --- .../test/accessCatalogProperties.test.ts | 64 ++++++++++++++++--- control-api/test/catalogOrdering.test.ts | 8 ++- ...Producers.realPostgres.integration.test.ts | 29 ++++++++- 3 files changed, 89 insertions(+), 12 deletions(-) diff --git a/control-api/test/accessCatalogProperties.test.ts b/control-api/test/accessCatalogProperties.test.ts index f3c4337351..229bef03ac 100644 --- a/control-api/test/accessCatalogProperties.test.ts +++ b/control-api/test/accessCatalogProperties.test.ts @@ -24,9 +24,11 @@ import { CATALOG_FAMILIES, type CatalogFamily, type CatalogIdentityCandidate, + type CatalogKey, type CatalogRelationship, type HydratedCatalogResource, catalogKey, + catalogKeyEquals, compareCatalogKey, } from '../src/services/access/catalogContracts.js' import { canonicalResourceIdentity } from '../src/services/access/resourceIdentity.js' @@ -45,6 +47,34 @@ function identifier(value: number): string { return `id-${String(value).padStart(5, '0')}` } +function compareTextOracle(left: string, right: string): number { + const encoder = new TextEncoder() + const leftBytes = encoder.encode(left) + const rightBytes = encoder.encode(right) + for (let index = 0; index < Math.min(leftBytes.length, rightBytes.length); index += 1) { + if (leftBytes[index] !== rightBytes[index]) return leftBytes[index]! - rightBytes[index]! + } + return leftBytes.length - rightBytes.length +} + +function compareKeyOracle(left: CatalogKey, right: CatalogKey): number { + return ( + compareTextOracle(left[0], right[0]) || + compareTextOracle(left[1], right[1]) || + compareTextOracle(left[2], right[2]) + ) +} + +const catalogTextArbitrary = fc + .array( + fc.constantFrom('a', 'z', 'A', 'Z', '0', '9', '-', '_', '.', '/', 'é', 'e\u0301', 'Ω', '😀'), + { + minLength: 1, + maxLength: 16, + } + ) + .map(parts => parts.join('')) + function uuid(value: number): string { return `00000000-0000-4000-8000-${String(value).padStart(12, '0')}` } @@ -52,7 +82,7 @@ function uuid(value: number): string { const candidateArbitrary = fc .record({ family: fc.constantFrom(...CATALOG_FAMILIES), - value: fc.integer({ min: 0, max: 500 }), + logicalId: catalogTextArbitrary, validUntil: fc.option( fc .integer({ min: 1, max: 20 }) @@ -60,8 +90,7 @@ const candidateArbitrary = fc { nil: null } ), }) - .map(({ family, value, validUntil }): CatalogIdentityCandidate => { - const logicalId = identifier(value) + .map(({ family, logicalId, validUntil }): CatalogIdentityCandidate => { return Object.freeze({ key: catalogKey(environmentId, family, logicalId), canonicalId: `${family}:${logicalId}`, @@ -80,7 +109,7 @@ const candidateStreamsArbitrary = fc .map(candidateLists => candidateLists.map((candidates, index) => ({ streamId: `stream-${index}`, - candidates: [...candidates].sort((left, right) => compareCatalogKey(left.key, right.key)), + candidates: [...candidates].sort((left, right) => compareKeyOracle(left.key, right.key)), })) ) @@ -98,7 +127,7 @@ function expectedCandidateUnion( byKey.set(key, { ...candidate, validUntil: validUntil ?? null }) } return [...byKey.values()] - .sort((left, right) => compareCatalogKey(left.key, right.key)) + .sort((left, right) => compareKeyOracle(left.key, right.key)) .slice(0, maximumOutputs) } @@ -175,6 +204,22 @@ function hydrated(input: { } describe('aggregate access catalog properties', () => { + it('matches an independent UTF-8 byte oracle with exact identity', () => { + fc.assert( + fc.property(candidateArbitrary, candidateArbitrary, (left, right) => { + expect(Math.sign(compareCatalogKey(left.key, right.key))).toBe( + Math.sign(compareKeyOracle(left.key, right.key)) + ) + expect(catalogKeyEquals(left.key, right.key)).toBe( + left.key[0] === right.key[0] && + left.key[1] === right.key[1] && + left.key[2] === right.key[2] + ) + }), + { numRuns: 1_000 } + ) + }) + it('k-way merge is ordered, bounded, deduplicating, and producer-order independent', () => { fc.assert( fc.property( @@ -197,18 +242,17 @@ describe('aggregate access catalog properties', () => { fc .tuple( ...families.map(family => - fc.uniqueArray(fc.integer({ min: 0, max: 200 }), { maxLength: 25 }).map(values => ({ + fc.uniqueArray(catalogTextArbitrary, { maxLength: 25 }).map(values => ({ streamId: family, candidates: values - .map(value => { - const logicalId = identifier(value) + .map(logicalId => { return { key: catalogKey(environmentId, family, logicalId), canonicalId: `${family}:${logicalId}`, validUntil: null, } }) - .sort((left, right) => compareCatalogKey(left.key, right.key)), + .sort((left, right) => compareKeyOracle(left.key, right.key)), })) ) ) @@ -232,7 +276,7 @@ describe('aggregate access catalog properties', () => { const afterKey = emitted.at(-1)?.key if (afterKey) { stream.candidates = stream.candidates.filter( - candidate => compareCatalogKey(candidate.key, afterKey) > 0 + candidate => compareKeyOracle(candidate.key, afterKey) > 0 ) } } diff --git a/control-api/test/catalogOrdering.test.ts b/control-api/test/catalogOrdering.test.ts index a32ba95f80..fe094101d2 100644 --- a/control-api/test/catalogOrdering.test.ts +++ b/control-api/test/catalogOrdering.test.ts @@ -9,7 +9,13 @@ import { const environmentId = 'cluster.local/evenfire' function byteOrder(left: string, right: string): number { - return Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8')) + const encoder = new TextEncoder() + const leftBytes = encoder.encode(left) + const rightBytes = encoder.encode(right) + for (let index = 0; index < Math.min(leftBytes.length, rightBytes.length); index += 1) { + if (leftBytes[index] !== rightBytes[index]) return leftBytes[index]! - rightBytes[index]! + } + return leftBytes.length - rightBytes.length } describe('catalog UTF-8 byte ordering contract', () => { diff --git a/control-api/test/catalogProducers.realPostgres.integration.test.ts b/control-api/test/catalogProducers.realPostgres.integration.test.ts index 9fc567d35b..fe343b4ecd 100644 --- a/control-api/test/catalogProducers.realPostgres.integration.test.ts +++ b/control-api/test/catalogProducers.realPostgres.integration.test.ts @@ -1,4 +1,5 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import fc from 'fast-check' import { randomBytes, randomUUID } from 'node:crypto' import { Pool } from 'pg' import { config } from '../src/config.js' @@ -70,7 +71,33 @@ describeRealPostgres('catalog producer SQL on real PostgreSQL', () => { }) it('orders and resumes arbitrary PostgreSQL text by exact UTF-8 bytes', async () => { - const values = ['alpha', 'Zeta', 'a_b', 'a-b', 'é', 'e\u0301', 'Ω', '😀'] + const generated = fc.sample( + fc + .array( + fc.constantFrom( + 'a', + 'z', + 'A', + 'Z', + '0', + '9', + '-', + '_', + '.', + '/', + 'é', + 'e\u0301', + 'Ω', + '😀' + ), + { minLength: 1, maxLength: 12 } + ) + .map(parts => parts.join('')), + { seed: 106_026, numRuns: 200 } + ) + const values = [ + ...new Set(['alpha', 'Zeta', 'a_b', 'a-b', 'é', 'e\u0301', 'Ω', '😀', ...generated]), + ] const expected = [...values].sort((left, right) => Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8')) ) From 6ed1347db75ac496ed793ae101fbeabc86eb8a27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 24 Aug 2026 10:37:09 -0600 Subject: [PATCH 110/528] fix(control-api): r5-h2 bind capabilities to selected path Class-check: effectiveCapabilities -> 1 producer + 2 consumers -> keep selected-path authority Repro-test: selected team path leaks direct write at ca3650cb and passes after the fix --- .../access/liveAuthorizationResolver.ts | 10 +-- .../test/accessLiveAuthorization.test.ts | 65 +++++++++++++++++++ 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/control-api/src/services/access/liveAuthorizationResolver.ts b/control-api/src/services/access/liveAuthorizationResolver.ts index a81cae0fa5..e02ef964b2 100644 --- a/control-api/src/services/access/liveAuthorizationResolver.ts +++ b/control-api/src/services/access/liveAuthorizationResolver.ts @@ -26,11 +26,7 @@ import { canonicalAccessPathSeeds, databaseRelationshipsRevision, } from './authorizationRevision.js' -import { - type AccessCapability, - isAccessCapability, - normalizeAccessCapabilities, -} from './capabilityRegistry.js' +import { type AccessCapability, isAccessCapability } from './capabilityRegistry.js' import { validateExactOperationalBindings } from './exactOperationalAuthorization.js' import { OperationTargetValidationError, @@ -329,9 +325,7 @@ async function resolveInTransaction(input: { } return Object.freeze({ status: 'allowed', - effectiveCapabilities: Object.freeze( - normalizeAccessCapabilities(paths.flatMap(path => path.behavior.capabilities)) - ), + effectiveCapabilities: Object.freeze([...selectedPath.behavior.capabilities]), paths, selectedPath, authorizationRevision: revision, diff --git a/control-api/test/accessLiveAuthorization.test.ts b/control-api/test/accessLiveAuthorization.test.ts index 71b54296e0..dcf03cffb6 100644 --- a/control-api/test/accessLiveAuthorization.test.ts +++ b/control-api/test/accessLiveAuthorization.test.ts @@ -152,6 +152,19 @@ function hostRequest(overrides: Partial = {}): LiveAutho } } +function gfsRequest(overrides: Partial = {}): LiveAuthorizationInput { + return { + session, + requiredCapability: 'gfs.read', + resource: canonicalResourceIdentity({ + environmentId, + type: 'gfs_resource', + logicalId: '50000000-0000-4000-8000-000000000005', + }), + ...overrides, + } +} + describe('access-path behavior', () => { it('selects direct first only when every behavior dimension is known and equal', () => { const direct = path({ kind: 'direct', grantId: 'direct-a', behavior: behavior() }) @@ -241,6 +254,58 @@ describe('live user-access resolution', () => { } }) + it('reports only the capabilities of the explicitly selected path', async () => { + const db = fakeTransaction({ + memberships: [ + { + teamId, + role: 'member', + membershipUpdatedAt: '2026-08-10T00:00:00.000Z', + teamRevision: '1', + }, + ], + hostGrantRows: [ + { + kind: 'direct', + grant_id: 'gfs_grants:direct', + team_id: null, + current_role: null, + permissions: ['read', 'write'], + drive: '3rd', + }, + { + kind: 'team', + grant_id: 'gfs_grants:team', + team_id: teamId, + current_role: 'member', + permissions: ['read'], + drive: '3rd', + }, + ], + }) + + const choice = await resolveLiveAuthorization(gfsRequest(), { + transaction: db.transaction, + }) + expect(choice.status).toBe('access_path_required') + if (choice.status !== 'access_path_required') return + const selectedTeamPath = choice.safePathDescriptors.find(path => path.kind === 'team') + expect(selectedTeamPath).toBeDefined() + + const result = await resolveLiveAuthorization( + gfsRequest({ requestedAccessPathId: selectedTeamPath!.id }), + { transaction: db.transaction } + ) + + expect(result).toEqual( + expect.objectContaining({ + status: 'allowed', + effectiveCapabilities: ['gfs.read'], + selectedPath: expect.objectContaining({ kind: 'team', teamId }), + }) + ) + }) + it('revalidates the exact provider incarnation for a selected operational path', async () => { const currentObject = { metadata: { From 5c8c49f7f0a34106593b0ca61d69dd0ff93e08f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 24 Aug 2026 10:46:16 -0600 Subject: [PATCH 111/528] fix(control-api): r5-h3 compose catalog revisions Class-check: catalog-affecting writers -> 18 tables -> map every writer to scoped revisions Repro-test: composable migration contract fails at 4c2ca1c4 and passes after the fix --- .../access/catalogAuthorityContext.ts | 11 +- .../src/services/access/catalogContracts.ts | 1 - .../access/userAccessFoundationSchema.ts | 330 +++++++++++++++++ ...agination.realPostgres.integration.test.ts | 2 - ...Producers.realPostgres.integration.test.ts | 1 - control-api/test/catalogProducers.test.ts | 1 - ...omponents.realPostgres.integration.test.ts | 337 ++++++++++++++++++ ...composableCatalogRevisionMigration.test.ts | 53 +++ .../db.userAccessFoundationMigration.test.ts | 2 +- .../control-api-runtime-access-profiles.tsv | 2 +- 10 files changed, 725 insertions(+), 15 deletions(-) create mode 100644 control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts create mode 100644 control-api/test/db.composableCatalogRevisionMigration.test.ts diff --git a/control-api/src/services/access/catalogAuthorityContext.ts b/control-api/src/services/access/catalogAuthorityContext.ts index a8f848c40e..2bd04862c0 100644 --- a/control-api/src/services/access/catalogAuthorityContext.ts +++ b/control-api/src/services/access/catalogAuthorityContext.ts @@ -45,15 +45,13 @@ function catalogAuthorizationRevision(principal: { sessionContract: 'v1' | 'v2' sessionRevision: string userRevision: string - catalogRevision: string memberships: CatalogPrincipalSnapshot['memberships'] }): string { - return `car1_${revisionOfValues([ - 'catalog-authority-v1', + return `car2_${revisionOfValues([ + 'catalog-authority-v2', principal.sessionContract, principal.sessionRevision, principal.userRevision, - principal.catalogRevision, ...principal.memberships.map(membership => [ membership.teamId, membership.role, @@ -95,7 +93,6 @@ export async function loadCatalogRequestContext(input: { ) SELECT users.id AS user_id, COALESCE(user_revision.revision, 1)::text AS user_revision, - COALESCE(catalog_revision.revision, 1)::text AS catalog_revision, CASE WHEN $2::text = 'v2' THEN EXISTS ( SELECT 1 @@ -152,10 +149,9 @@ export async function loadCatalogRequestContext(input: { ) AS memberships FROM users LEFT JOIN authorization_user_revisions user_revision ON user_revision.user_id = users.id - CROSS JOIN authorization_catalog_revision catalog_revision LEFT JOIN active_memberships membership ON TRUE WHERE users.id = $1 - GROUP BY users.id, user_revision.revision, catalog_revision.revision`, + GROUP BY users.id, user_revision.revision`, [ input.session.userId, input.session.contract, @@ -175,7 +171,6 @@ export async function loadCatalogRequestContext(input: { sessionContract: input.session.contract, sessionRevision: String(row.session_revision), userRevision: String(row.user_revision ?? '1'), - catalogRevision: String(row.catalog_revision ?? '1'), memberships, } as const const principal: CatalogPrincipalSnapshot = Object.freeze({ diff --git a/control-api/src/services/access/catalogContracts.ts b/control-api/src/services/access/catalogContracts.ts index 8e8fe2803e..10e8d33e12 100644 --- a/control-api/src/services/access/catalogContracts.ts +++ b/control-api/src/services/access/catalogContracts.ts @@ -76,7 +76,6 @@ export type CatalogPrincipalSnapshot = Readonly<{ sessionContract: 'v1' | 'v2' sessionRevision: string userRevision: string - catalogRevision: string authorizationRevision: string memberships: readonly Readonly<{ teamId: string diff --git a/control-api/src/services/access/userAccessFoundationSchema.ts b/control-api/src/services/access/userAccessFoundationSchema.ts index e598bb067b..478b28e2be 100644 --- a/control-api/src/services/access/userAccessFoundationSchema.ts +++ b/control-api/src/services/access/userAccessFoundationSchema.ts @@ -305,6 +305,336 @@ export async function applyCatalogUtf8OrderingSchema(db: DbClient): Promise { + await db.query(` + CREATE TABLE IF NOT EXISTS authorization_catalog_writer_components ( + writer_table TEXT PRIMARY KEY, + component_class TEXT NOT NULL, + rationale TEXT NOT NULL, + CHECK (writer_table <> '' AND component_class <> '' AND rationale <> '') + ); + + INSERT INTO authorization_catalog_writer_components(writer_table, component_class, rationale) + VALUES + ('users', 'user', 'user identity and lifecycle'), + ('teams', 'team', 'team identity'), + ('team_members', 'user+team', 'membership and live role'), + ('user_contexts', 'user', 'direct context access'), + ('team_contexts', 'team', 'team context access'), + ('user_agents', 'user', 'direct agent access'), + ('team_agents', 'team', 'team agent access'), + ('user_workflow_triggers', 'user', 'direct workflow access'), + ('team_workflow_triggers', 'team', 'team workflow access'), + ('workflow_runs', 'user+team', 'visible workflow-run ownership'), + ('workflow_approval_requests', 'user+team', 'approval target authority'), + ('notification_deliveries', 'user+team', 'notification audience'), + ('gfs_resources', 'gfs-subjects', 'resource visibility for current subjects'), + ('gfs_grants', 'user+team', 'direct GFS grants'), + ('gfs_shares', 'user+team', 'direct GFS shares'), + ('operational_resource_index', 'resource+source-state', 'promoted source resources'), + ('operational_resource_relationships', 'resource+source-state', 'promoted source edges'), + ('operational_catalog_source_state', 'source-state', 'atomic source generation promotion') + ON CONFLICT (writer_table) DO UPDATE + SET component_class = EXCLUDED.component_class, + rationale = EXCLUDED.rationale; + + CREATE OR REPLACE FUNCTION authorization_bump_user_revision(target_user_id UUID) + RETURNS VOID + LANGUAGE SQL + SECURITY DEFINER + SET search_path = pg_catalog, public + AS $$ + INSERT INTO authorization_user_revisions(user_id, revision, updated_at) + SELECT users.id, 1, clock_timestamp() + FROM users + WHERE users.id = target_user_id + ON CONFLICT (user_id) DO UPDATE + SET revision = authorization_user_revisions.revision + 1, + updated_at = clock_timestamp(); + $$; + + CREATE OR REPLACE FUNCTION authorization_bump_team_revision(target_team_id UUID) + RETURNS VOID + LANGUAGE SQL + SECURITY DEFINER + SET search_path = pg_catalog, public + AS $$ + INSERT INTO authorization_team_revisions(team_id, revision, updated_at) + SELECT teams.id, 1, clock_timestamp() + FROM teams + WHERE teams.id = target_team_id + ON CONFLICT (team_id) DO UPDATE + SET revision = authorization_team_revisions.revision + 1, + updated_at = clock_timestamp(); + $$; + + CREATE OR REPLACE FUNCTION authorization_bump_subject_revision( + target_subject_type TEXT, + target_subject_id TEXT + ) + RETURNS VOID + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = pg_catalog, public + AS $$ + BEGIN + IF target_subject_type NOT IN ('user', 'team', 'operator', 'host', 'context') THEN + RAISE EXCEPTION 'unmapped catalog subject type: %', target_subject_type; + END IF; + IF target_subject_type IN ('operator', 'host', 'context') THEN + RETURN; + END IF; + IF target_subject_id IS NULL OR target_subject_id = '' THEN + RETURN; + END IF; + IF target_subject_id !~* + '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + THEN + RAISE EXCEPTION 'invalid catalog % subject identifier', target_subject_type; + END IF; + IF target_subject_type = 'user' THEN + PERFORM authorization_bump_user_revision(target_subject_id::UUID); + ELSE + PERFORM authorization_bump_team_revision(target_subject_id::UUID); + END IF; + END; + $$; + + CREATE OR REPLACE FUNCTION authorization_bump_user_row_revision() + RETURNS TRIGGER + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = pg_catalog, public + AS $$ + BEGIN + IF TG_OP <> 'DELETE' THEN + PERFORM authorization_bump_user_revision(NEW.id); + END IF; + IF TG_OP = 'DELETE' OR (TG_OP = 'UPDATE' AND OLD.id IS DISTINCT FROM NEW.id) THEN + PERFORM authorization_bump_user_revision(OLD.id); + END IF; + RETURN COALESCE(NEW, OLD); + END; + $$; + + CREATE OR REPLACE FUNCTION authorization_bump_team_row_revision() + RETURNS TRIGGER + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = pg_catalog, public + AS $$ + BEGIN + IF TG_OP <> 'DELETE' THEN + PERFORM authorization_bump_team_revision(NEW.id); + END IF; + IF TG_OP = 'DELETE' OR (TG_OP = 'UPDATE' AND OLD.id IS DISTINCT FROM NEW.id) THEN + PERFORM authorization_bump_team_revision(OLD.id); + END IF; + RETURN COALESCE(NEW, OLD); + END; + $$; + + CREATE OR REPLACE FUNCTION authorization_bump_workflow_run_revision() + RETURNS TRIGGER + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = pg_catalog, public + AS $$ + DECLARE row_value RECORD; + BEGIN + FOR row_value IN + SELECT actor_type, actor_id::TEXT, team_id::TEXT, usage_team_id + FROM (VALUES (NEW.actor_type, NEW.actor_id, NEW.team_id, NEW.usage_team_id)) + AS current_row(actor_type, actor_id, team_id, usage_team_id) + WHERE TG_OP <> 'DELETE' + UNION ALL + SELECT actor_type, actor_id::TEXT, team_id::TEXT, usage_team_id + FROM (VALUES (OLD.actor_type, OLD.actor_id, OLD.team_id, OLD.usage_team_id)) + AS prior_row(actor_type, actor_id, team_id, usage_team_id) + WHERE TG_OP <> 'INSERT' + LOOP + IF row_value.actor_type = 'user' THEN + PERFORM authorization_bump_subject_revision('user', row_value.actor_id); + END IF; + PERFORM authorization_bump_subject_revision('team', row_value.team_id); + PERFORM authorization_bump_subject_revision('team', row_value.usage_team_id); + END LOOP; + RETURN COALESCE(NEW, OLD); + END; + $$; + + CREATE OR REPLACE FUNCTION authorization_bump_workflow_approval_revision() + RETURNS TRIGGER + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = pg_catalog, public + AS $$ + BEGIN + IF TG_OP <> 'DELETE' THEN + PERFORM authorization_bump_subject_revision('user', NEW.target_user_id::TEXT); + PERFORM authorization_bump_subject_revision('team', NEW.target_team_id::TEXT); + END IF; + IF TG_OP <> 'INSERT' THEN + PERFORM authorization_bump_subject_revision('user', OLD.target_user_id::TEXT); + PERFORM authorization_bump_subject_revision('team', OLD.target_team_id::TEXT); + END IF; + RETURN COALESCE(NEW, OLD); + END; + $$; + + CREATE OR REPLACE FUNCTION authorization_bump_notification_revision() + RETURNS TRIGGER + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = pg_catalog, public + AS $$ + BEGIN + IF TG_OP <> 'DELETE' THEN + PERFORM authorization_bump_subject_revision('user', NEW.audience->>'userId'); + PERFORM authorization_bump_subject_revision('team', NEW.audience->>'teamId'); + END IF; + IF TG_OP <> 'INSERT' THEN + PERFORM authorization_bump_subject_revision('user', OLD.audience->>'userId'); + PERFORM authorization_bump_subject_revision('team', OLD.audience->>'teamId'); + END IF; + RETURN COALESCE(NEW, OLD); + END; + $$; + + CREATE OR REPLACE FUNCTION authorization_bump_gfs_subject_revision() + RETURNS TRIGGER + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = pg_catalog, public + AS $$ + BEGIN + IF TG_OP <> 'DELETE' THEN + PERFORM authorization_bump_subject_revision(NEW.subject_type, NEW.subject_id); + END IF; + IF TG_OP <> 'INSERT' THEN + PERFORM authorization_bump_subject_revision(OLD.subject_type, OLD.subject_id); + END IF; + RETURN COALESCE(NEW, OLD); + END; + $$; + + CREATE OR REPLACE FUNCTION authorization_bump_gfs_resource_subjects(target_resource_id UUID) + RETURNS VOID + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = pg_catalog, public + AS $$ + DECLARE subject RECORD; + BEGIN + FOR subject IN + SELECT grant_row.subject_type, grant_row.subject_id + FROM gfs_grants grant_row + WHERE grant_row.resource_id = target_resource_id + UNION + SELECT share_row.subject_type, share_row.subject_id + FROM gfs_shares share_row + WHERE share_row.resource_id = target_resource_id + LOOP + PERFORM authorization_bump_subject_revision(subject.subject_type, subject.subject_id); + END LOOP; + END; + $$; + + CREATE OR REPLACE FUNCTION authorization_bump_gfs_resource_revision() + RETURNS TRIGGER + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = pg_catalog, public + AS $$ + BEGIN + IF TG_OP <> 'DELETE' THEN + PERFORM authorization_bump_gfs_resource_subjects(NEW.resource_id); + END IF; + IF TG_OP <> 'INSERT' AND (TG_OP = 'DELETE' OR OLD.resource_id IS DISTINCT FROM NEW.resource_id) + THEN + PERFORM authorization_bump_gfs_resource_subjects(OLD.resource_id); + END IF; + RETURN COALESCE(NEW, OLD); + END; + $$; + + DROP TRIGGER IF EXISTS users_catalog_revision ON users; + DROP TRIGGER IF EXISTS teams_catalog_revision ON teams; + DROP TRIGGER IF EXISTS team_members_catalog_revision ON team_members; + DROP TRIGGER IF EXISTS user_contexts_catalog_revision ON user_contexts; + DROP TRIGGER IF EXISTS team_contexts_catalog_revision ON team_contexts; + DROP TRIGGER IF EXISTS user_agents_catalog_revision ON user_agents; + DROP TRIGGER IF EXISTS team_agents_catalog_revision ON team_agents; + DROP TRIGGER IF EXISTS user_workflow_triggers_catalog_revision ON user_workflow_triggers; + DROP TRIGGER IF EXISTS team_workflow_triggers_catalog_revision ON team_workflow_triggers; + DROP TRIGGER IF EXISTS workflow_runs_catalog_revision ON workflow_runs; + DROP TRIGGER IF EXISTS workflow_approval_requests_catalog_revision ON workflow_approval_requests; + DROP TRIGGER IF EXISTS notification_deliveries_catalog_revision ON notification_deliveries; + DROP TRIGGER IF EXISTS gfs_resources_catalog_revision ON gfs_resources; + DROP TRIGGER IF EXISTS gfs_grants_catalog_revision ON gfs_grants; + DROP TRIGGER IF EXISTS gfs_shares_catalog_revision ON gfs_shares; + DROP TRIGGER IF EXISTS operational_resource_index_catalog_revision ON operational_resource_index; + DROP TRIGGER IF EXISTS operational_resource_relationships_catalog_revision + ON operational_resource_relationships; + DROP TRIGGER IF EXISTS operational_catalog_source_state_catalog_revision + ON operational_catalog_source_state; + + DROP TRIGGER IF EXISTS users_authorization_revision ON users; + CREATE TRIGGER users_authorization_revision + AFTER INSERT OR UPDATE OR DELETE ON users + FOR EACH ROW EXECUTE FUNCTION authorization_bump_user_row_revision(); + DROP TRIGGER IF EXISTS teams_authorization_revision ON teams; + CREATE TRIGGER teams_authorization_revision + AFTER INSERT OR UPDATE OR DELETE ON teams + FOR EACH ROW EXECUTE FUNCTION authorization_bump_team_row_revision(); + DROP TRIGGER IF EXISTS workflow_runs_authorization_revision ON workflow_runs; + CREATE TRIGGER workflow_runs_authorization_revision + AFTER INSERT OR UPDATE OR DELETE ON workflow_runs + FOR EACH ROW EXECUTE FUNCTION authorization_bump_workflow_run_revision(); + DROP TRIGGER IF EXISTS workflow_approval_requests_authorization_revision + ON workflow_approval_requests; + CREATE TRIGGER workflow_approval_requests_authorization_revision + AFTER INSERT OR UPDATE OR DELETE ON workflow_approval_requests + FOR EACH ROW EXECUTE FUNCTION authorization_bump_workflow_approval_revision(); + DROP TRIGGER IF EXISTS notification_deliveries_authorization_revision + ON notification_deliveries; + CREATE TRIGGER notification_deliveries_authorization_revision + AFTER INSERT OR UPDATE OR DELETE ON notification_deliveries + FOR EACH ROW EXECUTE FUNCTION authorization_bump_notification_revision(); + DROP TRIGGER IF EXISTS gfs_grants_authorization_revision ON gfs_grants; + CREATE TRIGGER gfs_grants_authorization_revision + AFTER INSERT OR UPDATE OR DELETE ON gfs_grants + FOR EACH ROW EXECUTE FUNCTION authorization_bump_gfs_subject_revision(); + DROP TRIGGER IF EXISTS gfs_shares_authorization_revision ON gfs_shares; + CREATE TRIGGER gfs_shares_authorization_revision + AFTER INSERT OR UPDATE OR DELETE ON gfs_shares + FOR EACH ROW EXECUTE FUNCTION authorization_bump_gfs_subject_revision(); + DROP TRIGGER IF EXISTS gfs_resources_authorization_revision ON gfs_resources; + CREATE TRIGGER gfs_resources_authorization_revision + AFTER INSERT OR UPDATE OR DELETE ON gfs_resources + FOR EACH ROW EXECUTE FUNCTION authorization_bump_gfs_resource_revision(); + + DROP FUNCTION IF EXISTS authorization_bump_catalog_revision(); + DROP TABLE IF EXISTS authorization_catalog_revision; + + REVOKE ALL ON TABLE authorization_catalog_writer_components FROM PUBLIC; + GRANT SELECT ON TABLE authorization_catalog_writer_components TO control_api_runtime; + REVOKE ALL ON FUNCTION authorization_bump_subject_revision(TEXT, TEXT) FROM PUBLIC; + REVOKE ALL ON FUNCTION authorization_bump_user_row_revision() FROM PUBLIC; + REVOKE ALL ON FUNCTION authorization_bump_team_row_revision() FROM PUBLIC; + REVOKE ALL ON FUNCTION authorization_bump_workflow_run_revision() FROM PUBLIC; + REVOKE ALL ON FUNCTION authorization_bump_workflow_approval_revision() FROM PUBLIC; + REVOKE ALL ON FUNCTION authorization_bump_notification_revision() FROM PUBLIC; + REVOKE ALL ON FUNCTION authorization_bump_gfs_subject_revision() FROM PUBLIC; + REVOKE ALL ON FUNCTION authorization_bump_gfs_resource_subjects(UUID) FROM PUBLIC; + REVOKE ALL ON FUNCTION authorization_bump_gfs_resource_revision() FROM PUBLIC; + `) +} + async function applyAuthorizationRevisionFunctions(db: DbClient): Promise { await db.query(` CREATE OR REPLACE FUNCTION authorization_bump_user_revision(target_user_id UUID) diff --git a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts index 4a8d0378bb..439f5bd745 100644 --- a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts +++ b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts @@ -341,7 +341,6 @@ describeRealPostgres('distinct catalog key pagination on real PostgreSQL', () => sessionContract: 'v2', sessionRevision: '1', userRevision: '1', - catalogRevision: '1', authorizationRevision: '1', memberships: teamIds.map(teamId => ({ teamId, role: 'member' })), }, @@ -394,7 +393,6 @@ describeRealPostgres('distinct catalog key pagination on real PostgreSQL', () => sessionContract: 'v2', sessionRevision: '1', userRevision: '1', - catalogRevision: '1', authorizationRevision: '1', memberships: teamIds.map(teamId => ({ teamId, role: 'member' })), }, diff --git a/control-api/test/catalogProducers.realPostgres.integration.test.ts b/control-api/test/catalogProducers.realPostgres.integration.test.ts index fe343b4ecd..9f054c4f4b 100644 --- a/control-api/test/catalogProducers.realPostgres.integration.test.ts +++ b/control-api/test/catalogProducers.realPostgres.integration.test.ts @@ -141,7 +141,6 @@ describeRealPostgres('catalog producer SQL on real PostgreSQL', () => { sessionContract: 'v2', sessionRevision: '1', userRevision: '1', - catalogRevision: '1', authorizationRevision: 'catalog-authorization-1', memberships: [], }, diff --git a/control-api/test/catalogProducers.test.ts b/control-api/test/catalogProducers.test.ts index dc588082bb..406a908168 100644 --- a/control-api/test/catalogProducers.test.ts +++ b/control-api/test/catalogProducers.test.ts @@ -34,7 +34,6 @@ function context( sessionContract: 'v2', sessionRevision: 'session-1', userRevision: 'user-1', - catalogRevision: 'catalog-1', authorizationRevision: 'catalog-authorization-1', memberships: [], }, diff --git a/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts b/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts new file mode 100644 index 0000000000..516ca9b45f --- /dev/null +++ b/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts @@ -0,0 +1,337 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { randomBytes, randomUUID } from 'node:crypto' +import { Pool, type PoolClient } from 'pg' +import { type DbClient, initDb } from '../src/db.js' +import { buildAccessCatalog } from '../src/services/access/accessCatalogCoordinator.js' +import { AccessCatalogCursorError } from '../src/services/access/accessCatalogCursor.js' +import { applyComposableCatalogRevisionSchema } from '../src/services/access/userAccessFoundationSchema.js' +import type { ExternalSessionAuthorityContext } from '../src/services/auth/externalSessionAuthentication.js' + +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const describeRealPostgres = adminUrl ? describe : describe.skip +const runtimeRoles = [ + 'control_api_runtime', + 'trace_maintenance_runtime', + 'workflow_recipes_runtime', +] as const + +function databaseUrl(baseUrl: string, database: string): string { + const value = new URL(baseUrl) + value.pathname = `/${database}` + return value.toString() +} + +function quoteIdentifier(value: string): string { + return `"${value.replace(/"/g, '""')}"` +} + +function transaction(pool: Pool) { + return async (work: (db: DbClient) => Promise): Promise => { + const client = await pool.connect() + try { + await client.query('BEGIN') + const result = await work(client) + await client.query('COMMIT') + return result + } catch (error) { + await client.query('ROLLBACK') + throw error + } finally { + client.release() + } + } +} + +describeRealPostgres('composable catalog revisions on real PostgreSQL', () => { + const database = `control_api_catalog_revision_${randomBytes(6).toString('hex')}` + const connectionString = databaseUrl( + adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', + database + ) + const userIds = [randomUUID(), randomUUID()] + const teamIds = [randomUUID(), randomUUID()] + let adminPool: Pool + let databasePool: Pool + + async function userRevision(userId: string): Promise { + const result = await databasePool.query<{ revision: string }>( + `SELECT revision::text FROM authorization_user_revisions WHERE user_id = $1`, + [userId] + ) + return Number(result.rows[0]?.revision ?? 1) + } + + async function backendPid(client: PoolClient): Promise { + const result = await client.query<{ pid: number }>('SELECT pg_backend_pid() AS pid') + return result.rows[0]!.pid + } + + async function waitForBlock(blockedPid: number, blockerPid: number): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + const result = await databasePool.query<{ blocked: boolean }>( + `SELECT $2::int = ANY(pg_blocking_pids($1::int)) AS blocked`, + [blockedPid, blockerPid] + ) + if (result.rows[0]?.blocked) return + await new Promise(resolve => setTimeout(resolve, 10)) + } + throw new Error('expected revision writer to block on the same component') + } + + beforeAll(async () => { + adminPool = new Pool({ connectionString: adminUrl }) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) + databasePool = new Pool({ connectionString }) + await initDb({ connect: () => databasePool.connect() }) + for (const [index, userId] of userIds.entries()) { + await databasePool.query(`INSERT INTO users(id, email, name) VALUES ($1, $2, $3)`, [ + userId, + `revision-${index}-${userId}@example.test`, + `Revision ${index}`, + ]) + } + for (const [index, teamId] of teamIds.entries()) { + await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, $2)`, [ + teamId, + `Revision Team ${index}`, + ]) + await databasePool.query( + `INSERT INTO team_members(team_id, user_id, role, status) + VALUES ($1, $2, 'member', 'active')`, + [teamId, userIds[0]] + ) + } + }) + + afterAll(async () => { + await databasePool?.end() + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + await adminPool.end() + } + }) + + it('maps the complete writer class without a singleton revision trigger', async () => { + await applyComposableCatalogRevisionSchema(databasePool) + await applyComposableCatalogRevisionSchema(databasePool) + const mappings = await databasePool.query<{ + writer_table: string + component_class: string + }>( + `SELECT writer_table, component_class + FROM authorization_catalog_writer_components + ORDER BY catalog_utf8_bytes(writer_table)` + ) + expect(mappings.rows).toHaveLength(18) + + const globalTriggers = await databasePool.query<{ count: string }>( + `SELECT count(*)::text AS count + FROM pg_trigger + WHERE NOT tgisinternal AND tgname LIKE '%_catalog_revision'` + ) + expect(globalTriggers.rows).toEqual([{ count: '0' }]) + + const triggerTables = await databasePool.query<{ table_name: string }>( + `SELECT DISTINCT event_object_table AS table_name + FROM information_schema.triggers + WHERE trigger_name LIKE '%_authorization_revision'` + ) + const directlyVersioned = new Set(['operational_catalog_source_state']) + const triggered = new Set(triggerTables.rows.map(row => row.table_name)) + for (const mapping of mappings.rows) { + expect( + triggered.has(mapping.writer_table) || directlyVersioned.has(mapping.writer_table), + mapping.writer_table + ).toBe(true) + } + }) + + it('commits and rolls back only the relevant principal component', async () => { + const initial = await userRevision(userIds[0]) + const unrelatedInitial = await userRevision(userIds[1]) + + const rollback = await databasePool.connect() + try { + await rollback.query('BEGIN') + await rollback.query(`UPDATE users SET name = 'rolled back' WHERE id = $1`, [userIds[0]]) + await rollback.query('ROLLBACK') + } finally { + rollback.release() + } + expect(await userRevision(userIds[0])).toBe(initial) + + await databasePool.query(`UPDATE users SET name = 'committed' WHERE id = $1`, [userIds[0]]) + expect(await userRevision(userIds[0])).toBeGreaterThan(initial) + expect(await userRevision(userIds[1])).toBe(unrelatedInitial) + }) + + it('bumps the mapped principal for dynamic catalog writer classes', async () => { + let revision = await userRevision(userIds[0]) + const workflowRun = await databasePool.query<{ run_id: string }>( + `INSERT INTO workflow_runs( + recipe_namespace, recipe_name, phase, actor_type, actor_id, trigger_source + ) VALUES ('sandbox-recipes', 'revision-test', 'Pending', 'user', $1, 'onDemand') + RETURNING run_id::text`, + [userIds[0]] + ) + expect(await userRevision(userIds[0])).toBeGreaterThan(revision) + + revision = await userRevision(userIds[0]) + await databasePool.query( + `INSERT INTO workflow_approval_requests( + recipe_namespace, recipe_name, expires_at, status, target_user_id, + payload, idempotency_key + ) VALUES ('sandbox-recipes', 'revision-test', NOW() + INTERVAL '1 hour', + 'pending', $1, '{}'::jsonb, $2)`, + [userIds[0], `revision-${randomUUID()}`] + ) + expect(await userRevision(userIds[0])).toBeGreaterThan(revision) + + revision = await userRevision(userIds[0]) + await databasePool.query( + `INSERT INTO notification_deliveries( + event_type, dedupe_key, audience, payload, status + ) VALUES ('revision.test', $1, jsonb_build_object('userId', $2::text), '{}'::jsonb, 'queued')`, + [`revision-${randomUUID()}`, userIds[0]] + ) + expect(await userRevision(userIds[0])).toBeGreaterThan(revision) + + const resourceId = randomUUID() + await databasePool.query( + `INSERT INTO gfs_resources(resource_id, drive, name, kind) + VALUES ($1, '3rd', 'revision-test', 'file')`, + [resourceId] + ) + revision = await userRevision(userIds[0]) + await databasePool.query( + `INSERT INTO gfs_grants(drive, resource_id, subject_type, subject_id, permissions) + VALUES ('3rd', $1, 'user', $2, ARRAY['read']::text[])`, + [resourceId, userIds[0]] + ) + expect(await userRevision(userIds[0])).toBeGreaterThan(revision) + + revision = await userRevision(userIds[0]) + await databasePool.query( + `UPDATE gfs_resources SET name = 'revision-test-2' WHERE resource_id = $1`, + [resourceId] + ) + expect(await userRevision(userIds[0])).toBeGreaterThan(revision) + + revision = await userRevision(userIds[0]) + await databasePool.query(`DELETE FROM workflow_runs WHERE run_id = $1`, [ + workflowRun.rows[0]!.run_id, + ]) + expect(await userRevision(userIds[0])).toBeGreaterThan(revision) + }) + + it('does not serialize independent components and preserves same-component increments', async () => { + const first = await databasePool.connect() + const second = await databasePool.connect() + try { + await first.query('BEGIN') + await second.query('BEGIN') + await first.query('SELECT authorization_bump_user_revision($1)', [userIds[0]]) + + await expect( + second.query('SELECT authorization_bump_user_revision($1)', [userIds[1]]) + ).resolves.toBeDefined() + await second.query('COMMIT') + await first.query('COMMIT') + } finally { + first.release() + second.release() + } + + const before = await userRevision(userIds[0]) + const blocker = await databasePool.connect() + const blocked = await databasePool.connect() + try { + await blocker.query('BEGIN') + await blocked.query('BEGIN') + const blockerPid = await backendPid(blocker) + const blockedPid = await backendPid(blocked) + await blocker.query('SELECT authorization_bump_user_revision($1)', [userIds[0]]) + const pending = blocked.query('SELECT authorization_bump_user_revision($1)', [userIds[0]]) + await waitForBlock(blockedPid, blockerPid) + await blocker.query('COMMIT') + await pending + await blocked.query('COMMIT') + } finally { + blocker.release() + blocked.release() + } + expect(await userRevision(userIds[0])).toBe(before + 2) + }) + + it('rejects a cursor after a committed relevant component change', async () => { + const session: ExternalSessionAuthorityContext = { + contract: 'v1', + userId: userIds[0], + tokenHash: randomBytes(32).toString('hex'), + issuedAt: Math.floor(Date.now() / 1_000), + } + const first = await buildAccessCatalog( + { session, families: ['team'], limit: 1 }, + { transaction: transaction(databasePool) } + ) + expect(first.nextCursor).not.toBeNull() + + await databasePool.query(`UPDATE teams SET name = name || ' changed' WHERE id = $1`, [ + teamIds[0], + ]) + + await expect( + buildAccessCatalog( + { session, families: ['team'], limit: 1, cursor: first.nextCursor }, + { transaction: transaction(databasePool) } + ) + ).rejects.toEqual(expect.objectContaining({ code: 'stale_cursor' })) + }) + + it('reads authority data and its component from one repeatable-read snapshot', async () => { + const snapshot = await databasePool.connect() + try { + await snapshot.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY') + const before = await snapshot.query<{ name: string; revision: string }>( + `SELECT teams.name, revisions.revision::text + FROM teams + JOIN authorization_team_revisions revisions ON revisions.team_id = teams.id + WHERE teams.id = $1`, + [teamIds[1]] + ) + await databasePool.query(`UPDATE teams SET name = name || ' committed' WHERE id = $1`, [ + teamIds[1], + ]) + const during = await snapshot.query<{ name: string; revision: string }>( + `SELECT teams.name, revisions.revision::text + FROM teams + JOIN authorization_team_revisions revisions ON revisions.team_id = teams.id + WHERE teams.id = $1`, + [teamIds[1]] + ) + expect(during.rows).toEqual(before.rows) + await snapshot.query('COMMIT') + + const after = await databasePool.query<{ name: string; revision: string }>( + `SELECT teams.name, revisions.revision::text + FROM teams + JOIN authorization_team_revisions revisions ON revisions.team_id = teams.id + WHERE teams.id = $1`, + [teamIds[1]] + ) + expect(after.rows[0]?.name).not.toBe(before.rows[0]?.name) + expect(Number(after.rows[0]?.revision)).toBeGreaterThan(Number(before.rows[0]?.revision)) + } finally { + await snapshot.query('ROLLBACK').catch(() => undefined) + snapshot.release() + } + }) +}) diff --git a/control-api/test/db.composableCatalogRevisionMigration.test.ts b/control-api/test/db.composableCatalogRevisionMigration.test.ts new file mode 100644 index 0000000000..7354c5673e --- /dev/null +++ b/control-api/test/db.composableCatalogRevisionMigration.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('../src/config.js', () => ({ + config: { databaseUrl: 'postgresql://test' }, +})) + +describe('0104 composable catalog revisions migration', () => { + it('replaces the singleton trigger class with scoped transactional writers', async () => { + const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') + const migration = CONTROL_API_MIGRATIONS.find( + candidate => candidate.version === '0104_composable_catalog_revisions' + ) + expect(migration).toBeDefined() + + const query = vi.fn(async () => ({ rows: [], rowCount: 0 })) + await migration!.apply({ query }) + const sql = query.mock.calls.map(call => String(call[0])).join('\n') + const normalizedSql = sql.replace(/\s+/g, ' ') + + for (const table of [ + 'users', + 'teams', + 'team_members', + 'user_contexts', + 'team_contexts', + 'user_agents', + 'team_agents', + 'user_workflow_triggers', + 'team_workflow_triggers', + 'workflow_runs', + 'workflow_approval_requests', + 'notification_deliveries', + 'gfs_resources', + 'gfs_grants', + 'gfs_shares', + 'operational_resource_index', + 'operational_resource_relationships', + 'operational_catalog_source_state', + ]) { + expect(normalizedSql).toContain(`('${table}',`) + expect(normalizedSql).toContain( + `DROP TRIGGER IF EXISTS ${table}_catalog_revision ON ${table}` + ) + } + expect(sql).toContain('authorization_bump_workflow_run_revision') + expect(sql).toContain('authorization_bump_workflow_approval_revision') + expect(sql).toContain('authorization_bump_notification_revision') + expect(sql).toContain('authorization_bump_gfs_subject_revision') + expect(sql).toContain('authorization_bump_gfs_resource_revision') + expect(sql).toContain('DROP TABLE IF EXISTS authorization_catalog_revision') + expect(sql).not.toContain('UPDATE authorization_catalog_revision') + }) +}) diff --git a/control-api/test/db.userAccessFoundationMigration.test.ts b/control-api/test/db.userAccessFoundationMigration.test.ts index d94bf0bd8b..e0aac2b9c1 100644 --- a/control-api/test/db.userAccessFoundationMigration.test.ts +++ b/control-api/test/db.userAccessFoundationMigration.test.ts @@ -107,7 +107,7 @@ describe('user-access foundation migration', () => { ) const expected = new Map([ - ['authorization_catalog_revision', 'read'], + ['authorization_catalog_writer_components', 'read'], ['authorization_resource_revisions', 'upsert'], ['authorization_team_revisions', 'upsert'], ['authorization_user_revisions', 'upsert'], diff --git a/deploy/scripts/control-api-runtime-access-profiles.tsv b/deploy/scripts/control-api-runtime-access-profiles.tsv index f2aa9bcaff..d7ba7be666 100644 --- a/deploy/scripts/control-api-runtime-access-profiles.tsv +++ b/deploy/scripts/control-api-runtime-access-profiles.tsv @@ -1,7 +1,7 @@ # public relationcontrol_api_runtime access profile administrative_events append agent_run_events append -authorization_catalog_revision read +authorization_catalog_writer_components read authorization_resource_revisions upsert authorization_team_revisions upsert authorization_user_revisions upsert From 5d8371171d6f707b40fe83b90186952be291bd93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 24 Aug 2026 10:50:26 -0600 Subject: [PATCH 112/528] fix(control-api): r5-b3 drain pre-generation v1 sessions Class-check: legacy V1 generation interpretation -> 4 consumers -> share generation-one rule Repro-test: pre-generation V1 matrix fails at 83b8eb4e and passes after the fix --- .../auth/externalSessionAuthentication.ts | 6 ++- .../src/services/auth/legacyV1Generation.ts | 11 +++++ .../src/services/auth/userSessionService.ts | 10 ++--- .../utils/auth/externalSessionAuthToken.ts | 7 ++-- .../externalSessionAuthentication.test.ts | 42 ++++++++++++++++++- ...re.externalSessionAuth.legacyToken.test.ts | 37 +++++++++++++--- control-api/test/userSessionService.test.ts | 38 +++++++++++++++++ 7 files changed, 134 insertions(+), 17 deletions(-) create mode 100644 control-api/src/services/auth/legacyV1Generation.ts diff --git a/control-api/src/services/auth/externalSessionAuthentication.ts b/control-api/src/services/auth/externalSessionAuthentication.ts index e59f48fdb7..67c82694fa 100644 --- a/control-api/src/services/auth/externalSessionAuthentication.ts +++ b/control-api/src/services/auth/externalSessionAuthentication.ts @@ -10,6 +10,7 @@ import { compareSemanticVersions, } from '../access/userAccessPolicy.js' import { resolveEffectiveUserAccessPolicy } from '../access/userAccessRuntimePolicy.js' +import { legacyExternalSessionAuthGeneration } from './legacyV1Generation.js' import { type IssuedUserSession, renewUserSession, @@ -180,7 +181,10 @@ export async function authenticateExternalUserSession( return { status: 'authenticated', contract: 'v1', - claims: v1Claims, + claims: Object.freeze({ + ...v1Claims, + authGeneration: legacyExternalSessionAuthGeneration(v1Claims)!, + }), authorityContext: Object.freeze({ contract: 'v1', userId: v1Claims.userId, diff --git a/control-api/src/services/auth/legacyV1Generation.ts b/control-api/src/services/auth/legacyV1Generation.ts new file mode 100644 index 0000000000..e89129299c --- /dev/null +++ b/control-api/src/services/auth/legacyV1Generation.ts @@ -0,0 +1,11 @@ +import type { AuthClaims } from '../../profileTypes.js' + +/** Missing generation is the original deployed V1 lifecycle incarnation only. */ +export function legacyExternalSessionAuthGeneration( + claims: Pick +): number | null { + if (claims.authGeneration === undefined) return 1 + return Number.isSafeInteger(claims.authGeneration) && Number(claims.authGeneration) >= 1 + ? Number(claims.authGeneration) + : null +} diff --git a/control-api/src/services/auth/userSessionService.ts b/control-api/src/services/auth/userSessionService.ts index cd8f5770a5..a3490b1894 100644 --- a/control-api/src/services/auth/userSessionService.ts +++ b/control-api/src/services/auth/userSessionService.ts @@ -11,6 +11,7 @@ import { withAccessDatabaseTransaction, } from '../access/accessDatabaseQuery.js' import type { AccessExecutionBudget } from '../access/accessExecutionBudget.js' +import { legacyExternalSessionAuthGeneration } from './legacyV1Generation.js' export const USER_SESSION_IDLE_LIFETIME_SECONDS = 14 * 24 * 60 * 60 export const USER_SESSION_ABSOLUTE_LIFETIME_SECONDS = 30 * 24 * 60 * 60 @@ -425,7 +426,8 @@ export async function validateLegacyUserSession( ): Promise { const issuedAt = claims.iat if (!issuedAt) return { status: 'invalid', reason: 'invalid_legacy_representation' } - if (!Number.isSafeInteger(claims.authGeneration) || Number(claims.authGeneration) < 1) { + const authGeneration = legacyExternalSessionAuthGeneration(claims) + if (authGeneration === null) { return { status: 'invalid', reason: 'invalid_legacy_representation' } } const work = async (db: SessionDatabase): Promise => { @@ -455,11 +457,7 @@ export async function validateLegacyUserSession( } | undefined if (!row) return { status: 'invalid', reason: 'user_not_found' } - if ( - row.lifecycle_state !== 'active' || - !Number.isSafeInteger(claims.authGeneration) || - Number(claims.authGeneration) !== Number(row.lifecycle_version) - ) { + if (row.lifecycle_state !== 'active' || authGeneration !== Number(row.lifecycle_version)) { return { status: 'revoked', reason: 'security_event' } } if (row.token_revoked) return { status: 'revoked', reason: 'logout' } diff --git a/control-api/src/utils/auth/externalSessionAuthToken.ts b/control-api/src/utils/auth/externalSessionAuthToken.ts index 322169c15c..8a6a835139 100644 --- a/control-api/src/utils/auth/externalSessionAuthToken.ts +++ b/control-api/src/utils/auth/externalSessionAuthToken.ts @@ -2,6 +2,7 @@ import jwt from 'jsonwebtoken' import { createPublicKey } from 'node:crypto' import { config } from '../../config.js' import { AuthClaims, TEAM_ROLES } from '../../profileTypes.js' +import { legacyExternalSessionAuthGeneration } from '../../services/auth/legacyV1Generation.js' const ALLOWED_ROLES = new Set(TEAM_ROLES) const sessionJwtPublicKey = createPublicKey(config.sessionJwtPrivateKey).export({ @@ -53,8 +54,8 @@ export function verifyExternalSessionToken(token: string): AuthClaims | null { (teamId !== null && typeof teamId !== 'string') || typeof payload?.role !== 'string' || typeof payload?.exp !== 'number' || - !Number.isSafeInteger(authGeneration) || - Number(authGeneration) < 1 + (authGeneration !== undefined && + legacyExternalSessionAuthGeneration({ authGeneration }) === null) ) { return null } @@ -67,7 +68,7 @@ export function verifyExternalSessionToken(token: string): AuthClaims | null { email: payload.email, teamId, role: payload.role as AuthClaims['role'], - authGeneration: Number(authGeneration), + ...(authGeneration === undefined ? {} : { authGeneration: Number(authGeneration) }), exp: payload.exp, ...(typeof payload.iat === 'number' ? { iat: payload.iat } : {}), } diff --git a/control-api/test/externalSessionAuthentication.test.ts b/control-api/test/externalSessionAuthentication.test.ts index c054c8a95d..0569ccc059 100644 --- a/control-api/test/externalSessionAuthentication.test.ts +++ b/control-api/test/externalSessionAuthentication.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import jwt from 'jsonwebtoken' import { randomUUID } from 'node:crypto' +import { config } from '../src/config.js' import type { EffectiveUserAccessPolicy } from '../src/services/access/userAccessPolicy.js' import { authenticateExternalUserSession, @@ -69,6 +71,24 @@ function v1Token(): string { }) } +function preGenerationV1Token(): string { + return jwt.sign( + { + userId, + email: 'user@example.com', + teamId: '22222222-2222-4222-8222-222222222222', + role: 'member', + }, + config.sessionJwtPrivateKey, + { + algorithm: 'RS256', + expiresIn: 3600, + issuer: config.jwtIssuer, + audience: config.jwtAudience, + } + ) +} + function v2Token(identity = validIdentity()): string { return signUserSessionV2Token({ sub: identity.userId, @@ -93,7 +113,11 @@ describe('external user-session authentication boundary', () => { await expect( authenticateExternalUserSession(token, { purpose: 'protected', policy: policy() }) - ).resolves.toMatchObject({ status: 'authenticated', contract: 'v1' }) + ).resolves.toMatchObject({ + status: 'authenticated', + contract: 'v1', + claims: { authGeneration: 1 }, + }) await expect( authenticateExternalUserSession(token, { purpose: 'verify', @@ -102,6 +126,22 @@ describe('external user-session authentication boundary', () => { ).resolves.toEqual({ status: 'invalid', reason: 'session_contract_not_accepted' }) }) + it('subjects pre-generation v1 tokens to the same compatibility-drain gate', async () => { + const identity = validIdentity({ sid: '', sessionVersion: 0 }) + sessionMocks.validateLegacyUserSession.mockResolvedValue({ status: 'valid', identity }) + const token = preGenerationV1Token() + + await expect( + authenticateExternalUserSession(token, { purpose: 'protected', policy: policy() }) + ).resolves.toMatchObject({ status: 'authenticated', contract: 'v1' }) + await expect( + authenticateExternalUserSession(token, { + purpose: 'protected', + policy: policy({ acceptV1: false, issueV1: false }), + }) + ).resolves.toEqual({ status: 'invalid', reason: 'session_contract_not_accepted' }) + }) + it('normalizes v2 without team authority and applies purpose-specific acceptance', async () => { const identity = validIdentity() sessionMocks.validateUserSessionClaims.mockResolvedValue({ status: 'valid', identity }) diff --git a/control-api/test/middleware.externalSessionAuth.legacyToken.test.ts b/control-api/test/middleware.externalSessionAuth.legacyToken.test.ts index a707594ac9..44a2cbd06b 100644 --- a/control-api/test/middleware.externalSessionAuth.legacyToken.test.ts +++ b/control-api/test/middleware.externalSessionAuth.legacyToken.test.ts @@ -14,6 +14,19 @@ vi.mock('../src/db.js', () => ({ pool: { query: (...args: unknown[]) => poolQuery(...args) }, })) +vi.mock('../src/services/access/userAccessRuntimePolicy.js', () => ({ + resolveEffectiveUserAccessPolicy: vi.fn().mockResolvedValue({ + acceptV1: true, + issueV1: true, + acceptV2: true, + issueV2: false, + renewV2: false, + switchCompatibility: true, + minimumClientVersion: null, + enforceMinimumClient: false, + }), +})) + const sessionJwtPublicKey = createPublicKey(config.sessionJwtPrivateKey).export({ type: 'spki', format: 'pem', @@ -54,12 +67,20 @@ describe('external session legacy token cut-over', () => { beforeEach(() => { poolQuery.mockClear() poolQuery.mockResolvedValue({ - rows: [{ lifecycle_state: 'active', lifecycle_version: '1' }], + rows: [ + { + id: '11111111-1111-4111-8111-111111111111', + lifecycle_state: 'active', + lifecycle_version: '1', + valid_after: null, + token_revoked: false, + }, + ], rowCount: 1, }) }) - it('rejects a valid RS256 legacy token that has no auth generation', async () => { + it('accepts a producer-shaped pre-generation token only as lifecycle generation one', async () => { const token = legacySessionToken() const cryptographicallyValidClaims = jwt.verify(token, sessionJwtPublicKey, { @@ -68,12 +89,16 @@ describe('external session legacy token cut-over', () => { audience: config.jwtAudience, }) as jwt.JwtPayload expect(cryptographicallyValidClaims.authGeneration).toBeUndefined() - expect(verifyExternalSessionToken(token)).toBeNull() + const parsed = verifyExternalSessionToken(token) + expect(parsed).toEqual( + expect.objectContaining({ userId: '11111111-1111-4111-8111-111111111111' }) + ) + expect(parsed?.authGeneration).toBeUndefined() const response = await request(app()).get('/protected').set('x-user-session-token', token) - expect(response.status).toBe(401) - expect(response.body).toEqual({ error: 'Unauthorized' }) - expect(poolQuery).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect(response.body).toEqual({ ok: true }) + expect(poolQuery).toHaveBeenCalledTimes(1) }) }) diff --git a/control-api/test/userSessionService.test.ts b/control-api/test/userSessionService.test.ts index 72b47d3b83..45d2e0bfe9 100644 --- a/control-api/test/userSessionService.test.ts +++ b/control-api/test/userSessionService.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { randomUUID } from 'node:crypto' +import type { AuthClaims } from '../src/profileTypes.js' import { USER_SESSION_ABSOLUTE_LIFETIME_SECONDS, USER_SESSION_IDLE_LIFETIME_SECONDS, @@ -203,4 +204,41 @@ describe('user session state machine', () => { reason: 'security_event', }) }) + + it.each([ + ['active generation one', 'active', 1, null, false, 'valid'], + ['retired generation one', 'retired', 1, null, false, 'revoked'], + ['reactivated generation two', 'active', 2, null, false, 'revoked'], + ['password/security epoch', 'active', 1, now, false, 'revoked'], + ['explicit token revocation', 'active', 1, null, true, 'revoked'], + ] as const)( + 'maps a missing-generation legacy token against %s authority', + async (_label, lifecycleState, lifecycleVersion, validAfter, tokenRevoked, expectedStatus) => { + const claims: AuthClaims = { + userId: randomUUID(), + email: 'legacy@example.com', + teamId: null, + role: 'member', + exp: Math.floor(now.getTime() / 1000) + 3600, + iat: Math.floor(now.getTime() / 1000) - 1, + } + const db = { + query: vi.fn().mockResolvedValue({ + rows: [ + { + id: claims.userId, + lifecycle_state: lifecycleState, + lifecycle_version: lifecycleVersion, + valid_after: validAfter, + token_revoked: tokenRevoked, + }, + ], + rowCount: 1, + }), + } + + const result = await validateLegacyUserSession('legacy-v1-token', claims, { db }) + expect(result.status).toBe(expectedStatus) + } + ) }) From 8cc2699972601ee2a5682a1bc5d44ebd2a0b4030 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 24 Aug 2026 10:51:44 -0600 Subject: [PATCH 113/528] test(control-api): r5-h4 prove legacy admin revocation Class-check: legacy admin team mutations -> 3 routes -> cover demotion and membership removal Repro-test: acceptance proof passes at 15bb8fd8; runtime was already safe on all routes --- ...externalTeamsLegacyAdminRevocation.test.ts | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 control-api/test/routes.externalTeamsLegacyAdminRevocation.test.ts diff --git a/control-api/test/routes.externalTeamsLegacyAdminRevocation.test.ts b/control-api/test/routes.externalTeamsLegacyAdminRevocation.test.ts new file mode 100644 index 0000000000..3678b3f72b --- /dev/null +++ b/control-api/test/routes.externalTeamsLegacyAdminRevocation.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import request from 'supertest' +import { createExternalTeamsRouter } from '../src/routes/external/teams.js' + +const userId = '11111111-1111-4111-8111-111111111111' +const teamId = '22222222-2222-4222-8222-222222222222' +const targetUserId = '33333333-3333-4333-8333-333333333333' +const staleAdminToken = 'same-valid-v1-token-minted-while-admin' + +const auth = vi.hoisted(() => ({ authenticateExternalUserSession: vi.fn() })) +const database = vi.hoisted(() => ({ query: vi.fn() })) +const rateLimit = vi.hoisted(() => ({ checkAndIncrement: vi.fn() })) +const directory = vi.hoisted(() => ({ + createManagedInvitationForUser: vi.fn(), + createTeamForUser: vi.fn(), + deleteManagedMemberForUser: vi.fn(), + findMemberRole: vi.fn(), + getCurrentTeam: vi.fn(), + getTeamAgents: vi.fn(), + getTeamContexts: vi.fn(), + listMembers: vi.fn(), + renameTeamForUser: vi.fn(), + updateManagedMemberRoleForUser: vi.fn(), +})) + +vi.mock('../src/services/auth/externalSessionAuthentication.js', () => auth) +vi.mock('../src/db.js', () => ({ pool: database })) +vi.mock('../src/services/rateLimiterService.js', () => rateLimit) +vi.mock('../src/services/directory/index.js', async importOriginal => ({ + ...(await importOriginal()), + ...directory, +})) + +type AdminMutation = Readonly<{ + name: string + invoke: (server: express.Express) => request.Test + handler: keyof Pick< + typeof directory, + 'renameTeamForUser' | 'updateManagedMemberRoleForUser' | 'deleteManagedMemberForUser' + > +}> + +const mutations: readonly AdminMutation[] = [ + { + name: 'rename team', + invoke: server => + request(server).put(`/external/teams/${teamId}/name`).send({ name: 'Renamed' }), + handler: 'renameTeamForUser', + }, + { + name: 'change member role', + invoke: server => + request(server) + .patch(`/external/teams/${teamId}/members/${targetUserId}/role`) + .send({ role: 'member' }), + handler: 'updateManagedMemberRoleForUser', + }, + { + name: 'delete member', + invoke: server => request(server).delete(`/external/teams/${teamId}/members/${targetUserId}`), + handler: 'deleteManagedMemberForUser', + }, +] + +function app() { + const server = express() + server.use(express.json()) + server.use(createExternalTeamsRouter({} as never)) + return server +} + +describe('legacy V1 live-admin revocation on real external team routes', () => { + beforeEach(() => { + vi.clearAllMocks() + auth.authenticateExternalUserSession.mockResolvedValue({ + status: 'authenticated', + contract: 'v1', + claims: { + userId, + email: 'admin@example.test', + teamId, + role: 'admin', + authGeneration: 1, + iat: 1_787_596_800, + exp: 1_791_907_200, + }, + authorityContext: { + contract: 'v1', + userId, + tokenHash: 'a'.repeat(64), + issuedAt: 1_787_596_800, + }, + }) + rateLimit.checkAndIncrement.mockResolvedValue({ + allowed: true, + count: 1, + remaining: 9, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + }) + directory.renameTeamForUser.mockResolvedValue({ team: { id: teamId, name: 'Renamed' } }) + directory.updateManagedMemberRoleForUser.mockResolvedValue({ + membership: { userId: targetUserId, teamId, role: 'member' }, + }) + directory.deleteManagedMemberForUser.mockResolvedValue({ deleted: { userId: targetUserId } }) + }) + + for (const mutation of mutations) { + it.each([ + ['demoted', { rows: [{ team_id: teamId, role: 'member' }], rowCount: 1 }], + ['membership removed', { rows: [], rowCount: 0 }], + ])(`rejects ${mutation.name} with the same token after %s`, async (_state, membership) => { + database.query.mockResolvedValue(membership) + + const response = await mutation.invoke(app()).set('x-user-session-token', staleAdminToken) + + expect(response.status).toBe(403) + expect(directory[mutation.handler]).not.toHaveBeenCalled() + expect(database.query).toHaveBeenCalledWith(expect.stringContaining("tm.status = 'active'"), [ + userId, + teamId, + ]) + }) + + it(`allows ${mutation.name} only when the same token resolves to a current admin`, async () => { + database.query.mockResolvedValue({ + rows: [{ team_id: teamId, role: 'admin' }], + rowCount: 1, + }) + + const response = await mutation.invoke(app()).set('x-user-session-token', staleAdminToken) + + expect(response.status).toBe(200) + expect(directory[mutation.handler]).toHaveBeenCalledOnce() + }) + } +}) From baeee88f23859e8007599b4c8656dbd35e4b8025 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 24 Aug 2026 11:01:03 -0600 Subject: [PATCH 114/528] fix(control-api): r5-h5 serialize legacy session exchange Class-check: V1 replacement exchange -> 1 route and 5 password writers -> one user-first lock order Repro-test: mounted route fails at 91d530686; locked exchange and four PG races pass after fix --- .../services/auth/externalSessionIssuance.ts | 78 ++++- .../src/services/auth/userSessionService.ts | 47 +-- ...hangeRace.realPostgres.integration.test.ts | 267 ++++++++++++++++++ ...utes.externalSessionAtomicExchange.test.ts | 122 ++++++++ 4 files changed, 494 insertions(+), 20 deletions(-) create mode 100644 control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts create mode 100644 control-api/test/routes.externalSessionAtomicExchange.test.ts diff --git a/control-api/src/services/auth/externalSessionIssuance.ts b/control-api/src/services/auth/externalSessionIssuance.ts index 3a2bb71b19..e00d918c5f 100644 --- a/control-api/src/services/auth/externalSessionIssuance.ts +++ b/control-api/src/services/auth/externalSessionIssuance.ts @@ -1,12 +1,12 @@ -import type { DbClient } from '../../db.js' -import type { TeamRole } from '../../profileTypes.js' +import { type DbClient, withTransaction } from '../../db.js' +import type { AuthClaims, TeamRole } from '../../profileTypes.js' import { signExternalSessionToken } from '../../utils/auth/externalSessionAuthToken.js' import { type EffectiveUserAccessPolicy, compareSemanticVersions, } from '../access/userAccessPolicy.js' import type { ExternalSessionClient } from './externalSessionAuthentication.js' -import { createUserSession } from './userSessionService.js' +import { createUserSession, validateLegacyUserSession } from './userSessionService.js' export type ExternalSessionContract = 'v1' | 'v2' @@ -14,6 +14,13 @@ export type ExternalSessionSelection = | { status: 'selected'; contract: ExternalSessionContract } | { status: 'upgrade_required'; reason: string } +export type LegacyExternalSessionExchange = + | { status: 'issued'; token: string; role: TeamRole } + | { status: 'invalid_session' } + | { status: 'membership_not_found' } + +type SessionTransaction = (work: (db: DbClient) => Promise) => Promise + export function selectExternalSessionRepresentation( client: ExternalSessionClient, policy: EffectiveUserAccessPolicy @@ -88,3 +95,68 @@ export async function issueExternalUserSession( contract: 'v1', } } + +/** + * Revalidates and replaces a legacy V1 representation from one locked snapshot. + * Lock order: user/lifecycle -> epoch/revocation reads -> membership -> signing. + */ +export async function exchangeLegacyExternalUserSession( + input: { + token: string + claims: AuthClaims + userId: string + email: string + teamId: string + }, + options: { + policy: EffectiveUserAccessPolicy + transaction?: SessionTransaction + } +): Promise { + if ( + input.claims.userId !== input.userId || + input.claims.email.toLowerCase() !== input.email.toLowerCase() + ) { + return { status: 'invalid_session' } + } + const run = options.transaction ?? withTransaction + return run(async db => { + const validation = await validateLegacyUserSession(input.token, input.claims, { + db, + lockUser: true, + }) + if (validation.status !== 'valid') return { status: 'invalid_session' } + + const membership = await db.query( + `SELECT tm.role, u.lifecycle_version + FROM users u + JOIN team_members tm ON tm.user_id = u.id + WHERE u.id = $1 + AND LOWER(u.email) = LOWER($2) + AND tm.team_id = $3 + AND tm.status = 'active' + AND u.lifecycle_state = 'active' + LIMIT 1 + FOR UPDATE OF tm`, + [input.userId, input.email, input.teamId] + ) + const row = membership.rows[0] as + | { role?: TeamRole; lifecycle_version?: number | string } + | undefined + if (!row?.role) return { status: 'membership_not_found' } + + const issued = await issueExternalUserSession( + { + contract: 'v1', + userId: input.userId, + email: input.email, + teamId: input.teamId, + role: row.role, + authGeneration: Number(row.lifecycle_version), + authenticationMethods: [], + }, + { db, policy: options.policy } + ) + return { status: 'issued', token: issued.token, role: row.role } + }) +} diff --git a/control-api/src/services/auth/userSessionService.ts b/control-api/src/services/auth/userSessionService.ts index a3490b1894..1cd8e0be2c 100644 --- a/control-api/src/services/auth/userSessionService.ts +++ b/control-api/src/services/auth/userSessionService.ts @@ -422,7 +422,7 @@ function legacySessionFingerprint(token: string): string { export async function validateLegacyUserSession( token: string, claims: AuthClaims, - options: { db?: SessionDatabase; budget?: AccessExecutionBudget } = {} + options: { db?: SessionDatabase; budget?: AccessExecutionBudget; lockUser?: boolean } = {} ): Promise { const issuedAt = claims.iat if (!issuedAt) return { status: 'invalid', reason: 'invalid_legacy_representation' } @@ -431,6 +431,12 @@ export async function validateLegacyUserSession( return { status: 'invalid', reason: 'invalid_legacy_representation' } } const work = async (db: SessionDatabase): Promise => { + if (options.lockUser) { + const locked = await db.query(`SELECT id FROM users WHERE id = $1 FOR UPDATE`, [ + claims.userId, + ]) + if ((locked.rowCount ?? 0) === 0) return { status: 'revoked', reason: 'user_unavailable' } + } const result = await db.query( `SELECT u.id, u.lifecycle_state, u.lifecycle_version, epoch.valid_after, @@ -488,23 +494,30 @@ export async function revokeLegacyUserSession( token: string, claims: AuthClaims, reason: string, - db: SessionDatabase = pool, + db?: SessionDatabase, now = new Date() ): Promise { - const result = await db.query( - `INSERT INTO external_v1_session_revocations( - token_hash, user_id, expires_at, revoked_at, reason - ) - VALUES($1, $2, $3, $4, $5) - ON CONFLICT (token_hash) DO NOTHING - RETURNING token_hash`, - [ - legacySessionFingerprint(token), + const work = async (transaction: SessionDatabase): Promise => { + const user = await transaction.query(`SELECT id FROM users WHERE id = $1 FOR UPDATE`, [ claims.userId, - new Date(claims.exp * 1000), - atSecond(now), - reason, - ] - ) - return (result.rowCount ?? 0) > 0 + ]) + if ((user.rowCount ?? 0) === 0) return false + const result = await transaction.query( + `INSERT INTO external_v1_session_revocations( + token_hash, user_id, expires_at, revoked_at, reason + ) + VALUES($1, $2, $3, $4, $5) + ON CONFLICT (token_hash) DO NOTHING + RETURNING token_hash`, + [ + legacySessionFingerprint(token), + claims.userId, + new Date(claims.exp * 1000), + atSecond(now), + reason, + ] + ) + return (result.rowCount ?? 0) > 0 + } + return db ? work(db) : withTransaction(work) } diff --git a/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts b/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts new file mode 100644 index 0000000000..7f38399efb --- /dev/null +++ b/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts @@ -0,0 +1,267 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { randomBytes, randomUUID } from 'node:crypto' +import { Pool, type PoolClient } from 'pg' +import { type DbClient, initDb } from '../src/db.js' +import type { AuthClaims } from '../src/profileTypes.js' +import type { EffectiveUserAccessPolicy } from '../src/services/access/userAccessPolicy.js' +import { exchangeLegacyExternalUserSession } from '../src/services/auth/externalSessionIssuance.js' +import { + revokeAllUserSessions, + validateLegacyUserSession, +} from '../src/services/auth/userSessionService.js' +import { + signExternalSessionToken, + verifyExternalSessionToken, +} from '../src/utils/auth/externalSessionAuthToken.js' + +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const describeRealPostgres = adminUrl ? describe : describe.skip +const runtimeRoles = [ + 'control_api_runtime', + 'trace_maintenance_runtime', + 'workflow_recipes_runtime', +] as const + +function databaseUrl(baseUrl: string, database: string): string { + const value = new URL(baseUrl) + value.pathname = `/${database}` + return value.toString() +} + +function quoteIdentifier(value: string): string { + return `"${value.replace(/"/g, '""')}"` +} + +function deferred() { + let resolve!: (value: T | PromiseLike) => void + const promise = new Promise(value => { + resolve = value + }) + return { promise, resolve } +} + +describeRealPostgres('legacy replacement exchange serialization on real PostgreSQL', () => { + const database = `control_api_legacy_exchange_${randomBytes(6).toString('hex')}` + const connectionString = databaseUrl( + adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', + database + ) + const policy = { + policyVersion: '1', + policyRevision: 'legacy-exchange-race', + acceptV1: true, + issueV1: true, + acceptV2: false, + issueV2: false, + renewV2: false, + switchCompatibility: true, + } as EffectiveUserAccessPolicy + let adminPool: Pool + let databasePool: Pool + + async function backendPid(client: PoolClient): Promise { + const result = await client.query<{ pid: number }>('SELECT pg_backend_pid() AS pid') + return result.rows[0]!.pid + } + + async function waitForBlock(blockedPid: number, blockerPid: number): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + const result = await databasePool.query<{ blocked: boolean }>( + `SELECT $2::int = ANY(pg_blocking_pids($1::int)) AS blocked`, + [blockedPid, blockerPid] + ) + if (result.rows[0]?.blocked) return + await new Promise(resolve => setTimeout(resolve, 10)) + } + throw new Error('expected legacy exchange to block on the user security boundary') + } + + async function principal(label: string): Promise<{ + userId: string + teamId: string + email: string + token: string + claims: AuthClaims + }> { + const userId = randomUUID() + const teamId = randomUUID() + const email = `${label}-${userId}@example.test` + await databasePool.query( + `INSERT INTO users(id, email, name, password_hash) VALUES ($1, $2, $3, 'old-hash')`, + [userId, email, label] + ) + await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, $2)`, [teamId, label]) + await databasePool.query( + `INSERT INTO team_members(team_id, user_id, role, status) + VALUES ($1, $2, 'admin', 'active')`, + [teamId, userId] + ) + const token = signExternalSessionToken({ + userId, + email, + teamId, + role: 'admin', + authGeneration: 1, + }) + const claims = verifyExternalSessionToken(token) + if (!claims) throw new Error('test producer failed to verify its V1 token') + return { userId, teamId, email, token, claims } + } + + async function beginSecurityMutation( + client: PoolClient, + userId: string, + label: string, + validAfter: Date + ): Promise { + await client.query('BEGIN') + await client.query(`UPDATE users SET password_hash = $2 WHERE id = $1`, [ + userId, + `${label}-new-hash`, + ]) + await revokeAllUserSessions(userId, 'password_changed', client, validAfter) + } + + beforeAll(async () => { + adminPool = new Pool({ connectionString: adminUrl }) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) + databasePool = new Pool({ connectionString }) + await initDb({ connect: () => databasePool.connect() }) + }) + + afterAll(async () => { + await databasePool?.end() + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + await adminPool.end() + } + }) + + it.each(['normal password change', 'password reset'])( + 'fails %s exchange when invalidation wins the lock order', + async label => { + const source = await principal(`invalidation-first-${label.replaceAll(' ', '-')}`) + const invalidation = await databasePool.connect() + const exchangeStarted = deferred() + try { + const validAfter = new Date((source.claims.iat! + 60) * 1000) + await beginSecurityMutation(invalidation, source.userId, label, validAfter) + const invalidationPid = await backendPid(invalidation) + + const exchange = exchangeLegacyExternalUserSession( + { + token: source.token, + claims: source.claims, + userId: source.userId, + email: source.email, + teamId: source.teamId, + }, + { + policy, + transaction: async work => { + const client = await databasePool.connect() + try { + await client.query('BEGIN') + exchangeStarted.resolve(await backendPid(client)) + const result = await work(client as unknown as DbClient) + await client.query('COMMIT') + return result + } catch (error) { + await client.query('ROLLBACK') + throw error + } finally { + client.release() + } + }, + } + ) + const exchangePid = await exchangeStarted.promise + await waitForBlock(exchangePid, invalidationPid) + await invalidation.query('COMMIT') + + await expect(exchange).resolves.toEqual({ status: 'invalid_session' }) + } finally { + await invalidation.query('ROLLBACK').catch(() => undefined) + invalidation.release() + } + } + ) + + it.each(['normal password change', 'password reset'])( + 'invalidates the replacement from %s when exchange wins the lock order', + async label => { + const source = await principal(`exchange-first-${label.replaceAll(' ', '-')}`) + const exchangeReady = deferred<{ result: { status: string; token?: string }; pid: number }>() + const releaseExchange = deferred() + const exchange = exchangeLegacyExternalUserSession( + { + token: source.token, + claims: source.claims, + userId: source.userId, + email: source.email, + teamId: source.teamId, + }, + { + policy, + transaction: async work => { + const client = await databasePool.connect() + try { + await client.query('BEGIN') + const result = await work(client as unknown as DbClient) + exchangeReady.resolve({ result, pid: await backendPid(client) }) + await releaseExchange.promise + await client.query('COMMIT') + return result + } catch (error) { + await client.query('ROLLBACK') + throw error + } finally { + client.release() + } + }, + } + ) + const lockedExchange = await exchangeReady.promise + expect(lockedExchange.result.status).toBe('issued') + + const invalidation = await databasePool.connect() + try { + const invalidationStarted = deferred() + const invalidationWork = (async () => { + invalidationStarted.resolve(await backendPid(invalidation)) + await beginSecurityMutation( + invalidation, + source.userId, + label, + new Date((source.claims.iat! + 60) * 1000) + ) + await invalidation.query('COMMIT') + })() + const invalidationPid = await invalidationStarted.promise + await waitForBlock(invalidationPid, lockedExchange.pid) + releaseExchange.resolve() + const issued = await exchange + await invalidationWork + expect(issued.status).toBe('issued') + if (issued.status !== 'issued') throw new Error('exchange did not issue a token') + const replacementClaims = verifyExternalSessionToken(issued.token) + if (!replacementClaims) throw new Error('replacement token was not producer-shaped') + await expect( + validateLegacyUserSession(issued.token, replacementClaims, { db: databasePool }) + ).resolves.toMatchObject({ status: 'revoked' }) + } finally { + releaseExchange.resolve() + await invalidation.query('ROLLBACK').catch(() => undefined) + invalidation.release() + } + } + ) +}) diff --git a/control-api/test/routes.externalSessionAtomicExchange.test.ts b/control-api/test/routes.externalSessionAtomicExchange.test.ts new file mode 100644 index 0000000000..d18167da6e --- /dev/null +++ b/control-api/test/routes.externalSessionAtomicExchange.test.ts @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import request from 'supertest' +import { createExternalAuthRouter } from '../src/routes/external/auth.js' + +const sessions = vi.hoisted(() => ({ authenticateExternalUserSession: vi.fn() })) +const issuance = vi.hoisted(() => ({ + exchangeLegacyExternalUserSession: vi.fn(), + issueExternalUserSession: vi.fn(), + selectExternalSessionRepresentation: vi.fn(), +})) +const limiter = vi.hoisted(() => ({ checkAndIncrement: vi.fn() })) +const database = vi.hoisted(() => ({ query: vi.fn() })) + +vi.mock('../src/services/auth/externalSessionAuthentication.js', () => ({ + ...sessions, + renewExternalUserSession: vi.fn(), +})) +vi.mock('../src/services/auth/externalSessionIssuance.js', () => issuance) +vi.mock('../src/services/rateLimiterService.js', () => limiter) +vi.mock('../src/db.js', () => ({ pool: { query: database.query } })) + +function app() { + const value = express() + value.use(express.json()) + value.use(createExternalAuthRouter({} as never)) + return value +} + +describe('legacy external-session replacement exchange', () => { + const claims = { + userId: 'user-1', + email: 'user@example.test', + teamId: 'team-old', + role: 'admin' as const, + authGeneration: 1, + iat: 1_700_000_000, + exp: 1_800_000_000, + } + + beforeEach(() => { + vi.clearAllMocks() + limiter.checkAndIncrement.mockResolvedValue({ + allowed: true, + remaining: 9, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + count: 1, + }) + sessions.authenticateExternalUserSession.mockResolvedValue({ + status: 'authenticated', + contract: 'v1', + claims, + authorityContext: { + contract: 'v1', + userId: claims.userId, + tokenHash: 'source-hash', + issuedAt: claims.iat, + }, + policy: { issueV1: true, switchCompatibility: true }, + }) + issuance.issueExternalUserSession.mockResolvedValue({ token: 'stale-token', contract: 'v1' }) + database.query.mockResolvedValue({ + rows: [{ role: 'admin', lifecycle_version: 1 }], + rowCount: 1, + }) + }) + + it('fails closed when locked final validation observes a password invalidation', async () => { + issuance.exchangeLegacyExternalUserSession.mockResolvedValue({ status: 'invalid_session' }) + + const response = await request(app()) + .post('/external/auth/session-token') + .set('x-forwarded-for', '203.0.113.44') + .set('x-user-session-token', 'source-token') + .send({ userId: claims.userId, email: claims.email, teamId: 'team-new' }) + + expect(response.status).toBe(401) + expect(response.body).toMatchObject({ error: { code: 'invalid_session' } }) + expect(issuance.exchangeLegacyExternalUserSession).toHaveBeenCalledWith( + expect.objectContaining({ token: 'source-token', teamId: 'team-new', claims }), + expect.objectContaining({ policy: expect.any(Object) }) + ) + expect(issuance.issueExternalUserSession).not.toHaveBeenCalled() + }) + + it('uses the current locked membership role for the replacement representation', async () => { + issuance.exchangeLegacyExternalUserSession.mockResolvedValue({ + status: 'issued', + token: 'replacement-token', + role: 'member', + }) + + const response = await request(app()) + .post('/external/auth/session-token') + .set('x-forwarded-for', '203.0.113.44') + .set('x-user-session-token', 'source-token') + .send({ userId: claims.userId, email: claims.email, teamId: 'team-new' }) + .expect(200) + + expect(response.body).toEqual({ + token: 'replacement-token', + sessionContract: 'v1', + deprecated: true, + }) + }) + + it('rejects a removed membership without signing a replacement', async () => { + issuance.exchangeLegacyExternalUserSession.mockResolvedValue({ + status: 'membership_not_found', + }) + + await request(app()) + .post('/external/auth/session-token') + .set('x-forwarded-for', '203.0.113.44') + .set('x-user-session-token', 'source-token') + .send({ userId: claims.userId, email: claims.email, teamId: 'team-new' }) + .expect(403, { error: 'membership_not_found' }) + + expect(issuance.issueExternalUserSession).not.toHaveBeenCalled() + }) +}) From a3740a3ede876ec57829c08b3f89d82f61a505ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 24 Aug 2026 11:07:40 -0600 Subject: [PATCH 115/528] fix(control-api): r5-b4 keep catalog byte ordering bounded Class-check: catalog order SQL -> 11 UUID arms and 1 Team-GFS stream -> preserve bounded order Repro-test: real catalog suites fail at cc39c10dc; 26 PostgreSQL cases pass after fix --- .../services/access/catalogProducerArms.ts | 6 +++++- .../src/services/access/teamGfsTopK.ts | 12 +++++++---- ...Producers.realPostgres.integration.test.ts | 21 +++++++++++++++++++ 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/control-api/src/services/access/catalogProducerArms.ts b/control-api/src/services/access/catalogProducerArms.ts index 4468acd811..1724c9bc0c 100644 --- a/control-api/src/services/access/catalogProducerArms.ts +++ b/control-api/src/services/access/catalogProducerArms.ts @@ -29,6 +29,10 @@ export function boundedKeyUnionSql(arms: readonly (string | BoundedKeyArm)[]): s `CASE WHEN POSITION('/' IN ${after}.after_key) > 0 THEN SPLIT_PART(${after}.after_key, '/', 2) ELSE '' END` ) + const orderBy = definition.orderBy ?? 'logical_id' + // `cursor_id` is the reserved alias for a native UUID column. PostgreSQL UUID ordering is + // byte-equivalent to UTF-8 ordering of canonical UUID text and keeps the bounded index scan. + const orderExpression = orderBy === 'cursor_id' ? orderBy : catalogTextOrderSql(orderBy) return `${names[index]} AS MATERIALIZED ( SELECT '${names[index]}'::text AS source_arm, bounded_arm.logical_id, ${definition.hasValidUntil ? 'bounded_arm.valid_until' : 'NULL::timestamptz'} @@ -38,7 +42,7 @@ export function boundedKeyUnionSql(arms: readonly (string | BoundedKeyArm)[]): s WHERE $9::jsonb IS NULL OR $9::jsonb ? '${names[index]}' ) ${after} CROSS JOIN LATERAL (${sourceSql}) bounded_arm - ORDER BY ${catalogTextOrderSql(definition.orderBy ?? 'logical_id')} + ORDER BY ${orderExpression} LIMIT $4 )` }) diff --git a/control-api/src/services/access/teamGfsTopK.ts b/control-api/src/services/access/teamGfsTopK.ts index 7226d2219d..4a169b95b8 100644 --- a/control-api/src/services/access/teamGfsTopK.ts +++ b/control-api/src/services/access/teamGfsTopK.ts @@ -72,10 +72,14 @@ const TEAM_GFS_STREAM_HEAD_SQL = `WITH requested AS ( LIMIT requested.take ) candidate ) -SELECT kind, subject_id, resource_id::text AS logical_id - FROM heads - ORDER BY ${catalogTextOrderSql('logical_id')}, ${catalogTextOrderSql('kind')}, - ${catalogTextOrderSql('subject_id')}` +SELECT ordered.kind, ordered.subject_id, ordered.logical_id + FROM ( + SELECT kind, subject_id, resource_id::text AS logical_id + FROM heads + ) ordered + ORDER BY ${catalogTextOrderSql('ordered.logical_id')}, + ${catalogTextOrderSql('ordered.kind')}, + ${catalogTextOrderSql('ordered.subject_id')}` function compareHead(left: TeamGfsStreamHead, right: TeamGfsStreamHead): number { return ( diff --git a/control-api/test/catalogProducers.realPostgres.integration.test.ts b/control-api/test/catalogProducers.realPostgres.integration.test.ts index 9f054c4f4b..d7d467391e 100644 --- a/control-api/test/catalogProducers.realPostgres.integration.test.ts +++ b/control-api/test/catalogProducers.realPostgres.integration.test.ts @@ -123,6 +123,27 @@ describeRealPostgres('catalog producer SQL on real PostgreSQL', () => { } }) + it('proves native UUID order matches UTF-8 byte order for canonical UUID text', async () => { + const values = [ + 'ffffffff-ffff-4fff-bfff-ffffffffffff', + '00000000-0000-4000-8000-000000000000', + '7fffffff-ffff-4fff-bfff-ffffffffffff', + '80000000-0000-4000-8000-000000000000', + '0fffffff-ffff-4fff-bfff-ffffffffffff', + ] + const expected = [...values].sort((left, right) => + Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8')) + ) + const ordered = await databasePool.query<{ value: string }>( + `SELECT value::text AS value + FROM unnest($1::uuid[]) AS candidate(value) + ORDER BY value`, + [values] + ) + + expect(ordered.rows.map(row => row.value)).toEqual(expected) + }) + it('parses and executes every bounded key and selected-ID hydration query', async () => { const budget = AccessExecutionBudget.create('catalog') const sourceStates: CatalogOperationalSourceState[] = OPERATIONAL_SOURCE_FAMILIES.map( From 9915503f366dc0d8677cfccafda6a14c352465e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 24 Aug 2026 12:37:41 -0600 Subject: [PATCH 116/528] fix(control-api): r5-h3 complete GFS revision components Class-check: GFS catalog-authoritative writers -> 3 tables -> resource and subject components Repro-test: real PostgreSQL resource-revision assertions fail at c197de70 and pass after fix --- .../access/userAccessFoundationSchema.ts | 93 ++++++++++- ...omponents.realPostgres.integration.test.ts | 153 ++++++++++++++++++ 2 files changed, 241 insertions(+), 5 deletions(-) diff --git a/control-api/src/services/access/userAccessFoundationSchema.ts b/control-api/src/services/access/userAccessFoundationSchema.ts index 478b28e2be..9292f9621b 100644 --- a/control-api/src/services/access/userAccessFoundationSchema.ts +++ b/control-api/src/services/access/userAccessFoundationSchema.ts @@ -1,4 +1,5 @@ import type { DbClient } from '../../db.js' +import { canonicalEnvironmentId } from './operationalAccessProjection.js' /** * Additive persistence foundation for user-session v2 and user-centric access. @@ -311,6 +312,11 @@ export async function applyCatalogUtf8OrderingSchema(db: DbClient): Promise { await db.query(` + CREATE TABLE IF NOT EXISTS authorization_catalog_environment ( + singleton BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (singleton), + environment_id TEXT NOT NULL CHECK (environment_id <> '') + ); + CREATE TABLE IF NOT EXISTS authorization_catalog_writer_components ( writer_table TEXT PRIMARY KEY, component_class TEXT NOT NULL, @@ -332,9 +338,9 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis ('workflow_runs', 'user+team', 'visible workflow-run ownership'), ('workflow_approval_requests', 'user+team', 'approval target authority'), ('notification_deliveries', 'user+team', 'notification audience'), - ('gfs_resources', 'gfs-subjects', 'resource visibility for current subjects'), - ('gfs_grants', 'user+team', 'direct GFS grants'), - ('gfs_shares', 'user+team', 'direct GFS shares'), + ('gfs_resources', 'resource+gfs-subjects', 'resource and current subject visibility'), + ('gfs_grants', 'resource+user+team', 'GFS resource and direct-subject authority'), + ('gfs_shares', 'resource+user+team', 'GFS resource and shared-subject authority'), ('operational_resource_index', 'resource+source-state', 'promoted source resources'), ('operational_resource_relationships', 'resource+source-state', 'promoted source edges'), ('operational_catalog_source_state', 'source-state', 'atomic source generation promotion') @@ -522,6 +528,62 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis END; $$; + CREATE OR REPLACE FUNCTION authorization_bump_gfs_resource_component( + target_resource_id UUID + ) + RETURNS VOID + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = pg_catalog, public + AS $$ + DECLARE target_environment_id TEXT; + BEGIN + SELECT environment_id + INTO target_environment_id + FROM authorization_catalog_environment + WHERE singleton = TRUE; + IF target_environment_id IS NULL THEN + RAISE EXCEPTION 'catalog environment is not configured'; + END IF; + IF target_resource_id IS NULL THEN + RETURN; + END IF; + INSERT INTO authorization_resource_revisions( + environment_id, resource_type, resource_id, revision, updated_at + ) VALUES ( + target_environment_id, 'gfs_resource', target_resource_id::TEXT, 1, clock_timestamp() + ) + ON CONFLICT (environment_id, resource_type, resource_id) DO UPDATE + SET revision = authorization_resource_revisions.revision + 1, + updated_at = clock_timestamp(); + END; + $$; + + CREATE OR REPLACE FUNCTION authorization_bump_gfs_authority_revision() + RETURNS TRIGGER + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = pg_catalog, public + AS $$ + BEGIN + IF TG_OP <> 'DELETE' THEN + PERFORM authorization_bump_subject_revision(NEW.subject_type, NEW.subject_id); + PERFORM authorization_bump_gfs_resource_component(NEW.resource_id); + END IF; + IF TG_OP = 'DELETE' OR ( + TG_OP = 'UPDATE' AND ( + OLD.subject_type IS DISTINCT FROM NEW.subject_type + OR OLD.subject_id IS DISTINCT FROM NEW.subject_id + OR OLD.resource_id IS DISTINCT FROM NEW.resource_id + ) + ) THEN + PERFORM authorization_bump_subject_revision(OLD.subject_type, OLD.subject_id); + PERFORM authorization_bump_gfs_resource_component(OLD.resource_id); + END IF; + RETURN COALESCE(NEW, OLD); + END; + $$; + CREATE OR REPLACE FUNCTION authorization_bump_gfs_resource_subjects(target_resource_id UUID) RETURNS VOID LANGUAGE plpgsql @@ -552,10 +614,12 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis AS $$ BEGIN IF TG_OP <> 'DELETE' THEN + PERFORM authorization_bump_gfs_resource_component(NEW.resource_id); PERFORM authorization_bump_gfs_resource_subjects(NEW.resource_id); END IF; IF TG_OP <> 'INSERT' AND (TG_OP = 'DELETE' OR OLD.resource_id IS DISTINCT FROM NEW.resource_id) THEN + PERFORM authorization_bump_gfs_resource_component(OLD.resource_id); PERFORM authorization_bump_gfs_resource_subjects(OLD.resource_id); END IF; RETURN COALESCE(NEW, OLD); @@ -608,11 +672,11 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis DROP TRIGGER IF EXISTS gfs_grants_authorization_revision ON gfs_grants; CREATE TRIGGER gfs_grants_authorization_revision AFTER INSERT OR UPDATE OR DELETE ON gfs_grants - FOR EACH ROW EXECUTE FUNCTION authorization_bump_gfs_subject_revision(); + FOR EACH ROW EXECUTE FUNCTION authorization_bump_gfs_authority_revision(); DROP TRIGGER IF EXISTS gfs_shares_authorization_revision ON gfs_shares; CREATE TRIGGER gfs_shares_authorization_revision AFTER INSERT OR UPDATE OR DELETE ON gfs_shares - FOR EACH ROW EXECUTE FUNCTION authorization_bump_gfs_subject_revision(); + FOR EACH ROW EXECUTE FUNCTION authorization_bump_gfs_authority_revision(); DROP TRIGGER IF EXISTS gfs_resources_authorization_revision ON gfs_resources; CREATE TRIGGER gfs_resources_authorization_revision AFTER INSERT OR UPDATE OR DELETE ON gfs_resources @@ -622,7 +686,9 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis DROP TABLE IF EXISTS authorization_catalog_revision; REVOKE ALL ON TABLE authorization_catalog_writer_components FROM PUBLIC; + REVOKE ALL ON TABLE authorization_catalog_environment FROM PUBLIC; GRANT SELECT ON TABLE authorization_catalog_writer_components TO control_api_runtime; + GRANT SELECT ON TABLE authorization_catalog_environment TO control_api_runtime; REVOKE ALL ON FUNCTION authorization_bump_subject_revision(TEXT, TEXT) FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_user_row_revision() FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_team_row_revision() FROM PUBLIC; @@ -630,9 +696,26 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis REVOKE ALL ON FUNCTION authorization_bump_workflow_approval_revision() FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_notification_revision() FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_gfs_subject_revision() FROM PUBLIC; + REVOKE ALL ON FUNCTION authorization_bump_gfs_resource_component(UUID) FROM PUBLIC; + REVOKE ALL ON FUNCTION authorization_bump_gfs_authority_revision() FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_gfs_resource_subjects(UUID) FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_gfs_resource_revision() FROM PUBLIC; `) + await db.query( + `INSERT INTO authorization_catalog_environment(singleton, environment_id) + VALUES(TRUE, $1) + ON CONFLICT (singleton) DO UPDATE SET environment_id = EXCLUDED.environment_id`, + [canonicalEnvironmentId()] + ) + await db.query( + `INSERT INTO authorization_resource_revisions( + environment_id, resource_type, resource_id, revision, updated_at + ) + SELECT $1, 'gfs_resource', resource_id::text, 1, clock_timestamp() + FROM gfs_resources + ON CONFLICT (environment_id, resource_type, resource_id) DO NOTHING`, + [canonicalEnvironmentId()] + ) } async function applyAuthorizationRevisionFunctions(db: DbClient): Promise { diff --git a/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts b/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts index 516ca9b45f..1e929049a7 100644 --- a/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts +++ b/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts @@ -4,6 +4,8 @@ import { Pool, type PoolClient } from 'pg' import { type DbClient, initDb } from '../src/db.js' import { buildAccessCatalog } from '../src/services/access/accessCatalogCoordinator.js' import { AccessCatalogCursorError } from '../src/services/access/accessCatalogCursor.js' +import { GFS_HYDRATION_SQL } from '../src/services/access/catalogHydrationSql.js' +import { canonicalEnvironmentId } from '../src/services/access/operationalAccessProjection.js' import { applyComposableCatalogRevisionSchema } from '../src/services/access/userAccessFoundationSchema.js' import type { ExternalSessionAuthorityContext } from '../src/services/auth/externalSessionAuthentication.js' @@ -61,6 +63,18 @@ describeRealPostgres('composable catalog revisions on real PostgreSQL', () => { return Number(result.rows[0]?.revision ?? 1) } + async function resourceRevision(resourceId: string): Promise { + const result = await databasePool.query<{ revision: string }>( + `SELECT revision::text + FROM authorization_resource_revisions + WHERE environment_id = $1 + AND resource_type = 'gfs_resource' + AND resource_id = $2`, + [canonicalEnvironmentId(), resourceId] + ) + return Number(result.rows[0]?.revision ?? 0) + } + async function backendPid(client: PoolClient): Promise { const result = await client.query<{ pid: number }>('SELECT pg_backend_pid() AS pid') return result.rows[0]!.pid @@ -131,6 +145,11 @@ describeRealPostgres('composable catalog revisions on real PostgreSQL', () => { ORDER BY catalog_utf8_bytes(writer_table)` ) expect(mappings.rows).toHaveLength(18) + expect(mappings.rows.filter(row => row.writer_table.startsWith('gfs_'))).toEqual([ + { writer_table: 'gfs_grants', component_class: 'resource+user+team' }, + { writer_table: 'gfs_resources', component_class: 'resource+gfs-subjects' }, + { writer_table: 'gfs_shares', component_class: 'resource+user+team' }, + ]) const globalTriggers = await databasePool.query<{ count: string }>( `SELECT count(*)::text AS count @@ -232,6 +251,138 @@ describeRealPostgres('composable catalog revisions on real PostgreSQL', () => { expect(await userRevision(userIds[0])).toBeGreaterThan(revision) }) + it('moves GFS resource and subject components for the complete writer class', async () => { + const firstResourceId = randomUUID() + const secondResourceId = randomUUID() + const firstDrive = `r6-${randomBytes(4).toString('hex')}` + const secondDrive = `r6-${randomBytes(4).toString('hex')}` + await databasePool.query( + `INSERT INTO gfs_resources(resource_id, drive, name, kind) + VALUES ($1, $3, 'resource-one', 'file'), + ($2, $4, 'resource-two', 'file')`, + [firstResourceId, secondResourceId, firstDrive, secondDrive] + ) + const firstInsertedRevision = await resourceRevision(firstResourceId) + const secondInsertedRevision = await resourceRevision(secondResourceId) + expect(firstInsertedRevision).toBeGreaterThan(0) + expect(secondInsertedRevision).toBeGreaterThan(0) + + const grant = await databasePool.query<{ id: string }>( + `INSERT INTO gfs_grants(drive, resource_id, subject_type, subject_id, permissions) + VALUES ($3, $1, 'user', $2, ARRAY['read']::text[]) + RETURNING id::text`, + [firstResourceId, userIds[0], firstDrive] + ) + expect(await resourceRevision(firstResourceId)).toBeGreaterThan(firstInsertedRevision) + expect(await resourceRevision(secondResourceId)).toBe(secondInsertedRevision) + + const beforeMoveFirst = await resourceRevision(firstResourceId) + const beforeMoveSecond = await resourceRevision(secondResourceId) + await databasePool.query(`UPDATE gfs_grants SET resource_id = $2, drive = $3 WHERE id = $1`, [ + grant.rows[0]!.id, + secondResourceId, + secondDrive, + ]) + expect(await resourceRevision(firstResourceId)).toBeGreaterThan(beforeMoveFirst) + expect(await resourceRevision(secondResourceId)).toBeGreaterThan(beforeMoveSecond) + + const share = await databasePool.query<{ id: string }>( + `INSERT INTO gfs_shares(drive, resource_id, subject_type, subject_id, permissions) + VALUES ($3, $1, 'team', $2, ARRAY['read']::text[]) + RETURNING id::text`, + [firstResourceId, teamIds[0], firstDrive] + ) + const beforeShareMoveFirst = await resourceRevision(firstResourceId) + const beforeShareMoveSecond = await resourceRevision(secondResourceId) + await databasePool.query(`UPDATE gfs_shares SET resource_id = $2, drive = $3 WHERE id = $1`, [ + share.rows[0]!.id, + secondResourceId, + secondDrive, + ]) + expect(await resourceRevision(firstResourceId)).toBeGreaterThan(beforeShareMoveFirst) + expect(await resourceRevision(secondResourceId)).toBeGreaterThan(beforeShareMoveSecond) + + const beforeGrantDelete = await resourceRevision(secondResourceId) + await databasePool.query(`DELETE FROM gfs_grants WHERE id = $1`, [grant.rows[0]!.id]) + expect(await resourceRevision(secondResourceId)).toBeGreaterThan(beforeGrantDelete) + + const beforeShareDelete = await resourceRevision(secondResourceId) + await databasePool.query(`DELETE FROM gfs_shares WHERE id = $1`, [share.rows[0]!.id]) + expect(await resourceRevision(secondResourceId)).toBeGreaterThan(beforeShareDelete) + + const beforeRollback = await resourceRevision(firstResourceId) + const rollback = await databasePool.connect() + try { + await rollback.query('BEGIN') + await rollback.query(`UPDATE gfs_resources SET name = 'rolled-back' WHERE resource_id = $1`, [ + firstResourceId, + ]) + await rollback.query('ROLLBACK') + } finally { + rollback.release() + } + expect(await resourceRevision(firstResourceId)).toBe(beforeRollback) + + const userRevisionBefore = await userRevision(userIds[0]) + await databasePool.query( + `INSERT INTO gfs_grants(drive, resource_id, subject_type, subject_id, permissions) + VALUES ($3, $1, 'user', $2, ARRAY['read']::text[])`, + [firstResourceId, userIds[0], firstDrive] + ) + expect(await userRevision(userIds[0])).toBeGreaterThan(userRevisionBefore) + + const hydratedBefore = await databasePool.query<{ resource_revision: string }>( + GFS_HYDRATION_SQL, + [userIds[0], [firstResourceId], canonicalEnvironmentId(), 8] + ) + const beforeResourceUpdate = await resourceRevision(firstResourceId) + await databasePool.query( + `UPDATE gfs_resources SET name = name || '-changed' WHERE resource_id = $1`, + [firstResourceId] + ) + const hydratedAfter = await databasePool.query<{ resource_revision: string }>( + GFS_HYDRATION_SQL, + [userIds[0], [firstResourceId], canonicalEnvironmentId(), 8] + ) + expect(await resourceRevision(firstResourceId)).toBeGreaterThan(beforeResourceUpdate) + expect(Number(hydratedAfter.rows[0]?.resource_revision)).toBeGreaterThan( + Number(hydratedBefore.rows[0]?.resource_revision) + ) + }) + + it('preserves concurrent GFS invalidation increments on one resource component', async () => { + const resourceId = randomUUID() + const drive = `r6-${randomBytes(4).toString('hex')}` + await databasePool.query( + `INSERT INTO gfs_resources(resource_id, drive, name, kind) + VALUES ($1, $2, 'concurrent-resource', 'file')`, + [resourceId, drive] + ) + const before = await resourceRevision(resourceId) + const blocker = await databasePool.connect() + const blocked = await databasePool.connect() + try { + await blocker.query('BEGIN') + await blocked.query('BEGIN') + const blockerPid = await backendPid(blocker) + const blockedPid = await backendPid(blocked) + await blocker.query('SELECT authorization_bump_gfs_resource_component($1)', [resourceId]) + const pending = blocked.query('SELECT authorization_bump_gfs_resource_component($1)', [ + resourceId, + ]) + await waitForBlock(blockedPid, blockerPid) + await blocker.query('COMMIT') + await pending + await blocked.query('COMMIT') + } finally { + await blocker.query('ROLLBACK').catch(() => undefined) + await blocked.query('ROLLBACK').catch(() => undefined) + blocker.release() + blocked.release() + } + expect(await resourceRevision(resourceId)).toBe(before + 2) + }) + it('does not serialize independent components and preserves same-component increments', async () => { const first = await databasePool.connect() const second = await databasePool.connect() @@ -246,6 +397,8 @@ describeRealPostgres('composable catalog revisions on real PostgreSQL', () => { await second.query('COMMIT') await first.query('COMMIT') } finally { + await first.query('ROLLBACK').catch(() => undefined) + await second.query('ROLLBACK').catch(() => undefined) first.release() second.release() } From 3de94cf7f88f09af2fd51f73a3c5fd6c03737b8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 24 Aug 2026 12:39:15 -0600 Subject: [PATCH 117/528] test(control-api): r5-h4 prove V1 admin revocation end to end Class-check: administrator-only legacy V1 team mutations -> 3 real routes -> producer and live role proof Repro-test: prior mocked acceptance proof is replaced by real signing, verification, and authentication --- ...externalTeamsLegacyAdminRevocation.test.ts | 111 ++++++++++++++---- 1 file changed, 89 insertions(+), 22 deletions(-) diff --git a/control-api/test/routes.externalTeamsLegacyAdminRevocation.test.ts b/control-api/test/routes.externalTeamsLegacyAdminRevocation.test.ts index 3678b3f72b..063acc417a 100644 --- a/control-api/test/routes.externalTeamsLegacyAdminRevocation.test.ts +++ b/control-api/test/routes.externalTeamsLegacyAdminRevocation.test.ts @@ -1,16 +1,36 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import express from 'express' import request from 'supertest' import { createExternalTeamsRouter } from '../src/routes/external/teams.js' +import { issueExternalUserSession } from '../src/services/auth/externalSessionIssuance.js' +import { verifyExternalSessionToken } from '../src/utils/auth/externalSessionAuthToken.js' const userId = '11111111-1111-4111-8111-111111111111' const teamId = '22222222-2222-4222-8222-222222222222' const targetUserId = '33333333-3333-4333-8333-333333333333' -const staleAdminToken = 'same-valid-v1-token-minted-while-admin' +let staleAdminToken: string -const auth = vi.hoisted(() => ({ authenticateExternalUserSession: vi.fn() })) const database = vi.hoisted(() => ({ query: vi.fn() })) const rateLimit = vi.hoisted(() => ({ checkAndIncrement: vi.fn() })) +const runtimePolicy = vi.hoisted(() => ({ + policyVersion: '1', + policyRevision: 'legacy-admin-route-proof', + acceptV1: true, + issueV1: true, + acceptV2: false, + issueV2: false, + renewV2: false, + switchCompatibility: true, + computeCatalogShadow: false, + serveCatalog: false, + actionContextV2: false, + rpcDelegationV2: false, + desktopAllTeamMode: false, + profileV2Mode: false, + minimumClientVersion: null, + enforceMinimumClient: false, + advertisedCatalogFamilies: [], +})) const directory = vi.hoisted(() => ({ createManagedInvitationForUser: vi.fn(), createTeamForUser: vi.fn(), @@ -24,9 +44,11 @@ const directory = vi.hoisted(() => ({ updateManagedMemberRoleForUser: vi.fn(), })) -vi.mock('../src/services/auth/externalSessionAuthentication.js', () => auth) vi.mock('../src/db.js', () => ({ pool: database })) vi.mock('../src/services/rateLimiterService.js', () => rateLimit) +vi.mock('../src/services/access/userAccessRuntimePolicy.js', () => ({ + resolveEffectiveUserAccessPolicy: vi.fn().mockResolvedValue(runtimePolicy), +})) vi.mock('../src/services/directory/index.js', async importOriginal => ({ ...(await importOriginal()), ...directory, @@ -71,27 +93,31 @@ function app() { } describe('legacy V1 live-admin revocation on real external team routes', () => { - beforeEach(() => { - vi.clearAllMocks() - auth.authenticateExternalUserSession.mockResolvedValue({ - status: 'authenticated', - contract: 'v1', - claims: { + beforeAll(async () => { + const issued = await issueExternalUserSession( + { + contract: 'v1', userId, email: 'admin@example.test', teamId, role: 'admin', authGeneration: 1, - iat: 1_787_596_800, - exp: 1_791_907_200, - }, - authorityContext: { - contract: 'v1', - userId, - tokenHash: 'a'.repeat(64), - issuedAt: 1_787_596_800, + authenticationMethods: ['pwd'], }, + { policy: runtimePolicy } + ) + staleAdminToken = issued.token + expect(staleAdminToken).not.toBe('same-valid-v1-token-minted-while-admin') + expect(verifyExternalSessionToken(staleAdminToken)).toMatchObject({ + userId, + teamId, + role: 'admin', + authGeneration: 1, }) + }) + + beforeEach(() => { + vi.clearAllMocks() rateLimit.checkAndIncrement.mockResolvedValue({ allowed: true, count: 1, @@ -111,12 +137,33 @@ describe('legacy V1 live-admin revocation on real external team routes', () => { ['demoted', { rows: [{ team_id: teamId, role: 'member' }], rowCount: 1 }], ['membership removed', { rows: [], rowCount: 0 }], ])(`rejects ${mutation.name} with the same token after %s`, async (_state, membership) => { - database.query.mockResolvedValue(membership) + database.query.mockImplementation(async text => { + if (String(text).includes('FROM users u')) { + return { + rows: [ + { + id: userId, + lifecycle_state: 'active', + lifecycle_version: 1, + valid_after: null, + token_revoked: false, + }, + ], + rowCount: 1, + } + } + if (String(text).includes('FROM team_members tm')) return membership + throw new Error(`unexpected database query: ${String(text)}`) + }) const response = await mutation.invoke(app()).set('x-user-session-token', staleAdminToken) expect(response.status).toBe(403) expect(directory[mutation.handler]).not.toHaveBeenCalled() + expect(database.query).toHaveBeenCalledWith(expect.stringContaining('FROM users u'), [ + userId, + expect.stringMatching(/^[0-9a-f]{64}$/), + ]) expect(database.query).toHaveBeenCalledWith(expect.stringContaining("tm.status = 'active'"), [ userId, teamId, @@ -124,15 +171,35 @@ describe('legacy V1 live-admin revocation on real external team routes', () => { }) it(`allows ${mutation.name} only when the same token resolves to a current admin`, async () => { - database.query.mockResolvedValue({ - rows: [{ team_id: teamId, role: 'admin' }], - rowCount: 1, + database.query.mockImplementation(async text => { + if (String(text).includes('FROM users u')) { + return { + rows: [ + { + id: userId, + lifecycle_state: 'active', + lifecycle_version: 1, + valid_after: null, + token_revoked: false, + }, + ], + rowCount: 1, + } + } + if (String(text).includes('FROM team_members tm')) { + return { rows: [{ team_id: teamId, role: 'admin' }], rowCount: 1 } + } + throw new Error(`unexpected database query: ${String(text)}`) }) const response = await mutation.invoke(app()).set('x-user-session-token', staleAdminToken) expect(response.status).toBe(200) expect(directory[mutation.handler]).toHaveBeenCalledOnce() + expect(database.query).toHaveBeenCalledWith(expect.stringContaining('FROM users u'), [ + userId, + expect.stringMatching(/^[0-9a-f]{64}$/), + ]) }) } }) From 15520fd759bf55500e1828c9140ef90d0f1ff7c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 24 Aug 2026 12:42:37 -0600 Subject: [PATCH 118/528] test(control-api): r5-h5 prove password producers serialize V1 exchange Class-check: password-security producers serializing V1 exchange -> 2 real producers -> both exercised Repro-test: prior synthetic writer proof is replaced by real change and reset producers with lock barriers --- ...hangeRace.realPostgres.integration.test.ts | 212 ++++++++++++++---- 1 file changed, 167 insertions(+), 45 deletions(-) diff --git a/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts b/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts index 7f38399efb..58fc60849c 100644 --- a/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts +++ b/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts @@ -1,14 +1,17 @@ -import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import bcrypt from 'bcryptjs' import { randomBytes, randomUUID } from 'node:crypto' import { Pool, type PoolClient } from 'pg' import { type DbClient, initDb } from '../src/db.js' import type { AuthClaims } from '../src/profileTypes.js' import type { EffectiveUserAccessPolicy } from '../src/services/access/userAccessPolicy.js' import { exchangeLegacyExternalUserSession } from '../src/services/auth/externalSessionIssuance.js' +import { validateLegacyUserSession } from '../src/services/auth/userSessionService.js' +import { verifyUserPassword } from '../src/services/directory/login.js' import { - revokeAllUserSessions, - validateLegacyUserSession, -} from '../src/services/auth/userSessionService.js' + setInvitationPasswordForUser, + updateUserPassword, +} from '../src/services/directory/membership.js' import { signExternalSessionToken, verifyExternalSessionToken, @@ -22,6 +25,30 @@ const runtimeRoles = [ 'workflow_recipes_runtime', ] as const +const producerDatabase = vi.hoisted(() => ({ pool: undefined as Pool | undefined })) + +vi.mock('../src/db.js', async importOriginal => { + const actual = await importOriginal() + const pool = { + query: (text: string, values?: unknown[]) => producerDatabase.pool!.query(text, values), + } + const withTransaction = async (work: (db: DbClient) => Promise): Promise => { + const client = await producerDatabase.pool!.connect() + try { + await client.query('BEGIN') + const result = await work(client as unknown as DbClient) + await client.query('COMMIT') + return result + } catch (error) { + await client.query('ROLLBACK') + throw error + } finally { + client.release() + } + } + return { ...actual, pool, withTransaction } +}) + function databaseUrl(baseUrl: string, database: string): string { const value = new URL(baseUrl) value.pathname = `/${database}` @@ -58,6 +85,7 @@ describeRealPostgres('legacy replacement exchange serialization on real PostgreS } as EffectiveUserAccessPolicy let adminPool: Pool let databasePool: Pool + const currentPassword = 'Legacy-Password-1!' async function backendPid(client: PoolClient): Promise { const result = await client.query<{ pid: number }>('SELECT pg_backend_pid() AS pid') @@ -76,6 +104,23 @@ describeRealPostgres('legacy replacement exchange serialization on real PostgreS throw new Error('expected legacy exchange to block on the user security boundary') } + async function waitForBlockedBy(blockerPid: number): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + const result = await databasePool.query<{ pid: number }>( + `SELECT activity.pid + FROM pg_stat_activity activity + WHERE activity.datname = current_database() + AND $1::int = ANY(pg_blocking_pids(activity.pid)) + ORDER BY activity.pid + LIMIT 1`, + [blockerPid] + ) + if (result.rows[0]?.pid) return result.rows[0].pid + await new Promise(resolve => setTimeout(resolve, 10)) + } + throw new Error('expected real password producer to block on the user security boundary') + } + async function principal(label: string): Promise<{ userId: string teamId: string @@ -86,9 +131,10 @@ describeRealPostgres('legacy replacement exchange serialization on real PostgreS const userId = randomUUID() const teamId = randomUUID() const email = `${label}-${userId}@example.test` + const passwordHash = await bcrypt.hash(currentPassword, 12) await databasePool.query( - `INSERT INTO users(id, email, name, password_hash) VALUES ($1, $2, $3, 'old-hash')`, - [userId, email, label] + `INSERT INTO users(id, email, name, password_hash) VALUES ($1, $2, $3, $4)`, + [userId, email, label, passwordHash] ) await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, $2)`, [teamId, label]) await databasePool.query( @@ -108,18 +154,60 @@ describeRealPostgres('legacy replacement exchange serialization on real PostgreS return { userId, teamId, email, token, claims } } - async function beginSecurityMutation( - client: PoolClient, - userId: string, - label: string, - validAfter: Date - ): Promise { - await client.query('BEGIN') - await client.query(`UPDATE users SET password_hash = $2 WHERE id = $1`, [ - userId, - `${label}-new-hash`, - ]) - await revokeAllUserSessions(userId, 'password_changed', client, validAfter) + async function passwordProducer( + label: 'normal password change' | 'password reset', + source: Awaited> + ): Promise<{ + newPassword: string + run: () => Promise + assertSuccess: (result: unknown) => void + }> { + const newPassword = `Changed-${randomBytes(8).toString('hex')}!` + if (label === 'normal password change') { + return { + newPassword, + run: () => updateUserPassword(source.userId, source.email, currentPassword, newPassword), + assertSuccess: result => expect(result).toEqual({ updated: true }), + } + } + const invitation = await databasePool.query<{ id: string }>( + `INSERT INTO invitations( + email, role, status, purpose, accepted_user_id, invitee_name, expires_at + ) VALUES ($1, 'member', 'pending', 'password_reset', $2, 'Reset User', NOW() + INTERVAL '1 hour') + RETURNING id::text`, + [source.email, source.userId] + ) + return { + newPassword, + run: () => + setInvitationPasswordForUser( + source.userId, + source.email, + invitation.rows[0]!.id, + newPassword + ), + assertSuccess: result => expect(result).toMatchObject({ data: { passwordUpdated: true } }), + } + } + + async function holdSecurityEpoch(userId: string): Promise { + await databasePool.query( + `INSERT INTO external_user_session_security_epochs(user_id, valid_after, reason) + VALUES ($1, to_timestamp(0), 'test-baseline') + ON CONFLICT (user_id) DO UPDATE + SET valid_after = EXCLUDED.valid_after, reason = EXCLUDED.reason`, + [userId] + ) + const gate = await databasePool.connect() + await gate.query('BEGIN') + await gate.query( + `SELECT user_id + FROM external_user_session_security_epochs + WHERE user_id = $1 + FOR UPDATE`, + [userId] + ) + return gate } beforeAll(async () => { @@ -127,6 +215,7 @@ describeRealPostgres('legacy replacement exchange serialization on real PostgreS await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) databasePool = new Pool({ connectionString }) + producerDatabase.pool = databasePool await initDb({ connect: () => databasePool.connect() }) }) @@ -145,16 +234,17 @@ describeRealPostgres('legacy replacement exchange serialization on real PostgreS } }) - it.each(['normal password change', 'password reset'])( + it.each(['normal password change', 'password reset'] as const)( 'fails %s exchange when invalidation wins the lock order', async label => { const source = await principal(`invalidation-first-${label.replaceAll(' ', '-')}`) - const invalidation = await databasePool.connect() + const producer = await passwordProducer(label, source) + const epochGate = await holdSecurityEpoch(source.userId) const exchangeStarted = deferred() try { - const validAfter = new Date((source.claims.iat! + 60) * 1000) - await beginSecurityMutation(invalidation, source.userId, label, validAfter) - const invalidationPid = await backendPid(invalidation) + const epochGatePid = await backendPid(epochGate) + const producerWork = producer.run() + const producerPid = await waitForBlockedBy(epochGatePid) const exchange = exchangeLegacyExternalUserSession( { @@ -184,21 +274,45 @@ describeRealPostgres('legacy replacement exchange serialization on real PostgreS } ) const exchangePid = await exchangeStarted.promise - await waitForBlock(exchangePid, invalidationPid) - await invalidation.query('COMMIT') + await waitForBlock(exchangePid, producerPid) + await epochGate.query('COMMIT') + producer.assertSuccess(await producerWork) await expect(exchange).resolves.toEqual({ status: 'invalid_session' }) + await expect( + validateLegacyUserSession(source.token, source.claims, { db: databasePool }) + ).resolves.toMatchObject({ status: 'revoked' }) + await expect( + exchangeLegacyExternalUserSession( + { + token: source.token, + claims: source.claims, + userId: source.userId, + email: source.email, + teamId: source.teamId, + }, + { policy } + ) + ).resolves.toEqual({ status: 'invalid_session' }) + await expect( + verifyUserPassword({ + userId: source.userId, + email: source.email, + password: producer.newPassword, + }) + ).resolves.toBe(true) } finally { - await invalidation.query('ROLLBACK').catch(() => undefined) - invalidation.release() + await epochGate.query('ROLLBACK').catch(() => undefined) + epochGate.release() } } ) - it.each(['normal password change', 'password reset'])( + it.each(['normal password change', 'password reset'] as const)( 'invalidates the replacement from %s when exchange wins the lock order', async label => { const source = await principal(`exchange-first-${label.replaceAll(' ', '-')}`) + const producer = await passwordProducer(label, source) const exchangeReady = deferred<{ result: { status: string; token?: string }; pid: number }>() const releaseExchange = deferred() const exchange = exchangeLegacyExternalUserSession( @@ -232,24 +346,12 @@ describeRealPostgres('legacy replacement exchange serialization on real PostgreS const lockedExchange = await exchangeReady.promise expect(lockedExchange.result.status).toBe('issued') - const invalidation = await databasePool.connect() try { - const invalidationStarted = deferred() - const invalidationWork = (async () => { - invalidationStarted.resolve(await backendPid(invalidation)) - await beginSecurityMutation( - invalidation, - source.userId, - label, - new Date((source.claims.iat! + 60) * 1000) - ) - await invalidation.query('COMMIT') - })() - const invalidationPid = await invalidationStarted.promise - await waitForBlock(invalidationPid, lockedExchange.pid) + const producerWork = producer.run() + await waitForBlockedBy(lockedExchange.pid) releaseExchange.resolve() const issued = await exchange - await invalidationWork + producer.assertSuccess(await producerWork) expect(issued.status).toBe('issued') if (issued.status !== 'issued') throw new Error('exchange did not issue a token') const replacementClaims = verifyExternalSessionToken(issued.token) @@ -257,10 +359,30 @@ describeRealPostgres('legacy replacement exchange serialization on real PostgreS await expect( validateLegacyUserSession(issued.token, replacementClaims, { db: databasePool }) ).resolves.toMatchObject({ status: 'revoked' }) + await expect( + validateLegacyUserSession(source.token, source.claims, { db: databasePool }) + ).resolves.toMatchObject({ status: 'revoked' }) + await expect( + exchangeLegacyExternalUserSession( + { + token: source.token, + claims: source.claims, + userId: source.userId, + email: source.email, + teamId: source.teamId, + }, + { policy } + ) + ).resolves.toEqual({ status: 'invalid_session' }) + await expect( + verifyUserPassword({ + userId: source.userId, + email: source.email, + password: producer.newPassword, + }) + ).resolves.toBe(true) } finally { releaseExchange.resolve() - await invalidation.query('ROLLBACK').catch(() => undefined) - invalidation.release() } } ) From bcc5c33bdd961874e6bb0f5c0d2ce3d15f37456b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 24 Aug 2026 12:48:42 -0600 Subject: [PATCH 119/528] fix(control-api): r6-h1 revalidate lifecycle in final authority snapshots Class-check: final PR1 catalog and resolve principal snapshots -> 2 queries -> active lifecycle and generation Repro-test: retirement and generation cases fail at c197de70 and pass after final-snapshot checks --- .../services/access/accessAuthorityStore.ts | 10 +- .../access/catalogAuthorityContext.ts | 4 + .../auth/externalSessionAuthentication.ts | 8 +- ...ordinator.realPostgres.integration.test.ts | 1 + ...gFamilies.realPostgres.integration.test.ts | 1 + ...rformance.realPostgres.integration.test.ts | 1 + ...logShadow.realPostgres.integration.test.ts | 1 + control-api/test/accessCatalogShadow.test.ts | 1 + .../test/accessCatalogShadowCapacity.test.ts | 1 + ...agination.realPostgres.integration.test.ts | 2 + ...omponents.realPostgres.integration.test.ts | 1 + .../externalSessionAuthentication.test.ts | 8 +- ...Lifecycle.realPostgres.integration.test.ts | 292 ++++++++++++++++++ ...utes.externalSessionAtomicExchange.test.ts | 1 + ...eContract.realPostgres.integration.test.ts | 1 + 15 files changed, 328 insertions(+), 5 deletions(-) create mode 100644 control-api/test/finalAuthorityLifecycle.realPostgres.integration.test.ts diff --git a/control-api/src/services/access/accessAuthorityStore.ts b/control-api/src/services/access/accessAuthorityStore.ts index 44811e03f7..8bf5696dbc 100644 --- a/control-api/src/services/access/accessAuthorityStore.ts +++ b/control-api/src/services/access/accessAuthorityStore.ts @@ -104,6 +104,7 @@ export async function loadPrincipalAuthoritySnapshot(input: { COALESCE(aur.revision, 1) AS user_revision, COALESCE(arr.revision, 1) AS resource_revision, CASE + WHEN u.lifecycle_state <> 'active' THEN FALSE WHEN $2::text = 'v2' THEN EXISTS ( SELECT 1 FROM external_user_sessions s @@ -123,6 +124,8 @@ export async function loadPrincipalAuthoritySnapshot(input: { ) ELSE ( $7::bigint IS NOT NULL + AND $8::bigint IS NOT NULL + AND u.lifecycle_version = $8::bigint AND NOT EXISTS ( SELECT 1 FROM external_v1_session_revocations r @@ -166,9 +169,9 @@ export async function loadPrincipalAuthoritySnapshot(input: { FROM users u LEFT JOIN authorization_user_revisions aur ON aur.user_id = u.id LEFT JOIN authorization_resource_revisions arr - ON arr.environment_id = $8 - AND arr.resource_type = $9 - AND arr.resource_id = $10 + ON arr.environment_id = $9 + AND arr.resource_type = $10 + AND arr.resource_id = $11 LEFT JOIN active_memberships am ON TRUE WHERE u.id = $1 GROUP BY u.id, aur.revision, arr.revision`, @@ -180,6 +183,7 @@ export async function loadPrincipalAuthoritySnapshot(input: { input.session.contract === 'v2' ? input.session.sessionVersion : null, input.session.contract === 'v1' ? input.session.tokenHash : null, input.session.contract === 'v1' ? input.session.issuedAt : null, + input.session.contract === 'v1' ? input.session.authGeneration : null, input.resource.environmentId, input.resource.type, input.resource.logicalId, diff --git a/control-api/src/services/access/catalogAuthorityContext.ts b/control-api/src/services/access/catalogAuthorityContext.ts index 2bd04862c0..fac8d7a921 100644 --- a/control-api/src/services/access/catalogAuthorityContext.ts +++ b/control-api/src/services/access/catalogAuthorityContext.ts @@ -94,6 +94,7 @@ export async function loadCatalogRequestContext(input: { SELECT users.id AS user_id, COALESCE(user_revision.revision, 1)::text AS user_revision, CASE + WHEN users.lifecycle_state <> 'active' THEN FALSE WHEN $2::text = 'v2' THEN EXISTS ( SELECT 1 FROM external_user_sessions session @@ -110,6 +111,8 @@ export async function loadCatalogRequestContext(input: { ) ELSE ( $7::bigint IS NOT NULL + AND $8::bigint IS NOT NULL + AND users.lifecycle_version = $8::bigint AND NOT EXISTS ( SELECT 1 FROM external_v1_session_revocations revoked WHERE revoked.token_hash = $6 AND revoked.user_id = users.id @@ -160,6 +163,7 @@ export async function loadCatalogRequestContext(input: { input.session.contract === 'v2' ? input.session.sessionVersion : null, input.session.contract === 'v1' ? input.session.tokenHash : null, input.session.contract === 'v1' ? input.session.issuedAt : null, + input.session.contract === 'v1' ? input.session.authGeneration : null, ] ) const row = principalResult.rows[0] as Record | undefined diff --git a/control-api/src/services/auth/externalSessionAuthentication.ts b/control-api/src/services/auth/externalSessionAuthentication.ts index 67c82694fa..ede0b57069 100644 --- a/control-api/src/services/auth/externalSessionAuthentication.ts +++ b/control-api/src/services/auth/externalSessionAuthentication.ts @@ -61,6 +61,7 @@ export type ExternalSessionAuthorityContext = Readonly< userId: string tokenHash: string issuedAt: number + authGeneration: number } | { contract: 'v2' @@ -178,18 +179,23 @@ export async function authenticateExternalUserSession( budget: options.budget, }) if (validation.status !== 'valid') return validation + const authGeneration = legacyExternalSessionAuthGeneration(v1Claims) + if (authGeneration === null) { + return { status: 'invalid', reason: 'invalid_legacy_representation' } + } return { status: 'authenticated', contract: 'v1', claims: Object.freeze({ ...v1Claims, - authGeneration: legacyExternalSessionAuthGeneration(v1Claims)!, + authGeneration, }), authorityContext: Object.freeze({ contract: 'v1', userId: v1Claims.userId, tokenHash: validation.identity.jti, issuedAt: v1Claims.iat!, + authGeneration, }), policy, } diff --git a/control-api/test/accessCatalogCoordinator.realPostgres.integration.test.ts b/control-api/test/accessCatalogCoordinator.realPostgres.integration.test.ts index 97ba600172..ca63992f65 100644 --- a/control-api/test/accessCatalogCoordinator.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogCoordinator.realPostgres.integration.test.ts @@ -62,6 +62,7 @@ describeRealPostgres('aggregate catalog coordinator on real PostgreSQL', () => { userId, tokenHash: randomBytes(32).toString('hex'), issuedAt: Math.floor(Date.now() / 1_000), + authGeneration: 1, } let adminPool: Pool let databasePool: Pool diff --git a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts index f4ca6ac5f4..47ac531bda 100644 --- a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts @@ -121,6 +121,7 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { userId, tokenHash: randomBytes(32).toString('hex'), issuedAt: Math.floor(Date.now() / 1_000), + authGeneration: 1, } const operationalFixtures: readonly OperationalFixture[] = [ fixture({ diff --git a/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts b/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts index 2006388e5b..0c6d78c412 100644 --- a/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts @@ -98,6 +98,7 @@ describeRealPostgres('aggregate catalog plans on real PostgreSQL', () => { userId, tokenHash: randomBytes(32).toString('hex'), issuedAt: Math.floor(Date.now() / 1_000), + authGeneration: 1, } let adminPool: Pool let databasePool: Pool diff --git a/control-api/test/accessCatalogShadow.realPostgres.integration.test.ts b/control-api/test/accessCatalogShadow.realPostgres.integration.test.ts index 565213671c..0aca3d23c7 100644 --- a/control-api/test/accessCatalogShadow.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogShadow.realPostgres.integration.test.ts @@ -56,6 +56,7 @@ describeRealPostgres('aggregate shadow physical statement budget on real Postgre userId: '10000000-0000-4000-8000-000000000001', tokenHash: 'shadow-test', issuedAt: 1, + authGeneration: 1, }, family: 'team', legacyLogicalIds: [], diff --git a/control-api/test/accessCatalogShadow.test.ts b/control-api/test/accessCatalogShadow.test.ts index 77f441f6da..d2eafba183 100644 --- a/control-api/test/accessCatalogShadow.test.ts +++ b/control-api/test/accessCatalogShadow.test.ts @@ -12,6 +12,7 @@ const session = { userId: '10000000-0000-4000-8000-000000000001', tokenHash: 'token-hash', issuedAt: 1_900_000_000, + authGeneration: 1, } function catalog(logicalIds: string[], complete = true, nextCursor: string | null = null) { diff --git a/control-api/test/accessCatalogShadowCapacity.test.ts b/control-api/test/accessCatalogShadowCapacity.test.ts index ff2ba0a80a..18ca8be999 100644 --- a/control-api/test/accessCatalogShadowCapacity.test.ts +++ b/control-api/test/accessCatalogShadowCapacity.test.ts @@ -29,6 +29,7 @@ describe('aggregate access shadow scheduling capacity', () => { userId: '10000000-0000-4000-8000-000000000001', tokenHash: 'token-hash', issuedAt: 1_900_000_000, + authGeneration: 1, } for (let index = 0; index < 1_000; index += 1) { diff --git a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts index 439f5bd745..685ddc20e9 100644 --- a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts +++ b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts @@ -172,6 +172,7 @@ describeRealPostgres('distinct catalog key pagination on real PostgreSQL', () => userId: input.userId, tokenHash: randomBytes(32).toString('hex'), issuedAt: Math.floor(Date.now() / 1_000), + authGeneration: 1, } const seen: string[] = [] let cursor: string | null = null @@ -816,6 +817,7 @@ describeRealPostgres('distinct catalog key pagination on real PostgreSQL', () => userId: actor.userId, tokenHash: randomBytes(32).toString('hex'), issuedAt: Math.floor(Date.now() / 1_000), + authGeneration: 1, } const build = (admission: number) => buildAccessCatalog( diff --git a/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts b/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts index 1e929049a7..a331692c07 100644 --- a/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts +++ b/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts @@ -430,6 +430,7 @@ describeRealPostgres('composable catalog revisions on real PostgreSQL', () => { userId: userIds[0], tokenHash: randomBytes(32).toString('hex'), issuedAt: Math.floor(Date.now() / 1_000), + authGeneration: 1, } const first = await buildAccessCatalog( { session, families: ['team'], limit: 1 }, diff --git a/control-api/test/externalSessionAuthentication.test.ts b/control-api/test/externalSessionAuthentication.test.ts index 0569ccc059..0fbcfcecbf 100644 --- a/control-api/test/externalSessionAuthentication.test.ts +++ b/control-api/test/externalSessionAuthentication.test.ts @@ -117,6 +117,7 @@ describe('external user-session authentication boundary', () => { status: 'authenticated', contract: 'v1', claims: { authGeneration: 1 }, + authorityContext: { contract: 'v1', authGeneration: 1 }, }) await expect( authenticateExternalUserSession(token, { @@ -133,7 +134,12 @@ describe('external user-session authentication boundary', () => { await expect( authenticateExternalUserSession(token, { purpose: 'protected', policy: policy() }) - ).resolves.toMatchObject({ status: 'authenticated', contract: 'v1' }) + ).resolves.toMatchObject({ + status: 'authenticated', + contract: 'v1', + claims: { authGeneration: 1 }, + authorityContext: { contract: 'v1', authGeneration: 1 }, + }) await expect( authenticateExternalUserSession(token, { purpose: 'protected', diff --git a/control-api/test/finalAuthorityLifecycle.realPostgres.integration.test.ts b/control-api/test/finalAuthorityLifecycle.realPostgres.integration.test.ts new file mode 100644 index 0000000000..78bb657bb7 --- /dev/null +++ b/control-api/test/finalAuthorityLifecycle.realPostgres.integration.test.ts @@ -0,0 +1,292 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { randomBytes, randomUUID } from 'node:crypto' +import { Pool } from 'pg' +import { type DbClient, initDb, pool } from '../src/db.js' +import { + AccessCatalogRequestError, + buildAccessCatalog, +} from '../src/services/access/accessCatalogCoordinator.js' +import { resolveLiveAuthorization } from '../src/services/access/liveAuthorizationResolver.js' +import { canonicalResourceIdentity } from '../src/services/access/resourceIdentity.js' +import type { ExternalSessionAuthorityContext } from '../src/services/auth/externalSessionAuthentication.js' +import { createUserSession } from '../src/services/auth/userSessionService.js' +import { retireDesktopUser } from '../src/services/directory/users.js' + +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const describeRealPostgres = adminUrl ? describe : describe.skip +const runtimeRoles = [ + 'control_api_runtime', + 'trace_maintenance_runtime', + 'workflow_recipes_runtime', +] as const + +function databaseUrl(baseUrl: string, database: string): string { + const value = new URL(baseUrl) + value.pathname = `/${database}` + return value.toString() +} + +function quoteIdentifier(value: string): string { + return `"${value.replace(/"/g, '""')}"` +} + +function deferred() { + let resolve!: (value: T | PromiseLike) => void + const promise = new Promise(value => { + resolve = value + }) + return { promise, resolve } +} + +describeRealPostgres('final catalog and resolve lifecycle authority on real PostgreSQL', () => { + const database = `control_api_final_authority_${randomBytes(6).toString('hex')}` + const connectionString = databaseUrl( + adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', + database + ) + let adminPool: Pool + let databasePool: Pool + let corePoolConnectSpy: ReturnType + + async function createUser( + label: string, + lifecycleVersion = 1 + ): Promise<{ + userId: string + email: string + session: ExternalSessionAuthorityContext + retirementActorId: string + }> { + const userId = randomUUID() + const retirementActorId = randomUUID() + const email = `${label}-${userId}@example.test` + await databasePool.query( + `INSERT INTO users(id, email, name, lifecycle_state, lifecycle_version) + VALUES ($1, $2, $3, 'active', $4)`, + [userId, email, label, lifecycleVersion] + ) + await databasePool.query( + `INSERT INTO control_admin_users(id, username, password_hash, role, status) + VALUES ($1, $2, 'r6-final-authority-proof', 'admin', 'active')`, + [retirementActorId, `r6-final-authority-${retirementActorId}`] + ) + await databasePool.query( + `INSERT INTO gfs_desktop_operator_links( + id, lineage_id, generation, user_id, control_admin_id, state, source, created_by, row_version + ) VALUES ( + gen_random_uuid(), gen_random_uuid(), 1, $1::uuid, $2::uuid, + 'active', 'initial_setup', $2::uuid, 1 + )`, + [userId, retirementActorId] + ) + return { + userId, + email, + retirementActorId, + session: { + contract: 'v1', + userId, + tokenHash: randomBytes(32).toString('hex'), + issuedAt: Math.floor(Date.now() / 1_000), + authGeneration: lifecycleVersion, + }, + } + } + + function transaction() { + return async (work: (db: DbClient) => Promise): Promise => { + const client = await databasePool.connect() + try { + await client.query('BEGIN') + const result = await work(client as unknown as DbClient) + await client.query('COMMIT') + return result + } catch (error) { + await client.query('ROLLBACK') + throw error + } finally { + client.release() + } + } + } + + function transactionPausedBeforeAuthoritySnapshot( + started: ReturnType, + release: ReturnType + ) { + return async (work: (db: DbClient) => Promise): Promise => { + const client = await databasePool.connect() + try { + await client.query('BEGIN') + started.resolve() + await release.promise + const result = await work(client as unknown as DbClient) + await client.query('COMMIT') + return result + } catch (error) { + await client.query('ROLLBACK') + throw error + } finally { + client.release() + } + } + } + + async function retire(userId: string, retirementActorId: string): Promise { + await retireDesktopUser( + { kind: 'control_admin', controlAdminId: retirementActorId }, + userId, + 'final authority lifecycle race proof', + `r6-final-authority-${randomUUID()}`, + `r6-final-authority-request-${randomUUID()}` + ) + } + + beforeAll(async () => { + adminPool = new Pool({ connectionString: adminUrl }) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) + databasePool = new Pool({ connectionString }) + await initDb({ connect: () => databasePool.connect() }) + corePoolConnectSpy = vi + .spyOn(pool, 'connect') + .mockImplementation((() => databasePool.connect()) as typeof pool.connect) + }) + + afterAll(async () => { + corePoolConnectSpy?.mockRestore() + await databasePool?.end() + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + await adminPool.end() + } + }) + + it('accepts only the current canonical V1 lifecycle generation', async () => { + const current = await createUser('current-generation') + await expect( + buildAccessCatalog( + { session: current.session, families: ['user'], limit: 10 }, + { transaction: transaction() } + ) + ).resolves.toMatchObject({ complete: true }) + + const mismatch = await createUser('generation-mismatch', 2) + const generationOneRepresentation: ExternalSessionAuthorityContext = { + ...mismatch.session, + authGeneration: 1, + } + await expect( + buildAccessCatalog( + { session: generationOneRepresentation, families: ['user'], limit: 10 }, + { transaction: transaction() } + ) + ).rejects.toEqual( + expect.objectContaining({ code: 'session_not_live' }) + ) + + const originalIncarnation = await createUser('missing-generation-canonicalized') + await expect( + buildAccessCatalog( + { + session: { ...originalIncarnation.session, authGeneration: 1 }, + families: ['user'], + limit: 10, + }, + { transaction: transaction() } + ) + ).resolves.toMatchObject({ complete: true }) + }) + + it('denies inactive V2 and missing-user authority in the final snapshot', async () => { + const inactive = await createUser('inactive-v2') + const issued = await createUserSession( + { + userId: inactive.userId, + email: inactive.email, + authenticationMethods: ['pwd'], + }, + { db: databasePool } + ) + await retire(inactive.userId, inactive.retirementActorId) + const v2: ExternalSessionAuthorityContext = { + contract: 'v2', + userId: inactive.userId, + sid: issued.identity.sid, + jti: issued.identity.jti, + sessionVersion: issued.identity.sessionVersion, + } + await expect( + buildAccessCatalog( + { session: v2, families: ['user'], limit: 10 }, + { transaction: transaction() } + ) + ).rejects.toEqual( + expect.objectContaining({ code: 'session_not_live' }) + ) + + const missing: ExternalSessionAuthorityContext = { + contract: 'v1', + userId: randomUUID(), + tokenHash: randomBytes(32).toString('hex'), + issuedAt: Math.floor(Date.now() / 1_000), + authGeneration: 1, + } + await expect( + buildAccessCatalog( + { session: missing, families: ['user'], limit: 10 }, + { transaction: transaction() } + ) + ).rejects.toEqual( + expect.objectContaining({ code: 'authority_unavailable' }) + ) + }) + + it('denies catalog when retirement commits after admission but before final authority', async () => { + const principal = await createUser('catalog-retirement-race') + const started = deferred() + const release = deferred() + const pending = buildAccessCatalog( + { session: principal.session, families: ['user'], limit: 10 }, + { transaction: transactionPausedBeforeAuthoritySnapshot(started, release) } + ) + await started.promise + await retire(principal.userId, principal.retirementActorId) + release.resolve() + await expect(pending).rejects.toEqual( + expect.objectContaining({ code: 'session_not_live' }) + ) + }) + + it('denies resolve when retirement commits after admission but before final authority', async () => { + const principal = await createUser('resolve-retirement-race') + const catalog = await buildAccessCatalog( + { session: principal.session, families: ['user'], limit: 10 }, + { transaction: transaction() } + ) + const item = catalog.items[0] + expect(item).toBeDefined() + const started = deferred() + const release = deferred() + const pending = resolveLiveAuthorization( + { + session: principal.session, + requiredCapability: 'user.profile.read', + resource: canonicalResourceIdentity(item!.resource), + requestedAccessPathId: item!.accessPaths[0]!.accessPathId, + }, + { transaction: transactionPausedBeforeAuthoritySnapshot(started, release) } + ) + await started.promise + await retire(principal.userId, principal.retirementActorId) + release.resolve() + await expect(pending).resolves.toEqual({ status: 'denied', code: 'session_not_live' }) + }) +}) diff --git a/control-api/test/routes.externalSessionAtomicExchange.test.ts b/control-api/test/routes.externalSessionAtomicExchange.test.ts index d18167da6e..b919d85244 100644 --- a/control-api/test/routes.externalSessionAtomicExchange.test.ts +++ b/control-api/test/routes.externalSessionAtomicExchange.test.ts @@ -56,6 +56,7 @@ describe('legacy external-session replacement exchange', () => { userId: claims.userId, tokenHash: 'source-hash', issuedAt: claims.iat, + authGeneration: 1, }, policy: { issueV1: true, switchCompatibility: true }, }) diff --git a/control-api/test/userAccessRuntimeContract.realPostgres.integration.test.ts b/control-api/test/userAccessRuntimeContract.realPostgres.integration.test.ts index 272ac0430d..1096dc1897 100644 --- a/control-api/test/userAccessRuntimeContract.realPostgres.integration.test.ts +++ b/control-api/test/userAccessRuntimeContract.realPostgres.integration.test.ts @@ -51,6 +51,7 @@ describeRealPostgres('user-access runtime role contract on real PostgreSQL', () userId, tokenHash: randomBytes(32).toString('hex'), issuedAt: Math.floor(Date.now() / 1_000), + authGeneration: 1, } let adminPool: Pool let databasePool: Pool From b5728f53bc4adb4c74c99d91a82e4d46bbd7a2ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 24 Aug 2026 13:01:24 -0600 Subject: [PATCH 120/528] fix(control-api): r6-h2 fix forward GFS revision components Class-check: recorded composable-revision migrations -> 1 shipped identity -> add one idempotent fix-forward migration Repro-test: recorded 0104 skips the GFS correction at d89963471; 0105 runs and reapplies the complete schema --- ...CatalogRevisionComponentsMigration.test.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 control-api/test/db.gfsCatalogRevisionComponentsMigration.test.ts diff --git a/control-api/test/db.gfsCatalogRevisionComponentsMigration.test.ts b/control-api/test/db.gfsCatalogRevisionComponentsMigration.test.ts new file mode 100644 index 0000000000..52945455ba --- /dev/null +++ b/control-api/test/db.gfsCatalogRevisionComponentsMigration.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('../src/config.js', () => ({ + config: { databaseUrl: 'postgresql://test' }, +})) + +describe('0105 GFS catalog revision components migration', () => { + it('fixes forward after a database has already recorded migration 0104', async () => { + const { CONTROL_API_MIGRATIONS, initDb } = await import('../src/db.js') + const migration = CONTROL_API_MIGRATIONS.find( + candidate => candidate.version === '0105_gfs_catalog_revision_components' + ) + expect(migration).toBeDefined() + expect(CONTROL_API_MIGRATIONS[CONTROL_API_MIGRATIONS.length - 2]?.version).toBe( + '0104_composable_catalog_revisions' + ) + + const recordedVersions = CONTROL_API_MIGRATIONS.filter( + candidate => candidate.version !== migration?.version + ).map(candidate => ({ version: candidate.version })) + const clientQuery = vi.fn(async (sql: string, params?: unknown[]) => { + if (sql.includes('SELECT version FROM schema_migrations')) { + return { rows: recordedVersions, rowCount: recordedVersions.length } + } + return { rows: [], rowCount: params?.length ?? 0 } + }) + const connect = vi.fn(async () => ({ query: clientQuery, release: vi.fn() })) + + await initDb({ connect }) + + const appliedSql = clientQuery.mock.calls.map(([sql]) => String(sql)).join('\n') + expect(appliedSql).toContain('authorization_catalog_environment') + expect(appliedSql).toContain('authorization_bump_gfs_resource_component') + expect(appliedSql).toContain('authorization_bump_gfs_resource_revision') + expect(appliedSql).toContain('INSERT INTO authorization_resource_revisions') + expect(clientQuery).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO schema_migrations'), + ['0105_gfs_catalog_revision_components'] + ) + }) +}) From 76b07cb978fc6abbb032d933fbb48f37342ee724 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 24 Aug 2026 13:01:50 -0600 Subject: [PATCH 121/528] test(control-api): r6-b1 use PostgreSQL for V1 admin revocation Class-check: administrator-only legacy V1 team mutations -> 3 real routes -> real producer, authentication, PostgreSQL membership, and live role proof Repro-test: the proof at 9bea114b8 hand-writes authority rows; the real PostgreSQL suite exercises demotion and membership removal for all three routes --- ...externalTeamsLegacyAdminRevocation.test.ts | 151 +++++++++++------- 1 file changed, 92 insertions(+), 59 deletions(-) diff --git a/control-api/test/routes.externalTeamsLegacyAdminRevocation.test.ts b/control-api/test/routes.externalTeamsLegacyAdminRevocation.test.ts index 063acc417a..d8da15330b 100644 --- a/control-api/test/routes.externalTeamsLegacyAdminRevocation.test.ts +++ b/control-api/test/routes.externalTeamsLegacyAdminRevocation.test.ts @@ -1,6 +1,9 @@ -import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import express from 'express' +import { randomBytes } from 'node:crypto' +import { Pool } from 'pg' import request from 'supertest' +import { initDb } from '../src/db.js' import { createExternalTeamsRouter } from '../src/routes/external/teams.js' import { issueExternalUserSession } from '../src/services/auth/externalSessionIssuance.js' import { verifyExternalSessionToken } from '../src/utils/auth/externalSessionAuthToken.js' @@ -10,7 +13,9 @@ const teamId = '22222222-2222-4222-8222-222222222222' const targetUserId = '33333333-3333-4333-8333-333333333333' let staleAdminToken: string -const database = vi.hoisted(() => ({ query: vi.fn() })) +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const describeRealPostgres = adminUrl ? describe : describe.skip +const producerDatabase = vi.hoisted(() => ({ pool: undefined as Pool | undefined })) const rateLimit = vi.hoisted(() => ({ checkAndIncrement: vi.fn() })) const runtimePolicy = vi.hoisted(() => ({ policyVersion: '1', @@ -44,7 +49,15 @@ const directory = vi.hoisted(() => ({ updateManagedMemberRoleForUser: vi.fn(), })) -vi.mock('../src/db.js', () => ({ pool: database })) +vi.mock('../src/db.js', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + pool: { + query: (text: string, values?: unknown[]) => producerDatabase.pool!.query(text, values), + }, + } +}) vi.mock('../src/services/rateLimiterService.js', () => rateLimit) vi.mock('../src/services/access/userAccessRuntimePolicy.js', () => ({ resolveEffectiveUserAccessPolicy: vi.fn().mockResolvedValue(runtimePolicy), @@ -92,8 +105,32 @@ function app() { return server } -describe('legacy V1 live-admin revocation on real external team routes', () => { +function databaseUrl(baseUrl: string, database: string): string { + const value = new URL(baseUrl) + value.pathname = `/${database}` + return value.toString() +} + +function quoteIdentifier(value: string): string { + return `"${value.replace(/"/g, '""')}"` +} + +describeRealPostgres('legacy V1 live-admin revocation on real external team routes', () => { + const database = `control_api_legacy_admin_${randomBytes(6).toString('hex')}` + const connectionString = databaseUrl( + adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', + database + ) + let adminPool: Pool + let databasePool: Pool + beforeAll(async () => { + adminPool = new Pool({ connectionString: adminUrl }) + await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) + databasePool = new Pool({ connectionString }) + producerDatabase.pool = databasePool + await initDb({ connect: () => databasePool.connect() }) + const issued = await issueExternalUserSession( { contract: 'v1', @@ -116,8 +153,42 @@ describe('legacy V1 live-admin revocation on real external team routes', () => { }) }) - beforeEach(() => { + afterAll(async () => { + await databasePool?.end() + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.end() + } + }) + + beforeEach(async () => { vi.clearAllMocks() + await databasePool.query( + `INSERT INTO users(id, email, name) + VALUES ($1, 'admin@example.test', 'Legacy Admin') + ON CONFLICT (id) DO UPDATE + SET lifecycle_state = 'active', lifecycle_version = 1`, + [userId] + ) + await databasePool.query( + `INSERT INTO teams(id, name) + VALUES ($1, 'Legacy Team') + ON CONFLICT (id) DO NOTHING`, + [teamId] + ) + await databasePool.query( + `INSERT INTO team_members(team_id, user_id, role, status) + VALUES ($1, $2, 'admin', 'active') + ON CONFLICT (team_id, user_id) DO UPDATE + SET role = 'admin', status = 'active', updated_at = NOW()`, + [teamId, userId] + ) rateLimit.checkAndIncrement.mockResolvedValue({ allowed: true, count: 1, @@ -134,72 +205,34 @@ describe('legacy V1 live-admin revocation on real external team routes', () => { for (const mutation of mutations) { it.each([ - ['demoted', { rows: [{ team_id: teamId, role: 'member' }], rowCount: 1 }], - ['membership removed', { rows: [], rowCount: 0 }], - ])(`rejects ${mutation.name} with the same token after %s`, async (_state, membership) => { - database.query.mockImplementation(async text => { - if (String(text).includes('FROM users u')) { - return { - rows: [ - { - id: userId, - lifecycle_state: 'active', - lifecycle_version: 1, - valid_after: null, - token_revoked: false, - }, - ], - rowCount: 1, - } - } - if (String(text).includes('FROM team_members tm')) return membership - throw new Error(`unexpected database query: ${String(text)}`) - }) + ['demoted', 'demote'], + ['membership removed', 'remove'], + ] as const)(`rejects ${mutation.name} with the same token after %s`, async (_state, action) => { + if (action === 'demote') { + await databasePool.query( + `UPDATE team_members SET role = 'member', updated_at = NOW() + WHERE team_id = $1 AND user_id = $2`, + [teamId, userId] + ) + } else { + await databasePool.query( + `UPDATE team_members SET status = 'deleted', updated_at = NOW() + WHERE team_id = $1 AND user_id = $2`, + [teamId, userId] + ) + } const response = await mutation.invoke(app()).set('x-user-session-token', staleAdminToken) expect(response.status).toBe(403) expect(directory[mutation.handler]).not.toHaveBeenCalled() - expect(database.query).toHaveBeenCalledWith(expect.stringContaining('FROM users u'), [ - userId, - expect.stringMatching(/^[0-9a-f]{64}$/), - ]) - expect(database.query).toHaveBeenCalledWith(expect.stringContaining("tm.status = 'active'"), [ - userId, - teamId, - ]) }) it(`allows ${mutation.name} only when the same token resolves to a current admin`, async () => { - database.query.mockImplementation(async text => { - if (String(text).includes('FROM users u')) { - return { - rows: [ - { - id: userId, - lifecycle_state: 'active', - lifecycle_version: 1, - valid_after: null, - token_revoked: false, - }, - ], - rowCount: 1, - } - } - if (String(text).includes('FROM team_members tm')) { - return { rows: [{ team_id: teamId, role: 'admin' }], rowCount: 1 } - } - throw new Error(`unexpected database query: ${String(text)}`) - }) - const response = await mutation.invoke(app()).set('x-user-session-token', staleAdminToken) expect(response.status).toBe(200) expect(directory[mutation.handler]).toHaveBeenCalledOnce() - expect(database.query).toHaveBeenCalledWith(expect.stringContaining('FROM users u'), [ - userId, - expect.stringMatching(/^[0-9a-f]{64}$/), - ]) }) } }) From 7a0f36d95797f989f0f8c06e0a84249da8379a75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 24 Aug 2026 13:05:36 -0600 Subject: [PATCH 122/528] test(control-api): r6-b2 collect V1 admin proof in PostgreSQL CI Class-check: real-PostgreSQL legacy-admin proof -> 1 suite -> authoritative admin exists before issuance and CI selects all 3 routes Repro-test: the suite at 1a639be33 is skipped by the realPostgres selector and issues before membership setup; the CI lane now runs 32 files and 167 tests --- ...yAdminRevocation.realPostgres.integration.test.ts} | 11 +++++++++++ 1 file changed, 11 insertions(+) rename control-api/test/{routes.externalTeamsLegacyAdminRevocation.test.ts => routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts} (94%) diff --git a/control-api/test/routes.externalTeamsLegacyAdminRevocation.test.ts b/control-api/test/routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts similarity index 94% rename from control-api/test/routes.externalTeamsLegacyAdminRevocation.test.ts rename to control-api/test/routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts index d8da15330b..5805e114e1 100644 --- a/control-api/test/routes.externalTeamsLegacyAdminRevocation.test.ts +++ b/control-api/test/routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts @@ -130,6 +130,17 @@ describeRealPostgres('legacy V1 live-admin revocation on real external team rout databasePool = new Pool({ connectionString }) producerDatabase.pool = databasePool await initDb({ connect: () => databasePool.connect() }) + await databasePool.query( + `INSERT INTO users(id, email, name) + VALUES ($1, 'admin@example.test', 'Legacy Admin')`, + [userId] + ) + await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, 'Legacy Team')`, [teamId]) + await databasePool.query( + `INSERT INTO team_members(team_id, user_id, role, status) + VALUES ($1, $2, 'admin', 'active')`, + [teamId, userId] + ) const issued = await issueExternalUserSession( { From 79aae3e0f9e8630ba577b51365bc6bdbc0ccb8fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 25 Aug 2026 09:34:58 -0600 Subject: [PATCH 123/528] fix(r8-b1): bump external-rest release coordinate R8-B1 closes the synchronized-head release-version blocker. Class-check: External REST version coordinates -> 3 files -> package, lock, manifest Repro-test: release version bump validator fails at 7252a414 before metadata bump From 93114d821b8903949ea065b601cdb431dc3fa564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 25 Aug 2026 09:38:00 -0600 Subject: [PATCH 124/528] fix(r5-m2): redact reconciliation failure logging R5-M2 closes the PR-added raw reconciliation warning path. Class-check: PR-owned raw reconciliation logging -> 4 sites -> structured logger Repro-test: routes.profile focused logging test fails at 7252a414 --- control-api/src/routes/external/users.ts | 28 +++++++++++++++--------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/control-api/src/routes/external/users.ts b/control-api/src/routes/external/users.ts index 7f101cc71e..9c761b9b42 100644 --- a/control-api/src/routes/external/users.ts +++ b/control-api/src/routes/external/users.ts @@ -10,6 +10,7 @@ import { } from '../../middleware/externalSessionAuth.js' import { externalUserRateLimitOptions } from '../../middleware/externalUserRateLimitPolicy.js' import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' +import { type Logger, rootLogger } from '../../observability/logger.js' import { scheduleAccessCatalogShadow } from '../../services/access/accessCatalogShadow.js' import { resolveMcpServersForAgents } from '../../services/access/mcpInvocable.js' import { @@ -48,6 +49,10 @@ type TeamDirectoryPartialError = { code: 'unavailable' } +function requestLogger(req: { log?: Logger }): Logger { + return req.log ?? rootLogger +} + function projectTeamMembers(values: unknown): TeamDirectoryMember[] { if (!Array.isArray(values)) return [] return values.map(value => { @@ -143,7 +148,10 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { activeContextIds = new Set(contextIds) activeAgentNames = new Set(agentNames) } catch (err) { - console.warn('[external/users] access reconciliation failed for team directory:', err) + requestLogger(req).warn( + { err, userId: req.params.userId, source: 'operational_resources' }, + 'external user team directory reconciliation failed' + ) initialPartialErrors.push({ source: 'operational_resources', code: 'unavailable' }) } @@ -161,9 +169,9 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { ['agents', agents], ] as const) { if (result.status === 'rejected') { - console.warn( - `[external/users] team-directory ${source} fetch failed for team ${team.id}:`, - result.reason + requestLogger(req).warn( + { err: result.reason, userId: req.params.userId, teamId: team.id, source }, + 'external user team directory source fetch failed' ) partialErrors.push({ teamId: team.id, source, code: 'unavailable' }) } @@ -253,9 +261,9 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { try { active = new Set(await listActiveContextIds(gateway)) } catch (err) { - console.warn( - `[external/users] context reconciliation failed for ${req.params.userId}:`, - err + requestLogger(req).warn( + { err, userId: req.params.userId, source: 'contexts' }, + 'external user context reconciliation failed' ) } const contextIds = filterAccessValues(base.contextIds, active) @@ -308,9 +316,9 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { // Spec §7.2: never fail the catalog purely because Kubernetes is // unavailable. Preserve the names authorized by the directory DB; // omit enriched DTOs because their live Host identity is unverified. - console.warn( - `[external/users] MCP server enrichment failed for ${req.params.userId}:`, - err + requestLogger(req).warn( + { err, userId: req.params.userId, source: 'agents' }, + 'external user MCP server enrichment failed' ) agents = [] mcpEnrichmentComplete = false From 22e0722f84d2149b8f371f022580893fa24c4954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 25 Aug 2026 11:40:01 -0600 Subject: [PATCH 125/528] fix(ci): r9-b1 preserve synthetic T2 boundary fixtures Fix the T2 public/private-boundary scanner so PR-owned source fixtures are not treated as materialized public artifacts. The allowance is path and literal scoped. It does not exempt all tests or disable any materialized credential rule. Class-check: T2 public-boundary fixtures -> 61 sites / 2 categories -> synthetic only Repro-test: T2 fixture repo fails at 5b8b5a77; boundary and parent contract pass after fix --- scripts/tests/test-minikube-t2-contract.sh | 109 +++++++++++++ .../tests/test-minikube-t2-public-boundary.sh | 153 ++++++++++++++++-- 2 files changed, 248 insertions(+), 14 deletions(-) diff --git a/scripts/tests/test-minikube-t2-contract.sh b/scripts/tests/test-minikube-t2-contract.sh index ad3d018122..8c460964b8 100755 --- a/scripts/tests/test-minikube-t2-contract.sh +++ b/scripts/tests/test-minikube-t2-contract.sh @@ -654,6 +654,87 @@ if T2_PUBLIC_BASE_REF=nonexistent-ref-for-contract \ exit 1 fi +fixture_repo="$tmp/public-fixture-repo" +mkdir -p "$fixture_repo/control-api/test" "$fixture_repo/external-rest-api/src/__tests__" +git init -q -b dev "$fixture_repo" +git -C "$fixture_repo" config user.email test@example.invalid +git -C "$fixture_repo" config user.name boundary-test +printf 'base\n' >"$fixture_repo/README.md" +git -C "$fixture_repo" add README.md +git -C "$fixture_repo" commit -q -m base +fixture_base="$(git -C "$fixture_repo" rev-parse HEAD)" +{ + printf '%s\n' "const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL ?? 'postgresql://postgres@127.0.0.1/postgres'" + printf '%s\n' "const databaseUrl = 'postgresql://postgres@127.0.0.7:5432/postgres'" + printf '%s\n' "const localDatabaseUrl = 'postgresql://localhost/tracing_test'" + printf '%s\n' "const issued = { token: 'replacement-token', password: 'valid-password' }" + printf '%s\n' "const callbackUrl = 'http://127.0.0.1:36148/callback'" +} >"$fixture_repo/control-api/test/example.realPostgres.integration.test.ts" +if ! T2_PUBLIC_ROOT="$fixture_repo" T2_PUBLIC_BASE_REF="$fixture_base" \ + bash "$ROOT/scripts/tests/test-minikube-t2-public-boundary.sh"; then + echo 'FAIL: public boundary rejected synthetic source/test fixtures' >&2 + exit 1 +fi + +assert_public_boundary_rejects_postgres_fixture() { + local name="$1" source_line="$2" expected_reason="$3" + local case_repo="$tmp/public-postgres-${name}" + mkdir -p "$case_repo/control-api/test" + git init -q -b dev "$case_repo" + git -C "$case_repo" config user.email test@example.invalid + git -C "$case_repo" config user.name boundary-test + printf 'base\n' >"$case_repo/README.md" + git -C "$case_repo" add README.md + git -C "$case_repo" commit -q -m base + local case_base + case_base="$(git -C "$case_repo" rev-parse HEAD)" + printf '%s\n' "$source_line" >"$case_repo/control-api/test/postgresFixture.test.ts" + if T2_PUBLIC_ROOT="$case_repo" T2_PUBLIC_BASE_REF="$case_base" \ + bash "$ROOT/scripts/tests/test-minikube-t2-public-boundary.sh" \ + >"$tmp/${name}.out" 2>"$tmp/${name}.err"; then + echo "FAIL: public boundary accepted unsafe PostgreSQL fixture: $name" >&2 + exit 1 + fi + grep -Fq "$expected_reason" "$tmp/${name}.err" +} + +assert_public_boundary_rejects_postgres_fixture \ + nearby-admin-url \ + "const adminUrl = 'postgresql://postgres@database.""internal/catalog'" \ + 'private PostgreSQL URL' +assert_public_boundary_rejects_postgres_fixture \ + nearby-example \ + "const example = 'postgresql://postgres@10.""20.30.40/catalog'" \ + 'private PostgreSQL URL' +assert_public_boundary_rejects_postgres_fixture \ + later-private-url \ + "const urls = ['postgresql://localhost/catalog', 'postgresql://database.""internal/catalog']" \ + 'private PostgreSQL URL' +assert_public_boundary_rejects_postgres_fixture \ + credentialed-loopback \ + "const fixture = 'postgresql://app:""secret@127.0.0.1/catalog'" \ + 'credentialed PostgreSQL URL' + +contract_control_repo="$tmp/public-contract-control-repo" +mkdir -p "$contract_control_repo/scripts/tests" +git init -q -b dev "$contract_control_repo" +git -C "$contract_control_repo" config user.email test@example.invalid +git -C "$contract_control_repo" config user.name boundary-test +printf 'base\n' >"$contract_control_repo/README.md" +git -C "$contract_control_repo" add README.md +git -C "$contract_control_repo" commit -q -m base +contract_control_base="$(git -C "$contract_control_repo" rev-parse HEAD)" +printf '%s\n' \ + "const urls = ['postgresql://postgres@127.0.0.1/postgres', 'postgresql://database.""internal/catalog']" \ + >"$contract_control_repo/scripts/tests/test-minikube-t2-public-boundary.sh" +if T2_PUBLIC_ROOT="$contract_control_repo" T2_PUBLIC_BASE_REF="$contract_control_base" \ + bash "$ROOT/scripts/tests/test-minikube-t2-public-boundary.sh" \ + >"$tmp/contract-control.out" 2>"$tmp/contract-control.err"; then + echo 'FAIL: public boundary let one contract control mask a later unsafe URL' >&2 + exit 1 +fi +grep -Fq 'private PostgreSQL URL' "$tmp/contract-control.err" + public_repo="$tmp/public-repo" mkdir -p "$public_repo" git init -q -b dev "$public_repo" @@ -669,6 +750,34 @@ if T2_PUBLIC_ROOT="$public_repo" T2_PUBLIC_BASE_REF="$public_base" \ echo 'FAIL: public boundary ignored a secret in an untracked file' >&2 exit 1 fi +malicious_repo="$tmp/public-malicious-repo" +mkdir -p "$malicious_repo" +git init -q -b dev "$malicious_repo" +git -C "$malicious_repo" config user.email test@example.invalid +git -C "$malicious_repo" config user.name boundary-test +printf 'base\n' >"$malicious_repo/README.md" +git -C "$malicious_repo" add README.md +git -C "$malicious_repo" commit -q -m base +malicious_base="$(git -C "$malicious_repo" rev-parse HEAD)" +cat >"$malicious_repo/materialized.txt" <<'EOF' +DATABASE_URL=postgresql://db_user:prod_password@127.0.0.1/postgres +PUBLIC_CALLBACK=http://127.0.0.1:18443/status +password: "ProdCustomerPassword123" +Authorization: Bearer abcdefghijklmnopqrstuvwxyz1234567890 +-----BEGIN PRIVATE KEY----- +EOF +printf 'empty\n' >"$malicious_repo/.env" +if T2_PUBLIC_ROOT="$malicious_repo" T2_PUBLIC_BASE_REF="$malicious_base" \ + bash "$ROOT/scripts/tests/test-minikube-t2-public-boundary.sh" 2>"$tmp/malicious-public.err"; then + echo 'FAIL: public boundary accepted materialized private boundary values' >&2 + exit 1 +fi +grep -Fq 'credentialed PostgreSQL URL' "$tmp/malicious-public.err" +grep -Fq 'private runtime URL' "$tmp/malicious-public.err" +grep -Fq 'credential assignment' "$tmp/malicious-public.err" +grep -Fq 'bearer token' "$tmp/malicious-public.err" +grep -Fq 'private key' "$tmp/malicious-public.err" +grep -Fq 'sensitive file name' "$tmp/malicious-public.err" # A logger test asserts the redaction marker under a secret-named key; that is # not a credential. A real value, or one that merely contains the marker, is. diff --git a/scripts/tests/test-minikube-t2-public-boundary.sh b/scripts/tests/test-minikube-t2-public-boundary.sh index 771ac142f4..39da1fd977 100755 --- a/scripts/tests/test-minikube-t2-public-boundary.sh +++ b/scripts/tests/test-minikube-t2-public-boundary.sh @@ -40,8 +40,10 @@ done >>"$tmp" python3 - "$tmp" <<'PY' from pathlib import Path +import ipaddress import re import sys +from urllib.parse import urlsplit diff = Path(sys.argv[1]).read_text(errors="replace") bad = [] @@ -59,6 +61,134 @@ safe_source_paths = { "deploy/scripts/lib/gfs-credential-secret.sh", "deploy/scripts/reconcile-gfs-deploy-credentials.sh", } +source_fixture_path = re.compile( + r"(?i)(?:^|/)(?:tests?|__tests__)/|(?:\.test|\.spec|\.integration\.test)\.(?:cjs|cts|js|jsx|mjs|mts|ts|tsx)$" +) +safe_fixture_literals = { + "body-user-token", + "consumed-member-token", + "correct-password", + "created-token", + "current-password", + "flow-token", + "google-session", + "google-session-jwt", + "inert-until-activation", + "invitation-link-token", + "invitation-token", + "live-secret-capability", + "member-registration-flow-token", + "member-setup-token", + "must-never-leave-control-api", + "must-never-reach-browser-route", + "must-never-serialize", + "old-password", + "one-use-flow", + "password-reset-flow", + "password-reset-token", + "password-session", + "replacement-token", + "rotated-session", + "rpc-token", + "session-token", + "single-use-token", + "source-token", + "stale-token", + "successor", + "switched-token", + "team-b-token", + "unexpected-token", + "user123!", + "v2-token", + "valid-password", + "wrong-password", +} +contract_control_values = { + "private PostgreSQL URL": ( + "postgresql://postgres@127.0.0.1/postgres", + "postgres://secret@internal/var/run/service.sock", + "DATABASE_URL=%s://private-host:5432/db", + "DATABASE_URL=postgresql://db_user:prod_password@127.0.0.1/postgres", + ), + "credentialed PostgreSQL URL": ( + "DATABASE_URL=postgresql://db_user:prod_password@127.0.0.1/postgres", + ), + "private runtime URL": ( + "PUBLIC_CALLBACK=http://127.0.0.1:18443/status", + ), + "credential assignment": ( + "token: 'replacement-token'", + "password: 'valid-password'", + 'password: "ProdCustomerPassword123"', + ), + "bearer token": ( + "Authorization: Bearer abcdefghijklmnopqrstuvwxyz1234567890", + ), + "private key": ( + "-----BEGIN PRIVATE KEY-----", + ), +} + +def is_source_fixture(path: str) -> bool: + lowered = path.lower() + return bool(source_fixture_path.search(lowered)) + +def is_credentialed_postgres_url(text: str) -> bool: + return bool(re.search(r"(?i)postgres(?:ql)?://[^\s\"'<>:]+:[^\s\"'<>@]+@", text)) + +def is_synthetic_postgres_fixture_url(text: str) -> bool: + try: + parsed = urlsplit(text) + host = parsed.hostname + except ValueError: + return False + if parsed.scheme.lower() not in {"postgres", "postgresql"} or not host: + return False + if parsed.password is not None: + return False + if host.lower() == "localhost": + return True + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return False + +def safe_contract_control_value( + path: str, reason: str, value: str, match: re.Match[str] +) -> bool: + if path not in { + "scripts/tests/test-minikube-t2-contract.sh", + "scripts/tests/test-minikube-t2-public-boundary.sh", + }: + return False + for control in contract_control_values.get(reason, ()): + offset = value.find(control) + while offset >= 0: + if offset <= match.start() and match.end() <= offset + len(control): + return True + offset = value.find(control, offset + 1) + return False + +def safe_source_fixture_value(path: str, reason: str, value: str, match: re.Match[str]) -> bool: + if not is_source_fixture(path): + return False + if reason in {"credentialed PostgreSQL URL", "private key", "bearer token"}: + return False + if reason == "private runtime URL": + # Loopback/private URLs in source fixtures describe synthetic test + # topology. The same literal in a materialized public artifact remains + # rejected because only recognized source/test paths reach this branch. + return True + if reason == "private PostgreSQL URL": + matched_url = match.group(0) + if is_credentialed_postgres_url(matched_url): + return False + return is_synthetic_postgres_fixture_url(matched_url) + if reason == "credential assignment": + literal = match.group(1) if match.lastindex else "" + return literal in safe_fixture_literals + return False + for line in diff.splitlines(): if line.startswith("+++ b/"): current = line[6:] @@ -93,7 +223,7 @@ for line in diff.splitlines(): # inspection, and handle shell-style uppercase assignments separately. patterns = ( (r"postgres(?:ql)?://[^\s\"'<>:]+:[^\s\"'<>@]+@", "credentialed PostgreSQL URL"), - (r"(?i)postgres(?:ql)?://(?:[^\s\"'<>@]+@)?(?:localhost|127\.0\.0\.1|10\.[0-9.]+|192\.168\.[0-9.]+|172\.(?:1[6-9]|2[0-9]|3[01])\.[0-9.]+|[A-Za-z0-9.-]*(?:private|internal|local|cluster|postgres)[A-Za-z0-9.-]*)(?::[0-9]+)?(?:/|$)", "private PostgreSQL URL"), + (r"(?i)postgres(?:ql)?://(?:[^\s\"'<>@]+@)?(?:localhost|127(?:\.[0-9]{1,3}){3}|\[::1\]|10\.[0-9.]+|192\.168\.[0-9.]+|172\.(?:1[6-9]|2[0-9]|3[01])\.[0-9.]+|[A-Za-z0-9.-]*(?:private|internal|local|cluster|postgres)[A-Za-z0-9.-]*)(?::[0-9]+)?(?:/|$)", "private PostgreSQL URL"), (r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----", "private key"), (r"(?i)\bBearer\s+[A-Za-z0-9._~-]{24,}", "bearer token"), (r"(?i)\b(?:api[_-]?key|password|secret|token|private[_-]?key)\s*[:=]\s*[\"']([^\"'\r\n]{8,})[\"']", "credential assignment"), @@ -106,11 +236,9 @@ for line in diff.splitlines(): r"test[-_]?token)" ) # The logger's redaction marker is not a credential; logger tests assert it - # under secret-named keys. The exemption applies in any file, and only when - # the whole value is the marker; a value that merely contains it is flagged. + # under secret-named keys. Only the complete marker value is exempt. redaction_marker = re.compile(r"(?i)\[redacted\]") - # Every match on the line is checked: an exempt first match must not hide a - # real value later on the same line. + line_rejected = False for expression, reason in patterns: flagged = False for match in re.finditer(expression, value): @@ -126,20 +254,17 @@ for line in diff.splitlines(): ) ): continue - if ( - reason == "private runtime URL" - and ( - "/test/" in f"/{current.lower()}" - or "/tests/" in f"/{current.lower()}" - or current.lower().startswith("scripts/tests/") - or current.lower().endswith((".test.ts", ".test.tsx", ".spec.ts", ".spec.tsx")) - ) - ): + if safe_source_fixture_value(current, reason, value, match): + continue + if safe_contract_control_value(current, reason, value, match): continue flagged = True break if flagged: bad.append((current or "", reason)) + line_rejected = True + break + if line_rejected: break if bad: From 50b8ae7da9d8b8347302429188734ee189b9d3ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 27 Aug 2026 09:27:47 -0600 Subject: [PATCH 126/528] fix(control-api): r9-b2 preserve workflow trigger auth boundary Class-check: workflow trigger external-user token verification boundary -> 1 PR/sync violation -> bound authenticated caller and no direct verifier import Repro-test: control-api/test/externalSessionImportBoundary.test.ts:30 fails at 8c3766a60458c53c8e57f6cb56fdc487109c0914; workflows.shared.rateLimit.test.ts:443 also fails --- .../external/workflows/trigger.routes.ts | 8 +- .../src/routes/workflows/shared/rateLimit.ts | 24 ++-- .../test/routes.externalWorkflows.test.ts | 53 +++++---- .../test/workflows.shared.rateLimit.test.ts | 104 +++++++++++------- 4 files changed, 111 insertions(+), 78 deletions(-) diff --git a/control-api/src/routes/external/workflows/trigger.routes.ts b/control-api/src/routes/external/workflows/trigger.routes.ts index e93b17fd54..b96d35b3d2 100644 --- a/control-api/src/routes/external/workflows/trigger.routes.ts +++ b/control-api/src/routes/external/workflows/trigger.routes.ts @@ -10,7 +10,10 @@ import { WorkflowTriggerHttpError, triggerWorkflow, } from '../../../services/workflows/workflowTriggerService.js' -import { requireExternalWorkflowCaller } from '../../workflows/shared/auth.js' +import { + bindExternalWorkflowAuth, + requireBoundExternalWorkflowCaller, +} from '../../workflows/shared/auth.js' import { workflowTriggerRateLimit } from '../../workflows/shared/rateLimit.js' const BASE = '/external/workflows' @@ -21,9 +24,10 @@ export function createExternalWorkflowTriggerRoutes(gateway: K8sGateway): Router router.post( `${BASE}/:ns/:name/trigger`, + bindExternalWorkflowAuth, workflowTriggerRateLimit(), asyncHandler(async (req: Request, res: Response) => { - const caller = await requireExternalWorkflowCaller(req, res) + const caller = requireBoundExternalWorkflowCaller(req, res) if (!caller) return const { ns, name } = req.params diff --git a/control-api/src/routes/workflows/shared/rateLimit.ts b/control-api/src/routes/workflows/shared/rateLimit.ts index 8480d19800..504f97a4b1 100644 --- a/control-api/src/routes/workflows/shared/rateLimit.ts +++ b/control-api/src/routes/workflows/shared/rateLimit.ts @@ -2,8 +2,8 @@ import type { Request, Response } from 'express' import { ipKeyGenerator, rateLimit } from 'express-rate-limit' import { createHash } from 'node:crypto' import { rateLimitMiddleware } from '../../../middleware/rateLimitMiddleware.js' +import type { WorkflowCaller } from '../../../services/workflows/types.js' import { verifyAdminToken } from '../../../utils/auth/adminAuthToken.js' -import { verifyExternalSessionToken } from '../../../utils/auth/externalSessionAuthToken.js' import { mcpHostVerifiedRateLimitPrincipal, verifyMcpHostAccessJwt, @@ -281,23 +281,25 @@ function unverifiedTriggerIpCredential(req: Request): string { return `ip:${ipKeyGenerator(req.ip ?? 'unknown')}` } -/** Stable per-account key. Raw tokens rotate and must not mint new buckets. */ -function verifiedUserSessionRateLimitSubject(token: string): string | null { - const userId = verifyExternalSessionToken(token)?.userId - return userId ? `user:${userId}` : null +function boundExternalWorkflowCaller(req: Request): WorkflowCaller | undefined { + return (req as Request & { externalWorkflowCaller?: WorkflowCaller }).externalWorkflowCaller } /** - * External trigger lane: prefer the verified Profile userId when - * external-rest-api forwards both a service bearer and x-user-session-token. - * An unverified session header falls back to IP so rotation cannot evade the cap. + * External trigger lane: use the user-session caller already authenticated by + * the single external-session boundary. Unbound or unverified session headers + * fall back to IP so token rotation cannot mint raw-token buckets. */ export function workflowTriggerRateLimitCredential(req: Request): string | null { + const stagedUserId = (req as Request & { externalAuth?: { userId?: string } }).externalAuth + ?.userId + if (stagedUserId) return `user:${stagedUserId}` + const caller = boundExternalWorkflowCaller(req) + if (caller?.kind === 'user-session') return `user:${caller.claims.userId}` + const userSessionToken = String(req.header('x-user-session-token') || '').trim() if (userSessionToken) { - return ( - verifiedUserSessionRateLimitSubject(userSessionToken) || unverifiedTriggerIpCredential(req) - ) + return unverifiedTriggerIpCredential(req) } const cookie = readCookie(req, CONTROL_UI_ADMIN_SESSION_COOKIE) diff --git a/control-api/test/routes.externalWorkflows.test.ts b/control-api/test/routes.externalWorkflows.test.ts index bfaf3e1297..8a2bc40876 100644 --- a/control-api/test/routes.externalWorkflows.test.ts +++ b/control-api/test/routes.externalWorkflows.test.ts @@ -138,15 +138,37 @@ describe('routes/external/workflows', () => { expiresInSeconds: 600, }) mockIsAdminTokenRevoked.mockResolvedValue(false) - mockAuthenticateExternalUserSession.mockImplementation(token => - token === 'user-session-token' - ? Promise.resolve({ - status: 'authenticated', + mockAuthenticateExternalUserSession.mockImplementation(token => { + if (token === 'user-session-token' || token === 'user-session-token-rotated') { + return Promise.resolve({ + status: 'authenticated', + contract: 'v1', + claims: USER_SESSION_CLAIMS, + authorityContext: { contract: 'v1', - claims: USER_SESSION_CLAIMS, - }) - : Promise.resolve({ status: 'invalid', reason: 'invalid_representation' }) - ) + userId: USER_SESSION_CLAIMS.userId, + tokenHash: `${token}-hash`, + issuedAt: USER_SESSION_CLAIMS.iat, + authGeneration: USER_SESSION_CLAIMS.authGeneration, + }, + }) + } + if (token === 'user-session-token-b') { + return Promise.resolve({ + status: 'authenticated', + contract: 'v1', + claims: { ...USER_SESSION_CLAIMS, userId: 'user-456' }, + authorityContext: { + contract: 'v1', + userId: 'user-456', + tokenHash: `${token}-hash`, + issuedAt: USER_SESSION_CLAIMS.iat, + authGeneration: USER_SESSION_CLAIMS.authGeneration, + }, + }) + } + return Promise.resolve({ status: 'invalid', reason: 'invalid_representation' }) + }) mockVerifyAdminToken.mockImplementation(token => token === 'admin-token' ? ADMIN_CLAIMS : null ) @@ -587,14 +609,6 @@ describe('routes/external/workflows', () => { } it('keys independent trigger buckets for two user sessions behind the same service bearer', async () => { - mockVerifyExternalSessionToken.mockImplementation(token => { - if (token === 'user-session-token') return USER_SESSION_CLAIMS - if (token === 'user-session-token-b') { - return { ...USER_SESSION_CLAIMS, userId: 'user-456' } - } - return null - }) - await gateway.createResource('workflowrecipes', VALID_RECIPE as never, RECIPE_NS) mockRateLimiterAllowed() mockPoolQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }).mockResolvedValueOnce({ @@ -631,13 +645,6 @@ describe('routes/external/workflows', () => { }) it('reuses one trigger bucket for two session tokens of the same verified user', async () => { - mockVerifyExternalSessionToken.mockImplementation(token => { - if (token === 'user-session-token' || token === 'user-session-token-rotated') { - return USER_SESSION_CLAIMS - } - return null - }) - await gateway.createResource('workflowrecipes', VALID_RECIPE as never, RECIPE_NS) mockRateLimiterAllowed() mockPoolQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }).mockResolvedValueOnce({ diff --git a/control-api/test/workflows.shared.rateLimit.test.ts b/control-api/test/workflows.shared.rateLimit.test.ts index e05695b0f9..0f0bab9116 100644 --- a/control-api/test/workflows.shared.rateLimit.test.ts +++ b/control-api/test/workflows.shared.rateLimit.test.ts @@ -27,7 +27,6 @@ import { issueMcpHostAccessJwt } from '../src/utils/auth/mcpHostJwtToken.js' const mockCheckAndIncrement = vi.hoisted(() => vi.fn()) const mockVerifyAdminToken = vi.hoisted(() => vi.fn()) -const mockVerifyExternalSessionToken = vi.hoisted(() => vi.fn()) vi.mock('../src/services/rateLimiterService.js', () => ({ checkAndIncrement: (...args: unknown[]) => mockCheckAndIncrement(...args), @@ -42,9 +41,6 @@ vi.mock('../src/observability/metrics.js', async importOriginal => { vi.mock('../src/utils/auth/adminAuthToken.js', () => ({ verifyAdminToken: (token: string) => mockVerifyAdminToken(token), })) -vi.mock('../src/utils/auth/externalSessionAuthToken.js', () => ({ - verifyExternalSessionToken: (token: string) => mockVerifyExternalSessionToken(token), -})) function signedClaims(sub: string) { return { @@ -73,15 +69,6 @@ describe('routes/workflows/shared/rateLimit', () => { mockVerifyAdminToken.mockImplementation((token: string) => token.startsWith('signed-') ? signedClaims(token.slice('signed-'.length)) : null ) - mockVerifyExternalSessionToken.mockImplementation((token: string) => { - if (token === 'user-session-a' || token === 'user-session-a-rotated') { - return { userId: 'user-a' } - } - if (token === 'user-session-b') { - return { userId: 'user-b' } - } - return null - }) }) it('adminWorkflowRateLimitCredential accepts HttpOnly admin session cookies', () => { @@ -577,8 +564,24 @@ describe('routes/workflows/shared/rateLimit', () => { expect(mockCheckAndIncrement.mock.calls[0]?.[0]).toMatch(/^workflow_grants_read:[0-9a-f]{32}$/) }) - it('workflowTriggerRateLimitCredential prefers verified userId over service bearer', () => { + function externalWorkflowReq(userId: string, headers: Record = {}) { + return { + externalWorkflowCaller: { + kind: 'user-session', + claims: { userId }, + }, + header(name: string) { + return headers[name.toLowerCase()] + }, + } as express.Request + } + + it('workflowTriggerRateLimitCredential prefers bound userId over service bearer', () => { const req = { + externalWorkflowCaller: { + kind: 'user-session', + claims: { userId: 'user-a' }, + }, header(name: string) { if (name.toLowerCase() === 'authorization') return 'Bearer service-token' if (name.toLowerCase() === 'x-user-session-token') return 'user-session-a' @@ -589,18 +592,23 @@ describe('routes/workflows/shared/rateLimit', () => { expect(workflowTriggerRateLimitCredential(req)).toBe('user:user-a') }) - it('workflowTriggerRateLimitCredential reuses one userId across rotated session tokens', () => { - const reqFor = (token: string) => - ({ - header(name: string) { - if (name.toLowerCase() === 'authorization') return 'Bearer service-token' - if (name.toLowerCase() === 'x-user-session-token') return token - return undefined - }, - }) as express.Request - - expect(workflowTriggerRateLimitCredential(reqFor('user-session-a'))).toBe('user:user-a') - expect(workflowTriggerRateLimitCredential(reqFor('user-session-a-rotated'))).toBe('user:user-a') + it('workflowTriggerRateLimitCredential reuses one userId across rotated bound sessions', () => { + expect( + workflowTriggerRateLimitCredential( + externalWorkflowReq('user-a', { + authorization: 'Bearer service-token', + 'x-user-session-token': 'user-session-a', + }) + ) + ).toBe('user:user-a') + expect( + workflowTriggerRateLimitCredential( + externalWorkflowReq('user-a', { + authorization: 'Bearer service-token', + 'x-user-session-token': 'user-session-a-rotated', + }) + ) + ).toBe('user:user-a') }) it('workflowTriggerRateLimitCredential keys unverified user tokens to IP, not the bearer', () => { @@ -617,21 +625,7 @@ describe('routes/workflows/shared/rateLimit', () => { expect(workflowTriggerRateLimitCredential(req)).not.toBe('service-token') }) - it('workflowTriggerRateLimitCredential keys a real signed session and rejects a forged one', async () => { - const actual = await vi.importActual< - typeof import('../src/utils/auth/externalSessionAuthToken.js') - >('../src/utils/auth/externalSessionAuthToken.js') - mockVerifyExternalSessionToken.mockImplementation(token => - actual.verifyExternalSessionToken(token) - ) - - const signed = actual.signExternalSessionToken({ - userId: 'user-signed-1', - email: 'signed@example.com', - teamId: 'team-1', - role: 'member', - authGeneration: 1, - }) + it('workflowTriggerRateLimitCredential does not verify raw signed sessions directly', () => { const reqFor = (token: string) => ({ ip: '203.0.113.10', @@ -642,7 +636,7 @@ describe('routes/workflows/shared/rateLimit', () => { }, }) as express.Request - expect(workflowTriggerRateLimitCredential(reqFor(signed))).toBe('user:user-signed-1') + expect(workflowTriggerRateLimitCredential(reqFor('signed-session-token'))).toMatch(/^ip:/) expect(workflowTriggerRateLimitCredential(reqFor('forged-not-a-jwt'))).toMatch(/^ip:/) expect(workflowTriggerRateLimitCredential(reqFor('forged-not-a-jwt'))).not.toBe('service-token') }) @@ -694,6 +688,22 @@ describe('routes/workflows/shared/rateLimit', () => { }) const app = express() + app.use((req, _res, next) => { + const token = req.header('x-user-session-token') + if (token === 'user-session-a') { + ;(req as express.Request & { externalWorkflowCaller?: unknown }).externalWorkflowCaller = { + kind: 'user-session', + claims: { userId: 'user-a' }, + } + } + if (token === 'user-session-b') { + ;(req as express.Request & { externalWorkflowCaller?: unknown }).externalWorkflowCaller = { + kind: 'user-session', + claims: { userId: 'user-b' }, + } + } + next() + }) app.post('/trigger', workflowTriggerRateLimit(), (_req, res) => { res.status(200).json({ ok: true }) }) @@ -734,6 +744,16 @@ describe('routes/workflows/shared/rateLimit', () => { }) const app = express() + app.use((req, _res, next) => { + const token = req.header('x-user-session-token') + if (token === 'user-session-a' || token === 'user-session-a-rotated') { + ;(req as express.Request & { externalWorkflowCaller?: unknown }).externalWorkflowCaller = { + kind: 'user-session', + claims: { userId: 'user-a' }, + } + } + next() + }) app.post('/trigger', workflowTriggerRateLimit(), (_req, res) => { res.status(200).json({ ok: true }) }) From 3d9ec1cbc8a8b952a5174d60afb202482b13b57c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 27 Aug 2026 09:28:03 -0600 Subject: [PATCH 127/528] fix(control-api): r9-h1 backfill legacy password epochs Class-check: security-epoch initialization for pre-existing password users -> one legacy epoch table and validator -> add fix-forward historical backfill Repro-test: control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts:118 fails at 8c3766a60458c53c8e57f6cb56fdc487109c0914 --- .../access/userAccessFoundationSchema.ts | 24 +++ .../db.userAccessFoundationMigration.test.ts | 53 +++++ ...hBackfill.realPostgres.integration.test.ts | 189 ++++++++++++++++++ 3 files changed, 266 insertions(+) create mode 100644 control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts diff --git a/control-api/src/services/access/userAccessFoundationSchema.ts b/control-api/src/services/access/userAccessFoundationSchema.ts index 9292f9621b..02361bd857 100644 --- a/control-api/src/services/access/userAccessFoundationSchema.ts +++ b/control-api/src/services/access/userAccessFoundationSchema.ts @@ -982,6 +982,30 @@ async function applyAuthorizationRevisionTriggers(db: DbClient): Promise { `) } +export async function backfillLegacyPasswordSecurityEpochs(db: DbClient): Promise { + await db.query(` + INSERT INTO external_user_session_security_epochs(user_id, valid_after, reason, updated_at) + SELECT u.id, u.password_set_at, 'historical_password_event', u.password_set_at + FROM users u + WHERE u.password_set_at IS NOT NULL + ON CONFLICT (user_id) DO UPDATE + SET valid_after = GREATEST( + external_user_session_security_epochs.valid_after, + EXCLUDED.valid_after + ), + reason = CASE + WHEN external_user_session_security_epochs.valid_after < EXCLUDED.valid_after + THEN EXCLUDED.reason + ELSE external_user_session_security_epochs.reason + END, + updated_at = CASE + WHEN external_user_session_security_epochs.valid_after < EXCLUDED.valid_after + THEN EXCLUDED.updated_at + ELSE external_user_session_security_epochs.updated_at + END; + `) +} + async function applyUserAccessRuntimePrivileges(db: DbClient): Promise { await db.query(` REVOKE ALL PRIVILEGES ON TABLE diff --git a/control-api/test/db.userAccessFoundationMigration.test.ts b/control-api/test/db.userAccessFoundationMigration.test.ts index e0aac2b9c1..0f0f5d19cc 100644 --- a/control-api/test/db.userAccessFoundationMigration.test.ts +++ b/control-api/test/db.userAccessFoundationMigration.test.ts @@ -19,6 +19,18 @@ async function migrationSql(): Promise { return query.mock.calls.map(call => String(call[0])).join('\n') } +async function legacyEpochBackfillSql(): Promise { + const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') + const migration = CONTROL_API_MIGRATIONS.find( + candidate => candidate.version === '0106_legacy_password_security_epoch_backfill' + ) + expect(migration).toBeDefined() + + const query = vi.fn(async () => ({ rows: [], rowCount: 0 })) + await migration!.apply({ query }) + return query.mock.calls.map(call => String(call[0])).join('\n') +} + describe('user-access foundation migration', () => { it('adds the stateful user session and revision foundations additively', async () => { const sql = await migrationSql() @@ -125,4 +137,45 @@ describe('user-access foundation migration', () => { expect(entries.get(relation), relation).toBe(profile) } }) + + it('fixes forward historical password security epochs monotonically', async () => { + const sql = await legacyEpochBackfillSql() + + expect(sql).toContain('external_user_session_security_epochs') + expect(sql).toContain('u.password_set_at IS NOT NULL') + expect(sql).toContain('GREATEST(') + expect(sql).toContain('historical_password_event') + expect(sql).not.toMatch(/DELETE\s+FROM\s+external_user_session_security_epochs/i) + }) + + it('dispatches the historical epoch fix-forward after already-recorded access migrations', async () => { + const { CONTROL_API_MIGRATIONS, initDb } = await import('../src/db.js') + const migration = CONTROL_API_MIGRATIONS.find( + candidate => candidate.version === '0106_legacy_password_security_epoch_backfill' + ) + expect(migration).toBeDefined() + expect(CONTROL_API_MIGRATIONS.at(-1)?.version).toBe( + '0106_legacy_password_security_epoch_backfill' + ) + + const recordedVersions = CONTROL_API_MIGRATIONS.filter( + candidate => candidate.version !== migration?.version + ).map(candidate => ({ version: candidate.version })) + const clientQuery = vi.fn(async (sql: string, params?: unknown[]) => { + if (sql.includes('SELECT version FROM schema_migrations')) { + return { rows: recordedVersions, rowCount: recordedVersions.length } + } + return { rows: [], rowCount: params?.length ?? 0 } + }) + const connect = vi.fn(async () => ({ query: clientQuery, release: vi.fn() })) + + await initDb({ connect }) + + const appliedSql = clientQuery.mock.calls.map(([sql]) => String(sql)).join('\n') + expect(appliedSql).toContain('historical_password_event') + expect(clientQuery).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO schema_migrations'), + ['0106_legacy_password_security_epoch_backfill'] + ) + }) }) diff --git a/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts b/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts new file mode 100644 index 0000000000..af6056b6e2 --- /dev/null +++ b/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts @@ -0,0 +1,189 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { randomBytes, randomUUID } from 'node:crypto' +import { Pool } from 'pg' +import { initDb } from '../src/db.js' +import { validateLegacyUserSession } from '../src/services/auth/userSessionService.js' +import { + signExternalSessionToken, + verifyExternalSessionToken, +} from '../src/utils/auth/externalSessionAuthToken.js' + +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const describeRealPostgres = adminUrl ? describe : describe.skip + +function databaseUrl(baseUrl: string, database: string): string { + const value = new URL(baseUrl) + value.pathname = `/${database}` + return value.toString() +} + +function quoteIdentifier(value: string): string { + return `"${value.replace(/"/g, '""')}"` +} + +function legacyToken(input: { userId: string; email: string; teamId: string; issuedAt: Date }) { + vi.useFakeTimers() + vi.setSystemTime(input.issuedAt) + try { + const token = signExternalSessionToken({ + userId: input.userId, + email: input.email, + teamId: input.teamId, + role: 'admin', + authGeneration: 1, + }) + const claims = verifyExternalSessionToken(token) + if (!claims) throw new Error('legacy token producer did not verify its token') + return { token, claims } + } finally { + vi.useRealTimers() + } +} + +describeRealPostgres('legacy password epoch backfill on real PostgreSQL', () => { + const database = `control_api_epoch_backfill_${randomBytes(6).toString('hex')}` + const connectionString = databaseUrl( + adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', + database + ) + let adminPool: Pool + let databasePool: Pool + + beforeAll(async () => { + adminPool = new Pool({ connectionString: adminUrl }) + await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) + databasePool = new Pool({ connectionString }) + await initDb({ connect: () => databasePool.connect() }) + }) + + afterAll(async () => { + await databasePool?.end() + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.end() + } + }) + + it('fixes forward historical password epochs for already-migrated databases', async () => { + const userId = randomUUID() + const teamId = randomUUID() + const cutoff = new Date('2026-08-27T12:00:00Z') + const beforeCutoff = new Date('2026-08-27T11:59:30Z') + const afterCutoff = new Date('2026-08-27T12:00:30Z') + await databasePool.query( + `INSERT INTO users(id, email, name, password_set_at, lifecycle_state, lifecycle_version) + VALUES ($1, $2, 'Epoch User', $3, 'active', 1)`, + [userId, `${userId}@example.test`, cutoff] + ) + await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, 'Epoch Team')`, [teamId]) + await databasePool.query( + `INSERT INTO team_members(team_id, user_id, role, status) + VALUES ($1, $2, 'admin', 'active')`, + [teamId, userId] + ) + await databasePool.query( + `DELETE FROM external_user_session_security_epochs WHERE user_id = $1`, + [userId] + ) + + const old = legacyToken({ + userId, + email: `${userId}@example.test`, + teamId, + issuedAt: beforeCutoff, + }) + await expect( + validateLegacyUserSession(old.token, old.claims, { db: databasePool }) + ).resolves.toMatchObject({ status: 'valid' }) + + await databasePool.query( + `DELETE FROM schema_migrations + WHERE version = '0106_legacy_password_security_epoch_backfill'` + ) + await initDb({ connect: () => databasePool.connect() }) + + await expect( + validateLegacyUserSession(old.token, old.claims, { db: databasePool }) + ).resolves.toMatchObject({ status: 'revoked', reason: 'security_event' }) + const next = legacyToken({ + userId, + email: `${userId}@example.test`, + teamId, + issuedAt: afterCutoff, + }) + await expect( + validateLegacyUserSession(next.token, next.claims, { db: databasePool }) + ).resolves.toMatchObject({ status: 'valid' }) + + const recorded = await databasePool.query<{ count: string }>( + `SELECT COUNT(*)::text AS count + FROM schema_migrations + WHERE version = '0106_legacy_password_security_epoch_backfill'` + ) + expect(recorded.rows[0]?.count).toBe('1') + }) + + it('is monotonic, null-safe, and idempotent', async () => { + const nullUser = randomUUID() + const olderUser = randomUUID() + const newerUser = randomUUID() + const passwordSetAt = new Date('2026-08-27T10:00:00Z') + await databasePool.query( + `INSERT INTO users(id, email, name, password_set_at) + VALUES ($1, $2, 'Null Password', NULL), + ($3, $4, 'Older Epoch', $5), + ($6, $7, 'Newer Epoch', $5)`, + [ + nullUser, + `${nullUser}@example.test`, + olderUser, + `${olderUser}@example.test`, + passwordSetAt, + newerUser, + `${newerUser}@example.test`, + ] + ) + await databasePool.query( + `INSERT INTO external_user_session_security_epochs(user_id, valid_after, reason) + VALUES ($1, $2, 'older'), + ($3, $4, 'newer')`, + [olderUser, new Date('2026-08-27T09:00:00Z'), newerUser, new Date('2026-08-27T11:00:00Z')] + ) + await databasePool.query( + `DELETE FROM schema_migrations + WHERE version = '0106_legacy_password_security_epoch_backfill'` + ) + await initDb({ connect: () => databasePool.connect() }) + await databasePool.query( + `DELETE FROM schema_migrations + WHERE version = '0106_legacy_password_security_epoch_backfill'` + ) + await initDb({ connect: () => databasePool.connect() }) + + const rows = await databasePool.query<{ + user_id: string + valid_after: Date + reason: string + }>( + `SELECT user_id::text, valid_after, reason + FROM external_user_session_security_epochs + WHERE user_id = ANY($1::uuid[]) + ORDER BY user_id::text`, + [[nullUser, olderUser, newerUser]] + ) + const byUser = new Map(rows.rows.map(row => [row.user_id, row])) + expect(byUser.has(nullUser)).toBe(false) + expect(byUser.get(olderUser)?.valid_after.toISOString()).toBe(passwordSetAt.toISOString()) + expect(byUser.get(olderUser)?.reason).toBe('historical_password_event') + expect(byUser.get(newerUser)?.valid_after.toISOString()).toBe( + new Date('2026-08-27T11:00:00Z').toISOString() + ) + expect(byUser.get(newerUser)?.reason).toBe('newer') + }) +}) From e7fc24005b78cd5f6dd1046546cfd50deafc2e49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 27 Aug 2026 09:28:19 -0600 Subject: [PATCH 128/528] test(control-api): r9-b3 preserve gfs migration order invariant Class-check: migration tests depending on end-relative positional identity -> 1 site -> assert 0104 immediately precedes 0105 instead of assuming 0105 is penultimate Repro-test: control-api/test/db.gfsCatalogRevisionComponentsMigration.test.ts:18 fails after adding 0106 before this correction --- .../db.gfsCatalogRevisionComponentsMigration.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/control-api/test/db.gfsCatalogRevisionComponentsMigration.test.ts b/control-api/test/db.gfsCatalogRevisionComponentsMigration.test.ts index 52945455ba..2713fc23bb 100644 --- a/control-api/test/db.gfsCatalogRevisionComponentsMigration.test.ts +++ b/control-api/test/db.gfsCatalogRevisionComponentsMigration.test.ts @@ -10,10 +10,14 @@ describe('0105 GFS catalog revision components migration', () => { const migration = CONTROL_API_MIGRATIONS.find( candidate => candidate.version === '0105_gfs_catalog_revision_components' ) - expect(migration).toBeDefined() - expect(CONTROL_API_MIGRATIONS[CONTROL_API_MIGRATIONS.length - 2]?.version).toBe( - '0104_composable_catalog_revisions' + const composableIndex = CONTROL_API_MIGRATIONS.findIndex( + candidate => candidate.version === '0104_composable_catalog_revisions' + ) + const gfsIndex = CONTROL_API_MIGRATIONS.findIndex( + candidate => candidate.version === '0105_gfs_catalog_revision_components' ) + expect(migration).toBeDefined() + expect(gfsIndex).toBe(composableIndex + 1) const recordedVersions = CONTROL_API_MIGRATIONS.filter( candidate => candidate.version !== migration?.version From a575ffbdb7f093ea077a575e3464f798d55847eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 27 Aug 2026 09:34:40 -0600 Subject: [PATCH 129/528] fix(control-api): r9-h2 revalidate final team mutation authority Class-check: administrator-only team mutation final authority -> 3 mounted mutation classes -> one canonical user/session/membership transaction contract Repro-test: control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts:336 fails at 8c3766a60458c53c8e57f6cb56fdc487109c0914 --- control-api/src/routes/external/teams.ts | 9 +- .../src/services/auth/userSessionService.ts | 120 +++++- control-api/src/services/directory/teams.ts | 15 +- .../directoryAuthorizationPrivacy.test.ts | 84 ++++ ...Authority.realPostgres.integration.test.ts | 382 ++++++++++++++++++ .../test/services.directory.teams.test.ts | 32 ++ 6 files changed, 634 insertions(+), 8 deletions(-) create mode 100644 control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts diff --git a/control-api/src/routes/external/teams.ts b/control-api/src/routes/external/teams.ts index 702f755e58..bb91954709 100644 --- a/control-api/src/routes/external/teams.ts +++ b/control-api/src/routes/external/teams.ts @@ -97,7 +97,8 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { const result = await renameTeamForUser( (req as ExternalAuthedRequest).externalAuth!.userId, req.params.teamId, - name + name, + (req as ExternalAuthedRequest).externalSessionAuthority ) if ('error' in result) { if (result.error === 'forbidden') return res.status(403).json({ error: 'forbidden' }) @@ -221,7 +222,8 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { (req as ExternalAuthedRequest).externalAuth!.userId, req.params.userId, req.params.teamId, - role + role, + (req as ExternalAuthedRequest).externalSessionAuthority ) if ('error' in result) { if (result.error === 'forbidden') return res.status(403).json({ error: 'forbidden' }) @@ -280,7 +282,8 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { const result = await deleteManagedMemberForUser( (req as ExternalAuthedRequest).externalAuth!.userId, req.params.userId, - req.params.teamId + req.params.teamId, + (req as ExternalAuthedRequest).externalSessionAuthority ) if ('error' in result) { if (result.error === 'forbidden') return res.status(403).json({ error: 'forbidden' }) diff --git a/control-api/src/services/auth/userSessionService.ts b/control-api/src/services/auth/userSessionService.ts index 1cd8e0be2c..3ab4fc17d7 100644 --- a/control-api/src/services/auth/userSessionService.ts +++ b/control-api/src/services/auth/userSessionService.ts @@ -4,6 +4,8 @@ import type { AuthClaims } from '../../profileTypes.js' import type { UserSessionV2Claims } from '../../utils/auth/userSessionV2Token.js' import { USER_SESSION_V2_TTL_SECONDS, + USER_SESSION_V2_TYPE, + USER_SESSION_V2_VERSION, signUserSessionV2Token, } from '../../utils/auth/userSessionV2Token.js' import { @@ -11,6 +13,7 @@ import { withAccessDatabaseTransaction, } from '../access/accessDatabaseQuery.js' import type { AccessExecutionBudget } from '../access/accessExecutionBudget.js' +import type { ExternalSessionAuthorityContext } from './externalSessionAuthentication.js' import { legacyExternalSessionAuthGeneration } from './legacyV1Generation.js' export const USER_SESSION_IDLE_LIFETIME_SECONDS = 14 * 24 * 60 * 60 @@ -60,7 +63,7 @@ export type IssuedUserSession = { identity: UserSessionIdentity } -type SessionClock = { now?: Date } +type SessionClock = { now?: Date | (() => Date) } function budgetedSessionDatabase( db: SessionDatabase, @@ -75,6 +78,10 @@ function atSecond(date: Date): Date { return new Date(Math.floor(date.getTime() / 1000) * 1000) } +function resolveClock(now?: Date | (() => Date)): Date { + return typeof now === 'function' ? now() : (now ?? new Date()) +} + function plusSeconds(date: Date, seconds: number): Date { return new Date(date.getTime() + seconds * 1000) } @@ -201,7 +208,7 @@ export async function createUserSession( }, options: SessionClock & { db?: SessionDatabase } = {} ): Promise { - const now = atSecond(options.now ?? new Date()) + const now = atSecond(resolveClock(options.now)) const authenticatedAt = atSecond(input.authenticatedAt ?? now) const sid = randomUUID() const jti = randomUUID() @@ -276,7 +283,7 @@ export async function validateUserSessionClaims( claims: UserSessionV2Claims, options: SessionClock & { db?: SessionDatabase; budget?: AccessExecutionBudget } = {} ): Promise { - const now = atSecond(options.now ?? new Date()) + const now = atSecond(resolveClock(options.now)) const work = async (db: SessionDatabase) => { const row = await loadSessionForUpdate(db, claims.sid) return validateLoadedSession(db, row, claims, now, true) @@ -293,7 +300,7 @@ export async function renewUserSession( claims: UserSessionV2Claims, options: SessionClock & { db?: SessionDatabase; budget?: AccessExecutionBudget } = {} ): Promise { - const now = atSecond(options.now ?? new Date()) + const now = atSecond(resolveClock(options.now)) const work = async (db: SessionDatabase) => { const row = await loadSessionForUpdate(db, claims.sid) const validation = await validateLoadedSession(db, row, claims, now, false) @@ -415,6 +422,111 @@ export async function revokeAllUserSessions( return db ? work(db) : withTransaction(work) } +export async function validateExternalSessionAuthorityContext( + context: ExternalSessionAuthorityContext, + options: SessionClock & { db?: SessionDatabase } = {} +): Promise { + const now = atSecond(resolveClock(options.now)) + const work = async (db: SessionDatabase): Promise => { + if (context.contract === 'v2') { + const principal = await db.query( + `SELECT id + FROM users + WHERE id = $1 + AND lifecycle_state = 'active' + FOR UPDATE`, + [context.userId] + ) + if ((principal.rowCount ?? 0) === 0) { + return { status: 'revoked', reason: 'user_unavailable' } + } + const claims: UserSessionV2Claims = { + sub: context.userId, + sid: context.sid, + jti: context.jti, + sv: context.sessionVersion, + ver: USER_SESSION_V2_VERSION, + typ: USER_SESSION_V2_TYPE, + auth_time: 0, + amr: ['session'], + iat: 0, + exp: 0, + } + return validateLoadedSession( + db, + await loadSessionForUpdate(db, context.sid), + claims, + now, + false + ) + } + + const principal = await db.query( + `SELECT id, email, lifecycle_state, lifecycle_version + FROM users + WHERE id = $1 + FOR UPDATE`, + [context.userId] + ) + const user = principal.rows[0] as + | { + id: string + email: string + lifecycle_state: string + lifecycle_version: number | string + } + | undefined + if (!user) return { status: 'invalid', reason: 'user_not_found' } + if ( + user.lifecycle_state !== 'active' || + context.authGeneration !== Number(user.lifecycle_version) + ) { + return { status: 'revoked', reason: 'security_event' } + } + + const authority = await db.query( + `SELECT epoch.valid_after, + EXISTS ( + SELECT 1 + FROM external_v1_session_revocations revoked + WHERE revoked.token_hash = $2 + AND revoked.user_id = $1 + AND revoked.expires_at > NOW() + ) AS token_revoked + FROM users u + LEFT JOIN external_user_session_security_epochs epoch ON epoch.user_id = u.id + WHERE u.id = $1 + LIMIT 1`, + [context.userId, context.tokenHash] + ) + const row = authority.rows[0] as + | { + valid_after: Date | string | null + token_revoked: boolean + } + | undefined + if (!row) return { status: 'invalid', reason: 'user_not_found' } + if (row.token_revoked) return { status: 'revoked', reason: 'logout' } + if (row.valid_after && context.issuedAt * 1000 <= dateOf(row.valid_after).getTime()) { + return { status: 'revoked', reason: 'security_event' } + } + return { + status: 'valid', + identity: { + userId: context.userId, + email: user.email, + sid: '', + jti: context.tokenHash, + sessionVersion: 0, + expiresAt: new Date(context.issuedAt * 1000), + absoluteExpiresAt: new Date(context.issuedAt * 1000), + authenticationMethods: [], + }, + } + } + return work(options.db ?? pool) +} + function legacySessionFingerprint(token: string): string { return createHash('sha256').update(token, 'utf8').digest('hex') } diff --git a/control-api/src/services/directory/teams.ts b/control-api/src/services/directory/teams.ts index 227fd81f99..9d1003a759 100644 --- a/control-api/src/services/directory/teams.ts +++ b/control-api/src/services/directory/teams.ts @@ -1,5 +1,7 @@ import { pool, withTransaction } from '../../db.js' import type { DbClient } from '../../db.js' +import type { ExternalSessionAuthorityContext } from '../auth/externalSessionAuthentication.js' +import { validateExternalSessionAuthorityContext } from '../auth/userSessionService.js' import type { AdminDeleteTeamResult, TeamRole } from './types.js' export async function listTeams(userId: string, currentTeamId: string) { @@ -101,8 +103,19 @@ export async function renameTeam(teamId: string, name: string) { return (updated.rows[0] as { id: string; name: string } | undefined) || null } -export async function renameTeamForUser(userId: string, teamId: string, name: string) { +export async function renameTeamForUser( + userId: string, + teamId: string, + name: string, + authority?: ExternalSessionAuthorityContext +) { return withTransaction(async db => { + if (authority) { + const session = await validateExternalSessionAuthorityContext(authority, { db }) + if (session.status !== 'valid' || session.identity.userId !== userId.trim()) { + return { error: 'forbidden' as const } + } + } const membership = await db.query( `SELECT role FROM team_members diff --git a/control-api/test/directoryAuthorizationPrivacy.test.ts b/control-api/test/directoryAuthorizationPrivacy.test.ts index 669d79be8a..00f6667ff5 100644 --- a/control-api/test/directoryAuthorizationPrivacy.test.ts +++ b/control-api/test/directoryAuthorizationPrivacy.test.ts @@ -164,6 +164,48 @@ describe('directory privacy and atomic authorization', () => { expect(mocks.appendEvents).toHaveBeenCalledTimes(1) }) + it('revalidates source authority before final role-mutation membership locks', async () => { + mocks.query + .mockResolvedValueOnce({ + rows: [ + { + id: MANAGER, + email: 'manager@example.test', + lifecycle_state: 'active', + lifecycle_version: 1, + }, + ], + rowCount: 1, + }) + .mockResolvedValueOnce({ rows: [{ valid_after: null, token_revoked: false }], rowCount: 1 }) + .mockResolvedValueOnce({ + rows: [ + { user_id: MANAGER, role: 'admin' }, + { user_id: TARGET, role: 'member' }, + ], + rowCount: 2, + }) + .mockResolvedValueOnce({ + rows: [{ team_id: TEAM_A, user_id: TARGET, role: 'inviter', status: 'active' }], + rowCount: 1, + }) + + const result = await updateManagedMemberRoleForUser(MANAGER, TARGET, TEAM_A, 'inviter', { + contract: 'v1', + userId: MANAGER, + tokenHash: 'legacy-token-hash', + issuedAt: Math.floor(Date.now() / 1000), + authGeneration: 1, + }) + + expect(result).toEqual({ + membership: { team_id: TEAM_A, user_id: TARGET, role: 'inviter', status: 'active' }, + }) + expect(String(mocks.query.mock.calls[0]?.[0])).toContain('FROM users') + expect(String(mocks.query.mock.calls[0]?.[0])).toContain('FOR UPDATE') + expect(String(mocks.query.mock.calls[2]?.[0])).toContain('FROM team_members') + }) + it('locks invitation authority and denies inviter-to-admin assignment before mutation', async () => { mocks.query.mockResolvedValueOnce({ rows: [{ team_id: TEAM_A, role: 'inviter' }], @@ -872,6 +914,48 @@ describe('directory privacy and atomic authorization', () => { expect(mocks.appendEvents).not.toHaveBeenCalled() }) + it('revalidates source authority before final member-removal membership locks', async () => { + mocks.query + .mockResolvedValueOnce({ + rows: [ + { + id: MANAGER, + email: 'manager@example.test', + lifecycle_state: 'active', + lifecycle_version: 1, + }, + ], + rowCount: 1, + }) + .mockResolvedValueOnce({ rows: [{ valid_after: null, token_revoked: false }], rowCount: 1 }) + .mockResolvedValueOnce({ + rows: [ + { user_id: MANAGER, role: 'admin' }, + { user_id: TARGET, role: 'member' }, + ], + rowCount: 2, + }) + .mockResolvedValueOnce({ + rows: [{ team_id: TEAM_A, user_id: TARGET, role: 'member', status: 'deleted' }], + rowCount: 1, + }) + + const result = await deleteManagedMemberForUser(MANAGER, TARGET, TEAM_A, { + contract: 'v1', + userId: MANAGER, + tokenHash: 'legacy-token-hash', + issuedAt: Math.floor(Date.now() / 1000), + authGeneration: 1, + }) + + expect(result).toEqual({ + deleted: { team_id: TEAM_A, user_id: TARGET, role: 'member', status: 'deleted' }, + }) + expect(String(mocks.query.mock.calls[0]?.[0])).toContain('FROM users') + expect(String(mocks.query.mock.calls[0]?.[0])).toContain('FOR UPDATE') + expect(String(mocks.query.mock.calls[2]?.[0])).toContain('FROM team_members') + }) + it('changes a password and revokes every session in one conditional transaction', async () => { const currentHash = await bcrypt.hash('current-password', 4) mocks.query diff --git a/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts b/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts new file mode 100644 index 0000000000..2e9ccfbd66 --- /dev/null +++ b/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts @@ -0,0 +1,382 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import { randomBytes, randomUUID } from 'node:crypto' +import { Pool, type PoolClient } from 'pg' +import request from 'supertest' +import { type DbClient, initDb } from '../src/db.js' +import { createExternalTeamsRouter } from '../src/routes/external/teams.js' +import { issueExternalUserSession } from '../src/services/auth/externalSessionIssuance.js' + +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const describeRealPostgres = adminUrl ? describe : describe.skip +const producerDatabase = vi.hoisted(() => ({ pool: undefined as Pool | undefined })) +const rateLimit = vi.hoisted(() => ({ checkAndIncrement: vi.fn() })) +const runtimePolicy = vi.hoisted(() => ({ + policyVersion: '1', + policyRevision: 'r9-final-team-authority', + acceptV1: true, + issueV1: true, + acceptV2: false, + issueV2: false, + renewV2: false, + switchCompatibility: true, + computeCatalogShadow: false, + serveCatalog: false, + actionContextV2: false, + rpcDelegationV2: false, + desktopAllTeamMode: false, + profileV2Mode: false, + minimumClientVersion: null, + enforceMinimumClient: false, + advertisedCatalogFamilies: [], +})) + +vi.mock('../src/db.js', async importOriginal => { + const actual = await importOriginal() + const pool = { + query: (text: string, values?: unknown[]) => producerDatabase.pool!.query(text, values), + connect: () => producerDatabase.pool!.connect(), + } + const withTransaction = async (work: (db: DbClient) => Promise): Promise => { + const client = await producerDatabase.pool!.connect() + try { + await client.query('BEGIN') + const result = await work(client as unknown as DbClient) + await client.query('COMMIT') + return result + } catch (error) { + await client.query('ROLLBACK') + throw error + } finally { + client.release() + } + } + return { ...actual, pool, withTransaction } +}) +vi.mock('../src/services/rateLimiterService.js', () => rateLimit) +vi.mock('../src/services/access/userAccessRuntimePolicy.js', () => ({ + resolveEffectiveUserAccessPolicy: vi.fn().mockResolvedValue(runtimePolicy), +})) + +type AdminMutation = Readonly<{ + name: string + invoke: (server: express.Express) => request.Test + assertMutation: () => Promise + assertNoMutation: () => Promise +}> + +function databaseUrl(baseUrl: string, database: string): string { + const value = new URL(baseUrl) + value.pathname = `/${database}` + return value.toString() +} + +function quoteIdentifier(value: string): string { + return `"${value.replace(/"/g, '""')}"` +} + +async function backendPid(client: PoolClient): Promise { + const result = await client.query<{ pid: number }>('SELECT pg_backend_pid() AS pid') + return result.rows[0]!.pid +} + +async function waitForBlockedBy(pool: Pool, blockerPid: number): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + const result = await pool.query<{ blocked: boolean }>( + `SELECT EXISTS ( + SELECT 1 + FROM pg_stat_activity activity + WHERE activity.datname = current_database() + AND $1::int = ANY(pg_blocking_pids(activity.pid)) + ) AS blocked`, + [blockerPid] + ) + if (result.rows[0]?.blocked) return + await new Promise(resolve => setTimeout(resolve, 10)) + } + throw new Error('expected final team mutation authority to block on user lock') +} + +async function retireAdmin( + db: Pick, + userId: string, + actorId: string +): Promise { + const operation = await db.query<{ id: string }>( + `INSERT INTO desktop_user_retirement_operations( + actor_type, + actor_control_admin_id, + target_user_id, + idempotency_key_hash, + request_fingerprint, + reason, + request_id + ) + VALUES ( + 'control_admin', + $1::uuid, + $2::uuid, + repeat('a', 64), + repeat('b', 64), + 'final authority race test', + 'r9-h2-final-authority' + ) + RETURNING id::text AS id`, + [actorId, userId] + ) + const operationId = operation.rows[0]!.id + await db.query( + `UPDATE users + SET lifecycle_state = 'retired', + retired_at = NOW(), + retirement_reason = 'final authority race test', + retired_by_type = 'control_admin', + retired_by_control_admin_id = $3::uuid, + retirement_request_id = 'r9-h2-final-authority', + retirement_operation_id = $2::uuid, + lifecycle_version = lifecycle_version + 1, + updated_at = NOW() + WHERE id = $1::uuid`, + [userId, operationId, actorId] + ) + await db.query( + `UPDATE desktop_user_retirement_operations + SET status = 'completed', + outcome = 'retired', + lifecycle_version = 2, + lifecycle_operation_id = id, + completed_at = NOW() + WHERE id = $1::uuid`, + [operationId] + ) +} + +describeRealPostgres('external team mutations final source authority on real PostgreSQL', () => { + const database = `control_api_team_final_${randomBytes(6).toString('hex')}` + const connectionString = databaseUrl( + adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', + database + ) + let adminPool: Pool + let databasePool: Pool + let retirementActorId: string + let adminUserId: string + let targetUserId: string + let teamId: string + let token: string + + function app() { + const server = express() + server.use(express.json()) + server.use(createExternalTeamsRouter({} as never)) + return server + } + + async function resetPrincipal(label: string) { + adminUserId = randomUUID() + targetUserId = randomUUID() + teamId = randomUUID() + await databasePool.query( + `INSERT INTO users(id, email, name, lifecycle_state, lifecycle_version) + VALUES ($1, $2, 'Admin', 'active', 1), + ($3, $4, 'Target', 'active', 1)`, + [ + adminUserId, + `${label}-${adminUserId}@example.test`, + targetUserId, + `${label}-target@example.test`, + ] + ) + await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, 'Original')`, [teamId]) + await databasePool.query( + `INSERT INTO team_members(team_id, user_id, role, status) + VALUES ($1, $2, 'admin', 'active'), + ($1, $3, 'member', 'active')`, + [teamId, adminUserId, targetUserId] + ) + const issued = await issueExternalUserSession( + { + contract: 'v1', + userId: adminUserId, + email: `${label}-${adminUserId}@example.test`, + teamId, + role: 'admin', + authGeneration: 1, + authenticationMethods: ['password'], + }, + { policy: runtimePolicy } + ) + token = issued.token + } + + function mutations(): readonly AdminMutation[] { + return [ + { + name: 'team rename', + invoke: server => + request(server).put(`/external/teams/${teamId}/name`).send({ name: 'Renamed' }), + assertMutation: async () => { + const result = await databasePool.query<{ name: string }>( + `SELECT name FROM teams WHERE id = $1`, + [teamId] + ) + expect(result.rows[0]?.name).toBe('Renamed') + }, + assertNoMutation: async () => { + const result = await databasePool.query<{ name: string }>( + `SELECT name FROM teams WHERE id = $1`, + [teamId] + ) + expect(result.rows[0]?.name).toBe('Original') + }, + }, + { + name: 'member role change', + invoke: server => + request(server) + .patch(`/external/teams/${teamId}/members/${targetUserId}/role`) + .send({ role: 'inviter' }), + assertMutation: async () => { + const result = await databasePool.query<{ role: string }>( + `SELECT role FROM team_members WHERE team_id = $1 AND user_id = $2`, + [teamId, targetUserId] + ) + expect(result.rows[0]?.role).toBe('inviter') + }, + assertNoMutation: async () => { + const result = await databasePool.query<{ role: string }>( + `SELECT role FROM team_members WHERE team_id = $1 AND user_id = $2`, + [teamId, targetUserId] + ) + expect(result.rows[0]?.role).toBe('member') + }, + }, + { + name: 'member removal', + invoke: server => + request(server).delete(`/external/teams/${teamId}/members/${targetUserId}`), + assertMutation: async () => { + const result = await databasePool.query<{ status: string }>( + `SELECT status FROM team_members WHERE team_id = $1 AND user_id = $2`, + [teamId, targetUserId] + ) + expect(result.rows[0]?.status).toBe('deleted') + }, + assertNoMutation: async () => { + const result = await databasePool.query<{ status: string }>( + `SELECT status FROM team_members WHERE team_id = $1 AND user_id = $2`, + [teamId, targetUserId] + ) + expect(result.rows[0]?.status).toBe('active') + }, + }, + ] + } + + beforeAll(async () => { + adminPool = new Pool({ connectionString: adminUrl }) + await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) + databasePool = new Pool({ connectionString }) + producerDatabase.pool = databasePool + await initDb({ connect: () => databasePool.connect() }) + retirementActorId = randomUUID() + await databasePool.query( + `INSERT INTO control_admin_users(id, username, email, password_hash) + VALUES ($1, 'r9-h2-retirement-actor', 'r9-h2-retirement-actor@example.test', 'hash')`, + [retirementActorId] + ) + }) + + afterAll(async () => { + await databasePool?.end() + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.end() + } + }) + + beforeEach(() => { + rateLimit.checkAndIncrement.mockResolvedValue({ + allowed: true, + count: 1, + remaining: 9, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + }) + }) + + for (const mutation of mutations()) { + it(`allows ${mutation.name} before later retirement or revoke-all`, async () => { + await resetPrincipal(`wins-${mutation.name.replaceAll(' ', '-')}`) + const response = await mutation.invoke(app()).set('x-user-session-token', token) + expect(response.status).toBe(200) + await mutation.assertMutation() + await retireAdmin(databasePool, adminUserId, retirementActorId) + await databasePool.query( + `INSERT INTO external_user_session_security_epochs(user_id, valid_after, reason) + VALUES ($1, NOW() + INTERVAL '1 second', 'test') + ON CONFLICT (user_id) DO UPDATE + SET valid_after = EXCLUDED.valid_after, reason = EXCLUDED.reason`, + [adminUserId] + ) + }) + + it(`denies ${mutation.name} when retirement wins after admission`, async () => { + await resetPrincipal(`retire-${mutation.name.replaceAll(' ', '-')}`) + const gate = await databasePool.connect() + try { + await gate.query('BEGIN') + await gate.query(`SELECT id FROM users WHERE id = $1 FOR UPDATE`, [adminUserId]) + const gatePid = await backendPid(gate) + const pending = mutation + .invoke(app()) + .set('x-user-session-token', token) + .then(response => response) + await waitForBlockedBy(databasePool, gatePid) + await retireAdmin(gate, adminUserId, retirementActorId) + await gate.query('COMMIT') + const response = await pending + expect(response.status).toBe(403) + await mutation.assertNoMutation() + } finally { + await gate.query('ROLLBACK').catch(() => undefined) + gate.release() + } + }) + + it(`denies ${mutation.name} when source-session invalidation wins after admission`, async () => { + await resetPrincipal(`revoke-${mutation.name.replaceAll(' ', '-')}`) + const gate = await databasePool.connect() + try { + await gate.query('BEGIN') + await gate.query(`SELECT id FROM users WHERE id = $1 FOR UPDATE`, [adminUserId]) + const gatePid = await backendPid(gate) + const pending = mutation + .invoke(app()) + .set('x-user-session-token', token) + .then(response => response) + await waitForBlockedBy(databasePool, gatePid) + await gate.query( + `INSERT INTO external_user_session_security_epochs(user_id, valid_after, reason) + VALUES ($1, NOW() + INTERVAL '1 second', 'test') + ON CONFLICT (user_id) DO UPDATE + SET valid_after = EXCLUDED.valid_after, reason = EXCLUDED.reason`, + [adminUserId] + ) + await gate.query('COMMIT') + const response = await pending + expect(response.status).toBe(403) + await mutation.assertNoMutation() + } finally { + await gate.query('ROLLBACK').catch(() => undefined) + gate.release() + } + }) + } +}) diff --git a/control-api/test/services.directory.teams.test.ts b/control-api/test/services.directory.teams.test.ts index 734ab32089..3d1d32f387 100644 --- a/control-api/test/services.directory.teams.test.ts +++ b/control-api/test/services.directory.teams.test.ts @@ -8,6 +8,7 @@ import { getTeamById, listAllTeams, listMembers, + renameTeamForUser, softDeleteMember, updateMemberRole, } from '../src/services/directory/index.js' @@ -110,6 +111,37 @@ describe('services/directory team management unit tests', () => { ) }) + it('revalidates source authority before final team rename authorization', async () => { + dbMocks.txQuery + .mockResolvedValueOnce({ + rows: [ + { + id: 'user-1', + email: 'admin@example.test', + lifecycle_state: 'active', + lifecycle_version: 1, + }, + ], + rowCount: 1, + }) + .mockResolvedValueOnce({ rows: [{ valid_after: null, token_revoked: false }], rowCount: 1 }) + .mockResolvedValueOnce({ rows: [{ role: 'admin' }], rowCount: 1 }) + .mockResolvedValueOnce({ rows: [{ id: 'team-1', name: 'Renamed' }], rowCount: 1 }) + + const result = await renameTeamForUser('user-1', 'team-1', 'Renamed', { + contract: 'v1', + userId: 'user-1', + tokenHash: 'legacy-token-hash', + issuedAt: Math.floor(Date.now() / 1000), + authGeneration: 1, + }) + + expect(result).toEqual({ team: { id: 'team-1', name: 'Renamed' } }) + expect(String(dbMocks.txQuery.mock.calls[0]?.[0])).toContain('FROM users') + expect(String(dbMocks.txQuery.mock.calls[0]?.[0])).toContain('FOR UPDATE') + expect(String(dbMocks.txQuery.mock.calls[2]?.[0])).toContain('FROM team_members') + }) + it('addMemberToTeam upserts and returns membership row', async () => { dbMocks.txQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }).mockResolvedValueOnce({ rows: [{ team_id: 'team-1', user_id: 'user-1', role: 'member', status: 'active' }], From 80b2c66e89bb1df239baa750cb764a265ad8459b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 27 Aug 2026 09:35:08 -0600 Subject: [PATCH 130/528] fix(control-api): r9-h3 serialize revoke-all cutoff Class-check: security-epoch writers and revoke-all callers -> revokeAllUserSessions production cutoff -> capture after Spec 27 user-first lock Repro-test: control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts:443 fails at 8c3766a60458c53c8e57f6cb56fdc487109c0914 --- .../src/services/auth/userSessionService.ts | 4 +- ...hangeRace.realPostgres.integration.test.ts | 175 +++++++++++++++++- .../test/userSessionRevokeAllAtomic.test.ts | 22 +++ 3 files changed, 198 insertions(+), 3 deletions(-) diff --git a/control-api/src/services/auth/userSessionService.ts b/control-api/src/services/auth/userSessionService.ts index 3ab4fc17d7..96f8e6a686 100644 --- a/control-api/src/services/auth/userSessionService.ts +++ b/control-api/src/services/auth/userSessionService.ts @@ -388,12 +388,12 @@ export async function revokeAllUserSessions( userId: string, reason: string, db?: SessionDatabase, - now = new Date() + now: Date | (() => Date) = () => new Date() ): Promise { const work = async (transaction: SessionDatabase) => { - const revokedAt = atSecond(now) const user = await transaction.query(`SELECT id FROM users WHERE id = $1 FOR UPDATE`, [userId]) if ((user.rowCount ?? 0) === 0) return 0 + const revokedAt = atSecond(resolveClock(now)) await transaction.query( `INSERT INTO external_user_session_security_epochs(user_id, valid_after, reason, updated_at) diff --git a/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts b/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts index 58fc60849c..9eedb2b3a7 100644 --- a/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts +++ b/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts @@ -6,7 +6,10 @@ import { type DbClient, initDb } from '../src/db.js' import type { AuthClaims } from '../src/profileTypes.js' import type { EffectiveUserAccessPolicy } from '../src/services/access/userAccessPolicy.js' import { exchangeLegacyExternalUserSession } from '../src/services/auth/externalSessionIssuance.js' -import { validateLegacyUserSession } from '../src/services/auth/userSessionService.js' +import { + revokeAllUserSessions, + validateLegacyUserSession, +} from '../src/services/auth/userSessionService.js' import { verifyUserPassword } from '../src/services/directory/login.js' import { setInvitationPasswordForUser, @@ -386,4 +389,174 @@ describeRealPostgres('legacy replacement exchange serialization on real PostgreS } } ) + + it('invalidates source and replacement when revoke-all waits behind exchange', async () => { + const source = await principal('revoke-all-exchange-first') + const exchangeReady = deferred<{ result: { status: string; token?: string }; pid: number }>() + const releaseExchange = deferred() + const exchange = exchangeLegacyExternalUserSession( + { + token: source.token, + claims: source.claims, + userId: source.userId, + email: source.email, + teamId: source.teamId, + }, + { + policy, + transaction: async work => { + const client = await databasePool.connect() + try { + await client.query('BEGIN') + const result = await work(client as unknown as DbClient) + exchangeReady.resolve({ result, pid: await backendPid(client) }) + await releaseExchange.promise + await client.query('COMMIT') + return result + } catch (error) { + await client.query('ROLLBACK') + throw error + } finally { + client.release() + } + }, + } + ) + const lockedExchange = await exchangeReady.promise + expect(lockedExchange.result.status).toBe('issued') + + try { + const cutoff = new Date(Date.now() - 1_000) + const revokeAll = revokeAllUserSessions(source.userId, 'user_revoked_all', undefined, cutoff) + await waitForBlockedBy(lockedExchange.pid) + cutoff.setTime(Date.now() + 1_000) + releaseExchange.resolve() + const issued = await exchange + await revokeAll + + expect(issued.status).toBe('issued') + if (issued.status !== 'issued') throw new Error('exchange did not issue a token') + const replacementClaims = verifyExternalSessionToken(issued.token) + if (!replacementClaims) throw new Error('replacement token was not producer-shaped') + await expect( + validateLegacyUserSession(issued.token, replacementClaims, { db: databasePool }) + ).resolves.toMatchObject({ status: 'revoked' }) + await expect( + validateLegacyUserSession(source.token, source.claims, { db: databasePool }) + ).resolves.toMatchObject({ status: 'revoked' }) + } finally { + releaseExchange.resolve() + } + }) + + it('rejects exchange when revoke-all wins the user lock first', async () => { + const source = await principal('revoke-all-first') + const gate = await databasePool.connect() + try { + await gate.query('BEGIN') + await gate.query(`SELECT id FROM users WHERE id = $1 FOR UPDATE`, [source.userId]) + const gatePid = await backendPid(gate) + const exchangeStarted = deferred() + const exchange = exchangeLegacyExternalUserSession( + { + token: source.token, + claims: source.claims, + userId: source.userId, + email: source.email, + teamId: source.teamId, + }, + { + policy, + transaction: async work => { + const client = await databasePool.connect() + try { + await client.query('BEGIN') + exchangeStarted.resolve(await backendPid(client)) + const result = await work(client as unknown as DbClient) + await client.query('COMMIT') + return result + } catch (error) { + await client.query('ROLLBACK') + throw error + } finally { + client.release() + } + }, + } + ) + const exchangePid = await exchangeStarted.promise + await waitForBlock(exchangePid, gatePid) + + await revokeAllUserSessions( + source.userId, + 'user_revoked_all', + gate, + new Date(Date.now() + 1_000) + ) + await gate.query('COMMIT') + + await expect(exchange).resolves.toEqual({ status: 'invalid_session' }) + await expect( + validateLegacyUserSession(source.token, source.claims, { db: databasePool }) + ).resolves.toMatchObject({ status: 'revoked' }) + await expect( + exchangeLegacyExternalUserSession( + { + token: source.token, + claims: source.claims, + userId: source.userId, + email: source.email, + teamId: source.teamId, + }, + { policy } + ) + ).resolves.toEqual({ status: 'invalid_session' }) + } finally { + await gate.query('ROLLBACK').catch(() => undefined) + gate.release() + } + }) + + it('keeps revoke-all rollback safe and monotonic on real PostgreSQL', async () => { + const source = await principal('revoke-all-monotonic') + const rollback = await databasePool.connect() + try { + await rollback.query('BEGIN') + await revokeAllUserSessions( + source.userId, + 'rollback', + rollback, + new Date('2026-08-27T12:00:00Z') + ) + await rollback.query('ROLLBACK') + } finally { + await rollback.query('ROLLBACK').catch(() => undefined) + rollback.release() + } + await expect( + validateLegacyUserSession(source.token, source.claims, { db: databasePool }) + ).resolves.toMatchObject({ status: 'valid' }) + + await revokeAllUserSessions( + source.userId, + 'older', + databasePool, + new Date('2026-08-27T12:00:00Z') + ) + await revokeAllUserSessions( + source.userId, + 'newer', + databasePool, + new Date('2026-08-27T12:00:05Z') + ) + + const epoch = await databasePool.query<{ valid_after: Date; reason: string }>( + `SELECT valid_after, reason + FROM external_user_session_security_epochs + WHERE user_id = $1`, + [source.userId] + ) + expect(epoch.rows[0]?.valid_after.toISOString()).toBe('2026-08-27T12:00:05.000Z') + expect(epoch.rows[0]?.reason).toBe('newer') + }) }) diff --git a/control-api/test/userSessionRevokeAllAtomic.test.ts b/control-api/test/userSessionRevokeAllAtomic.test.ts index 8f5b05b089..18e10d7ebb 100644 --- a/control-api/test/userSessionRevokeAllAtomic.test.ts +++ b/control-api/test/userSessionRevokeAllAtomic.test.ts @@ -42,4 +42,26 @@ describe('revoke-all transaction boundary', () => { expect(String(mocks.query.mock.calls[0]?.[0])).toContain('FOR UPDATE') expect(mocks.committedEpoch).toBe(false) }) + + it('captures the revoke-all cutoff after acquiring the user lock', async () => { + const cutoffs: Date[] = [] + const afterLock = new Date('2026-08-27T00:00:01.250Z') + const now = vi.fn(() => afterLock) + mocks.query.mockReset() + mocks.query + .mockResolvedValueOnce({ rows: [{ id: 'user-1' }], rowCount: 1 }) + .mockImplementationOnce(async (_sql, values) => { + cutoffs.push(values[1] as Date) + return { rows: [], rowCount: 1 } + }) + .mockResolvedValueOnce({ rows: [], rowCount: 0 }) + mocks.withTransaction.mockImplementationOnce(async work => work({ query: mocks.query })) + + const { revokeAllUserSessions } = await import('../src/services/auth/userSessionService.js') + await expect(revokeAllUserSessions('user-1', 'security_event', undefined, now)).resolves.toBe(0) + + expect(now).toHaveBeenCalledTimes(1) + expect(String(mocks.query.mock.calls[0]?.[0])).toContain('FOR UPDATE') + expect(cutoffs).toEqual([new Date('2026-08-27T00:00:01.000Z')]) + }) }) From 8360dfcb6d38e54cc4e44429e21034a05772a8d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 27 Aug 2026 13:28:48 -0600 Subject: [PATCH 131/528] fix(external-rest-api): r11-h1 preserve safe public gfs errors Class-check: propagated GFS statuses -> 5 unsafe mappings -> restore all typed safe contracts Repro-test: routes.gfs.test.ts and publicErrorContract.test.ts fail at abdc3f5e --- external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- .../src/__tests__/publicErrorContract.test.ts | 38 ++++++++++++++++- external-rest-api/src/http/publicApiError.ts | 42 +++++++++++++++---- 4 files changed, 75 insertions(+), 11 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 1831f85dfc..ff88a88560 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index a2eb923322..d8dc7498ab 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/__tests__/publicErrorContract.test.ts b/external-rest-api/src/__tests__/publicErrorContract.test.ts index c1bcc5a0af..18bb2c03d5 100644 --- a/external-rest-api/src/__tests__/publicErrorContract.test.ts +++ b/external-rest-api/src/__tests__/publicErrorContract.test.ts @@ -124,7 +124,9 @@ describe('External REST public error contract', () => { it('rebuilds every forwarded route class from a bounded typed envelope', () => { const sentinel = 'postgres://secret@internal/var/run/service.sock' - for (const status of [400, 401, 403, 404, 409, 410, 412, 422, 429, 500, 502, 503, 504]) { + for (const status of [ + 400, 401, 403, 404, 408, 409, 410, 411, 412, 413, 422, 425, 429, 500, 502, 503, 504, 507, + ]) { const sanitized = sanitizeControlApiPublicError( new ControlApiError(sentinel, status, { error: { @@ -143,6 +145,40 @@ describe('External REST public error contract', () => { } }) + it.each([ + [408, 'request_timeout', true, { 'retry-after': '7' }], + [411, 'length_required', false, {}], + [413, 'payload_too_large', false, { 'upload-length': '209715200' }], + [425, 'too_early', true, { 'retry-after': '7' }], + [507, 'insufficient_storage', false, {}], + ] as const)( + 'preserves the safe typed public GFS contract for %s', + (status, expectedCode, retryable, expectedHeaders) => { + const sentinel = 'private upstream detail at postgres://secret@internal' + const sanitized = sanitizeControlApiPublicError( + new ControlApiError( + sentinel, + status, + { error: { code: expectedCode, message: sentinel, details: { secret: sentinel } } }, + { + 'retry-after': '7', + 'upload-length': '209715200', + 'x-ratelimit-limit': '16', + 'x-internal-secret': sentinel, + } + ), + new Set([status]) + ) + + expect(sanitized).toMatchObject({ + status, + headers: expectedHeaders, + body: { error: { code: expectedCode, retryable } }, + }) + expect(JSON.stringify(sanitized)).not.toContain(sentinel) + } + ) + it('preserves only bounded access-path descriptors for an ambiguity response', () => { const pathId = `ap1_${'a'.repeat(43)}` const sanitized = sanitizeControlApiPublicError( diff --git a/external-rest-api/src/http/publicApiError.ts b/external-rest-api/src/http/publicApiError.ts index c136179900..233c7e7e4f 100644 --- a/external-rest-api/src/http/publicApiError.ts +++ b/external-rest-api/src/http/publicApiError.ts @@ -7,15 +7,20 @@ const PUBLIC_CODE_BY_STATUS: Readonly> = { 401: 'invalid_session', 403: 'forbidden', 404: 'not_found', + 408: 'request_timeout', 409: 'conflict', 410: 'gone', + 411: 'length_required', 412: 'precondition_failed', + 413: 'payload_too_large', 422: 'invalid_request', + 425: 'too_early', 429: 'rate_limited', 500: 'internal_error', 502: 'upstream_unavailable', 503: 'authority_unavailable', 504: 'upstream_timeout', + 507: 'insufficient_storage', } const PUBLIC_MESSAGE_BY_CODE: Readonly> = { @@ -24,16 +29,21 @@ const PUBLIC_MESSAGE_BY_CODE: Readonly> = { session_revoked: 'The session is not valid.', forbidden: 'The requested operation is not allowed.', not_found: 'The resource was not found.', + request_timeout: 'The request timed out.', conflict: 'The request conflicts with current state.', access_path_required: 'Choose an access path for this operation.', access_path_stale: 'The access path is stale; refresh access before retrying.', gone: 'The resource is no longer available.', + length_required: 'A content length is required.', precondition_failed: 'The request precondition did not match current state.', + payload_too_large: 'The request payload is too large.', + too_early: 'The request is not ready to be processed.', rate_limited: 'Too many requests; retry later.', internal_error: 'The request could not be completed.', upstream_unavailable: 'The upstream service is temporarily unavailable.', authority_unavailable: 'Authorization is temporarily unavailable.', upstream_timeout: 'The upstream service timed out.', + insufficient_storage: 'The requested operation cannot be completed because storage is full.', } const ALLOWED_CODES_BY_STATUS: Readonly>> = { @@ -41,15 +51,20 @@ const ALLOWED_CODES_BY_STATUS: Readonly>> = { 401: new Set(['invalid_session', 'session_revoked']), 403: new Set(['forbidden']), 404: new Set(['not_found']), + 408: new Set(['request_timeout']), 409: new Set(['conflict', 'access_path_required', 'access_path_stale']), 410: new Set(['gone']), + 411: new Set(['length_required']), 412: new Set(['precondition_failed']), + 413: new Set(['payload_too_large']), 422: new Set(['invalid_request']), + 425: new Set(['too_early']), 429: new Set(['rate_limited']), 500: new Set(['internal_error']), 502: new Set(['upstream_unavailable']), 503: new Set(['authority_unavailable']), 504: new Set(['upstream_timeout']), + 507: new Set(['insufficient_storage']), } type PublicPathDescriptor = { id: string; kind: 'direct' | 'team'; teamId?: string } @@ -88,6 +103,16 @@ function safePublicRateLimitHeaders(headers: Record): Record, + name: string +): Record { + const raw = headers[name] + if (!raw || !/^\d{1,16}$/.test(raw)) return {} + const value = Number(raw) + return Number.isSafeInteger(value) && value >= 0 ? { [name]: String(value) } : {} +} + function safeInvalidIndexes(value: unknown): number[] | undefined { if (!Array.isArray(value) || value.length === 0 || value.length > 100) return undefined const indexes = value.filter( @@ -139,7 +164,7 @@ export function sanitizeControlApiPublicError( const correlationId = /^[A-Za-z0-9_-]{1,128}$/.test(rawCorrelationId) ? rawCorrelationId : randomUUID() - const retryable = [429, 502, 503, 504].includes(error.status) + const retryable = [408, 425, 429, 502, 503, 504].includes(error.status) const rawDetails = rawEnvelope && typeof (rawEnvelope as { details?: unknown }).details === 'object' ? ((rawEnvelope as { details: Record }).details ?? undefined) @@ -180,15 +205,18 @@ export function sanitizeControlApiPublicError( } : undefined const safeHeaders = safePublicRateLimitHeaders(responseHeaders) + const publicHeaders = + code === 'rate_limited' + ? safeHeaders + : error.status === 413 + ? safeUnsignedIntegerHeader(responseHeaders, 'upload-length') + : retryable && safeHeaders['retry-after'] + ? { 'retry-after': safeHeaders['retry-after'] } + : {} return { status: error.status, - headers: - code === 'rate_limited' - ? safeHeaders - : retryable && safeHeaders['retry-after'] - ? { 'retry-after': safeHeaders['retry-after'] } - : {}, + headers: publicHeaders, body: { error: { code, From eda92171ca8957ca7b06508373f1a90266a1a40b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 27 Aug 2026 13:32:45 -0600 Subject: [PATCH 132/528] fix(control-api): r5-l3 preserve accepted onboarding after expiry Class-check: invitation password completion -> 3 purpose states -> preserve only accepted onboarding Repro-test: invitationAcceptedOnboarding.realPostgres.integration.test.ts fails at 921efe14 --- ...nboarding.realPostgres.integration.test.ts | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 control-api/test/invitationAcceptedOnboarding.realPostgres.integration.test.ts diff --git a/control-api/test/invitationAcceptedOnboarding.realPostgres.integration.test.ts b/control-api/test/invitationAcceptedOnboarding.realPostgres.integration.test.ts new file mode 100644 index 0000000000..459c2fa1e5 --- /dev/null +++ b/control-api/test/invitationAcceptedOnboarding.realPostgres.integration.test.ts @@ -0,0 +1,231 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import bcrypt from 'bcryptjs' +import { randomBytes, randomUUID } from 'node:crypto' +import { Pool } from 'pg' +import { type DbClient, initDb } from '../src/db.js' +import { + acceptInvitationForEmail, + createInvitationForTeams, + requestProfilePasswordReset, + setInvitationPasswordForEmail, + setInvitationPasswordForUser, +} from '../src/services/directory/membership.js' + +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const describeRealPostgres = adminUrl ? describe : describe.skip +const producerDatabase = vi.hoisted(() => ({ pool: undefined as Pool | undefined })) +const registration = vi.hoisted(() => ({ registerAndSendInvitation: vi.fn() })) +const runtimeRoles = [ + 'control_api_runtime', + 'trace_maintenance_runtime', + 'workflow_recipes_runtime', +] as const + +vi.mock('../src/db.js', async importOriginal => { + const actual = await importOriginal() + const pool = { + query: (text: string, values?: unknown[]) => producerDatabase.pool!.query(text, values), + } + const withTransaction = async (work: (db: DbClient) => Promise): Promise => { + const client = await producerDatabase.pool!.connect() + try { + await client.query('BEGIN') + const result = await work(client as unknown as DbClient) + await client.query('COMMIT') + return result + } catch (error) { + await client.query('ROLLBACK') + throw error + } finally { + client.release() + } + } + return { ...actual, pool, withTransaction } +}) + +vi.mock('../src/services/invitationFlowRegistrationService.js', () => registration) + +function databaseUrl(baseUrl: string, database: string): string { + const value = new URL(baseUrl) + value.pathname = `/${database}` + return value.toString() +} + +function quoteIdentifier(value: string): string { + return `"${value.replace(/"/g, '""')}"` +} + +describeRealPostgres('accepted onboarding expiry on real PostgreSQL', () => { + const database = `control_api_accepted_onboarding_${randomBytes(6).toString('hex')}` + const connectionString = databaseUrl( + adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', + database + ) + let adminPool: Pool + let databasePool: Pool + let teamId: string + + async function createPending( + purpose: 'member_invitation' | 'admin_desktop_access' | 'password_reset', + label: string + ) { + const email = `${label}-${randomUUID()}@example.test` + const invitation = await createInvitationForTeams({ + inviteeName: label, + email, + purpose, + teamAssignments: + purpose === 'password_reset' + ? [] + : [{ teamId, role: purpose === 'admin_desktop_access' ? 'admin' : 'member' }], + fallbackRole: purpose === 'admin_desktop_access' ? 'admin' : 'member', + }) + return { + id: String(invitation.id), + token: String(invitation.token), + email, + } + } + + beforeAll(async () => { + adminPool = new Pool({ connectionString: adminUrl }) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) + databasePool = new Pool({ connectionString }) + producerDatabase.pool = databasePool + registration.registerAndSendInvitation.mockResolvedValue(undefined) + await initDb({ connect: () => databasePool.connect() }) + teamId = randomUUID() + await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, 'Onboarding Team')`, [teamId]) + }) + + afterAll(async () => { + await databasePool?.end() + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + await adminPool.end() + } + }) + + it('keeps an expired pending onboarding capability unusable', async () => { + const invitation = await createPending('member_invitation', 'expired-pending') + await databasePool.query( + `UPDATE invitations SET expires_at = NOW() - INTERVAL '1 minute' WHERE id = $1`, + [invitation.id] + ) + + await expect( + acceptInvitationForEmail(invitation.email, invitation.token, invitation.id) + ).resolves.toEqual({ error: 'expired' }) + }) + + it.each(['member_invitation', 'admin_desktop_access'] as const)( + 'allows accepted %s onboarding to finish password setup after the original TTL', + async purpose => { + const invitation = await createPending(purpose, `accepted-${purpose}`) + const accepted = await acceptInvitationForEmail( + invitation.email, + invitation.token, + invitation.id + ) + expect(accepted).toMatchObject({ data: { accepted: true, userId: expect.any(String) } }) + if (!('data' in accepted)) throw new Error('producer did not accept onboarding invitation') + const userId = String(accepted.data.userId) + + await databasePool.query( + `UPDATE invitations + SET accepted_at = NOW() - INTERVAL '2 hours', + expires_at = NOW() - INTERVAL '1 hour' + WHERE id = $1`, + [invitation.id] + ) + + await expect( + setInvitationPasswordForUser( + userId, + invitation.email, + invitation.id, + 'Accepted-Onboarding-Password-1!' + ) + ).resolves.toMatchObject({ data: { passwordUpdated: true, status: 'accepted' } }) + + await expect( + acceptInvitationForEmail(invitation.email, invitation.token, invitation.id) + ).resolves.toEqual({ error: 'not_pending' }) + await expect( + setInvitationPasswordForEmail( + invitation.email, + invitation.token, + invitation.id, + 'Replay-Password-1!' + ) + ).resolves.toEqual({ error: 'expired' }) + + const stored = await databasePool.query<{ password_hash: string | null }>( + `SELECT password_hash FROM users WHERE id = $1`, + [userId] + ) + expect(stored.rows[0]?.password_hash).toEqual(expect.any(String)) + } + ) + + it('keeps expired password-reset capabilities unusable', async () => { + const userId = randomUUID() + const email = `expired-reset-${userId}@example.test` + const originalHash = await bcrypt.hash('Original-Password-1!', 12) + await databasePool.query( + `INSERT INTO users(id, email, name, password_hash) VALUES ($1, $2, 'Reset User', $3)`, + [userId, email, originalHash] + ) + await requestProfilePasswordReset(email) + const produced = await databasePool.query<{ id: string }>( + `SELECT id::text AS id + FROM invitations + WHERE email = $1 + AND purpose = 'password_reset' + ORDER BY created_at DESC + LIMIT 1`, + [email] + ) + const invitationId = produced.rows[0]!.id + await databasePool.query( + `UPDATE invitations SET expires_at = NOW() - INTERVAL '1 minute' WHERE id = $1`, + [invitationId] + ) + + await expect( + setInvitationPasswordForUser(userId, email, invitationId, 'Replacement-Password-1!') + ).resolves.toEqual({ error: 'expired' }) + const stored = await databasePool.query<{ password_hash: string | null }>( + `SELECT password_hash FROM users WHERE id = $1`, + [userId] + ) + expect(stored.rows[0]?.password_hash).toBe(originalHash) + }) + + it('preserves normal within-TTL onboarding completion', async () => { + const invitation = await createPending('member_invitation', 'within-ttl') + const accepted = await acceptInvitationForEmail( + invitation.email, + invitation.token, + invitation.id + ) + if (!('data' in accepted)) throw new Error('producer did not accept onboarding invitation') + + await expect( + setInvitationPasswordForUser( + String(accepted.data.userId), + invitation.email, + invitation.id, + 'Within-Ttl-Password-1!' + ) + ).resolves.toMatchObject({ data: { passwordUpdated: true, status: 'accepted' } }) + }) +}) From 70f000e255a9c37d934bd8340dc53518e29504f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 27 Aug 2026 13:39:35 -0600 Subject: [PATCH 133/528] fix(control-api): r11-b1 bind team gfs admission configuration Class-check: catalog budget production paths -> 4 sites -> bind 3 Team-GFS paths; exempt indexer Repro-test: accessCatalogConfiguredAdmission.test.ts fails at 61aaa8d8 --- control-api/src/routes/external/access.ts | 10 +- .../access/accessCatalogCoordinator.ts | 5 +- .../services/access/accessCatalogShadow.ts | 5 +- .../services/access/accessExecutionBudget.ts | 4 +- .../src/services/access/userAccessPolicy.ts | 57 +++++++- .../accessCatalogConfiguredAdmission.test.ts | 129 ++++++++++++++++++ ...gFamilies.realPostgres.integration.test.ts | 48 +++++++ .../test/accessCatalogShadowCapacity.test.ts | 9 ++ .../test/accessExecutionBudget.test.ts | 13 ++ control-api/test/userAccessPolicy.test.ts | 6 +- 10 files changed, 272 insertions(+), 14 deletions(-) create mode 100644 control-api/test/accessCatalogConfiguredAdmission.test.ts diff --git a/control-api/src/routes/external/access.ts b/control-api/src/routes/external/access.ts index 1e64c32ba4..904461cecd 100644 --- a/control-api/src/routes/external/access.ts +++ b/control-api/src/routes/external/access.ts @@ -27,7 +27,10 @@ import { resolveLiveAuthorization } from '../../services/access/liveAuthorizatio import { validateOperationTarget } from '../../services/access/operationTarget.js' import { canonicalEnvironmentId } from '../../services/access/operationalAccessProjection.js' import { canonicalResourceIdentity } from '../../services/access/resourceIdentity.js' -import { userAccessCapabilityManifest } from '../../services/access/userAccessPolicy.js' +import { + configuredCatalogBudgetOptions, + userAccessCapabilityManifest, +} from '../../services/access/userAccessPolicy.js' import { resolveEffectiveUserAccessPolicy } from '../../services/access/userAccessRuntimePolicy.js' const CATALOG_RATE_LIMIT_PER_MINUTE = 10 @@ -40,7 +43,10 @@ export function attachAccessExecutionBudget( next: () => void ): void { const kind = req.method === 'GET' && req.path.endsWith('/catalog') ? 'catalog' : 'action' - const budget = AccessExecutionBudget.create(kind) + const budget = AccessExecutionBudget.create( + kind, + kind === 'catalog' ? configuredCatalogBudgetOptions : undefined + ) req.accessExecutionBudget = budget let settled = false const detach = () => { diff --git a/control-api/src/services/access/accessCatalogCoordinator.ts b/control-api/src/services/access/accessCatalogCoordinator.ts index 06e91d1f9c..fad368ebb1 100644 --- a/control-api/src/services/access/accessCatalogCoordinator.ts +++ b/control-api/src/services/access/accessCatalogCoordinator.ts @@ -41,6 +41,7 @@ import { CatalogProducerContractError } from './catalogProducerSupport.js' import { requireCatalogProducer } from './catalogProducers.js' import { canonicalEnvironmentId } from './operationalAccessProjection.js' import type { CanonicalResourceIdentity } from './resourceIdentity.js' +import { configuredCatalogBudgetOptions } from './userAccessPolicy.js' export type PublicCatalogAccessPath = Readonly<{ accessPathId: string @@ -368,7 +369,9 @@ export async function buildAccessCatalog( options.budget ?? AccessExecutionBudget.create('catalog', { limits: options.limits, - teamGfsMembershipAdmissionLimit: options.teamGfsMembershipAdmissionLimit, + teamGfsMembershipAdmissionLimit: + options.teamGfsMembershipAdmissionLimit ?? + configuredCatalogBudgetOptions.teamGfsMembershipAdmissionLimit, }) const ownedBudget = options.budget ? null : budget try { diff --git a/control-api/src/services/access/accessCatalogShadow.ts b/control-api/src/services/access/accessCatalogShadow.ts index d43c724104..03a1ccba6c 100644 --- a/control-api/src/services/access/accessCatalogShadow.ts +++ b/control-api/src/services/access/accessCatalogShadow.ts @@ -11,7 +11,7 @@ import { AccessExecutionCancelledError, } from './accessExecutionBudget.js' import type { CatalogFamily } from './catalogContracts.js' -import { configuredUserAccessIntent } from './userAccessPolicy.js' +import { configuredCatalogBudgetOptions, configuredUserAccessIntent } from './userAccessPolicy.js' import { resolveEffectiveUserAccessPolicy } from './userAccessRuntimePolicy.js' const SHADOW_MAX_IDENTITIES = 100 @@ -124,7 +124,8 @@ export async function compareAccessCatalogShadow( return 'skipped_unavailable' } - const parent = options.budget ?? AccessExecutionBudget.create('catalog') + const parent = + options.budget ?? AccessExecutionBudget.create('catalog', configuredCatalogBudgetOptions) let child: AccessExecutionBudget | undefined try { child = parent.child({ diff --git a/control-api/src/services/access/accessExecutionBudget.ts b/control-api/src/services/access/accessExecutionBudget.ts index 172fe4f3dd..c60536ac5b 100644 --- a/control-api/src/services/access/accessExecutionBudget.ts +++ b/control-api/src/services/access/accessExecutionBudget.ts @@ -252,11 +252,11 @@ export class AccessExecutionBudget { if ( teamGfsMembershipAdmissionLimit !== null && (!Number.isSafeInteger(teamGfsMembershipAdmissionLimit) || - teamGfsMembershipAdmissionLimit < 0) + teamGfsMembershipAdmissionLimit < 1) ) { throw new AccessBudgetConfigurationError( 'objects', - 'team GFS membership admission must be a non-negative safe integer' + 'team GFS membership admission must be a positive safe integer' ) } const controller = new AbortController() diff --git a/control-api/src/services/access/userAccessPolicy.ts b/control-api/src/services/access/userAccessPolicy.ts index cc39229dad..02aa0641e6 100644 --- a/control-api/src/services/access/userAccessPolicy.ts +++ b/control-api/src/services/access/userAccessPolicy.ts @@ -27,6 +27,7 @@ export type ConfiguredUserAccessIntent = Readonly<{ sessionV2Acceptance: boolean sessionV2Issuance: boolean catalogMode: CatalogMode + teamGfsMembershipAdmissionLimit: number | null actionContextV2: boolean rpcDelegationV2: boolean desktopAllTeamMode: boolean @@ -165,6 +166,12 @@ export function compileUserAccessPolicy( const wantsShadow = intent.catalogMode === 'shadow' || intent.catalogMode === 'serve_and_shadow' const wantsServe = intent.catalogMode === 'serve' || intent.catalogMode === 'serve_and_shadow' + if (wantsShadow || wantsServe) { + requireCondition( + intent.teamGfsMembershipAdmissionLimit !== null, + 'catalog_team_gfs_membership_admission_missing' + ) + } const advertisedCatalogFamilies = wantsShadow || wantsServe ? intersection( @@ -298,7 +305,35 @@ function parseVersion(env: NodeJS.ProcessEnv, name: string): string | null { return raw } +function parseOptionalPositiveSafeInteger(env: NodeJS.ProcessEnv, name: string): number | null { + const raw = env[name]?.trim() + if (!raw) return null + if (!/^[1-9][0-9]*$/.test(raw)) { + throw new UserAccessPolicyConfigurationError(`${name.toLowerCase()}_invalid`) + } + const value = Number(raw) + if (!Number.isSafeInteger(value) || value < 1) { + throw new UserAccessPolicyConfigurationError(`${name.toLowerCase()}_invalid`) + } + return value +} + export function loadConfiguredUserAccessIntent(env: NodeJS.ProcessEnv): ConfiguredUserAccessIntent { + const catalogMode = parseEnum( + env, + 'CONTROL_API_USER_ACCESS_CATALOG_MODE', + ['off', 'shadow', 'serve_and_shadow', 'serve'] as const, + 'off' + ) + const teamGfsMembershipAdmissionLimit = parseOptionalPositiveSafeInteger( + env, + 'CONTROL_API_USER_ACCESS_TEAM_GFS_MEMBERSHIP_ADMISSION_LIMIT' + ) + if (catalogMode !== 'off' && teamGfsMembershipAdmissionLimit === null) { + throw new UserAccessPolicyConfigurationError( + 'control_api_user_access_team_gfs_membership_admission_limit_missing' + ) + } return Object.freeze({ legacyLifecycle: parseEnum( env, @@ -308,12 +343,8 @@ export function loadConfiguredUserAccessIntent(env: NodeJS.ProcessEnv): Configur ), sessionV2Acceptance: parseBoolean(env, 'CONTROL_API_USER_ACCESS_SESSION_V2_ACCEPTANCE', true), sessionV2Issuance: parseBoolean(env, 'CONTROL_API_USER_ACCESS_SESSION_V2_ISSUANCE', false), - catalogMode: parseEnum( - env, - 'CONTROL_API_USER_ACCESS_CATALOG_MODE', - ['off', 'shadow', 'serve_and_shadow', 'serve'] as const, - 'off' - ), + catalogMode, + teamGfsMembershipAdmissionLimit, actionContextV2: parseBoolean(env, 'CONTROL_API_USER_ACCESS_ACTION_CONTEXT_V2', false), rpcDelegationV2: parseBoolean(env, 'CONTROL_API_USER_ACCESS_RPC_DELEGATION_V2', false), desktopAllTeamMode: parseBoolean(env, 'CONTROL_API_USER_ACCESS_DESKTOP_ALL_TEAM_MODE', false), @@ -357,6 +388,20 @@ export const reconstructionReadiness: DeploymentReadiness = Object.freeze({ export const configuredUserAccessIntent = loadConfiguredUserAccessIntent(process.env) +export function catalogBudgetOptionsForIntent( + intent: ConfiguredUserAccessIntent +): Readonly<{ teamGfsMembershipAdmissionLimit?: number }> { + return intent.teamGfsMembershipAdmissionLimit === null + ? Object.freeze({}) + : Object.freeze({ + teamGfsMembershipAdmissionLimit: intent.teamGfsMembershipAdmissionLimit, + }) +} + +export const configuredCatalogBudgetOptions = catalogBudgetOptionsForIntent( + configuredUserAccessIntent +) + export function userAccessCapabilityManifest(policy: EffectiveUserAccessPolicy): Readonly<{ policyVersion: string policyRevision: string diff --git a/control-api/test/accessCatalogConfiguredAdmission.test.ts b/control-api/test/accessCatalogConfiguredAdmission.test.ts new file mode 100644 index 0000000000..b35b20b9d1 --- /dev/null +++ b/control-api/test/accessCatalogConfiguredAdmission.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' + +const MODE = 'CONTROL_API_USER_ACCESS_CATALOG_MODE' +const ADMISSION = 'CONTROL_API_USER_ACCESS_TEAM_GFS_MEMBERSHIP_ADMISSION_LIMIT' +const originalMode = process.env[MODE] +const originalAdmission = process.env[ADMISSION] + +function restore(name: string, value: string | undefined): void { + if (value === undefined) delete process.env[name] + else process.env[name] = value +} + +afterEach(() => { + restore(MODE, originalMode) + restore(ADMISSION, originalAdmission) + vi.resetModules() +}) + +describe('configured Team-GFS catalog admission', () => { + it('allows rollout off without an admission input', async () => { + delete process.env[MODE] + delete process.env[ADMISSION] + vi.resetModules() + const { loadConfiguredUserAccessIntent } = + await import('../src/services/access/userAccessPolicy.js') + + expect(loadConfiguredUserAccessIntent(process.env)).toMatchObject({ + catalogMode: 'off', + teamGfsMembershipAdmissionLimit: null, + }) + }) + + it.each(['shadow', 'serve_and_shadow', 'serve'] as const)( + 'rejects missing Team-GFS admission before %s activation', + async catalogMode => { + const { loadConfiguredUserAccessIntent, UserAccessPolicyConfigurationError } = + await import('../src/services/access/userAccessPolicy.js') + + expect(() => + loadConfiguredUserAccessIntent({ CONTROL_API_USER_ACCESS_CATALOG_MODE: catalogMode }) + ).toThrowError( + new UserAccessPolicyConfigurationError( + 'control_api_user_access_team_gfs_membership_admission_limit_missing' + ) + ) + } + ) + + it.each(['0', '-1', '1.5', 'not-a-number'])( + 'rejects invalid operator admission value %s', + async value => { + const { loadConfiguredUserAccessIntent, UserAccessPolicyConfigurationError } = + await import('../src/services/access/userAccessPolicy.js') + + expect(() => + loadConfiguredUserAccessIntent({ + CONTROL_API_USER_ACCESS_CATALOG_MODE: 'shadow', + CONTROL_API_USER_ACCESS_TEAM_GFS_MEMBERSHIP_ADMISSION_LIMIT: value, + }) + ).toThrowError( + new UserAccessPolicyConfigurationError( + 'control_api_user_access_team_gfs_membership_admission_limit_invalid' + ) + ) + } + ) + + it('passes the exact operator value through the real route budget construction', async () => { + process.env[MODE] = 'serve' + process.env[ADMISSION] = '3' + vi.resetModules() + const { attachAccessExecutionBudget } = await import('../src/routes/external/access.js') + const req = Object.assign(new EventEmitter(), { + method: 'GET', + path: '/external/access/catalog', + }) + const res = Object.assign(new EventEmitter(), { writableEnded: false }) + const next = vi.fn() + + attachAccessExecutionBudget(req as never, res as never, next) + + const budget = ( + req as { accessExecutionBudget?: { teamGfsMembershipAdmissionLimit: number | null } } + ).accessExecutionBudget + expect(next).toHaveBeenCalledOnce() + expect(budget?.teamGfsMembershipAdmissionLimit).toBe(3) + res.emit('finish') + }) + + it('passes the exact operator value into the production shadow comparison budget', async () => { + process.env[MODE] = 'shadow' + process.env[ADMISSION] = '2' + vi.resetModules() + const { compareAccessCatalogShadow } = + await import('../src/services/access/accessCatalogShadow.js') + const buildCatalog = vi.fn(async (_input, options) => { + expect(options.budget?.teamGfsMembershipAdmissionLimit).toBe(2) + return { + contractVersion: '2' as const, + authorizationRevision: 'revision', + sourceStateRevision: 'source', + complete: true, + partialErrors: [], + items: [], + nextCursor: null, + } + }) + + await expect( + compareAccessCatalogShadow( + { + session: { + contract: 'v1', + userId: '10000000-0000-4000-8000-000000000001', + tokenHash: 'token-hash', + issuedAt: 1_900_000_000, + authGeneration: 1, + }, + family: 'gfs_resource', + legacyLogicalIds: [], + legacyComplete: true, + }, + { enabled: true, buildCatalog } + ) + ).resolves.toBe('match') + expect(buildCatalog).toHaveBeenCalledOnce() + }) +}) diff --git a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts index 47ac531bda..b802ebcd49 100644 --- a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts @@ -5,6 +5,10 @@ import { config } from '../src/config.js' import { type DbClient, initDb } from '../src/db.js' import { K8sGateway } from '../src/k8s.js' import { buildAccessCatalog } from '../src/services/access/accessCatalogCoordinator.js' +import { + AccessBudgetExceededError, + AccessExecutionBudget, +} from '../src/services/access/accessExecutionBudget.js' import type { AccessCapability } from '../src/services/access/capabilityRegistry.js' import { CATALOG_FAMILIES, type CatalogFamily } from '../src/services/access/catalogContracts.js' import { resolveLiveAuthorization } from '../src/services/access/liveAuthorizationResolver.js' @@ -15,6 +19,10 @@ import { } from '../src/services/access/operationalAccessIndexer.js' import { canonicalEnvironmentId } from '../src/services/access/operationalAccessProjection.js' import { canonicalResourceIdentity } from '../src/services/access/resourceIdentity.js' +import { + catalogBudgetOptionsForIntent, + loadConfiguredUserAccessIntent, +} from '../src/services/access/userAccessPolicy.js' import type { ExternalSessionAuthorityContext } from '../src/services/auth/externalSessionAuthentication.js' import { TemporaryKubernetesApi } from './helpers/temporaryKubernetesApi.js' @@ -419,6 +427,46 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { }) } + it('enforces the operator-configured Team-GFS admission through the production budget contract', async () => { + const intent = loadConfiguredUserAccessIntent({ + CONTROL_API_USER_ACCESS_CATALOG_MODE: 'serve', + CONTROL_API_USER_ACCESS_TEAM_GFS_MEMBERSHIP_ADMISSION_LIMIT: '1', + }) + const budget = AccessExecutionBudget.create('catalog', catalogBudgetOptionsForIntent(intent)) + try { + await expect( + buildAccessCatalog( + { session, families: ['gfs_resource'], limit: 100 }, + { transaction: transaction(databasePool), budget } + ) + ).resolves.toMatchObject({ complete: true, items: [{ resource: { type: 'gfs_resource' } }] }) + } finally { + budget.close() + } + + const secondTeamId = randomUUID() + await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, 'Admission Overflow Team')`, [ + secondTeamId, + ]) + await databasePool.query( + `INSERT INTO team_members(team_id, user_id, role, status) + VALUES ($1, $2, 'member', 'active')`, + [secondTeamId, userId] + ) + const exhausted = AccessExecutionBudget.create('catalog', catalogBudgetOptionsForIntent(intent)) + try { + await expect( + buildAccessCatalog( + { session, families: ['gfs_resource'], limit: 100 }, + { transaction: transaction(databasePool), budget: exhausted } + ) + ).rejects.toBeInstanceOf(AccessBudgetExceededError) + } finally { + exhausted.close() + await databasePool.query(`DELETE FROM teams WHERE id = $1`, [secondTeamId]) + } + }) + it('preserves both direct/team provenance across duplicate filesystem mounts', async () => { const catalog = await buildAccessCatalog( { session, families: ['shared_filesystem'], limit: 100 }, diff --git a/control-api/test/accessCatalogShadowCapacity.test.ts b/control-api/test/accessCatalogShadowCapacity.test.ts index 18ca8be999..f1025a73ab 100644 --- a/control-api/test/accessCatalogShadowCapacity.test.ts +++ b/control-api/test/accessCatalogShadowCapacity.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { ACCESS_EXECUTION_LIMIT_CLAMPS } from '../src/services/access/accessExecutionBudget.js' const ORIGINAL_MODE = process.env.CONTROL_API_USER_ACCESS_CATALOG_MODE +const ORIGINAL_TEAM_GFS_ADMISSION = + process.env.CONTROL_API_USER_ACCESS_TEAM_GFS_MEMBERSHIP_ADMISSION_LIMIT afterEach(() => { vi.unstubAllGlobals() @@ -11,11 +13,18 @@ afterEach(() => { } else { process.env.CONTROL_API_USER_ACCESS_CATALOG_MODE = ORIGINAL_MODE } + if (ORIGINAL_TEAM_GFS_ADMISSION === undefined) { + delete process.env.CONTROL_API_USER_ACCESS_TEAM_GFS_MEMBERSHIP_ADMISSION_LIMIT + } else { + process.env.CONTROL_API_USER_ACCESS_TEAM_GFS_MEMBERSHIP_ADMISSION_LIMIT = + ORIGINAL_TEAM_GFS_ADMISSION + } }) describe('aggregate access shadow scheduling capacity', () => { it('reserves bounded capacity before deferring comparison work', async () => { process.env.CONTROL_API_USER_ACCESS_CATALOG_MODE = 'shadow' + process.env.CONTROL_API_USER_ACCESS_TEAM_GFS_MEMBERSHIP_ADMISSION_LIMIT = '4' vi.resetModules() const deferred: Array<() => void> = [] vi.stubGlobal('setImmediate', (callback: () => void) => { diff --git a/control-api/test/accessExecutionBudget.test.ts b/control-api/test/accessExecutionBudget.test.ts index e606bbb580..7498f80fac 100644 --- a/control-api/test/accessExecutionBudget.test.ts +++ b/control-api/test/accessExecutionBudget.test.ts @@ -63,8 +63,10 @@ describe('AccessExecutionBudget', () => { it('reserves child capacity and returns only unused capacity on close', () => { const parent = AccessExecutionBudget.create('catalog', { limits: { producerCalls: 4, objects: 4 }, + teamGfsMembershipAdmissionLimit: 3, }) const child = parent.child({ producerCalls: 2, objects: 3 }) + expect(child.teamGfsMembershipAdmissionLimit).toBe(3) expect(parent.remaining('producerCalls')).toBe(2) expect(parent.remaining('objects')).toBe(1) child.charge({ kind: 'producerCalls' }) @@ -75,6 +77,17 @@ describe('AccessExecutionBudget', () => { parent.close() }) + it('requires an explicit positive Team-GFS membership admission when supplied', () => { + expect(() => + AccessExecutionBudget.create('catalog', { teamGfsMembershipAdmissionLimit: 0 }) + ).toThrow(AccessBudgetConfigurationError) + const budget = AccessExecutionBudget.create('catalog', { + teamGfsMembershipAdmissionLimit: 1, + }) + expect(budget.teamGfsMembershipAdmissionLimit).toBe(1) + budget.close() + }) + it('bounds producer concurrency and releases slots after completion', async () => { const budget = AccessExecutionBudget.create('catalog', { limits: { producerCalls: 3, producerConcurrency: 1 }, diff --git a/control-api/test/userAccessPolicy.test.ts b/control-api/test/userAccessPolicy.test.ts index f5bd857763..e896b42b2d 100644 --- a/control-api/test/userAccessPolicy.test.ts +++ b/control-api/test/userAccessPolicy.test.ts @@ -16,11 +16,12 @@ import { resolveEffectiveUserAccessPolicy } from '../src/services/access/userAcc const allFamilies = new Set(CATALOG_FAMILIES) function intent(overrides: Partial = {}): ConfiguredUserAccessIntent { - return { + const configured: ConfiguredUserAccessIntent = { legacyLifecycle: 'issue_and_accept', sessionV2Acceptance: true, sessionV2Issuance: false, catalogMode: 'off', + teamGfsMembershipAdmissionLimit: null, actionContextV2: false, rpcDelegationV2: false, desktopAllTeamMode: false, @@ -30,6 +31,9 @@ function intent(overrides: Partial = {}): Configured minimumClientEnforcement: false, ...overrides, } + return configured.catalogMode !== 'off' && overrides.teamGfsMembershipAdmissionLimit === undefined + ? { ...configured, teamGfsMembershipAdmissionLimit: 4 } + : configured } function readiness(overrides: Partial = {}): DeploymentReadiness { From 35c4600c31e473094bd691b99b5caed4bc900000 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 27 Aug 2026 13:53:49 -0600 Subject: [PATCH 134/528] fix(external-rest-api): r11-b2 preserve gfs upload length boundary Class-check: Control API error header extraction -> 4 callers -> one status-aware safe boundary Repro-test: publicErrorContract.test.ts fails at 82de1421 --- external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- .../src/__tests__/publicErrorContract.test.ts | 44 ++++++++++++++++++- .../test/controlApiClient.test.ts | 33 ++++++++++++++ 4 files changed, 78 insertions(+), 5 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index ff88a88560..1831f85dfc 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index d8dc7498ab..a2eb923322 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/__tests__/publicErrorContract.test.ts b/external-rest-api/src/__tests__/publicErrorContract.test.ts index 18bb2c03d5..3be6515e68 100644 --- a/external-rest-api/src/__tests__/publicErrorContract.test.ts +++ b/external-rest-api/src/__tests__/publicErrorContract.test.ts @@ -1,8 +1,8 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import express from 'express' import request from 'supertest' import { externalRestPublicErrorHandler } from '../app.js' -import { ControlApiError } from '../controlApiClient.js' +import { ControlApiError, controlApiRequest } from '../controlApiClient.js' import { sanitizeControlApiPublicError } from '../http/publicApiError.js' function appThrowing(error: Error) { @@ -13,6 +13,8 @@ function appThrowing(error: Error) { } describe('External REST public error contract', () => { + afterEach(() => vi.unstubAllGlobals()) + it('never reflects an internal upstream message, path, or secret-like value', async () => { const sentinel = 'oauth-secret-at-/var/run/internal/provider.json' const response = await request(appThrowing(new Error(sentinel))).get('/failure') @@ -179,6 +181,44 @@ describe('External REST public error contract', () => { } ) + it('preserves bounded upload size through the real Control API client and sanitizer boundary', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + error: { + code: 'payload_too_large', + message: 'private upstream upload detail', + }, + }), + { + status: 413, + headers: { + 'content-type': 'application/json', + 'upload-length': '209715200', + 'x-internal-secret': 'must-not-cross', + }, + } + ) + ) + ) + + const upstream = await controlApiRequest('PUT', '/external/gfs/resources/id/content').catch( + (error: unknown) => error + ) + expect(upstream).toBeInstanceOf(ControlApiError) + + const sanitized = sanitizeControlApiPublicError(upstream, new Set([413])) + expect(sanitized).toMatchObject({ + status: 413, + headers: { 'upload-length': '209715200' }, + body: { error: { code: 'payload_too_large', retryable: false } }, + }) + expect(JSON.stringify(sanitized)).not.toContain('private upstream') + expect(JSON.stringify(sanitized)).not.toContain('must-not-cross') + }) + it('preserves only bounded access-path descriptors for an ambiguity response', () => { const pathId = `ap1_${'a'.repeat(43)}` const sanitized = sanitizeControlApiPublicError( diff --git a/external-rest-api/test/controlApiClient.test.ts b/external-rest-api/test/controlApiClient.test.ts index cfde01db1b..8bb05981cf 100644 --- a/external-rest-api/test/controlApiClient.test.ts +++ b/external-rest-api/test/controlApiClient.test.ts @@ -59,6 +59,39 @@ describe('controlApiClient error headers', () => { expect(error.headers).not.toHaveProperty('server') expect(error.headers).not.toHaveProperty('x-internal-debug') }) + + it.each([ + [413, '209715200', '209715200'], + [413, '0007', '7'], + [413, '-1', undefined], + [413, '9007199254740992', undefined], + [413, 'not-a-number', undefined], + [409, '209715200', undefined], + ] as const)( + 'for status %s forwards only a bounded 413 upload-length value %s', + async (status, uploadLength, expected) => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: 'upstream' }), { + status, + headers: { + 'content-type': 'application/json', + 'upload-length': uploadLength, + 'x-internal-secret': 'must-not-leak', + }, + }) + ) + ) + + const error = await captureError(() => + controlApiRequestWithStatus('PUT', '/external/gfs/resources/id/content') + ) + + expect(error.responseHeaders['upload-length']).toBe(expected) + expect(error.responseHeaders).not.toHaveProperty('x-internal-secret') + } + ) }) describe('controlApiClient request identity', () => { From ee8b20488785bf70ed53cea92050a72bb1cf6c33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 28 Aug 2026 09:26:10 -0600 Subject: [PATCH 135/528] test(control-api): r12-b1 expose deterministic failures Class-check: ordinary retry policy and 2 masked tests -> 3 sites -> fail on first error Repro-test: retry=0 route suites fail deterministically at 4ba09612 --- control-api/vitest.config.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/control-api/vitest.config.ts b/control-api/vitest.config.ts index 85c16c4d61..8f5b262cee 100644 --- a/control-api/vitest.config.ts +++ b/control-api/vitest.config.ts @@ -30,15 +30,18 @@ export default defineConfig({ fileParallelism: false, maxWorkers: 1, pool: 'threads', - // No retries: a retry turns a flaky test green and hides the flake. A - // failing test is fixed in the test. Real-Postgres tests could not be - // retried safely anyway, because abandoned async work may still commit. + // Deterministic failures must remain visible in ordinary and Real-Postgres + // lanes. Retrying can also overlap abandoned database work after a timeout. retry: 0, - testTimeout: 10_000, + // A single retry-free pass can spend more than ten seconds inside a + // Supertest request while this 5k-test suite is under full worker load. + // Give the request its execution budget without rerunning assertions or + // hiding deterministic failures behind retry state. + testTimeout: 30_000, // Real-Postgres beforeAll runs initDb across the full migration list. - // Default hookTimeout follows testTimeout (10s) and turns a slow CREATE + // A bounded extra allowance prevents a slow CREATE // DATABASE into skipped tests with an empty JSON reporter message. - hookTimeout: process.env.CONTROL_API_REAL_PG_ADMIN_URL ? 60_000 : 10_000, + hookTimeout: process.env.CONTROL_API_REAL_PG_ADMIN_URL ? 60_000 : 30_000, sequence: { hooks: 'list', }, From 4d136f5dfa3aaebb5aabf7fdc6b8473655a040ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 28 Aug 2026 09:31:22 -0600 Subject: [PATCH 136/528] fix(control-api): r12-h1 complete final directory authority Class-check: managed directory authority -> 7 mounted mutations -> one final transaction Repro-test: Real PG omitted-mutation matrix fails at 4ba09612 --- control-api/src/routes/external/teams.ts | 3 +- .../directoryAuthorizationPrivacy.test.ts | 2 +- ...Authority.realPostgres.integration.test.ts | 122 +++++++++++++++++- 3 files changed, 124 insertions(+), 3 deletions(-) diff --git a/control-api/src/routes/external/teams.ts b/control-api/src/routes/external/teams.ts index bb91954709..5ef2a48da8 100644 --- a/control-api/src/routes/external/teams.ts +++ b/control-api/src/routes/external/teams.ts @@ -258,7 +258,8 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { (req as ExternalAuthedRequest).externalAuth!.userId, email, [{ teamId: req.params.teamId, role }], - name || fallbackName + name || fallbackName, + (req as ExternalAuthedRequest).externalSessionAuthority ) if ('error' in result) { return res.status(result.error === 'forbidden' ? 403 : 400).json({ error: result.error }) diff --git a/control-api/test/directoryAuthorizationPrivacy.test.ts b/control-api/test/directoryAuthorizationPrivacy.test.ts index 00f6667ff5..4226973ec0 100644 --- a/control-api/test/directoryAuthorizationPrivacy.test.ts +++ b/control-api/test/directoryAuthorizationPrivacy.test.ts @@ -120,7 +120,7 @@ describe('directory privacy and atomic authorization', () => { rowCount: 1, } } - if (sql.includes('SELECT team_id, role')) { + if (sql.includes('SELECT tm.team_id, tm.role')) { return { rows: [{ team_id: TEAM_A, role: 'admin' }], rowCount: 1 } } throw new Error(`unexpected query: ${sql.slice(0, 40)}`) diff --git a/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts b/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts index 2e9ccfbd66..5be39b760c 100644 --- a/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts +++ b/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts @@ -4,13 +4,16 @@ import { randomBytes, randomUUID } from 'node:crypto' import { Pool, type PoolClient } from 'pg' import request from 'supertest' import { type DbClient, initDb } from '../src/db.js' +import { createExternalMembersRouter } from '../src/routes/external/members.js' import { createExternalTeamsRouter } from '../src/routes/external/teams.js' import { issueExternalUserSession } from '../src/services/auth/externalSessionIssuance.js' +import { createManagedInvitationForUser } from '../src/services/directory/index.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip const producerDatabase = vi.hoisted(() => ({ pool: undefined as Pool | undefined })) const rateLimit = vi.hoisted(() => ({ checkAndIncrement: vi.fn() })) +const invitationDelivery = vi.hoisted(() => ({ registerAndSendInvitation: vi.fn() })) const runtimePolicy = vi.hoisted(() => ({ policyVersion: '1', policyRevision: 'r9-final-team-authority', @@ -54,12 +57,15 @@ vi.mock('../src/db.js', async importOriginal => { return { ...actual, pool, withTransaction } }) vi.mock('../src/services/rateLimiterService.js', () => rateLimit) +vi.mock('../src/services/invitationFlowRegistrationService.js', () => invitationDelivery) vi.mock('../src/services/access/userAccessRuntimePolicy.js', () => ({ resolveEffectiveUserAccessPolicy: vi.fn().mockResolvedValue(runtimePolicy), })) type AdminMutation = Readonly<{ name: string + expectedStatus?: number + prepare?: () => Promise invoke: (server: express.Express) => request.Test assertMutation: () => Promise assertNoMutation: () => Promise @@ -164,11 +170,14 @@ describeRealPostgres('external team mutations final source authority on real Pos let targetUserId: string let teamId: string let token: string + let invitationId: string + let invitationEmail: string function app() { const server = express() server.use(express.json()) server.use(createExternalTeamsRouter({} as never)) + server.use(createExternalMembersRouter()) return server } @@ -176,6 +185,8 @@ describeRealPostgres('external team mutations final source authority on real Pos adminUserId = randomUUID() targetUserId = randomUUID() teamId = randomUUID() + invitationId = '' + invitationEmail = `${label}-invitee-${randomUUID()}@example.test` await databasePool.query( `INSERT INTO users(id, email, name, lifecycle_state, lifecycle_version) VALUES ($1, $2, 'Admin', 'active', 1), @@ -209,6 +220,17 @@ describeRealPostgres('external team mutations final source authority on real Pos token = issued.token } + async function prepareInvitation() { + const result = await createManagedInvitationForUser( + adminUserId, + invitationEmail, + [{ teamId, role: 'member' }], + 'Invitee' + ) + if ('error' in result) throw new Error(`invitation preparation failed: ${result.error}`) + invitationId = String(result.invitation.id) + } + function mutations(): readonly AdminMutation[] { return [ { @@ -270,6 +292,99 @@ describeRealPostgres('external team mutations final source authority on real Pos expect(result.rows[0]?.status).toBe('active') }, }, + { + name: 'managed invitation creation', + expectedStatus: 201, + invoke: server => + request(server) + .post('/external/members/invitations') + .send({ + email: invitationEmail, + name: 'Invitee', + teams: [{ teamId, role: 'member' }], + }), + assertMutation: async () => { + const result = await databasePool.query<{ status: string }>( + `SELECT status FROM invitations WHERE email = $1`, + [invitationEmail] + ) + expect(result.rows[0]?.status).toBe('pending') + }, + assertNoMutation: async () => { + const result = await databasePool.query(`SELECT 1 FROM invitations WHERE email = $1`, [ + invitationEmail, + ]) + expect(result.rowCount).toBe(0) + }, + }, + { + name: 'managed invitation resend', + prepare: prepareInvitation, + invoke: server => + request(server).post(`/external/members/invitations/${invitationId}/resend`), + assertMutation: async () => { + const result = await databasePool.query( + `SELECT 1 + FROM invitation_delivery_commands + WHERE invitation_id = $1 + AND delivery_kind = 'resend' + AND status = 'delivered'`, + [invitationId] + ) + expect(result.rowCount).toBe(1) + }, + assertNoMutation: async () => { + const result = await databasePool.query( + `SELECT 1 + FROM invitation_delivery_commands + WHERE invitation_id = $1 + AND delivery_kind = 'resend'`, + [invitationId] + ) + expect(result.rowCount).toBe(0) + }, + }, + { + name: 'managed invitation revoke', + prepare: prepareInvitation, + invoke: server => request(server).delete(`/external/members/invitations/${invitationId}`), + assertMutation: async () => { + const result = await databasePool.query<{ status: string }>( + `SELECT status FROM invitations WHERE id = $1`, + [invitationId] + ) + expect(result.rows[0]?.status).toBe('revoked') + }, + assertNoMutation: async () => { + const result = await databasePool.query<{ status: string }>( + `SELECT status FROM invitations WHERE id = $1`, + [invitationId] + ) + expect(result.rows[0]?.status).toBe('pending') + }, + }, + { + name: 'managed user retirement', + invoke: server => + request(server) + .delete(`/external/members/${targetUserId}`) + .set('Idempotency-Key', `retire-${targetUserId}`) + .send({ reason: 'managed retirement test' }), + assertMutation: async () => { + const result = await databasePool.query<{ lifecycle_state: string }>( + `SELECT lifecycle_state FROM users WHERE id = $1`, + [targetUserId] + ) + expect(result.rowCount === 0 || result.rows[0]?.lifecycle_state === 'retired').toBe(true) + }, + assertNoMutation: async () => { + const result = await databasePool.query<{ lifecycle_state: string }>( + `SELECT lifecycle_state FROM users WHERE id = $1`, + [targetUserId] + ) + expect(result.rows[0]?.lifecycle_state).toBe('active') + }, + }, ] } @@ -302,6 +417,8 @@ describeRealPostgres('external team mutations final source authority on real Pos }) beforeEach(() => { + invitationDelivery.registerAndSendInvitation.mockReset() + invitationDelivery.registerAndSendInvitation.mockResolvedValue(undefined) rateLimit.checkAndIncrement.mockResolvedValue({ allowed: true, count: 1, @@ -314,8 +431,9 @@ describeRealPostgres('external team mutations final source authority on real Pos for (const mutation of mutations()) { it(`allows ${mutation.name} before later retirement or revoke-all`, async () => { await resetPrincipal(`wins-${mutation.name.replaceAll(' ', '-')}`) + await mutation.prepare?.() const response = await mutation.invoke(app()).set('x-user-session-token', token) - expect(response.status).toBe(200) + expect(response.status).toBe(mutation.expectedStatus ?? 200) await mutation.assertMutation() await retireAdmin(databasePool, adminUserId, retirementActorId) await databasePool.query( @@ -329,6 +447,7 @@ describeRealPostgres('external team mutations final source authority on real Pos it(`denies ${mutation.name} when retirement wins after admission`, async () => { await resetPrincipal(`retire-${mutation.name.replaceAll(' ', '-')}`) + await mutation.prepare?.() const gate = await databasePool.connect() try { await gate.query('BEGIN') @@ -352,6 +471,7 @@ describeRealPostgres('external team mutations final source authority on real Pos it(`denies ${mutation.name} when source-session invalidation wins after admission`, async () => { await resetPrincipal(`revoke-${mutation.name.replaceAll(' ', '-')}`) + await mutation.prepare?.() const gate = await databasePool.connect() try { await gate.query('BEGIN') From 21aa1d540629d526e4cac4bbe29998aaeab110b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 28 Aug 2026 09:35:14 -0600 Subject: [PATCH 137/528] fix(ui): r12-h2 preserve typed public errors Class-check: nested public errors -> 4 generic parsers -> safe code and message extraction Repro-test: producer-backed UI compatibility tests fail at 4ba09612 --- .../publicErrorCompatibility.test.ts | 27 ++++++++++++ external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- .../profileUiPublicErrorCompatibility.test.ts | 38 ++++++++++++++++ profile-ui/lib/api.ts | 44 +++++++++++++------ 5 files changed, 98 insertions(+), 17 deletions(-) create mode 100644 control-ui/lib/__tests__/publicErrorCompatibility.test.ts create mode 100644 external-rest-api/test/profileUiPublicErrorCompatibility.test.ts diff --git a/control-ui/lib/__tests__/publicErrorCompatibility.test.ts b/control-ui/lib/__tests__/publicErrorCompatibility.test.ts new file mode 100644 index 0000000000..051831d452 --- /dev/null +++ b/control-ui/lib/__tests__/publicErrorCompatibility.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { formatApiError } from '../api' + +describe('typed public error compatibility', () => { + it('extracts safe nested public code and message', () => { + const response = new Response(null, { + status: 503, + statusText: 'Service Unavailable', + }) + const error = formatApiError( + response, + JSON.stringify({ + error: { + code: 'authority_unavailable', + message: 'Authorization is temporarily unavailable.', + retryable: true, + }, + }) + ) as Error & { code?: string } + + expect(error.message).toBe( + '503 Service Unavailable - Authorization is temporarily unavailable.' + ) + expect(error.code).toBe('authority_unavailable') + expect(error.message).not.toContain('[object Object]') + }) +}) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 1831f85dfc..ff88a88560 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index a2eb923322..d8dc7498ab 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/test/profileUiPublicErrorCompatibility.test.ts b/external-rest-api/test/profileUiPublicErrorCompatibility.test.ts new file mode 100644 index 0000000000..3c5f856dbb --- /dev/null +++ b/external-rest-api/test/profileUiPublicErrorCompatibility.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { apiSend } from '../../profile-ui/lib/api.js' +import { ControlApiError } from '../src/controlApiClient.js' +import { sanitizeControlApiPublicError } from '../src/http/publicApiError.js' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('Profile UI public error compatibility', () => { + it('renders the safe typed External REST message without object stringification', async () => { + const publicError = sanitizeControlApiPublicError( + new ControlApiError('private upstream failure', 503, { + error: { code: 'authority_unavailable', message: 'private upstream failure' }, + }), + new Set([503]) + ) + expect(publicError).not.toBeNull() + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation( + async () => + new Response(JSON.stringify(publicError!.body), { + status: 503, + statusText: 'Service Unavailable', + headers: { 'content-type': 'application/json' }, + }) + ) + ) + + await expect(apiSend('POST', '/api/v1/members/invite', {})).rejects.toThrow( + '503 Service Unavailable - Authorization is temporarily unavailable.' + ) + await expect(apiSend('POST', '/api/v1/members/invite', {})).rejects.not.toThrow( + /\[object Object\]|private upstream failure/ + ) + }) +}) diff --git a/profile-ui/lib/api.ts b/profile-ui/lib/api.ts index 25a1d65d50..86e51c60e2 100644 --- a/profile-ui/lib/api.ts +++ b/profile-ui/lib/api.ts @@ -67,6 +67,33 @@ function handleUnauthorized(): never { throw new AuthExpiredError() } +function publicErrorFields(text: string): { code?: string; detail?: string } { + try { + const parsed = JSON.parse(text) as unknown + if (!parsed || typeof parsed !== 'object') return {} + const body = parsed as Record + const envelope = + body.error && typeof body.error === 'object' + ? (body.error as Record) + : undefined + const code = + typeof envelope?.code === 'string' + ? envelope.code + : typeof body.error === 'string' + ? body.error + : undefined + const message = + typeof envelope?.message === 'string' + ? envelope.message + : typeof body.message === 'string' + ? body.message + : undefined + return { code, detail: message || code } + } catch { + return { detail: text } + } +} + async function parseJsonResponse(res: Response): Promise { const text = await res.text() if (!text.trim()) return undefined @@ -125,13 +152,8 @@ export async function apiSend( if (!res.ok) { const text = await res.text() if (res.status === 401) handleUnauthorized() - let detail: string - try { - const parsed = JSON.parse(text) as { error?: unknown; message?: unknown } - detail = String(parsed.message || parsed.error || text) - } catch { - detail = text - } + const publicError = publicErrorFields(text) + const detail = publicError.detail || res.statusText // Substring match is REQUIRED here (not === like control-ui): profile-ui reaches // control-api through external-rest-api, whose error middleware collapses any 5xx // into { message: "Control API ... failed (503): " }, so the code arrives @@ -166,13 +188,7 @@ export async function loginWithPassword( }) if (!res.ok) { const text = await res.text() - let detail: string - try { - const parsed = JSON.parse(text) as { error?: unknown; message?: unknown } - detail = String(parsed.message || parsed.error || text) - } catch { - detail = text - } + const detail = publicErrorFields(text).detail || res.statusText throw new Error(`${res.status} ${res.statusText} - ${detail}`) } clearToken() From 2c08caba83ed1905e2d13f9be6ba74d138a278cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 28 Aug 2026 09:36:06 -0600 Subject: [PATCH 138/528] fix(external-rest): r12-h3 preserve safe RPC denial reasons Class-check: RPC denial producers -> 2 approved reasons -> one public sanitizer allowlist Repro-test: RPC guardian cases lose both reasons at 4ba09612 --- external-rest-api/package-lock.json | 4 +-- external-rest-api/package.json | 2 +- external-rest-api/src/http/publicApiError.ts | 2 ++ external-rest-api/test/routes.rpc.test.ts | 26 ++++++++++++++++++++ 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index ff88a88560..1831f85dfc 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index d8dc7498ab..a2eb923322 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/http/publicApiError.ts b/external-rest-api/src/http/publicApiError.ts index 233c7e7e4f..366a454653 100644 --- a/external-rest-api/src/http/publicApiError.ts +++ b/external-rest-api/src/http/publicApiError.ts @@ -81,6 +81,8 @@ const SAFE_DOMAIN_REASONS = new Set([ 'subjects_invalid', 'escalation_rejected', 'manage_acl_required', + 'desktop_requires_team', + 'no_permitted_scopes', ]) const PUBLIC_RATE_LIMIT_HEADERS = [ diff --git a/external-rest-api/test/routes.rpc.test.ts b/external-rest-api/test/routes.rpc.test.ts index 8a826cece5..db46298db6 100644 --- a/external-rest-api/test/routes.rpc.test.ts +++ b/external-rest-api/test/routes.rpc.test.ts @@ -56,6 +56,32 @@ describe('routes/rpc /rpc/token', () => { expect(JSON.stringify(res.body)).not.toContain('/internal/control-api') }) + it.each(['desktop_requires_team', 'no_permitted_scopes'])( + 'preserves approved safe RPC denial reason %s', + async reason => { + rpcServiceMock.issueRpcAccessToken.mockRejectedValue( + new ControlApiError('private upstream denial', 403, { error: reason }) + ) + + const res = await request(buildApp()) + .post('/rpc/token') + .set('authorization', 'Bearer session-xyz') + .send({ scopes: ['desktop:view'], hostRefs: ['pro-agent'] }) + + expect(res.status).toBe(403) + expect(res.body).toEqual({ + error: { + code: 'forbidden', + message: 'The requested operation is not allowed.', + correlationId: expect.any(String), + retryable: false, + details: { reason }, + }, + }) + expect(JSON.stringify(res.body)).not.toContain('private upstream denial') + } + ) + it('returns the issued token on success', async () => { rpcServiceMock.issueRpcAccessToken.mockResolvedValue({ token: 't', From 2b7daf3414dbba79bc08346aa23c8c9958f23b41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 28 Aug 2026 09:37:51 -0600 Subject: [PATCH 139/528] fix(external-rest): r12-h4 preserve invitation candidates Class-check: member-invite sentinel fabrication -> 2 payload classes -> forward originals Repro-test: 9 invitation boundary assertions fail at 4ba09612 --- external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- external-rest-api/test/routes.members.test.ts | 42 +++++++++++++------ 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 1831f85dfc..ff88a88560 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index a2eb923322..d8dc7498ab 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/test/routes.members.test.ts b/external-rest-api/test/routes.members.test.ts index eabe9df6c6..d2ca31c189 100644 --- a/external-rest-api/test/routes.members.test.ts +++ b/external-rest-api/test/routes.members.test.ts @@ -64,13 +64,13 @@ describe('routes/members', () => { expect(memberManagementMock.inviteManagedMember).toHaveBeenCalledWith( 'invitee@example.com', - 'Full Name', + ' Full Name ', [{ teamId: 'team-1', role: 'inviter' }], 'good-token' ) }) - it('preserves exact public invitation-role normalization before forwarding', async () => { + it('forwards bounded invitation role candidates to the Control API authority unchanged', async () => { authTokenMock.verifyToken.mockReturnValueOnce(claims) memberManagementMock.inviteManagedMember.mockResolvedValueOnce({ id: 'inv-roles' }) @@ -91,10 +91,13 @@ describe('routes/members', () => { expect(memberManagementMock.inviteManagedMember).toHaveBeenCalledWith( 'invitee@example.com', - '', + undefined, [ { teamId: 'team-admin', role: 'admin' }, - { teamId: 'team-default', role: 'member' }, + { teamId: 'team-alias', role: 'leader' }, + { teamId: 'team-case', role: 'ADMIN' }, + { teamId: 'team-invalid', role: 'unexpected' }, + { teamId: 'team-default' }, ], 'good-token' ) @@ -163,7 +166,7 @@ describe('routes/members', () => { expect(memberManagementMock.inviteManagedMember).toHaveBeenCalledWith( 'not-an-email', - '', + undefined, [{ teamId: 'team-1', role: 'member' }], 'good-token' ) @@ -183,7 +186,7 @@ describe('routes/members', () => { expect(memberManagementMock.inviteManagedMember).toHaveBeenCalledWith( 'not-an-email', - '', + undefined, [], 'good-token' ) @@ -229,7 +232,7 @@ describe('routes/members', () => { expect(memberManagementMock.inviteManagedMember).toHaveBeenCalledWith( 'invitee@example.com', - '', + undefined, [], 'good-token' ) @@ -251,7 +254,12 @@ describe('routes/members', () => { }) .expect(400, { error: 'Name is too long' }) - expect(memberManagementMock.inviteManagedMember).toHaveBeenCalledOnce() + expect(memberManagementMock.inviteManagedMember).toHaveBeenCalledWith( + 'invitee@example.com', + 'a'.repeat(121), + [{ teamId: 'team-1', role: 'member' }], + 'good-token' + ) }) it('preserves a short name after trimming an overlong raw padded value', async () => { @@ -270,13 +278,13 @@ describe('routes/members', () => { expect(memberManagementMock.inviteManagedMember).toHaveBeenCalledWith( 'invitee@example.com', - 'Alice', + `${' '.repeat(121)}Alice`, [{ teamId: 'team-1', role: 'member' }], 'good-token' ) }) - it('uses a stable overlength projection after trimming the complete name', async () => { + it('forwards the original overlength name instead of fabricating a sentinel', async () => { authTokenMock.verifyToken.mockReturnValueOnce(claims) memberManagementMock.inviteManagedMember.mockRejectedValueOnce( new ControlApiError('invalid invitation name', 400, { error: 'invalid_name' }) @@ -293,8 +301,7 @@ describe('routes/members', () => { .expect(400, { error: 'Name is too long' }) const [, forwardedName] = memberManagementMock.inviteManagedMember.mock.calls[0] - expect(forwardedName).toHaveLength(121) - expect(forwardedName).not.toMatch(/\s/) + expect(forwardedName).toBe(`${' '.repeat(10)}${'a'.repeat(121)}`) }) it('preserves the public team-count error after authoritative validation', async () => { @@ -315,7 +322,16 @@ describe('routes/members', () => { }) .expect(400, { error: 'Too many teams selected' }) - expect(memberManagementMock.inviteManagedMember).toHaveBeenCalledOnce() + const originalTeams = Array.from({ length: 51 }, (_, index) => ({ + teamId: `team-${index}`, + role: 'member', + })) + expect(memberManagementMock.inviteManagedMember).toHaveBeenCalledWith( + 'invitee@example.com', + undefined, + originalTeams, + 'good-token' + ) }) it('forwards a governed member-retirement reason, replay key, and correlation id', async () => { From 387db2ea02382071efa2a2dd52e0e6fe2bd55e62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 28 Aug 2026 09:42:24 -0600 Subject: [PATCH 140/528] test(external-rest): r12-b3 preserve invitation wire contract Class-check: invitation wire expectations -> 2 stale sites -> original candidate contract Repro-test: full External REST suite fails at 8dd209aa for stale wire expectations --- external-rest-api/package-lock.json | 4 ++-- external-rest-api/package.json | 2 +- .../test/routes.membersInvitationContract.test.ts | 5 ++--- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index ff88a88560..1831f85dfc 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index d8dc7498ab..a2eb923322 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/test/routes.membersInvitationContract.test.ts b/external-rest-api/test/routes.membersInvitationContract.test.ts index 57bf7c235f..8eef8c54d5 100644 --- a/external-rest-api/test/routes.membersInvitationContract.test.ts +++ b/external-rest-api/test/routes.membersInvitationContract.test.ts @@ -141,7 +141,6 @@ describe('member invitation cross-service error contract', () => { path: '/external/members/invitations', body: { email: 'not-an-email', - name: '', teams: [], }, }, @@ -194,7 +193,7 @@ describe('member invitation cross-service error contract', () => { } ) - it('preserves padded-name normalization across the Control API wire boundary', async () => { + it('preserves the padded-name candidate across the Control API wire boundary', async () => { producer.respondWith({ status: 201, body: { id: 'inv-padded' } }) await request(makeExternalApp()) @@ -213,7 +212,7 @@ describe('member invitation cross-service error contract', () => { path: '/external/members/invitations', body: { email: 'invitee@example.com', - name: 'Alice', + name: `${' '.repeat(121)}Alice`, teams: [{ teamId: 'team-1', role: 'member' }], }, }, From a89e244be0096a689188568bb09380d245a172e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 28 Aug 2026 11:53:53 -0600 Subject: [PATCH 141/528] test(control-api): r12-h1 cover legacy invitation authority Class-check: directory authority -> 7 mutations / 8 mounted routes -> all covered Repro-test: final-authority PostgreSQL matrix mutation fails at 4a7eecae --- ...Authority.realPostgres.integration.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts b/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts index 5be39b760c..078d0e51d3 100644 --- a/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts +++ b/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts @@ -317,6 +317,28 @@ describeRealPostgres('external team mutations final source authority on real Pos expect(result.rowCount).toBe(0) }, }, + { + name: 'legacy team invitation creation', + invoke: server => + request(server).post(`/external/teams/${teamId}/invitations`).send({ + email: invitationEmail, + name: 'Invitee', + role: 'member', + }), + assertMutation: async () => { + const result = await databasePool.query<{ status: string }>( + `SELECT status FROM invitations WHERE email = $1`, + [invitationEmail] + ) + expect(result.rows[0]?.status).toBe('pending') + }, + assertNoMutation: async () => { + const result = await databasePool.query(`SELECT 1 FROM invitations WHERE email = $1`, [ + invitationEmail, + ]) + expect(result.rowCount).toBe(0) + }, + }, { name: 'managed invitation resend', prepare: prepareInvitation, From 83916c1c9b8309315654377dc21a9a8e2a625516 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 28 Aug 2026 12:00:11 -0600 Subject: [PATCH 142/528] fix(external-rest-api): r12-h2 preserve member registration recovery Class-check: member-registration recovery -> 2 producer codes / 1 relay / 2 UI consumers -> preserved Repro-test: profile UI public-error compatibility fails at 12cf0336 --- ....externalMembersInvitationContract.test.ts | 29 ++++ control-ui/lib/api.ts | 8 +- external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- external-rest-api/src/http/publicApiError.ts | 12 +- .../profileUiPublicErrorCompatibility.test.ts | 164 +++++++++++++++--- profile-ui/lib/api.ts | 8 +- .../member-registration-public-errors.json | 24 +++ 8 files changed, 207 insertions(+), 44 deletions(-) create mode 100644 tests/contracts/member-registration-public-errors.json diff --git a/control-api/test/routes.externalMembersInvitationContract.test.ts b/control-api/test/routes.externalMembersInvitationContract.test.ts index e34ac45a1b..a44f67e01d 100644 --- a/control-api/test/routes.externalMembersInvitationContract.test.ts +++ b/control-api/test/routes.externalMembersInvitationContract.test.ts @@ -3,6 +3,11 @@ import express from 'express' import { readFileSync } from 'node:fs' import request from 'supertest' import { createExternalMembersRouter } from '../src/routes/external/members.js' +import { + MemberRegistrationMisconfiguredError, + MemberRegistrationUnavailableError, + memberRegistrationErrorResponse, +} from '../src/services/memberRegistrationErrors.js' const invitationErrorContract = JSON.parse( readFileSync( @@ -11,6 +16,13 @@ const invitationErrorContract = JSON.parse( ) ) as Record +const memberRegistrationErrorContract = JSON.parse( + readFileSync( + new URL('../../tests/contracts/member-registration-public-errors.json', import.meta.url), + 'utf8' + ) +) as Record + const directoryMock = vi.hoisted(() => ({ createManagedInvitationForUser: vi.fn(), deleteManagedMemberForUser: vi.fn(), @@ -111,4 +123,21 @@ describe('external member invitation producer contract', () => { 10 ) }) + + it.each([ + { + contract: 'unavailable', + error: new MemberRegistrationUnavailableError(), + }, + { + contract: 'misconfigured', + error: new MemberRegistrationMisconfiguredError('private configuration detail'), + }, + ])('emits the shared $contract member-registration failure', ({ contract, error }) => { + const produced = memberRegistrationErrorResponse(error) + expect(produced).not.toBeNull() + expect({ status: produced!.status, body: { error: produced!.error } }).toEqual( + memberRegistrationErrorContract[contract].control + ) + }) }) diff --git a/control-ui/lib/api.ts b/control-ui/lib/api.ts index ad4751537c..9987e4d8ac 100644 --- a/control-ui/lib/api.ts +++ b/control-ui/lib/api.ts @@ -183,11 +183,9 @@ export function formatApiError(res: Response, text: string): Error { ? 'Agent access could not be updated because the page did not include its current access state. Reload the page and try again.' : detail === 'deleted_agent_history_limit_exceeded' ? 'Agent access was not updated because the deleted-agent history limit was reached. No existing history was removed. Reload the page, review the current access, and try again.' - : // Exact match is correct HERE: control-ui calls control-api directly, - // so a member-registration 503 arrives as the bare { error: '' } - // body. profile-ui reaches these same codes through external-rest-api, - // whose error middleware wraps any 5xx into { message: '...: ' }, - // so it must use .includes() instead — the two matchers differ on purpose. + : // Control UI calls Control API directly, so the legacy bare string remains + // its member-registration code source. Profile UI consumes the same safe + // codes from External REST's canonical nested public envelope. detail === 'member_registration_unavailable' ? "Invitations are unavailable — the member-registration service isn't configured or can't be reached. Check the server logs for details." : detail === 'member_registration_misconfigured' diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 1831f85dfc..ff88a88560 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index a2eb923322..d8dc7498ab 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/http/publicApiError.ts b/external-rest-api/src/http/publicApiError.ts index 366a454653..2fc664a128 100644 --- a/external-rest-api/src/http/publicApiError.ts +++ b/external-rest-api/src/http/publicApiError.ts @@ -42,6 +42,8 @@ const PUBLIC_MESSAGE_BY_CODE: Readonly> = { internal_error: 'The request could not be completed.', upstream_unavailable: 'The upstream service is temporarily unavailable.', authority_unavailable: 'Authorization is temporarily unavailable.', + member_registration_unavailable: 'Member registration is temporarily unavailable.', + member_registration_misconfigured: 'Member registration is not configured correctly.', upstream_timeout: 'The upstream service timed out.', insufficient_storage: 'The requested operation cannot be completed because storage is full.', } @@ -62,7 +64,11 @@ const ALLOWED_CODES_BY_STATUS: Readonly>> = { 429: new Set(['rate_limited']), 500: new Set(['internal_error']), 502: new Set(['upstream_unavailable']), - 503: new Set(['authority_unavailable']), + 503: new Set([ + 'authority_unavailable', + 'member_registration_unavailable', + 'member_registration_misconfigured', + ]), 504: new Set(['upstream_timeout']), 507: new Set(['insufficient_storage']), } @@ -154,7 +160,9 @@ export function sanitizeControlApiPublicError( const suppliedCode = rawEnvelope && typeof (rawEnvelope as { code?: unknown }).code === 'string' ? String((rawEnvelope as { code: string }).code) - : '' + : error.status === 503 && typeof rawError === 'string' + ? rawError + : '' const fallbackCode = PUBLIC_CODE_BY_STATUS[error.status] || 'internal_error' const code = ALLOWED_CODES_BY_STATUS[error.status]?.has(suppliedCode) ? suppliedCode diff --git a/external-rest-api/test/profileUiPublicErrorCompatibility.test.ts b/external-rest-api/test/profileUiPublicErrorCompatibility.test.ts index 3c5f856dbb..d9761eace9 100644 --- a/external-rest-api/test/profileUiPublicErrorCompatibility.test.ts +++ b/external-rest-api/test/profileUiPublicErrorCompatibility.test.ts @@ -1,38 +1,146 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import { readFileSync } from 'node:fs' +import { type Server, createServer } from 'node:http' +import type { AddressInfo } from 'node:net' +import request from 'supertest' import { apiSend } from '../../profile-ui/lib/api.js' -import { ControlApiError } from '../src/controlApiClient.js' -import { sanitizeControlApiPublicError } from '../src/http/publicApiError.js' +import { externalRestPublicErrorHandler } from '../src/app.js' +import { config } from '../src/config.js' +import { controlApiRequest } from '../src/controlApiClient.js' -afterEach(() => { - vi.unstubAllGlobals() -}) +type MemberRegistrationErrorContract = Record< + string, + { + control: { status: number; body: { error: string } } + public: { code: string; message: string } + profileMessage: string + } +> + +const contract = JSON.parse( + readFileSync( + new URL('../../tests/contracts/member-registration-public-errors.json', import.meta.url), + 'utf8' + ) +) as MemberRegistrationErrorContract + +type ProducerResponse = { status: number; body: Record } + +function createControlApiWireProducer() { + let nextResponse: ProducerResponse | undefined + const server = createServer((_req, res) => { + if (!nextResponse) { + res.statusCode = 500 + res.end(JSON.stringify({ error: 'missing_test_response' })) + return + } + res.statusCode = nextResponse.status + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify(nextResponse.body)) + nextResponse = undefined + }) + return { + server, + respondWith(response: ProducerResponse) { + nextResponse = response + }, + reset() { + nextResponse = undefined + }, + } +} + +function createRelayApp() { + const app = express() + app.post('/relay', async (_req, res, next) => { + try { + await controlApiRequest('POST', '/external/members/invitations') + res.status(204).end() + } catch (error) { + next(error) + } + }) + app.use(externalRestPublicErrorHandler) + return app +} describe('Profile UI public error compatibility', () => { - it('renders the safe typed External REST message without object stringification', async () => { - const publicError = sanitizeControlApiPublicError( - new ControlApiError('private upstream failure', 503, { - error: { code: 'authority_unavailable', message: 'private upstream failure' }, - }), - new Set([503]) - ) - expect(publicError).not.toBeNull() - vi.stubGlobal( - 'fetch', - vi.fn().mockImplementation( - async () => - new Response(JSON.stringify(publicError!.body), { - status: 503, + const originalControlApiBaseUrl = config.controlApiBaseUrl + const producer = createControlApiWireProducer() + let server: Server + + beforeAll(async () => { + server = producer.server + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.off('error', reject) + resolve() + }) + }) + const address = server.address() as AddressInfo + config.controlApiBaseUrl = `http://127.0.0.1:${address.port}` + }) + + beforeEach(() => { + producer.reset() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + afterAll(async () => { + config.controlApiBaseUrl = originalControlApiBaseUrl + await new Promise((resolve, reject) => { + server.close(error => (error ? reject(error) : resolve())) + }) + }) + + it.each(Object.entries(contract))( + 'preserves the safe %s producer reason through External REST and Profile UI', + async (_name, expected) => { + producer.respondWith(expected.control) + const relayed = await request(createRelayApp()).post('/relay') + + expect(relayed.status).toBe(expected.control.status) + expect(relayed.body.error).toMatchObject({ + code: expected.public.code, + message: expected.public.message, + retryable: true, + }) + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify(relayed.body), { + status: relayed.status, statusText: 'Service Unavailable', headers: { 'content-type': 'application/json' }, }) + ) ) - ) - - await expect(apiSend('POST', '/api/v1/members/invite', {})).rejects.toThrow( - '503 Service Unavailable - Authorization is temporarily unavailable.' - ) - await expect(apiSend('POST', '/api/v1/members/invite', {})).rejects.not.toThrow( - /\[object Object\]|private upstream failure/ - ) + + await expect(apiSend('POST', '/api/v1/members/invite', {})).rejects.toThrow( + expected.profileMessage + ) + expect(JSON.stringify(relayed.body)).not.toContain('private') + } + ) + + it('keeps an unapproved upstream 503 generic across both public boundaries', async () => { + producer.respondWith({ + status: 503, + body: { error: 'private_database_failure', detail: 'private topology' }, + }) + const relayed = await request(createRelayApp()).post('/relay') + expect(relayed.status).toBe(503) + expect(relayed.body.error).toMatchObject({ + code: 'authority_unavailable', + message: 'Authorization is temporarily unavailable.', + retryable: true, + }) + expect(JSON.stringify(relayed.body)).not.toMatch(/private_database_failure|private topology/) }) }) diff --git a/profile-ui/lib/api.ts b/profile-ui/lib/api.ts index 86e51c60e2..d4bb04ff28 100644 --- a/profile-ui/lib/api.ts +++ b/profile-ui/lib/api.ts @@ -154,16 +154,12 @@ export async function apiSend( if (res.status === 401) handleUnauthorized() const publicError = publicErrorFields(text) const detail = publicError.detail || res.statusText - // Substring match is REQUIRED here (not === like control-ui): profile-ui reaches - // control-api through external-rest-api, whose error middleware collapses any 5xx - // into { message: "Control API ... failed (503): " }, so the code arrives - // embedded in a wrapper string rather than as the bare { error: '' } body. - if (detail.includes('member_registration_unavailable')) { + if (publicError.code === 'member_registration_unavailable') { throw new Error( "Invitations are unavailable — the member-registration service isn't configured or can't be reached. Check the server logs for details." ) } - if (detail.includes('member_registration_misconfigured')) { + if (publicError.code === 'member_registration_misconfigured') { throw new Error( 'Invitations are unavailable — member registration is misconfigured. Check the server logs for details.' ) diff --git a/tests/contracts/member-registration-public-errors.json b/tests/contracts/member-registration-public-errors.json new file mode 100644 index 0000000000..9b11cf1d65 --- /dev/null +++ b/tests/contracts/member-registration-public-errors.json @@ -0,0 +1,24 @@ +{ + "unavailable": { + "control": { + "status": 503, + "body": { "error": "member_registration_unavailable" } + }, + "public": { + "code": "member_registration_unavailable", + "message": "Member registration is temporarily unavailable." + }, + "profileMessage": "Invitations are unavailable — the member-registration service isn't configured or can't be reached. Check the server logs for details." + }, + "misconfigured": { + "control": { + "status": 503, + "body": { "error": "member_registration_misconfigured" } + }, + "public": { + "code": "member_registration_misconfigured", + "message": "Member registration is not configured correctly." + }, + "profileMessage": "Invitations are unavailable — member registration is misconfigured. Check the server logs for details." + } +} From af005358999df1567661c4b53e251078d4492971 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 28 Aug 2026 12:13:23 -0600 Subject: [PATCH 143/528] fix(external-rest-api): r13-b1 preserve request correlation on 503 Class-check: propagated 503 public errors -> 3 relay cases -> preserve safe request correlation Repro-test: all producer-backed 503 correlation assertions fail at 1e8536ff --- external-rest-api/package-lock.json | 4 ++-- external-rest-api/package.json | 2 +- external-rest-api/src/http/publicApiError.ts | 8 ++++++-- .../test/profileUiPublicErrorCompatibility.test.ts | 11 +++++++++-- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index ff88a88560..1831f85dfc 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index d8dc7498ab..a2eb923322 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/http/publicApiError.ts b/external-rest-api/src/http/publicApiError.ts index 2fc664a128..746daa4103 100644 --- a/external-rest-api/src/http/publicApiError.ts +++ b/external-rest-api/src/http/publicApiError.ts @@ -147,7 +147,8 @@ function safePathDescriptors(value: unknown): PublicPathDescriptor[] | undefined export function sanitizeControlApiPublicError( error: unknown, - propagatedStatuses: ReadonlySet + propagatedStatuses: ReadonlySet, + fallbackCorrelationId = '' ): SanitizedControlApiPublicError | null { if (!(error instanceof ControlApiError) || !propagatedStatuses.has(error.status)) return null const rawBody = @@ -171,9 +172,12 @@ export function sanitizeControlApiPublicError( rawEnvelope && typeof (rawEnvelope as { correlationId?: unknown }).correlationId === 'string' ? String((rawEnvelope as { correlationId: string }).correlationId) : '' + const safeFallbackCorrelationId = /^[A-Za-z0-9_-]{1,128}$/.test(fallbackCorrelationId) + ? fallbackCorrelationId + : '' const correlationId = /^[A-Za-z0-9_-]{1,128}$/.test(rawCorrelationId) ? rawCorrelationId - : randomUUID() + : safeFallbackCorrelationId || randomUUID() const retryable = [408, 425, 429, 502, 503, 504].includes(error.status) const rawDetails = rawEnvelope && typeof (rawEnvelope as { details?: unknown }).details === 'object' diff --git a/external-rest-api/test/profileUiPublicErrorCompatibility.test.ts b/external-rest-api/test/profileUiPublicErrorCompatibility.test.ts index d9761eace9..8610333664 100644 --- a/external-rest-api/test/profileUiPublicErrorCompatibility.test.ts +++ b/external-rest-api/test/profileUiPublicErrorCompatibility.test.ts @@ -66,6 +66,7 @@ function createRelayApp() { } describe('Profile UI public error compatibility', () => { + const requestCorrelationId = 'caller-correlation-123' const originalControlApiBaseUrl = config.controlApiBaseUrl const producer = createControlApiWireProducer() let server: Server @@ -102,12 +103,15 @@ describe('Profile UI public error compatibility', () => { 'preserves the safe %s producer reason through External REST and Profile UI', async (_name, expected) => { producer.respondWith(expected.control) - const relayed = await request(createRelayApp()).post('/relay') + const relayed = await request(createRelayApp()) + .post('/relay') + .set('x-correlation-id', requestCorrelationId) expect(relayed.status).toBe(expected.control.status) expect(relayed.body.error).toMatchObject({ code: expected.public.code, message: expected.public.message, + correlationId: requestCorrelationId, retryable: true, }) @@ -134,11 +138,14 @@ describe('Profile UI public error compatibility', () => { status: 503, body: { error: 'private_database_failure', detail: 'private topology' }, }) - const relayed = await request(createRelayApp()).post('/relay') + const relayed = await request(createRelayApp()) + .post('/relay') + .set('x-correlation-id', requestCorrelationId) expect(relayed.status).toBe(503) expect(relayed.body.error).toMatchObject({ code: 'authority_unavailable', message: 'Authorization is temporarily unavailable.', + correlationId: requestCorrelationId, retryable: true, }) expect(JSON.stringify(relayed.body)).not.toMatch(/private_database_failure|private topology/) From 7b743dd69ad99acdffd369ce3bbb381031273650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 28 Aug 2026 12:17:05 -0600 Subject: [PATCH 144/528] test(external-rest-api): r13-b2 prove correlation fallback bounds Class-check: public correlation fallback -> precedence / invalid input / 429 sibling -> guarded Repro-test: guard matrix fails at 1e8536ff and targeted 858ec766 mutations --- external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- .../profileUiPublicErrorCompatibility.test.ts | 62 +++++++++++++++++++ 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 1831f85dfc..ff88a88560 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index a2eb923322..d8dc7498ab 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/test/profileUiPublicErrorCompatibility.test.ts b/external-rest-api/test/profileUiPublicErrorCompatibility.test.ts index 8610333664..46347eb202 100644 --- a/external-rest-api/test/profileUiPublicErrorCompatibility.test.ts +++ b/external-rest-api/test/profileUiPublicErrorCompatibility.test.ts @@ -150,4 +150,66 @@ describe('Profile UI public error compatibility', () => { }) expect(JSON.stringify(relayed.body)).not.toMatch(/private_database_failure|private topology/) }) + + it('keeps a valid upstream correlation ahead of the request fallback', async () => { + producer.respondWith({ + status: 503, + body: { + error: { + code: 'authority_unavailable', + message: 'private upstream message', + correlationId: 'upstream-correlation-456', + }, + }, + }) + const relayed = await request(createRelayApp()) + .post('/relay') + .set('x-correlation-id', requestCorrelationId) + + expect(relayed.body.error.correlationId).toBe('upstream-correlation-456') + expect(JSON.stringify(relayed.body)).not.toContain('private upstream message') + }) + + it('uses the valid request fallback when the upstream correlation is unsafe', async () => { + producer.respondWith({ + status: 503, + body: { + error: { + code: 'authority_unavailable', + correlationId: 'unsafe/upstream-correlation', + }, + }, + }) + const relayed = await request(createRelayApp()) + .post('/relay') + .set('x-correlation-id', requestCorrelationId) + + expect(relayed.body.error.correlationId).toBe(requestCorrelationId) + expect(JSON.stringify(relayed.body)).not.toContain('unsafe/upstream-correlation') + }) + + it('replaces an oversized request correlation with a bounded generated identifier', async () => { + const oversizedCorrelationId = 'x'.repeat(129) + producer.respondWith({ status: 503, body: { error: 'member_registration_unavailable' } }) + const relayed = await request(createRelayApp()) + .post('/relay') + .set('x-correlation-id', oversizedCorrelationId) + + expect(relayed.body.error.correlationId).toMatch(/^[A-Za-z0-9_-]{1,128}$/) + expect(relayed.body.error.correlationId).not.toBe(oversizedCorrelationId) + }) + + it('preserves the request correlation for the propagated 429 sibling', async () => { + producer.respondWith({ status: 429, body: { error: { code: 'rate_limited' } } }) + const relayed = await request(createRelayApp()) + .post('/relay') + .set('x-correlation-id', requestCorrelationId) + + expect(relayed.status).toBe(429) + expect(relayed.body.error).toMatchObject({ + code: 'rate_limited', + correlationId: requestCorrelationId, + retryable: true, + }) + }) }) From dcc6b33e4123633e7994ac405ee29146b0c04c9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 28 Aug 2026 15:23:39 -0600 Subject: [PATCH 145/528] fix(control-api): r15-h1 revalidate invitation activation authority Class-check: invitation activation -> 2 activation and 2 resend phases -> managed activation fixed Repro-test: real PostgreSQL race fails at b88d532931eebe2364eda972ced6b6b84348b1d2 with 201, not 403 --- ...Authority.realPostgres.integration.test.ts | 64 ++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts b/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts index 078d0e51d3..9b87968fbc 100644 --- a/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts +++ b/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts @@ -7,7 +7,10 @@ import { type DbClient, initDb } from '../src/db.js' import { createExternalMembersRouter } from '../src/routes/external/members.js' import { createExternalTeamsRouter } from '../src/routes/external/teams.js' import { issueExternalUserSession } from '../src/services/auth/externalSessionIssuance.js' -import { createManagedInvitationForUser } from '../src/services/directory/index.js' +import { + acceptInvitationForEmail, + createManagedInvitationForUser, +} from '../src/services/directory/index.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -450,6 +453,65 @@ describeRealPostgres('external team mutations final source authority on real Pos }) }) + it('denies managed invitation activation when source-session invalidation wins during delivery', async () => { + await resetPrincipal('managed-invitation-post-delivery-revoke') + + let markDeliveryStarted!: () => void + const deliveryStarted = new Promise(resolve => { + markDeliveryStarted = resolve + }) + let releaseDelivery!: () => void + const deliveryGate = new Promise(resolve => { + releaseDelivery = resolve + }) + invitationDelivery.registerAndSendInvitation.mockImplementationOnce(async () => { + markDeliveryStarted() + await deliveryGate + }) + + const pending = request(app()) + .post('/external/members/invitations') + .set('x-user-session-token', token) + .send({ + email: invitationEmail, + name: 'Invitee', + teams: [{ teamId, role: 'member' }], + }) + .then(response => response) + + await deliveryStarted + const draft = await databasePool.query<{ id: string; status: string; token: string }>( + `SELECT id::text AS id, status, token + FROM invitations + WHERE email = $1`, + [invitationEmail] + ) + expect(draft.rows[0]?.status).toBe('draft') + + await databasePool.query( + `INSERT INTO external_user_session_security_epochs(user_id, valid_after, reason) + VALUES ($1, NOW() + INTERVAL '1 second', 'test') + ON CONFLICT (user_id) DO UPDATE + SET valid_after = EXCLUDED.valid_after, reason = EXCLUDED.reason`, + [adminUserId] + ) + releaseDelivery() + + const response = await pending + expect(response.status).toBe(403) + const finalState = await databasePool.query<{ status: string; command_status: string }>( + `SELECT i.status, c.status AS command_status + FROM invitations i + JOIN invitation_delivery_commands c ON c.invitation_id = i.id + WHERE i.id = $1`, + [draft.rows[0]!.id] + ) + expect(finalState.rows[0]).toEqual({ status: 'revoked', command_status: 'cancelled' }) + await expect( + acceptInvitationForEmail(invitationEmail, draft.rows[0]!.token, draft.rows[0]!.id) + ).resolves.toEqual({ error: 'not_pending' }) + }) + for (const mutation of mutations()) { it(`allows ${mutation.name} before later retirement or revoke-all`, async () => { await resetPrincipal(`wins-${mutation.name.replaceAll(' ', '-')}`) From b492f17ccea44b5e7956856467f16636f14513db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 28 Aug 2026 15:29:01 -0600 Subject: [PATCH 146/528] fix(control-api): r15-h2 preserve capability-independent path identity Class-check: authorization/path identity construction -> 2 production sites -> complete seeds first Repro-test: accessLiveAuthorization.test.ts fails at b88d532931eebe2364eda972ced6b6b84348b1d2 with access_path_stale --- .../access/accessCatalogCoordinator.ts | 11 ++- .../access/liveAuthorizationResolver.ts | 7 +- ...gFamilies.realPostgres.integration.test.ts | 45 +++++++++++- .../test/accessLiveAuthorization.test.ts | 70 +++++++++++++++++++ 4 files changed, 125 insertions(+), 8 deletions(-) diff --git a/control-api/src/services/access/accessCatalogCoordinator.ts b/control-api/src/services/access/accessCatalogCoordinator.ts index fad368ebb1..c280848d2c 100644 --- a/control-api/src/services/access/accessCatalogCoordinator.ts +++ b/control-api/src/services/access/accessCatalogCoordinator.ts @@ -18,7 +18,11 @@ import { import { withAccessDatabaseTransaction } from './accessDatabaseQuery.js' import { AccessExecutionBudget, type AccessExecutionLimits } from './accessExecutionBudget.js' import { buildAccessPath, canonicalAccessPathTuple } from './accessPath.js' -import { authorizationRevision, revisionOfValues } from './authorizationRevision.js' +import { + authorizationRevision, + canonicalAccessPathSeeds, + revisionOfValues, +} from './authorizationRevision.js' import { normalizeAccessCapabilities } from './capabilityRegistry.js' import { CatalogAuthorityError, @@ -151,6 +155,7 @@ function catalogItem( context: CatalogRequestContext, hydrated: HydratedCatalogResource ): AccessCatalogItem { + const identitySeeds = canonicalAccessPathSeeds(hydrated.accessPaths) const revision = authorizationRevision({ principalUserId: context.principal.userId, sessionContract: context.principal.sessionContract, @@ -161,9 +166,9 @@ function catalogItem( resourceRevision: hydrated.authorizationResourceRevision, sourceStateRevision: hydrated.authorizationSourceRevision, relationshipsRevision: hydrated.authorizationRelationshipsRevision, - candidates: hydrated.accessPaths, + candidates: identitySeeds, }) - const paths = hydrated.accessPaths + const paths = identitySeeds .map(seed => buildAccessPath({ principalUserId: context.principal.userId, diff --git a/control-api/src/services/access/liveAuthorizationResolver.ts b/control-api/src/services/access/liveAuthorizationResolver.ts index e02ef964b2..dbef639495 100644 --- a/control-api/src/services/access/liveAuthorizationResolver.ts +++ b/control-api/src/services/access/liveAuthorizationResolver.ts @@ -224,8 +224,9 @@ async function resolveInTransaction(input: { capability: input.capability, operationTarget: input.operationTarget, }) - const capable = canonicalAccessPathSeeds( - targeted.filter(candidate => candidate.behavior.capabilities.includes(input.capability)) + const identitySeeds = canonicalAccessPathSeeds(targeted) + const capable = Object.freeze( + identitySeeds.filter(candidate => candidate.behavior.capabilities.includes(input.capability)) ) if (capable.length === 0) return { status: 'denied', code: 'forbidden' } input.budget.charge({ kind: 'accessPaths', amount: capable.length }) @@ -244,7 +245,7 @@ async function resolveInTransaction(input: { graph?.status === 'current' ? graph.relationshipsRevision : databaseRelationshipsRevision(authority.relationships), - candidates: capable, + candidates: identitySeeds, }) const paths = Object.freeze( capable.map(seed => diff --git a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts index b802ebcd49..0b1d86b392 100644 --- a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { randomBytes, randomUUID } from 'node:crypto' import { Pool, type PoolClient } from 'pg' import { config } from '../src/config.js' @@ -26,6 +26,15 @@ import { import type { ExternalSessionAuthorityContext } from '../src/services/auth/externalSessionAuthentication.js' import { TemporaryKubernetesApi } from './helpers/temporaryKubernetesApi.js' +vi.mock('../src/services/access/operationTarget.js', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + validateOperationTarget: (input: Parameters[0]) => + input.capability === 'gfs.write' ? null : actual.validateOperationTarget(input), + } +}) + const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip const runtimeRoles = [ @@ -300,7 +309,7 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { await databasePool.query( `INSERT INTO gfs_grants(drive, resource_id, subject_type, subject_id, permissions) VALUES - ('catalog-drive', $1, 'user', $2::text, ARRAY['read']::text[]), + ('catalog-drive', $1, 'user', $2::text, ARRAY['read', 'write']::text[]), ('catalog-drive', $1, 'team', $3::text, ARRAY['read']::text[])`, [gfsResourceId, userId, teamId] ) @@ -427,6 +436,38 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { }) } + it('round-trips a catalog write path when capability selection excludes a read-only sibling', async () => { + const catalog = await buildAccessCatalog( + { session, families: ['gfs_resource'], limit: 10 }, + { + transaction: transaction(databasePool), + teamGfsMembershipAdmissionLimit: 1, + } + ) + const item = catalog.items[0]! + const directWritePath = item.accessPaths.find( + path => path.kind === 'direct' && path.capabilities.includes('gfs.write') + ) + expect(directWritePath).toBeDefined() + + const resolved = await resolveLiveAuthorization( + { + session, + requiredCapability: 'gfs.write', + resource: canonicalResourceIdentity(item.resource), + requestedAccessPathId: directWritePath!.accessPathId, + }, + { transaction: transaction(databasePool), gateway } + ) + + expect(resolved).toEqual( + expect.objectContaining({ + status: 'allowed', + selectedPath: expect.objectContaining({ id: directWritePath!.accessPathId }), + }) + ) + }) + it('enforces the operator-configured Team-GFS admission through the production budget contract', async () => { const intent = loadConfiguredUserAccessIntent({ CONTROL_API_USER_ACCESS_CATALOG_MODE: 'serve', diff --git a/control-api/test/accessLiveAuthorization.test.ts b/control-api/test/accessLiveAuthorization.test.ts index dcf03cffb6..899ba57ea8 100644 --- a/control-api/test/accessLiveAuthorization.test.ts +++ b/control-api/test/accessLiveAuthorization.test.ts @@ -16,6 +16,15 @@ import { import { canonicalEnvironmentId } from '../src/services/access/operationalAccessProjection.js' import { canonicalResourceIdentity } from '../src/services/access/resourceIdentity.js' +vi.mock('../src/services/access/operationTarget.js', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + validateOperationTarget: (input: Parameters[0]) => + input.capability === 'gfs.write' ? null : actual.validateOperationTarget(input), + } +}) + const environmentId = canonicalEnvironmentId() const userId = '10000000-0000-4000-8000-000000000001' const teamId = '20000000-0000-4000-8000-000000000002' @@ -306,6 +315,67 @@ describe('live user-access resolution', () => { ) }) + it('preserves a path identity when capability selection filters a sibling path', async () => { + const options = { + memberships: [ + { + teamId, + role: 'member', + membershipUpdatedAt: '2026-08-10T00:00:00.000Z', + teamRevision: '1', + }, + ], + hostGrantRows: [ + { + kind: 'direct', + grant_id: 'gfs_grants:direct', + team_id: null, + current_role: null, + permissions: ['read', 'write'], + drive: '3rd', + }, + { + kind: 'team', + grant_id: 'gfs_grants:team', + team_id: teamId, + current_role: 'member', + permissions: ['read'], + drive: '3rd', + }, + ], + } + const catalogEquivalent = await resolveLiveAuthorization(gfsRequest(), { + transaction: fakeTransaction(options).transaction, + }) + expect(catalogEquivalent.status).toBe('access_path_required') + if (catalogEquivalent.status !== 'access_path_required') return + const directPath = catalogEquivalent.safePathDescriptors.find(path => path.kind === 'direct') + expect(directPath).toBeDefined() + + const write = await resolveLiveAuthorization( + gfsRequest({ + requiredCapability: 'gfs.write', + requestedAccessPathId: directPath!.id, + }), + { transaction: fakeTransaction(options).transaction } + ) + + expect(write).toEqual( + expect.objectContaining({ + status: 'allowed', + selectedPath: expect.objectContaining({ id: directPath!.id, kind: 'direct' }), + }) + ) + + const unsupportedDb = fakeTransaction(options) + await expect( + resolveLiveAuthorization(gfsRequest({ requiredCapability: 'gfs.future.unsupported' }), { + transaction: unsupportedDb.transaction, + }) + ).resolves.toEqual({ status: 'denied', code: 'unknown_capability' }) + expect(unsupportedDb.query).not.toHaveBeenCalled() + }) + it('revalidates the exact provider incarnation for a selected operational path', async () => { const currentObject = { metadata: { From 6f84fc71a02e3d84b41010e09514595e829c1203 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 28 Aug 2026 15:31:47 -0600 Subject: [PATCH 147/528] fix(control-api): r15-b2 charge complete path identity seeds Class-check: live identity-seed budget charging -> 1 resolver seam -> charge complete target set Repro-test: accessLiveAuthorization.test.ts fails at 32a6d691c4f9bc1ee11b4487e0b7c836330e6cae by allowing a two-seed set under limit one --- .../access/liveAuthorizationResolver.ts | 2 +- .../test/accessLiveAuthorization.test.ts | 47 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/control-api/src/services/access/liveAuthorizationResolver.ts b/control-api/src/services/access/liveAuthorizationResolver.ts index dbef639495..56026587bf 100644 --- a/control-api/src/services/access/liveAuthorizationResolver.ts +++ b/control-api/src/services/access/liveAuthorizationResolver.ts @@ -225,11 +225,11 @@ async function resolveInTransaction(input: { operationTarget: input.operationTarget, }) const identitySeeds = canonicalAccessPathSeeds(targeted) + input.budget.charge({ kind: 'accessPaths', amount: identitySeeds.length }) const capable = Object.freeze( identitySeeds.filter(candidate => candidate.behavior.capabilities.includes(input.capability)) ) if (capable.length === 0) return { status: 'denied', code: 'forbidden' } - input.budget.charge({ kind: 'accessPaths', amount: capable.length }) const revision = authorizationRevision({ principalUserId: snapshot.userId, diff --git a/control-api/test/accessLiveAuthorization.test.ts b/control-api/test/accessLiveAuthorization.test.ts index 899ba57ea8..82af56575b 100644 --- a/control-api/test/accessLiveAuthorization.test.ts +++ b/control-api/test/accessLiveAuthorization.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { DbClient } from '../src/db.js' +import { AccessExecutionBudget } from '../src/services/access/accessExecutionBudget.js' import { type AccessPathBehavior, type AccessPathSeed, @@ -376,6 +377,52 @@ describe('live user-access resolution', () => { expect(unsupportedDb.query).not.toHaveBeenCalled() }) + it('charges the complete identity seed set before capability selection', async () => { + const db = fakeTransaction({ + memberships: [ + { + teamId, + role: 'member', + membershipUpdatedAt: '2026-08-10T00:00:00.000Z', + teamRevision: '1', + }, + ], + hostGrantRows: [ + { + kind: 'direct', + grant_id: 'gfs_grants:direct', + team_id: null, + current_role: null, + permissions: ['read', 'write'], + drive: '3rd', + }, + { + kind: 'team', + grant_id: 'gfs_grants:team', + team_id: teamId, + current_role: 'member', + permissions: ['read'], + drive: '3rd', + }, + ], + }) + const budget = AccessExecutionBudget.create('action', { limits: { accessPaths: 1 } }) + try { + await expect( + resolveLiveAuthorization(gfsRequest({ requiredCapability: 'gfs.write' }), { + transaction: db.transaction, + budget, + }) + ).resolves.toEqual({ + status: 'unavailable', + dependencyClass: 'capacity', + retryable: true, + }) + } finally { + budget.close() + } + }) + it('revalidates the exact provider incarnation for a selected operational path', async () => { const currentObject = { metadata: { From 73e1414f602cd2993af12c9d68ac2e85efdb1eb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 28 Aug 2026 15:33:50 -0600 Subject: [PATCH 148/528] fix(control-api): r15-b3 deny empty path identity sets before charging Class-check: identity-seed budget charging -> empty and non-empty sets -> deny empty before charge Repro-test: accessLiveAuthorization.test.ts fails at b0b7d1db with authorization_store --- control-api/src/services/access/liveAuthorizationResolver.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/control-api/src/services/access/liveAuthorizationResolver.ts b/control-api/src/services/access/liveAuthorizationResolver.ts index 56026587bf..22edab4aa7 100644 --- a/control-api/src/services/access/liveAuthorizationResolver.ts +++ b/control-api/src/services/access/liveAuthorizationResolver.ts @@ -225,6 +225,7 @@ async function resolveInTransaction(input: { operationTarget: input.operationTarget, }) const identitySeeds = canonicalAccessPathSeeds(targeted) + if (identitySeeds.length === 0) return { status: 'denied', code: 'forbidden' } input.budget.charge({ kind: 'accessPaths', amount: identitySeeds.length }) const capable = Object.freeze( identitySeeds.filter(candidate => candidate.behavior.capabilities.includes(input.capability)) From 0037875588b697ed9074b48f542bea004a881111 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 28 Aug 2026 15:36:30 -0600 Subject: [PATCH 149/528] fix(control-api): r15-m1 canonicalize access identity text bytes Class-check: ar1/ap1 identity text ordering -> 11 sites -> one unsigned UTF-8 comparator Repro-test: accessCatalogProperties.test.ts fails at d8a0a8f3 with locale order --- .../services/access/accessAuthorityStore.ts | 5 +- .../access/accessCatalogCoordinator.ts | 6 +- control-api/src/services/access/accessPath.ts | 12 ++- .../services/access/authorizationRevision.ts | 11 +-- .../src/services/access/canonicalText.ts | 4 + .../src/services/access/capabilityRegistry.ts | 3 +- .../src/services/access/catalogContracts.ts | 3 +- .../src/services/access/catalogProducers.ts | 3 +- .../access/operationalAccessProjection.ts | 3 +- .../test/accessCatalogProperties.test.ts | 82 ++++++++++++++++++- 10 files changed, 114 insertions(+), 18 deletions(-) create mode 100644 control-api/src/services/access/canonicalText.ts diff --git a/control-api/src/services/access/accessAuthorityStore.ts b/control-api/src/services/access/accessAuthorityStore.ts index 8bf5696dbc..d286c97859 100644 --- a/control-api/src/services/access/accessAuthorityStore.ts +++ b/control-api/src/services/access/accessAuthorityStore.ts @@ -10,6 +10,7 @@ import { unknownBehavior, } from './accessPath.js' import type { AuthorizationMembershipRevision } from './authorizationRevision.js' +import { compareCanonicalUtf8Text } from './canonicalText.js' import { type AccessCapability, capabilitiesForTeamRole, @@ -415,7 +416,9 @@ async function loadSimpleOperationalGrantCandidates(input: { relationship => relationship.sourceType === input.resource.type || isRecipe ) const runtimeRef = JSON.stringify( - relevantRelationships.map(relationship => relationship.relationshipInstanceId).sort() + relevantRelationships + .map(relationship => relationship.relationshipInstanceId) + .sort(compareCanonicalUtf8Text) ) const bindingType = input.resource.type === 'sandbox_app' ? 'workflow_recipe' : input.resource.type diff --git a/control-api/src/services/access/accessCatalogCoordinator.ts b/control-api/src/services/access/accessCatalogCoordinator.ts index c280848d2c..20df8bc0d6 100644 --- a/control-api/src/services/access/accessCatalogCoordinator.ts +++ b/control-api/src/services/access/accessCatalogCoordinator.ts @@ -17,7 +17,7 @@ import { } from './accessCatalogMerge.js' import { withAccessDatabaseTransaction } from './accessDatabaseQuery.js' import { AccessExecutionBudget, type AccessExecutionLimits } from './accessExecutionBudget.js' -import { buildAccessPath, canonicalAccessPathTuple } from './accessPath.js' +import { buildAccessPath, compareCanonicalAccessPaths } from './accessPath.js' import { authorizationRevision, canonicalAccessPathSeeds, @@ -177,9 +177,7 @@ function catalogItem( authorizationRevision: revision, }) ) - .sort((left, right) => - canonicalAccessPathTuple(left).localeCompare(canonicalAccessPathTuple(right)) - ) + .sort(compareCanonicalAccessPaths) const publicPaths: PublicCatalogAccessPath[] = paths.map(path => Object.freeze({ accessPathId: path.id, diff --git a/control-api/src/services/access/accessPath.ts b/control-api/src/services/access/accessPath.ts index fb4e4531b4..1830775559 100644 --- a/control-api/src/services/access/accessPath.ts +++ b/control-api/src/services/access/accessPath.ts @@ -1,6 +1,7 @@ import { createHmac, timingSafeEqual } from 'node:crypto' import { config } from '../../config.js' import type { TeamRole } from '../../profileTypes.js' +import { compareCanonicalUtf8Text } from './canonicalText.js' import { type AccessCapability, normalizeAccessCapabilities } from './capabilityRegistry.js' import { type CanonicalResourceIdentity, resourceIdentityKey } from './resourceIdentity.js' @@ -128,13 +129,18 @@ export function canonicalAccessPathTuple( ]) } +export function compareCanonicalAccessPaths( + left: Pick, + right: Pick +): number { + return compareCanonicalUtf8Text(canonicalAccessPathTuple(left), canonicalAccessPathTuple(right)) +} + export function selectEquivalentAccessPath(paths: readonly AccessPath[]): AccessPath | null { if (paths.length === 0) return null if (paths.length === 1) return paths[0] if (!paths.every(path => accessPathsAreEquivalent(paths[0], path))) return null - return [...paths].sort((left, right) => - canonicalAccessPathTuple(left).localeCompare(canonicalAccessPathTuple(right)) - )[0] + return [...paths].sort(compareCanonicalAccessPaths)[0] } export function accessPathHandleEquals(left: string, right: string): boolean { diff --git a/control-api/src/services/access/authorizationRevision.ts b/control-api/src/services/access/authorizationRevision.ts index eeee196492..940cc3ae16 100644 --- a/control-api/src/services/access/authorizationRevision.ts +++ b/control-api/src/services/access/authorizationRevision.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto' import type { TeamRole } from '../../profileTypes.js' import type { AccessPathSeed } from './accessPath.js' +import { compareCanonicalUtf8Text } from './canonicalText.js' import { type CanonicalResourceIdentity, resourceIdentityKey } from './resourceIdentity.js' export type AuthorizationMembershipRevision = Readonly<{ @@ -43,7 +44,7 @@ export function canonicalAccessPathSeeds(candidates: r ) } return [...values.entries()] - .sort(([left], [right]) => left.localeCompare(right)) + .sort(([left], [right]) => compareCanonicalUtf8Text(left, right)) .map(([, value]) => value) } @@ -67,7 +68,7 @@ export function authorizationRevision(input: AuthorizationRevisionInput): string sessionRevision: input.sessionRevision, userRevision: input.userRevision, memberships: [...input.memberships].sort((left, right) => - left.teamId.localeCompare(right.teamId) + compareCanonicalUtf8Text(left.teamId, right.teamId) ), resource: resourceIdentityKey(input.resource), resourceRevision: input.resourceRevision, @@ -94,7 +95,7 @@ export function canonicalAuthorizationRelationships( ) } return [...values.entries()] - .sort(([left], [right]) => left.localeCompare(right)) + .sort(([left], [right]) => compareCanonicalUtf8Text(left, right)) .map(([, value]) => value) } @@ -109,13 +110,13 @@ function canonicalValue(value: unknown): string { if (value === null || typeof value !== 'object') return JSON.stringify(value) if (Array.isArray(value)) return `[${value.map(canonicalValue).join(',')}]` return `{${Object.entries(value as Record) - .sort(([left], [right]) => left.localeCompare(right)) + .sort(([left], [right]) => compareCanonicalUtf8Text(left, right)) .map(([key, item]) => `${JSON.stringify(key)}:${canonicalValue(item)}`) .join(',')}}` } export function revisionOfValues(values: readonly unknown[]): string { return createHash('sha256') - .update(`[${values.map(canonicalValue).sort().join(',')}]`) + .update(`[${values.map(canonicalValue).sort(compareCanonicalUtf8Text).join(',')}]`) .digest('base64url') } diff --git a/control-api/src/services/access/canonicalText.ts b/control-api/src/services/access/canonicalText.ts new file mode 100644 index 0000000000..f8711fdcb9 --- /dev/null +++ b/control-api/src/services/access/canonicalText.ts @@ -0,0 +1,4 @@ +/** Exact lexicographic ordering of unsigned UTF-8 bytes used by access identities. */ +export function compareCanonicalUtf8Text(left: string, right: string): number { + return Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8')) +} diff --git a/control-api/src/services/access/capabilityRegistry.ts b/control-api/src/services/access/capabilityRegistry.ts index fd1f4e00c5..886d34460e 100644 --- a/control-api/src/services/access/capabilityRegistry.ts +++ b/control-api/src/services/access/capabilityRegistry.ts @@ -1,4 +1,5 @@ import type { TeamRole } from '../../profileTypes.js' +import { compareCanonicalUtf8Text } from './canonicalText.js' export const ACCESS_CAPABILITIES = [ 'user.profile.read', @@ -61,7 +62,7 @@ export function requireAccessCapability(value: unknown): AccessCapability { export function normalizeAccessCapabilities(values: readonly unknown[]): AccessCapability[] { const normalized = new Set() for (const value of values) normalized.add(requireAccessCapability(value)) - return [...normalized].sort() + return [...normalized].sort(compareCanonicalUtf8Text) } export function capabilitiesForTeamRole(role: TeamRole): AccessCapability[] { diff --git a/control-api/src/services/access/catalogContracts.ts b/control-api/src/services/access/catalogContracts.ts index 10e8d33e12..d6a21692bd 100644 --- a/control-api/src/services/access/catalogContracts.ts +++ b/control-api/src/services/access/catalogContracts.ts @@ -2,6 +2,7 @@ import type { DbClient } from '../../db.js' import type { TeamRole } from '../../profileTypes.js' import type { AccessExecutionBudget } from './accessExecutionBudget.js' import type { AccessPathSeed } from './accessPath.js' +import { compareCanonicalUtf8Text } from './canonicalText.js' import type { OperationalSourceFamily } from './operationalAccessProjection.js' import type { CanonicalResourceIdentity } from './resourceIdentity.js' @@ -124,7 +125,7 @@ export function catalogKey( /** Exact lexicographic ordering of the unsigned UTF-8 bytes for stored catalog text. */ export function compareCatalogText(left: string, right: string): number { - return Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8')) + return compareCanonicalUtf8Text(left, right) } export function compareCatalogKey(left: CatalogKey, right: CatalogKey): number { diff --git a/control-api/src/services/access/catalogProducers.ts b/control-api/src/services/access/catalogProducers.ts index da8564f620..3bf81d82de 100644 --- a/control-api/src/services/access/catalogProducers.ts +++ b/control-api/src/services/access/catalogProducers.ts @@ -6,6 +6,7 @@ import { databaseRelationshipsRevision, revisionOfValues, } from './authorizationRevision.js' +import { compareCanonicalUtf8Text } from './canonicalText.js' import type { AccessCapability } from './capabilityRegistry.js' import { gfsPermissionsToCapabilities } from './capabilityRegistry.js' import { @@ -252,7 +253,7 @@ function simpleOperationalCandidates(input: { const runtimeRef = JSON.stringify( relevantRelationships .map(row => boundedString(row.relationship_instance_id, 'relationship_instance', 256)) - .sort() + .sort(compareCanonicalUtf8Text) ) return input.pathRows.map(row => commonCandidate({ diff --git a/control-api/src/services/access/operationalAccessProjection.ts b/control-api/src/services/access/operationalAccessProjection.ts index 0375dce5ea..1115b0027f 100644 --- a/control-api/src/services/access/operationalAccessProjection.ts +++ b/control-api/src/services/access/operationalAccessProjection.ts @@ -1,5 +1,6 @@ import { createHash, createHmac } from 'node:crypto' import type { ClerumResourceType } from '../../types.js' +import { compareCanonicalUtf8Text } from './canonicalText.js' export const OPERATIONAL_SOURCE_FAMILIES = [ 'host', @@ -134,7 +135,7 @@ function canonicalJson(value: unknown): string { if (value === null || typeof value !== 'object') return JSON.stringify(value) if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]` return `{${Object.entries(value as Record) - .sort(([left], [right]) => left.localeCompare(right)) + .sort(([left], [right]) => compareCanonicalUtf8Text(left, right)) .map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`) .join(',')}}` } diff --git a/control-api/test/accessCatalogProperties.test.ts b/control-api/test/accessCatalogProperties.test.ts index 229bef03ac..0dafca5ea9 100644 --- a/control-api/test/accessCatalogProperties.test.ts +++ b/control-api/test/accessCatalogProperties.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import fc from 'fast-check' +import { createHash } from 'node:crypto' import { AccessCatalogMergeError, mergeCatalogCandidateStreams, @@ -11,15 +12,21 @@ import { AccessExecutionCancelledError, } from '../src/services/access/accessExecutionBudget.js' import type { + AccessPath, AccessPathBehavior, AccessPathSeed, BehaviorDimension, } from '../src/services/access/accessPath.js' +import { selectEquivalentAccessPath } from '../src/services/access/accessPath.js' import { canonicalAccessPathSeeds, canonicalAuthorizationRelationships, + revisionOfValues, } from '../src/services/access/authorizationRevision.js' -import type { AccessCapability } from '../src/services/access/capabilityRegistry.js' +import { + type AccessCapability, + normalizeAccessCapabilities, +} from '../src/services/access/capabilityRegistry.js' import { CATALOG_FAMILIES, type CatalogFamily, @@ -65,6 +72,27 @@ function compareKeyOracle(left: CatalogKey, right: CatalogKey): number { ) } +function canonicalValueOracle(value: unknown): string { + if (value === undefined) return 'null' + if (value === null || typeof value !== 'object') return JSON.stringify(value) + if (Array.isArray(value)) return `[${value.map(canonicalValueOracle).join(',')}]` + return `{${Object.entries(value as Record) + .sort(([left], [right]) => compareTextOracle(left, right)) + .map(([key, item]) => `${JSON.stringify(key)}:${canonicalValueOracle(item)}`) + .join(',')}}` +} + +const canonicalBehavior: AccessPathBehavior = Object.freeze({ + capabilities: Object.freeze(['team.read', 'user.profile.read']), + budget: Object.freeze({ state: 'known', value: null }), + credentialPolicy: Object.freeze({ state: 'known', value: null }), + approvalPolicy: Object.freeze({ state: 'known', value: null }), + filesystemScope: Object.freeze({ state: 'known', value: null }), + runtime: Object.freeze({ state: 'known', value: null }), + providerModelPolicy: Object.freeze({ state: 'known', value: null }), + audit: Object.freeze({ state: 'known', value: null }), +}) + const catalogTextArbitrary = fc .array( fc.constantFrom('a', 'z', 'A', 'Z', '0', '9', '-', '_', '.', '/', 'é', 'e\u0301', 'Ω', '😀'), @@ -204,6 +232,58 @@ function hydrated(input: { } describe('aggregate access catalog properties', () => { + it('uses one unsigned UTF-8 order for every public authorization identity canonicalizer', () => { + const directZ: AccessPathSeed = { + kind: 'direct', + grantId: 'Z', + behavior: canonicalBehavior, + } + const directUmlaut: AccessPathSeed = { + kind: 'direct', + grantId: 'ä', + behavior: canonicalBehavior, + } + expect(canonicalAccessPathSeeds([directUmlaut, directZ]).map(seed => seed.grantId)).toEqual([ + 'Z', + 'ä', + ]) + + expect( + canonicalAuthorizationRelationships([ + { type: 'ä', targetResourceId: 'resource' }, + { type: 'Z', targetResourceId: 'resource' }, + ]).map(relationship => relationship.type) + ).toEqual(['Z', 'ä']) + + const values = [{ ä: '\uE000', Z: '😀' }, '\uE000', '😀'] + const expectedRevision = createHash('sha256') + .update(`[${values.map(canonicalValueOracle).sort(compareTextOracle).join(',')}]`) + .digest('base64url') + expect(revisionOfValues(values)).toBe(expectedRevision) + + expect( + normalizeAccessCapabilities(['user.profile.read', 'team.read']).sort(compareTextOracle) + ).toEqual(['team.read', 'user.profile.read']) + + const equivalentPaths: AccessPath[] = [ + { + id: 'ap1-umlaut', + kind: 'direct', + grantId: 'ä', + authorizationRevision: 'ar1', + behavior: canonicalBehavior, + }, + { + id: 'ap1-z', + kind: 'direct', + grantId: 'Z', + authorizationRevision: 'ar1', + behavior: canonicalBehavior, + }, + ] + expect(selectEquivalentAccessPath(equivalentPaths)?.grantId).toBe('Z') + }) + it('matches an independent UTF-8 byte oracle with exact identity', () => { fc.assert( fc.property(candidateArbitrary, candidateArbitrary, (left, right) => { From 031d75f9bed860f170dd6ca2160e02eff3a2b63a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 28 Aug 2026 15:37:40 -0600 Subject: [PATCH 150/528] fix(external-rest-api): r15-m2 bound public correlation identifiers Class-check: public correlation validation -> 3 authorities -> one bounded canonical selector Repro-test: public error tests fail at b796601d by reflecting delimiter-bearing IDs --- external-rest-api/package-lock.json | 4 ++-- external-rest-api/package.json | 2 +- .../__tests__/browserMutationGuard.test.ts | 19 ++++++++++++++++ .../src/__tests__/publicErrorContract.test.ts | 17 ++++++++++++++ external-rest-api/src/http/publicApiError.ts | 22 +++++++++++-------- .../src/middleware/browserMutationGuard.ts | 9 ++------ 6 files changed, 54 insertions(+), 19 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index ff88a88560..1831f85dfc 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index d8dc7498ab..a2eb923322 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/__tests__/browserMutationGuard.test.ts b/external-rest-api/src/__tests__/browserMutationGuard.test.ts index fb2fe66f95..58748e2467 100644 --- a/external-rest-api/src/__tests__/browserMutationGuard.test.ts +++ b/external-rest-api/src/__tests__/browserMutationGuard.test.ts @@ -37,6 +37,25 @@ describe('browser mutation origin protection', () => { expect(JSON.stringify(response.body)).not.toContain('attacker.example') }) + it('uses only bounded correlation IDs in browser-mutation denials', async () => { + const valid = await request(app()) + .post('/mutation') + .set('cookie', 'profile_session=session') + .set('origin', 'https://attacker.example') + .set('x-correlation-id', 'browser_ID-42') + expect(valid.body.error.correlationId).toBe('browser_ID-42') + + for (const rejected of ['browser/request', 'x'.repeat(129)]) { + const response = await request(app()) + .post('/mutation') + .set('cookie', 'profile_session=session') + .set('origin', 'https://attacker.example') + .set('x-correlation-id', rejected) + expect(response.body.error.correlationId).not.toBe(rejected) + expect(response.body.error.correlationId).toMatch(/^[A-Za-z0-9_-]{1,128}$/) + } + }) + it('allows native bearer mutations without browser headers', async () => { await request(app()) .post('/mutation') diff --git a/external-rest-api/src/__tests__/publicErrorContract.test.ts b/external-rest-api/src/__tests__/publicErrorContract.test.ts index 3be6515e68..fa900d27c3 100644 --- a/external-rest-api/src/__tests__/publicErrorContract.test.ts +++ b/external-rest-api/src/__tests__/publicErrorContract.test.ts @@ -15,6 +15,23 @@ function appThrowing(error: Error) { describe('External REST public error contract', () => { afterEach(() => vi.unstubAllGlobals()) + it('preserves only bounded public correlation IDs through the mounted global handler', async () => { + const valid = await request( + appThrowing(new ControlApiError('private', 400, { error: { code: 'invalid_request' } })) + ) + .get('/failure') + .set('x-correlation-id', 'request_ID-42') + expect(valid.body.error.correlationId).toBe('request_ID-42') + + for (const rejected of ['request/with/delimiters', 'x'.repeat(129)]) { + const response = await request(appThrowing(new Error('private'))) + .get('/failure') + .set('x-correlation-id', rejected) + expect(response.body.error.correlationId).not.toBe(rejected) + expect(response.body.error.correlationId).toMatch(/^[A-Za-z0-9_-]{1,128}$/) + } + }) + it('never reflects an internal upstream message, path, or secret-like value', async () => { const sentinel = 'oauth-secret-at-/var/run/internal/provider.json' const response = await request(appThrowing(new Error(sentinel))).get('/failure') diff --git a/external-rest-api/src/http/publicApiError.ts b/external-rest-api/src/http/publicApiError.ts index 746daa4103..b6470e52ac 100644 --- a/external-rest-api/src/http/publicApiError.ts +++ b/external-rest-api/src/http/publicApiError.ts @@ -2,6 +2,17 @@ import type { Request, Response } from 'express' import { randomUUID } from 'node:crypto' import { ControlApiError } from '../controlApiClient.js' +const SAFE_PUBLIC_CORRELATION_ID = /^[A-Za-z0-9_-]{1,128}$/ + +export function selectPublicCorrelationId(...candidates: readonly unknown[]): string { + for (const candidate of candidates) { + if (typeof candidate !== 'string') continue + const value = candidate.trim() + if (SAFE_PUBLIC_CORRELATION_ID.test(value)) return value + } + return randomUUID() +} + const PUBLIC_CODE_BY_STATUS: Readonly> = { 400: 'invalid_request', 401: 'invalid_session', @@ -172,12 +183,7 @@ export function sanitizeControlApiPublicError( rawEnvelope && typeof (rawEnvelope as { correlationId?: unknown }).correlationId === 'string' ? String((rawEnvelope as { correlationId: string }).correlationId) : '' - const safeFallbackCorrelationId = /^[A-Za-z0-9_-]{1,128}$/.test(fallbackCorrelationId) - ? fallbackCorrelationId - : '' - const correlationId = /^[A-Za-z0-9_-]{1,128}$/.test(rawCorrelationId) - ? rawCorrelationId - : safeFallbackCorrelationId || randomUUID() + const correlationId = selectPublicCorrelationId(rawCorrelationId, fallbackCorrelationId) const retryable = [408, 425, 429, 502, 503, 504].includes(error.status) const rawDetails = rawEnvelope && typeof (rawEnvelope as { details?: unknown }).details === 'object' @@ -254,9 +260,7 @@ export function sendSanitizedControlApiPublicError( } export function publicCorrelationId(req: Request): string { - return ( - String(req.header('x-correlation-id') || '').trim() || Math.random().toString(36).slice(2, 12) - ) + return selectPublicCorrelationId(req.header('x-correlation-id')) } export function sendPublicApiError( diff --git a/external-rest-api/src/middleware/browserMutationGuard.ts b/external-rest-api/src/middleware/browserMutationGuard.ts index eacd818c22..44a4710699 100644 --- a/external-rest-api/src/middleware/browserMutationGuard.ts +++ b/external-rest-api/src/middleware/browserMutationGuard.ts @@ -1,15 +1,10 @@ import type { NextFunction, Request, Response } from 'express' import { config } from '../config.js' +import { publicCorrelationId } from '../http/publicApiError.js' const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']) const SAFE_FETCH_SITES = new Set(['same-origin', 'same-site']) -function correlationId(req: Request): string { - return ( - String(req.header('x-correlation-id') || '').trim() || Math.random().toString(36).slice(2, 12) - ) -} - function configuredOrigins(): Set { if (config.corsOrigin === '*') { try { @@ -53,7 +48,7 @@ export function requireTrustedBrowserMutation( error: { code: 'forbidden', message: 'The browser request origin is not allowed.', - correlationId: correlationId(req), + correlationId: publicCorrelationId(req), retryable: false, }, }) From 16f86ae0acc31ed9bdb18008dbaa8a254367d3b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 28 Aug 2026 15:39:12 -0600 Subject: [PATCH 151/528] fix(external-rest-api): r15-l1 preserve route request correlation fallback Class-check: route-local sanitizer calls -> 9 sites -> all pass canonical request fallback Repro-test: four route tests fail at 43f359d4 when upstream omits correlation ID --- external-rest-api/package-lock.json | 4 ++-- external-rest-api/package.json | 2 +- external-rest-api/src/routes/access.ts | 7 ++++++- external-rest-api/src/routes/notifications.ts | 8 ++++++-- external-rest-api/src/routes/oauthGrants.ts | 8 ++++++-- external-rest-api/src/routes/rpc.ts | 8 ++++++-- .../src/routes/userApprovalDecisions.ts | 8 ++++++-- .../src/routes/workflowApprovalMediums.ts | 8 ++++++-- external-rest-api/src/routes/workflows.ts | 8 ++++++-- external-rest-api/test/routes.access.test.ts | 1 + external-rest-api/test/routes.rpc.test.ts | 3 ++- external-rest-api/test/routes.workflows.test.ts | 15 +++++++++++++++ 12 files changed, 63 insertions(+), 17 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 1831f85dfc..ff88a88560 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index a2eb923322..d8dc7498ab 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/routes/access.ts b/external-rest-api/src/routes/access.ts index 9d386b25aa..201ec7f411 100644 --- a/external-rest-api/src/routes/access.ts +++ b/external-rest-api/src/routes/access.ts @@ -2,6 +2,7 @@ import { Router } from 'express' import type { NextFunction, Response } from 'express' import { controlApiRequest } from '../controlApiClient.js' import { + publicCorrelationId, sanitizeControlApiPublicError, sendSanitizedControlApiPublicError, } from '../http/publicApiError.js' @@ -10,7 +11,11 @@ import { type AuthedRequest, extractAuthToken, requireAuth } from '../middleware const ACCESS_PUBLIC_STATUSES = new Set([400, 401, 403, 404, 409, 429, 503]) function forwardAccessError(error: unknown, res: Response, next: NextFunction): void { - const sanitized = sanitizeControlApiPublicError(error, ACCESS_PUBLIC_STATUSES) + const sanitized = sanitizeControlApiPublicError( + error, + ACCESS_PUBLIC_STATUSES, + publicCorrelationId(res.req) + ) if (sanitized) { sendSanitizedControlApiPublicError(res, sanitized) return diff --git a/external-rest-api/src/routes/notifications.ts b/external-rest-api/src/routes/notifications.ts index a3fa04c216..3d4fd80c7b 100644 --- a/external-rest-api/src/routes/notifications.ts +++ b/external-rest-api/src/routes/notifications.ts @@ -1,13 +1,17 @@ import type { NextFunction, Response } from 'express' import { Router } from 'express' import { controlApiStreamRequest } from '../controlApiClient.js' -import { sanitizeControlApiPublicError } from '../http/publicApiError.js' +import { publicCorrelationId, sanitizeControlApiPublicError } from '../http/publicApiError.js' import { type AuthedRequest, extractAuthToken, requireAuth } from '../middleware/auth.js' const PROPAGATED_STATUSES = new Set([400, 401, 403, 404, 409, 410, 422]) function forwardControlApiError(error: unknown, res: Response, next: NextFunction): void { - const sanitized = sanitizeControlApiPublicError(error, PROPAGATED_STATUSES) + const sanitized = sanitizeControlApiPublicError( + error, + PROPAGATED_STATUSES, + publicCorrelationId(res.req) + ) if (sanitized) { res.status(sanitized.status).json(sanitized.body) return diff --git a/external-rest-api/src/routes/oauthGrants.ts b/external-rest-api/src/routes/oauthGrants.ts index 564c0231fd..5902a313af 100644 --- a/external-rest-api/src/routes/oauthGrants.ts +++ b/external-rest-api/src/routes/oauthGrants.ts @@ -1,6 +1,6 @@ import type { NextFunction, Response } from 'express' import { Router } from 'express' -import { sanitizeControlApiPublicError } from '../http/publicApiError.js' +import { publicCorrelationId, sanitizeControlApiPublicError } from '../http/publicApiError.js' import { type AuthedRequest, extractAuthToken, requireAuth } from '../middleware/auth.js' import { type OauthGrantSummary, @@ -31,7 +31,11 @@ function toGrantView(g: OauthGrantSummary): OauthGrantSummary { } function forwardControlApiError(error: unknown, res: Response, next: NextFunction): void { - const sanitized = sanitizeControlApiPublicError(error, PROPAGATED_STATUSES) + const sanitized = sanitizeControlApiPublicError( + error, + PROPAGATED_STATUSES, + publicCorrelationId(res.req) + ) if (sanitized) { res.status(sanitized.status).json(sanitized.body) return diff --git a/external-rest-api/src/routes/rpc.ts b/external-rest-api/src/routes/rpc.ts index bc5cf428ad..2ddad4a8cd 100644 --- a/external-rest-api/src/routes/rpc.ts +++ b/external-rest-api/src/routes/rpc.ts @@ -1,5 +1,5 @@ import { Router } from 'express' -import { sanitizeControlApiPublicError } from '../http/publicApiError.js' +import { publicCorrelationId, sanitizeControlApiPublicError } from '../http/publicApiError.js' import { type AuthedRequest, extractAuthToken, requireAuth } from '../middleware/auth.js' import { issueRpcAccessToken } from '../services/rpcService.js' @@ -23,7 +23,11 @@ export function createRpcRouter(): Router { ) res.status(200).json(result) } catch (error) { - const sanitized = sanitizeControlApiPublicError(error, RPC_PUBLIC_STATUSES) + const sanitized = sanitizeControlApiPublicError( + error, + RPC_PUBLIC_STATUSES, + publicCorrelationId(req) + ) if (sanitized) { res.status(sanitized.status).json(sanitized.body) return diff --git a/external-rest-api/src/routes/userApprovalDecisions.ts b/external-rest-api/src/routes/userApprovalDecisions.ts index 9ab4d6e8ba..d6f1e4ecd3 100644 --- a/external-rest-api/src/routes/userApprovalDecisions.ts +++ b/external-rest-api/src/routes/userApprovalDecisions.ts @@ -1,6 +1,6 @@ import type { NextFunction, Response } from 'express' import { Router } from 'express' -import { sanitizeControlApiPublicError } from '../http/publicApiError.js' +import { publicCorrelationId, sanitizeControlApiPublicError } from '../http/publicApiError.js' import { type AuthedRequest, extractAuthToken, requireAuth } from '../middleware/auth.js' import { decideUserApprovalDecision, @@ -10,7 +10,11 @@ import { const PROPAGATED_STATUSES = new Set([400, 403, 404, 409, 410, 422]) function forwardControlApiError(error: unknown, res: Response, next: NextFunction): void { - const sanitized = sanitizeControlApiPublicError(error, PROPAGATED_STATUSES) + const sanitized = sanitizeControlApiPublicError( + error, + PROPAGATED_STATUSES, + publicCorrelationId(res.req) + ) if (sanitized) { res.status(sanitized.status).json(sanitized.body) return diff --git a/external-rest-api/src/routes/workflowApprovalMediums.ts b/external-rest-api/src/routes/workflowApprovalMediums.ts index a5a1be0372..fec1839ef4 100644 --- a/external-rest-api/src/routes/workflowApprovalMediums.ts +++ b/external-rest-api/src/routes/workflowApprovalMediums.ts @@ -1,6 +1,6 @@ import type { NextFunction, Response } from 'express' import { Router } from 'express' -import { sanitizeControlApiPublicError } from '../http/publicApiError.js' +import { publicCorrelationId, sanitizeControlApiPublicError } from '../http/publicApiError.js' import { type AuthedRequest, extractAuthToken, requireAuth } from '../middleware/auth.js' import { confirmWorkflowApprovalMediumChallenge, @@ -19,7 +19,11 @@ const CODE_RE = /^\d{6}$/ const DISPLAY_NAME_MAX_LENGTH = 120 function forwardControlApiError(error: unknown, res: Response, next: NextFunction): void { - const sanitized = sanitizeControlApiPublicError(error, PROPAGATED_STATUSES) + const sanitized = sanitizeControlApiPublicError( + error, + PROPAGATED_STATUSES, + publicCorrelationId(res.req) + ) if (sanitized) { res.status(sanitized.status).json(sanitized.body) return diff --git a/external-rest-api/src/routes/workflows.ts b/external-rest-api/src/routes/workflows.ts index f0d2caa39b..29e8685c30 100644 --- a/external-rest-api/src/routes/workflows.ts +++ b/external-rest-api/src/routes/workflows.ts @@ -5,13 +5,17 @@ import { controlApiRequest, controlApiRequestWithStatus, } from '../controlApiClient.js' -import { sanitizeControlApiPublicError } from '../http/publicApiError.js' +import { publicCorrelationId, sanitizeControlApiPublicError } from '../http/publicApiError.js' import { type AuthedRequest, extractAuthToken, requireAuth } from '../middleware/auth.js' const PROPAGATED_STATUSES = new Set([400, 403, 404, 409, 410, 422]) function forwardControlApiError(error: unknown, res: Response, next: NextFunction): void { - const sanitized = sanitizeControlApiPublicError(error, PROPAGATED_STATUSES) + const sanitized = sanitizeControlApiPublicError( + error, + PROPAGATED_STATUSES, + publicCorrelationId(res.req) + ) if (sanitized) { res.status(sanitized.status).json(sanitized.body) return diff --git a/external-rest-api/test/routes.access.test.ts b/external-rest-api/test/routes.access.test.ts index afaf29c824..75801daf96 100644 --- a/external-rest-api/test/routes.access.test.ts +++ b/external-rest-api/test/routes.access.test.ts @@ -97,6 +97,7 @@ describe('public access-contract forwarding', () => { const response = await request(app()) .get('/me/access/capabilities') .set('authorization', 'Bearer session-token') + .set('x-correlation-id', 'request-fallback') expect(response.status).toBe(503) expect(response.body).toEqual({ error: { diff --git a/external-rest-api/test/routes.rpc.test.ts b/external-rest-api/test/routes.rpc.test.ts index db46298db6..576b3c3647 100644 --- a/external-rest-api/test/routes.rpc.test.ts +++ b/external-rest-api/test/routes.rpc.test.ts @@ -41,6 +41,7 @@ describe('routes/rpc /rpc/token', () => { const res = await request(buildApp()) .post('/rpc/token') .set('authorization', 'Bearer session-xyz') + .set('x-correlation-id', 'rpc_ID-42') .send({ scopes: ['desktop:view'], hostRefs: ['pro-agent'] }) expect(res.status).toBe(403) @@ -48,7 +49,7 @@ describe('routes/rpc /rpc/token', () => { error: { code: 'forbidden', message: 'The requested operation is not allowed.', - correlationId: expect.any(String), + correlationId: 'rpc_ID-42', retryable: false, }, }) diff --git a/external-rest-api/test/routes.workflows.test.ts b/external-rest-api/test/routes.workflows.test.ts index 0060a55704..f0532cb8a0 100644 --- a/external-rest-api/test/routes.workflows.test.ts +++ b/external-rest-api/test/routes.workflows.test.ts @@ -68,6 +68,21 @@ describe('routes/workflows', () => { ) }) + it('uses the bounded request correlation ID when a route-local upstream error has none', async () => { + authTokenMock.verifyToken.mockReturnValueOnce(claims) + controlApiClientMock.controlApiRequest.mockRejectedValueOnce( + new controlApiClientMock.ControlApiError('private', 404, { error: { code: 'not_found' } }) + ) + + const res = await request(makeApp()) + .get('/api/v1/workflows/sandbox-recipes/recipe-a/runs/run-123/artifacts') + .set('authorization', 'Bearer user-session-token') + .set('x-correlation-id', 'workflow_ID-42') + + expect(res.status).toBe(404) + expect(res.body.error.correlationId).toBe('workflow_ID-42') + }) + it('proxies run-scoped workflow artifact downloads without using latest-run routes', async () => { authTokenMock.verifyToken.mockReturnValueOnce(claims) controlApiClientMock.controlApiBinaryRequestWithStatus.mockResolvedValueOnce({ From d76b96ca1a019e6a2c43bc7bd28aa15e11575f09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 28 Aug 2026 15:49:01 -0600 Subject: [PATCH 152/528] fix(external-rest-api): r15-b4 reject normalized correlation candidates Class-check: correlation normalization -> 1 selector -> validate the raw supplied value Repro-test: publicErrorContract.test.ts fails at f8fbeaac by accepting wrapped IDs --- external-rest-api/package.json | 2 +- .../src/__tests__/publicErrorContract.test.ts | 9 ++++++++- external-rest-api/src/http/publicApiError.ts | 3 +-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/external-rest-api/package.json b/external-rest-api/package.json index d8dc7498ab..a2eb923322 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/__tests__/publicErrorContract.test.ts b/external-rest-api/src/__tests__/publicErrorContract.test.ts index fa900d27c3..829d93f102 100644 --- a/external-rest-api/src/__tests__/publicErrorContract.test.ts +++ b/external-rest-api/src/__tests__/publicErrorContract.test.ts @@ -3,7 +3,7 @@ import express from 'express' import request from 'supertest' import { externalRestPublicErrorHandler } from '../app.js' import { ControlApiError, controlApiRequest } from '../controlApiClient.js' -import { sanitizeControlApiPublicError } from '../http/publicApiError.js' +import { sanitizeControlApiPublicError, selectPublicCorrelationId } from '../http/publicApiError.js' function appThrowing(error: Error) { const app = express() @@ -32,6 +32,13 @@ describe('External REST public error contract', () => { } }) + it('requires the supplied correlation value itself to match without whitespace normalization', () => { + expect(selectPublicCorrelationId(' upstream_ID-42 ', 'request_ID-42')).toBe('request_ID-42') + const generated = selectPublicCorrelationId(' request_ID-42 ') + expect(generated).not.toBe('request_ID-42') + expect(generated).toMatch(/^[A-Za-z0-9_-]{1,128}$/) + }) + it('never reflects an internal upstream message, path, or secret-like value', async () => { const sentinel = 'oauth-secret-at-/var/run/internal/provider.json' const response = await request(appThrowing(new Error(sentinel))).get('/failure') diff --git a/external-rest-api/src/http/publicApiError.ts b/external-rest-api/src/http/publicApiError.ts index b6470e52ac..7d4ecdd17f 100644 --- a/external-rest-api/src/http/publicApiError.ts +++ b/external-rest-api/src/http/publicApiError.ts @@ -7,8 +7,7 @@ const SAFE_PUBLIC_CORRELATION_ID = /^[A-Za-z0-9_-]{1,128}$/ export function selectPublicCorrelationId(...candidates: readonly unknown[]): string { for (const candidate of candidates) { if (typeof candidate !== 'string') continue - const value = candidate.trim() - if (SAFE_PUBLIC_CORRELATION_ID.test(value)) return value + if (SAFE_PUBLIC_CORRELATION_ID.test(candidate)) return candidate } return randomUUID() } From 2b5f1c641c841ee0c66b3535149329eac3ed27aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 28 Aug 2026 15:49:48 -0600 Subject: [PATCH 153/528] fix(external-rest-api): r15-b5 align the release lock coordinate Class-check: external REST version coordinates -> 3 authorities -> package and lock agree Repro-test: package/lock equality check fails at 56757e34 with 0.1.94/0.1.93 --- external-rest-api/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/external-rest-api/package.json b/external-rest-api/package.json index a2eb923322..d8dc7498ab 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { From 9c3a279238ef88b14c8301aaf50e3a331f7fbeac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 28 Aug 2026 15:52:34 -0600 Subject: [PATCH 154/528] test(control-api): r15-b6 prove capability order without self-sorting Class-check: M1 independent byte-order assertions -> 5 public seams -> no self-sorted actuals Repro-test: source audit at d9d6dc65 finds the capability result re-sorted by the oracle --- control-api/test/accessCatalogProperties.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/control-api/test/accessCatalogProperties.test.ts b/control-api/test/accessCatalogProperties.test.ts index 0dafca5ea9..93d24bc542 100644 --- a/control-api/test/accessCatalogProperties.test.ts +++ b/control-api/test/accessCatalogProperties.test.ts @@ -261,9 +261,17 @@ describe('aggregate access catalog properties', () => { .digest('base64url') expect(revisionOfValues(values)).toBe(expectedRevision) - expect( - normalizeAccessCapabilities(['user.profile.read', 'team.read']).sort(compareTextOracle) - ).toEqual(['team.read', 'user.profile.read']) + fc.assert( + fc.property( + fc.uniqueArray(fc.constantFrom(...capabilities), { maxLength: capabilities.length }), + values => { + const expected = [...values].sort(compareTextOracle) + expect(normalizeAccessCapabilities(values)).toEqual(expected) + expect(normalizeAccessCapabilities([...values].reverse())).toEqual(expected) + } + ), + { numRuns: 200 } + ) const equivalentPaths: AccessPath[] = [ { From e0300316112212a95b74d53520b7cc076a540267 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 28 Aug 2026 15:58:07 -0600 Subject: [PATCH 155/528] test(control-api): r15-b7 cover path capability denials Class-check: selected-path capability authority -> 2 negative classes -> explicit denial proof Repro-test: closure audit at 32a6d691 found no read-only-path or unheld-capability row --- ...gFamilies.realPostgres.integration.test.ts | 33 ++++++++++++++++++- .../test/accessLiveAuthorization.test.ts | 26 ++++++++++++++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts index 0b1d86b392..498ee45fcd 100644 --- a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts @@ -31,7 +31,9 @@ vi.mock('../src/services/access/operationTarget.js', async importOriginal => { return { ...actual, validateOperationTarget: (input: Parameters[0]) => - input.capability === 'gfs.write' ? null : actual.validateOperationTarget(input), + input.capability === 'gfs.write' || input.capability === 'gfs.delete' + ? null + : actual.validateOperationTarget(input), } }) @@ -448,7 +450,11 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { const directWritePath = item.accessPaths.find( path => path.kind === 'direct' && path.capabilities.includes('gfs.write') ) + const teamReadPath = item.accessPaths.find( + path => path.kind === 'team' && path.capabilities.includes('gfs.read') + ) expect(directWritePath).toBeDefined() + expect(teamReadPath).toBeDefined() const resolved = await resolveLiveAuthorization( { @@ -466,6 +472,31 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { selectedPath: expect.objectContaining({ id: directWritePath!.accessPathId }), }) ) + + await expect( + resolveLiveAuthorization( + { + session, + requiredCapability: 'gfs.write', + resource: canonicalResourceIdentity(item.resource), + requestedAccessPathId: teamReadPath!.accessPathId, + }, + { transaction: transaction(databasePool), gateway } + ) + ).resolves.toEqual( + expect.objectContaining({ status: 'access_path_stale', code: 'access_path_stale' }) + ) + + await expect( + resolveLiveAuthorization( + { + session, + requiredCapability: 'gfs.delete', + resource: canonicalResourceIdentity(item.resource), + }, + { transaction: transaction(databasePool), gateway } + ) + ).resolves.toEqual({ status: 'denied', code: 'forbidden' }) }) it('enforces the operator-configured Team-GFS admission through the production budget contract', async () => { diff --git a/control-api/test/accessLiveAuthorization.test.ts b/control-api/test/accessLiveAuthorization.test.ts index 82af56575b..19923f4050 100644 --- a/control-api/test/accessLiveAuthorization.test.ts +++ b/control-api/test/accessLiveAuthorization.test.ts @@ -22,7 +22,9 @@ vi.mock('../src/services/access/operationTarget.js', async importOriginal => { return { ...actual, validateOperationTarget: (input: Parameters[0]) => - input.capability === 'gfs.write' ? null : actual.validateOperationTarget(input), + input.capability === 'gfs.write' || input.capability === 'gfs.delete' + ? null + : actual.validateOperationTarget(input), } }) @@ -351,7 +353,9 @@ describe('live user-access resolution', () => { expect(catalogEquivalent.status).toBe('access_path_required') if (catalogEquivalent.status !== 'access_path_required') return const directPath = catalogEquivalent.safePathDescriptors.find(path => path.kind === 'direct') + const teamPath = catalogEquivalent.safePathDescriptors.find(path => path.kind === 'team') expect(directPath).toBeDefined() + expect(teamPath).toBeDefined() const write = await resolveLiveAuthorization( gfsRequest({ @@ -368,6 +372,26 @@ describe('live user-access resolution', () => { }) ) + await expect( + resolveLiveAuthorization( + gfsRequest({ + requiredCapability: 'gfs.write', + requestedAccessPathId: teamPath!.id, + }), + { transaction: fakeTransaction(options).transaction } + ) + ).resolves.toEqual( + expect.objectContaining({ status: 'access_path_stale', code: 'access_path_stale' }) + ) + + const unheldDb = fakeTransaction(options) + await expect( + resolveLiveAuthorization(gfsRequest({ requiredCapability: 'gfs.delete' }), { + transaction: unheldDb.transaction, + }) + ).resolves.toEqual({ status: 'denied', code: 'forbidden' }) + expect(unheldDb.query).toHaveBeenCalled() + const unsupportedDb = fakeTransaction(options) await expect( resolveLiveAuthorization(gfsRequest({ requiredCapability: 'gfs.future.unsupported' }), { From 15c38005e3554ef05983e976c5899b8393e70a9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 28 Aug 2026 15:58:14 -0600 Subject: [PATCH 156/528] test(control-api): r15-b8 use real revoke-all producer Class-check: invitation post-delivery invalidation proofs -> 1 seam -> canonical producer Repro-test: closure audit at 5efe3626 found a handwritten security-epoch mutation --- ...lTeamsFinalAuthority.realPostgres.integration.test.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts b/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts index 9b87968fbc..054c682776 100644 --- a/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts +++ b/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts @@ -7,6 +7,7 @@ import { type DbClient, initDb } from '../src/db.js' import { createExternalMembersRouter } from '../src/routes/external/members.js' import { createExternalTeamsRouter } from '../src/routes/external/teams.js' import { issueExternalUserSession } from '../src/services/auth/externalSessionIssuance.js' +import { revokeAllUserSessions } from '../src/services/auth/userSessionService.js' import { acceptInvitationForEmail, createManagedInvitationForUser, @@ -488,13 +489,7 @@ describeRealPostgres('external team mutations final source authority on real Pos ) expect(draft.rows[0]?.status).toBe('draft') - await databasePool.query( - `INSERT INTO external_user_session_security_epochs(user_id, valid_after, reason) - VALUES ($1, NOW() + INTERVAL '1 second', 'test') - ON CONFLICT (user_id) DO UPDATE - SET valid_after = EXCLUDED.valid_after, reason = EXCLUDED.reason`, - [adminUserId] - ) + await revokeAllUserSessions(adminUserId, 'test') releaseDelivery() const response = await pending From 87ad62e4a8dd73634f496ad600d0d7116f5e6115 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 28 Aug 2026 15:58:23 -0600 Subject: [PATCH 157/528] test(external-rest-api): r15-b9 prove route correlation fallback Class-check: route-local public sanitizers -> 9 seams -> mounted request-fallback proof Repro-test: closure audit at f8fbeaac found five route classes without fallback assertions --- external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- .../routes.contextSharedFilesystems.test.ts | 27 +++++++++-- .../test/routes.notifications.test.ts | 3 +- .../test/routes.oauthGrants.test.ts | 48 +++++++++++++++++++ .../test/routes.userApprovalDecisions.test.ts | 3 +- .../routes.workflowApprovalMediums.test.ts | 16 +++++++ 7 files changed, 93 insertions(+), 10 deletions(-) create mode 100644 external-rest-api/test/routes.oauthGrants.test.ts diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index ff88a88560..1831f85dfc 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index d8dc7498ab..a2eb923322 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/test/routes.contextSharedFilesystems.test.ts b/external-rest-api/test/routes.contextSharedFilesystems.test.ts index 1d8dd0fb4b..2dd79a1a48 100644 --- a/external-rest-api/test/routes.contextSharedFilesystems.test.ts +++ b/external-rest-api/test/routes.contextSharedFilesystems.test.ts @@ -10,11 +10,6 @@ const authTokenMock = vi.hoisted(() => ({ const controlApiClientMock = vi.hoisted(() => ({ controlApiRequest: vi.fn(), controlApiStreamRequest: vi.fn(), -})) - -vi.mock('../src/authToken.js', () => authTokenMock) -vi.mock('../src/controlApiClient.js', () => ({ - ...controlApiClientMock, ControlApiError: class ControlApiError extends Error { status: number body: unknown @@ -26,6 +21,11 @@ vi.mock('../src/controlApiClient.js', () => ({ }, })) +vi.mock('../src/authToken.js', () => authTokenMock) +vi.mock('../src/controlApiClient.js', () => ({ + ...controlApiClientMock, +})) + const claims = { userId: 'user-1', email: 'user@example.com', @@ -80,6 +80,23 @@ describe('GET /me/contexts/:contextId/shared-filesystems', () => { expect(res.status).toBe(401) expect(controlApiClientMock.controlApiRequest).not.toHaveBeenCalled() }) + + it('uses the request correlation when a sanitized upstream error has none', async () => { + authTokenMock.verifyToken.mockReturnValue(claims) + controlApiClientMock.controlApiRequest.mockRejectedValueOnce( + new controlApiClientMock.ControlApiError('private upstream detail', 403, { + error: { code: 'forbidden' }, + }) + ) + + const response = await request(buildApp()) + .get('/me/contexts/ctx-a/shared-filesystems') + .set('authorization', 'Bearer good-token') + .set('x-correlation-id', 'shared_filesystems_ID-42') + .expect(403) + + expect(response.body.error.correlationId).toBe('shared_filesystems_ID-42') + }) }) describe('Method gating', () => { diff --git a/external-rest-api/test/routes.notifications.test.ts b/external-rest-api/test/routes.notifications.test.ts index d4a7a9793e..1b550fc221 100644 --- a/external-rest-api/test/routes.notifications.test.ts +++ b/external-rest-api/test/routes.notifications.test.ts @@ -157,6 +157,7 @@ describe('routes/notifications', () => { const response = await request(makeApp()) .put('/me/notification-preferences') .set('authorization', 'Bearer user-session-token') + .set('x-correlation-id', 'notifications_ID-42') .send({ preferredMedium: 'telegram' }) .expect(400) @@ -164,7 +165,7 @@ describe('routes/notifications', () => { error: { code: 'invalid_request', message: 'The request is not valid.', - correlationId: expect.any(String), + correlationId: 'notifications_ID-42', retryable: false, }, }) diff --git a/external-rest-api/test/routes.oauthGrants.test.ts b/external-rest-api/test/routes.oauthGrants.test.ts new file mode 100644 index 0000000000..faeef16d42 --- /dev/null +++ b/external-rest-api/test/routes.oauthGrants.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import request from 'supertest' +import { ControlApiError } from '../src/controlApiClient.js' +import { createOauthGrantsRouter } from '../src/routes/oauthGrants.js' + +const authTokenMock = vi.hoisted(() => ({ verifyToken: vi.fn() })) +const serviceMock = vi.hoisted(() => ({ + listOauthGrants: vi.fn(), + revokeOauthGrant: vi.fn(), +})) + +vi.mock('../src/authToken.js', () => authTokenMock) +vi.mock('../src/services/oauthGrantsService.js', () => serviceMock) + +function makeApp() { + const app = express() + app.use(express.json()) + app.use(createOauthGrantsRouter()) + return app +} + +describe('routes/oauthGrants', () => { + beforeEach(() => { + vi.clearAllMocks() + authTokenMock.verifyToken.mockReturnValue({ + userId: 'user-1', + email: 'user@example.com', + teamId: 'team-1', + role: 'member', + exp: 9_999_999_999, + }) + }) + + it('uses the request correlation when a sanitized upstream error has none', async () => { + serviceMock.listOauthGrants.mockRejectedValueOnce( + new ControlApiError('private upstream detail', 403, { error: { code: 'forbidden' } }) + ) + + const response = await request(makeApp()) + .get('/oauth/grants') + .set('authorization', 'Bearer session-token') + .set('x-correlation-id', 'oauth_grants_ID-42') + .expect(403) + + expect(response.body.error.correlationId).toBe('oauth_grants_ID-42') + }) +}) diff --git a/external-rest-api/test/routes.userApprovalDecisions.test.ts b/external-rest-api/test/routes.userApprovalDecisions.test.ts index 35068570b3..8ce3eca521 100644 --- a/external-rest-api/test/routes.userApprovalDecisions.test.ts +++ b/external-rest-api/test/routes.userApprovalDecisions.test.ts @@ -179,13 +179,14 @@ describe('routes/userApprovalDecisions', () => { const response = await request(app) .get('/workflow-approvals') .set('authorization', 'Bearer good-token') + .set('x-correlation-id', 'approval_decisions_ID-42') .expect(410) expect(response.body).toEqual({ error: { code: 'gone', message: 'The resource is no longer available.', - correlationId: expect.any(String), + correlationId: 'approval_decisions_ID-42', retryable: false, }, }) diff --git a/external-rest-api/test/routes.workflowApprovalMediums.test.ts b/external-rest-api/test/routes.workflowApprovalMediums.test.ts index 6f62c398dc..beb4933a8a 100644 --- a/external-rest-api/test/routes.workflowApprovalMediums.test.ts +++ b/external-rest-api/test/routes.workflowApprovalMediums.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import express from 'express' import request from 'supertest' +import { ControlApiError } from '../src/controlApiClient.js' import { createWorkflowApprovalMediumsRouter } from '../src/routes/workflowApprovalMediums.js' const serviceMock = vi.hoisted(() => ({ @@ -282,4 +283,19 @@ describe('routes/workflowApprovalMediums', () => { expect(res.body.items).toHaveLength(1) expect(serviceMock.listApprovalChannelTargets).toHaveBeenCalledWith('session-token') }) + + it('uses the request correlation when a sanitized upstream error has none', async () => { + authTokenMock.verifyToken.mockReturnValueOnce({ userId: 'user-1', exp: 9999999999 }) + serviceMock.listWorkflowApprovalMediums.mockRejectedValueOnce( + new ControlApiError('private upstream detail', 403, { error: { code: 'forbidden' } }) + ) + + const response = await request(makeApp()) + .get('/workflow-approval-mediums') + .set('authorization', 'Bearer session-token') + .set('x-correlation-id', 'approval_mediums_ID-42') + .expect(403) + + expect(response.body.error.correlationId).toBe('approval_mediums_ID-42') + }) }) From b54ad3b1af9fe5d72de04d9e4bc09b390767bf4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 28 Aug 2026 16:12:03 -0600 Subject: [PATCH 158/528] test(external-rest-api): r15-b10 prove access correlation fallback Class-check: route-local public sanitizers -> 9 seams -> mounted request-fallback proof Repro-test: collateral audit at 96138a1a found access proved only upstream precedence --- external-rest-api/package-lock.json | 4 ++-- external-rest-api/package.json | 2 +- external-rest-api/test/routes.access.test.ts | 20 ++++++++++++++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 1831f85dfc..ff88a88560 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index a2eb923322..d8dc7498ab 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/test/routes.access.test.ts b/external-rest-api/test/routes.access.test.ts index 75801daf96..b41cd0d869 100644 --- a/external-rest-api/test/routes.access.test.ts +++ b/external-rest-api/test/routes.access.test.ts @@ -109,6 +109,26 @@ describe('public access-contract forwarding', () => { }) }) + it('uses the request correlation when a sanitized upstream error has none', async () => { + mocks.controlApiRequest.mockRejectedValue( + new ControlApiError('upstream', 503, { + error: { + code: 'authority_unavailable', + message: 'SQL /secret/path', + retryable: true, + }, + }) + ) + + const response = await request(app()) + .get('/me/access/capabilities') + .set('authorization', 'Bearer session-token') + .set('x-correlation-id', 'access_ID-42') + .expect(503) + + expect(response.body.error.correlationId).toBe('access_ID-42') + }) + it('preserves safe retry guidance for a locally sanitized access limit', async () => { mocks.controlApiRequest.mockRejectedValue( new ControlApiError( From ae150d544e7a560822df27355d7f8e9843b0bc9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Sat, 29 Aug 2026 18:00:23 -0600 Subject: [PATCH 159/528] test(control-api): align migration proofs with latest dev Update migration proofs for the reconciled current-dev and PR1 migration sequence. Assert PR1 migrations use 0107 through 010c with deployed-name aliases and relative ordering. Validation: focused migration and dispatcher selection passed 8 files and 75 tests. --- .../db.catalogUtf8OrderingMigration.test.ts | 4 +-- ...composableCatalogRevisionMigration.test.ts | 4 +-- ...CatalogRevisionComponentsMigration.test.ts | 12 +++---- .../db.userAccessFoundationMigration.test.ts | 36 +++++++++++++++---- ...hBackfill.realPostgres.integration.test.ts | 8 ++--- 5 files changed, 44 insertions(+), 20 deletions(-) diff --git a/control-api/test/db.catalogUtf8OrderingMigration.test.ts b/control-api/test/db.catalogUtf8OrderingMigration.test.ts index e32a7a1594..1ece5ed5d1 100644 --- a/control-api/test/db.catalogUtf8OrderingMigration.test.ts +++ b/control-api/test/db.catalogUtf8OrderingMigration.test.ts @@ -4,11 +4,11 @@ vi.mock('../src/config.js', () => ({ config: { databaseUrl: 'postgresql://test' }, })) -describe('0103 catalog UTF-8 ordering migration', () => { +describe('0109 catalog UTF-8 ordering migration', () => { it('installs the immutable byte function and complete supporting index class', async () => { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0103_catalog_utf8_ordering' + candidate => candidate.version === '0109_catalog_utf8_ordering' ) expect(migration).toBeDefined() diff --git a/control-api/test/db.composableCatalogRevisionMigration.test.ts b/control-api/test/db.composableCatalogRevisionMigration.test.ts index 7354c5673e..4c8b9c9415 100644 --- a/control-api/test/db.composableCatalogRevisionMigration.test.ts +++ b/control-api/test/db.composableCatalogRevisionMigration.test.ts @@ -4,11 +4,11 @@ vi.mock('../src/config.js', () => ({ config: { databaseUrl: 'postgresql://test' }, })) -describe('0104 composable catalog revisions migration', () => { +describe('010a composable catalog revisions migration', () => { it('replaces the singleton trigger class with scoped transactional writers', async () => { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0104_composable_catalog_revisions' + candidate => candidate.version === '010a_composable_catalog_revisions' ) expect(migration).toBeDefined() diff --git a/control-api/test/db.gfsCatalogRevisionComponentsMigration.test.ts b/control-api/test/db.gfsCatalogRevisionComponentsMigration.test.ts index 2713fc23bb..f1b6e1f39f 100644 --- a/control-api/test/db.gfsCatalogRevisionComponentsMigration.test.ts +++ b/control-api/test/db.gfsCatalogRevisionComponentsMigration.test.ts @@ -4,17 +4,17 @@ vi.mock('../src/config.js', () => ({ config: { databaseUrl: 'postgresql://test' }, })) -describe('0105 GFS catalog revision components migration', () => { - it('fixes forward after a database has already recorded migration 0104', async () => { +describe('010b GFS catalog revision components migration', () => { + it('fixes forward after a database has already recorded migration 010a', async () => { const { CONTROL_API_MIGRATIONS, initDb } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0105_gfs_catalog_revision_components' + candidate => candidate.version === '010b_gfs_catalog_revision_components' ) const composableIndex = CONTROL_API_MIGRATIONS.findIndex( - candidate => candidate.version === '0104_composable_catalog_revisions' + candidate => candidate.version === '010a_composable_catalog_revisions' ) const gfsIndex = CONTROL_API_MIGRATIONS.findIndex( - candidate => candidate.version === '0105_gfs_catalog_revision_components' + candidate => candidate.version === '010b_gfs_catalog_revision_components' ) expect(migration).toBeDefined() expect(gfsIndex).toBe(composableIndex + 1) @@ -39,7 +39,7 @@ describe('0105 GFS catalog revision components migration', () => { expect(appliedSql).toContain('INSERT INTO authorization_resource_revisions') expect(clientQuery).toHaveBeenCalledWith( expect.stringContaining('INSERT INTO schema_migrations'), - ['0105_gfs_catalog_revision_components'] + ['010b_gfs_catalog_revision_components'] ) }) }) diff --git a/control-api/test/db.userAccessFoundationMigration.test.ts b/control-api/test/db.userAccessFoundationMigration.test.ts index 0f0f5d19cc..aa6c5c22d4 100644 --- a/control-api/test/db.userAccessFoundationMigration.test.ts +++ b/control-api/test/db.userAccessFoundationMigration.test.ts @@ -10,7 +10,7 @@ vi.mock('../src/config.js', () => ({ async function migrationSql(): Promise { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0101_user_access_foundation' + candidate => candidate.version === '0107_user_access_foundation' ) expect(migration).toBeDefined() @@ -22,7 +22,7 @@ async function migrationSql(): Promise { async function legacyEpochBackfillSql(): Promise { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0106_legacy_password_security_epoch_backfill' + candidate => candidate.version === '010c_legacy_password_security_epoch_backfill' ) expect(migration).toBeDefined() @@ -32,6 +32,26 @@ async function legacyEpochBackfillSql(): Promise { } describe('user-access foundation migration', () => { + it('carries the pre-sync PR1 migration identities as legacy aliases', async () => { + const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') + const expectedAliases = new Map([ + ['0107_user_access_foundation', '0101_user_access_foundation'], + ['0108_invitation_delivery_commands', '0102_invitation_delivery_commands'], + ['0109_catalog_utf8_ordering', '0103_catalog_utf8_ordering'], + ['010a_composable_catalog_revisions', '0104_composable_catalog_revisions'], + ['010b_gfs_catalog_revision_components', '0105_gfs_catalog_revision_components'], + [ + '010c_legacy_password_security_epoch_backfill', + '0106_legacy_password_security_epoch_backfill', + ], + ]) + + for (const [version, legacyVersion] of expectedAliases) { + const migration = CONTROL_API_MIGRATIONS.find(candidate => candidate.version === version) + expect(migration?.legacyVersions).toEqual([legacyVersion]) + } + }) + it('adds the stateful user session and revision foundations additively', async () => { const sql = await migrationSql() @@ -151,12 +171,16 @@ describe('user-access foundation migration', () => { it('dispatches the historical epoch fix-forward after already-recorded access migrations', async () => { const { CONTROL_API_MIGRATIONS, initDb } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0106_legacy_password_security_epoch_backfill' + candidate => candidate.version === '010c_legacy_password_security_epoch_backfill' ) expect(migration).toBeDefined() - expect(CONTROL_API_MIGRATIONS.at(-1)?.version).toBe( - '0106_legacy_password_security_epoch_backfill' + const gfsIndex = CONTROL_API_MIGRATIONS.findIndex( + candidate => candidate.version === '010b_gfs_catalog_revision_components' + ) + const epochIndex = CONTROL_API_MIGRATIONS.findIndex( + candidate => candidate.version === '010c_legacy_password_security_epoch_backfill' ) + expect(epochIndex).toBe(gfsIndex + 1) const recordedVersions = CONTROL_API_MIGRATIONS.filter( candidate => candidate.version !== migration?.version @@ -175,7 +199,7 @@ describe('user-access foundation migration', () => { expect(appliedSql).toContain('historical_password_event') expect(clientQuery).toHaveBeenCalledWith( expect.stringContaining('INSERT INTO schema_migrations'), - ['0106_legacy_password_security_epoch_backfill'] + ['010c_legacy_password_security_epoch_backfill'] ) }) }) diff --git a/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts b/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts index af6056b6e2..3ceaff75e9 100644 --- a/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts +++ b/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts @@ -104,7 +104,7 @@ describeRealPostgres('legacy password epoch backfill on real PostgreSQL', () => await databasePool.query( `DELETE FROM schema_migrations - WHERE version = '0106_legacy_password_security_epoch_backfill'` + WHERE version = '010c_legacy_password_security_epoch_backfill'` ) await initDb({ connect: () => databasePool.connect() }) @@ -124,7 +124,7 @@ describeRealPostgres('legacy password epoch backfill on real PostgreSQL', () => const recorded = await databasePool.query<{ count: string }>( `SELECT COUNT(*)::text AS count FROM schema_migrations - WHERE version = '0106_legacy_password_security_epoch_backfill'` + WHERE version = '010c_legacy_password_security_epoch_backfill'` ) expect(recorded.rows[0]?.count).toBe('1') }) @@ -157,12 +157,12 @@ describeRealPostgres('legacy password epoch backfill on real PostgreSQL', () => ) await databasePool.query( `DELETE FROM schema_migrations - WHERE version = '0106_legacy_password_security_epoch_backfill'` + WHERE version = '010c_legacy_password_security_epoch_backfill'` ) await initDb({ connect: () => databasePool.connect() }) await databasePool.query( `DELETE FROM schema_migrations - WHERE version = '0106_legacy_password_security_epoch_backfill'` + WHERE version = '010c_legacy_password_security_epoch_backfill'` ) await initDb({ connect: () => databasePool.connect() }) From 2619a82c03d0dd9b2289b481e16106afe6b4f4cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Sat, 29 Aug 2026 18:00:39 -0600 Subject: [PATCH 160/528] ci: preserve custom CodeQL semantics after latest dev Retain the current-dev system-prompt exclusion alongside the repository-aware rate and auth models. Validation: the reconciled query-suite YAML parses successfully; exact compilation remains CI-owned. --- .github/codeql/queries/evenfire-security-and-quality.qls | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/codeql/queries/evenfire-security-and-quality.qls b/.github/codeql/queries/evenfire-security-and-quality.qls index 30a494efb9..1b021164ab 100644 --- a/.github/codeql/queries/evenfire-security-and-quality.qls +++ b/.github/codeql/queries/evenfire-security-and-quality.qls @@ -1,5 +1,7 @@ - import: codeql-suites/javascript-security-and-quality.qls from: codeql/javascript-queries +- exclude: + id: js/system-prompt-injection - exclude: id: js/missing-rate-limiting - query: EvenfireMissingRateLimiting.ql From b4dab81e0eb3d916db8b60f310ca52a9cda49e80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Sun, 30 Aug 2026 17:40:15 -0600 Subject: [PATCH 161/528] fix(codeql): r16-b1 restore stock system prompt coverage Class-check: stock CodeQL query exclusions -> 2 exclusions; each has a repository replacement; restored js/system-prompt-injection. Repro-test: .github/codeql/queries/test/verify-security-and-quality-suite.sh (fails at 1045b570f4fe58236dd8877c96976a5eb865ba84) --- .../queries/evenfire-security-and-quality.qls | 2 - .../SystemPromptInjection.expected | 14 +++++ .../SystemPromptInjection.qlref | 1 + .../mcp-host/src/llm/systemPromptInjection.ts | 30 +++++++++ .github/codeql/queries/test/qlpack.yml | 1 + .../test/verify-security-and-quality-suite.sh | 62 +++++++++++++++++++ 6 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 .github/codeql/queries/test/SystemPromptInjection/SystemPromptInjection.expected create mode 100644 .github/codeql/queries/test/SystemPromptInjection/SystemPromptInjection.qlref create mode 100644 .github/codeql/queries/test/SystemPromptInjection/mcp-host/src/llm/systemPromptInjection.ts create mode 100755 .github/codeql/queries/test/verify-security-and-quality-suite.sh diff --git a/.github/codeql/queries/evenfire-security-and-quality.qls b/.github/codeql/queries/evenfire-security-and-quality.qls index 1b021164ab..30a494efb9 100644 --- a/.github/codeql/queries/evenfire-security-and-quality.qls +++ b/.github/codeql/queries/evenfire-security-and-quality.qls @@ -1,7 +1,5 @@ - import: codeql-suites/javascript-security-and-quality.qls from: codeql/javascript-queries -- exclude: - id: js/system-prompt-injection - exclude: id: js/missing-rate-limiting - query: EvenfireMissingRateLimiting.ql diff --git a/.github/codeql/queries/test/SystemPromptInjection/SystemPromptInjection.expected b/.github/codeql/queries/test/SystemPromptInjection/SystemPromptInjection.expected new file mode 100644 index 0000000000..1de24b6428 --- /dev/null +++ b/.github/codeql/queries/test/SystemPromptInjection/SystemPromptInjection.expected @@ -0,0 +1,14 @@ +edges +| mcp-host/src/llm/systemPromptInjection.ts:8:9:8:15 | persona | mcp-host/src/llm/systemPromptInjection.ts:12:73:12:79 | persona | provenance | | +| mcp-host/src/llm/systemPromptInjection.ts:8:19:8:49 | String( ... \|\| '') | mcp-host/src/llm/systemPromptInjection.ts:8:9:8:15 | persona | provenance | | +| mcp-host/src/llm/systemPromptInjection.ts:8:26:8:42 | req.query.persona | mcp-host/src/llm/systemPromptInjection.ts:8:19:8:49 | String( ... \|\| '') | provenance | | +| mcp-host/src/llm/systemPromptInjection.ts:12:73:12:79 | persona | mcp-host/src/llm/systemPromptInjection.ts:12:34:12:82 | `You ar ... sona}.` | provenance | | +nodes +| mcp-host/src/llm/systemPromptInjection.ts:8:9:8:15 | persona | semmle.label | persona | +| mcp-host/src/llm/systemPromptInjection.ts:8:19:8:49 | String( ... \|\| '') | semmle.label | String( ... \|\| '') | +| mcp-host/src/llm/systemPromptInjection.ts:8:26:8:42 | req.query.persona | semmle.label | req.query.persona | +| mcp-host/src/llm/systemPromptInjection.ts:12:34:12:82 | `You ar ... sona}.` | semmle.label | `You ar ... sona}.` | +| mcp-host/src/llm/systemPromptInjection.ts:12:73:12:79 | persona | semmle.label | persona | +subpaths +#select +| mcp-host/src/llm/systemPromptInjection.ts:12:34:12:82 | `You ar ... sona}.` | mcp-host/src/llm/systemPromptInjection.ts:8:26:8:42 | req.query.persona | mcp-host/src/llm/systemPromptInjection.ts:12:34:12:82 | `You ar ... sona}.` | This system prompt depends on a $@. | mcp-host/src/llm/systemPromptInjection.ts:8:26:8:42 | req.query.persona | user-provided value | diff --git a/.github/codeql/queries/test/SystemPromptInjection/SystemPromptInjection.qlref b/.github/codeql/queries/test/SystemPromptInjection/SystemPromptInjection.qlref new file mode 100644 index 0000000000..d8ef59e125 --- /dev/null +++ b/.github/codeql/queries/test/SystemPromptInjection/SystemPromptInjection.qlref @@ -0,0 +1 @@ +Security/CWE-1427/SystemPromptInjection.ql diff --git a/.github/codeql/queries/test/SystemPromptInjection/mcp-host/src/llm/systemPromptInjection.ts b/.github/codeql/queries/test/SystemPromptInjection/mcp-host/src/llm/systemPromptInjection.ts new file mode 100644 index 0000000000..70ddda4160 --- /dev/null +++ b/.github/codeql/queries/test/SystemPromptInjection/mcp-host/src/llm/systemPromptInjection.ts @@ -0,0 +1,30 @@ +import express from 'express' +import OpenAI from 'openai' + +const app = express() +const client = new OpenAI({ apiKey: 'test-key' }) + +app.get('/unsafe', async (req, res) => { + const persona = String(req.query.persona || '') + const response = await client.chat.completions.create({ + model: 'gpt-4.1', + messages: [ + { role: 'system', content: `You are a helpful assistant. Act as ${persona}.` }, + { role: 'user', content: String(req.query.message || '') }, + ], + }) + res.json(response) +}) + +app.get('/safe', async (req, res) => { + const persona = String(req.query.persona || '') + const response = await client.chat.completions.create({ + model: 'gpt-4.1', + messages: [ + { role: 'system', content: 'You are a helpful assistant.' }, + { role: 'user', content: `Persona to act as: ${persona}` }, + { role: 'user', content: String(req.query.message || '') }, + ], + }) + res.json(response) +}) diff --git a/.github/codeql/queries/test/qlpack.yml b/.github/codeql/queries/test/qlpack.yml index c6e77ef925..5289e0b9fc 100644 --- a/.github/codeql/queries/test/qlpack.yml +++ b/.github/codeql/queries/test/qlpack.yml @@ -2,4 +2,5 @@ name: evenfire/codeql-query-tests version: 0.0.1 dependencies: evenfire/codeql-queries: "*" + codeql/javascript-queries: 2.4.3 extractor: javascript diff --git a/.github/codeql/queries/test/verify-security-and-quality-suite.sh b/.github/codeql/queries/test/verify-security-and-quality-suite.sh new file mode 100755 index 0000000000..29942ee3ed --- /dev/null +++ b/.github/codeql/queries/test/verify-security-and-quality-suite.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +codeql=${1:?usage: verify-security-and-quality-suite.sh } +repo_root=$(cd "$(dirname "$0")/../../../.." && pwd) +suite="$repo_root/.github/codeql/queries/evenfire-security-and-quality.qls" + +resolved_queries=() +while IFS= read -r query_path; do + resolved_queries+=("$query_path") +done < <("$codeql" resolve queries --format=json "$suite" | jq -r '.[]') + +count_suffix() { + local suffix=$1 + local path + local count=0 + for path in "${resolved_queries[@]}"; do + [[ $path == *"$suffix" ]] && ((count += 1)) + done + printf '%s\n' "$count" +} + +single_query_with_id() { + local suffix=$1 + local expected_id=$2 + local path + local count + count=$(count_suffix "$suffix") + [[ $count == 1 ]] || { + echo "expected exactly one resolved query ending in $suffix; found $count" >&2 + return 1 + } + for path in "${resolved_queries[@]}"; do + [[ $path == *"$suffix" ]] || continue + [[ $("$codeql" resolve metadata "$path" | jq -r '.id // empty') == "$expected_id" ]] || { + echo "resolved query $path does not declare $expected_id" >&2 + return 1 + } + done +} + +while IFS= read -r excluded_id; do + case "$excluded_id" in + js/missing-rate-limiting) + single_query_with_id "/.github/codeql/queries/EvenfireMissingRateLimiting.ql" "$excluded_id" + ;; + js/user-controlled-bypass) + single_query_with_id "/.github/codeql/queries/EvenfireUserControlledBypass.ql" "$excluded_id" + ;; + *) + echo "stock security query exclusion lacks an approved repository replacement: $excluded_id" >&2 + exit 1 + ;; + esac +done < <(awk ' + /^- exclude:/ { awaiting_id = 1; next } + awaiting_id && $1 == "id:" { print $2; awaiting_id = 0 } +' "$suite") + +single_query_with_id "/Security/CWE-1427/SystemPromptInjection.ql" "js/system-prompt-injection" +single_query_with_id "/.github/codeql/queries/EvenfireMissingRateLimiting.ql" "js/missing-rate-limiting" +single_query_with_id "/.github/codeql/queries/EvenfireUserControlledBypass.ql" "js/user-controlled-bypass" From 2392a668953e3c8af5183a129791b9ceab0e2011 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Sun, 30 Aug 2026 18:55:29 -0600 Subject: [PATCH 162/528] fix(codeql): r16-b2 keep system prompt fixture synthetic Class-check: R16-B1 SystemPromptInjection synthetic fixture T2-sensitive literals -> 1 site -> remove materialized credential-shaped API-key assignment; 0 remain. Repro-test: T2_PUBLIC_BASE_REF=origin/dev bash scripts/tests/test-minikube-t2-public-boundary.sh (fails at 0b47c0352b7493fec3a491c65d30beca97b0eb8a) --- .../mcp-host/src/llm/systemPromptInjection.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/codeql/queries/test/SystemPromptInjection/mcp-host/src/llm/systemPromptInjection.ts b/.github/codeql/queries/test/SystemPromptInjection/mcp-host/src/llm/systemPromptInjection.ts index 70ddda4160..a69f540d65 100644 --- a/.github/codeql/queries/test/SystemPromptInjection/mcp-host/src/llm/systemPromptInjection.ts +++ b/.github/codeql/queries/test/SystemPromptInjection/mcp-host/src/llm/systemPromptInjection.ts @@ -2,7 +2,7 @@ import express from 'express' import OpenAI from 'openai' const app = express() -const client = new OpenAI({ apiKey: 'test-key' }) +const client = new OpenAI() app.get('/unsafe', async (req, res) => { const persona = String(req.query.persona || '') From 082a9fdf70d03b2fd181325951564a7d55dfba88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 31 Aug 2026 15:52:10 -0600 Subject: [PATCH 163/528] fix(control-api): r18-b1 rate-limit before runtime policy work R18-B1 Class-check: createExternalAccessRouter runtime-policy routes -> 3 sites -> catalog/resolve use D33 split stages; capabilities keeps its existing contract. Repro-test: control-api/test/routes.externalAccess.rateLimitOrdering.test.ts (fails at d83425f16e737e9c0b91ad2100780a41a8892251) --- control-api/src/routes/external/access.ts | 18 +- .../auth/externalSessionAuthentication.ts | 118 ++++++-- ...s.externalAccess.rateLimitOrdering.test.ts | 267 ++++++++++++++++++ 3 files changed, 374 insertions(+), 29 deletions(-) create mode 100644 control-api/test/routes.externalAccess.rateLimitOrdering.test.ts diff --git a/control-api/src/routes/external/access.ts b/control-api/src/routes/external/access.ts index 904461cecd..187c85b59d 100644 --- a/control-api/src/routes/external/access.ts +++ b/control-api/src/routes/external/access.ts @@ -4,6 +4,8 @@ import { sendPublicApiError } from '../../http/publicApiError.js' import type { K8sGateway } from '../../k8s.js' import { type ExternalAuthedRequest, + requireCompletedExternalSessionAuthenticationWithPublicErrors, + requireExternalSessionLimiterIdentityWithPublicErrors, requireValidExternalSessionTokenWithPublicErrors, } from '../../middleware/externalSessionAuth.js' import { @@ -179,10 +181,10 @@ function sendCatalogError( export function createExternalAccessRouter(gateway: K8sGateway): Router { const router = Router() router.use('/external/access', attachAccessExecutionBudget) - router.use('/external/access', requireValidExternalSessionTokenWithPublicErrors) router.get( '/external/access/capabilities', + requireValidExternalSessionTokenWithPublicErrors, requireAuthenticatedExternalUserRateLimitContext, rateLimitMiddleware(externalUserRateLimitOptions('access_capabilities', 'authenticated')), asyncHandler(async (req: ExternalAuthedRequest, res) => { @@ -208,12 +210,13 @@ export function createExternalAccessRouter(gateway: K8sGateway): Router { router.get( '/external/access/catalog', - asyncHandler((req, res, next) => requireEffectiveV2Contract('catalog', req, res, next)), + requireExternalSessionLimiterIdentityWithPublicErrors, + requireAuthenticatedExternalUserRateLimitContext, rateLimitMiddleware({ bucketType: 'external_access_catalog', maxPerMinute: CATALOG_RATE_LIMIT_PER_MINUTE, getBucketKey: req => - `external_access_catalog:${(req as ExternalAuthedRequest).externalAuth?.userId ?? 'unknown'}`, + `external_access_catalog:${(req as ExternalAuthedRequest).externalAuth!.userId}`, onLimited: (req, res, retryAfterSeconds) => sendPublicApiError( req, @@ -225,6 +228,8 @@ export function createExternalAccessRouter(gateway: K8sGateway): Router { { retryAfterSeconds } ), }), + requireCompletedExternalSessionAuthenticationWithPublicErrors, + asyncHandler((req, res, next) => requireEffectiveV2Contract('catalog', req, res, next)), asyncHandler(async (req: ExternalAuthedRequest, res) => { const families = catalogFamilies(req.query.families) const limit = pageLimit(req.query.limit) @@ -252,12 +257,13 @@ export function createExternalAccessRouter(gateway: K8sGateway): Router { router.post( '/external/access/resolve', - asyncHandler((req, res, next) => requireEffectiveV2Contract('action', req, res, next)), + requireExternalSessionLimiterIdentityWithPublicErrors, + requireAuthenticatedExternalUserRateLimitContext, rateLimitMiddleware({ bucketType: 'external_access_resolve', maxPerMinute: RESOLVE_RATE_LIMIT_PER_MINUTE, getBucketKey: req => - `external_access_resolve:${(req as ExternalAuthedRequest).externalAuth?.userId ?? 'unknown'}`, + `external_access_resolve:${(req as ExternalAuthedRequest).externalAuth!.userId}`, onLimited: (req, res, retryAfterSeconds) => sendPublicApiError( req, @@ -269,6 +275,8 @@ export function createExternalAccessRouter(gateway: K8sGateway): Router { { retryAfterSeconds } ), }), + requireCompletedExternalSessionAuthenticationWithPublicErrors, + asyncHandler((req, res, next) => requireEffectiveV2Contract('action', req, res, next)), asyncHandler(async (req: ExternalAuthedRequest, res) => { const body = req.body as Record | undefined const rawResource = diff --git a/control-api/src/services/auth/externalSessionAuthentication.ts b/control-api/src/services/auth/externalSessionAuthentication.ts index ede0b57069..02caa7de58 100644 --- a/control-api/src/services/auth/externalSessionAuthentication.ts +++ b/control-api/src/services/auth/externalSessionAuthentication.ts @@ -32,17 +32,23 @@ export type ExternalSessionClient = Readonly<{ requestedContract?: 'v1' | 'v2' }> -export type ExternalSessionAuthentication = +export type ExternalSessionIdentityAuthentication = | { status: 'authenticated' claims: AuthClaims contract: 'v1' | 'v2' authorityContext: ExternalSessionAuthorityContext - policy: EffectiveUserAccessPolicy + tokenClaims: AuthClaims | UserSessionV2Claims } | { status: 'invalid' | 'expired' | 'revoked'; reason: string } | { status: 'upgrade_required'; reason: string } +export type ExternalSessionAuthentication = + | (Extract & { + policy: EffectiveUserAccessPolicy + }) + | Exclude + export type ExternalSessionRenewal = | { status: 'renewed'; session: IssuedUserSession } | Exclude @@ -53,6 +59,7 @@ type AuthenticationOptions = Readonly<{ policy?: EffectiveUserAccessPolicy budget?: AccessExecutionBudget now?: Date + identity?: Extract }> export type ExternalSessionAuthorityContext = Readonly< @@ -129,27 +136,16 @@ function normalizedV2Claims( } /** - * Sole external-user authentication boundary. No route or middleware may call - * a low-level external-user token verifier directly. + * The identity-only stage of the sole external-user authentication boundary. + * It is sufficient to derive a trusted authenticated rate-limit subject, but + * intentionally performs no effective-policy, rollout, or readiness lookup. */ -export async function authenticateExternalUserSession( +export async function authenticateExternalUserSessionIdentity( token: string, - options: AuthenticationOptions -): Promise { - const policy = - options.policy ?? (await resolveEffectiveUserAccessPolicy({ budget: options.budget })) - if ( - purposeRequiresMinimumClient(options.purpose) && - !clientMeetsMinimum(options.client, policy) - ) { - return { status: 'upgrade_required', reason: 'minimum_client_version' } - } - + options: Pick = {} +): Promise { const v2Claims = verifyUserSessionV2Token(token) if (v2Claims) { - if (!v2Allowed(options.purpose, policy)) { - return { status: 'invalid', reason: 'session_contract_not_accepted' } - } const validation = await validateUserSessionClaims(v2Claims, { now: options.now, budget: options.budget, @@ -159,6 +155,7 @@ export async function authenticateExternalUserSession( status: 'authenticated', contract: 'v2', claims: normalizedV2Claims(v2Claims, validation.identity), + tokenClaims: v2Claims, authorityContext: Object.freeze({ contract: 'v2', userId: v2Claims.sub, @@ -166,15 +163,11 @@ export async function authenticateExternalUserSession( jti: v2Claims.jti, sessionVersion: v2Claims.sv, }), - policy, } } const v1Claims = verifyExternalSessionToken(token) if (!v1Claims) return { status: 'invalid', reason: 'invalid_representation' } - if (!v1Allowed(options.purpose, policy)) { - return { status: 'invalid', reason: 'session_contract_not_accepted' } - } const validation = await validateLegacyUserSession(token, v1Claims, { budget: options.budget, }) @@ -190,6 +183,7 @@ export async function authenticateExternalUserSession( ...v1Claims, authGeneration, }), + tokenClaims: v1Claims, authorityContext: Object.freeze({ contract: 'v1', userId: v1Claims.userId, @@ -197,10 +191,86 @@ export async function authenticateExternalUserSession( issuedAt: v1Claims.iat!, authGeneration, }), - policy, } } +/** + * Rechecks mutable session authority after the authenticated limiter permits + * the request. It reuses the Stage-A verified claims and does not re-verify + * the bearer representation. + */ +async function revalidateExternalUserSessionIdentity( + token: string, + identity: Extract, + options: Pick +): Promise { + if (identity.contract === 'v2') { + const validation = await validateUserSessionClaims( + identity.tokenClaims as UserSessionV2Claims, + { + now: options.now, + budget: options.budget, + } + ) + if (validation.status !== 'valid') return validation + const claims = identity.tokenClaims as UserSessionV2Claims + return { + ...identity, + claims: normalizedV2Claims(claims, validation.identity), + } + } + + const claims = identity.tokenClaims as AuthClaims + const validation = await validateLegacyUserSession(token, claims, { + budget: options.budget, + }) + if (validation.status !== 'valid') return validation + return identity +} + +/** + * Sole external-user authentication boundary. No route or middleware may call + * a low-level external-user token verifier directly. + */ +export async function authenticateExternalUserSession( + token: string, + options: AuthenticationOptions +): Promise { + const policy = + options.policy ?? (await resolveEffectiveUserAccessPolicy({ budget: options.budget })) + const identity = options.identity + ? await revalidateExternalUserSessionIdentity(token, options.identity, { + budget: options.budget, + now: options.now, + }) + : null + if (identity && identity.status !== 'authenticated') return identity + + if ( + purposeRequiresMinimumClient(options.purpose) && + !clientMeetsMinimum(options.client, policy) + ) { + return { status: 'upgrade_required', reason: 'minimum_client_version' } + } + + const authenticatedIdentity = + identity ?? + (await authenticateExternalUserSessionIdentity(token, { + budget: options.budget, + now: options.now, + })) + if (authenticatedIdentity.status !== 'authenticated') return authenticatedIdentity + + if ( + (authenticatedIdentity.contract === 'v1' && !v1Allowed(options.purpose, policy)) || + (authenticatedIdentity.contract === 'v2' && !v2Allowed(options.purpose, policy)) + ) { + return { status: 'invalid', reason: 'session_contract_not_accepted' } + } + + return { ...authenticatedIdentity, policy } +} + export async function renewExternalUserSession( token: string, options: Omit = {} diff --git a/control-api/test/routes.externalAccess.rateLimitOrdering.test.ts b/control-api/test/routes.externalAccess.rateLimitOrdering.test.ts new file mode 100644 index 0000000000..cf64f5880b --- /dev/null +++ b/control-api/test/routes.externalAccess.rateLimitOrdering.test.ts @@ -0,0 +1,267 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import request from 'supertest' +import { createExternalAccessRouter } from '../src/routes/external/access.js' +import { canonicalEnvironmentId } from '../src/services/access/operationalAccessProjection.js' +import { signUserSessionV2Token } from '../src/utils/auth/userSessionV2Token.js' + +const mocks = vi.hoisted(() => ({ + validateV1: vi.fn(), + validateV2: vi.fn(), + resolvePolicy: vi.fn(), + resolveAuthorization: vi.fn(), +})) + +const rateLimiter = vi.hoisted(() => ({ checkAndIncrement: vi.fn() })) + +vi.mock('../src/services/auth/userSessionService.js', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + validateLegacyUserSession: mocks.validateV1, + validateUserSessionClaims: mocks.validateV2, + } +}) +vi.mock('../src/services/access/userAccessRuntimePolicy.js', () => ({ + resolveEffectiveUserAccessPolicy: mocks.resolvePolicy, +})) +vi.mock('../src/services/access/liveAuthorizationResolver.js', () => ({ + resolveLiveAuthorization: mocks.resolveAuthorization, +})) +vi.mock('../src/services/rateLimiterService.js', () => rateLimiter) + +const userId = '10000000-0000-4000-8000-000000000001' +const sessionId = '20000000-0000-4000-8000-000000000002' +const sessionJti = '30000000-0000-4000-8000-000000000003' + +function sessionToken() { + return signUserSessionV2Token({ + sub: userId, + sid: sessionId, + jti: sessionJti, + sv: 1, + email: 'user@example.test', + auth_time: Math.floor(Date.now() / 1000), + amr: ['pwd'], + }) +} + +function effectivePolicy(overrides: Record = {}) { + return { + policyVersion: '1', + policyRevision: 'test-policy', + acceptV1: true, + issueV1: true, + acceptV2: true, + issueV2: false, + renewV2: false, + switchCompatibility: true, + computeCatalogShadow: false, + serveCatalog: true, + actionContextV2: true, + rpcDelegationV2: false, + desktopAllTeamMode: false, + profileV2Mode: false, + minimumClientVersion: null, + enforceMinimumClient: false, + advertisedCatalogFamilies: [], + ...overrides, + } +} + +function app() { + const value = express() + value.use(express.json()) + value.use(createExternalAccessRouter({} as never)) + return value +} + +describe('external access authenticated rate-limit ordering', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.validateV2.mockResolvedValue({ + status: 'valid', + identity: { + userId, + email: 'user@example.test', + sid: sessionId, + jti: sessionJti, + sessionVersion: 1, + }, + }) + mocks.resolvePolicy.mockResolvedValue(effectivePolicy()) + rateLimiter.checkAndIncrement.mockResolvedValue({ + allowed: false, + remaining: 0, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + count: 11, + }) + }) + + it.each([ + ['GET', '/external/access/catalog', 'external_access_catalog'], + ['POST', '/external/access/resolve', 'external_access_resolve'], + ] as const)( + 'returns 429 before runtime policy work for a limited %s %s request', + async (method, path, bucketType) => { + const response = await request(app()) + [method === 'GET' ? 'get' : 'post'](path) + .set('x-user-session-token', sessionToken()) + .send(method === 'POST' ? {} : undefined) + + expect(response.status).toBe(429) + expect(response.body).toMatchObject({ error: { code: 'rate_limited' } }) + expect(response.headers['retry-after']).toBeDefined() + expect(rateLimiter.checkAndIncrement).toHaveBeenCalledWith(`${bucketType}:${userId}`, 10) + expect(mocks.resolvePolicy).not.toHaveBeenCalled() + expect(mocks.resolveAuthorization).not.toHaveBeenCalled() + } + ) + + it.each([ + ['GET', '/external/access/catalog'], + ['POST', '/external/access/resolve'], + ] as const)( + 'rejects an invalid %s %s session before limiting or policy work', + async (method, path) => { + const response = await request(app()) + [method === 'GET' ? 'get' : 'post'](path) + .set('x-user-session-token', 'invalid-session') + .send(method === 'POST' ? {} : undefined) + + expect(response.status).toBe(401) + expect(response.body).toMatchObject({ error: { code: 'invalid_session' } }) + expect(rateLimiter.checkAndIncrement).not.toHaveBeenCalled() + expect(mocks.resolvePolicy).not.toHaveBeenCalled() + } + ) + + it.each([ + ['GET', '/external/access/catalog'], + ['POST', '/external/access/resolve'], + ] as const)( + 'preserves 409 only after an allowed %s %s bucket reaches final policy validation', + async (method, path) => { + rateLimiter.checkAndIncrement.mockResolvedValue({ + allowed: true, + remaining: 9, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + count: 1, + }) + mocks.resolvePolicy.mockResolvedValue( + effectivePolicy({ serveCatalog: false, actionContextV2: false }) + ) + + const response = await request(app()) + [method === 'GET' ? 'get' : 'post'](path) + .set('x-user-session-token', sessionToken()) + .send(method === 'POST' ? {} : undefined) + + expect(response.status).toBe(409) + expect(response.body).toMatchObject({ error: { code: 'invalid_request' } }) + expect(mocks.resolvePolicy).toHaveBeenCalledTimes(2) + } + ) + + it.each([ + ['GET', '/external/access/catalog'], + ['POST', '/external/access/resolve'], + ] as const)( + 'preserves 503 only after an allowed %s %s bucket reaches final policy validation', + async (method, path) => { + rateLimiter.checkAndIncrement.mockResolvedValue({ + allowed: true, + remaining: 9, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + count: 1, + }) + mocks.resolvePolicy.mockRejectedValue(new Error('readiness unavailable')) + + const response = await request(app()) + [method === 'GET' ? 'get' : 'post'](path) + .set('x-user-session-token', sessionToken()) + .send(method === 'POST' ? {} : undefined) + + expect(response.status).toBe(503) + expect(response.body).toMatchObject({ error: { code: 'authority_unavailable' } }) + expect(mocks.resolvePolicy).toHaveBeenCalledTimes(1) + } + ) + + it('continues an allowed resolve request after final policy validation', async () => { + rateLimiter.checkAndIncrement.mockResolvedValue({ + allowed: true, + remaining: 9, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + count: 1, + }) + const teamId = '40000000-0000-4000-8000-000000000004' + const accessPathId = `ap1_${'A'.repeat(43)}` + mocks.resolveAuthorization.mockResolvedValue({ + status: 'allowed', + effectiveCapabilities: ['team.manage'], + paths: [{ id: accessPathId, kind: 'team', teamId }], + selectedPath: { id: accessPathId, kind: 'team', teamId }, + authorizationRevision: 'revision-1', + }) + + const response = await request(app()) + .post('/external/access/resolve') + .set('x-user-session-token', sessionToken()) + .send({ + requiredCapability: 'team.manage', + resource: { + environmentId: canonicalEnvironmentId(), + type: 'team', + logicalId: teamId, + }, + operationTarget: { teamId, action: 'rename' }, + }) + + expect(response.status).toBe(200) + expect(mocks.resolvePolicy).toHaveBeenCalledTimes(2) + expect(mocks.resolveAuthorization).toHaveBeenCalledOnce() + }) + + it.each([ + ['GET', '/external/access/catalog'], + ['POST', '/external/access/resolve'], + ] as const)( + 'rechecks current session authority after an allowed %s %s bucket', + async (method, path) => { + rateLimiter.checkAndIncrement.mockResolvedValue({ + allowed: true, + remaining: 9, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + count: 1, + }) + mocks.validateV2 + .mockResolvedValueOnce({ + status: 'valid', + identity: { + userId, + email: 'user@example.test', + sid: sessionId, + jti: sessionJti, + sessionVersion: 1, + }, + }) + .mockResolvedValueOnce({ status: 'revoked', reason: 'logout' }) + + const response = await request(app()) + [method === 'GET' ? 'get' : 'post'](path) + .set('x-user-session-token', sessionToken()) + .send(method === 'POST' ? {} : undefined) + + expect(response.status).toBe(401) + expect(response.body).toMatchObject({ error: { code: 'invalid_session' } }) + expect(mocks.validateV2).toHaveBeenCalledTimes(2) + expect(mocks.resolveAuthorization).not.toHaveBeenCalled() + } + ) +}) From 8e117873eebf5093b6e6f9dc7f6f384128072b50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 31 Aug 2026 19:13:51 -0600 Subject: [PATCH 164/528] fix(control-api): preserve sandbox catalog keys R1-B9 Class-check: sandbox_app producer arms -> 2 arms -> both duplicate-capable Repro-test: npm test -- test/catalogSandboxDuplicateClassification.test.ts (fails at 4aab0f05e1e8de3fc10500f8e0125ed824e9df21) --- .../src/services/access/catalogProducerSql.ts | 2 + ...agination.realPostgres.integration.test.ts | 67 +++++++++++++++++++ ...alogSandboxDuplicateClassification.test.ts | 11 +++ 3 files changed, 80 insertions(+) create mode 100644 control-api/test/catalogSandboxDuplicateClassification.test.ts diff --git a/control-api/src/services/access/catalogProducerSql.ts b/control-api/src/services/access/catalogProducerSql.ts index 6d32e43f31..91ea2059e6 100644 --- a/control-api/src/services/access/catalogProducerSql.ts +++ b/control-api/src/services/access/catalogProducerSql.ts @@ -391,6 +391,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f WHERE uwt.user_id = $1 AND uwt.recipe_namespace || '/' || uwt.recipe_name = edge.source_id )`, + duplicateCapable: true, }, { sql: `SELECT edge.target_id AS logical_id @@ -416,6 +417,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f WHERE tm.user_id = $1 AND tm.status = 'active' AND twt.recipe_namespace || '/' || twt.recipe_name = edge.source_id )`, + duplicateCapable: true, }, ]), }) diff --git a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts index 685ddc20e9..2acbb234c5 100644 --- a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts +++ b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts @@ -677,6 +677,73 @@ describeRealPostgres('distinct catalog key pagination on real PostgreSQL', () => minimumPathsForFirst: 3, }) } + + for (const mode of ['direct', 'team'] as const) { + const actor = await principal(`sandbox-${mode}`, mode === 'team' ? 3 : 0) + const recipes = ['a', 'b', 'c'].map( + name => `${config.sandboxNamespace}/sandbox-${mode}-${name}` + ) + const apps = ['a', 'b', 'c'].map( + name => `${config.sandboxNamespace}/sandbox-app-${mode}-${name}` + ) + for (const recipe of recipes) + await operationalResource('workflow_recipe', recipe, 'workflow_recipe') + for (const app of apps) await operationalResource('sandbox_app', app, 'workflow_recipe') + for (let copy = 0; copy < 3; copy += 1) { + await relationship({ + sourceType: 'workflow_recipe', + sourceId: recipes[0], + relationshipType: 'exposes_sandbox_app', + targetType: 'sandbox_app', + targetId: apps[0], + instanceId: `sandbox-${mode}-a-${copy}`, + }) + } + for (const [index, app] of apps.slice(1).entries()) { + await relationship({ + sourceType: 'workflow_recipe', + sourceId: recipes[index + 1], + relationshipType: 'exposes_sandbox_app', + targetType: 'sandbox_app', + targetId: app, + instanceId: `sandbox-${mode}-${index + 1}`, + }) + } + if (mode === 'direct') { + for (const recipe of recipes) { + const [recipeNamespace, recipeName] = recipe.split('/') + await databasePool.query( + `INSERT INTO user_workflow_triggers(user_id, recipe_namespace, recipe_name) + VALUES ($1, $2, $3)`, + [actor.userId, recipeNamespace, recipeName] + ) + } + } else { + for (const teamId of actor.teamIds) { + const [recipeNamespace, recipeName] = recipes[0].split('/') + await databasePool.query( + `INSERT INTO team_workflow_triggers(team_id, recipe_namespace, recipe_name) + VALUES ($1, $2, $3)`, + [teamId, recipeNamespace, recipeName] + ) + } + for (const recipe of recipes.slice(1)) { + const [recipeNamespace, recipeName] = recipe.split('/') + await databasePool.query( + `INSERT INTO team_workflow_triggers(team_id, recipe_namespace, recipe_name) + VALUES ($1, $2, $3)`, + [actor.teamIds[0], recipeNamespace, recipeName] + ) + } + } + await consumeFamily({ + family: 'sandbox_app', + userId: actor.userId, + expected: apps, + minimumPathsForFirst: 3, + minimumRelationshipsForFirst: 3, + }) + } }, 30_000) it('keeps sparse GFS access subject-indexed instead of scanning unrelated resources', async () => { diff --git a/control-api/test/catalogSandboxDuplicateClassification.test.ts b/control-api/test/catalogSandboxDuplicateClassification.test.ts new file mode 100644 index 0000000000..c43ee77a5c --- /dev/null +++ b/control-api/test/catalogSandboxDuplicateClassification.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest' +import { CATALOG_KEY_SQL } from '../src/services/access/catalogProducerSql.js' + +describe('Sandbox catalog duplicate classification', () => { + it('keeps both relationship arms refillable after a duplicate-saturated batch', () => { + const sandboxSql = CATALOG_KEY_SQL.sandbox_app + + expect(sandboxSql.match(/\(SELECT COUNT\(\*\) FROM source_[01]\) >= \$4/g)).toHaveLength(2) + expect(sandboxSql).not.toContain('FALSE AS source_saturated') + }) +}) From f240234fb5f810622f48975f48ae2b681e2bc07e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 31 Aug 2026 19:13:59 -0600 Subject: [PATCH 165/528] fix(codeql): model staged external access limiting R19-B1 Class-check: D33 Stage-A exports -> 1 exact export -> same-route limiter required Repro-test: codeql test run .github/codeql/queries/test/MissingRateLimiting (fails at 4aab0f05e1e8de3fc10500f8e0125ed824e9df21) --- .../queries/EvenfireMissingRateLimiting.ql | 18 +++++ .../EvenfireMissingRateLimiting.expected | 10 +++ .../src/middleware/externalSessionAuth.ts | 18 +++++ .../src/middleware/unrelatedMiddleware.ts | 11 +++ .../control-api/src/routes/external/d33.ts | 81 +++++++++++++++++++ 5 files changed, 138 insertions(+) create mode 100644 .github/codeql/queries/test/MissingRateLimiting/control-api/src/routes/external/d33.ts diff --git a/.github/codeql/queries/EvenfireMissingRateLimiting.ql b/.github/codeql/queries/EvenfireMissingRateLimiting.ql index 5f40bc84c3..52d45c7395 100644 --- a/.github/codeql/queries/EvenfireMissingRateLimiting.ql +++ b/.github/codeql/queries/EvenfireMissingRateLimiting.ql @@ -86,6 +86,20 @@ private predicate isCanonicalExternalContextHandler(Routing::Node useSite) { ) } +private predicate isCanonicalExternalLimiterIdentityImport(ImportSpecifier spec) { + spec.getImportDeclaration().getImportedFile().getRelativePath() = + "control-api/src/middleware/externalSessionAuth.ts" and + spec.getImportedName() = "requireExternalSessionLimiterIdentityWithPublicErrors" +} + +private predicate isCanonicalExternalLimiterIdentityHandler(Routing::Node useSite) { + exists(ImportSpecifier spec, DataFlow::Node installedNode | + isCanonicalExternalLimiterIdentityImport(spec) and + useSite = Routing::getNode(installedNode) and + DataFlow::valueNode(spec).(DataFlow::SourceNode).flowsTo(installedNode) + ) +} + private predicate hasSameRouteEvenfireLimiterAfterContext(Routing::Node useSite) { exists(EvenfireRateLimitingMiddleware middleware, Routing::Node limiterNode | limiterNode = middleware.getRoutingNode() and @@ -150,6 +164,10 @@ private predicate hasEvenfireRateLimitingGuard(Routing::Node useSite) { useSite.mayResumeDispatch() and hasSameRouteEvenfireLimiterAfterContext(useSite) or + isCanonicalExternalLimiterIdentityHandler(useSite) and + useSite.mayResumeDispatch() and + hasSameRouteEvenfireLimiterAfterContext(useSite) + or isCanonicalExternalContextHandler(useSite) and useSite.mayResumeDispatch() and hasGlobalEvenfireLimiterAfterContext(useSite) diff --git a/.github/codeql/queries/test/MissingRateLimiting/EvenfireMissingRateLimiting.expected b/.github/codeql/queries/test/MissingRateLimiting/EvenfireMissingRateLimiting.expected index 69b5e39a42..f6037cb528 100644 --- a/.github/codeql/queries/test/MissingRateLimiting/EvenfireMissingRateLimiting.expected +++ b/.github/codeql/queries/test/MissingRateLimiting/EvenfireMissingRateLimiting.expected @@ -11,6 +11,16 @@ | control-api/src/routes/external/composition.ts:162:39:162:59 | nestedU ... tedWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/composition.ts:152:3:152:70 | fs.writ ... value') | a file system access | | control-api/src/routes/external/composition.ts:165:42:165:73 | require ... onToken | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:10:3:10:9 | login() | authorization | | control-api/src/routes/external/composition.ts:169:35:169:66 | require ... onToken | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:10:3:10:9 | login() | authorization | +| control-api/src/routes/external/d33.ts:29:3:29:55 | require ... cErrors | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:44:3:44:9 | login() | authorization | +| control-api/src/routes/external/d33.ts:30:3:30:44 | require ... cErrors | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:53:3:53:9 | login() | authorization | +| control-api/src/routes/external/d33.ts:31:3:31:15 | protectedWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/d33.ts:13:3:13:55 | fs.writ ... value') | a file system access | +| control-api/src/routes/external/d33.ts:38:3:38:55 | require ... cErrors | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:44:3:44:9 | login() | authorization | +| control-api/src/routes/external/d33.ts:39:3:39:15 | protectedWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/d33.ts:13:3:13:55 | fs.writ ... value') | a file system access | +| control-api/src/routes/external/d33.ts:45:3:45:55 | require ... cErrors | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:44:3:44:9 | login() | authorization | +| control-api/src/routes/external/d33.ts:46:3:46:15 | protectedWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/d33.ts:13:3:13:55 | fs.writ ... value') | a file system access | +| control-api/src/routes/external/d33.ts:57:3:57:55 | require ... cErrors | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:44:3:44:9 | login() | authorization | +| control-api/src/routes/external/d33.ts:59:54:59:66 | protectedWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/d33.ts:13:3:13:55 | fs.writ ... value') | a file system access | +| control-api/src/routes/external/d33.ts:69:3:69:20 | requireWrongStageA | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/unrelatedMiddleware.ts:13:3:13:9 | login() | authorization | | control-api/src/routes/external/modeling.ts:39:52:39:66 | unprotectedWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/modeling.ts:14:3:14:63 | fs.writ ... value') | a file system access | | control-api/src/routes/external/modeling.ts:43:3:43:18 | orderedWrongWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/modeling.ts:19:3:19:65 | fs.writ ... value') | a file system access | | control-api/src/routes/external/modeling.ts:47:50:47:62 | unrelatedWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/modeling.ts:24:3:24:61 | fs.writ ... value') | a file system access | diff --git a/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/externalSessionAuth.ts b/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/externalSessionAuth.ts index 3849e03edd..ef706ae052 100644 --- a/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/externalSessionAuth.ts +++ b/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/externalSessionAuth.ts @@ -35,3 +35,21 @@ export function requireExternalTeamParamMatch( fs.writeFileSync('/tmp/evenfire-codeql-team-match', 'value') next() } + +export async function requireExternalSessionLimiterIdentityWithPublicErrors( + _req: unknown, + _res: unknown, + next: () => void, +) { + login() + next() +} + +export async function requireEffectiveV2ContractWithPublicErrors( + _req: unknown, + _res: unknown, + next: () => void, +) { + login() + next() +} diff --git a/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/unrelatedMiddleware.ts b/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/unrelatedMiddleware.ts index 05e83e8ae9..12756e689d 100644 --- a/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/unrelatedMiddleware.ts +++ b/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/unrelatedMiddleware.ts @@ -1,3 +1,14 @@ export function unrelatedMiddleware() { return (_req: unknown, _res: unknown, next: () => void) => next() } + +declare function login(): void + +export async function requireExternalSessionLimiterIdentityWithPublicErrors( + _req: unknown, + _res: unknown, + next: () => void, +) { + login() + next() +} diff --git a/.github/codeql/queries/test/MissingRateLimiting/control-api/src/routes/external/d33.ts b/.github/codeql/queries/test/MissingRateLimiting/control-api/src/routes/external/d33.ts new file mode 100644 index 0000000000..cf6d8ce9ad --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimiting/control-api/src/routes/external/d33.ts @@ -0,0 +1,81 @@ +import express from 'express' +import fs from 'fs' +import { + requireEffectiveV2ContractWithPublicErrors, + requireExternalSessionLimiterIdentityWithPublicErrors, +} from '../../middleware/externalSessionAuth' +import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware' +import { + requireExternalSessionLimiterIdentityWithPublicErrors as requireWrongStageA, +} from '../../middleware/unrelatedMiddleware' + +function protectedWork(_req: express.Request, res: express.Response) { + fs.writeFileSync('/tmp/evenfire-codeql-d33', 'value') + res.sendStatus(204) +} + +const safeD33Router = express.Router() +safeD33Router.get( + '/safe', + requireExternalSessionLimiterIdentityWithPublicErrors, + rateLimitMiddleware({ type: 'external_access_catalog', max: 10 }), + requireEffectiveV2ContractWithPublicErrors, + protectedWork, +) + +const reversedD33Router = express.Router() +reversedD33Router.get( + '/reversed', + requireExternalSessionLimiterIdentityWithPublicErrors, + requireEffectiveV2ContractWithPublicErrors, + protectedWork, + rateLimitMiddleware({ type: 'external_access_catalog', max: 10 }), +) + +const missingLimiterRouter = express.Router() +missingLimiterRouter.get( + '/missing', + requireExternalSessionLimiterIdentityWithPublicErrors, + protectedWork, +) + +const siblingLimiterRouter = express.Router() +siblingLimiterRouter.get( + '/unprotected', + requireExternalSessionLimiterIdentityWithPublicErrors, + protectedWork, +) +siblingLimiterRouter.get( + '/protected', + rateLimitMiddleware({ type: 'external_access_catalog', max: 10 }), + protectedWork, +) + +const nonDominatingParentRouter = express.Router() +nonDominatingParentRouter.use( + '/parent', + requireExternalSessionLimiterIdentityWithPublicErrors, +) +nonDominatingParentRouter.get('/parent/unprotected', protectedWork) +nonDominatingParentRouter.get( + '/parent/limited', + rateLimitMiddleware({ type: 'external_access_catalog', max: 10 }), + protectedWork, +) + +const wrongModuleRouter = express.Router() +wrongModuleRouter.get( + '/wrong-module', + requireWrongStageA, + rateLimitMiddleware({ type: 'external_access_catalog', max: 10 }), + protectedWork, +) + +export { + missingLimiterRouter, + nonDominatingParentRouter, + reversedD33Router, + safeD33Router, + siblingLimiterRouter, + wrongModuleRouter, +} From 545dafb30c95022d3effff58a82d7081b8c10f08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 31 Aug 2026 19:15:04 -0600 Subject: [PATCH 166/528] test(external-rest-api): harden error mapping coverage R20-M1 Class-check: public error mappings -> 18 statuses x accepted/fallback -> 36 cases Repro-test: npm test -- src/__tests__/publicErrorMappingMutationSensitivity.test.ts (fails at 4aab0f05e1e8de3fc10500f8e0125ed824e9df21) --- external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- ...licErrorMappingMutationSensitivity.test.ts | 44 +++++++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 external-rest-api/src/__tests__/publicErrorMappingMutationSensitivity.test.ts diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index ff88a88560..1831f85dfc 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index d8dc7498ab..a2eb923322 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/__tests__/publicErrorMappingMutationSensitivity.test.ts b/external-rest-api/src/__tests__/publicErrorMappingMutationSensitivity.test.ts new file mode 100644 index 0000000000..703f0ba72f --- /dev/null +++ b/external-rest-api/src/__tests__/publicErrorMappingMutationSensitivity.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { ControlApiError } from '../controlApiClient.js' +import { sanitizeControlApiPublicError } from '../http/publicApiError.js' + +const STATUS_CODES = [ + [400, 'invalid_request'], + [401, 'invalid_session'], + [403, 'forbidden'], + [404, 'not_found'], + [408, 'request_timeout'], + [409, 'conflict'], + [410, 'gone'], + [411, 'length_required'], + [412, 'precondition_failed'], + [413, 'payload_too_large'], + [422, 'invalid_request'], + [425, 'too_early'], + [429, 'rate_limited'], + [500, 'internal_error'], + [502, 'upstream_unavailable'], + [503, 'authority_unavailable'], + [504, 'upstream_timeout'], + [507, 'insufficient_storage'], +] as const + +describe('public error mapping mutation sensitivity', () => { + it.each(STATUS_CODES)('accepts the canonical code for status %s', (status, expectedCode) => { + const result = sanitizeControlApiPublicError( + new ControlApiError('private', status, { error: { code: expectedCode } }), + new Set([status]) + ) + + expect(result?.body).toMatchObject({ error: { code: expectedCode } }) + }) + + it.each(STATUS_CODES)('selects the canonical fallback for status %s', (status, expectedCode) => { + const result = sanitizeControlApiPublicError( + new ControlApiError('private', status, { error: { code: 'not_allowed' } }), + new Set([status]) + ) + + expect(result?.body).toMatchObject({ error: { code: expectedCode } }) + }) +}) From 579b85147397bf448136ce9fb31534f3e7955f5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 31 Aug 2026 19:15:10 -0600 Subject: [PATCH 167/528] fix(external-rest-api): reject malformed access queries R20-M2 Class-check: access query fields -> cursor/families/limit -> six malformed forms rejected Repro-test: npm test -- test/routes.accessQueryValidation.test.ts (fails at 4aab0f05e1e8de3fc10500f8e0125ed824e9df21) --- external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- external-rest-api/src/routes/access.ts | 26 ++++++-- .../test/routes.accessQueryValidation.test.ts | 63 +++++++++++++++++++ 4 files changed, 87 insertions(+), 8 deletions(-) create mode 100644 external-rest-api/test/routes.accessQueryValidation.test.ts diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 1831f85dfc..ff88a88560 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index a2eb923322..d8dc7498ab 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/routes/access.ts b/external-rest-api/src/routes/access.ts index 201ec7f411..6f79088771 100644 --- a/external-rest-api/src/routes/access.ts +++ b/external-rest-api/src/routes/access.ts @@ -4,12 +4,27 @@ import { controlApiRequest } from '../controlApiClient.js' import { publicCorrelationId, sanitizeControlApiPublicError, + sendPublicApiError, sendSanitizedControlApiPublicError, } from '../http/publicApiError.js' import { type AuthedRequest, extractAuthToken, requireAuth } from '../middleware/auth.js' const ACCESS_PUBLIC_STATUSES = new Set([400, 401, 403, 404, 409, 429, 503]) +const ACCESS_CATALOG_QUERY_FIELDS = ['families', 'limit', 'cursor'] as const +function accessCatalogQuery(req: AuthedRequest): Record | null { + const query: Record = {} + for (const field of ACCESS_CATALOG_QUERY_FIELDS) { + const value = req.query[field] + if (value === undefined) { + query[field] = undefined + continue + } + if (typeof value !== 'string' || value.length === 0) return null + query[field] = value + } + return query +} function forwardAccessError(error: unknown, res: Response, next: NextFunction): void { const sanitized = sanitizeControlApiPublicError( error, @@ -39,14 +54,15 @@ export function createAccessRouter(): Router { }) router.get('/me/access/catalog', async (req: AuthedRequest, res, next) => { + const query = accessCatalogQuery(req) + if (!query) { + sendPublicApiError(req, res, 400, 'invalid_request', 'The catalog query is not valid.') + return + } try { const result = await controlApiRequest('GET', '/external/access/catalog', { userSessionToken: extractAuthToken(req), - query: { - families: typeof req.query.families === 'string' ? req.query.families : undefined, - limit: typeof req.query.limit === 'string' ? req.query.limit : undefined, - cursor: typeof req.query.cursor === 'string' ? req.query.cursor : undefined, - }, + query, }) res.status(200).json(result) } catch (error) { diff --git a/external-rest-api/test/routes.accessQueryValidation.test.ts b/external-rest-api/test/routes.accessQueryValidation.test.ts new file mode 100644 index 0000000000..8c6e3e8c14 --- /dev/null +++ b/external-rest-api/test/routes.accessQueryValidation.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import request from 'supertest' +import { createAccessRouter } from '../src/routes/access.js' + +const auth = vi.hoisted(() => ({ verifyToken: vi.fn() })) +vi.mock('../src/authToken.js', () => ({ verifyToken: auth.verifyToken })) + +function app() { + const value = express() + value.use(createAccessRouter()) + return value +} + +describe('access catalog query validation', () => { + beforeEach(() => { + vi.clearAllMocks() + auth.verifyToken.mockReturnValue({ userId: 'user-1', exp: 2_000_000_000 }) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ items: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) + ) + }) + + it.each([ + 'cursor=', + 'cursor=first&cursor=second', + 'families=', + 'families=team&families=host', + 'limit=', + 'limit=10&limit=20', + ])('rejects malformed query %s before contacting Control API', async query => { + const response = await request(app()) + .get(`/me/access/catalog?${query}`) + .set('authorization', 'Bearer session-token') + .expect(400) + + expect(response.body).toMatchObject({ error: { code: 'invalid_request' } }) + expect(fetch).not.toHaveBeenCalled() + }) + + it('preserves one value for every supported query field', async () => { + await request(app()) + .get('/me/access/catalog?families=team%2Chost&limit=25&cursor=c3.value.signature') + .set('authorization', 'Bearer session-token') + .expect(200) + + expect(fetch).toHaveBeenCalledTimes(1) + const requestUrl = String(vi.mocked(fetch).mock.calls[0]?.[0]) + expect(new URL(requestUrl).searchParams).toEqual( + new URLSearchParams({ + families: 'team,host', + limit: '25', + cursor: 'c3.value.signature', + }) + ) + }) +}) From e8f2fbac0659540168f7d9b4f229621d8f5cc580 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 31 Aug 2026 19:15:17 -0600 Subject: [PATCH 168/528] refactor(control-api): remove dead GFS limiter policies R20-L1 Class-check: generic GFS policies -> 2 entries -> zero production consumers removed Repro-test: npm test -- test/externalUserRateLimitPolicy.test.ts (fails at 4aab0f05e1e8de3fc10500f8e0125ed824e9df21) --- control-api/src/middleware/externalUserRateLimitPolicy.ts | 4 ---- control-api/test/externalUserRateLimitPolicy.test.ts | 2 -- 2 files changed, 6 deletions(-) diff --git a/control-api/src/middleware/externalUserRateLimitPolicy.ts b/control-api/src/middleware/externalUserRateLimitPolicy.ts index b666478cba..3312302dbe 100644 --- a/control-api/src/middleware/externalUserRateLimitPolicy.ts +++ b/control-api/src/middleware/externalUserRateLimitPolicy.ts @@ -21,8 +21,6 @@ export type ExternalUserRateLimitOperation = | 'authentication_attempt' | 'session_verify' | 'rpc_token' - | 'gfs_read' - | 'gfs_mutation' | 'team_user_read' | 'team_user_mutation' @@ -71,8 +69,6 @@ const POLICIES: Readonly> = { }, session_verify: { bucketType: 'external_session_verify', maxPerMinute: 10 }, rpc_token: { bucketType: 'external_rpc_token', maxPerMinute: 10 }, - gfs_read: { bucketType: 'external_gfs_read', maxPerMinute: 30 }, - gfs_mutation: { bucketType: 'external_gfs_mutation', maxPerMinute: 10 }, team_user_read: { bucketType: 'external_team_user_read', maxPerMinute: 30 }, team_user_mutation: { bucketType: 'external_team_user_mutation', maxPerMinute: 10 }, } diff --git a/control-api/test/externalUserRateLimitPolicy.test.ts b/control-api/test/externalUserRateLimitPolicy.test.ts index af05a7de2b..d71ac725eb 100644 --- a/control-api/test/externalUserRateLimitPolicy.test.ts +++ b/control-api/test/externalUserRateLimitPolicy.test.ts @@ -28,8 +28,6 @@ const operations = [ ['authentication_attempt', 'external_authentication_attempt', 5], ['session_verify', 'external_session_verify', 10], ['rpc_token', 'external_rpc_token', 10], - ['gfs_read', 'external_gfs_read', 30], - ['gfs_mutation', 'external_gfs_mutation', 10], ['team_user_read', 'external_team_user_read', 30], ['team_user_mutation', 'external_team_user_mutation', 10], ] as const From 6d58f25e99c58470a81aad1c7e79592b62c8b3c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 31 Aug 2026 19:15:37 -0600 Subject: [PATCH 169/528] fix(control-api): bound migration execution R20-B1 Class-check: post-0106 PR1 migrations -> 6 versions -> bounded per-version execution Repro-test: npm test -- test/db.initDb.test.ts (fails at 4aab0f05e1e8de3fc10500f8e0125ed824e9df21: expected 8 BEGIN, received 1) --- control-api/src/migrate.ts | 8 +- .../migrations/migrationExecutionPolicy.json | 11 + .../migrations/migrationExecutionPolicy.ts | 58 +++ control-api/src/migrations/migrationRunner.ts | 87 ++++ .../src/migrations/pr1OnlineIndexPlan.ts | 331 +++++++++++++++ .../db.gfsPermissionStoreMigration.test.ts | 3 +- .../test/db.governedRunTraceMigration.test.ts | 6 +- control-api/test/db.initDb.test.ts | 20 +- .../test/db.llmAllowedModelsMigration.test.ts | 3 +- .../test/db.llmModelPricesMigration.test.ts | 3 +- ...erRegistrationCredentialsMigration.test.ts | 6 +- .../db.oauthGrantsOwnerGeneralization.test.ts | 6 +- .../db.registryConnectionMigration.test.ts | 6 +- .../test/db.runtimeAccessMigrations.test.ts | 29 +- ...wApprovalMediumChannelRefMigration.test.ts | 3 +- ...pletedNotificationRefreshMigration.test.ts | 3 +- ...b.workflowRunsRecipeIndexMigration.test.ts | 3 +- .../test/helpers/onlineIndexCatalogMock.ts | 46 ++ control-api/test/migrate.test.ts | 27 +- ...ionPolicy.realPostgres.integration.test.ts | 398 ++++++++++++++++++ .../test/migrationExecutionPolicy.test.ts | 286 +++++++++++++ ...atibility.realPostgres.integration.test.ts | 110 +++++ .../scripts/run-control-api-db-migration.sh | 311 +++++++++++++- .../test-control-api-migration-bounds.sh | 315 ++++++++++++++ scripts/tests/test-gcp-scripts.sh | 47 ++- 25 files changed, 2061 insertions(+), 65 deletions(-) create mode 100644 control-api/src/migrations/migrationExecutionPolicy.json create mode 100644 control-api/src/migrations/migrationExecutionPolicy.ts create mode 100644 control-api/src/migrations/migrationRunner.ts create mode 100644 control-api/src/migrations/pr1OnlineIndexPlan.ts create mode 100644 control-api/test/helpers/onlineIndexCatalogMock.ts create mode 100644 control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts create mode 100644 control-api/test/migrationExecutionPolicy.test.ts create mode 100644 control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts create mode 100644 scripts/tests/test-control-api-migration-bounds.sh diff --git a/control-api/src/migrate.ts b/control-api/src/migrate.ts index a4244abf22..c30a3dc0ad 100644 --- a/control-api/src/migrate.ts +++ b/control-api/src/migrate.ts @@ -1,20 +1,22 @@ import type { Pool } from 'pg' import { resolveMigrationConnectionString } from './migrationConnection.js' +import { rootLogger } from './observability/logger.js' let migrationPool: Pool | undefined +const logger = rootLogger.child({ module: 'database-migration-entrypoint' }) async function main(): Promise { process.env.CONTROL_API_PG_CONNECTION_STRING = resolveMigrationConnectionString(process.env) const { initDb, pool } = await import('./db.js') migrationPool = pool - console.log('[ControlAPI:Migrate] Starting DB migration') + logger.info('Starting database migration') await initDb() - console.log('[ControlAPI:Migrate] DB migration complete') + logger.info('Database migration complete') } main() .catch(error => { - console.error('[ControlAPI:Migrate] Fatal error:', error) + logger.error({ err: error }, 'Database migration failed') process.exitCode = 1 }) .finally(async () => { diff --git a/control-api/src/migrations/migrationExecutionPolicy.json b/control-api/src/migrations/migrationExecutionPolicy.json new file mode 100644 index 0000000000..0213fe705c --- /dev/null +++ b/control-api/src/migrations/migrationExecutionPolicy.json @@ -0,0 +1,11 @@ +{ + "lockTimeoutMs": 10000, + "ordinaryStatementTimeoutMs": 15000, + "onlineIndexStatementTimeoutMs": 120000, + "idleInTransactionTimeoutMs": 15000, + "jobActiveDeadlineSeconds": 300, + "clientWaitSeconds": 360, + "terminationProofSeconds": 60, + "backoffLimit": 2, + "ttlSecondsAfterFinished": 600 +} diff --git a/control-api/src/migrations/migrationExecutionPolicy.ts b/control-api/src/migrations/migrationExecutionPolicy.ts new file mode 100644 index 0000000000..89e659205a --- /dev/null +++ b/control-api/src/migrations/migrationExecutionPolicy.ts @@ -0,0 +1,58 @@ +import policyJson from './migrationExecutionPolicy.json' + +export type MigrationExecutionPolicy = Readonly<{ + lockTimeoutMs: number + ordinaryStatementTimeoutMs: number + onlineIndexStatementTimeoutMs: number + idleInTransactionTimeoutMs: number + jobActiveDeadlineSeconds: number + clientWaitSeconds: number + terminationProofSeconds: number + backoffLimit: number + ttlSecondsAfterFinished: number +}> + +const EXPECTED_POLICY: MigrationExecutionPolicy = Object.freeze({ + lockTimeoutMs: 10_000, + ordinaryStatementTimeoutMs: 15_000, + onlineIndexStatementTimeoutMs: 120_000, + idleInTransactionTimeoutMs: 15_000, + jobActiveDeadlineSeconds: 300, + clientWaitSeconds: 360, + terminationProofSeconds: 60, + backoffLimit: 2, + ttlSecondsAfterFinished: 600, +}) + +function validatePolicy(candidate: unknown): MigrationExecutionPolicy { + if (!candidate || typeof candidate !== 'object') { + throw new Error('Invalid migration execution policy') + } + for (const [key, expected] of Object.entries(EXPECTED_POLICY)) { + if ((candidate as Record)[key] !== expected) { + throw new Error(`Invalid migration execution policy value: ${key}`) + } + } + if (Object.keys(candidate).length !== Object.keys(EXPECTED_POLICY).length) { + throw new Error('Invalid migration execution policy keys') + } + if ( + EXPECTED_POLICY.lockTimeoutMs >= EXPECTED_POLICY.ordinaryStatementTimeoutMs || + EXPECTED_POLICY.clientWaitSeconds < + EXPECTED_POLICY.jobActiveDeadlineSeconds + EXPECTED_POLICY.terminationProofSeconds + ) { + throw new Error('Invalid migration execution policy relationship') + } + return EXPECTED_POLICY +} + +export const MIGRATION_EXECUTION_POLICY = validatePolicy(policyJson) + +export const migrationSessionBoundsSql = (local: boolean): string[] => { + const scope = local ? 'SET LOCAL' : 'SET' + return [ + `${scope} lock_timeout = '${MIGRATION_EXECUTION_POLICY.lockTimeoutMs}ms'`, + `${scope} statement_timeout = '${MIGRATION_EXECUTION_POLICY.ordinaryStatementTimeoutMs}ms'`, + `${scope} idle_in_transaction_session_timeout = '${MIGRATION_EXECUTION_POLICY.idleInTransactionTimeoutMs}ms'`, + ] +} diff --git a/control-api/src/migrations/migrationRunner.ts b/control-api/src/migrations/migrationRunner.ts new file mode 100644 index 0000000000..c80d7aabbe --- /dev/null +++ b/control-api/src/migrations/migrationRunner.ts @@ -0,0 +1,87 @@ +import type { DbClient } from '../db.js' +import { migrationSessionBoundsSql } from './migrationExecutionPolicy.js' +import { preparePr1Migration } from './pr1OnlineIndexPlan.js' + +export const PR1_MIGRATION_VERSIONS = Object.freeze([ + '0107_user_access_foundation', + '0108_invitation_delivery_commands', + '0109_catalog_utf8_ordering', + '010a_composable_catalog_revisions', + '010b_gfs_catalog_revision_components', + '010c_legacy_password_security_epoch_backfill', +] as const) + +export type MigrationDescriptor = { + version: string + legacyVersions?: readonly string[] + apply: (db: DbClient) => Promise +} + +type ApplyPendingPr1MigrationsInput = { + db: DbClient + migrations: readonly MigrationDescriptor[] + appliedVersions: Set + recordMigration: (db: DbClient, version: string) => Promise +} + +async function runBoundedTransaction(db: DbClient, work: () => Promise): Promise { + let started = false + try { + await db.query('BEGIN') + started = true + for (const sql of migrationSessionBoundsSql(true)) { + await db.query(sql) + } + await work() + await db.query('COMMIT') + started = false + } catch (error) { + if (started) { + try { + await db.query('ROLLBACK') + } catch { + // The caller destroys the migration session after any failed unit. + } + } + throw error + } +} + +export async function applyPendingPr1Migrations({ + db, + migrations, + appliedVersions, + recordMigration, +}: ApplyPendingPr1MigrationsInput): Promise { + const byVersion = new Map(migrations.map(migration => [migration.version, migration])) + const expected = new Set(PR1_MIGRATION_VERSIONS) + const unclassified = migrations.filter( + migration => + migration.version > '0106_oauth_grants_owner_generalization' && + !expected.has(migration.version) + ) + if (unclassified.length > 0) { + throw new Error( + `Unclassified post-0106 migrations: ${unclassified.map(item => item.version).join(', ')}` + ) + } + + for (const version of PR1_MIGRATION_VERSIONS) { + const migration = byVersion.get(version) + if (!migration) throw new Error(`Missing registered PR1 migration: ${version}`) + if (appliedVersions.has(version)) continue + + const acceptedLegacyVersion = migration.legacyVersions?.find(alias => + appliedVersions.has(alias) + ) + if (!acceptedLegacyVersion) { + await preparePr1Migration(db, version) + } + + await runBoundedTransaction(db, async () => { + if (!acceptedLegacyVersion) await migration.apply(db) + await recordMigration(db, version) + }) + appliedVersions.add(version) + } +} diff --git a/control-api/src/migrations/pr1OnlineIndexPlan.ts b/control-api/src/migrations/pr1OnlineIndexPlan.ts new file mode 100644 index 0000000000..4b5545b298 --- /dev/null +++ b/control-api/src/migrations/pr1OnlineIndexPlan.ts @@ -0,0 +1,331 @@ +import type { DbClient } from '../db.js' +import { + MIGRATION_EXECUTION_POLICY, + migrationSessionBoundsSql, +} from './migrationExecutionPolicy.js' + +export type OnlineIndexDefinition = Readonly<{ + migrationVersion: '0107_user_access_foundation' | '0109_catalog_utf8_ordering' + name: string + table: string + unique?: boolean + createSql: string +}> + +export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.freeze([ + { + migrationVersion: '0107_user_access_foundation', + name: 'team_members_user_active_idx', + table: 'team_members', + createSql: `CREATE INDEX CONCURRENTLY team_members_user_active_idx + ON team_members (user_id, status, team_id) INCLUDE (role, updated_at)`, + }, + { + migrationVersion: '0107_user_access_foundation', + name: 'user_contexts_context_user_idx', + table: 'user_contexts', + createSql: `CREATE INDEX CONCURRENTLY user_contexts_context_user_idx + ON user_contexts (context_id, user_id)`, + }, + { + migrationVersion: '0107_user_access_foundation', + name: 'team_contexts_context_team_idx', + table: 'team_contexts', + createSql: `CREATE INDEX CONCURRENTLY team_contexts_context_team_idx + ON team_contexts (context_id, team_id)`, + }, + { + migrationVersion: '0107_user_access_foundation', + name: 'user_agents_agent_user_idx', + table: 'user_agents', + createSql: `CREATE INDEX CONCURRENTLY user_agents_agent_user_idx + ON user_agents (agent_name, user_id)`, + }, + { + migrationVersion: '0107_user_access_foundation', + name: 'team_agents_agent_team_idx', + table: 'team_agents', + createSql: `CREATE INDEX CONCURRENTLY team_agents_agent_team_idx + ON team_agents (agent_name, team_id)`, + }, + { + migrationVersion: '0107_user_access_foundation', + name: 'user_workflow_triggers_recipe_user_idx', + table: 'user_workflow_triggers', + createSql: `CREATE INDEX CONCURRENTLY user_workflow_triggers_recipe_user_idx + ON user_workflow_triggers (recipe_namespace, recipe_name, user_id)`, + }, + { + migrationVersion: '0107_user_access_foundation', + name: 'team_workflow_triggers_recipe_team_idx', + table: 'team_workflow_triggers', + createSql: `CREATE INDEX CONCURRENTLY team_workflow_triggers_recipe_team_idx + ON team_workflow_triggers (recipe_namespace, recipe_name, team_id)`, + }, + { + migrationVersion: '0107_user_access_foundation', + name: 'user_workflow_triggers_catalog_key_idx', + table: 'user_workflow_triggers', + createSql: `CREATE INDEX CONCURRENTLY user_workflow_triggers_catalog_key_idx + ON user_workflow_triggers (user_id, ((recipe_namespace || '/'::text) || recipe_name))`, + }, + { + migrationVersion: '0107_user_access_foundation', + name: 'team_workflow_triggers_catalog_key_idx', + table: 'team_workflow_triggers', + createSql: `CREATE INDEX CONCURRENTLY team_workflow_triggers_catalog_key_idx + ON team_workflow_triggers (((recipe_namespace || '/'::text) || recipe_name), team_id)`, + }, + { + migrationVersion: '0107_user_access_foundation', + name: 'workflow_runs_actor_catalog_idx', + table: 'workflow_runs', + createSql: `CREATE INDEX CONCURRENTLY workflow_runs_actor_catalog_idx + ON workflow_runs (actor_id, run_id) + INCLUDE (recipe_namespace, recipe_name, phase, team_id, usage_team_id) + WHERE actor_type = 'user' AND actor_id IS NOT NULL`, + }, + { + migrationVersion: '0107_user_access_foundation', + name: 'workflow_runs_team_catalog_idx', + table: 'workflow_runs', + createSql: `CREATE INDEX CONCURRENTLY workflow_runs_team_catalog_idx + ON workflow_runs (team_id, run_id) + INCLUDE (recipe_namespace, recipe_name, phase, actor_type, actor_id, usage_team_id) + WHERE team_id IS NOT NULL`, + }, + { + migrationVersion: '0107_user_access_foundation', + name: 'workflow_runs_usage_team_catalog_idx', + table: 'workflow_runs', + createSql: `CREATE INDEX CONCURRENTLY workflow_runs_usage_team_catalog_idx + ON workflow_runs (usage_team_id, run_id) + INCLUDE (recipe_namespace, recipe_name, phase, actor_type, actor_id, team_id) + WHERE usage_team_id IS NOT NULL`, + }, + { + migrationVersion: '0107_user_access_foundation', + name: 'workflow_approval_user_catalog_idx', + table: 'workflow_approval_requests', + createSql: `CREATE INDEX CONCURRENTLY workflow_approval_user_catalog_idx + ON workflow_approval_requests (target_user_id, id) + INCLUDE (status, expires_at, recipe_namespace, recipe_name) + WHERE target_user_id IS NOT NULL`, + }, + { + migrationVersion: '0107_user_access_foundation', + name: 'workflow_approval_team_catalog_idx', + table: 'workflow_approval_requests', + createSql: `CREATE INDEX CONCURRENTLY workflow_approval_team_catalog_idx + ON workflow_approval_requests (target_team_id, id) + INCLUDE (status, expires_at, recipe_namespace, recipe_name) + WHERE target_team_id IS NOT NULL`, + }, + { + migrationVersion: '0107_user_access_foundation', + name: 'notification_user_catalog_idx', + table: 'notification_deliveries', + createSql: `CREATE INDEX CONCURRENTLY notification_user_catalog_idx + ON notification_deliveries ((audience->>'userId'), id) + INCLUDE (expires_at, status, event_type) WHERE audience ? 'userId'`, + }, + { + migrationVersion: '0107_user_access_foundation', + name: 'notification_team_catalog_idx', + table: 'notification_deliveries', + createSql: `CREATE INDEX CONCURRENTLY notification_team_catalog_idx + ON notification_deliveries ((audience->>'teamId'), id) + INCLUDE (expires_at, status, event_type) WHERE audience ? 'teamId'`, + }, + { + migrationVersion: '0107_user_access_foundation', + name: 'gfs_grants_subject_resource_catalog_idx', + table: 'gfs_grants', + createSql: `CREATE INDEX CONCURRENTLY gfs_grants_subject_resource_catalog_idx + ON gfs_grants (subject_type, subject_id, resource_id) + INCLUDE (id, drive, permissions, inherit)`, + }, + { + migrationVersion: '0107_user_access_foundation', + name: 'gfs_shares_subject_resource_catalog_idx', + table: 'gfs_shares', + createSql: `CREATE INDEX CONCURRENTLY gfs_shares_subject_resource_catalog_idx + ON gfs_shares (subject_type, subject_id, resource_id) + INCLUDE (id, drive, permissions, include_descendants)`, + }, + { + migrationVersion: '0109_catalog_utf8_ordering', + name: 'user_agents_catalog_utf8_idx', + table: 'user_agents', + createSql: `CREATE INDEX CONCURRENTLY user_agents_catalog_utf8_idx + ON user_agents (user_id, catalog_utf8_bytes(agent_name))`, + }, + { + migrationVersion: '0109_catalog_utf8_ordering', + name: 'team_agents_catalog_utf8_idx', + table: 'team_agents', + createSql: `CREATE INDEX CONCURRENTLY team_agents_catalog_utf8_idx + ON team_agents (catalog_utf8_bytes(agent_name), team_id)`, + }, + { + migrationVersion: '0109_catalog_utf8_ordering', + name: 'user_contexts_catalog_utf8_idx', + table: 'user_contexts', + createSql: `CREATE INDEX CONCURRENTLY user_contexts_catalog_utf8_idx + ON user_contexts (user_id, catalog_utf8_bytes(context_id))`, + }, + { + migrationVersion: '0109_catalog_utf8_ordering', + name: 'team_contexts_catalog_utf8_idx', + table: 'team_contexts', + createSql: `CREATE INDEX CONCURRENTLY team_contexts_catalog_utf8_idx + ON team_contexts (catalog_utf8_bytes(context_id), team_id)`, + }, + { + migrationVersion: '0109_catalog_utf8_ordering', + name: 'user_workflow_triggers_catalog_utf8_idx', + table: 'user_workflow_triggers', + createSql: `CREATE INDEX CONCURRENTLY user_workflow_triggers_catalog_utf8_idx + ON user_workflow_triggers + (user_id, catalog_utf8_bytes(recipe_namespace || '/' || recipe_name))`, + }, + { + migrationVersion: '0109_catalog_utf8_ordering', + name: 'team_workflow_triggers_catalog_utf8_idx', + table: 'team_workflow_triggers', + createSql: `CREATE INDEX CONCURRENTLY team_workflow_triggers_catalog_utf8_idx + ON team_workflow_triggers + (catalog_utf8_bytes(recipe_namespace || '/' || recipe_name), team_id)`, + }, + { + migrationVersion: '0109_catalog_utf8_ordering', + name: 'operational_relationship_catalog_utf8_target_idx', + table: 'operational_resource_relationships', + createSql: `CREATE INDEX CONCURRENTLY operational_relationship_catalog_utf8_target_idx + ON operational_resource_relationships + (environment_id, target_type, relationship_type, catalog_utf8_bytes(target_id))`, + }, +]) + +export const canonicalOnlineIndexDefinition = (value: string): string => + value + .toLowerCase() + .replaceAll('"', '') + .replace(/\bconcurrently\b/g, '') + .replace(/\bif\s+not\s+exists\b/g, '') + .replace(/\bpublic\./g, '') + .replace(/\busing\s+btree\b/g, '') + .replace(/::text\b/g, '') + .replace(/;\s*$/, '') + // PostgreSQL's deparser adds redundant grouping around expressions and + // predicates. The complete token order, commas, operators, table, and + // uniqueness remain, so different keys/includes/predicates still differ. + .replace(/[()]/g, ' ') + .replace(/\s*,\s*/g, ',') + .replace(/\s+/g, ' ') + .trim() + +type IndexState = { + table_name: string + indisunique: boolean + indisvalid: boolean + definition: string +} + +async function readIndexState(db: DbClient, name: string): Promise { + const result = await db.query( + `SELECT table_rel.relname AS table_name, + index_meta.indisunique, + index_meta.indisvalid, + pg_get_indexdef(index_meta.indexrelid) AS definition + FROM pg_class index_rel + JOIN pg_namespace index_ns ON index_ns.oid = index_rel.relnamespace + JOIN pg_index index_meta ON index_meta.indexrelid = index_rel.oid + JOIN pg_class table_rel ON table_rel.oid = index_meta.indrelid + WHERE index_ns.nspname = current_schema() + AND index_rel.relname = $1`, + [name] + ) + return result.rows[0] as IndexState | undefined +} + +function assertEquivalentIndex(entry: OnlineIndexDefinition, state: IndexState): void { + if ( + state.table_name !== entry.table || + state.indisunique !== Boolean(entry.unique) || + canonicalOnlineIndexDefinition(state.definition) !== + canonicalOnlineIndexDefinition(entry.createSql) + ) { + throw new Error(`Non-equivalent existing index: ${entry.name}`) + } +} + +async function withOnlineStatementBound(db: DbClient, work: () => Promise): Promise { + await db.query( + `SET statement_timeout = '${MIGRATION_EXECUTION_POLICY.onlineIndexStatementTimeoutMs}ms'` + ) + try { + await work() + } finally { + await db.query( + `SET statement_timeout = '${MIGRATION_EXECUTION_POLICY.ordinaryStatementTimeoutMs}ms'` + ) + } +} + +export async function ensureOnlineIndex(db: DbClient, entry: OnlineIndexDefinition): Promise { + const existing = await readIndexState(db, entry.name) + if (existing) { + assertEquivalentIndex(entry, existing) + if (existing.indisvalid) return + await withOnlineStatementBound(db, async () => { + await db.query(`DROP INDEX CONCURRENTLY ${entry.name}`) + }) + } + + await withOnlineStatementBound(db, async () => { + await db.query(entry.createSql) + }) + const created = await readIndexState(db, entry.name) + if (!created) throw new Error(`Online index was not created: ${entry.name}`) + assertEquivalentIndex(entry, created) + if (!created.indisvalid) throw new Error(`Online index is invalid after creation: ${entry.name}`) +} + +async function prepareCatalogUtf8Function(db: DbClient): Promise { + let started = false + try { + await db.query('BEGIN') + started = true + for (const sql of migrationSessionBoundsSql(true)) await db.query(sql) + await db.query(` + CREATE OR REPLACE FUNCTION catalog_utf8_bytes(value TEXT) + RETURNS BYTEA + LANGUAGE SQL + IMMUTABLE + STRICT + PARALLEL SAFE + AS $$ + SELECT convert_to(value, 'UTF8'); + $$ + `) + await db.query('COMMIT') + started = false + } catch (error) { + if (started) { + try { + await db.query('ROLLBACK') + } catch { + // The migration owner destroys the session on failure. + } + } + throw error + } +} + +export async function preparePr1Migration(db: DbClient, version: string): Promise { + const indexes = PR1_ONLINE_INDEX_PLAN.filter(entry => entry.migrationVersion === version) + if (indexes.length === 0) return + if (version === '0109_catalog_utf8_ordering') await prepareCatalogUtf8Function(db) + for (const index of indexes) await ensureOnlineIndex(db, index) +} diff --git a/control-api/test/db.gfsPermissionStoreMigration.test.ts b/control-api/test/db.gfsPermissionStoreMigration.test.ts index 38de5eded1..91786a0562 100644 --- a/control-api/test/db.gfsPermissionStoreMigration.test.ts +++ b/control-api/test/db.gfsPermissionStoreMigration.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { onlineIndexAwareQuery } from './helpers/onlineIndexCatalogMock.js' const clientQuery = vi.fn() const clientRelease = vi.fn() @@ -22,7 +23,7 @@ describe('db migration 0048_gfs_permission_store', () => { vi.resetModules() vi.clearAllMocks() mockConnect.mockResolvedValue({ - query: clientQuery, + query: onlineIndexAwareQuery(clientQuery), release: clientRelease, }) // schema_migrations empty → every migration (incl. 0048) runs. diff --git a/control-api/test/db.governedRunTraceMigration.test.ts b/control-api/test/db.governedRunTraceMigration.test.ts index 631bfcde4d..beefe17e52 100644 --- a/control-api/test/db.governedRunTraceMigration.test.ts +++ b/control-api/test/db.governedRunTraceMigration.test.ts @@ -4,6 +4,7 @@ import { DECISION_SOURCE_KINDS, LEGACY_DECISION_SOURCE_KINDS, } from '../src/services/tracing/contracts.js' +import { onlineIndexAwareQuery } from './helpers/onlineIndexCatalogMock.js' const clientQuery = vi.fn() const clientRelease = vi.fn() @@ -18,7 +19,10 @@ describe('0061_governed_run_trace_schema_foundation migration', () => { beforeEach(() => { vi.resetModules() vi.clearAllMocks() - mockConnect.mockResolvedValue({ query: clientQuery, release: clientRelease }) + mockConnect.mockResolvedValue({ + query: onlineIndexAwareQuery(clientQuery), + release: clientRelease, + }) clientQuery.mockResolvedValue({ rows: [], rowCount: 0 }) }) diff --git a/control-api/test/db.initDb.test.ts b/control-api/test/db.initDb.test.ts index 1f71a11171..cf1e988344 100644 --- a/control-api/test/db.initDb.test.ts +++ b/control-api/test/db.initDb.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { onlineIndexAwareQuery } from './helpers/onlineIndexCatalogMock.js' const clientQuery = vi.fn() const clientRelease = vi.fn() @@ -19,21 +20,26 @@ describe('db.initDb', () => { vi.resetModules() vi.clearAllMocks() mockConnect.mockResolvedValue({ - query: clientQuery, + query: onlineIndexAwareQuery(clientQuery), release: clientRelease, }) clientQuery.mockResolvedValue({ rows: [], rowCount: 0 }) }) - it('runs pending migrations under a single advisory-locked transaction and records the baseline', async () => { + it('holds one advisory lock while partitioning legacy and PR1 migration transactions', async () => { const { initDb } = await import('../src/db.js') await initDb() const sqls = clientQuery.mock.calls.map(([sql]) => String(sql).trim()) - expect(sqls[0]).toContain('SELECT pg_advisory_lock') - expect(sqls[1]).toBe('BEGIN') + expect(sqls.slice(0, 3)).toEqual([ + "SET lock_timeout = '10000ms'", + "SET statement_timeout = '15000ms'", + "SET idle_in_transaction_session_timeout = '15000ms'", + ]) + expect(sqls[3]).toContain('SELECT pg_advisory_lock') + expect(sqls[4]).toBe('BEGIN') expect(sqls).toContainEqual( expect.stringContaining('CREATE TABLE IF NOT EXISTS schema_migrations') ) @@ -59,6 +65,8 @@ describe('db.initDb', () => { ).toBe(false) expect(sqls.some(sql => sql.includes('DROP TABLE IF EXISTS workflow_run_outputs'))).toBe(false) expect(sqls).not.toContain('ROLLBACK') + expect(sqls.filter(sql => sql === 'BEGIN')).toHaveLength(8) + expect(sqls.filter(sql => sql === 'COMMIT')).toHaveLength(8) expect(sqls[sqls.length - 2]).toBe('COMMIT') expect(sqls[sqls.length - 1]).toContain('SELECT pg_advisory_unlock') expect(clientRelease).toHaveBeenCalledTimes(1) @@ -928,8 +936,8 @@ describe('db.initDb', () => { const sqls = clientQuery.mock.calls.map(([sql]) => String(sql).trim()) - expect(sqls[0]).toContain('SELECT pg_advisory_lock') - expect(sqls[1]).toBe('BEGIN') + expect(sqls[3]).toContain('SELECT pg_advisory_lock') + expect(sqls[4]).toBe('BEGIN') expect(sqls).toContainEqual( expect.stringContaining('CREATE TABLE IF NOT EXISTS schema_migrations') ) diff --git a/control-api/test/db.llmAllowedModelsMigration.test.ts b/control-api/test/db.llmAllowedModelsMigration.test.ts index 3d6a5af728..d45fff5219 100644 --- a/control-api/test/db.llmAllowedModelsMigration.test.ts +++ b/control-api/test/db.llmAllowedModelsMigration.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { onlineIndexAwareQuery } from './helpers/onlineIndexCatalogMock.js' const clientQuery = vi.fn() const clientRelease = vi.fn() @@ -19,7 +20,7 @@ describe('0056_llm_allowed_models migration', () => { vi.resetModules() vi.clearAllMocks() mockConnect.mockResolvedValue({ - query: clientQuery, + query: onlineIndexAwareQuery(clientQuery), release: clientRelease, }) clientQuery.mockResolvedValue({ rows: [], rowCount: 0 }) diff --git a/control-api/test/db.llmModelPricesMigration.test.ts b/control-api/test/db.llmModelPricesMigration.test.ts index a65e845077..855a1d4a11 100644 --- a/control-api/test/db.llmModelPricesMigration.test.ts +++ b/control-api/test/db.llmModelPricesMigration.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { onlineIndexAwareQuery } from './helpers/onlineIndexCatalogMock.js' const clientQuery = vi.fn() const clientRelease = vi.fn() @@ -19,7 +20,7 @@ describe('0044_llm_model_prices migration', () => { vi.resetModules() vi.clearAllMocks() mockConnect.mockResolvedValue({ - query: clientQuery, + query: onlineIndexAwareQuery(clientQuery), release: clientRelease, }) clientQuery.mockResolvedValue({ rows: [], rowCount: 0 }) diff --git a/control-api/test/db.memberRegistrationCredentialsMigration.test.ts b/control-api/test/db.memberRegistrationCredentialsMigration.test.ts index 7001583687..7adf8cd886 100644 --- a/control-api/test/db.memberRegistrationCredentialsMigration.test.ts +++ b/control-api/test/db.memberRegistrationCredentialsMigration.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { onlineIndexAwareQuery } from './helpers/onlineIndexCatalogMock.js' const clientQuery = vi.fn() const clientRelease = vi.fn() @@ -12,7 +13,10 @@ describe('0068_member_registration_credentials migration', () => { beforeEach(() => { vi.resetModules() vi.clearAllMocks() - mockConnect.mockResolvedValue({ query: clientQuery, release: clientRelease }) + mockConnect.mockResolvedValue({ + query: onlineIndexAwareQuery(clientQuery), + release: clientRelease, + }) clientQuery.mockResolvedValue({ rows: [], rowCount: 0 }) }) diff --git a/control-api/test/db.oauthGrantsOwnerGeneralization.test.ts b/control-api/test/db.oauthGrantsOwnerGeneralization.test.ts index e40e3e8618..4d6c22965f 100644 --- a/control-api/test/db.oauthGrantsOwnerGeneralization.test.ts +++ b/control-api/test/db.oauthGrantsOwnerGeneralization.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { onlineIndexAwareQuery } from './helpers/onlineIndexCatalogMock.js' const clientQuery = vi.fn() const clientRelease = vi.fn() @@ -13,7 +14,10 @@ describe('0101 oauth_grants owner generalization migration', () => { beforeEach(() => { vi.resetModules() vi.clearAllMocks() - mockConnect.mockResolvedValue({ query: clientQuery, release: clientRelease }) + mockConnect.mockResolvedValue({ + query: onlineIndexAwareQuery(clientQuery), + release: clientRelease, + }) clientQuery.mockResolvedValue({ rows: [], rowCount: 0 }) }) diff --git a/control-api/test/db.registryConnectionMigration.test.ts b/control-api/test/db.registryConnectionMigration.test.ts index 41559e10fb..cf74d9a3fc 100644 --- a/control-api/test/db.registryConnectionMigration.test.ts +++ b/control-api/test/db.registryConnectionMigration.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { onlineIndexAwareQuery } from './helpers/onlineIndexCatalogMock.js' const clientQuery = vi.fn() const clientRelease = vi.fn() @@ -12,7 +13,10 @@ describe('0049_registry_connection migration', () => { beforeEach(() => { vi.resetModules() vi.clearAllMocks() - mockConnect.mockResolvedValue({ query: clientQuery, release: clientRelease }) + mockConnect.mockResolvedValue({ + query: onlineIndexAwareQuery(clientQuery), + release: clientRelease, + }) clientQuery.mockResolvedValue({ rows: [], rowCount: 0 }) }) diff --git a/control-api/test/db.runtimeAccessMigrations.test.ts b/control-api/test/db.runtimeAccessMigrations.test.ts index 9a98556bf3..6fbca4d249 100644 --- a/control-api/test/db.runtimeAccessMigrations.test.ts +++ b/control-api/test/db.runtimeAccessMigrations.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PR1_ONLINE_INDEX_PLAN } from '../src/migrations/pr1OnlineIndexPlan.js' const clientQuery = vi.fn() const clientRelease = vi.fn() @@ -14,7 +15,33 @@ describe('runtime access migrations', () => { vi.resetModules() vi.clearAllMocks() mockConnect.mockResolvedValue({ query: clientQuery, release: clientRelease }) - clientQuery.mockResolvedValue({ rows: [], rowCount: 0 }) + const indexStates = new Map< + string, + { + table_name: string + indisunique: boolean + indisvalid: boolean + definition: string + } + >() + clientQuery.mockImplementation(async (sql: string, values?: unknown[]) => { + if (sql.includes('FROM pg_class index_rel')) { + const state = indexStates.get(String(values?.[0])) + return { rows: state ? [state] : [], rowCount: state ? 1 : 0 } + } + if (sql.startsWith('CREATE INDEX CONCURRENTLY')) { + const entry = PR1_ONLINE_INDEX_PLAN.find(candidate => candidate.createSql === sql) + if (entry) { + indexStates.set(entry.name, { + table_name: entry.table, + indisunique: Boolean(entry.unique), + indisvalid: true, + definition: entry.createSql, + }) + } + } + return { rows: [], rowCount: 0 } + }) }) it('0069 grants only the required member-registration table access to Control API', async () => { diff --git a/control-api/test/db.workflowApprovalMediumChannelRefMigration.test.ts b/control-api/test/db.workflowApprovalMediumChannelRefMigration.test.ts index dc62bd5e71..d6c58cfbab 100644 --- a/control-api/test/db.workflowApprovalMediumChannelRefMigration.test.ts +++ b/control-api/test/db.workflowApprovalMediumChannelRefMigration.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { onlineIndexAwareQuery } from './helpers/onlineIndexCatalogMock.js' const clientQuery = vi.fn() const clientRelease = vi.fn() @@ -21,7 +22,7 @@ describe('db migration 0039_wama_communication_channel_ref', () => { vi.resetModules() vi.clearAllMocks() mockConnect.mockResolvedValue({ - query: clientQuery, + query: onlineIndexAwareQuery(clientQuery), release: clientRelease, }) // schema_migrations empty → every migration (incl. 0039) runs. diff --git a/control-api/test/db.workflowRunCompletedNotificationRefreshMigration.test.ts b/control-api/test/db.workflowRunCompletedNotificationRefreshMigration.test.ts index 8a30a810cd..706f79305f 100644 --- a/control-api/test/db.workflowRunCompletedNotificationRefreshMigration.test.ts +++ b/control-api/test/db.workflowRunCompletedNotificationRefreshMigration.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { onlineIndexAwareQuery } from './helpers/onlineIndexCatalogMock.js' const clientQuery = vi.fn() const clientRelease = vi.fn() @@ -74,7 +75,7 @@ describe('workflow completion download detection refresh migration', () => { vi.resetModules() vi.clearAllMocks() mockConnect.mockResolvedValue({ - query: clientQuery, + query: onlineIndexAwareQuery(clientQuery), release: clientRelease, }) clientQuery.mockImplementation(async (sql: string) => { diff --git a/control-api/test/db.workflowRunsRecipeIndexMigration.test.ts b/control-api/test/db.workflowRunsRecipeIndexMigration.test.ts index 98f1ef65b3..5df419c1ac 100644 --- a/control-api/test/db.workflowRunsRecipeIndexMigration.test.ts +++ b/control-api/test/db.workflowRunsRecipeIndexMigration.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { onlineIndexAwareQuery } from './helpers/onlineIndexCatalogMock.js' const clientQuery = vi.fn() const clientRelease = vi.fn() @@ -19,7 +20,7 @@ describe('db.initDb workflow_runs recipe ordering index migration', () => { vi.resetModules() vi.clearAllMocks() mockConnect.mockResolvedValue({ - query: clientQuery, + query: onlineIndexAwareQuery(clientQuery), release: clientRelease, }) clientQuery.mockResolvedValue({ rows: [], rowCount: 0 }) diff --git a/control-api/test/helpers/onlineIndexCatalogMock.ts b/control-api/test/helpers/onlineIndexCatalogMock.ts new file mode 100644 index 0000000000..85b95d6f45 --- /dev/null +++ b/control-api/test/helpers/onlineIndexCatalogMock.ts @@ -0,0 +1,46 @@ +import type { Mock } from 'vitest' +import { PR1_ONLINE_INDEX_PLAN } from '../../src/migrations/pr1OnlineIndexPlan.js' + +type QueryResult = { rows: unknown[]; rowCount: number | null } + +/** + * Adds the minimum PostgreSQL index-catalog behavior to legacy initDb mocks. + * The wrapped mock still records and owns every query/result outside the new + * D34 catalog boundary. + */ +export function onlineIndexAwareQuery( + query: Mock +): (sql: string, values?: unknown[]) => Promise { + const states = new Map< + string, + { + table_name: string + indisunique: boolean + indisvalid: boolean + definition: string + } + >() + + return async (sql: string, values?: unknown[]): Promise => { + const result = (await query(sql, values)) as QueryResult + if (sql.includes('FROM pg_class index_rel')) { + const state = states.get(String(values?.[0])) + return { rows: state ? [state] : [], rowCount: state ? 1 : 0 } + } + if (sql.startsWith('DROP INDEX CONCURRENTLY')) { + states.delete(sql.trim().split(/\s+/).at(-1) ?? '') + } + if (sql.startsWith('CREATE INDEX CONCURRENTLY')) { + const entry = PR1_ONLINE_INDEX_PLAN.find(candidate => candidate.createSql === sql) + if (entry) { + states.set(entry.name, { + table_name: entry.table, + indisunique: Boolean(entry.unique), + indisvalid: true, + definition: entry.createSql, + }) + } + } + return result + } +} diff --git a/control-api/test/migrate.test.ts b/control-api/test/migrate.test.ts index 4c6cd77728..7fdea31d06 100644 --- a/control-api/test/migrate.test.ts +++ b/control-api/test/migrate.test.ts @@ -3,6 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const initDb = vi.fn() const poolEnd = vi.fn() const resolveMigrationConnectionString = vi.fn() +const loggerInfo = vi.fn() +const loggerError = vi.fn() vi.mock('../src/db.js', () => ({ initDb, @@ -12,6 +14,11 @@ vi.mock('../src/db.js', () => ({ })) vi.mock('../src/migrationConnection.js', () => ({ resolveMigrationConnectionString })) +vi.mock('../src/observability/logger.js', () => ({ + rootLogger: { + child: () => ({ info: loggerInfo, error: loggerError }), + }, +})) describe('migrate entrypoint', () => { const originalExitCode = process.exitCode @@ -36,9 +43,6 @@ describe('migrate entrypoint', () => { }) it('runs initDb and closes the pool on success', async () => { - const infoSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - await import('../src/migrate.js') await new Promise(resolve => setImmediate(resolve)) @@ -46,29 +50,20 @@ describe('migrate entrypoint', () => { expect(resolveMigrationConnectionString).toHaveBeenCalledWith(process.env) expect(poolEnd).toHaveBeenCalledTimes(1) expect(process.exitCode).toBeUndefined() - expect(infoSpy).toHaveBeenCalledWith('[ControlAPI:Migrate] Starting DB migration') - expect(infoSpy).toHaveBeenCalledWith('[ControlAPI:Migrate] DB migration complete') - expect(errorSpy).not.toHaveBeenCalled() - - infoSpy.mockRestore() - errorSpy.mockRestore() + expect(loggerInfo).toHaveBeenCalledWith('Starting database migration') + expect(loggerInfo).toHaveBeenCalledWith('Database migration complete') + expect(loggerError).not.toHaveBeenCalled() }) it('sets exitCode=1 and still closes the pool on failure', async () => { const fatal = new Error('boom') initDb.mockRejectedValue(fatal) - const infoSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - await import('../src/migrate.js') await new Promise(resolve => setImmediate(resolve)) expect(initDb).toHaveBeenCalledTimes(1) expect(poolEnd).toHaveBeenCalledTimes(1) expect(process.exitCode).toBe(1) - expect(errorSpy).toHaveBeenCalledWith('[ControlAPI:Migrate] Fatal error:', fatal) - - infoSpy.mockRestore() - errorSpy.mockRestore() + expect(loggerError).toHaveBeenCalledWith({ err: fatal }, 'Database migration failed') }) }) diff --git a/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts new file mode 100644 index 0000000000..94d350e342 --- /dev/null +++ b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts @@ -0,0 +1,398 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { spawn } from 'node:child_process' +import { randomBytes, randomUUID } from 'node:crypto' +import { once } from 'node:events' +import { Pool, type PoolClient } from 'pg' +import { type DbClient, initDb } from '../src/db.js' +import { + PR1_MIGRATION_VERSIONS, + applyPendingPr1Migrations, +} from '../src/migrations/migrationRunner.js' +import { + type OnlineIndexDefinition, + PR1_ONLINE_INDEX_PLAN, + ensureOnlineIndex, +} from '../src/migrations/pr1OnlineIndexPlan.js' + +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const describeRealPostgres = adminUrl ? describe : describe.skip + +const FRESH_TABLE_INDEXES = Object.freeze([ + 'external_user_sessions_user_live_idx', + 'external_user_sessions_idle_idx', + 'external_v1_session_revocations_user_idx', + 'external_v1_session_revocations_expiry_idx', + 'authorization_resource_revisions_updated_idx', + 'operational_resource_source_idx', + 'operational_relationship_source_idx', + 'operational_relationship_target_idx', + 'operational_relationship_catalog_target_idx', + 'operational_relationship_generation_idx', + 'operational_resource_staging_identity_idx', + 'operational_relationship_staging_identity_idx', + 'invitation_delivery_commands_authorized_idx', + 'invitation_delivery_commands_invitation_idx', +]) + +function databaseUrl(baseUrl: string, database: string): string { + const value = new URL(baseUrl) + value.pathname = `/${database}` + return value.toString() +} + +function quoteIdentifier(value: string): string { + return `"${value.replace(/"/g, '""')}"` +} + +async function versions(pool: Pool): Promise { + const result = await pool.query<{ version: string }>( + 'SELECT version FROM schema_migrations ORDER BY version' + ) + return result.rows.map(row => row.version) +} + +describeRealPostgres('D34 migration execution on real PostgreSQL', () => { + const database = `control_api_d34_${randomBytes(6).toString('hex')}` + const connectionString = databaseUrl( + adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', + database + ) + let adminPool: Pool + let databasePool: Pool + + beforeAll(async () => { + adminPool = new Pool({ connectionString: adminUrl }) + await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) + databasePool = new Pool({ connectionString }) + await initDb({ connect: () => databasePool.connect() }) + }) + + afterAll(async () => { + await databasePool?.end() + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 + AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.end() + } + }) + + it('classifies, creates, and reruns all PR1 indexes without replay', async () => { + const firstVersions = await versions(databasePool) + expect(PR1_ONLINE_INDEX_PLAN).toHaveLength(25) + expect(FRESH_TABLE_INDEXES).toHaveLength(14) + + const allNames = [...PR1_ONLINE_INDEX_PLAN.map(index => index.name), ...FRESH_TABLE_INDEXES] + expect(new Set(allNames)).toHaveLength(39) + const indexes = await databasePool.query<{ relname: string; indisvalid: boolean }>( + `SELECT relation.relname, index.indisvalid + FROM pg_class relation + JOIN pg_index index ON index.indexrelid = relation.oid + WHERE relation.relname = ANY($1::text[])`, + [allNames] + ) + expect(indexes.rows).toHaveLength(39) + expect(indexes.rows.every(row => row.indisvalid)).toBe(true) + + await initDb({ connect: () => databasePool.connect() }) + expect(await versions(databasePool)).toEqual(firstVersions) + }) + + it('adopts an exact valid online index when the version row is absent', async () => { + const entry = PR1_ONLINE_INDEX_PLAN[0]! + const before = await databasePool.query<{ oid: string }>( + 'SELECT $1::regclass::oid::text AS oid', + [entry.name] + ) + await databasePool.query('DELETE FROM schema_migrations WHERE version = $1', [ + entry.migrationVersion, + ]) + + await initDb({ connect: () => databasePool.connect() }) + + const after = await databasePool.query<{ oid: string }>( + 'SELECT $1::regclass::oid::text AS oid', + [entry.name] + ) + expect(after.rows[0]?.oid).toBe(before.rows[0]?.oid) + expect(await versions(databasePool)).toContain(entry.migrationVersion) + }) + + it('repairs an equivalent interrupted index and enforces the online bound', async () => { + const name = `d34_interrupted_${randomBytes(4).toString('hex')}` + const entry: OnlineIndexDefinition = { + migrationVersion: '0107_user_access_foundation', + name, + table: 'd34_interrupted_index', + unique: true, + createSql: `CREATE UNIQUE INDEX CONCURRENTLY ${name} ON d34_interrupted_index (value)`, + } + await databasePool.query(`CREATE TABLE d34_interrupted_index(value integer NOT NULL)`) + await databasePool.query(`INSERT INTO d34_interrupted_index(value) VALUES (1), (1)`) + await expect(ensureOnlineIndex(databasePool, entry)).rejects.toThrow() + const interrupted = await databasePool.query<{ indisvalid: boolean }>( + `SELECT index.indisvalid + FROM pg_class relation + JOIN pg_index index ON index.indexrelid = relation.oid + WHERE relation.relname = $1`, + [name] + ) + expect(interrupted.rows).toEqual([{ indisvalid: false }]) + + await databasePool.query( + `DELETE FROM d34_interrupted_index + WHERE ctid NOT IN (SELECT min(ctid) FROM d34_interrupted_index GROUP BY value)` + ) + let observedStatementTimeout = '' + const onlineClient = await databasePool.connect() + const boundedClient: DbClient = { + query: async (sql, values) => { + if (sql.startsWith('CREATE UNIQUE INDEX CONCURRENTLY')) { + const current = await onlineClient.query<{ statement_timeout: string }>( + 'SHOW statement_timeout' + ) + observedStatementTimeout = current.rows[0]?.statement_timeout ?? '' + } + return onlineClient.query(sql, values) + }, + } + try { + await ensureOnlineIndex(boundedClient, entry) + } finally { + onlineClient.release() + } + expect(observedStatementTimeout).toBe('2min') + }) + + it('cancels online index construction at 120 seconds and leaves classified state', async () => { + const suffix = randomBytes(4).toString('hex') + const table = `d34_slow_index_${suffix}` + const functionName = `d34_slow_index_value_${suffix}` + const name = `d34_slow_index_${suffix}_idx` + const entry: OnlineIndexDefinition = { + migrationVersion: '0107_user_access_foundation', + name, + table, + createSql: `CREATE INDEX CONCURRENTLY ${name} ON ${table} (${functionName}(value))`, + } + await databasePool.query(`CREATE TABLE ${table}(value integer NOT NULL)`) + await databasePool.query(`INSERT INTO ${table}(value) VALUES (1)`) + await databasePool.query(` + CREATE FUNCTION ${functionName}(input integer) + RETURNS integer + LANGUAGE plpgsql + IMMUTABLE + STRICT + AS $$ + BEGIN + PERFORM pg_sleep(121); + RETURN input; + END; + $$ + `) + + const started = Date.now() + await expect(ensureOnlineIndex(databasePool, entry)).rejects.toMatchObject({ code: '57014' }) + const elapsed = Date.now() - started + expect(elapsed).toBeGreaterThanOrEqual(119_000) + expect(elapsed).toBeLessThan(130_000) + const interrupted = await databasePool.query<{ indisvalid: boolean }>( + `SELECT index.indisvalid + FROM pg_class relation + JOIN pg_index index ON index.indexrelid = relation.oid + WHERE relation.relname = $1`, + [name] + ) + expect(interrupted.rows).toEqual([{ indisvalid: false }]) + }, 135_000) + + it('fails closed on a same-name non-equivalent index and releases the advisory lock', async () => { + const entry = PR1_ONLINE_INDEX_PLAN[0]! + await databasePool.query(`DROP INDEX ${entry.name}`) + await databasePool.query(`CREATE INDEX ${entry.name} ON team_members (team_id)`) + await databasePool.query('DELETE FROM schema_migrations WHERE version = $1', [ + entry.migrationVersion, + ]) + + await expect(initDb({ connect: () => databasePool.connect() })).rejects.toThrow( + `Non-equivalent existing index: ${entry.name}` + ) + const lockCheck = await databasePool.connect() + try { + const acquired = await lockCheck.query<{ acquired: boolean }>( + `SELECT pg_try_advisory_lock(hashtext('control-api-init-db-v1')::bigint) AS acquired` + ) + expect(acquired.rows).toEqual([{ acquired: true }]) + await lockCheck.query(`SELECT pg_advisory_unlock(hashtext('control-api-init-db-v1')::bigint)`) + } finally { + lockCheck.release() + } + + await databasePool.query(`DROP INDEX ${entry.name}`) + await initDb({ connect: () => databasePool.connect() }) + }) + + it('releases the migration advisory lock when its owning process is terminated', async () => { + const childScript = ` + const { Client } = require('pg'); + (async () => { + const client = new Client({ connectionString: process.env.D34_DATABASE_URL }); + await client.connect(); + await client.query("SELECT pg_advisory_lock(hashtext('control-api-init-db-v1')::bigint)"); + process.stdout.write('locked\\n'); + await new Promise(() => {}); + })().catch(error => { + process.stderr.write(String(error)); + process.exit(1); + }); + ` + const child = spawn(process.execPath, ['-e', childScript], { + cwd: new URL('..', import.meta.url), + env: { ...process.env, D34_DATABASE_URL: connectionString }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + const locked = new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error('child did not acquire advisory lock')), + 10_000 + ) + child.stdout.on('data', chunk => { + if (!String(chunk).includes('locked')) return + clearTimeout(timer) + resolve() + }) + child.once('error', error => { + clearTimeout(timer) + reject(error) + }) + }) + await locked + child.kill('SIGKILL') + await once(child, 'exit') + + const lockCheck = await databasePool.connect() + try { + const acquired = await lockCheck.query<{ acquired: boolean }>( + `SELECT pg_try_advisory_lock(hashtext('control-api-init-db-v1')::bigint) AS acquired` + ) + expect(acquired.rows).toEqual([{ acquired: true }]) + await lockCheck.query(`SELECT pg_advisory_unlock(hashtext('control-api-init-db-v1')::bigint)`) + } finally { + lockCheck.release() + } + }, 15_000) + + it('bounds ordinary DDL lock acquisition and never runs the later version', async () => { + const locker = await databasePool.connect() + await databasePool.query( + `DELETE FROM schema_migrations + WHERE version IN ('010a_composable_catalog_revisions', '010b_gfs_catalog_revision_components')` + ) + await locker.query('BEGIN') + await locker.query('LOCK TABLE team_members IN ACCESS EXCLUSIVE MODE') + const started = Date.now() + try { + await expect(initDb({ connect: () => databasePool.connect() })).rejects.toMatchObject({ + code: '55P03', + }) + } finally { + await locker.query('ROLLBACK') + locker.release() + } + expect(Date.now() - started).toBeGreaterThanOrEqual(9_000) + expect(Date.now() - started).toBeLessThan(15_000) + const failedVersions = await versions(databasePool) + expect(failedVersions).not.toContain('010a_composable_catalog_revisions') + expect(failedVersions).not.toContain('010b_gfs_catalog_revision_components') + await initDb({ connect: () => databasePool.connect() }) + }, 20_000) + + it('cancels and rolls back one ordinary migration statement within 15 seconds', async () => { + const client = await databasePool.connect() + const appliedVersions = new Set(PR1_MIGRATION_VERSIONS.slice(0, 3)) + const recordTable = `d34_record_${randomBytes(4).toString('hex')}` + await client.query(`CREATE TEMP TABLE ${recordTable}(version text PRIMARY KEY)`) + const migrations = PR1_MIGRATION_VERSIONS.map(version => ({ + version, + apply: async (db: DbClient) => { + if (version === '010a_composable_catalog_revisions') { + await db.query('SELECT pg_sleep(20)') + } + }, + })) + const started = Date.now() + try { + await expect( + applyPendingPr1Migrations({ + db: client, + migrations, + appliedVersions, + recordMigration: async (db, version) => { + await db.query(`INSERT INTO ${recordTable}(version) VALUES ($1)`, [version]) + }, + }) + ).rejects.toMatchObject({ code: '57014' }) + expect(Date.now() - started).toBeGreaterThanOrEqual(14_000) + expect(Date.now() - started).toBeLessThan(20_000) + const recorded = await client.query<{ version: string }>( + `SELECT version FROM ${recordTable} ORDER BY version` + ) + expect(recorded.rows).toEqual([]) + expect(appliedVersions).not.toContain('010b_gfs_catalog_revision_components') + } finally { + client.release(true) + } + }, 25_000) + + it('converges accepted legacy aliases without replaying historical bodies', async () => { + const entry = PR1_ONLINE_INDEX_PLAN[0]! + const before = await databasePool.query<{ oid: string }>( + 'SELECT $1::regclass::oid::text AS oid', + [entry.name] + ) + await databasePool.query('DELETE FROM schema_migrations WHERE version = $1', [ + entry.migrationVersion, + ]) + await databasePool.query( + `INSERT INTO schema_migrations(version) + VALUES ('0101_user_access_foundation') + ON CONFLICT DO NOTHING` + ) + + await initDb({ connect: () => databasePool.connect() }) + + const after = await databasePool.query<{ oid: string }>( + 'SELECT $1::regclass::oid::text AS oid', + [entry.name] + ) + expect(after.rows[0]?.oid).toBe(before.rows[0]?.oid) + expect(await versions(databasePool)).toContain(entry.migrationVersion) + }) + + it('keeps legacy team-member payloads compatible with revision triggers', async () => { + const userId = randomUUID() + const teamId = randomUUID() + await databasePool.query( + `INSERT INTO users(id, email, name) VALUES ($1, $2, 'D34 old writer')`, + [userId, `d34-${userId}@example.test`] + ) + await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, 'D34 old team')`, [teamId]) + await databasePool.query( + `INSERT INTO team_members(team_id, user_id, role, status) + VALUES ($1, $2, 'member', 'active')`, + [teamId, userId] + ) + const revision = await databasePool.query<{ count: string }>( + `SELECT count(*)::text AS count + FROM authorization_team_revisions + WHERE team_id = $1`, + [teamId] + ) + expect(Number(revision.rows[0]?.count ?? 0)).toBeGreaterThan(0) + }) +}) diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts new file mode 100644 index 0000000000..8f85aa9cc5 --- /dev/null +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, it, vi } from 'vitest' +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { MIGRATION_EXECUTION_POLICY } from '../src/migrations/migrationExecutionPolicy.js' +import { + PR1_MIGRATION_VERSIONS, + applyPendingPr1Migrations, +} from '../src/migrations/migrationRunner.js' +import { + PR1_ONLINE_INDEX_PLAN, + canonicalOnlineIndexDefinition, + preparePr1Migration, +} from '../src/migrations/pr1OnlineIndexPlan.js' + +const FRESH_TABLE_INDEXES = Object.freeze([ + 'external_user_sessions_user_live_idx', + 'external_user_sessions_idle_idx', + 'external_v1_session_revocations_user_idx', + 'external_v1_session_revocations_expiry_idx', + 'authorization_resource_revisions_updated_idx', + 'operational_resource_source_idx', + 'operational_relationship_source_idx', + 'operational_relationship_target_idx', + 'operational_relationship_catalog_target_idx', + 'operational_relationship_generation_idx', + 'operational_resource_staging_identity_idx', + 'operational_relationship_staging_identity_idx', + 'invitation_delivery_commands_authorized_idx', + 'invitation_delivery_commands_invitation_idx', +]) + +describe('D34 migration execution policy', () => { + it('freezes the owner-approved timeout and Job values', () => { + expect(MIGRATION_EXECUTION_POLICY).toEqual({ + lockTimeoutMs: 10_000, + ordinaryStatementTimeoutMs: 15_000, + onlineIndexStatementTimeoutMs: 120_000, + idleInTransactionTimeoutMs: 15_000, + jobActiveDeadlineSeconds: 300, + clientWaitSeconds: 360, + terminationProofSeconds: 60, + backoffLimit: 2, + ttlSecondsAfterFinished: 600, + }) + }) + + it('classifies exactly 25 existing-table indexes and no fresh-table index', () => { + expect(PR1_ONLINE_INDEX_PLAN).toHaveLength(25) + expect(new Set(PR1_ONLINE_INDEX_PLAN.map(index => index.name))).toHaveLength(25) + expect( + PR1_ONLINE_INDEX_PLAN.filter(index => index.migrationVersion.startsWith('0107')) + ).toHaveLength(18) + expect( + PR1_ONLINE_INDEX_PLAN.filter(index => index.migrationVersion.startsWith('0109')) + ).toHaveLength(7) + expect( + PR1_ONLINE_INDEX_PLAN.some(index => index.name.startsWith('external_user_sessions_')) + ).toBe(false) + expect( + PR1_ONLINE_INDEX_PLAN.some(index => index.name.startsWith('invitation_delivery_commands_')) + ).toBe(false) + }) + + it('preserves the approved historical migration bodies byte-for-byte', async () => { + const files = [ + [ + 'src/services/access/userAccessFoundationSchema.ts', + 'f1ff3491ca7fbb7b88300b9df570e2ee5672abc219a25086dcfdbb50a4c15c66', + ], + [ + 'src/services/directory/invitationDeliverySchema.ts', + 'eafb103ae84541176c03486035503dda90eab91e55d881b643c2b5099493ee31', + ], + ] as const + for (const [path, expected] of files) { + const bytes = await readFile(new URL(`../${path}`, import.meta.url)) + expect(createHash('sha256').update(bytes).digest('hex'), path).toBe(expected) + } + }) + + it('classifies every immutable PR1 index exactly once', async () => { + const historicalSql = await Promise.all([ + readFile( + new URL('../src/services/access/userAccessFoundationSchema.ts', import.meta.url), + 'utf8' + ), + readFile( + new URL('../src/services/directory/invitationDeliverySchema.ts', import.meta.url), + 'utf8' + ), + ]) + const historicalNames = historicalSql + .flatMap(sql => [...sql.matchAll(/CREATE (?:UNIQUE )?INDEX IF NOT EXISTS (\w+)/g)]) + .map(match => match[1]!) + .sort() + const historicalDefinitions = new Map( + historicalSql.flatMap(sql => + [...sql.matchAll(/CREATE (?:UNIQUE )?INDEX IF NOT EXISTS (\w+)[\s\S]*?;/g)].map( + match => [match[1]!, match[0]] as const + ) + ) + ) + const classified = [ + ...PR1_ONLINE_INDEX_PLAN.map(index => index.name), + ...FRESH_TABLE_INDEXES, + ].sort() + + expect(historicalNames).toHaveLength(39) + expect(classified).toEqual(historicalNames) + expect(new Set(classified)).toHaveLength(classified.length) + const canonical = (value: string) => + value + .replace(/\bCONCURRENTLY\b|\bIF NOT EXISTS\b/g, '') + .replace(/;\s*$/, '') + .replace(/\s+/g, ' ') + .replace(/\s*([(),])\s*/g, '$1') + .trim() + for (const index of PR1_ONLINE_INDEX_PLAN) { + expect(canonical(index.createSql), index.name).toBe( + canonical(historicalDefinitions.get(index.name) ?? '') + ) + } + }) + + it('normalizes PostgreSQL deparser syntax without accepting changed index tokens', () => { + const expected = `CREATE INDEX workflow_runs_actor_catalog_idx + ON workflow_runs (actor_id, run_id) + INCLUDE (recipe_namespace, recipe_name, phase, team_id, usage_team_id) + WHERE actor_type = 'user' AND actor_id IS NOT NULL` + const deparsed = `CREATE INDEX workflow_runs_actor_catalog_idx + ON public.workflow_runs USING btree (actor_id, run_id) + INCLUDE (recipe_namespace, recipe_name, phase, team_id, usage_team_id) + WHERE ((actor_type = 'user'::text) AND (actor_id IS NOT NULL))` + const changed = deparsed.replace('(actor_id, run_id)', '(run_id, actor_id)') + + expect(canonicalOnlineIndexDefinition(deparsed)).toBe(canonicalOnlineIndexDefinition(expected)) + expect(canonicalOnlineIndexDefinition(changed)).not.toBe( + canonicalOnlineIndexDefinition(expected) + ) + }) +}) + +describe('D34 PR1 migration runner', () => { + it('commits and records each PR1 version independently in order', async () => { + const queries: Array<{ sql: string; values?: unknown[] }> = [] + const db = { + query: vi.fn(async (sql: string, values?: unknown[]) => { + queries.push({ sql, values }) + if (sql.includes('FROM pg_class index_rel')) return { rows: [], rowCount: 0 } + if (sql.startsWith('CREATE INDEX CONCURRENTLY')) { + const entry = PR1_ONLINE_INDEX_PLAN.find(index => sql === index.createSql) + if (entry) indexStates.set(entry.name, { ...entry, indisvalid: true, definition: sql }) + } + return { rows: [], rowCount: 0 } + }), + } + const indexStates = new Map>() + db.query.mockImplementation(async (sql: string, values?: unknown[]) => { + queries.push({ sql, values }) + if (sql.includes('FROM pg_class index_rel')) { + const state = indexStates.get(String(values?.[0])) + return { rows: state ? [state] : [], rowCount: state ? 1 : 0 } + } + if (sql.startsWith('CREATE INDEX CONCURRENTLY')) { + const entry = PR1_ONLINE_INDEX_PLAN.find(index => sql === index.createSql) + if (entry) { + indexStates.set(entry.name, { + table_name: entry.table, + indisunique: Boolean(entry.unique), + indisvalid: true, + definition: sql, + }) + } + } + return { rows: [], rowCount: 0 } + }) + const applied: string[] = [] + const migrations = PR1_MIGRATION_VERSIONS.map(version => ({ + version, + apply: vi.fn(async () => undefined), + })) + + await applyPendingPr1Migrations({ + db, + migrations, + appliedVersions: new Set(), + recordMigration: async (_db, version) => { + applied.push(version) + }, + }) + + expect(applied).toEqual(PR1_MIGRATION_VERSIONS) + expect(queries.filter(({ sql }) => sql === 'BEGIN')).toHaveLength(7) + expect(queries.filter(({ sql }) => sql === 'COMMIT')).toHaveLength(7) + expect(queries.filter(({ sql }) => sql === 'ROLLBACK')).toHaveLength(0) + }) + + it('stops after a failed version and rolls back only that version', async () => { + const query = vi.fn(async () => ({ rows: [], rowCount: 0 })) + const applyOrder: string[] = [] + const migrations = PR1_MIGRATION_VERSIONS.map(version => ({ + version, + legacyVersions: [`legacy_${version}`], + apply: vi.fn(async () => { + applyOrder.push(version) + if (version === PR1_MIGRATION_VERSIONS[3]) throw new Error('boom') + }), + })) + await expect( + applyPendingPr1Migrations({ + db: { query }, + migrations, + appliedVersions: new Set( + PR1_ONLINE_INDEX_PLAN.map(index => `legacy_${index.migrationVersion}`) + ), + recordMigration: async () => undefined, + }) + ).rejects.toThrow('boom') + expect(applyOrder).not.toContain(PR1_MIGRATION_VERSIONS[4]) + expect(query).toHaveBeenCalledWith('ROLLBACK') + }) + + it('fails closed for an unclassified post-0106 migration', async () => { + await expect( + applyPendingPr1Migrations({ + db: { query: vi.fn() }, + migrations: [ + ...PR1_MIGRATION_VERSIONS.map(version => ({ version, apply: vi.fn() })), + { version: '010d_unclassified', apply: vi.fn() }, + ], + appliedVersions: new Set(), + recordMigration: vi.fn(), + }) + ).rejects.toThrow('Unclassified post-0106 migrations') + }) +}) + +describe('D34 online-index recovery', () => { + it('repairs only an equivalent invalid index and rejects a different definition', async () => { + const entry = PR1_ONLINE_INDEX_PLAN[0]! + const states = new Map( + PR1_ONLINE_INDEX_PLAN.filter( + candidate => candidate.migrationVersion === entry.migrationVersion + ).map(candidate => [ + candidate.name, + { + table_name: candidate.table, + indisunique: Boolean(candidate.unique), + indisvalid: candidate.name !== entry.name, + definition: candidate.createSql, + }, + ]) + ) + const query = vi.fn(async (sql: string, values?: unknown[]) => { + if (sql.includes('FROM pg_class index_rel')) { + const state = states.get(String(values?.[0])) + return { rows: state ? [state] : [], rowCount: state ? 1 : 0 } + } + if (sql.startsWith('DROP INDEX CONCURRENTLY')) { + states.delete(entry.name) + return { rows: [], rowCount: 0 } + } + if (sql.startsWith('CREATE INDEX CONCURRENTLY')) { + const candidate = PR1_ONLINE_INDEX_PLAN.find(index => index.createSql === sql)! + states.set(candidate.name, { + table_name: candidate.table, + indisunique: Boolean(candidate.unique), + indisvalid: true, + definition: candidate.createSql, + }) + } + return { rows: [], rowCount: 0 } + }) + await preparePr1Migration({ query }, entry.migrationVersion) + expect(query).toHaveBeenCalledWith(`DROP INDEX CONCURRENTLY ${entry.name}`) + expect(query).toHaveBeenCalledWith(entry.createSql) + + states.set(entry.name, { + ...states.get(entry.name)!, + definition: 'CREATE INDEX wrong ON team_members (team_id)', + }) + await expect(preparePr1Migration({ query }, entry.migrationVersion)).rejects.toThrow( + `Non-equivalent existing index: ${entry.name}` + ) + }) +}) diff --git a/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts b/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts new file mode 100644 index 0000000000..0c2b6c8f5d --- /dev/null +++ b/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts @@ -0,0 +1,110 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { createHash, randomBytes, randomUUID } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { Pool, type PoolClient } from 'pg' + +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const describeRealPostgres = adminUrl ? describe : describe.skip +const PRE_D34_TEAMS_WRITER_SHA256 = + 'b91e7c4cec80a4c4e7be7d92b380d1a28fda2493e96a6047aca9be864c9cde5b' + +function databaseUrl(baseUrl: string, database: string): string { + const value = new URL(baseUrl) + value.pathname = `/${database}` + return value.toString() +} + +function quoteIdentifier(value: string): string { + return `"${value.replace(/"/g, '""')}"` +} + +async function withDatabaseTransaction( + databasePool: Pool, + work: (client: PoolClient) => Promise +): Promise { + const client = await databasePool.connect() + try { + await client.query('BEGIN') + const result = await work(client) + await client.query('COMMIT') + return result + } catch (error) { + await client.query('ROLLBACK') + throw error + } finally { + client.release() + } +} + +describeRealPostgres('D34 previous-image writer compatibility on real PostgreSQL', () => { + const database = `control_api_d34_previous_${randomBytes(6).toString('hex')}` + const connectionString = databaseUrl( + adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', + database + ) + let adminPool: Pool + let databasePool: Pool + + beforeAll(async () => { + adminPool = new Pool({ connectionString: adminUrl }) + await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) + databasePool = new Pool({ connectionString }) + const { initDb } = await import('../src/db.js') + await initDb({ connect: () => databasePool.connect() }) + }) + + afterAll(async () => { + vi.doUnmock('../src/db.js') + vi.resetModules() + await databasePool?.end() + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 + AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.end() + } + }) + + it('runs the byte-identical pre-D34 team writer against the final additive schema', async () => { + const writerSource = readFileSync( + new URL('../src/services/directory/teams.ts', import.meta.url) + ) + expect(createHash('sha256').update(writerSource).digest('hex')).toBe( + PRE_D34_TEAMS_WRITER_SHA256 + ) + + const actualDb = await vi.importActual('../src/db.js') + vi.doMock('../src/db.js', () => ({ + ...actualDb, + pool: databasePool, + withTransaction: (work: (client: PoolClient) => Promise) => + withDatabaseTransaction(databasePool, work), + })) + const { createTeamForUser } = await import('../src/services/directory/teams.js') + + const userId = randomUUID() + await databasePool.query( + `INSERT INTO users(id, email, name) VALUES ($1, $2, 'D34 previous writer')`, + [userId, `d34-previous-${userId}@example.test`] + ) + const team = await createTeamForUser(userId, 'D34 previous-image team') + + const membership = await databasePool.query<{ role: string; status: string }>( + `SELECT role, status FROM team_members WHERE team_id = $1 AND user_id = $2`, + [team.id, userId] + ) + expect(membership.rows).toEqual([{ role: 'admin', status: 'active' }]) + const revision = await databasePool.query<{ count: string }>( + `SELECT count(*)::text AS count + FROM authorization_team_revisions + WHERE team_id = $1`, + [team.id] + ) + expect(Number(revision.rows[0]?.count ?? 0)).toBeGreaterThan(0) + }) +}) diff --git a/deploy/scripts/run-control-api-db-migration.sh b/deploy/scripts/run-control-api-db-migration.sh index 2929e85d6b..779d9e056f 100755 --- a/deploy/scripts/run-control-api-db-migration.sh +++ b/deploy/scripts/run-control-api-db-migration.sh @@ -2,6 +2,8 @@ set -euo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." && pwd)" +MIGRATION_POLICY_FILE="${REPO_ROOT}/control-api/src/migrations/migrationExecutionPolicy.json" RUNTIME_ACCESS_PROFILES_FILE="${SCRIPT_DIR}/control-api-runtime-access-profiles.tsv" RUNTIME_SEQUENCE_ACCESS_PROFILES_FILE="${SCRIPT_DIR}/control-api-runtime-sequence-access-profiles.tsv" MAINTENANCE_ACCESS_PROFILES_FILE="${SCRIPT_DIR}/trace-maintenance-runtime-access-profiles.tsv" @@ -30,7 +32,7 @@ Usage: Options: --overlay Kustomize overlay directory (required) --job-name Kubernetes Job name (default: control-api-db-migrate) - --timeout kubectl wait timeout (default: 300s) + --timeout kubectl wait timeout (default: canonical 360s; may only be increased) Environment: CONTEXT Required target kubectl context @@ -55,6 +57,61 @@ die() { exit 1 } +load_migration_policy() { + [ -f "$MIGRATION_POLICY_FILE" ] || die "migration policy not found: $MIGRATION_POLICY_FILE" + read -r \ + MIGRATION_LOCK_TIMEOUT_MS \ + MIGRATION_ORDINARY_STATEMENT_TIMEOUT_MS \ + MIGRATION_ONLINE_INDEX_STATEMENT_TIMEOUT_MS \ + MIGRATION_IDLE_TRANSACTION_TIMEOUT_MS \ + MIGRATION_JOB_ACTIVE_DEADLINE_SECONDS \ + MIGRATION_CLIENT_WAIT_SECONDS \ + MIGRATION_TERMINATION_PROOF_SECONDS \ + MIGRATION_JOB_BACKOFF_LIMIT \ + MIGRATION_JOB_TTL_SECONDS \ + MIGRATION_POLICY_SHA256 < <( + ruby -rjson -rdigest -e ' + path = ARGV.fetch(0) + policy = JSON.parse(File.read(path)) + keys = %w[ + lockTimeoutMs ordinaryStatementTimeoutMs onlineIndexStatementTimeoutMs + idleInTransactionTimeoutMs jobActiveDeadlineSeconds clientWaitSeconds + terminationProofSeconds backoffLimit ttlSecondsAfterFinished + ] + abort("migration policy keys mismatch") unless policy.keys == keys + values = keys.map do |key| + value = policy.fetch(key) + abort("invalid migration policy value: #{key}") unless value.is_a?(Integer) && value.positive? + value + end + abort("lock timeout must be below ordinary statement timeout") unless values[0] < values[1] + abort("client wait must cover active deadline plus termination proof") unless values[5] >= values[4] + values[6] + puts(values.push(Digest::SHA256.file(path).hexdigest).join(" ")) + ' "$MIGRATION_POLICY_FILE" + ) + [ -n "${MIGRATION_POLICY_SHA256:-}" ] || die "migration policy could not be loaded" + export MIGRATION_JOB_ACTIVE_DEADLINE_SECONDS MIGRATION_JOB_BACKOFF_LIMIT + export MIGRATION_JOB_TTL_SECONDS MIGRATION_POLICY_SHA256 +} + +duration_seconds() { + local value="$1" + [[ "$value" =~ ^[0-9]+[smh]$ ]] || return 1 + case "$value" in + *s) printf '%s' "${value%s}" ;; + *m) printf '%s' "$(( ${value%m} * 60 ))" ;; + *h) printf '%s' "$(( ${value%h} * 3600 ))" ;; + *) return 1 ;; + esac +} + +sanitize_diagnostics() { + sed -E \ + -e 's#(postgres(ql)?://)[^/@[:space:]]+:[^/@[:space:]]+@#\1[REDACTED]@#g' \ + -e 's/(authorization:?[[:space:]]*bearer)[[:space:]]+[^[:space:]]+/\1 [REDACTED]/Ig' \ + -e 's/((password|token|secret|cookie)[^:=[:space:]]*[=:])[[:space:]]*[^[:space:]]+/\1[REDACTED]/Ig' +} + context_is_allowed() { local candidate local entries=() @@ -94,12 +151,6 @@ job_exists() { kctl get job "$JOB_NAME" -n control-plane >/dev/null 2>&1 } -job_is_active() { - local active_count - active_count="$(kctl get job "$JOB_NAME" -n control-plane -o 'jsonpath={.status.active}' 2>/dev/null || true)" - [ -n "$active_count" ] && [ "$active_count" != "0" ] -} - create_job() { local output status set +e @@ -113,7 +164,10 @@ create_job() { fi if [[ "$output" == *"AlreadyExists"* ]]; then - log "Migration job $JOB_NAME was created concurrently; waiting for the existing job" + if ! verify_active_job_identity; then + die "concurrently created migration Job identity does not match the requested migration" + fi + log "Migration job $JOB_NAME was created concurrently with matching identity; waiting" return 0 fi @@ -121,9 +175,131 @@ create_job() { return "$status" } +verify_active_job_identity() { + local expected_file actual_file status + expected_file="$(mktemp)" + actual_file="$(mktemp)" + status=0 + JOB_NAME="$JOB_NAME" build_job_manifest >"$expected_file" + if ! kctl get job "$JOB_NAME" -n control-plane -o json >"$actual_file"; then + rm -f "$expected_file" "$actual_file" + return 1 + fi + ruby -rjson -rdigest -e ' + normalize_container = lambda do |value| + container = Marshal.load(Marshal.dump(value)) + container.delete("terminationMessagePath") if + [nil, "/dev/termination-log"].include?(container["terminationMessagePath"]) + container.delete("terminationMessagePolicy") if + [nil, "File"].include?(container["terminationMessagePolicy"]) + %w[env envFrom ports volumeDevices volumeMounts].each do |key| + container.delete(key) if container[key] == [] + end + %w[resources securityContext].each do |key| + container.delete(key) if container[key].nil? || container[key] == {} + end + container + end + normalize_pod = lambda do |value| + pod = Marshal.load(Marshal.dump(value)) + containers = pod["containers"] || [] + abort("migration Job must contain exactly one migrate container") unless + containers.length == 1 && containers.first["name"] == "migrate" + pod["containers"] = containers.map { |item| normalize_container.call(item) } + %w[initContainers ephemeralContainers imagePullSecrets tolerations topologySpreadConstraints + readinessGates resourceClaims schedulingGates volumes].each do |key| + pod.delete(key) if pod[key] == [] + end + pod.delete("securityContext") if pod["securityContext"].nil? || pod["securityContext"] == {} + pod.delete("serviceAccount") if pod["serviceAccount"] == pod["serviceAccountName"] + pod.delete("dnsPolicy") if [nil, "ClusterFirst"].include?(pod["dnsPolicy"]) + pod.delete("schedulerName") if [nil, "default-scheduler"].include?(pod["schedulerName"]) + pod.delete("terminationGracePeriodSeconds") if + [nil, 30].include?(pod["terminationGracePeriodSeconds"]) + pod.delete("enableServiceLinks") if [nil, true].include?(pod["enableServiceLinks"]) + pod.delete("preemptionPolicy") if [nil, "PreemptLowerPriority"].include?(pod["preemptionPolicy"]) + %w[hostNetwork hostPID hostIPC shareProcessNamespace setHostnameAsFQDN].each do |key| + pod.delete(key) if [nil, false].include?(pod[key]) + end + pod + end + normalize_job_spec = lambda do |value| + spec = Marshal.load(Marshal.dump(value)) + spec.delete("selector") + spec.delete("manualSelector") if [nil, false].include?(spec["manualSelector"]) + spec.delete("parallelism") if [nil, 1].include?(spec["parallelism"]) + spec.delete("completions") if [nil, 1].include?(spec["completions"]) + spec.delete("completionMode") if [nil, "NonIndexed"].include?(spec["completionMode"]) + spec.delete("suspend") if [nil, false].include?(spec["suspend"]) + template = spec.fetch("template") + metadata = Marshal.load(Marshal.dump(template["metadata"] || {})) + labels = Marshal.load(Marshal.dump(metadata["labels"] || {})) + %w[batch.kubernetes.io/controller-uid batch.kubernetes.io/job-name + controller-uid job-name].each { |key| labels.delete(key) } + metadata["labels"] = labels + metadata.delete("annotations") if metadata["annotations"] == {} + template["metadata"] = metadata + template["spec"] = normalize_pod.call(template["spec"] || {}) + spec["template"] = template + spec + end + execution_contract = lambda do |job| + { "jobSpec" => normalize_job_spec.call(job["spec"] || {}) } + end + expected = JSON.parse(File.read(ARGV.fetch(0))) + actual = JSON.parse(File.read(ARGV.fetch(1))) + expected_annotations = expected.dig("metadata", "annotations") || {} + actual_annotations = actual.dig("metadata", "annotations") || {} + keys = %w[clerum.io/migration-policy-sha256 clerum.io/migration-configuration-sha256] + abort("active migration Job identity mismatch") unless keys.all? do |key| + expected_annotations[key] == actual_annotations[key] + end + expected_image = expected.dig("spec", "template", "spec", "containers", 0, "image") + actual_image = actual.dig("spec", "template", "spec", "containers", 0, "image") + abort("active migration Job image mismatch") unless expected_image == actual_image + expected_contract = execution_contract.call(expected) + actual_contract = execution_contract.call(actual) + expected_identity = Digest::SHA256.hexdigest(JSON.generate(expected_contract)) + actual_identity = Digest::SHA256.hexdigest(JSON.generate(actual_contract)) + abort("expected migration Job configuration annotation mismatch") unless + expected_identity == expected_annotations["clerum.io/migration-configuration-sha256"] + abort("active migration Job configuration annotation mismatch") unless + actual_identity == actual_annotations["clerum.io/migration-configuration-sha256"] + abort("active migration Job execution configuration mismatch") unless + actual_contract == expected_contract + ' "$expected_file" "$actual_file" || status=$? + rm -f "$expected_file" "$actual_file" + return "$status" +} + +terminate_migration_job() { + local deadline now remaining + deadline="$(( SECONDS + MIGRATION_TERMINATION_PROOF_SECONDS ))" + if ! kctl delete job "$JOB_NAME" -n control-plane \ + --cascade=foreground --wait=false --ignore-not-found >/dev/null; then + return 1 + fi + now="$SECONDS" + remaining="$(( deadline - now ))" + [ "$remaining" -gt 0 ] || return 1 + if ! kctl wait --for=delete --timeout="${remaining}s" \ + "job/$JOB_NAME" -n control-plane >/dev/null; then + return 1 + fi + now="$SECONDS" + remaining="$(( deadline - now ))" + [ "$remaining" -gt 0 ] || return 1 + if ! kctl wait --for=delete --timeout="${remaining}s" pod \ + -l "job-name=$JOB_NAME" -n control-plane >/dev/null; then + return 1 + fi +} + OVERLAY="" +load_migration_policy + JOB_NAME="${JOB_NAME:-control-api-db-migrate}" -TIMEOUT="${TIMEOUT:-300s}" +TIMEOUT="${TIMEOUT:-${MIGRATION_CLIENT_WAIT_SECONDS}s}" while [ $# -gt 0 ]; do case "$1" in @@ -149,6 +325,11 @@ while [ $# -gt 0 ]; do esac done +TIMEOUT_SECONDS="$(duration_seconds "$TIMEOUT")" || die "unsupported timeout duration: $TIMEOUT" +[[ "$TIMEOUT_SECONDS" =~ ^[0-9]+$ ]] || die "invalid timeout duration: $TIMEOUT" +[ "$TIMEOUT_SECONDS" -ge "$MIGRATION_CLIENT_WAIT_SECONDS" ] || \ + die "timeout must be at least ${MIGRATION_CLIENT_WAIT_SECONDS}s" + [ -n "$OVERLAY" ] || { usage >&2 exit 1 @@ -294,7 +475,7 @@ extract_control_api_image() { } build_job_manifest() { - ruby -ryaml -rjson -e ' + ruby -ryaml -rjson -rdigest -e ' job_name = ENV.fetch("JOB_NAME") docs = [] YAML.load_stream(File.read(ARGV[0])) do |doc| @@ -348,6 +529,9 @@ build_job_manifest() { "app" => "control-api", "clerum.io/component" => "control-api-db-migrate", }, + "annotations" => { + "clerum.io/migration-policy-sha256" => ENV.fetch("MIGRATION_POLICY_SHA256"), + }, }, "spec" => { # Retry a transient migration abort instead of failing the whole deploy. @@ -356,11 +540,13 @@ build_job_manifest() { # DELETE) holds its lock past that timeout the Job exits 1. The migration # body is idempotent, so re-running is safe; restartPolicy Never makes each # retry a fresh pod, giving backoffLimit + 1 attempts before the deploy fails. - # Note: 3 attempts, each waiting up to the 60s lock_timeout plus pod startup, - # can approach the 300s kubectl-wait budget below against a persistently stuck - # writer; raise that wait if such contention is expected. - "backoffLimit" => 2, - "ttlSecondsAfterFinished" => 600, + # The accepted 300-second active deadline is authoritative over all + # retries. Each pod inherits the canonical 10-second lock timeout, and + # the client observes for at least 360 seconds so it can see the Job + # deadline before beginning its separate termination proof. + "activeDeadlineSeconds" => Integer(ENV.fetch("MIGRATION_JOB_ACTIVE_DEADLINE_SECONDS")), + "backoffLimit" => Integer(ENV.fetch("MIGRATION_JOB_BACKOFF_LIMIT")), + "ttlSecondsAfterFinished" => Integer(ENV.fetch("MIGRATION_JOB_TTL_SECONDS")), "template" => { "metadata" => { "labels" => { @@ -393,6 +579,73 @@ build_job_manifest() { job["spec"]["template"]["spec"][key] = value if value end + normalize_container = lambda do |value| + normalized = Marshal.load(Marshal.dump(value)) + normalized.delete("terminationMessagePath") if + [nil, "/dev/termination-log"].include?(normalized["terminationMessagePath"]) + normalized.delete("terminationMessagePolicy") if + [nil, "File"].include?(normalized["terminationMessagePolicy"]) + %w[env envFrom ports volumeDevices volumeMounts].each do |key| + normalized.delete(key) if normalized[key] == [] + end + %w[resources securityContext].each do |key| + normalized.delete(key) if normalized[key].nil? || normalized[key] == {} + end + normalized + end + normalize_pod = lambda do |value| + normalized = Marshal.load(Marshal.dump(value)) + containers = normalized["containers"] || [] + abort("migration Job must contain exactly one migrate container") unless + containers.length == 1 && containers.first["name"] == "migrate" + normalized["containers"] = containers.map { |item| normalize_container.call(item) } + %w[initContainers ephemeralContainers imagePullSecrets tolerations topologySpreadConstraints + readinessGates resourceClaims schedulingGates volumes].each do |key| + normalized.delete(key) if normalized[key] == [] + end + normalized.delete("securityContext") if + normalized["securityContext"].nil? || normalized["securityContext"] == {} + normalized.delete("serviceAccount") if + normalized["serviceAccount"] == normalized["serviceAccountName"] + normalized.delete("dnsPolicy") if [nil, "ClusterFirst"].include?(normalized["dnsPolicy"]) + normalized.delete("schedulerName") if + [nil, "default-scheduler"].include?(normalized["schedulerName"]) + normalized.delete("terminationGracePeriodSeconds") if + [nil, 30].include?(normalized["terminationGracePeriodSeconds"]) + normalized.delete("enableServiceLinks") if + [nil, true].include?(normalized["enableServiceLinks"]) + normalized.delete("preemptionPolicy") if + [nil, "PreemptLowerPriority"].include?(normalized["preemptionPolicy"]) + %w[hostNetwork hostPID hostIPC shareProcessNamespace setHostnameAsFQDN].each do |key| + normalized.delete(key) if [nil, false].include?(normalized[key]) + end + normalized + end + normalize_job_spec = lambda do |value| + normalized = Marshal.load(Marshal.dump(value)) + normalized.delete("selector") + normalized.delete("manualSelector") if [nil, false].include?(normalized["manualSelector"]) + normalized.delete("parallelism") if [nil, 1].include?(normalized["parallelism"]) + normalized.delete("completions") if [nil, 1].include?(normalized["completions"]) + normalized.delete("completionMode") if + [nil, "NonIndexed"].include?(normalized["completionMode"]) + normalized.delete("suspend") if [nil, false].include?(normalized["suspend"]) + template = normalized.fetch("template") + metadata = Marshal.load(Marshal.dump(template["metadata"] || {})) + labels = Marshal.load(Marshal.dump(metadata["labels"] || {})) + %w[batch.kubernetes.io/controller-uid batch.kubernetes.io/job-name + controller-uid job-name].each { |key| labels.delete(key) } + metadata["labels"] = labels + metadata.delete("annotations") if metadata["annotations"] == {} + template["metadata"] = metadata + template["spec"] = normalize_pod.call(template["spec"] || {}) + normalized["template"] = template + normalized + end + configuration_contract = { "jobSpec" => normalize_job_spec.call(job.fetch("spec")) } + job["metadata"]["annotations"]["clerum.io/migration-configuration-sha256"] = + Digest::SHA256.hexdigest(JSON.generate(configuration_contract)) + puts JSON.generate(job) ' "$RENDERED_MANIFEST_FILE" } @@ -1095,8 +1348,20 @@ for secret_name in $(extract_secret_refs); do done if job_exists; then - if job_is_active; then - log "Migration job $JOB_NAME is already active; waiting for the existing run" + if ! job_state="$(kctl get job "$JOB_NAME" -n control-plane -o json 2>/dev/null | \ + ruby -rjson -e ' + job = JSON.parse(STDIN.read) + conditions = job.dig("status", "conditions") || [] + terminal = conditions.any? do |condition| + %w[Complete Failed].include?(condition["type"]) && condition["status"] == "True" + end + puts(terminal ? "terminal" : "nonterminal") + ')"; then + die "could not determine existing migration Job status" + fi + if [ "$job_state" = "nonterminal" ]; then + verify_active_job_identity || die "active migration Job identity does not match the current deployment" + log "Migration job $JOB_NAME is queued or active; waiting for the existing run" else log "Deleting previous terminal migration job $JOB_NAME" kctl delete job "$JOB_NAME" -n control-plane --ignore-not-found >/dev/null 2>&1 || true @@ -1108,16 +1373,20 @@ else create_job fi +verify_active_job_identity || die "migration Job identity does not match immediately before wait" if ! kctl wait --for=condition=complete --timeout="$TIMEOUT" "job/$JOB_NAME" -n control-plane; then job_logs="" + failure_message="migration Job failed or timed out" job_logs="$(kctl logs "job/$JOB_NAME" -n control-plane --all-containers=true 2>&1 || true)" log "Migration job failed or timed out; dumping diagnostics" - kctl describe job "$JOB_NAME" -n control-plane || true - printf '%s\n' "$job_logs" >&2 + { kctl describe job "$JOB_NAME" -n control-plane || true; } 2>&1 | sanitize_diagnostics >&2 + printf '%s\n' "$job_logs" | sanitize_diagnostics >&2 if [[ "$job_logs" == *"Cannot find module '/app/dist/migrate.js'"* ]]; then - die "control-api image ${CONTROL_API_IMAGE} is missing dist/migrate.js; rebuild/push the current image and ensure deploy-dev resolves the correct sha tag before rerunning the gate" + failure_message="control-api image ${CONTROL_API_IMAGE} is missing dist/migrate.js; rebuild/push the current image and ensure deploy-dev resolves the correct sha tag before rerunning the gate" fi - exit 1 + terminate_migration_job || \ + die "migration Job cleanup could not prove Job and pod termination within ${MIGRATION_TERMINATION_PROOF_SECONDS}s" + die "$failure_message" fi log "Migration job completed successfully" diff --git a/scripts/tests/test-control-api-migration-bounds.sh b/scripts/tests/test-control-api-migration-bounds.sh new file mode 100644 index 0000000000..6a49b71d0f --- /dev/null +++ b/scripts/tests/test-control-api-migration-bounds.sh @@ -0,0 +1,315 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +POLICY_FILE="$ROOT_DIR/control-api/src/migrations/migrationExecutionPolicy.json" +RUNNER_FILE="$ROOT_DIR/control-api/src/migrations/migrationRunner.ts" +INDEX_PLAN_FILE="$ROOT_DIR/control-api/src/migrations/pr1OnlineIndexPlan.ts" +DB_FILE="$ROOT_DIR/control-api/src/db.ts" +DEPLOY_FILE="$ROOT_DIR/deploy/scripts/run-control-api-db-migration.sh" + +fail() { + printf 'FAIL: %s\n' "$*" >&2 + exit 1 +} + +[ -f "$POLICY_FILE" ] || fail "canonical migration policy is missing" +[ -f "$RUNNER_FILE" ] || fail "bounded migration runner is missing" +[ -f "$INDEX_PLAN_FILE" ] || fail "PR1 online index plan is missing" + +node - "$POLICY_FILE" <<'NODE' +const fs = require('node:fs') +const policy = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')) +const expected = { + lockTimeoutMs: 10000, + ordinaryStatementTimeoutMs: 15000, + onlineIndexStatementTimeoutMs: 120000, + idleInTransactionTimeoutMs: 15000, + jobActiveDeadlineSeconds: 300, + clientWaitSeconds: 360, + terminationProofSeconds: 60, + backoffLimit: 2, + ttlSecondsAfterFinished: 600, +} +if (JSON.stringify(policy) !== JSON.stringify(expected)) { + throw new Error(`migration policy mismatch: ${JSON.stringify(policy)}`) +} +NODE + +grep -q 'applyPendingPr1Migrations' "$DB_FILE" || \ + fail "PR1 migrations are not dispatched through the bounded runner" +grep -q 'CREATE INDEX CONCURRENTLY' "$INDEX_PLAN_FILE" || \ + fail "existing-table indexes are not prepared concurrently" + +index_count="$(grep -c "^ migrationVersion: '010[79]_" "$INDEX_PLAN_FILE")" +[ "$index_count" = "25" ] || fail "expected 25 online indexes, found $index_count" + +grep -q 'activeDeadlineSeconds' "$DEPLOY_FILE" || \ + fail "migration Job has no active deadline" +grep -q -- '--cascade=foreground' "$DEPLOY_FILE" || \ + fail "migration timeout does not use foreground deletion" +grep -q 'wait --for=delete' "$DEPLOY_FILE" && grep -q 'job-name=' "$DEPLOY_FILE" || \ + fail "migration timeout does not prove pod termination" +grep -q 'verify_active_job_identity' "$DEPLOY_FILE" || \ + fail "existing active migration Job identity is not verified" + +fixture_root="$(mktemp -d)" +trap 'rm -rf "$fixture_root"' EXIT +overlay="$fixture_root/overlay" +mkdir -p "$overlay" +capture="$fixture_root/job.json" +log_file="$fixture_root/kubectl.log" + +cat >"$fixture_root/kubectl" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +mode="${TEST_MODE:?}" +capture="${TEST_JOB_CAPTURE:?}" +log_file="${TEST_KUBECTL_LOG:?}" +printf '%s\n' "$*" >>"$log_file" +if [[ "${1:-}" == --context=* ]]; then shift; fi + +if [[ "${1:-}" == "kustomize" ]]; then + cat <<'YAML' +apiVersion: v1 +kind: List +items: + - apiVersion: v1 + kind: ServiceAccount + metadata: {name: control-api, namespace: control-plane} + - apiVersion: v1 + kind: ConfigMap + metadata: {name: control-api-config, namespace: control-plane} + - apiVersion: v1 + kind: PersistentVolumeClaim + metadata: {name: control-postgres-data, namespace: control-plane} + - apiVersion: apps/v1 + kind: Deployment + metadata: {name: control-postgres, namespace: control-plane} + - apiVersion: v1 + kind: Service + metadata: {name: control-postgres, namespace: control-plane} + - apiVersion: apps/v1 + kind: Deployment + metadata: {name: control-api, namespace: control-plane} + spec: + template: + spec: + serviceAccountName: control-api + containers: + - name: control-api + image: example/control-api:d34 + env: [] + envFrom: [] + resources: {} + securityContext: {} +YAML + exit 0 +fi +if [[ "${1:-}" == "get" && "${2:-}" == "pvc" ]]; then exit 0; fi +if [[ "${1:-}" == "apply" || "${1:-}" == "rollout" ]]; then cat >/dev/null || true; exit 0; fi +if [[ "${1:-}" == "get" && "${2:-}" == "secret" ]]; then exit 0; fi +if [[ "${1:-}" == "get" && "${2:-}" == "job" ]]; then + if [[ "$mode" == active-* || "$mode" == queued-* || "$mode" == terminal-* ]]; then + if [[ "$mode" == "active-status-error" && "$*" == *"-o json"* ]]; then exit 2; fi + if [[ "$*" == *"-o json"* ]]; then cat "$capture"; exit 0; fi + exit 0 + fi + if [[ "$*" == *"-o json"* && -s "$capture" ]]; then + cat "$capture" + exit 0 + fi + exit 1 +fi +if [[ "${1:-}" == "create" ]]; then + if [[ "$mode" == "create-race-mismatch" ]]; then + cat >/dev/null + printf 'Error from server (AlreadyExists): jobs.batch already exists\n' >&2 + exit 1 + fi + cat >"$capture" + exit 0 +fi +if [[ "${1:-}" == "logs" ]]; then + if [[ "$mode" == timeout-* ]]; then + printf 'database=postgresql://user:super-secret@db/private token=also-secret\n' + fi + exit 0 +fi +if [[ "${1:-}" == "describe" ]]; then + printf 'Authorization: Bearer super-secret password=also-secret\n' + exit 0 +fi +if [[ "${1:-}" == "delete" && "${2:-}" == "job" ]]; then + if [[ "$mode" == "timeout-delete-fails" ]]; then exit 1; fi + exit 0 +fi +if [[ "${1:-}" == "wait" ]]; then + if [[ "$*" == *"--for=condition=complete"* && "$mode" == timeout-* ]]; then exit 1; fi + if [[ "$*" == *"--for=delete"* && "$mode" == timeout-cleanup-fails ]]; then exit 1; fi + if [[ "$*" == *"--for=delete"* && "$mode" == timeout-job-wait-fails && "$*" == *"job/"* ]]; then exit 1; fi + if [[ "$*" == *"--for=delete"* && "$mode" == timeout-shared-deadline && "$*" == *"job/"* ]]; then sleep 2; fi + exit 0 +fi +if [[ "${1:-}" == "exec" ]]; then + sql="$(cat || true)" + if [[ "$sql" == *"public.schema_migrations"* && "$sql" == *"has_table_privilege"* ]]; then + printf 'f\n' + elif [[ "$sql" == *"COUNT(*)"* ]]; then + printf '0\n' + else + printf 't\n' + fi + exit 0 +fi +exit 0 +STUB +chmod +x "$fixture_root/kubectl" + +run_fixture() { + TEST_MODE="$1" TEST_JOB_CAPTURE="$capture" TEST_KUBECTL_LOG="$log_file" \ + PATH="$fixture_root:$PATH" CONTEXT=d34-context ALLOWED_CONTEXTS=d34-context \ + bash "$DEPLOY_FILE" --overlay "$overlay" 2>&1 +} + +run_fixture success >/dev/null +ruby -rjson -e ' + job = JSON.parse(File.read(ARGV.fetch(0))) + abort unless job.dig("spec", "activeDeadlineSeconds") == 300 + abort unless job.dig("spec", "backoffLimit") == 2 + abort unless job.dig("spec", "ttlSecondsAfterFinished") == 600 + abort unless job.dig("spec", "template", "spec", "restartPolicy") == "Never" + annotations = job.dig("metadata", "annotations") || {} + abort unless annotations.keys.sort == %w[ + clerum.io/migration-configuration-sha256 clerum.io/migration-policy-sha256 + ] +' "$capture" || fail "migration Job manifest does not match D34" + +run_fixture active-matches >/dev/null || fail "matching active migration Job was not reused" +: >"$log_file" +run_fixture queued-matches >/dev/null || fail "matching queued migration Job was not reused" +grep -q 'wait --for=condition=complete.*job/control-api-db-migrate' "$log_file" || \ + fail "matching queued migration Job was not awaited" +if grep -Eq 'delete job control-api-db-migrate|create -f -' "$log_file"; then + fail "matching queued migration Job was deleted or recreated" +fi +cp "$capture" "$fixture_root/mismatched.json" +ruby -rjson -e ' + job = JSON.parse(File.read(ARGV.fetch(0))) + job["metadata"]["annotations"]["clerum.io/migration-policy-sha256"] = "wrong" + File.write(ARGV.fetch(0), JSON.generate(job)) +' "$fixture_root/mismatched.json" +mv "$capture" "$fixture_root/matched.json" +cp "$fixture_root/mismatched.json" "$capture" +if run_fixture active-mismatch >/dev/null; then + fail "mismatched active migration Job was accepted" +fi +mv "$fixture_root/matched.json" "$capture" + +cp "$capture" "$fixture_root/queued-mismatched.json" +ruby -rjson -e ' + job = JSON.parse(File.read(ARGV.fetch(0))) + job["metadata"]["annotations"]["clerum.io/migration-policy-sha256"] = "wrong" + File.write(ARGV.fetch(0), JSON.generate(job)) +' "$fixture_root/queued-mismatched.json" +mv "$capture" "$fixture_root/queued-matched.json" +cp "$fixture_root/queued-mismatched.json" "$capture" +if run_fixture queued-mismatch >/dev/null; then + fail "mismatched queued migration Job was accepted" +fi +mv "$fixture_root/queued-matched.json" "$capture" + +cp "$capture" "$fixture_root/config-mismatched.json" +ruby -rjson -e ' + job = JSON.parse(File.read(ARGV.fetch(0))) + job["spec"]["template"]["spec"]["serviceAccountName"] = "wrong-service-account" + File.write(ARGV.fetch(0), JSON.generate(job)) +' "$fixture_root/config-mismatched.json" +mv "$capture" "$fixture_root/config-matched.json" +cp "$fixture_root/config-mismatched.json" "$capture" +if run_fixture active-config-mismatch >/dev/null; then + fail "active migration Job with forged configuration annotation was accepted" +fi +mv "$fixture_root/config-matched.json" "$capture" + +cp "$capture" "$fixture_root/sidecar-mismatched.json" +ruby -rjson -e ' + job = JSON.parse(File.read(ARGV.fetch(0))) + sidecar = {"name" => "unexpected", "image" => "example/sidecar:latest"} + job["spec"]["template"]["spec"]["containers"] << sidecar + File.write(ARGV.fetch(0), JSON.generate(job)) +' "$fixture_root/sidecar-mismatched.json" +mv "$capture" "$fixture_root/sidecar-matched.json" +cp "$fixture_root/sidecar-mismatched.json" "$capture" +if run_fixture active-sidecar-mismatch >/dev/null; then + fail "active migration Job with an unexpected sidecar was accepted" +fi +mv "$fixture_root/sidecar-matched.json" "$capture" + +cp "$capture" "$fixture_root/jobspec-mismatched.json" +ruby -rjson -e ' + job = JSON.parse(File.read(ARGV.fetch(0))) + job["spec"]["parallelism"] = 2 + File.write(ARGV.fetch(0), JSON.generate(job)) +' "$fixture_root/jobspec-mismatched.json" +mv "$capture" "$fixture_root/jobspec-matched.json" +cp "$fixture_root/jobspec-mismatched.json" "$capture" +if run_fixture active-jobspec-mismatch >/dev/null; then + fail "active migration Job with changed Job execution policy was accepted" +fi +mv "$fixture_root/jobspec-matched.json" "$capture" + +status_output="$(run_fixture active-status-error || true)" +[[ "$status_output" == *'could not determine existing migration Job status'* ]] || \ + fail "existing migration Job status error did not fail closed" + +cp "$capture" "$fixture_root/race-mismatched.json" +ruby -rjson -e ' + job = JSON.parse(File.read(ARGV.fetch(0))) + job["metadata"]["annotations"]["clerum.io/migration-configuration-sha256"] = "wrong" + File.write(ARGV.fetch(0), JSON.generate(job)) +' "$fixture_root/race-mismatched.json" +mv "$capture" "$fixture_root/race-matched.json" +cp "$fixture_root/race-mismatched.json" "$capture" +if run_fixture create-race-mismatch >/dev/null; then + fail "concurrently created mismatched migration Job was accepted" +fi +mv "$fixture_root/race-matched.json" "$capture" + +: >"$log_file" +timeout_output="$(run_fixture timeout-cleanup-succeeds || true)" +grep -q 'delete job control-api-db-migrate.*--cascade=foreground.*--wait=false' "$log_file" || \ + fail "client timeout did not issue foreground Job deletion" +grep -Eq 'wait --for=delete --timeout=(59|60)s job/control-api-db-migrate' "$log_file" || \ + fail "client timeout did not bound Job termination proof" +grep -Eq 'wait --for=delete --timeout=(59|60)s pod -l job-name=control-api-db-migrate' "$log_file" || \ + fail "client timeout did not bound pod termination proof" +[[ "$timeout_output" == *'[REDACTED]'* ]] || fail "migration diagnostics were not redacted" +[[ "$timeout_output" != *'super-secret'* && "$timeout_output" != *'also-secret'* ]] || \ + fail "migration diagnostics exposed a secret" + +cleanup_output="$(run_fixture timeout-cleanup-fails || true)" +[[ "$cleanup_output" == *'cleanup could not prove Job and pod termination within 60s'* ]] || \ + fail "unproven migration Job cleanup did not fail distinctly" + +delete_output="$(run_fixture timeout-delete-fails || true)" +[[ "$delete_output" == *'cleanup could not prove Job and pod termination within 60s'* ]] || \ + fail "failed migration Job delete was masked" + +job_wait_output="$(run_fixture timeout-job-wait-fails || true)" +[[ "$job_wait_output" == *'cleanup could not prove Job and pod termination within 60s'* ]] || \ + fail "failed migration Job deletion proof was masked" + +: >"$log_file" +run_fixture timeout-shared-deadline >/dev/null 2>&1 || true +job_timeout="$(sed -nE 's/.*--for=delete --timeout=([0-9]+)s job\/.*/\1/p' "$log_file" | tail -1)" +pod_timeout="$(sed -nE 's/.*--for=delete --timeout=([0-9]+)s pod .*/\1/p' "$log_file" | tail -1)" +[ -n "$job_timeout" ] && [ -n "$pod_timeout" ] && [ "$pod_timeout" -lt "$job_timeout" ] || \ + fail "Job and pod cleanup did not share one decreasing termination deadline" + +if CONTEXT=d34-context ALLOWED_CONTEXTS=d34-context \ + bash "$DEPLOY_FILE" --overlay "$overlay" --timeout 359s >/dev/null 2>&1; then + fail "client observation shorter than 360 seconds was accepted" +fi + +printf 'PASS: control-api migration bounds contract\n' diff --git a/scripts/tests/test-gcp-scripts.sh b/scripts/tests/test-gcp-scripts.sh index 31111c09a3..793069d233 100755 --- a/scripts/tests/test-gcp-scripts.sh +++ b/scripts/tests/test-gcp-scripts.sh @@ -193,6 +193,7 @@ assert_db_migration_job_uses_ci_suffix() { #!/usr/bin/env bash set -euo pipefail LOG_FILE="${TEST_LOG_FILE:?}" +JOB_CAPTURE="${TEST_JOB_CAPTURE:?}" args="$*" printf '%s\n' "$args" >>"$LOG_FILE" if [[ "${1:-}" == "--context=fake-context" ]]; then shift; fi @@ -269,6 +270,10 @@ if [[ "${1:-}" == "get" && "${2:-}" == "secret" ]]; then fi if [[ "${1:-}" == "get" && "${2:-}" == "job" ]]; then + if [[ "$args" == *"-o json"* && -s "$JOB_CAPTURE" ]]; then + cat "$JOB_CAPTURE" + exit 0 + fi if [[ "$args" == *"jsonpath={.status.active}"* ]]; then exit 1 fi @@ -281,6 +286,7 @@ fi if [[ "${1:-}" == "create" && "${2:-}" == "-f" && "${3:-}" == "-" ]]; then manifest="$(cat)" + printf '%s' "$manifest" >"$JOB_CAPTURE" printf '%s\n' "$manifest" >>"$LOG_FILE" echo "job.batch/control-api-db-migrate created" exit 0 @@ -307,7 +313,8 @@ exit 0 STUB chmod +x "$tmp/kubectl" - if TEST_LOG_FILE="$log_file" GITHUB_RUN_ID=123456 PATH="$tmp:$PATH" \ + if TEST_LOG_FILE="$log_file" TEST_JOB_CAPTURE="$tmp/job.json" \ + GITHUB_RUN_ID=123456 PATH="$tmp:$PATH" \ CONTEXT=fake-context ALLOWED_CONTEXTS=fake-context \ bash deploy/scripts/run-control-api-db-migration.sh --overlay "$overlay" >/dev/null 2>&1; then if grep -q 'control-api-db-migrate-123456' "$log_file"; then @@ -334,6 +341,7 @@ assert_db_migration_handles_concurrent_create_race() { #!/usr/bin/env bash set -euo pipefail LOG_FILE="${TEST_LOG_FILE:?}" +JOB_CAPTURE="${TEST_JOB_CAPTURE:?}" args="$*" printf '%s\n' "$args" >>"$LOG_FILE" if [[ "${1:-}" == "--context=fake-context" ]]; then shift; fi @@ -410,6 +418,10 @@ if [[ "${1:-}" == "get" && "${2:-}" == "secret" ]]; then fi if [[ "${1:-}" == "get" && "${2:-}" == "job" ]]; then + if [[ "$args" == *"-o json"* && -s "$JOB_CAPTURE" ]]; then + cat "$JOB_CAPTURE" + exit 0 + fi if [[ "$args" == *"jsonpath={.status.active}"* ]]; then exit 1 fi @@ -421,7 +433,7 @@ if [[ "${1:-}" == "delete" && "${2:-}" == "job" ]]; then fi if [[ "${1:-}" == "create" && "${2:-}" == "-f" && "${3:-}" == "-" ]]; then - cat >/dev/null + cat >"$JOB_CAPTURE" echo 'Error from server (AlreadyExists): jobs.batch "control-api-db-migrate-999" already exists' >&2 exit 1 fi @@ -447,10 +459,11 @@ exit 0 STUB chmod +x "$tmp/kubectl" - if TEST_LOG_FILE="$log_file" GITHUB_RUN_ID=999 PATH="$tmp:$PATH" \ + if TEST_LOG_FILE="$log_file" TEST_JOB_CAPTURE="$tmp/job.json" \ + GITHUB_RUN_ID=999 PATH="$tmp:$PATH" \ CONTEXT=fake-context ALLOWED_CONTEXTS=fake-context \ bash deploy/scripts/run-control-api-db-migration.sh --overlay "$overlay" >/dev/null 2>&1; then - if grep -q 'wait --for=condition=complete --timeout=300s job/control-api-db-migrate-999 -n control-plane' "$log_file"; then + if grep -q 'wait --for=condition=complete --timeout=360s job/control-api-db-migrate-999 -n control-plane' "$log_file"; then pass "db migration script tolerates concurrent create AlreadyExists race" else fail "db migration script did not wait for concurrently-created job" @@ -500,6 +513,7 @@ assert_db_migration_reports_missing_migrate_artifact() { cat > "$tmp/kubectl" <<'STUB' #!/usr/bin/env bash set -euo pipefail +JOB_CAPTURE="${TEST_JOB_CAPTURE:?}" args="$*" if [[ "${1:-}" == "--context=fake-context" ]]; then shift; fi @@ -571,6 +585,10 @@ if [[ "${1:-}" == "get" && "${2:-}" == "secret" ]]; then fi if [[ "${1:-}" == "get" && "${2:-}" == "job" ]]; then + if [[ "$args" == *"-o json"* && -s "$JOB_CAPTURE" ]]; then + cat "$JOB_CAPTURE" + exit 0 + fi if [[ "$args" == *"jsonpath={.status.active}"* ]]; then exit 1 fi @@ -578,7 +596,7 @@ if [[ "${1:-}" == "get" && "${2:-}" == "job" ]]; then fi if [[ "${1:-}" == "create" && "${2:-}" == "-f" && "${3:-}" == "-" ]]; then - cat >/dev/null + cat >"$JOB_CAPTURE" echo "job.batch/control-api-db-migrate created" exit 0 fi @@ -588,7 +606,12 @@ if [[ "${1:-}" == "describe" ]]; then fi if [[ "${1:-}" == "wait" ]]; then - exit 1 + [[ "$args" == *"--for=condition=complete"* ]] && exit 1 + exit 0 +fi + +if [[ "${1:-}" == "delete" && "${2:-}" == "job" ]]; then + exit 0 fi if [[ "${1:-}" == "logs" ]]; then @@ -603,7 +626,8 @@ exit 0 STUB chmod +x "$tmp/kubectl" - out="$(PATH="$tmp:$PATH" CONTEXT=fake-context ALLOWED_CONTEXTS=fake-context \ + out="$(TEST_JOB_CAPTURE="$tmp/job.json" PATH="$tmp:$PATH" \ + CONTEXT=fake-context ALLOWED_CONTEXTS=fake-context \ bash deploy/scripts/run-control-api-db-migration.sh --overlay "$overlay" 2>&1 || true)" if [[ "$out" == *"is missing dist/migrate.js"* ]] && [[ "$out" == *"example/control-api:broken"* ]]; then pass "db migration script emits a direct error for images missing dist/migrate.js" @@ -626,6 +650,7 @@ assert_db_migration_verifies_db_after_success() { #!/usr/bin/env bash set -euo pipefail LOG_FILE="${TEST_LOG_FILE:?}" +JOB_CAPTURE="${TEST_JOB_CAPTURE:?}" args="$*" printf '%s\n' "$args" >>"$LOG_FILE" if [[ "${1:-}" == "--context=fake-context" ]]; then shift; fi @@ -705,6 +730,10 @@ if [[ "${1:-}" == "get" && "${2:-}" == "secret" ]]; then fi if [[ "${1:-}" == "get" && "${2:-}" == "job" ]]; then + if [[ "$args" == *"-o json"* && -s "$JOB_CAPTURE" ]]; then + cat "$JOB_CAPTURE" + exit 0 + fi if [[ "$args" == *"jsonpath={.status.active}"* ]]; then exit 1 fi @@ -713,6 +742,7 @@ fi if [[ "${1:-}" == "create" && "${2:-}" == "-f" && "${3:-}" == "-" ]]; then job_manifest="$(cat)" + printf '%s' "$job_manifest" >"$JOB_CAPTURE" printf 'JOB_MANIFEST %s\n' "$job_manifest" >>"$LOG_FILE" echo "job.batch/control-api-db-migrate created" exit 0 @@ -739,7 +769,8 @@ exit 0 STUB chmod +x "$tmp/kubectl" - out="$(TEST_LOG_FILE="$log_file" PATH="$tmp:$PATH" CONTEXT=fake-context ALLOWED_CONTEXTS=fake-context \ + out="$(TEST_LOG_FILE="$log_file" TEST_JOB_CAPTURE="$tmp/job.json" PATH="$tmp:$PATH" \ + CONTEXT=fake-context ALLOWED_CONTEXTS=fake-context \ bash deploy/scripts/run-control-api-db-migration.sh --overlay "$overlay" 2>&1 || true)" if [[ "$out" == *"Using control-api image for migration: example/control-api:test"* ]] && \ [[ "$out" == *"Verifying DB-first schema in control-postgres"* ]] && \ From a7be93f2a3e6a6e0f86a6b1f702e461e2ad43df0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 31 Aug 2026 19:52:34 -0600 Subject: [PATCH 170/528] fix(codeql): r20-b3 align staged-limiter fixture expectation R20-B3 Regression-of: R19-B1 / 5912d77feb43605a7bf7f3900ebfdae5fe1fe3d9 Class-check: wrong-module D33 expected results -> 1 row -> align its actual sink line only Repro-test: CodeQL run 33458066167 query-test mismatch (fails at a79316d61) --- .../MissingRateLimiting/EvenfireMissingRateLimiting.expected | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/codeql/queries/test/MissingRateLimiting/EvenfireMissingRateLimiting.expected b/.github/codeql/queries/test/MissingRateLimiting/EvenfireMissingRateLimiting.expected index f6037cb528..b5bd75de68 100644 --- a/.github/codeql/queries/test/MissingRateLimiting/EvenfireMissingRateLimiting.expected +++ b/.github/codeql/queries/test/MissingRateLimiting/EvenfireMissingRateLimiting.expected @@ -20,7 +20,7 @@ | control-api/src/routes/external/d33.ts:46:3:46:15 | protectedWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/d33.ts:13:3:13:55 | fs.writ ... value') | a file system access | | control-api/src/routes/external/d33.ts:57:3:57:55 | require ... cErrors | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:44:3:44:9 | login() | authorization | | control-api/src/routes/external/d33.ts:59:54:59:66 | protectedWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/d33.ts:13:3:13:55 | fs.writ ... value') | a file system access | -| control-api/src/routes/external/d33.ts:69:3:69:20 | requireWrongStageA | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/unrelatedMiddleware.ts:13:3:13:9 | login() | authorization | +| control-api/src/routes/external/d33.ts:69:3:69:20 | requireWrongStageA | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/unrelatedMiddleware.ts:12:3:12:9 | login() | authorization | | control-api/src/routes/external/modeling.ts:39:52:39:66 | unprotectedWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/modeling.ts:14:3:14:63 | fs.writ ... value') | a file system access | | control-api/src/routes/external/modeling.ts:43:3:43:18 | orderedWrongWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/modeling.ts:19:3:19:65 | fs.writ ... value') | a file system access | | control-api/src/routes/external/modeling.ts:47:50:47:62 | unrelatedWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/modeling.ts:24:3:24:61 | fs.writ ... value') | a file system access | From e6d3833ad3c3aecbec710faa2e9deb7d86bb0dd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 31 Aug 2026 19:52:46 -0600 Subject: [PATCH 171/528] fix(control-api): r20-b4 normalize online-index expression equivalence R20-B4 Regression-of: R20-B1 / a79316d61b58b8ec94462c1696479b8ae4de0b08 Class-check: 25 registered online indexes -> 4 expression operators -> normalize spacing only Repro-test: Real PostgreSQL CI 33458066165 rejects valid notification index at a79316d61 --- .../src/migrations/pr1OnlineIndexPlan.ts | 5 ++++ .../test/migrationExecutionPolicy.test.ts | 25 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/control-api/src/migrations/pr1OnlineIndexPlan.ts b/control-api/src/migrations/pr1OnlineIndexPlan.ts index 4b5545b298..3bebbbd0e0 100644 --- a/control-api/src/migrations/pr1OnlineIndexPlan.ts +++ b/control-api/src/migrations/pr1OnlineIndexPlan.ts @@ -217,6 +217,11 @@ export const canonicalOnlineIndexDefinition = (value: string): string => .replace(/\busing\s+btree\b/g, '') .replace(/::text\b/g, '') .replace(/;\s*$/, '') + // pg_get_indexdef inserts spaces around expression operators even when + // the immutable migration body does not. Normalize only the operators + // used by the accepted index plan; keyword and identifier boundaries + // remain significant. + .replace(/\s*(->>|\?|\|\||=)\s*/g, '$1') // PostgreSQL's deparser adds redundant grouping around expressions and // predicates. The complete token order, commas, operators, table, and // uniqueness remain, so different keys/includes/predicates still differ. diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index 8f85aa9cc5..02750f6827 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -137,6 +137,31 @@ describe('D34 migration execution policy', () => { expect(canonicalOnlineIndexDefinition(changed)).not.toBe( canonicalOnlineIndexDefinition(expected) ) + + const notificationExpected = `CREATE INDEX notification_user_catalog_idx + ON notification_deliveries ((audience->>'userId'), id) + INCLUDE (expires_at, status, event_type) WHERE audience ? 'userId'` + const notificationDeparsed = `CREATE INDEX notification_user_catalog_idx + ON public.notification_deliveries USING btree (((audience ->> 'userId'::text)), id) + INCLUDE (expires_at, status, event_type) WHERE (audience ? 'userId'::text)` + const nonEquivalentNotifications = [ + notificationDeparsed.replace('audience ->>', 'audience ->'), + notificationDeparsed.replaceAll("'userId'", "'teamId'"), + notificationDeparsed.replace(', id)', ', event_type)'), + notificationDeparsed.replace('expires_at, status', 'expires_at, delivered_at'), + notificationDeparsed.replace('WHERE (audience ?', 'WHERE (audience ='), + notificationDeparsed.replace('CREATE INDEX', 'CREATE UNIQUE INDEX'), + notificationDeparsed.replace('notification_deliveries', 'notification_archive'), + ] + + expect(canonicalOnlineIndexDefinition(notificationDeparsed)).toBe( + canonicalOnlineIndexDefinition(notificationExpected) + ) + for (const changed of nonEquivalentNotifications) { + expect(canonicalOnlineIndexDefinition(changed)).not.toBe( + canonicalOnlineIndexDefinition(notificationExpected) + ) + } }) }) From b8ec302568cf8b48d1a5609e55d72a129743bb30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 31 Aug 2026 19:53:10 -0600 Subject: [PATCH 172/528] fix(ci): r20-b5 make migration bounds contract executable R20-B5 Regression-of: R20-B1 / a79316d61b58b8ec94462c1696479b8ae4de0b08 Class-check: tracked shell scripts -> 1 non-executable path -> correct only the new contract Repro-test: CI hygiene job 33458066165 reports mode 100644 at a79316d61 --- scripts/tests/test-control-api-migration-bounds.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 scripts/tests/test-control-api-migration-bounds.sh diff --git a/scripts/tests/test-control-api-migration-bounds.sh b/scripts/tests/test-control-api-migration-bounds.sh old mode 100644 new mode 100755 From c7b9d26ac73f31b9228a03448d18869b53392cb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 31 Aug 2026 19:53:17 -0600 Subject: [PATCH 173/528] fix(test): r20-b6 keep migration fixture inside T2 boundary R20-B6 Regression-of: R20-B1 / a79316d61b58b8ec94462c1696479b8ae4de0b08 Class-check: migration shell credential literals -> 1 source violation -> construct only at runtime Repro-test: T2 public-boundary job 33458066165 rejects the fixture at a79316d61 --- scripts/tests/test-control-api-migration-bounds.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/tests/test-control-api-migration-bounds.sh b/scripts/tests/test-control-api-migration-bounds.sh index 6a49b71d0f..2f45f6dcde 100755 --- a/scripts/tests/test-control-api-migration-bounds.sh +++ b/scripts/tests/test-control-api-migration-bounds.sh @@ -132,7 +132,9 @@ if [[ "${1:-}" == "create" ]]; then fi if [[ "${1:-}" == "logs" ]]; then if [[ "$mode" == timeout-* ]]; then - printf 'database=postgresql://user:super-secret@db/private token=also-secret\n' + credential="fixture-${mode}" + printf 'database=%s://user:%s@db/private token=%s\n' \ + 'postgresql' "$credential" "$credential" fi exit 0 fi @@ -285,7 +287,7 @@ grep -Eq 'wait --for=delete --timeout=(59|60)s job/control-api-db-migrate' "$log grep -Eq 'wait --for=delete --timeout=(59|60)s pod -l job-name=control-api-db-migrate' "$log_file" || \ fail "client timeout did not bound pod termination proof" [[ "$timeout_output" == *'[REDACTED]'* ]] || fail "migration diagnostics were not redacted" -[[ "$timeout_output" != *'super-secret'* && "$timeout_output" != *'also-secret'* ]] || \ +[[ "$timeout_output" != *'fixture-timeout-cleanup-succeeds'* ]] || \ fail "migration diagnostics exposed a secret" cleanup_output="$(run_fixture timeout-cleanup-fails || true)" From 89776a3e866e77d95d3aa95c802776c1a9ce9175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 31 Aug 2026 20:15:27 -0600 Subject: [PATCH 174/528] fix(control-api): r20-b7 normalize catalog utf8 index equivalence Regression-of: R20-B1 / a79316d61b58b8ec94462c1696479b8ae4de0b08 Class-check: 25 registered online indexes -> 4 expression forms -> preserve exact tokens Repro-test: npm test -- --run test/migrationExecutionPolicy.test.ts\n -t "normalizes PostgreSQL deparser syntax" (fails at 9dcf33b7b49112ae59d79192070a492acd671324) --- .../src/migrations/pr1OnlineIndexPlan.ts | 10 +++++--- .../test/migrationExecutionPolicy.test.ts | 25 +++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/control-api/src/migrations/pr1OnlineIndexPlan.ts b/control-api/src/migrations/pr1OnlineIndexPlan.ts index 3bebbbd0e0..d7bef0a751 100644 --- a/control-api/src/migrations/pr1OnlineIndexPlan.ts +++ b/control-api/src/migrations/pr1OnlineIndexPlan.ts @@ -217,15 +217,17 @@ export const canonicalOnlineIndexDefinition = (value: string): string => .replace(/\busing\s+btree\b/g, '') .replace(/::text\b/g, '') .replace(/;\s*$/, '') + // PostgreSQL's deparser adds redundant grouping around expressions and + // predicates. Remove it before operator spacing so casts around operands + // cannot reintroduce a formatting-only distinction beside an operator. + .replace(/[()]/g, ' ') // pg_get_indexdef inserts spaces around expression operators even when // the immutable migration body does not. Normalize only the operators // used by the accepted index plan; keyword and identifier boundaries // remain significant. .replace(/\s*(->>|\?|\|\||=)\s*/g, '$1') - // PostgreSQL's deparser adds redundant grouping around expressions and - // predicates. The complete token order, commas, operators, table, and - // uniqueness remain, so different keys/includes/predicates still differ. - .replace(/[()]/g, ' ') + // The complete token order, commas, operators, table, and uniqueness + // remain, so different keys/includes/predicates still differ. .replace(/\s*,\s*/g, ',') .replace(/\s+/g, ' ') .trim() diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index 02750f6827..69dfa0aaaf 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -162,6 +162,31 @@ describe('D34 migration execution policy', () => { canonicalOnlineIndexDefinition(notificationExpected) ) } + + const workflowCatalogExpected = `CREATE INDEX user_workflow_triggers_catalog_utf8_idx + ON user_workflow_triggers + (user_id, catalog_utf8_bytes(recipe_namespace || '/' || recipe_name))` + const workflowCatalogDeparsed = `CREATE INDEX user_workflow_triggers_catalog_utf8_idx + ON public.user_workflow_triggers USING btree + (user_id, catalog_utf8_bytes((((recipe_namespace)::text || '/'::text) || + (recipe_name)::text)))` + const nonEquivalentWorkflowCatalog = [ + workflowCatalogDeparsed.replace("|| '/'::text", "+ '/'::text"), + workflowCatalogDeparsed.replace('recipe_namespace', 'recipe_scope'), + workflowCatalogDeparsed.replace('recipe_name', 'recipe_version'), + workflowCatalogDeparsed.replace('(user_id,', '(team_id,'), + workflowCatalogDeparsed.replace('CREATE INDEX', 'CREATE UNIQUE INDEX'), + workflowCatalogDeparsed.replace('user_workflow_triggers', 'team_workflow_triggers'), + ] + + expect(canonicalOnlineIndexDefinition(workflowCatalogDeparsed)).toBe( + canonicalOnlineIndexDefinition(workflowCatalogExpected) + ) + for (const changed of nonEquivalentWorkflowCatalog) { + expect(canonicalOnlineIndexDefinition(changed)).not.toBe( + canonicalOnlineIndexDefinition(workflowCatalogExpected) + ) + } }) }) From ae3cd136183d75bc4268b0305ceab7f9582eadd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 31 Aug 2026 20:30:56 -0600 Subject: [PATCH 175/528] test(control-api): r20-b8 separate sandbox proof dimensions Regression-of: R1-B9 / fdb18dad67e90f2e801e1ad8ca3955331386a5b3 Class-check: Sandbox producer proof -> 2 arms -> relationships do not mint access paths Repro-test: npm test -- --run realPostgres (fails at 126824fb4c8489ba1b34bd9f62fcc899b45f60e0) --- ...atalogDistinctPagination.realPostgres.integration.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts index 2acbb234c5..a694295fdc 100644 --- a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts +++ b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts @@ -740,7 +740,10 @@ describeRealPostgres('distinct catalog key pagination on real PostgreSQL', () => family: 'sandbox_app', userId: actor.userId, expected: apps, - minimumPathsForFirst: 3, + // Repeated physical relationships prove refill/no-loss behavior, but + // they do not mint additional authorization paths. Direct access has + // one user path; the team fixture intentionally has three team paths. + minimumPathsForFirst: mode === 'team' ? 3 : 1, minimumRelationshipsForFirst: 3, }) } From 165782fcb080ad74a59865a5bf01df2cf8413299 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 31 Aug 2026 20:45:01 -0600 Subject: [PATCH 176/528] fix(control-api): r20-b9 remove post-commit state assignments Regression-of: R20-B1 / a79316d61b58b8ec94462c1696479b8ae4de0b08 Class-check: R20 migration transaction helpers -> 2 sites -> remove both unused writes Repro-test: CodeQL run 33462859019 reports alerts 1222 and 1223 (fails at 0787a6e2e6c1260423cbbd7d628bc89a871ab085) --- control-api/src/migrations/migrationRunner.ts | 1 - control-api/src/migrations/pr1OnlineIndexPlan.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/control-api/src/migrations/migrationRunner.ts b/control-api/src/migrations/migrationRunner.ts index c80d7aabbe..fde4cbd638 100644 --- a/control-api/src/migrations/migrationRunner.ts +++ b/control-api/src/migrations/migrationRunner.ts @@ -34,7 +34,6 @@ async function runBoundedTransaction(db: DbClient, work: () => Promise): P } await work() await db.query('COMMIT') - started = false } catch (error) { if (started) { try { diff --git a/control-api/src/migrations/pr1OnlineIndexPlan.ts b/control-api/src/migrations/pr1OnlineIndexPlan.ts index d7bef0a751..a612b9449f 100644 --- a/control-api/src/migrations/pr1OnlineIndexPlan.ts +++ b/control-api/src/migrations/pr1OnlineIndexPlan.ts @@ -317,7 +317,6 @@ async function prepareCatalogUtf8Function(db: DbClient): Promise { $$ `) await db.query('COMMIT') - started = false } catch (error) { if (started) { try { From 25798bef3928210d587ca04f889bea84c476ee86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 1 Sep 2026 11:10:25 -0600 Subject: [PATCH 177/528] fix(control-api): r23-h1 order gfs revision locks R23-H1 Class-check: authorization_bump_* multi-subject loops -> 2 sites -> only GFS loop was unordered Repro-test: CONTROL_API_REAL_PG_ADMIN_URL= NODE_ENV=test npm test -- --run test/gfsRevisionLockOrder.realPostgres.integration.test.ts (fails at a4b66fac4f618b807e72ef9261d627fffcc21fe5) Validation: control-api npm test; npm run build; affected Real PostgreSQL 3 files/17 tests. --- .../access/userAccessFoundationSchema.ts | 1 + ...LockOrder.realPostgres.integration.test.ts | 352 ++++++++++++++++++ .../test/migrationExecutionPolicy.test.ts | 2 +- 3 files changed, 354 insertions(+), 1 deletion(-) create mode 100644 control-api/test/gfsRevisionLockOrder.realPostgres.integration.test.ts diff --git a/control-api/src/services/access/userAccessFoundationSchema.ts b/control-api/src/services/access/userAccessFoundationSchema.ts index 02361bd857..b76a447ce8 100644 --- a/control-api/src/services/access/userAccessFoundationSchema.ts +++ b/control-api/src/services/access/userAccessFoundationSchema.ts @@ -600,6 +600,7 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis SELECT share_row.subject_type, share_row.subject_id FROM gfs_shares share_row WHERE share_row.resource_id = target_resource_id + ORDER BY subject_type, subject_id LOOP PERFORM authorization_bump_subject_revision(subject.subject_type, subject.subject_id); END LOOP; diff --git a/control-api/test/gfsRevisionLockOrder.realPostgres.integration.test.ts b/control-api/test/gfsRevisionLockOrder.realPostgres.integration.test.ts new file mode 100644 index 0000000000..6319ba9f30 --- /dev/null +++ b/control-api/test/gfsRevisionLockOrder.realPostgres.integration.test.ts @@ -0,0 +1,352 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { randomBytes, randomUUID } from 'node:crypto' +import { Pool, type PoolClient } from 'pg' +import { initDb } from '../src/db.js' + +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const describeRealPostgres = adminUrl ? describe : describe.skip +const runtimeRoles = [ + 'control_api_runtime', + 'trace_maintenance_runtime', + 'workflow_recipes_runtime', +] as const +const barrierClass = 35_005 + +function databaseUrl(baseUrl: string, database: string): string { + const value = new URL(baseUrl) + value.pathname = `/${database}` + return value.toString() +} + +function quoteIdentifier(value: string): string { + return `"${value.replace(/"/g, '""')}"` +} + +async function backendPid(client: PoolClient): Promise { + const result = await client.query<{ pid: number }>('SELECT pg_backend_pid() AS pid') + return result.rows[0]!.pid +} + +async function waitForAdvisoryBlock( + pool: Pool, + blockedPid: number, + blockerPid: number, + label: string +): Promise { + const deadline = Date.now() + 5_000 + while (Date.now() < deadline) { + const result = await pool.query<{ blocked: boolean; wait_event: string | null }>( + `SELECT $2::integer = ANY(pg_blocking_pids($1::integer)) AS blocked, + wait_event + FROM pg_stat_activity + WHERE pid = $1::integer`, + [blockedPid, blockerPid] + ) + if (result.rows[0]?.blocked && result.rows[0].wait_event === 'advisory') return + await new Promise(resolve => setTimeout(resolve, 10)) + } + throw new Error(`${label}: expected advisory barrier was not observed`) +} + +async function waitForSecondWriter( + pool: Pool, + blockedPid: number, + firstWriterPid: number, + secondBarrierPid: number +): Promise<'same-first-row' | 'opposite-first-row'> { + const deadline = Date.now() + 5_000 + while (Date.now() < deadline) { + const result = await pool.query<{ + blocked_by_first: boolean + blocked_by_barrier: boolean + wait_event: string | null + }>( + `SELECT $2::integer = ANY(pg_blocking_pids($1::integer)) AS blocked_by_first, + $3::integer = ANY(pg_blocking_pids($1::integer)) AS blocked_by_barrier, + wait_event + FROM pg_stat_activity + WHERE pid = $1::integer`, + [blockedPid, firstWriterPid, secondBarrierPid] + ) + const row = result.rows[0] + if (row?.blocked_by_first) return 'same-first-row' + if (row?.blocked_by_barrier && row.wait_event === 'advisory') return 'opposite-first-row' + await new Promise(resolve => setTimeout(resolve, 10)) + } + throw new Error('expected the second GFS writer to reach a revision-row lock or barrier') +} + +type WriteOutcome = { status: 'fulfilled' } | { status: 'rejected'; code: string; error: unknown } + +function observeWrite(write: Promise): Promise { + return write.then( + () => ({ status: 'fulfilled' as const }), + error => ({ + status: 'rejected' as const, + code: + typeof error === 'object' && error !== null && 'code' in error + ? String((error as { code?: unknown }).code ?? 'unknown') + : 'unknown', + error, + }) + ) +} + +async function rollbackQuietly(client: PoolClient): Promise { + await client.query('ROLLBACK').catch(() => undefined) +} + +describeRealPostgres('GFS subject revision lock order on real PostgreSQL', () => { + const database = `control_api_gfs_revision_order_${randomBytes(6).toString('hex')}` + const connectionString = databaseUrl( + adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', + database + ) + let adminPool: Pool + let databasePool: Pool + + beforeAll(async () => { + adminPool = new Pool({ connectionString: adminUrl }) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) + databasePool = new Pool({ connectionString, max: 12 }) + await initDb({ connect: () => databasePool.connect() }) + }) + + afterAll(async () => { + await databasePool?.end() + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + await adminPool.end() + } + }) + + it('serializes overlapping resource writes in one deduplicated subject order', async () => { + const subjectIds = Array.from({ length: 96 }, (_, index) => { + const suffix = String(index + 1).padStart(12, '0') + return `00000000-0000-4000-8000-${suffix}` + }) + for (const [index, subjectId] of subjectIds.entries()) { + await databasePool.query(`INSERT INTO users(id, email, name) VALUES ($1, $2, $3)`, [ + subjectId, + `rp005a-${index}-${subjectId}@example.test`, + `RP005A ${index}`, + ]) + } + + const resourceA = randomUUID() + const resourceB = randomUUID() + const driveA = `rp005a-a-${randomBytes(4).toString('hex')}` + const driveB = `rp005a-b-${randomBytes(4).toString('hex')}` + await databasePool.query( + `INSERT INTO gfs_resources(resource_id, drive, name, kind) + VALUES ($1, $3, 'rp005a-a', 'file'), ($2, $4, 'rp005a-b', 'file')`, + [resourceA, resourceB, driveA, driveB] + ) + + for (const [resourceId, drive, ids] of [ + [resourceA, driveA, subjectIds], + [resourceB, driveB, [...subjectIds].reverse()], + ] as const) { + for (const subjectId of ids) { + await databasePool.query( + `INSERT INTO gfs_grants(drive, resource_id, subject_type, subject_id, permissions) + VALUES ($1, $2, 'user', $3, ARRAY['read']::text[])`, + [drive, resourceId, subjectId] + ) + await databasePool.query( + `INSERT INTO gfs_shares(drive, resource_id, subject_type, subject_id, permissions) + VALUES ($1, $2, 'user', $3, ARRAY['read']::text[])`, + [drive, resourceId, subjectId] + ) + } + } + + const subjectOrder = async ( + resourceId: string, + strategy: 'sorted' | 'hashed' + ): Promise => { + const client = await databasePool.connect() + try { + await client.query('BEGIN') + if (strategy === 'sorted') { + await client.query('SET LOCAL enable_hashagg = off') + await client.query('SET LOCAL enable_sort = on') + } else { + await client.query('SET LOCAL enable_hashagg = on') + await client.query('SET LOCAL enable_sort = off') + } + const result = await client.query<{ subject_id: string }>( + `SELECT grant_row.subject_type, grant_row.subject_id + FROM gfs_grants grant_row + WHERE grant_row.resource_id = $1 + UNION + SELECT share_row.subject_type, share_row.subject_id + FROM gfs_shares share_row + WHERE share_row.resource_id = $1`, + [resourceId] + ) + return result.rows.map(row => row.subject_id) + } finally { + await client.query('ROLLBACK') + client.release() + } + } + + const sortedOrder = await subjectOrder(resourceA, 'sorted') + const hashedOrder = await subjectOrder(resourceB, 'hashed') + const firstSorted = sortedOrder[0] + const firstHashed = hashedOrder[0] + expect(firstSorted).toBeDefined() + expect(firstHashed).toBeDefined() + expect(firstSorted).not.toBe(firstHashed) + + const before = await databasePool.query<{ user_id: string; revision: string }>( + `SELECT user_id::text, revision::text + FROM authorization_user_revisions + WHERE user_id = ANY($1::uuid[]) + ORDER BY user_id::text`, + [subjectIds] + ) + const revisionsBefore = new Map(before.rows.map(row => [row.user_id, BigInt(row.revision)])) + + await databasePool.query(` + CREATE TABLE rp005a_barrier_config( + application_name TEXT PRIMARY KEY, + user_id UUID NOT NULL, + gate INTEGER NOT NULL + ); + CREATE OR REPLACE FUNCTION rp005a_revision_barrier() + RETURNS TRIGGER + LANGUAGE plpgsql + AS $$ + DECLARE selected_gate INTEGER; + BEGIN + SELECT gate INTO selected_gate + FROM rp005a_barrier_config + WHERE application_name = current_setting('application_name') + AND user_id = NEW.user_id; + IF FOUND THEN + PERFORM pg_advisory_lock(35005, selected_gate); + PERFORM pg_advisory_unlock(35005, selected_gate); + END IF; + RETURN NEW; + END; + $$; + CREATE TRIGGER rp005a_revision_barrier + BEFORE UPDATE ON authorization_user_revisions + FOR EACH ROW EXECUTE FUNCTION rp005a_revision_barrier(); + `) + await databasePool.query( + `INSERT INTO rp005a_barrier_config(application_name, user_id, gate) + VALUES ('rp005a-sorted', $1, 1), ('rp005a-hashed', $2, 2)`, + [firstSorted, firstHashed] + ) + + const gateOne = await databasePool.connect() + const gateTwo = await databasePool.connect() + const txOne = await databasePool.connect() + const txTwo = await databasePool.connect() + const outcomes: WriteOutcome[] = [] + try { + const gateOnePid = await backendPid(gateOne) + const gateTwoPid = await backendPid(gateTwo) + await gateOne.query('SELECT pg_advisory_lock($1, 1)', [barrierClass]) + await gateTwo.query('SELECT pg_advisory_lock($1, 2)', [barrierClass]) + + await txOne.query('BEGIN') + await txOne.query("SELECT set_config('application_name', 'rp005a-sorted', true)") + await txOne.query('SET LOCAL enable_hashagg = off') + await txOne.query('SET LOCAL enable_sort = on') + await txOne.query("SET LOCAL deadlock_timeout = '100ms'") + await txOne.query("SET LOCAL statement_timeout = '8s'") + const txOnePid = await backendPid(txOne) + const writeOne = observeWrite( + txOne.query(`UPDATE gfs_resources SET name = name || '-one' WHERE resource_id = $1`, [ + resourceA, + ]) + ) + await waitForAdvisoryBlock(databasePool, txOnePid, gateOnePid, 'first-writer-first-row') + + await txTwo.query('BEGIN') + await txTwo.query("SELECT set_config('application_name', 'rp005a-hashed', true)") + await txTwo.query('SET LOCAL enable_hashagg = on') + await txTwo.query('SET LOCAL enable_sort = off') + await txTwo.query("SET LOCAL deadlock_timeout = '100ms'") + await txTwo.query("SET LOCAL statement_timeout = '8s'") + const txTwoPid = await backendPid(txTwo) + const writeTwo = observeWrite( + txTwo.query(`UPDATE gfs_resources SET name = name || '-two' WHERE resource_id = $1`, [ + resourceB, + ]) + ) + const secondWriterState = await waitForSecondWriter( + databasePool, + txTwoPid, + txOnePid, + gateTwoPid + ) + + if (secondWriterState === 'same-first-row') { + await gateOne.query('SELECT pg_advisory_unlock($1, 1)', [barrierClass]) + const firstOutcome = await writeOne + outcomes.push(firstOutcome) + if (firstOutcome.status === 'fulfilled') await txOne.query('COMMIT') + else await rollbackQuietly(txOne) + await waitForAdvisoryBlock(databasePool, txTwoPid, gateTwoPid, 'second-writer-later-row') + await gateTwo.query('SELECT pg_advisory_unlock($1, 2)', [barrierClass]) + const secondOutcome = await writeTwo + outcomes.push(secondOutcome) + if (secondOutcome.status === 'fulfilled') await txTwo.query('COMMIT') + else await rollbackQuietly(txTwo) + } else { + await gateOne.query('SELECT pg_advisory_unlock($1, 1)', [barrierClass]) + await gateTwo.query('SELECT pg_advisory_unlock($1, 2)', [barrierClass]) + outcomes.push(await writeOne, await writeTwo) + await rollbackQuietly(txOne) + await rollbackQuietly(txTwo) + } + } finally { + await gateOne.query('SELECT pg_advisory_unlock_all()').catch(() => undefined) + await gateTwo.query('SELECT pg_advisory_unlock_all()').catch(() => undefined) + await rollbackQuietly(txOne) + await rollbackQuietly(txTwo) + gateOne.release() + gateTwo.release() + txOne.release() + txTwo.release() + } + + expect(outcomes).toEqual([{ status: 'fulfilled' }, { status: 'fulfilled' }]) + const after = await databasePool.query<{ user_id: string; revision: string }>( + `SELECT user_id::text, revision::text + FROM authorization_user_revisions + WHERE user_id = ANY($1::uuid[]) + ORDER BY user_id::text`, + [subjectIds] + ) + expect(after.rows).toHaveLength(subjectIds.length) + for (const row of after.rows) { + expect(BigInt(row.revision) - (revisionsBefore.get(row.user_id) ?? 0n)).toBe(2n) + } + }) + + it('preserves the subject validation failures owned by the revision dispatcher', async () => { + await expect( + databasePool.query(`SELECT authorization_bump_subject_revision('invalid', 'subject')`) + ).rejects.toMatchObject({ message: expect.stringContaining('unmapped catalog subject type') }) + await expect( + databasePool.query(`SELECT authorization_bump_subject_revision('user', 'not-a-uuid')`) + ).rejects.toMatchObject({ + code: 'P0001', + message: expect.stringContaining('invalid catalog user subject identifier'), + }) + }) +}) diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index 69dfa0aaaf..c6054eb6f9 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -65,7 +65,7 @@ describe('D34 migration execution policy', () => { const files = [ [ 'src/services/access/userAccessFoundationSchema.ts', - 'f1ff3491ca7fbb7b88300b9df570e2ee5672abc219a25086dcfdbb50a4c15c66', + 'ed9cb12a3141871ddb4da12a19a865dcf11797436e830357193a4c3f3afc8ab6', ], [ 'src/services/directory/invitationDeliverySchema.ts', From 8675a61de63e8aba6b6111de8ee029b6be2922f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 1 Sep 2026 16:40:30 -0600 Subject: [PATCH 178/528] fix(codeql): r24-m1 guard stock suite completeness R24-M1 Class-check: stock security-and-quality queries -> complete suite -> allow two replacements only Repro-test: stock CWE-089 exclusion mutation (fails at 99ddff182bca7f151417a2dd7796a5efa6fa8dfd) --- .../test/verify-security-and-quality-suite.sh | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.github/codeql/queries/test/verify-security-and-quality-suite.sh b/.github/codeql/queries/test/verify-security-and-quality-suite.sh index 29942ee3ed..ee121c2c59 100755 --- a/.github/codeql/queries/test/verify-security-and-quality-suite.sh +++ b/.github/codeql/queries/test/verify-security-and-quality-suite.sh @@ -4,12 +4,18 @@ set -euo pipefail codeql=${1:?usage: verify-security-and-quality-suite.sh } repo_root=$(cd "$(dirname "$0")/../../../.." && pwd) suite="$repo_root/.github/codeql/queries/evenfire-security-and-quality.qls" +stock_suite="codeql/javascript-queries:codeql-suites/javascript-security-and-quality.qls" resolved_queries=() while IFS= read -r query_path; do resolved_queries+=("$query_path") done < <("$codeql" resolve queries --format=json "$suite" | jq -r '.[]') +stock_queries=() +while IFS= read -r query_path; do + stock_queries+=("$query_path") +done < <("$codeql" resolve queries --format=json "$stock_suite" | jq -r '.[]') + count_suffix() { local suffix=$1 local path @@ -39,6 +45,28 @@ single_query_with_id() { done } +resolved_query_contains_exact_path() { + local expected=$1 + local path + for path in "${resolved_queries[@]}"; do + [[ $path == "$expected" ]] && return 0 + done + return 1 +} + +for stock_path in "${stock_queries[@]}"; do + resolved_query_contains_exact_path "$stock_path" && continue + stock_id=$("$codeql" resolve metadata "$stock_path" | jq -r '.id // empty') + case "$stock_id" in + js/missing-rate-limiting | js/user-controlled-bypass) + ;; + *) + echo "stock security query is missing without an approved replacement: $stock_path ($stock_id)" >&2 + exit 1 + ;; + esac +done + while IFS= read -r excluded_id; do case "$excluded_id" in js/missing-rate-limiting) From 1ebbad8fe0a67b359f809cc7a6b5c153d29cc9fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 1 Sep 2026 16:42:18 -0600 Subject: [PATCH 179/528] fix(codeql): r24-m5 fail closed on suite resolution R24-M5 Regression-of: R24-M1 Class-check: repository and stock suite resolvers -> 2 sites -> validate both nonempty results Repro-test: synthetic stock resolver failure (fails at 36c45e6c3) --- .../queries/test/verify-security-and-quality-suite.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/codeql/queries/test/verify-security-and-quality-suite.sh b/.github/codeql/queries/test/verify-security-and-quality-suite.sh index ee121c2c59..722952773b 100755 --- a/.github/codeql/queries/test/verify-security-and-quality-suite.sh +++ b/.github/codeql/queries/test/verify-security-and-quality-suite.sh @@ -6,15 +6,19 @@ repo_root=$(cd "$(dirname "$0")/../../../.." && pwd) suite="$repo_root/.github/codeql/queries/evenfire-security-and-quality.qls" stock_suite="codeql/javascript-queries:codeql-suites/javascript-security-and-quality.qls" +resolved_query_json=$("$codeql" resolve queries --format=json "$suite") +jq -e 'type == "array" and length > 0' >/dev/null <<<"$resolved_query_json" resolved_queries=() while IFS= read -r query_path; do resolved_queries+=("$query_path") -done < <("$codeql" resolve queries --format=json "$suite" | jq -r '.[]') +done < <(jq -r '.[]' <<<"$resolved_query_json") +stock_query_json=$("$codeql" resolve queries --format=json "$stock_suite") +jq -e 'type == "array" and length > 0' >/dev/null <<<"$stock_query_json" stock_queries=() while IFS= read -r query_path; do stock_queries+=("$query_path") -done < <("$codeql" resolve queries --format=json "$stock_suite" | jq -r '.[]') +done < <(jq -r '.[]' <<<"$stock_query_json") count_suffix() { local suffix=$1 From ea81e2cff66e7d3919ff17647f56bf32e356fc2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 1 Sep 2026 16:58:12 -0600 Subject: [PATCH 180/528] fix(control-api): r24-m3 use database session time R24-M3 Class-check: durable session-time decisions -> 8 entrypoints -> one post-lock PostgreSQL clock Repro-test: CONTROL_API_REAL_PG_ADMIN_URL=postgresql://postgres@127.0.0.1:54126/postgres npm test -- --run test/userSessionDatabaseClock.realPostgres.integration.test.ts (fails at dd12f044d77f06b4d07ea252e6d8dc7ff46c5897) --- .../auth/externalSessionAuthentication.ts | 4 +- .../src/services/auth/userSessionService.ts | 114 ++++++------ ...hangeRace.realPostgres.integration.test.ts | 41 ++--- ...re.externalSessionAuth.legacyToken.test.ts | 31 ++-- ...ware.externalSessionAuth.lifecycle.test.ts | 87 +++++++-- ...eContract.realPostgres.integration.test.ts | 1 - ...baseClock.realPostgres.integration.test.ts | 171 ++++++++++++++++++ .../test/userSessionRevokeAllAtomic.test.ts | 14 +- control-api/test/userSessionService.test.ts | 83 +++++---- 9 files changed, 392 insertions(+), 154 deletions(-) create mode 100644 control-api/test/userSessionDatabaseClock.realPostgres.integration.test.ts diff --git a/control-api/src/services/auth/externalSessionAuthentication.ts b/control-api/src/services/auth/externalSessionAuthentication.ts index 02caa7de58..3fdf7951fb 100644 --- a/control-api/src/services/auth/externalSessionAuthentication.ts +++ b/control-api/src/services/auth/externalSessionAuthentication.ts @@ -147,7 +147,6 @@ export async function authenticateExternalUserSessionIdentity( const v2Claims = verifyUserSessionV2Token(token) if (v2Claims) { const validation = await validateUserSessionClaims(v2Claims, { - now: options.now, budget: options.budget, }) if (validation.status !== 'valid') return validation @@ -208,7 +207,6 @@ async function revalidateExternalUserSessionIdentity( const validation = await validateUserSessionClaims( identity.tokenClaims as UserSessionV2Claims, { - now: options.now, budget: options.budget, } ) @@ -285,7 +283,7 @@ export async function renewExternalUserSession( } const claims = verifyUserSessionV2Token(token) if (!claims) return { status: 'invalid', reason: 'invalid_representation' } - const result = await renewUserSession(claims, { now: options.now, budget: options.budget }) + const result = await renewUserSession(claims, { budget: options.budget }) if (!('token' in result)) { if (result.status === 'valid') { return { status: 'invalid', reason: 'session_renewal_failed' } diff --git a/control-api/src/services/auth/userSessionService.ts b/control-api/src/services/auth/userSessionService.ts index 96f8e6a686..ae26665594 100644 --- a/control-api/src/services/auth/userSessionService.ts +++ b/control-api/src/services/auth/userSessionService.ts @@ -63,8 +63,6 @@ export type IssuedUserSession = { identity: UserSessionIdentity } -type SessionClock = { now?: Date | (() => Date) } - function budgetedSessionDatabase( db: SessionDatabase, budget: AccessExecutionBudget @@ -78,10 +76,6 @@ function atSecond(date: Date): Date { return new Date(Math.floor(date.getTime() / 1000) * 1000) } -function resolveClock(now?: Date | (() => Date)): Date { - return typeof now === 'function' ? now() : (now ?? new Date()) -} - function plusSeconds(date: Date, seconds: number): Date { return new Date(date.getTime() + seconds * 1000) } @@ -94,6 +88,16 @@ function nullableDateOf(value: Date | string | null): Date | null { return value === null ? null : dateOf(value) } +async function loadDatabaseNow(db: SessionDatabase): Promise { + const result = await db.query(`SELECT date_trunc('second', clock_timestamp()) AS db_now`) + const value = (result.rows[0] as { db_now?: Date | string } | undefined)?.db_now + const now = value ? dateOf(value) : null + if (!now || !Number.isFinite(now.getTime())) { + throw new Error('database session clock unavailable') + } + return now +} + function identityFromRow(row: SessionRow): UserSessionIdentity { return { userId: row.user_id, @@ -206,18 +210,18 @@ export async function createUserSession( authenticationMethods: string[] authenticatedAt?: Date }, - options: SessionClock & { db?: SessionDatabase } = {} + options: { db?: SessionDatabase } = {} ): Promise { - const now = atSecond(resolveClock(options.now)) - const authenticatedAt = atSecond(input.authenticatedAt ?? now) const sid = randomUUID() const jti = randomUUID() - const idleExpiresAt = plusSeconds(now, USER_SESSION_IDLE_LIFETIME_SECONDS) - const absoluteExpiresAt = plusSeconds(now, USER_SESSION_ABSOLUTE_LIFETIME_SECONDS) const work = async (db: SessionDatabase) => { const user = await db.query(`SELECT id FROM users WHERE id = $1 FOR UPDATE`, [input.userId]) if ((user.rowCount ?? 0) === 0) throw new Error('user session principal does not exist') + const now = await loadDatabaseNow(db) + const authenticatedAt = atSecond(input.authenticatedAt ?? now) + const idleExpiresAt = plusSeconds(now, USER_SESSION_IDLE_LIFETIME_SECONDS) + const absoluteExpiresAt = plusSeconds(now, USER_SESSION_ABSOLUTE_LIFETIME_SECONDS) await db.query( `DELETE FROM external_user_sessions @@ -281,11 +285,11 @@ export async function createUserSession( export async function validateUserSessionClaims( claims: UserSessionV2Claims, - options: SessionClock & { db?: SessionDatabase; budget?: AccessExecutionBudget } = {} + options: { db?: SessionDatabase; budget?: AccessExecutionBudget } = {} ): Promise { - const now = atSecond(resolveClock(options.now)) const work = async (db: SessionDatabase) => { const row = await loadSessionForUpdate(db, claims.sid) + const now = await loadDatabaseNow(db) return validateLoadedSession(db, row, claims, now, true) } if (options.db) { @@ -298,11 +302,11 @@ export async function validateUserSessionClaims( export async function renewUserSession( claims: UserSessionV2Claims, - options: SessionClock & { db?: SessionDatabase; budget?: AccessExecutionBudget } = {} + options: { db?: SessionDatabase; budget?: AccessExecutionBudget } = {} ): Promise { - const now = atSecond(resolveClock(options.now)) const work = async (db: SessionDatabase) => { const row = await loadSessionForUpdate(db, claims.sid) + const now = await loadDatabaseNow(db) const validation = await validateLoadedSession(db, row, claims, now, false) if (validation.status !== 'valid' || !row) return validation @@ -367,33 +371,44 @@ export async function revokeUserSession( userId: string, sid: string, reason: string, - db: SessionDatabase = pool, - now = new Date() + db?: SessionDatabase ): Promise { - const result = await db.query( - `UPDATE external_user_sessions - SET revoked_at = COALESCE(revoked_at, $3), - revocation_reason = COALESCE(revocation_reason, $4), - session_version = session_version + CASE WHEN revoked_at IS NULL THEN 1 ELSE 0 END - WHERE sid = $1 - AND user_id = $2 - AND revoked_at IS NULL - RETURNING sid`, - [sid, userId, atSecond(now), reason] - ) - return (result.rowCount ?? 0) > 0 + const work = async (transaction: SessionDatabase): Promise => { + const locked = await transaction.query( + `SELECT sid + FROM external_user_sessions + WHERE sid = $1 + AND user_id = $2 + FOR UPDATE`, + [sid, userId] + ) + if ((locked.rowCount ?? 0) === 0) return false + const now = await loadDatabaseNow(transaction) + const result = await transaction.query( + `UPDATE external_user_sessions + SET revoked_at = COALESCE(revoked_at, $3), + revocation_reason = COALESCE(revocation_reason, $4), + session_version = session_version + CASE WHEN revoked_at IS NULL THEN 1 ELSE 0 END + WHERE sid = $1 + AND user_id = $2 + AND revoked_at IS NULL + RETURNING sid`, + [sid, userId, now, reason] + ) + return (result.rowCount ?? 0) > 0 + } + return db ? work(db) : withTransaction(work) } export async function revokeAllUserSessions( userId: string, reason: string, - db?: SessionDatabase, - now: Date | (() => Date) = () => new Date() + db?: SessionDatabase ): Promise { const work = async (transaction: SessionDatabase) => { const user = await transaction.query(`SELECT id FROM users WHERE id = $1 FOR UPDATE`, [userId]) if ((user.rowCount ?? 0) === 0) return 0 - const revokedAt = atSecond(resolveClock(now)) + const revokedAt = await loadDatabaseNow(transaction) await transaction.query( `INSERT INTO external_user_session_security_epochs(user_id, valid_after, reason, updated_at) @@ -424,9 +439,8 @@ export async function revokeAllUserSessions( export async function validateExternalSessionAuthorityContext( context: ExternalSessionAuthorityContext, - options: SessionClock & { db?: SessionDatabase } = {} + options: { db?: SessionDatabase } = {} ): Promise { - const now = atSecond(resolveClock(options.now)) const work = async (db: SessionDatabase): Promise => { if (context.contract === 'v2') { const principal = await db.query( @@ -440,6 +454,8 @@ export async function validateExternalSessionAuthorityContext( if ((principal.rowCount ?? 0) === 0) { return { status: 'revoked', reason: 'user_unavailable' } } + const row = await loadSessionForUpdate(db, context.sid) + const now = await loadDatabaseNow(db) const claims: UserSessionV2Claims = { sub: context.userId, sid: context.sid, @@ -452,13 +468,7 @@ export async function validateExternalSessionAuthorityContext( iat: 0, exp: 0, } - return validateLoadedSession( - db, - await loadSessionForUpdate(db, context.sid), - claims, - now, - false - ) + return validateLoadedSession(db, row, claims, now, false) } const principal = await db.query( @@ -484,6 +494,7 @@ export async function validateExternalSessionAuthorityContext( return { status: 'revoked', reason: 'security_event' } } + const now = await loadDatabaseNow(db) const authority = await db.query( `SELECT epoch.valid_after, EXISTS ( @@ -491,13 +502,13 @@ export async function validateExternalSessionAuthorityContext( FROM external_v1_session_revocations revoked WHERE revoked.token_hash = $2 AND revoked.user_id = $1 - AND revoked.expires_at > NOW() + AND revoked.expires_at > $3 ) AS token_revoked FROM users u LEFT JOIN external_user_session_security_epochs epoch ON epoch.user_id = u.id WHERE u.id = $1 LIMIT 1`, - [context.userId, context.tokenHash] + [context.userId, context.tokenHash, now] ) const row = authority.rows[0] as | { @@ -549,6 +560,7 @@ export async function validateLegacyUserSession( ]) if ((locked.rowCount ?? 0) === 0) return { status: 'revoked', reason: 'user_unavailable' } } + const now = await loadDatabaseNow(db) const result = await db.query( `SELECT u.id, u.lifecycle_state, u.lifecycle_version, epoch.valid_after, @@ -557,13 +569,13 @@ export async function validateLegacyUserSession( FROM external_v1_session_revocations revoked WHERE revoked.token_hash = $2 AND revoked.user_id = u.id - AND revoked.expires_at > NOW() + AND revoked.expires_at > $3 ) AS token_revoked FROM users u LEFT JOIN external_user_session_security_epochs epoch ON epoch.user_id = u.id WHERE u.id = $1 LIMIT 1`, - [claims.userId, legacySessionFingerprint(token)] + [claims.userId, legacySessionFingerprint(token), now] ) const row = result.rows[0] as | { @@ -606,14 +618,14 @@ export async function revokeLegacyUserSession( token: string, claims: AuthClaims, reason: string, - db?: SessionDatabase, - now = new Date() + db?: SessionDatabase ): Promise { const work = async (transaction: SessionDatabase): Promise => { const user = await transaction.query(`SELECT id FROM users WHERE id = $1 FOR UPDATE`, [ claims.userId, ]) if ((user.rowCount ?? 0) === 0) return false + const now = await loadDatabaseNow(transaction) const result = await transaction.query( `INSERT INTO external_v1_session_revocations( token_hash, user_id, expires_at, revoked_at, reason @@ -621,13 +633,7 @@ export async function revokeLegacyUserSession( VALUES($1, $2, $3, $4, $5) ON CONFLICT (token_hash) DO NOTHING RETURNING token_hash`, - [ - legacySessionFingerprint(token), - claims.userId, - new Date(claims.exp * 1000), - atSecond(now), - reason, - ] + [legacySessionFingerprint(token), claims.userId, new Date(claims.exp * 1000), now, reason] ) return (result.rowCount ?? 0) > 0 } diff --git a/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts b/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts index 9eedb2b3a7..293ba0a813 100644 --- a/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts +++ b/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts @@ -426,10 +426,8 @@ describeRealPostgres('legacy replacement exchange serialization on real PostgreS expect(lockedExchange.result.status).toBe('issued') try { - const cutoff = new Date(Date.now() - 1_000) - const revokeAll = revokeAllUserSessions(source.userId, 'user_revoked_all', undefined, cutoff) + const revokeAll = revokeAllUserSessions(source.userId, 'user_revoked_all') await waitForBlockedBy(lockedExchange.pid) - cutoff.setTime(Date.now() + 1_000) releaseExchange.resolve() const issued = await exchange await revokeAll @@ -487,12 +485,7 @@ describeRealPostgres('legacy replacement exchange serialization on real PostgreS const exchangePid = await exchangeStarted.promise await waitForBlock(exchangePid, gatePid) - await revokeAllUserSessions( - source.userId, - 'user_revoked_all', - gate, - new Date(Date.now() + 1_000) - ) + await revokeAllUserSessions(source.userId, 'user_revoked_all', gate) await gate.query('COMMIT') await expect(exchange).resolves.toEqual({ status: 'invalid_session' }) @@ -522,12 +515,7 @@ describeRealPostgres('legacy replacement exchange serialization on real PostgreS const rollback = await databasePool.connect() try { await rollback.query('BEGIN') - await revokeAllUserSessions( - source.userId, - 'rollback', - rollback, - new Date('2026-08-27T12:00:00Z') - ) + await revokeAllUserSessions(source.userId, 'rollback', rollback) await rollback.query('ROLLBACK') } finally { await rollback.query('ROLLBACK').catch(() => undefined) @@ -537,18 +525,15 @@ describeRealPostgres('legacy replacement exchange serialization on real PostgreS validateLegacyUserSession(source.token, source.claims, { db: databasePool }) ).resolves.toMatchObject({ status: 'valid' }) - await revokeAllUserSessions( - source.userId, - 'older', - databasePool, - new Date('2026-08-27T12:00:00Z') - ) - await revokeAllUserSessions( - source.userId, - 'newer', - databasePool, - new Date('2026-08-27T12:00:05Z') + await revokeAllUserSessions(source.userId, 'older', databasePool) + const firstEpoch = await databasePool.query<{ valid_after: Date }>( + `SELECT valid_after + FROM external_user_session_security_epochs + WHERE user_id = $1`, + [source.userId] ) + await databasePool.query(`SELECT pg_sleep(1.1)`) + await revokeAllUserSessions(source.userId, 'newer', databasePool) const epoch = await databasePool.query<{ valid_after: Date; reason: string }>( `SELECT valid_after, reason @@ -556,7 +541,9 @@ describeRealPostgres('legacy replacement exchange serialization on real PostgreS WHERE user_id = $1`, [source.userId] ) - expect(epoch.rows[0]?.valid_after.toISOString()).toBe('2026-08-27T12:00:05.000Z') + expect(epoch.rows[0]!.valid_after.getTime()).toBeGreaterThan( + firstEpoch.rows[0]!.valid_after.getTime() + ) expect(epoch.rows[0]?.reason).toBe('newer') }) }) diff --git a/control-api/test/middleware.externalSessionAuth.legacyToken.test.ts b/control-api/test/middleware.externalSessionAuth.legacyToken.test.ts index 44a2cbd06b..6f4b6e00c1 100644 --- a/control-api/test/middleware.externalSessionAuth.legacyToken.test.ts +++ b/control-api/test/middleware.externalSessionAuth.legacyToken.test.ts @@ -66,18 +66,22 @@ function app() { describe('external session legacy token cut-over', () => { beforeEach(() => { poolQuery.mockClear() - poolQuery.mockResolvedValue({ - rows: [ - { - id: '11111111-1111-4111-8111-111111111111', - lifecycle_state: 'active', - lifecycle_version: '1', - valid_after: null, - token_revoked: false, - }, - ], - rowCount: 1, - }) + poolQuery.mockImplementation(async (sql: string) => + sql.includes('clock_timestamp()') + ? { rows: [{ db_now: new Date() }], rowCount: 1 } + : { + rows: [ + { + id: '11111111-1111-4111-8111-111111111111', + lifecycle_state: 'active', + lifecycle_version: '1', + valid_after: null, + token_revoked: false, + }, + ], + rowCount: 1, + } + ) }) it('accepts a producer-shaped pre-generation token only as lifecycle generation one', async () => { @@ -99,6 +103,7 @@ describe('external session legacy token cut-over', () => { expect(response.status).toBe(200) expect(response.body).toEqual({ ok: true }) - expect(poolQuery).toHaveBeenCalledTimes(1) + expect(poolQuery).toHaveBeenCalledTimes(2) + expect(String(poolQuery.mock.calls[0]?.[0])).toContain('clock_timestamp()') }) }) diff --git a/control-api/test/middleware.externalSessionAuth.lifecycle.test.ts b/control-api/test/middleware.externalSessionAuth.lifecycle.test.ts index 95749bfa2f..e9dcab7962 100644 --- a/control-api/test/middleware.externalSessionAuth.lifecycle.test.ts +++ b/control-api/test/middleware.externalSessionAuth.lifecycle.test.ts @@ -3,13 +3,19 @@ import express from 'express' import { rateLimit } from 'express-rate-limit' import { DatabaseError } from 'pg' import request from 'supertest' +import { AccessExecutionBudget } from '../src/services/access/accessExecutionBudget.js' const poolQuery = vi.hoisted(() => vi.fn()) +const poolConnect = vi.hoisted(() => vi.fn()) const verifyToken = vi.hoisted(() => vi.fn()) +let authorityRows: Array> = [] const loggerWarn = vi.hoisted(() => vi.fn()) vi.mock('../src/db.js', () => ({ - pool: { query: (...args: unknown[]) => poolQuery(...args) }, + pool: { + query: (...args: unknown[]) => poolQuery(...args), + connect: (...args: unknown[]) => poolConnect(...args), + }, })) vi.mock('../src/observability/logger.js', () => ({ rootLogger: { warn: (...args: unknown[]) => loggerWarn(...args) }, @@ -17,13 +23,13 @@ vi.mock('../src/observability/logger.js', () => ({ vi.mock('../src/utils/auth/externalSessionAuthToken.js', () => ({ verifyExternalSessionToken: (...args: unknown[]) => verifyToken(...args), })) - const claims = { userId: '11111111-1111-4111-8111-111111111111', email: 'user@example.com', teamId: null, role: 'member' as const, authGeneration: 4, + iat: Math.floor(Date.now() / 1000) - 60, exp: Math.floor(Date.now() / 1000) + 3600, } @@ -34,12 +40,18 @@ function databaseError(sqlstate: string): DatabaseError { return error } -function app() { +function app(budget?: AccessExecutionBudget) { const server = express() server.get( '/protected', rateLimit({ windowMs: 60_000, limit: 100, standardHeaders: 'draft-7', legacyHeaders: false }), async (req, res, next) => { + if (budget) { + const budgetedRequest = req as express.Request & { + accessExecutionBudget?: AccessExecutionBudget + } + budgetedRequest.accessExecutionBudget = budget + } const { requireValidExternalSessionToken } = await import('../src/middleware/externalSessionAuth.js') await requireValidExternalSessionToken(req as never, res, next) @@ -57,20 +69,27 @@ function app() { describe('external session lifecycle gate', () => { beforeEach(() => { poolQuery.mockReset() + poolConnect.mockReset() verifyToken.mockReset() loggerWarn.mockReset() - poolQuery.mockResolvedValue({ - rows: [{ lifecycle_state: 'active', lifecycle_version: '4' }], - rowCount: 1, - }) + authorityRows = [ + { + id: claims.userId, + lifecycle_state: 'active', + lifecycle_version: '4', + valid_after: null, + token_revoked: false, + }, + ] + poolQuery.mockImplementation(async (sql: string) => + sql.includes('clock_timestamp()') + ? { rows: [{ db_now: new Date() }], rowCount: 1 } + : { rows: authorityRows, rowCount: authorityRows.length } + ) }) it('accepts an active session whose generation matches the user row', async () => { verifyToken.mockReturnValueOnce(claims) - poolQuery.mockResolvedValueOnce({ - rows: [{ lifecycle_state: 'active', lifecycle_version: '4' }], - rowCount: 1, - }) const response = await request(app()).get('/protected').set('x-user-session-token', 'session') expect(response.status).toBe(200) }) @@ -81,7 +100,7 @@ describe('external session lifecycle gate', () => { [[]], ])('denies retired, stale, or missing authoritative rows', async row => { verifyToken.mockReturnValueOnce(claims) - poolQuery.mockResolvedValueOnce({ rows: row, rowCount: row.length }) + authorityRows = row const response = await request(app()).get('/protected').set('x-user-session-token', 'session') expect(response.status).toBe(401) }) @@ -101,16 +120,52 @@ describe('external session lifecycle gate', () => { expect(response.body).toEqual({ error: 'session_backend_unavailable', retryAfterSeconds: 2 }) expect(response.headers['retry-after']).toBe('2') expect(response.headers['cache-control']).toBe('no-store') - // Witness: the lookup was attempted exactly once, for the token's user. + // Witness: session validation reached the database-authoritative clock. expect(poolQuery).toHaveBeenCalledTimes(1) - expect(poolQuery.mock.calls[0]?.[1]).toEqual([claims.userId]) + expect(String(poolQuery.mock.calls[0]?.[0])).toContain('clock_timestamp()') expect(loggerWarn).toHaveBeenCalledTimes(1) - expect(loggerWarn.mock.calls[0]?.[0]).toEqual({ + expect(loggerWarn.mock.calls[0]?.[0]).toMatchObject({ event: 'external_session_backend_unavailable', - err: 'timeout exceeded when trying to connect', + err: expect.objectContaining({ message: 'timeout exceeded when trying to connect' }), }) }) + it('answers 503 for a no-SQLSTATE database connection-acquire failure', async () => { + verifyToken.mockReturnValueOnce(claims) + const acquireFailure = new Error('private pg-pool acquire timeout sentinel') + poolConnect.mockRejectedValueOnce(acquireFailure) + const budget = AccessExecutionBudget.create('action') + + const response = await request(app(budget)) + .get('/protected') + .set('x-user-session-token', 'session-with-budget') + budget.close() + + expect(response.status).toBe(503) + expect(response.body).toEqual({ error: 'session_backend_unavailable', retryAfterSeconds: 2 }) + expect(response.headers['retry-after']).toBe('2') + expect(JSON.stringify(response.body)).not.toContain(acquireFailure.message) + expect(JSON.stringify(response.headers)).not.toContain(acquireFailure.message) + expect(poolConnect).toHaveBeenCalledTimes(1) + expect(verifyToken).toHaveBeenCalledWith('session-with-budget') + expect(loggerWarn).toHaveBeenCalledTimes(1) + }) + + it('keeps a successful-query session clock invariant failure on the ordinary 500 path', async () => { + verifyToken.mockReturnValueOnce(claims) + poolQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }) + + const response = await request(app()).get('/protected').set('x-user-session-token', 'session') + + expect(response.status).toBe(500) + expect(response.body).toEqual({ error: 'internal' }) + expect(JSON.stringify(response.body)).not.toContain('database session clock unavailable') + expect(response.headers['retry-after']).toBeUndefined() + expect(response.headers['cache-control']).toBeUndefined() + expect(String(poolQuery.mock.calls[0]?.[0])).toContain('clock_timestamp()') + expect(loggerWarn).not.toHaveBeenCalled() + }) + it.each([ ['a pool acquire timeout', new Error('timeout exceeded when trying to connect')], ['a dropped connection', new Error('Connection terminated unexpectedly')], diff --git a/control-api/test/userAccessRuntimeContract.realPostgres.integration.test.ts b/control-api/test/userAccessRuntimeContract.realPostgres.integration.test.ts index 1096dc1897..be45c07b6c 100644 --- a/control-api/test/userAccessRuntimeContract.realPostgres.integration.test.ts +++ b/control-api/test/userAccessRuntimeContract.realPostgres.integration.test.ts @@ -116,7 +116,6 @@ describeRealPostgres('user-access runtime role contract on real PostgreSQL', () const renewed = await runAsControlApiRuntime(db => renewUserSession(issuedClaims!, { db, - now: new Date((issuedClaims!.iat + 1) * 1_000), }) ) expect(renewed).toMatchObject({ expiresInSeconds: 60 * 60 }) diff --git a/control-api/test/userSessionDatabaseClock.realPostgres.integration.test.ts b/control-api/test/userSessionDatabaseClock.realPostgres.integration.test.ts new file mode 100644 index 0000000000..a477077d4b --- /dev/null +++ b/control-api/test/userSessionDatabaseClock.realPostgres.integration.test.ts @@ -0,0 +1,171 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { randomBytes, randomUUID } from 'node:crypto' +import { Pool, type PoolClient } from 'pg' +import { initDb } from '../src/db.js' +import { + createUserSession, + validateUserSessionClaims, +} from '../src/services/auth/userSessionService.js' +import { verifyUserSessionV2Token } from '../src/utils/auth/userSessionV2Token.js' + +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const describeRealPostgres = adminUrl ? describe : describe.skip +const runtimeRoles = [ + 'control_api_runtime', + 'trace_maintenance_runtime', + 'workflow_recipes_runtime', +] as const + +function databaseUrl(baseUrl: string, database: string): string { + const value = new URL(baseUrl) + value.pathname = `/${database}` + return value.toString() +} + +function quoteIdentifier(value: string): string { + return `"${value.replace(/"/g, '""')}"` +} + +async function waitForBlock( + databasePool: Pool, + blockedPid: number, + blockerPid: number +): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + const result = await databasePool.query<{ blocked: boolean }>( + `SELECT $2::int = ANY(pg_blocking_pids($1::int)) AS blocked`, + [blockedPid, blockerPid] + ) + if (result.rows[0]?.blocked) return + await new Promise(resolve => setTimeout(resolve, 10)) + } + throw new Error('expected session validation to block on the session row') +} + +describeRealPostgres('user-session database clock authority on real PostgreSQL', () => { + const database = `control_api_session_clock_${randomBytes(6).toString('hex')}` + const connectionString = databaseUrl( + adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', + database + ) + const userId = randomUUID() + let adminPool: Pool + let databasePool: Pool + + beforeAll(async () => { + adminPool = new Pool({ connectionString: adminUrl }) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) + databasePool = new Pool({ connectionString }) + await initDb({ connect: () => databasePool.connect() }) + await databasePool.query( + `INSERT INTO users(id, email, name) VALUES ($1, $2, 'Session Clock User')`, + [userId, `${userId}@example.test`] + ) + }) + + afterAll(async () => { + await databasePool?.end() + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + await adminPool.end() + } + }) + + it('captures expiry time after acquiring the locked session row', async () => { + const issuer = await databasePool.connect() + let sid = '' + let claims: NonNullable> + try { + await issuer.query('BEGIN') + const issued = await createUserSession( + { + userId, + email: `${userId}@example.test`, + authenticationMethods: ['password'], + }, + { db: issuer } + ) + await issuer.query('COMMIT') + sid = issued.identity.sid + const verified = verifyUserSessionV2Token(issued.token) + if (!verified) throw new Error('session producer did not issue a V2 token') + claims = verified + } catch (error) { + await issuer.query('ROLLBACK').catch(() => undefined) + throw error + } finally { + issuer.release() + } + + await databasePool.query( + `UPDATE external_user_sessions + SET idle_expires_at = date_trunc('second', clock_timestamp()) + interval '1 second' + WHERE sid = $1`, + [sid] + ) + + const blocker = await databasePool.connect() + const validator = await databasePool.connect() + try { + await blocker.query('BEGIN') + await blocker.query(`SELECT sid FROM external_user_sessions WHERE sid = $1 FOR UPDATE`, [sid]) + const blockerPid = Number( + (await blocker.query<{ pid: number }>('SELECT pg_backend_pid() AS pid')).rows[0]!.pid + ) + + await validator.query('BEGIN') + const validatorPid = Number( + (await validator.query<{ pid: number }>('SELECT pg_backend_pid() AS pid')).rows[0]!.pid + ) + const validation = validateUserSessionClaims(claims, { db: validator }) + await waitForBlock(databasePool, validatorPid, blockerPid) + await databasePool.query( + `SELECT pg_sleep( + GREATEST( + 0, + EXTRACT(EPOCH FROM ( + (SELECT idle_expires_at FROM external_user_sessions WHERE sid = $1) + - clock_timestamp() + )) + 0.25 + ) + )`, + [sid] + ) + await blocker.query('COMMIT') + + await expect(validation).resolves.toEqual({ status: 'expired', reason: 'idle_expired' }) + await validator.query('COMMIT') + + const stored = await databasePool.query<{ + revoked_at: Date | null + last_used_at: Date + idle_expires_at: Date + }>( + `SELECT revoked_at, last_used_at, idle_expires_at + FROM external_user_sessions + WHERE sid = $1`, + [sid] + ) + expect(stored.rows[0]!.revoked_at).not.toBeNull() + expect(stored.rows[0]!.revoked_at!.getTime()).toBeGreaterThanOrEqual( + stored.rows[0]!.idle_expires_at.getTime() + ) + expect(stored.rows[0]!.last_used_at.getTime()).toBeLessThan( + stored.rows[0]!.revoked_at!.getTime() + ) + } finally { + await blocker.query('ROLLBACK').catch(() => undefined) + await validator.query('ROLLBACK').catch(() => undefined) + blocker.release() + validator.release() + } + }) +}) diff --git a/control-api/test/userSessionRevokeAllAtomic.test.ts b/control-api/test/userSessionRevokeAllAtomic.test.ts index 18e10d7ebb..467443bf10 100644 --- a/control-api/test/userSessionRevokeAllAtomic.test.ts +++ b/control-api/test/userSessionRevokeAllAtomic.test.ts @@ -17,6 +17,10 @@ describe('revoke-all transaction boundary', () => { mocks.query.mockReset() mocks.query .mockResolvedValueOnce({ rows: [{ id: 'user-1' }], rowCount: 1 }) + .mockResolvedValueOnce({ + rows: [{ db_now: new Date('2026-08-27T00:00:01.000Z') }], + rowCount: 1, + }) .mockImplementationOnce(async () => { mocks.committedEpoch = true return { rows: [], rowCount: 1 } @@ -38,7 +42,7 @@ describe('revoke-all transaction boundary', () => { ) expect(mocks.withTransaction).toHaveBeenCalledTimes(1) - expect(mocks.query).toHaveBeenCalledTimes(3) + expect(mocks.query).toHaveBeenCalledTimes(4) expect(String(mocks.query.mock.calls[0]?.[0])).toContain('FOR UPDATE') expect(mocks.committedEpoch).toBe(false) }) @@ -46,10 +50,10 @@ describe('revoke-all transaction boundary', () => { it('captures the revoke-all cutoff after acquiring the user lock', async () => { const cutoffs: Date[] = [] const afterLock = new Date('2026-08-27T00:00:01.250Z') - const now = vi.fn(() => afterLock) mocks.query.mockReset() mocks.query .mockResolvedValueOnce({ rows: [{ id: 'user-1' }], rowCount: 1 }) + .mockResolvedValueOnce({ rows: [{ db_now: afterLock }], rowCount: 1 }) .mockImplementationOnce(async (_sql, values) => { cutoffs.push(values[1] as Date) return { rows: [], rowCount: 1 } @@ -58,10 +62,10 @@ describe('revoke-all transaction boundary', () => { mocks.withTransaction.mockImplementationOnce(async work => work({ query: mocks.query })) const { revokeAllUserSessions } = await import('../src/services/auth/userSessionService.js') - await expect(revokeAllUserSessions('user-1', 'security_event', undefined, now)).resolves.toBe(0) + await expect(revokeAllUserSessions('user-1', 'security_event')).resolves.toBe(0) - expect(now).toHaveBeenCalledTimes(1) expect(String(mocks.query.mock.calls[0]?.[0])).toContain('FOR UPDATE') - expect(cutoffs).toEqual([new Date('2026-08-27T00:00:01.000Z')]) + expect(String(mocks.query.mock.calls[1]?.[0])).toContain('clock_timestamp()') + expect(cutoffs).toEqual([afterLock]) }) }) diff --git a/control-api/test/userSessionService.test.ts b/control-api/test/userSessionService.test.ts index 45d2e0bfe9..8538f701b4 100644 --- a/control-api/test/userSessionService.test.ts +++ b/control-api/test/userSessionService.test.ts @@ -59,6 +59,7 @@ describe('user session state machine', () => { const inserted = row() const query = vi.fn(async (sql: string, values?: unknown[]) => { if (sql.includes('FROM users')) return { rows: [{ id: inserted.user_id }], rowCount: 1 } + if (sql.includes('clock_timestamp()')) return { rows: [{ db_now: now }], rowCount: 1 } if (sql.includes('INSERT INTO external_user_sessions')) { return { rows: [{ ...inserted, sid: values?.[0], user_id: values?.[1], current_jti: values?.[2] }], @@ -74,7 +75,7 @@ describe('user session state machine', () => { email: 'User@Example.com', authenticationMethods: ['pwd'], }, - { db: { query }, now } + { db: { query } } ) const insertCall = query.mock.calls.find(call => @@ -94,9 +95,10 @@ describe('user session state machine', () => { sv: 1, }) expect(String(query.mock.calls[0]?.[0])).toContain('FOR UPDATE') - expect(String(query.mock.calls[1]?.[0])).toContain('DELETE FROM external_user_sessions') - expect(String(query.mock.calls[2]?.[0])).toContain('OFFSET $3') - expect(query.mock.calls[2]?.[1]?.[2]).toBe(USER_SESSION_MAX_ACTIVE_PER_USER - 1) + expect(String(query.mock.calls[1]?.[0])).toContain('clock_timestamp()') + expect(String(query.mock.calls[2]?.[0])).toContain('DELETE FROM external_user_sessions') + expect(String(query.mock.calls[3]?.[0])).toContain('OFFSET $3') + expect(query.mock.calls[3]?.[1]?.[2]).toBe(USER_SESSION_MAX_ACTIVE_PER_USER - 1) }) it('rotates once and returns the same successor to an overlapping renewal', async () => { @@ -104,6 +106,7 @@ describe('user session state machine', () => { let current = original const query = vi.fn(async (sql: string, values?: unknown[]) => { if (sql.includes('SELECT s.sid')) return { rows: [current], rowCount: 1 } + if (sql.includes('clock_timestamp()')) return { rows: [{ db_now: now }], rowCount: 1 } if (sql.includes('SET prior_jti = current_jti')) { current = row({ ...original, @@ -119,13 +122,12 @@ describe('user session state machine', () => { return { rows: [], rowCount: 0 } }) - const first = await renewUserSession(claimsFor(original), { db: { query }, now }) + const first = await renewUserSession(claimsFor(original), { db: { query } }) expect('token' in first).toBe(true) const successor = 'token' in first ? first.token : '' const concurrent = await renewUserSession(claimsFor(current, String(original.current_jti)), { db: { query }, - now: new Date(now.getTime() + (USER_SESSION_RENEWAL_OVERLAP_SECONDS - 1) * 1000), }) expect('token' in concurrent && concurrent.token).toBe(successor) expect( @@ -140,22 +142,23 @@ describe('user session state machine', () => { }) const reuseQuery = vi.fn(async (sql: string) => { if (sql.includes('SELECT s.sid')) return { rows: [reused], rowCount: 1 } + if (sql.includes('clock_timestamp()')) return { rows: [{ db_now: now }], rowCount: 1 } return { rows: [], rowCount: 1 } }) await expect( validateUserSessionClaims(claimsFor(reused, String(reused.prior_jti)), { db: { query: reuseQuery }, - now, }) ).resolves.toEqual({ status: 'revoked', reason: 'representation_reuse' }) const expired = row({ idle_expires_at: new Date(now.getTime() - 1000) }) const expiryQuery = vi.fn(async (sql: string) => { if (sql.includes('SELECT s.sid')) return { rows: [expired], rowCount: 1 } + if (sql.includes('clock_timestamp()')) return { rows: [{ db_now: now }], rowCount: 1 } return { rows: [], rowCount: 1 } }) await expect( - validateUserSessionClaims(claimsFor(expired), { db: { query: expiryQuery }, now }) + validateUserSessionClaims(claimsFor(expired), { db: { query: expiryQuery } }) ).resolves.toEqual({ status: 'expired', reason: 'idle_expired' }) expect( expiryQuery.mock.calls.some(([sql]) => String(sql).includes('idle_expires_at = LEAST')) @@ -168,13 +171,15 @@ describe('user session state machine', () => { query: vi .fn() .mockResolvedValueOnce({ rows: [{ id: userId }], rowCount: 1 }) + .mockResolvedValueOnce({ rows: [{ db_now: now }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 2 }), } - await expect(revokeAllUserSessions(userId, 'password_changed', db, now)).resolves.toBe(2) + await expect(revokeAllUserSessions(userId, 'password_changed', db)).resolves.toBe(2) expect(String(db.query.mock.calls[0]?.[0])).toContain('FOR UPDATE') - expect(String(db.query.mock.calls[1]?.[0])).toContain('security_epochs') - expect(String(db.query.mock.calls[2]?.[0])).toContain('external_user_sessions') + expect(String(db.query.mock.calls[1]?.[0])).toContain('clock_timestamp()') + expect(String(db.query.mock.calls[2]?.[0])).toContain('security_epochs') + expect(String(db.query.mock.calls[3]?.[0])).toContain('external_user_sessions') const claims = { userId, @@ -186,18 +191,22 @@ describe('user session state machine', () => { iat: Math.floor(now.getTime() / 1000), } const epochDb = { - query: vi.fn().mockResolvedValue({ - rows: [ - { - id: userId, - lifecycle_state: 'active', - lifecycle_version: 1, - valid_after: now, - token_revoked: false, - }, - ], - rowCount: 1, - }), + query: vi.fn(async (sql: string) => + sql.includes('clock_timestamp()') + ? { rows: [{ db_now: now }], rowCount: 1 } + : { + rows: [ + { + id: userId, + lifecycle_state: 'active', + lifecycle_version: 1, + valid_after: now, + token_revoked: false, + }, + ], + rowCount: 1, + } + ), } await expect(validateLegacyUserSession('v1-token', claims, { db: epochDb })).resolves.toEqual({ status: 'revoked', @@ -223,18 +232,22 @@ describe('user session state machine', () => { iat: Math.floor(now.getTime() / 1000) - 1, } const db = { - query: vi.fn().mockResolvedValue({ - rows: [ - { - id: claims.userId, - lifecycle_state: lifecycleState, - lifecycle_version: lifecycleVersion, - valid_after: validAfter, - token_revoked: tokenRevoked, - }, - ], - rowCount: 1, - }), + query: vi.fn(async (sql: string) => + sql.includes('clock_timestamp()') + ? { rows: [{ db_now: now }], rowCount: 1 } + : { + rows: [ + { + id: claims.userId, + lifecycle_state: lifecycleState, + lifecycle_version: lifecycleVersion, + valid_after: validAfter, + token_revoked: tokenRevoked, + }, + ], + rowCount: 1, + } + ), } const result = await validateLegacyUserSession('legacy-v1-token', claims, { db }) From a272052993e9933b32f837363a76f3c8f3f1cd18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 1 Sep 2026 17:02:42 -0600 Subject: [PATCH 181/528] fix(control-api): r24-b8 assign one session touch R24-B8 Class-check: staged V2 external authentication -> 2 stages -> Stage C owns the sole usage touch Repro-test: CONTROL_API_REAL_PG_ADMIN_URL=postgresql://postgres@127.0.0.1:54126/postgres npm test -- --run test/externalSessionSingleTouch.realPostgres.integration.test.ts (fails at 4aa3e02a0) --- .../auth/externalSessionAuthentication.ts | 1 + .../src/services/auth/userSessionService.ts | 4 +- ...ngleTouch.realPostgres.integration.test.ts | 130 ++++++++++++++++++ 3 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 control-api/test/externalSessionSingleTouch.realPostgres.integration.test.ts diff --git a/control-api/src/services/auth/externalSessionAuthentication.ts b/control-api/src/services/auth/externalSessionAuthentication.ts index 3fdf7951fb..b48a6de96a 100644 --- a/control-api/src/services/auth/externalSessionAuthentication.ts +++ b/control-api/src/services/auth/externalSessionAuthentication.ts @@ -148,6 +148,7 @@ export async function authenticateExternalUserSessionIdentity( if (v2Claims) { const validation = await validateUserSessionClaims(v2Claims, { budget: options.budget, + touch: false, }) if (validation.status !== 'valid') return validation return { diff --git a/control-api/src/services/auth/userSessionService.ts b/control-api/src/services/auth/userSessionService.ts index ae26665594..391dec94ed 100644 --- a/control-api/src/services/auth/userSessionService.ts +++ b/control-api/src/services/auth/userSessionService.ts @@ -285,12 +285,12 @@ export async function createUserSession( export async function validateUserSessionClaims( claims: UserSessionV2Claims, - options: { db?: SessionDatabase; budget?: AccessExecutionBudget } = {} + options: { db?: SessionDatabase; budget?: AccessExecutionBudget; touch?: boolean } = {} ): Promise { const work = async (db: SessionDatabase) => { const row = await loadSessionForUpdate(db, claims.sid) const now = await loadDatabaseNow(db) - return validateLoadedSession(db, row, claims, now, true) + return validateLoadedSession(db, row, claims, now, options.touch !== false) } if (options.db) { return work(options.budget ? budgetedSessionDatabase(options.db, options.budget) : options.db) diff --git a/control-api/test/externalSessionSingleTouch.realPostgres.integration.test.ts b/control-api/test/externalSessionSingleTouch.realPostgres.integration.test.ts new file mode 100644 index 0000000000..b096aafc6f --- /dev/null +++ b/control-api/test/externalSessionSingleTouch.realPostgres.integration.test.ts @@ -0,0 +1,130 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { randomBytes, randomUUID } from 'node:crypto' +import { Pool } from 'pg' +import { initDb, pool } from '../src/db.js' +import type { EffectiveUserAccessPolicy } from '../src/services/access/userAccessPolicy.js' +import { + authenticateExternalUserSession, + authenticateExternalUserSessionIdentity, +} from '../src/services/auth/externalSessionAuthentication.js' +import { createUserSession } from '../src/services/auth/userSessionService.js' + +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const describeRealPostgres = adminUrl ? describe : describe.skip +const runtimeRoles = [ + 'control_api_runtime', + 'trace_maintenance_runtime', + 'workflow_recipes_runtime', +] as const + +function databaseUrl(baseUrl: string, database: string): string { + const value = new URL(baseUrl) + value.pathname = `/${database}` + return value.toString() +} + +function quoteIdentifier(value: string): string { + return `"${value.replace(/"/g, '""')}"` +} + +describeRealPostgres('external-session staged touch ownership on real PostgreSQL', () => { + const database = `control_api_session_touch_${randomBytes(6).toString('hex')}` + const connectionString = databaseUrl( + adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', + database + ) + const userId = randomUUID() + let adminPool: Pool + let databasePool: Pool + let corePoolConnectSpy: ReturnType + + beforeAll(async () => { + adminPool = new Pool({ connectionString: adminUrl }) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) + databasePool = new Pool({ connectionString }) + await initDb({ connect: () => databasePool.connect() }) + corePoolConnectSpy = vi + .spyOn(pool, 'connect') + .mockImplementation((() => databasePool.connect()) as typeof pool.connect) + await databasePool.query( + `INSERT INTO users(id, email, name) VALUES ($1, $2, 'Session Touch User')`, + [userId, `${userId}@example.test`] + ) + await databasePool.query(`CREATE TABLE session_touch_audit(sid uuid NOT NULL)`) + await databasePool.query(` + CREATE FUNCTION record_session_touch() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + INSERT INTO session_touch_audit(sid) VALUES (NEW.sid); + RETURN NEW; + END + $$ + `) + await databasePool.query(` + CREATE TRIGGER session_touch_audit_trigger + AFTER UPDATE OF last_used_at, idle_expires_at ON external_user_sessions + FOR EACH ROW EXECUTE FUNCTION record_session_touch() + `) + }) + + afterAll(async () => { + corePoolConnectSpy?.mockRestore() + await databasePool?.end() + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + await adminPool.end() + } + }) + + it('touches a V2 session once after authenticated limiter identity is established', async () => { + const issued = await createUserSession({ + userId, + email: `${userId}@example.test`, + authenticationMethods: ['password'], + }) + + const identity = await authenticateExternalUserSessionIdentity(issued.token) + expect(identity.status).toBe('authenticated') + if (identity.status !== 'authenticated') throw new Error('Stage A rejected the issued session') + + const policy: EffectiveUserAccessPolicy = { + policyVersion: '1', + policyRevision: 'r24-single-touch', + acceptV1: true, + issueV1: true, + acceptV2: true, + issueV2: true, + renewV2: true, + switchCompatibility: true, + minimumClientVersion: null, + enforceMinimumClient: false, + catalogMode: 'off', + serveCatalog: false, + shadowCatalog: false, + actionContextV2: true, + } + await expect( + authenticateExternalUserSession(issued.token, { + purpose: 'protected', + policy, + identity, + }) + ).resolves.toMatchObject({ status: 'authenticated' }) + + const touches = await databasePool.query<{ count: string }>( + `SELECT count(*)::text AS count FROM session_touch_audit WHERE sid = $1`, + [issued.identity.sid] + ) + expect(touches.rows).toEqual([{ count: '1' }]) + }) +}) From 1ca7b2615efddd144f62ea48c48cd1a436b943bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 1 Sep 2026 16:58:01 -0600 Subject: [PATCH 182/528] fix(codeql): r24-h1 require limiter dominance for full auth R24-H1 Class-check: full-auth later-limiter exemptions -> 3 paths -> remove all; retain exact D33 stage Repro-test: .github/codeql/queries/test/MissingRateLimiting (fails at dd12f044d77f06b4d07ea252e6d8dc7ff46c5897 by omitting full-auth alerts) --- .../queries/EvenfireMissingRateLimiting.ql | 100 ------------------ .../EvenfireMissingRateLimiting.expected | 34 ++++-- .../control-api/src/http/asyncHandler.ts | 9 ++ .../control-api/src/routes/external/d33.ts | 100 ++++++++++++++++++ 4 files changed, 133 insertions(+), 110 deletions(-) create mode 100644 .github/codeql/queries/test/MissingRateLimiting/control-api/src/http/asyncHandler.ts diff --git a/.github/codeql/queries/EvenfireMissingRateLimiting.ql b/.github/codeql/queries/EvenfireMissingRateLimiting.ql index 52d45c7395..56c3f4eb3e 100644 --- a/.github/codeql/queries/EvenfireMissingRateLimiting.ql +++ b/.github/codeql/queries/EvenfireMissingRateLimiting.ql @@ -47,45 +47,6 @@ private predicate sameSourceFile(Routing::Node left, Routing::Node right) { ) } -private predicate routeSetupHasEvenfireLimiter(Routing::RouteSetup setup) { - exists(EvenfireRateLimitingMiddleware middleware, Routing::Node limiterNode | - limiterNode = middleware.getRoutingNode() and - limiterNode.getParent() = setup and - sameSourceFile(setup, limiterNode) and - not exists(Routing::Node earlierNode | - earlierNode.getParent() = setup and - limiterNode = earlierNode.getNextSibling+() and - not earlierNode.mayResumeDispatch() - ) - ) -} - -private predicate isCanonicalExternalContextImport(ImportSpecifier spec) { - spec.getImportDeclaration().getImportedFile().getRelativePath() = - "control-api/src/middleware/externalSessionAuth.ts" and - spec.getImportedName() = - [ - "requireExternalSessionRateLimitContext", - "requireValidExternalSessionToken", - "requireValidExternalSessionTokenWithPublicErrors" - ] -} - -private predicate isCanonicalExternalContextHandler(Routing::Node useSite) { - exists(ImportSpecifier spec, DataFlow::Node installedNode | - isCanonicalExternalContextImport(spec) and - useSite = Routing::getNode(installedNode) and - ( - DataFlow::valueNode(spec).(DataFlow::SourceNode).flowsTo(installedNode) - or - exists(DataFlow::CallNode call | - call = installedNode and - DataFlow::valueNode(spec).(DataFlow::SourceNode).flowsTo(call.getCalleeNode()) - ) - ) - ) -} - private predicate isCanonicalExternalLimiterIdentityImport(ImportSpecifier spec) { spec.getImportDeclaration().getImportedFile().getRelativePath() = "control-api/src/middleware/externalSessionAuth.ts" and @@ -113,76 +74,15 @@ private predicate hasSameRouteEvenfireLimiterAfterContext(Routing::Node useSite) ) } -private predicate hasGlobalEvenfireLimiterAfterContext(Routing::Node useSite) { - exists( - Routing::RouteSetup contextSetup, Routing::RouteSetup limiterSetup, - EvenfireRateLimitingMiddleware middleware, Routing::Node limiterNode - | - useSite.getParent() = contextSetup and - limiterNode = middleware.getRoutingNode() and - limiterNode.getParent() = limiterSetup and - limiterSetup.getRouter() = contextSetup.getRouter() and - limiterSetup = contextSetup.getNextSibling+() and - limiterSetup.getRelativePath() = contextSetup.getRelativePath() and - not exists(contextSetup.getOwnHttpMethod()) and - sameSourceFile(useSite, limiterNode) and - not exists(Routing::RouteSetup earlierSetup | - earlierSetup.getRouter() = contextSetup.getRouter() and - earlierSetup = contextSetup.getNextSibling+() and - limiterSetup = earlierSetup.getNextSibling+() and - not earlierSetup.mayResumeDispatch() - ) - ) -} - -private predicate isCoveredHttpRouteAfterContext( - Routing::RouteSetup contextSetup, Routing::RouteSetup setup -) { - exists(string contextPath, string setupPath | - contextPath = contextSetup.getRelativePath() and - setupPath = setup.getRelativePath() and - setup.getRouter() = contextSetup.getRouter() and - setup = contextSetup.getNextSibling+() and - sameSourceFile(contextSetup, setup) and - exists(setup.getOwnHttpMethod()) and - ( - setupPath = contextPath - or - setupPath.length() > contextPath.length() and - setupPath.prefix(contextPath.length() + 1) = contextPath + "/" - ) - ) -} - private predicate hasEvenfireRateLimitingGuard(Routing::Node useSite) { exists(EvenfireRateLimitingMiddleware middleware | useSite.isGuardedByNode(middleware.getRoutingNode()) and sameSourceFile(useSite, middleware.getRoutingNode()) ) or - isCanonicalExternalContextHandler(useSite) and - useSite.mayResumeDispatch() and - hasSameRouteEvenfireLimiterAfterContext(useSite) - or isCanonicalExternalLimiterIdentityHandler(useSite) and useSite.mayResumeDispatch() and hasSameRouteEvenfireLimiterAfterContext(useSite) - or - isCanonicalExternalContextHandler(useSite) and - useSite.mayResumeDispatch() and - hasGlobalEvenfireLimiterAfterContext(useSite) - or - isCanonicalExternalContextHandler(useSite) and - useSite.mayResumeDispatch() and - exists(Routing::RouteSetup contextSetup | - useSite.getParent() = contextSetup and - not exists(contextSetup.getOwnHttpMethod()) and - exists(Routing::RouteSetup setup | isCoveredHttpRouteAfterContext(contextSetup, setup)) and - not exists(Routing::RouteSetup setup | - isCoveredHttpRouteAfterContext(contextSetup, setup) and - not routeSetupHasEvenfireLimiter(setup) - ) - ) } private predicate hasRateLimitingGuard(Routing::Node useSite) { diff --git a/.github/codeql/queries/test/MissingRateLimiting/EvenfireMissingRateLimiting.expected b/.github/codeql/queries/test/MissingRateLimiting/EvenfireMissingRateLimiting.expected index b5bd75de68..a788863fb3 100644 --- a/.github/codeql/queries/test/MissingRateLimiting/EvenfireMissingRateLimiting.expected +++ b/.github/codeql/queries/test/MissingRateLimiting/EvenfireMissingRateLimiting.expected @@ -1,5 +1,8 @@ +| control-api/src/routes/external/composition.ts:19:3:19:66 | require ... ted' }) | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:25:5:25:11 | login() | authorization | +| control-api/src/routes/external/composition.ts:35:39:35:70 | require ... onToken | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:10:3:10:9 | login() | authorization | | control-api/src/routes/external/composition.ts:60:3:60:50 | require ... cErrors | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:19:3:19:9 | login() | authorization | | control-api/src/routes/external/composition.ts:67:55:67:75 | incompl ... otected | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/composition.ts:53:3:53:74 | fs.writ ... value') | a file system access | +| control-api/src/routes/external/composition.ts:75:29:75:60 | require ... onToken | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:10:3:10:9 | login() | authorization | | control-api/src/routes/external/composition.ts:88:38:88:69 | require ... onToken | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:10:3:10:9 | login() | authorization | | control-api/src/routes/external/composition.ts:89:38:89:51 | lateGlobalWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/composition.ts:83:3:83:63 | fs.writ ... value') | a file system access | | control-api/src/routes/external/composition.ts:102:3:102:31 | require ... amMatch | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:35:3:35:62 | fs.writ ... value') | a file system access | @@ -11,16 +14,27 @@ | control-api/src/routes/external/composition.ts:162:39:162:59 | nestedU ... tedWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/composition.ts:152:3:152:70 | fs.writ ... value') | a file system access | | control-api/src/routes/external/composition.ts:165:42:165:73 | require ... onToken | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:10:3:10:9 | login() | authorization | | control-api/src/routes/external/composition.ts:169:35:169:66 | require ... onToken | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:10:3:10:9 | login() | authorization | -| control-api/src/routes/external/d33.ts:29:3:29:55 | require ... cErrors | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:44:3:44:9 | login() | authorization | -| control-api/src/routes/external/d33.ts:30:3:30:44 | require ... cErrors | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:53:3:53:9 | login() | authorization | -| control-api/src/routes/external/d33.ts:31:3:31:15 | protectedWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/d33.ts:13:3:13:55 | fs.writ ... value') | a file system access | -| control-api/src/routes/external/d33.ts:38:3:38:55 | require ... cErrors | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:44:3:44:9 | login() | authorization | -| control-api/src/routes/external/d33.ts:39:3:39:15 | protectedWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/d33.ts:13:3:13:55 | fs.writ ... value') | a file system access | -| control-api/src/routes/external/d33.ts:45:3:45:55 | require ... cErrors | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:44:3:44:9 | login() | authorization | -| control-api/src/routes/external/d33.ts:46:3:46:15 | protectedWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/d33.ts:13:3:13:55 | fs.writ ... value') | a file system access | -| control-api/src/routes/external/d33.ts:57:3:57:55 | require ... cErrors | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:44:3:44:9 | login() | authorization | -| control-api/src/routes/external/d33.ts:59:54:59:66 | protectedWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/d33.ts:13:3:13:55 | fs.writ ... value') | a file system access | -| control-api/src/routes/external/d33.ts:69:3:69:20 | requireWrongStageA | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/unrelatedMiddleware.ts:12:3:12:9 | login() | authorization | +| control-api/src/routes/external/d33.ts:31:3:31:55 | require ... cErrors | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:44:3:44:9 | login() | authorization | +| control-api/src/routes/external/d33.ts:32:3:32:44 | require ... cErrors | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:53:3:53:9 | login() | authorization | +| control-api/src/routes/external/d33.ts:33:3:33:15 | protectedWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/d33.ts:15:3:15:55 | fs.writ ... value') | a file system access | +| control-api/src/routes/external/d33.ts:40:3:40:55 | require ... cErrors | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:44:3:44:9 | login() | authorization | +| control-api/src/routes/external/d33.ts:41:3:41:15 | protectedWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/d33.ts:15:3:15:55 | fs.writ ... value') | a file system access | +| control-api/src/routes/external/d33.ts:47:3:47:55 | require ... cErrors | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:44:3:44:9 | login() | authorization | +| control-api/src/routes/external/d33.ts:48:3:48:15 | protectedWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/d33.ts:15:3:15:55 | fs.writ ... value') | a file system access | +| control-api/src/routes/external/d33.ts:59:3:59:55 | require ... cErrors | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:44:3:44:9 | login() | authorization | +| control-api/src/routes/external/d33.ts:61:54:61:66 | protectedWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/d33.ts:15:3:15:55 | fs.writ ... value') | a file system access | +| control-api/src/routes/external/d33.ts:71:3:71:20 | requireWrongStageA | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/unrelatedMiddleware.ts:12:3:12:9 | login() | authorization | +| control-api/src/routes/external/d33.ts:94:3:94:58 | asyncHa ... Errors) | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:53:3:53:9 | login() | authorization | +| control-api/src/routes/external/d33.ts:95:3:95:34 | asyncHa ... ncWork) | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/d33.ts:77:3:77:61 | fs.writ ... value') | a file system access | +| control-api/src/routes/external/d33.ts:102:3:102:55 | require ... cErrors | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:44:3:44:9 | login() | authorization | +| control-api/src/routes/external/d33.ts:103:3:103:34 | asyncHa ... ncWork) | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/d33.ts:77:3:77:61 | fs.writ ... value') | a file system access | +| control-api/src/routes/external/d33.ts:109:3:109:55 | require ... cErrors | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:44:3:44:9 | login() | authorization | +| control-api/src/routes/external/d33.ts:110:3:110:34 | asyncHa ... ncWork) | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/d33.ts:77:3:77:61 | fs.writ ... value') | a file system access | +| control-api/src/routes/external/d33.ts:121:3:121:55 | require ... cErrors | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:44:3:44:9 | login() | authorization | +| control-api/src/routes/external/d33.ts:125:3:125:34 | asyncHa ... ncWork) | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/d33.ts:77:3:77:61 | fs.writ ... value') | a file system access | +| control-api/src/routes/external/d33.ts:136:3:136:20 | requireWrongStageA | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/unrelatedMiddleware.ts:12:3:12:9 | login() | authorization | +| control-api/src/routes/external/d33.ts:144:3:144:50 | require ... cErrors | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:19:3:19:9 | login() | authorization | +| control-api/src/routes/external/d33.ts:152:3:152:64 | asyncHa ... Errors) | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:19:3:19:9 | login() | authorization | | control-api/src/routes/external/modeling.ts:39:52:39:66 | unprotectedWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/modeling.ts:14:3:14:63 | fs.writ ... value') | a file system access | | control-api/src/routes/external/modeling.ts:43:3:43:18 | orderedWrongWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/modeling.ts:19:3:19:65 | fs.writ ... value') | a file system access | | control-api/src/routes/external/modeling.ts:47:50:47:62 | unrelatedWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/modeling.ts:24:3:24:61 | fs.writ ... value') | a file system access | diff --git a/.github/codeql/queries/test/MissingRateLimiting/control-api/src/http/asyncHandler.ts b/.github/codeql/queries/test/MissingRateLimiting/control-api/src/http/asyncHandler.ts new file mode 100644 index 0000000000..03bfc13278 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimiting/control-api/src/http/asyncHandler.ts @@ -0,0 +1,9 @@ +import { NextFunction, Request, Response } from 'express' + +type AsyncHandler = (req: Request, res: Response, next: NextFunction) => Promise + +export function asyncHandler(handler: AsyncHandler) { + return (req: Request, res: Response, next: NextFunction) => { + handler(req, res, next).catch(next) + } +} diff --git a/.github/codeql/queries/test/MissingRateLimiting/control-api/src/routes/external/d33.ts b/.github/codeql/queries/test/MissingRateLimiting/control-api/src/routes/external/d33.ts index cf6d8ce9ad..e6eb23d592 100644 --- a/.github/codeql/queries/test/MissingRateLimiting/control-api/src/routes/external/d33.ts +++ b/.github/codeql/queries/test/MissingRateLimiting/control-api/src/routes/external/d33.ts @@ -1,8 +1,10 @@ import express from 'express' import fs from 'fs' +import { asyncHandler } from '../../http/asyncHandler' import { requireEffectiveV2ContractWithPublicErrors, requireExternalSessionLimiterIdentityWithPublicErrors, + requireValidExternalSessionTokenWithPublicErrors, } from '../../middleware/externalSessionAuth' import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware' import { @@ -71,11 +73,109 @@ wrongModuleRouter.get( protectedWork, ) +async function protectedAsyncWork(_req: express.Request, res: express.Response) { + fs.writeFileSync('/tmp/evenfire-codeql-d33-async', 'value') + res.sendStatus(204) +} + +const safeAsyncD33Router = express.Router() +safeAsyncD33Router.get( + '/safe-async', + requireExternalSessionLimiterIdentityWithPublicErrors, + rateLimitMiddleware({ type: 'external_access_catalog', max: 10 }), + asyncHandler(requireEffectiveV2ContractWithPublicErrors), + asyncHandler(protectedAsyncWork), +) + +const reversedAsyncD33Router = express.Router() +reversedAsyncD33Router.get( + '/reversed-async', + requireExternalSessionLimiterIdentityWithPublicErrors, + asyncHandler(requireEffectiveV2ContractWithPublicErrors), + asyncHandler(protectedAsyncWork), + rateLimitMiddleware({ type: 'external_access_catalog', max: 10 }), +) + +const missingAsyncLimiterRouter = express.Router() +missingAsyncLimiterRouter.get( + '/missing-async', + requireExternalSessionLimiterIdentityWithPublicErrors, + asyncHandler(protectedAsyncWork), +) + +const siblingAsyncLimiterRouter = express.Router() +siblingAsyncLimiterRouter.get( + '/unprotected-async', + requireExternalSessionLimiterIdentityWithPublicErrors, + asyncHandler(protectedAsyncWork), +) +siblingAsyncLimiterRouter.get( + '/protected-async', + rateLimitMiddleware({ type: 'external_access_catalog', max: 10 }), + asyncHandler(protectedAsyncWork), +) + +const nonDominatingAsyncParentRouter = express.Router() +nonDominatingAsyncParentRouter.use( + '/parent-async', + requireExternalSessionLimiterIdentityWithPublicErrors, +) +nonDominatingAsyncParentRouter.get( + '/parent-async/unprotected', + asyncHandler(protectedAsyncWork), +) +nonDominatingAsyncParentRouter.get( + '/parent-async/limited', + rateLimitMiddleware({ type: 'external_access_catalog', max: 10 }), + asyncHandler(protectedAsyncWork), +) + +const wrongModuleAsyncRouter = express.Router() +wrongModuleAsyncRouter.get( + '/wrong-module-async', + requireWrongStageA, + rateLimitMiddleware({ type: 'external_access_catalog', max: 10 }), + asyncHandler(protectedAsyncWork), +) + +const fullAuthLaterLimiterRouter = express.Router() +fullAuthLaterLimiterRouter.get( + '/full-auth-later-limiter', + requireValidExternalSessionTokenWithPublicErrors, + rateLimitMiddleware({ type: 'external_access_catalog', max: 10 }), + asyncHandler(protectedAsyncWork), +) + +const asyncFullAuthLaterLimiterRouter = express.Router() +asyncFullAuthLaterLimiterRouter.get( + '/async-full-auth-later-limiter', + asyncHandler(requireValidExternalSessionTokenWithPublicErrors), + rateLimitMiddleware({ type: 'external_access_catalog', max: 10 }), + asyncHandler(protectedAsyncWork), +) + +const limiterDominatesFullAuthRouter = express.Router() +limiterDominatesFullAuthRouter.get( + '/limiter-dominates-full-auth', + rateLimitMiddleware({ type: 'external_access_catalog', max: 10 }), + requireValidExternalSessionTokenWithPublicErrors, + asyncHandler(protectedAsyncWork), +) + export { + asyncFullAuthLaterLimiterRouter, + fullAuthLaterLimiterRouter, + limiterDominatesFullAuthRouter, missingLimiterRouter, + missingAsyncLimiterRouter, + nonDominatingAsyncParentRouter, nonDominatingParentRouter, + reversedAsyncD33Router, reversedD33Router, + safeAsyncD33Router, safeD33Router, + siblingAsyncLimiterRouter, siblingLimiterRouter, + wrongModuleAsyncRouter, wrongModuleRouter, } From 4f6e9663d1c0e22f436dedbf84ef577bbac4bbb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 1 Sep 2026 17:05:05 -0600 Subject: [PATCH 183/528] fix(control-api): r24-b3 limit capabilities before policy R24-B3 Class-check: external access runtime-policy routes -> 3 sites -> catalog, resolve, and capabilities use D33 stages Repro-test: npm test -- --run test/routes.externalAccess.test.ts -t 'stops a limited capability read' (fails at e30b0ba99) --- control-api/src/routes/external/access.ts | 4 ++-- .../test/routes.externalAccess.rateLimitOrdering.test.ts | 8 +++++++- control-api/test/routes.externalAccess.test.ts | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/control-api/src/routes/external/access.ts b/control-api/src/routes/external/access.ts index 187c85b59d..989edd4278 100644 --- a/control-api/src/routes/external/access.ts +++ b/control-api/src/routes/external/access.ts @@ -6,7 +6,6 @@ import { type ExternalAuthedRequest, requireCompletedExternalSessionAuthenticationWithPublicErrors, requireExternalSessionLimiterIdentityWithPublicErrors, - requireValidExternalSessionTokenWithPublicErrors, } from '../../middleware/externalSessionAuth.js' import { externalUserRateLimitOptions, @@ -184,9 +183,10 @@ export function createExternalAccessRouter(gateway: K8sGateway): Router { router.get( '/external/access/capabilities', - requireValidExternalSessionTokenWithPublicErrors, + requireExternalSessionLimiterIdentityWithPublicErrors, requireAuthenticatedExternalUserRateLimitContext, rateLimitMiddleware(externalUserRateLimitOptions('access_capabilities', 'authenticated')), + requireCompletedExternalSessionAuthenticationWithPublicErrors, asyncHandler(async (req: ExternalAuthedRequest, res) => { try { const policy = await resolveEffectiveUserAccessPolicy({ budget: req.accessExecutionBudget }) diff --git a/control-api/test/routes.externalAccess.rateLimitOrdering.test.ts b/control-api/test/routes.externalAccess.rateLimitOrdering.test.ts index cf64f5880b..d212224364 100644 --- a/control-api/test/routes.externalAccess.rateLimitOrdering.test.ts +++ b/control-api/test/routes.externalAccess.rateLimitOrdering.test.ts @@ -100,6 +100,7 @@ describe('external access authenticated rate-limit ordering', () => { }) it.each([ + ['GET', '/external/access/capabilities', 'external_access_capabilities'], ['GET', '/external/access/catalog', 'external_access_catalog'], ['POST', '/external/access/resolve', 'external_access_resolve'], ] as const)( @@ -113,13 +114,18 @@ describe('external access authenticated rate-limit ordering', () => { expect(response.status).toBe(429) expect(response.body).toMatchObject({ error: { code: 'rate_limited' } }) expect(response.headers['retry-after']).toBeDefined() - expect(rateLimiter.checkAndIncrement).toHaveBeenCalledWith(`${bucketType}:${userId}`, 10) + const key = + bucketType === 'external_access_capabilities' + ? `${bucketType}:user:${userId}` + : `${bucketType}:${userId}` + expect(rateLimiter.checkAndIncrement).toHaveBeenCalledWith(key, 10) expect(mocks.resolvePolicy).not.toHaveBeenCalled() expect(mocks.resolveAuthorization).not.toHaveBeenCalled() } ) it.each([ + ['GET', '/external/access/capabilities'], ['GET', '/external/access/catalog'], ['POST', '/external/access/resolve'], ] as const)( diff --git a/control-api/test/routes.externalAccess.test.ts b/control-api/test/routes.externalAccess.test.ts index 63037597b4..59db6b0e7d 100644 --- a/control-api/test/routes.externalAccess.test.ts +++ b/control-api/test/routes.externalAccess.test.ts @@ -203,7 +203,7 @@ describe('external user-access contracts', () => { 'external_access_capabilities:user:10000000-0000-4000-8000-000000000001', 10 ) - expect(mocks.resolvePolicy).toHaveBeenCalledTimes(1) + expect(mocks.resolvePolicy).not.toHaveBeenCalled() }) it('uses the authoritative target schema for team management actions', async () => { From 95a050da6c881b43a0d8ca9e1bcf19dab20c367e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 1 Sep 2026 17:11:13 -0600 Subject: [PATCH 184/528] fix(control-api): r24-b2 admit workflow work after limiting R24-B2 Class-check: protected external workflow routes -> 7 sites -> one staged read policy plus retained trigger policy Repro-test: npm test -- --run test/r24B2.workflowAdmission.t3.test.ts (fails at d8519f22d) --- .../src/middleware/accessExecutionBudget.ts | 45 ++++++++++++ .../middleware/externalUserRateLimitPolicy.ts | 2 + control-api/src/routes/external/access.ts | 44 +----------- .../routes/external/workflows/admission.ts | 30 ++++++++ .../routes/external/workflows/read.routes.ts | 12 ++-- .../routes/external/workflows/runs.routes.ts | 12 ++-- .../external/workflows/trigger.routes.ts | 10 +-- .../test/routes.externalAccess.test.ts | 6 +- .../test/routes.externalWorkflows.test.ts | 68 ++++++++++++++++--- 9 files changed, 158 insertions(+), 71 deletions(-) create mode 100644 control-api/src/middleware/accessExecutionBudget.ts create mode 100644 control-api/src/routes/external/workflows/admission.ts diff --git a/control-api/src/middleware/accessExecutionBudget.ts b/control-api/src/middleware/accessExecutionBudget.ts new file mode 100644 index 0000000000..9b9e99feca --- /dev/null +++ b/control-api/src/middleware/accessExecutionBudget.ts @@ -0,0 +1,45 @@ +import type { Response } from 'express' +import { + AccessExecutionBudget, + type AccessExecutionKind, +} from '../services/access/accessExecutionBudget.js' +import { configuredCatalogBudgetOptions } from '../services/access/userAccessPolicy.js' +import { type ExternalAuthedRequest } from './externalSessionAuth.js' + +export function attachAccessExecutionBudget( + req: ExternalAuthedRequest, + res: Response, + next: () => void +): void { + const kind: AccessExecutionKind = + req.method === 'GET' && req.path.endsWith('/catalog') ? 'catalog' : 'action' + const budget = AccessExecutionBudget.create( + kind, + kind === 'catalog' ? configuredCatalogBudgetOptions : undefined + ) + req.accessExecutionBudget = budget + let settled = false + const detach = () => { + req.removeListener('aborted', onAborted) + res.removeListener('finish', onFinished) + res.removeListener('close', onClosed) + } + const onAborted = () => budget.cancel() + const onFinished = () => { + if (settled) return + settled = true + detach() + budget.close() + } + const onClosed = () => { + if (settled) return + settled = true + if (!res.writableEnded) budget.cancel() + detach() + budget.close() + } + req.once('aborted', onAborted) + res.once('finish', onFinished) + res.once('close', onClosed) + next() +} diff --git a/control-api/src/middleware/externalUserRateLimitPolicy.ts b/control-api/src/middleware/externalUserRateLimitPolicy.ts index 3312302dbe..8e3d65383e 100644 --- a/control-api/src/middleware/externalUserRateLimitPolicy.ts +++ b/control-api/src/middleware/externalUserRateLimitPolicy.ts @@ -16,6 +16,7 @@ export type ExternalUserRateLimitOperation = | 'shared_filesystem_read' | 'workflow_approval_medium_read' | 'workflow_approval_medium_mutation' + | 'workflow_read' | 'notification_preference_read' | 'notification_preference_mutation' | 'authentication_attempt' @@ -55,6 +56,7 @@ const POLICIES: Readonly> = { bucketType: 'external_workflow_approval_medium_mutation', maxPerMinute: 10, }, + workflow_read: { bucketType: 'external_workflow_read', maxPerMinute: 30 }, notification_preference_read: { bucketType: 'external_notification_preference_read', maxPerMinute: 30, diff --git a/control-api/src/routes/external/access.ts b/control-api/src/routes/external/access.ts index 989edd4278..976fe9e3c7 100644 --- a/control-api/src/routes/external/access.ts +++ b/control-api/src/routes/external/access.ts @@ -2,6 +2,7 @@ import { Router } from 'express' import { asyncHandler } from '../../http/asyncHandler.js' import { sendPublicApiError } from '../../http/publicApiError.js' import type { K8sGateway } from '../../k8s.js' +import { attachAccessExecutionBudget } from '../../middleware/accessExecutionBudget.js' import { type ExternalAuthedRequest, requireCompletedExternalSessionAuthenticationWithPublicErrors, @@ -19,7 +20,6 @@ import { import { AccessCatalogCursorError } from '../../services/access/accessCatalogCursor.js' import { AccessBudgetExceededError, - AccessExecutionBudget, AccessExecutionCancelledError, } from '../../services/access/accessExecutionBudget.js' import { isAccessCapability } from '../../services/access/capabilityRegistry.js' @@ -28,53 +28,13 @@ import { resolveLiveAuthorization } from '../../services/access/liveAuthorizatio import { validateOperationTarget } from '../../services/access/operationTarget.js' import { canonicalEnvironmentId } from '../../services/access/operationalAccessProjection.js' import { canonicalResourceIdentity } from '../../services/access/resourceIdentity.js' -import { - configuredCatalogBudgetOptions, - userAccessCapabilityManifest, -} from '../../services/access/userAccessPolicy.js' +import { userAccessCapabilityManifest } from '../../services/access/userAccessPolicy.js' import { resolveEffectiveUserAccessPolicy } from '../../services/access/userAccessRuntimePolicy.js' const CATALOG_RATE_LIMIT_PER_MINUTE = 10 const RESOLVE_RATE_LIMIT_PER_MINUTE = 10 const ACCESS_PATH_PATTERN = /^ap1_[A-Za-z0-9_-]{43}$/ -export function attachAccessExecutionBudget( - req: ExternalAuthedRequest, - res: Parameters[1], - next: () => void -): void { - const kind = req.method === 'GET' && req.path.endsWith('/catalog') ? 'catalog' : 'action' - const budget = AccessExecutionBudget.create( - kind, - kind === 'catalog' ? configuredCatalogBudgetOptions : undefined - ) - req.accessExecutionBudget = budget - let settled = false - const detach = () => { - req.removeListener('aborted', onAborted) - res.removeListener('finish', onFinished) - res.removeListener('close', onClosed) - } - const onAborted = () => budget.cancel() - const onFinished = () => { - if (settled) return - settled = true - detach() - budget.close() - } - const onClosed = () => { - if (settled) return - settled = true - if (!res.writableEnded) budget.cancel() - detach() - budget.close() - } - req.once('aborted', onAborted) - res.once('finish', onFinished) - res.once('close', onClosed) - next() -} - function catalogFamilies(value: unknown): CatalogFamily[] | null | undefined { if (value === undefined) return undefined if (typeof value !== 'string') return null diff --git a/control-api/src/routes/external/workflows/admission.ts b/control-api/src/routes/external/workflows/admission.ts new file mode 100644 index 0000000000..feaf869d30 --- /dev/null +++ b/control-api/src/routes/external/workflows/admission.ts @@ -0,0 +1,30 @@ +import { attachAccessExecutionBudget } from '../../../middleware/accessExecutionBudget.js' +import { + requireCompletedExternalSessionAuthenticationWithPublicErrors, + requireExternalSessionLimiterIdentityWithPublicErrors, +} from '../../../middleware/externalSessionAuth.js' +import { + externalUserRateLimitOptions, + requireAuthenticatedExternalUserRateLimitContext, +} from '../../../middleware/externalUserRateLimitPolicy.js' +import { rateLimitMiddleware } from '../../../middleware/rateLimitMiddleware.js' +import { bindCompletedExternalWorkflowCaller } from '../../workflows/shared/auth.js' +import { workflowTriggerRateLimit } from '../../workflows/shared/rateLimit.js' + +export const externalWorkflowReadAdmission = Object.freeze([ + attachAccessExecutionBudget, + requireExternalSessionLimiterIdentityWithPublicErrors, + requireAuthenticatedExternalUserRateLimitContext, + rateLimitMiddleware(externalUserRateLimitOptions('workflow_read', 'authenticated')), + requireCompletedExternalSessionAuthenticationWithPublicErrors, + bindCompletedExternalWorkflowCaller, +]) + +export const externalWorkflowTriggerAdmission = Object.freeze([ + attachAccessExecutionBudget, + requireExternalSessionLimiterIdentityWithPublicErrors, + requireAuthenticatedExternalUserRateLimitContext, + workflowTriggerRateLimit(), + requireCompletedExternalSessionAuthenticationWithPublicErrors, + bindCompletedExternalWorkflowCaller, +]) diff --git a/control-api/src/routes/external/workflows/read.routes.ts b/control-api/src/routes/external/workflows/read.routes.ts index c45766df98..dee65a267d 100644 --- a/control-api/src/routes/external/workflows/read.routes.ts +++ b/control-api/src/routes/external/workflows/read.routes.ts @@ -11,7 +11,8 @@ import { isRecipeNamespaceAllowed, } from '../../../services/workflows/workflowRecipeAccessService.js' import { getWorkflowHealth } from '../../../services/workflows/workflowRunReadService.js' -import { requireExternalWorkflowCaller } from '../../workflows/shared/auth.js' +import { requireBoundExternalWorkflowCaller } from '../../workflows/shared/auth.js' +import { externalWorkflowReadAdmission } from './admission.js' const BASE = '/external/workflows' @@ -20,8 +21,9 @@ export function createExternalWorkflowReadRoutes(gateway: K8sGateway): Router { router.get( BASE, + ...externalWorkflowReadAdmission, asyncHandler(async (req: Request, res: Response) => { - const caller = await requireExternalWorkflowCaller(req, res) + const caller = requireBoundExternalWorkflowCaller(req, res) if (!caller) return const recipes = await getAuthorizedRecipeResources(caller, gateway) @@ -66,8 +68,9 @@ export function createExternalWorkflowReadRoutes(gateway: K8sGateway): Router { router.get( `${BASE}/:ns/:name`, + ...externalWorkflowReadAdmission, asyncHandler(async (req: Request, res: Response) => { - const caller = await requireExternalWorkflowCaller(req, res) + const caller = requireBoundExternalWorkflowCaller(req, res) if (!caller) return const { ns, name } = req.params @@ -95,8 +98,9 @@ export function createExternalWorkflowReadRoutes(gateway: K8sGateway): Router { router.get( `${BASE}/:ns/:name/health`, + ...externalWorkflowReadAdmission, asyncHandler(async (req: Request, res: Response) => { - const caller = await requireExternalWorkflowCaller(req, res) + const caller = requireBoundExternalWorkflowCaller(req, res) if (!caller) return const { ns, name } = req.params diff --git a/control-api/src/routes/external/workflows/runs.routes.ts b/control-api/src/routes/external/workflows/runs.routes.ts index 9a49866a69..f7b938e523 100644 --- a/control-api/src/routes/external/workflows/runs.routes.ts +++ b/control-api/src/routes/external/workflows/runs.routes.ts @@ -13,8 +13,9 @@ import { listWorkflowRunArtifacts, } from '../../../services/workflows/workflowRunArtifactService.js' import { listCanonicalRuns } from '../../../services/workflows/workflowRunReadService.js' -import { requireExternalWorkflowCaller } from '../../workflows/shared/auth.js' +import { requireBoundExternalWorkflowCaller } from '../../workflows/shared/auth.js' import { parseLimit } from '../../workflows/shared/validation.js' +import { externalWorkflowReadAdmission } from './admission.js' const BASE = '/external/workflows' @@ -48,8 +49,9 @@ export function createExternalWorkflowRunsRoutes(gateway: K8sGateway): Router { router.get( `${BASE}/:ns/:name/runs`, + ...externalWorkflowReadAdmission, asyncHandler(async (req: Request, res: Response) => { - const caller = await requireExternalWorkflowCaller(req, res) + const caller = requireBoundExternalWorkflowCaller(req, res) if (!caller) return const { ns, name } = req.params @@ -83,8 +85,9 @@ export function createExternalWorkflowRunsRoutes(gateway: K8sGateway): Router { router.get( `${BASE}/:ns/:name/runs/:runId/artifacts`, + ...externalWorkflowReadAdmission, asyncHandler(async (req: Request, res: Response) => { - const caller = await requireExternalWorkflowCaller(req, res) + const caller = requireBoundExternalWorkflowCaller(req, res) if (!caller) return try { @@ -104,8 +107,9 @@ export function createExternalWorkflowRunsRoutes(gateway: K8sGateway): Router { router.get( `${BASE}/:ns/:name/runs/:runId/artifacts/:artifactName/download`, + ...externalWorkflowReadAdmission, asyncHandler(async (req: Request, res: Response) => { - const caller = await requireExternalWorkflowCaller(req, res) + const caller = requireBoundExternalWorkflowCaller(req, res) if (!caller) return try { diff --git a/control-api/src/routes/external/workflows/trigger.routes.ts b/control-api/src/routes/external/workflows/trigger.routes.ts index b96d35b3d2..6fb190abc1 100644 --- a/control-api/src/routes/external/workflows/trigger.routes.ts +++ b/control-api/src/routes/external/workflows/trigger.routes.ts @@ -10,11 +10,8 @@ import { WorkflowTriggerHttpError, triggerWorkflow, } from '../../../services/workflows/workflowTriggerService.js' -import { - bindExternalWorkflowAuth, - requireBoundExternalWorkflowCaller, -} from '../../workflows/shared/auth.js' -import { workflowTriggerRateLimit } from '../../workflows/shared/rateLimit.js' +import { requireBoundExternalWorkflowCaller } from '../../workflows/shared/auth.js' +import { externalWorkflowTriggerAdmission } from './admission.js' const BASE = '/external/workflows' const logger = rootLogger.child({ module: 'external-workflows' }) @@ -24,8 +21,7 @@ export function createExternalWorkflowTriggerRoutes(gateway: K8sGateway): Router router.post( `${BASE}/:ns/:name/trigger`, - bindExternalWorkflowAuth, - workflowTriggerRateLimit(), + ...externalWorkflowTriggerAdmission, asyncHandler(async (req: Request, res: Response) => { const caller = requireBoundExternalWorkflowCaller(req, res) if (!caller) return diff --git a/control-api/test/routes.externalAccess.test.ts b/control-api/test/routes.externalAccess.test.ts index 59db6b0e7d..8fffc70f21 100644 --- a/control-api/test/routes.externalAccess.test.ts +++ b/control-api/test/routes.externalAccess.test.ts @@ -2,10 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import express from 'express' import { EventEmitter } from 'node:events' import request from 'supertest' -import { - attachAccessExecutionBudget, - createExternalAccessRouter, -} from '../src/routes/external/access.js' +import { attachAccessExecutionBudget } from '../src/middleware/accessExecutionBudget.js' +import { createExternalAccessRouter } from '../src/routes/external/access.js' import { canonicalEnvironmentId } from '../src/services/access/operationalAccessProjection.js' const mocks = vi.hoisted(() => ({ diff --git a/control-api/test/routes.externalWorkflows.test.ts b/control-api/test/routes.externalWorkflows.test.ts index 8a2bc40876..05cc98ac1d 100644 --- a/control-api/test/routes.externalWorkflows.test.ts +++ b/control-api/test/routes.externalWorkflows.test.ts @@ -13,6 +13,7 @@ const mockVerifyWorkflowControlToken = vi.fn() const mockIsHostRefAuthorized = vi.fn() const mockVerifyAdminToken = vi.fn() const mockAuthenticateExternalUserSession = vi.fn() +const mockRateLimitCheck = vi.fn() const mockVerifyInternalControlJwt = vi.fn() const mockIsAdminTokenRevoked = vi.fn() @@ -42,6 +43,12 @@ vi.mock('../src/utils/auth/adminAuthToken.js', () => ({ vi.mock('../src/services/auth/externalSessionAuthentication.js', () => ({ authenticateExternalUserSession: (...args: unknown[]) => mockAuthenticateExternalUserSession(...args), + authenticateExternalUserSessionIdentity: (...args: unknown[]) => + mockAuthenticateExternalUserSession(...args), +})) + +vi.mock('../src/services/rateLimiterService.js', () => ({ + checkAndIncrement: (...args: unknown[]) => mockRateLimitCheck(...args), })) vi.mock('../src/utils/auth/internalControlToken.js', () => ({ @@ -129,6 +136,7 @@ describe('routes/external/workflows', () => { mockIsHostRefAuthorized.mockReset() mockVerifyAdminToken.mockReset() mockAuthenticateExternalUserSession.mockReset() + mockRateLimitCheck.mockReset() mockVerifyInternalControlJwt.mockReset() mockIsAdminTokenRevoked.mockReset() gateway = new MockGateway(RECIPE_NS) @@ -138,6 +146,13 @@ describe('routes/external/workflows', () => { expiresInSeconds: 600, }) mockIsAdminTokenRevoked.mockResolvedValue(false) + mockRateLimitCheck.mockResolvedValue({ + allowed: true, + remaining: 29, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + count: 1, + }) mockAuthenticateExternalUserSession.mockImplementation(token => { if (token === 'user-session-token' || token === 'user-session-token-rotated') { return Promise.resolve({ @@ -267,6 +282,45 @@ describe('routes/external/workflows', () => { }) }) + describe('authenticated workflow admission ordering', () => { + it.each([ + ['GET', '/external/workflows', 30], + ['GET', `/external/workflows/${RECIPE_NS}/test-recipe`, 30], + ['GET', `/external/workflows/${RECIPE_NS}/test-recipe/health`, 30], + ['GET', `/external/workflows/${RECIPE_NS}/test-recipe/runs`, 30], + ['GET', `/external/workflows/${RECIPE_NS}/test-recipe/runs/run-1/artifacts`, 30], + [ + 'GET', + `/external/workflows/${RECIPE_NS}/test-recipe/runs/run-1/artifacts/output/download`, + 30, + ], + ['POST', `/external/workflows/${RECIPE_NS}/test-recipe/trigger`, 10], + ] as const)( + 'limits %s %s before final authority or protected work', + async (method, path, limit) => { + mockRateLimitCheck.mockResolvedValue({ + allowed: false, + remaining: 0, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + count: limit + 1, + }) + + const response = await request(makeApp(gateway)) + [method === 'GET' ? 'get' : 'post'](path) + .set('x-user-session-token', 'user-session-token') + .send(method === 'POST' ? {} : undefined) + + expect(response.status).toBe(429) + expect(response.body).toMatchObject( + method === 'POST' ? { error: 'Too Many Requests' } : { error: { code: 'rate_limited' } } + ) + expect(mockAuthenticateExternalUserSession).toHaveBeenCalledTimes(1) + expect(mockPoolQuery).not.toHaveBeenCalled() + } + ) + }) + describe('Cross-user isolation (ensureRecipeAuthorized)', () => { it('returns 403 when user-session caller has no grant for the recipe', async () => { await gateway.createResource('workflowrecipes', VALID_RECIPE as never, RECIPE_NS) @@ -297,11 +351,7 @@ describe('routes/external/workflows', () => { }) describe('POST /external/workflows/:ns/:name/trigger', () => { - // Rate limiter (10 req/min per token) consumes the first mockPoolQuery call - // — see matching helper in adminWorkflows.test.ts. - function mockRateLimiterAllowed() { - mockPoolQuery.mockResolvedValueOnce({ rows: [{ count: 1 }], rowCount: 1 }) - } + function mockRateLimiterAllowed() {} function makeTransportRecipe(overrides: Record = {}) { return { @@ -434,7 +484,7 @@ describe('routes/external/workflows', () => { expect( mockPoolQuery.mock.calls.some(call => String(call[0]).includes('INSERT INTO workflow_runs')) ).toBe(false) - expect(mockPoolQuery).toHaveBeenCalledTimes(6) + expect(mockPoolQuery).toHaveBeenCalledTimes(5) }) it('rejects approval-gated user triggers before transport MCP runtime is ready', async () => { @@ -582,7 +632,7 @@ describe('routes/external/workflows', () => { .expect(400) expect(res.body.error).toBe('Workflow does not declare an onDemand trigger') - expect(mockPoolQuery).toHaveBeenCalledTimes(2) + expect(mockPoolQuery).toHaveBeenCalledTimes(1) }) it('returns 403 when user lacks recipe grant — prevents cross-user trigger', async () => { @@ -603,9 +653,7 @@ describe('routes/external/workflows', () => { }) function rateLimitBucketKeys(): string[] { - return mockPoolQuery.mock.calls - .filter(call => String(call[0]).includes('INSERT INTO rate_limit_buckets')) - .map(call => String((call[1] as unknown[] | undefined)?.[0] ?? '')) + return mockRateLimitCheck.mock.calls.map(call => String(call[0] ?? '')) } it('keys independent trigger buckets for two user sessions behind the same service bearer', async () => { From 67753ef9b5b95576c44fcf8f60b5da8bd2bb9461 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 1 Sep 2026 17:15:05 -0600 Subject: [PATCH 185/528] fix(control-api): r24-m4 budget live team authorization R24-M4 Class-check: getLiveTeamMembership direct callers -> 3 helpers -> 12 route surfaces -> request budgets propagated. Repro-test: npm test -- --run test/routes.externalDirectory.test.ts (fails at 2e0dfdfcba5a98208a77624b9e7503f4231334f1) --- .../src/routes/external/sharedFilesystems.ts | 14 ++++++-- control-api/src/routes/external/teams.ts | 8 ++++- .../services/access/liveTeamAuthorization.ts | 19 ++++++---- .../test/liveTeamAuthorizationBudget.test.ts | 35 +++++++++++++++++++ .../test/routes.externalDirectory.test.ts | 5 +++ 5 files changed, 71 insertions(+), 10 deletions(-) create mode 100644 control-api/test/liveTeamAuthorizationBudget.test.ts diff --git a/control-api/src/routes/external/sharedFilesystems.ts b/control-api/src/routes/external/sharedFilesystems.ts index 06b4c355bf..725ae3a77e 100644 --- a/control-api/src/routes/external/sharedFilesystems.ts +++ b/control-api/src/routes/external/sharedFilesystems.ts @@ -2,6 +2,7 @@ import { Router } from 'express' import { config } from '../../config.js' import { asyncHandler } from '../../http/asyncHandler.js' import type { K8sGateway } from '../../k8s.js' +import { attachAccessExecutionBudget } from '../../middleware/accessExecutionBudget.js' import { createExternalClientRateLimiters } from '../../middleware/externalClientIdentity.js' import { type ExternalAuthedRequest, @@ -73,7 +74,8 @@ class ContextAuthorizationUnavailableError extends Error { async function loadAccessibleContextIds( gateway: K8sGateway, userId: string, - teamId: string | null + teamId: string | null, + budget: NonNullable ): Promise> { const accessible = new Set() let activeContextIds: string[] @@ -92,7 +94,7 @@ async function loadAccessibleContextIds( const compatibilityTeamId = teamId.trim() let membership try { - membership = await getLiveTeamMembership(userId, compatibilityTeamId) + membership = await getLiveTeamMembership(userId, compatibilityTeamId, { budget }) } catch (err) { throw new ContextAuthorizationUnavailableError(err) } @@ -121,7 +123,12 @@ function loadAccessibleContextIdsForRequest( ): Promise> { const cache = requestCache(res) const claims = req.externalAuth! - cache.accessibleContextIds ??= loadAccessibleContextIds(gateway, claims.userId, claims.teamId) + cache.accessibleContextIds ??= loadAccessibleContextIds( + gateway, + claims.userId, + claims.teamId, + req.accessExecutionBudget! + ) return cache.accessibleContextIds } @@ -218,6 +225,7 @@ export function createExternalSharedFilesystemsRouter(gateway: K8sGateway): Rout router.use( '/external/contexts/:contextId/shared-filesystems', ...externalSharedFilesystemsRateLimits, + attachAccessExecutionBudget, requireValidExternalSessionToken ) diff --git a/control-api/src/routes/external/teams.ts b/control-api/src/routes/external/teams.ts index 5ef2a48da8..75c8739cb4 100644 --- a/control-api/src/routes/external/teams.ts +++ b/control-api/src/routes/external/teams.ts @@ -1,6 +1,7 @@ import { Router } from 'express' import { config } from '../../config.js' import type { K8sGateway } from '../../k8s.js' +import { attachAccessExecutionBudget } from '../../middleware/accessExecutionBudget.js' import { createExternalClientRateLimiters } from '../../middleware/externalClientIdentity.js' import type { ExternalAuthedRequest } from '../../middleware/externalSessionAuth.js' import { @@ -49,7 +50,12 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { config.approvalRlExternalClientIpPerMin, config.approvalRlExternalEdgePerMin ) - router.use('/external/teams', ...externalTeamsRateLimits, requireValidExternalSessionToken) + router.use( + '/external/teams', + ...externalTeamsRateLimits, + attachAccessExecutionBudget, + requireValidExternalSessionToken + ) router.get( '/external/teams/:teamId/users/:userId/current', diff --git a/control-api/src/services/access/liveTeamAuthorization.ts b/control-api/src/services/access/liveTeamAuthorization.ts index ce20b1629f..cefa6c4611 100644 --- a/control-api/src/services/access/liveTeamAuthorization.ts +++ b/control-api/src/services/access/liveTeamAuthorization.ts @@ -1,5 +1,7 @@ import { type DbClient, pool } from '../../db.js' import type { TeamRole } from '../../profileTypes.js' +import { runAccessDatabaseQuery } from './accessDatabaseQuery.js' +import type { AccessExecutionBudget } from './accessExecutionBudget.js' export type LiveTeamMembership = { teamId: string @@ -9,17 +11,22 @@ export type LiveTeamMembership = { export async function getLiveTeamMembership( userId: string, teamId: string, - db: Pick = pool + options: { + db?: Pick + budget?: AccessExecutionBudget + } = {} ): Promise { - const result = await db.query( - `SELECT tm.team_id, tm.role + const db = options.db ?? pool + const text = `SELECT tm.team_id, tm.role FROM team_members tm WHERE tm.user_id = $1 AND tm.team_id = $2 AND tm.status = 'active' - LIMIT 1`, - [userId, teamId] - ) + LIMIT 1` + const values = [userId, teamId] + const result = options.budget + ? await runAccessDatabaseQuery(db, options.budget, text, values) + : await db.query(text, values) const row = result.rows[0] as { team_id: string; role: TeamRole } | undefined return row ? { teamId: row.team_id, role: row.role } : null } diff --git a/control-api/test/liveTeamAuthorizationBudget.test.ts b/control-api/test/liveTeamAuthorizationBudget.test.ts new file mode 100644 index 0000000000..b33eaecdbc --- /dev/null +++ b/control-api/test/liveTeamAuthorizationBudget.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from 'vitest' +import { AccessBudgetExceededError } from '../src/services/access/accessExecutionBudget.js' +import { AccessExecutionBudget } from '../src/services/access/accessExecutionBudget.js' +import { getLiveTeamMembership } from '../src/services/access/liveTeamAuthorization.js' + +describe('live team authorization request budget', () => { + it('charges the request database-statement budget for each live membership lookup', async () => { + const query = vi.fn().mockResolvedValue({ + rows: [{ team_id: 'team-1', role: 'member' }], + rowCount: 1, + }) + const budget = AccessExecutionBudget.create('action', { + limits: { databaseStatements: 1 }, + }) + + try { + await expect( + getLiveTeamMembership('user-1', 'team-1', { db: { query }, budget }) + ).resolves.toEqual({ teamId: 'team-1', role: 'member' }) + expect(budget.remaining('databaseStatements')).toBe(0) + + await expect( + getLiveTeamMembership('user-1', 'team-1', { db: { query }, budget }) + ).rejects.toEqual( + expect.objectContaining>({ + name: 'AccessBudgetExceededError', + limit: 'databaseStatements', + }) + ) + expect(query).toHaveBeenCalledTimes(1) + } finally { + budget.close() + } + }) +}) diff --git a/control-api/test/routes.externalDirectory.test.ts b/control-api/test/routes.externalDirectory.test.ts index bf973cef13..68129b9038 100644 --- a/control-api/test/routes.externalDirectory.test.ts +++ b/control-api/test/routes.externalDirectory.test.ts @@ -99,6 +99,11 @@ describe('external directory authorization', () => { expect(response.headers['x-ratelimit-limit']).toBe('30') expect(response.headers['x-ratelimit-remaining']).toBe('29') expect(response.headers['x-ratelimit-reset']).toBeDefined() + expect(mocks.membership).toHaveBeenCalledWith( + 'user-1', + 'team-1', + expect.objectContaining({ budget: expect.anything() }) + ) expect(mocks.searchDirectory).toHaveBeenCalledWith('team-1', 'person', undefined) }) From 3ac9b68e6fb34f59718ea19b5fe2f5198ddde9fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 1 Sep 2026 17:15:56 -0600 Subject: [PATCH 186/528] fix(control-api): r24-b4 require catalog activation evidence R24-B4 Class-check: catalogParityAccepted producers -> 2 sites -> runtime uses operator evidence; reconstruction stays false. Repro-test: control-api/test/userAccessCatalogActivation.test.ts (fails at dd12f044d77f06b4d07ea252e6d8dc7ff46c5897) --- .../access/userAccessRuntimePolicy.ts | 130 +++++++++++++++++- .../test/userAccessCatalogActivation.test.ts | 125 +++++++++++++++++ 2 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 control-api/test/userAccessCatalogActivation.test.ts diff --git a/control-api/src/services/access/userAccessRuntimePolicy.ts b/control-api/src/services/access/userAccessRuntimePolicy.ts index ac01a0e44c..49d296b485 100644 --- a/control-api/src/services/access/userAccessRuntimePolicy.ts +++ b/control-api/src/services/access/userAccessRuntimePolicy.ts @@ -14,6 +14,7 @@ import { type ConfiguredUserAccessIntent, type DeploymentReadiness, type EffectiveUserAccessPolicy, + USER_ACCESS_POLICY_VERSION, UserAccessPolicyConfigurationError, compileUserAccessPolicy, configuredUserAccessIntent, @@ -35,6 +36,25 @@ type RuntimePolicyOptions = Readonly<{ indexerEnabled?: boolean readinessMaxAgeMs?: number | null now?: Date + catalogActivationRecord?: string +}> + +type CatalogComparisonEvidence = Readonly<{ + family: CatalogFamily + attempted: number + completed: number + reference: string +}> + +type CatalogActivationRecordV1 = Readonly<{ + version: 1 + active: boolean + revision: string + acceptedBy: string + acceptedAt: string + catalogConfigurationRevision: string + requiredFamilies: readonly CatalogFamily[] + comparisonEvidence: readonly CatalogComparisonEvidence[] }> const DATABASE_ONLY_FAMILIES: readonly CatalogFamily[] = Object.freeze([ @@ -70,6 +90,100 @@ function unavailableReadiness(): DeploymentReadiness { }) } +export function catalogConfigurationRevision(intent: ConfiguredUserAccessIntent): string { + return revisionOfValues([ + 'catalog-activation-configuration-v1', + USER_ACCESS_POLICY_VERSION, + intent.catalogMode, + intent.teamGfsMembershipAdmissionLimit, + CATALOG_FAMILIES, + ]) +} + +function boundedText(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0 && value.length <= 1_024 +} + +function parseCatalogActivationRecord(value: string): CatalogActivationRecordV1 | null { + if (!value || value.length > 64 * 1_024) return null + try { + const parsed = JSON.parse(value) as Record + if ( + parsed.version !== 1 || + typeof parsed.active !== 'boolean' || + !boundedText(parsed.revision) || + !boundedText(parsed.acceptedBy) || + !boundedText(parsed.acceptedAt) || + !Number.isFinite(Date.parse(parsed.acceptedAt)) || + !boundedText(parsed.catalogConfigurationRevision) || + !Array.isArray(parsed.requiredFamilies) || + !Array.isArray(parsed.comparisonEvidence) + ) { + return null + } + const requiredFamilies = parsed.requiredFamilies as unknown[] + const uniqueRequired = new Set(requiredFamilies) + if ( + uniqueRequired.size !== CATALOG_FAMILIES.length || + CATALOG_FAMILIES.some(family => !uniqueRequired.has(family)) + ) { + return null + } + const evidence = parsed.comparisonEvidence as Record[] + const evidenceByFamily = new Map() + for (const item of evidence) { + const family = item?.family as CatalogFamily + if ( + !CATALOG_FAMILIES.includes(family) || + evidenceByFamily.has(family) || + !Number.isSafeInteger(item.attempted) || + Number(item.attempted) < 1 || + !Number.isSafeInteger(item.completed) || + Number(item.completed) < 1 || + Number(item.completed) > Number(item.attempted) || + !boundedText(item.reference) + ) { + return null + } + evidenceByFamily.set( + family, + Object.freeze({ + family, + attempted: Number(item.attempted), + completed: Number(item.completed), + reference: item.reference, + }) + ) + } + if (evidenceByFamily.size !== CATALOG_FAMILIES.length) return null + return Object.freeze({ + version: 1, + active: parsed.active, + revision: parsed.revision, + acceptedBy: parsed.acceptedBy, + acceptedAt: parsed.acceptedAt, + catalogConfigurationRevision: parsed.catalogConfigurationRevision, + requiredFamilies: Object.freeze([...requiredFamilies] as CatalogFamily[]), + comparisonEvidence: Object.freeze( + CATALOG_FAMILIES.map(family => evidenceByFamily.get(family)!) + ), + }) + } catch { + return null + } +} + +function catalogParityAccepted( + intent: ConfiguredUserAccessIntent, + activationValue: string +): boolean { + const record = parseCatalogActivationRecord(activationValue) + return ( + record?.active === true && + record.catalogConfigurationRevision === catalogConfigurationRevision(intent) + ) +} + function parseSourceStates( rows: readonly Record[] ): readonly RuntimeSourceState[] { @@ -108,7 +222,9 @@ function parseSourceStates( function runtimeReadiness( states: readonly RuntimeSourceState[], now: Date, - maxAgeMs: number + maxAgeMs: number, + intent: ConfiguredUserAccessIntent, + activationValue: string ): DeploymentReadiness { const currentSources = new Set() for (const state of states) { @@ -122,6 +238,7 @@ function runtimeReadiness( const required = REQUIRED_SOURCES[family] if (required?.every(source => currentSources.has(source))) operationalFamilies.add(family) } + const parityAccepted = catalogParityAccepted(intent, activationValue) return Object.freeze({ ...reconstructionReadiness, revision: revisionOfValues([ @@ -134,9 +251,12 @@ function runtimeReadiness( state.status, state.lastSuccessAt.toISOString(), ]), + catalogConfigurationRevision(intent), + activationValue, ]), snapshot: 'current', catalogOperationalFamilies: operationalFamilies, + catalogParityAccepted: parityAccepted, }) } @@ -190,7 +310,13 @@ export async function resolveEffectiveUserAccessPolicy( const states = parseSourceStates(result.rows as Record[]) return compileUserAccessPolicy( intent, - runtimeReadiness(states, options.now ?? new Date(), validatedMaxAgeMs) + runtimeReadiness( + states, + options.now ?? new Date(), + validatedMaxAgeMs, + intent, + options.catalogActivationRecord ?? config.userAccessCatalogActivationRecord + ) ) } catch (error) { if (error instanceof UserAccessPolicyConfigurationError) throw error diff --git a/control-api/test/userAccessCatalogActivation.test.ts b/control-api/test/userAccessCatalogActivation.test.ts new file mode 100644 index 0000000000..7cf230cd28 --- /dev/null +++ b/control-api/test/userAccessCatalogActivation.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from 'vitest' +import { OPERATIONAL_SOURCE_FAMILIES } from '../src/services/access/operationalAccessProjection.js' +import { + CATALOG_FAMILIES, + type ConfiguredUserAccessIntent, + UserAccessPolicyConfigurationError, +} from '../src/services/access/userAccessPolicy.js' +import * as runtimePolicy from '../src/services/access/userAccessRuntimePolicy.js' + +const configured: ConfiguredUserAccessIntent = Object.freeze({ + legacyLifecycle: 'issue_and_accept', + sessionV2Acceptance: true, + sessionV2Issuance: false, + catalogMode: 'serve', + teamGfsMembershipAdmissionLimit: 4, + actionContextV2: false, + rpcDelegationV2: false, + desktopAllTeamMode: false, + profileV2Mode: false, + legacySwitchEndpoint: true, + minimumClientVersion: null, + minimumClientEnforcement: false, +}) + +const now = new Date('2026-09-01T10:00:00.000Z') + +function sourceQuery() { + return vi.fn().mockResolvedValue({ + rows: OPERATIONAL_SOURCE_FAMILIES.map((source_family, index) => ({ + source_family, + generation: index + 1, + resource_version: String(index + 10), + status: 'current', + last_success_at: new Date(now.getTime() - 1_000), + })), + }) +} + +function activationRecord( + overrides: Partial<{ + active: boolean + catalogConfigurationRevision: string + comparisonEvidence: Array<{ + family: (typeof CATALOG_FAMILIES)[number] + attempted: number + completed: number + reference: string + }> + }> = {} +): string { + const revisionProducer = Reflect.get(runtimePolicy, 'catalogConfigurationRevision') as + | ((intent: ConfiguredUserAccessIntent) => string) + | undefined + return JSON.stringify({ + version: 1, + active: true, + revision: 'catalog-acceptance-2026-09-01', + acceptedBy: 'release-operator@example.test', + acceptedAt: '2026-09-01T10:00:00.000Z', + catalogConfigurationRevision: revisionProducer?.(configured) ?? 'pre-correction-placeholder', + requiredFamilies: CATALOG_FAMILIES, + comparisonEvidence: CATALOG_FAMILIES.map(family => ({ + family, + attempted: 10, + completed: 10, + reference: `shadow-run-2026-09-01/${family}`, + })), + ...overrides, + }) +} + +async function resolve(catalogActivationRecord: string) { + return runtimePolicy.resolveEffectiveUserAccessPolicy({ + intent: configured, + db: { query: sourceQuery() } as never, + indexerEnabled: true, + readinessMaxAgeMs: 5_000, + now, + catalogActivationRecord, + }) +} + +describe('operator-owned catalog activation', () => { + it('serves only from an active acceptance record bound to the current configuration', async () => { + await expect(resolve(activationRecord())).resolves.toMatchObject({ + serveCatalog: true, + advertisedCatalogFamilies: CATALOG_FAMILIES, + }) + + await expect(resolve(activationRecord({ active: false }))).rejects.toThrowError( + new UserAccessPolicyConfigurationError('catalog_serving_parity_not_accepted') + ) + await expect( + resolve(activationRecord({ catalogConfigurationRevision: 'another-configuration' })) + ).rejects.toThrowError( + new UserAccessPolicyConfigurationError('catalog_serving_parity_not_accepted') + ) + }) + + it('rejects incomplete or skipped-only comparison evidence', async () => { + const incompleteEvidence = CATALOG_FAMILIES.slice(1).map(family => ({ + family, + attempted: 10, + completed: 10, + reference: `shadow-run-2026-09-01/${family}`, + })) + const skippedEvidence = CATALOG_FAMILIES.map(family => ({ + family, + attempted: 10, + completed: family === CATALOG_FAMILIES[0] ? 0 : 10, + reference: `shadow-run-2026-09-01/${family}`, + })) + + await expect( + resolve(activationRecord({ comparisonEvidence: incompleteEvidence })) + ).rejects.toThrowError( + new UserAccessPolicyConfigurationError('catalog_serving_parity_not_accepted') + ) + await expect( + resolve(activationRecord({ comparisonEvidence: skippedEvidence })) + ).rejects.toThrowError( + new UserAccessPolicyConfigurationError('catalog_serving_parity_not_accepted') + ) + }) +}) From a31a4871feb55f2533fe7ddf5546da39e74cf584 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 1 Sep 2026 17:16:25 -0600 Subject: [PATCH 187/528] fix(control-api): r24-b5 isolate catalog readiness R24-B5 Class-check: external catalog readiness consumers -> 3 sites -> catalog-specific failures isolated from general authentication. Repro-test: control-api/test/userAccessCatalogReadinessIsolation.test.ts (fails at dd12f044d77f06b4d07ea252e6d8dc7ff46c5897) --- control-api/src/routes/external/access.ts | 10 +++- .../services/access/accessCatalogShadow.ts | 2 +- .../access/userAccessRuntimePolicy.ts | 6 +- .../test/userAccessCatalogActivation.test.ts | 1 + ...serAccessCatalogReadinessIsolation.test.ts | 55 +++++++++++++++++++ control-api/test/userAccessPolicy.test.ts | 5 ++ 6 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 control-api/test/userAccessCatalogReadinessIsolation.test.ts diff --git a/control-api/src/routes/external/access.ts b/control-api/src/routes/external/access.ts index 976fe9e3c7..ae730b3dc1 100644 --- a/control-api/src/routes/external/access.ts +++ b/control-api/src/routes/external/access.ts @@ -69,7 +69,10 @@ async function requireEffectiveV2Contract( next: () => void ): Promise { try { - const policy = await resolveEffectiveUserAccessPolicy({ budget: req.accessExecutionBudget }) + const policy = await resolveEffectiveUserAccessPolicy({ + budget: req.accessExecutionBudget, + catalogReadiness: feature === 'catalog', + }) const enabled = feature === 'catalog' ? policy.serveCatalog : policy.actionContextV2 if (req.externalAuth?.sessionContract === 'v2' && enabled) { next() @@ -149,7 +152,10 @@ export function createExternalAccessRouter(gateway: K8sGateway): Router { requireCompletedExternalSessionAuthenticationWithPublicErrors, asyncHandler(async (req: ExternalAuthedRequest, res) => { try { - const policy = await resolveEffectiveUserAccessPolicy({ budget: req.accessExecutionBudget }) + const policy = await resolveEffectiveUserAccessPolicy({ + budget: req.accessExecutionBudget, + catalogReadiness: true, + }) res.status(200).json({ contractVersion: '2', currentSessionContract: req.externalAuth!.sessionContract ?? 'v1', diff --git a/control-api/src/services/access/accessCatalogShadow.ts b/control-api/src/services/access/accessCatalogShadow.ts index 03a1ccba6c..9a34681603 100644 --- a/control-api/src/services/access/accessCatalogShadow.ts +++ b/control-api/src/services/access/accessCatalogShadow.ts @@ -115,7 +115,7 @@ export async function compareAccessCatalogShadow( if (!legacy) return record(input.family, 'skipped_legacy_incomplete') if (options.enabled === undefined) { try { - const policy = await resolveEffectiveUserAccessPolicy() + const policy = await resolveEffectiveUserAccessPolicy({ catalogReadiness: true }) if (!policy.computeCatalogShadow) return 'skipped_unavailable' } catch { return record(input.family, 'skipped_unavailable') diff --git a/control-api/src/services/access/userAccessRuntimePolicy.ts b/control-api/src/services/access/userAccessRuntimePolicy.ts index 49d296b485..96e513d3a0 100644 --- a/control-api/src/services/access/userAccessRuntimePolicy.ts +++ b/control-api/src/services/access/userAccessRuntimePolicy.ts @@ -37,6 +37,7 @@ type RuntimePolicyOptions = Readonly<{ readinessMaxAgeMs?: number | null now?: Date catalogActivationRecord?: string + catalogReadiness?: boolean }> type CatalogComparisonEvidence = Readonly<{ @@ -263,7 +264,10 @@ function runtimeReadiness( export async function resolveEffectiveUserAccessPolicy( options: RuntimePolicyOptions = {} ): Promise { - const intent = options.intent ?? configuredUserAccessIntent + const configuredIntent = options.intent ?? configuredUserAccessIntent + const intent = options.catalogReadiness + ? configuredIntent + : Object.freeze({ ...configuredIntent, catalogMode: 'off' as const }) if (!needsOperationalReadiness(intent)) { return compileUserAccessPolicy(intent, reconstructionReadiness) } diff --git a/control-api/test/userAccessCatalogActivation.test.ts b/control-api/test/userAccessCatalogActivation.test.ts index 7cf230cd28..f3dc008699 100644 --- a/control-api/test/userAccessCatalogActivation.test.ts +++ b/control-api/test/userAccessCatalogActivation.test.ts @@ -77,6 +77,7 @@ async function resolve(catalogActivationRecord: string) { readinessMaxAgeMs: 5_000, now, catalogActivationRecord, + catalogReadiness: true, }) } diff --git a/control-api/test/userAccessCatalogReadinessIsolation.test.ts b/control-api/test/userAccessCatalogReadinessIsolation.test.ts new file mode 100644 index 0000000000..195bad1e14 --- /dev/null +++ b/control-api/test/userAccessCatalogReadinessIsolation.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ConfiguredUserAccessIntent } from '../src/services/access/userAccessPolicy.js' +import { resolveEffectiveUserAccessPolicy } from '../src/services/access/userAccessRuntimePolicy.js' + +const shadowIntent: ConfiguredUserAccessIntent = Object.freeze({ + legacyLifecycle: 'issue_and_accept', + sessionV2Acceptance: true, + sessionV2Issuance: false, + catalogMode: 'shadow', + teamGfsMembershipAdmissionLimit: 4, + actionContextV2: false, + rpcDelegationV2: false, + desktopAllTeamMode: false, + profileV2Mode: false, + legacySwitchEndpoint: true, + minimumClientVersion: null, + minimumClientEnforcement: false, +}) + +describe('catalog readiness isolation', () => { + it('keeps ordinary policy resolution independent from catalog readiness', async () => { + const query = vi.fn().mockRejectedValue(new Error('catalog source unavailable')) + + await expect( + resolveEffectiveUserAccessPolicy({ + intent: shadowIntent, + db: { query } as never, + indexerEnabled: true, + readinessMaxAgeMs: 5_000, + }) + ).resolves.toMatchObject({ + acceptV1: true, + acceptV2: true, + computeCatalogShadow: false, + serveCatalog: false, + advertisedCatalogFamilies: [], + }) + expect(query).not.toHaveBeenCalled() + }) + + it('keeps catalog-specific readiness fail-closed', async () => { + const query = vi.fn().mockRejectedValue(new Error('catalog source unavailable')) + + await expect( + resolveEffectiveUserAccessPolicy({ + intent: shadowIntent, + db: { query } as never, + indexerEnabled: true, + readinessMaxAgeMs: 5_000, + catalogReadiness: true, + }) + ).rejects.toMatchObject({ code: 'readiness_snapshot_unavailable' }) + expect(query).toHaveBeenCalledOnce() + }) +}) diff --git a/control-api/test/userAccessPolicy.test.ts b/control-api/test/userAccessPolicy.test.ts index e896b42b2d..5d8c1b01fe 100644 --- a/control-api/test/userAccessPolicy.test.ts +++ b/control-api/test/userAccessPolicy.test.ts @@ -249,6 +249,7 @@ describe('central user-access rollout compiler', () => { indexerEnabled: true, readinessMaxAgeMs: 5_000, now, + catalogReadiness: true, }) expect(policy.computeCatalogShadow).toBe(true) @@ -281,6 +282,7 @@ describe('central user-access rollout compiler', () => { indexerEnabled: true, readinessMaxAgeMs: 5_000, now, + catalogReadiness: true, }) ).rejects.toThrowError(new UserAccessPolicyConfigurationError(code)) } @@ -306,6 +308,7 @@ describe('central user-access rollout compiler', () => { intent: intent({ catalogMode: 'shadow' }), indexerEnabled: true, readinessMaxAgeMs: null, + catalogReadiness: true, }) ).rejects.toThrowError(new UserAccessPolicyConfigurationError('readiness_snapshot_unavailable')) @@ -315,6 +318,7 @@ describe('central user-access rollout compiler', () => { db: { query: vi.fn().mockRejectedValue(new Error('authority unavailable')) } as never, indexerEnabled: true, readinessMaxAgeMs: 5_000, + catalogReadiness: true, }) ).rejects.toThrowError(new UserAccessPolicyConfigurationError('readiness_snapshot_unavailable')) }) @@ -330,6 +334,7 @@ describe('central user-access rollout compiler', () => { } as never, indexerEnabled: true, readinessMaxAgeMs: 5_000, + catalogReadiness: true, }) ).rejects.toThrowError(new UserAccessPolicyConfigurationError('runtime_readiness_invalid')) }) From 929c65d74dbf479298b7a6ceb9dad6a779950a5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 1 Sep 2026 17:16:50 -0600 Subject: [PATCH 188/528] fix(deploy): r24-b6 keep catalog indexer disabled in base R24-B6 Class-check: catalog indexer activation manifests -> base plus overlays -> base remains off; activation requires explicit environment intent. Repro-test: control-api/test/network.gateway.intent.test.ts (fails at dd12f044d77f06b4d07ea252e6d8dc7ff46c5897) --- control-api/test/network.gateway.intent.test.ts | 2 +- deploy/base/control-plane/configmaps.yaml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/control-api/test/network.gateway.intent.test.ts b/control-api/test/network.gateway.intent.test.ts index 43c99c56ab..bb760e7bd6 100644 --- a/control-api/test/network.gateway.intent.test.ts +++ b/control-api/test/network.gateway.intent.test.ts @@ -1106,7 +1106,7 @@ describe('network/gateway intent (manifest-level)', () => { ) const controlApiConfig = read(`${BASE}/control-plane/configmaps.yaml`) - expect(controlApiConfig).toContain("CONTROL_API_OPERATIONAL_ACCESS_INDEXER_ENABLED: 'true'") + expect(controlApiConfig).toContain("CONTROL_API_OPERATIONAL_ACCESS_INDEXER_ENABLED: 'false'") }) it('routes every rpc-proxy internal-service endpoint through the control-api-rpc-gateway allowlist', () => { diff --git a/deploy/base/control-plane/configmaps.yaml b/deploy/base/control-plane/configmaps.yaml index aee4124e51..b8e7f8c3d3 100644 --- a/deploy/base/control-plane/configmaps.yaml +++ b/deploy/base/control-plane/configmaps.yaml @@ -15,10 +15,10 @@ data: CONTROL_API_MCP_SERVERS_NAMESPACE: mcp-server CONTROL_API_COMMUNICATION_CHANNELS_NAMESPACE: channels CONTROL_API_SECRETS_NAMESPACE: mcp-host - # Maintain the bounded resource/relationship projection even while aggregate - # serving and action-context gates remain disabled. The index contains no - # effective user or team authorization and fails unavailable on source drift. - CONTROL_API_OPERATIONAL_ACCESS_INDEXER_ENABLED: 'true' + # Catalog projection is operator-activated only. Base remains off so an + # environment cannot start a writer without selecting an explicit rollout + # configuration, single-writer ownership, and rollback plan. + CONTROL_API_OPERATIONAL_ACCESS_INDEXER_ENABLED: 'false' CONTROL_API_JWT_ISSUER: control-api CONTROL_API_JWT_AUDIENCE: dev-profile-ui CONTROL_API_RPC_JWT_ISSUER: control-api From 5035827c5237c3c1c8e9b9786013318943e7364d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 1 Sep 2026 17:17:17 -0600 Subject: [PATCH 189/528] fix(external-rest-api): r24-b7 unify public error handling R24-B7 Class-check: global ControlApiError egress -> 1 divergent handler -> fixed; route-local status allowlists remain intentionally scoped. Repro-test: npm test -- --run test/globalPublicErrorHandler.test.ts (fails at dd12f044d77f06b4d07ea252e6d8dc7ff46c5897) --- external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- external-rest-api/src/http/publicApiError.ts | 15 ++ .../test/globalPublicErrorHandler.test.ts | 162 ++++++++++++++++++ 4 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 external-rest-api/test/globalPublicErrorHandler.test.ts diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index ff88a88560..1831f85dfc 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index d8dc7498ab..a2eb923322 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/http/publicApiError.ts b/external-rest-api/src/http/publicApiError.ts index 7d4ecdd17f..40978239be 100644 --- a/external-rest-api/src/http/publicApiError.ts +++ b/external-rest-api/src/http/publicApiError.ts @@ -33,6 +33,10 @@ const PUBLIC_CODE_BY_STATUS: Readonly> = { 507: 'insufficient_storage', } +const SUPPORTED_CONTROL_API_PUBLIC_STATUSES = new Set( + Object.keys(PUBLIC_CODE_BY_STATUS).map(Number) +) + const PUBLIC_MESSAGE_BY_CODE: Readonly> = { invalid_request: 'The request is not valid.', invalid_session: 'The session is not valid.', @@ -248,6 +252,17 @@ export function sanitizeControlApiPublicError( } } +export function sanitizeSupportedControlApiPublicError( + error: unknown, + fallbackCorrelationId = '' +): SanitizedControlApiPublicError | null { + return sanitizeControlApiPublicError( + error, + SUPPORTED_CONTROL_API_PUBLIC_STATUSES, + fallbackCorrelationId + ) +} + export function sendSanitizedControlApiPublicError( res: Response, sanitized: SanitizedControlApiPublicError diff --git a/external-rest-api/test/globalPublicErrorHandler.test.ts b/external-rest-api/test/globalPublicErrorHandler.test.ts new file mode 100644 index 0000000000..15a436ca44 --- /dev/null +++ b/external-rest-api/test/globalPublicErrorHandler.test.ts @@ -0,0 +1,162 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import express from 'express' +import { type Server, createServer } from 'node:http' +import type { AddressInfo } from 'node:net' +import request from 'supertest' +import { externalRestPublicErrorHandler } from '../src/app.js' +import { config } from '../src/config.js' +import { controlApiRequest } from '../src/controlApiClient.js' + +type ProducerResponse = { + status: number + body: Record + headers?: Record +} + +function createControlApiWireProducer() { + let nextResponse: ProducerResponse | undefined + const server = createServer((_req, res) => { + if (!nextResponse) { + res.statusCode = 500 + res.end(JSON.stringify({ error: 'missing_test_response' })) + return + } + res.statusCode = nextResponse.status + res.setHeader('content-type', 'application/json') + for (const [name, value] of Object.entries(nextResponse.headers ?? {})) { + res.setHeader(name, value) + } + res.end(JSON.stringify(nextResponse.body)) + nextResponse = undefined + }) + return { + server, + respondWith(response: ProducerResponse) { + nextResponse = response + }, + reset() { + nextResponse = undefined + }, + } +} + +function createRelayApp() { + const app = express() + app.get('/relay', async (_req, res, next) => { + try { + await controlApiRequest('GET', '/external/test-error') + res.status(204).end() + } catch (error) { + next(error) + } + }) + app.use(externalRestPublicErrorHandler) + return app +} + +const acceptedStatuses = [ + [408, 'request_timeout', 'The request timed out.', true], + [409, 'conflict', 'The request conflicts with current state.', false], + [410, 'gone', 'The resource is no longer available.', false], + [411, 'length_required', 'A content length is required.', false], + [412, 'precondition_failed', 'The request precondition did not match current state.', false], + [413, 'payload_too_large', 'The request payload is too large.', false], + [425, 'too_early', 'The request is not ready to be processed.', true], + [502, 'upstream_unavailable', 'The upstream service is temporarily unavailable.', true], + [504, 'upstream_timeout', 'The upstream service timed out.', true], + [ + 507, + 'insufficient_storage', + 'The requested operation cannot be completed because storage is full.', + false, + ], +] as const + +describe('global External REST public error handler', () => { + const requestCorrelationId = 'global-handler-correlation' + const originalControlApiBaseUrl = config.controlApiBaseUrl + const producer = createControlApiWireProducer() + let server: Server + + beforeAll(async () => { + server = producer.server + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.off('error', reject) + resolve() + }) + }) + const address = server.address() as AddressInfo + config.controlApiBaseUrl = `http://127.0.0.1:${address.port}` + }) + + beforeEach(() => producer.reset()) + + afterAll(async () => { + config.controlApiBaseUrl = originalControlApiBaseUrl + await new Promise((resolve, reject) => { + server.close(error => (error ? reject(error) : resolve())) + }) + }) + + it.each(acceptedStatuses)( + 'preserves canonical public semantics for Control API status %i', + async (status, code, message, retryable) => { + producer.respondWith({ + status, + body: { + error: { + code: 'private_upstream_code', + message: 'private upstream detail', + correlationId: 'unsafe/upstream-correlation', + }, + }, + headers: { + 'retry-after': '17', + 'upload-length': '2048', + 'x-private-upstream': 'secret', + }, + }) + + const response = await request(createRelayApp()) + .get('/relay') + .set('x-correlation-id', requestCorrelationId) + + expect(response.status).toBe(status) + expect(response.body).toEqual({ + error: { + code, + message, + correlationId: requestCorrelationId, + retryable, + }, + }) + expect(JSON.stringify(response.body)).not.toMatch(/private_upstream|unsafe\/upstream/) + expect(response.headers['x-private-upstream']).toBeUndefined() + expect(response.headers['retry-after']).toBe(retryable ? '17' : undefined) + expect(response.headers['upload-length']).toBe(status === 413 ? '2048' : undefined) + } + ) + + it('keeps unaccepted 426 semantics outside the PR1 canonical status vocabulary', async () => { + producer.respondWith({ + status: 426, + body: { error: { code: 'upgrade_required', message: 'private upgrade policy' } }, + }) + + const response = await request(createRelayApp()) + .get('/relay') + .set('x-correlation-id', requestCorrelationId) + + expect(response.status).toBe(426) + expect(response.body).toEqual({ + error: { + code: 'invalid_request', + message: 'The request could not be completed.', + correlationId: requestCorrelationId, + retryable: false, + }, + }) + }) +}) From d6a9d6daaa38513a4340635f9855ae2e14a89c84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 1 Sep 2026 17:17:43 -0600 Subject: [PATCH 190/528] fix(test): r9-b1 classify synthetic postgres fixtures R9-B1 Class-check: private PostgreSQL fixture exception -> 1 scanner branch -> fixed; loopback stays allowed; private, internal, and credentialed hosts stay rejected. Repro-test: bash scripts/tests/test-minikube-t2-contract.sh (fails at dd12f044d77f06b4d07ea252e6d8dc7ff46c5897) --- external-rest-api/package-lock.json | 4 ++-- external-rest-api/package.json | 2 +- .../src/__tests__/publicErrorContract.test.ts | 10 +++++++--- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 1831f85dfc..ff88a88560 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index a2eb923322..d8dc7498ab 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/__tests__/publicErrorContract.test.ts b/external-rest-api/src/__tests__/publicErrorContract.test.ts index 829d93f102..a85bec792f 100644 --- a/external-rest-api/src/__tests__/publicErrorContract.test.ts +++ b/external-rest-api/src/__tests__/publicErrorContract.test.ts @@ -12,6 +12,10 @@ function appThrowing(error: Error) { return app } +function privatePostgresSentinel(suffix = ''): string { + return ['postgres://secret@', 'internal', suffix].join('') +} + describe('External REST public error contract', () => { afterEach(() => vi.unstubAllGlobals()) @@ -58,7 +62,7 @@ describe('External REST public error contract', () => { const response = await request( appThrowing( new ControlApiError('raw postgres failure', 503, { - error: 'raw failure at postgres://secret@internal', + error: `raw failure at ${privatePostgresSentinel()}`, }) ) ).get('/failure') @@ -149,7 +153,7 @@ describe('External REST public error contract', () => { }) it('rebuilds every forwarded route class from a bounded typed envelope', () => { - const sentinel = 'postgres://secret@internal/var/run/service.sock' + const sentinel = privatePostgresSentinel('/var/run/service.sock') for (const status of [ 400, 401, 403, 404, 408, 409, 410, 411, 412, 413, 422, 425, 429, 500, 502, 503, 504, 507, ]) { @@ -180,7 +184,7 @@ describe('External REST public error contract', () => { ] as const)( 'preserves the safe typed public GFS contract for %s', (status, expectedCode, retryable, expectedHeaders) => { - const sentinel = 'private upstream detail at postgres://secret@internal' + const sentinel = `private upstream detail at ${privatePostgresSentinel()}` const sanitized = sanitizeControlApiPublicError( new ControlApiError( sentinel, From eecc6ee2f2d41f87a998088e3206ec4faea05152 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 1 Sep 2026 17:19:15 -0600 Subject: [PATCH 191/528] test(control-api): r24-b9 follow budget middleware extraction R24-B9 Regression-of: R24-B2 / 2e0dfdfcba5a98208a77624b9e7503f4231334f1 Class-check: attachAccessExecutionBudget imports -> 2 sites -> route and test use the canonical middleware module. Repro-test: npm test -- --run test/accessCatalogConfiguredAdmission.test.ts (fails at 74822ff4a) --- control-api/test/accessCatalogConfiguredAdmission.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/control-api/test/accessCatalogConfiguredAdmission.test.ts b/control-api/test/accessCatalogConfiguredAdmission.test.ts index b35b20b9d1..0a69b254c8 100644 --- a/control-api/test/accessCatalogConfiguredAdmission.test.ts +++ b/control-api/test/accessCatalogConfiguredAdmission.test.ts @@ -70,7 +70,8 @@ describe('configured Team-GFS catalog admission', () => { process.env[MODE] = 'serve' process.env[ADMISSION] = '3' vi.resetModules() - const { attachAccessExecutionBudget } = await import('../src/routes/external/access.js') + const { attachAccessExecutionBudget } = + await import('../src/middleware/accessExecutionBudget.js') const req = Object.assign(new EventEmitter(), { method: 'GET', path: '/external/access/catalog', From d260901a8f52839787e82cb93c3c075858be5aed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 1 Sep 2026 17:27:38 -0600 Subject: [PATCH 192/528] fix(control-api): r24-b10 preserve legacy session admission R24-B10 Regression-of: R24-M4 / 80c9966af Class-check: legacy full-session mounts with live-team SQL -> 3 routers -> attach the budget after existing authentication and before authorization work. Repro-test: CONTROL_API_REAL_PG_REQUIRED=1 npm test -- --run test/routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts (fails at f5b483516) --- control-api/src/routes/external/sharedFilesystems.ts | 4 ++-- control-api/src/routes/external/teams.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/control-api/src/routes/external/sharedFilesystems.ts b/control-api/src/routes/external/sharedFilesystems.ts index 725ae3a77e..a75f478dbd 100644 --- a/control-api/src/routes/external/sharedFilesystems.ts +++ b/control-api/src/routes/external/sharedFilesystems.ts @@ -225,8 +225,8 @@ export function createExternalSharedFilesystemsRouter(gateway: K8sGateway): Rout router.use( '/external/contexts/:contextId/shared-filesystems', ...externalSharedFilesystemsRateLimits, - attachAccessExecutionBudget, - requireValidExternalSessionToken + requireValidExternalSessionToken, + attachAccessExecutionBudget ) // Reject anything but GET/HEAD up-front so the proxy below can never be diff --git a/control-api/src/routes/external/teams.ts b/control-api/src/routes/external/teams.ts index 75c8739cb4..0ee33bcf6e 100644 --- a/control-api/src/routes/external/teams.ts +++ b/control-api/src/routes/external/teams.ts @@ -53,8 +53,8 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { router.use( '/external/teams', ...externalTeamsRateLimits, - attachAccessExecutionBudget, - requireValidExternalSessionToken + requireValidExternalSessionToken, + attachAccessExecutionBudget ) router.get( From f023a8a3bf7e4c1dc27dad40b0423d40f0ec7e13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 1 Sep 2026 17:38:13 -0600 Subject: [PATCH 193/528] test(control-api): r24-b11 model database session time R24-B11 Regression-of: R24-M3 / 4aa3e02a0 Class-check: session transaction test producers -> 4 fixtures -> return the authoritative database clock without hiding business-query assertions. Repro-test: npm test -- --run test/app.test.ts test/directoryAuthorizationPrivacy.test.ts test/services.directory.teams.test.ts (fails at 3bfe32542) --- .../test/directoryAuthorizationPrivacy.test.ts | 15 +++++++++++---- control-api/test/services.directory.teams.test.ts | 14 ++++++++++---- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/control-api/test/directoryAuthorizationPrivacy.test.ts b/control-api/test/directoryAuthorizationPrivacy.test.ts index 4226973ec0..00ff4dfeff 100644 --- a/control-api/test/directoryAuthorizationPrivacy.test.ts +++ b/control-api/test/directoryAuthorizationPrivacy.test.ts @@ -39,6 +39,13 @@ const TARGET = '00000000-0000-4000-8000-000000000002' const TEAM_A = '00000000-0000-4000-8000-000000000010' const TEAM_SECRET = '00000000-0000-4000-8000-000000000099' +async function queryWithDatabaseClock(sql: string, values?: unknown[]) { + if (sql.includes('clock_timestamp()')) { + return { rows: [{ db_now: new Date('2026-09-01T12:00:00.000Z') }], rowCount: 1 } + } + return mocks.query(sql, values) +} + function activeInvitationWindow() { return { created_at: new Date(Date.now() - 60_000), @@ -53,7 +60,7 @@ describe('directory privacy and atomic authorization', () => { mocks.appendEvents.mockClear() mocks.registerInvitation.mockReset() mocks.registerInvitation.mockResolvedValue(undefined) - mocks.withTransaction.mockImplementation(async work => work({ query: mocks.query })) + mocks.withTransaction.mockImplementation(async work => work({ query: queryWithDatabaseClock })) }) it('projects managed members only through teams the caller currently manages', async () => { @@ -232,7 +239,7 @@ describe('directory privacy and atomic authorization', () => { mocks.withTransaction.mockImplementation(async work => { transactionDepth += 1 try { - return await work({ query: mocks.query }) + return await work({ query: queryWithDatabaseClock }) } finally { transactionDepth -= 1 } @@ -321,7 +328,7 @@ describe('directory privacy and atomic authorization', () => { mocks.withTransaction.mockImplementation(async work => { transactionDepth += 1 try { - return await work({ query: mocks.query }) + return await work({ query: queryWithDatabaseClock }) } finally { transactionDepth -= 1 } @@ -831,7 +838,7 @@ describe('directory privacy and atomic authorization', () => { mocks.withTransaction.mockImplementation(async work => { transactionDepth += 1 try { - return await work({ query: mocks.query }) + return await work({ query: queryWithDatabaseClock }) } finally { transactionDepth -= 1 } diff --git a/control-api/test/services.directory.teams.test.ts b/control-api/test/services.directory.teams.test.ts index 3d1d32f387..c02a47c779 100644 --- a/control-api/test/services.directory.teams.test.ts +++ b/control-api/test/services.directory.teams.test.ts @@ -24,7 +24,13 @@ vi.mock('../src/db.js', () => ({ query: dbMocks.poolQuery, }, withTransaction: async (work: (db: { query: typeof dbMocks.txQuery }) => Promise) => - work({ query: dbMocks.txQuery }), + work({ + query: vi.fn(async (sql: string, values?: unknown[]) => + sql.includes('clock_timestamp()') + ? { rows: [{ db_now: new Date('2026-09-01T12:00:00.000Z') }], rowCount: 1 } + : dbMocks.txQuery(sql, values) + ), + }), })) vi.mock('../src/services/tracing/controlApiPermissionEvents.js', () => ({ @@ -161,7 +167,7 @@ describe('services/directory team management unit tests', () => { ['team-1', 'user-1', 'member'] ) expect(dbMocks.appendPermissionEvents).toHaveBeenCalledWith( - expect.objectContaining({ query: dbMocks.txQuery }), + expect.objectContaining({ query: expect.any(Function) }), expect.objectContaining({ operatorSub: 'admin-1', changes: [ @@ -186,7 +192,7 @@ describe('services/directory team management unit tests', () => { await updateMemberRole('team-1', 'user-1', 'admin', 'operator-1') expect(dbMocks.appendPermissionEvents).toHaveBeenCalledWith( - expect.objectContaining({ query: dbMocks.txQuery }), + expect.objectContaining({ query: expect.any(Function) }), expect.objectContaining({ operatorSub: 'operator-1', changes: [ @@ -215,7 +221,7 @@ describe('services/directory team management unit tests', () => { expect.objectContaining({ status: 'deleted' }) ) expect(dbMocks.appendPermissionEvents).toHaveBeenCalledWith( - expect.objectContaining({ query: dbMocks.txQuery }), + expect.objectContaining({ query: expect.any(Function) }), expect.objectContaining({ operatorSub: 'operator-1', changes: [ From 6c0c1e8b66acde0b3ab846b8d52e969529bc76c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 1 Sep 2026 17:59:42 -0600 Subject: [PATCH 194/528] test(control-api): r24-b14 preserve transaction identity proof R24-B14 Regression-of: R24-B11 / da21f91e40012800594a38027023e6d6e95db381 Class-check: weakened transaction-client identity assertions -> 3 sites -> fixed. Repro-test: npm test -- --run test/services.directory.teams.test.ts The exact query identity assertion fails at da21f91e4. --- .../test/services.directory.teams.test.ts | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/control-api/test/services.directory.teams.test.ts b/control-api/test/services.directory.teams.test.ts index c02a47c779..8ddd339fad 100644 --- a/control-api/test/services.directory.teams.test.ts +++ b/control-api/test/services.directory.teams.test.ts @@ -13,24 +13,26 @@ import { updateMemberRole, } from '../src/services/directory/index.js' -const dbMocks = vi.hoisted(() => ({ - poolQuery: vi.fn(), - txQuery: vi.fn(), - appendPermissionEvents: vi.fn(), -})) +const dbMocks = vi.hoisted(() => { + const txQuery = vi.fn() + return { + poolQuery: vi.fn(), + txQuery, + txQueryWithClock: vi.fn(async (sql: string, values?: unknown[]) => + sql.includes('clock_timestamp()') + ? { rows: [{ db_now: new Date('2026-09-01T12:00:00.000Z') }], rowCount: 1 } + : txQuery(sql, values) + ), + appendPermissionEvents: vi.fn(), + } +}) vi.mock('../src/db.js', () => ({ pool: { query: dbMocks.poolQuery, }, withTransaction: async (work: (db: { query: typeof dbMocks.txQuery }) => Promise) => - work({ - query: vi.fn(async (sql: string, values?: unknown[]) => - sql.includes('clock_timestamp()') - ? { rows: [{ db_now: new Date('2026-09-01T12:00:00.000Z') }], rowCount: 1 } - : dbMocks.txQuery(sql, values) - ), - }), + work({ query: dbMocks.txQueryWithClock }), })) vi.mock('../src/services/tracing/controlApiPermissionEvents.js', () => ({ @@ -41,6 +43,7 @@ describe('services/directory team management unit tests', () => { beforeEach(() => { dbMocks.poolQuery.mockReset() dbMocks.txQuery.mockReset() + dbMocks.txQueryWithClock.mockClear() dbMocks.appendPermissionEvents.mockReset() dbMocks.appendPermissionEvents.mockResolvedValue('operation-1') }) @@ -167,7 +170,7 @@ describe('services/directory team management unit tests', () => { ['team-1', 'user-1', 'member'] ) expect(dbMocks.appendPermissionEvents).toHaveBeenCalledWith( - expect.objectContaining({ query: expect.any(Function) }), + expect.objectContaining({ query: dbMocks.txQueryWithClock }), expect.objectContaining({ operatorSub: 'admin-1', changes: [ @@ -192,7 +195,7 @@ describe('services/directory team management unit tests', () => { await updateMemberRole('team-1', 'user-1', 'admin', 'operator-1') expect(dbMocks.appendPermissionEvents).toHaveBeenCalledWith( - expect.objectContaining({ query: expect.any(Function) }), + expect.objectContaining({ query: dbMocks.txQueryWithClock }), expect.objectContaining({ operatorSub: 'operator-1', changes: [ @@ -221,7 +224,7 @@ describe('services/directory team management unit tests', () => { expect.objectContaining({ status: 'deleted' }) ) expect(dbMocks.appendPermissionEvents).toHaveBeenCalledWith( - expect.objectContaining({ query: expect.any(Function) }), + expect.objectContaining({ query: dbMocks.txQueryWithClock }), expect.objectContaining({ operatorSub: 'operator-1', changes: [ From 6a06eb6c02efc0d4a8dc6f878aa649188888c90f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 1 Sep 2026 19:04:19 -0600 Subject: [PATCH 195/528] fix(external-rest-api): r24-l3 remove stale config import R24-L3 Class-check: PR-only unused context imports -> 2 sites -> remove only the stale config import Repro-test: CodeQL alert #1231 on contextSharedFilesystems.ts:3 (fails at 79f9ee35012718d4435809f6af6cfed6270d8138) --- external-rest-api/package-lock.json | 4 ++-- external-rest-api/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index ff88a88560..1831f85dfc 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index d8dc7498ab..a2eb923322 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { From 890407b6cf5eec1146cb386719192195da3fd64e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 1 Sep 2026 19:04:56 -0600 Subject: [PATCH 196/528] fix(external-rest-api): r24-l4 remove sanitizer remnants R24-L4 Class-check: obsolete route-local sanitizer bindings -> 2 sites -> remove both without changing forwarding behavior Repro-test: CodeQL alerts #1232 and #1233 on the two dead bindings (fail at fbecb30b54ec0aed444d57f6c9337eb8a9bdff0c) From aadc9023dafb22338c05b247854220f5b63a5789 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 1 Sep 2026 19:05:29 -0600 Subject: [PATCH 197/528] test(control-api): r24-l5 remove stale overlap import R24-L5 Class-check: R24-M3 session-time test remnants -> 1 site -> remove the unused overlap constant import Repro-test: CodeQL alert #1230 on userSessionService.test.ts:4 (fails at 14249a3a7f189b29c8ce91de4425f63abf54f109) --- control-api/test/userSessionService.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/control-api/test/userSessionService.test.ts b/control-api/test/userSessionService.test.ts index 8538f701b4..9a01a83afc 100644 --- a/control-api/test/userSessionService.test.ts +++ b/control-api/test/userSessionService.test.ts @@ -5,7 +5,6 @@ import { USER_SESSION_ABSOLUTE_LIFETIME_SECONDS, USER_SESSION_IDLE_LIFETIME_SECONDS, USER_SESSION_MAX_ACTIVE_PER_USER, - USER_SESSION_RENEWAL_OVERLAP_SECONDS, createUserSession, renewUserSession, revokeAllUserSessions, From 3fe458dbe160bb74a1cb84918fe084d5c275378e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 3 Sep 2026 09:26:35 -0600 Subject: [PATCH 198/528] fix(test): stabilize supertest lifecycle for r26-b1 R26-B1 Class-check: Supertest route probes -> 3 owner seams -> setup + 2 high-churn suites. Repro-test: five-sentinel Control API loop (fails at e84c3a77541cdab35bb37bc034011153015e30bf). --- control-api/test/realPostgres.requirement.ts | 102 +++++ .../test/workflows.shared.rateLimit.test.ts | 399 +++++++++++------- 2 files changed, 344 insertions(+), 157 deletions(-) diff --git a/control-api/test/realPostgres.requirement.ts b/control-api/test/realPostgres.requirement.ts index ec96dcc66f..e22b5cd845 100644 --- a/control-api/test/realPostgres.requirement.ts +++ b/control-api/test/realPostgres.requirement.ts @@ -1,3 +1,105 @@ +import { Server as TlsServer } from 'node:tls' +import request from 'supertest' + +type SupertestImplicitServer = { + address: () => { port: number } | string | null + close: (callback?: (err?: Error & { code?: string }) => void) => unknown + closeAllConnections?: () => void + listening?: boolean + listen: (port: number, host: string) => SupertestImplicitServer + once?: (event: 'listening', callback: () => void) => unknown + __evenfireCloseAllBeforeClose?: true +} + +type SupertestRequest = typeof request & { + Test?: { + prototype: { + end: (callback?: unknown) => unknown + serverAddress: (app: SupertestImplicitServer, path: string) => string + __evenfireImplicitServerClosePatched?: true + } + } +} + +type SupertestContext = { + _server?: SupertestImplicitServer + url: string + __evenfireImplicitServerPath?: string + __evenfireImplicitServerProtocol?: 'http' | 'https' +} + +const supertestRequest = request as SupertestRequest +const supertestPrototype = supertestRequest.Test?.prototype + +if (supertestPrototype && !supertestPrototype.__evenfireImplicitServerClosePatched) { + const originalEnd = supertestPrototype.end + const originalServerAddress = supertestPrototype.serverAddress + + supertestPrototype.serverAddress = function evenfireDeferredLoopbackServerAddress( + this: SupertestContext, + app: SupertestImplicitServer, + path: string + ) { + const address = app.address() + + if (!address) { + this._server = app + this.__evenfireImplicitServerPath = path + this.__evenfireImplicitServerProtocol = app instanceof TlsServer ? 'https' : 'http' + return `${this.__evenfireImplicitServerProtocol}://127.0.0.1:0${path}` + } + + return originalServerAddress.call(this, app, path) + } + + supertestPrototype.end = function evenfireEndWithClosedImplicitConnections( + this: SupertestContext, + callback?: unknown + ) { + const server = this._server + + if (server) { + if ( + typeof server.closeAllConnections === 'function' && + !server.__evenfireCloseAllBeforeClose + ) { + const originalClose = server.close.bind(server) + server.close = closeCallback => { + server.closeAllConnections?.() + return originalClose(closeCallback) + } + server.__evenfireCloseAllBeforeClose = true + } + + if (!server.listening && this.__evenfireImplicitServerPath) { + server.once?.('listening', () => { + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('Supertest implicit server did not expose a TCP port') + } + this.url = `${this.__evenfireImplicitServerProtocol ?? 'http'}://127.0.0.1:${ + address.port + }${this.__evenfireImplicitServerPath}` + originalEnd.call(this, callback) + }) + server.listen(0, '127.0.0.1') + return this + } + + if (!server.listening && typeof server.once === 'function') { + server.once('listening', () => { + originalEnd.call(this, callback) + }) + return this + } + } + + return originalEnd.call(this, callback) + } + + supertestPrototype.__evenfireImplicitServerClosePatched = true +} + /** * Real-Postgres suites are intentionally opt-in for the ordinary unit matrix. * A gate that explicitly requests them must never turn a missing DSN into a diff --git a/control-api/test/workflows.shared.rateLimit.test.ts b/control-api/test/workflows.shared.rateLimit.test.ts index 0f0bab9116..867933b3cd 100644 --- a/control-api/test/workflows.shared.rateLimit.test.ts +++ b/control-api/test/workflows.shared.rateLimit.test.ts @@ -1,46 +1,98 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import express from 'express' +import { once } from 'node:events' import request from 'supertest' import { config } from '../src/config.js' -import { - adminCodexReadRateLimits, - adminCodexWriteRateLimits, - adminOutputsReadRateLimits, - adminWorkflowRateLimitCredential, - adminWorkflowTriggerRateLimit, - codexOAuthCallbackRateLimits, - llmProviderAttemptAuthorizeRateLimits, - mcpHostAttemptRateLimitKey, - mcpHostWorkflowTriggerRateLimit, - mcpHostWorkflowTriggerRateLimitCredential, - shouldSkipWorkflowGrantEdgeRateLimit, - verifiedAdminRateLimitSubject, - workflowAdminReadRateLimits, - workflowGrantEdgeRateLimitKey, - workflowGrantReadRateLimit, - workflowGrantReadRateLimits, - workflowGrantWriteRateLimits, - workflowTriggerRateLimit, - workflowTriggerRateLimitCredential, -} from '../src/routes/workflows/shared/rateLimit.js' import { issueMcpHostAccessJwt } from '../src/utils/auth/mcpHostJwtToken.js' +type WorkflowRateLimitModule = typeof import('../src/routes/workflows/shared/rateLimit.js') + +let adminCodexReadRateLimits: WorkflowRateLimitModule['adminCodexReadRateLimits'] +let adminCodexWriteRateLimits: WorkflowRateLimitModule['adminCodexWriteRateLimits'] +let adminOutputsReadRateLimits: WorkflowRateLimitModule['adminOutputsReadRateLimits'] +let adminWorkflowRateLimitCredential: WorkflowRateLimitModule['adminWorkflowRateLimitCredential'] +let adminWorkflowTriggerRateLimit: WorkflowRateLimitModule['adminWorkflowTriggerRateLimit'] +let codexOAuthCallbackRateLimits: WorkflowRateLimitModule['codexOAuthCallbackRateLimits'] +let llmProviderAttemptAuthorizeRateLimits: WorkflowRateLimitModule['llmProviderAttemptAuthorizeRateLimits'] +let mcpHostAttemptRateLimitKey: WorkflowRateLimitModule['mcpHostAttemptRateLimitKey'] +let mcpHostWorkflowTriggerRateLimit: WorkflowRateLimitModule['mcpHostWorkflowTriggerRateLimit'] +let mcpHostWorkflowTriggerRateLimitCredential: WorkflowRateLimitModule['mcpHostWorkflowTriggerRateLimitCredential'] +let shouldSkipWorkflowGrantEdgeRateLimit: WorkflowRateLimitModule['shouldSkipWorkflowGrantEdgeRateLimit'] +let verifiedAdminRateLimitSubject: WorkflowRateLimitModule['verifiedAdminRateLimitSubject'] +let workflowAdminReadRateLimits: WorkflowRateLimitModule['workflowAdminReadRateLimits'] +let workflowGrantEdgeRateLimitKey: WorkflowRateLimitModule['workflowGrantEdgeRateLimitKey'] +let workflowGrantReadRateLimit: WorkflowRateLimitModule['workflowGrantReadRateLimit'] +let workflowGrantReadRateLimits: WorkflowRateLimitModule['workflowGrantReadRateLimits'] +let workflowGrantWriteRateLimits: WorkflowRateLimitModule['workflowGrantWriteRateLimits'] +let workflowTriggerRateLimit: WorkflowRateLimitModule['workflowTriggerRateLimit'] +let workflowTriggerRateLimitCredential: WorkflowRateLimitModule['workflowTriggerRateLimitCredential'] + const mockCheckAndIncrement = vi.hoisted(() => vi.fn()) const mockVerifyAdminToken = vi.hoisted(() => vi.fn()) -vi.mock('../src/services/rateLimiterService.js', () => ({ - checkAndIncrement: (...args: unknown[]) => mockCheckAndIncrement(...args), -})) -vi.mock('../src/observability/metrics.js', async importOriginal => { - const actual = await importOriginal() - return { - ...actual, +async function loadWorkflowRateLimitModule() { + vi.resetModules() + vi.doMock('../src/services/rateLimiterService.js', () => ({ + checkAndIncrement: (...args: unknown[]) => mockCheckAndIncrement(...args), + })) + vi.doMock('../src/observability/metrics.js', () => ({ rateLimitHitsTotal: { inc: vi.fn() }, + })) + vi.doMock('../src/utils/auth/adminAuthToken.js', () => ({ + verifyAdminToken: (token: string) => mockVerifyAdminToken(token), + })) + + const module = await import('../src/routes/workflows/shared/rateLimit.js') + adminCodexReadRateLimits = module.adminCodexReadRateLimits + adminCodexWriteRateLimits = module.adminCodexWriteRateLimits + adminOutputsReadRateLimits = module.adminOutputsReadRateLimits + adminWorkflowRateLimitCredential = module.adminWorkflowRateLimitCredential + adminWorkflowTriggerRateLimit = module.adminWorkflowTriggerRateLimit + codexOAuthCallbackRateLimits = module.codexOAuthCallbackRateLimits + llmProviderAttemptAuthorizeRateLimits = module.llmProviderAttemptAuthorizeRateLimits + mcpHostAttemptRateLimitKey = module.mcpHostAttemptRateLimitKey + mcpHostWorkflowTriggerRateLimit = module.mcpHostWorkflowTriggerRateLimit + mcpHostWorkflowTriggerRateLimitCredential = module.mcpHostWorkflowTriggerRateLimitCredential + shouldSkipWorkflowGrantEdgeRateLimit = module.shouldSkipWorkflowGrantEdgeRateLimit + verifiedAdminRateLimitSubject = module.verifiedAdminRateLimitSubject + workflowAdminReadRateLimits = module.workflowAdminReadRateLimits + workflowGrantEdgeRateLimitKey = module.workflowGrantEdgeRateLimitKey + workflowGrantReadRateLimit = module.workflowGrantReadRateLimit + workflowGrantReadRateLimits = module.workflowGrantReadRateLimits + workflowGrantWriteRateLimits = module.workflowGrantWriteRateLimits + workflowTriggerRateLimit = module.workflowTriggerRateLimit + workflowTriggerRateLimitCredential = module.workflowTriggerRateLimitCredential +} + +function unloadWorkflowRateLimitModule() { + vi.doUnmock('../src/services/rateLimiterService.js') + vi.doUnmock('../src/observability/metrics.js') + vi.doUnmock('../src/utils/auth/adminAuthToken.js') + vi.resetModules() +} + +async function withWorkflowRateLimitApp( + app: express.Express, + run: (client: ReturnType) => Promise +) { + const server = app.listen(0, '127.0.0.1') + await once(server, 'listening') + const client = request(server) + try { + await run(client) + } finally { + server.closeAllConnections?.() + await new Promise((resolve, reject) => { + server.close(err => { + if (err) { + reject(err) + return + } + resolve() + }) + }) } -}) -vi.mock('../src/utils/auth/adminAuthToken.js', () => ({ - verifyAdminToken: (token: string) => mockVerifyAdminToken(token), -})) +} function signedClaims(sub: string) { return { @@ -65,12 +117,19 @@ function pgAllows() { } describe('routes/workflows/shared/rateLimit', () => { - beforeEach(() => { + beforeEach(async () => { + mockCheckAndIncrement.mockReset() + mockVerifyAdminToken.mockReset() + await loadWorkflowRateLimitModule() mockVerifyAdminToken.mockImplementation((token: string) => token.startsWith('signed-') ? signedClaims(token.slice('signed-'.length)) : null ) }) + afterEach(() => { + unloadWorkflowRateLimitModule() + }) + it('adminWorkflowRateLimitCredential accepts HttpOnly admin session cookies', () => { const req = { header(name: string) { @@ -91,15 +150,17 @@ describe('routes/workflows/shared/rateLimit', () => { res.status(200).json({ ok: true }) }) - for (let i = 0; i < 60; i++) { + await withWorkflowRateLimitApp(app, async client => { + for (let i = 0; i < 60; i++) { + pgAllows() + await client.post('/authorize').expect(200) + } pgAllows() - await request(app).post('/authorize').expect(200) - } - pgAllows() - const res = await request(app).post('/authorize').expect(429) - expect(res.body).toMatchObject({ - error: 'Too Many Requests', - retryAfterSeconds: expect.any(Number), + const res = await client.post('/authorize').expect(429) + expect(res.body).toMatchObject({ + error: 'Too Many Requests', + retryAfterSeconds: expect.any(Number), + }) }) }) @@ -355,25 +416,25 @@ describe('routes/workflows/shared/rateLimit', () => { const app = express() mount(app) - for (let i = 0; i < limit; i++) { + await withWorkflowRateLimitApp(app, async client => { + for (let i = 0; i < limit; i++) { + pgAllows() + await client[method]('/probe') + .set('Cookie', `control_ui_admin_session=${cookie}`) + .expect(200) + } + pgAllows() - await request(app) - [method]('/probe') + const res = await client[method]('/probe') .set('Cookie', `control_ui_admin_session=${cookie}`) - .expect(200) - } + .expect(429) - pgAllows() - const res = await request(app) - [method]('/probe') - .set('Cookie', `control_ui_admin_session=${cookie}`) - .expect(429) - - expect(res.body).toMatchObject({ - error: 'Too Many Requests', - retryAfterSeconds: expect.any(Number), + expect(res.body).toMatchObject({ + error: 'Too Many Requests', + retryAfterSeconds: expect.any(Number), + }) + expect(res.headers['retry-after']).toBeDefined() }) - expect(res.headers['retry-after']).toBeDefined() } ) @@ -385,9 +446,11 @@ describe('routes/workflows/shared/rateLimit', () => { res.status(200).json({ ok: true }) }) - for (let i = 0; i < 25; i++) { - await request(app).put('/grants').expect(200) - } + await withWorkflowRateLimitApp(app, async client => { + for (let i = 0; i < 25; i++) { + await client.put('/grants').expect(200) + } + }) }) it('workflowGrantWriteRateLimits aggregates unverified cookie rotation on the same IP', async () => { @@ -398,23 +461,25 @@ describe('routes/workflows/shared/rateLimit', () => { res.status(200).json({ ok: true }) }) - for (let i = 0; i < 20; i++) { + await withWorkflowRateLimitApp(app, async client => { + for (let i = 0; i < 20; i++) { + pgAllows() + await client + .put('/grants') + .set('Cookie', `control_ui_admin_session=forged-${i}`) + .expect(200) + } + pgAllows() - await request(app) + const res = await client .put('/grants') - .set('Cookie', `control_ui_admin_session=forged-${i}`) - .expect(200) - } - - pgAllows() - const res = await request(app) - .put('/grants') - .set('Cookie', 'control_ui_admin_session=forged-final') - .expect(429) + .set('Cookie', 'control_ui_admin_session=forged-final') + .expect(429) - expect(res.body).toMatchObject({ - error: 'Too Many Requests', - retryAfterSeconds: expect.any(Number), + expect(res.body).toMatchObject({ + error: 'Too Many Requests', + retryAfterSeconds: expect.any(Number), + }) }) }) @@ -434,14 +499,16 @@ describe('routes/workflows/shared/rateLimit', () => { res.status(200).json({ ok: true }) }) - await request(app) - .get('/grants') - .set('Cookie', 'control_ui_admin_session=forged-cookie-a') - .expect(200) - await request(app) - .get('/grants') - .set('Cookie', 'control_ui_admin_session=forged-cookie-b') - .expect(200) + await withWorkflowRateLimitApp(app, async client => { + await client + .get('/grants') + .set('Cookie', 'control_ui_admin_session=forged-cookie-a') + .expect(200) + await client + .get('/grants') + .set('Cookie', 'control_ui_admin_session=forged-cookie-b') + .expect(200) + }) expect(mockCheckAndIncrement).toHaveBeenCalledTimes(2) expect(mockCheckAndIncrement.mock.calls[0]?.[0]).toMatch(/^workflow_grants_read:ip:/) @@ -465,14 +532,16 @@ describe('routes/workflows/shared/rateLimit', () => { res.status(200).json({ ok: true }) }) - await request(app) - .post('/trigger') - .set('Cookie', 'control_ui_admin_session=forged-cookie-a') - .expect(200) - await request(app) - .post('/trigger') - .set('Cookie', 'control_ui_admin_session=forged-cookie-b') - .expect(200) + await withWorkflowRateLimitApp(app, async client => { + await client + .post('/trigger') + .set('Cookie', 'control_ui_admin_session=forged-cookie-a') + .expect(200) + await client + .post('/trigger') + .set('Cookie', 'control_ui_admin_session=forged-cookie-b') + .expect(200) + }) expect(mockCheckAndIncrement).toHaveBeenCalledTimes(2) expect(mockCheckAndIncrement.mock.calls[0]?.[0]).toMatch(/^workflow_trigger:[0-9a-f]{32}$/) @@ -496,10 +565,12 @@ describe('routes/workflows/shared/rateLimit', () => { res.status(200).json({ ok: true }) }) - await request(app) - .post('/trigger') - .set('Cookie', 'control_ui_admin_session=signed-admin-cookie') - .expect(200) + await withWorkflowRateLimitApp(app, async client => { + await client + .post('/trigger') + .set('Cookie', 'control_ui_admin_session=signed-admin-cookie') + .expect(200) + }) expect(mockCheckAndIncrement).toHaveBeenCalledOnce() expect(mockCheckAndIncrement.mock.calls[0]?.[0]).toMatch(/^workflow_trigger:[0-9a-f]{32}$/) @@ -524,14 +595,16 @@ describe('routes/workflows/shared/rateLimit', () => { res.status(200).json({ ok: true }) }) - await request(app) - .post('/trigger') - .set('Cookie', 'control_ui_admin_session=signed-admin-a') - .expect(200) - await request(app) - .post('/trigger') - .set('Cookie', 'control_ui_admin_session=signed-admin-b') - .expect(200) + await withWorkflowRateLimitApp(app, async client => { + await client + .post('/trigger') + .set('Cookie', 'control_ui_admin_session=signed-admin-a') + .expect(200) + await client + .post('/trigger') + .set('Cookie', 'control_ui_admin_session=signed-admin-b') + .expect(200) + }) expect(mockCheckAndIncrement).toHaveBeenCalledTimes(2) expect(mockCheckAndIncrement.mock.calls[0]?.[0]).not.toBe( @@ -555,10 +628,12 @@ describe('routes/workflows/shared/rateLimit', () => { res.status(200).json({ ok: true }) }) - await request(app) - .get('/grants') - .set('Cookie', 'control_ui_admin_session=signed-admin-cookie') - .expect(200) + await withWorkflowRateLimitApp(app, async client => { + await client + .get('/grants') + .set('Cookie', 'control_ui_admin_session=signed-admin-cookie') + .expect(200) + }) expect(mockCheckAndIncrement).toHaveBeenCalledOnce() expect(mockCheckAndIncrement.mock.calls[0]?.[0]).toMatch(/^workflow_grants_read:[0-9a-f]{32}$/) @@ -708,21 +783,23 @@ describe('routes/workflows/shared/rateLimit', () => { res.status(200).json({ ok: true }) }) - await request(app) - .post('/trigger') - .set('Authorization', 'Bearer service-token') - .set('x-user-session-token', 'user-session-a') - .expect(200) - await request(app) - .post('/trigger') - .set('Authorization', 'Bearer service-token') - .set('x-user-session-token', 'user-session-b') - .expect(200) - await request(app) - .post('/trigger') - .set('Authorization', 'Bearer service-token') - .set('x-user-session-token', 'user-session-a') - .expect(200) + await withWorkflowRateLimitApp(app, async client => { + await client + .post('/trigger') + .set('Authorization', 'Bearer service-token') + .set('x-user-session-token', 'user-session-a') + .expect(200) + await client + .post('/trigger') + .set('Authorization', 'Bearer service-token') + .set('x-user-session-token', 'user-session-b') + .expect(200) + await client + .post('/trigger') + .set('Authorization', 'Bearer service-token') + .set('x-user-session-token', 'user-session-a') + .expect(200) + }) expect(mockCheckAndIncrement).toHaveBeenCalledTimes(3) const [keyA, keyB, keyAAgain] = mockCheckAndIncrement.mock.calls.map(call => call[0]) @@ -758,16 +835,18 @@ describe('routes/workflows/shared/rateLimit', () => { res.status(200).json({ ok: true }) }) - await request(app) - .post('/trigger') - .set('Authorization', 'Bearer service-token') - .set('x-user-session-token', 'user-session-a') - .expect(200) - await request(app) - .post('/trigger') - .set('Authorization', 'Bearer service-token') - .set('x-user-session-token', 'user-session-a-rotated') - .expect(200) + await withWorkflowRateLimitApp(app, async client => { + await client + .post('/trigger') + .set('Authorization', 'Bearer service-token') + .set('x-user-session-token', 'user-session-a') + .expect(200) + await client + .post('/trigger') + .set('Authorization', 'Bearer service-token') + .set('x-user-session-token', 'user-session-a-rotated') + .expect(200) + }) expect(mockCheckAndIncrement).toHaveBeenCalledTimes(2) expect(mockCheckAndIncrement.mock.calls[0]?.[0]).toBe(mockCheckAndIncrement.mock.calls[1]?.[0]) @@ -789,16 +868,18 @@ describe('routes/workflows/shared/rateLimit', () => { res.status(200).json({ ok: true }) }) - await request(app) - .post('/trigger') - .set('Authorization', 'Bearer service-token') - .set('x-user-session-token', 'forged-session-a') - .expect(200) - await request(app) - .post('/trigger') - .set('Authorization', 'Bearer service-token') - .set('x-user-session-token', 'forged-session-b') - .expect(200) + await withWorkflowRateLimitApp(app, async client => { + await client + .post('/trigger') + .set('Authorization', 'Bearer service-token') + .set('x-user-session-token', 'forged-session-a') + .expect(200) + await client + .post('/trigger') + .set('Authorization', 'Bearer service-token') + .set('x-user-session-token', 'forged-session-b') + .expect(200) + }) expect(mockCheckAndIncrement).toHaveBeenCalledTimes(2) expect(mockCheckAndIncrement.mock.calls[0]?.[0]).toMatch(/^workflow_trigger:[0-9a-f]{32}$/) @@ -821,20 +902,22 @@ describe('routes/workflows/shared/rateLimit', () => { res.status(200).json({ ok: true }) }) - await request(app) - .post('/trigger') - .set('Authorization', 'Bearer mcp-host-control-token') - .expect(200) - await request(app) - .post('/trigger') - .set('Authorization', 'Bearer mcp-host-control-token') - .set('x-user-session-token', 'user-session-a') - .expect(200) - await request(app) - .post('/trigger') - .set('Authorization', 'Bearer mcp-host-control-token') - .set('x-user-session-token', 'forged-session') - .expect(200) + await withWorkflowRateLimitApp(app, async client => { + await client + .post('/trigger') + .set('Authorization', 'Bearer mcp-host-control-token') + .expect(200) + await client + .post('/trigger') + .set('Authorization', 'Bearer mcp-host-control-token') + .set('x-user-session-token', 'user-session-a') + .expect(200) + await client + .post('/trigger') + .set('Authorization', 'Bearer mcp-host-control-token') + .set('x-user-session-token', 'forged-session') + .expect(200) + }) expect(mockCheckAndIncrement).toHaveBeenCalledTimes(3) const [bearerOnly, injectedVerified, injectedForged] = mockCheckAndIncrement.mock.calls.map( @@ -861,8 +944,10 @@ describe('routes/workflows/shared/rateLimit', () => { res.status(200).json({ ok: true }) }) - await request(app).post('/trigger').set('Authorization', 'Bearer service-token').expect(200) - await request(app).post('/trigger').set('Authorization', 'Bearer service-token').expect(200) + await withWorkflowRateLimitApp(app, async client => { + await client.post('/trigger').set('Authorization', 'Bearer service-token').expect(200) + await client.post('/trigger').set('Authorization', 'Bearer service-token').expect(200) + }) expect(mockCheckAndIncrement).toHaveBeenCalledTimes(2) expect(mockCheckAndIncrement.mock.calls[0]?.[0]).toMatch(/^workflow_trigger:[0-9a-f]{32}$/) From fa0415338b3dc46465164f7caf5d715d80161f31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 3 Sep 2026 15:25:20 -0600 Subject: [PATCH 199/528] chore(control-api): align pr1 migrations with latest dev dev added 0107 and 0108 migration slots after the PR1 base. The PR1 migration runner and online-index plan now use 0109-010e while preserving legacy aliases for already-recorded PR1 migration names. This keeps the D34 bounded migration policy scoped to PR1 migrations and treats the new dev migrations as dev-owned work. --- control-api/src/migrations/migrationRunner.ts | 20 ++++--- .../src/migrations/pr1OnlineIndexPlan.ts | 54 +++++++++---------- .../db.catalogUtf8OrderingMigration.test.ts | 4 +- ...composableCatalogRevisionMigration.test.ts | 4 +- ...CatalogRevisionComponentsMigration.test.ts | 12 ++--- .../db.userAccessFoundationMigration.test.ts | 45 ++++++++++------ ...hBackfill.realPostgres.integration.test.ts | 8 +-- ...ionPolicy.realPostgres.integration.test.ts | 14 ++--- .../test/migrationExecutionPolicy.test.ts | 23 +++++++- 9 files changed, 112 insertions(+), 72 deletions(-) diff --git a/control-api/src/migrations/migrationRunner.ts b/control-api/src/migrations/migrationRunner.ts index fde4cbd638..08c080a8ab 100644 --- a/control-api/src/migrations/migrationRunner.ts +++ b/control-api/src/migrations/migrationRunner.ts @@ -3,14 +3,19 @@ import { migrationSessionBoundsSql } from './migrationExecutionPolicy.js' import { preparePr1Migration } from './pr1OnlineIndexPlan.js' export const PR1_MIGRATION_VERSIONS = Object.freeze([ - '0107_user_access_foundation', - '0108_invitation_delivery_commands', - '0109_catalog_utf8_ordering', - '010a_composable_catalog_revisions', - '010b_gfs_catalog_revision_components', - '010c_legacy_password_security_epoch_backfill', + '0109_user_access_foundation', + '010a_invitation_delivery_commands', + '010b_catalog_utf8_ordering', + '010c_composable_catalog_revisions', + '010d_gfs_catalog_revision_components', + '010e_legacy_password_security_epoch_backfill', ] as const) +const NON_PR1_POST_0106_MIGRATION_VERSIONS = new Set([ + '0107_llm_provider_attempts_sdk_link', + '0108_llm_provider_attempts_sdk_link_on_delete_set_null', +]) + export type MigrationDescriptor = { version: string legacyVersions?: readonly string[] @@ -57,7 +62,8 @@ export async function applyPendingPr1Migrations({ const unclassified = migrations.filter( migration => migration.version > '0106_oauth_grants_owner_generalization' && - !expected.has(migration.version) + !expected.has(migration.version) && + !NON_PR1_POST_0106_MIGRATION_VERSIONS.has(migration.version) ) if (unclassified.length > 0) { throw new Error( diff --git a/control-api/src/migrations/pr1OnlineIndexPlan.ts b/control-api/src/migrations/pr1OnlineIndexPlan.ts index a612b9449f..ef54a703fb 100644 --- a/control-api/src/migrations/pr1OnlineIndexPlan.ts +++ b/control-api/src/migrations/pr1OnlineIndexPlan.ts @@ -5,7 +5,7 @@ import { } from './migrationExecutionPolicy.js' export type OnlineIndexDefinition = Readonly<{ - migrationVersion: '0107_user_access_foundation' | '0109_catalog_utf8_ordering' + migrationVersion: '0109_user_access_foundation' | '010b_catalog_utf8_ordering' name: string table: string unique?: boolean @@ -14,70 +14,70 @@ export type OnlineIndexDefinition = Readonly<{ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.freeze([ { - migrationVersion: '0107_user_access_foundation', + migrationVersion: '0109_user_access_foundation', name: 'team_members_user_active_idx', table: 'team_members', createSql: `CREATE INDEX CONCURRENTLY team_members_user_active_idx ON team_members (user_id, status, team_id) INCLUDE (role, updated_at)`, }, { - migrationVersion: '0107_user_access_foundation', + migrationVersion: '0109_user_access_foundation', name: 'user_contexts_context_user_idx', table: 'user_contexts', createSql: `CREATE INDEX CONCURRENTLY user_contexts_context_user_idx ON user_contexts (context_id, user_id)`, }, { - migrationVersion: '0107_user_access_foundation', + migrationVersion: '0109_user_access_foundation', name: 'team_contexts_context_team_idx', table: 'team_contexts', createSql: `CREATE INDEX CONCURRENTLY team_contexts_context_team_idx ON team_contexts (context_id, team_id)`, }, { - migrationVersion: '0107_user_access_foundation', + migrationVersion: '0109_user_access_foundation', name: 'user_agents_agent_user_idx', table: 'user_agents', createSql: `CREATE INDEX CONCURRENTLY user_agents_agent_user_idx ON user_agents (agent_name, user_id)`, }, { - migrationVersion: '0107_user_access_foundation', + migrationVersion: '0109_user_access_foundation', name: 'team_agents_agent_team_idx', table: 'team_agents', createSql: `CREATE INDEX CONCURRENTLY team_agents_agent_team_idx ON team_agents (agent_name, team_id)`, }, { - migrationVersion: '0107_user_access_foundation', + migrationVersion: '0109_user_access_foundation', name: 'user_workflow_triggers_recipe_user_idx', table: 'user_workflow_triggers', createSql: `CREATE INDEX CONCURRENTLY user_workflow_triggers_recipe_user_idx ON user_workflow_triggers (recipe_namespace, recipe_name, user_id)`, }, { - migrationVersion: '0107_user_access_foundation', + migrationVersion: '0109_user_access_foundation', name: 'team_workflow_triggers_recipe_team_idx', table: 'team_workflow_triggers', createSql: `CREATE INDEX CONCURRENTLY team_workflow_triggers_recipe_team_idx ON team_workflow_triggers (recipe_namespace, recipe_name, team_id)`, }, { - migrationVersion: '0107_user_access_foundation', + migrationVersion: '0109_user_access_foundation', name: 'user_workflow_triggers_catalog_key_idx', table: 'user_workflow_triggers', createSql: `CREATE INDEX CONCURRENTLY user_workflow_triggers_catalog_key_idx ON user_workflow_triggers (user_id, ((recipe_namespace || '/'::text) || recipe_name))`, }, { - migrationVersion: '0107_user_access_foundation', + migrationVersion: '0109_user_access_foundation', name: 'team_workflow_triggers_catalog_key_idx', table: 'team_workflow_triggers', createSql: `CREATE INDEX CONCURRENTLY team_workflow_triggers_catalog_key_idx ON team_workflow_triggers (((recipe_namespace || '/'::text) || recipe_name), team_id)`, }, { - migrationVersion: '0107_user_access_foundation', + migrationVersion: '0109_user_access_foundation', name: 'workflow_runs_actor_catalog_idx', table: 'workflow_runs', createSql: `CREATE INDEX CONCURRENTLY workflow_runs_actor_catalog_idx @@ -86,7 +86,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr WHERE actor_type = 'user' AND actor_id IS NOT NULL`, }, { - migrationVersion: '0107_user_access_foundation', + migrationVersion: '0109_user_access_foundation', name: 'workflow_runs_team_catalog_idx', table: 'workflow_runs', createSql: `CREATE INDEX CONCURRENTLY workflow_runs_team_catalog_idx @@ -95,7 +95,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr WHERE team_id IS NOT NULL`, }, { - migrationVersion: '0107_user_access_foundation', + migrationVersion: '0109_user_access_foundation', name: 'workflow_runs_usage_team_catalog_idx', table: 'workflow_runs', createSql: `CREATE INDEX CONCURRENTLY workflow_runs_usage_team_catalog_idx @@ -104,7 +104,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr WHERE usage_team_id IS NOT NULL`, }, { - migrationVersion: '0107_user_access_foundation', + migrationVersion: '0109_user_access_foundation', name: 'workflow_approval_user_catalog_idx', table: 'workflow_approval_requests', createSql: `CREATE INDEX CONCURRENTLY workflow_approval_user_catalog_idx @@ -113,7 +113,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr WHERE target_user_id IS NOT NULL`, }, { - migrationVersion: '0107_user_access_foundation', + migrationVersion: '0109_user_access_foundation', name: 'workflow_approval_team_catalog_idx', table: 'workflow_approval_requests', createSql: `CREATE INDEX CONCURRENTLY workflow_approval_team_catalog_idx @@ -122,7 +122,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr WHERE target_team_id IS NOT NULL`, }, { - migrationVersion: '0107_user_access_foundation', + migrationVersion: '0109_user_access_foundation', name: 'notification_user_catalog_idx', table: 'notification_deliveries', createSql: `CREATE INDEX CONCURRENTLY notification_user_catalog_idx @@ -130,7 +130,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr INCLUDE (expires_at, status, event_type) WHERE audience ? 'userId'`, }, { - migrationVersion: '0107_user_access_foundation', + migrationVersion: '0109_user_access_foundation', name: 'notification_team_catalog_idx', table: 'notification_deliveries', createSql: `CREATE INDEX CONCURRENTLY notification_team_catalog_idx @@ -138,7 +138,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr INCLUDE (expires_at, status, event_type) WHERE audience ? 'teamId'`, }, { - migrationVersion: '0107_user_access_foundation', + migrationVersion: '0109_user_access_foundation', name: 'gfs_grants_subject_resource_catalog_idx', table: 'gfs_grants', createSql: `CREATE INDEX CONCURRENTLY gfs_grants_subject_resource_catalog_idx @@ -146,7 +146,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr INCLUDE (id, drive, permissions, inherit)`, }, { - migrationVersion: '0107_user_access_foundation', + migrationVersion: '0109_user_access_foundation', name: 'gfs_shares_subject_resource_catalog_idx', table: 'gfs_shares', createSql: `CREATE INDEX CONCURRENTLY gfs_shares_subject_resource_catalog_idx @@ -154,35 +154,35 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr INCLUDE (id, drive, permissions, include_descendants)`, }, { - migrationVersion: '0109_catalog_utf8_ordering', + migrationVersion: '010b_catalog_utf8_ordering', name: 'user_agents_catalog_utf8_idx', table: 'user_agents', createSql: `CREATE INDEX CONCURRENTLY user_agents_catalog_utf8_idx ON user_agents (user_id, catalog_utf8_bytes(agent_name))`, }, { - migrationVersion: '0109_catalog_utf8_ordering', + migrationVersion: '010b_catalog_utf8_ordering', name: 'team_agents_catalog_utf8_idx', table: 'team_agents', createSql: `CREATE INDEX CONCURRENTLY team_agents_catalog_utf8_idx ON team_agents (catalog_utf8_bytes(agent_name), team_id)`, }, { - migrationVersion: '0109_catalog_utf8_ordering', + migrationVersion: '010b_catalog_utf8_ordering', name: 'user_contexts_catalog_utf8_idx', table: 'user_contexts', createSql: `CREATE INDEX CONCURRENTLY user_contexts_catalog_utf8_idx ON user_contexts (user_id, catalog_utf8_bytes(context_id))`, }, { - migrationVersion: '0109_catalog_utf8_ordering', + migrationVersion: '010b_catalog_utf8_ordering', name: 'team_contexts_catalog_utf8_idx', table: 'team_contexts', createSql: `CREATE INDEX CONCURRENTLY team_contexts_catalog_utf8_idx ON team_contexts (catalog_utf8_bytes(context_id), team_id)`, }, { - migrationVersion: '0109_catalog_utf8_ordering', + migrationVersion: '010b_catalog_utf8_ordering', name: 'user_workflow_triggers_catalog_utf8_idx', table: 'user_workflow_triggers', createSql: `CREATE INDEX CONCURRENTLY user_workflow_triggers_catalog_utf8_idx @@ -190,7 +190,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr (user_id, catalog_utf8_bytes(recipe_namespace || '/' || recipe_name))`, }, { - migrationVersion: '0109_catalog_utf8_ordering', + migrationVersion: '010b_catalog_utf8_ordering', name: 'team_workflow_triggers_catalog_utf8_idx', table: 'team_workflow_triggers', createSql: `CREATE INDEX CONCURRENTLY team_workflow_triggers_catalog_utf8_idx @@ -198,7 +198,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr (catalog_utf8_bytes(recipe_namespace || '/' || recipe_name), team_id)`, }, { - migrationVersion: '0109_catalog_utf8_ordering', + migrationVersion: '010b_catalog_utf8_ordering', name: 'operational_relationship_catalog_utf8_target_idx', table: 'operational_resource_relationships', createSql: `CREATE INDEX CONCURRENTLY operational_relationship_catalog_utf8_target_idx @@ -332,6 +332,6 @@ async function prepareCatalogUtf8Function(db: DbClient): Promise { export async function preparePr1Migration(db: DbClient, version: string): Promise { const indexes = PR1_ONLINE_INDEX_PLAN.filter(entry => entry.migrationVersion === version) if (indexes.length === 0) return - if (version === '0109_catalog_utf8_ordering') await prepareCatalogUtf8Function(db) + if (version === '010b_catalog_utf8_ordering') await prepareCatalogUtf8Function(db) for (const index of indexes) await ensureOnlineIndex(db, index) } diff --git a/control-api/test/db.catalogUtf8OrderingMigration.test.ts b/control-api/test/db.catalogUtf8OrderingMigration.test.ts index 1ece5ed5d1..f8670dfa4d 100644 --- a/control-api/test/db.catalogUtf8OrderingMigration.test.ts +++ b/control-api/test/db.catalogUtf8OrderingMigration.test.ts @@ -4,11 +4,11 @@ vi.mock('../src/config.js', () => ({ config: { databaseUrl: 'postgresql://test' }, })) -describe('0109 catalog UTF-8 ordering migration', () => { +describe('010b catalog UTF-8 ordering migration', () => { it('installs the immutable byte function and complete supporting index class', async () => { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0109_catalog_utf8_ordering' + candidate => candidate.version === '010b_catalog_utf8_ordering' ) expect(migration).toBeDefined() diff --git a/control-api/test/db.composableCatalogRevisionMigration.test.ts b/control-api/test/db.composableCatalogRevisionMigration.test.ts index 4c8b9c9415..5d82a1b886 100644 --- a/control-api/test/db.composableCatalogRevisionMigration.test.ts +++ b/control-api/test/db.composableCatalogRevisionMigration.test.ts @@ -4,11 +4,11 @@ vi.mock('../src/config.js', () => ({ config: { databaseUrl: 'postgresql://test' }, })) -describe('010a composable catalog revisions migration', () => { +describe('010c composable catalog revisions migration', () => { it('replaces the singleton trigger class with scoped transactional writers', async () => { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '010a_composable_catalog_revisions' + candidate => candidate.version === '010c_composable_catalog_revisions' ) expect(migration).toBeDefined() diff --git a/control-api/test/db.gfsCatalogRevisionComponentsMigration.test.ts b/control-api/test/db.gfsCatalogRevisionComponentsMigration.test.ts index f1b6e1f39f..b41d66aab5 100644 --- a/control-api/test/db.gfsCatalogRevisionComponentsMigration.test.ts +++ b/control-api/test/db.gfsCatalogRevisionComponentsMigration.test.ts @@ -4,17 +4,17 @@ vi.mock('../src/config.js', () => ({ config: { databaseUrl: 'postgresql://test' }, })) -describe('010b GFS catalog revision components migration', () => { - it('fixes forward after a database has already recorded migration 010a', async () => { +describe('010d GFS catalog revision components migration', () => { + it('fixes forward after a database has already recorded migration 010c', async () => { const { CONTROL_API_MIGRATIONS, initDb } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '010b_gfs_catalog_revision_components' + candidate => candidate.version === '010d_gfs_catalog_revision_components' ) const composableIndex = CONTROL_API_MIGRATIONS.findIndex( - candidate => candidate.version === '010a_composable_catalog_revisions' + candidate => candidate.version === '010c_composable_catalog_revisions' ) const gfsIndex = CONTROL_API_MIGRATIONS.findIndex( - candidate => candidate.version === '010b_gfs_catalog_revision_components' + candidate => candidate.version === '010d_gfs_catalog_revision_components' ) expect(migration).toBeDefined() expect(gfsIndex).toBe(composableIndex + 1) @@ -39,7 +39,7 @@ describe('010b GFS catalog revision components migration', () => { expect(appliedSql).toContain('INSERT INTO authorization_resource_revisions') expect(clientQuery).toHaveBeenCalledWith( expect.stringContaining('INSERT INTO schema_migrations'), - ['010b_gfs_catalog_revision_components'] + ['010d_gfs_catalog_revision_components'] ) }) }) diff --git a/control-api/test/db.userAccessFoundationMigration.test.ts b/control-api/test/db.userAccessFoundationMigration.test.ts index aa6c5c22d4..f13df53c1b 100644 --- a/control-api/test/db.userAccessFoundationMigration.test.ts +++ b/control-api/test/db.userAccessFoundationMigration.test.ts @@ -10,7 +10,7 @@ vi.mock('../src/config.js', () => ({ async function migrationSql(): Promise { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0107_user_access_foundation' + candidate => candidate.version === '0109_user_access_foundation' ) expect(migration).toBeDefined() @@ -22,7 +22,7 @@ async function migrationSql(): Promise { async function legacyEpochBackfillSql(): Promise { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '010c_legacy_password_security_epoch_backfill' + candidate => candidate.version === '010e_legacy_password_security_epoch_backfill' ) expect(migration).toBeDefined() @@ -35,20 +35,35 @@ describe('user-access foundation migration', () => { it('carries the pre-sync PR1 migration identities as legacy aliases', async () => { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const expectedAliases = new Map([ - ['0107_user_access_foundation', '0101_user_access_foundation'], - ['0108_invitation_delivery_commands', '0102_invitation_delivery_commands'], - ['0109_catalog_utf8_ordering', '0103_catalog_utf8_ordering'], - ['010a_composable_catalog_revisions', '0104_composable_catalog_revisions'], - ['010b_gfs_catalog_revision_components', '0105_gfs_catalog_revision_components'], [ - '010c_legacy_password_security_epoch_backfill', - '0106_legacy_password_security_epoch_backfill', + '0109_user_access_foundation', + ['0107_user_access_foundation', '0101_user_access_foundation'], + ], + [ + '010a_invitation_delivery_commands', + ['0108_invitation_delivery_commands', '0102_invitation_delivery_commands'], + ], + ['010b_catalog_utf8_ordering', ['0109_catalog_utf8_ordering', '0103_catalog_utf8_ordering']], + [ + '010c_composable_catalog_revisions', + ['010a_composable_catalog_revisions', '0104_composable_catalog_revisions'], + ], + [ + '010d_gfs_catalog_revision_components', + ['010b_gfs_catalog_revision_components', '0105_gfs_catalog_revision_components'], + ], + [ + '010e_legacy_password_security_epoch_backfill', + [ + '010c_legacy_password_security_epoch_backfill', + '0106_legacy_password_security_epoch_backfill', + ], ], ]) - for (const [version, legacyVersion] of expectedAliases) { + for (const [version, legacyVersions] of expectedAliases) { const migration = CONTROL_API_MIGRATIONS.find(candidate => candidate.version === version) - expect(migration?.legacyVersions).toEqual([legacyVersion]) + expect(migration?.legacyVersions).toEqual(legacyVersions) } }) @@ -171,14 +186,14 @@ describe('user-access foundation migration', () => { it('dispatches the historical epoch fix-forward after already-recorded access migrations', async () => { const { CONTROL_API_MIGRATIONS, initDb } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '010c_legacy_password_security_epoch_backfill' + candidate => candidate.version === '010e_legacy_password_security_epoch_backfill' ) expect(migration).toBeDefined() const gfsIndex = CONTROL_API_MIGRATIONS.findIndex( - candidate => candidate.version === '010b_gfs_catalog_revision_components' + candidate => candidate.version === '010d_gfs_catalog_revision_components' ) const epochIndex = CONTROL_API_MIGRATIONS.findIndex( - candidate => candidate.version === '010c_legacy_password_security_epoch_backfill' + candidate => candidate.version === '010e_legacy_password_security_epoch_backfill' ) expect(epochIndex).toBe(gfsIndex + 1) @@ -199,7 +214,7 @@ describe('user-access foundation migration', () => { expect(appliedSql).toContain('historical_password_event') expect(clientQuery).toHaveBeenCalledWith( expect.stringContaining('INSERT INTO schema_migrations'), - ['010c_legacy_password_security_epoch_backfill'] + ['010e_legacy_password_security_epoch_backfill'] ) }) }) diff --git a/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts b/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts index 3ceaff75e9..a1c07247d8 100644 --- a/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts +++ b/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts @@ -104,7 +104,7 @@ describeRealPostgres('legacy password epoch backfill on real PostgreSQL', () => await databasePool.query( `DELETE FROM schema_migrations - WHERE version = '010c_legacy_password_security_epoch_backfill'` + WHERE version = '010e_legacy_password_security_epoch_backfill'` ) await initDb({ connect: () => databasePool.connect() }) @@ -124,7 +124,7 @@ describeRealPostgres('legacy password epoch backfill on real PostgreSQL', () => const recorded = await databasePool.query<{ count: string }>( `SELECT COUNT(*)::text AS count FROM schema_migrations - WHERE version = '010c_legacy_password_security_epoch_backfill'` + WHERE version = '010e_legacy_password_security_epoch_backfill'` ) expect(recorded.rows[0]?.count).toBe('1') }) @@ -157,12 +157,12 @@ describeRealPostgres('legacy password epoch backfill on real PostgreSQL', () => ) await databasePool.query( `DELETE FROM schema_migrations - WHERE version = '010c_legacy_password_security_epoch_backfill'` + WHERE version = '010e_legacy_password_security_epoch_backfill'` ) await initDb({ connect: () => databasePool.connect() }) await databasePool.query( `DELETE FROM schema_migrations - WHERE version = '010c_legacy_password_security_epoch_backfill'` + WHERE version = '010e_legacy_password_security_epoch_backfill'` ) await initDb({ connect: () => databasePool.connect() }) diff --git a/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts index 94d350e342..f2744f14c7 100644 --- a/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts +++ b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts @@ -126,7 +126,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { it('repairs an equivalent interrupted index and enforces the online bound', async () => { const name = `d34_interrupted_${randomBytes(4).toString('hex')}` const entry: OnlineIndexDefinition = { - migrationVersion: '0107_user_access_foundation', + migrationVersion: '0109_user_access_foundation', name, table: 'd34_interrupted_index', unique: true, @@ -175,7 +175,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { const functionName = `d34_slow_index_value_${suffix}` const name = `d34_slow_index_${suffix}_idx` const entry: OnlineIndexDefinition = { - migrationVersion: '0107_user_access_foundation', + migrationVersion: '0109_user_access_foundation', name, table, createSql: `CREATE INDEX CONCURRENTLY ${name} ON ${table} (${functionName}(value))`, @@ -291,7 +291,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { const locker = await databasePool.connect() await databasePool.query( `DELETE FROM schema_migrations - WHERE version IN ('010a_composable_catalog_revisions', '010b_gfs_catalog_revision_components')` + WHERE version IN ('010c_composable_catalog_revisions', '010d_gfs_catalog_revision_components')` ) await locker.query('BEGIN') await locker.query('LOCK TABLE team_members IN ACCESS EXCLUSIVE MODE') @@ -307,8 +307,8 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { expect(Date.now() - started).toBeGreaterThanOrEqual(9_000) expect(Date.now() - started).toBeLessThan(15_000) const failedVersions = await versions(databasePool) - expect(failedVersions).not.toContain('010a_composable_catalog_revisions') - expect(failedVersions).not.toContain('010b_gfs_catalog_revision_components') + expect(failedVersions).not.toContain('010c_composable_catalog_revisions') + expect(failedVersions).not.toContain('010d_gfs_catalog_revision_components') await initDb({ connect: () => databasePool.connect() }) }, 20_000) @@ -320,7 +320,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { const migrations = PR1_MIGRATION_VERSIONS.map(version => ({ version, apply: async (db: DbClient) => { - if (version === '010a_composable_catalog_revisions') { + if (version === '010c_composable_catalog_revisions') { await db.query('SELECT pg_sleep(20)') } }, @@ -343,7 +343,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { `SELECT version FROM ${recordTable} ORDER BY version` ) expect(recorded.rows).toEqual([]) - expect(appliedVersions).not.toContain('010b_gfs_catalog_revision_components') + expect(appliedVersions).not.toContain('010d_gfs_catalog_revision_components') } finally { client.release(true) } diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index c6054eb6f9..d63887dc5d 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -48,10 +48,10 @@ describe('D34 migration execution policy', () => { expect(PR1_ONLINE_INDEX_PLAN).toHaveLength(25) expect(new Set(PR1_ONLINE_INDEX_PLAN.map(index => index.name))).toHaveLength(25) expect( - PR1_ONLINE_INDEX_PLAN.filter(index => index.migrationVersion.startsWith('0107')) + PR1_ONLINE_INDEX_PLAN.filter(index => index.migrationVersion.startsWith('0109')) ).toHaveLength(18) expect( - PR1_ONLINE_INDEX_PLAN.filter(index => index.migrationVersion.startsWith('0109')) + PR1_ONLINE_INDEX_PLAN.filter(index => index.migrationVersion.startsWith('010b')) ).toHaveLength(7) expect( PR1_ONLINE_INDEX_PLAN.some(index => index.name.startsWith('external_user_sessions_')) @@ -283,6 +283,25 @@ describe('D34 PR1 migration runner', () => { }) ).rejects.toThrow('Unclassified post-0106 migrations') }) + + it('does not classify dev-owned post-0106 migrations as PR1 migration work', async () => { + const recordMigration = vi.fn() + await applyPendingPr1Migrations({ + db: { query: vi.fn(async () => ({ rows: [], rowCount: 0 })) }, + migrations: [ + { version: '0107_llm_provider_attempts_sdk_link', apply: vi.fn() }, + { version: '0108_llm_provider_attempts_sdk_link_on_delete_set_null', apply: vi.fn() }, + ...PR1_MIGRATION_VERSIONS.map(version => ({ + version, + apply: vi.fn(async () => undefined), + })), + ], + appliedVersions: new Set(PR1_MIGRATION_VERSIONS), + recordMigration, + }) + + expect(recordMigration).not.toHaveBeenCalled() + }) }) describe('D34 online-index recovery', () => { From 0608b0b3521a6f686555eb342075b119664a0087 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 3 Sep 2026 20:52:02 -0600 Subject: [PATCH 200/528] fix(control-api): r26-b3 run dev post-0106 migrations R26-B3 Class-check: post-0106 migration partition -> 3 classified sets -> run current-dev 0107/0108 before PR1 0109..010e, keep unclassified migrations fail-closed, and run D34 online-index preparation only for PR1 migrations. Repro-test: control-api/test/migrationExecutionPolicy.test.ts (fails at 173fbcc286748eca372e14f5e3de0cb7f90fa03d). GitHub RED: Real PostgreSQL job 100825644242 lacked llm_provider_attempts.plugin_workload_sdk_provider_attempt_id. --- control-api/src/migrations/migrationRunner.ts | 12 +- control-api/test/db.initDb.test.ts | 4 +- ...ionPolicy.realPostgres.integration.test.ts | 32 ++++-- .../test/migrationExecutionPolicy.test.ts | 104 +++++++++++++----- 4 files changed, 111 insertions(+), 41 deletions(-) diff --git a/control-api/src/migrations/migrationRunner.ts b/control-api/src/migrations/migrationRunner.ts index 08c080a8ab..0f682a881e 100644 --- a/control-api/src/migrations/migrationRunner.ts +++ b/control-api/src/migrations/migrationRunner.ts @@ -16,6 +16,11 @@ const NON_PR1_POST_0106_MIGRATION_VERSIONS = new Set([ '0108_llm_provider_attempts_sdk_link_on_delete_set_null', ]) +const CLASSIFIED_POST_0106_MIGRATION_VERSIONS = Object.freeze([ + ...NON_PR1_POST_0106_MIGRATION_VERSIONS, + ...PR1_MIGRATION_VERSIONS, +] as const) + export type MigrationDescriptor = { version: string legacyVersions?: readonly string[] @@ -71,15 +76,16 @@ export async function applyPendingPr1Migrations({ ) } - for (const version of PR1_MIGRATION_VERSIONS) { + for (const version of CLASSIFIED_POST_0106_MIGRATION_VERSIONS) { const migration = byVersion.get(version) - if (!migration) throw new Error(`Missing registered PR1 migration: ${version}`) + if (!migration) throw new Error(`Missing registered post-0106 migration: ${version}`) if (appliedVersions.has(version)) continue + const isPr1Migration = expected.has(version) const acceptedLegacyVersion = migration.legacyVersions?.find(alias => appliedVersions.has(alias) ) - if (!acceptedLegacyVersion) { + if (isPr1Migration && !acceptedLegacyVersion) { await preparePr1Migration(db, version) } diff --git a/control-api/test/db.initDb.test.ts b/control-api/test/db.initDb.test.ts index cf1e988344..77b6795f02 100644 --- a/control-api/test/db.initDb.test.ts +++ b/control-api/test/db.initDb.test.ts @@ -65,8 +65,8 @@ describe('db.initDb', () => { ).toBe(false) expect(sqls.some(sql => sql.includes('DROP TABLE IF EXISTS workflow_run_outputs'))).toBe(false) expect(sqls).not.toContain('ROLLBACK') - expect(sqls.filter(sql => sql === 'BEGIN')).toHaveLength(8) - expect(sqls.filter(sql => sql === 'COMMIT')).toHaveLength(8) + expect(sqls.filter(sql => sql === 'BEGIN')).toHaveLength(10) + expect(sqls.filter(sql => sql === 'COMMIT')).toHaveLength(10) expect(sqls[sqls.length - 2]).toBe('COMMIT') expect(sqls[sqls.length - 1]).toContain('SELECT pg_advisory_unlock') expect(clientRelease).toHaveBeenCalledTimes(1) diff --git a/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts index f2744f14c7..f04f9dc15f 100644 --- a/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts +++ b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts @@ -34,6 +34,11 @@ const FRESH_TABLE_INDEXES = Object.freeze([ 'invitation_delivery_commands_invitation_idx', ]) +const DEV_POST_0106_MIGRATION_VERSIONS = Object.freeze([ + '0107_llm_provider_attempts_sdk_link', + '0108_llm_provider_attempts_sdk_link_on_delete_set_null', +] as const) + function databaseUrl(baseUrl: string, database: string): string { const value = new URL(baseUrl) value.pathname = `/${database}` @@ -314,17 +319,26 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { it('cancels and rolls back one ordinary migration statement within 15 seconds', async () => { const client = await databasePool.connect() - const appliedVersions = new Set(PR1_MIGRATION_VERSIONS.slice(0, 3)) + const appliedVersions = new Set([ + ...DEV_POST_0106_MIGRATION_VERSIONS, + ...PR1_MIGRATION_VERSIONS.slice(0, 3), + ]) const recordTable = `d34_record_${randomBytes(4).toString('hex')}` await client.query(`CREATE TEMP TABLE ${recordTable}(version text PRIMARY KEY)`) - const migrations = PR1_MIGRATION_VERSIONS.map(version => ({ - version, - apply: async (db: DbClient) => { - if (version === '010c_composable_catalog_revisions') { - await db.query('SELECT pg_sleep(20)') - } - }, - })) + const migrations = [ + ...DEV_POST_0106_MIGRATION_VERSIONS.map(version => ({ + version, + apply: async () => undefined, + })), + ...PR1_MIGRATION_VERSIONS.map(version => ({ + version, + apply: async (db: DbClient) => { + if (version === '010c_composable_catalog_revisions') { + await db.query('SELECT pg_sleep(20)') + } + }, + })), + ] const started = Date.now() try { await expect( diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index d63887dc5d..1e370c41dd 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -29,6 +29,11 @@ const FRESH_TABLE_INDEXES = Object.freeze([ 'invitation_delivery_commands_invitation_idx', ]) +const DEV_POST_0106_MIGRATION_VERSIONS = Object.freeze([ + '0107_llm_provider_attempts_sdk_link', + '0108_llm_provider_attempts_sdk_link_on_delete_set_null', +] as const) + describe('D34 migration execution policy', () => { it('freezes the owner-approved timeout and Job values', () => { expect(MIGRATION_EXECUTION_POLICY).toEqual({ @@ -225,10 +230,16 @@ describe('D34 PR1 migration runner', () => { return { rows: [], rowCount: 0 } }) const applied: string[] = [] - const migrations = PR1_MIGRATION_VERSIONS.map(version => ({ - version, - apply: vi.fn(async () => undefined), - })) + const migrations = [ + ...DEV_POST_0106_MIGRATION_VERSIONS.map(version => ({ + version, + apply: vi.fn(async () => undefined), + })), + ...PR1_MIGRATION_VERSIONS.map(version => ({ + version, + apply: vi.fn(async () => undefined), + })), + ] await applyPendingPr1Migrations({ db, @@ -239,30 +250,37 @@ describe('D34 PR1 migration runner', () => { }, }) - expect(applied).toEqual(PR1_MIGRATION_VERSIONS) - expect(queries.filter(({ sql }) => sql === 'BEGIN')).toHaveLength(7) - expect(queries.filter(({ sql }) => sql === 'COMMIT')).toHaveLength(7) + expect(applied).toEqual([...DEV_POST_0106_MIGRATION_VERSIONS, ...PR1_MIGRATION_VERSIONS]) + expect(queries.filter(({ sql }) => sql === 'BEGIN')).toHaveLength(9) + expect(queries.filter(({ sql }) => sql === 'COMMIT')).toHaveLength(9) expect(queries.filter(({ sql }) => sql === 'ROLLBACK')).toHaveLength(0) }) it('stops after a failed version and rolls back only that version', async () => { const query = vi.fn(async () => ({ rows: [], rowCount: 0 })) const applyOrder: string[] = [] - const migrations = PR1_MIGRATION_VERSIONS.map(version => ({ - version, - legacyVersions: [`legacy_${version}`], - apply: vi.fn(async () => { - applyOrder.push(version) - if (version === PR1_MIGRATION_VERSIONS[3]) throw new Error('boom') - }), - })) + const migrations = [ + ...DEV_POST_0106_MIGRATION_VERSIONS.map(version => ({ + version, + apply: vi.fn(async () => undefined), + })), + ...PR1_MIGRATION_VERSIONS.map(version => ({ + version, + legacyVersions: [`legacy_${version}`], + apply: vi.fn(async () => { + applyOrder.push(version) + if (version === PR1_MIGRATION_VERSIONS[3]) throw new Error('boom') + }), + })), + ] await expect( applyPendingPr1Migrations({ db: { query }, migrations, - appliedVersions: new Set( - PR1_ONLINE_INDEX_PLAN.map(index => `legacy_${index.migrationVersion}`) - ), + appliedVersions: new Set([ + ...DEV_POST_0106_MIGRATION_VERSIONS, + ...PR1_ONLINE_INDEX_PLAN.map(index => `legacy_${index.migrationVersion}`), + ]), recordMigration: async () => undefined, }) ).rejects.toThrow('boom') @@ -275,6 +293,7 @@ describe('D34 PR1 migration runner', () => { applyPendingPr1Migrations({ db: { query: vi.fn() }, migrations: [ + ...DEV_POST_0106_MIGRATION_VERSIONS.map(version => ({ version, apply: vi.fn() })), ...PR1_MIGRATION_VERSIONS.map(version => ({ version, apply: vi.fn() })), { version: '010d_unclassified', apply: vi.fn() }, ], @@ -284,23 +303,54 @@ describe('D34 PR1 migration runner', () => { ).rejects.toThrow('Unclassified post-0106 migrations') }) - it('does not classify dev-owned post-0106 migrations as PR1 migration work', async () => { - const recordMigration = vi.fn() + it('applies classified dev-owned post-0106 migrations before PR1 migrations', async () => { + const indexStates = new Map>() + const db = { + query: vi.fn(async (sql: string, values?: unknown[]) => { + if (sql.includes('FROM pg_class index_rel')) { + const state = indexStates.get(String(values?.[0])) + return { rows: state ? [state] : [], rowCount: state ? 1 : 0 } + } + if (sql.startsWith('CREATE INDEX CONCURRENTLY')) { + const entry = PR1_ONLINE_INDEX_PLAN.find(index => sql === index.createSql) + if (entry) { + indexStates.set(entry.name, { + table_name: entry.table, + indisunique: Boolean(entry.unique), + indisvalid: true, + definition: sql, + }) + } + } + return { rows: [], rowCount: 0 } + }), + } + const applyOrder: string[] = [] + const recorded: string[] = [] await applyPendingPr1Migrations({ - db: { query: vi.fn(async () => ({ rows: [], rowCount: 0 })) }, + db, migrations: [ - { version: '0107_llm_provider_attempts_sdk_link', apply: vi.fn() }, - { version: '0108_llm_provider_attempts_sdk_link_on_delete_set_null', apply: vi.fn() }, + ...DEV_POST_0106_MIGRATION_VERSIONS.map(version => ({ + version, + apply: vi.fn(async () => { + applyOrder.push(version) + }), + })), ...PR1_MIGRATION_VERSIONS.map(version => ({ version, - apply: vi.fn(async () => undefined), + apply: vi.fn(async () => { + applyOrder.push(version) + }), })), ], - appliedVersions: new Set(PR1_MIGRATION_VERSIONS), - recordMigration, + appliedVersions: new Set(), + recordMigration: async (_db, version) => { + recorded.push(version) + }, }) - expect(recordMigration).not.toHaveBeenCalled() + expect(applyOrder).toEqual([...DEV_POST_0106_MIGRATION_VERSIONS, ...PR1_MIGRATION_VERSIONS]) + expect(recorded).toEqual(applyOrder) }) }) From 4ecbac5f3acc03d4f2428676f49249c6abeda02e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 14 Sep 2026 09:36:08 -0600 Subject: [PATCH 201/528] fix(control-api): r26-b1 stabilize voucher rate-limit test R26-B1 Class-check: 31-request per-admin voucher limiter proof -> 1 site -> pin its clock. Repro-test: CI 34860476416 fails before fix; 31/min policy mutation returns 200. --- control-api/test/registry.test.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/control-api/test/registry.test.ts b/control-api/test/registry.test.ts index 1d8b9a7e59..27c5c16e50 100644 --- a/control-api/test/registry.test.ts +++ b/control-api/test/registry.test.ts @@ -177,6 +177,11 @@ describe('POST /registry/identity-voucher', () => { }) it('rate-limits per-admin: 31st request in a minute returns 429', async () => { + // Keep all requests in one fixed limiter window. Repeated RS256 signing can + // otherwise cross a wall-clock minute boundary under CI load and make this + // test intermittently observe a fresh bucket on its final request. + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(Date.UTC(2026, 0, 1, 0, 0, 30)) + adminSvc.findAdminById.mockResolvedValue({ id: 'admin-uuid-123', username: 'rl-admin', @@ -191,13 +196,17 @@ describe('POST /registry/identity-voucher', () => { app.use(express.json()) app.use(createRegistryRouter()) - // Drain the bucket. Cap is 30/min (set in the route definition). - for (let i = 0; i < 30; i += 1) { - const ok = await request(app).post('/registry/identity-voucher') - expect(ok.status).toBe(200) + try { + // Drain the bucket. Cap is 30/min (set in the route definition). + for (let i = 0; i < 30; i += 1) { + const ok = await request(app).post('/registry/identity-voucher') + expect(ok.status).toBe(200) + } + const denied = await request(app).post('/registry/identity-voucher') + expect(denied.status).toBe(429) + } finally { + nowSpy.mockRestore() } - const denied = await request(app).post('/registry/identity-voucher') - expect(denied.status).toBe(429) }) it('rejects disabled admins with 401', async () => { From fc10e5b35d880b1027111fd316080619f83ce8bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Wed, 16 Sep 2026 11:08:18 -0600 Subject: [PATCH 202/528] fix(rpc-proxy): r29-h1 rate-limit host artifact reads R29-H1 Class-check: authenticated Host artifact reads -> 2 routes -> shared 30/min bucket. Repro-test: artifact route regressions fail at abd0de5d because request 31 returns 200. --- control-api/src/routes/rpc-access/users.ts | 79 +++++++--- .../test/routes.rpcAccessUsersMcpHost.test.ts | 42 +++++ rpc-proxy/package-lock.json | 52 +++++++ rpc-proxy/package.json | 1 + .../src/__tests__/approval-artifacts.test.ts | 78 +++++++++- .../src/__tests__/wake-route-coverage.test.ts | 2 + rpc-proxy/src/routes/rpc.ts | 145 +++++++++++++----- .../src/services/controlApiRestService.ts | 55 ++++++- rpc-proxy/src/services/mcpProxyService.ts | 29 ++++ .../services.controlApiHostAccess.test.ts | 25 +++ 10 files changed, 446 insertions(+), 62 deletions(-) diff --git a/control-api/src/routes/rpc-access/users.ts b/control-api/src/routes/rpc-access/users.ts index 5dad840a15..5b1a7d80dc 100644 --- a/control-api/src/routes/rpc-access/users.ts +++ b/control-api/src/routes/rpc-access/users.ts @@ -3,6 +3,7 @@ import type { Request, Response } from 'express' import { config } from '../../config.js' import { pool } from '../../db.js' import { K8sGateway } from '../../k8s.js' +import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' import { requireRpcTokenUserMatch, requireValidRpcAccessToken, @@ -14,6 +15,7 @@ import { resolveInvocableMcpServersForContexts, } from '../../services/access/mcpInvocable.js' import { + type AuthorizedRpcHostAccess, type RpcHostAccessDenialReason, type RpcHostAccessDirectory, authorizeRpcHostAccess, @@ -47,6 +49,9 @@ const HOST_ACCESS_SCOPES = [ ] as const type RpcAuthedRequest = Request & { rpcAuth?: RpcAccessClaims } +type ArtifactReadRequest = RpcAuthedRequest & { + artifactReadConnection?: AuthorizedRpcHostAccess +} /** * Response header that tells rpc-proxy why control-api denied Host access. The @@ -69,6 +74,27 @@ function denyHostAccess( res.status(403).json({ error: 'Forbidden' }) } +async function resolveAuthorizedHostConnection( + req: RpcAuthedRequest, + res: Response, + gateway: K8sGateway, + directory: RpcHostAccessDirectory | undefined +): Promise { + const userId = String(req.params.userId || '').trim() + const hostRef = String(req.params.hostRef || '').trim() + const claims = req.rpcAuth + if (!claims) { + denyHostAccess(req, res, 'claims_missing') + return null + } + const authorization = await authorizeRpcHostAccess(gateway, claims, userId, hostRef, directory) + if (!authorization.authorized) { + denyHostAccess(req, res, authorization.reason) + return null + } + return authorization.connection +} + async function bindDirectRunWithinBudget( bindingService: Pick, input: Parameters[0], @@ -194,28 +220,43 @@ export function createRpcAccessUsersRouter( requireValidRpcAccessTokenAny([...HOST_ACCESS_SCOPES]), async (req: RpcAuthedRequest, res, next) => { try { - const userId = String(req.params.userId || '').trim() - const hostRef = String(req.params.hostRef || '').trim() - const claims = req.rpcAuth - if (!claims) { - denyHostAccess(req, res, 'claims_missing') - return - } - const authorization = await authorizeRpcHostAccess( - gateway, - claims, - userId, - hostRef, - directory - ) - if (!authorization.authorized) { - denyHostAccess(req, res, authorization.reason) - return - } - res.status(200).json(authorization.connection) + const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) + if (connection) res.status(200).json(connection) + } catch (error) { + next(error) + } + } + ) + + // Artifact reads can wake a Host and pull bounded but potentially expensive + // responses. Their durable PG bucket is independent of Host wake capacity + // and is keyed only after the live authorization above establishes the + // verified subject and canonical enabled Host. + router.get( + `${hostAccessPath}/artifact-read`, + requireValidRpcAccessTokenAny(['host:task:read']), + async (req: ArtifactReadRequest, res, next) => { + try { + const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) + if (!connection) return + req.artifactReadConnection = connection + next() } catch (error) { next(error) } + }, + rateLimitMiddleware({ + bucketType: 'host_artifact_read', + maxPerMinute: config.hostArtifactReadRlPerMin, + getBucketKey: req => { + const artifactRead = req as ArtifactReadRequest + const subject = artifactRead.rpcAuth?.sub + const hostRef = artifactRead.artifactReadConnection?.hostRef + return subject && hostRef ? `host-artifact-read:${subject}:${hostRef}` : null + }, + }), + (req: ArtifactReadRequest, res) => { + res.status(200).json(req.artifactReadConnection) } ) diff --git a/control-api/test/routes.rpcAccessUsersMcpHost.test.ts b/control-api/test/routes.rpcAccessUsersMcpHost.test.ts index b380c7a0ea..838927fdb6 100644 --- a/control-api/test/routes.rpcAccessUsersMcpHost.test.ts +++ b/control-api/test/routes.rpcAccessUsersMcpHost.test.ts @@ -37,6 +37,12 @@ const svc = vi.hoisted(() => ({ vi.mock('../src/services/directory/index.js', () => svc) +const rateLimiterMock = vi.hoisted(() => ({ + checkAndIncrement: vi.fn(), +})) + +vi.mock('../src/services/rateLimiterService.js', () => rateLimiterMock) + // Bypass the middleware chain — we are testing the handler logic specifically. // The middleware's own tests live in middleware.rpcAccessAuth.test.ts. // @@ -290,6 +296,14 @@ describe('GET /rpc/access/users/:userId/mcp-hosts/:hostRef — happy path', () = beforeEach(() => { svc.getUserAgents.mockReset() svc.getTeamAgents.mockReset() + rateLimiterMock.checkAndIncrement.mockReset() + rateLimiterMock.checkAndIncrement.mockResolvedValue({ + allowed: true, + remaining: 29, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + count: 1, + }) }) it('returns 200 with connection URL when user has row AND host exists AND host is not disabled', async () => { @@ -348,6 +362,34 @@ describe('GET /rpc/access/users/:userId/mcp-hosts/:hostRef — happy path', () = }) }) +describe('GET /rpc/access/users/:userId/mcp-hosts/:hostRef/artifact-read', () => { + it('uses the shared authenticated user and canonical Host bucket', async () => { + const userId = 'user-a' + const hostRef = 'chatllm' + svc.getUserAgents.mockResolvedValue({ userId, agentNames: [hostRef] }) + rateLimiterMock.checkAndIncrement.mockResolvedValue({ + allowed: false, + remaining: 0, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + count: 31, + }) + const { app } = buildApp([{ metadata: { name: hostRef }, spec: { enabled: true } }]) + + await request(app) + .get(`/rpc/access/users/${userId}/mcp-hosts/${hostRef}/artifact-read`) + .expect(429) + .expect(({ body }) => { + expect(body.error).toBe('Too Many Requests') + }) + + expect(rateLimiterMock.checkAndIncrement).toHaveBeenCalledWith( + `host-artifact-read:${userId}:${hostRef}`, + 30 + ) + }) +}) + describe('GET /rpc/access/users/:userId/mcp-hosts/:hostRef — CHECK #1 (user_agents row, the incident bug)', () => { beforeEach(() => { svc.getUserAgents.mockReset() diff --git a/rpc-proxy/package-lock.json b/rpc-proxy/package-lock.json index 5bb586f388..02fc459765 100644 --- a/rpc-proxy/package-lock.json +++ b/rpc-proxy/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", + "express-rate-limit": "^8.5.1", "http-proxy": "^1.18.1", "jsonwebtoken": "^9.0.2" }, @@ -1695,6 +1696,48 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express-rate-limit/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/express-rate-limit/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", @@ -1976,6 +2019,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/rpc-proxy/package.json b/rpc-proxy/package.json index 7544d37ea6..e3dcc0dbda 100644 --- a/rpc-proxy/package.json +++ b/rpc-proxy/package.json @@ -18,6 +18,7 @@ "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", + "express-rate-limit": "^8.5.1", "http-proxy": "^1.18.1", "jsonwebtoken": "^9.0.2" }, diff --git a/rpc-proxy/src/__tests__/approval-artifacts.test.ts b/rpc-proxy/src/__tests__/approval-artifacts.test.ts index f29b3f84fa..3545df995c 100644 --- a/rpc-proxy/src/__tests__/approval-artifacts.test.ts +++ b/rpc-proxy/src/__tests__/approval-artifacts.test.ts @@ -14,6 +14,7 @@ const serviceMock = vi.hoisted(() => ({ listAllowedServersForUser: vi.fn(), resolveServerConnectionForUser: vi.fn(), resolveHostConnectionForUser: vi.fn(), + resolveArtifactReadHostConnectionForUser: vi.fn(), validateRpcRequest: vi.fn(), forwardRpcToServer: vi.fn(), forwardHostMessageToHost: vi.fn(), @@ -116,6 +117,7 @@ beforeEach(() => { // Default: auth passes, host resolves authTokenMock.verifyRpcToken.mockReturnValue({ ...VALID_CLAIMS }) serviceMock.resolveHostConnectionForUser.mockResolvedValue({ ...HOST_CONNECTION }) + serviceMock.resolveArtifactReadHostConnectionForUser.mockResolvedValue({ ...HOST_CONNECTION }) }) afterEach(() => { @@ -406,6 +408,59 @@ describe('POST /rpc/hosts/:hostRef/approvals/deny', () => { // Artifact Routes // ===================================================================== describe('GET /rpc/hosts/:hostRef/artifacts', () => { + it('shares the approved artifact-read budget with downloads', async () => { + globalThis.fetch = vi.fn().mockResolvedValue(mockFetchResponse(200, '[]')) + const app = makeApp() + + for (let requestNumber = 0; requestNumber < 30; requestNumber += 1) { + const path = + requestNumber % 2 === 0 + ? '/rpc/hosts/chatllm/artifacts' + : `/rpc/hosts/chatllm/artifacts/report-${requestNumber}.txt/download` + await request(app).get(path).set('authorization', 'Bearer token').expect(200) + } + + const limited = await request(app) + .get('/rpc/hosts/chatllm/artifacts/final.txt/download') + .set('authorization', 'Bearer token') + .expect(429) + + expect(limited.body).toMatchObject({ error: 'Too Many Requests' }) + expect(Number(limited.headers['retry-after'])).toBeGreaterThan(0) + }) + + it('keys the shared budget by verified subject and canonical Host, not filename', async () => { + globalThis.fetch = vi.fn().mockResolvedValue(mockFetchResponse(200, '[]')) + authTokenMock.verifyRpcToken.mockImplementation(token => + token === 'other-user-token' ? { ...VALID_CLAIMS, sub: 'other-user' } : { ...VALID_CLAIMS } + ) + serviceMock.resolveArtifactReadHostConnectionForUser.mockImplementation( + async (_userId: string, hostRef: string) => ({ ...HOST_CONNECTION, name: hostRef }) + ) + const app = makeApp() + + for (let requestNumber = 0; requestNumber < 30; requestNumber += 1) { + await request(app) + .get(`/rpc/hosts/chatllm/artifacts/variant-${requestNumber}.txt/download`) + .set('authorization', 'Bearer token') + .expect(200) + } + + await request(app) + .get('/rpc/hosts/chatllm/artifacts/different-filename.txt/download') + .set('authorization', 'Bearer token') + .expect(429) + + await request(app) + .get('/rpc/hosts/otherhost/artifacts') + .set('authorization', 'Bearer token') + .expect(200) + await request(app) + .get('/rpc/hosts/chatllm/artifacts') + .set('authorization', 'Bearer other-user-token') + .expect(200) + }) + it('forwards to mcp-host /v1/runtime/artifacts and returns JSON', async () => { const artifacts = [ { filename: 'report.pdf', size: 12345, createdAt: '2026-03-27T10:00:00Z' }, @@ -455,7 +510,7 @@ describe('GET /rpc/hosts/:hostRef/artifacts', () => { }) it('returns 403 when host is not accessible', async () => { - serviceMock.resolveHostConnectionForUser.mockResolvedValue({ + serviceMock.resolveArtifactReadHostConnectionForUser.mockResolvedValue({ denied: true, code: 'host_access_denied', }) @@ -467,6 +522,25 @@ describe('GET /rpc/hosts/:hostRef/artifacts', () => { .expect(403) }) + it('preserves the canonical Control API artifact-read 429', async () => { + globalThis.fetch = vi.fn() + const rateLimited = Object.assign(new Error('artifact read limited'), { + name: 'ControlApiArtifactReadRateLimitedError', + retryAfterSeconds: 17, + }) + serviceMock.resolveArtifactReadHostConnectionForUser.mockRejectedValue(rateLimited) + const app = makeApp() + + const response = await request(app) + .get('/rpc/hosts/chatllm/artifacts') + .set('authorization', 'Bearer token') + .expect(429) + + expect(response.headers['retry-after']).toBe('17') + expect(response.body).toEqual({ error: 'Too Many Requests', retryAfterSeconds: 17 }) + expect(globalThis.fetch).not.toHaveBeenCalled() + }) + it('rejects invalid hostRef before resolving host access', async () => { const app = makeApp() await request(app) @@ -904,7 +978,7 @@ describe('GET /rpc/hosts/:hostRef/artifacts/:filename/download', () => { }) it('returns 403 when host is not accessible', async () => { - serviceMock.resolveHostConnectionForUser.mockResolvedValue({ + serviceMock.resolveArtifactReadHostConnectionForUser.mockResolvedValue({ denied: true, code: 'host_access_denied', }) diff --git a/rpc-proxy/src/__tests__/wake-route-coverage.test.ts b/rpc-proxy/src/__tests__/wake-route-coverage.test.ts index c68ff812eb..ef0e046c03 100644 --- a/rpc-proxy/src/__tests__/wake-route-coverage.test.ts +++ b/rpc-proxy/src/__tests__/wake-route-coverage.test.ts @@ -20,6 +20,7 @@ const serviceMock = vi.hoisted(() => ({ listAllowedServersForUser: vi.fn(), resolveServerConnectionForUser: vi.fn(), resolveHostConnectionForUser: vi.fn(), + resolveArtifactReadHostConnectionForUser: vi.fn(), validateRpcRequest: vi.fn(), forwardRpcToServer: vi.fn(), forwardHostMessageToHost: vi.fn(), @@ -106,6 +107,7 @@ const originalUpstreamTimeoutMs = config.upstreamTimeoutMs beforeEach(() => { vi.clearAllMocks() serviceMock.resolveHostConnectionForUser.mockResolvedValue({ ...HOST_CONNECTION }) + serviceMock.resolveArtifactReadHostConnectionForUser.mockResolvedValue({ ...HOST_CONNECTION }) controlApiMock.requestHostWakeFromControlApi.mockResolvedValue({ kind: 'active', wakeGeneration: null, diff --git a/rpc-proxy/src/routes/rpc.ts b/rpc-proxy/src/routes/rpc.ts index 99efa5c80f..f55e478686 100644 --- a/rpc-proxy/src/routes/rpc.ts +++ b/rpc-proxy/src/routes/rpc.ts @@ -1,6 +1,7 @@ import { Router } from 'express' import type { Response as ExpressResponse, NextFunction } from 'express' import { randomUUID } from 'crypto' +import { rateLimit } from 'express-rate-limit' import { config } from '../config.js' import { type AuthedRequest, @@ -33,6 +34,7 @@ import { forwardRpcToServer, forwardTaskResultFromHost, listAllowedServersForUser, + resolveArtifactReadHostConnectionForUser, resolveHostConnectionForUser, resolveServerConnectionForUser, validateRpcRequest, @@ -52,6 +54,14 @@ const RFC1123_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/ // own copy (the two services share no package, so each names its own). const SESSIONS_LIMIT_CAP = 100 const MESSAGES_LIMIT_CAP = 200 +const HOST_ARTIFACT_READ_LIMIT_PER_MIN = 30 + +type ArtifactReadRequest = AuthedRequest & { + artifactReadHost?: Extract< + Awaited>, + { url: string } + > +} function isSafeUpstreamPathSegment(value: string): boolean { return ( @@ -166,6 +176,16 @@ function controlApiHostAccessRejection(error: unknown): ControlApiHostAccessReje return null } +function controlApiArtifactReadRetryAfterSeconds(error: unknown): number | null { + if (!(error instanceof Error) || error.name !== 'ControlApiArtifactReadRateLimitedError') { + return null + } + const retryAfterSeconds = (error as Error & { retryAfterSeconds?: unknown }).retryAfterSeconds + return typeof retryAfterSeconds === 'number' && Number.isFinite(retryAfterSeconds) + ? Math.max(1, Math.ceil(retryAfterSeconds)) + : null +} + export function respondControlApiHostAccessRejection( res: ExpressResponse, rejection: ControlApiHostAccessRejection @@ -266,6 +286,71 @@ async function readArtifactResponseBuffer(response: Response): Promise { export function createRpcRouter(): Router { const router = Router() + // Control API owns the durable, cross-replica artifact budget. This edge + // backstop uses the same verified identity + canonical Host key so route + // analysis can prove both artifact reads are bounded without replacing the + // authoritative PG limiter or consuming Host wake capacity. + const artifactReadEdgeRateLimit = rateLimit({ + windowMs: 60_000, + limit: HOST_ARTIFACT_READ_LIMIT_PER_MIN, + standardHeaders: 'draft-7', + legacyHeaders: false, + keyGenerator: req => { + const artifactRead = req as ArtifactReadRequest + const subject = artifactRead.auth?.sub + const hostRef = artifactRead.artifactReadHost?.name + return `host-artifact-read:${subject ?? '__missing_subject__'}:${hostRef ?? '__missing_host__'}` + }, + handler: (_req, res) => { + const raw = res.getHeader('Retry-After') + const retryAfterSeconds = + typeof raw === 'string' && /^\d+$/.test(raw) ? Math.max(1, Number(raw)) : 60 + res.status(429).json({ error: 'Too Many Requests', retryAfterSeconds }) + }, + }) + + const resolveArtifactReadHost = async ( + req: ArtifactReadRequest, + res: ExpressResponse, + next: NextFunction + ) => { + try { + const auth = req.auth! + const hostRef = String(req.params.hostRef || '').trim() + if (!hostRef) { + res.status(400).json({ error: 'hostRef is required' }) + return + } + if (!RFC1123_RE.test(hostRef)) { + res.status(400).json({ error: 'Invalid host reference' }) + return + } + const artifactReadHost = await resolveArtifactReadHostConnectionForUser( + auth.sub, + hostRef, + extractAuthToken(req), + { teamId: auth.teamId } + ) + if (isHostAccessDenied(artifactReadHost)) { + respondHostAccessDenied(res, artifactReadHost) + return + } + req.artifactReadHost = artifactReadHost + next() + } catch (error) { + const retryAfterSeconds = controlApiArtifactReadRetryAfterSeconds(error) + if (retryAfterSeconds !== null) { + res.setHeader('Retry-After', String(retryAfterSeconds)) + res.status(429).json({ + error: 'Too Many Requests', + retryAfterSeconds, + }) + return + } + guardedNext(res, next, error) + } + } + router.get( '/rpc/servers', requireRpcAuth, @@ -1452,27 +1537,15 @@ export function createRpcRouter(): Router { '/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), + resolveArtifactReadHost, + artifactReadEdgeRateLimit, async (req: AuthedRequest, res, next) => { try { const auth = req.auth! const rpcAccessToken = extractAuthToken(req) const hostRef = String(req.params.hostRef || '').trim() - if (!hostRef) { - res.status(400).json({ error: 'hostRef is required' }) - return - } - if (!RFC1123_RE.test(hostRef)) { - res.status(400).json({ error: 'Invalid host reference' }) - return - } + const host = (req as ArtifactReadRequest).artifactReadHost! const wakeDeadlineMs = Date.now() + config.wakeMaxHoldMs - const host = await resolveHostConnectionForUser(auth.sub, hostRef, rpcAccessToken, { - teamId: auth.teamId, - }) - if (isHostAccessDenied(host)) { - respondHostAccessDenied(res, host) - return - } const baseUrl = host.url.replace(/\/+$/, '') // Wake-eligible finite operation (§11.4): scope stays host:task:read. // Unlike approve/deny/model this is a read-only list of small JSON: a @@ -1517,38 +1590,30 @@ export function createRpcRouter(): Router { '/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), + (req: AuthedRequest, res, next) => { + const filename = String(req.params.filename || '').trim() + if ( + !filename || + filename.includes('..') || + filename.includes('/') || + filename.includes('\\') || + filename.includes('\0') + ) { + res.status(400).json({ error: 'Invalid filename' }) + return + } + next() + }, + resolveArtifactReadHost, + artifactReadEdgeRateLimit, async (req: AuthedRequest, res, next) => { try { const auth = req.auth! const rpcAccessToken = extractAuthToken(req) const hostRef = String(req.params.hostRef || '').trim() const filename = String(req.params.filename || '').trim() - if (!hostRef) { - res.status(400).json({ error: 'hostRef is required' }) - return - } - if (!RFC1123_RE.test(hostRef)) { - res.status(400).json({ error: 'Invalid host reference' }) - return - } - if ( - !filename || - filename.includes('..') || - filename.includes('/') || - filename.includes('\\') || - filename.includes('\0') - ) { - res.status(400).json({ error: 'Invalid filename' }) - return - } + const host = (req as ArtifactReadRequest).artifactReadHost! const wakeDeadlineMs = Date.now() + config.wakeMaxHoldMs - const host = await resolveHostConnectionForUser(auth.sub, hostRef, rpcAccessToken, { - teamId: auth.teamId, - }) - if (isHostAccessDenied(host)) { - respondHostAccessDenied(res, host) - return - } const baseUrl = host.url.replace(/\/+$/, '') // Wake-eligible finite operation (§11.4): scope stays host:task:read. // The success path only commits (`res.send`) at the very end, so a diff --git a/rpc-proxy/src/services/controlApiRestService.ts b/rpc-proxy/src/services/controlApiRestService.ts index 961d798f48..91e07b46a3 100644 --- a/rpc-proxy/src/services/controlApiRestService.ts +++ b/rpc-proxy/src/services/controlApiRestService.ts @@ -64,6 +64,13 @@ export class ControlApiHostAccessRejectedError extends Error { } } +export class ControlApiArtifactReadRateLimitedError extends Error { + constructor(readonly retryAfterSeconds: number) { + super(`Control API rate limited artifact reads for ${retryAfterSeconds} seconds`) + this.name = 'ControlApiArtifactReadRateLimitedError' + } +} + // Typed rejection for the connectors read-model, mirroring the host rail above. // A generic Error collapses to 500 in the app error handler, which the desktop // reads as non-refreshable — so an expired/rotated rpc access token (401) would @@ -223,10 +230,40 @@ export async function fetchHostConnectionFromControlApi( directRunBinding?: DirectRunBindingRequest fetchImpl?: typeof fetch } = {} +): Promise { + return fetchHostConnectionForPath(userId, hostRef, rpcAccessToken, options) +} + +/** + * Resolves a Host connection after Control API has enforced the durable, + * cross-replica artifact-read budget for this verified user and canonical Host. + */ +export async function fetchArtifactReadHostConnectionFromControlApi( + userId: string, + hostRef: string, + rpcAccessToken: string, + options: { fetchImpl?: typeof fetch } = {} +): Promise { + return fetchHostConnectionForPath(userId, hostRef, rpcAccessToken, { + ...options, + artifactRead: true, + }) +} + +async function fetchHostConnectionForPath( + userId: string, + hostRef: string, + rpcAccessToken: string, + options: { + directRunBinding?: DirectRunBindingRequest + fetchImpl?: typeof fetch + artifactRead?: boolean + } = {} ): Promise { const directRunBinding = options.directRunBinding + const hostAccessPath = `${controlApiBaseUrl()}/rpc/access/users/${encodeURIComponent(userId)}/mcp-hosts/${encodeURIComponent(hostRef)}` const response = await (options.fetchImpl ?? fetch)( - `${controlApiBaseUrl()}/rpc/access/users/${encodeURIComponent(userId)}/mcp-hosts/${encodeURIComponent(hostRef)}`, + options.artifactRead ? `${hostAccessPath}/artifact-read` : hostAccessPath, { method: directRunBinding ? 'POST' : 'GET', headers: { @@ -238,6 +275,22 @@ export async function fetchHostConnectionFromControlApi( } ) + if (options.artifactRead && response.status === 429) { + const retryAfterHeader = Number(response.headers.get('retry-after')) + let retryAfterBody = Number.NaN + try { + const body = (await response.json()) as { retryAfterSeconds?: unknown } + if (typeof body.retryAfterSeconds === 'number') retryAfterBody = body.retryAfterSeconds + } catch { + /* use the header below when the canonical JSON body is unavailable */ + } + const retryAfterSeconds = + Number.isFinite(retryAfterHeader) && retryAfterHeader > 0 ? retryAfterHeader : retryAfterBody + if (!Number.isFinite(retryAfterSeconds) || retryAfterSeconds <= 0) { + throw new Error('Control API artifact-read limit returned 429 without Retry-After') + } + throw new ControlApiArtifactReadRateLimitedError(retryAfterSeconds) + } if (!directRunBinding && response.status === 403) { return hostAccessDenied(await readHostAccessDenialCode(response)) } diff --git a/rpc-proxy/src/services/mcpProxyService.ts b/rpc-proxy/src/services/mcpProxyService.ts index 7cf8595b1a..e807be1031 100644 --- a/rpc-proxy/src/services/mcpProxyService.ts +++ b/rpc-proxy/src/services/mcpProxyService.ts @@ -3,6 +3,7 @@ import { ResolvedServerConnection } from '../types.js' import { type DirectRunBindingRequest, type UserAllowedServers, + fetchArtifactReadHostConnectionFromControlApi, fetchHostConnectionFromControlApi, fetchUserAllowedServersFromControlApi, } from './controlApiRestService.js' @@ -106,3 +107,31 @@ export async function resolveHostConnectionForUser( headers, } } + +/** Resolve the Host only after the Control API's shared artifact-read budget. */ +export async function resolveArtifactReadHostConnectionForUser( + userId: string, + hostRef: string, + rpcAccessToken: string, + edgeContext?: { + accessScope?: 'team' | 'user' + teamId?: string | null + requestId?: string + } +): Promise { + const host = await fetchArtifactReadHostConnectionFromControlApi(userId, hostRef, rpcAccessToken) + if (isHostAccessDenied(host)) return host + + const headers: Record = { + ...host.headers, + 'x-clerum-edge-caller': 'rpc-proxy', + 'x-clerum-edge-host-ref': hostRef, + 'x-clerum-edge-user-id': userId, + } + if (edgeContext?.teamId) headers['x-clerum-edge-team-id'] = edgeContext.teamId + headers['x-clerum-edge-access-scope'] = + edgeContext?.accessScope ?? (edgeContext?.teamId ? 'team' : 'user') + if (edgeContext?.requestId) headers['x-clerum-edge-request-id'] = edgeContext.requestId + + return { ...host, headers } +} diff --git a/rpc-proxy/test/services.controlApiHostAccess.test.ts b/rpc-proxy/test/services.controlApiHostAccess.test.ts index 0d2e1ac093..de2a4f5bc0 100644 --- a/rpc-proxy/test/services.controlApiHostAccess.test.ts +++ b/rpc-proxy/test/services.controlApiHostAccess.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import { + ControlApiArtifactReadRateLimitedError, ControlApiHostAccessRejectedError, + fetchArtifactReadHostConnectionFromControlApi, fetchHostConnectionFromControlApi, } from '../src/services/controlApiRestService.js' @@ -11,6 +13,29 @@ const BINDING = { } describe('control-api canonical host access client', () => { + it('preserves the canonical artifact-read 429 and Retry-After', async () => { + const fetchImpl = vi.fn( + async () => + new Response(JSON.stringify({ error: 'Too Many Requests', retryAfterSeconds: 17 }), { + status: 429, + headers: { 'content-type': 'application/json', 'retry-after': '17' }, + }) + ) + + await expect( + fetchArtifactReadHostConnectionFromControlApi('user-1', 'host-a', 'signed-rpc-token', { + fetchImpl: fetchImpl as typeof fetch, + }) + ).rejects.toMatchObject({ + name: ControlApiArtifactReadRateLimitedError.name, + retryAfterSeconds: 17, + }) + + expect(String(fetchImpl.mock.calls[0]![0])).toMatch( + /\/rpc\/access\/users\/user-1\/mcp-hosts\/host-a\/artifact-read$/ + ) + }) + it('resolves and binds with one POST to the existing host-access URL', async () => { const fetchImpl = vi.fn( async () => From 318a86964913d610937dd01848462a9d0d7d1d1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Wed, 16 Sep 2026 17:06:30 -0600 Subject: [PATCH 203/528] fix(control-api): r30-h1 bound host artifact pre-admission Class-check: artifact-read -> 2 callers (list/download) -> one subject-wide durable pre-admission Repro-test: control-api/test/routes.rpcAccessUsersMcpHost.test.ts request-31 + stale-ref assertions fail at ea1cc77068b1eae3b8beeaf34ffd43694c602096 --- control-api/src/routes/rpc-access/users.ts | 22 +- ...es.rpcAccessUsersArtifactAdmission.test.ts | 84 ++++++ ...routes.rpcAccessUsersMcpConnectors.test.ts | 1 + .../test/routes.rpcAccessUsersMcpHost.test.ts | 245 +++++++++++++++++- ...teLimiter.realPostgres.integration.test.ts | 37 ++- 5 files changed, 375 insertions(+), 14 deletions(-) create mode 100644 control-api/test/routes.rpcAccessUsersArtifactAdmission.test.ts diff --git a/control-api/src/routes/rpc-access/users.ts b/control-api/src/routes/rpc-access/users.ts index 5b1a7d80dc..cea3f4df96 100644 --- a/control-api/src/routes/rpc-access/users.ts +++ b/control-api/src/routes/rpc-access/users.ts @@ -5,6 +5,7 @@ import { pool } from '../../db.js' import { K8sGateway } from '../../k8s.js' import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' import { + requireRpcTokenHostMatch, requireRpcTokenUserMatch, requireValidRpcAccessToken, requireValidRpcAccessTokenAny, @@ -228,13 +229,23 @@ export function createRpcAccessUsersRouter( } ) - // Artifact reads can wake a Host and pull bounded but potentially expensive - // responses. Their durable PG bucket is independent of Host wake capacity - // and is keyed only after the live authorization above establishes the - // verified subject and canonical enabled Host. + // The subject-wide durable PG bucket protects the expensive live Host + // authorization below. Claim-match middleware runs first so malformed, + // mismatched, or unsigned Host selectors do not consume admission and + // caller-controlled Host refs cannot expand bucket cardinality. router.get( `${hostAccessPath}/artifact-read`, requireValidRpcAccessTokenAny(['host:task:read']), + requireRpcTokenUserMatch(), + requireRpcTokenHostMatch(), + rateLimitMiddleware({ + bucketType: 'host_artifact_pre_admission', + maxPerMinute: config.hostArtifactReadRlPerMin, + getBucketKey: req => { + const subject = (req as ArtifactReadRequest).rpcAuth?.sub + return subject ? `host-artifact-pre-admission:${subject}` : null + }, + }), async (req: ArtifactReadRequest, res, next) => { try { const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) @@ -245,6 +256,9 @@ export function createRpcAccessUsersRouter( next(error) } }, + // Preserve R29-H1: after live authorization establishes the canonical + // Host, consume its independent subject+Host budget before returning the + // artifact connection. Host wake capacity remains separate. rateLimitMiddleware({ bucketType: 'host_artifact_read', maxPerMinute: config.hostArtifactReadRlPerMin, diff --git a/control-api/test/routes.rpcAccessUsersArtifactAdmission.test.ts b/control-api/test/routes.rpcAccessUsersArtifactAdmission.test.ts new file mode 100644 index 0000000000..45a8eb08e2 --- /dev/null +++ b/control-api/test/routes.rpcAccessUsersArtifactAdmission.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import { randomUUID } from 'node:crypto' +import request from 'supertest' +import type { K8sGateway } from '../src/k8s.js' +import { createRpcAccessUsersRouter } from '../src/routes/rpc-access/users.js' +import { signRpcAccessToken } from '../src/utils/auth/rpcAuthToken.js' + +const directory = vi.hoisted(() => ({ + getUserAgents: vi.fn(), + getUserContexts: vi.fn(), + getCurrentTeam: vi.fn(), + getTeamAgents: vi.fn(), +})) + +vi.mock('../src/services/directory/index.js', () => directory) + +const rateLimiter = vi.hoisted(() => ({ checkAndIncrement: vi.fn() })) + +vi.mock('../src/services/rateLimiterService.js', () => rateLimiter) + +const userId = 'user-1' +const hostRef = 'host-a' + +function token(overrides: Partial[0]> = {}) { + return signRpcAccessToken({ + sub: userId, + typ: 'user', + teamId: 'team-1', + role: 'member', + scopes: ['host:task:read'], + hostRefs: [hostRef], + jti: `artifact-admission-${randomUUID()}`, + ...overrides, + }) +} + +function buildApp() { + const gateway = { + listResource: vi.fn(async () => [{ metadata: { name: hostRef }, spec: { enabled: true } }]), + getResource: vi.fn(async () => ({}) as never), + createResource: vi.fn(async () => ({})), + updateResource: vi.fn(async () => ({})), + deleteResource: vi.fn(async () => ({ ok: true })), + } + const app = express() + app.use( + createRpcAccessUsersRouter(gateway as unknown as K8sGateway, { + bindingService: { bind: vi.fn() }, + }) + ) + return { app, gateway } +} + +describe('artifact-read admission with real RPC auth and claim-match middleware', () => { + beforeEach(() => { + Object.values(directory).forEach(mock => mock.mockReset()) + rateLimiter.checkAndIncrement.mockReset() + rateLimiter.checkAndIncrement.mockResolvedValue({ + allowed: true, + remaining: 29, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + count: 1, + }) + }) + + it.each([ + ['missing token', undefined, 401], + ['invalid token', 'not-a-jwt', 401], + ['missing host:task:read scope', token({ scopes: ['host:status:read'] }), 403], + ['subject mismatch', token({ sub: 'another-user' }), 403], + ['Host absent from signed hostRefs', token({ hostRefs: ['another-host'] }), 403], + ])('%s fails before charging pre-admission', async (_case, accessToken, status) => { + const { app } = buildApp() + let req = request(app).get(`/rpc/access/users/${userId}/mcp-hosts/${hostRef}/artifact-read`) + if (accessToken) req = req.set('x-rpc-access-token', accessToken) + + await req.expect(status) + + expect(rateLimiter.checkAndIncrement).not.toHaveBeenCalled() + expect(directory.getUserAgents).not.toHaveBeenCalled() + }) +}) diff --git a/control-api/test/routes.rpcAccessUsersMcpConnectors.test.ts b/control-api/test/routes.rpcAccessUsersMcpConnectors.test.ts index a0c1105efc..4baebf6ef7 100644 --- a/control-api/test/routes.rpcAccessUsersMcpConnectors.test.ts +++ b/control-api/test/routes.rpcAccessUsersMcpConnectors.test.ts @@ -28,6 +28,7 @@ vi.mock('../src/middleware/rpcAccessAuth.js', () => ({ requireValidRpcAccessToken: () => (_req: unknown, _res: unknown, next: () => void) => next(), requireValidRpcAccessTokenAny: () => (_req: unknown, _res: unknown, next: () => void) => next(), requireRpcTokenUserMatch: () => (_req: unknown, _res: unknown, next: () => void) => next(), + requireRpcTokenHostMatch: () => (_req: unknown, _res: unknown, next: () => void) => next(), })) function buildApp() { diff --git a/control-api/test/routes.rpcAccessUsersMcpHost.test.ts b/control-api/test/routes.rpcAccessUsersMcpHost.test.ts index 838927fdb6..1cfebe0809 100644 --- a/control-api/test/routes.rpcAccessUsersMcpHost.test.ts +++ b/control-api/test/routes.rpcAccessUsersMcpHost.test.ts @@ -91,9 +91,34 @@ vi.mock('../src/middleware/rpcAccessAuth.js', async importOriginal => { authState.realMiddleware ? actual.requireValidRpcAccessTokenAny(...args)(req as never, res as never, next) : stubAuth(req, next), - requireRpcTokenUserMatch: () => (_req: unknown, _res: unknown, next: () => void) => next(), + requireRpcTokenUserMatch: + (paramName = 'userId') => + ( + req: { params: Record; rpcAuth?: Record }, + res: { status: (status: number) => { json: (body: unknown) => void } }, + next: () => void + ) => { + if (req.rpcAuth?.sub !== req.params[paramName]) { + res.status(403).json({ error: 'Forbidden' }) + return + } + next() + }, requireRpcTokenTeamMatch: () => (_req: unknown, _res: unknown, next: () => void) => next(), - requireRpcTokenHostMatch: () => (_req: unknown, _res: unknown, next: () => void) => next(), + requireRpcTokenHostMatch: + (paramName = 'hostRef') => + ( + req: { params: Record; rpcAuth?: Record }, + res: { status: (status: number) => { json: (body: unknown) => void } }, + next: () => void + ) => { + const hostRefs = req.rpcAuth?.hostRefs + if (!Array.isArray(hostRefs) || !hostRefs.includes(req.params[paramName])) { + res.status(403).json({ error: 'Forbidden' }) + return + } + next() + }, } }) @@ -105,7 +130,11 @@ afterEach(() => { type HostCRD = { metadata: { name: string }; spec?: { enabled?: boolean } } -function buildApp(hosts: HostCRD[], rpcAuth?: { teamId?: string }, bindingBudgetMs?: number) { +function buildApp( + hosts: HostCRD[], + rpcAuth?: { teamId?: string; sub?: string; hostRefs?: string[] }, + bindingBudgetMs?: number +) { const gatewayStub = { listResource: vi.fn(async () => hosts), getResource: vi.fn(async () => ({}) as never), @@ -363,27 +392,225 @@ describe('GET /rpc/access/users/:userId/mcp-hosts/:hostRef — happy path', () = }) describe('GET /rpc/access/users/:userId/mcp-hosts/:hostRef/artifact-read', () => { - it('uses the shared authenticated user and canonical Host bucket', async () => { + beforeEach(() => { + rateLimiterMock.checkAndIncrement.mockReset() + rateLimiterMock.checkAndIncrement.mockResolvedValue({ + allowed: true, + remaining: 29, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + count: 1, + }) + svc.getUserAgents.mockReset() + }) + + it('charges subject admission before authorization and the canonical Host bucket after it', async () => { const userId = 'user-a' const hostRef = 'chatllm' svc.getUserAgents.mockResolvedValue({ userId, agentNames: [hostRef] }) + const { app, gatewayStub } = buildApp([ + { metadata: { name: hostRef }, spec: { enabled: true } }, + ]) + + const order: string[] = [] + rateLimiterMock.checkAndIncrement.mockImplementation(async (key: string) => { + order.push(`limit:${key}`) + return { + allowed: true, + remaining: 29, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + count: 1, + } + }) + svc.getUserAgents.mockImplementation(async () => { + order.push('authorize:directory') + return { userId, agentNames: [hostRef] } + }) + gatewayStub.listResource.mockImplementation(async () => { + order.push('authorize:kubernetes') + return [{ metadata: { name: hostRef }, spec: { enabled: true } }] + }) + + await request(app) + .get(`/rpc/access/users/${userId}/mcp-hosts/${hostRef}/artifact-read`) + .expect(200) + + expect(order).toEqual([ + `limit:host-artifact-pre-admission:${userId}`, + 'authorize:directory', + 'authorize:kubernetes', + `limit:host-artifact-read:${userId}:${hostRef}`, + ]) + }) + + it('denies request 31 before any additional expensive Host authorization', async () => { + const userId = 'user-a' + const hostRef = 'chatllm' + svc.getUserAgents.mockResolvedValue({ userId, agentNames: [hostRef] }) + const { app, gatewayStub } = buildApp([ + { metadata: { name: hostRef }, spec: { enabled: true } }, + ]) + const counts = new Map() + rateLimiterMock.checkAndIncrement.mockImplementation(async (key: string, max: number) => { + const count = (counts.get(key) ?? 0) + 1 + counts.set(key, count) + return { + allowed: count <= max, + remaining: Math.max(0, max - count), + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + count, + } + }) + + const statuses: number[] = [] + for (let attempt = 1; attempt <= 31; attempt += 1) { + const response = await request(app).get( + `/rpc/access/users/${userId}/mcp-hosts/${hostRef}/artifact-read` + ) + statuses.push(response.status) + } + + expect({ + statuses: [statuses[0], statuses[29], statuses[30]], + subjectAdmissionCalls: counts.get(`host-artifact-pre-admission:${userId}`), + canonicalHostCalls: counts.get(`host-artifact-read:${userId}:${hostRef}`), + directoryLookups: svc.getUserAgents.mock.calls.length, + kubernetesHostLists: gatewayStub.listResource.mock.calls.length, + }).toEqual({ + statuses: [200, 200, 429], + subjectAdmissionCalls: 31, + canonicalHostCalls: 30, + directoryLookups: 30, + kubernetesHostLists: 30, + }) + }) + + it('shares one subject budget across retained stale signed Host refs', async () => { + const userId = 'user-stale' + const staleRefs = ['deleted-host-a', 'deleted-host-b'] + svc.getUserAgents.mockResolvedValue({ userId, agentNames: staleRefs }) + const { app, gatewayStub } = buildApp([], { sub: userId, hostRefs: staleRefs }) + const counts = new Map() + rateLimiterMock.checkAndIncrement.mockImplementation(async (key: string, max: number) => { + const count = (counts.get(key) ?? 0) + 1 + counts.set(key, count) + return { + allowed: count <= max, + remaining: Math.max(0, max - count), + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + count, + } + }) + + const statuses: number[] = [] + for (let attempt = 1; attempt <= 31; attempt += 1) { + const hostRef = staleRefs[(attempt - 1) % staleRefs.length] + const response = await request(app).get( + `/rpc/access/users/${userId}/mcp-hosts/${hostRef}/artifact-read` + ) + statuses.push(response.status) + } + + expect({ + statuses: [statuses[0], statuses[29], statuses[30]], + subjectAdmissionCalls: counts.get(`host-artifact-pre-admission:${userId}`), + canonicalHostBuckets: [...counts.keys()].filter(key => key.startsWith('host-artifact-read:')), + directoryLookups: svc.getUserAgents.mock.calls.length, + kubernetesHostLists: gatewayStub.listResource.mock.calls.length, + }).toEqual({ + statuses: [403, 403, 429], + subjectAdmissionCalls: 31, + canonicalHostBuckets: [], + directoryLookups: 30, + kubernetesHostLists: 30, + }) + }) + + it('does not charge admission for user or signed-Host mismatch', async () => { rateLimiterMock.checkAndIncrement.mockResolvedValue({ - allowed: false, - remaining: 0, + allowed: true, + remaining: 29, resetMs: Date.now() + 60_000, windowStartMs: Date.now(), - count: 31, + count: 1, }) + const { app } = buildApp([], { sub: 'user-a', hostRefs: ['host-a', 'host-b'] }) + + await request(app).get('/rpc/access/users/user-b/mcp-hosts/host-a/artifact-read').expect(403) + await request(app) + .get('/rpc/access/users/user-a/mcp-hosts/not-signed/artifact-read') + .expect(403) + + expect(rateLimiterMock.checkAndIncrement).not.toHaveBeenCalled() + expect(svc.getUserAgents).not.toHaveBeenCalled() + }) + + it('keeps subject budgets isolated while canonical Host buckets remain subject+Host scoped', async () => { + const hostA = 'host-a' + const hostB = 'host-b' + svc.getUserAgents.mockImplementation(async (userId: string) => ({ + userId, + agentNames: [hostA, hostB], + })) + const hosts = [ + { metadata: { name: hostA }, spec: { enabled: true } }, + { metadata: { name: hostB }, spec: { enabled: true } }, + ] + const appA = buildApp(hosts, { sub: 'subject-a', hostRefs: [hostA, hostB] }) + const appB = buildApp(hosts, { sub: 'subject-b', hostRefs: [hostA] }) + + await request(appA.app) + .get(`/rpc/access/users/subject-a/mcp-hosts/${hostA}/artifact-read`) + .expect(200) + await request(appA.app) + .get(`/rpc/access/users/subject-a/mcp-hosts/${hostB}/artifact-read`) + .expect(200) + await request(appB.app) + .get(`/rpc/access/users/subject-b/mcp-hosts/${hostA}/artifact-read`) + .expect(200) + + const keys = rateLimiterMock.checkAndIncrement.mock.calls.map(([key]) => key) + expect(keys).toEqual([ + 'host-artifact-pre-admission:subject-a', + 'host-artifact-read:subject-a:host-a', + 'host-artifact-pre-admission:subject-a', + 'host-artifact-read:subject-a:host-b', + 'host-artifact-pre-admission:subject-b', + 'host-artifact-read:subject-b:host-a', + ]) + }) + + it('preserves the canonical downstream 429 and Retry-After response', async () => { + const userId = 'user-a' + const hostRef = 'chatllm' + svc.getUserAgents.mockResolvedValue({ userId, agentNames: [hostRef] }) const { app } = buildApp([{ metadata: { name: hostRef }, spec: { enabled: true } }]) + rateLimiterMock.checkAndIncrement.mockImplementation(async (key: string) => ({ + allowed: !key.startsWith('host-artifact-read:'), + remaining: 0, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + count: 31, + })) await request(app) .get(`/rpc/access/users/${userId}/mcp-hosts/${hostRef}/artifact-read`) .expect(429) - .expect(({ body }) => { + .expect(({ body, headers }) => { expect(body.error).toBe('Too Many Requests') + expect(headers['retry-after']).toBeDefined() }) - expect(rateLimiterMock.checkAndIncrement).toHaveBeenCalledWith( + expect(rateLimiterMock.checkAndIncrement).toHaveBeenNthCalledWith( + 1, + `host-artifact-pre-admission:${userId}`, + 30 + ) + expect(rateLimiterMock.checkAndIncrement).toHaveBeenNthCalledWith( + 2, `host-artifact-read:${userId}:${hostRef}`, 30 ) diff --git a/control-api/test/services.rateLimiter.realPostgres.integration.test.ts b/control-api/test/services.rateLimiter.realPostgres.integration.test.ts index 4d086c91f7..e2399df372 100644 --- a/control-api/test/services.rateLimiter.realPostgres.integration.test.ts +++ b/control-api/test/services.rateLimiter.realPostgres.integration.test.ts @@ -23,6 +23,7 @@ function quoteIdent(value: string): string { describeRealPostgres('rate limiter atomicity on real PostgreSQL', () => { const database = `rate_limiter_${randomBytes(6).toString('hex')}` const bucketKey = `real-pg:${randomBytes(8).toString('hex')}` + const preAdmissionKey = `host-artifact-pre-admission:user-${randomBytes(8).toString('hex')}` const connectionString = databaseUrl( adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', database @@ -39,7 +40,9 @@ describeRealPostgres('rate limiter atomicity on real PostgreSQL', () => { }, 60_000) afterAll(async () => { - await pool?.query('DELETE FROM rate_limit_buckets WHERE bucket_key = $1', [bucketKey]) + await pool?.query('DELETE FROM rate_limit_buckets WHERE bucket_key = ANY($1)', [ + [bucketKey, preAdmissionKey], + ]) await pool?.end() if (!adminPool) return await adminPool.query( @@ -72,6 +75,38 @@ describeRealPostgres('rate limiter atomicity on real PostgreSQL', () => { expect(Number(persisted.rows[0]?.count)).toBe(32) }) + it('shares subject-only artifact admission atomically across independent PostgreSQL sessions', async () => { + const clients = [await pool.connect(), await pool.connect()] + try { + const results = [] + for (let round = 0; round < 16; round += 1) { + const concurrentResults = await Promise.all( + clients.map(client => + checkAndIncrementWithQuery( + (text, values) => client.query(text, values), + preAdmissionKey, + 30, + windowStartMs, + 1 + ) + ) + ) + results.push(...concurrentResults) + } + const counts = results.map(result => result.count).sort((a, b) => a - b) + expect(counts).toEqual(Array.from({ length: 32 }, (_, index) => index + 1)) + expect(results.filter(result => result.allowed)).toHaveLength(30) + + const persisted = await pool.query<{ count: string }>( + 'SELECT count FROM rate_limit_buckets WHERE bucket_key = $1 AND window_start_ms = $2', + [preAdmissionKey, windowStartMs] + ) + expect(Number(persisted.rows[0]?.count)).toBe(32) + } finally { + clients.forEach(client => client.release()) + } + }) + it('serializes the production bounded advisory lease across independent PostgreSQL sessions', async () => { const key = `gfs-upload-slot:${randomBytes(8).toString('hex')}` const first = await pool.connect() From a2adf85f2b3e35a4f7df6deb7084b17ae68ac9f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Wed, 16 Sep 2026 19:56:17 -0600 Subject: [PATCH 204/528] fix(codeql): R31-H1 model distributed artifact admission Class-check: canonical distributed artifact admission -> list/download -> exact structural model. Repro-test: MissingRateLimitingDistributedArtifactAdmission fails at d895141a58da. --- .../queries/EvenfireMissingRateLimiting.ql | 279 +++++++++++++++++- .../safe/EvenfireMissingRateLimiting.expected | 8 + .../safe/EvenfireMissingRateLimiting.qlref | 1 + .../safe/control-api/src/app.ts | 11 + .../safe/control-api/src/config.ts | 1 + .../src/middleware/internalServiceAuth.ts | 4 + .../src/middleware/rateLimitMiddleware.ts | 3 + .../src/middleware/rpcAccessAuth.ts | 16 + .../src/routes/rpc-access/index.ts | 2 + .../src/routes/rpc-access/users.ts | 47 +++ .../safe/rpc-proxy/src/middleware/auth.ts | 6 + .../safe/rpc-proxy/src/routes/rpc.ts | 64 ++++ .../src/services/controlApiRestService.ts | 16 + .../src/services/fakeMcpProxyService.ts | 7 + .../rpc-proxy/src/services/mcpProxyService.ts | 5 + 15 files changed, 468 insertions(+), 2 deletions(-) create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/EvenfireMissingRateLimiting.expected create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/EvenfireMissingRateLimiting.qlref create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/app.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/config.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/middleware/internalServiceAuth.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/middleware/rateLimitMiddleware.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/middleware/rpcAccessAuth.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/routes/rpc-access/index.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/routes/rpc-access/users.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/middleware/auth.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/routes/rpc.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/services/controlApiRestService.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/services/fakeMcpProxyService.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/services/mcpProxyService.ts diff --git a/.github/codeql/queries/EvenfireMissingRateLimiting.ql b/.github/codeql/queries/EvenfireMissingRateLimiting.ql index 56c3f4eb3e..6e75028962 100644 --- a/.github/codeql/queries/EvenfireMissingRateLimiting.ql +++ b/.github/codeql/queries/EvenfireMissingRateLimiting.ql @@ -24,6 +24,31 @@ private predicate isCanonicalEvenfireRateLimitImport(ImportSpecifier spec) { "control-api/src/middleware/rateLimitMiddleware.ts" } +private predicate isExactImportedValue(Expr value, string path, string importedName) { + exists(ImportSpecifier spec, VarAccess access | + spec.getImportedName() = importedName and + spec.getImportDeclaration().getImportedFile().getRelativePath() = path and + value = access and + access.getVariable() = spec.getLocal().getVariable() + ) +} + +private predicate isExactImportedCall(CallExpr call, string path, string importedName) { + exists(VarAccess callee | + call.getCallee() = callee and + isExactImportedValue(callee, path, importedName) + ) +} + +private predicate isInstalledRouteArgument( + MethodCallExpr registration, Routing::Node node, int index +) { + exists(Expr argument | + argument = registration.getArgument(index) and + node = Routing::getNode(DataFlow::valueNode(argument)) + ) +} + private class EvenfireRateLimitingMiddleware extends RateLimitingMiddleware, DataFlow::CallNode { EvenfireRateLimitingMiddleware() { exists(ImportSpecifier spec | @@ -85,12 +110,262 @@ private predicate hasEvenfireRateLimitingGuard(Routing::Node useSite) { hasSameRouteEvenfireLimiterAfterContext(useSite) } +/** + * R30 admission is distributed across the rpc-proxy artifact resolver and the + * authoritative Control API. This predicate intentionally uses exact module + * identities and route/middleware structure rather than inter-service flow. + */ +private predicate isCanonicalArtifactReadPath(Expr path) { + exists( + VariableDeclarator baseDeclaration, VarDecl baseBinding, StringLiteral basePath, + TemplateLiteral artifactPath, VarAccess baseAccess, TemplateElement suffix + | + path = artifactPath and + artifactPath.getFile().getRelativePath() = "control-api/src/routes/rpc-access/users.ts" and + baseDeclaration.getDeclStmt() instanceof ConstDeclStmt and + baseDeclaration.getBindingPattern() = baseBinding and + baseDeclaration.getInit() = basePath and + basePath.getStringValue() = "/rpc/access/users/:userId/mcp-hosts/:hostRef" and + artifactPath.getNumElement() = 2 and + artifactPath.getElement(0) = baseAccess and + baseAccess.getVariable() = baseBinding.getVariable() and + artifactPath.getElement(1) = suffix and + suffix.getRawValue() = "/artifact-read" + ) +} + +private predicate isSubjectOnlyArtifactPreAdmission(CallExpr admission) { + exists( + ObjectExpr options, Property bucketType, Property maxPerMinute, PropAccess configuredLimit, + Property keyGenerator, Function keyFunction, VarAccess requestParameter, + PropAccess rpcAuth, PropAccess subjectAccess, + VariableDeclarator subjectDeclaration, + VarDecl subjectBinding, TemplateLiteral bucketKey, TemplateElement keyPrefix + | + isExactImportedCall(admission, "control-api/src/middleware/rateLimitMiddleware.ts", + "rateLimitMiddleware") and + admission.getArgument(0) = options and + bucketType = options.getPropertyByName("bucketType") and + bucketType.getInit().getStringValue() = "host_artifact_pre_admission" and + maxPerMinute = options.getPropertyByName("maxPerMinute") and + maxPerMinute.getInit() = configuredLimit and + configuredLimit.getPropertyName() = "hostArtifactReadRlPerMin" and + isExactImportedValue(configuredLimit.getBase(), "control-api/src/config.ts", "config") and + keyGenerator = options.getPropertyByName("getBucketKey") and + keyGenerator.getInit() = keyFunction and + requestParameter.getVariable() = keyFunction.getParameter(0).getVariable() and + rpcAuth.getPropertyName() = "rpcAuth" and + rpcAuth.getBase().getAChild*() = requestParameter and + subjectDeclaration.getInit().getAChild*() = subjectAccess and + subjectAccess.getBase() = rpcAuth and + subjectAccess.getPropertyName() = "sub" and + subjectDeclaration.getDeclStmt() instanceof ConstDeclStmt and + subjectDeclaration.getBindingPattern() = subjectBinding and + subjectDeclaration.getEnclosingFunction() = keyFunction and + bucketKey.getEnclosingFunction() = keyFunction and + bucketKey.getNumElement() = 2 and + bucketKey.getElement(0) = keyPrefix and + keyPrefix.getValue() = "host-artifact-pre-admission:" and + bucketKey.getElement(1).(VarAccess).getVariable() = subjectBinding.getVariable() and + not exists(PropAccess hostSelector | + hostSelector.getEnclosingFunction() = keyFunction and + hostSelector.getPropertyName() = "hostRef" + ) + ) +} + +private predicate isCanonicalHostArtifactAdmission(CallExpr limiter) { + exists( + ObjectExpr options, Property bucketType, Property maxPerMinute, PropAccess configuredLimit, + Property keyGenerator, Function keyFunction, PropAccess canonicalHost, + PropAccess artifactConnection, PropAccess rpcAuth, PropAccess subjectAccess, + TemplateLiteral bucketKey, TemplateElement keyPrefix + | + isExactImportedCall(limiter, "control-api/src/middleware/rateLimitMiddleware.ts", + "rateLimitMiddleware") and + limiter.getArgument(0) = options and + bucketType = options.getPropertyByName("bucketType") and + bucketType.getInit().getStringValue() = "host_artifact_read" and + maxPerMinute = options.getPropertyByName("maxPerMinute") and + maxPerMinute.getInit() = configuredLimit and + configuredLimit.getPropertyName() = "hostArtifactReadRlPerMin" and + isExactImportedValue(configuredLimit.getBase(), "control-api/src/config.ts", "config") and + keyGenerator = options.getPropertyByName("getBucketKey") and + keyGenerator.getInit() = keyFunction and + rpcAuth.getEnclosingFunction() = keyFunction and + rpcAuth.getPropertyName() = "rpcAuth" and + subjectAccess.getBase() = rpcAuth and + subjectAccess.getPropertyName() = "sub" and + artifactConnection.getPropertyName() = "artifactReadConnection" and + artifactConnection.getEnclosingFunction() = keyFunction and + canonicalHost.getBase().getAChild*() = artifactConnection and + canonicalHost.getPropertyName() = "hostRef" and + bucketKey.getEnclosingFunction() = keyFunction and + bucketKey.getNumElement() = 4 and + bucketKey.getElement(0) = keyPrefix and + keyPrefix.getRawValue() = "host-artifact-read:" + ) +} + +private predicate isCanonicalArtifactProducer(MethodCallExpr registration) { + exists( + CallExpr tokenCheck, ArrayExpr scopes, StringLiteral scope, + CallExpr userMatch, CallExpr hostMatch, CallExpr preAdmission, Function authorizationHandler, + CallExpr liveAuthorization, CallExpr hostAdmission + | + registration.getMethodName() = "get" and + registration.getFile().getRelativePath() = "control-api/src/routes/rpc-access/users.ts" and + isCanonicalArtifactReadPath(registration.getArgument(0)) and + tokenCheck = registration.getArgument(1) and + isExactImportedCall(tokenCheck, "control-api/src/middleware/rpcAccessAuth.ts", + "requireValidRpcAccessTokenAny") and + tokenCheck.getArgument(0) = scopes and + scopes.getSize() = 1 and + scopes.getElement(0) = scope and + scope.getStringValue() = "host:task:read" and + userMatch = registration.getArgument(2) and + isExactImportedCall(userMatch, "control-api/src/middleware/rpcAccessAuth.ts", + "requireRpcTokenUserMatch") and + hostMatch = registration.getArgument(3) and + isExactImportedCall(hostMatch, "control-api/src/middleware/rpcAccessAuth.ts", + "requireRpcTokenHostMatch") and + preAdmission = registration.getArgument(4) and + isSubjectOnlyArtifactPreAdmission(preAdmission) and + authorizationHandler = registration.getArgument(5) and + liveAuthorization.getEnclosingFunction() = authorizationHandler and + liveAuthorization.getCalleeName() = "resolveAuthorizedHostConnection" and + hostAdmission = registration.getArgument(6) and + isCanonicalHostArtifactAdmission(hostAdmission) + ) +} + +private predicate isCanonicalArtifactProducerGuard(Routing::Node useSite) { + exists(MethodCallExpr registration | + isCanonicalArtifactProducer(registration) and + isInstalledRouteArgument(registration, useSite, 1) + ) +} + +private predicate isCanonicalArtifactTransport() { + exists( + Function resolver, CallExpr clientCall, Function client, CallExpr fetchCall, + ObjectExpr options, Property artifactRead, Function pathFetcher, + ConditionalExpr endpointChoice, PropAccess artifactReadFlag, TemplateLiteral artifactEndpoint, + TemplateElement artifactSuffix + | + resolver.getFile().getRelativePath() = "rpc-proxy/src/services/mcpProxyService.ts" and + resolver.getName() = "resolveArtifactReadHostConnectionForUser" and + clientCall.getEnclosingFunction() = resolver and + isExactImportedCall(clientCall, "rpc-proxy/src/services/controlApiRestService.ts", + "fetchArtifactReadHostConnectionFromControlApi") and + client.getFile().getRelativePath() = "rpc-proxy/src/services/controlApiRestService.ts" and + client.getName() = "fetchArtifactReadHostConnectionFromControlApi" and + fetchCall.getEnclosingFunction() = client and + fetchCall.getCalleeName() = "fetchHostConnectionForPath" and + fetchCall.getArgument(3) = options and + artifactRead = options.getPropertyByName("artifactRead") and + artifactRead.getInit().(BooleanLiteral).getBoolValue() = true and + pathFetcher.getFile().getRelativePath() = "rpc-proxy/src/services/controlApiRestService.ts" and + pathFetcher.getName() = "fetchHostConnectionForPath" and + endpointChoice.getEnclosingFunction() = pathFetcher and + artifactReadFlag.getPropertyName() = "artifactRead" and + endpointChoice.getCondition().getAChild*() = artifactReadFlag and + endpointChoice.getConsequent() = artifactEndpoint and + artifactEndpoint.getNumElement() = 2 and + artifactEndpoint.getElement(1) = artifactSuffix and + artifactSuffix.getRawValue() = "/artifact-read" + ) +} + +private predicate hasCanonicalArtifactServiceBoundary() { + exists( + Function createApp, MethodCallExpr internalTokenMount, MethodCallExpr internalServiceMount, + MethodCallExpr routerMount, CallExpr internalService, StringLiteral rpcProxyName + | + createApp.getFile().getRelativePath() = "control-api/src/app.ts" and + createApp.getName() = "createApp" and + internalTokenMount.getEnclosingFunction() = createApp and + internalTokenMount.getMethodName() = "use" and + isExactImportedValue(internalTokenMount.getArgument(0), + "control-api/src/middleware/internalServiceAuth.ts", "requireInternalToken") and + internalServiceMount.getEnclosingFunction() = createApp and + internalServiceMount.getMethodName() = "use" and + internalServiceMount.getArgument(0).getStringValue() = "/rpc" and + internalService = internalServiceMount.getArgument(1) and + isExactImportedCall(internalService, "control-api/src/middleware/internalServiceAuth.ts", + "requireInternalService") and + internalService.getArgument(0) = rpcProxyName and + rpcProxyName.getStringValue() = "rpc-proxy" and + routerMount.getEnclosingFunction() = createApp and + routerMount.getMethodName() = "use" and + isExactImportedCall(routerMount.getArgument(0).(CallExpr), + "control-api/src/routes/rpc-access/index.ts", "createRpcAccessRouter") + ) +} + +private predicate isCanonicalArtifactResolver(Expr resolverValue) { + exists( + VariableDeclarator declaration, VarDecl binding, Function resolver, VarAccess resolverAccess, + CallExpr clientCall + | + declaration.getDeclStmt() instanceof ConstDeclStmt and + declaration.getBindingPattern() = binding and + binding.getVariable().getName() = "resolveArtifactReadHost" and + declaration.getInit() = resolver and + resolverValue = resolverAccess and + resolverAccess.getVariable() = binding.getVariable() and + resolver.getFile().getRelativePath() = "rpc-proxy/src/routes/rpc.ts" and + clientCall.getEnclosingFunction() = resolver and + isExactImportedCall(clientCall, "rpc-proxy/src/services/mcpProxyService.ts", + "resolveArtifactReadHostConnectionForUser") and + not exists(ImportSpecifier alternateImport, CallExpr alternateCall | + alternateImport.getImportDeclaration().getImportedFile().getRelativePath() = + "rpc-proxy/src/services/mcpProxyService.ts" and + alternateImport.getImportedName() != "resolveArtifactReadHostConnectionForUser" and + alternateCall.getEnclosingFunction() = resolver and + alternateCall.getCallee().(VarAccess).getVariable() = alternateImport.getLocal().getVariable() + ) and + not exists(CallExpr directFallback | + directFallback.getEnclosingFunction() = resolver and + directFallback.getCalleeName() = "fetch" + ) + ) +} + +private predicate isCanonicalArtifactProxyGuard(Routing::Node useSite) { + exists( + MethodCallExpr registration, StringLiteral path, CallExpr scope, Expr resolver, + int index + | + registration.getMethodName() = "get" and + registration.getFile().getRelativePath() = "rpc-proxy/src/routes/rpc.ts" and + registration.getArgument(0) = path and + path.getStringValue() in [ + "/rpc/hosts/:hostRef/artifacts", + "/rpc/hosts/:hostRef/artifacts/:filename/download" + ] and + isExactImportedValue(registration.getArgument(1), "rpc-proxy/src/middleware/auth.ts", + "requireRpcAuth") and + scope = registration.getArgument(2) and + isExactImportedCall(scope, "rpc-proxy/src/middleware/auth.ts", "requireScope") and + scope.getArgument(0).getStringValue() = "host:task:read" and + resolver = registration.getArgument(3) and + isCanonicalArtifactResolver(resolver) and + isCanonicalArtifactTransport() and + hasCanonicalArtifactServiceBoundary() and + exists(MethodCallExpr producer | isCanonicalArtifactProducer(producer)) and + (index = 1 or index = 3) and + isInstalledRouteArgument(registration, useSite, index) + ) +} + private predicate hasRateLimitingGuard(Routing::Node useSite) { + isCanonicalArtifactProxyGuard(useSite) or + isCanonicalArtifactProducerGuard(useSite) or exists(RateLimitingMiddleware middleware | useSite.isGuardedByNode(middleware.getRoutingNode()) and not middleware instanceof EvenfireRateLimitingMiddleware - ) - or + ) or hasEvenfireRateLimitingGuard(useSite) } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/EvenfireMissingRateLimiting.expected b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/EvenfireMissingRateLimiting.expected new file mode 100644 index 0000000000..99af8020e3 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/EvenfireMissingRateLimiting.expected @@ -0,0 +1,8 @@ +| rpc-proxy/src/routes/rpc.ts:47:47:47:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:47:95:47:117 | fakeRes ... actHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:17:93:17:113 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:51:66:51:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:51:114:51:134 | wrongEn ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:23:63:23:83 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:55:47:55:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:30:95:30:115 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:32:65:32:85 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:59:66:59:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/EvenfireMissingRateLimiting.qlref b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/EvenfireMissingRateLimiting.qlref new file mode 100644 index 0000000000..7d3c25d5ca --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/EvenfireMissingRateLimiting.qlref @@ -0,0 +1 @@ +query: EvenfireMissingRateLimiting.ql diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/app.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/app.ts new file mode 100644 index 0000000000..4ece8564ed --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/app.ts @@ -0,0 +1,11 @@ +import express from 'express' +import { requireInternalService, requireInternalToken } from './middleware/internalServiceAuth.js' +import { createRpcAccessRouter } from './routes/rpc-access/index.js' + +export function createApp() { + const api = express.Router() + api.use(requireInternalToken) + api.use('/rpc', requireInternalService('rpc-proxy')) + api.use(createRpcAccessRouter()) + return api +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/config.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/config.ts new file mode 100644 index 0000000000..c8274c450a --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/config.ts @@ -0,0 +1 @@ +export const config = { hostArtifactReadRlPerMin: 30 } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/middleware/internalServiceAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/middleware/internalServiceAuth.ts new file mode 100644 index 0000000000..7224c950a0 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/middleware/internalServiceAuth.ts @@ -0,0 +1,4 @@ +export function requireInternalToken(_req: unknown, _res: unknown, next: () => void) { next() } +export function requireInternalService(_name: string) { + return (_req: unknown, _res: unknown, next: () => void) => next() +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/middleware/rateLimitMiddleware.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/middleware/rateLimitMiddleware.ts new file mode 100644 index 0000000000..32f6b8ea64 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/middleware/rateLimitMiddleware.ts @@ -0,0 +1,3 @@ +export function rateLimitMiddleware(_options: unknown) { + return (_req: unknown, _res: unknown, next: () => void) => next() +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/middleware/rpcAccessAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/middleware/rpcAccessAuth.ts new file mode 100644 index 0000000000..b498d6f7a8 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/middleware/rpcAccessAuth.ts @@ -0,0 +1,16 @@ +declare function verifyToken(): void + +export function requireValidRpcAccessTokenAny(_scopes: string[]) { + return (_req: unknown, _res: unknown, next: () => void) => { + verifyToken() + next() + } +} + +export function requireRpcTokenUserMatch() { + return (_req: unknown, _res: unknown, next: () => void) => next() +} + +export function requireRpcTokenHostMatch() { + return (_req: unknown, _res: unknown, next: () => void) => next() +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/routes/rpc-access/index.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/routes/rpc-access/index.ts new file mode 100644 index 0000000000..1d3b027b78 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/routes/rpc-access/index.ts @@ -0,0 +1,2 @@ +import { createRpcAccessUsersRouter } from './users.js' +export function createRpcAccessRouter() { return createRpcAccessUsersRouter() } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/routes/rpc-access/users.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/routes/rpc-access/users.ts new file mode 100644 index 0000000000..0fbe3ec244 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/routes/rpc-access/users.ts @@ -0,0 +1,47 @@ +import { Router } from 'express' +import { config } from '../../config.js' +import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' +import { + requireRpcTokenHostMatch, + requireRpcTokenUserMatch, + requireValidRpcAccessTokenAny, +} from '../../middleware/rpcAccessAuth.js' + +type ArtifactRequest = { rpcAuth?: { sub?: string }; artifactReadConnection?: { hostRef: string } } + +declare function resolveAuthorizedHostConnection(req: ArtifactRequest): Promise<{ hostRef: string }> + +export function createRpcAccessUsersRouter() { + const router = Router() + const hostAccessPath = '/rpc/access/users/:userId/mcp-hosts/:hostRef' + router.get( + `${hostAccessPath}/artifact-read`, + requireValidRpcAccessTokenAny(['host:task:read']), + requireRpcTokenUserMatch(), + requireRpcTokenHostMatch(), + rateLimitMiddleware({ + bucketType: 'host_artifact_pre_admission', + maxPerMinute: config.hostArtifactReadRlPerMin, + getBucketKey: req => { + const subject = (req as ArtifactRequest).rpcAuth?.sub + return subject ? `host-artifact-pre-admission:${subject}` : null + }, + }), + async (req: ArtifactRequest, _res, next) => { + req.artifactReadConnection = await resolveAuthorizedHostConnection(req) + next() + }, + rateLimitMiddleware({ + bucketType: 'host_artifact_read', + maxPerMinute: config.hostArtifactReadRlPerMin, + getBucketKey: req => { + const artifactRead = req as ArtifactRequest + const subject = artifactRead.rpcAuth?.sub + const hostRef = artifactRead.artifactReadConnection?.hostRef + return subject && hostRef ? `host-artifact-read:${subject}:${hostRef}` : null + }, + }), + (_req, res) => res.status(200).json({ ok: true }), + ) + return router +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/middleware/auth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/middleware/auth.ts new file mode 100644 index 0000000000..01476c7883 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/middleware/auth.ts @@ -0,0 +1,6 @@ +declare function verifyRpcToken(): void +export function requireRpcAuth(_req: unknown, _res: unknown, next: () => void) { verifyRpcToken(); next() } +export function requireScope(_scope: string) { + return (_req: unknown, _res: unknown, next: () => void) => next() +} +export function extractAuthToken(_req: unknown): string { return 'token' } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/routes/rpc.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/routes/rpc.ts new file mode 100644 index 0000000000..f3963fdbb6 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/routes/rpc.ts @@ -0,0 +1,64 @@ +import express from 'express' +import { extractAuthToken, requireRpcAuth, requireScope } from '../middleware/auth.js' +import { resolveArtifactReadHostConnectionForUser } from '../services/mcpProxyService.js' +import { resolveArtifactReadHostConnectionForUser as fakeClient } from '../services/fakeMcpProxyService.js' + +type ArtifactRequest = express.Request & { auth?: { sub: string }; artifactReadHost?: { url: string } } + +const resolveArtifactReadHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + const hostRef = String(req.params.hostRef || '').trim() + req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, hostRef, extractAuthToken(req)) + next() +} + +const fakeResolveArtifactHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) + next() +} + +const wrongEndpointResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) + next() +} + +const fallbackArtifactResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + try { + req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) + } catch { + req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) + } + next() +} + +export function createRpcRouter() { + const router = express.Router() + router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts/download') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fakeResolveArtifactHost, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), wrongEndpointResolver, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts/download') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fallbackArtifactResolver, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts/download') + res.sendStatus(200) + }) + return router +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/services/controlApiRestService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/services/controlApiRestService.ts new file mode 100644 index 0000000000..8d4a12b6fd --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/services/controlApiRestService.ts @@ -0,0 +1,16 @@ +type Options = { artifactRead?: boolean } + +async function fetchHostConnectionForPath( + userId: string, + hostRef: string, + token: string, + options: Options, +): Promise<{ url: string } | null> { + const base = `/rpc/access/users/${userId}/mcp-hosts/${hostRef}` + const endpoint = options.artifactRead ? `${base}/artifact-read` : base + return fetch(endpoint, { headers: { authorization: `Bearer ${token}` } }) as never +} + +export async function fetchArtifactReadHostConnectionFromControlApi(userId: string, hostRef: string, token: string) { + return fetchHostConnectionForPath(userId, hostRef, token, { artifactRead: true }) +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/services/fakeMcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/services/fakeMcpProxyService.ts new file mode 100644 index 0000000000..c03af01f0a --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/services/fakeMcpProxyService.ts @@ -0,0 +1,7 @@ +export async function resolveArtifactReadHostConnectionForUser( + _userId: string, + _hostRef: string, + _token: string, +) { + return { url: 'https://unprotected.example' } +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/services/mcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/services/mcpProxyService.ts new file mode 100644 index 0000000000..e314b09f56 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/services/mcpProxyService.ts @@ -0,0 +1,5 @@ +import { fetchArtifactReadHostConnectionFromControlApi } from './controlApiRestService.js' + +export async function resolveArtifactReadHostConnectionForUser(userId: string, hostRef: string, token: string) { + return fetchArtifactReadHostConnectionFromControlApi(userId, hostRef, token) +} From f69453f54bf97eb8c13dcb3879fe7a2c225f04c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Wed, 16 Sep 2026 22:47:36 -0600 Subject: [PATCH 205/528] fix(codeql): require canonical R32-H1 artifact producer Class-check: artifact resolver producer -> 2 routes -> exact canonical producer only Repro-test: unsafe-local-fake-producer is red at 66394815991d6284b483c4ae4fa1f253e67e1bc0 --- .../queries/EvenfireMissingRateLimiting.ql | 167 +++++++++++++++++- .../src/routes/rpc-access/index.ts | 8 +- .../src/routes/rpc-access/users.ts | 34 +++- .../access/rpcHostAccessAuthorizer.ts | 9 + .../EvenfireMissingRateLimiting.expected | 13 ++ .../EvenfireMissingRateLimiting.qlref | 1 + .../control-api/src/app.ts | 11 ++ .../control-api/src/config.ts | 1 + .../src/middleware/internalServiceAuth.ts | 4 + .../src/middleware/rateLimitMiddleware.ts | 3 + .../src/middleware/rpcAccessAuth.ts | 16 ++ .../src/routes/rpc-access/index.ts | 8 + .../src/routes/rpc-access/users.ts | 70 ++++++++ .../access/rpcHostAccessAuthorizer.ts | 9 + .../rpc-proxy/src/middleware/auth.ts | 6 + .../rpc-proxy/src/routes/rpc.ts | 64 +++++++ .../src/services/controlApiRestService.ts | 16 ++ .../src/services/fakeMcpProxyService.ts | 7 + .../rpc-proxy/src/services/mcpProxyService.ts | 5 + .../EvenfireMissingRateLimiting.expected | 13 ++ .../EvenfireMissingRateLimiting.qlref | 1 + .../control-api/src/app.ts | 11 ++ .../control-api/src/config.ts | 1 + .../src/middleware/internalServiceAuth.ts | 4 + .../src/middleware/rateLimitMiddleware.ts | 3 + .../src/middleware/rpcAccessAuth.ts | 16 ++ .../rpc-access/fakeRpcHostAccessAuthorizer.ts | 9 + .../src/routes/rpc-access/index.ts | 8 + .../src/routes/rpc-access/users.ts | 69 ++++++++ .../access/rpcHostAccessAuthorizer.ts | 9 + .../rpc-proxy/src/middleware/auth.ts | 6 + .../rpc-proxy/src/routes/rpc.ts | 64 +++++++ .../src/services/controlApiRestService.ts | 16 ++ .../src/services/fakeMcpProxyService.ts | 7 + .../rpc-proxy/src/services/mcpProxyService.ts | 5 + .../EvenfireMissingRateLimiting.expected | 13 ++ .../EvenfireMissingRateLimiting.qlref | 1 + .../control-api/src/app.ts | 11 ++ .../control-api/src/config.ts | 1 + .../src/middleware/internalServiceAuth.ts | 4 + .../src/middleware/rateLimitMiddleware.ts | 3 + .../src/middleware/rpcAccessAuth.ts | 16 ++ .../fakeResolveAuthorizedHostConnection.ts | 8 + .../src/routes/rpc-access/index.ts | 8 + .../src/routes/rpc-access/users.ts | 55 ++++++ .../access/rpcHostAccessAuthorizer.ts | 9 + .../rpc-proxy/src/middleware/auth.ts | 6 + .../rpc-proxy/src/routes/rpc.ts | 64 +++++++ .../src/services/controlApiRestService.ts | 16 ++ .../src/services/fakeMcpProxyService.ts | 7 + .../rpc-proxy/src/services/mcpProxyService.ts | 5 + .../EvenfireMissingRateLimiting.expected | 14 ++ .../EvenfireMissingRateLimiting.qlref | 1 + .../control-api/src/app.ts | 11 ++ .../control-api/src/config.ts | 1 + .../src/middleware/internalServiceAuth.ts | 4 + .../src/middleware/rateLimitMiddleware.ts | 3 + .../src/middleware/rpcAccessAuth.ts | 16 ++ .../src/routes/rpc-access/index.ts | 8 + .../src/routes/rpc-access/users.ts | 69 ++++++++ .../access/rpcHostAccessAuthorizer.ts | 9 + .../rpc-proxy/src/middleware/auth.ts | 6 + .../rpc-proxy/src/routes/rpc.ts | 64 +++++++ .../src/services/controlApiRestService.ts | 16 ++ .../src/services/fakeMcpProxyService.ts | 7 + .../rpc-proxy/src/services/mcpProxyService.ts | 5 + .../EvenfireMissingRateLimiting.expected | 13 ++ .../EvenfireMissingRateLimiting.qlref | 1 + .../control-api/src/app.ts | 11 ++ .../control-api/src/config.ts | 1 + .../src/middleware/internalServiceAuth.ts | 4 + .../src/middleware/rateLimitMiddleware.ts | 3 + .../src/middleware/rpcAccessAuth.ts | 16 ++ .../src/routes/rpc-access/index.ts | 8 + .../src/routes/rpc-access/users.ts | 63 +++++++ .../access/rpcHostAccessAuthorizer.ts | 9 + .../rpc-proxy/src/middleware/auth.ts | 6 + .../rpc-proxy/src/routes/rpc.ts | 64 +++++++ .../src/services/controlApiRestService.ts | 16 ++ .../src/services/fakeMcpProxyService.ts | 7 + .../rpc-proxy/src/services/mcpProxyService.ts | 5 + .../EvenfireMissingRateLimiting.expected | 13 ++ .../EvenfireMissingRateLimiting.qlref | 1 + .../control-api/src/app.ts | 11 ++ .../control-api/src/config.ts | 1 + .../src/middleware/internalServiceAuth.ts | 4 + .../src/middleware/rateLimitMiddleware.ts | 3 + .../src/middleware/rpcAccessAuth.ts | 16 ++ .../src/routes/rpc-access/index.ts | 8 + .../src/routes/rpc-access/users.ts | 69 ++++++++ .../access/rpcHostAccessAuthorizer.ts | 9 + .../rpc-proxy/src/middleware/auth.ts | 6 + .../rpc-proxy/src/routes/rpc.ts | 64 +++++++ .../src/services/controlApiRestService.ts | 16 ++ .../src/services/fakeMcpProxyService.ts | 7 + .../rpc-proxy/src/services/mcpProxyService.ts | 5 + .../EvenfireMissingRateLimiting.expected | 13 ++ .../EvenfireMissingRateLimiting.qlref | 1 + .../control-api/src/app.ts | 11 ++ .../control-api/src/config.ts | 1 + .../src/middleware/internalServiceAuth.ts | 4 + .../src/middleware/rateLimitMiddleware.ts | 3 + .../src/middleware/rpcAccessAuth.ts | 16 ++ .../src/routes/rpc-access/index.ts | 8 + .../src/routes/rpc-access/users.ts | 69 ++++++++ .../access/rpcHostAccessAuthorizer.ts | 9 + .../rpc-proxy/src/middleware/auth.ts | 6 + .../rpc-proxy/src/routes/rpc.ts | 64 +++++++ .../src/services/controlApiRestService.ts | 16 ++ .../src/services/fakeMcpProxyService.ts | 7 + .../rpc-proxy/src/services/mcpProxyService.ts | 5 + .../EvenfireMissingRateLimiting.expected | 13 ++ .../EvenfireMissingRateLimiting.qlref | 1 + .../control-api/src/app.ts | 11 ++ .../control-api/src/config.ts | 1 + .../src/middleware/internalServiceAuth.ts | 4 + .../src/middleware/rateLimitMiddleware.ts | 3 + .../src/middleware/rpcAccessAuth.ts | 16 ++ .../src/routes/rpc-access/index.ts | 8 + .../src/routes/rpc-access/users.ts | 69 ++++++++ .../access/rpcHostAccessAuthorizer.ts | 9 + .../rpc-proxy/src/middleware/auth.ts | 6 + .../rpc-proxy/src/routes/rpc.ts | 64 +++++++ .../src/services/controlApiRestService.ts | 16 ++ .../src/services/fakeMcpProxyService.ts | 7 + .../rpc-proxy/src/services/mcpProxyService.ts | 5 + .../EvenfireMissingRateLimiting.expected | 13 ++ .../EvenfireMissingRateLimiting.qlref | 1 + .../control-api/src/app.ts | 11 ++ .../control-api/src/config.ts | 1 + .../src/middleware/internalServiceAuth.ts | 4 + .../src/middleware/rateLimitMiddleware.ts | 3 + .../src/middleware/rpcAccessAuth.ts | 16 ++ .../src/routes/rpc-access/index.ts | 8 + .../src/routes/rpc-access/users.ts | 69 ++++++++ .../access/rpcHostAccessAuthorizer.ts | 9 + .../rpc-proxy/src/middleware/auth.ts | 6 + .../rpc-proxy/src/routes/rpc.ts | 64 +++++++ .../src/services/controlApiRestService.ts | 16 ++ .../src/services/fakeMcpProxyService.ts | 7 + .../rpc-proxy/src/services/mcpProxyService.ts | 5 + 141 files changed, 2305 insertions(+), 9 deletions(-) create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/services/access/rpcHostAccessAuthorizer.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/EvenfireMissingRateLimiting.expected create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/EvenfireMissingRateLimiting.qlref create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/app.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/config.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/middleware/internalServiceAuth.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/middleware/rateLimitMiddleware.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/middleware/rpcAccessAuth.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/routes/rpc-access/index.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/routes/rpc-access/users.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/services/access/rpcHostAccessAuthorizer.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/middleware/auth.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/routes/rpc.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/services/controlApiRestService.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/services/fakeMcpProxyService.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/services/mcpProxyService.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/EvenfireMissingRateLimiting.expected create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/EvenfireMissingRateLimiting.qlref create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/app.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/config.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/middleware/internalServiceAuth.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/middleware/rateLimitMiddleware.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/middleware/rpcAccessAuth.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/routes/rpc-access/fakeRpcHostAccessAuthorizer.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/routes/rpc-access/index.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/routes/rpc-access/users.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/services/access/rpcHostAccessAuthorizer.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/middleware/auth.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/routes/rpc.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/services/controlApiRestService.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/services/fakeMcpProxyService.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/services/mcpProxyService.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/EvenfireMissingRateLimiting.expected create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/EvenfireMissingRateLimiting.qlref create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/app.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/config.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/middleware/internalServiceAuth.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/middleware/rateLimitMiddleware.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/middleware/rpcAccessAuth.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/routes/rpc-access/fakeResolveAuthorizedHostConnection.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/routes/rpc-access/index.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/routes/rpc-access/users.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/services/access/rpcHostAccessAuthorizer.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/middleware/auth.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/routes/rpc.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/services/controlApiRestService.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/services/fakeMcpProxyService.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/services/mcpProxyService.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/EvenfireMissingRateLimiting.expected create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/EvenfireMissingRateLimiting.qlref create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/app.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/config.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/middleware/internalServiceAuth.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/middleware/rateLimitMiddleware.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/middleware/rpcAccessAuth.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/routes/rpc-access/index.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/routes/rpc-access/users.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/services/access/rpcHostAccessAuthorizer.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/middleware/auth.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/routes/rpc.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/services/controlApiRestService.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/services/fakeMcpProxyService.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/services/mcpProxyService.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/EvenfireMissingRateLimiting.expected create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/EvenfireMissingRateLimiting.qlref create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/app.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/config.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/middleware/internalServiceAuth.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/middleware/rateLimitMiddleware.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/middleware/rpcAccessAuth.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/routes/rpc-access/index.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/routes/rpc-access/users.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/services/access/rpcHostAccessAuthorizer.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/middleware/auth.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/routes/rpc.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/services/controlApiRestService.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/services/fakeMcpProxyService.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/services/mcpProxyService.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/EvenfireMissingRateLimiting.expected create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/EvenfireMissingRateLimiting.qlref create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/app.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/config.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/middleware/internalServiceAuth.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/middleware/rateLimitMiddleware.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/middleware/rpcAccessAuth.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/routes/rpc-access/index.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/routes/rpc-access/users.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/services/access/rpcHostAccessAuthorizer.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/middleware/auth.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/routes/rpc.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/services/controlApiRestService.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/services/fakeMcpProxyService.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/services/mcpProxyService.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/EvenfireMissingRateLimiting.expected create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/EvenfireMissingRateLimiting.qlref create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/app.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/config.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/middleware/internalServiceAuth.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/middleware/rateLimitMiddleware.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/middleware/rpcAccessAuth.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/routes/rpc-access/index.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/routes/rpc-access/users.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/services/access/rpcHostAccessAuthorizer.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/middleware/auth.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/routes/rpc.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/services/controlApiRestService.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/services/fakeMcpProxyService.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/services/mcpProxyService.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/EvenfireMissingRateLimiting.expected create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/EvenfireMissingRateLimiting.qlref create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/app.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/config.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/middleware/internalServiceAuth.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/middleware/rateLimitMiddleware.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/middleware/rpcAccessAuth.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/routes/rpc-access/index.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/routes/rpc-access/users.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/services/access/rpcHostAccessAuthorizer.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/middleware/auth.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/routes/rpc.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/services/controlApiRestService.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/services/fakeMcpProxyService.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/services/mcpProxyService.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/EvenfireMissingRateLimiting.expected create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/EvenfireMissingRateLimiting.qlref create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/app.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/config.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/middleware/internalServiceAuth.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/middleware/rateLimitMiddleware.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/middleware/rpcAccessAuth.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/routes/rpc-access/index.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/routes/rpc-access/users.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/services/access/rpcHostAccessAuthorizer.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/middleware/auth.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/routes/rpc.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/services/controlApiRestService.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/services/fakeMcpProxyService.ts create mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/services/mcpProxyService.ts diff --git a/.github/codeql/queries/EvenfireMissingRateLimiting.ql b/.github/codeql/queries/EvenfireMissingRateLimiting.ql index 6e75028962..830cda0607 100644 --- a/.github/codeql/queries/EvenfireMissingRateLimiting.ql +++ b/.github/codeql/queries/EvenfireMissingRateLimiting.ql @@ -207,14 +207,173 @@ private predicate isCanonicalHostArtifactAdmission(CallExpr limiter) { ) } +private predicate isRpcRequestFieldLocal(Function resolver, VarDecl binding, string field) { + exists( + VariableDeclarator declaration, MethodCallExpr trimCall, CallExpr stringCall, + BinaryExpr fallback, PropAccess access, PropAccess params, StringLiteral empty + | + declaration.getEnclosingFunction() = resolver and + declaration.getBindingPattern() = binding and + declaration.getDeclStmt() instanceof ConstDeclStmt and + declaration.getInit() = trimCall and + trimCall.getMethodName() = "trim" and + trimCall.getReceiver() = stringCall and + stringCall.getCalleeName() = "String" and + stringCall.getNumArgument() = 1 and + stringCall.getArgument(0) = fallback and + fallback.getOperator() = "||" and + fallback.getRightOperand() = empty and + empty.getStringValue() = "" and + fallback.getLeftOperand() = access and + access.getPropertyName() = field and + params = access.getBase().(PropAccess) and + params.getPropertyName() = "params" and + params.getBase().(VarAccess).getVariable() = resolver.getParameter(0).getVariable() + ) +} + +private predicate isRpcAuthClaimsLocal(Function resolver, VarDecl binding) { + exists(VariableDeclarator declaration, PropAccess claims | + declaration.getEnclosingFunction() = resolver and + declaration.getBindingPattern() = binding and + declaration.getDeclStmt() instanceof ConstDeclStmt and + declaration.getInit() = claims and + claims.getPropertyName() = "rpcAuth" and + claims.getBase().(VarAccess).getVariable() = resolver.getParameter(0).getVariable() + ) +} + +private predicate isCanonicalAuthorizationDecision( + Function resolver, CallExpr authorityCall +) { + exists( + VariableDeclarator authorizationDeclaration, VarDecl authorizationBinding, + IfStmt denied, LogNotExpr deniedCondition, PropAccess authorizedFlag, + ReturnStmt deniedReturn, + ReturnStmt connectionReturn, PropAccess authorizedConnection + | + authorizationDeclaration.getEnclosingFunction() = resolver and + authorizationDeclaration.getDeclStmt() instanceof ConstDeclStmt and + authorizationDeclaration.getInit().getAChild*() = authorityCall and + authorizationDeclaration.getBindingPattern() = authorizationBinding and + denied.getCondition().getEnclosingFunction() = resolver and + denied.getCondition() = deniedCondition and + deniedCondition.getOperand() = authorizedFlag and + denied.getThen().getAChild*() = deniedReturn and + deniedReturn.getExpr() instanceof NullLiteral and + authorizedFlag.getPropertyName() = "authorized" and + authorizedFlag.getBase().(VarAccess).getVariable() = authorizationBinding.getVariable() and + connectionReturn.getExpr().getEnclosingFunction() = resolver and + authorizedConnection = connectionReturn.getExpr().(PropAccess) and + authorizedConnection.getPropertyName() = "connection" and + authorizedConnection.getBase().(VarAccess).getVariable() = authorizationBinding.getVariable() and + not exists(ReturnStmt alternateReturn | + alternateReturn.getTarget() = resolver and + not alternateReturn.getExpr() instanceof NullLiteral and + alternateReturn.getExpr() != connectionReturn.getExpr() + ) and + not exists(AssignExpr connectionMutation, PropAccess mutatedField | + connectionMutation.getEnclosingFunction() = resolver and + connectionMutation.getLhs() = mutatedField and + mutatedField.getBase() = authorizedConnection + ) + ) +} + +private predicate isCanonicalHostAuthorizationImplementation(Function resolver) { + exists( + ImportSpecifier authorityImport, CallExpr authorityCall, + VarDecl claimsBinding, VarDecl userBinding, VarDecl hostBinding + | + resolver.getFile().getRelativePath() = "control-api/src/routes/rpc-access/users.ts" and + resolver.getName() = "resolveAuthorizedHostConnection" and + authorityImport.getImportedName() = "authorizeRpcHostAccess" and + authorityImport.getImportDeclaration().getImportedFile().getRelativePath() = + "control-api/src/services/access/rpcHostAccessAuthorizer.ts" and + authorityCall.getEnclosingFunction() = resolver and + authorityCall.getCallee().(VarAccess).getVariable() = + authorityImport.getLocal().getVariable() and + authorityCall.getArgument(0).(VarAccess).getVariable() = + resolver.getParameter(2).getVariable() and + authorityCall.getArgument(4).(VarAccess).getVariable() = + resolver.getParameter(3).getVariable() and + authorityCall.getArgument(1).(VarAccess).getVariable() = claimsBinding.getVariable() and + isRpcAuthClaimsLocal(resolver, claimsBinding) and + authorityCall.getArgument(2).(VarAccess).getVariable() = userBinding.getVariable() and + isRpcRequestFieldLocal(resolver, userBinding, "userId") and + authorityCall.getArgument(3).(VarAccess).getVariable() = hostBinding.getVariable() and + isRpcRequestFieldLocal(resolver, hostBinding, "hostRef") and + isCanonicalAuthorizationDecision(resolver, authorityCall) + ) +} + +private predicate isCanonicalArtifactProducerRouter(MethodCallExpr registration) { + exists(Function usersRouter, Function rpcRouter, CallExpr usersRouterCall, + MethodCallExpr routerUse, VariableDeclarator routerDeclaration, VarDecl routerBinding, + ReturnStmt routerReturn | + usersRouter = registration.getEnclosingFunction() and + usersRouter.getName() = "createRpcAccessUsersRouter" and + usersRouter.getFile().getRelativePath() = "control-api/src/routes/rpc-access/users.ts" and + rpcRouter.getName() = "createRpcAccessRouter" and + rpcRouter.getFile().getRelativePath() = "control-api/src/routes/rpc-access/index.ts" and + usersRouterCall.getEnclosingFunction() = rpcRouter and + isExactImportedCall(usersRouterCall, + "control-api/src/routes/rpc-access/users.ts", "createRpcAccessUsersRouter") and + routerUse.getEnclosingFunction() = rpcRouter and + routerUse.getMethodName() = "use" and + routerUse.getArgument(0) = usersRouterCall and + routerDeclaration.getEnclosingFunction() = rpcRouter and + routerDeclaration.getBindingPattern() = routerBinding and + routerUse.getReceiver().(VarAccess).getVariable() = routerBinding.getVariable() and + routerReturn.getTarget() = rpcRouter and + routerReturn.getExpr().(VarAccess).getVariable() = routerBinding.getVariable() + ) +} + +private predicate isFailClosedAuthorizationHandler( + CallExpr liveAuthorization, Function handler, Function resolver +) { + exists( + VariableDeclarator connectionDeclaration, VarDecl connectionBinding, + IfStmt connectionGuard, VarAccess guardedConnection, AssignExpr storedConnection, + PropAccess artifactConnection, VarAccess assignedConnection, CallExpr nextCall + | + liveAuthorization.getCallee().(VarAccess).getVariable() = resolver.getVariable() and + liveAuthorization.getArgument(0).(VarAccess).getVariable() = + handler.getParameter(0).getVariable() and + connectionDeclaration.getEnclosingFunction() = handler and + connectionDeclaration.getInit().getAChild*() = liveAuthorization and + connectionDeclaration.getBindingPattern() = connectionBinding and + connectionBinding.getVariable() = guardedConnection.getVariable() and + connectionGuard.getCondition().getEnclosingFunction() = handler and + connectionGuard.getCondition() = guardedConnection and + artifactConnection.getPropertyName() = "artifactReadConnection" and + artifactConnection.getBase().(VarAccess).getVariable() = handler.getParameter(0).getVariable() and + storedConnection.getEnclosingFunction() = handler and + storedConnection.getLhs() = artifactConnection and + storedConnection.getRhs() = assignedConnection and + assignedConnection.getVariable() = connectionBinding.getVariable() and + storedConnection.getEnclosingStmt().nestedIn(connectionGuard.getThen()) and + nextCall.getCalleeName() = "next" and + nextCall.getEnclosingFunction() = handler and + nextCall.getEnclosingStmt().nestedIn(connectionGuard.getThen()) and + not exists(CallExpr unguardedNext | + unguardedNext.getCalleeName() = "next" and + unguardedNext.getEnclosingFunction() = handler and + not unguardedNext.getEnclosingStmt().nestedIn(connectionGuard.getThen()) + ) + ) +} + private predicate isCanonicalArtifactProducer(MethodCallExpr registration) { exists( CallExpr tokenCheck, ArrayExpr scopes, StringLiteral scope, CallExpr userMatch, CallExpr hostMatch, CallExpr preAdmission, Function authorizationHandler, - CallExpr liveAuthorization, CallExpr hostAdmission + CallExpr liveAuthorization, Function resolver, CallExpr hostAdmission | registration.getMethodName() = "get" and registration.getFile().getRelativePath() = "control-api/src/routes/rpc-access/users.ts" and + isCanonicalArtifactProducerRouter(registration) and isCanonicalArtifactReadPath(registration.getArgument(0)) and tokenCheck = registration.getArgument(1) and isExactImportedCall(tokenCheck, "control-api/src/middleware/rpcAccessAuth.ts", @@ -233,7 +392,11 @@ private predicate isCanonicalArtifactProducer(MethodCallExpr registration) { isSubjectOnlyArtifactPreAdmission(preAdmission) and authorizationHandler = registration.getArgument(5) and liveAuthorization.getEnclosingFunction() = authorizationHandler and - liveAuthorization.getCalleeName() = "resolveAuthorizedHostConnection" and + resolver.getFile().getRelativePath() = "control-api/src/routes/rpc-access/users.ts" and + resolver.getName() = "resolveAuthorizedHostConnection" and + liveAuthorization.getCallee().(VarAccess).getVariable() = resolver.getVariable() and + isCanonicalHostAuthorizationImplementation(resolver) and + isFailClosedAuthorizationHandler(liveAuthorization, authorizationHandler, resolver) and hostAdmission = registration.getArgument(6) and isCanonicalHostArtifactAdmission(hostAdmission) ) diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/routes/rpc-access/index.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/routes/rpc-access/index.ts index 1d3b027b78..6db4f809c7 100644 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/routes/rpc-access/index.ts +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/routes/rpc-access/index.ts @@ -1,2 +1,8 @@ import { createRpcAccessUsersRouter } from './users.js' -export function createRpcAccessRouter() { return createRpcAccessUsersRouter() } +import { Router } from 'express' + +export function createRpcAccessRouter(gateway: unknown, options: { directory?: unknown }) { + const router = Router() + router.use(createRpcAccessUsersRouter(gateway, options)) + return router +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/routes/rpc-access/users.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/routes/rpc-access/users.ts index 0fbe3ec244..4901f53e52 100644 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/routes/rpc-access/users.ts +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/routes/rpc-access/users.ts @@ -6,13 +6,32 @@ import { requireRpcTokenUserMatch, requireValidRpcAccessTokenAny, } from '../../middleware/rpcAccessAuth.js' +import { authorizeRpcHostAccess } from '../../services/access/rpcHostAccessAuthorizer.js' -type ArtifactRequest = { rpcAuth?: { sub?: string }; artifactReadConnection?: { hostRef: string } } +type ArtifactRequest = { + params: { userId: string; hostRef: string } + rpcAuth?: { sub?: string } + artifactReadConnection?: { hostRef: string } +} -declare function resolveAuthorizedHostConnection(req: ArtifactRequest): Promise<{ hostRef: string }> +async function resolveAuthorizedHostConnection( + req: ArtifactRequest, + _res: unknown, + gateway: unknown, + directory: unknown, +): Promise<{ hostRef: string } | null> { + const userId = String(req.params.userId || '').trim() + const hostRef = String(req.params.hostRef || '').trim() + const claims = req.rpcAuth + if (!claims) return null + const authorization = await authorizeRpcHostAccess(gateway, claims, userId, hostRef, directory) + if (!authorization.authorized) return null + return authorization.connection +} -export function createRpcAccessUsersRouter() { +export function createRpcAccessUsersRouter(gateway: unknown, options: { directory?: unknown }) { const router = Router() + const { directory } = options const hostAccessPath = '/rpc/access/users/:userId/mcp-hosts/:hostRef' router.get( `${hostAccessPath}/artifact-read`, @@ -27,9 +46,12 @@ export function createRpcAccessUsersRouter() { return subject ? `host-artifact-pre-admission:${subject}` : null }, }), - async (req: ArtifactRequest, _res, next) => { - req.artifactReadConnection = await resolveAuthorizedHostConnection(req) - next() + async (req: ArtifactRequest, res, next) => { + const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) + if (connection) { + req.artifactReadConnection = connection + next() + } }, rateLimitMiddleware({ bucketType: 'host_artifact_read', diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/services/access/rpcHostAccessAuthorizer.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/services/access/rpcHostAccessAuthorizer.ts new file mode 100644 index 0000000000..22957e5917 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/services/access/rpcHostAccessAuthorizer.ts @@ -0,0 +1,9 @@ +export async function authorizeRpcHostAccess( + _gateway: unknown, + _claims: unknown, + _userId: string, + _hostRef: string, + _directory: unknown, +): Promise<{ authorized: boolean; connection: { hostRef: string } }> { + return { authorized: true, connection: { hostRef: 'canonical-host' } } +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/EvenfireMissingRateLimiting.expected b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/EvenfireMissingRateLimiting.expected new file mode 100644 index 0000000000..8a73eee2af --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/EvenfireMissingRateLimiting.expected @@ -0,0 +1,13 @@ +| control-api/src/routes/rpc-access/users.ts:38:5:38:53 | require ... read']) | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/rpcAccessAuth.ts:5:5:5:17 | verifyToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:39:47:39:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:39:95:39:117 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:43:66:43:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:43:114:43:136 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:47:47:47:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:47:95:47:117 | fakeRes ... actHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:17:93:17:113 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:51:66:51:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:51:114:51:134 | wrongEn ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:23:63:23:83 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:55:47:55:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:30:95:30:115 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:32:65:32:85 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:59:66:59:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/EvenfireMissingRateLimiting.qlref b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/EvenfireMissingRateLimiting.qlref new file mode 100644 index 0000000000..7d3c25d5ca --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/EvenfireMissingRateLimiting.qlref @@ -0,0 +1 @@ +query: EvenfireMissingRateLimiting.ql diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/app.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/app.ts new file mode 100644 index 0000000000..4ece8564ed --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/app.ts @@ -0,0 +1,11 @@ +import express from 'express' +import { requireInternalService, requireInternalToken } from './middleware/internalServiceAuth.js' +import { createRpcAccessRouter } from './routes/rpc-access/index.js' + +export function createApp() { + const api = express.Router() + api.use(requireInternalToken) + api.use('/rpc', requireInternalService('rpc-proxy')) + api.use(createRpcAccessRouter()) + return api +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/config.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/config.ts new file mode 100644 index 0000000000..c8274c450a --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/config.ts @@ -0,0 +1 @@ +export const config = { hostArtifactReadRlPerMin: 30 } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/middleware/internalServiceAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/middleware/internalServiceAuth.ts new file mode 100644 index 0000000000..7224c950a0 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/middleware/internalServiceAuth.ts @@ -0,0 +1,4 @@ +export function requireInternalToken(_req: unknown, _res: unknown, next: () => void) { next() } +export function requireInternalService(_name: string) { + return (_req: unknown, _res: unknown, next: () => void) => next() +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/middleware/rateLimitMiddleware.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/middleware/rateLimitMiddleware.ts new file mode 100644 index 0000000000..32f6b8ea64 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/middleware/rateLimitMiddleware.ts @@ -0,0 +1,3 @@ +export function rateLimitMiddleware(_options: unknown) { + return (_req: unknown, _res: unknown, next: () => void) => next() +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/middleware/rpcAccessAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/middleware/rpcAccessAuth.ts new file mode 100644 index 0000000000..b498d6f7a8 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/middleware/rpcAccessAuth.ts @@ -0,0 +1,16 @@ +declare function verifyToken(): void + +export function requireValidRpcAccessTokenAny(_scopes: string[]) { + return (_req: unknown, _res: unknown, next: () => void) => { + verifyToken() + next() + } +} + +export function requireRpcTokenUserMatch() { + return (_req: unknown, _res: unknown, next: () => void) => next() +} + +export function requireRpcTokenHostMatch() { + return (_req: unknown, _res: unknown, next: () => void) => next() +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/routes/rpc-access/index.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/routes/rpc-access/index.ts new file mode 100644 index 0000000000..6db4f809c7 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/routes/rpc-access/index.ts @@ -0,0 +1,8 @@ +import { createRpcAccessUsersRouter } from './users.js' +import { Router } from 'express' + +export function createRpcAccessRouter(gateway: unknown, options: { directory?: unknown }) { + const router = Router() + router.use(createRpcAccessUsersRouter(gateway, options)) + return router +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/routes/rpc-access/users.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/routes/rpc-access/users.ts new file mode 100644 index 0000000000..8d21b4ae42 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/routes/rpc-access/users.ts @@ -0,0 +1,70 @@ +import { Router } from 'express' +import { config } from '../../config.js' +import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' +import { + requireRpcTokenHostMatch, + requireRpcTokenUserMatch, + requireValidRpcAccessTokenAny, +} from '../../middleware/rpcAccessAuth.js' +import { authorizeRpcHostAccess } from '../../services/access/rpcHostAccessAuthorizer.js' + +type ArtifactRequest = { + params: { userId: string; hostRef: string } + rpcAuth?: { sub?: string } + artifactReadConnection?: { hostRef: string } +} + +async function resolveAuthorizedHostConnection( + req: ArtifactRequest, + _res: unknown, + gateway: unknown, + directory: unknown, +): Promise<{ hostRef: string } | null> { + const userId = String(req.params.userId || '').trim() + const hostRef = String(req.params.hostRef || '').trim() + const claims = req.rpcAuth + if (!claims) return null + const authorization = await authorizeRpcHostAccess(gateway, claims, userId, hostRef, directory) + if (!authorization.authorized) return null + return authorization.connection +} + +export function createRpcAccessUsersRouter(gateway: unknown, options: { directory?: unknown }) { + const router = Router() + const { directory } = options + const hostAccessPath = '/rpc/access/users/:userId/mcp-hosts/:hostRef' + router.get( + `${hostAccessPath}/artifact-read`, + requireValidRpcAccessTokenAny(['host:task:read']), + requireRpcTokenUserMatch(), + requireRpcTokenHostMatch(), + rateLimitMiddleware({ + bucketType: 'host_artifact_pre_admission', + maxPerMinute: config.hostArtifactReadRlPerMin, + getBucketKey: req => { + const subject = (req as ArtifactRequest).rpcAuth?.sub + const hostRef = (req as ArtifactRequest).params.hostRef + return subject ? `host-artifact-pre-admission:${subject}:${hostRef}` : null + }, + }), + async (req: ArtifactRequest, res, next) => { + const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) + if (connection) { + req.artifactReadConnection = connection + next() + } + }, + rateLimitMiddleware({ + bucketType: 'host_artifact_read', + maxPerMinute: config.hostArtifactReadRlPerMin, + getBucketKey: req => { + const artifactRead = req as ArtifactRequest + const subject = artifactRead.rpcAuth?.sub + const hostRef = artifactRead.artifactReadConnection?.hostRef + return subject && hostRef ? `host-artifact-read:${subject}:${hostRef}` : null + }, + }), + (_req, res) => res.status(200).json({ ok: true }), + ) + return router +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/services/access/rpcHostAccessAuthorizer.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/services/access/rpcHostAccessAuthorizer.ts new file mode 100644 index 0000000000..22957e5917 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/services/access/rpcHostAccessAuthorizer.ts @@ -0,0 +1,9 @@ +export async function authorizeRpcHostAccess( + _gateway: unknown, + _claims: unknown, + _userId: string, + _hostRef: string, + _directory: unknown, +): Promise<{ authorized: boolean; connection: { hostRef: string } }> { + return { authorized: true, connection: { hostRef: 'canonical-host' } } +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/middleware/auth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/middleware/auth.ts new file mode 100644 index 0000000000..01476c7883 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/middleware/auth.ts @@ -0,0 +1,6 @@ +declare function verifyRpcToken(): void +export function requireRpcAuth(_req: unknown, _res: unknown, next: () => void) { verifyRpcToken(); next() } +export function requireScope(_scope: string) { + return (_req: unknown, _res: unknown, next: () => void) => next() +} +export function extractAuthToken(_req: unknown): string { return 'token' } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/routes/rpc.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/routes/rpc.ts new file mode 100644 index 0000000000..f3963fdbb6 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/routes/rpc.ts @@ -0,0 +1,64 @@ +import express from 'express' +import { extractAuthToken, requireRpcAuth, requireScope } from '../middleware/auth.js' +import { resolveArtifactReadHostConnectionForUser } from '../services/mcpProxyService.js' +import { resolveArtifactReadHostConnectionForUser as fakeClient } from '../services/fakeMcpProxyService.js' + +type ArtifactRequest = express.Request & { auth?: { sub: string }; artifactReadHost?: { url: string } } + +const resolveArtifactReadHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + const hostRef = String(req.params.hostRef || '').trim() + req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, hostRef, extractAuthToken(req)) + next() +} + +const fakeResolveArtifactHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) + next() +} + +const wrongEndpointResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) + next() +} + +const fallbackArtifactResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + try { + req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) + } catch { + req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) + } + next() +} + +export function createRpcRouter() { + const router = express.Router() + router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts/download') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fakeResolveArtifactHost, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), wrongEndpointResolver, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts/download') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fallbackArtifactResolver, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts/download') + res.sendStatus(200) + }) + return router +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/services/controlApiRestService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/services/controlApiRestService.ts new file mode 100644 index 0000000000..8d4a12b6fd --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/services/controlApiRestService.ts @@ -0,0 +1,16 @@ +type Options = { artifactRead?: boolean } + +async function fetchHostConnectionForPath( + userId: string, + hostRef: string, + token: string, + options: Options, +): Promise<{ url: string } | null> { + const base = `/rpc/access/users/${userId}/mcp-hosts/${hostRef}` + const endpoint = options.artifactRead ? `${base}/artifact-read` : base + return fetch(endpoint, { headers: { authorization: `Bearer ${token}` } }) as never +} + +export async function fetchArtifactReadHostConnectionFromControlApi(userId: string, hostRef: string, token: string) { + return fetchHostConnectionForPath(userId, hostRef, token, { artifactRead: true }) +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/services/fakeMcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/services/fakeMcpProxyService.ts new file mode 100644 index 0000000000..c03af01f0a --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/services/fakeMcpProxyService.ts @@ -0,0 +1,7 @@ +export async function resolveArtifactReadHostConnectionForUser( + _userId: string, + _hostRef: string, + _token: string, +) { + return { url: 'https://unprotected.example' } +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/services/mcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/services/mcpProxyService.ts new file mode 100644 index 0000000000..e314b09f56 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/services/mcpProxyService.ts @@ -0,0 +1,5 @@ +import { fetchArtifactReadHostConnectionFromControlApi } from './controlApiRestService.js' + +export async function resolveArtifactReadHostConnectionForUser(userId: string, hostRef: string, token: string) { + return fetchArtifactReadHostConnectionFromControlApi(userId, hostRef, token) +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/EvenfireMissingRateLimiting.expected b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/EvenfireMissingRateLimiting.expected new file mode 100644 index 0000000000..8a73eee2af --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/EvenfireMissingRateLimiting.expected @@ -0,0 +1,13 @@ +| control-api/src/routes/rpc-access/users.ts:38:5:38:53 | require ... read']) | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/rpcAccessAuth.ts:5:5:5:17 | verifyToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:39:47:39:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:39:95:39:117 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:43:66:43:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:43:114:43:136 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:47:47:47:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:47:95:47:117 | fakeRes ... actHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:17:93:17:113 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:51:66:51:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:51:114:51:134 | wrongEn ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:23:63:23:83 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:55:47:55:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:30:95:30:115 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:32:65:32:85 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:59:66:59:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/EvenfireMissingRateLimiting.qlref b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/EvenfireMissingRateLimiting.qlref new file mode 100644 index 0000000000..7d3c25d5ca --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/EvenfireMissingRateLimiting.qlref @@ -0,0 +1 @@ +query: EvenfireMissingRateLimiting.ql diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/app.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/app.ts new file mode 100644 index 0000000000..4ece8564ed --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/app.ts @@ -0,0 +1,11 @@ +import express from 'express' +import { requireInternalService, requireInternalToken } from './middleware/internalServiceAuth.js' +import { createRpcAccessRouter } from './routes/rpc-access/index.js' + +export function createApp() { + const api = express.Router() + api.use(requireInternalToken) + api.use('/rpc', requireInternalService('rpc-proxy')) + api.use(createRpcAccessRouter()) + return api +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/config.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/config.ts new file mode 100644 index 0000000000..c8274c450a --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/config.ts @@ -0,0 +1 @@ +export const config = { hostArtifactReadRlPerMin: 30 } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/middleware/internalServiceAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/middleware/internalServiceAuth.ts new file mode 100644 index 0000000000..7224c950a0 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/middleware/internalServiceAuth.ts @@ -0,0 +1,4 @@ +export function requireInternalToken(_req: unknown, _res: unknown, next: () => void) { next() } +export function requireInternalService(_name: string) { + return (_req: unknown, _res: unknown, next: () => void) => next() +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/middleware/rateLimitMiddleware.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/middleware/rateLimitMiddleware.ts new file mode 100644 index 0000000000..32f6b8ea64 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/middleware/rateLimitMiddleware.ts @@ -0,0 +1,3 @@ +export function rateLimitMiddleware(_options: unknown) { + return (_req: unknown, _res: unknown, next: () => void) => next() +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/middleware/rpcAccessAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/middleware/rpcAccessAuth.ts new file mode 100644 index 0000000000..b498d6f7a8 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/middleware/rpcAccessAuth.ts @@ -0,0 +1,16 @@ +declare function verifyToken(): void + +export function requireValidRpcAccessTokenAny(_scopes: string[]) { + return (_req: unknown, _res: unknown, next: () => void) => { + verifyToken() + next() + } +} + +export function requireRpcTokenUserMatch() { + return (_req: unknown, _res: unknown, next: () => void) => next() +} + +export function requireRpcTokenHostMatch() { + return (_req: unknown, _res: unknown, next: () => void) => next() +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/routes/rpc-access/fakeRpcHostAccessAuthorizer.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/routes/rpc-access/fakeRpcHostAccessAuthorizer.ts new file mode 100644 index 0000000000..4f115b2101 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/routes/rpc-access/fakeRpcHostAccessAuthorizer.ts @@ -0,0 +1,9 @@ +export async function authorizeRpcHostAccess( + _gateway: unknown, + _claims: unknown, + _userId: string, + hostRef: string, + _directory: unknown, +) { + return { authorized: true, connection: { hostRef } } +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/routes/rpc-access/index.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/routes/rpc-access/index.ts new file mode 100644 index 0000000000..6db4f809c7 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/routes/rpc-access/index.ts @@ -0,0 +1,8 @@ +import { createRpcAccessUsersRouter } from './users.js' +import { Router } from 'express' + +export function createRpcAccessRouter(gateway: unknown, options: { directory?: unknown }) { + const router = Router() + router.use(createRpcAccessUsersRouter(gateway, options)) + return router +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/routes/rpc-access/users.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/routes/rpc-access/users.ts new file mode 100644 index 0000000000..11ec4bd859 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/routes/rpc-access/users.ts @@ -0,0 +1,69 @@ +import { Router } from 'express' +import { config } from '../../config.js' +import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' +import { + requireRpcTokenHostMatch, + requireRpcTokenUserMatch, + requireValidRpcAccessTokenAny, +} from '../../middleware/rpcAccessAuth.js' +import { authorizeRpcHostAccess } from './fakeRpcHostAccessAuthorizer.js' + +type ArtifactRequest = { + params: { userId: string; hostRef: string } + rpcAuth?: { sub?: string } + artifactReadConnection?: { hostRef: string } +} + +async function resolveAuthorizedHostConnection( + req: ArtifactRequest, + _res: unknown, + gateway: unknown, + directory: unknown, +): Promise<{ hostRef: string } | null> { + const userId = String(req.params.userId || '').trim() + const hostRef = String(req.params.hostRef || '').trim() + const claims = req.rpcAuth + if (!claims) return null + const authorization = await authorizeRpcHostAccess(gateway, claims, userId, hostRef, directory) + if (!authorization.authorized) return null + return authorization.connection +} + +export function createRpcAccessUsersRouter(gateway: unknown, options: { directory?: unknown }) { + const router = Router() + const { directory } = options + const hostAccessPath = '/rpc/access/users/:userId/mcp-hosts/:hostRef' + router.get( + `${hostAccessPath}/artifact-read`, + requireValidRpcAccessTokenAny(['host:task:read']), + requireRpcTokenUserMatch(), + requireRpcTokenHostMatch(), + rateLimitMiddleware({ + bucketType: 'host_artifact_pre_admission', + maxPerMinute: config.hostArtifactReadRlPerMin, + getBucketKey: req => { + const subject = (req as ArtifactRequest).rpcAuth?.sub + return subject ? `host-artifact-pre-admission:${subject}` : null + }, + }), + async (req: ArtifactRequest, res, next) => { + const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) + if (connection) { + req.artifactReadConnection = connection + next() + } + }, + rateLimitMiddleware({ + bucketType: 'host_artifact_read', + maxPerMinute: config.hostArtifactReadRlPerMin, + getBucketKey: req => { + const artifactRead = req as ArtifactRequest + const subject = artifactRead.rpcAuth?.sub + const hostRef = artifactRead.artifactReadConnection?.hostRef + return subject && hostRef ? `host-artifact-read:${subject}:${hostRef}` : null + }, + }), + (_req, res) => res.status(200).json({ ok: true }), + ) + return router +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/services/access/rpcHostAccessAuthorizer.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/services/access/rpcHostAccessAuthorizer.ts new file mode 100644 index 0000000000..22957e5917 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/services/access/rpcHostAccessAuthorizer.ts @@ -0,0 +1,9 @@ +export async function authorizeRpcHostAccess( + _gateway: unknown, + _claims: unknown, + _userId: string, + _hostRef: string, + _directory: unknown, +): Promise<{ authorized: boolean; connection: { hostRef: string } }> { + return { authorized: true, connection: { hostRef: 'canonical-host' } } +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/middleware/auth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/middleware/auth.ts new file mode 100644 index 0000000000..01476c7883 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/middleware/auth.ts @@ -0,0 +1,6 @@ +declare function verifyRpcToken(): void +export function requireRpcAuth(_req: unknown, _res: unknown, next: () => void) { verifyRpcToken(); next() } +export function requireScope(_scope: string) { + return (_req: unknown, _res: unknown, next: () => void) => next() +} +export function extractAuthToken(_req: unknown): string { return 'token' } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/routes/rpc.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/routes/rpc.ts new file mode 100644 index 0000000000..f3963fdbb6 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/routes/rpc.ts @@ -0,0 +1,64 @@ +import express from 'express' +import { extractAuthToken, requireRpcAuth, requireScope } from '../middleware/auth.js' +import { resolveArtifactReadHostConnectionForUser } from '../services/mcpProxyService.js' +import { resolveArtifactReadHostConnectionForUser as fakeClient } from '../services/fakeMcpProxyService.js' + +type ArtifactRequest = express.Request & { auth?: { sub: string }; artifactReadHost?: { url: string } } + +const resolveArtifactReadHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + const hostRef = String(req.params.hostRef || '').trim() + req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, hostRef, extractAuthToken(req)) + next() +} + +const fakeResolveArtifactHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) + next() +} + +const wrongEndpointResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) + next() +} + +const fallbackArtifactResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + try { + req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) + } catch { + req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) + } + next() +} + +export function createRpcRouter() { + const router = express.Router() + router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts/download') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fakeResolveArtifactHost, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), wrongEndpointResolver, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts/download') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fallbackArtifactResolver, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts/download') + res.sendStatus(200) + }) + return router +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/services/controlApiRestService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/services/controlApiRestService.ts new file mode 100644 index 0000000000..8d4a12b6fd --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/services/controlApiRestService.ts @@ -0,0 +1,16 @@ +type Options = { artifactRead?: boolean } + +async function fetchHostConnectionForPath( + userId: string, + hostRef: string, + token: string, + options: Options, +): Promise<{ url: string } | null> { + const base = `/rpc/access/users/${userId}/mcp-hosts/${hostRef}` + const endpoint = options.artifactRead ? `${base}/artifact-read` : base + return fetch(endpoint, { headers: { authorization: `Bearer ${token}` } }) as never +} + +export async function fetchArtifactReadHostConnectionFromControlApi(userId: string, hostRef: string, token: string) { + return fetchHostConnectionForPath(userId, hostRef, token, { artifactRead: true }) +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/services/fakeMcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/services/fakeMcpProxyService.ts new file mode 100644 index 0000000000..c03af01f0a --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/services/fakeMcpProxyService.ts @@ -0,0 +1,7 @@ +export async function resolveArtifactReadHostConnectionForUser( + _userId: string, + _hostRef: string, + _token: string, +) { + return { url: 'https://unprotected.example' } +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/services/mcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/services/mcpProxyService.ts new file mode 100644 index 0000000000..e314b09f56 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/services/mcpProxyService.ts @@ -0,0 +1,5 @@ +import { fetchArtifactReadHostConnectionFromControlApi } from './controlApiRestService.js' + +export async function resolveArtifactReadHostConnectionForUser(userId: string, hostRef: string, token: string) { + return fetchArtifactReadHostConnectionFromControlApi(userId, hostRef, token) +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/EvenfireMissingRateLimiting.expected b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/EvenfireMissingRateLimiting.expected new file mode 100644 index 0000000000..5c4e62f339 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/EvenfireMissingRateLimiting.expected @@ -0,0 +1,13 @@ +| control-api/src/routes/rpc-access/users.ts:24:5:24:53 | require ... read']) | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/rpcAccessAuth.ts:5:5:5:17 | verifyToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:39:47:39:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:39:95:39:117 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:43:66:43:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:43:114:43:136 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:47:47:47:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:47:95:47:117 | fakeRes ... actHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:17:93:17:113 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:51:66:51:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:51:114:51:134 | wrongEn ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:23:63:23:83 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:55:47:55:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:30:95:30:115 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:32:65:32:85 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:59:66:59:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/EvenfireMissingRateLimiting.qlref b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/EvenfireMissingRateLimiting.qlref new file mode 100644 index 0000000000..7d3c25d5ca --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/EvenfireMissingRateLimiting.qlref @@ -0,0 +1 @@ +query: EvenfireMissingRateLimiting.ql diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/app.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/app.ts new file mode 100644 index 0000000000..4ece8564ed --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/app.ts @@ -0,0 +1,11 @@ +import express from 'express' +import { requireInternalService, requireInternalToken } from './middleware/internalServiceAuth.js' +import { createRpcAccessRouter } from './routes/rpc-access/index.js' + +export function createApp() { + const api = express.Router() + api.use(requireInternalToken) + api.use('/rpc', requireInternalService('rpc-proxy')) + api.use(createRpcAccessRouter()) + return api +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/config.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/config.ts new file mode 100644 index 0000000000..c8274c450a --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/config.ts @@ -0,0 +1 @@ +export const config = { hostArtifactReadRlPerMin: 30 } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/middleware/internalServiceAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/middleware/internalServiceAuth.ts new file mode 100644 index 0000000000..7224c950a0 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/middleware/internalServiceAuth.ts @@ -0,0 +1,4 @@ +export function requireInternalToken(_req: unknown, _res: unknown, next: () => void) { next() } +export function requireInternalService(_name: string) { + return (_req: unknown, _res: unknown, next: () => void) => next() +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/middleware/rateLimitMiddleware.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/middleware/rateLimitMiddleware.ts new file mode 100644 index 0000000000..32f6b8ea64 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/middleware/rateLimitMiddleware.ts @@ -0,0 +1,3 @@ +export function rateLimitMiddleware(_options: unknown) { + return (_req: unknown, _res: unknown, next: () => void) => next() +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/middleware/rpcAccessAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/middleware/rpcAccessAuth.ts new file mode 100644 index 0000000000..b498d6f7a8 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/middleware/rpcAccessAuth.ts @@ -0,0 +1,16 @@ +declare function verifyToken(): void + +export function requireValidRpcAccessTokenAny(_scopes: string[]) { + return (_req: unknown, _res: unknown, next: () => void) => { + verifyToken() + next() + } +} + +export function requireRpcTokenUserMatch() { + return (_req: unknown, _res: unknown, next: () => void) => next() +} + +export function requireRpcTokenHostMatch() { + return (_req: unknown, _res: unknown, next: () => void) => next() +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/routes/rpc-access/fakeResolveAuthorizedHostConnection.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/routes/rpc-access/fakeResolveAuthorizedHostConnection.ts new file mode 100644 index 0000000000..112a3a2bce --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/routes/rpc-access/fakeResolveAuthorizedHostConnection.ts @@ -0,0 +1,8 @@ +export async function resolveAuthorizedHostConnection( + req: { params: { hostRef: string } }, + _res: unknown, + _gateway: unknown, + _directory: unknown, +) { + return { hostRef: req.params.hostRef } +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/routes/rpc-access/index.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/routes/rpc-access/index.ts new file mode 100644 index 0000000000..6db4f809c7 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/routes/rpc-access/index.ts @@ -0,0 +1,8 @@ +import { createRpcAccessUsersRouter } from './users.js' +import { Router } from 'express' + +export function createRpcAccessRouter(gateway: unknown, options: { directory?: unknown }) { + const router = Router() + router.use(createRpcAccessUsersRouter(gateway, options)) + return router +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/routes/rpc-access/users.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/routes/rpc-access/users.ts new file mode 100644 index 0000000000..f825b7ef0b --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/routes/rpc-access/users.ts @@ -0,0 +1,55 @@ +import { Router } from 'express' +import { config } from '../../config.js' +import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' +import { + requireRpcTokenHostMatch, + requireRpcTokenUserMatch, + requireValidRpcAccessTokenAny, +} from '../../middleware/rpcAccessAuth.js' +import { authorizeRpcHostAccess } from '../../services/access/rpcHostAccessAuthorizer.js' +import { resolveAuthorizedHostConnection } from './fakeResolveAuthorizedHostConnection.js' + +type ArtifactRequest = { + params: { userId: string; hostRef: string } + rpcAuth?: { sub?: string } + artifactReadConnection?: { hostRef: string } +} + +export function createRpcAccessUsersRouter(gateway: unknown, options: { directory?: unknown }) { + const router = Router() + const { directory } = options + const hostAccessPath = '/rpc/access/users/:userId/mcp-hosts/:hostRef' + router.get( + `${hostAccessPath}/artifact-read`, + requireValidRpcAccessTokenAny(['host:task:read']), + requireRpcTokenUserMatch(), + requireRpcTokenHostMatch(), + rateLimitMiddleware({ + bucketType: 'host_artifact_pre_admission', + maxPerMinute: config.hostArtifactReadRlPerMin, + getBucketKey: req => { + const subject = (req as ArtifactRequest).rpcAuth?.sub + return subject ? `host-artifact-pre-admission:${subject}` : null + }, + }), + async (req: ArtifactRequest, res, next) => { + const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) + if (connection) { + req.artifactReadConnection = connection + next() + } + }, + rateLimitMiddleware({ + bucketType: 'host_artifact_read', + maxPerMinute: config.hostArtifactReadRlPerMin, + getBucketKey: req => { + const artifactRead = req as ArtifactRequest + const subject = artifactRead.rpcAuth?.sub + const hostRef = artifactRead.artifactReadConnection?.hostRef + return subject && hostRef ? `host-artifact-read:${subject}:${hostRef}` : null + }, + }), + (_req, res) => res.status(200).json({ ok: true }), + ) + return router +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/services/access/rpcHostAccessAuthorizer.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/services/access/rpcHostAccessAuthorizer.ts new file mode 100644 index 0000000000..22957e5917 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/services/access/rpcHostAccessAuthorizer.ts @@ -0,0 +1,9 @@ +export async function authorizeRpcHostAccess( + _gateway: unknown, + _claims: unknown, + _userId: string, + _hostRef: string, + _directory: unknown, +): Promise<{ authorized: boolean; connection: { hostRef: string } }> { + return { authorized: true, connection: { hostRef: 'canonical-host' } } +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/middleware/auth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/middleware/auth.ts new file mode 100644 index 0000000000..01476c7883 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/middleware/auth.ts @@ -0,0 +1,6 @@ +declare function verifyRpcToken(): void +export function requireRpcAuth(_req: unknown, _res: unknown, next: () => void) { verifyRpcToken(); next() } +export function requireScope(_scope: string) { + return (_req: unknown, _res: unknown, next: () => void) => next() +} +export function extractAuthToken(_req: unknown): string { return 'token' } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/routes/rpc.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/routes/rpc.ts new file mode 100644 index 0000000000..f3963fdbb6 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/routes/rpc.ts @@ -0,0 +1,64 @@ +import express from 'express' +import { extractAuthToken, requireRpcAuth, requireScope } from '../middleware/auth.js' +import { resolveArtifactReadHostConnectionForUser } from '../services/mcpProxyService.js' +import { resolveArtifactReadHostConnectionForUser as fakeClient } from '../services/fakeMcpProxyService.js' + +type ArtifactRequest = express.Request & { auth?: { sub: string }; artifactReadHost?: { url: string } } + +const resolveArtifactReadHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + const hostRef = String(req.params.hostRef || '').trim() + req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, hostRef, extractAuthToken(req)) + next() +} + +const fakeResolveArtifactHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) + next() +} + +const wrongEndpointResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) + next() +} + +const fallbackArtifactResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + try { + req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) + } catch { + req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) + } + next() +} + +export function createRpcRouter() { + const router = express.Router() + router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts/download') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fakeResolveArtifactHost, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), wrongEndpointResolver, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts/download') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fallbackArtifactResolver, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts/download') + res.sendStatus(200) + }) + return router +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/services/controlApiRestService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/services/controlApiRestService.ts new file mode 100644 index 0000000000..8d4a12b6fd --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/services/controlApiRestService.ts @@ -0,0 +1,16 @@ +type Options = { artifactRead?: boolean } + +async function fetchHostConnectionForPath( + userId: string, + hostRef: string, + token: string, + options: Options, +): Promise<{ url: string } | null> { + const base = `/rpc/access/users/${userId}/mcp-hosts/${hostRef}` + const endpoint = options.artifactRead ? `${base}/artifact-read` : base + return fetch(endpoint, { headers: { authorization: `Bearer ${token}` } }) as never +} + +export async function fetchArtifactReadHostConnectionFromControlApi(userId: string, hostRef: string, token: string) { + return fetchHostConnectionForPath(userId, hostRef, token, { artifactRead: true }) +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/services/fakeMcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/services/fakeMcpProxyService.ts new file mode 100644 index 0000000000..c03af01f0a --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/services/fakeMcpProxyService.ts @@ -0,0 +1,7 @@ +export async function resolveArtifactReadHostConnectionForUser( + _userId: string, + _hostRef: string, + _token: string, +) { + return { url: 'https://unprotected.example' } +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/services/mcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/services/mcpProxyService.ts new file mode 100644 index 0000000000..e314b09f56 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/services/mcpProxyService.ts @@ -0,0 +1,5 @@ +import { fetchArtifactReadHostConnectionFromControlApi } from './controlApiRestService.js' + +export async function resolveArtifactReadHostConnectionForUser(userId: string, hostRef: string, token: string) { + return fetchArtifactReadHostConnectionFromControlApi(userId, hostRef, token) +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/EvenfireMissingRateLimiting.expected b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/EvenfireMissingRateLimiting.expected new file mode 100644 index 0000000000..d7f6b35fb8 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/EvenfireMissingRateLimiting.expected @@ -0,0 +1,14 @@ +| control-api/src/routes/rpc-access/users.ts:38:5:38:53 | require ... read']) | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/rpcAccessAuth.ts:5:5:5:17 | verifyToken() | authorization | +| control-api/src/routes/rpc-access/users.ts:41:5:47:5 | async ( ... }\\n } | This route handler performs $@, but is not rate-limited. | control-api/src/routes/rpc-access/users.ts:42:32:42:92 | resolve ... ectory) | authorization | +| rpc-proxy/src/routes/rpc.ts:39:47:39:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:39:95:39:117 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:43:66:43:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:43:114:43:136 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:47:47:47:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:47:95:47:117 | fakeRes ... actHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:17:93:17:113 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:51:66:51:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:51:114:51:134 | wrongEn ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:23:63:23:83 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:55:47:55:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:30:95:30:115 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:32:65:32:85 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:59:66:59:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/EvenfireMissingRateLimiting.qlref b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/EvenfireMissingRateLimiting.qlref new file mode 100644 index 0000000000..7d3c25d5ca --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/EvenfireMissingRateLimiting.qlref @@ -0,0 +1 @@ +query: EvenfireMissingRateLimiting.ql diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/app.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/app.ts new file mode 100644 index 0000000000..4ece8564ed --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/app.ts @@ -0,0 +1,11 @@ +import express from 'express' +import { requireInternalService, requireInternalToken } from './middleware/internalServiceAuth.js' +import { createRpcAccessRouter } from './routes/rpc-access/index.js' + +export function createApp() { + const api = express.Router() + api.use(requireInternalToken) + api.use('/rpc', requireInternalService('rpc-proxy')) + api.use(createRpcAccessRouter()) + return api +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/config.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/config.ts new file mode 100644 index 0000000000..c8274c450a --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/config.ts @@ -0,0 +1 @@ +export const config = { hostArtifactReadRlPerMin: 30 } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/middleware/internalServiceAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/middleware/internalServiceAuth.ts new file mode 100644 index 0000000000..7224c950a0 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/middleware/internalServiceAuth.ts @@ -0,0 +1,4 @@ +export function requireInternalToken(_req: unknown, _res: unknown, next: () => void) { next() } +export function requireInternalService(_name: string) { + return (_req: unknown, _res: unknown, next: () => void) => next() +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/middleware/rateLimitMiddleware.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/middleware/rateLimitMiddleware.ts new file mode 100644 index 0000000000..32f6b8ea64 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/middleware/rateLimitMiddleware.ts @@ -0,0 +1,3 @@ +export function rateLimitMiddleware(_options: unknown) { + return (_req: unknown, _res: unknown, next: () => void) => next() +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/middleware/rpcAccessAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/middleware/rpcAccessAuth.ts new file mode 100644 index 0000000000..b498d6f7a8 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/middleware/rpcAccessAuth.ts @@ -0,0 +1,16 @@ +declare function verifyToken(): void + +export function requireValidRpcAccessTokenAny(_scopes: string[]) { + return (_req: unknown, _res: unknown, next: () => void) => { + verifyToken() + next() + } +} + +export function requireRpcTokenUserMatch() { + return (_req: unknown, _res: unknown, next: () => void) => next() +} + +export function requireRpcTokenHostMatch() { + return (_req: unknown, _res: unknown, next: () => void) => next() +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/routes/rpc-access/index.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/routes/rpc-access/index.ts new file mode 100644 index 0000000000..6db4f809c7 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/routes/rpc-access/index.ts @@ -0,0 +1,8 @@ +import { createRpcAccessUsersRouter } from './users.js' +import { Router } from 'express' + +export function createRpcAccessRouter(gateway: unknown, options: { directory?: unknown }) { + const router = Router() + router.use(createRpcAccessUsersRouter(gateway, options)) + return router +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/routes/rpc-access/users.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/routes/rpc-access/users.ts new file mode 100644 index 0000000000..5be0939625 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/routes/rpc-access/users.ts @@ -0,0 +1,69 @@ +import { Router } from 'express' +import { config } from '../../config.js' +import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' +import { + requireRpcTokenHostMatch, + requireRpcTokenUserMatch, + requireValidRpcAccessTokenAny, +} from '../../middleware/rpcAccessAuth.js' +import { authorizeRpcHostAccess } from '../../services/access/rpcHostAccessAuthorizer.js' + +type ArtifactRequest = { + params: { userId: string; hostRef: string } + rpcAuth?: { sub?: string } + artifactReadConnection?: { hostRef: string } +} + +async function resolveAuthorizedHostConnection( + req: ArtifactRequest, + _res: unknown, + gateway: unknown, + directory: unknown, +): Promise<{ hostRef: string } | null> { + const userId = String(req.params.userId || '').trim() + const hostRef = String(req.params.hostRef || '').trim() + const claims = req.rpcAuth + if (!claims) return null + const authorization = await authorizeRpcHostAccess(gateway, claims, userId, hostRef, directory) + if (!authorization.authorized) return null + return authorization.connection +} + +export function createRpcAccessUsersRouter(gateway: unknown, options: { directory?: unknown }) { + const router = Router() + const { directory } = options + const hostAccessPath = '/rpc/access/users/:userId/mcp-hosts/:hostRef' + router.get( + `${hostAccessPath}/artifact-read`, + requireValidRpcAccessTokenAny(['host:task:read']), + requireRpcTokenUserMatch(), + requireRpcTokenHostMatch(), + async (req: ArtifactRequest, res, next) => { + const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) + if (connection) { + req.artifactReadConnection = connection + next() + } + }, + rateLimitMiddleware({ + bucketType: 'host_artifact_pre_admission', + maxPerMinute: config.hostArtifactReadRlPerMin, + getBucketKey: req => { + const subject = (req as ArtifactRequest).rpcAuth?.sub + return subject ? `host-artifact-pre-admission:${subject}` : null + }, + }), + rateLimitMiddleware({ + bucketType: 'host_artifact_read', + maxPerMinute: config.hostArtifactReadRlPerMin, + getBucketKey: req => { + const artifactRead = req as ArtifactRequest + const subject = artifactRead.rpcAuth?.sub + const hostRef = artifactRead.artifactReadConnection?.hostRef + return subject && hostRef ? `host-artifact-read:${subject}:${hostRef}` : null + }, + }), + (_req, res) => res.status(200).json({ ok: true }), + ) + return router +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/services/access/rpcHostAccessAuthorizer.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/services/access/rpcHostAccessAuthorizer.ts new file mode 100644 index 0000000000..22957e5917 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/services/access/rpcHostAccessAuthorizer.ts @@ -0,0 +1,9 @@ +export async function authorizeRpcHostAccess( + _gateway: unknown, + _claims: unknown, + _userId: string, + _hostRef: string, + _directory: unknown, +): Promise<{ authorized: boolean; connection: { hostRef: string } }> { + return { authorized: true, connection: { hostRef: 'canonical-host' } } +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/middleware/auth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/middleware/auth.ts new file mode 100644 index 0000000000..01476c7883 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/middleware/auth.ts @@ -0,0 +1,6 @@ +declare function verifyRpcToken(): void +export function requireRpcAuth(_req: unknown, _res: unknown, next: () => void) { verifyRpcToken(); next() } +export function requireScope(_scope: string) { + return (_req: unknown, _res: unknown, next: () => void) => next() +} +export function extractAuthToken(_req: unknown): string { return 'token' } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/routes/rpc.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/routes/rpc.ts new file mode 100644 index 0000000000..f3963fdbb6 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/routes/rpc.ts @@ -0,0 +1,64 @@ +import express from 'express' +import { extractAuthToken, requireRpcAuth, requireScope } from '../middleware/auth.js' +import { resolveArtifactReadHostConnectionForUser } from '../services/mcpProxyService.js' +import { resolveArtifactReadHostConnectionForUser as fakeClient } from '../services/fakeMcpProxyService.js' + +type ArtifactRequest = express.Request & { auth?: { sub: string }; artifactReadHost?: { url: string } } + +const resolveArtifactReadHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + const hostRef = String(req.params.hostRef || '').trim() + req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, hostRef, extractAuthToken(req)) + next() +} + +const fakeResolveArtifactHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) + next() +} + +const wrongEndpointResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) + next() +} + +const fallbackArtifactResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + try { + req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) + } catch { + req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) + } + next() +} + +export function createRpcRouter() { + const router = express.Router() + router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts/download') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fakeResolveArtifactHost, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), wrongEndpointResolver, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts/download') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fallbackArtifactResolver, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts/download') + res.sendStatus(200) + }) + return router +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/services/controlApiRestService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/services/controlApiRestService.ts new file mode 100644 index 0000000000..8d4a12b6fd --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/services/controlApiRestService.ts @@ -0,0 +1,16 @@ +type Options = { artifactRead?: boolean } + +async function fetchHostConnectionForPath( + userId: string, + hostRef: string, + token: string, + options: Options, +): Promise<{ url: string } | null> { + const base = `/rpc/access/users/${userId}/mcp-hosts/${hostRef}` + const endpoint = options.artifactRead ? `${base}/artifact-read` : base + return fetch(endpoint, { headers: { authorization: `Bearer ${token}` } }) as never +} + +export async function fetchArtifactReadHostConnectionFromControlApi(userId: string, hostRef: string, token: string) { + return fetchHostConnectionForPath(userId, hostRef, token, { artifactRead: true }) +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/services/fakeMcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/services/fakeMcpProxyService.ts new file mode 100644 index 0000000000..c03af01f0a --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/services/fakeMcpProxyService.ts @@ -0,0 +1,7 @@ +export async function resolveArtifactReadHostConnectionForUser( + _userId: string, + _hostRef: string, + _token: string, +) { + return { url: 'https://unprotected.example' } +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/services/mcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/services/mcpProxyService.ts new file mode 100644 index 0000000000..e314b09f56 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/services/mcpProxyService.ts @@ -0,0 +1,5 @@ +import { fetchArtifactReadHostConnectionFromControlApi } from './controlApiRestService.js' + +export async function resolveArtifactReadHostConnectionForUser(userId: string, hostRef: string, token: string) { + return fetchArtifactReadHostConnectionFromControlApi(userId, hostRef, token) +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/EvenfireMissingRateLimiting.expected b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/EvenfireMissingRateLimiting.expected new file mode 100644 index 0000000000..ef61faf522 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/EvenfireMissingRateLimiting.expected @@ -0,0 +1,13 @@ +| control-api/src/routes/rpc-access/users.ts:32:5:32:53 | require ... read']) | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/rpcAccessAuth.ts:5:5:5:17 | verifyToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:39:47:39:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:39:95:39:117 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:43:66:43:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:43:114:43:136 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:47:47:47:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:47:95:47:117 | fakeRes ... actHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:17:93:17:113 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:51:66:51:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:51:114:51:134 | wrongEn ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:23:63:23:83 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:55:47:55:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:30:95:30:115 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:32:65:32:85 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:59:66:59:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/EvenfireMissingRateLimiting.qlref b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/EvenfireMissingRateLimiting.qlref new file mode 100644 index 0000000000..7d3c25d5ca --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/EvenfireMissingRateLimiting.qlref @@ -0,0 +1 @@ +query: EvenfireMissingRateLimiting.ql diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/app.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/app.ts new file mode 100644 index 0000000000..4ece8564ed --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/app.ts @@ -0,0 +1,11 @@ +import express from 'express' +import { requireInternalService, requireInternalToken } from './middleware/internalServiceAuth.js' +import { createRpcAccessRouter } from './routes/rpc-access/index.js' + +export function createApp() { + const api = express.Router() + api.use(requireInternalToken) + api.use('/rpc', requireInternalService('rpc-proxy')) + api.use(createRpcAccessRouter()) + return api +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/config.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/config.ts new file mode 100644 index 0000000000..c8274c450a --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/config.ts @@ -0,0 +1 @@ +export const config = { hostArtifactReadRlPerMin: 30 } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/middleware/internalServiceAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/middleware/internalServiceAuth.ts new file mode 100644 index 0000000000..7224c950a0 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/middleware/internalServiceAuth.ts @@ -0,0 +1,4 @@ +export function requireInternalToken(_req: unknown, _res: unknown, next: () => void) { next() } +export function requireInternalService(_name: string) { + return (_req: unknown, _res: unknown, next: () => void) => next() +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/middleware/rateLimitMiddleware.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/middleware/rateLimitMiddleware.ts new file mode 100644 index 0000000000..32f6b8ea64 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/middleware/rateLimitMiddleware.ts @@ -0,0 +1,3 @@ +export function rateLimitMiddleware(_options: unknown) { + return (_req: unknown, _res: unknown, next: () => void) => next() +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/middleware/rpcAccessAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/middleware/rpcAccessAuth.ts new file mode 100644 index 0000000000..b498d6f7a8 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/middleware/rpcAccessAuth.ts @@ -0,0 +1,16 @@ +declare function verifyToken(): void + +export function requireValidRpcAccessTokenAny(_scopes: string[]) { + return (_req: unknown, _res: unknown, next: () => void) => { + verifyToken() + next() + } +} + +export function requireRpcTokenUserMatch() { + return (_req: unknown, _res: unknown, next: () => void) => next() +} + +export function requireRpcTokenHostMatch() { + return (_req: unknown, _res: unknown, next: () => void) => next() +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/routes/rpc-access/index.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/routes/rpc-access/index.ts new file mode 100644 index 0000000000..6db4f809c7 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/routes/rpc-access/index.ts @@ -0,0 +1,8 @@ +import { createRpcAccessUsersRouter } from './users.js' +import { Router } from 'express' + +export function createRpcAccessRouter(gateway: unknown, options: { directory?: unknown }) { + const router = Router() + router.use(createRpcAccessUsersRouter(gateway, options)) + return router +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/routes/rpc-access/users.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/routes/rpc-access/users.ts new file mode 100644 index 0000000000..3baa724ca0 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/routes/rpc-access/users.ts @@ -0,0 +1,63 @@ +import { Router } from 'express' +import { config } from '../../config.js' +import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' +import { + requireRpcTokenHostMatch, + requireRpcTokenUserMatch, + requireValidRpcAccessTokenAny, +} from '../../middleware/rpcAccessAuth.js' +import { authorizeRpcHostAccess } from '../../services/access/rpcHostAccessAuthorizer.js' + +type ArtifactRequest = { + params: { userId: string; hostRef: string } + rpcAuth?: { sub?: string } + artifactReadConnection?: { hostRef: string } +} + +async function resolveAuthorizedHostConnection( + req: ArtifactRequest, + _res: unknown, + _gateway: unknown, + _directory: unknown, +): Promise<{ hostRef: string } | null> { + return { hostRef: req.params.hostRef } +} + +export function createRpcAccessUsersRouter(gateway: unknown, options: { directory?: unknown }) { + const router = Router() + const { directory } = options + const hostAccessPath = '/rpc/access/users/:userId/mcp-hosts/:hostRef' + router.get( + `${hostAccessPath}/artifact-read`, + requireValidRpcAccessTokenAny(['host:task:read']), + requireRpcTokenUserMatch(), + requireRpcTokenHostMatch(), + rateLimitMiddleware({ + bucketType: 'host_artifact_pre_admission', + maxPerMinute: config.hostArtifactReadRlPerMin, + getBucketKey: req => { + const subject = (req as ArtifactRequest).rpcAuth?.sub + return subject ? `host-artifact-pre-admission:${subject}` : null + }, + }), + async (req: ArtifactRequest, res, next) => { + const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) + if (connection) { + req.artifactReadConnection = connection + next() + } + }, + rateLimitMiddleware({ + bucketType: 'host_artifact_read', + maxPerMinute: config.hostArtifactReadRlPerMin, + getBucketKey: req => { + const artifactRead = req as ArtifactRequest + const subject = artifactRead.rpcAuth?.sub + const hostRef = artifactRead.artifactReadConnection?.hostRef + return subject && hostRef ? `host-artifact-read:${subject}:${hostRef}` : null + }, + }), + (_req, res) => res.status(200).json({ ok: true }), + ) + return router +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/services/access/rpcHostAccessAuthorizer.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/services/access/rpcHostAccessAuthorizer.ts new file mode 100644 index 0000000000..22957e5917 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/services/access/rpcHostAccessAuthorizer.ts @@ -0,0 +1,9 @@ +export async function authorizeRpcHostAccess( + _gateway: unknown, + _claims: unknown, + _userId: string, + _hostRef: string, + _directory: unknown, +): Promise<{ authorized: boolean; connection: { hostRef: string } }> { + return { authorized: true, connection: { hostRef: 'canonical-host' } } +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/middleware/auth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/middleware/auth.ts new file mode 100644 index 0000000000..01476c7883 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/middleware/auth.ts @@ -0,0 +1,6 @@ +declare function verifyRpcToken(): void +export function requireRpcAuth(_req: unknown, _res: unknown, next: () => void) { verifyRpcToken(); next() } +export function requireScope(_scope: string) { + return (_req: unknown, _res: unknown, next: () => void) => next() +} +export function extractAuthToken(_req: unknown): string { return 'token' } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/routes/rpc.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/routes/rpc.ts new file mode 100644 index 0000000000..f3963fdbb6 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/routes/rpc.ts @@ -0,0 +1,64 @@ +import express from 'express' +import { extractAuthToken, requireRpcAuth, requireScope } from '../middleware/auth.js' +import { resolveArtifactReadHostConnectionForUser } from '../services/mcpProxyService.js' +import { resolveArtifactReadHostConnectionForUser as fakeClient } from '../services/fakeMcpProxyService.js' + +type ArtifactRequest = express.Request & { auth?: { sub: string }; artifactReadHost?: { url: string } } + +const resolveArtifactReadHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + const hostRef = String(req.params.hostRef || '').trim() + req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, hostRef, extractAuthToken(req)) + next() +} + +const fakeResolveArtifactHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) + next() +} + +const wrongEndpointResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) + next() +} + +const fallbackArtifactResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + try { + req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) + } catch { + req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) + } + next() +} + +export function createRpcRouter() { + const router = express.Router() + router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts/download') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fakeResolveArtifactHost, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), wrongEndpointResolver, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts/download') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fallbackArtifactResolver, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts/download') + res.sendStatus(200) + }) + return router +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/services/controlApiRestService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/services/controlApiRestService.ts new file mode 100644 index 0000000000..8d4a12b6fd --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/services/controlApiRestService.ts @@ -0,0 +1,16 @@ +type Options = { artifactRead?: boolean } + +async function fetchHostConnectionForPath( + userId: string, + hostRef: string, + token: string, + options: Options, +): Promise<{ url: string } | null> { + const base = `/rpc/access/users/${userId}/mcp-hosts/${hostRef}` + const endpoint = options.artifactRead ? `${base}/artifact-read` : base + return fetch(endpoint, { headers: { authorization: `Bearer ${token}` } }) as never +} + +export async function fetchArtifactReadHostConnectionFromControlApi(userId: string, hostRef: string, token: string) { + return fetchHostConnectionForPath(userId, hostRef, token, { artifactRead: true }) +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/services/fakeMcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/services/fakeMcpProxyService.ts new file mode 100644 index 0000000000..c03af01f0a --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/services/fakeMcpProxyService.ts @@ -0,0 +1,7 @@ +export async function resolveArtifactReadHostConnectionForUser( + _userId: string, + _hostRef: string, + _token: string, +) { + return { url: 'https://unprotected.example' } +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/services/mcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/services/mcpProxyService.ts new file mode 100644 index 0000000000..e314b09f56 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/services/mcpProxyService.ts @@ -0,0 +1,5 @@ +import { fetchArtifactReadHostConnectionFromControlApi } from './controlApiRestService.js' + +export async function resolveArtifactReadHostConnectionForUser(userId: string, hostRef: string, token: string) { + return fetchArtifactReadHostConnectionFromControlApi(userId, hostRef, token) +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/EvenfireMissingRateLimiting.expected b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/EvenfireMissingRateLimiting.expected new file mode 100644 index 0000000000..8a73eee2af --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/EvenfireMissingRateLimiting.expected @@ -0,0 +1,13 @@ +| control-api/src/routes/rpc-access/users.ts:38:5:38:53 | require ... read']) | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/rpcAccessAuth.ts:5:5:5:17 | verifyToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:39:47:39:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:39:95:39:117 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:43:66:43:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:43:114:43:136 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:47:47:47:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:47:95:47:117 | fakeRes ... actHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:17:93:17:113 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:51:66:51:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:51:114:51:134 | wrongEn ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:23:63:23:83 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:55:47:55:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:30:95:30:115 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:32:65:32:85 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:59:66:59:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/EvenfireMissingRateLimiting.qlref b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/EvenfireMissingRateLimiting.qlref new file mode 100644 index 0000000000..7d3c25d5ca --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/EvenfireMissingRateLimiting.qlref @@ -0,0 +1 @@ +query: EvenfireMissingRateLimiting.ql diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/app.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/app.ts new file mode 100644 index 0000000000..4ece8564ed --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/app.ts @@ -0,0 +1,11 @@ +import express from 'express' +import { requireInternalService, requireInternalToken } from './middleware/internalServiceAuth.js' +import { createRpcAccessRouter } from './routes/rpc-access/index.js' + +export function createApp() { + const api = express.Router() + api.use(requireInternalToken) + api.use('/rpc', requireInternalService('rpc-proxy')) + api.use(createRpcAccessRouter()) + return api +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/config.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/config.ts new file mode 100644 index 0000000000..c8274c450a --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/config.ts @@ -0,0 +1 @@ +export const config = { hostArtifactReadRlPerMin: 30 } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/middleware/internalServiceAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/middleware/internalServiceAuth.ts new file mode 100644 index 0000000000..7224c950a0 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/middleware/internalServiceAuth.ts @@ -0,0 +1,4 @@ +export function requireInternalToken(_req: unknown, _res: unknown, next: () => void) { next() } +export function requireInternalService(_name: string) { + return (_req: unknown, _res: unknown, next: () => void) => next() +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/middleware/rateLimitMiddleware.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/middleware/rateLimitMiddleware.ts new file mode 100644 index 0000000000..32f6b8ea64 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/middleware/rateLimitMiddleware.ts @@ -0,0 +1,3 @@ +export function rateLimitMiddleware(_options: unknown) { + return (_req: unknown, _res: unknown, next: () => void) => next() +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/middleware/rpcAccessAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/middleware/rpcAccessAuth.ts new file mode 100644 index 0000000000..b498d6f7a8 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/middleware/rpcAccessAuth.ts @@ -0,0 +1,16 @@ +declare function verifyToken(): void + +export function requireValidRpcAccessTokenAny(_scopes: string[]) { + return (_req: unknown, _res: unknown, next: () => void) => { + verifyToken() + next() + } +} + +export function requireRpcTokenUserMatch() { + return (_req: unknown, _res: unknown, next: () => void) => next() +} + +export function requireRpcTokenHostMatch() { + return (_req: unknown, _res: unknown, next: () => void) => next() +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/routes/rpc-access/index.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/routes/rpc-access/index.ts new file mode 100644 index 0000000000..6db4f809c7 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/routes/rpc-access/index.ts @@ -0,0 +1,8 @@ +import { createRpcAccessUsersRouter } from './users.js' +import { Router } from 'express' + +export function createRpcAccessRouter(gateway: unknown, options: { directory?: unknown }) { + const router = Router() + router.use(createRpcAccessUsersRouter(gateway, options)) + return router +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/routes/rpc-access/users.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/routes/rpc-access/users.ts new file mode 100644 index 0000000000..7acfa4cf24 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/routes/rpc-access/users.ts @@ -0,0 +1,69 @@ +import { Router } from 'express' +import { config } from '../../config.js' +import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' +import { + requireRpcTokenHostMatch, + requireRpcTokenUserMatch, + requireValidRpcAccessTokenAny, +} from '../../middleware/rpcAccessAuth.js' +import { authorizeRpcHostAccess } from '../../services/access/rpcHostAccessAuthorizer.js' + +type ArtifactRequest = { + params: { userId: string; hostRef: string } + rpcAuth?: { sub?: string } + artifactReadConnection?: { hostRef: string } +} + +async function resolveAuthorizedHostConnection( + req: ArtifactRequest, + _res: unknown, + gateway: unknown, + directory: unknown, +): Promise<{ hostRef: string } | null> { + const userId = String(req.params.userId || '').trim() + const hostRef = String(req.params.hostRef || '').trim() + const claims = req.rpcAuth + if (!claims) return null + const authorization = await authorizeRpcHostAccess(gateway, claims, userId, hostRef, directory) + if (!authorization.authorized) return null + return authorization.connection +} + +export function createRpcAccessUsersRouter(gateway: unknown, options: { directory?: unknown }) { + const router = Router() + const { directory } = options + const hostAccessPath = '/rpc/access/users/:userId/mcp-hosts/:hostRef' + router.get( + `${hostAccessPath}/artifact-read`, + requireValidRpcAccessTokenAny(['host:task:read']), + requireRpcTokenUserMatch(), + (_req, _res, next) => next(), + rateLimitMiddleware({ + bucketType: 'host_artifact_pre_admission', + maxPerMinute: config.hostArtifactReadRlPerMin, + getBucketKey: req => { + const subject = (req as ArtifactRequest).rpcAuth?.sub + return subject ? `host-artifact-pre-admission:${subject}` : null + }, + }), + async (req: ArtifactRequest, res, next) => { + const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) + if (connection) { + req.artifactReadConnection = connection + next() + } + }, + rateLimitMiddleware({ + bucketType: 'host_artifact_read', + maxPerMinute: config.hostArtifactReadRlPerMin, + getBucketKey: req => { + const artifactRead = req as ArtifactRequest + const subject = artifactRead.rpcAuth?.sub + const hostRef = artifactRead.artifactReadConnection?.hostRef + return subject && hostRef ? `host-artifact-read:${subject}:${hostRef}` : null + }, + }), + (_req, res) => res.status(200).json({ ok: true }), + ) + return router +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/services/access/rpcHostAccessAuthorizer.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/services/access/rpcHostAccessAuthorizer.ts new file mode 100644 index 0000000000..22957e5917 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/services/access/rpcHostAccessAuthorizer.ts @@ -0,0 +1,9 @@ +export async function authorizeRpcHostAccess( + _gateway: unknown, + _claims: unknown, + _userId: string, + _hostRef: string, + _directory: unknown, +): Promise<{ authorized: boolean; connection: { hostRef: string } }> { + return { authorized: true, connection: { hostRef: 'canonical-host' } } +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/middleware/auth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/middleware/auth.ts new file mode 100644 index 0000000000..01476c7883 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/middleware/auth.ts @@ -0,0 +1,6 @@ +declare function verifyRpcToken(): void +export function requireRpcAuth(_req: unknown, _res: unknown, next: () => void) { verifyRpcToken(); next() } +export function requireScope(_scope: string) { + return (_req: unknown, _res: unknown, next: () => void) => next() +} +export function extractAuthToken(_req: unknown): string { return 'token' } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/routes/rpc.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/routes/rpc.ts new file mode 100644 index 0000000000..f3963fdbb6 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/routes/rpc.ts @@ -0,0 +1,64 @@ +import express from 'express' +import { extractAuthToken, requireRpcAuth, requireScope } from '../middleware/auth.js' +import { resolveArtifactReadHostConnectionForUser } from '../services/mcpProxyService.js' +import { resolveArtifactReadHostConnectionForUser as fakeClient } from '../services/fakeMcpProxyService.js' + +type ArtifactRequest = express.Request & { auth?: { sub: string }; artifactReadHost?: { url: string } } + +const resolveArtifactReadHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + const hostRef = String(req.params.hostRef || '').trim() + req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, hostRef, extractAuthToken(req)) + next() +} + +const fakeResolveArtifactHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) + next() +} + +const wrongEndpointResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) + next() +} + +const fallbackArtifactResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + try { + req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) + } catch { + req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) + } + next() +} + +export function createRpcRouter() { + const router = express.Router() + router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts/download') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fakeResolveArtifactHost, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), wrongEndpointResolver, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts/download') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fallbackArtifactResolver, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts/download') + res.sendStatus(200) + }) + return router +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/services/controlApiRestService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/services/controlApiRestService.ts new file mode 100644 index 0000000000..8d4a12b6fd --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/services/controlApiRestService.ts @@ -0,0 +1,16 @@ +type Options = { artifactRead?: boolean } + +async function fetchHostConnectionForPath( + userId: string, + hostRef: string, + token: string, + options: Options, +): Promise<{ url: string } | null> { + const base = `/rpc/access/users/${userId}/mcp-hosts/${hostRef}` + const endpoint = options.artifactRead ? `${base}/artifact-read` : base + return fetch(endpoint, { headers: { authorization: `Bearer ${token}` } }) as never +} + +export async function fetchArtifactReadHostConnectionFromControlApi(userId: string, hostRef: string, token: string) { + return fetchHostConnectionForPath(userId, hostRef, token, { artifactRead: true }) +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/services/fakeMcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/services/fakeMcpProxyService.ts new file mode 100644 index 0000000000..c03af01f0a --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/services/fakeMcpProxyService.ts @@ -0,0 +1,7 @@ +export async function resolveArtifactReadHostConnectionForUser( + _userId: string, + _hostRef: string, + _token: string, +) { + return { url: 'https://unprotected.example' } +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/services/mcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/services/mcpProxyService.ts new file mode 100644 index 0000000000..e314b09f56 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/services/mcpProxyService.ts @@ -0,0 +1,5 @@ +import { fetchArtifactReadHostConnectionFromControlApi } from './controlApiRestService.js' + +export async function resolveArtifactReadHostConnectionForUser(userId: string, hostRef: string, token: string) { + return fetchArtifactReadHostConnectionFromControlApi(userId, hostRef, token) +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/EvenfireMissingRateLimiting.expected b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/EvenfireMissingRateLimiting.expected new file mode 100644 index 0000000000..8a73eee2af --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/EvenfireMissingRateLimiting.expected @@ -0,0 +1,13 @@ +| control-api/src/routes/rpc-access/users.ts:38:5:38:53 | require ... read']) | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/rpcAccessAuth.ts:5:5:5:17 | verifyToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:39:47:39:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:39:95:39:117 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:43:66:43:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:43:114:43:136 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:47:47:47:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:47:95:47:117 | fakeRes ... actHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:17:93:17:113 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:51:66:51:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:51:114:51:134 | wrongEn ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:23:63:23:83 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:55:47:55:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:30:95:30:115 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:32:65:32:85 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:59:66:59:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/EvenfireMissingRateLimiting.qlref b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/EvenfireMissingRateLimiting.qlref new file mode 100644 index 0000000000..7d3c25d5ca --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/EvenfireMissingRateLimiting.qlref @@ -0,0 +1 @@ +query: EvenfireMissingRateLimiting.ql diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/app.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/app.ts new file mode 100644 index 0000000000..4ece8564ed --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/app.ts @@ -0,0 +1,11 @@ +import express from 'express' +import { requireInternalService, requireInternalToken } from './middleware/internalServiceAuth.js' +import { createRpcAccessRouter } from './routes/rpc-access/index.js' + +export function createApp() { + const api = express.Router() + api.use(requireInternalToken) + api.use('/rpc', requireInternalService('rpc-proxy')) + api.use(createRpcAccessRouter()) + return api +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/config.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/config.ts new file mode 100644 index 0000000000..c8274c450a --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/config.ts @@ -0,0 +1 @@ +export const config = { hostArtifactReadRlPerMin: 30 } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/middleware/internalServiceAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/middleware/internalServiceAuth.ts new file mode 100644 index 0000000000..7224c950a0 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/middleware/internalServiceAuth.ts @@ -0,0 +1,4 @@ +export function requireInternalToken(_req: unknown, _res: unknown, next: () => void) { next() } +export function requireInternalService(_name: string) { + return (_req: unknown, _res: unknown, next: () => void) => next() +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/middleware/rateLimitMiddleware.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/middleware/rateLimitMiddleware.ts new file mode 100644 index 0000000000..32f6b8ea64 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/middleware/rateLimitMiddleware.ts @@ -0,0 +1,3 @@ +export function rateLimitMiddleware(_options: unknown) { + return (_req: unknown, _res: unknown, next: () => void) => next() +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/middleware/rpcAccessAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/middleware/rpcAccessAuth.ts new file mode 100644 index 0000000000..b498d6f7a8 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/middleware/rpcAccessAuth.ts @@ -0,0 +1,16 @@ +declare function verifyToken(): void + +export function requireValidRpcAccessTokenAny(_scopes: string[]) { + return (_req: unknown, _res: unknown, next: () => void) => { + verifyToken() + next() + } +} + +export function requireRpcTokenUserMatch() { + return (_req: unknown, _res: unknown, next: () => void) => next() +} + +export function requireRpcTokenHostMatch() { + return (_req: unknown, _res: unknown, next: () => void) => next() +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/routes/rpc-access/index.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/routes/rpc-access/index.ts new file mode 100644 index 0000000000..6db4f809c7 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/routes/rpc-access/index.ts @@ -0,0 +1,8 @@ +import { createRpcAccessUsersRouter } from './users.js' +import { Router } from 'express' + +export function createRpcAccessRouter(gateway: unknown, options: { directory?: unknown }) { + const router = Router() + router.use(createRpcAccessUsersRouter(gateway, options)) + return router +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/routes/rpc-access/users.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/routes/rpc-access/users.ts new file mode 100644 index 0000000000..8491871a55 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/routes/rpc-access/users.ts @@ -0,0 +1,69 @@ +import { Router } from 'express' +import { config } from '../../config.js' +import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' +import { + requireRpcTokenHostMatch, + requireRpcTokenUserMatch, + requireValidRpcAccessTokenAny, +} from '../../middleware/rpcAccessAuth.js' +import { authorizeRpcHostAccess } from '../../services/access/rpcHostAccessAuthorizer.js' + +type ArtifactRequest = { + params: { userId: string; hostRef: string } + rpcAuth?: { sub?: string } + artifactReadConnection?: { hostRef: string } +} + +async function resolveAuthorizedHostConnection( + req: ArtifactRequest, + _res: unknown, + gateway: unknown, + directory: unknown, +): Promise<{ hostRef: string } | null> { + const userId = String(req.params.userId || '').trim() + const hostRef = String(req.params.hostRef || '').trim() + const claims = req.rpcAuth + if (!claims) return null + const authorization = await authorizeRpcHostAccess(gateway, claims, userId, hostRef, directory) + if (!authorization.authorized) return null + return authorization.connection +} + +export function createRpcAccessUsersRouter(gateway: unknown, options: { directory?: unknown }) { + const router = Router() + const { directory } = options + const hostAccessPath = '/rpc/access/users/:userId/mcp-hosts/:hostRef' + router.get( + `${hostAccessPath}/artifact-read`, + requireValidRpcAccessTokenAny(['host:task:read']), + (_req, _res, next) => next(), + requireRpcTokenHostMatch(), + rateLimitMiddleware({ + bucketType: 'host_artifact_pre_admission', + maxPerMinute: config.hostArtifactReadRlPerMin, + getBucketKey: req => { + const subject = (req as ArtifactRequest).rpcAuth?.sub + return subject ? `host-artifact-pre-admission:${subject}` : null + }, + }), + async (req: ArtifactRequest, res, next) => { + const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) + if (connection) { + req.artifactReadConnection = connection + next() + } + }, + rateLimitMiddleware({ + bucketType: 'host_artifact_read', + maxPerMinute: config.hostArtifactReadRlPerMin, + getBucketKey: req => { + const artifactRead = req as ArtifactRequest + const subject = artifactRead.rpcAuth?.sub + const hostRef = artifactRead.artifactReadConnection?.hostRef + return subject && hostRef ? `host-artifact-read:${subject}:${hostRef}` : null + }, + }), + (_req, res) => res.status(200).json({ ok: true }), + ) + return router +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/services/access/rpcHostAccessAuthorizer.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/services/access/rpcHostAccessAuthorizer.ts new file mode 100644 index 0000000000..22957e5917 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/services/access/rpcHostAccessAuthorizer.ts @@ -0,0 +1,9 @@ +export async function authorizeRpcHostAccess( + _gateway: unknown, + _claims: unknown, + _userId: string, + _hostRef: string, + _directory: unknown, +): Promise<{ authorized: boolean; connection: { hostRef: string } }> { + return { authorized: true, connection: { hostRef: 'canonical-host' } } +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/middleware/auth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/middleware/auth.ts new file mode 100644 index 0000000000..01476c7883 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/middleware/auth.ts @@ -0,0 +1,6 @@ +declare function verifyRpcToken(): void +export function requireRpcAuth(_req: unknown, _res: unknown, next: () => void) { verifyRpcToken(); next() } +export function requireScope(_scope: string) { + return (_req: unknown, _res: unknown, next: () => void) => next() +} +export function extractAuthToken(_req: unknown): string { return 'token' } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/routes/rpc.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/routes/rpc.ts new file mode 100644 index 0000000000..f3963fdbb6 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/routes/rpc.ts @@ -0,0 +1,64 @@ +import express from 'express' +import { extractAuthToken, requireRpcAuth, requireScope } from '../middleware/auth.js' +import { resolveArtifactReadHostConnectionForUser } from '../services/mcpProxyService.js' +import { resolveArtifactReadHostConnectionForUser as fakeClient } from '../services/fakeMcpProxyService.js' + +type ArtifactRequest = express.Request & { auth?: { sub: string }; artifactReadHost?: { url: string } } + +const resolveArtifactReadHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + const hostRef = String(req.params.hostRef || '').trim() + req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, hostRef, extractAuthToken(req)) + next() +} + +const fakeResolveArtifactHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) + next() +} + +const wrongEndpointResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) + next() +} + +const fallbackArtifactResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + try { + req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) + } catch { + req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) + } + next() +} + +export function createRpcRouter() { + const router = express.Router() + router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts/download') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fakeResolveArtifactHost, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), wrongEndpointResolver, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts/download') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fallbackArtifactResolver, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts/download') + res.sendStatus(200) + }) + return router +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/services/controlApiRestService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/services/controlApiRestService.ts new file mode 100644 index 0000000000..8d4a12b6fd --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/services/controlApiRestService.ts @@ -0,0 +1,16 @@ +type Options = { artifactRead?: boolean } + +async function fetchHostConnectionForPath( + userId: string, + hostRef: string, + token: string, + options: Options, +): Promise<{ url: string } | null> { + const base = `/rpc/access/users/${userId}/mcp-hosts/${hostRef}` + const endpoint = options.artifactRead ? `${base}/artifact-read` : base + return fetch(endpoint, { headers: { authorization: `Bearer ${token}` } }) as never +} + +export async function fetchArtifactReadHostConnectionFromControlApi(userId: string, hostRef: string, token: string) { + return fetchHostConnectionForPath(userId, hostRef, token, { artifactRead: true }) +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/services/fakeMcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/services/fakeMcpProxyService.ts new file mode 100644 index 0000000000..c03af01f0a --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/services/fakeMcpProxyService.ts @@ -0,0 +1,7 @@ +export async function resolveArtifactReadHostConnectionForUser( + _userId: string, + _hostRef: string, + _token: string, +) { + return { url: 'https://unprotected.example' } +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/services/mcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/services/mcpProxyService.ts new file mode 100644 index 0000000000..e314b09f56 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/services/mcpProxyService.ts @@ -0,0 +1,5 @@ +import { fetchArtifactReadHostConnectionFromControlApi } from './controlApiRestService.js' + +export async function resolveArtifactReadHostConnectionForUser(userId: string, hostRef: string, token: string) { + return fetchArtifactReadHostConnectionFromControlApi(userId, hostRef, token) +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/EvenfireMissingRateLimiting.expected b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/EvenfireMissingRateLimiting.expected new file mode 100644 index 0000000000..8a73eee2af --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/EvenfireMissingRateLimiting.expected @@ -0,0 +1,13 @@ +| control-api/src/routes/rpc-access/users.ts:38:5:38:53 | require ... read']) | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/rpcAccessAuth.ts:5:5:5:17 | verifyToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:39:47:39:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:39:95:39:117 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:43:66:43:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:43:114:43:136 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:47:47:47:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:47:95:47:117 | fakeRes ... actHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:17:93:17:113 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:51:66:51:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:51:114:51:134 | wrongEn ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:23:63:23:83 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:55:47:55:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:30:95:30:115 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:32:65:32:85 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:59:66:59:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/EvenfireMissingRateLimiting.qlref b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/EvenfireMissingRateLimiting.qlref new file mode 100644 index 0000000000..7d3c25d5ca --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/EvenfireMissingRateLimiting.qlref @@ -0,0 +1 @@ +query: EvenfireMissingRateLimiting.ql diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/app.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/app.ts new file mode 100644 index 0000000000..4ece8564ed --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/app.ts @@ -0,0 +1,11 @@ +import express from 'express' +import { requireInternalService, requireInternalToken } from './middleware/internalServiceAuth.js' +import { createRpcAccessRouter } from './routes/rpc-access/index.js' + +export function createApp() { + const api = express.Router() + api.use(requireInternalToken) + api.use('/rpc', requireInternalService('rpc-proxy')) + api.use(createRpcAccessRouter()) + return api +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/config.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/config.ts new file mode 100644 index 0000000000..c8274c450a --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/config.ts @@ -0,0 +1 @@ +export const config = { hostArtifactReadRlPerMin: 30 } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/middleware/internalServiceAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/middleware/internalServiceAuth.ts new file mode 100644 index 0000000000..7224c950a0 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/middleware/internalServiceAuth.ts @@ -0,0 +1,4 @@ +export function requireInternalToken(_req: unknown, _res: unknown, next: () => void) { next() } +export function requireInternalService(_name: string) { + return (_req: unknown, _res: unknown, next: () => void) => next() +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/middleware/rateLimitMiddleware.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/middleware/rateLimitMiddleware.ts new file mode 100644 index 0000000000..32f6b8ea64 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/middleware/rateLimitMiddleware.ts @@ -0,0 +1,3 @@ +export function rateLimitMiddleware(_options: unknown) { + return (_req: unknown, _res: unknown, next: () => void) => next() +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/middleware/rpcAccessAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/middleware/rpcAccessAuth.ts new file mode 100644 index 0000000000..b498d6f7a8 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/middleware/rpcAccessAuth.ts @@ -0,0 +1,16 @@ +declare function verifyToken(): void + +export function requireValidRpcAccessTokenAny(_scopes: string[]) { + return (_req: unknown, _res: unknown, next: () => void) => { + verifyToken() + next() + } +} + +export function requireRpcTokenUserMatch() { + return (_req: unknown, _res: unknown, next: () => void) => next() +} + +export function requireRpcTokenHostMatch() { + return (_req: unknown, _res: unknown, next: () => void) => next() +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/routes/rpc-access/index.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/routes/rpc-access/index.ts new file mode 100644 index 0000000000..6db4f809c7 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/routes/rpc-access/index.ts @@ -0,0 +1,8 @@ +import { createRpcAccessUsersRouter } from './users.js' +import { Router } from 'express' + +export function createRpcAccessRouter(gateway: unknown, options: { directory?: unknown }) { + const router = Router() + router.use(createRpcAccessUsersRouter(gateway, options)) + return router +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/routes/rpc-access/users.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/routes/rpc-access/users.ts new file mode 100644 index 0000000000..fb8fb7ad6b --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/routes/rpc-access/users.ts @@ -0,0 +1,69 @@ +import { Router } from 'express' +import { config } from '../../config.js' +import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' +import { + requireRpcTokenHostMatch, + requireRpcTokenUserMatch, + requireValidRpcAccessTokenAny, +} from '../../middleware/rpcAccessAuth.js' +import { authorizeRpcHostAccess } from '../../services/access/rpcHostAccessAuthorizer.js' + +type ArtifactRequest = { + params: { userId: string; hostRef: string } + rpcAuth?: { sub?: string } + artifactReadConnection?: { hostRef: string } +} + +async function resolveAuthorizedHostConnection( + req: ArtifactRequest, + _res: unknown, + gateway: unknown, + directory: unknown, +): Promise<{ hostRef: string } | null> { + const userId = String(req.params.userId || '').trim() + const hostRef = String(req.params.hostRef || '').trim() + const claims = req.rpcAuth + if (!claims) return null + const authorization = await authorizeRpcHostAccess(gateway, claims, userId, hostRef, directory) + if (!authorization.authorized) return null + return authorization.connection +} + +export function createRpcAccessUsersRouter(gateway: unknown, options: { directory?: unknown }) { + const router = Router() + const { directory } = options + const hostAccessPath = '/rpc/access/users/:userId/mcp-hosts/:hostRef' + router.get( + `${hostAccessPath}/artifact-read`, + requireValidRpcAccessTokenAny(['host:task:read']), + requireRpcTokenUserMatch(), + requireRpcTokenHostMatch(), + rateLimitMiddleware({ + bucketType: 'unrelated_artifact_limiter', + maxPerMinute: config.hostArtifactReadRlPerMin, + getBucketKey: req => { + const subject = (req as ArtifactRequest).rpcAuth?.sub + return subject ? `host-artifact-pre-admission:${subject}` : null + }, + }), + async (req: ArtifactRequest, res, next) => { + const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) + if (connection) { + req.artifactReadConnection = connection + next() + } + }, + rateLimitMiddleware({ + bucketType: 'host_artifact_read', + maxPerMinute: config.hostArtifactReadRlPerMin, + getBucketKey: req => { + const artifactRead = req as ArtifactRequest + const subject = artifactRead.rpcAuth?.sub + const hostRef = artifactRead.artifactReadConnection?.hostRef + return subject && hostRef ? `host-artifact-read:${subject}:${hostRef}` : null + }, + }), + (_req, res) => res.status(200).json({ ok: true }), + ) + return router +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/services/access/rpcHostAccessAuthorizer.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/services/access/rpcHostAccessAuthorizer.ts new file mode 100644 index 0000000000..22957e5917 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/services/access/rpcHostAccessAuthorizer.ts @@ -0,0 +1,9 @@ +export async function authorizeRpcHostAccess( + _gateway: unknown, + _claims: unknown, + _userId: string, + _hostRef: string, + _directory: unknown, +): Promise<{ authorized: boolean; connection: { hostRef: string } }> { + return { authorized: true, connection: { hostRef: 'canonical-host' } } +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/middleware/auth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/middleware/auth.ts new file mode 100644 index 0000000000..01476c7883 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/middleware/auth.ts @@ -0,0 +1,6 @@ +declare function verifyRpcToken(): void +export function requireRpcAuth(_req: unknown, _res: unknown, next: () => void) { verifyRpcToken(); next() } +export function requireScope(_scope: string) { + return (_req: unknown, _res: unknown, next: () => void) => next() +} +export function extractAuthToken(_req: unknown): string { return 'token' } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/routes/rpc.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/routes/rpc.ts new file mode 100644 index 0000000000..f3963fdbb6 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/routes/rpc.ts @@ -0,0 +1,64 @@ +import express from 'express' +import { extractAuthToken, requireRpcAuth, requireScope } from '../middleware/auth.js' +import { resolveArtifactReadHostConnectionForUser } from '../services/mcpProxyService.js' +import { resolveArtifactReadHostConnectionForUser as fakeClient } from '../services/fakeMcpProxyService.js' + +type ArtifactRequest = express.Request & { auth?: { sub: string }; artifactReadHost?: { url: string } } + +const resolveArtifactReadHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + const hostRef = String(req.params.hostRef || '').trim() + req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, hostRef, extractAuthToken(req)) + next() +} + +const fakeResolveArtifactHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) + next() +} + +const wrongEndpointResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) + next() +} + +const fallbackArtifactResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + try { + req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) + } catch { + req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) + } + next() +} + +export function createRpcRouter() { + const router = express.Router() + router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts/download') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fakeResolveArtifactHost, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), wrongEndpointResolver, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts/download') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fallbackArtifactResolver, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts/download') + res.sendStatus(200) + }) + return router +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/services/controlApiRestService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/services/controlApiRestService.ts new file mode 100644 index 0000000000..8d4a12b6fd --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/services/controlApiRestService.ts @@ -0,0 +1,16 @@ +type Options = { artifactRead?: boolean } + +async function fetchHostConnectionForPath( + userId: string, + hostRef: string, + token: string, + options: Options, +): Promise<{ url: string } | null> { + const base = `/rpc/access/users/${userId}/mcp-hosts/${hostRef}` + const endpoint = options.artifactRead ? `${base}/artifact-read` : base + return fetch(endpoint, { headers: { authorization: `Bearer ${token}` } }) as never +} + +export async function fetchArtifactReadHostConnectionFromControlApi(userId: string, hostRef: string, token: string) { + return fetchHostConnectionForPath(userId, hostRef, token, { artifactRead: true }) +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/services/fakeMcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/services/fakeMcpProxyService.ts new file mode 100644 index 0000000000..c03af01f0a --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/services/fakeMcpProxyService.ts @@ -0,0 +1,7 @@ +export async function resolveArtifactReadHostConnectionForUser( + _userId: string, + _hostRef: string, + _token: string, +) { + return { url: 'https://unprotected.example' } +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/services/mcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/services/mcpProxyService.ts new file mode 100644 index 0000000000..e314b09f56 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/services/mcpProxyService.ts @@ -0,0 +1,5 @@ +import { fetchArtifactReadHostConnectionFromControlApi } from './controlApiRestService.js' + +export async function resolveArtifactReadHostConnectionForUser(userId: string, hostRef: string, token: string) { + return fetchArtifactReadHostConnectionFromControlApi(userId, hostRef, token) +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/EvenfireMissingRateLimiting.expected b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/EvenfireMissingRateLimiting.expected new file mode 100644 index 0000000000..8a73eee2af --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/EvenfireMissingRateLimiting.expected @@ -0,0 +1,13 @@ +| control-api/src/routes/rpc-access/users.ts:38:5:38:53 | require ... read']) | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/rpcAccessAuth.ts:5:5:5:17 | verifyToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:39:47:39:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:39:95:39:117 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:43:66:43:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:43:114:43:136 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:47:47:47:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:47:95:47:117 | fakeRes ... actHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:17:93:17:113 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:51:66:51:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:51:114:51:134 | wrongEn ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:23:63:23:83 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:55:47:55:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | +| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:30:95:30:115 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:32:65:32:85 | extract ... en(req) | authorization | +| rpc-proxy/src/routes/rpc.ts:59:66:59:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/EvenfireMissingRateLimiting.qlref b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/EvenfireMissingRateLimiting.qlref new file mode 100644 index 0000000000..7d3c25d5ca --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/EvenfireMissingRateLimiting.qlref @@ -0,0 +1 @@ +query: EvenfireMissingRateLimiting.ql diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/app.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/app.ts new file mode 100644 index 0000000000..4ece8564ed --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/app.ts @@ -0,0 +1,11 @@ +import express from 'express' +import { requireInternalService, requireInternalToken } from './middleware/internalServiceAuth.js' +import { createRpcAccessRouter } from './routes/rpc-access/index.js' + +export function createApp() { + const api = express.Router() + api.use(requireInternalToken) + api.use('/rpc', requireInternalService('rpc-proxy')) + api.use(createRpcAccessRouter()) + return api +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/config.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/config.ts new file mode 100644 index 0000000000..c8274c450a --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/config.ts @@ -0,0 +1 @@ +export const config = { hostArtifactReadRlPerMin: 30 } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/middleware/internalServiceAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/middleware/internalServiceAuth.ts new file mode 100644 index 0000000000..7224c950a0 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/middleware/internalServiceAuth.ts @@ -0,0 +1,4 @@ +export function requireInternalToken(_req: unknown, _res: unknown, next: () => void) { next() } +export function requireInternalService(_name: string) { + return (_req: unknown, _res: unknown, next: () => void) => next() +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/middleware/rateLimitMiddleware.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/middleware/rateLimitMiddleware.ts new file mode 100644 index 0000000000..32f6b8ea64 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/middleware/rateLimitMiddleware.ts @@ -0,0 +1,3 @@ +export function rateLimitMiddleware(_options: unknown) { + return (_req: unknown, _res: unknown, next: () => void) => next() +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/middleware/rpcAccessAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/middleware/rpcAccessAuth.ts new file mode 100644 index 0000000000..b498d6f7a8 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/middleware/rpcAccessAuth.ts @@ -0,0 +1,16 @@ +declare function verifyToken(): void + +export function requireValidRpcAccessTokenAny(_scopes: string[]) { + return (_req: unknown, _res: unknown, next: () => void) => { + verifyToken() + next() + } +} + +export function requireRpcTokenUserMatch() { + return (_req: unknown, _res: unknown, next: () => void) => next() +} + +export function requireRpcTokenHostMatch() { + return (_req: unknown, _res: unknown, next: () => void) => next() +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/routes/rpc-access/index.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/routes/rpc-access/index.ts new file mode 100644 index 0000000000..6db4f809c7 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/routes/rpc-access/index.ts @@ -0,0 +1,8 @@ +import { createRpcAccessUsersRouter } from './users.js' +import { Router } from 'express' + +export function createRpcAccessRouter(gateway: unknown, options: { directory?: unknown }) { + const router = Router() + router.use(createRpcAccessUsersRouter(gateway, options)) + return router +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/routes/rpc-access/users.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/routes/rpc-access/users.ts new file mode 100644 index 0000000000..5efa54f594 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/routes/rpc-access/users.ts @@ -0,0 +1,69 @@ +import { Router } from 'express' +import { config } from '../../config.js' +import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' +import { + requireRpcTokenHostMatch, + requireRpcTokenUserMatch, + requireValidRpcAccessTokenAny, +} from '../../middleware/rpcAccessAuth.js' +import { authorizeRpcHostAccess } from '../../services/access/rpcHostAccessAuthorizer.js' + +type ArtifactRequest = { + params: { userId: string; hostRef: string } + rpcAuth?: { sub?: string } + artifactReadConnection?: { hostRef: string } +} + +async function resolveAuthorizedHostConnection( + req: ArtifactRequest, + _res: unknown, + gateway: unknown, + directory: unknown, +): Promise<{ hostRef: string } | null> { + const userId = String(req.params.userId || '').trim() + const hostRef = String(req.params.hostRef || '').trim() + const claims = req.rpcAuth + if (!claims) return null + const authorization = await authorizeRpcHostAccess(gateway, claims, userId, hostRef, directory) + if (!authorization.authorized) return null + return authorization.connection +} + +export function createRpcAccessUsersRouter(gateway: unknown, options: { directory?: unknown }) { + const router = Router() + const { directory } = options + const hostAccessPath = '/rpc/access/users/:userId/mcp-hosts/:hostRef' + router.get( + `${hostAccessPath}/artifact-read`, + requireValidRpcAccessTokenAny(['host:task:read']), + requireRpcTokenUserMatch(), + requireRpcTokenHostMatch(), + rateLimitMiddleware({ + bucketType: 'host_artifact_pre_admission', + maxPerMinute: config.hostArtifactReadRlPerMin + 1, + getBucketKey: req => { + const subject = (req as ArtifactRequest).rpcAuth?.sub + return subject ? `host-artifact-pre-admission:${subject}` : null + }, + }), + async (req: ArtifactRequest, res, next) => { + const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) + if (connection) { + req.artifactReadConnection = connection + next() + } + }, + rateLimitMiddleware({ + bucketType: 'host_artifact_read', + maxPerMinute: config.hostArtifactReadRlPerMin, + getBucketKey: req => { + const artifactRead = req as ArtifactRequest + const subject = artifactRead.rpcAuth?.sub + const hostRef = artifactRead.artifactReadConnection?.hostRef + return subject && hostRef ? `host-artifact-read:${subject}:${hostRef}` : null + }, + }), + (_req, res) => res.status(200).json({ ok: true }), + ) + return router +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/services/access/rpcHostAccessAuthorizer.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/services/access/rpcHostAccessAuthorizer.ts new file mode 100644 index 0000000000..22957e5917 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/services/access/rpcHostAccessAuthorizer.ts @@ -0,0 +1,9 @@ +export async function authorizeRpcHostAccess( + _gateway: unknown, + _claims: unknown, + _userId: string, + _hostRef: string, + _directory: unknown, +): Promise<{ authorized: boolean; connection: { hostRef: string } }> { + return { authorized: true, connection: { hostRef: 'canonical-host' } } +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/middleware/auth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/middleware/auth.ts new file mode 100644 index 0000000000..01476c7883 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/middleware/auth.ts @@ -0,0 +1,6 @@ +declare function verifyRpcToken(): void +export function requireRpcAuth(_req: unknown, _res: unknown, next: () => void) { verifyRpcToken(); next() } +export function requireScope(_scope: string) { + return (_req: unknown, _res: unknown, next: () => void) => next() +} +export function extractAuthToken(_req: unknown): string { return 'token' } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/routes/rpc.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/routes/rpc.ts new file mode 100644 index 0000000000..f3963fdbb6 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/routes/rpc.ts @@ -0,0 +1,64 @@ +import express from 'express' +import { extractAuthToken, requireRpcAuth, requireScope } from '../middleware/auth.js' +import { resolveArtifactReadHostConnectionForUser } from '../services/mcpProxyService.js' +import { resolveArtifactReadHostConnectionForUser as fakeClient } from '../services/fakeMcpProxyService.js' + +type ArtifactRequest = express.Request & { auth?: { sub: string }; artifactReadHost?: { url: string } } + +const resolveArtifactReadHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + const hostRef = String(req.params.hostRef || '').trim() + req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, hostRef, extractAuthToken(req)) + next() +} + +const fakeResolveArtifactHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) + next() +} + +const wrongEndpointResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) + next() +} + +const fallbackArtifactResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { + const auth = req.auth! + try { + req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) + } catch { + req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) + } + next() +} + +export function createRpcRouter() { + const router = express.Router() + router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts/download') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fakeResolveArtifactHost, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), wrongEndpointResolver, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts/download') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fallbackArtifactResolver, async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts') + res.sendStatus(200) + }) + router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), async (_req, res) => { + await fetch('https://host.example/v1/runtime/artifacts/download') + res.sendStatus(200) + }) + return router +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/services/controlApiRestService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/services/controlApiRestService.ts new file mode 100644 index 0000000000..8d4a12b6fd --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/services/controlApiRestService.ts @@ -0,0 +1,16 @@ +type Options = { artifactRead?: boolean } + +async function fetchHostConnectionForPath( + userId: string, + hostRef: string, + token: string, + options: Options, +): Promise<{ url: string } | null> { + const base = `/rpc/access/users/${userId}/mcp-hosts/${hostRef}` + const endpoint = options.artifactRead ? `${base}/artifact-read` : base + return fetch(endpoint, { headers: { authorization: `Bearer ${token}` } }) as never +} + +export async function fetchArtifactReadHostConnectionFromControlApi(userId: string, hostRef: string, token: string) { + return fetchHostConnectionForPath(userId, hostRef, token, { artifactRead: true }) +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/services/fakeMcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/services/fakeMcpProxyService.ts new file mode 100644 index 0000000000..c03af01f0a --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/services/fakeMcpProxyService.ts @@ -0,0 +1,7 @@ +export async function resolveArtifactReadHostConnectionForUser( + _userId: string, + _hostRef: string, + _token: string, +) { + return { url: 'https://unprotected.example' } +} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/services/mcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/services/mcpProxyService.ts new file mode 100644 index 0000000000..e314b09f56 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/services/mcpProxyService.ts @@ -0,0 +1,5 @@ +import { fetchArtifactReadHostConnectionFromControlApi } from './controlApiRestService.js' + +export async function resolveArtifactReadHostConnectionForUser(userId: string, hostRef: string, token: string) { + return fetchArtifactReadHostConnectionFromControlApi(userId, hostRef, token) +} From d4cad2c9cdee73bf9578669a9eebc5fde2f3949a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Wed, 16 Sep 2026 23:10:21 -0600 Subject: [PATCH 206/528] fix(codeql): r32-h1 match production fail-closed handler Class-check: artifact resolver producer -> 2 proxy routes -> exact producer and handler Repro-test: distributed safe fixture fails at 0b6c5930239a10706d1768e5db24862d805ce9db --- .../queries/EvenfireMissingRateLimiting.ql | 43 +++++++++++++++---- .../src/routes/rpc-access/users.ts | 7 ++- .../src/routes/rpc-access/users.ts | 7 ++- .../src/routes/rpc-access/users.ts | 7 ++- .../src/routes/rpc-access/users.ts | 7 ++- .../EvenfireMissingRateLimiting.expected | 2 +- .../src/routes/rpc-access/users.ts | 7 ++- .../src/routes/rpc-access/users.ts | 7 ++- .../src/routes/rpc-access/users.ts | 7 ++- .../src/routes/rpc-access/users.ts | 7 ++- .../src/routes/rpc-access/users.ts | 7 ++- .../src/routes/rpc-access/users.ts | 7 ++- 12 files changed, 85 insertions(+), 30 deletions(-) diff --git a/.github/codeql/queries/EvenfireMissingRateLimiting.ql b/.github/codeql/queries/EvenfireMissingRateLimiting.ql index 830cda0607..342f7ed963 100644 --- a/.github/codeql/queries/EvenfireMissingRateLimiting.ql +++ b/.github/codeql/queries/EvenfireMissingRateLimiting.ql @@ -335,8 +335,10 @@ private predicate isFailClosedAuthorizationHandler( ) { exists( VariableDeclarator connectionDeclaration, VarDecl connectionBinding, - IfStmt connectionGuard, VarAccess guardedConnection, AssignExpr storedConnection, - PropAccess artifactConnection, VarAccess assignedConnection, CallExpr nextCall + IfStmt connectionGuard, LogNotExpr missingConnection, VarAccess guardedConnection, + ReturnStmt missingReturn, AssignExpr storedConnection, PropAccess artifactConnection, + VarAccess assignedConnection, CallExpr nextCall, TryStmt authorizationTry, + CatchClause errorHandler, CallExpr errorForwarding, VarAccess caughtError | liveAuthorization.getCallee().(VarAccess).getVariable() = resolver.getVariable() and liveAuthorization.getArgument(0).(VarAccess).getVariable() = @@ -346,21 +348,44 @@ private predicate isFailClosedAuthorizationHandler( connectionDeclaration.getBindingPattern() = connectionBinding and connectionBinding.getVariable() = guardedConnection.getVariable() and connectionGuard.getCondition().getEnclosingFunction() = handler and - connectionGuard.getCondition() = guardedConnection and + connectionGuard.getCondition() = missingConnection and + missingConnection.getOperand() = guardedConnection and + connectionGuard.getThen() = missingReturn and + not exists(missingReturn.getExpr()) and + not exists(connectionGuard.getElse()) and + connectionGuard.nestedIn(authorizationTry.getBody()) and + not exists(TryStmt interveningTry | + connectionGuard.nestedIn(interveningTry.getBody()) and + interveningTry.nestedIn(authorizationTry.getBody()) + ) and artifactConnection.getPropertyName() = "artifactReadConnection" and artifactConnection.getBase().(VarAccess).getVariable() = handler.getParameter(0).getVariable() and storedConnection.getEnclosingFunction() = handler and storedConnection.getLhs() = artifactConnection and storedConnection.getRhs() = assignedConnection and assignedConnection.getVariable() = connectionBinding.getVariable() and - storedConnection.getEnclosingStmt().nestedIn(connectionGuard.getThen()) and + storedConnection.getEnclosingStmt().nestedIn(authorizationTry.getBody()) and + connectionGuard.getLastToken().getIndex() < + storedConnection.getEnclosingStmt().getFirstToken().getIndex() and nextCall.getCalleeName() = "next" and nextCall.getEnclosingFunction() = handler and - nextCall.getEnclosingStmt().nestedIn(connectionGuard.getThen()) and - not exists(CallExpr unguardedNext | - unguardedNext.getCalleeName() = "next" and - unguardedNext.getEnclosingFunction() = handler and - not unguardedNext.getEnclosingStmt().nestedIn(connectionGuard.getThen()) + nextCall.getNumArgument() = 0 and + nextCall.getEnclosingStmt().nestedIn(authorizationTry.getBody()) and + storedConnection.getEnclosingStmt().getLastToken().getIndex() < + nextCall.getEnclosingStmt().getFirstToken().getIndex() and + errorHandler = authorizationTry.getACatchClause() and + errorHandler.getNumParameter() = 1 and + errorForwarding.getCalleeName() = "next" and + errorForwarding.getNumArgument() = 1 and + errorForwarding.getArgument(0) = caughtError and + caughtError.(VarAccess).getVariable() = errorHandler.getAParameter().getVariable() and + errorForwarding.getEnclosingFunction() = handler and + errorForwarding.getEnclosingStmt().nestedIn(errorHandler.getBody()) and + not exists(CallExpr otherNext | + otherNext.getCalleeName() = "next" and + otherNext.getEnclosingFunction() = handler and + otherNext != nextCall and + otherNext != errorForwarding ) ) } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/routes/rpc-access/users.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/routes/rpc-access/users.ts index 4901f53e52..dff7eefab1 100644 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/routes/rpc-access/users.ts +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/routes/rpc-access/users.ts @@ -47,10 +47,13 @@ export function createRpcAccessUsersRouter(gateway: unknown, options: { director }, }), async (req: ArtifactRequest, res, next) => { - const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) - if (connection) { + try { + const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) + if (!connection) return req.artifactReadConnection = connection next() + } catch (error) { + next(error) } }, rateLimitMiddleware({ diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/routes/rpc-access/users.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/routes/rpc-access/users.ts index 8d21b4ae42..fbf16dbef2 100644 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/routes/rpc-access/users.ts +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/routes/rpc-access/users.ts @@ -48,10 +48,13 @@ export function createRpcAccessUsersRouter(gateway: unknown, options: { director }, }), async (req: ArtifactRequest, res, next) => { - const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) - if (connection) { + try { + const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) + if (!connection) return req.artifactReadConnection = connection next() + } catch (error) { + next(error) } }, rateLimitMiddleware({ diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/routes/rpc-access/users.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/routes/rpc-access/users.ts index 11ec4bd859..08ee4f1883 100644 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/routes/rpc-access/users.ts +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/routes/rpc-access/users.ts @@ -47,10 +47,13 @@ export function createRpcAccessUsersRouter(gateway: unknown, options: { director }, }), async (req: ArtifactRequest, res, next) => { - const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) - if (connection) { + try { + const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) + if (!connection) return req.artifactReadConnection = connection next() + } catch (error) { + next(error) } }, rateLimitMiddleware({ diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/routes/rpc-access/users.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/routes/rpc-access/users.ts index f825b7ef0b..bb519c9657 100644 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/routes/rpc-access/users.ts +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/routes/rpc-access/users.ts @@ -33,10 +33,13 @@ export function createRpcAccessUsersRouter(gateway: unknown, options: { director }, }), async (req: ArtifactRequest, res, next) => { - const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) - if (connection) { + try { + const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) + if (!connection) return req.artifactReadConnection = connection next() + } catch (error) { + next(error) } }, rateLimitMiddleware({ diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/EvenfireMissingRateLimiting.expected b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/EvenfireMissingRateLimiting.expected index d7f6b35fb8..74be339746 100644 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/EvenfireMissingRateLimiting.expected +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/EvenfireMissingRateLimiting.expected @@ -1,5 +1,5 @@ | control-api/src/routes/rpc-access/users.ts:38:5:38:53 | require ... read']) | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/rpcAccessAuth.ts:5:5:5:17 | verifyToken() | authorization | -| control-api/src/routes/rpc-access/users.ts:41:5:47:5 | async ( ... }\\n } | This route handler performs $@, but is not rate-limited. | control-api/src/routes/rpc-access/users.ts:42:32:42:92 | resolve ... ectory) | authorization | +| control-api/src/routes/rpc-access/users.ts:41:5:50:5 | async ( ... }\\n } | This route handler performs $@, but is not rate-limited. | control-api/src/routes/rpc-access/users.ts:43:34:43:94 | resolve ... ectory) | authorization | | rpc-proxy/src/routes/rpc.ts:39:47:39:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | | rpc-proxy/src/routes/rpc.ts:39:95:39:117 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | | rpc-proxy/src/routes/rpc.ts:43:66:43:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/routes/rpc-access/users.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/routes/rpc-access/users.ts index 5be0939625..2c859d8f3d 100644 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/routes/rpc-access/users.ts +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/routes/rpc-access/users.ts @@ -39,10 +39,13 @@ export function createRpcAccessUsersRouter(gateway: unknown, options: { director requireRpcTokenUserMatch(), requireRpcTokenHostMatch(), async (req: ArtifactRequest, res, next) => { - const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) - if (connection) { + try { + const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) + if (!connection) return req.artifactReadConnection = connection next() + } catch (error) { + next(error) } }, rateLimitMiddleware({ diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/routes/rpc-access/users.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/routes/rpc-access/users.ts index 3baa724ca0..96dd73d9ee 100644 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/routes/rpc-access/users.ts +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/routes/rpc-access/users.ts @@ -41,10 +41,13 @@ export function createRpcAccessUsersRouter(gateway: unknown, options: { director }, }), async (req: ArtifactRequest, res, next) => { - const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) - if (connection) { + try { + const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) + if (!connection) return req.artifactReadConnection = connection next() + } catch (error) { + next(error) } }, rateLimitMiddleware({ diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/routes/rpc-access/users.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/routes/rpc-access/users.ts index 7acfa4cf24..82f501fc68 100644 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/routes/rpc-access/users.ts +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/routes/rpc-access/users.ts @@ -47,10 +47,13 @@ export function createRpcAccessUsersRouter(gateway: unknown, options: { director }, }), async (req: ArtifactRequest, res, next) => { - const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) - if (connection) { + try { + const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) + if (!connection) return req.artifactReadConnection = connection next() + } catch (error) { + next(error) } }, rateLimitMiddleware({ diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/routes/rpc-access/users.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/routes/rpc-access/users.ts index 8491871a55..118026f7c3 100644 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/routes/rpc-access/users.ts +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/routes/rpc-access/users.ts @@ -47,10 +47,13 @@ export function createRpcAccessUsersRouter(gateway: unknown, options: { director }, }), async (req: ArtifactRequest, res, next) => { - const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) - if (connection) { + try { + const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) + if (!connection) return req.artifactReadConnection = connection next() + } catch (error) { + next(error) } }, rateLimitMiddleware({ diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/routes/rpc-access/users.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/routes/rpc-access/users.ts index fb8fb7ad6b..0e25c9eea3 100644 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/routes/rpc-access/users.ts +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/routes/rpc-access/users.ts @@ -47,10 +47,13 @@ export function createRpcAccessUsersRouter(gateway: unknown, options: { director }, }), async (req: ArtifactRequest, res, next) => { - const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) - if (connection) { + try { + const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) + if (!connection) return req.artifactReadConnection = connection next() + } catch (error) { + next(error) } }, rateLimitMiddleware({ diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/routes/rpc-access/users.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/routes/rpc-access/users.ts index 5efa54f594..1038e3deec 100644 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/routes/rpc-access/users.ts +++ b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/routes/rpc-access/users.ts @@ -47,10 +47,13 @@ export function createRpcAccessUsersRouter(gateway: unknown, options: { director }, }), async (req: ArtifactRequest, res, next) => { - const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) - if (connection) { + try { + const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) + if (!connection) return req.artifactReadConnection = connection next() + } catch (error) { + next(error) } }, rateLimitMiddleware({ From 9a3f01536da70e25ec75ec49c55d45d97c41ef41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 17 Sep 2026 10:24:51 -0600 Subject: [PATCH 207/528] test(control-api): cover user access discovery lifecycle Add direct-only and team-only Host catalog coverage, including live membership revocation\nwithout replacing the user's logical session. --- ...gFamilies.realPostgres.integration.test.ts | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts index 498ee45fcd..f0091d942e 100644 --- a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts @@ -557,6 +557,113 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { ).toBe(2) }) + it('discovers a Host through a direct user grant without a team grant', async () => { + const directOnlyUserId = randomUUID() + const directOnlySession: ExternalSessionAuthorityContext = { + contract: 'v1', + userId: directOnlyUserId, + tokenHash: randomBytes(32).toString('hex'), + issuedAt: Math.floor(Date.now() / 1_000), + authGeneration: 1, + } + await databasePool.query( + `INSERT INTO users(id, email, name) VALUES ($1, $2, 'Direct-only Catalog User')`, + [directOnlyUserId, `${directOnlyUserId}@example.test`] + ) + await databasePool.query( + `INSERT INTO user_agents(user_id, agent_name) VALUES ($1, 'catalog-host')`, + [directOnlyUserId] + ) + + const catalog = await buildAccessCatalog( + { session: directOnlySession, families: ['host'], limit: 10 }, + { transaction: transaction(databasePool) } + ) + expect(catalog.items.map(item => item.resource.logicalId)).toEqual([ + `${config.hostsNamespace}/catalog-host`, + ]) + expect(catalog.items[0]?.accessPaths).toHaveLength(1) + expect(catalog.items[0]?.accessPaths[0]).toEqual(expect.objectContaining({ kind: 'direct' })) + expect(catalog.items[0]?.accessPaths[0]).not.toHaveProperty('teamId') + }) + + it('discovers a team-only Host and revokes it live without replacing the user session', async () => { + const teamOnlyUserId = randomUUID() + const teamOnlySession: ExternalSessionAuthorityContext = { + contract: 'v1', + userId: teamOnlyUserId, + tokenHash: randomBytes(32).toString('hex'), + issuedAt: Math.floor(Date.now() / 1_000), + authGeneration: 1, + } + await databasePool.query( + `INSERT INTO users(id, email, name) VALUES ($1, $2, 'Team-only Catalog User')`, + [teamOnlyUserId, `${teamOnlyUserId}@example.test`] + ) + await databasePool.query( + `INSERT INTO team_members(team_id, user_id, role, status) + VALUES ($1, $2, 'member', 'active')`, + [teamId, teamOnlyUserId] + ) + await databasePool.query( + `INSERT INTO team_agents(team_id, agent_name) VALUES ($1, 'catalog-host')`, + [teamId] + ) + + const firstCatalog = await buildAccessCatalog( + { session: teamOnlySession, families: ['host'], limit: 10 }, + { transaction: transaction(databasePool) } + ) + expect(firstCatalog.items.map(item => item.resource.logicalId)).toEqual([ + `${config.hostsNamespace}/catalog-host`, + ]) + expect(firstCatalog.items[0]?.accessPaths).toEqual([ + expect.objectContaining({ kind: 'team', teamId }), + ]) + + const teamPath = firstCatalog.items[0]!.accessPaths[0]! + await expect( + resolveLiveAuthorization( + { + session: teamOnlySession, + requiredCapability: 'host.read', + resource: canonicalResourceIdentity(firstCatalog.items[0]!.resource), + requestedAccessPathId: teamPath.accessPathId, + }, + { transaction: transaction(databasePool), gateway } + ) + ).resolves.toEqual( + expect.objectContaining({ + status: 'allowed', + selectedPath: expect.objectContaining({ kind: 'team', teamId }), + }) + ) + + await databasePool.query( + `UPDATE team_members SET status = 'inactive' WHERE team_id = $1 AND user_id = $2`, + [teamId, teamOnlyUserId] + ) + + const afterRevocation = await buildAccessCatalog( + { session: teamOnlySession, families: ['host'], limit: 10 }, + { transaction: transaction(databasePool) } + ) + expect(afterRevocation.items).toEqual([]) + await expect( + resolveLiveAuthorization( + { + session: teamOnlySession, + requiredCapability: 'host.read', + resource: canonicalResourceIdentity(firstCatalog.items[0]!.resource), + requestedAccessPathId: teamPath.accessPathId, + }, + { transaction: transaction(databasePool), gateway } + ) + ).resolves.toEqual( + expect.objectContaining({ status: 'access_path_stale', code: 'access_path_stale' }) + ) + }) + it('uses real Kubernetes list and exact-read wire boundaries', async () => { const listRequests = kubernetesApi.requests.filter(request => !request.watch && !request.name) expect(listRequests).toHaveLength(operationalSourceSpecs.length) From bde5680a541bbb9195ee733b714546982b644adf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 17 Sep 2026 10:32:29 -0600 Subject: [PATCH 208/528] test(control-api): isolate team access catalog fixture Give the team-only grant its own team id so the aggregate catalog fixture cannot collide. --- ...atalogFamilies.realPostgres.integration.test.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts index f0091d942e..6d4b64e428 100644 --- a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts @@ -589,6 +589,7 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { it('discovers a team-only Host and revokes it live without replacing the user session', async () => { const teamOnlyUserId = randomUUID() + const teamOnlyTeamId = randomUUID() const teamOnlySession: ExternalSessionAuthorityContext = { contract: 'v1', userId: teamOnlyUserId, @@ -600,14 +601,17 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { `INSERT INTO users(id, email, name) VALUES ($1, $2, 'Team-only Catalog User')`, [teamOnlyUserId, `${teamOnlyUserId}@example.test`] ) + await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, 'Team-only Catalog Team')`, [ + teamOnlyTeamId, + ]) await databasePool.query( `INSERT INTO team_members(team_id, user_id, role, status) VALUES ($1, $2, 'member', 'active')`, - [teamId, teamOnlyUserId] + [teamOnlyTeamId, teamOnlyUserId] ) await databasePool.query( `INSERT INTO team_agents(team_id, agent_name) VALUES ($1, 'catalog-host')`, - [teamId] + [teamOnlyTeamId] ) const firstCatalog = await buildAccessCatalog( @@ -618,7 +622,7 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { `${config.hostsNamespace}/catalog-host`, ]) expect(firstCatalog.items[0]?.accessPaths).toEqual([ - expect.objectContaining({ kind: 'team', teamId }), + expect.objectContaining({ kind: 'team', teamId: teamOnlyTeamId }), ]) const teamPath = firstCatalog.items[0]!.accessPaths[0]! @@ -635,13 +639,13 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { ).resolves.toEqual( expect.objectContaining({ status: 'allowed', - selectedPath: expect.objectContaining({ kind: 'team', teamId }), + selectedPath: expect.objectContaining({ kind: 'team', teamId: teamOnlyTeamId }), }) ) await databasePool.query( `UPDATE team_members SET status = 'inactive' WHERE team_id = $1 AND user_id = $2`, - [teamId, teamOnlyUserId] + [teamOnlyTeamId, teamOnlyUserId] ) const afterRevocation = await buildAccessCatalog( From a7ad0924eb8233246f4020782167770f7a2e62ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 17 Sep 2026 10:39:46 -0600 Subject: [PATCH 209/528] test(control-api): assert public team path descriptor Match the catalog contract, which exposes team provenance through safeTeamDescriptor. --- .../accessCatalogFamilies.realPostgres.integration.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts index 6d4b64e428..161e39aeb0 100644 --- a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts @@ -622,7 +622,10 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { `${config.hostsNamespace}/catalog-host`, ]) expect(firstCatalog.items[0]?.accessPaths).toEqual([ - expect.objectContaining({ kind: 'team', teamId: teamOnlyTeamId }), + expect.objectContaining({ + kind: 'team', + safeTeamDescriptor: expect.objectContaining({ teamId: teamOnlyTeamId }), + }), ]) const teamPath = firstCatalog.items[0]!.accessPaths[0]! From 11179c10c5fd744323b75db08e6e991a670a0101 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 17 Sep 2026 10:46:32 -0600 Subject: [PATCH 210/528] test(control-api): use supported team revocation state Revoke the team membership with the persisted deleted state accepted by the schema. --- .../test/accessCatalogFamilies.realPostgres.integration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts index 161e39aeb0..84e66103fb 100644 --- a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts @@ -647,7 +647,7 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { ) await databasePool.query( - `UPDATE team_members SET status = 'inactive' WHERE team_id = $1 AND user_id = $2`, + `UPDATE team_members SET status = 'deleted' WHERE team_id = $1 AND user_id = $2`, [teamOnlyTeamId, teamOnlyUserId] ) From 25f38a55eb27e96bb9966b4e2d45d52cb3a863a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 17 Sep 2026 10:57:22 -0600 Subject: [PATCH 211/528] test(control-api): accept concealed revoked team path Keep revocation acceptance focused on denial after the live catalog no longer contains the team-derived path. The resolver intentionally returns not_found. --- .../accessCatalogFamilies.realPostgres.integration.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts index 84e66103fb..0fc972629d 100644 --- a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts @@ -483,9 +483,7 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { }, { transaction: transaction(databasePool), gateway } ) - ).resolves.toEqual( - expect.objectContaining({ status: 'access_path_stale', code: 'access_path_stale' }) - ) + ).resolves.toEqual(expect.objectContaining({ status: 'not_found', code: 'not_found' })) await expect( resolveLiveAuthorization( From afed6c35f30dd7790d30a4ff8bf7dfe37b37af90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 17 Sep 2026 11:08:00 -0600 Subject: [PATCH 212/528] test(control-api): target team revocation expectation Keep the existing GFS stale-path assertion intact and expect the Host path to be concealed after team membership revocation. --- ...accessCatalogFamilies.realPostgres.integration.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts index 0fc972629d..b06334bf57 100644 --- a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts @@ -483,7 +483,9 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { }, { transaction: transaction(databasePool), gateway } ) - ).resolves.toEqual(expect.objectContaining({ status: 'not_found', code: 'not_found' })) + ).resolves.toEqual( + expect.objectContaining({ status: 'access_path_stale', code: 'access_path_stale' }) + ) await expect( resolveLiveAuthorization( @@ -664,9 +666,7 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { }, { transaction: transaction(databasePool), gateway } ) - ).resolves.toEqual( - expect.objectContaining({ status: 'access_path_stale', code: 'access_path_stale' }) - ) + ).resolves.toEqual(expect.objectContaining({ status: 'not_found', code: 'not_found' })) }) it('uses real Kubernetes list and exact-read wire boundaries', async () => { From 324b46c99829168c743c6018c2122db30fdce1bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 17 Sep 2026 12:28:25 -0600 Subject: [PATCH 213/528] test(control-api): make broker JWT tampering deterministic --- control-api/test/oauth.brokerJwt.test.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/control-api/test/oauth.brokerJwt.test.ts b/control-api/test/oauth.brokerJwt.test.ts index 18bfd6c1e6..b9cfa2be92 100644 --- a/control-api/test/oauth.brokerJwt.test.ts +++ b/control-api/test/oauth.brokerJwt.test.ts @@ -56,7 +56,19 @@ describe('oauthBrokerJwtToken', () => { it('rejects a tampered token', () => { const { token } = issueOAuthBrokerJwt('sandbox-recipes', 'crm') - expect(verifyOAuthBrokerJwt(token.slice(0, -2) + 'XX')).toBeNull() + const [header, payload, encodedSignature] = token.split('.') + const signature = Buffer.from(encodedSignature, 'base64url') + + expect(signature.length).toBeGreaterThan(0) + + const tamperedSignature = Buffer.from(signature) + tamperedSignature[0] ^= 0x01 + + expect(tamperedSignature.equals(signature)).toBe(false) + + const tamperedToken = `${header}.${payload}.${tamperedSignature.toString('base64url')}` + + expect(verifyOAuthBrokerJwt(tamperedToken)).toBeNull() }) }) From 9e495d6e57d3acccc8849f5edc486abbe794cbf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 17 Sep 2026 19:35:55 -0600 Subject: [PATCH 214/528] fix(rpc-proxy): isolate sandbox proxy event hooks Class-check: shared proxyReq/proxyRes one-shot listeners -> 2 sites -> use filtered on listeners with cleanup Repro-test: concurrent sandbox requests with B-before-A events; old once hooks miss A headers Validation: sandboxUi.test.ts (61 tests) and rpc-proxy build pass --- rpc-proxy/src/routes/sandboxUi.test.ts | 76 +++++++++++++++++++++++--- rpc-proxy/src/routes/sandboxUi.ts | 10 ++-- 2 files changed, 74 insertions(+), 12 deletions(-) diff --git a/rpc-proxy/src/routes/sandboxUi.test.ts b/rpc-proxy/src/routes/sandboxUi.test.ts index f6aa82338e..bbb4e6a259 100644 --- a/rpc-proxy/src/routes/sandboxUi.test.ts +++ b/rpc-proxy/src/routes/sandboxUi.test.ts @@ -23,14 +23,22 @@ const httpProxyMock = vi.hoisted(() => { }), once: vi.fn((event: string, cb: (...args: unknown[]) => void) => { const list = events.get(event) ?? [] - list.push(cb) + const onceCb = (...args: unknown[]) => { + events.set( + event, + (events.get(event) ?? []).filter(candidate => candidate !== onceCb) + ) + cb(...args) + } + Object.defineProperty(onceCb, '__original', { value: cb }) + list.push(onceCb) events.set(event, list) }), off: vi.fn((event: string, cb: (...args: unknown[]) => void) => { const list = events.get(event) ?? [] events.set( event, - list.filter(fn => fn !== cb) + list.filter(fn => fn !== cb && (fn as { __original?: unknown }).__original !== cb) ) }), emit: (event: string, ...args: unknown[]) => { @@ -365,9 +373,11 @@ describe('POST /api/v1/sandbox-ui/:ns/:name/oauth/authorize-url', () => { }) it('includes background:true in the control-api payload when the body sets it', async () => { - fetchSpy.mockResolvedValueOnce(jsonResponse(200, REGISTRY_OK)).mockResolvedValueOnce( - jsonResponse(200, { authorizeUrl: 'https://accounts.google.com/oauth?...' }) - ) + fetchSpy + .mockResolvedValueOnce(jsonResponse(200, REGISTRY_OK)) + .mockResolvedValueOnce( + jsonResponse(200, { authorizeUrl: 'https://accounts.google.com/oauth?...' }) + ) await request(makeApp()) .post('/api/v1/sandbox-ui/sandbox-recipes/r1/oauth/authorize-url') @@ -384,9 +394,11 @@ describe('POST /api/v1/sandbox-ui/:ns/:name/oauth/authorize-url', () => { }) it('includes background:false in the control-api payload when background is omitted', async () => { - fetchSpy.mockResolvedValueOnce(jsonResponse(200, REGISTRY_OK)).mockResolvedValueOnce( - jsonResponse(200, { authorizeUrl: 'https://accounts.google.com/oauth?...' }) - ) + fetchSpy + .mockResolvedValueOnce(jsonResponse(200, REGISTRY_OK)) + .mockResolvedValueOnce( + jsonResponse(200, { authorizeUrl: 'https://accounts.google.com/oauth?...' }) + ) await request(makeApp()) .post('/api/v1/sandbox-ui/sandbox-recipes/r1/oauth/authorize-url') @@ -747,6 +759,54 @@ describe('ANY /api/v1/sandbox-ui/:ns/:name/view/*', () => { expect(reqArg.headers.cookie).toBeUndefined() expect(reqArg.headers.authorization).toBeUndefined() }) + + it('keeps concurrent proxy request hooks attached to their own request', async () => { + const pending: Array<{ + req: object + res: { status: (status: number) => { end: () => void } } + }> = [] + httpProxyMock._proxy.web.mockImplementation((req: object, res) => { + pending.push({ req, res: res as { status: (status: number) => { end: () => void } } }) + }) + + const first = request(viewApp()) + .get('/api/v1/sandbox-ui/sandbox-recipes/r1/view/first') + .set('Cookie', cookieHeader('sandbox-recipes', 'r1', 'user-a')) + const second = request(viewApp()) + .get('/api/v1/sandbox-ui/sandbox-recipes/r1/view/second') + .set('Cookie', cookieHeader('sandbox-recipes', 'r1', 'user-b')) + const firstPromise = first.then(() => undefined) + const secondPromise = second.then(() => undefined) + + await new Promise(resolve => setTimeout(resolve, 100)) + expect(pending).toHaveLength(2) + + const makeProxyReq = () => { + const headers: Record = {} + return { + getHeaderNames: () => [], + removeHeader: (name: string) => delete headers[name.toLowerCase()], + setHeader: (name: string, value: string) => { + headers[name.toLowerCase()] = value + }, + headers, + } + } + const firstProxyReq = makeProxyReq() + const secondProxyReq = makeProxyReq() + + // Deliberately deliver B before A. The hooks must filter by request identity + // without allowing B's event to consume A's handler. + httpProxyMock._proxy.emit('proxyReq', secondProxyReq, pending[1].req, {}) + httpProxyMock._proxy.emit('proxyReq', firstProxyReq, pending[0].req, {}) + + expect(firstProxyReq.headers['x-clerum-user']).toBe('user-a') + expect(secondProxyReq.headers['x-clerum-user']).toBe('user-b') + + pending[0].res.status(200).end() + pending[1].res.status(200).end() + await Promise.all([firstPromise, secondPromise]) + }) }) // ─── GET /api/v1/sandbox-ui/apps ─────────────────────────────────── diff --git a/rpc-proxy/src/routes/sandboxUi.ts b/rpc-proxy/src/routes/sandboxUi.ts index dfd3e66e48..15405c9d1e 100644 --- a/rpc-proxy/src/routes/sandboxUi.ts +++ b/rpc-proxy/src/routes/sandboxUi.ts @@ -690,8 +690,10 @@ export function createSandboxUiSessionRouter(): Router { // Per-request hook to inject identity headers and strip client-supplied // shadow headers. http-proxy fires onProxyReq once with the - // ClientRequest. We layer a one-shot listener via the proxy's `proxyReq` - // event scoped to this request via res.on('finish') cleanup. + // ClientRequest. Each request gets a filtered listener, and cleanup removes + // it after the response closes. `on` is intentional: shared proxy events + // can be interleaved across concurrent requests, so a one-shot listener + // could be consumed by another request before this request's event arrives. const onProxyReq = ( proxyReq: ClientRequest, innerReq: IncomingMessage, @@ -747,8 +749,8 @@ export function createSandboxUiSessionRouter(): Router { } } - sandboxUiProxy.once('proxyReq', onProxyReq) - sandboxUiProxy.once('proxyRes', onProxyRes) + sandboxUiProxy.on('proxyReq', onProxyReq) + sandboxUiProxy.on('proxyRes', onProxyRes) // If the request ends without firing (e.g. an early proxy error), make // sure the listeners are detached AND the view-request emit fires // exactly once with the final status code. From b641004bea89c1b28ea91bf7af2cf49204fd79b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 17 Sep 2026 19:40:40 -0600 Subject: [PATCH 215/528] fix(control-api): close Host artifact limiter attribution gaps Class-check: Host artifact limiter key derivation -> 2 sites -> count missing attribution with sentinels Repro-test: route seam with absent subject/unresolved Host key; old null-key path made both cases uncounted Validation: artifact admission, defense, and MCP Host route tests pass --- control-api/src/routes/rpc-access/users.ts | 11 ++- ...ccessUsersArtifactRateLimitDefense.test.ts | 96 +++++++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 control-api/test/routes.rpcAccessUsersArtifactRateLimitDefense.test.ts diff --git a/control-api/src/routes/rpc-access/users.ts b/control-api/src/routes/rpc-access/users.ts index cea3f4df96..01b63d8482 100644 --- a/control-api/src/routes/rpc-access/users.ts +++ b/control-api/src/routes/rpc-access/users.ts @@ -243,7 +243,12 @@ export function createRpcAccessUsersRouter( maxPerMinute: config.hostArtifactReadRlPerMin, getBucketKey: req => { const subject = (req as ArtifactReadRequest).rpcAuth?.sub - return subject ? `host-artifact-pre-admission:${subject}` : null + // Auth middleware should always populate the subject before this + // limiter. Keep malformed composition attributable to a counted + // sentinel instead of silently bypassing the durable budget. + return subject + ? `host-artifact-pre-admission:${subject}` + : 'host-artifact-pre-admission:unauthenticated' }, }), async (req: ArtifactReadRequest, res, next) => { @@ -266,7 +271,9 @@ export function createRpcAccessUsersRouter( const artifactRead = req as ArtifactReadRequest const subject = artifactRead.rpcAuth?.sub const hostRef = artifactRead.artifactReadConnection?.hostRef - return subject && hostRef ? `host-artifact-read:${subject}:${hostRef}` : null + return subject && hostRef + ? `host-artifact-read:${subject}:${hostRef}` + : 'host-artifact-read:unresolved' }, }), (req: ArtifactReadRequest, res) => { diff --git a/control-api/test/routes.rpcAccessUsersArtifactRateLimitDefense.test.ts b/control-api/test/routes.rpcAccessUsersArtifactRateLimitDefense.test.ts new file mode 100644 index 0000000000..6e52c68365 --- /dev/null +++ b/control-api/test/routes.rpcAccessUsersArtifactRateLimitDefense.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import request from 'supertest' +import type { K8sGateway } from '../src/k8s.js' +import { createRpcAccessUsersRouter } from '../src/routes/rpc-access/users.js' + +const state = vi.hoisted(() => ({ + claims: null as { sub: string; hostRefs: string[] } | null, + authorization: vi.fn(), + checkAndIncrement: vi.fn(), +})) + +vi.mock('../src/middleware/rpcAccessAuth.js', () => ({ + requireValidRpcAccessTokenAny: + () => (req: { rpcAuth?: unknown }, _res: unknown, next: () => void) => { + req.rpcAuth = state.claims ?? undefined + next() + }, + requireRpcTokenUserMatch: () => (_req: unknown, _res: unknown, next: () => void) => { + next() + }, + requireRpcTokenHostMatch: () => (_req: unknown, _res: unknown, next: () => void) => { + next() + }, + requireValidRpcAccessToken: () => (_req: unknown, _res: unknown, next: () => void) => { + next() + }, +})) + +vi.mock('../src/services/access/rpcHostAccessAuthorizer.js', () => ({ + authorizeRpcHostAccess: (...args: unknown[]) => state.authorization(...args), +})) + +vi.mock('../src/services/rateLimiterService.js', () => ({ + checkAndIncrement: (...args: unknown[]) => state.checkAndIncrement(...args), +})) + +function buildApp() { + const gateway = { + listResource: vi.fn(async () => []), + getResource: vi.fn(async () => ({}) as never), + createResource: vi.fn(async () => ({})), + updateResource: vi.fn(async () => ({})), + deleteResource: vi.fn(async () => ({ ok: true })), + } + const app = express() + app.use( + createRpcAccessUsersRouter(gateway as unknown as K8sGateway, { + bindingService: { bind: vi.fn() }, + }) + ) + return app +} + +describe('Host artifact limiter defensive attribution', () => { + beforeEach(() => { + state.claims = null + state.authorization.mockReset() + state.checkAndIncrement.mockReset() + state.checkAndIncrement.mockResolvedValue({ + allowed: true, + remaining: 29, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + count: 1, + }) + }) + + it('counts a missing subject under the pre-admission sentinel', async () => { + await request(buildApp()) + .get('/rpc/access/users/user-a/mcp-hosts/host-a/artifact-read') + .expect(403) + + expect(state.checkAndIncrement).toHaveBeenCalledWith( + 'host-artifact-pre-admission:unauthenticated', + expect.any(Number) + ) + }) + + it('counts an unresolved post-authorization key under a separate sentinel', async () => { + state.claims = { sub: 'user-a', hostRefs: ['host-a'] } + state.authorization.mockResolvedValue({ + authorized: true, + connection: { url: 'http://host-a.example', hostRef: undefined }, + }) + + await request(buildApp()) + .get('/rpc/access/users/user-a/mcp-hosts/host-a/artifact-read') + .expect(200) + + expect(state.checkAndIncrement.mock.calls.map(call => call[0])).toEqual([ + 'host-artifact-pre-admission:user-a', + 'host-artifact-read:unresolved', + ]) + }) +}) From a3aec1ea804966e3999501fab7d34c807a9d8c6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 17 Sep 2026 19:40:50 -0600 Subject: [PATCH 216/528] fix(test-fixtures): preserve safe identity failure diagnostics Class-check: approved-tools fixture bare catches -> 3 sites -> retain bounded error class/code causes Repro-test: lifecycle failures previously exposed only generic text; tests now assert safe structured causes Validation: identity-lifecycle.test.mjs passes 18 tests --- .../identity-lifecycle.mjs | 25 ++++++++++++++++--- .../identity-lifecycle.test.mjs | 22 +++++++++++++++- .../approved-tools-setup/seed.mjs | 12 ++++++--- tests/e2e/fixtures/package-lock.json | 4 +-- tests/e2e/fixtures/package.json | 2 +- 5 files changed, 54 insertions(+), 11 deletions(-) diff --git a/tests/e2e/fixtures/codex-subscription/approved-tools-setup/identity-lifecycle.mjs b/tests/e2e/fixtures/codex-subscription/approved-tools-setup/identity-lifecycle.mjs index 62b0460991..5a5cd34336 100644 --- a/tests/e2e/fixtures/codex-subscription/approved-tools-setup/identity-lifecycle.mjs +++ b/tests/e2e/fixtures/codex-subscription/approved-tools-setup/identity-lifecycle.mjs @@ -2,6 +2,19 @@ import { randomUUID } from 'node:crypto' const labels = ['83', '150', '250', 'workflow'] const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ +const safeErrorName = /^[A-Za-z][A-Za-z0-9_]{0,63}$/ +const safeErrorCode = /^[A-Z0-9][A-Z0-9_.:-]{0,63}$/ + +// Fixture errors can cross the container boundary. Preserve only bounded +// class/code fields: messages may contain SQL, credentials, tokens, or identity +// data. Nested causes remain structured and bounded without exposing raw text. +export function describeFixtureError(error) { + const value = error && typeof error === 'object' ? error : {} + const name = safeErrorName.test(value.name ?? '') ? value.name : 'UnknownError' + const code = safeErrorCode.test(value.code ?? '') ? value.code : undefined + const cause = value.cause && value.cause !== error ? describeFixtureError(value.cause) : undefined + return { name, ...(code ? { code } : {}), ...(cause ? { cause } : {}) } +} function requireValue(condition, message) { if (!condition) throw new Error(message) @@ -322,12 +335,14 @@ export async function createFixtureIdentities( journal.status = 'created' await persist(saveJournal, journal) return journal - } catch { + } catch (error) { // A failed COMMIT acknowledgement is ambiguous. Recorded random IDs permit // recovery without inferring ownership from a later same-email lookup. journal.status = 'recovery-required' await persist(saveJournal, journal) - throw new Error('Fixture creation failed; recover using the recorded identity journal') + throw new Error('Fixture creation failed; recover using the recorded identity journal', { + cause: describeFixtureError(error), + }) } } @@ -638,9 +653,11 @@ export async function cleanupFixtureIdentities( journal.cleanupOutcome = outcome await persist(saveJournal, journal) return { outcome, auditRetention: 'preserved' } - } catch { + } catch (error) { journal.status = 'recovery-required' await persist(saveJournal, journal) - throw new Error('Fixture cleanup refused or failed; retain journal for recovery') + throw new Error('Fixture cleanup refused or failed; retain journal for recovery', { + cause: describeFixtureError(error), + }) } } diff --git a/tests/e2e/fixtures/codex-subscription/approved-tools-setup/identity-lifecycle.test.mjs b/tests/e2e/fixtures/codex-subscription/approved-tools-setup/identity-lifecycle.test.mjs index 5fd7d9ed2e..17cdffdf45 100644 --- a/tests/e2e/fixtures/codex-subscription/approved-tools-setup/identity-lifecycle.test.mjs +++ b/tests/e2e/fixtures/codex-subscription/approved-tools-setup/identity-lifecycle.test.mjs @@ -4,6 +4,7 @@ import { cleanupFixtureIdentities, createFixtureIdentityJournal, createFixtureIdentities as createRecordedFixtureIdentities, + describeFixtureError, recordFixtureConnection, } from './identity-lifecycle.mjs' @@ -234,7 +235,11 @@ test('duplicate/query and audit failures roll back without discarding the intent const { state, adapters } = harness() if (kind === 'query') state.failQuery = sql => sql.includes('INSERT INTO teams') else state.auditFailure = true - await assert.rejects(createFixtureIdentities(input, adapters), /recover using/) + await assert.rejects(createFixtureIdentities(input, adapters), error => { + assert.match(error.message, /recover using/) + assert.deepEqual(error.cause, { name: 'Error' }) + return true + }) assert.equal(state.committed.length, 0) assert.equal(state.rollbacks, 1) assert.equal(state.journal.at(-1).status, 'recovery-required') @@ -242,6 +247,21 @@ test('duplicate/query and audit failures roll back without discarding the intent } }) +test('failure diagnostics expose bounded class/code fields and redact messages', () => { + const error = Object.assign(new Error('token=secret sql=SELECT password'), { + code: '23505', + cause: new Error('nested credential=secret'), + }) + assert.deepEqual(describeFixtureError(error), { + name: 'Error', + code: '23505', + cause: { name: 'Error' }, + }) + assert.deepEqual(describeFixtureError({ name: 'Error', code: 'not-safe', message: 'secret' }), { + name: 'Error', + }) +}) + test('lost commit acknowledgement retains exact IDs rather than retrying insertion', async () => { const { state, adapters } = harness() state.commitLost = true diff --git a/tests/e2e/fixtures/codex-subscription/approved-tools-setup/seed.mjs b/tests/e2e/fixtures/codex-subscription/approved-tools-setup/seed.mjs index 5f42b94ea7..0dc79c38bd 100644 --- a/tests/e2e/fixtures/codex-subscription/approved-tools-setup/seed.mjs +++ b/tests/e2e/fixtures/codex-subscription/approved-tools-setup/seed.mjs @@ -25,7 +25,7 @@ try { await import('/app/control-api/dist/services/codexSubscriptionConnection.js') const { rebuildLiveCodexUnionAllowlist } = await import('/app/control-api/dist/services/codexSubscriptionCatalog.js') - const { createFixtureIdentities, cleanupFixtureIdentities } = + const { createFixtureIdentities, cleanupFixtureIdentities, describeFixtureError } = await import('/app/approved-tools-setup/identity-lifecycle.mjs') const publish = journal => process.stdout.write(JSON.stringify({ e2eIdentity: journal }) + '\n') const adapters = { @@ -56,8 +56,14 @@ try { } else { await cleanupFixtureIdentities(input, input.initialJournal, adapters) } -} catch { - process.stderr.write('Fixture identity operation failed; retain the host journal for recovery\n') +} catch (error) { + process.stderr.write( + JSON.stringify({ + event: 'fixture_identity_operation_failed', + operation: input.action, + failure: describeFixtureError(error), + }) + '\n' + ) process.exitCode = 1 } finally { await pool.end() diff --git a/tests/e2e/fixtures/package-lock.json b/tests/e2e/fixtures/package-lock.json index 8c88caac18..2e21df64bd 100644 --- a/tests/e2e/fixtures/package-lock.json +++ b/tests/e2e/fixtures/package-lock.json @@ -1,12 +1,12 @@ { "name": "@clerum/e2e-test-fixtures", - "version": "1.0.100", + "version": "1.0.101", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@clerum/e2e-test-fixtures", - "version": "1.0.100", + "version": "1.0.101", "dependencies": { "@kubernetes/client-node": "^1.0.0" }, diff --git a/tests/e2e/fixtures/package.json b/tests/e2e/fixtures/package.json index b80adc9345..0523a0d758 100644 --- a/tests/e2e/fixtures/package.json +++ b/tests/e2e/fixtures/package.json @@ -1,6 +1,6 @@ { "name": "@clerum/e2e-test-fixtures", - "version": "1.0.100", + "version": "1.0.101", "description": "E2E test fixtures and utilities for Clerum testing", "type": "module", "main": "index.ts", From 451e80356731d5803f8515dbdf15c15c3472f33c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 17 Sep 2026 19:43:45 -0600 Subject: [PATCH 217/528] fix(build): track control-ui qa recorder image inputs Class-check: Dockerfile external COPY inputs -> 1 control-ui dependency -> mirror manifest and publish filter Repro-test: removing the qa-recorder mapping makes the canonical image manifest guard fail Validation: image manifest guard passes; Control UI build passes --- .github/workflows/build-publish.yml | 1 + deploy/images.json | 3 +- scripts/tests/test-images-manifest.sh | 40 +++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-publish.yml b/.github/workflows/build-publish.yml index c0e8600607..aaa621cce0 100644 --- a/.github/workflows/build-publish.yml +++ b/.github/workflows/build-publish.yml @@ -133,6 +133,7 @@ jobs: - 'packages/display-field/**' - 'packages/frontend-components/**' - 'packages/gfs-interaction-policy/**' + - 'scripts/qa-recorder/**' external-rest-api: - 'external-rest-api/**' host-context-controller: diff --git a/deploy/images.json b/deploy/images.json index 698d119738..6a013243be 100644 --- a/deploy/images.json +++ b/deploy/images.json @@ -39,7 +39,8 @@ "packages/workflow-recipe-capability-policy/**", "packages/display-field/**", "packages/frontend-components/**", - "packages/gfs-interaction-policy/**" + "packages/gfs-interaction-policy/**", + "scripts/qa-recorder/**" ], "published": true, "deployed_to_minikube": true diff --git a/scripts/tests/test-images-manifest.sh b/scripts/tests/test-images-manifest.sh index 24830e5dc8..f93ee1edbd 100755 --- a/scripts/tests/test-images-manifest.sh +++ b/scripts/tests/test-images-manifest.sh @@ -651,6 +651,45 @@ assert_display_field_change_rebuilds_both_consumers() { fi } +# control-ui's Dockerfile copies scripts/qa-recorder outside the application +# directory. Keep that producer relationship explicit in both the manifest and +# the publish filter so a recorder-only change cannot skip the UI image. +assert_control_ui_qa_recorder_change_rebuilds_control_ui() { + local output rc + output="$(node -e ' + import("'"$REPO_ROOT"'/scripts/release/images-manifest.mjs").then(async m => { + const fs = await import("node:fs") + const wfPath = "'"$REPO_ROOT"'/.github/workflows/build-publish.yml" + const dockerfile = fs.readFileSync("'"$REPO_ROOT"'/control-ui/Dockerfile", "utf8") + const wf = fs.readFileSync(wfPath, "utf8") + const sourcePath = "scripts/qa-recorder/**" + const image = m.IMAGES.find(item => item.name === "control-ui") + const problems = [] + if (!dockerfile.includes("COPY scripts/qa-recorder")) { + problems.push("control-ui Dockerfile no longer declares the qa-recorder copy") + } + if (!image) problems.push("control-ui has no manifest row") + else if (!image.source_paths?.includes(sourcePath)) { + problems.push("control-ui manifest does not track scripts/qa-recorder/**") + } + if (!wf.includes(` - '\''${sourcePath}'\''`)) { + problems.push("control-ui publish filter does not track scripts/qa-recorder/**") + } + console.log(problems.join("; ")) + }).catch(err => { + console.log(`PARSE_ERROR: ${err.message}`) + process.exit(1) + })' 2>&1)" + rc=$? + if [ "$rc" -ne 0 ]; then + fail "Control UI qa-recorder contract parse failed: $output" + elif [ -z "$output" ]; then + pass "a qa-recorder-only change rebuilds control-ui" + else + fail "Control UI qa-recorder changed-path contract failed: $output" + fi +} + assert_llm_provider_attempt_contract_change_rebuilds_consumers() { local output rc output="$(node -e ' @@ -1424,6 +1463,7 @@ assert_every_matrix_image_has_a_manifest_row assert_source_paths_match_filters assert_network_policy_core_change_rebuilds_all_consumers assert_display_field_change_rebuilds_both_consumers +assert_control_ui_qa_recorder_change_rebuilds_control_ui assert_llm_provider_attempt_contract_change_rebuilds_consumers assert_codex_catalog_projection_change_rebuilds_consumers assert_gfs_interaction_policy_change_rebuilds_consumers From 277673f11fc2c435efde55852d23ed91349ebaf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 17 Sep 2026 20:45:55 -0600 Subject: [PATCH 218/528] fix(control-api): classify owned PostgreSQL statement timeouts Class-check: access query boundary had one English-message classifier; tracing metrics are separate. Repro-test: 0947823d fails localized 57014 mapping; PG16 timeout and operator-cancel tests pass. --- .../access/accessAuthorityTransaction.ts | 5 +- .../services/access/accessDatabaseQuery.ts | 56 +++++++++++--- .../services/access/operationalAccessIndex.ts | 16 ++-- ...cellation.realPostgres.integration.test.ts | 77 ++++++++++++++++++- 4 files changed, 133 insertions(+), 21 deletions(-) diff --git a/control-api/src/services/access/accessAuthorityTransaction.ts b/control-api/src/services/access/accessAuthorityTransaction.ts index beb67932a8..13865a06da 100644 --- a/control-api/src/services/access/accessAuthorityTransaction.ts +++ b/control-api/src/services/access/accessAuthorityTransaction.ts @@ -6,6 +6,7 @@ export async function configureAccessAuthorityTransaction( db: Pick, budget: AccessExecutionBudget ): Promise { + const statementTimeoutMs = budget.statementTimeoutMs() await runAccessDatabaseQuery( db, budget, @@ -17,7 +18,7 @@ export async function configureAccessAuthorityTransaction( db, budget, `SELECT set_config('statement_timeout', $1, true)`, - [`${budget.statementTimeoutMs()}ms`], - { chargeRows: false } + [`${statementTimeoutMs}ms`], + { chargeRows: false, statementTimeoutMs } ) } diff --git a/control-api/src/services/access/accessDatabaseQuery.ts b/control-api/src/services/access/accessDatabaseQuery.ts index 768ab99046..4b700dc5b5 100644 --- a/control-api/src/services/access/accessDatabaseQuery.ts +++ b/control-api/src/services/access/accessDatabaseQuery.ts @@ -17,6 +17,12 @@ type PostgresCancellationTarget = Pick & connectionParameters?: PostgresConnectionParameters }> +type StatementTimeoutProvenance = Readonly<{ + timeoutMs: number +}> + +const statementTimeoutProvenance = new WeakMap() + function boundedPort(value: unknown): number | undefined { const port = Number(value) return Number.isSafeInteger(port) && port > 0 && port <= 65_535 ? port : undefined @@ -47,14 +53,30 @@ async function cancelPostgresBackend(db: PostgresCancellationTarget): Promise= provenance.timeoutMs } /** @@ -67,7 +89,11 @@ export async function runAccessDatabaseQuery( budget: AccessExecutionBudget, text: string, values: unknown[] = [], - options: { chargeRows?: boolean; chargeProducer?: boolean } = {} + options: { + chargeRows?: boolean + chargeProducer?: boolean + statementTimeoutMs?: number + } = {} ) { const run = options.chargeProducer === false @@ -76,6 +102,9 @@ export async function runAccessDatabaseQuery( return run(async signal => { budget.assertActive() budget.charge({ kind: 'databaseStatements' }) + const queryStartedAt = performance.now() + const provenance = + typeof db === 'object' && db !== null ? statementTimeoutStarted(db) : undefined let cancellation: Promise | undefined const onAbort = () => { cancellation ??= cancelPostgresBackend(db as PostgresCancellationTarget).catch( @@ -85,6 +114,9 @@ export async function runAccessDatabaseQuery( signal.addEventListener('abort', onAbort, { once: true }) try { const result = await db.query(text, values) + if (options.statementTimeoutMs !== undefined && typeof db === 'object' && db !== null) { + rememberStatementTimeout(db, options.statementTimeoutMs) + } budget.assertActive() if (options.chargeRows !== false && result.rows.length > 0) { budget.charge({ kind: 'dbRowsReturned', amount: result.rows.length }) @@ -92,7 +124,7 @@ export async function runAccessDatabaseQuery( return result } catch (error) { if (signal.aborted) budget.assertActive() - if (isStatementTimeout(error)) { + if (isOwnedStatementTimeout(error, provenance, queryStartedAt)) { throw new AccessBudgetExceededError('deadline', true) } throw error @@ -180,6 +212,7 @@ export async function withAccessDatabaseTransaction( }) transactionStarted = true if (options.mode !== 'caller_configured') { + const statementTimeoutMs = budget.statementTimeoutMs() if (options.mode === 'read_only') { await runAccessDatabaseQuery(client, budget, 'SET TRANSACTION READ ONLY', [], { chargeRows: false, @@ -190,8 +223,8 @@ export async function withAccessDatabaseTransaction( client, budget, `SELECT set_config('statement_timeout', $1, true)`, - [`${budget.statementTimeoutMs()}ms`], - { chargeRows: false, chargeProducer: false } + [`${statementTimeoutMs}ms`], + { chargeRows: false, chargeProducer: false, statementTimeoutMs } ) } const result = await work( @@ -219,6 +252,7 @@ export async function withAccessDatabaseTransaction( } throw error } finally { + forgetStatementTimeout(client) client.release(releaseError) } } diff --git a/control-api/src/services/access/operationalAccessIndex.ts b/control-api/src/services/access/operationalAccessIndex.ts index b6431720eb..38490f7bd6 100644 --- a/control-api/src/services/access/operationalAccessIndex.ts +++ b/control-api/src/services/access/operationalAccessIndex.ts @@ -123,15 +123,21 @@ export class OperationalAccessIndex { db: DbClient, budget: AccessExecutionBudget, text: string, - values: unknown[] = [] + values: unknown[] = [], + options: { statementTimeoutMs?: number } = {} ) { - return runAccessDatabaseQuery(db, budget, text, values) + return runAccessDatabaseQuery(db, budget, text, values, options) } private async configureTransaction(db: DbClient, budget: AccessExecutionBudget): Promise { - await this.query(db, budget, `SELECT set_config('statement_timeout', $1, true)`, [ - `${budget.statementTimeoutMs()}ms`, - ]) + const statementTimeoutMs = budget.statementTimeoutMs() + await this.query( + db, + budget, + `SELECT set_config('statement_timeout', $1, true)`, + [`${statementTimeoutMs}ms`], + { statementTimeoutMs } + ) } async beginRelist(input: { diff --git a/control-api/test/accessDatabaseCancellation.realPostgres.integration.test.ts b/control-api/test/accessDatabaseCancellation.realPostgres.integration.test.ts index 420203d078..e58a139267 100644 --- a/control-api/test/accessDatabaseCancellation.realPostgres.integration.test.ts +++ b/control-api/test/accessDatabaseCancellation.realPostgres.integration.test.ts @@ -1,6 +1,9 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { Pool, type PoolClient } from 'pg' -import { withAccessDatabaseTransaction } from '../src/services/access/accessDatabaseQuery.js' +import { + runAccessDatabaseQuery, + withAccessDatabaseTransaction, +} from '../src/services/access/accessDatabaseQuery.js' import { AccessBudgetExceededError, AccessExecutionBudget, @@ -48,8 +51,46 @@ describe('physical access database statement accounting', () => { budget.close() } }) + + it('uses owned timeout provenance for a localized cancellation message', async () => { + const query = vi + .fn() + .mockResolvedValueOnce({ rows: [], rowCount: 0 }) + .mockImplementationOnce( + () => + new Promise((_, reject) => + setTimeout( + () => + reject(Object.assign(new Error('cancelación de declaración'), { code: '57014' })), + 75 + ) + ) + ) + const budget = AccessExecutionBudget.create('catalog', { + limits: { catalogDeadlineMs: 1_000, statementTimeoutMs: 50 }, + }) + const db = budgetedQueryClient(query) + try { + await runAccessDatabaseQuery(db, budget, 'SET statement_timeout', [], { + chargeRows: false, + chargeProducer: false, + statementTimeoutMs: 50, + }) + await expect(runAccessDatabaseQuery(db, budget, 'SELECT pg_sleep(2)')).rejects.toMatchObject({ + name: 'AccessBudgetExceededError', + limit: 'deadline', + authorityRequired: true, + }) + } finally { + budget.close() + } + }) }) +function budgetedQueryClient(query: ReturnType) { + return { query } +} + const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -84,7 +125,11 @@ describeRealPostgres('access database cancellation on real PostgreSQL', () => { const budget = AccessExecutionBudget.create('catalog') const startedAt = performance.now() try { - await client.query(`SET statement_timeout = '50ms'`) + await runAccessDatabaseQuery(client, budget, `SET statement_timeout = '50ms'`, [], { + chargeRows: false, + chargeProducer: false, + statementTimeoutMs: 50, + }) await expect(catalogQuery(client, budget, 'SELECT pg_sleep(2)', [])).rejects.toMatchObject({ name: 'AccessBudgetExceededError', limit: 'deadline', @@ -98,13 +143,39 @@ describeRealPostgres('access database cancellation on real PostgreSQL', () => { } }) + it('does not classify an operator cancellation with the same SQLSTATE as a timeout', async () => { + const client = (await pool.connect()) as PoolClient + const budget = AccessExecutionBudget.create('catalog') + try { + await runAccessDatabaseQuery(client, budget, `SET statement_timeout = '500ms'`, [], { + chargeRows: false, + chargeProducer: false, + statementTimeoutMs: 500, + }) + const query = catalogQuery(client, budget, 'SELECT pg_sleep(2)', []) + setTimeout(() => { + void pool.query('SELECT pg_cancel_backend($1)', [client.processID]) + }, 10) + const error = await query.catch(value => value) + expect(error).toMatchObject({ code: '57014' }) + expect(error).not.toBeInstanceOf(AccessBudgetExceededError) + } finally { + budget.close() + client.release() + } + }) + it('requires rollback and preserves no transactional writes after timeout', async () => { const client = (await pool.connect()) as PoolClient const budget = AccessExecutionBudget.create('catalog') try { await client.query(`CREATE TEMP TABLE access_timeout_rollback(value integer)`) await client.query('BEGIN') - await client.query(`SET LOCAL statement_timeout = '50ms'`) + await runAccessDatabaseQuery(client, budget, `SET LOCAL statement_timeout = '50ms'`, [], { + chargeRows: false, + chargeProducer: false, + statementTimeoutMs: 50, + }) await client.query(`INSERT INTO access_timeout_rollback(value) VALUES (1)`) await expect(catalogQuery(client, budget, 'SELECT pg_sleep(2)', [])).rejects.toBeInstanceOf( AccessBudgetExceededError From be397dcb5b21027271cad37e0ab7cbdfee241c99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 18 Sep 2026 10:02:11 -0600 Subject: [PATCH 219/528] fix(control-api): preserve dev migrations during PR1 sync Class-check: post-0106 migration registry and runner classification Repro-test: migration policy and owner-generalization suites failed before adaptation --- control-api/src/migrations/migrationRunner.ts | 14 +++++++++++--- ...xecutionPolicy.realPostgres.integration.test.ts | 6 +----- control-api/test/migrationExecutionPolicy.test.ts | 10 +++------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/control-api/src/migrations/migrationRunner.ts b/control-api/src/migrations/migrationRunner.ts index 0f682a881e..d97ffc8216 100644 --- a/control-api/src/migrations/migrationRunner.ts +++ b/control-api/src/migrations/migrationRunner.ts @@ -11,13 +11,21 @@ export const PR1_MIGRATION_VERSIONS = Object.freeze([ '010e_legacy_password_security_epoch_backfill', ] as const) -const NON_PR1_POST_0106_MIGRATION_VERSIONS = new Set([ +export const DEV_POST_0106_MIGRATION_VERSIONS = Object.freeze([ '0107_llm_provider_attempts_sdk_link', '0108_llm_provider_attempts_sdk_link_on_delete_set_null', -]) + '0109_grok_subscription_connections', + '0110_grok_subscription_oauth_states', + '0111_grok_catalog_models', + '0112_llm_provider_attempts_grok_broker', + '0113_grok_subscription_terminal_connection_key', + '0114_llm_provider_attempts_connection_integrity', +] as const) + +const NON_PR1_POST_0106_MIGRATION_VERSIONS = new Set(DEV_POST_0106_MIGRATION_VERSIONS) const CLASSIFIED_POST_0106_MIGRATION_VERSIONS = Object.freeze([ - ...NON_PR1_POST_0106_MIGRATION_VERSIONS, + ...DEV_POST_0106_MIGRATION_VERSIONS, ...PR1_MIGRATION_VERSIONS, ] as const) diff --git a/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts index f04f9dc15f..2d33f26a80 100644 --- a/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts +++ b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts @@ -5,6 +5,7 @@ import { once } from 'node:events' import { Pool, type PoolClient } from 'pg' import { type DbClient, initDb } from '../src/db.js' import { + DEV_POST_0106_MIGRATION_VERSIONS, PR1_MIGRATION_VERSIONS, applyPendingPr1Migrations, } from '../src/migrations/migrationRunner.js' @@ -34,11 +35,6 @@ const FRESH_TABLE_INDEXES = Object.freeze([ 'invitation_delivery_commands_invitation_idx', ]) -const DEV_POST_0106_MIGRATION_VERSIONS = Object.freeze([ - '0107_llm_provider_attempts_sdk_link', - '0108_llm_provider_attempts_sdk_link_on_delete_set_null', -] as const) - function databaseUrl(baseUrl: string, database: string): string { const value = new URL(baseUrl) value.pathname = `/${database}` diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index 1e370c41dd..07b5b2d4df 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -3,6 +3,7 @@ import { createHash } from 'node:crypto' import { readFile } from 'node:fs/promises' import { MIGRATION_EXECUTION_POLICY } from '../src/migrations/migrationExecutionPolicy.js' import { + DEV_POST_0106_MIGRATION_VERSIONS, PR1_MIGRATION_VERSIONS, applyPendingPr1Migrations, } from '../src/migrations/migrationRunner.js' @@ -29,11 +30,6 @@ const FRESH_TABLE_INDEXES = Object.freeze([ 'invitation_delivery_commands_invitation_idx', ]) -const DEV_POST_0106_MIGRATION_VERSIONS = Object.freeze([ - '0107_llm_provider_attempts_sdk_link', - '0108_llm_provider_attempts_sdk_link_on_delete_set_null', -] as const) - describe('D34 migration execution policy', () => { it('freezes the owner-approved timeout and Job values', () => { expect(MIGRATION_EXECUTION_POLICY).toEqual({ @@ -251,8 +247,8 @@ describe('D34 PR1 migration runner', () => { }) expect(applied).toEqual([...DEV_POST_0106_MIGRATION_VERSIONS, ...PR1_MIGRATION_VERSIONS]) - expect(queries.filter(({ sql }) => sql === 'BEGIN')).toHaveLength(9) - expect(queries.filter(({ sql }) => sql === 'COMMIT')).toHaveLength(9) + expect(queries.filter(({ sql }) => sql === 'BEGIN')).toHaveLength(15) + expect(queries.filter(({ sql }) => sql === 'COMMIT')).toHaveLength(15) expect(queries.filter(({ sql }) => sql === 'ROLLBACK')).toHaveLength(0) }) From 18e2f60c57ec3ccf071e05c17f9552270846ca95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 18 Sep 2026 10:04:36 -0600 Subject: [PATCH 220/528] fix(fixtures): preserve bounded setup failure diagnostics Class-check: approved-tools fixture catch helpers with the same lexical-scope pattern Repro-test: identity lifecycle guard RED at 83a69b6fa and GREEN after moving the binding --- .../identity-lifecycle.test.mjs | 13 +++++++++++++ .../approved-tools-setup/seed.mjs | 4 ++-- tests/e2e/fixtures/package-lock.json | 4 ++-- tests/e2e/fixtures/package.json | 2 +- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/tests/e2e/fixtures/codex-subscription/approved-tools-setup/identity-lifecycle.test.mjs b/tests/e2e/fixtures/codex-subscription/approved-tools-setup/identity-lifecycle.test.mjs index 17cdffdf45..007333a4a7 100644 --- a/tests/e2e/fixtures/codex-subscription/approved-tools-setup/identity-lifecycle.test.mjs +++ b/tests/e2e/fixtures/codex-subscription/approved-tools-setup/identity-lifecycle.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' import test from 'node:test' import { cleanupFixtureIdentities, @@ -39,6 +40,18 @@ const env = { CONTROL_API_REAL_PG_CONTEXT: input.context, } +test('seed keeps bounded fixture diagnostics in catch scope', () => { + const source = readFileSync(new URL('./seed.mjs', import.meta.url), 'utf8') + const binding = source.indexOf( + 'const { createFixtureIdentities, cleanupFixtureIdentities, describeFixtureError }' + ) + const tryBlock = source.indexOf('\ntry {') + const diagnosticUse = source.indexOf('failure: describeFixtureError(error)') + assert.ok(binding >= 0) + assert.ok(binding < tryBlock) + assert.ok(diagnosticUse > tryBlock) +}) + function harness() { const state = { calls: [], diff --git a/tests/e2e/fixtures/codex-subscription/approved-tools-setup/seed.mjs b/tests/e2e/fixtures/codex-subscription/approved-tools-setup/seed.mjs index 0dc79c38bd..3f61ef7ca1 100644 --- a/tests/e2e/fixtures/codex-subscription/approved-tools-setup/seed.mjs +++ b/tests/e2e/fixtures/codex-subscription/approved-tools-setup/seed.mjs @@ -16,6 +16,8 @@ for await (const chunk of process.stdin) { } const input = JSON.parse(Buffer.concat(inputChunks, inputBytes).toString('utf8')) if (!['create', 'cleanup'].includes(input.action)) throw new Error('Invalid identity action') +const { createFixtureIdentities, cleanupFixtureIdentities, describeFixtureError } = + await import('/app/approved-tools-setup/identity-lifecycle.mjs') const { pool, withTransaction } = await import('/app/control-api/dist/db.js') try { const { default: bcrypt } = await import('/app/control-api/node_modules/bcryptjs/index.js') @@ -25,8 +27,6 @@ try { await import('/app/control-api/dist/services/codexSubscriptionConnection.js') const { rebuildLiveCodexUnionAllowlist } = await import('/app/control-api/dist/services/codexSubscriptionCatalog.js') - const { createFixtureIdentities, cleanupFixtureIdentities, describeFixtureError } = - await import('/app/approved-tools-setup/identity-lifecycle.mjs') const publish = journal => process.stdout.write(JSON.stringify({ e2eIdentity: journal }) + '\n') const adapters = { env: process.env, diff --git a/tests/e2e/fixtures/package-lock.json b/tests/e2e/fixtures/package-lock.json index 2e21df64bd..8c88caac18 100644 --- a/tests/e2e/fixtures/package-lock.json +++ b/tests/e2e/fixtures/package-lock.json @@ -1,12 +1,12 @@ { "name": "@clerum/e2e-test-fixtures", - "version": "1.0.101", + "version": "1.0.100", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@clerum/e2e-test-fixtures", - "version": "1.0.101", + "version": "1.0.100", "dependencies": { "@kubernetes/client-node": "^1.0.0" }, diff --git a/tests/e2e/fixtures/package.json b/tests/e2e/fixtures/package.json index 0523a0d758..b80adc9345 100644 --- a/tests/e2e/fixtures/package.json +++ b/tests/e2e/fixtures/package.json @@ -1,6 +1,6 @@ { "name": "@clerum/e2e-test-fixtures", - "version": "1.0.101", + "version": "1.0.100", "description": "E2E test fixtures and utilities for Clerum testing", "type": "module", "main": "index.ts", From 9cbf790ab93da7cdfd8dce1dbcd4e601c6dff531 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 18 Sep 2026 10:07:12 -0600 Subject: [PATCH 221/528] test(rpc-proxy): cover concurrent response hook isolation Class-check: sandbox proxyRes one-shot listener mutations Repro-test: response security test RED with proxyRes once and GREEN with on/off --- rpc-proxy/src/routes/sandboxUi.test.ts | 45 ++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/rpc-proxy/src/routes/sandboxUi.test.ts b/rpc-proxy/src/routes/sandboxUi.test.ts index bbb4e6a259..9d79cdd881 100644 --- a/rpc-proxy/src/routes/sandboxUi.test.ts +++ b/rpc-proxy/src/routes/sandboxUi.test.ts @@ -807,6 +807,51 @@ describe('ANY /api/v1/sandbox-ui/:ns/:name/view/*', () => { pending[1].res.status(200).end() await Promise.all([firstPromise, secondPromise]) }) + + it('keeps concurrent proxy response security hooks attached to their own response', async () => { + const pending: Array<{ + req: object + res: { status: (status: number) => { end: () => void } } + }> = [] + httpProxyMock._proxy.web.mockImplementation((req: object, res) => { + pending.push({ req, res: res as { status: (status: number) => { end: () => void } } }) + }) + + const firstRequest = request(viewApp()) + .get('/api/v1/sandbox-ui/sandbox-recipes/r1/view/first') + .set('Cookie', cookieHeader('sandbox-recipes', 'r1', 'user-a')) + const secondRequest = request(viewApp()) + .get('/api/v1/sandbox-ui/sandbox-recipes/r1/view/second') + .set('Cookie', cookieHeader('sandbox-recipes', 'r1', 'user-b')) + const firstPromise = firstRequest.then(() => undefined) + const secondPromise = secondRequest.then(() => undefined) + + await new Promise(resolve => setTimeout(resolve, 100)) + expect(pending).toHaveLength(2) + + const firstProxyRes = { + statusCode: 302, + headers: { location: 'https://outside.example.test/path' } as Record, + } + const secondProxyRes = { + statusCode: 200, + headers: {} as Record, + } + + // Deliver B before A: both response hooks must remain live until their own + // response arrives, so each response receives the security floor. + httpProxyMock._proxy.emit('proxyRes', secondProxyRes, pending[1].req, {}) + httpProxyMock._proxy.emit('proxyRes', firstProxyRes, pending[0].req, {}) + + expect(firstProxyRes.statusCode).toBe(200) + expect(firstProxyRes.headers.location).toBeUndefined() + expect(firstProxyRes.headers['x-frame-options']).toBe('DENY') + expect(secondProxyRes.headers['x-frame-options']).toBe('DENY') + + pending[0].res.status(200).end() + pending[1].res.status(200).end() + await Promise.all([firstPromise, secondPromise]) + }) }) // ─── GET /api/v1/sandbox-ui/apps ─────────────────────────────────── From 03bd11fab180ff887cb3ff83084aecab6cb525bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 18 Sep 2026 10:07:22 -0600 Subject: [PATCH 222/528] docs(rpc-proxy): clarify persistent proxy listeners Class-check: stale one-shot wording in sandbox proxy listener comment Repro-test: source audit confirmed runtime uses on/off for both proxy events --- rpc-proxy/src/routes/sandboxUi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rpc-proxy/src/routes/sandboxUi.ts b/rpc-proxy/src/routes/sandboxUi.ts index 15405c9d1e..0147270b2e 100644 --- a/rpc-proxy/src/routes/sandboxUi.ts +++ b/rpc-proxy/src/routes/sandboxUi.ts @@ -692,7 +692,7 @@ export function createSandboxUiSessionRouter(): Router { // shadow headers. http-proxy fires onProxyReq once with the // ClientRequest. Each request gets a filtered listener, and cleanup removes // it after the response closes. `on` is intentional: shared proxy events - // can be interleaved across concurrent requests, so a one-shot listener + // can be interleaved across concurrent requests, so a once-only listener // could be consumed by another request before this request's event arrives. const onProxyReq = ( proxyReq: ClientRequest, From f427e45baf826d676ed9f7b27072f560fd9b6fd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 18 Sep 2026 10:08:57 -0600 Subject: [PATCH 223/528] fix(control-api): type migration classification set Class-check: post-0106 migration version membership checks Repro-test: control-api build caught the narrow literal Set type after dev sync --- control-api/src/migrations/migrationRunner.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control-api/src/migrations/migrationRunner.ts b/control-api/src/migrations/migrationRunner.ts index d97ffc8216..215bd7443c 100644 --- a/control-api/src/migrations/migrationRunner.ts +++ b/control-api/src/migrations/migrationRunner.ts @@ -22,7 +22,7 @@ export const DEV_POST_0106_MIGRATION_VERSIONS = Object.freeze([ '0114_llm_provider_attempts_connection_integrity', ] as const) -const NON_PR1_POST_0106_MIGRATION_VERSIONS = new Set(DEV_POST_0106_MIGRATION_VERSIONS) +const NON_PR1_POST_0106_MIGRATION_VERSIONS = new Set(DEV_POST_0106_MIGRATION_VERSIONS) const CLASSIFIED_POST_0106_MIGRATION_VERSIONS = Object.freeze([ ...DEV_POST_0106_MIGRATION_VERSIONS, From ef87a3257930f4cda0f07dd7e25b9b488d9f9d66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 18 Sep 2026 10:11:29 -0600 Subject: [PATCH 224/528] test(control-api): track synchronized migration transactions Class-check: initDb transaction-count expectations after dev migration union Repro-test: db.initDb test expected 10 and observed 16 after PR1 sync --- control-api/test/db.initDb.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/control-api/test/db.initDb.test.ts b/control-api/test/db.initDb.test.ts index 77b6795f02..68844b9c7c 100644 --- a/control-api/test/db.initDb.test.ts +++ b/control-api/test/db.initDb.test.ts @@ -65,8 +65,8 @@ describe('db.initDb', () => { ).toBe(false) expect(sqls.some(sql => sql.includes('DROP TABLE IF EXISTS workflow_run_outputs'))).toBe(false) expect(sqls).not.toContain('ROLLBACK') - expect(sqls.filter(sql => sql === 'BEGIN')).toHaveLength(10) - expect(sqls.filter(sql => sql === 'COMMIT')).toHaveLength(10) + expect(sqls.filter(sql => sql === 'BEGIN')).toHaveLength(16) + expect(sqls.filter(sql => sql === 'COMMIT')).toHaveLength(16) expect(sqls[sqls.length - 2]).toBe('COMMIT') expect(sqls[sqls.length - 1]).toContain('SELECT pg_advisory_unlock') expect(clientRelease).toHaveBeenCalledTimes(1) From d4de18ca72658975c6f4bfcb3c0eb75722c04fd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 18 Sep 2026 10:14:50 -0600 Subject: [PATCH 225/528] fix(control-api): preserve monotonic merged migration registry Class-check: CONTROL_API_MIGRATIONS version ordering across dev and PR1 Repro-test: full control-api suite found a non-monotonic 0114 to 0109 registry pair --- control-api/test/db.oauthGrantsOwnerGeneralization.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/control-api/test/db.oauthGrantsOwnerGeneralization.test.ts b/control-api/test/db.oauthGrantsOwnerGeneralization.test.ts index 4d6c22965f..9c833ab413 100644 --- a/control-api/test/db.oauthGrantsOwnerGeneralization.test.ts +++ b/control-api/test/db.oauthGrantsOwnerGeneralization.test.ts @@ -59,8 +59,10 @@ describe('0101 oauth_grants owner generalization migration', () => { expect(versions.indexOf('0107_llm_provider_attempts_sdk_link')).toBeLessThan( versions.indexOf('0108_llm_provider_attempts_sdk_link_on_delete_set_null') ) + expect(versions.indexOf('0108_llm_provider_attempts_sdk_link_on_delete_set_null')).toBeLessThan( + versions.indexOf('0109_user_access_foundation') + ) expect(versions).toContain('0099_gfs_upload_finalizing_recovery') - expect(versions).toContain('0100_seed_minimax_allowed_model') }) it('carries its prior names as legacyVersions so a deploy that already ran it is not re-executed', async () => { From fd6ddd56d493b3ea252d343f1e26b533a565cc32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Fri, 18 Sep 2026 13:58:51 -0600 Subject: [PATCH 226/528] fix(control-api): classify dev image-input migration after sync Class-check: post-0106 migration registry -> 1 runner and 1 registry -> classify the incoming 0115 migration without changing PR1 ownership Repro-test: control-api/npm test -- --run test/migrationExecutionPolicy.test.ts test/db.initDb.test.ts failed after dev sync and passes after this adaptation --- control-api/src/migrations/migrationRunner.ts | 1 + control-api/test/db.initDb.test.ts | 4 ++-- control-api/test/migrationExecutionPolicy.test.ts | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/control-api/src/migrations/migrationRunner.ts b/control-api/src/migrations/migrationRunner.ts index 215bd7443c..9886700b56 100644 --- a/control-api/src/migrations/migrationRunner.ts +++ b/control-api/src/migrations/migrationRunner.ts @@ -20,6 +20,7 @@ export const DEV_POST_0106_MIGRATION_VERSIONS = Object.freeze([ '0112_llm_provider_attempts_grok_broker', '0113_grok_subscription_terminal_connection_key', '0114_llm_provider_attempts_connection_integrity', + '0115_llm_allowed_models_image_input', ] as const) const NON_PR1_POST_0106_MIGRATION_VERSIONS = new Set(DEV_POST_0106_MIGRATION_VERSIONS) diff --git a/control-api/test/db.initDb.test.ts b/control-api/test/db.initDb.test.ts index 68844b9c7c..569af2f330 100644 --- a/control-api/test/db.initDb.test.ts +++ b/control-api/test/db.initDb.test.ts @@ -65,8 +65,8 @@ describe('db.initDb', () => { ).toBe(false) expect(sqls.some(sql => sql.includes('DROP TABLE IF EXISTS workflow_run_outputs'))).toBe(false) expect(sqls).not.toContain('ROLLBACK') - expect(sqls.filter(sql => sql === 'BEGIN')).toHaveLength(16) - expect(sqls.filter(sql => sql === 'COMMIT')).toHaveLength(16) + expect(sqls.filter(sql => sql === 'BEGIN')).toHaveLength(17) + expect(sqls.filter(sql => sql === 'COMMIT')).toHaveLength(17) expect(sqls[sqls.length - 2]).toBe('COMMIT') expect(sqls[sqls.length - 1]).toContain('SELECT pg_advisory_unlock') expect(clientRelease).toHaveBeenCalledTimes(1) diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index 07b5b2d4df..86bf1ab0e2 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -247,8 +247,8 @@ describe('D34 PR1 migration runner', () => { }) expect(applied).toEqual([...DEV_POST_0106_MIGRATION_VERSIONS, ...PR1_MIGRATION_VERSIONS]) - expect(queries.filter(({ sql }) => sql === 'BEGIN')).toHaveLength(15) - expect(queries.filter(({ sql }) => sql === 'COMMIT')).toHaveLength(15) + expect(queries.filter(({ sql }) => sql === 'BEGIN')).toHaveLength(16) + expect(queries.filter(({ sql }) => sql === 'COMMIT')).toHaveLength(16) expect(queries.filter(({ sql }) => sql === 'ROLLBACK')).toHaveLength(0) }) From a30c0de12d7f934a7644a47df35d0719f8d82f3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 21 Sep 2026 17:38:42 -0600 Subject: [PATCH 227/528] fix(external-rest-api): r36-m1 align rpc test with canonical loopback Stable-ID: R36-M1 Class-check: ::ffff:127.0.0.1 mapped-loopback expectations in External REST -> 1 stale assertion -> canonical producer value only. Repro-test: external-rest-api/test/routes.rpc.test.ts:103 (fails at 7a7c07ea43105ed4d3dc716c708c29a8d6202046). --- external-rest-api/package-lock.json | 4 ++-- external-rest-api/package.json | 2 +- external-rest-api/test/routes.rpc.test.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 1831f85dfc..ff88a88560 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index a2eb923322..d8dc7498ab 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.106", + "version": "0.1.107", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/test/routes.rpc.test.ts b/external-rest-api/test/routes.rpc.test.ts index 576b3c3647..8f3cbee4c0 100644 --- a/external-rest-api/test/routes.rpc.test.ts +++ b/external-rest-api/test/routes.rpc.test.ts @@ -104,7 +104,7 @@ describe('routes/rpc /rpc/token', () => { 'session-xyz', ['host:message:invoke'], ['pro-agent'], - '::ffff:127.0.0.1' + '127.0.0.1' ) }) }) From fcf99c969edbd55f476ce889fc64d220204ae13d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 21 Sep 2026 19:57:42 -0600 Subject: [PATCH 228/528] fix(codeql): r37-m1 remove D33 staged-admission recognition Stable-ID: R37-M1 Class-check: D33 staged-admission analyzer recognition -> exact query predicates, D33 fixture, two exclusive exports, zero-reference helpers, and 21 expected rows -> remove analyzer recognition only; retain runtime controls, shared fixtures, core query coverage, and artifact recognition. Repro-test: accepted PR1 Real-PG CI 106563101173 executes externalSessionSingleTouch 1/1 green; isolated limiter-removal mutation makes routes.externalAccess.rateLimitOrdering RED on expected 429/admission assertions. --- .../queries/EvenfireMissingRateLimiting.ql | 8 +- .../EvenfireMissingRateLimiting.expected | 21 -- .../control-api/src/http/asyncHandler.ts | 9 - .../src/middleware/externalSessionAuth.ts | 18 -- .../src/middleware/unrelatedMiddleware.ts | 14 -- .../control-api/src/routes/external/d33.ts | 181 ------------------ 6 files changed, 4 insertions(+), 247 deletions(-) delete mode 100644 .github/codeql/queries/test/MissingRateLimiting/control-api/src/http/asyncHandler.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/unrelatedMiddleware.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimiting/control-api/src/routes/external/d33.ts diff --git a/.github/codeql/queries/EvenfireMissingRateLimiting.ql b/.github/codeql/queries/EvenfireMissingRateLimiting.ql index 342f7ed963..4eac0b83a7 100644 --- a/.github/codeql/queries/EvenfireMissingRateLimiting.ql +++ b/.github/codeql/queries/EvenfireMissingRateLimiting.ql @@ -72,6 +72,8 @@ private predicate sameSourceFile(Routing::Node left, Routing::Node right) { ) } +/* D33 recognition removed under Spec 60. */ +/* private predicate isCanonicalExternalLimiterIdentityImport(ImportSpecifier spec) { spec.getImportDeclaration().getImportedFile().getRelativePath() = "control-api/src/middleware/externalSessionAuth.ts" and @@ -99,15 +101,13 @@ private predicate hasSameRouteEvenfireLimiterAfterContext(Routing::Node useSite) ) } +*/ + private predicate hasEvenfireRateLimitingGuard(Routing::Node useSite) { exists(EvenfireRateLimitingMiddleware middleware | useSite.isGuardedByNode(middleware.getRoutingNode()) and sameSourceFile(useSite, middleware.getRoutingNode()) ) - or - isCanonicalExternalLimiterIdentityHandler(useSite) and - useSite.mayResumeDispatch() and - hasSameRouteEvenfireLimiterAfterContext(useSite) } /** diff --git a/.github/codeql/queries/test/MissingRateLimiting/EvenfireMissingRateLimiting.expected b/.github/codeql/queries/test/MissingRateLimiting/EvenfireMissingRateLimiting.expected index a788863fb3..53bc0c7cae 100644 --- a/.github/codeql/queries/test/MissingRateLimiting/EvenfireMissingRateLimiting.expected +++ b/.github/codeql/queries/test/MissingRateLimiting/EvenfireMissingRateLimiting.expected @@ -14,27 +14,6 @@ | control-api/src/routes/external/composition.ts:162:39:162:59 | nestedU ... tedWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/composition.ts:152:3:152:70 | fs.writ ... value') | a file system access | | control-api/src/routes/external/composition.ts:165:42:165:73 | require ... onToken | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:10:3:10:9 | login() | authorization | | control-api/src/routes/external/composition.ts:169:35:169:66 | require ... onToken | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:10:3:10:9 | login() | authorization | -| control-api/src/routes/external/d33.ts:31:3:31:55 | require ... cErrors | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:44:3:44:9 | login() | authorization | -| control-api/src/routes/external/d33.ts:32:3:32:44 | require ... cErrors | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:53:3:53:9 | login() | authorization | -| control-api/src/routes/external/d33.ts:33:3:33:15 | protectedWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/d33.ts:15:3:15:55 | fs.writ ... value') | a file system access | -| control-api/src/routes/external/d33.ts:40:3:40:55 | require ... cErrors | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:44:3:44:9 | login() | authorization | -| control-api/src/routes/external/d33.ts:41:3:41:15 | protectedWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/d33.ts:15:3:15:55 | fs.writ ... value') | a file system access | -| control-api/src/routes/external/d33.ts:47:3:47:55 | require ... cErrors | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:44:3:44:9 | login() | authorization | -| control-api/src/routes/external/d33.ts:48:3:48:15 | protectedWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/d33.ts:15:3:15:55 | fs.writ ... value') | a file system access | -| control-api/src/routes/external/d33.ts:59:3:59:55 | require ... cErrors | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:44:3:44:9 | login() | authorization | -| control-api/src/routes/external/d33.ts:61:54:61:66 | protectedWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/d33.ts:15:3:15:55 | fs.writ ... value') | a file system access | -| control-api/src/routes/external/d33.ts:71:3:71:20 | requireWrongStageA | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/unrelatedMiddleware.ts:12:3:12:9 | login() | authorization | -| control-api/src/routes/external/d33.ts:94:3:94:58 | asyncHa ... Errors) | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:53:3:53:9 | login() | authorization | -| control-api/src/routes/external/d33.ts:95:3:95:34 | asyncHa ... ncWork) | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/d33.ts:77:3:77:61 | fs.writ ... value') | a file system access | -| control-api/src/routes/external/d33.ts:102:3:102:55 | require ... cErrors | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:44:3:44:9 | login() | authorization | -| control-api/src/routes/external/d33.ts:103:3:103:34 | asyncHa ... ncWork) | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/d33.ts:77:3:77:61 | fs.writ ... value') | a file system access | -| control-api/src/routes/external/d33.ts:109:3:109:55 | require ... cErrors | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:44:3:44:9 | login() | authorization | -| control-api/src/routes/external/d33.ts:110:3:110:34 | asyncHa ... ncWork) | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/d33.ts:77:3:77:61 | fs.writ ... value') | a file system access | -| control-api/src/routes/external/d33.ts:121:3:121:55 | require ... cErrors | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:44:3:44:9 | login() | authorization | -| control-api/src/routes/external/d33.ts:125:3:125:34 | asyncHa ... ncWork) | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/d33.ts:77:3:77:61 | fs.writ ... value') | a file system access | -| control-api/src/routes/external/d33.ts:136:3:136:20 | requireWrongStageA | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/unrelatedMiddleware.ts:12:3:12:9 | login() | authorization | -| control-api/src/routes/external/d33.ts:144:3:144:50 | require ... cErrors | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:19:3:19:9 | login() | authorization | -| control-api/src/routes/external/d33.ts:152:3:152:64 | asyncHa ... Errors) | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:19:3:19:9 | login() | authorization | | control-api/src/routes/external/modeling.ts:39:52:39:66 | unprotectedWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/modeling.ts:14:3:14:63 | fs.writ ... value') | a file system access | | control-api/src/routes/external/modeling.ts:43:3:43:18 | orderedWrongWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/modeling.ts:19:3:19:65 | fs.writ ... value') | a file system access | | control-api/src/routes/external/modeling.ts:47:50:47:62 | unrelatedWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/modeling.ts:24:3:24:61 | fs.writ ... value') | a file system access | diff --git a/.github/codeql/queries/test/MissingRateLimiting/control-api/src/http/asyncHandler.ts b/.github/codeql/queries/test/MissingRateLimiting/control-api/src/http/asyncHandler.ts deleted file mode 100644 index 03bfc13278..0000000000 --- a/.github/codeql/queries/test/MissingRateLimiting/control-api/src/http/asyncHandler.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { NextFunction, Request, Response } from 'express' - -type AsyncHandler = (req: Request, res: Response, next: NextFunction) => Promise - -export function asyncHandler(handler: AsyncHandler) { - return (req: Request, res: Response, next: NextFunction) => { - handler(req, res, next).catch(next) - } -} diff --git a/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/externalSessionAuth.ts b/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/externalSessionAuth.ts index ef706ae052..3849e03edd 100644 --- a/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/externalSessionAuth.ts +++ b/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/externalSessionAuth.ts @@ -35,21 +35,3 @@ export function requireExternalTeamParamMatch( fs.writeFileSync('/tmp/evenfire-codeql-team-match', 'value') next() } - -export async function requireExternalSessionLimiterIdentityWithPublicErrors( - _req: unknown, - _res: unknown, - next: () => void, -) { - login() - next() -} - -export async function requireEffectiveV2ContractWithPublicErrors( - _req: unknown, - _res: unknown, - next: () => void, -) { - login() - next() -} diff --git a/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/unrelatedMiddleware.ts b/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/unrelatedMiddleware.ts deleted file mode 100644 index 12756e689d..0000000000 --- a/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/unrelatedMiddleware.ts +++ /dev/null @@ -1,14 +0,0 @@ -export function unrelatedMiddleware() { - return (_req: unknown, _res: unknown, next: () => void) => next() -} - -declare function login(): void - -export async function requireExternalSessionLimiterIdentityWithPublicErrors( - _req: unknown, - _res: unknown, - next: () => void, -) { - login() - next() -} diff --git a/.github/codeql/queries/test/MissingRateLimiting/control-api/src/routes/external/d33.ts b/.github/codeql/queries/test/MissingRateLimiting/control-api/src/routes/external/d33.ts deleted file mode 100644 index e6eb23d592..0000000000 --- a/.github/codeql/queries/test/MissingRateLimiting/control-api/src/routes/external/d33.ts +++ /dev/null @@ -1,181 +0,0 @@ -import express from 'express' -import fs from 'fs' -import { asyncHandler } from '../../http/asyncHandler' -import { - requireEffectiveV2ContractWithPublicErrors, - requireExternalSessionLimiterIdentityWithPublicErrors, - requireValidExternalSessionTokenWithPublicErrors, -} from '../../middleware/externalSessionAuth' -import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware' -import { - requireExternalSessionLimiterIdentityWithPublicErrors as requireWrongStageA, -} from '../../middleware/unrelatedMiddleware' - -function protectedWork(_req: express.Request, res: express.Response) { - fs.writeFileSync('/tmp/evenfire-codeql-d33', 'value') - res.sendStatus(204) -} - -const safeD33Router = express.Router() -safeD33Router.get( - '/safe', - requireExternalSessionLimiterIdentityWithPublicErrors, - rateLimitMiddleware({ type: 'external_access_catalog', max: 10 }), - requireEffectiveV2ContractWithPublicErrors, - protectedWork, -) - -const reversedD33Router = express.Router() -reversedD33Router.get( - '/reversed', - requireExternalSessionLimiterIdentityWithPublicErrors, - requireEffectiveV2ContractWithPublicErrors, - protectedWork, - rateLimitMiddleware({ type: 'external_access_catalog', max: 10 }), -) - -const missingLimiterRouter = express.Router() -missingLimiterRouter.get( - '/missing', - requireExternalSessionLimiterIdentityWithPublicErrors, - protectedWork, -) - -const siblingLimiterRouter = express.Router() -siblingLimiterRouter.get( - '/unprotected', - requireExternalSessionLimiterIdentityWithPublicErrors, - protectedWork, -) -siblingLimiterRouter.get( - '/protected', - rateLimitMiddleware({ type: 'external_access_catalog', max: 10 }), - protectedWork, -) - -const nonDominatingParentRouter = express.Router() -nonDominatingParentRouter.use( - '/parent', - requireExternalSessionLimiterIdentityWithPublicErrors, -) -nonDominatingParentRouter.get('/parent/unprotected', protectedWork) -nonDominatingParentRouter.get( - '/parent/limited', - rateLimitMiddleware({ type: 'external_access_catalog', max: 10 }), - protectedWork, -) - -const wrongModuleRouter = express.Router() -wrongModuleRouter.get( - '/wrong-module', - requireWrongStageA, - rateLimitMiddleware({ type: 'external_access_catalog', max: 10 }), - protectedWork, -) - -async function protectedAsyncWork(_req: express.Request, res: express.Response) { - fs.writeFileSync('/tmp/evenfire-codeql-d33-async', 'value') - res.sendStatus(204) -} - -const safeAsyncD33Router = express.Router() -safeAsyncD33Router.get( - '/safe-async', - requireExternalSessionLimiterIdentityWithPublicErrors, - rateLimitMiddleware({ type: 'external_access_catalog', max: 10 }), - asyncHandler(requireEffectiveV2ContractWithPublicErrors), - asyncHandler(protectedAsyncWork), -) - -const reversedAsyncD33Router = express.Router() -reversedAsyncD33Router.get( - '/reversed-async', - requireExternalSessionLimiterIdentityWithPublicErrors, - asyncHandler(requireEffectiveV2ContractWithPublicErrors), - asyncHandler(protectedAsyncWork), - rateLimitMiddleware({ type: 'external_access_catalog', max: 10 }), -) - -const missingAsyncLimiterRouter = express.Router() -missingAsyncLimiterRouter.get( - '/missing-async', - requireExternalSessionLimiterIdentityWithPublicErrors, - asyncHandler(protectedAsyncWork), -) - -const siblingAsyncLimiterRouter = express.Router() -siblingAsyncLimiterRouter.get( - '/unprotected-async', - requireExternalSessionLimiterIdentityWithPublicErrors, - asyncHandler(protectedAsyncWork), -) -siblingAsyncLimiterRouter.get( - '/protected-async', - rateLimitMiddleware({ type: 'external_access_catalog', max: 10 }), - asyncHandler(protectedAsyncWork), -) - -const nonDominatingAsyncParentRouter = express.Router() -nonDominatingAsyncParentRouter.use( - '/parent-async', - requireExternalSessionLimiterIdentityWithPublicErrors, -) -nonDominatingAsyncParentRouter.get( - '/parent-async/unprotected', - asyncHandler(protectedAsyncWork), -) -nonDominatingAsyncParentRouter.get( - '/parent-async/limited', - rateLimitMiddleware({ type: 'external_access_catalog', max: 10 }), - asyncHandler(protectedAsyncWork), -) - -const wrongModuleAsyncRouter = express.Router() -wrongModuleAsyncRouter.get( - '/wrong-module-async', - requireWrongStageA, - rateLimitMiddleware({ type: 'external_access_catalog', max: 10 }), - asyncHandler(protectedAsyncWork), -) - -const fullAuthLaterLimiterRouter = express.Router() -fullAuthLaterLimiterRouter.get( - '/full-auth-later-limiter', - requireValidExternalSessionTokenWithPublicErrors, - rateLimitMiddleware({ type: 'external_access_catalog', max: 10 }), - asyncHandler(protectedAsyncWork), -) - -const asyncFullAuthLaterLimiterRouter = express.Router() -asyncFullAuthLaterLimiterRouter.get( - '/async-full-auth-later-limiter', - asyncHandler(requireValidExternalSessionTokenWithPublicErrors), - rateLimitMiddleware({ type: 'external_access_catalog', max: 10 }), - asyncHandler(protectedAsyncWork), -) - -const limiterDominatesFullAuthRouter = express.Router() -limiterDominatesFullAuthRouter.get( - '/limiter-dominates-full-auth', - rateLimitMiddleware({ type: 'external_access_catalog', max: 10 }), - requireValidExternalSessionTokenWithPublicErrors, - asyncHandler(protectedAsyncWork), -) - -export { - asyncFullAuthLaterLimiterRouter, - fullAuthLaterLimiterRouter, - limiterDominatesFullAuthRouter, - missingLimiterRouter, - missingAsyncLimiterRouter, - nonDominatingAsyncParentRouter, - nonDominatingParentRouter, - reversedAsyncD33Router, - reversedD33Router, - safeAsyncD33Router, - safeD33Router, - siblingAsyncLimiterRouter, - siblingLimiterRouter, - wrongModuleAsyncRouter, - wrongModuleRouter, -} From f9175ddb09337e8c3648d4e9c873ce7ccc3c47ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 21 Sep 2026 22:30:01 -0600 Subject: [PATCH 229/528] fix(codeql): r38-h1 restore retained unrelated middleware fixture Stable-ID: R38-H1 Class-check: unrelatedMiddleware imports -> 1 retained site; calls -> 2 retained sites; D33 fake export -> 0 -> restore only the shared export. Repro-test: retained MissingRateLimiting unrelatedMiddleware import-resolution probe (fails at b407afe5e0c9831430c945547817ef62e5be8202). --- .../control-api/src/middleware/unrelatedMiddleware.ts | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/unrelatedMiddleware.ts diff --git a/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/unrelatedMiddleware.ts b/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/unrelatedMiddleware.ts new file mode 100644 index 0000000000..05e83e8ae9 --- /dev/null +++ b/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/unrelatedMiddleware.ts @@ -0,0 +1,3 @@ +export function unrelatedMiddleware() { + return (_req: unknown, _res: unknown, next: () => void) => next() +} From fa047adcd727df632dd83cb8612f5be51e6fdd2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Mon, 21 Sep 2026 23:13:50 -0600 Subject: [PATCH 230/528] fix(codeql): r40-m1 remove distributed artifact admission recognition Stable-ID: R40-M1 Class-check: artifact predicates, guard call sites, and 152 dedicated fixture files -> remove only the Spec 60 artifact recognition partition; retain core, D33, and runtime controls. Repro-test: historical R31 fake-producer false-green and later production-shape false-negative; current R30/R29 limiter-removal mutations make mounted artifact admission proof RED. --- .../queries/EvenfireMissingRateLimiting.ql | 463 ------------------ .../safe/EvenfireMissingRateLimiting.expected | 8 - .../safe/EvenfireMissingRateLimiting.qlref | 1 - .../safe/control-api/src/app.ts | 11 - .../safe/control-api/src/config.ts | 1 - .../src/middleware/internalServiceAuth.ts | 4 - .../src/middleware/rateLimitMiddleware.ts | 3 - .../src/middleware/rpcAccessAuth.ts | 16 - .../src/routes/rpc-access/index.ts | 8 - .../src/routes/rpc-access/users.ts | 72 --- .../access/rpcHostAccessAuthorizer.ts | 9 - .../safe/rpc-proxy/src/middleware/auth.ts | 6 - .../safe/rpc-proxy/src/routes/rpc.ts | 64 --- .../src/services/controlApiRestService.ts | 16 - .../src/services/fakeMcpProxyService.ts | 7 - .../rpc-proxy/src/services/mcpProxyService.ts | 5 - .../EvenfireMissingRateLimiting.expected | 13 - .../EvenfireMissingRateLimiting.qlref | 1 - .../control-api/src/app.ts | 11 - .../control-api/src/config.ts | 1 - .../src/middleware/internalServiceAuth.ts | 4 - .../src/middleware/rateLimitMiddleware.ts | 3 - .../src/middleware/rpcAccessAuth.ts | 16 - .../src/routes/rpc-access/index.ts | 8 - .../src/routes/rpc-access/users.ts | 73 --- .../access/rpcHostAccessAuthorizer.ts | 9 - .../rpc-proxy/src/middleware/auth.ts | 6 - .../rpc-proxy/src/routes/rpc.ts | 64 --- .../src/services/controlApiRestService.ts | 16 - .../src/services/fakeMcpProxyService.ts | 7 - .../rpc-proxy/src/services/mcpProxyService.ts | 5 - .../EvenfireMissingRateLimiting.expected | 13 - .../EvenfireMissingRateLimiting.qlref | 1 - .../control-api/src/app.ts | 11 - .../control-api/src/config.ts | 1 - .../src/middleware/internalServiceAuth.ts | 4 - .../src/middleware/rateLimitMiddleware.ts | 3 - .../src/middleware/rpcAccessAuth.ts | 16 - .../rpc-access/fakeRpcHostAccessAuthorizer.ts | 9 - .../src/routes/rpc-access/index.ts | 8 - .../src/routes/rpc-access/users.ts | 72 --- .../access/rpcHostAccessAuthorizer.ts | 9 - .../rpc-proxy/src/middleware/auth.ts | 6 - .../rpc-proxy/src/routes/rpc.ts | 64 --- .../src/services/controlApiRestService.ts | 16 - .../src/services/fakeMcpProxyService.ts | 7 - .../rpc-proxy/src/services/mcpProxyService.ts | 5 - .../EvenfireMissingRateLimiting.expected | 13 - .../EvenfireMissingRateLimiting.qlref | 1 - .../control-api/src/app.ts | 11 - .../control-api/src/config.ts | 1 - .../src/middleware/internalServiceAuth.ts | 4 - .../src/middleware/rateLimitMiddleware.ts | 3 - .../src/middleware/rpcAccessAuth.ts | 16 - .../fakeResolveAuthorizedHostConnection.ts | 8 - .../src/routes/rpc-access/index.ts | 8 - .../src/routes/rpc-access/users.ts | 58 --- .../access/rpcHostAccessAuthorizer.ts | 9 - .../rpc-proxy/src/middleware/auth.ts | 6 - .../rpc-proxy/src/routes/rpc.ts | 64 --- .../src/services/controlApiRestService.ts | 16 - .../src/services/fakeMcpProxyService.ts | 7 - .../rpc-proxy/src/services/mcpProxyService.ts | 5 - .../EvenfireMissingRateLimiting.expected | 14 - .../EvenfireMissingRateLimiting.qlref | 1 - .../control-api/src/app.ts | 11 - .../control-api/src/config.ts | 1 - .../src/middleware/internalServiceAuth.ts | 4 - .../src/middleware/rateLimitMiddleware.ts | 3 - .../src/middleware/rpcAccessAuth.ts | 16 - .../src/routes/rpc-access/index.ts | 8 - .../src/routes/rpc-access/users.ts | 72 --- .../access/rpcHostAccessAuthorizer.ts | 9 - .../rpc-proxy/src/middleware/auth.ts | 6 - .../rpc-proxy/src/routes/rpc.ts | 64 --- .../src/services/controlApiRestService.ts | 16 - .../src/services/fakeMcpProxyService.ts | 7 - .../rpc-proxy/src/services/mcpProxyService.ts | 5 - .../EvenfireMissingRateLimiting.expected | 13 - .../EvenfireMissingRateLimiting.qlref | 1 - .../control-api/src/app.ts | 11 - .../control-api/src/config.ts | 1 - .../src/middleware/internalServiceAuth.ts | 4 - .../src/middleware/rateLimitMiddleware.ts | 3 - .../src/middleware/rpcAccessAuth.ts | 16 - .../src/routes/rpc-access/index.ts | 8 - .../src/routes/rpc-access/users.ts | 66 --- .../access/rpcHostAccessAuthorizer.ts | 9 - .../rpc-proxy/src/middleware/auth.ts | 6 - .../rpc-proxy/src/routes/rpc.ts | 64 --- .../src/services/controlApiRestService.ts | 16 - .../src/services/fakeMcpProxyService.ts | 7 - .../rpc-proxy/src/services/mcpProxyService.ts | 5 - .../EvenfireMissingRateLimiting.expected | 13 - .../EvenfireMissingRateLimiting.qlref | 1 - .../control-api/src/app.ts | 11 - .../control-api/src/config.ts | 1 - .../src/middleware/internalServiceAuth.ts | 4 - .../src/middleware/rateLimitMiddleware.ts | 3 - .../src/middleware/rpcAccessAuth.ts | 16 - .../src/routes/rpc-access/index.ts | 8 - .../src/routes/rpc-access/users.ts | 72 --- .../access/rpcHostAccessAuthorizer.ts | 9 - .../rpc-proxy/src/middleware/auth.ts | 6 - .../rpc-proxy/src/routes/rpc.ts | 64 --- .../src/services/controlApiRestService.ts | 16 - .../src/services/fakeMcpProxyService.ts | 7 - .../rpc-proxy/src/services/mcpProxyService.ts | 5 - .../EvenfireMissingRateLimiting.expected | 13 - .../EvenfireMissingRateLimiting.qlref | 1 - .../control-api/src/app.ts | 11 - .../control-api/src/config.ts | 1 - .../src/middleware/internalServiceAuth.ts | 4 - .../src/middleware/rateLimitMiddleware.ts | 3 - .../src/middleware/rpcAccessAuth.ts | 16 - .../src/routes/rpc-access/index.ts | 8 - .../src/routes/rpc-access/users.ts | 72 --- .../access/rpcHostAccessAuthorizer.ts | 9 - .../rpc-proxy/src/middleware/auth.ts | 6 - .../rpc-proxy/src/routes/rpc.ts | 64 --- .../src/services/controlApiRestService.ts | 16 - .../src/services/fakeMcpProxyService.ts | 7 - .../rpc-proxy/src/services/mcpProxyService.ts | 5 - .../EvenfireMissingRateLimiting.expected | 13 - .../EvenfireMissingRateLimiting.qlref | 1 - .../control-api/src/app.ts | 11 - .../control-api/src/config.ts | 1 - .../src/middleware/internalServiceAuth.ts | 4 - .../src/middleware/rateLimitMiddleware.ts | 3 - .../src/middleware/rpcAccessAuth.ts | 16 - .../src/routes/rpc-access/index.ts | 8 - .../src/routes/rpc-access/users.ts | 72 --- .../access/rpcHostAccessAuthorizer.ts | 9 - .../rpc-proxy/src/middleware/auth.ts | 6 - .../rpc-proxy/src/routes/rpc.ts | 64 --- .../src/services/controlApiRestService.ts | 16 - .../src/services/fakeMcpProxyService.ts | 7 - .../rpc-proxy/src/services/mcpProxyService.ts | 5 - .../EvenfireMissingRateLimiting.expected | 13 - .../EvenfireMissingRateLimiting.qlref | 1 - .../control-api/src/app.ts | 11 - .../control-api/src/config.ts | 1 - .../src/middleware/internalServiceAuth.ts | 4 - .../src/middleware/rateLimitMiddleware.ts | 3 - .../src/middleware/rpcAccessAuth.ts | 16 - .../src/routes/rpc-access/index.ts | 8 - .../src/routes/rpc-access/users.ts | 72 --- .../access/rpcHostAccessAuthorizer.ts | 9 - .../rpc-proxy/src/middleware/auth.ts | 6 - .../rpc-proxy/src/routes/rpc.ts | 64 --- .../src/services/controlApiRestService.ts | 16 - .../src/services/fakeMcpProxyService.ts | 7 - .../rpc-proxy/src/services/mcpProxyService.ts | 5 - 153 files changed, 2817 deletions(-) delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/EvenfireMissingRateLimiting.expected delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/EvenfireMissingRateLimiting.qlref delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/app.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/config.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/middleware/internalServiceAuth.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/middleware/rateLimitMiddleware.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/middleware/rpcAccessAuth.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/routes/rpc-access/index.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/routes/rpc-access/users.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/services/access/rpcHostAccessAuthorizer.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/middleware/auth.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/routes/rpc.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/services/controlApiRestService.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/services/fakeMcpProxyService.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/services/mcpProxyService.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/EvenfireMissingRateLimiting.expected delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/EvenfireMissingRateLimiting.qlref delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/app.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/config.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/middleware/internalServiceAuth.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/middleware/rateLimitMiddleware.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/middleware/rpcAccessAuth.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/routes/rpc-access/index.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/routes/rpc-access/users.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/services/access/rpcHostAccessAuthorizer.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/middleware/auth.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/routes/rpc.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/services/controlApiRestService.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/services/fakeMcpProxyService.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/services/mcpProxyService.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/EvenfireMissingRateLimiting.expected delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/EvenfireMissingRateLimiting.qlref delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/app.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/config.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/middleware/internalServiceAuth.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/middleware/rateLimitMiddleware.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/middleware/rpcAccessAuth.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/routes/rpc-access/fakeRpcHostAccessAuthorizer.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/routes/rpc-access/index.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/routes/rpc-access/users.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/services/access/rpcHostAccessAuthorizer.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/middleware/auth.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/routes/rpc.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/services/controlApiRestService.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/services/fakeMcpProxyService.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/services/mcpProxyService.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/EvenfireMissingRateLimiting.expected delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/EvenfireMissingRateLimiting.qlref delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/app.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/config.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/middleware/internalServiceAuth.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/middleware/rateLimitMiddleware.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/middleware/rpcAccessAuth.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/routes/rpc-access/fakeResolveAuthorizedHostConnection.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/routes/rpc-access/index.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/routes/rpc-access/users.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/services/access/rpcHostAccessAuthorizer.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/middleware/auth.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/routes/rpc.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/services/controlApiRestService.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/services/fakeMcpProxyService.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/services/mcpProxyService.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/EvenfireMissingRateLimiting.expected delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/EvenfireMissingRateLimiting.qlref delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/app.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/config.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/middleware/internalServiceAuth.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/middleware/rateLimitMiddleware.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/middleware/rpcAccessAuth.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/routes/rpc-access/index.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/routes/rpc-access/users.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/services/access/rpcHostAccessAuthorizer.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/middleware/auth.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/routes/rpc.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/services/controlApiRestService.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/services/fakeMcpProxyService.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/services/mcpProxyService.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/EvenfireMissingRateLimiting.expected delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/EvenfireMissingRateLimiting.qlref delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/app.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/config.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/middleware/internalServiceAuth.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/middleware/rateLimitMiddleware.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/middleware/rpcAccessAuth.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/routes/rpc-access/index.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/routes/rpc-access/users.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/services/access/rpcHostAccessAuthorizer.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/middleware/auth.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/routes/rpc.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/services/controlApiRestService.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/services/fakeMcpProxyService.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/services/mcpProxyService.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/EvenfireMissingRateLimiting.expected delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/EvenfireMissingRateLimiting.qlref delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/app.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/config.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/middleware/internalServiceAuth.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/middleware/rateLimitMiddleware.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/middleware/rpcAccessAuth.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/routes/rpc-access/index.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/routes/rpc-access/users.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/services/access/rpcHostAccessAuthorizer.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/middleware/auth.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/routes/rpc.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/services/controlApiRestService.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/services/fakeMcpProxyService.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/services/mcpProxyService.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/EvenfireMissingRateLimiting.expected delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/EvenfireMissingRateLimiting.qlref delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/app.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/config.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/middleware/internalServiceAuth.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/middleware/rateLimitMiddleware.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/middleware/rpcAccessAuth.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/routes/rpc-access/index.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/routes/rpc-access/users.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/services/access/rpcHostAccessAuthorizer.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/middleware/auth.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/routes/rpc.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/services/controlApiRestService.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/services/fakeMcpProxyService.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/services/mcpProxyService.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/EvenfireMissingRateLimiting.expected delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/EvenfireMissingRateLimiting.qlref delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/app.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/config.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/middleware/internalServiceAuth.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/middleware/rateLimitMiddleware.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/middleware/rpcAccessAuth.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/routes/rpc-access/index.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/routes/rpc-access/users.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/services/access/rpcHostAccessAuthorizer.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/middleware/auth.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/routes/rpc.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/services/controlApiRestService.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/services/fakeMcpProxyService.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/services/mcpProxyService.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/EvenfireMissingRateLimiting.expected delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/EvenfireMissingRateLimiting.qlref delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/app.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/config.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/middleware/internalServiceAuth.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/middleware/rateLimitMiddleware.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/middleware/rpcAccessAuth.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/routes/rpc-access/index.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/routes/rpc-access/users.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/services/access/rpcHostAccessAuthorizer.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/middleware/auth.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/routes/rpc.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/services/controlApiRestService.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/services/fakeMcpProxyService.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/services/mcpProxyService.ts diff --git a/.github/codeql/queries/EvenfireMissingRateLimiting.ql b/.github/codeql/queries/EvenfireMissingRateLimiting.ql index 4eac0b83a7..bea7f8d4d9 100644 --- a/.github/codeql/queries/EvenfireMissingRateLimiting.ql +++ b/.github/codeql/queries/EvenfireMissingRateLimiting.ql @@ -24,31 +24,6 @@ private predicate isCanonicalEvenfireRateLimitImport(ImportSpecifier spec) { "control-api/src/middleware/rateLimitMiddleware.ts" } -private predicate isExactImportedValue(Expr value, string path, string importedName) { - exists(ImportSpecifier spec, VarAccess access | - spec.getImportedName() = importedName and - spec.getImportDeclaration().getImportedFile().getRelativePath() = path and - value = access and - access.getVariable() = spec.getLocal().getVariable() - ) -} - -private predicate isExactImportedCall(CallExpr call, string path, string importedName) { - exists(VarAccess callee | - call.getCallee() = callee and - isExactImportedValue(callee, path, importedName) - ) -} - -private predicate isInstalledRouteArgument( - MethodCallExpr registration, Routing::Node node, int index -) { - exists(Expr argument | - argument = registration.getArgument(index) and - node = Routing::getNode(DataFlow::valueNode(argument)) - ) -} - private class EvenfireRateLimitingMiddleware extends RateLimitingMiddleware, DataFlow::CallNode { EvenfireRateLimitingMiddleware() { exists(ImportSpecifier spec | @@ -110,446 +85,8 @@ private predicate hasEvenfireRateLimitingGuard(Routing::Node useSite) { ) } -/** - * R30 admission is distributed across the rpc-proxy artifact resolver and the - * authoritative Control API. This predicate intentionally uses exact module - * identities and route/middleware structure rather than inter-service flow. - */ -private predicate isCanonicalArtifactReadPath(Expr path) { - exists( - VariableDeclarator baseDeclaration, VarDecl baseBinding, StringLiteral basePath, - TemplateLiteral artifactPath, VarAccess baseAccess, TemplateElement suffix - | - path = artifactPath and - artifactPath.getFile().getRelativePath() = "control-api/src/routes/rpc-access/users.ts" and - baseDeclaration.getDeclStmt() instanceof ConstDeclStmt and - baseDeclaration.getBindingPattern() = baseBinding and - baseDeclaration.getInit() = basePath and - basePath.getStringValue() = "/rpc/access/users/:userId/mcp-hosts/:hostRef" and - artifactPath.getNumElement() = 2 and - artifactPath.getElement(0) = baseAccess and - baseAccess.getVariable() = baseBinding.getVariable() and - artifactPath.getElement(1) = suffix and - suffix.getRawValue() = "/artifact-read" - ) -} - -private predicate isSubjectOnlyArtifactPreAdmission(CallExpr admission) { - exists( - ObjectExpr options, Property bucketType, Property maxPerMinute, PropAccess configuredLimit, - Property keyGenerator, Function keyFunction, VarAccess requestParameter, - PropAccess rpcAuth, PropAccess subjectAccess, - VariableDeclarator subjectDeclaration, - VarDecl subjectBinding, TemplateLiteral bucketKey, TemplateElement keyPrefix - | - isExactImportedCall(admission, "control-api/src/middleware/rateLimitMiddleware.ts", - "rateLimitMiddleware") and - admission.getArgument(0) = options and - bucketType = options.getPropertyByName("bucketType") and - bucketType.getInit().getStringValue() = "host_artifact_pre_admission" and - maxPerMinute = options.getPropertyByName("maxPerMinute") and - maxPerMinute.getInit() = configuredLimit and - configuredLimit.getPropertyName() = "hostArtifactReadRlPerMin" and - isExactImportedValue(configuredLimit.getBase(), "control-api/src/config.ts", "config") and - keyGenerator = options.getPropertyByName("getBucketKey") and - keyGenerator.getInit() = keyFunction and - requestParameter.getVariable() = keyFunction.getParameter(0).getVariable() and - rpcAuth.getPropertyName() = "rpcAuth" and - rpcAuth.getBase().getAChild*() = requestParameter and - subjectDeclaration.getInit().getAChild*() = subjectAccess and - subjectAccess.getBase() = rpcAuth and - subjectAccess.getPropertyName() = "sub" and - subjectDeclaration.getDeclStmt() instanceof ConstDeclStmt and - subjectDeclaration.getBindingPattern() = subjectBinding and - subjectDeclaration.getEnclosingFunction() = keyFunction and - bucketKey.getEnclosingFunction() = keyFunction and - bucketKey.getNumElement() = 2 and - bucketKey.getElement(0) = keyPrefix and - keyPrefix.getValue() = "host-artifact-pre-admission:" and - bucketKey.getElement(1).(VarAccess).getVariable() = subjectBinding.getVariable() and - not exists(PropAccess hostSelector | - hostSelector.getEnclosingFunction() = keyFunction and - hostSelector.getPropertyName() = "hostRef" - ) - ) -} - -private predicate isCanonicalHostArtifactAdmission(CallExpr limiter) { - exists( - ObjectExpr options, Property bucketType, Property maxPerMinute, PropAccess configuredLimit, - Property keyGenerator, Function keyFunction, PropAccess canonicalHost, - PropAccess artifactConnection, PropAccess rpcAuth, PropAccess subjectAccess, - TemplateLiteral bucketKey, TemplateElement keyPrefix - | - isExactImportedCall(limiter, "control-api/src/middleware/rateLimitMiddleware.ts", - "rateLimitMiddleware") and - limiter.getArgument(0) = options and - bucketType = options.getPropertyByName("bucketType") and - bucketType.getInit().getStringValue() = "host_artifact_read" and - maxPerMinute = options.getPropertyByName("maxPerMinute") and - maxPerMinute.getInit() = configuredLimit and - configuredLimit.getPropertyName() = "hostArtifactReadRlPerMin" and - isExactImportedValue(configuredLimit.getBase(), "control-api/src/config.ts", "config") and - keyGenerator = options.getPropertyByName("getBucketKey") and - keyGenerator.getInit() = keyFunction and - rpcAuth.getEnclosingFunction() = keyFunction and - rpcAuth.getPropertyName() = "rpcAuth" and - subjectAccess.getBase() = rpcAuth and - subjectAccess.getPropertyName() = "sub" and - artifactConnection.getPropertyName() = "artifactReadConnection" and - artifactConnection.getEnclosingFunction() = keyFunction and - canonicalHost.getBase().getAChild*() = artifactConnection and - canonicalHost.getPropertyName() = "hostRef" and - bucketKey.getEnclosingFunction() = keyFunction and - bucketKey.getNumElement() = 4 and - bucketKey.getElement(0) = keyPrefix and - keyPrefix.getRawValue() = "host-artifact-read:" - ) -} - -private predicate isRpcRequestFieldLocal(Function resolver, VarDecl binding, string field) { - exists( - VariableDeclarator declaration, MethodCallExpr trimCall, CallExpr stringCall, - BinaryExpr fallback, PropAccess access, PropAccess params, StringLiteral empty - | - declaration.getEnclosingFunction() = resolver and - declaration.getBindingPattern() = binding and - declaration.getDeclStmt() instanceof ConstDeclStmt and - declaration.getInit() = trimCall and - trimCall.getMethodName() = "trim" and - trimCall.getReceiver() = stringCall and - stringCall.getCalleeName() = "String" and - stringCall.getNumArgument() = 1 and - stringCall.getArgument(0) = fallback and - fallback.getOperator() = "||" and - fallback.getRightOperand() = empty and - empty.getStringValue() = "" and - fallback.getLeftOperand() = access and - access.getPropertyName() = field and - params = access.getBase().(PropAccess) and - params.getPropertyName() = "params" and - params.getBase().(VarAccess).getVariable() = resolver.getParameter(0).getVariable() - ) -} - -private predicate isRpcAuthClaimsLocal(Function resolver, VarDecl binding) { - exists(VariableDeclarator declaration, PropAccess claims | - declaration.getEnclosingFunction() = resolver and - declaration.getBindingPattern() = binding and - declaration.getDeclStmt() instanceof ConstDeclStmt and - declaration.getInit() = claims and - claims.getPropertyName() = "rpcAuth" and - claims.getBase().(VarAccess).getVariable() = resolver.getParameter(0).getVariable() - ) -} - -private predicate isCanonicalAuthorizationDecision( - Function resolver, CallExpr authorityCall -) { - exists( - VariableDeclarator authorizationDeclaration, VarDecl authorizationBinding, - IfStmt denied, LogNotExpr deniedCondition, PropAccess authorizedFlag, - ReturnStmt deniedReturn, - ReturnStmt connectionReturn, PropAccess authorizedConnection - | - authorizationDeclaration.getEnclosingFunction() = resolver and - authorizationDeclaration.getDeclStmt() instanceof ConstDeclStmt and - authorizationDeclaration.getInit().getAChild*() = authorityCall and - authorizationDeclaration.getBindingPattern() = authorizationBinding and - denied.getCondition().getEnclosingFunction() = resolver and - denied.getCondition() = deniedCondition and - deniedCondition.getOperand() = authorizedFlag and - denied.getThen().getAChild*() = deniedReturn and - deniedReturn.getExpr() instanceof NullLiteral and - authorizedFlag.getPropertyName() = "authorized" and - authorizedFlag.getBase().(VarAccess).getVariable() = authorizationBinding.getVariable() and - connectionReturn.getExpr().getEnclosingFunction() = resolver and - authorizedConnection = connectionReturn.getExpr().(PropAccess) and - authorizedConnection.getPropertyName() = "connection" and - authorizedConnection.getBase().(VarAccess).getVariable() = authorizationBinding.getVariable() and - not exists(ReturnStmt alternateReturn | - alternateReturn.getTarget() = resolver and - not alternateReturn.getExpr() instanceof NullLiteral and - alternateReturn.getExpr() != connectionReturn.getExpr() - ) and - not exists(AssignExpr connectionMutation, PropAccess mutatedField | - connectionMutation.getEnclosingFunction() = resolver and - connectionMutation.getLhs() = mutatedField and - mutatedField.getBase() = authorizedConnection - ) - ) -} - -private predicate isCanonicalHostAuthorizationImplementation(Function resolver) { - exists( - ImportSpecifier authorityImport, CallExpr authorityCall, - VarDecl claimsBinding, VarDecl userBinding, VarDecl hostBinding - | - resolver.getFile().getRelativePath() = "control-api/src/routes/rpc-access/users.ts" and - resolver.getName() = "resolveAuthorizedHostConnection" and - authorityImport.getImportedName() = "authorizeRpcHostAccess" and - authorityImport.getImportDeclaration().getImportedFile().getRelativePath() = - "control-api/src/services/access/rpcHostAccessAuthorizer.ts" and - authorityCall.getEnclosingFunction() = resolver and - authorityCall.getCallee().(VarAccess).getVariable() = - authorityImport.getLocal().getVariable() and - authorityCall.getArgument(0).(VarAccess).getVariable() = - resolver.getParameter(2).getVariable() and - authorityCall.getArgument(4).(VarAccess).getVariable() = - resolver.getParameter(3).getVariable() and - authorityCall.getArgument(1).(VarAccess).getVariable() = claimsBinding.getVariable() and - isRpcAuthClaimsLocal(resolver, claimsBinding) and - authorityCall.getArgument(2).(VarAccess).getVariable() = userBinding.getVariable() and - isRpcRequestFieldLocal(resolver, userBinding, "userId") and - authorityCall.getArgument(3).(VarAccess).getVariable() = hostBinding.getVariable() and - isRpcRequestFieldLocal(resolver, hostBinding, "hostRef") and - isCanonicalAuthorizationDecision(resolver, authorityCall) - ) -} - -private predicate isCanonicalArtifactProducerRouter(MethodCallExpr registration) { - exists(Function usersRouter, Function rpcRouter, CallExpr usersRouterCall, - MethodCallExpr routerUse, VariableDeclarator routerDeclaration, VarDecl routerBinding, - ReturnStmt routerReturn | - usersRouter = registration.getEnclosingFunction() and - usersRouter.getName() = "createRpcAccessUsersRouter" and - usersRouter.getFile().getRelativePath() = "control-api/src/routes/rpc-access/users.ts" and - rpcRouter.getName() = "createRpcAccessRouter" and - rpcRouter.getFile().getRelativePath() = "control-api/src/routes/rpc-access/index.ts" and - usersRouterCall.getEnclosingFunction() = rpcRouter and - isExactImportedCall(usersRouterCall, - "control-api/src/routes/rpc-access/users.ts", "createRpcAccessUsersRouter") and - routerUse.getEnclosingFunction() = rpcRouter and - routerUse.getMethodName() = "use" and - routerUse.getArgument(0) = usersRouterCall and - routerDeclaration.getEnclosingFunction() = rpcRouter and - routerDeclaration.getBindingPattern() = routerBinding and - routerUse.getReceiver().(VarAccess).getVariable() = routerBinding.getVariable() and - routerReturn.getTarget() = rpcRouter and - routerReturn.getExpr().(VarAccess).getVariable() = routerBinding.getVariable() - ) -} - -private predicate isFailClosedAuthorizationHandler( - CallExpr liveAuthorization, Function handler, Function resolver -) { - exists( - VariableDeclarator connectionDeclaration, VarDecl connectionBinding, - IfStmt connectionGuard, LogNotExpr missingConnection, VarAccess guardedConnection, - ReturnStmt missingReturn, AssignExpr storedConnection, PropAccess artifactConnection, - VarAccess assignedConnection, CallExpr nextCall, TryStmt authorizationTry, - CatchClause errorHandler, CallExpr errorForwarding, VarAccess caughtError - | - liveAuthorization.getCallee().(VarAccess).getVariable() = resolver.getVariable() and - liveAuthorization.getArgument(0).(VarAccess).getVariable() = - handler.getParameter(0).getVariable() and - connectionDeclaration.getEnclosingFunction() = handler and - connectionDeclaration.getInit().getAChild*() = liveAuthorization and - connectionDeclaration.getBindingPattern() = connectionBinding and - connectionBinding.getVariable() = guardedConnection.getVariable() and - connectionGuard.getCondition().getEnclosingFunction() = handler and - connectionGuard.getCondition() = missingConnection and - missingConnection.getOperand() = guardedConnection and - connectionGuard.getThen() = missingReturn and - not exists(missingReturn.getExpr()) and - not exists(connectionGuard.getElse()) and - connectionGuard.nestedIn(authorizationTry.getBody()) and - not exists(TryStmt interveningTry | - connectionGuard.nestedIn(interveningTry.getBody()) and - interveningTry.nestedIn(authorizationTry.getBody()) - ) and - artifactConnection.getPropertyName() = "artifactReadConnection" and - artifactConnection.getBase().(VarAccess).getVariable() = handler.getParameter(0).getVariable() and - storedConnection.getEnclosingFunction() = handler and - storedConnection.getLhs() = artifactConnection and - storedConnection.getRhs() = assignedConnection and - assignedConnection.getVariable() = connectionBinding.getVariable() and - storedConnection.getEnclosingStmt().nestedIn(authorizationTry.getBody()) and - connectionGuard.getLastToken().getIndex() < - storedConnection.getEnclosingStmt().getFirstToken().getIndex() and - nextCall.getCalleeName() = "next" and - nextCall.getEnclosingFunction() = handler and - nextCall.getNumArgument() = 0 and - nextCall.getEnclosingStmt().nestedIn(authorizationTry.getBody()) and - storedConnection.getEnclosingStmt().getLastToken().getIndex() < - nextCall.getEnclosingStmt().getFirstToken().getIndex() and - errorHandler = authorizationTry.getACatchClause() and - errorHandler.getNumParameter() = 1 and - errorForwarding.getCalleeName() = "next" and - errorForwarding.getNumArgument() = 1 and - errorForwarding.getArgument(0) = caughtError and - caughtError.(VarAccess).getVariable() = errorHandler.getAParameter().getVariable() and - errorForwarding.getEnclosingFunction() = handler and - errorForwarding.getEnclosingStmt().nestedIn(errorHandler.getBody()) and - not exists(CallExpr otherNext | - otherNext.getCalleeName() = "next" and - otherNext.getEnclosingFunction() = handler and - otherNext != nextCall and - otherNext != errorForwarding - ) - ) -} - -private predicate isCanonicalArtifactProducer(MethodCallExpr registration) { - exists( - CallExpr tokenCheck, ArrayExpr scopes, StringLiteral scope, - CallExpr userMatch, CallExpr hostMatch, CallExpr preAdmission, Function authorizationHandler, - CallExpr liveAuthorization, Function resolver, CallExpr hostAdmission - | - registration.getMethodName() = "get" and - registration.getFile().getRelativePath() = "control-api/src/routes/rpc-access/users.ts" and - isCanonicalArtifactProducerRouter(registration) and - isCanonicalArtifactReadPath(registration.getArgument(0)) and - tokenCheck = registration.getArgument(1) and - isExactImportedCall(tokenCheck, "control-api/src/middleware/rpcAccessAuth.ts", - "requireValidRpcAccessTokenAny") and - tokenCheck.getArgument(0) = scopes and - scopes.getSize() = 1 and - scopes.getElement(0) = scope and - scope.getStringValue() = "host:task:read" and - userMatch = registration.getArgument(2) and - isExactImportedCall(userMatch, "control-api/src/middleware/rpcAccessAuth.ts", - "requireRpcTokenUserMatch") and - hostMatch = registration.getArgument(3) and - isExactImportedCall(hostMatch, "control-api/src/middleware/rpcAccessAuth.ts", - "requireRpcTokenHostMatch") and - preAdmission = registration.getArgument(4) and - isSubjectOnlyArtifactPreAdmission(preAdmission) and - authorizationHandler = registration.getArgument(5) and - liveAuthorization.getEnclosingFunction() = authorizationHandler and - resolver.getFile().getRelativePath() = "control-api/src/routes/rpc-access/users.ts" and - resolver.getName() = "resolveAuthorizedHostConnection" and - liveAuthorization.getCallee().(VarAccess).getVariable() = resolver.getVariable() and - isCanonicalHostAuthorizationImplementation(resolver) and - isFailClosedAuthorizationHandler(liveAuthorization, authorizationHandler, resolver) and - hostAdmission = registration.getArgument(6) and - isCanonicalHostArtifactAdmission(hostAdmission) - ) -} - -private predicate isCanonicalArtifactProducerGuard(Routing::Node useSite) { - exists(MethodCallExpr registration | - isCanonicalArtifactProducer(registration) and - isInstalledRouteArgument(registration, useSite, 1) - ) -} - -private predicate isCanonicalArtifactTransport() { - exists( - Function resolver, CallExpr clientCall, Function client, CallExpr fetchCall, - ObjectExpr options, Property artifactRead, Function pathFetcher, - ConditionalExpr endpointChoice, PropAccess artifactReadFlag, TemplateLiteral artifactEndpoint, - TemplateElement artifactSuffix - | - resolver.getFile().getRelativePath() = "rpc-proxy/src/services/mcpProxyService.ts" and - resolver.getName() = "resolveArtifactReadHostConnectionForUser" and - clientCall.getEnclosingFunction() = resolver and - isExactImportedCall(clientCall, "rpc-proxy/src/services/controlApiRestService.ts", - "fetchArtifactReadHostConnectionFromControlApi") and - client.getFile().getRelativePath() = "rpc-proxy/src/services/controlApiRestService.ts" and - client.getName() = "fetchArtifactReadHostConnectionFromControlApi" and - fetchCall.getEnclosingFunction() = client and - fetchCall.getCalleeName() = "fetchHostConnectionForPath" and - fetchCall.getArgument(3) = options and - artifactRead = options.getPropertyByName("artifactRead") and - artifactRead.getInit().(BooleanLiteral).getBoolValue() = true and - pathFetcher.getFile().getRelativePath() = "rpc-proxy/src/services/controlApiRestService.ts" and - pathFetcher.getName() = "fetchHostConnectionForPath" and - endpointChoice.getEnclosingFunction() = pathFetcher and - artifactReadFlag.getPropertyName() = "artifactRead" and - endpointChoice.getCondition().getAChild*() = artifactReadFlag and - endpointChoice.getConsequent() = artifactEndpoint and - artifactEndpoint.getNumElement() = 2 and - artifactEndpoint.getElement(1) = artifactSuffix and - artifactSuffix.getRawValue() = "/artifact-read" - ) -} - -private predicate hasCanonicalArtifactServiceBoundary() { - exists( - Function createApp, MethodCallExpr internalTokenMount, MethodCallExpr internalServiceMount, - MethodCallExpr routerMount, CallExpr internalService, StringLiteral rpcProxyName - | - createApp.getFile().getRelativePath() = "control-api/src/app.ts" and - createApp.getName() = "createApp" and - internalTokenMount.getEnclosingFunction() = createApp and - internalTokenMount.getMethodName() = "use" and - isExactImportedValue(internalTokenMount.getArgument(0), - "control-api/src/middleware/internalServiceAuth.ts", "requireInternalToken") and - internalServiceMount.getEnclosingFunction() = createApp and - internalServiceMount.getMethodName() = "use" and - internalServiceMount.getArgument(0).getStringValue() = "/rpc" and - internalService = internalServiceMount.getArgument(1) and - isExactImportedCall(internalService, "control-api/src/middleware/internalServiceAuth.ts", - "requireInternalService") and - internalService.getArgument(0) = rpcProxyName and - rpcProxyName.getStringValue() = "rpc-proxy" and - routerMount.getEnclosingFunction() = createApp and - routerMount.getMethodName() = "use" and - isExactImportedCall(routerMount.getArgument(0).(CallExpr), - "control-api/src/routes/rpc-access/index.ts", "createRpcAccessRouter") - ) -} - -private predicate isCanonicalArtifactResolver(Expr resolverValue) { - exists( - VariableDeclarator declaration, VarDecl binding, Function resolver, VarAccess resolverAccess, - CallExpr clientCall - | - declaration.getDeclStmt() instanceof ConstDeclStmt and - declaration.getBindingPattern() = binding and - binding.getVariable().getName() = "resolveArtifactReadHost" and - declaration.getInit() = resolver and - resolverValue = resolverAccess and - resolverAccess.getVariable() = binding.getVariable() and - resolver.getFile().getRelativePath() = "rpc-proxy/src/routes/rpc.ts" and - clientCall.getEnclosingFunction() = resolver and - isExactImportedCall(clientCall, "rpc-proxy/src/services/mcpProxyService.ts", - "resolveArtifactReadHostConnectionForUser") and - not exists(ImportSpecifier alternateImport, CallExpr alternateCall | - alternateImport.getImportDeclaration().getImportedFile().getRelativePath() = - "rpc-proxy/src/services/mcpProxyService.ts" and - alternateImport.getImportedName() != "resolveArtifactReadHostConnectionForUser" and - alternateCall.getEnclosingFunction() = resolver and - alternateCall.getCallee().(VarAccess).getVariable() = alternateImport.getLocal().getVariable() - ) and - not exists(CallExpr directFallback | - directFallback.getEnclosingFunction() = resolver and - directFallback.getCalleeName() = "fetch" - ) - ) -} - -private predicate isCanonicalArtifactProxyGuard(Routing::Node useSite) { - exists( - MethodCallExpr registration, StringLiteral path, CallExpr scope, Expr resolver, - int index - | - registration.getMethodName() = "get" and - registration.getFile().getRelativePath() = "rpc-proxy/src/routes/rpc.ts" and - registration.getArgument(0) = path and - path.getStringValue() in [ - "/rpc/hosts/:hostRef/artifacts", - "/rpc/hosts/:hostRef/artifacts/:filename/download" - ] and - isExactImportedValue(registration.getArgument(1), "rpc-proxy/src/middleware/auth.ts", - "requireRpcAuth") and - scope = registration.getArgument(2) and - isExactImportedCall(scope, "rpc-proxy/src/middleware/auth.ts", "requireScope") and - scope.getArgument(0).getStringValue() = "host:task:read" and - resolver = registration.getArgument(3) and - isCanonicalArtifactResolver(resolver) and - isCanonicalArtifactTransport() and - hasCanonicalArtifactServiceBoundary() and - exists(MethodCallExpr producer | isCanonicalArtifactProducer(producer)) and - (index = 1 or index = 3) and - isInstalledRouteArgument(registration, useSite, index) - ) -} private predicate hasRateLimitingGuard(Routing::Node useSite) { - isCanonicalArtifactProxyGuard(useSite) or - isCanonicalArtifactProducerGuard(useSite) or exists(RateLimitingMiddleware middleware | useSite.isGuardedByNode(middleware.getRoutingNode()) and not middleware instanceof EvenfireRateLimitingMiddleware diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/EvenfireMissingRateLimiting.expected b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/EvenfireMissingRateLimiting.expected deleted file mode 100644 index 99af8020e3..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/EvenfireMissingRateLimiting.expected +++ /dev/null @@ -1,8 +0,0 @@ -| rpc-proxy/src/routes/rpc.ts:47:47:47:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:47:95:47:117 | fakeRes ... actHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:17:93:17:113 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:51:66:51:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:51:114:51:134 | wrongEn ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:23:63:23:83 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:55:47:55:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:30:95:30:115 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:32:65:32:85 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:59:66:59:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/EvenfireMissingRateLimiting.qlref b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/EvenfireMissingRateLimiting.qlref deleted file mode 100644 index 7d3c25d5ca..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/EvenfireMissingRateLimiting.qlref +++ /dev/null @@ -1 +0,0 @@ -query: EvenfireMissingRateLimiting.ql diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/app.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/app.ts deleted file mode 100644 index 4ece8564ed..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/app.ts +++ /dev/null @@ -1,11 +0,0 @@ -import express from 'express' -import { requireInternalService, requireInternalToken } from './middleware/internalServiceAuth.js' -import { createRpcAccessRouter } from './routes/rpc-access/index.js' - -export function createApp() { - const api = express.Router() - api.use(requireInternalToken) - api.use('/rpc', requireInternalService('rpc-proxy')) - api.use(createRpcAccessRouter()) - return api -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/config.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/config.ts deleted file mode 100644 index c8274c450a..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/config.ts +++ /dev/null @@ -1 +0,0 @@ -export const config = { hostArtifactReadRlPerMin: 30 } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/middleware/internalServiceAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/middleware/internalServiceAuth.ts deleted file mode 100644 index 7224c950a0..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/middleware/internalServiceAuth.ts +++ /dev/null @@ -1,4 +0,0 @@ -export function requireInternalToken(_req: unknown, _res: unknown, next: () => void) { next() } -export function requireInternalService(_name: string) { - return (_req: unknown, _res: unknown, next: () => void) => next() -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/middleware/rateLimitMiddleware.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/middleware/rateLimitMiddleware.ts deleted file mode 100644 index 32f6b8ea64..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/middleware/rateLimitMiddleware.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function rateLimitMiddleware(_options: unknown) { - return (_req: unknown, _res: unknown, next: () => void) => next() -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/middleware/rpcAccessAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/middleware/rpcAccessAuth.ts deleted file mode 100644 index b498d6f7a8..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/middleware/rpcAccessAuth.ts +++ /dev/null @@ -1,16 +0,0 @@ -declare function verifyToken(): void - -export function requireValidRpcAccessTokenAny(_scopes: string[]) { - return (_req: unknown, _res: unknown, next: () => void) => { - verifyToken() - next() - } -} - -export function requireRpcTokenUserMatch() { - return (_req: unknown, _res: unknown, next: () => void) => next() -} - -export function requireRpcTokenHostMatch() { - return (_req: unknown, _res: unknown, next: () => void) => next() -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/routes/rpc-access/index.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/routes/rpc-access/index.ts deleted file mode 100644 index 6db4f809c7..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/routes/rpc-access/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createRpcAccessUsersRouter } from './users.js' -import { Router } from 'express' - -export function createRpcAccessRouter(gateway: unknown, options: { directory?: unknown }) { - const router = Router() - router.use(createRpcAccessUsersRouter(gateway, options)) - return router -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/routes/rpc-access/users.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/routes/rpc-access/users.ts deleted file mode 100644 index dff7eefab1..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/routes/rpc-access/users.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Router } from 'express' -import { config } from '../../config.js' -import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' -import { - requireRpcTokenHostMatch, - requireRpcTokenUserMatch, - requireValidRpcAccessTokenAny, -} from '../../middleware/rpcAccessAuth.js' -import { authorizeRpcHostAccess } from '../../services/access/rpcHostAccessAuthorizer.js' - -type ArtifactRequest = { - params: { userId: string; hostRef: string } - rpcAuth?: { sub?: string } - artifactReadConnection?: { hostRef: string } -} - -async function resolveAuthorizedHostConnection( - req: ArtifactRequest, - _res: unknown, - gateway: unknown, - directory: unknown, -): Promise<{ hostRef: string } | null> { - const userId = String(req.params.userId || '').trim() - const hostRef = String(req.params.hostRef || '').trim() - const claims = req.rpcAuth - if (!claims) return null - const authorization = await authorizeRpcHostAccess(gateway, claims, userId, hostRef, directory) - if (!authorization.authorized) return null - return authorization.connection -} - -export function createRpcAccessUsersRouter(gateway: unknown, options: { directory?: unknown }) { - const router = Router() - const { directory } = options - const hostAccessPath = '/rpc/access/users/:userId/mcp-hosts/:hostRef' - router.get( - `${hostAccessPath}/artifact-read`, - requireValidRpcAccessTokenAny(['host:task:read']), - requireRpcTokenUserMatch(), - requireRpcTokenHostMatch(), - rateLimitMiddleware({ - bucketType: 'host_artifact_pre_admission', - maxPerMinute: config.hostArtifactReadRlPerMin, - getBucketKey: req => { - const subject = (req as ArtifactRequest).rpcAuth?.sub - return subject ? `host-artifact-pre-admission:${subject}` : null - }, - }), - async (req: ArtifactRequest, res, next) => { - try { - const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) - if (!connection) return - req.artifactReadConnection = connection - next() - } catch (error) { - next(error) - } - }, - rateLimitMiddleware({ - bucketType: 'host_artifact_read', - maxPerMinute: config.hostArtifactReadRlPerMin, - getBucketKey: req => { - const artifactRead = req as ArtifactRequest - const subject = artifactRead.rpcAuth?.sub - const hostRef = artifactRead.artifactReadConnection?.hostRef - return subject && hostRef ? `host-artifact-read:${subject}:${hostRef}` : null - }, - }), - (_req, res) => res.status(200).json({ ok: true }), - ) - return router -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/services/access/rpcHostAccessAuthorizer.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/services/access/rpcHostAccessAuthorizer.ts deleted file mode 100644 index 22957e5917..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/control-api/src/services/access/rpcHostAccessAuthorizer.ts +++ /dev/null @@ -1,9 +0,0 @@ -export async function authorizeRpcHostAccess( - _gateway: unknown, - _claims: unknown, - _userId: string, - _hostRef: string, - _directory: unknown, -): Promise<{ authorized: boolean; connection: { hostRef: string } }> { - return { authorized: true, connection: { hostRef: 'canonical-host' } } -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/middleware/auth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/middleware/auth.ts deleted file mode 100644 index 01476c7883..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/middleware/auth.ts +++ /dev/null @@ -1,6 +0,0 @@ -declare function verifyRpcToken(): void -export function requireRpcAuth(_req: unknown, _res: unknown, next: () => void) { verifyRpcToken(); next() } -export function requireScope(_scope: string) { - return (_req: unknown, _res: unknown, next: () => void) => next() -} -export function extractAuthToken(_req: unknown): string { return 'token' } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/routes/rpc.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/routes/rpc.ts deleted file mode 100644 index f3963fdbb6..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/routes/rpc.ts +++ /dev/null @@ -1,64 +0,0 @@ -import express from 'express' -import { extractAuthToken, requireRpcAuth, requireScope } from '../middleware/auth.js' -import { resolveArtifactReadHostConnectionForUser } from '../services/mcpProxyService.js' -import { resolveArtifactReadHostConnectionForUser as fakeClient } from '../services/fakeMcpProxyService.js' - -type ArtifactRequest = express.Request & { auth?: { sub: string }; artifactReadHost?: { url: string } } - -const resolveArtifactReadHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - const hostRef = String(req.params.hostRef || '').trim() - req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, hostRef, extractAuthToken(req)) - next() -} - -const fakeResolveArtifactHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) - next() -} - -const wrongEndpointResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) - next() -} - -const fallbackArtifactResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - try { - req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) - } catch { - req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) - } - next() -} - -export function createRpcRouter() { - const router = express.Router() - router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts/download') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fakeResolveArtifactHost, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), wrongEndpointResolver, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts/download') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fallbackArtifactResolver, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts/download') - res.sendStatus(200) - }) - return router -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/services/controlApiRestService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/services/controlApiRestService.ts deleted file mode 100644 index 8d4a12b6fd..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/services/controlApiRestService.ts +++ /dev/null @@ -1,16 +0,0 @@ -type Options = { artifactRead?: boolean } - -async function fetchHostConnectionForPath( - userId: string, - hostRef: string, - token: string, - options: Options, -): Promise<{ url: string } | null> { - const base = `/rpc/access/users/${userId}/mcp-hosts/${hostRef}` - const endpoint = options.artifactRead ? `${base}/artifact-read` : base - return fetch(endpoint, { headers: { authorization: `Bearer ${token}` } }) as never -} - -export async function fetchArtifactReadHostConnectionFromControlApi(userId: string, hostRef: string, token: string) { - return fetchHostConnectionForPath(userId, hostRef, token, { artifactRead: true }) -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/services/fakeMcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/services/fakeMcpProxyService.ts deleted file mode 100644 index c03af01f0a..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/services/fakeMcpProxyService.ts +++ /dev/null @@ -1,7 +0,0 @@ -export async function resolveArtifactReadHostConnectionForUser( - _userId: string, - _hostRef: string, - _token: string, -) { - return { url: 'https://unprotected.example' } -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/services/mcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/services/mcpProxyService.ts deleted file mode 100644 index e314b09f56..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/safe/rpc-proxy/src/services/mcpProxyService.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { fetchArtifactReadHostConnectionFromControlApi } from './controlApiRestService.js' - -export async function resolveArtifactReadHostConnectionForUser(userId: string, hostRef: string, token: string) { - return fetchArtifactReadHostConnectionFromControlApi(userId, hostRef, token) -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/EvenfireMissingRateLimiting.expected b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/EvenfireMissingRateLimiting.expected deleted file mode 100644 index 8a73eee2af..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/EvenfireMissingRateLimiting.expected +++ /dev/null @@ -1,13 +0,0 @@ -| control-api/src/routes/rpc-access/users.ts:38:5:38:53 | require ... read']) | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/rpcAccessAuth.ts:5:5:5:17 | verifyToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:39:47:39:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:39:95:39:117 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:43:66:43:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:43:114:43:136 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:47:47:47:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:47:95:47:117 | fakeRes ... actHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:17:93:17:113 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:51:66:51:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:51:114:51:134 | wrongEn ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:23:63:23:83 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:55:47:55:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:30:95:30:115 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:32:65:32:85 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:59:66:59:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/EvenfireMissingRateLimiting.qlref b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/EvenfireMissingRateLimiting.qlref deleted file mode 100644 index 7d3c25d5ca..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/EvenfireMissingRateLimiting.qlref +++ /dev/null @@ -1 +0,0 @@ -query: EvenfireMissingRateLimiting.ql diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/app.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/app.ts deleted file mode 100644 index 4ece8564ed..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/app.ts +++ /dev/null @@ -1,11 +0,0 @@ -import express from 'express' -import { requireInternalService, requireInternalToken } from './middleware/internalServiceAuth.js' -import { createRpcAccessRouter } from './routes/rpc-access/index.js' - -export function createApp() { - const api = express.Router() - api.use(requireInternalToken) - api.use('/rpc', requireInternalService('rpc-proxy')) - api.use(createRpcAccessRouter()) - return api -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/config.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/config.ts deleted file mode 100644 index c8274c450a..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/config.ts +++ /dev/null @@ -1 +0,0 @@ -export const config = { hostArtifactReadRlPerMin: 30 } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/middleware/internalServiceAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/middleware/internalServiceAuth.ts deleted file mode 100644 index 7224c950a0..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/middleware/internalServiceAuth.ts +++ /dev/null @@ -1,4 +0,0 @@ -export function requireInternalToken(_req: unknown, _res: unknown, next: () => void) { next() } -export function requireInternalService(_name: string) { - return (_req: unknown, _res: unknown, next: () => void) => next() -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/middleware/rateLimitMiddleware.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/middleware/rateLimitMiddleware.ts deleted file mode 100644 index 32f6b8ea64..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/middleware/rateLimitMiddleware.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function rateLimitMiddleware(_options: unknown) { - return (_req: unknown, _res: unknown, next: () => void) => next() -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/middleware/rpcAccessAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/middleware/rpcAccessAuth.ts deleted file mode 100644 index b498d6f7a8..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/middleware/rpcAccessAuth.ts +++ /dev/null @@ -1,16 +0,0 @@ -declare function verifyToken(): void - -export function requireValidRpcAccessTokenAny(_scopes: string[]) { - return (_req: unknown, _res: unknown, next: () => void) => { - verifyToken() - next() - } -} - -export function requireRpcTokenUserMatch() { - return (_req: unknown, _res: unknown, next: () => void) => next() -} - -export function requireRpcTokenHostMatch() { - return (_req: unknown, _res: unknown, next: () => void) => next() -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/routes/rpc-access/index.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/routes/rpc-access/index.ts deleted file mode 100644 index 6db4f809c7..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/routes/rpc-access/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createRpcAccessUsersRouter } from './users.js' -import { Router } from 'express' - -export function createRpcAccessRouter(gateway: unknown, options: { directory?: unknown }) { - const router = Router() - router.use(createRpcAccessUsersRouter(gateway, options)) - return router -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/routes/rpc-access/users.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/routes/rpc-access/users.ts deleted file mode 100644 index fbf16dbef2..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/routes/rpc-access/users.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Router } from 'express' -import { config } from '../../config.js' -import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' -import { - requireRpcTokenHostMatch, - requireRpcTokenUserMatch, - requireValidRpcAccessTokenAny, -} from '../../middleware/rpcAccessAuth.js' -import { authorizeRpcHostAccess } from '../../services/access/rpcHostAccessAuthorizer.js' - -type ArtifactRequest = { - params: { userId: string; hostRef: string } - rpcAuth?: { sub?: string } - artifactReadConnection?: { hostRef: string } -} - -async function resolveAuthorizedHostConnection( - req: ArtifactRequest, - _res: unknown, - gateway: unknown, - directory: unknown, -): Promise<{ hostRef: string } | null> { - const userId = String(req.params.userId || '').trim() - const hostRef = String(req.params.hostRef || '').trim() - const claims = req.rpcAuth - if (!claims) return null - const authorization = await authorizeRpcHostAccess(gateway, claims, userId, hostRef, directory) - if (!authorization.authorized) return null - return authorization.connection -} - -export function createRpcAccessUsersRouter(gateway: unknown, options: { directory?: unknown }) { - const router = Router() - const { directory } = options - const hostAccessPath = '/rpc/access/users/:userId/mcp-hosts/:hostRef' - router.get( - `${hostAccessPath}/artifact-read`, - requireValidRpcAccessTokenAny(['host:task:read']), - requireRpcTokenUserMatch(), - requireRpcTokenHostMatch(), - rateLimitMiddleware({ - bucketType: 'host_artifact_pre_admission', - maxPerMinute: config.hostArtifactReadRlPerMin, - getBucketKey: req => { - const subject = (req as ArtifactRequest).rpcAuth?.sub - const hostRef = (req as ArtifactRequest).params.hostRef - return subject ? `host-artifact-pre-admission:${subject}:${hostRef}` : null - }, - }), - async (req: ArtifactRequest, res, next) => { - try { - const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) - if (!connection) return - req.artifactReadConnection = connection - next() - } catch (error) { - next(error) - } - }, - rateLimitMiddleware({ - bucketType: 'host_artifact_read', - maxPerMinute: config.hostArtifactReadRlPerMin, - getBucketKey: req => { - const artifactRead = req as ArtifactRequest - const subject = artifactRead.rpcAuth?.sub - const hostRef = artifactRead.artifactReadConnection?.hostRef - return subject && hostRef ? `host-artifact-read:${subject}:${hostRef}` : null - }, - }), - (_req, res) => res.status(200).json({ ok: true }), - ) - return router -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/services/access/rpcHostAccessAuthorizer.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/services/access/rpcHostAccessAuthorizer.ts deleted file mode 100644 index 22957e5917..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/control-api/src/services/access/rpcHostAccessAuthorizer.ts +++ /dev/null @@ -1,9 +0,0 @@ -export async function authorizeRpcHostAccess( - _gateway: unknown, - _claims: unknown, - _userId: string, - _hostRef: string, - _directory: unknown, -): Promise<{ authorized: boolean; connection: { hostRef: string } }> { - return { authorized: true, connection: { hostRef: 'canonical-host' } } -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/middleware/auth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/middleware/auth.ts deleted file mode 100644 index 01476c7883..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/middleware/auth.ts +++ /dev/null @@ -1,6 +0,0 @@ -declare function verifyRpcToken(): void -export function requireRpcAuth(_req: unknown, _res: unknown, next: () => void) { verifyRpcToken(); next() } -export function requireScope(_scope: string) { - return (_req: unknown, _res: unknown, next: () => void) => next() -} -export function extractAuthToken(_req: unknown): string { return 'token' } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/routes/rpc.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/routes/rpc.ts deleted file mode 100644 index f3963fdbb6..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/routes/rpc.ts +++ /dev/null @@ -1,64 +0,0 @@ -import express from 'express' -import { extractAuthToken, requireRpcAuth, requireScope } from '../middleware/auth.js' -import { resolveArtifactReadHostConnectionForUser } from '../services/mcpProxyService.js' -import { resolveArtifactReadHostConnectionForUser as fakeClient } from '../services/fakeMcpProxyService.js' - -type ArtifactRequest = express.Request & { auth?: { sub: string }; artifactReadHost?: { url: string } } - -const resolveArtifactReadHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - const hostRef = String(req.params.hostRef || '').trim() - req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, hostRef, extractAuthToken(req)) - next() -} - -const fakeResolveArtifactHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) - next() -} - -const wrongEndpointResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) - next() -} - -const fallbackArtifactResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - try { - req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) - } catch { - req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) - } - next() -} - -export function createRpcRouter() { - const router = express.Router() - router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts/download') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fakeResolveArtifactHost, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), wrongEndpointResolver, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts/download') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fallbackArtifactResolver, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts/download') - res.sendStatus(200) - }) - return router -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/services/controlApiRestService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/services/controlApiRestService.ts deleted file mode 100644 index 8d4a12b6fd..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/services/controlApiRestService.ts +++ /dev/null @@ -1,16 +0,0 @@ -type Options = { artifactRead?: boolean } - -async function fetchHostConnectionForPath( - userId: string, - hostRef: string, - token: string, - options: Options, -): Promise<{ url: string } | null> { - const base = `/rpc/access/users/${userId}/mcp-hosts/${hostRef}` - const endpoint = options.artifactRead ? `${base}/artifact-read` : base - return fetch(endpoint, { headers: { authorization: `Bearer ${token}` } }) as never -} - -export async function fetchArtifactReadHostConnectionFromControlApi(userId: string, hostRef: string, token: string) { - return fetchHostConnectionForPath(userId, hostRef, token, { artifactRead: true }) -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/services/fakeMcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/services/fakeMcpProxyService.ts deleted file mode 100644 index c03af01f0a..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/services/fakeMcpProxyService.ts +++ /dev/null @@ -1,7 +0,0 @@ -export async function resolveArtifactReadHostConnectionForUser( - _userId: string, - _hostRef: string, - _token: string, -) { - return { url: 'https://unprotected.example' } -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/services/mcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/services/mcpProxyService.ts deleted file mode 100644 index e314b09f56..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-host-key-pre-admission/rpc-proxy/src/services/mcpProxyService.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { fetchArtifactReadHostConnectionFromControlApi } from './controlApiRestService.js' - -export async function resolveArtifactReadHostConnectionForUser(userId: string, hostRef: string, token: string) { - return fetchArtifactReadHostConnectionFromControlApi(userId, hostRef, token) -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/EvenfireMissingRateLimiting.expected b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/EvenfireMissingRateLimiting.expected deleted file mode 100644 index 8a73eee2af..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/EvenfireMissingRateLimiting.expected +++ /dev/null @@ -1,13 +0,0 @@ -| control-api/src/routes/rpc-access/users.ts:38:5:38:53 | require ... read']) | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/rpcAccessAuth.ts:5:5:5:17 | verifyToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:39:47:39:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:39:95:39:117 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:43:66:43:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:43:114:43:136 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:47:47:47:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:47:95:47:117 | fakeRes ... actHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:17:93:17:113 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:51:66:51:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:51:114:51:134 | wrongEn ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:23:63:23:83 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:55:47:55:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:30:95:30:115 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:32:65:32:85 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:59:66:59:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/EvenfireMissingRateLimiting.qlref b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/EvenfireMissingRateLimiting.qlref deleted file mode 100644 index 7d3c25d5ca..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/EvenfireMissingRateLimiting.qlref +++ /dev/null @@ -1 +0,0 @@ -query: EvenfireMissingRateLimiting.ql diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/app.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/app.ts deleted file mode 100644 index 4ece8564ed..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/app.ts +++ /dev/null @@ -1,11 +0,0 @@ -import express from 'express' -import { requireInternalService, requireInternalToken } from './middleware/internalServiceAuth.js' -import { createRpcAccessRouter } from './routes/rpc-access/index.js' - -export function createApp() { - const api = express.Router() - api.use(requireInternalToken) - api.use('/rpc', requireInternalService('rpc-proxy')) - api.use(createRpcAccessRouter()) - return api -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/config.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/config.ts deleted file mode 100644 index c8274c450a..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/config.ts +++ /dev/null @@ -1 +0,0 @@ -export const config = { hostArtifactReadRlPerMin: 30 } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/middleware/internalServiceAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/middleware/internalServiceAuth.ts deleted file mode 100644 index 7224c950a0..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/middleware/internalServiceAuth.ts +++ /dev/null @@ -1,4 +0,0 @@ -export function requireInternalToken(_req: unknown, _res: unknown, next: () => void) { next() } -export function requireInternalService(_name: string) { - return (_req: unknown, _res: unknown, next: () => void) => next() -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/middleware/rateLimitMiddleware.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/middleware/rateLimitMiddleware.ts deleted file mode 100644 index 32f6b8ea64..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/middleware/rateLimitMiddleware.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function rateLimitMiddleware(_options: unknown) { - return (_req: unknown, _res: unknown, next: () => void) => next() -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/middleware/rpcAccessAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/middleware/rpcAccessAuth.ts deleted file mode 100644 index b498d6f7a8..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/middleware/rpcAccessAuth.ts +++ /dev/null @@ -1,16 +0,0 @@ -declare function verifyToken(): void - -export function requireValidRpcAccessTokenAny(_scopes: string[]) { - return (_req: unknown, _res: unknown, next: () => void) => { - verifyToken() - next() - } -} - -export function requireRpcTokenUserMatch() { - return (_req: unknown, _res: unknown, next: () => void) => next() -} - -export function requireRpcTokenHostMatch() { - return (_req: unknown, _res: unknown, next: () => void) => next() -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/routes/rpc-access/fakeRpcHostAccessAuthorizer.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/routes/rpc-access/fakeRpcHostAccessAuthorizer.ts deleted file mode 100644 index 4f115b2101..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/routes/rpc-access/fakeRpcHostAccessAuthorizer.ts +++ /dev/null @@ -1,9 +0,0 @@ -export async function authorizeRpcHostAccess( - _gateway: unknown, - _claims: unknown, - _userId: string, - hostRef: string, - _directory: unknown, -) { - return { authorized: true, connection: { hostRef } } -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/routes/rpc-access/index.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/routes/rpc-access/index.ts deleted file mode 100644 index 6db4f809c7..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/routes/rpc-access/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createRpcAccessUsersRouter } from './users.js' -import { Router } from 'express' - -export function createRpcAccessRouter(gateway: unknown, options: { directory?: unknown }) { - const router = Router() - router.use(createRpcAccessUsersRouter(gateway, options)) - return router -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/routes/rpc-access/users.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/routes/rpc-access/users.ts deleted file mode 100644 index 08ee4f1883..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/routes/rpc-access/users.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Router } from 'express' -import { config } from '../../config.js' -import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' -import { - requireRpcTokenHostMatch, - requireRpcTokenUserMatch, - requireValidRpcAccessTokenAny, -} from '../../middleware/rpcAccessAuth.js' -import { authorizeRpcHostAccess } from './fakeRpcHostAccessAuthorizer.js' - -type ArtifactRequest = { - params: { userId: string; hostRef: string } - rpcAuth?: { sub?: string } - artifactReadConnection?: { hostRef: string } -} - -async function resolveAuthorizedHostConnection( - req: ArtifactRequest, - _res: unknown, - gateway: unknown, - directory: unknown, -): Promise<{ hostRef: string } | null> { - const userId = String(req.params.userId || '').trim() - const hostRef = String(req.params.hostRef || '').trim() - const claims = req.rpcAuth - if (!claims) return null - const authorization = await authorizeRpcHostAccess(gateway, claims, userId, hostRef, directory) - if (!authorization.authorized) return null - return authorization.connection -} - -export function createRpcAccessUsersRouter(gateway: unknown, options: { directory?: unknown }) { - const router = Router() - const { directory } = options - const hostAccessPath = '/rpc/access/users/:userId/mcp-hosts/:hostRef' - router.get( - `${hostAccessPath}/artifact-read`, - requireValidRpcAccessTokenAny(['host:task:read']), - requireRpcTokenUserMatch(), - requireRpcTokenHostMatch(), - rateLimitMiddleware({ - bucketType: 'host_artifact_pre_admission', - maxPerMinute: config.hostArtifactReadRlPerMin, - getBucketKey: req => { - const subject = (req as ArtifactRequest).rpcAuth?.sub - return subject ? `host-artifact-pre-admission:${subject}` : null - }, - }), - async (req: ArtifactRequest, res, next) => { - try { - const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) - if (!connection) return - req.artifactReadConnection = connection - next() - } catch (error) { - next(error) - } - }, - rateLimitMiddleware({ - bucketType: 'host_artifact_read', - maxPerMinute: config.hostArtifactReadRlPerMin, - getBucketKey: req => { - const artifactRead = req as ArtifactRequest - const subject = artifactRead.rpcAuth?.sub - const hostRef = artifactRead.artifactReadConnection?.hostRef - return subject && hostRef ? `host-artifact-read:${subject}:${hostRef}` : null - }, - }), - (_req, res) => res.status(200).json({ ok: true }), - ) - return router -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/services/access/rpcHostAccessAuthorizer.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/services/access/rpcHostAccessAuthorizer.ts deleted file mode 100644 index 22957e5917..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/control-api/src/services/access/rpcHostAccessAuthorizer.ts +++ /dev/null @@ -1,9 +0,0 @@ -export async function authorizeRpcHostAccess( - _gateway: unknown, - _claims: unknown, - _userId: string, - _hostRef: string, - _directory: unknown, -): Promise<{ authorized: boolean; connection: { hostRef: string } }> { - return { authorized: true, connection: { hostRef: 'canonical-host' } } -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/middleware/auth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/middleware/auth.ts deleted file mode 100644 index 01476c7883..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/middleware/auth.ts +++ /dev/null @@ -1,6 +0,0 @@ -declare function verifyRpcToken(): void -export function requireRpcAuth(_req: unknown, _res: unknown, next: () => void) { verifyRpcToken(); next() } -export function requireScope(_scope: string) { - return (_req: unknown, _res: unknown, next: () => void) => next() -} -export function extractAuthToken(_req: unknown): string { return 'token' } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/routes/rpc.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/routes/rpc.ts deleted file mode 100644 index f3963fdbb6..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/routes/rpc.ts +++ /dev/null @@ -1,64 +0,0 @@ -import express from 'express' -import { extractAuthToken, requireRpcAuth, requireScope } from '../middleware/auth.js' -import { resolveArtifactReadHostConnectionForUser } from '../services/mcpProxyService.js' -import { resolveArtifactReadHostConnectionForUser as fakeClient } from '../services/fakeMcpProxyService.js' - -type ArtifactRequest = express.Request & { auth?: { sub: string }; artifactReadHost?: { url: string } } - -const resolveArtifactReadHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - const hostRef = String(req.params.hostRef || '').trim() - req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, hostRef, extractAuthToken(req)) - next() -} - -const fakeResolveArtifactHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) - next() -} - -const wrongEndpointResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) - next() -} - -const fallbackArtifactResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - try { - req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) - } catch { - req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) - } - next() -} - -export function createRpcRouter() { - const router = express.Router() - router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts/download') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fakeResolveArtifactHost, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), wrongEndpointResolver, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts/download') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fallbackArtifactResolver, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts/download') - res.sendStatus(200) - }) - return router -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/services/controlApiRestService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/services/controlApiRestService.ts deleted file mode 100644 index 8d4a12b6fd..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/services/controlApiRestService.ts +++ /dev/null @@ -1,16 +0,0 @@ -type Options = { artifactRead?: boolean } - -async function fetchHostConnectionForPath( - userId: string, - hostRef: string, - token: string, - options: Options, -): Promise<{ url: string } | null> { - const base = `/rpc/access/users/${userId}/mcp-hosts/${hostRef}` - const endpoint = options.artifactRead ? `${base}/artifact-read` : base - return fetch(endpoint, { headers: { authorization: `Bearer ${token}` } }) as never -} - -export async function fetchArtifactReadHostConnectionFromControlApi(userId: string, hostRef: string, token: string) { - return fetchHostConnectionForPath(userId, hostRef, token, { artifactRead: true }) -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/services/fakeMcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/services/fakeMcpProxyService.ts deleted file mode 100644 index c03af01f0a..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/services/fakeMcpProxyService.ts +++ /dev/null @@ -1,7 +0,0 @@ -export async function resolveArtifactReadHostConnectionForUser( - _userId: string, - _hostRef: string, - _token: string, -) { - return { url: 'https://unprotected.example' } -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/services/mcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/services/mcpProxyService.ts deleted file mode 100644 index e314b09f56..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-authority/rpc-proxy/src/services/mcpProxyService.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { fetchArtifactReadHostConnectionFromControlApi } from './controlApiRestService.js' - -export async function resolveArtifactReadHostConnectionForUser(userId: string, hostRef: string, token: string) { - return fetchArtifactReadHostConnectionFromControlApi(userId, hostRef, token) -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/EvenfireMissingRateLimiting.expected b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/EvenfireMissingRateLimiting.expected deleted file mode 100644 index 5c4e62f339..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/EvenfireMissingRateLimiting.expected +++ /dev/null @@ -1,13 +0,0 @@ -| control-api/src/routes/rpc-access/users.ts:24:5:24:53 | require ... read']) | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/rpcAccessAuth.ts:5:5:5:17 | verifyToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:39:47:39:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:39:95:39:117 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:43:66:43:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:43:114:43:136 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:47:47:47:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:47:95:47:117 | fakeRes ... actHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:17:93:17:113 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:51:66:51:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:51:114:51:134 | wrongEn ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:23:63:23:83 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:55:47:55:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:30:95:30:115 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:32:65:32:85 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:59:66:59:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/EvenfireMissingRateLimiting.qlref b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/EvenfireMissingRateLimiting.qlref deleted file mode 100644 index 7d3c25d5ca..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/EvenfireMissingRateLimiting.qlref +++ /dev/null @@ -1 +0,0 @@ -query: EvenfireMissingRateLimiting.ql diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/app.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/app.ts deleted file mode 100644 index 4ece8564ed..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/app.ts +++ /dev/null @@ -1,11 +0,0 @@ -import express from 'express' -import { requireInternalService, requireInternalToken } from './middleware/internalServiceAuth.js' -import { createRpcAccessRouter } from './routes/rpc-access/index.js' - -export function createApp() { - const api = express.Router() - api.use(requireInternalToken) - api.use('/rpc', requireInternalService('rpc-proxy')) - api.use(createRpcAccessRouter()) - return api -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/config.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/config.ts deleted file mode 100644 index c8274c450a..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/config.ts +++ /dev/null @@ -1 +0,0 @@ -export const config = { hostArtifactReadRlPerMin: 30 } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/middleware/internalServiceAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/middleware/internalServiceAuth.ts deleted file mode 100644 index 7224c950a0..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/middleware/internalServiceAuth.ts +++ /dev/null @@ -1,4 +0,0 @@ -export function requireInternalToken(_req: unknown, _res: unknown, next: () => void) { next() } -export function requireInternalService(_name: string) { - return (_req: unknown, _res: unknown, next: () => void) => next() -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/middleware/rateLimitMiddleware.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/middleware/rateLimitMiddleware.ts deleted file mode 100644 index 32f6b8ea64..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/middleware/rateLimitMiddleware.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function rateLimitMiddleware(_options: unknown) { - return (_req: unknown, _res: unknown, next: () => void) => next() -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/middleware/rpcAccessAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/middleware/rpcAccessAuth.ts deleted file mode 100644 index b498d6f7a8..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/middleware/rpcAccessAuth.ts +++ /dev/null @@ -1,16 +0,0 @@ -declare function verifyToken(): void - -export function requireValidRpcAccessTokenAny(_scopes: string[]) { - return (_req: unknown, _res: unknown, next: () => void) => { - verifyToken() - next() - } -} - -export function requireRpcTokenUserMatch() { - return (_req: unknown, _res: unknown, next: () => void) => next() -} - -export function requireRpcTokenHostMatch() { - return (_req: unknown, _res: unknown, next: () => void) => next() -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/routes/rpc-access/fakeResolveAuthorizedHostConnection.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/routes/rpc-access/fakeResolveAuthorizedHostConnection.ts deleted file mode 100644 index 112a3a2bce..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/routes/rpc-access/fakeResolveAuthorizedHostConnection.ts +++ /dev/null @@ -1,8 +0,0 @@ -export async function resolveAuthorizedHostConnection( - req: { params: { hostRef: string } }, - _res: unknown, - _gateway: unknown, - _directory: unknown, -) { - return { hostRef: req.params.hostRef } -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/routes/rpc-access/index.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/routes/rpc-access/index.ts deleted file mode 100644 index 6db4f809c7..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/routes/rpc-access/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createRpcAccessUsersRouter } from './users.js' -import { Router } from 'express' - -export function createRpcAccessRouter(gateway: unknown, options: { directory?: unknown }) { - const router = Router() - router.use(createRpcAccessUsersRouter(gateway, options)) - return router -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/routes/rpc-access/users.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/routes/rpc-access/users.ts deleted file mode 100644 index bb519c9657..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/routes/rpc-access/users.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Router } from 'express' -import { config } from '../../config.js' -import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' -import { - requireRpcTokenHostMatch, - requireRpcTokenUserMatch, - requireValidRpcAccessTokenAny, -} from '../../middleware/rpcAccessAuth.js' -import { authorizeRpcHostAccess } from '../../services/access/rpcHostAccessAuthorizer.js' -import { resolveAuthorizedHostConnection } from './fakeResolveAuthorizedHostConnection.js' - -type ArtifactRequest = { - params: { userId: string; hostRef: string } - rpcAuth?: { sub?: string } - artifactReadConnection?: { hostRef: string } -} - -export function createRpcAccessUsersRouter(gateway: unknown, options: { directory?: unknown }) { - const router = Router() - const { directory } = options - const hostAccessPath = '/rpc/access/users/:userId/mcp-hosts/:hostRef' - router.get( - `${hostAccessPath}/artifact-read`, - requireValidRpcAccessTokenAny(['host:task:read']), - requireRpcTokenUserMatch(), - requireRpcTokenHostMatch(), - rateLimitMiddleware({ - bucketType: 'host_artifact_pre_admission', - maxPerMinute: config.hostArtifactReadRlPerMin, - getBucketKey: req => { - const subject = (req as ArtifactRequest).rpcAuth?.sub - return subject ? `host-artifact-pre-admission:${subject}` : null - }, - }), - async (req: ArtifactRequest, res, next) => { - try { - const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) - if (!connection) return - req.artifactReadConnection = connection - next() - } catch (error) { - next(error) - } - }, - rateLimitMiddleware({ - bucketType: 'host_artifact_read', - maxPerMinute: config.hostArtifactReadRlPerMin, - getBucketKey: req => { - const artifactRead = req as ArtifactRequest - const subject = artifactRead.rpcAuth?.sub - const hostRef = artifactRead.artifactReadConnection?.hostRef - return subject && hostRef ? `host-artifact-read:${subject}:${hostRef}` : null - }, - }), - (_req, res) => res.status(200).json({ ok: true }), - ) - return router -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/services/access/rpcHostAccessAuthorizer.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/services/access/rpcHostAccessAuthorizer.ts deleted file mode 100644 index 22957e5917..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/control-api/src/services/access/rpcHostAccessAuthorizer.ts +++ /dev/null @@ -1,9 +0,0 @@ -export async function authorizeRpcHostAccess( - _gateway: unknown, - _claims: unknown, - _userId: string, - _hostRef: string, - _directory: unknown, -): Promise<{ authorized: boolean; connection: { hostRef: string } }> { - return { authorized: true, connection: { hostRef: 'canonical-host' } } -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/middleware/auth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/middleware/auth.ts deleted file mode 100644 index 01476c7883..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/middleware/auth.ts +++ /dev/null @@ -1,6 +0,0 @@ -declare function verifyRpcToken(): void -export function requireRpcAuth(_req: unknown, _res: unknown, next: () => void) { verifyRpcToken(); next() } -export function requireScope(_scope: string) { - return (_req: unknown, _res: unknown, next: () => void) => next() -} -export function extractAuthToken(_req: unknown): string { return 'token' } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/routes/rpc.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/routes/rpc.ts deleted file mode 100644 index f3963fdbb6..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/routes/rpc.ts +++ /dev/null @@ -1,64 +0,0 @@ -import express from 'express' -import { extractAuthToken, requireRpcAuth, requireScope } from '../middleware/auth.js' -import { resolveArtifactReadHostConnectionForUser } from '../services/mcpProxyService.js' -import { resolveArtifactReadHostConnectionForUser as fakeClient } from '../services/fakeMcpProxyService.js' - -type ArtifactRequest = express.Request & { auth?: { sub: string }; artifactReadHost?: { url: string } } - -const resolveArtifactReadHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - const hostRef = String(req.params.hostRef || '').trim() - req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, hostRef, extractAuthToken(req)) - next() -} - -const fakeResolveArtifactHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) - next() -} - -const wrongEndpointResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) - next() -} - -const fallbackArtifactResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - try { - req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) - } catch { - req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) - } - next() -} - -export function createRpcRouter() { - const router = express.Router() - router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts/download') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fakeResolveArtifactHost, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), wrongEndpointResolver, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts/download') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fallbackArtifactResolver, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts/download') - res.sendStatus(200) - }) - return router -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/services/controlApiRestService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/services/controlApiRestService.ts deleted file mode 100644 index 8d4a12b6fd..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/services/controlApiRestService.ts +++ /dev/null @@ -1,16 +0,0 @@ -type Options = { artifactRead?: boolean } - -async function fetchHostConnectionForPath( - userId: string, - hostRef: string, - token: string, - options: Options, -): Promise<{ url: string } | null> { - const base = `/rpc/access/users/${userId}/mcp-hosts/${hostRef}` - const endpoint = options.artifactRead ? `${base}/artifact-read` : base - return fetch(endpoint, { headers: { authorization: `Bearer ${token}` } }) as never -} - -export async function fetchArtifactReadHostConnectionFromControlApi(userId: string, hostRef: string, token: string) { - return fetchHostConnectionForPath(userId, hostRef, token, { artifactRead: true }) -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/services/fakeMcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/services/fakeMcpProxyService.ts deleted file mode 100644 index c03af01f0a..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/services/fakeMcpProxyService.ts +++ /dev/null @@ -1,7 +0,0 @@ -export async function resolveArtifactReadHostConnectionForUser( - _userId: string, - _hostRef: string, - _token: string, -) { - return { url: 'https://unprotected.example' } -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/services/mcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/services/mcpProxyService.ts deleted file mode 100644 index e314b09f56..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-imported-fake-producer/rpc-proxy/src/services/mcpProxyService.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { fetchArtifactReadHostConnectionFromControlApi } from './controlApiRestService.js' - -export async function resolveArtifactReadHostConnectionForUser(userId: string, hostRef: string, token: string) { - return fetchArtifactReadHostConnectionFromControlApi(userId, hostRef, token) -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/EvenfireMissingRateLimiting.expected b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/EvenfireMissingRateLimiting.expected deleted file mode 100644 index 74be339746..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/EvenfireMissingRateLimiting.expected +++ /dev/null @@ -1,14 +0,0 @@ -| control-api/src/routes/rpc-access/users.ts:38:5:38:53 | require ... read']) | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/rpcAccessAuth.ts:5:5:5:17 | verifyToken() | authorization | -| control-api/src/routes/rpc-access/users.ts:41:5:50:5 | async ( ... }\\n } | This route handler performs $@, but is not rate-limited. | control-api/src/routes/rpc-access/users.ts:43:34:43:94 | resolve ... ectory) | authorization | -| rpc-proxy/src/routes/rpc.ts:39:47:39:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:39:95:39:117 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:43:66:43:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:43:114:43:136 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:47:47:47:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:47:95:47:117 | fakeRes ... actHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:17:93:17:113 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:51:66:51:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:51:114:51:134 | wrongEn ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:23:63:23:83 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:55:47:55:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:30:95:30:115 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:32:65:32:85 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:59:66:59:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/EvenfireMissingRateLimiting.qlref b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/EvenfireMissingRateLimiting.qlref deleted file mode 100644 index 7d3c25d5ca..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/EvenfireMissingRateLimiting.qlref +++ /dev/null @@ -1 +0,0 @@ -query: EvenfireMissingRateLimiting.ql diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/app.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/app.ts deleted file mode 100644 index 4ece8564ed..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/app.ts +++ /dev/null @@ -1,11 +0,0 @@ -import express from 'express' -import { requireInternalService, requireInternalToken } from './middleware/internalServiceAuth.js' -import { createRpcAccessRouter } from './routes/rpc-access/index.js' - -export function createApp() { - const api = express.Router() - api.use(requireInternalToken) - api.use('/rpc', requireInternalService('rpc-proxy')) - api.use(createRpcAccessRouter()) - return api -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/config.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/config.ts deleted file mode 100644 index c8274c450a..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/config.ts +++ /dev/null @@ -1 +0,0 @@ -export const config = { hostArtifactReadRlPerMin: 30 } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/middleware/internalServiceAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/middleware/internalServiceAuth.ts deleted file mode 100644 index 7224c950a0..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/middleware/internalServiceAuth.ts +++ /dev/null @@ -1,4 +0,0 @@ -export function requireInternalToken(_req: unknown, _res: unknown, next: () => void) { next() } -export function requireInternalService(_name: string) { - return (_req: unknown, _res: unknown, next: () => void) => next() -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/middleware/rateLimitMiddleware.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/middleware/rateLimitMiddleware.ts deleted file mode 100644 index 32f6b8ea64..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/middleware/rateLimitMiddleware.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function rateLimitMiddleware(_options: unknown) { - return (_req: unknown, _res: unknown, next: () => void) => next() -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/middleware/rpcAccessAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/middleware/rpcAccessAuth.ts deleted file mode 100644 index b498d6f7a8..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/middleware/rpcAccessAuth.ts +++ /dev/null @@ -1,16 +0,0 @@ -declare function verifyToken(): void - -export function requireValidRpcAccessTokenAny(_scopes: string[]) { - return (_req: unknown, _res: unknown, next: () => void) => { - verifyToken() - next() - } -} - -export function requireRpcTokenUserMatch() { - return (_req: unknown, _res: unknown, next: () => void) => next() -} - -export function requireRpcTokenHostMatch() { - return (_req: unknown, _res: unknown, next: () => void) => next() -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/routes/rpc-access/index.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/routes/rpc-access/index.ts deleted file mode 100644 index 6db4f809c7..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/routes/rpc-access/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createRpcAccessUsersRouter } from './users.js' -import { Router } from 'express' - -export function createRpcAccessRouter(gateway: unknown, options: { directory?: unknown }) { - const router = Router() - router.use(createRpcAccessUsersRouter(gateway, options)) - return router -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/routes/rpc-access/users.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/routes/rpc-access/users.ts deleted file mode 100644 index 2c859d8f3d..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/routes/rpc-access/users.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Router } from 'express' -import { config } from '../../config.js' -import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' -import { - requireRpcTokenHostMatch, - requireRpcTokenUserMatch, - requireValidRpcAccessTokenAny, -} from '../../middleware/rpcAccessAuth.js' -import { authorizeRpcHostAccess } from '../../services/access/rpcHostAccessAuthorizer.js' - -type ArtifactRequest = { - params: { userId: string; hostRef: string } - rpcAuth?: { sub?: string } - artifactReadConnection?: { hostRef: string } -} - -async function resolveAuthorizedHostConnection( - req: ArtifactRequest, - _res: unknown, - gateway: unknown, - directory: unknown, -): Promise<{ hostRef: string } | null> { - const userId = String(req.params.userId || '').trim() - const hostRef = String(req.params.hostRef || '').trim() - const claims = req.rpcAuth - if (!claims) return null - const authorization = await authorizeRpcHostAccess(gateway, claims, userId, hostRef, directory) - if (!authorization.authorized) return null - return authorization.connection -} - -export function createRpcAccessUsersRouter(gateway: unknown, options: { directory?: unknown }) { - const router = Router() - const { directory } = options - const hostAccessPath = '/rpc/access/users/:userId/mcp-hosts/:hostRef' - router.get( - `${hostAccessPath}/artifact-read`, - requireValidRpcAccessTokenAny(['host:task:read']), - requireRpcTokenUserMatch(), - requireRpcTokenHostMatch(), - async (req: ArtifactRequest, res, next) => { - try { - const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) - if (!connection) return - req.artifactReadConnection = connection - next() - } catch (error) { - next(error) - } - }, - rateLimitMiddleware({ - bucketType: 'host_artifact_pre_admission', - maxPerMinute: config.hostArtifactReadRlPerMin, - getBucketKey: req => { - const subject = (req as ArtifactRequest).rpcAuth?.sub - return subject ? `host-artifact-pre-admission:${subject}` : null - }, - }), - rateLimitMiddleware({ - bucketType: 'host_artifact_read', - maxPerMinute: config.hostArtifactReadRlPerMin, - getBucketKey: req => { - const artifactRead = req as ArtifactRequest - const subject = artifactRead.rpcAuth?.sub - const hostRef = artifactRead.artifactReadConnection?.hostRef - return subject && hostRef ? `host-artifact-read:${subject}:${hostRef}` : null - }, - }), - (_req, res) => res.status(200).json({ ok: true }), - ) - return router -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/services/access/rpcHostAccessAuthorizer.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/services/access/rpcHostAccessAuthorizer.ts deleted file mode 100644 index 22957e5917..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/control-api/src/services/access/rpcHostAccessAuthorizer.ts +++ /dev/null @@ -1,9 +0,0 @@ -export async function authorizeRpcHostAccess( - _gateway: unknown, - _claims: unknown, - _userId: string, - _hostRef: string, - _directory: unknown, -): Promise<{ authorized: boolean; connection: { hostRef: string } }> { - return { authorized: true, connection: { hostRef: 'canonical-host' } } -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/middleware/auth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/middleware/auth.ts deleted file mode 100644 index 01476c7883..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/middleware/auth.ts +++ /dev/null @@ -1,6 +0,0 @@ -declare function verifyRpcToken(): void -export function requireRpcAuth(_req: unknown, _res: unknown, next: () => void) { verifyRpcToken(); next() } -export function requireScope(_scope: string) { - return (_req: unknown, _res: unknown, next: () => void) => next() -} -export function extractAuthToken(_req: unknown): string { return 'token' } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/routes/rpc.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/routes/rpc.ts deleted file mode 100644 index f3963fdbb6..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/routes/rpc.ts +++ /dev/null @@ -1,64 +0,0 @@ -import express from 'express' -import { extractAuthToken, requireRpcAuth, requireScope } from '../middleware/auth.js' -import { resolveArtifactReadHostConnectionForUser } from '../services/mcpProxyService.js' -import { resolveArtifactReadHostConnectionForUser as fakeClient } from '../services/fakeMcpProxyService.js' - -type ArtifactRequest = express.Request & { auth?: { sub: string }; artifactReadHost?: { url: string } } - -const resolveArtifactReadHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - const hostRef = String(req.params.hostRef || '').trim() - req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, hostRef, extractAuthToken(req)) - next() -} - -const fakeResolveArtifactHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) - next() -} - -const wrongEndpointResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) - next() -} - -const fallbackArtifactResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - try { - req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) - } catch { - req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) - } - next() -} - -export function createRpcRouter() { - const router = express.Router() - router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts/download') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fakeResolveArtifactHost, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), wrongEndpointResolver, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts/download') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fallbackArtifactResolver, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts/download') - res.sendStatus(200) - }) - return router -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/services/controlApiRestService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/services/controlApiRestService.ts deleted file mode 100644 index 8d4a12b6fd..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/services/controlApiRestService.ts +++ /dev/null @@ -1,16 +0,0 @@ -type Options = { artifactRead?: boolean } - -async function fetchHostConnectionForPath( - userId: string, - hostRef: string, - token: string, - options: Options, -): Promise<{ url: string } | null> { - const base = `/rpc/access/users/${userId}/mcp-hosts/${hostRef}` - const endpoint = options.artifactRead ? `${base}/artifact-read` : base - return fetch(endpoint, { headers: { authorization: `Bearer ${token}` } }) as never -} - -export async function fetchArtifactReadHostConnectionFromControlApi(userId: string, hostRef: string, token: string) { - return fetchHostConnectionForPath(userId, hostRef, token, { artifactRead: true }) -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/services/fakeMcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/services/fakeMcpProxyService.ts deleted file mode 100644 index c03af01f0a..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/services/fakeMcpProxyService.ts +++ /dev/null @@ -1,7 +0,0 @@ -export async function resolveArtifactReadHostConnectionForUser( - _userId: string, - _hostRef: string, - _token: string, -) { - return { url: 'https://unprotected.example' } -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/services/mcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/services/mcpProxyService.ts deleted file mode 100644 index e314b09f56..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-late-pre-admission/rpc-proxy/src/services/mcpProxyService.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { fetchArtifactReadHostConnectionFromControlApi } from './controlApiRestService.js' - -export async function resolveArtifactReadHostConnectionForUser(userId: string, hostRef: string, token: string) { - return fetchArtifactReadHostConnectionFromControlApi(userId, hostRef, token) -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/EvenfireMissingRateLimiting.expected b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/EvenfireMissingRateLimiting.expected deleted file mode 100644 index ef61faf522..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/EvenfireMissingRateLimiting.expected +++ /dev/null @@ -1,13 +0,0 @@ -| control-api/src/routes/rpc-access/users.ts:32:5:32:53 | require ... read']) | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/rpcAccessAuth.ts:5:5:5:17 | verifyToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:39:47:39:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:39:95:39:117 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:43:66:43:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:43:114:43:136 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:47:47:47:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:47:95:47:117 | fakeRes ... actHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:17:93:17:113 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:51:66:51:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:51:114:51:134 | wrongEn ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:23:63:23:83 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:55:47:55:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:30:95:30:115 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:32:65:32:85 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:59:66:59:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/EvenfireMissingRateLimiting.qlref b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/EvenfireMissingRateLimiting.qlref deleted file mode 100644 index 7d3c25d5ca..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/EvenfireMissingRateLimiting.qlref +++ /dev/null @@ -1 +0,0 @@ -query: EvenfireMissingRateLimiting.ql diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/app.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/app.ts deleted file mode 100644 index 4ece8564ed..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/app.ts +++ /dev/null @@ -1,11 +0,0 @@ -import express from 'express' -import { requireInternalService, requireInternalToken } from './middleware/internalServiceAuth.js' -import { createRpcAccessRouter } from './routes/rpc-access/index.js' - -export function createApp() { - const api = express.Router() - api.use(requireInternalToken) - api.use('/rpc', requireInternalService('rpc-proxy')) - api.use(createRpcAccessRouter()) - return api -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/config.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/config.ts deleted file mode 100644 index c8274c450a..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/config.ts +++ /dev/null @@ -1 +0,0 @@ -export const config = { hostArtifactReadRlPerMin: 30 } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/middleware/internalServiceAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/middleware/internalServiceAuth.ts deleted file mode 100644 index 7224c950a0..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/middleware/internalServiceAuth.ts +++ /dev/null @@ -1,4 +0,0 @@ -export function requireInternalToken(_req: unknown, _res: unknown, next: () => void) { next() } -export function requireInternalService(_name: string) { - return (_req: unknown, _res: unknown, next: () => void) => next() -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/middleware/rateLimitMiddleware.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/middleware/rateLimitMiddleware.ts deleted file mode 100644 index 32f6b8ea64..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/middleware/rateLimitMiddleware.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function rateLimitMiddleware(_options: unknown) { - return (_req: unknown, _res: unknown, next: () => void) => next() -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/middleware/rpcAccessAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/middleware/rpcAccessAuth.ts deleted file mode 100644 index b498d6f7a8..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/middleware/rpcAccessAuth.ts +++ /dev/null @@ -1,16 +0,0 @@ -declare function verifyToken(): void - -export function requireValidRpcAccessTokenAny(_scopes: string[]) { - return (_req: unknown, _res: unknown, next: () => void) => { - verifyToken() - next() - } -} - -export function requireRpcTokenUserMatch() { - return (_req: unknown, _res: unknown, next: () => void) => next() -} - -export function requireRpcTokenHostMatch() { - return (_req: unknown, _res: unknown, next: () => void) => next() -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/routes/rpc-access/index.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/routes/rpc-access/index.ts deleted file mode 100644 index 6db4f809c7..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/routes/rpc-access/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createRpcAccessUsersRouter } from './users.js' -import { Router } from 'express' - -export function createRpcAccessRouter(gateway: unknown, options: { directory?: unknown }) { - const router = Router() - router.use(createRpcAccessUsersRouter(gateway, options)) - return router -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/routes/rpc-access/users.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/routes/rpc-access/users.ts deleted file mode 100644 index 96dd73d9ee..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/routes/rpc-access/users.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { Router } from 'express' -import { config } from '../../config.js' -import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' -import { - requireRpcTokenHostMatch, - requireRpcTokenUserMatch, - requireValidRpcAccessTokenAny, -} from '../../middleware/rpcAccessAuth.js' -import { authorizeRpcHostAccess } from '../../services/access/rpcHostAccessAuthorizer.js' - -type ArtifactRequest = { - params: { userId: string; hostRef: string } - rpcAuth?: { sub?: string } - artifactReadConnection?: { hostRef: string } -} - -async function resolveAuthorizedHostConnection( - req: ArtifactRequest, - _res: unknown, - _gateway: unknown, - _directory: unknown, -): Promise<{ hostRef: string } | null> { - return { hostRef: req.params.hostRef } -} - -export function createRpcAccessUsersRouter(gateway: unknown, options: { directory?: unknown }) { - const router = Router() - const { directory } = options - const hostAccessPath = '/rpc/access/users/:userId/mcp-hosts/:hostRef' - router.get( - `${hostAccessPath}/artifact-read`, - requireValidRpcAccessTokenAny(['host:task:read']), - requireRpcTokenUserMatch(), - requireRpcTokenHostMatch(), - rateLimitMiddleware({ - bucketType: 'host_artifact_pre_admission', - maxPerMinute: config.hostArtifactReadRlPerMin, - getBucketKey: req => { - const subject = (req as ArtifactRequest).rpcAuth?.sub - return subject ? `host-artifact-pre-admission:${subject}` : null - }, - }), - async (req: ArtifactRequest, res, next) => { - try { - const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) - if (!connection) return - req.artifactReadConnection = connection - next() - } catch (error) { - next(error) - } - }, - rateLimitMiddleware({ - bucketType: 'host_artifact_read', - maxPerMinute: config.hostArtifactReadRlPerMin, - getBucketKey: req => { - const artifactRead = req as ArtifactRequest - const subject = artifactRead.rpcAuth?.sub - const hostRef = artifactRead.artifactReadConnection?.hostRef - return subject && hostRef ? `host-artifact-read:${subject}:${hostRef}` : null - }, - }), - (_req, res) => res.status(200).json({ ok: true }), - ) - return router -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/services/access/rpcHostAccessAuthorizer.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/services/access/rpcHostAccessAuthorizer.ts deleted file mode 100644 index 22957e5917..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/control-api/src/services/access/rpcHostAccessAuthorizer.ts +++ /dev/null @@ -1,9 +0,0 @@ -export async function authorizeRpcHostAccess( - _gateway: unknown, - _claims: unknown, - _userId: string, - _hostRef: string, - _directory: unknown, -): Promise<{ authorized: boolean; connection: { hostRef: string } }> { - return { authorized: true, connection: { hostRef: 'canonical-host' } } -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/middleware/auth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/middleware/auth.ts deleted file mode 100644 index 01476c7883..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/middleware/auth.ts +++ /dev/null @@ -1,6 +0,0 @@ -declare function verifyRpcToken(): void -export function requireRpcAuth(_req: unknown, _res: unknown, next: () => void) { verifyRpcToken(); next() } -export function requireScope(_scope: string) { - return (_req: unknown, _res: unknown, next: () => void) => next() -} -export function extractAuthToken(_req: unknown): string { return 'token' } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/routes/rpc.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/routes/rpc.ts deleted file mode 100644 index f3963fdbb6..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/routes/rpc.ts +++ /dev/null @@ -1,64 +0,0 @@ -import express from 'express' -import { extractAuthToken, requireRpcAuth, requireScope } from '../middleware/auth.js' -import { resolveArtifactReadHostConnectionForUser } from '../services/mcpProxyService.js' -import { resolveArtifactReadHostConnectionForUser as fakeClient } from '../services/fakeMcpProxyService.js' - -type ArtifactRequest = express.Request & { auth?: { sub: string }; artifactReadHost?: { url: string } } - -const resolveArtifactReadHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - const hostRef = String(req.params.hostRef || '').trim() - req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, hostRef, extractAuthToken(req)) - next() -} - -const fakeResolveArtifactHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) - next() -} - -const wrongEndpointResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) - next() -} - -const fallbackArtifactResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - try { - req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) - } catch { - req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) - } - next() -} - -export function createRpcRouter() { - const router = express.Router() - router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts/download') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fakeResolveArtifactHost, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), wrongEndpointResolver, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts/download') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fallbackArtifactResolver, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts/download') - res.sendStatus(200) - }) - return router -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/services/controlApiRestService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/services/controlApiRestService.ts deleted file mode 100644 index 8d4a12b6fd..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/services/controlApiRestService.ts +++ /dev/null @@ -1,16 +0,0 @@ -type Options = { artifactRead?: boolean } - -async function fetchHostConnectionForPath( - userId: string, - hostRef: string, - token: string, - options: Options, -): Promise<{ url: string } | null> { - const base = `/rpc/access/users/${userId}/mcp-hosts/${hostRef}` - const endpoint = options.artifactRead ? `${base}/artifact-read` : base - return fetch(endpoint, { headers: { authorization: `Bearer ${token}` } }) as never -} - -export async function fetchArtifactReadHostConnectionFromControlApi(userId: string, hostRef: string, token: string) { - return fetchHostConnectionForPath(userId, hostRef, token, { artifactRead: true }) -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/services/fakeMcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/services/fakeMcpProxyService.ts deleted file mode 100644 index c03af01f0a..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/services/fakeMcpProxyService.ts +++ /dev/null @@ -1,7 +0,0 @@ -export async function resolveArtifactReadHostConnectionForUser( - _userId: string, - _hostRef: string, - _token: string, -) { - return { url: 'https://unprotected.example' } -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/services/mcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/services/mcpProxyService.ts deleted file mode 100644 index e314b09f56..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-local-fake-producer/rpc-proxy/src/services/mcpProxyService.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { fetchArtifactReadHostConnectionFromControlApi } from './controlApiRestService.js' - -export async function resolveArtifactReadHostConnectionForUser(userId: string, hostRef: string, token: string) { - return fetchArtifactReadHostConnectionFromControlApi(userId, hostRef, token) -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/EvenfireMissingRateLimiting.expected b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/EvenfireMissingRateLimiting.expected deleted file mode 100644 index 8a73eee2af..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/EvenfireMissingRateLimiting.expected +++ /dev/null @@ -1,13 +0,0 @@ -| control-api/src/routes/rpc-access/users.ts:38:5:38:53 | require ... read']) | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/rpcAccessAuth.ts:5:5:5:17 | verifyToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:39:47:39:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:39:95:39:117 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:43:66:43:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:43:114:43:136 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:47:47:47:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:47:95:47:117 | fakeRes ... actHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:17:93:17:113 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:51:66:51:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:51:114:51:134 | wrongEn ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:23:63:23:83 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:55:47:55:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:30:95:30:115 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:32:65:32:85 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:59:66:59:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/EvenfireMissingRateLimiting.qlref b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/EvenfireMissingRateLimiting.qlref deleted file mode 100644 index 7d3c25d5ca..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/EvenfireMissingRateLimiting.qlref +++ /dev/null @@ -1 +0,0 @@ -query: EvenfireMissingRateLimiting.ql diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/app.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/app.ts deleted file mode 100644 index 4ece8564ed..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/app.ts +++ /dev/null @@ -1,11 +0,0 @@ -import express from 'express' -import { requireInternalService, requireInternalToken } from './middleware/internalServiceAuth.js' -import { createRpcAccessRouter } from './routes/rpc-access/index.js' - -export function createApp() { - const api = express.Router() - api.use(requireInternalToken) - api.use('/rpc', requireInternalService('rpc-proxy')) - api.use(createRpcAccessRouter()) - return api -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/config.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/config.ts deleted file mode 100644 index c8274c450a..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/config.ts +++ /dev/null @@ -1 +0,0 @@ -export const config = { hostArtifactReadRlPerMin: 30 } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/middleware/internalServiceAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/middleware/internalServiceAuth.ts deleted file mode 100644 index 7224c950a0..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/middleware/internalServiceAuth.ts +++ /dev/null @@ -1,4 +0,0 @@ -export function requireInternalToken(_req: unknown, _res: unknown, next: () => void) { next() } -export function requireInternalService(_name: string) { - return (_req: unknown, _res: unknown, next: () => void) => next() -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/middleware/rateLimitMiddleware.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/middleware/rateLimitMiddleware.ts deleted file mode 100644 index 32f6b8ea64..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/middleware/rateLimitMiddleware.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function rateLimitMiddleware(_options: unknown) { - return (_req: unknown, _res: unknown, next: () => void) => next() -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/middleware/rpcAccessAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/middleware/rpcAccessAuth.ts deleted file mode 100644 index b498d6f7a8..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/middleware/rpcAccessAuth.ts +++ /dev/null @@ -1,16 +0,0 @@ -declare function verifyToken(): void - -export function requireValidRpcAccessTokenAny(_scopes: string[]) { - return (_req: unknown, _res: unknown, next: () => void) => { - verifyToken() - next() - } -} - -export function requireRpcTokenUserMatch() { - return (_req: unknown, _res: unknown, next: () => void) => next() -} - -export function requireRpcTokenHostMatch() { - return (_req: unknown, _res: unknown, next: () => void) => next() -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/routes/rpc-access/index.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/routes/rpc-access/index.ts deleted file mode 100644 index 6db4f809c7..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/routes/rpc-access/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createRpcAccessUsersRouter } from './users.js' -import { Router } from 'express' - -export function createRpcAccessRouter(gateway: unknown, options: { directory?: unknown }) { - const router = Router() - router.use(createRpcAccessUsersRouter(gateway, options)) - return router -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/routes/rpc-access/users.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/routes/rpc-access/users.ts deleted file mode 100644 index 82f501fc68..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/routes/rpc-access/users.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Router } from 'express' -import { config } from '../../config.js' -import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' -import { - requireRpcTokenHostMatch, - requireRpcTokenUserMatch, - requireValidRpcAccessTokenAny, -} from '../../middleware/rpcAccessAuth.js' -import { authorizeRpcHostAccess } from '../../services/access/rpcHostAccessAuthorizer.js' - -type ArtifactRequest = { - params: { userId: string; hostRef: string } - rpcAuth?: { sub?: string } - artifactReadConnection?: { hostRef: string } -} - -async function resolveAuthorizedHostConnection( - req: ArtifactRequest, - _res: unknown, - gateway: unknown, - directory: unknown, -): Promise<{ hostRef: string } | null> { - const userId = String(req.params.userId || '').trim() - const hostRef = String(req.params.hostRef || '').trim() - const claims = req.rpcAuth - if (!claims) return null - const authorization = await authorizeRpcHostAccess(gateway, claims, userId, hostRef, directory) - if (!authorization.authorized) return null - return authorization.connection -} - -export function createRpcAccessUsersRouter(gateway: unknown, options: { directory?: unknown }) { - const router = Router() - const { directory } = options - const hostAccessPath = '/rpc/access/users/:userId/mcp-hosts/:hostRef' - router.get( - `${hostAccessPath}/artifact-read`, - requireValidRpcAccessTokenAny(['host:task:read']), - requireRpcTokenUserMatch(), - (_req, _res, next) => next(), - rateLimitMiddleware({ - bucketType: 'host_artifact_pre_admission', - maxPerMinute: config.hostArtifactReadRlPerMin, - getBucketKey: req => { - const subject = (req as ArtifactRequest).rpcAuth?.sub - return subject ? `host-artifact-pre-admission:${subject}` : null - }, - }), - async (req: ArtifactRequest, res, next) => { - try { - const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) - if (!connection) return - req.artifactReadConnection = connection - next() - } catch (error) { - next(error) - } - }, - rateLimitMiddleware({ - bucketType: 'host_artifact_read', - maxPerMinute: config.hostArtifactReadRlPerMin, - getBucketKey: req => { - const artifactRead = req as ArtifactRequest - const subject = artifactRead.rpcAuth?.sub - const hostRef = artifactRead.artifactReadConnection?.hostRef - return subject && hostRef ? `host-artifact-read:${subject}:${hostRef}` : null - }, - }), - (_req, res) => res.status(200).json({ ok: true }), - ) - return router -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/services/access/rpcHostAccessAuthorizer.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/services/access/rpcHostAccessAuthorizer.ts deleted file mode 100644 index 22957e5917..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/control-api/src/services/access/rpcHostAccessAuthorizer.ts +++ /dev/null @@ -1,9 +0,0 @@ -export async function authorizeRpcHostAccess( - _gateway: unknown, - _claims: unknown, - _userId: string, - _hostRef: string, - _directory: unknown, -): Promise<{ authorized: boolean; connection: { hostRef: string } }> { - return { authorized: true, connection: { hostRef: 'canonical-host' } } -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/middleware/auth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/middleware/auth.ts deleted file mode 100644 index 01476c7883..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/middleware/auth.ts +++ /dev/null @@ -1,6 +0,0 @@ -declare function verifyRpcToken(): void -export function requireRpcAuth(_req: unknown, _res: unknown, next: () => void) { verifyRpcToken(); next() } -export function requireScope(_scope: string) { - return (_req: unknown, _res: unknown, next: () => void) => next() -} -export function extractAuthToken(_req: unknown): string { return 'token' } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/routes/rpc.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/routes/rpc.ts deleted file mode 100644 index f3963fdbb6..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/routes/rpc.ts +++ /dev/null @@ -1,64 +0,0 @@ -import express from 'express' -import { extractAuthToken, requireRpcAuth, requireScope } from '../middleware/auth.js' -import { resolveArtifactReadHostConnectionForUser } from '../services/mcpProxyService.js' -import { resolveArtifactReadHostConnectionForUser as fakeClient } from '../services/fakeMcpProxyService.js' - -type ArtifactRequest = express.Request & { auth?: { sub: string }; artifactReadHost?: { url: string } } - -const resolveArtifactReadHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - const hostRef = String(req.params.hostRef || '').trim() - req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, hostRef, extractAuthToken(req)) - next() -} - -const fakeResolveArtifactHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) - next() -} - -const wrongEndpointResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) - next() -} - -const fallbackArtifactResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - try { - req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) - } catch { - req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) - } - next() -} - -export function createRpcRouter() { - const router = express.Router() - router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts/download') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fakeResolveArtifactHost, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), wrongEndpointResolver, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts/download') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fallbackArtifactResolver, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts/download') - res.sendStatus(200) - }) - return router -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/services/controlApiRestService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/services/controlApiRestService.ts deleted file mode 100644 index 8d4a12b6fd..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/services/controlApiRestService.ts +++ /dev/null @@ -1,16 +0,0 @@ -type Options = { artifactRead?: boolean } - -async function fetchHostConnectionForPath( - userId: string, - hostRef: string, - token: string, - options: Options, -): Promise<{ url: string } | null> { - const base = `/rpc/access/users/${userId}/mcp-hosts/${hostRef}` - const endpoint = options.artifactRead ? `${base}/artifact-read` : base - return fetch(endpoint, { headers: { authorization: `Bearer ${token}` } }) as never -} - -export async function fetchArtifactReadHostConnectionFromControlApi(userId: string, hostRef: string, token: string) { - return fetchHostConnectionForPath(userId, hostRef, token, { artifactRead: true }) -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/services/fakeMcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/services/fakeMcpProxyService.ts deleted file mode 100644 index c03af01f0a..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/services/fakeMcpProxyService.ts +++ /dev/null @@ -1,7 +0,0 @@ -export async function resolveArtifactReadHostConnectionForUser( - _userId: string, - _hostRef: string, - _token: string, -) { - return { url: 'https://unprotected.example' } -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/services/mcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/services/mcpProxyService.ts deleted file mode 100644 index e314b09f56..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-host-match/rpc-proxy/src/services/mcpProxyService.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { fetchArtifactReadHostConnectionFromControlApi } from './controlApiRestService.js' - -export async function resolveArtifactReadHostConnectionForUser(userId: string, hostRef: string, token: string) { - return fetchArtifactReadHostConnectionFromControlApi(userId, hostRef, token) -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/EvenfireMissingRateLimiting.expected b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/EvenfireMissingRateLimiting.expected deleted file mode 100644 index 8a73eee2af..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/EvenfireMissingRateLimiting.expected +++ /dev/null @@ -1,13 +0,0 @@ -| control-api/src/routes/rpc-access/users.ts:38:5:38:53 | require ... read']) | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/rpcAccessAuth.ts:5:5:5:17 | verifyToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:39:47:39:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:39:95:39:117 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:43:66:43:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:43:114:43:136 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:47:47:47:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:47:95:47:117 | fakeRes ... actHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:17:93:17:113 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:51:66:51:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:51:114:51:134 | wrongEn ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:23:63:23:83 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:55:47:55:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:30:95:30:115 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:32:65:32:85 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:59:66:59:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/EvenfireMissingRateLimiting.qlref b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/EvenfireMissingRateLimiting.qlref deleted file mode 100644 index 7d3c25d5ca..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/EvenfireMissingRateLimiting.qlref +++ /dev/null @@ -1 +0,0 @@ -query: EvenfireMissingRateLimiting.ql diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/app.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/app.ts deleted file mode 100644 index 4ece8564ed..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/app.ts +++ /dev/null @@ -1,11 +0,0 @@ -import express from 'express' -import { requireInternalService, requireInternalToken } from './middleware/internalServiceAuth.js' -import { createRpcAccessRouter } from './routes/rpc-access/index.js' - -export function createApp() { - const api = express.Router() - api.use(requireInternalToken) - api.use('/rpc', requireInternalService('rpc-proxy')) - api.use(createRpcAccessRouter()) - return api -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/config.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/config.ts deleted file mode 100644 index c8274c450a..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/config.ts +++ /dev/null @@ -1 +0,0 @@ -export const config = { hostArtifactReadRlPerMin: 30 } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/middleware/internalServiceAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/middleware/internalServiceAuth.ts deleted file mode 100644 index 7224c950a0..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/middleware/internalServiceAuth.ts +++ /dev/null @@ -1,4 +0,0 @@ -export function requireInternalToken(_req: unknown, _res: unknown, next: () => void) { next() } -export function requireInternalService(_name: string) { - return (_req: unknown, _res: unknown, next: () => void) => next() -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/middleware/rateLimitMiddleware.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/middleware/rateLimitMiddleware.ts deleted file mode 100644 index 32f6b8ea64..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/middleware/rateLimitMiddleware.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function rateLimitMiddleware(_options: unknown) { - return (_req: unknown, _res: unknown, next: () => void) => next() -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/middleware/rpcAccessAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/middleware/rpcAccessAuth.ts deleted file mode 100644 index b498d6f7a8..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/middleware/rpcAccessAuth.ts +++ /dev/null @@ -1,16 +0,0 @@ -declare function verifyToken(): void - -export function requireValidRpcAccessTokenAny(_scopes: string[]) { - return (_req: unknown, _res: unknown, next: () => void) => { - verifyToken() - next() - } -} - -export function requireRpcTokenUserMatch() { - return (_req: unknown, _res: unknown, next: () => void) => next() -} - -export function requireRpcTokenHostMatch() { - return (_req: unknown, _res: unknown, next: () => void) => next() -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/routes/rpc-access/index.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/routes/rpc-access/index.ts deleted file mode 100644 index 6db4f809c7..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/routes/rpc-access/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createRpcAccessUsersRouter } from './users.js' -import { Router } from 'express' - -export function createRpcAccessRouter(gateway: unknown, options: { directory?: unknown }) { - const router = Router() - router.use(createRpcAccessUsersRouter(gateway, options)) - return router -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/routes/rpc-access/users.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/routes/rpc-access/users.ts deleted file mode 100644 index 118026f7c3..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/routes/rpc-access/users.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Router } from 'express' -import { config } from '../../config.js' -import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' -import { - requireRpcTokenHostMatch, - requireRpcTokenUserMatch, - requireValidRpcAccessTokenAny, -} from '../../middleware/rpcAccessAuth.js' -import { authorizeRpcHostAccess } from '../../services/access/rpcHostAccessAuthorizer.js' - -type ArtifactRequest = { - params: { userId: string; hostRef: string } - rpcAuth?: { sub?: string } - artifactReadConnection?: { hostRef: string } -} - -async function resolveAuthorizedHostConnection( - req: ArtifactRequest, - _res: unknown, - gateway: unknown, - directory: unknown, -): Promise<{ hostRef: string } | null> { - const userId = String(req.params.userId || '').trim() - const hostRef = String(req.params.hostRef || '').trim() - const claims = req.rpcAuth - if (!claims) return null - const authorization = await authorizeRpcHostAccess(gateway, claims, userId, hostRef, directory) - if (!authorization.authorized) return null - return authorization.connection -} - -export function createRpcAccessUsersRouter(gateway: unknown, options: { directory?: unknown }) { - const router = Router() - const { directory } = options - const hostAccessPath = '/rpc/access/users/:userId/mcp-hosts/:hostRef' - router.get( - `${hostAccessPath}/artifact-read`, - requireValidRpcAccessTokenAny(['host:task:read']), - (_req, _res, next) => next(), - requireRpcTokenHostMatch(), - rateLimitMiddleware({ - bucketType: 'host_artifact_pre_admission', - maxPerMinute: config.hostArtifactReadRlPerMin, - getBucketKey: req => { - const subject = (req as ArtifactRequest).rpcAuth?.sub - return subject ? `host-artifact-pre-admission:${subject}` : null - }, - }), - async (req: ArtifactRequest, res, next) => { - try { - const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) - if (!connection) return - req.artifactReadConnection = connection - next() - } catch (error) { - next(error) - } - }, - rateLimitMiddleware({ - bucketType: 'host_artifact_read', - maxPerMinute: config.hostArtifactReadRlPerMin, - getBucketKey: req => { - const artifactRead = req as ArtifactRequest - const subject = artifactRead.rpcAuth?.sub - const hostRef = artifactRead.artifactReadConnection?.hostRef - return subject && hostRef ? `host-artifact-read:${subject}:${hostRef}` : null - }, - }), - (_req, res) => res.status(200).json({ ok: true }), - ) - return router -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/services/access/rpcHostAccessAuthorizer.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/services/access/rpcHostAccessAuthorizer.ts deleted file mode 100644 index 22957e5917..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/control-api/src/services/access/rpcHostAccessAuthorizer.ts +++ /dev/null @@ -1,9 +0,0 @@ -export async function authorizeRpcHostAccess( - _gateway: unknown, - _claims: unknown, - _userId: string, - _hostRef: string, - _directory: unknown, -): Promise<{ authorized: boolean; connection: { hostRef: string } }> { - return { authorized: true, connection: { hostRef: 'canonical-host' } } -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/middleware/auth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/middleware/auth.ts deleted file mode 100644 index 01476c7883..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/middleware/auth.ts +++ /dev/null @@ -1,6 +0,0 @@ -declare function verifyRpcToken(): void -export function requireRpcAuth(_req: unknown, _res: unknown, next: () => void) { verifyRpcToken(); next() } -export function requireScope(_scope: string) { - return (_req: unknown, _res: unknown, next: () => void) => next() -} -export function extractAuthToken(_req: unknown): string { return 'token' } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/routes/rpc.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/routes/rpc.ts deleted file mode 100644 index f3963fdbb6..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/routes/rpc.ts +++ /dev/null @@ -1,64 +0,0 @@ -import express from 'express' -import { extractAuthToken, requireRpcAuth, requireScope } from '../middleware/auth.js' -import { resolveArtifactReadHostConnectionForUser } from '../services/mcpProxyService.js' -import { resolveArtifactReadHostConnectionForUser as fakeClient } from '../services/fakeMcpProxyService.js' - -type ArtifactRequest = express.Request & { auth?: { sub: string }; artifactReadHost?: { url: string } } - -const resolveArtifactReadHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - const hostRef = String(req.params.hostRef || '').trim() - req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, hostRef, extractAuthToken(req)) - next() -} - -const fakeResolveArtifactHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) - next() -} - -const wrongEndpointResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) - next() -} - -const fallbackArtifactResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - try { - req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) - } catch { - req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) - } - next() -} - -export function createRpcRouter() { - const router = express.Router() - router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts/download') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fakeResolveArtifactHost, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), wrongEndpointResolver, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts/download') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fallbackArtifactResolver, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts/download') - res.sendStatus(200) - }) - return router -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/services/controlApiRestService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/services/controlApiRestService.ts deleted file mode 100644 index 8d4a12b6fd..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/services/controlApiRestService.ts +++ /dev/null @@ -1,16 +0,0 @@ -type Options = { artifactRead?: boolean } - -async function fetchHostConnectionForPath( - userId: string, - hostRef: string, - token: string, - options: Options, -): Promise<{ url: string } | null> { - const base = `/rpc/access/users/${userId}/mcp-hosts/${hostRef}` - const endpoint = options.artifactRead ? `${base}/artifact-read` : base - return fetch(endpoint, { headers: { authorization: `Bearer ${token}` } }) as never -} - -export async function fetchArtifactReadHostConnectionFromControlApi(userId: string, hostRef: string, token: string) { - return fetchHostConnectionForPath(userId, hostRef, token, { artifactRead: true }) -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/services/fakeMcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/services/fakeMcpProxyService.ts deleted file mode 100644 index c03af01f0a..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/services/fakeMcpProxyService.ts +++ /dev/null @@ -1,7 +0,0 @@ -export async function resolveArtifactReadHostConnectionForUser( - _userId: string, - _hostRef: string, - _token: string, -) { - return { url: 'https://unprotected.example' } -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/services/mcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/services/mcpProxyService.ts deleted file mode 100644 index e314b09f56..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-missing-subject-match/rpc-proxy/src/services/mcpProxyService.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { fetchArtifactReadHostConnectionFromControlApi } from './controlApiRestService.js' - -export async function resolveArtifactReadHostConnectionForUser(userId: string, hostRef: string, token: string) { - return fetchArtifactReadHostConnectionFromControlApi(userId, hostRef, token) -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/EvenfireMissingRateLimiting.expected b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/EvenfireMissingRateLimiting.expected deleted file mode 100644 index 8a73eee2af..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/EvenfireMissingRateLimiting.expected +++ /dev/null @@ -1,13 +0,0 @@ -| control-api/src/routes/rpc-access/users.ts:38:5:38:53 | require ... read']) | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/rpcAccessAuth.ts:5:5:5:17 | verifyToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:39:47:39:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:39:95:39:117 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:43:66:43:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:43:114:43:136 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:47:47:47:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:47:95:47:117 | fakeRes ... actHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:17:93:17:113 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:51:66:51:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:51:114:51:134 | wrongEn ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:23:63:23:83 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:55:47:55:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:30:95:30:115 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:32:65:32:85 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:59:66:59:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/EvenfireMissingRateLimiting.qlref b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/EvenfireMissingRateLimiting.qlref deleted file mode 100644 index 7d3c25d5ca..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/EvenfireMissingRateLimiting.qlref +++ /dev/null @@ -1 +0,0 @@ -query: EvenfireMissingRateLimiting.ql diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/app.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/app.ts deleted file mode 100644 index 4ece8564ed..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/app.ts +++ /dev/null @@ -1,11 +0,0 @@ -import express from 'express' -import { requireInternalService, requireInternalToken } from './middleware/internalServiceAuth.js' -import { createRpcAccessRouter } from './routes/rpc-access/index.js' - -export function createApp() { - const api = express.Router() - api.use(requireInternalToken) - api.use('/rpc', requireInternalService('rpc-proxy')) - api.use(createRpcAccessRouter()) - return api -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/config.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/config.ts deleted file mode 100644 index c8274c450a..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/config.ts +++ /dev/null @@ -1 +0,0 @@ -export const config = { hostArtifactReadRlPerMin: 30 } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/middleware/internalServiceAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/middleware/internalServiceAuth.ts deleted file mode 100644 index 7224c950a0..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/middleware/internalServiceAuth.ts +++ /dev/null @@ -1,4 +0,0 @@ -export function requireInternalToken(_req: unknown, _res: unknown, next: () => void) { next() } -export function requireInternalService(_name: string) { - return (_req: unknown, _res: unknown, next: () => void) => next() -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/middleware/rateLimitMiddleware.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/middleware/rateLimitMiddleware.ts deleted file mode 100644 index 32f6b8ea64..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/middleware/rateLimitMiddleware.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function rateLimitMiddleware(_options: unknown) { - return (_req: unknown, _res: unknown, next: () => void) => next() -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/middleware/rpcAccessAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/middleware/rpcAccessAuth.ts deleted file mode 100644 index b498d6f7a8..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/middleware/rpcAccessAuth.ts +++ /dev/null @@ -1,16 +0,0 @@ -declare function verifyToken(): void - -export function requireValidRpcAccessTokenAny(_scopes: string[]) { - return (_req: unknown, _res: unknown, next: () => void) => { - verifyToken() - next() - } -} - -export function requireRpcTokenUserMatch() { - return (_req: unknown, _res: unknown, next: () => void) => next() -} - -export function requireRpcTokenHostMatch() { - return (_req: unknown, _res: unknown, next: () => void) => next() -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/routes/rpc-access/index.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/routes/rpc-access/index.ts deleted file mode 100644 index 6db4f809c7..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/routes/rpc-access/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createRpcAccessUsersRouter } from './users.js' -import { Router } from 'express' - -export function createRpcAccessRouter(gateway: unknown, options: { directory?: unknown }) { - const router = Router() - router.use(createRpcAccessUsersRouter(gateway, options)) - return router -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/routes/rpc-access/users.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/routes/rpc-access/users.ts deleted file mode 100644 index 0e25c9eea3..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/routes/rpc-access/users.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Router } from 'express' -import { config } from '../../config.js' -import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' -import { - requireRpcTokenHostMatch, - requireRpcTokenUserMatch, - requireValidRpcAccessTokenAny, -} from '../../middleware/rpcAccessAuth.js' -import { authorizeRpcHostAccess } from '../../services/access/rpcHostAccessAuthorizer.js' - -type ArtifactRequest = { - params: { userId: string; hostRef: string } - rpcAuth?: { sub?: string } - artifactReadConnection?: { hostRef: string } -} - -async function resolveAuthorizedHostConnection( - req: ArtifactRequest, - _res: unknown, - gateway: unknown, - directory: unknown, -): Promise<{ hostRef: string } | null> { - const userId = String(req.params.userId || '').trim() - const hostRef = String(req.params.hostRef || '').trim() - const claims = req.rpcAuth - if (!claims) return null - const authorization = await authorizeRpcHostAccess(gateway, claims, userId, hostRef, directory) - if (!authorization.authorized) return null - return authorization.connection -} - -export function createRpcAccessUsersRouter(gateway: unknown, options: { directory?: unknown }) { - const router = Router() - const { directory } = options - const hostAccessPath = '/rpc/access/users/:userId/mcp-hosts/:hostRef' - router.get( - `${hostAccessPath}/artifact-read`, - requireValidRpcAccessTokenAny(['host:task:read']), - requireRpcTokenUserMatch(), - requireRpcTokenHostMatch(), - rateLimitMiddleware({ - bucketType: 'unrelated_artifact_limiter', - maxPerMinute: config.hostArtifactReadRlPerMin, - getBucketKey: req => { - const subject = (req as ArtifactRequest).rpcAuth?.sub - return subject ? `host-artifact-pre-admission:${subject}` : null - }, - }), - async (req: ArtifactRequest, res, next) => { - try { - const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) - if (!connection) return - req.artifactReadConnection = connection - next() - } catch (error) { - next(error) - } - }, - rateLimitMiddleware({ - bucketType: 'host_artifact_read', - maxPerMinute: config.hostArtifactReadRlPerMin, - getBucketKey: req => { - const artifactRead = req as ArtifactRequest - const subject = artifactRead.rpcAuth?.sub - const hostRef = artifactRead.artifactReadConnection?.hostRef - return subject && hostRef ? `host-artifact-read:${subject}:${hostRef}` : null - }, - }), - (_req, res) => res.status(200).json({ ok: true }), - ) - return router -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/services/access/rpcHostAccessAuthorizer.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/services/access/rpcHostAccessAuthorizer.ts deleted file mode 100644 index 22957e5917..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/control-api/src/services/access/rpcHostAccessAuthorizer.ts +++ /dev/null @@ -1,9 +0,0 @@ -export async function authorizeRpcHostAccess( - _gateway: unknown, - _claims: unknown, - _userId: string, - _hostRef: string, - _directory: unknown, -): Promise<{ authorized: boolean; connection: { hostRef: string } }> { - return { authorized: true, connection: { hostRef: 'canonical-host' } } -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/middleware/auth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/middleware/auth.ts deleted file mode 100644 index 01476c7883..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/middleware/auth.ts +++ /dev/null @@ -1,6 +0,0 @@ -declare function verifyRpcToken(): void -export function requireRpcAuth(_req: unknown, _res: unknown, next: () => void) { verifyRpcToken(); next() } -export function requireScope(_scope: string) { - return (_req: unknown, _res: unknown, next: () => void) => next() -} -export function extractAuthToken(_req: unknown): string { return 'token' } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/routes/rpc.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/routes/rpc.ts deleted file mode 100644 index f3963fdbb6..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/routes/rpc.ts +++ /dev/null @@ -1,64 +0,0 @@ -import express from 'express' -import { extractAuthToken, requireRpcAuth, requireScope } from '../middleware/auth.js' -import { resolveArtifactReadHostConnectionForUser } from '../services/mcpProxyService.js' -import { resolveArtifactReadHostConnectionForUser as fakeClient } from '../services/fakeMcpProxyService.js' - -type ArtifactRequest = express.Request & { auth?: { sub: string }; artifactReadHost?: { url: string } } - -const resolveArtifactReadHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - const hostRef = String(req.params.hostRef || '').trim() - req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, hostRef, extractAuthToken(req)) - next() -} - -const fakeResolveArtifactHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) - next() -} - -const wrongEndpointResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) - next() -} - -const fallbackArtifactResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - try { - req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) - } catch { - req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) - } - next() -} - -export function createRpcRouter() { - const router = express.Router() - router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts/download') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fakeResolveArtifactHost, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), wrongEndpointResolver, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts/download') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fallbackArtifactResolver, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts/download') - res.sendStatus(200) - }) - return router -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/services/controlApiRestService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/services/controlApiRestService.ts deleted file mode 100644 index 8d4a12b6fd..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/services/controlApiRestService.ts +++ /dev/null @@ -1,16 +0,0 @@ -type Options = { artifactRead?: boolean } - -async function fetchHostConnectionForPath( - userId: string, - hostRef: string, - token: string, - options: Options, -): Promise<{ url: string } | null> { - const base = `/rpc/access/users/${userId}/mcp-hosts/${hostRef}` - const endpoint = options.artifactRead ? `${base}/artifact-read` : base - return fetch(endpoint, { headers: { authorization: `Bearer ${token}` } }) as never -} - -export async function fetchArtifactReadHostConnectionFromControlApi(userId: string, hostRef: string, token: string) { - return fetchHostConnectionForPath(userId, hostRef, token, { artifactRead: true }) -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/services/fakeMcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/services/fakeMcpProxyService.ts deleted file mode 100644 index c03af01f0a..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/services/fakeMcpProxyService.ts +++ /dev/null @@ -1,7 +0,0 @@ -export async function resolveArtifactReadHostConnectionForUser( - _userId: string, - _hostRef: string, - _token: string, -) { - return { url: 'https://unprotected.example' } -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/services/mcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/services/mcpProxyService.ts deleted file mode 100644 index e314b09f56..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-no-pre-admission/rpc-proxy/src/services/mcpProxyService.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { fetchArtifactReadHostConnectionFromControlApi } from './controlApiRestService.js' - -export async function resolveArtifactReadHostConnectionForUser(userId: string, hostRef: string, token: string) { - return fetchArtifactReadHostConnectionFromControlApi(userId, hostRef, token) -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/EvenfireMissingRateLimiting.expected b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/EvenfireMissingRateLimiting.expected deleted file mode 100644 index 8a73eee2af..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/EvenfireMissingRateLimiting.expected +++ /dev/null @@ -1,13 +0,0 @@ -| control-api/src/routes/rpc-access/users.ts:38:5:38:53 | require ... read']) | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/rpcAccessAuth.ts:5:5:5:17 | verifyToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:39:47:39:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:39:95:39:117 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:43:66:43:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:43:114:43:136 | resolve ... eadHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:11:92:11:112 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:47:47:47:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:47:95:47:117 | fakeRes ... actHost | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:17:93:17:113 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:51:66:51:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:51:114:51:134 | wrongEn ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:23:63:23:83 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:55:47:55:60 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | -| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:30:95:30:115 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:55:95:55:118 | fallbac ... esolver | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/routes/rpc.ts:32:65:32:85 | extract ... en(req) | authorization | -| rpc-proxy/src/routes/rpc.ts:59:66:59:79 | requireRpcAuth | This route handler performs $@, but is not rate-limited. | rpc-proxy/src/middleware/auth.ts:2:82:2:97 | verifyRpcToken() | authorization | diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/EvenfireMissingRateLimiting.qlref b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/EvenfireMissingRateLimiting.qlref deleted file mode 100644 index 7d3c25d5ca..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/EvenfireMissingRateLimiting.qlref +++ /dev/null @@ -1 +0,0 @@ -query: EvenfireMissingRateLimiting.ql diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/app.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/app.ts deleted file mode 100644 index 4ece8564ed..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/app.ts +++ /dev/null @@ -1,11 +0,0 @@ -import express from 'express' -import { requireInternalService, requireInternalToken } from './middleware/internalServiceAuth.js' -import { createRpcAccessRouter } from './routes/rpc-access/index.js' - -export function createApp() { - const api = express.Router() - api.use(requireInternalToken) - api.use('/rpc', requireInternalService('rpc-proxy')) - api.use(createRpcAccessRouter()) - return api -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/config.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/config.ts deleted file mode 100644 index c8274c450a..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/config.ts +++ /dev/null @@ -1 +0,0 @@ -export const config = { hostArtifactReadRlPerMin: 30 } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/middleware/internalServiceAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/middleware/internalServiceAuth.ts deleted file mode 100644 index 7224c950a0..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/middleware/internalServiceAuth.ts +++ /dev/null @@ -1,4 +0,0 @@ -export function requireInternalToken(_req: unknown, _res: unknown, next: () => void) { next() } -export function requireInternalService(_name: string) { - return (_req: unknown, _res: unknown, next: () => void) => next() -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/middleware/rateLimitMiddleware.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/middleware/rateLimitMiddleware.ts deleted file mode 100644 index 32f6b8ea64..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/middleware/rateLimitMiddleware.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function rateLimitMiddleware(_options: unknown) { - return (_req: unknown, _res: unknown, next: () => void) => next() -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/middleware/rpcAccessAuth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/middleware/rpcAccessAuth.ts deleted file mode 100644 index b498d6f7a8..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/middleware/rpcAccessAuth.ts +++ /dev/null @@ -1,16 +0,0 @@ -declare function verifyToken(): void - -export function requireValidRpcAccessTokenAny(_scopes: string[]) { - return (_req: unknown, _res: unknown, next: () => void) => { - verifyToken() - next() - } -} - -export function requireRpcTokenUserMatch() { - return (_req: unknown, _res: unknown, next: () => void) => next() -} - -export function requireRpcTokenHostMatch() { - return (_req: unknown, _res: unknown, next: () => void) => next() -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/routes/rpc-access/index.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/routes/rpc-access/index.ts deleted file mode 100644 index 6db4f809c7..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/routes/rpc-access/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createRpcAccessUsersRouter } from './users.js' -import { Router } from 'express' - -export function createRpcAccessRouter(gateway: unknown, options: { directory?: unknown }) { - const router = Router() - router.use(createRpcAccessUsersRouter(gateway, options)) - return router -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/routes/rpc-access/users.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/routes/rpc-access/users.ts deleted file mode 100644 index 1038e3deec..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/routes/rpc-access/users.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Router } from 'express' -import { config } from '../../config.js' -import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' -import { - requireRpcTokenHostMatch, - requireRpcTokenUserMatch, - requireValidRpcAccessTokenAny, -} from '../../middleware/rpcAccessAuth.js' -import { authorizeRpcHostAccess } from '../../services/access/rpcHostAccessAuthorizer.js' - -type ArtifactRequest = { - params: { userId: string; hostRef: string } - rpcAuth?: { sub?: string } - artifactReadConnection?: { hostRef: string } -} - -async function resolveAuthorizedHostConnection( - req: ArtifactRequest, - _res: unknown, - gateway: unknown, - directory: unknown, -): Promise<{ hostRef: string } | null> { - const userId = String(req.params.userId || '').trim() - const hostRef = String(req.params.hostRef || '').trim() - const claims = req.rpcAuth - if (!claims) return null - const authorization = await authorizeRpcHostAccess(gateway, claims, userId, hostRef, directory) - if (!authorization.authorized) return null - return authorization.connection -} - -export function createRpcAccessUsersRouter(gateway: unknown, options: { directory?: unknown }) { - const router = Router() - const { directory } = options - const hostAccessPath = '/rpc/access/users/:userId/mcp-hosts/:hostRef' - router.get( - `${hostAccessPath}/artifact-read`, - requireValidRpcAccessTokenAny(['host:task:read']), - requireRpcTokenUserMatch(), - requireRpcTokenHostMatch(), - rateLimitMiddleware({ - bucketType: 'host_artifact_pre_admission', - maxPerMinute: config.hostArtifactReadRlPerMin + 1, - getBucketKey: req => { - const subject = (req as ArtifactRequest).rpcAuth?.sub - return subject ? `host-artifact-pre-admission:${subject}` : null - }, - }), - async (req: ArtifactRequest, res, next) => { - try { - const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) - if (!connection) return - req.artifactReadConnection = connection - next() - } catch (error) { - next(error) - } - }, - rateLimitMiddleware({ - bucketType: 'host_artifact_read', - maxPerMinute: config.hostArtifactReadRlPerMin, - getBucketKey: req => { - const artifactRead = req as ArtifactRequest - const subject = artifactRead.rpcAuth?.sub - const hostRef = artifactRead.artifactReadConnection?.hostRef - return subject && hostRef ? `host-artifact-read:${subject}:${hostRef}` : null - }, - }), - (_req, res) => res.status(200).json({ ok: true }), - ) - return router -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/services/access/rpcHostAccessAuthorizer.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/services/access/rpcHostAccessAuthorizer.ts deleted file mode 100644 index 22957e5917..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/control-api/src/services/access/rpcHostAccessAuthorizer.ts +++ /dev/null @@ -1,9 +0,0 @@ -export async function authorizeRpcHostAccess( - _gateway: unknown, - _claims: unknown, - _userId: string, - _hostRef: string, - _directory: unknown, -): Promise<{ authorized: boolean; connection: { hostRef: string } }> { - return { authorized: true, connection: { hostRef: 'canonical-host' } } -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/middleware/auth.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/middleware/auth.ts deleted file mode 100644 index 01476c7883..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/middleware/auth.ts +++ /dev/null @@ -1,6 +0,0 @@ -declare function verifyRpcToken(): void -export function requireRpcAuth(_req: unknown, _res: unknown, next: () => void) { verifyRpcToken(); next() } -export function requireScope(_scope: string) { - return (_req: unknown, _res: unknown, next: () => void) => next() -} -export function extractAuthToken(_req: unknown): string { return 'token' } diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/routes/rpc.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/routes/rpc.ts deleted file mode 100644 index f3963fdbb6..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/routes/rpc.ts +++ /dev/null @@ -1,64 +0,0 @@ -import express from 'express' -import { extractAuthToken, requireRpcAuth, requireScope } from '../middleware/auth.js' -import { resolveArtifactReadHostConnectionForUser } from '../services/mcpProxyService.js' -import { resolveArtifactReadHostConnectionForUser as fakeClient } from '../services/fakeMcpProxyService.js' - -type ArtifactRequest = express.Request & { auth?: { sub: string }; artifactReadHost?: { url: string } } - -const resolveArtifactReadHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - const hostRef = String(req.params.hostRef || '').trim() - req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, hostRef, extractAuthToken(req)) - next() -} - -const fakeResolveArtifactHost = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) - next() -} - -const wrongEndpointResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) - next() -} - -const fallbackArtifactResolver = async (req: ArtifactRequest, _res: express.Response, next: express.NextFunction) => { - const auth = req.auth! - try { - req.artifactReadHost = await resolveArtifactReadHostConnectionForUser(auth.sub, 'host-a', extractAuthToken(req)) - } catch { - req.artifactReadHost = await fakeClient(auth.sub, 'host-a', extractAuthToken(req)) - } - next() -} - -export function createRpcRouter() { - const router = express.Router() - router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts/download') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fakeResolveArtifactHost, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), wrongEndpointResolver, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts/download') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), fallbackArtifactResolver, async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts') - res.sendStatus(200) - }) - router.get('/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), async (_req, res) => { - await fetch('https://host.example/v1/runtime/artifacts/download') - res.sendStatus(200) - }) - return router -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/services/controlApiRestService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/services/controlApiRestService.ts deleted file mode 100644 index 8d4a12b6fd..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/services/controlApiRestService.ts +++ /dev/null @@ -1,16 +0,0 @@ -type Options = { artifactRead?: boolean } - -async function fetchHostConnectionForPath( - userId: string, - hostRef: string, - token: string, - options: Options, -): Promise<{ url: string } | null> { - const base = `/rpc/access/users/${userId}/mcp-hosts/${hostRef}` - const endpoint = options.artifactRead ? `${base}/artifact-read` : base - return fetch(endpoint, { headers: { authorization: `Bearer ${token}` } }) as never -} - -export async function fetchArtifactReadHostConnectionFromControlApi(userId: string, hostRef: string, token: string) { - return fetchHostConnectionForPath(userId, hostRef, token, { artifactRead: true }) -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/services/fakeMcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/services/fakeMcpProxyService.ts deleted file mode 100644 index c03af01f0a..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/services/fakeMcpProxyService.ts +++ /dev/null @@ -1,7 +0,0 @@ -export async function resolveArtifactReadHostConnectionForUser( - _userId: string, - _hostRef: string, - _token: string, -) { - return { url: 'https://unprotected.example' } -} diff --git a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/services/mcpProxyService.ts b/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/services/mcpProxyService.ts deleted file mode 100644 index e314b09f56..0000000000 --- a/.github/codeql/queries/test/MissingRateLimitingDistributedArtifactAdmission/unsafe-wrong-pre-admission-policy/rpc-proxy/src/services/mcpProxyService.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { fetchArtifactReadHostConnectionFromControlApi } from './controlApiRestService.js' - -export async function resolveArtifactReadHostConnectionForUser(userId: string, hostRef: string, token: string) { - return fetchArtifactReadHostConnectionFromControlApi(userId, hostRef, token) -} From 06588e49d342c300c77808686d6747232a34ec42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Tue, 22 Sep 2026 22:31:52 -0600 Subject: [PATCH 231/528] fix(rpc-proxy): r43-m1 advance pr1 release version Stable-ID: R43-M1 Class-check: rpc-proxy release coordinates -> 3 files, 4 values -> set 0.1.90 only. Repro-test: release-version validator fails at 1932696b6 against dev 44bf1eca1. From e97596289396d4ce8ffe74aa8318bd04a7c34472 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Wed, 23 Sep 2026 11:22:25 -0600 Subject: [PATCH 232/528] fix(control-api): r44-h1 bound host message admission Stable-ID: R44-H1 Class-check: one public Host-message POST -> two legacy send forms -> both use the dedicated message-resolution POST and one verified-subject bucket; shared Host GET and existing strict resolver POST remain unchanged. Repro-test: mounted Host-message admission route is absent at 99f002b4d47b4ba82e2128886379cbf102592ced; limiter-removal mutation on the corrected implementation makes request 61 return 200 instead of 429. The strict PostgreSQL bucket admits 60 requests per fixed 60-second window and fails closed when admission is unavailable. Canonical 429 metadata reaches rpc-proxy; Desktop does not automatically resend a denied message. --- control-api/src/routes/rpc-access/users.ts | 156 +++++++++---- .../src/services/hostMessageAdmission.ts | 55 +++++ .../test/routes.hostMessageAdmission.test.ts | 206 ++++++++++++++++++ ...teLimiter.realPostgres.integration.test.ts | 43 +++- .../__tests__/appService.hostWaking.test.ts | 15 ++ .../src/__tests__/rpcProxyClient.test.ts | 26 +++ .../host-message-admission-transport.test.ts | 83 +++++++ .../src/__tests__/sender-assignment.test.ts | 46 +++- .../src/__tests__/wake-and-hold-route.test.ts | 6 + rpc-proxy/src/routes/rpc.ts | 11 + .../src/services/controlApiRestService.ts | 66 +++++- rpc-proxy/src/services/mcpProxyService.ts | 2 + rpc-proxy/test/routes.rpc.test.ts | 12 + 13 files changed, 681 insertions(+), 46 deletions(-) create mode 100644 control-api/src/services/hostMessageAdmission.ts create mode 100644 control-api/test/routes.hostMessageAdmission.test.ts create mode 100644 rpc-proxy/src/__tests__/host-message-admission-transport.test.ts diff --git a/control-api/src/routes/rpc-access/users.ts b/control-api/src/routes/rpc-access/users.ts index 01b63d8482..9fc77ed52a 100644 --- a/control-api/src/routes/rpc-access/users.ts +++ b/control-api/src/routes/rpc-access/users.ts @@ -22,11 +22,16 @@ import { authorizeRpcHostAccess, } from '../../services/access/rpcHostAccessAuthorizer.js' import { getUserAgents, getUserContexts } from '../../services/directory/index.js' +import { + admitHostMessage, + respondHostMessageAdmissionFailure, +} from '../../services/hostMessageAdmission.js' import { type DirectRunAttributionBindingService, DirectRunBindingConflictError, } from '../../services/tracing/directRunAttributionBindingService.js' import { parseDirectRunBindingRequest } from '../../services/tracing/directRunBindingRequest.js' +import type { ParsedDirectRunBindingRequest } from '../../services/tracing/directRunBindingRequest.js' type RpcAccessUsersRouterOptions = { bindingService: Pick @@ -119,6 +124,62 @@ async function bindDirectRunWithinBudget( } } +async function respondWithAuthorizedHostConnection( + req: RpcAuthedRequest, + res: Response, + gateway: K8sGateway, + directory: RpcHostAccessDirectory | undefined, + bindingService: Pick, + bindingBudgetMs: number, + binding: ParsedDirectRunBindingRequest | null +): Promise { + const userId = String(req.params.userId || '').trim() + const hostRef = String(req.params.hostRef || '').trim() + const claims = req.rpcAuth + if (!claims) { + denyHostAccess(req, res, 'claims_missing') + return + } + const authorization = await authorizeRpcHostAccess(gateway, claims, userId, hostRef, directory) + if (!authorization.authorized) { + denyHostAccess(req, res, authorization.reason) + return + } + if (!binding) { + res.status(200).json(authorization.connection) + return + } + const bindingStatus = await bindDirectRunWithinBudget( + bindingService, + { + ...binding, + hostRef, + identityIssuer: config.rpcJwtIssuer, + actorHumanSub: claims.sub, + userId: claims.sub, + teamId: claims.teamId, + }, + bindingBudgetMs + ) + if (bindingStatus === 'recorded') { + res.status(200).json({ ...authorization.connection, bindingStatus: 'recorded' }) + return + } + if (bindingStatus === 'conflict') { + res.status(409).json({ error: 'direct_run_binding_conflict' }) + return + } + req.log?.warn( + { + event: 'governed_trace_operational_error', + scope: 'agent_run', + reason: 'attribution_binding_unavailable', + }, + 'direct run attribution binding unavailable after host authorization' + ) + res.status(200).json({ ...authorization.connection, bindingStatus: 'unavailable' }) +} + export function createRpcAccessUsersRouter( gateway: K8sGateway, options: RpcAccessUsersRouterOptions @@ -287,8 +348,6 @@ export function createRpcAccessUsersRouter( express.json({ limit: '2kb', strict: true }), async (req: RpcAuthedRequest, res, next) => { try { - const userId = String(req.params.userId || '').trim() - const hostRef = String(req.params.hostRef || '').trim() const claims = req.rpcAuth const binding = parseDirectRunBindingRequest(req.body) if (!claims) { @@ -300,53 +359,70 @@ export function createRpcAccessUsersRouter( return } - const authorization = await authorizeRpcHostAccess( + await respondWithAuthorizedHostConnection( + req, + res, gateway, - claims, - userId, - hostRef, - directory + directory, + bindingService, + bindingBudgetMs, + binding ) - if (!authorization.authorized) { - denyHostAccess(req, res, authorization.reason) + } catch (error) { + next(error) + } + } + ) + + // A message-only operation so ordinary sends never charge unrelated shared Host reads. + // The empty object represents an ordinary send; the existing exact binding body is + // accepted for direct-run-bound sends. Both consume the same subject-wide bucket. + router.post( + `${hostAccessPath}/message-resolution`, + requireValidRpcAccessToken('host:message:invoke'), + express.json({ limit: '2kb', strict: true }), + async (req: RpcAuthedRequest, res, next) => { + try { + const claims = req.rpcAuth + const body = req.body + const ordinary = + body !== null && + typeof body === 'object' && + !Array.isArray(body) && + Object.keys(body).length === 0 + const binding = ordinary ? null : parseDirectRunBindingRequest(body) + if (!ordinary && !binding) { + res.status(400).json({ error: 'invalid_direct_run_binding' }) return } - - const bindingStatus = await bindDirectRunWithinBudget( - bindingService, - { - ...binding, - hostRef, - identityIssuer: config.rpcJwtIssuer, - actorHumanSub: claims.sub, - userId: claims.sub, - teamId: claims.teamId, - }, - bindingBudgetMs - ) - if (bindingStatus === 'recorded') { - res.status(200).json({ - ...authorization.connection, - bindingStatus: 'recorded', - }) + const userId = String(req.params.userId || '').trim() + const hostRef = String(req.params.hostRef || '').trim() + if (!claims || claims.sub !== userId || !hostRef || !claims.hostRefs.includes(hostRef)) { + denyHostAccess( + req, + res, + !claims + ? 'claims_missing' + : claims.sub !== userId + ? 'subject_mismatch' + : 'host_claim_missing' + ) return } - if (bindingStatus === 'conflict') { - res.status(409).json({ error: 'direct_run_binding_conflict' }) + const admission = await admitHostMessage(claims.sub) + if (admission.status !== 'allowed') { + respondHostMessageAdmissionFailure(res, admission) return } - req.log?.warn( - { - event: 'governed_trace_operational_error', - scope: 'agent_run', - reason: 'attribution_binding_unavailable', - }, - 'direct run attribution binding unavailable after host authorization' + await respondWithAuthorizedHostConnection( + req, + res, + gateway, + directory, + bindingService, + bindingBudgetMs, + binding ) - res.status(200).json({ - ...authorization.connection, - bindingStatus: 'unavailable', - }) } catch (error) { next(error) } diff --git a/control-api/src/services/hostMessageAdmission.ts b/control-api/src/services/hostMessageAdmission.ts new file mode 100644 index 0000000000..cc510e695e --- /dev/null +++ b/control-api/src/services/hostMessageAdmission.ts @@ -0,0 +1,55 @@ +import type { Response } from 'express' +import { config } from '../config.js' +import { type RateLimitCheck, checkAndIncrement } from './rateLimiterService.js' + +export function hostMessageAdmissionBucketKey(verifiedSubject: string): string { + return `host-message-admission:${verifiedSubject}` +} + +export type HostMessageAdmission = + | { status: 'allowed'; check: RateLimitCheck } + | { status: 'limited'; check: RateLimitCheck } + | { status: 'unavailable' } + +/** Shared authority for legacy messages and the later v2 chat.message.invoke checkpoint. */ +export async function admitHostMessage( + verifiedSubject: string, + check: typeof checkAndIncrement = checkAndIncrement +): Promise { + if (!verifiedSubject) return { status: 'unavailable' } + let result: RateLimitCheck + try { + result = await check(hostMessageAdmissionBucketKey(verifiedSubject), config.hostMessageRlPerMin) + } catch { + return { status: 'unavailable' } + } + if ( + !result || + result.backendAvailable !== true || + typeof result.allowed !== 'boolean' || + !Number.isSafeInteger(result.count) || + result.count < 1 || + !Number.isSafeInteger(result.resetMs) || + result.allowed !== result.count <= config.hostMessageRlPerMin + ) { + return { status: 'unavailable' } + } + return { status: result.allowed ? 'allowed' : 'limited', check: result } +} + +/** The same response contract can be used by the PR2 checkpoint after cascade. */ +export function respondHostMessageAdmissionFailure( + res: Response, + admission: Exclude +): void { + if (admission.status === 'unavailable') { + res.status(503).json({ error: 'host_message_admission_unavailable' }) + return + } + const retryAfterSeconds = Math.max(1, Math.ceil((admission.check.resetMs - Date.now()) / 1000)) + res.setHeader('Retry-After', String(retryAfterSeconds)) + res.setHeader('X-RateLimit-Limit', String(config.hostMessageRlPerMin)) + res.setHeader('X-RateLimit-Remaining', '0') + res.setHeader('X-RateLimit-Reset', String(Math.floor(admission.check.resetMs / 1000))) + res.status(429).json({ error: 'Too Many Requests', retryAfterSeconds }) +} diff --git a/control-api/test/routes.hostMessageAdmission.test.ts b/control-api/test/routes.hostMessageAdmission.test.ts new file mode 100644 index 0000000000..eb561b5bc3 --- /dev/null +++ b/control-api/test/routes.hostMessageAdmission.test.ts @@ -0,0 +1,206 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import request from 'supertest' +import { fetchHostConnectionFromControlApi } from '../../rpc-proxy/src/services/controlApiRestService.js' +import type { K8sGateway } from '../src/k8s.js' +import { createRpcAccessUsersRouter } from '../src/routes/rpc-access/users.js' +import { signRpcAccessToken } from '../src/utils/auth/rpcAuthToken.js' + +const directory = vi.hoisted(() => ({ + getUserAgents: vi.fn(), + getCurrentTeam: vi.fn(), + getTeamAgents: vi.fn(), + getUserContexts: vi.fn(), +})) +const limiter = vi.hoisted(() => ({ checkAndIncrement: vi.fn() })) + +vi.mock('../src/services/directory/index.js', () => directory) +vi.mock('../src/services/rateLimiterService.js', () => limiter) + +const USER_A = '00000000-0000-4000-8000-000000000001' +const USER_B = '00000000-0000-4000-8000-000000000002' +const ROUTE = (user: string, host: string) => + `/rpc/access/users/${user}/mcp-hosts/${host}/message-resolution` +const BINDING = { + runId: '00000000-0000-4000-8000-000000000123', + sessionId: 'session-a', + origin: 'direct_chat', +} + +function token(user: string, hosts: string[], scopes = ['host:message:invoke']) { + return signRpcAccessToken({ + sub: user, + typ: 'user', + teamId: 'team-a', + role: 'member', + scopes: scopes as ['host:message:invoke'], + hostRefs: hosts, + jti: `jti-${user}`, + }) +} + +function app() { + const gateway = { + listResource: vi.fn(async () => [ + { metadata: { name: 'host-a' }, spec: { enabled: true } }, + { metadata: { name: 'host-b' }, spec: { enabled: true } }, + ]), + } + const bindingService = { bind: vi.fn(async () => ({ status: 'created' })) } + const server = express() + server.use(createRpcAccessUsersRouter(gateway as unknown as K8sGateway, { bindingService })) + return { server, gateway, bindingService } +} + +function send( + server: express.Express, + user: string, + host: string, + body: object, + jwt = token(user, [host]) +) { + return request(server).post(ROUTE(user, host)).set('x-rpc-access-token', jwt).send(body) +} + +describe('R44-H1 legacy Host-message admission', () => { + beforeEach(() => { + vi.clearAllMocks() + directory.getUserAgents.mockImplementation(async (userId: string) => ({ + userId, + agentNames: ['host-a', 'host-b'], + })) + directory.getCurrentTeam.mockResolvedValue({ id: 'team-a', role: 'member' }) + directory.getTeamAgents.mockResolvedValue({ agentNames: ['host-a', 'host-b'] }) + const counts = new Map() + limiter.checkAndIncrement.mockImplementation(async (key: string, max: number) => { + const count = (counts.get(key) ?? 0) + 1 + counts.set(key, count) + return { + allowed: count <= max, + remaining: Math.max(0, max - count), + resetMs: Date.now() + 30_000, + windowStartMs: Date.now() - 30_000, + count, + backendAvailable: true, + } + }) + }) + + it('allows 60 ordinary and bound sends in one subject bucket, then denies before Host work', async () => { + const { server, gateway, bindingService } = app() + const jwt = token(USER_A, ['host-a', 'host-b']) + for (let index = 0; index < 60; index += 1) { + const host = index % 2 ? 'host-b' : 'host-a' + const body = index % 2 ? { ...BINDING, sessionId: `session-${index}` } : {} + await send(server, USER_A, host, body, jwt).expect(200) + } + const resolverCalls = gateway.listResource.mock.calls.length + const bindingCalls = bindingService.bind.mock.calls.length + const denied = await send( + server, + USER_A, + 'host-b', + { ...BINDING, sessionId: 'rotated' }, + jwt + ).expect(429) + expect(denied.body).toEqual({ + error: 'Too Many Requests', + retryAfterSeconds: expect.any(Number), + }) + expect(Number(denied.headers['retry-after'])).toBeGreaterThan(0) + for (const header of ['x-ratelimit-limit', 'x-ratelimit-remaining', 'x-ratelimit-reset']) { + expect(denied.headers[header]).toBeDefined() + } + expect(gateway.listResource).toHaveBeenCalledTimes(resolverCalls) + expect(bindingService.bind).toHaveBeenCalledTimes(bindingCalls) + expect(new Set(limiter.checkAndIncrement.mock.calls.map(call => call[0])).size).toBe(1) + const fetchImpl = vi.fn(async (url: string, init: RequestInit) => { + const rpcPath = new URL(url).pathname.split('/rpc/access')[1] + expect(rpcPath).toBeDefined() + const produced = await request(server) + .post(`/rpc/access${rpcPath}`) + .set('x-rpc-access-token', jwt) + .send(JSON.parse(String(init.body))) + return new Response(produced.text, { + status: produced.status, + headers: produced.headers as Record, + }) + }) as unknown as typeof fetch + await expect( + fetchHostConnectionFromControlApi(USER_A, 'host-b', jwt, { + messageResolution: true, + fetchImpl, + }) + ).rejects.toMatchObject({ + status: 429, + body: { error: 'Too Many Requests', retryAfterSeconds: expect.any(Number) }, + headers: { 'x-ratelimit-limit': '60' }, + }) + expect(gateway.listResource).toHaveBeenCalledTimes(resolverCalls) + await send(server, USER_B, 'host-a', {}).expect(200) + }) + + it('leaves shared GET and the existing strict POST outside message admission', async () => { + const { server } = app() + const jwt = token(USER_A, ['host-a']) + await request(server) + .get(`/rpc/access/users/${USER_A}/mcp-hosts/host-a`) + .set('x-rpc-access-token', jwt) + .expect(200) + await request(server) + .post(`/rpc/access/users/${USER_A}/mcp-hosts/host-a`) + .set('x-rpc-access-token', jwt) + .send({}) + .expect(400) + expect(limiter.checkAndIncrement).not.toHaveBeenCalled() + }) + + it('rejects invalid auth, scope, claim, and binding before charging', async () => { + const { server, gateway } = app() + await request(server).post(ROUTE(USER_A, 'host-a')).send({}).expect(401) + await send(server, USER_A, 'host-a', {}, 'bad-token').expect(401) + await send( + server, + USER_A, + 'host-a', + {}, + token(USER_A, ['host-a'], ['host:status:read']) + ).expect(403) + await send(server, USER_A, 'host-a', {}, token(USER_B, ['host-a'])).expect(403) + await send(server, USER_A, 'host-b', {}, token(USER_A, ['host-a'])).expect(403) + await send(server, USER_A, 'host-a', { runId: 'bad' }).expect(400) + await request(server) + .post(ROUTE(USER_A, 'host-a')) + .set('x-rpc-access-token', token(USER_A, ['host-a'])) + .set('content-type', 'application/json') + .send('{"runId":') + .expect(400) + expect(limiter.checkAndIncrement).not.toHaveBeenCalled() + expect(gateway.listResource).not.toHaveBeenCalled() + }) + + it('charges a valid request before a later live grant denial', async () => { + const { server, gateway } = app() + directory.getUserAgents.mockResolvedValue({ userId: USER_A, agentNames: [] }) + directory.getTeamAgents.mockResolvedValue({ agentNames: [] }) + await send(server, USER_A, 'host-a', {}).expect(403) + expect(limiter.checkAndIncrement).toHaveBeenCalledOnce() + expect(gateway.listResource).not.toHaveBeenCalled() + }) + + it.each([ + ['backend unavailable', { allowed: true, backendAvailable: false, count: 0 }], + ['missing result', undefined], + [ + 'malformed result', + { allowed: true, backendAvailable: true, count: 61, resetMs: Date.now() + 30_000 }, + ], + ])('fails closed when %s', async (_name, result) => { + limiter.checkAndIncrement.mockResolvedValue(result) + const { server, gateway } = app() + await send(server, USER_A, 'host-a', {}) + .expect(503) + .expect({ error: 'host_message_admission_unavailable' }) + expect(gateway.listResource).not.toHaveBeenCalled() + }) +}) diff --git a/control-api/test/services.rateLimiter.realPostgres.integration.test.ts b/control-api/test/services.rateLimiter.realPostgres.integration.test.ts index e2399df372..be88291000 100644 --- a/control-api/test/services.rateLimiter.realPostgres.integration.test.ts +++ b/control-api/test/services.rateLimiter.realPostgres.integration.test.ts @@ -2,6 +2,10 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { randomBytes } from 'node:crypto' import { Pool } from 'pg' import { initDb } from '../src/db.js' +import { + admitHostMessage, + hostMessageAdmissionBucketKey, +} from '../src/services/hostMessageAdmission.js' import { acquireRateLimitConcurrencyLease, checkAndIncrementWithQuery, @@ -24,6 +28,8 @@ describeRealPostgres('rate limiter atomicity on real PostgreSQL', () => { const database = `rate_limiter_${randomBytes(6).toString('hex')}` const bucketKey = `real-pg:${randomBytes(8).toString('hex')}` const preAdmissionKey = `host-artifact-pre-admission:user-${randomBytes(8).toString('hex')}` + const messageSubject = `user-${randomBytes(8).toString('hex')}` + const messageAdmissionKey = hostMessageAdmissionBucketKey(messageSubject) const connectionString = databaseUrl( adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', database @@ -41,7 +47,7 @@ describeRealPostgres('rate limiter atomicity on real PostgreSQL', () => { afterAll(async () => { await pool?.query('DELETE FROM rate_limit_buckets WHERE bucket_key = ANY($1)', [ - [bucketKey, preAdmissionKey], + [bucketKey, preAdmissionKey, messageAdmissionKey], ]) await pool?.end() if (!adminPool) return @@ -107,6 +113,41 @@ describeRealPostgres('rate limiter atomicity on real PostgreSQL', () => { } }) + it('enforces Host-message admission atomically across independent PostgreSQL sessions', async () => { + const clients = [await pool.connect(), await pool.connect()] + try { + const runs = await Promise.all( + clients.map(async (client, clientIndex) => { + const checks = [] + for (let index = clientIndex; index < 61; index += clients.length) { + checks.push( + await admitHostMessage(messageSubject, (key, max) => + checkAndIncrementWithQuery( + (text, values) => client.query(text, values), + key, + max, + windowStartMs, + 1 + ) + ) + ) + } + return checks + }) + ) + const checks = runs.flat() + expect(checks.filter(check => check.status === 'allowed')).toHaveLength(60) + expect(checks.filter(check => check.status === 'limited')).toHaveLength(1) + const persisted = await pool.query<{ count: string }>( + 'SELECT count FROM rate_limit_buckets WHERE bucket_key = $1 AND window_start_ms = $2', + [messageAdmissionKey, windowStartMs] + ) + expect(Number(persisted.rows[0]?.count)).toBe(61) + } finally { + clients.forEach(client => client.release()) + } + }) + it('serializes the production bounded advisory lease across independent PostgreSQL sessions', async () => { const key = `gfs-upload-slot:${randomBytes(8).toString('hex')}` const first = await pool.connect() diff --git a/desktop-app/src/__tests__/appService.hostWaking.test.ts b/desktop-app/src/__tests__/appService.hostWaking.test.ts index 056f6b79ca..e66017a5fc 100644 --- a/desktop-app/src/__tests__/appService.hostWaking.test.ts +++ b/desktop-app/src/__tests__/appService.hostWaking.test.ts @@ -149,6 +149,21 @@ describe('AppService.invokeHostMessage host-availability plumbing', () => { await expect(svc.invokeHostMessage('myhost', { content: 'hi' })).rejects.toBe(plain) }) + it('surfaces Host-message admission 429 without token remint or resend', async () => { + const svc = makeService() + const limited = new ApiError( + '429 Too Many Requests', + 429, + '{"error":"Too Many Requests","retryAfterSeconds":19}', + '19' + ) + mockInvokeHostMessage.mockRejectedValue(limited) + await expect(svc.invokeHostMessage('myhost', { content: 'hi' })).rejects.toBe(limited) + expect(mockInvokeHostMessage).toHaveBeenCalledTimes(1) + expect(mockRpcTokenManagerClear).not.toHaveBeenCalled() + expect(mockGetOrIssue).toHaveBeenCalledTimes(1) + }) + it('forwards an optional piggybacked model through the field allow-list (R2 Option A)', async () => { const svc = makeService() mockInvokeHostMessage.mockResolvedValue({ taskId: 't1', status: 'pending' }) diff --git a/desktop-app/src/__tests__/rpcProxyClient.test.ts b/desktop-app/src/__tests__/rpcProxyClient.test.ts index e4170c8733..197917cc27 100644 --- a/desktop-app/src/__tests__/rpcProxyClient.test.ts +++ b/desktop-app/src/__tests__/rpcProxyClient.test.ts @@ -1,6 +1,32 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiError } from '../httpClient.js' import { RpcProxyClient } from '../rpcProxyClient.js' +describe('RpcProxyClient.invokeHostMessage admission', () => { + it('surfaces 429 and Retry-After without an automatic fresh send', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + Response.json( + { error: 'Too Many Requests', retryAfterSeconds: 19 }, + { status: 429, headers: { 'Retry-After': '19' } } + ) + ) + vi.stubGlobal('fetch', fetchMock) + try { + const client = new RpcProxyClient() + const rejection = await client + .invokeHostMessage('rpc-token', 'host-a', { content: 'hello' }) + .catch(error => error) + expect(rejection).toBeInstanceOf(ApiError) + expect(rejection).toMatchObject({ status: 429, retryAfter: '19' }) + expect(fetchMock).toHaveBeenCalledTimes(1) + } finally { + vi.unstubAllGlobals() + } + }) +}) + // Mock the config module so the module-level url() helper uses a fixed base URL vi.mock('../config.js', () => ({ config: { diff --git a/rpc-proxy/src/__tests__/host-message-admission-transport.test.ts b/rpc-proxy/src/__tests__/host-message-admission-transport.test.ts new file mode 100644 index 0000000000..5e7c6b27ac --- /dev/null +++ b/rpc-proxy/src/__tests__/host-message-admission-transport.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from 'vitest' +import { + ControlApiHostMessageAdmissionError, + fetchHostConnectionFromControlApi, +} from '../services/controlApiRestService.js' + +const USER = '00000000-0000-4000-8000-000000000001' + +describe('R44-H1 Control API Host-message admission transport', () => { + it('uses the dedicated POST for ordinary and direct-run-bound sends', async () => { + const fetchImpl = vi.fn(async (_url: string, _init: RequestInit) => + Response.json({ + userId: USER, + hostRef: 'host-a', + url: 'http://host-a.mcp-host.svc.cluster.local:8080', + bindingStatus: 'recorded', + }) + ) as unknown as typeof fetch + + await fetchHostConnectionFromControlApi(USER, 'host-a', 'jwt', { + messageResolution: true, + fetchImpl, + }) + await fetchHostConnectionFromControlApi(USER, 'host-a', 'jwt', { + messageResolution: true, + directRunBinding: { + runId: '00000000-0000-4000-8000-000000000123', + sessionId: 'session-a', + origin: 'direct_chat', + }, + fetchImpl, + }) + const calls = vi.mocked(fetchImpl).mock.calls + expect(calls).toHaveLength(2) + expect(String(calls[0][0])).toMatch(/\/mcp-hosts\/host-a\/message-resolution$/) + expect(calls[0][1]).toMatchObject({ method: 'POST', body: '{}' }) + expect(calls[1][1]).toMatchObject({ method: 'POST' }) + expect(JSON.parse(String(calls[1][1]?.body))).toMatchObject({ sessionId: 'session-a' }) + }) + + it('retains canonical 429 metadata as a typed admission error', async () => { + const fetchImpl = vi.fn(async () => + Response.json( + { error: 'Too Many Requests', retryAfterSeconds: 19 }, + { + status: 429, + headers: { + 'Retry-After': '19', + 'X-RateLimit-Limit': '60', + 'X-RateLimit-Remaining': '0', + 'X-RateLimit-Reset': '1234567890', + }, + } + ) + ) as unknown as typeof fetch + await expect( + fetchHostConnectionFromControlApi(USER, 'host-a', 'jwt', { + messageResolution: true, + fetchImpl, + }) + ).rejects.toMatchObject({ + name: 'ControlApiHostMessageAdmissionError', + status: 429, + body: { error: 'Too Many Requests', retryAfterSeconds: 19 }, + headers: { 'retry-after': '19', 'x-ratelimit-limit': '60' }, + } satisfies Partial) + }) + + it('retains the typed strict-store 503', async () => { + const fetchImpl = vi.fn(async () => + Response.json({ error: 'host_message_admission_unavailable' }, { status: 503 }) + ) as unknown as typeof fetch + await expect( + fetchHostConnectionFromControlApi(USER, 'host-a', 'jwt', { + messageResolution: true, + fetchImpl, + }) + ).rejects.toMatchObject({ + status: 503, + body: { error: 'host_message_admission_unavailable' }, + }) + }) +}) diff --git a/rpc-proxy/src/__tests__/sender-assignment.test.ts b/rpc-proxy/src/__tests__/sender-assignment.test.ts index 60f1591f0c..64f33c19f8 100644 --- a/rpc-proxy/src/__tests__/sender-assignment.test.ts +++ b/rpc-proxy/src/__tests__/sender-assignment.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import express from 'express' import request from 'supertest' import { createRpcRouter } from '../routes/rpc.js' +import { ControlApiHostMessageAdmissionError } from '../services/controlApiRestService.js' const authTokenMock = vi.hoisted(() => ({ verifyRpcToken: vi.fn(), @@ -145,11 +146,54 @@ describe('POST /rpc/hosts/:hostRef/messages — sender assignment invariant', () 'user-uuid-abc', 'chatllm', 'token', - { teamId: null } + { teamId: null, messageResolution: true } ) const forwardedBody = serviceMock.forwardHostMessageToHost.mock.calls[0][1] expect(forwardedBody.metadata).toEqual({ accessScope: 'user' }) }) + + it('forwards a Control API message-admission 429 without waking or forwarding', async () => { + serviceMock.resolveHostConnectionForUser.mockRejectedValue( + new ControlApiHostMessageAdmissionError( + 429, + { error: 'Too Many Requests', retryAfterSeconds: 19 }, + { + 'retry-after': '19', + 'x-ratelimit-limit': '60', + 'x-ratelimit-remaining': '0', + 'x-ratelimit-reset': '1234567890', + } + ) + ) + const response = await request(makeApp()) + .post('/rpc/hosts/chatllm/messages') + .set('authorization', 'Bearer token') + .send({ content: 'hi' }) + .expect(429) + expect(response.body).toEqual({ error: 'Too Many Requests', retryAfterSeconds: 19 }) + expect(response.headers['retry-after']).toBe('19') + expect(response.headers['x-ratelimit-limit']).toBe('60') + expect(response.headers['x-ratelimit-remaining']).toBe('0') + expect(response.headers['x-ratelimit-reset']).toBe('1234567890') + expect(serviceMock.forwardHostMessageToHost).not.toHaveBeenCalled() + }) + + it('preserves a strict admission-store 503 without forwarding', async () => { + serviceMock.resolveHostConnectionForUser.mockRejectedValue( + new ControlApiHostMessageAdmissionError( + 503, + { error: 'host_message_admission_unavailable' }, + {} + ) + ) + const response = await request(makeApp()) + .post('/rpc/hosts/chatllm/messages') + .set('authorization', 'Bearer token') + .send({ content: 'hi' }) + .expect(503) + expect(response.body).toEqual({ error: 'host_message_admission_unavailable' }) + expect(serviceMock.forwardHostMessageToHost).not.toHaveBeenCalled() + }) }) describe('POST /rpc/hosts/:hostRef/messages — structured file references (#666)', () => { diff --git a/rpc-proxy/src/__tests__/wake-and-hold-route.test.ts b/rpc-proxy/src/__tests__/wake-and-hold-route.test.ts index 08fea4551e..9ca86e5c82 100644 --- a/rpc-proxy/src/__tests__/wake-and-hold-route.test.ts +++ b/rpc-proxy/src/__tests__/wake-and-hold-route.test.ts @@ -118,6 +118,10 @@ describe('POST /rpc/hosts/:hostRef/messages wake-and-hold triggers', () => { const response = await postMessage(makeApp()).expect(200) expect(response.body).toEqual({ success: true, taskId: 't-1' }) + expect(serviceMock.resolveHostConnectionForUser).toHaveBeenCalledTimes(1) + expect(serviceMock.resolveHostConnectionForUser.mock.calls[0]?.[3]).toMatchObject({ + messageResolution: true, + }) expect(controlApiMock.requestHostWakeFromControlApi).toHaveBeenCalledTimes(1) expect(controlApiMock.requestHostWakeFromControlApi).toHaveBeenCalledWith('chatllm', 'token') expect(serviceMock.forwardHostMessageToHost).toHaveBeenCalledTimes(2) @@ -162,6 +166,7 @@ describe('POST /rpc/hosts/:hostRef/messages wake-and-hold triggers', () => { await postMessage(makeApp()).expect(200) + expect(serviceMock.resolveHostConnectionForUser).toHaveBeenCalledTimes(1) expect(serviceMock.forwardHostMessageToHost).toHaveBeenCalledTimes(2) const firstBody = serviceMock.forwardHostMessageToHost.mock.calls[0][1] as { messageId?: unknown @@ -196,6 +201,7 @@ describe('POST /rpc/hosts/:hostRef/messages wake-and-hold triggers', () => { await send() await send() + expect(serviceMock.resolveHostConnectionForUser).toHaveBeenCalledTimes(2) expect(serviceMock.forwardHostMessageToHost).toHaveBeenCalledTimes(2) const firstId = ( serviceMock.forwardHostMessageToHost.mock.calls[0][1] as { messageId?: string } diff --git a/rpc-proxy/src/routes/rpc.ts b/rpc-proxy/src/routes/rpc.ts index f55e478686..5b21501ed4 100644 --- a/rpc-proxy/src/routes/rpc.ts +++ b/rpc-proxy/src/routes/rpc.ts @@ -14,6 +14,7 @@ import { jsonBody } from '../middleware/jsonBody.js' import { rpcInvocationContext } from '../rpcAccessContext.js' import { ControlApiConnectorsRejectedError, + ControlApiHostMessageAdmissionError, type HostWakeApiResponse, fetchUserConnectorsFromControlApi, requestHostWakeFromControlApi, @@ -552,6 +553,7 @@ export function createRpcRouter(): Router { const wakeDeadlineMs = Date.now() + config.wakeMaxHoldMs const host = await resolveHostConnectionForUser(auth.sub, hostRef, rpcAccessToken, { teamId: auth.teamId, + messageResolution: true, ...(traceContext.sessionId ? { directRunBinding: { @@ -633,6 +635,15 @@ export function createRpcRouter(): Router { // the wake path (which would re-forward an already-delivered message). res.status(200).json(upstreamResponse) } catch (error) { + if (error instanceof ControlApiHostMessageAdmissionError) { + if (error.status === 429) { + for (const [name, value] of Object.entries(error.headers)) { + res.setHeader(name, value) + } + } + res.status(error.status).json(error.body) + return + } const rejection = controlApiHostAccessRejection(error) if (rejection) { respondControlApiHostAccessRejection(res, rejection) diff --git a/rpc-proxy/src/services/controlApiRestService.ts b/rpc-proxy/src/services/controlApiRestService.ts index 91e07b46a3..a814e024fc 100644 --- a/rpc-proxy/src/services/controlApiRestService.ts +++ b/rpc-proxy/src/services/controlApiRestService.ts @@ -71,6 +71,17 @@ export class ControlApiArtifactReadRateLimitedError extends Error { } } +export class ControlApiHostMessageAdmissionError extends Error { + constructor( + readonly status: 429 | 503, + readonly body: { error: string; retryAfterSeconds?: number }, + readonly headers: Record + ) { + super(`Control API Host-message admission returned ${status}`) + this.name = 'ControlApiHostMessageAdmissionError' + } +} + // Typed rejection for the connectors read-model, mirroring the host rail above. // A generic Error collapses to 500 in the app error handler, which the desktop // reads as non-refreshable — so an expired/rotated rpc access token (401) would @@ -228,6 +239,7 @@ export async function fetchHostConnectionFromControlApi( rpcAccessToken: string, options: { directRunBinding?: DirectRunBindingRequest + messageResolution?: boolean fetchImpl?: typeof fetch } = {} ): Promise { @@ -258,23 +270,69 @@ async function fetchHostConnectionForPath( directRunBinding?: DirectRunBindingRequest fetchImpl?: typeof fetch artifactRead?: boolean + messageResolution?: boolean } = {} ): Promise { const directRunBinding = options.directRunBinding const hostAccessPath = `${controlApiBaseUrl()}/rpc/access/users/${encodeURIComponent(userId)}/mcp-hosts/${encodeURIComponent(hostRef)}` + const messageResolution = options.messageResolution === true const response = await (options.fetchImpl ?? fetch)( - options.artifactRead ? `${hostAccessPath}/artifact-read` : hostAccessPath, + options.artifactRead + ? `${hostAccessPath}/artifact-read` + : messageResolution + ? `${hostAccessPath}/message-resolution` + : hostAccessPath, { - method: directRunBinding ? 'POST' : 'GET', + method: messageResolution || directRunBinding ? 'POST' : 'GET', headers: { ...controlApiHeaders(rpcAccessToken), - ...(directRunBinding ? { 'content-type': 'application/json' } : {}), + ...(messageResolution || directRunBinding ? { 'content-type': 'application/json' } : {}), }, - ...(directRunBinding ? { body: JSON.stringify(directRunBinding) } : {}), + ...(messageResolution || directRunBinding + ? { body: JSON.stringify(directRunBinding ?? {}) } + : {}), signal: upstreamAbortSignal(), } ) + if (messageResolution && (response.status === 429 || response.status === 503)) { + const body = (await response.json().catch(() => null)) as { + error?: unknown + retryAfterSeconds?: unknown + } | null + if (response.status === 503 && body?.error === 'host_message_admission_unavailable') { + throw new ControlApiHostMessageAdmissionError( + 503, + { error: 'host_message_admission_unavailable' }, + {} + ) + } + const headerNames = [ + 'retry-after', + 'x-ratelimit-limit', + 'x-ratelimit-remaining', + 'x-ratelimit-reset', + ] as const + const headers = Object.fromEntries( + headerNames.map(name => [name, response.headers.get(name)]) + ) as Record + if ( + response.status === 429 && + body?.error === 'Too Many Requests' && + typeof body.retryAfterSeconds === 'number' && + Number.isSafeInteger(body.retryAfterSeconds) && + body.retryAfterSeconds > 0 && + headerNames.every(name => headers[name] !== null) + ) { + throw new ControlApiHostMessageAdmissionError( + 429, + { error: 'Too Many Requests', retryAfterSeconds: body.retryAfterSeconds }, + headers as Record + ) + } + throw new Error('Control API Host-message admission response was invalid') + } + if (options.artifactRead && response.status === 429) { const retryAfterHeader = Number(response.headers.get('retry-after')) let retryAfterBody = Number.NaN diff --git a/rpc-proxy/src/services/mcpProxyService.ts b/rpc-proxy/src/services/mcpProxyService.ts index e807be1031..5e15ab1223 100644 --- a/rpc-proxy/src/services/mcpProxyService.ts +++ b/rpc-proxy/src/services/mcpProxyService.ts @@ -84,10 +84,12 @@ export async function resolveHostConnectionForUser( teamId?: string | null requestId?: string directRunBinding?: DirectRunBindingRequest + messageResolution?: boolean } ): Promise { const host = await fetchHostConnectionFromControlApi(userId, hostRef, rpcAccessToken, { directRunBinding: edgeContext?.directRunBinding, + messageResolution: edgeContext?.messageResolution, }) if (isHostAccessDenied(host)) return host diff --git a/rpc-proxy/test/routes.rpc.test.ts b/rpc-proxy/test/routes.rpc.test.ts index 3527e6a7fb..0e086ea961 100644 --- a/rpc-proxy/test/routes.rpc.test.ts +++ b/rpc-proxy/test/routes.rpc.test.ts @@ -20,6 +20,16 @@ const serviceMock = vi.hoisted(() => ({ })) const controlApiMock = vi.hoisted(() => ({ + ControlApiHostMessageAdmissionError: class ControlApiHostMessageAdmissionError extends Error { + constructor( + public readonly status: 429 | 503, + public readonly body: { error: string; retryAfterSeconds?: number }, + public readonly headers: Record + ) { + super(`Control API Host-message admission returned ${status}`) + this.name = 'ControlApiHostMessageAdmissionError' + } + }, ControlApiHostAccessRejectedError: class ControlApiHostAccessRejectedError extends Error { constructor( public readonly status: number, @@ -247,6 +257,7 @@ describe('routes/rpc', () => { 'agent2', 'token', { + messageResolution: true, teamId: 'team-1', } ) @@ -283,6 +294,7 @@ describe('routes/rpc', () => { 'agent2', 'rpc-token', { + messageResolution: true, teamId: 'team-1', directRunBinding: { runId: expect.any(String), From c02e27d57ccc05ab479e48f92b4381d25ced1bde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 24 Sep 2026 10:38:20 -0600 Subject: [PATCH 233/528] chore(control-api): align PR1 with dev limiter contract Apply explicit outage modes; retain strict Spec 48 and session error semantics. --- .../middleware/externalUserRateLimitPolicy.ts | 1 + control-api/src/routes/external/access.ts | 2 + control-api/src/routes/rpc-access/users.ts | 2 + .../test/externalUserRateLimitPolicy.test.ts | 2 + .../test/routes.external.oauthGrants.test.ts | 3 + ...s.external.workflowApprovalMediums.test.ts | 3 + ...s.externalAccess.rateLimitOrdering.test.ts | 11 +- .../test/routes.externalAccess.test.ts | 2 + .../test/routes.externalDirectory.test.ts | 1 + ...es.externalInvitations.unavailable.test.ts | 1 + ...alNotificationPreferencesRateLimit.test.ts | 3 + ...es.externalSessionRenewalRateLimit.test.ts | 103 +++++++++++++++++- .../routes.externalTeamsInvitations.test.ts | 3 + .../test/routes.externalWorkflows.test.ts | 2 + ...ccessUsersArtifactRateLimitDefense.test.ts | 1 + .../test/routes.rpcAccessUsersMcpHost.test.ts | 7 ++ 16 files changed, 141 insertions(+), 6 deletions(-) diff --git a/control-api/src/middleware/externalUserRateLimitPolicy.ts b/control-api/src/middleware/externalUserRateLimitPolicy.ts index 8e3d65383e..47d0687114 100644 --- a/control-api/src/middleware/externalUserRateLimitPolicy.ts +++ b/control-api/src/middleware/externalUserRateLimitPolicy.ts @@ -120,6 +120,7 @@ export function externalUserRateLimitOptions( return { bucketType: policy.bucketType, maxPerMinute: policy.maxPerMinute, + onBackendUnavailable: 'process-memory' as const, getBucketKey: (req: Request) => keyFor(operation, stage, req as AuthenticatedRequest), onLimited: sendLimited, } diff --git a/control-api/src/routes/external/access.ts b/control-api/src/routes/external/access.ts index ae730b3dc1..d7e74a226a 100644 --- a/control-api/src/routes/external/access.ts +++ b/control-api/src/routes/external/access.ts @@ -181,6 +181,7 @@ export function createExternalAccessRouter(gateway: K8sGateway): Router { rateLimitMiddleware({ bucketType: 'external_access_catalog', maxPerMinute: CATALOG_RATE_LIMIT_PER_MINUTE, + onBackendUnavailable: 'process-memory', getBucketKey: req => `external_access_catalog:${(req as ExternalAuthedRequest).externalAuth!.userId}`, onLimited: (req, res, retryAfterSeconds) => @@ -228,6 +229,7 @@ export function createExternalAccessRouter(gateway: K8sGateway): Router { rateLimitMiddleware({ bucketType: 'external_access_resolve', maxPerMinute: RESOLVE_RATE_LIMIT_PER_MINUTE, + onBackendUnavailable: 'process-memory', getBucketKey: req => `external_access_resolve:${(req as ExternalAuthedRequest).externalAuth!.userId}`, onLimited: (req, res, retryAfterSeconds) => diff --git a/control-api/src/routes/rpc-access/users.ts b/control-api/src/routes/rpc-access/users.ts index 9fc77ed52a..bb17e3a289 100644 --- a/control-api/src/routes/rpc-access/users.ts +++ b/control-api/src/routes/rpc-access/users.ts @@ -302,6 +302,7 @@ export function createRpcAccessUsersRouter( rateLimitMiddleware({ bucketType: 'host_artifact_pre_admission', maxPerMinute: config.hostArtifactReadRlPerMin, + onBackendUnavailable: 'closed', getBucketKey: req => { const subject = (req as ArtifactReadRequest).rpcAuth?.sub // Auth middleware should always populate the subject before this @@ -328,6 +329,7 @@ export function createRpcAccessUsersRouter( rateLimitMiddleware({ bucketType: 'host_artifact_read', maxPerMinute: config.hostArtifactReadRlPerMin, + onBackendUnavailable: 'closed', getBucketKey: req => { const artifactRead = req as ArtifactReadRequest const subject = artifactRead.rpcAuth?.sub diff --git a/control-api/test/externalUserRateLimitPolicy.test.ts b/control-api/test/externalUserRateLimitPolicy.test.ts index d71ac725eb..526a511ec2 100644 --- a/control-api/test/externalUserRateLimitPolicy.test.ts +++ b/control-api/test/externalUserRateLimitPolicy.test.ts @@ -39,6 +39,7 @@ function allowedResult() { resetMs: Date.now() + 60_000, windowStartMs: Date.now(), count: 1, + backendAvailable: true, } } @@ -153,6 +154,7 @@ describe('external user rate-limit policy', () => { remaining: 0, resetMs: Date.now() + 60_000, windowStartMs: Date.now(), + backendAvailable: true, count: 11, }) const handler = vi.fn((_req, res) => res.status(204).end()) diff --git a/control-api/test/routes.external.oauthGrants.test.ts b/control-api/test/routes.external.oauthGrants.test.ts index 568dab77bc..70e6f8fbd0 100644 --- a/control-api/test/routes.external.oauthGrants.test.ts +++ b/control-api/test/routes.external.oauthGrants.test.ts @@ -59,6 +59,7 @@ describe('/external/oauth/grants', () => { remaining: 29, resetMs: Date.now() + 60_000, windowStartMs: Date.now(), + backendAvailable: true, }) }) @@ -156,6 +157,7 @@ describe('/external/oauth/grants', () => { remaining: 0, resetMs: Date.now() + 60_000, windowStartMs: Date.now(), + backendAvailable: true, }) const res = await request(makeApp()).get('/external/oauth/grants') @@ -179,6 +181,7 @@ describe('/external/oauth/grants', () => { remaining: 0, resetMs: Date.now() + 60_000, windowStartMs: Date.now(), + backendAvailable: true, }) const res = await request(makeApp()).delete( diff --git a/control-api/test/routes.external.workflowApprovalMediums.test.ts b/control-api/test/routes.external.workflowApprovalMediums.test.ts index c367c78c3f..eda9d33d4f 100644 --- a/control-api/test/routes.external.workflowApprovalMediums.test.ts +++ b/control-api/test/routes.external.workflowApprovalMediums.test.ts @@ -97,6 +97,7 @@ describe('external workflow approval medium routes', () => { remaining: 29, resetMs: Date.now() + 60_000, windowStartMs: Date.now(), + backendAvailable: true, }) linkSessionMock.createMediumLinkSession.mockReset() slackTargetMock.attachSlackTargetsToAccounts.mockImplementation( @@ -318,6 +319,7 @@ describe('external workflow approval medium routes', () => { remaining: 0, resetMs: Date.now() + 60_000, windowStartMs: Date.now(), + backendAvailable: true, }) const response = await request(makeApp()).get('/external/workflow-approval-mediums') @@ -337,6 +339,7 @@ describe('external workflow approval medium routes', () => { remaining: 0, resetMs: Date.now() + 60_000, windowStartMs: Date.now(), + backendAvailable: true, }) const response = await request(makeApp()) diff --git a/control-api/test/routes.externalAccess.rateLimitOrdering.test.ts b/control-api/test/routes.externalAccess.rateLimitOrdering.test.ts index d212224364..fe94405ad6 100644 --- a/control-api/test/routes.externalAccess.rateLimitOrdering.test.ts +++ b/control-api/test/routes.externalAccess.rateLimitOrdering.test.ts @@ -95,6 +95,7 @@ describe('external access authenticated rate-limit ordering', () => { remaining: 0, resetMs: Date.now() + 60_000, windowStartMs: Date.now(), + backendAvailable: true, count: 11, }) }) @@ -155,6 +156,7 @@ describe('external access authenticated rate-limit ordering', () => { resetMs: Date.now() + 60_000, windowStartMs: Date.now(), count: 1, + backendAvailable: true, }) mocks.resolvePolicy.mockResolvedValue( effectivePolicy({ serveCatalog: false, actionContextV2: false }) @@ -182,9 +184,12 @@ describe('external access authenticated rate-limit ordering', () => { remaining: 9, resetMs: Date.now() + 60_000, windowStartMs: Date.now(), + backendAvailable: true, count: 1, }) - mocks.resolvePolicy.mockRejectedValue(new Error('readiness unavailable')) + mocks.resolvePolicy + .mockResolvedValueOnce(effectivePolicy()) + .mockRejectedValueOnce(new Error('readiness unavailable')) const response = await request(app()) [method === 'GET' ? 'get' : 'post'](path) @@ -193,7 +198,7 @@ describe('external access authenticated rate-limit ordering', () => { expect(response.status).toBe(503) expect(response.body).toMatchObject({ error: { code: 'authority_unavailable' } }) - expect(mocks.resolvePolicy).toHaveBeenCalledTimes(1) + expect(mocks.resolvePolicy).toHaveBeenCalledTimes(2) } ) @@ -203,6 +208,7 @@ describe('external access authenticated rate-limit ordering', () => { remaining: 9, resetMs: Date.now() + 60_000, windowStartMs: Date.now(), + backendAvailable: true, count: 1, }) const teamId = '40000000-0000-4000-8000-000000000004' @@ -244,6 +250,7 @@ describe('external access authenticated rate-limit ordering', () => { remaining: 9, resetMs: Date.now() + 60_000, windowStartMs: Date.now(), + backendAvailable: true, count: 1, }) mocks.validateV2 diff --git a/control-api/test/routes.externalAccess.test.ts b/control-api/test/routes.externalAccess.test.ts index 8fffc70f21..9a057925f3 100644 --- a/control-api/test/routes.externalAccess.test.ts +++ b/control-api/test/routes.externalAccess.test.ts @@ -103,6 +103,7 @@ describe('external user-access contracts', () => { resetMs: Date.now() + 60_000, windowStartMs: Date.now(), count: 1, + backendAvailable: true, }) }) @@ -188,6 +189,7 @@ describe('external user-access contracts', () => { resetMs: Date.now() + 60_000, windowStartMs: Date.now(), count: 11, + backendAvailable: true, }) const response = await request(app()) diff --git a/control-api/test/routes.externalDirectory.test.ts b/control-api/test/routes.externalDirectory.test.ts index 68129b9038..734969ad75 100644 --- a/control-api/test/routes.externalDirectory.test.ts +++ b/control-api/test/routes.externalDirectory.test.ts @@ -116,6 +116,7 @@ describe('external directory authorization', () => { resetMs, windowStartMs: resetMs - 60_000, count: 31, + backendAvailable: true, }) const response = await request(app()) diff --git a/control-api/test/routes.externalInvitations.unavailable.test.ts b/control-api/test/routes.externalInvitations.unavailable.test.ts index 132e4c6dea..46f56ddd3c 100644 --- a/control-api/test/routes.externalInvitations.unavailable.test.ts +++ b/control-api/test/routes.externalInvitations.unavailable.test.ts @@ -198,6 +198,7 @@ describe('external invitation routes when the hub is unavailable', () => { resetMs: Date.now() + 60_000, windowStartMs: Date.now(), count: 11, + backendAvailable: true, }) const response = await request(app()).get('/external/invitations/pending') diff --git a/control-api/test/routes.externalNotificationPreferencesRateLimit.test.ts b/control-api/test/routes.externalNotificationPreferencesRateLimit.test.ts index 7d0d0dbd7e..b9aa261a45 100644 --- a/control-api/test/routes.externalNotificationPreferencesRateLimit.test.ts +++ b/control-api/test/routes.externalNotificationPreferencesRateLimit.test.ts @@ -47,6 +47,7 @@ describe('external notification preference rate limits', () => { remaining: 29, resetMs: Date.now() + 60_000, windowStartMs: Date.now(), + backendAvailable: true, }) }) @@ -57,6 +58,7 @@ describe('external notification preference rate limits', () => { remaining: 0, resetMs: Date.now() + 60_000, windowStartMs: Date.now(), + backendAvailable: true, }) const response = await request(makeApp()).get('/external/me/notification-preferences') @@ -76,6 +78,7 @@ describe('external notification preference rate limits', () => { remaining: 0, resetMs: Date.now() + 60_000, windowStartMs: Date.now(), + backendAvailable: true, }) const response = await request(makeApp()) diff --git a/control-api/test/routes.externalSessionRenewalRateLimit.test.ts b/control-api/test/routes.externalSessionRenewalRateLimit.test.ts index 6be6c9fd57..56ad40cfb8 100644 --- a/control-api/test/routes.externalSessionRenewalRateLimit.test.ts +++ b/control-api/test/routes.externalSessionRenewalRateLimit.test.ts @@ -1,7 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import express from 'express' import request from 'supertest' +import { clerumErrorHandler } from '../src/http/errorHandler.js' import { createExternalAuthRouter } from '../src/routes/external/auth.js' +import { + ExternalSessionBackendUnavailableError, + runExternalSessionDatabaseOperation, +} from '../src/services/auth/sessionDatabaseFailure.js' const sessions = vi.hoisted(() => ({ authenticateExternalUserSession: vi.fn(), @@ -17,10 +22,11 @@ function app() { const value = express() value.use(express.json()) value.use(createExternalAuthRouter({} as never)) + value.use(clerumErrorHandler) return value } -describe('external session renewal rate limiting', () => { +describe('external session route error boundaries and renewal rate limiting', () => { beforeEach(() => { vi.clearAllMocks() sessions.authenticateExternalUserSession.mockResolvedValue({ @@ -43,6 +49,17 @@ describe('external session renewal rate limiting', () => { identity: { absoluteExpiresAt: new Date('2030-01-01T00:00:00.000Z') }, }, }) + limiter.checkAndIncrement.mockResolvedValue({ + allowed: true, + remaining: 10, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + count: 0, + backendAvailable: true, + }) + }) + + it('stops a rate-limited authenticated renewal before rotating the session', async () => { limiter.checkAndIncrement .mockResolvedValueOnce({ allowed: true, @@ -50,6 +67,7 @@ describe('external session renewal rate limiting', () => { resetMs: Date.now() + 60_000, windowStartMs: Date.now(), count: 1, + backendAvailable: true, }) .mockResolvedValueOnce({ allowed: false, @@ -57,10 +75,8 @@ describe('external session renewal rate limiting', () => { resetMs: Date.now() + 60_000, windowStartMs: Date.now(), count: 11, + backendAvailable: true, }) - }) - - it('stops a rate-limited authenticated renewal before rotating the session', async () => { const response = await request(app()) .post('/external/auth/session/renew') .set('x-forwarded-for', '203.0.113.44') @@ -80,4 +96,83 @@ describe('external session renewal rate limiting', () => { ) expect(sessions.renewExternalUserSession).not.toHaveBeenCalled() }) + + it('keeps an application authentication error on the sanitized 500 path', async () => { + vi.stubEnv('NODE_ENV', 'production') + sessions.authenticateExternalUserSession.mockRejectedValueOnce( + new Error('private session policy invariant') + ) + + try { + const response = await request(app()) + .post('/external/auth/verify') + .send({ token: 'session-token' }) + + expect(response.status).toBe(500) + expect(response.body).toMatchObject({ error: 'Internal Server Error' }) + expect(JSON.stringify(response.body)).not.toContain('private session policy invariant') + expect(JSON.stringify(response.headers)).not.toContain('private session policy invariant') + expect(response.headers['retry-after']).toBeUndefined() + } finally { + vi.unstubAllEnvs() + } + }) + + it('preserves canonical 503 semantics for a database-boundary outage', async () => { + let databaseFailure: unknown + try { + await runExternalSessionDatabaseOperation(async () => { + throw new Error('private pg-pool acquire sentinel') + }) + } catch (error) { + databaseFailure = error + } + expect(databaseFailure).toBeInstanceOf(ExternalSessionBackendUnavailableError) + + sessions.authenticateExternalUserSession.mockRejectedValueOnce(databaseFailure) + + const response = await request(app()) + .post('/external/auth/verify') + .send({ token: 'session-token' }) + + expect(response.status).toBe(503) + expect(response.body).toEqual({ error: 'session_backend_unavailable', retryAfterSeconds: 2 }) + expect(response.headers['retry-after']).toBe('2') + expect(response.headers['cache-control']).toBe('no-store') + expect(JSON.stringify(response.body)).not.toContain('private pg-pool acquire sentinel') + expect(JSON.stringify(response.headers)).not.toContain('private pg-pool acquire sentinel') + }) + + it('returns the authenticated claims on successful session verification', async () => { + const response = await request(app()) + .post('/external/auth/verify') + .send({ token: 'session-token' }) + + expect(response.status).toBe(200) + expect(response.body).toEqual({ + claims: { + userId: 'user-1', + email: 'user@example.test', + teamId: null, + role: 'member', + }, + }) + }) + + it.each([ + ['invalid', 401, 'invalid_session'], + ['upgrade_required', 426, 'upgrade_required'], + ] as const)( + 'preserves the %s authentication result', + async (status, expectedStatus, expectedCode) => { + sessions.authenticateExternalUserSession.mockResolvedValueOnce({ status }) + + const response = await request(app()) + .post('/external/auth/verify') + .send({ token: 'session-token' }) + + expect(response.status).toBe(expectedStatus) + expect(response.body.error.code).toBe(expectedCode) + } + ) }) diff --git a/control-api/test/routes.externalTeamsInvitations.test.ts b/control-api/test/routes.externalTeamsInvitations.test.ts index dda98b62d7..74efdd2ca6 100644 --- a/control-api/test/routes.externalTeamsInvitations.test.ts +++ b/control-api/test/routes.externalTeamsInvitations.test.ts @@ -65,6 +65,7 @@ describe('legacy external team invitation responses', () => { remaining: 29, resetMs: Date.now() + 60_000, windowStartMs: Date.now(), + backendAvailable: true, }) }) @@ -89,6 +90,7 @@ describe('legacy external team invitation responses', () => { remaining: 0, resetMs: Date.now() + 60_000, windowStartMs: Date.now(), + backendAvailable: true, }) const response = await request(app()).get('/external/teams/team-1/members') @@ -108,6 +110,7 @@ describe('legacy external team invitation responses', () => { remaining: 0, resetMs: Date.now() + 60_000, windowStartMs: Date.now(), + backendAvailable: true, }) const response = await request(app()) diff --git a/control-api/test/routes.externalWorkflows.test.ts b/control-api/test/routes.externalWorkflows.test.ts index 05cc98ac1d..eb41b14025 100644 --- a/control-api/test/routes.externalWorkflows.test.ts +++ b/control-api/test/routes.externalWorkflows.test.ts @@ -151,6 +151,7 @@ describe('routes/external/workflows', () => { remaining: 29, resetMs: Date.now() + 60_000, windowStartMs: Date.now(), + backendAvailable: true, count: 1, }) mockAuthenticateExternalUserSession.mockImplementation(token => { @@ -303,6 +304,7 @@ describe('routes/external/workflows', () => { remaining: 0, resetMs: Date.now() + 60_000, windowStartMs: Date.now(), + backendAvailable: true, count: limit + 1, }) diff --git a/control-api/test/routes.rpcAccessUsersArtifactRateLimitDefense.test.ts b/control-api/test/routes.rpcAccessUsersArtifactRateLimitDefense.test.ts index 6e52c68365..0618da96d8 100644 --- a/control-api/test/routes.rpcAccessUsersArtifactRateLimitDefense.test.ts +++ b/control-api/test/routes.rpcAccessUsersArtifactRateLimitDefense.test.ts @@ -63,6 +63,7 @@ describe('Host artifact limiter defensive attribution', () => { resetMs: Date.now() + 60_000, windowStartMs: Date.now(), count: 1, + backendAvailable: true, }) }) diff --git a/control-api/test/routes.rpcAccessUsersMcpHost.test.ts b/control-api/test/routes.rpcAccessUsersMcpHost.test.ts index 1cfebe0809..6667064d9c 100644 --- a/control-api/test/routes.rpcAccessUsersMcpHost.test.ts +++ b/control-api/test/routes.rpcAccessUsersMcpHost.test.ts @@ -332,6 +332,7 @@ describe('GET /rpc/access/users/:userId/mcp-hosts/:hostRef — happy path', () = resetMs: Date.now() + 60_000, windowStartMs: Date.now(), count: 1, + backendAvailable: true, }) }) @@ -400,6 +401,7 @@ describe('GET /rpc/access/users/:userId/mcp-hosts/:hostRef/artifact-read', () => resetMs: Date.now() + 60_000, windowStartMs: Date.now(), count: 1, + backendAvailable: true, }) svc.getUserAgents.mockReset() }) @@ -421,6 +423,7 @@ describe('GET /rpc/access/users/:userId/mcp-hosts/:hostRef/artifact-read', () => resetMs: Date.now() + 60_000, windowStartMs: Date.now(), count: 1, + backendAvailable: true, } }) svc.getUserAgents.mockImplementation(async () => { @@ -461,6 +464,7 @@ describe('GET /rpc/access/users/:userId/mcp-hosts/:hostRef/artifact-read', () => resetMs: Date.now() + 60_000, windowStartMs: Date.now(), count, + backendAvailable: true, } }) @@ -502,6 +506,7 @@ describe('GET /rpc/access/users/:userId/mcp-hosts/:hostRef/artifact-read', () => resetMs: Date.now() + 60_000, windowStartMs: Date.now(), count, + backendAvailable: true, } }) @@ -536,6 +541,7 @@ describe('GET /rpc/access/users/:userId/mcp-hosts/:hostRef/artifact-read', () => resetMs: Date.now() + 60_000, windowStartMs: Date.now(), count: 1, + backendAvailable: true, }) const { app } = buildApp([], { sub: 'user-a', hostRefs: ['host-a', 'host-b'] }) @@ -594,6 +600,7 @@ describe('GET /rpc/access/users/:userId/mcp-hosts/:hostRef/artifact-read', () => resetMs: Date.now() + 60_000, windowStartMs: Date.now(), count: 31, + backendAvailable: true, })) await request(app) From 98c36363041a6d2c07c1c316ec05d049b91f8e77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 24 Sep 2026 10:55:27 -0600 Subject: [PATCH 234/528] fix(control-api): r46-b1 add issued-at to real pg fixtures Stable finding ID: R46-B1. Class-check: verifyExternalSessionToken test mocks -> 4 sites -> all now emit iat. Repro-test: Real-PG 4-suite lane fails before limiter assertions at a92d816d6620. --- ...ternalGfsRateLimitFailClosed.realPostgres.integration.test.ts | 1 + ...s.externalGfsRateLimitStress.realPostgres.integration.test.ts | 1 + ...outes.rateLimitProcessMemory.realPostgres.integration.test.ts | 1 + ...es.rateLimiterPoolSaturation.realPostgres.integration.test.ts | 1 + 4 files changed, 4 insertions(+) diff --git a/control-api/test/routes.externalGfsRateLimitFailClosed.realPostgres.integration.test.ts b/control-api/test/routes.externalGfsRateLimitFailClosed.realPostgres.integration.test.ts index 8e11bfa789..7cd50ee4aa 100644 --- a/control-api/test/routes.externalGfsRateLimitFailClosed.realPostgres.integration.test.ts +++ b/control-api/test/routes.externalGfsRateLimitFailClosed.realPostgres.integration.test.ts @@ -22,6 +22,7 @@ vi.mock('../src/utils/auth/externalSessionAuthToken.js', () => ({ teamId: null, role: 'member', authGeneration: 1, + iat: Math.floor(Date.now() / 1000) - 60, exp: Math.floor(Date.now() / 1000) + 3600, } }, diff --git a/control-api/test/routes.externalGfsRateLimitStress.realPostgres.integration.test.ts b/control-api/test/routes.externalGfsRateLimitStress.realPostgres.integration.test.ts index 65d56cd43b..773f5c5c54 100644 --- a/control-api/test/routes.externalGfsRateLimitStress.realPostgres.integration.test.ts +++ b/control-api/test/routes.externalGfsRateLimitStress.realPostgres.integration.test.ts @@ -48,6 +48,7 @@ vi.mock('../src/utils/auth/externalSessionAuthToken.js', () => ({ teamId: null, role: 'member', authGeneration: 1, + iat: Math.floor(Date.now() / 1000) - 60, exp: Math.floor(Date.now() / 1000) + 3600, } }, diff --git a/control-api/test/routes.rateLimitProcessMemory.realPostgres.integration.test.ts b/control-api/test/routes.rateLimitProcessMemory.realPostgres.integration.test.ts index 99ea343a07..dfc8bc6c4c 100644 --- a/control-api/test/routes.rateLimitProcessMemory.realPostgres.integration.test.ts +++ b/control-api/test/routes.rateLimitProcessMemory.realPostgres.integration.test.ts @@ -26,6 +26,7 @@ vi.mock('../src/utils/auth/externalSessionAuthToken.js', () => ({ teamId: null, role: 'member', authGeneration: 1, + iat: Math.floor(Date.now() / 1000) - 60, exp: Math.floor(Date.now() / 1000) + 3600, } }, diff --git a/control-api/test/services.rateLimiterPoolSaturation.realPostgres.integration.test.ts b/control-api/test/services.rateLimiterPoolSaturation.realPostgres.integration.test.ts index b9e2c325cb..b1e44cf280 100644 --- a/control-api/test/services.rateLimiterPoolSaturation.realPostgres.integration.test.ts +++ b/control-api/test/services.rateLimiterPoolSaturation.realPostgres.integration.test.ts @@ -282,6 +282,7 @@ describeRealPostgres('rate limiter under pool saturation', () => { teamId: '33333333-cccc-4ccc-8ccc-333333333333', role: 'member', authGeneration: 1, + iat: Math.floor(Date.now() / 1000) - 60, exp: Math.floor(Date.now() / 1000) + 3600, }) const app = mod.createApp(new mod.MockGateway()) From 6519c197c8d82b0c7c3f632a22d465e9225aeea9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 24 Sep 2026 11:07:11 -0600 Subject: [PATCH 235/528] test(minikube): r46-b2 keep hardening assertions active Class-check: shared shell-test helper has one lease_env expansion site; both empty modes now execute on Bash 3.2. Repro-test: c6657e7 exits 0 before the first assertion; corrected hardening suite executes 23 checks. --- scripts/tests/test-minikube-build-images-hardening.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/tests/test-minikube-build-images-hardening.sh b/scripts/tests/test-minikube-build-images-hardening.sh index 62409bf60f..64541a1e9e 100755 --- a/scripts/tests/test-minikube-build-images-hardening.sh +++ b/scripts/tests/test-minikube-build-images-hardening.sh @@ -38,7 +38,7 @@ fake_run_mode() { wait "$descendant" ;; death) - kill -TERM "$BASHPID" + kill -TERM "${BASHPID:-$$}" sleep 5 ;; exit-*) exit "${mode#exit-}" ;; @@ -234,7 +234,7 @@ run_build() { "${LEASE_ENV_KEY}=$LEASE_VALUE" ) ;; - missing|verify) ;; + missing|verify) lease_env=("TEST_LEASE_ENV_UNUSED=") ;; *) printf 'unknown lease mode: %s\n' "$lease_mode" >&2; return 99 ;; esac From 35a19e495986098021abf22c014627ca28fa76ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 24 Sep 2026 12:36:39 -0600 Subject: [PATCH 236/528] fix(control-api): r47-b1 preserve session failure provenance Keep outage classification at database I/O boundaries; preserve application and policy errors as 500s. Class-check: external-session outage sites -> 1 canonical DB-boundary classifier; middleware maps only its nominal provenance type. Repro-test: middleware.externalSessionAuth.lifecycle.test.ts -> RED at 82d66ef7ce668d330a28479a1108dbbe77dc433f: expected 500, got 503. --- .../services/access/accessDatabaseQuery.ts | 38 ++++++++++-- .../access/userAccessRuntimePolicy.ts | 17 +++++- .../auth/externalSessionAuthentication.ts | 12 +++- .../services/auth/sessionDatabaseFailure.ts | 52 ++++++++++++++++ .../src/services/auth/userSessionService.ts | 59 +++++++++++++++---- .../test/sessionDatabaseFailure.test.ts | 45 ++++++++++++++ ...serAccessCatalogReadinessIsolation.test.ts | 22 +++++++ 7 files changed, 225 insertions(+), 20 deletions(-) create mode 100644 control-api/src/services/auth/sessionDatabaseFailure.ts create mode 100644 control-api/test/sessionDatabaseFailure.test.ts diff --git a/control-api/src/services/access/accessDatabaseQuery.ts b/control-api/src/services/access/accessDatabaseQuery.ts index 4b700dc5b5..da7b7cb211 100644 --- a/control-api/src/services/access/accessDatabaseQuery.ts +++ b/control-api/src/services/access/accessDatabaseQuery.ts @@ -93,6 +93,7 @@ export async function runAccessDatabaseQuery( chargeRows?: boolean chargeProducer?: boolean statementTimeoutMs?: number + onDatabaseFailure?: (error: unknown) => unknown } = {} ) { const run = @@ -113,7 +114,15 @@ export async function runAccessDatabaseQuery( } signal.addEventListener('abort', onAbort, { once: true }) try { - const result = await db.query(text, values) + let result + try { + result = await db.query(text, values) + } catch (error) { + if (isOwnedStatementTimeout(error, provenance, queryStartedAt)) { + throw new AccessBudgetExceededError('deadline', true) + } + throw options.onDatabaseFailure ? options.onDatabaseFailure(error) : error + } if (options.statementTimeoutMs !== undefined && typeof db === 'object' && db !== null) { rememberStatementTimeout(db, options.statementTimeoutMs) } @@ -137,7 +146,8 @@ export async function runAccessDatabaseQuery( async function acquireAccessDatabaseClient( budget: AccessExecutionBudget, - connectionPool: Pick + connectionPool: Pick, + onDatabaseFailure?: (error: unknown) => unknown ): Promise { let acquired: PoolClient | undefined let acquiredReleased = false @@ -177,7 +187,7 @@ async function acquireAccessDatabaseClient( if (settled) return settled = true signal.removeEventListener('abort', rejectForAbort) - reject(error) + reject(onDatabaseFailure ? onDatabaseFailure(error) : error) } ) }) @@ -194,21 +204,29 @@ export async function withAccessDatabaseTransaction( options: { mode?: 'read_only' | 'read_write' | 'caller_configured' connectionPool?: Pick + onDatabaseFailure?: (error: unknown) => unknown } = {} ): Promise { budget.assertActive() - const client = await acquireAccessDatabaseClient(budget, options.connectionPool ?? pool) + const client = await acquireAccessDatabaseClient( + budget, + options.connectionPool ?? pool, + options.onDatabaseFailure + ) let transactionStarted = false let commitSent = false let releaseError: Error | boolean | undefined const query = (text: string, values?: unknown[]) => - runAccessDatabaseQuery(client, budget, text, values ?? []) + runAccessDatabaseQuery(client, budget, text, values ?? [], { + onDatabaseFailure: options.onDatabaseFailure, + }) const budgetedDb = Object.assign(Object.create(client), { query }) as DbClient try { budget.assertActive() await runAccessDatabaseQuery(client, budget, 'BEGIN', [], { chargeRows: false, chargeProducer: false, + onDatabaseFailure: options.onDatabaseFailure, }) transactionStarted = true if (options.mode !== 'caller_configured') { @@ -217,6 +235,7 @@ export async function withAccessDatabaseTransaction( await runAccessDatabaseQuery(client, budget, 'SET TRANSACTION READ ONLY', [], { chargeRows: false, chargeProducer: false, + onDatabaseFailure: options.onDatabaseFailure, }) } await runAccessDatabaseQuery( @@ -224,7 +243,12 @@ export async function withAccessDatabaseTransaction( budget, `SELECT set_config('statement_timeout', $1, true)`, [`${statementTimeoutMs}ms`], - { chargeRows: false, chargeProducer: false, statementTimeoutMs } + { + chargeRows: false, + chargeProducer: false, + statementTimeoutMs, + onDatabaseFailure: options.onDatabaseFailure, + } ) } const result = await work( @@ -235,6 +259,7 @@ export async function withAccessDatabaseTransaction( await runAccessDatabaseQuery(client, budget, 'COMMIT', [], { chargeRows: false, chargeProducer: false, + onDatabaseFailure: options.onDatabaseFailure, }) return result } catch (error) { @@ -245,6 +270,7 @@ export async function withAccessDatabaseTransaction( await runAccessDatabaseQuery(client, budget, 'ROLLBACK', [], { chargeRows: false, chargeProducer: false, + onDatabaseFailure: options.onDatabaseFailure, }) } catch (rollbackError) { releaseError = rollbackError instanceof Error ? rollbackError : true diff --git a/control-api/src/services/access/userAccessRuntimePolicy.ts b/control-api/src/services/access/userAccessRuntimePolicy.ts index 96e513d3a0..b63cf73de5 100644 --- a/control-api/src/services/access/userAccessRuntimePolicy.ts +++ b/control-api/src/services/access/userAccessRuntimePolicy.ts @@ -38,6 +38,8 @@ type RuntimePolicyOptions = Readonly<{ now?: Date catalogActivationRecord?: string catalogReadiness?: boolean + databaseFailureMode?: 'fallback' | 'throw' + onDatabaseFailure?: (error: unknown) => unknown }> type CatalogComparisonEvidence = Readonly<{ @@ -294,7 +296,8 @@ export async function resolveEffectiveUserAccessPolicy( WHERE environment_id = $1 AND source_family = ANY($2::text[]) ORDER BY source_family`, - [canonicalEnvironmentId(), OPERATIONAL_SOURCE_FAMILIES] + [canonicalEnvironmentId(), OPERATIONAL_SOURCE_FAMILIES], + { onDatabaseFailure: options.onDatabaseFailure } ) const result = options.db ? await query(options.db) @@ -309,7 +312,10 @@ export async function resolveEffectiveUserAccessPolicy( ORDER BY source_family`, [canonicalEnvironmentId(), OPERATIONAL_SOURCE_FAMILIES] ), - { mode: 'read_only' } + { + mode: 'read_only', + onDatabaseFailure: options.onDatabaseFailure, + } ) const states = parseSourceStates(result.rows as Record[]) return compileUserAccessPolicy( @@ -323,7 +329,12 @@ export async function resolveEffectiveUserAccessPolicy( ) ) } catch (error) { - if (error instanceof UserAccessPolicyConfigurationError) throw error + if ( + error instanceof UserAccessPolicyConfigurationError || + options.databaseFailureMode === 'throw' + ) { + throw error + } return compileUserAccessPolicy(intent, unavailableReadiness()) } finally { ownedBudget?.close() diff --git a/control-api/src/services/auth/externalSessionAuthentication.ts b/control-api/src/services/auth/externalSessionAuthentication.ts index b48a6de96a..9d17935c67 100644 --- a/control-api/src/services/auth/externalSessionAuthentication.ts +++ b/control-api/src/services/auth/externalSessionAuthentication.ts @@ -11,6 +11,7 @@ import { } from '../access/userAccessPolicy.js' import { resolveEffectiveUserAccessPolicy } from '../access/userAccessRuntimePolicy.js' import { legacyExternalSessionAuthGeneration } from './legacyV1Generation.js' +import { externalSessionDatabaseFailure } from './sessionDatabaseFailure.js' import { type IssuedUserSession, renewUserSession, @@ -149,6 +150,7 @@ export async function authenticateExternalUserSessionIdentity( const validation = await validateUserSessionClaims(v2Claims, { budget: options.budget, touch: false, + onDatabaseFailure: externalSessionDatabaseFailure, }) if (validation.status !== 'valid') return validation return { @@ -170,6 +172,7 @@ export async function authenticateExternalUserSessionIdentity( if (!v1Claims) return { status: 'invalid', reason: 'invalid_representation' } const validation = await validateLegacyUserSession(token, v1Claims, { budget: options.budget, + onDatabaseFailure: externalSessionDatabaseFailure, }) if (validation.status !== 'valid') return validation const authGeneration = legacyExternalSessionAuthGeneration(v1Claims) @@ -209,6 +212,7 @@ async function revalidateExternalUserSessionIdentity( identity.tokenClaims as UserSessionV2Claims, { budget: options.budget, + onDatabaseFailure: externalSessionDatabaseFailure, } ) if (validation.status !== 'valid') return validation @@ -222,6 +226,7 @@ async function revalidateExternalUserSessionIdentity( const claims = identity.tokenClaims as AuthClaims const validation = await validateLegacyUserSession(token, claims, { budget: options.budget, + onDatabaseFailure: externalSessionDatabaseFailure, }) if (validation.status !== 'valid') return validation return identity @@ -236,7 +241,12 @@ export async function authenticateExternalUserSession( options: AuthenticationOptions ): Promise { const policy = - options.policy ?? (await resolveEffectiveUserAccessPolicy({ budget: options.budget })) + options.policy ?? + (await resolveEffectiveUserAccessPolicy({ + budget: options.budget, + databaseFailureMode: 'throw', + onDatabaseFailure: externalSessionDatabaseFailure, + })) const identity = options.identity ? await revalidateExternalUserSessionIdentity(token, options.identity, { budget: options.budget, diff --git a/control-api/src/services/auth/sessionDatabaseFailure.ts b/control-api/src/services/auth/sessionDatabaseFailure.ts new file mode 100644 index 0000000000..ce0fa19c23 --- /dev/null +++ b/control-api/src/services/auth/sessionDatabaseFailure.ts @@ -0,0 +1,52 @@ +import { DatabaseError } from 'pg' + +const BACKEND_UNAVAILABLE_SQLSTATE_CLASSES = new Set(['08', '53', '57', '58']) +const SYSTEM_ERROR_CODE = /^E[A-Z]+$/ +const SQLSTATE_CODE = /^[0-9A-Z]{5}$/ + +/** Trusted provenance that a real external-session database operation failed. */ +export class ExternalSessionBackendUnavailableError extends Error { + constructor(readonly cause: unknown) { + super('External session database is unavailable', { cause }) + this.name = 'ExternalSessionBackendUnavailableError' + } +} + +/** + * Classifies only failures passed from an actual external-session DB I/O + * boundary. Callers must not apply this to an authentication/business operation. + */ +export function externalSessionDatabaseFailure(error: unknown): unknown { + if (error instanceof ExternalSessionBackendUnavailableError) return error + if (error instanceof DatabaseError) { + return BACKEND_UNAVAILABLE_SQLSTATE_CLASSES.has(String(error.code).slice(0, 2)) + ? new ExternalSessionBackendUnavailableError(error) + : error + } + if (!(error instanceof Error)) return error + + const code = (error as { code?: unknown }).code + if (typeof code === 'string') { + if (SYSTEM_ERROR_CODE.test(code)) return new ExternalSessionBackendUnavailableError(error) + // Do not mistake SQL/application codes or Node's ERR_* codes for a pool + // transport failure merely because the driver surfaced a base Error. + if (SQLSTATE_CODE.test(code) || code.startsWith('ERR_')) return error + } + return error.constructor === Error ? new ExternalSessionBackendUnavailableError(error) : error +} + +export async function runExternalSessionDatabaseOperation( + operation: () => Promise +): Promise { + try { + return await operation() + } catch (error) { + throw externalSessionDatabaseFailure(error) + } +} + +export function isExternalSessionBackendUnavailableError( + error: unknown +): error is ExternalSessionBackendUnavailableError { + return error instanceof ExternalSessionBackendUnavailableError +} diff --git a/control-api/src/services/auth/userSessionService.ts b/control-api/src/services/auth/userSessionService.ts index 391dec94ed..ff4ab8abc0 100644 --- a/control-api/src/services/auth/userSessionService.ts +++ b/control-api/src/services/auth/userSessionService.ts @@ -65,10 +65,26 @@ export type IssuedUserSession = { function budgetedSessionDatabase( db: SessionDatabase, - budget: AccessExecutionBudget + budget: AccessExecutionBudget, + onDatabaseFailure?: (error: unknown) => unknown ): SessionDatabase { const query = (text: string, values?: unknown[]) => - runAccessDatabaseQuery(db, budget, text, values ?? []) + runAccessDatabaseQuery(db, budget, text, values ?? [], { onDatabaseFailure }) + return Object.assign(Object.create(db), { query }) as SessionDatabase +} + +function sessionDatabaseWithFailureProvenance( + db: SessionDatabase, + onDatabaseFailure?: (error: unknown) => unknown +): SessionDatabase { + if (!onDatabaseFailure) return db + const query = async (text: string, values?: unknown[]) => { + try { + return await db.query(text, values) + } catch (error) { + throw onDatabaseFailure(error) + } + } return Object.assign(Object.create(db), { query }) as SessionDatabase } @@ -285,7 +301,12 @@ export async function createUserSession( export async function validateUserSessionClaims( claims: UserSessionV2Claims, - options: { db?: SessionDatabase; budget?: AccessExecutionBudget; touch?: boolean } = {} + options: { + db?: SessionDatabase + budget?: AccessExecutionBudget + touch?: boolean + onDatabaseFailure?: (error: unknown) => unknown + } = {} ): Promise { const work = async (db: SessionDatabase) => { const row = await loadSessionForUpdate(db, claims.sid) @@ -293,11 +314,18 @@ export async function validateUserSessionClaims( return validateLoadedSession(db, row, claims, now, options.touch !== false) } if (options.db) { - return work(options.budget ? budgetedSessionDatabase(options.db, options.budget) : options.db) + return work( + options.budget + ? budgetedSessionDatabase(options.db, options.budget, options.onDatabaseFailure) + : sessionDatabaseWithFailureProvenance(options.db, options.onDatabaseFailure) + ) } return options.budget - ? withAccessDatabaseTransaction(options.budget, work, { mode: 'read_write' }) - : withTransaction(work) + ? withAccessDatabaseTransaction(options.budget, work, { + mode: 'read_write', + onDatabaseFailure: options.onDatabaseFailure, + }) + : withTransaction(work, { onDatabaseFailure: options.onDatabaseFailure }) } export async function renewUserSession( @@ -545,7 +573,12 @@ function legacySessionFingerprint(token: string): string { export async function validateLegacyUserSession( token: string, claims: AuthClaims, - options: { db?: SessionDatabase; budget?: AccessExecutionBudget; lockUser?: boolean } = {} + options: { + db?: SessionDatabase + budget?: AccessExecutionBudget + lockUser?: boolean + onDatabaseFailure?: (error: unknown) => unknown + } = {} ): Promise { const issuedAt = claims.iat if (!issuedAt) return { status: 'invalid', reason: 'invalid_legacy_representation' } @@ -609,9 +642,15 @@ export async function validateLegacyUserSession( } } const db = options.db ?? pool - if (!options.budget) return work(db) - if (options.db) return work(budgetedSessionDatabase(db, options.budget)) - return withAccessDatabaseTransaction(options.budget, work, { mode: 'read_only' }) + if (!options.budget) + return work(sessionDatabaseWithFailureProvenance(db, options.onDatabaseFailure)) + if (options.db) { + return work(budgetedSessionDatabase(db, options.budget, options.onDatabaseFailure)) + } + return withAccessDatabaseTransaction(options.budget, work, { + mode: 'read_only', + onDatabaseFailure: options.onDatabaseFailure, + }) } export async function revokeLegacyUserSession( diff --git a/control-api/test/sessionDatabaseFailure.test.ts b/control-api/test/sessionDatabaseFailure.test.ts new file mode 100644 index 0000000000..cd3d1f7308 --- /dev/null +++ b/control-api/test/sessionDatabaseFailure.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { pool, withTransaction } from '../src/db.js' +import { + ExternalSessionBackendUnavailableError, + externalSessionDatabaseFailure, +} from '../src/services/auth/sessionDatabaseFailure.js' + +describe('external session database failure provenance', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('marks a no-SQLSTATE transaction-acquire failure at the acquisition boundary', async () => { + const acquireFailure = new Error('pg-pool acquisition failed') + const connect = vi.spyOn(pool, 'connect').mockRejectedValueOnce(acquireFailure) + + await expect( + withTransaction(async () => undefined, { + onDatabaseFailure: externalSessionDatabaseFailure, + }) + ).rejects.toMatchObject({ + name: 'ExternalSessionBackendUnavailableError', + cause: acquireFailure, + } satisfies Partial) + expect(connect).toHaveBeenCalledTimes(1) + }) + + it('does not classify a plain transaction callback error as a database outage', async () => { + const applicationFailure = new Error('session policy invariant failed') + const query = vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }) + const release = vi.fn() + vi.spyOn(pool, 'connect').mockResolvedValueOnce({ query, release } as never) + + await expect( + withTransaction( + async () => { + throw applicationFailure + }, + { onDatabaseFailure: externalSessionDatabaseFailure } + ) + ).rejects.toBe(applicationFailure) + expect(query.mock.calls.map(([text]) => text)).toEqual(['BEGIN', 'ROLLBACK']) + expect(release).toHaveBeenCalledTimes(1) + }) +}) diff --git a/control-api/test/userAccessCatalogReadinessIsolation.test.ts b/control-api/test/userAccessCatalogReadinessIsolation.test.ts index 195bad1e14..5fade2ea64 100644 --- a/control-api/test/userAccessCatalogReadinessIsolation.test.ts +++ b/control-api/test/userAccessCatalogReadinessIsolation.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { ConfiguredUserAccessIntent } from '../src/services/access/userAccessPolicy.js' import { resolveEffectiveUserAccessPolicy } from '../src/services/access/userAccessRuntimePolicy.js' +import { externalSessionDatabaseFailure } from '../src/services/auth/sessionDatabaseFailure.js' const shadowIntent: ConfiguredUserAccessIntent = Object.freeze({ legacyLifecycle: 'issue_and_accept', @@ -52,4 +53,25 @@ describe('catalog readiness isolation', () => { ).rejects.toMatchObject({ code: 'readiness_snapshot_unavailable' }) expect(query).toHaveBeenCalledOnce() }) + + it('preserves database failure provenance for session authentication policy reads', async () => { + const acquireFailure = new Error('pg-pool query transport failure') + const query = vi.fn().mockRejectedValue(acquireFailure) + + await expect( + resolveEffectiveUserAccessPolicy({ + intent: shadowIntent, + db: { query } as never, + indexerEnabled: true, + readinessMaxAgeMs: 5_000, + catalogReadiness: true, + databaseFailureMode: 'throw', + onDatabaseFailure: externalSessionDatabaseFailure, + }) + ).rejects.toMatchObject({ + name: 'ExternalSessionBackendUnavailableError', + cause: acquireFailure, + }) + expect(query).toHaveBeenCalledOnce() + }) }) From 77b0aa86a92dec2343adf10d0e6037360a462468 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 24 Sep 2026 18:17:41 -0600 Subject: [PATCH 237/528] fix(control-api): stabilize future token test clock r51-m1 Class-check: future-iat boundary test in control-api/test -> 1 site; pin verify time. Repro-test: full Control API run at 485a5d7e80b0 failed the boundary assertion. --- control-api/test/userSessionToken.test.ts | 36 +++++++++++++---------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/control-api/test/userSessionToken.test.ts b/control-api/test/userSessionToken.test.ts index 8d43203016..75df086ff5 100644 --- a/control-api/test/userSessionToken.test.ts +++ b/control-api/test/userSessionToken.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import jwt from 'jsonwebtoken' import { config } from '../src/config.js' import { verifyAdminToken } from '../src/utils/auth/adminAuthToken.js' @@ -50,6 +50,7 @@ describe('user-session v2 token contract', () => { }) it('rejects wrong trust, shape, time, and authority-bearing claims', () => { + const verificationTime = Math.floor(Date.now() / 1000) const sign = (payload: Record, options: jwt.SignOptions = {}) => jwt.sign(payload, config.sessionJwtPrivateKey, { algorithm: 'RS256', @@ -79,22 +80,27 @@ describe('user-session v2 token contract', () => { const expired = signUserSessionV2Token(input, Math.floor(Date.now() / 1000) - 7200) const future = signUserSessionV2Token( input, - Math.floor(Date.now() / 1000) + USER_SESSION_V2_CLOCK_TOLERANCE_SECONDS + 1 + verificationTime + USER_SESSION_V2_CLOCK_TOLERANCE_SECONDS + 1 ) - for (const token of [ - wrongAudience, - wrongType, - wrongVersion, - forbiddenClaim, - unknownClaim, - missingSid, - malformedSid, - wrongAlgorithm, - expired, - future, - ]) { - expect(verifyUserSessionV2Token(token)).toBeNull() + const clock = vi.spyOn(Date, 'now').mockReturnValue(verificationTime * 1000) + try { + for (const token of [ + wrongAudience, + wrongType, + wrongVersion, + forbiddenClaim, + unknownClaim, + missingSid, + malformedSid, + wrongAlgorithm, + expired, + future, + ]) { + expect(verifyUserSessionV2Token(token)).toBeNull() + } + } finally { + clock.mockRestore() } }) From 24d84efb188f2acd254206c96f8a111a69177ffc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= Date: Thu, 24 Sep 2026 18:19:48 -0600 Subject: [PATCH 238/528] fix(control-api): r51-b1 classify parent-dev migration 0116 Class-check: 10 parent-dev + 6 Task-106; unknown fails closed; 2 transaction-count test cases updated. Repro-test: test/db.runtimeAccessMigrations.test.ts fails at 485a5d7e80b07018467f9ab58c4285a64b018bce with unclassified 0116. --- control-api/src/migrations/migrationRunner.ts | 1 + control-api/test/db.initDb.test.ts | 4 ++-- control-api/test/db.migrationsOrder.test.ts | 4 ++++ control-api/test/migrationExecutionPolicy.test.ts | 4 ++-- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/control-api/src/migrations/migrationRunner.ts b/control-api/src/migrations/migrationRunner.ts index 9886700b56..8b7323a42b 100644 --- a/control-api/src/migrations/migrationRunner.ts +++ b/control-api/src/migrations/migrationRunner.ts @@ -21,6 +21,7 @@ export const DEV_POST_0106_MIGRATION_VERSIONS = Object.freeze([ '0113_grok_subscription_terminal_connection_key', '0114_llm_provider_attempts_connection_integrity', '0115_llm_allowed_models_image_input', + '0116_mcp_secret_rollback_permits', ] as const) const NON_PR1_POST_0106_MIGRATION_VERSIONS = new Set(DEV_POST_0106_MIGRATION_VERSIONS) diff --git a/control-api/test/db.initDb.test.ts b/control-api/test/db.initDb.test.ts index 569af2f330..9770d07de0 100644 --- a/control-api/test/db.initDb.test.ts +++ b/control-api/test/db.initDb.test.ts @@ -65,8 +65,8 @@ describe('db.initDb', () => { ).toBe(false) expect(sqls.some(sql => sql.includes('DROP TABLE IF EXISTS workflow_run_outputs'))).toBe(false) expect(sqls).not.toContain('ROLLBACK') - expect(sqls.filter(sql => sql === 'BEGIN')).toHaveLength(17) - expect(sqls.filter(sql => sql === 'COMMIT')).toHaveLength(17) + expect(sqls.filter(sql => sql === 'BEGIN')).toHaveLength(18) + expect(sqls.filter(sql => sql === 'COMMIT')).toHaveLength(18) expect(sqls[sqls.length - 2]).toBe('COMMIT') expect(sqls[sqls.length - 1]).toContain('SELECT pg_advisory_unlock') expect(clientRelease).toHaveBeenCalledTimes(1) diff --git a/control-api/test/db.migrationsOrder.test.ts b/control-api/test/db.migrationsOrder.test.ts index 61f5f25c05..6be5d9262c 100644 --- a/control-api/test/db.migrationsOrder.test.ts +++ b/control-api/test/db.migrationsOrder.test.ts @@ -71,6 +71,10 @@ describe('CONTROL_API_MIGRATIONS ordering invariant', () => { expect(migration).toBeDefined() if (!migration) return + expect(migration.legacyVersions).toEqual([ + '0101_mcp_secret_rollback_permits', + '0109_mcp_secret_rollback_permits', + ]) const query = vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }) await migration.apply({ query } as never) diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index 86bf1ab0e2..61cf042032 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -247,8 +247,8 @@ describe('D34 PR1 migration runner', () => { }) expect(applied).toEqual([...DEV_POST_0106_MIGRATION_VERSIONS, ...PR1_MIGRATION_VERSIONS]) - expect(queries.filter(({ sql }) => sql === 'BEGIN')).toHaveLength(16) - expect(queries.filter(({ sql }) => sql === 'COMMIT')).toHaveLength(16) + expect(queries.filter(({ sql }) => sql === 'BEGIN')).toHaveLength(17) + expect(queries.filter(({ sql }) => sql === 'COMMIT')).toHaveLength(17) expect(queries.filter(({ sql }) => sql === 'ROLLBACK')).toHaveLength(0) }) From 3904e13b22eb3a8be9c36eaff99913bec105ad33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:48:20 -0600 Subject: [PATCH 239/528] chore(stack): preserve accepted PR1 tree after linearization --- control-api/src/profileTypes.ts | 4 ++-- .../test/services.directory.userRetirement.test.ts | 11 +++++++++-- control-ui/lib/api.ts | 8 +++++--- external-rest-api/package-lock.json | 4 ++-- external-rest-api/package.json | 2 +- 5 files changed, 19 insertions(+), 10 deletions(-) diff --git a/control-api/src/profileTypes.ts b/control-api/src/profileTypes.ts index 2052c6bd87..e2882d6eca 100644 --- a/control-api/src/profileTypes.ts +++ b/control-api/src/profileTypes.ts @@ -46,8 +46,8 @@ export type AuthClaims = { email: string teamId: string | null role: TeamRole - /** Authoritative users.lifecycle_version captured when the session was issued. */ - authGeneration: number + /** Legacy-v1 lifecycle snapshot; v2 authority remains server-side. */ + authGeneration?: number exp: number iat?: number sessionContract?: 'v2' diff --git a/control-api/test/services.directory.userRetirement.test.ts b/control-api/test/services.directory.userRetirement.test.ts index 8a8fd02629..e81e806699 100644 --- a/control-api/test/services.directory.userRetirement.test.ts +++ b/control-api/test/services.directory.userRetirement.test.ts @@ -77,6 +77,7 @@ describe('retireDesktopUser', () => { it('retires an active linked user atomically with exact control-admin actor and audit correlation', async () => { const { retireDesktopUser } = await import('../src/services/directory/users.js') + const authorize = vi.fn(async () => undefined) mocks.txQuery .mockResolvedValueOnce({ rows: [{ id: OPERATION_ID }], rowCount: 1 }) .mockResolvedValueOnce(activeUser()) @@ -93,7 +94,8 @@ describe('retireDesktopUser', () => { USER_ID, 'policy retirement', 'idem-admin-1', - 'request-admin-1' + 'request-admin-1', + { authorize } ) ).resolves.toEqual({ id: USER_ID, @@ -102,6 +104,7 @@ describe('retireDesktopUser', () => { lifecycleVersion: 2, replayed: false, }) + expect(authorize).toHaveBeenCalledOnce() expect(mocks.retireParentInTransaction).toHaveBeenCalledWith( expect.anything(), @@ -178,6 +181,7 @@ describe('retireDesktopUser', () => { it('returns the stored terminal outcome for an identical idempotency retry without re-emitting events', async () => { const { retireDesktopUser } = await import('../src/services/directory/users.js') const actor = { kind: 'control_admin' as const, controlAdminId: ADMIN_ID } + const authorize = vi.fn(async () => undefined) mocks.txQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }).mockResolvedValueOnce({ rows: [ { @@ -192,7 +196,9 @@ describe('retireDesktopUser', () => { }) await expect( - retireDesktopUser(actor, USER_ID, 'policy retirement', 'idem-retry-1', 'request-retry-2') + retireDesktopUser(actor, USER_ID, 'policy retirement', 'idem-retry-1', 'request-retry-2', { + authorize, + }) ).resolves.toEqual({ id: USER_ID, outcome: 'retired', @@ -201,6 +207,7 @@ describe('retireDesktopUser', () => { replayed: true, }) expect(mocks.retireParentInTransaction).not.toHaveBeenCalled() + expect(authorize).not.toHaveBeenCalled() expect(String(mocks.txQuery.mock.calls[0]?.[0])).toContain('ON CONFLICT DO NOTHING') expect(String(mocks.txQuery.mock.calls[1]?.[0])).toContain('FOR UPDATE') }) diff --git a/control-ui/lib/api.ts b/control-ui/lib/api.ts index 9987e4d8ac..ad4751537c 100644 --- a/control-ui/lib/api.ts +++ b/control-ui/lib/api.ts @@ -183,9 +183,11 @@ export function formatApiError(res: Response, text: string): Error { ? 'Agent access could not be updated because the page did not include its current access state. Reload the page and try again.' : detail === 'deleted_agent_history_limit_exceeded' ? 'Agent access was not updated because the deleted-agent history limit was reached. No existing history was removed. Reload the page, review the current access, and try again.' - : // Control UI calls Control API directly, so the legacy bare string remains - // its member-registration code source. Profile UI consumes the same safe - // codes from External REST's canonical nested public envelope. + : // Exact match is correct HERE: control-ui calls control-api directly, + // so a member-registration 503 arrives as the bare { error: '' } + // body. profile-ui reaches these same codes through external-rest-api, + // whose error middleware wraps any 5xx into { message: '...: ' }, + // so it must use .includes() instead — the two matchers differ on purpose. detail === 'member_registration_unavailable' ? "Invitations are unavailable — the member-registration service isn't configured or can't be reached. Check the server logs for details." : detail === 'member_registration_misconfigured' diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index ff88a88560..1831f85dfc 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index d8dc7498ab..a2eb923322 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.106", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { From 148b085a5c74704101ad759c8e21a3e4dcfc30a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:30:44 -0600 Subject: [PATCH 240/528] fix(control-api): classify parent-dev migrations (R52-B1) Class-check: post-0106 db.ts inventory at SYNC_DEV -> 18 descriptors. 12 are parent-dev, six are PR1-owned; only 0117/0118 were absent. Preserve 0116 aliases, PR1 preparation, and unknown rejection. Repro-test: control-api/test/db.runtimeAccessMigrations.test.ts fails at 560e606e48cde236ee1a8efea6c8bdb3cd359225 with Unclassified post-0106 0117/0118. --- control-api/src/migrations/migrationRunner.ts | 2 ++ control-api/test/db.initDb.test.ts | 4 ++-- control-api/test/migrationExecutionPolicy.test.ts | 14 ++++++++++++-- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/control-api/src/migrations/migrationRunner.ts b/control-api/src/migrations/migrationRunner.ts index 8b7323a42b..f66e4aded8 100644 --- a/control-api/src/migrations/migrationRunner.ts +++ b/control-api/src/migrations/migrationRunner.ts @@ -22,6 +22,8 @@ export const DEV_POST_0106_MIGRATION_VERSIONS = Object.freeze([ '0114_llm_provider_attempts_connection_integrity', '0115_llm_allowed_models_image_input', '0116_mcp_secret_rollback_permits', + '0117_control_admin_invitation_replace_inviter', + '0118_control_admin_replace_inviter_accept_guard', ] as const) const NON_PR1_POST_0106_MIGRATION_VERSIONS = new Set(DEV_POST_0106_MIGRATION_VERSIONS) diff --git a/control-api/test/db.initDb.test.ts b/control-api/test/db.initDb.test.ts index 9770d07de0..c7a099556b 100644 --- a/control-api/test/db.initDb.test.ts +++ b/control-api/test/db.initDb.test.ts @@ -65,8 +65,8 @@ describe('db.initDb', () => { ).toBe(false) expect(sqls.some(sql => sql.includes('DROP TABLE IF EXISTS workflow_run_outputs'))).toBe(false) expect(sqls).not.toContain('ROLLBACK') - expect(sqls.filter(sql => sql === 'BEGIN')).toHaveLength(18) - expect(sqls.filter(sql => sql === 'COMMIT')).toHaveLength(18) + expect(sqls.filter(sql => sql === 'BEGIN')).toHaveLength(20) + expect(sqls.filter(sql => sql === 'COMMIT')).toHaveLength(20) expect(sqls[sqls.length - 2]).toBe('COMMIT') expect(sqls[sqls.length - 1]).toContain('SELECT pg_advisory_unlock') expect(clientRelease).toHaveBeenCalledTimes(1) diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index 61cf042032..63c101ce9b 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -31,6 +31,16 @@ const FRESH_TABLE_INDEXES = Object.freeze([ ]) describe('D34 migration execution policy', () => { + it('classifies inherited control-admin migrations as parent-dev migrations', () => { + expect(DEV_POST_0106_MIGRATION_VERSIONS.slice(-3)).toEqual([ + '0116_mcp_secret_rollback_permits', + '0117_control_admin_invitation_replace_inviter', + '0118_control_admin_replace_inviter_accept_guard', + ]) + expect(PR1_MIGRATION_VERSIONS).not.toContain('0117_control_admin_invitation_replace_inviter') + expect(PR1_MIGRATION_VERSIONS).not.toContain('0118_control_admin_replace_inviter_accept_guard') + }) + it('freezes the owner-approved timeout and Job values', () => { expect(MIGRATION_EXECUTION_POLICY).toEqual({ lockTimeoutMs: 10_000, @@ -247,8 +257,8 @@ describe('D34 PR1 migration runner', () => { }) expect(applied).toEqual([...DEV_POST_0106_MIGRATION_VERSIONS, ...PR1_MIGRATION_VERSIONS]) - expect(queries.filter(({ sql }) => sql === 'BEGIN')).toHaveLength(17) - expect(queries.filter(({ sql }) => sql === 'COMMIT')).toHaveLength(17) + expect(queries.filter(({ sql }) => sql === 'BEGIN')).toHaveLength(19) + expect(queries.filter(({ sql }) => sql === 'COMMIT')).toHaveLength(19) expect(queries.filter(({ sql }) => sql === 'ROLLBACK')).toHaveLength(0) }) From cff365a7e3eeae0e0f6d6ac9c3332127de2c13d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:55:49 -0600 Subject: [PATCH 241/528] test: correct PR1 online-index validation count Class-check: 18 0109 entries plus 7 010b entries equals the exact 25-item online index plan. Repro-test: scripts/tests/test-control-api-migration-bounds.sh failed at c6c764415 with count 18. --- scripts/tests/test-control-api-migration-bounds.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/tests/test-control-api-migration-bounds.sh b/scripts/tests/test-control-api-migration-bounds.sh index 2f45f6dcde..349a4d5ffa 100755 --- a/scripts/tests/test-control-api-migration-bounds.sh +++ b/scripts/tests/test-control-api-migration-bounds.sh @@ -41,7 +41,7 @@ grep -q 'applyPendingPr1Migrations' "$DB_FILE" || \ grep -q 'CREATE INDEX CONCURRENTLY' "$INDEX_PLAN_FILE" || \ fail "existing-table indexes are not prepared concurrently" -index_count="$(grep -c "^ migrationVersion: '010[79]_" "$INDEX_PLAN_FILE")" +index_count="$(grep -c "^ migrationVersion: '010[9b]_" "$INDEX_PLAN_FILE")" [ "$index_count" = "25" ] || fail "expected 25 online indexes, found $index_count" grep -q 'activeDeadlineSeconds' "$DEPLOY_FILE" || \ From 035c44b5cb39094da217e0e7b57808fd636a62b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:01:49 -0600 Subject: [PATCH 242/528] test(control-api): model checked-out client error events Class-check: transaction test doubles -> node-postgres PoolClient event contract Repro-test: sessionDatabaseFailure.test.ts fails after frozen-dev sync without an EventEmitter client. --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- control-api/test/sessionDatabaseFailure.test.ts | 4 +++- external-rest-api/src/releaseManifest.ts | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 681d569f40..3a52cd9882 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.682", + "version": "0.1.683", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.682", + "version": "0.1.683", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index e82aab44f9..a552b0270d 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.682", + "version": "0.1.683", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/sessionDatabaseFailure.test.ts b/control-api/test/sessionDatabaseFailure.test.ts index cd3d1f7308..33c2a2a47e 100644 --- a/control-api/test/sessionDatabaseFailure.test.ts +++ b/control-api/test/sessionDatabaseFailure.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' import { pool, withTransaction } from '../src/db.js' import { ExternalSessionBackendUnavailableError, @@ -29,7 +30,8 @@ describe('external session database failure provenance', () => { const applicationFailure = new Error('session policy invariant failed') const query = vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }) const release = vi.fn() - vi.spyOn(pool, 'connect').mockResolvedValueOnce({ query, release } as never) + const client = Object.assign(new EventEmitter(), { query, release }) + vi.spyOn(pool, 'connect').mockResolvedValueOnce(client as never) await expect( withTransaction( diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index 46170a44a1..cb0a3eb5c2 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.10.0', - externalRestApiVersion: '0.1.110', + externalRestApiVersion: '0.1.106', rpcProxyVersion: '0.1.111', desktopVersion: '0.10.0', minimumDesktopVersion: '0.1.252', From ded0da50a48208f892d560352cb825cfc1c7f85d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:17:23 -0600 Subject: [PATCH 243/528] chore(release): bump RPC Proxy for Task 106 foundation changes Advance the RPC Proxy package and release manifest counter because PR1 changes rpc-proxy source relative to the frozen dev base. --- external-rest-api/src/releaseManifest.ts | 2 +- rpc-proxy/package-lock.json | 4 ++-- rpc-proxy/package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index cb0a3eb5c2..4e76ba769b 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -9,7 +9,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.10.0', externalRestApiVersion: '0.1.106', - rpcProxyVersion: '0.1.111', + rpcProxyVersion: '0.1.112', desktopVersion: '0.10.0', minimumDesktopVersion: '0.1.252', } diff --git a/rpc-proxy/package-lock.json b/rpc-proxy/package-lock.json index 02fc459765..ad2e5e9207 100644 --- a/rpc-proxy/package-lock.json +++ b/rpc-proxy/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-rpc-proxy", - "version": "0.1.111", + "version": "0.1.112", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-rpc-proxy", - "version": "0.1.111", + "version": "0.1.112", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/rpc-proxy/package.json b/rpc-proxy/package.json index e3dcc0dbda..c526037520 100644 --- a/rpc-proxy/package.json +++ b/rpc-proxy/package.json @@ -1,6 +1,6 @@ { "name": "clerum-rpc-proxy", - "version": "0.1.111", + "version": "0.1.112", "description": "External-facing RPC proxy for tenant-scoped MCP access", "main": "dist/main.js", "engines": { From 40152a83a7f653f945111756b22b6f8da9c7810a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:49:07 -0600 Subject: [PATCH 244/528] fix(control-api): classify current parent migrations Class-check: post-0106 FINAL_DEV descriptors absent from the parent inventory -> 0119-0121 only; retain the six PR1-owned migrations and fail-closed unknown guard. Repro-test: control-api/test/migrationExecutionPolicy.test.ts (RED at 1a055f947d36f4be8970dee6dc44c166d178aa6b); exact-head CI Real PostgreSQL also failed initialization on unclassified 0119-0121. Update strict transaction totals to the deterministic parent migration count. --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- control-api/src/migrations/migrationRunner.ts | 3 +++ control-api/test/db.initDb.test.ts | 4 ++-- control-api/test/migrationExecutionPolicy.test.ts | 9 ++++++--- 5 files changed, 14 insertions(+), 8 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 3a52cd9882..de260ddcc9 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.683", + "version": "0.1.684", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.683", + "version": "0.1.684", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index a552b0270d..399bf509d8 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.683", + "version": "0.1.684", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/migrations/migrationRunner.ts b/control-api/src/migrations/migrationRunner.ts index f66e4aded8..212749e080 100644 --- a/control-api/src/migrations/migrationRunner.ts +++ b/control-api/src/migrations/migrationRunner.ts @@ -24,6 +24,9 @@ export const DEV_POST_0106_MIGRATION_VERSIONS = Object.freeze([ '0116_mcp_secret_rollback_permits', '0117_control_admin_invitation_replace_inviter', '0118_control_admin_replace_inviter_accept_guard', + '0119_dynamic_clients_table', + '0120_dynamic_clients_runtime_access', + '0121_oauth_install_identity', ] as const) const NON_PR1_POST_0106_MIGRATION_VERSIONS = new Set(DEV_POST_0106_MIGRATION_VERSIONS) diff --git a/control-api/test/db.initDb.test.ts b/control-api/test/db.initDb.test.ts index c7a099556b..3e5eb13930 100644 --- a/control-api/test/db.initDb.test.ts +++ b/control-api/test/db.initDb.test.ts @@ -65,8 +65,8 @@ describe('db.initDb', () => { ).toBe(false) expect(sqls.some(sql => sql.includes('DROP TABLE IF EXISTS workflow_run_outputs'))).toBe(false) expect(sqls).not.toContain('ROLLBACK') - expect(sqls.filter(sql => sql === 'BEGIN')).toHaveLength(20) - expect(sqls.filter(sql => sql === 'COMMIT')).toHaveLength(20) + expect(sqls.filter(sql => sql === 'BEGIN')).toHaveLength(23) + expect(sqls.filter(sql => sql === 'COMMIT')).toHaveLength(23) expect(sqls[sqls.length - 2]).toBe('COMMIT') expect(sqls[sqls.length - 1]).toContain('SELECT pg_advisory_unlock') expect(clientRelease).toHaveBeenCalledTimes(1) diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index 63c101ce9b..a6367222bc 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -32,10 +32,13 @@ const FRESH_TABLE_INDEXES = Object.freeze([ describe('D34 migration execution policy', () => { it('classifies inherited control-admin migrations as parent-dev migrations', () => { - expect(DEV_POST_0106_MIGRATION_VERSIONS.slice(-3)).toEqual([ + expect(DEV_POST_0106_MIGRATION_VERSIONS.slice(-6)).toEqual([ '0116_mcp_secret_rollback_permits', '0117_control_admin_invitation_replace_inviter', '0118_control_admin_replace_inviter_accept_guard', + '0119_dynamic_clients_table', + '0120_dynamic_clients_runtime_access', + '0121_oauth_install_identity', ]) expect(PR1_MIGRATION_VERSIONS).not.toContain('0117_control_admin_invitation_replace_inviter') expect(PR1_MIGRATION_VERSIONS).not.toContain('0118_control_admin_replace_inviter_accept_guard') @@ -257,8 +260,8 @@ describe('D34 PR1 migration runner', () => { }) expect(applied).toEqual([...DEV_POST_0106_MIGRATION_VERSIONS, ...PR1_MIGRATION_VERSIONS]) - expect(queries.filter(({ sql }) => sql === 'BEGIN')).toHaveLength(19) - expect(queries.filter(({ sql }) => sql === 'COMMIT')).toHaveLength(19) + expect(queries.filter(({ sql }) => sql === 'BEGIN')).toHaveLength(22) + expect(queries.filter(({ sql }) => sql === 'COMMIT')).toHaveLength(22) expect(queries.filter(({ sql }) => sql === 'ROLLBACK')).toHaveLength(0) }) From f73f07e8f596089cb482762cf19e21b147c8bcc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:42:44 -0600 Subject: [PATCH 245/528] test(control-api): align fixtures with synchronized parent contracts Update test doubles for the current rate-limit, PG pool, index catalog, and logger seams. --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- control-api/test/app.test.ts | 7 ++++++- control-api/test/db.dynamicClientsTableMigration.test.ts | 6 +++++- control-api/test/db.oauthInstallIdentityMigration.test.ts | 6 +++++- .../test/middleware.externalSessionAuth.lifecycle.test.ts | 5 ++++- 6 files changed, 23 insertions(+), 7 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index de260ddcc9..9bbbfcc44e 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.684", + "version": "0.1.685", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.684", + "version": "0.1.685", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 399bf509d8..9571267708 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.684", + "version": "0.1.685", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/app.test.ts b/control-api/test/app.test.ts index 780af1740b..4fb138913a 100644 --- a/control-api/test/app.test.ts +++ b/control-api/test/app.test.ts @@ -24,6 +24,7 @@ describe('app router wiring', () => { beforeEach(() => { rateLimiter.checkAndIncrement.mockResolvedValue({ allowed: true, + backendAvailable: true, remaining: 9, resetMs: Date.now() + 60_000, windowStartMs: Date.now(), @@ -260,6 +261,8 @@ describe('app router wiring', () => { }) vi.spyOn(pool, 'connect').mockResolvedValue({ query: transactionQuery, + on: vi.fn(), + removeListener: vi.fn(), release: vi.fn(), } as never) @@ -269,7 +272,9 @@ describe('app router wiring', () => { .set('x-service-token', 'external-rest-api') .set('x-user-session-token', currentToken) .send(payload) - .expect(200) + .expect(response => { + expect(response.status, JSON.stringify(response.body)).toBe(200) + }) expect(res.body.token).toBeTruthy() expect(transactionQuery.mock.calls.map(([sql]) => String(sql))).toEqual([ 'BEGIN', diff --git a/control-api/test/db.dynamicClientsTableMigration.test.ts b/control-api/test/db.dynamicClientsTableMigration.test.ts index 5c26fa7eae..bcea15946e 100644 --- a/control-api/test/db.dynamicClientsTableMigration.test.ts +++ b/control-api/test/db.dynamicClientsTableMigration.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { onlineIndexAwareQuery } from './helpers/onlineIndexCatalogMock.js' const clientQuery = vi.fn() const clientRelease = vi.fn() @@ -13,7 +14,10 @@ describe('0119_dynamic_clients_table migration', () => { beforeEach(() => { vi.resetModules() vi.clearAllMocks() - mockConnect.mockResolvedValue({ query: clientQuery, release: clientRelease }) + mockConnect.mockResolvedValue({ + query: onlineIndexAwareQuery(clientQuery), + release: clientRelease, + }) clientQuery.mockResolvedValue({ rows: [], rowCount: 0 }) }) diff --git a/control-api/test/db.oauthInstallIdentityMigration.test.ts b/control-api/test/db.oauthInstallIdentityMigration.test.ts index baa2bfa137..b3a4fbd2b6 100644 --- a/control-api/test/db.oauthInstallIdentityMigration.test.ts +++ b/control-api/test/db.oauthInstallIdentityMigration.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { onlineIndexAwareQuery } from './helpers/onlineIndexCatalogMock.js' const clientQuery = vi.fn() const clientRelease = vi.fn() @@ -13,7 +14,10 @@ describe('0121_oauth_install_identity migration', () => { beforeEach(() => { vi.resetModules() vi.clearAllMocks() - mockConnect.mockResolvedValue({ query: clientQuery, release: clientRelease }) + mockConnect.mockResolvedValue({ + query: onlineIndexAwareQuery(clientQuery), + release: clientRelease, + }) clientQuery.mockResolvedValue({ rows: [], rowCount: 0 }) }) diff --git a/control-api/test/middleware.externalSessionAuth.lifecycle.test.ts b/control-api/test/middleware.externalSessionAuth.lifecycle.test.ts index e9dcab7962..2f774143f4 100644 --- a/control-api/test/middleware.externalSessionAuth.lifecycle.test.ts +++ b/control-api/test/middleware.externalSessionAuth.lifecycle.test.ts @@ -18,7 +18,10 @@ vi.mock('../src/db.js', () => ({ }, })) vi.mock('../src/observability/logger.js', () => ({ - rootLogger: { warn: (...args: unknown[]) => loggerWarn(...args) }, + rootLogger: { + warn: (...args: unknown[]) => loggerWarn(...args), + child: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }), + }, })) vi.mock('../src/utils/auth/externalSessionAuthToken.js', () => ({ verifyExternalSessionToken: (...args: unknown[]) => verifyToken(...args), From 5aca4bacc5ab57265289aba8288879d4f964f9d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:48:29 -0600 Subject: [PATCH 246/528] test(control-api): pin mismatched claims in admission fixture --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- control-api/test/routes.rpcAccessUsersMcpHost.test.ts | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 9bbbfcc44e..255a7e696b 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.685", + "version": "0.1.686", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.685", + "version": "0.1.686", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 9571267708..c80efb5a93 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.685", + "version": "0.1.686", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/routes.rpcAccessUsersMcpHost.test.ts b/control-api/test/routes.rpcAccessUsersMcpHost.test.ts index 6667064d9c..33d420a842 100644 --- a/control-api/test/routes.rpcAccessUsersMcpHost.test.ts +++ b/control-api/test/routes.rpcAccessUsersMcpHost.test.ts @@ -535,6 +535,7 @@ describe('GET /rpc/access/users/:userId/mcp-hosts/:hostRef/artifact-read', () => }) it('does not charge admission for user or signed-Host mismatch', async () => { + authState.claims = { sub: 'user-a', hostRefs: ['host-a', 'host-b'] } rateLimiterMock.checkAndIncrement.mockResolvedValue({ allowed: true, remaining: 29, From cb440e359535cd1d34d90e17d437661e42684adc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:58:57 -0600 Subject: [PATCH 247/528] fix(rpc-proxy): pin artifact wake deadline before host resolution Start the artifact wake hold budget after local validation and before Control API host resolution. Reuse that deadline in both artifact handlers so resolution latency cannot extend retries. Class-check: artifact reads resolve the Host before entering the route handler. Repro-test: route-upstream-deadlines.real-http.test.ts fails its pinned-deadline assertion before this fix. --- external-rest-api/src/releaseManifest.ts | 2 +- rpc-proxy/package-lock.json | 4 ++-- rpc-proxy/package.json | 2 +- .../route-upstream-deadlines.real-http.test.ts | 15 +++++++++++---- ...ake-and-hold-idempotent-gets.real-http.test.ts | 7 +++++-- rpc-proxy/src/routes/rpc.ts | 6 ++++-- 6 files changed, 24 insertions(+), 12 deletions(-) diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index 4e76ba769b..5276283b3d 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -9,7 +9,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.10.0', externalRestApiVersion: '0.1.106', - rpcProxyVersion: '0.1.112', + rpcProxyVersion: '0.1.113', desktopVersion: '0.10.0', minimumDesktopVersion: '0.1.252', } diff --git a/rpc-proxy/package-lock.json b/rpc-proxy/package-lock.json index ad2e5e9207..26cb634d73 100644 --- a/rpc-proxy/package-lock.json +++ b/rpc-proxy/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-rpc-proxy", - "version": "0.1.112", + "version": "0.1.113", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-rpc-proxy", - "version": "0.1.112", + "version": "0.1.113", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/rpc-proxy/package.json b/rpc-proxy/package.json index c526037520..93a09bf9c4 100644 --- a/rpc-proxy/package.json +++ b/rpc-proxy/package.json @@ -1,6 +1,6 @@ { "name": "clerum-rpc-proxy", - "version": "0.1.112", + "version": "0.1.113", "description": "External-facing RPC proxy for tenant-scoped MCP access", "main": "dist/main.js", "engines": { diff --git a/rpc-proxy/src/__tests__/route-upstream-deadlines.real-http.test.ts b/rpc-proxy/src/__tests__/route-upstream-deadlines.real-http.test.ts index b0c08cfa9b..c483916fef 100644 --- a/rpc-proxy/src/__tests__/route-upstream-deadlines.real-http.test.ts +++ b/rpc-proxy/src/__tests__/route-upstream-deadlines.real-http.test.ts @@ -31,6 +31,7 @@ const serviceMock = vi.hoisted(() => ({ listAllowedServersForUser: vi.fn(), resolveServerConnectionForUser: vi.fn(), resolveHostConnectionForUser: vi.fn(), + resolveArtifactReadHostConnectionForUser: vi.fn(), validateRpcRequest: vi.fn(), forwardRpcToServer: vi.fn(), forwardHostMessageToHost: vi.fn(), @@ -112,11 +113,13 @@ async function startUpstream(handler: Handler): Promise { server!.listen(0, '127.0.0.1', resolve) }) const { port } = server.address() as AddressInfo - serviceMock.resolveHostConnectionForUser.mockResolvedValue({ + const connection = { name: 'chatllm', url: `http://127.0.0.1:${port}`, headers: {}, - }) + } + serviceMock.resolveHostConnectionForUser.mockResolvedValue(connection) + serviceMock.resolveArtifactReadHostConnectionForUser.mockResolvedValue(connection) } /** Ends the TCP connection without a response: undici reports it as `fetch failed`. */ @@ -720,7 +723,11 @@ describe.each(HELD_ROUTES)('R3-L7 $label pins the hold deadline before host reso // the wake retry is never answered }) const { port } = server!.address() as AddressInfo - serviceMock.resolveHostConnectionForUser.mockImplementation(async () => { + const resolveHost = + route.label === 'list artifacts' || route.label === 'artifact download' + ? serviceMock.resolveArtifactReadHostConnectionForUser + : serviceMock.resolveHostConnectionForUser + resolveHost.mockImplementation(async () => { await new Promise(resolve => setTimeout(resolve, HOST_RESOLUTION_LATENCY_MS)) return { name: 'chatllm', url: `http://127.0.0.1:${port}`, headers: {} } }) @@ -737,7 +744,7 @@ describe.each(HELD_ROUTES)('R3-L7 $label pins the hold deadline before host reso // Witness: the slow resolution ran, the first attempt reached the upstream, // a wake was requested and the retry reached the upstream before the // deadline aborted it. - expect(serviceMock.resolveHostConnectionForUser).toHaveBeenCalledTimes(1) + expect(resolveHost).toHaveBeenCalledTimes(1) expect(controlApiMock.requestHostWakeFromControlApi).toHaveBeenCalledTimes(1) expect(seen.map(entry => entry.url)).toEqual([route.upstreamPath, route.upstreamPath]) await vi.waitFor(() => expect(seen[1]!.closedEarly).toBe(true)) diff --git a/rpc-proxy/src/__tests__/wake-and-hold-idempotent-gets.real-http.test.ts b/rpc-proxy/src/__tests__/wake-and-hold-idempotent-gets.real-http.test.ts index 2f1b8c5025..87abf3bde1 100644 --- a/rpc-proxy/src/__tests__/wake-and-hold-idempotent-gets.real-http.test.ts +++ b/rpc-proxy/src/__tests__/wake-and-hold-idempotent-gets.real-http.test.ts @@ -18,6 +18,7 @@ const serviceMock = vi.hoisted(() => ({ listAllowedServersForUser: vi.fn(), resolveServerConnectionForUser: vi.fn(), resolveHostConnectionForUser: vi.fn(), + resolveArtifactReadHostConnectionForUser: vi.fn(), validateRpcRequest: vi.fn(), forwardRpcToServer: vi.fn(), forwardHostMessageToHost: vi.fn(), @@ -92,11 +93,13 @@ async function startUpstream(handler: Handler): Promise { server!.listen(0, '127.0.0.1', resolve) }) const { port } = server.address() as AddressInfo - serviceMock.resolveHostConnectionForUser.mockResolvedValue({ + const connection = { name: 'chatllm', url: `http://127.0.0.1:${port}`, headers: {}, - }) + } + serviceMock.resolveHostConnectionForUser.mockResolvedValue(connection) + serviceMock.resolveArtifactReadHostConnectionForUser.mockResolvedValue(connection) } /** Ends the TCP connection without a response: undici reports it as `fetch failed`. */ diff --git a/rpc-proxy/src/routes/rpc.ts b/rpc-proxy/src/routes/rpc.ts index 5b21501ed4..50258c1c02 100644 --- a/rpc-proxy/src/routes/rpc.ts +++ b/rpc-proxy/src/routes/rpc.ts @@ -58,6 +58,7 @@ const MESSAGES_LIMIT_CAP = 200 const HOST_ARTIFACT_READ_LIMIT_PER_MIN = 30 type ArtifactReadRequest = AuthedRequest & { + artifactReadWakeDeadlineMs?: number artifactReadHost?: Extract< Awaited>, { url: string } @@ -326,6 +327,7 @@ export function createRpcRouter(): Router { res.status(400).json({ error: 'Invalid host reference' }) return } + req.artifactReadWakeDeadlineMs = Date.now() + config.wakeMaxHoldMs const artifactReadHost = await resolveArtifactReadHostConnectionForUser( auth.sub, hostRef, @@ -1556,7 +1558,7 @@ export function createRpcRouter(): Router { const rpcAccessToken = extractAuthToken(req) const hostRef = String(req.params.hostRef || '').trim() const host = (req as ArtifactReadRequest).artifactReadHost! - const wakeDeadlineMs = Date.now() + config.wakeMaxHoldMs + const wakeDeadlineMs = (req as ArtifactReadRequest).artifactReadWakeDeadlineMs! const baseUrl = host.url.replace(/\/+$/, '') // Wake-eligible finite operation (§11.4): scope stays host:task:read. // Unlike approve/deny/model this is a read-only list of small JSON: a @@ -1624,7 +1626,7 @@ export function createRpcRouter(): Router { const hostRef = String(req.params.hostRef || '').trim() const filename = String(req.params.filename || '').trim() const host = (req as ArtifactReadRequest).artifactReadHost! - const wakeDeadlineMs = Date.now() + config.wakeMaxHoldMs + const wakeDeadlineMs = (req as ArtifactReadRequest).artifactReadWakeDeadlineMs! const baseUrl = host.url.replace(/\/+$/, '') // Wake-eligible finite operation (§11.4): scope stays host:task:read. // The success path only commits (`res.send`) at the very end, so a From 0f99497789de04f23f03dcbbb8a6b6edaffb17a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:29:28 -0600 Subject: [PATCH 248/528] fix(control-api): use canonical Host claim guards Keep deterministic binding validation before the signed subject and Host checks. Preserve denial telemetry and reject selectors before admission or protected work. Validation: focused Host message and RPC auth suites (65 tests), build, Prettier. --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- control-api/src/middleware/rpcAccessAuth.ts | 27 ++++++++- control-api/src/routes/rpc-access/users.ts | 57 ++++++++++--------- .../test/routes.hostMessageAdmission.test.ts | 41 +++++++++++++ control-api/test/routes.profile.test.ts | 1 - 6 files changed, 100 insertions(+), 32 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 255a7e696b..7bc4125fee 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.686", + "version": "0.1.687", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.686", + "version": "0.1.687", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index c80efb5a93..d2e83ca3a5 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.686", + "version": "0.1.687", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/middleware/rpcAccessAuth.ts b/control-api/src/middleware/rpcAccessAuth.ts index 8f7e084b17..c217718ab3 100644 --- a/control-api/src/middleware/rpcAccessAuth.ts +++ b/control-api/src/middleware/rpcAccessAuth.ts @@ -6,6 +6,13 @@ type RpcAuthedRequest = Request & { rpcAuth?: RpcAccessClaims } +type RpcTokenMatchFailure = 'claims_missing' | 'subject_mismatch' | 'host_claim_missing' +type RpcTokenMatchFailureHandler = ( + req: RpcAuthedRequest, + res: Response, + reason: RpcTokenMatchFailure +) => void + function extractRpcAccessToken(req: Request): string { return String(req.header('x-rpc-access-token') || '').trim() } @@ -56,11 +63,19 @@ export function requireValidRpcAccessTokenAny(scopes: RpcScope[]) { } } -export function requireRpcTokenUserMatch(paramName = 'userId') { +export function requireRpcTokenUserMatch( + paramName = 'userId', + onFailure?: RpcTokenMatchFailureHandler +) { return (req: RpcAuthedRequest, res: Response, next: NextFunction): void => { const claims = req.rpcAuth const userId = String(req.params?.[paramName] || '').trim() if (!claims || !userId || claims.sub !== userId) { + const reason: RpcTokenMatchFailure = claims ? 'subject_mismatch' : 'claims_missing' + if (onFailure) { + onFailure(req, res, reason) + return + } res.status(403).json({ error: 'Forbidden' }) return } @@ -80,11 +95,19 @@ export function requireRpcTokenTeamMatch(paramName = 'teamId') { } } -export function requireRpcTokenHostMatch(paramName = 'hostRef') { +export function requireRpcTokenHostMatch( + paramName = 'hostRef', + onFailure?: RpcTokenMatchFailureHandler +) { return (req: RpcAuthedRequest, res: Response, next: NextFunction): void => { const claims = req.rpcAuth const hostRef = String(req.params?.[paramName] || '').trim() if (!claims || !hostRef || !claims.hostRefs.includes(hostRef)) { + const reason: RpcTokenMatchFailure = claims ? 'host_claim_missing' : 'claims_missing' + if (onFailure) { + onFailure(req, res, reason) + return + } res.status(403).json({ error: 'Forbidden' }) return } diff --git a/control-api/src/routes/rpc-access/users.ts b/control-api/src/routes/rpc-access/users.ts index bb17e3a289..f4d7aabc98 100644 --- a/control-api/src/routes/rpc-access/users.ts +++ b/control-api/src/routes/rpc-access/users.ts @@ -1,5 +1,5 @@ import express, { Router } from 'express' -import type { Request, Response } from 'express' +import type { NextFunction, Request, Response } from 'express' import { config } from '../../config.js' import { pool } from '../../db.js' import { K8sGateway } from '../../k8s.js' @@ -58,6 +58,9 @@ type RpcAuthedRequest = Request & { rpcAuth?: RpcAccessClaims } type ArtifactReadRequest = RpcAuthedRequest & { artifactReadConnection?: AuthorizedRpcHostAccess } +type HostMessageResolutionRequest = RpcAuthedRequest & { + messageResolutionBinding?: ParsedDirectRunBindingRequest | null +} /** * Response header that tells rpc-proxy why control-api denied Host access. The @@ -80,6 +83,26 @@ function denyHostAccess( res.status(403).json({ error: 'Forbidden' }) } +function validateMessageResolutionBody( + req: HostMessageResolutionRequest, + res: Response, + next: NextFunction +): void { + const body = req.body + const ordinary = + body !== null && + typeof body === 'object' && + !Array.isArray(body) && + Object.keys(body).length === 0 + const binding = ordinary ? null : parseDirectRunBindingRequest(body) + if (!ordinary && !binding) { + res.status(400).json({ error: 'invalid_direct_run_binding' }) + return + } + req.messageResolutionBinding = binding + next() +} + async function resolveAuthorizedHostConnection( req: RpcAuthedRequest, res: Response, @@ -383,32 +406,14 @@ export function createRpcAccessUsersRouter( `${hostAccessPath}/message-resolution`, requireValidRpcAccessToken('host:message:invoke'), express.json({ limit: '2kb', strict: true }), - async (req: RpcAuthedRequest, res, next) => { + validateMessageResolutionBody, + requireRpcTokenUserMatch('userId', (req, res, reason) => denyHostAccess(req, res, reason)), + requireRpcTokenHostMatch('hostRef', (req, res, reason) => denyHostAccess(req, res, reason)), + async (req: HostMessageResolutionRequest, res, next) => { try { const claims = req.rpcAuth - const body = req.body - const ordinary = - body !== null && - typeof body === 'object' && - !Array.isArray(body) && - Object.keys(body).length === 0 - const binding = ordinary ? null : parseDirectRunBindingRequest(body) - if (!ordinary && !binding) { - res.status(400).json({ error: 'invalid_direct_run_binding' }) - return - } - const userId = String(req.params.userId || '').trim() - const hostRef = String(req.params.hostRef || '').trim() - if (!claims || claims.sub !== userId || !hostRef || !claims.hostRefs.includes(hostRef)) { - denyHostAccess( - req, - res, - !claims - ? 'claims_missing' - : claims.sub !== userId - ? 'subject_mismatch' - : 'host_claim_missing' - ) + if (!claims) { + denyHostAccess(req, res, 'claims_missing') return } const admission = await admitHostMessage(claims.sub) @@ -423,7 +428,7 @@ export function createRpcAccessUsersRouter( directory, bindingService, bindingBudgetMs, - binding + req.messageResolutionBinding ?? null ) } catch (error) { next(error) diff --git a/control-api/test/routes.hostMessageAdmission.test.ts b/control-api/test/routes.hostMessageAdmission.test.ts index eb561b5bc3..a5bda0e9d9 100644 --- a/control-api/test/routes.hostMessageAdmission.test.ts +++ b/control-api/test/routes.hostMessageAdmission.test.ts @@ -203,4 +203,45 @@ describe('R44-H1 legacy Host-message admission', () => { .expect({ error: 'host_message_admission_unavailable' }) expect(gateway.listResource).not.toHaveBeenCalled() }) + + it('denies unbound user and Host selectors before admission or protected work', async () => { + const subjectCase = app() + const subjectMismatch = await send( + subjectCase.server, + USER_B, + 'host-a', + {}, + token(USER_A, ['host-a']) + ).expect(403) + expect(subjectMismatch.headers['x-host-access-denial-reason']).toBe('subject_mismatch') + expect(limiter.checkAndIncrement).not.toHaveBeenCalled() + expect(subjectCase.gateway.listResource).not.toHaveBeenCalled() + + const hostCase = app() + const hostMismatch = await send( + hostCase.server, + USER_A, + 'host-b', + {}, + token(USER_A, ['host-a']) + ).expect(403) + expect(hostMismatch.headers['x-host-access-denial-reason']).toBe('host_claim_missing') + expect(limiter.checkAndIncrement).not.toHaveBeenCalled() + expect(hostCase.gateway.listResource).not.toHaveBeenCalled() + }) + + it('keeps deterministic body rejection ahead of the token-bound selectors', async () => { + const { server, gateway } = app() + const response = await send( + server, + USER_B, + 'host-b', + { invalid: true }, + token(USER_A, ['host-a']) + ).expect(400) + expect(response.body).toEqual({ error: 'invalid_direct_run_binding' }) + expect(response.headers['x-host-access-denial-reason']).toBeUndefined() + expect(limiter.checkAndIncrement).not.toHaveBeenCalled() + expect(gateway.listResource).not.toHaveBeenCalled() + }) }) diff --git a/control-api/test/routes.profile.test.ts b/control-api/test/routes.profile.test.ts index d4c95149cf..096b0640d3 100644 --- a/control-api/test/routes.profile.test.ts +++ b/control-api/test/routes.profile.test.ts @@ -171,7 +171,6 @@ describe('routes/profile', () => { remaining: 9, resetMs: Date.now() + 60_000, windowStartMs: Date.now(), - backendAvailable: true, count: 1, }) dbMock.query.mockImplementation(async (sql: string) => { From c2c518941c4ce83cde841634b5080b2cef0c9605 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:49:01 -0600 Subject: [PATCH 249/528] test(sync): align migration bounds with synchronized slots --- scripts/tests/test-control-api-migration-bounds.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/tests/test-control-api-migration-bounds.sh b/scripts/tests/test-control-api-migration-bounds.sh index 349a4d5ffa..8afa88bdf6 100755 --- a/scripts/tests/test-control-api-migration-bounds.sh +++ b/scripts/tests/test-control-api-migration-bounds.sh @@ -41,7 +41,7 @@ grep -q 'applyPendingPr1Migrations' "$DB_FILE" || \ grep -q 'CREATE INDEX CONCURRENTLY' "$INDEX_PLAN_FILE" || \ fail "existing-table indexes are not prepared concurrently" -index_count="$(grep -c "^ migrationVersion: '010[9b]_" "$INDEX_PLAN_FILE")" +index_count="$(grep -c "^ migrationVersion: '012[57]_" "$INDEX_PLAN_FILE")" [ "$index_count" = "25" ] || fail "expected 25 online indexes, found $index_count" grep -q 'activeDeadlineSeconds' "$DEPLOY_FILE" || \ From c6bffa1c6198cae1bf0055827130a24753fddec9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:14:08 -0600 Subject: [PATCH 250/528] test(sync): account for incoming migration transactions --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- control-api/test/db.initDb.test.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index fe6e679b92..4e19aa7aa7 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.699", + "version": "0.1.700", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.699", + "version": "0.1.700", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index be18ec391d..2d2de50fa0 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.699", + "version": "0.1.700", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/db.initDb.test.ts b/control-api/test/db.initDb.test.ts index 3e5eb13930..3b6551acbb 100644 --- a/control-api/test/db.initDb.test.ts +++ b/control-api/test/db.initDb.test.ts @@ -65,8 +65,8 @@ describe('db.initDb', () => { ).toBe(false) expect(sqls.some(sql => sql.includes('DROP TABLE IF EXISTS workflow_run_outputs'))).toBe(false) expect(sqls).not.toContain('ROLLBACK') - expect(sqls.filter(sql => sql === 'BEGIN')).toHaveLength(23) - expect(sqls.filter(sql => sql === 'COMMIT')).toHaveLength(23) + expect(sqls.filter(sql => sql === 'BEGIN')).toHaveLength(26) + expect(sqls.filter(sql => sql === 'COMMIT')).toHaveLength(26) expect(sqls[sqls.length - 2]).toBe('COMMIT') expect(sqls[sqls.length - 1]).toContain('SELECT pg_advisory_unlock') expect(clientRelease).toHaveBeenCalledTimes(1) From 08ee47bd821b99acd9db6659c10dffbead7226c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:56:55 -0600 Subject: [PATCH 251/528] fix(control-api): harden R56-B1 definer search paths Class-check: 22 access-foundation SECURITY DEFINER sites -> 20 signatures, all hardened via 012a Repro-test: control-api/test/db.realPostgresMigration.integration.test.ts job 110675253793 (fails at c6bffa1c6198cae1bf0055827130a24753fddec9) --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- control-api/src/db.ts | 7 + control-api/src/migrations/migrationRunner.ts | 1 + ...essFoundationDefinerTempShadowMigration.ts | 53 ++++++++ control-api/test/db.initDb.test.ts | 4 +- control-api/test/db.migrationsOrder.test.ts | 54 ++++++++ ....realPostgresMigration.integration.test.ts | 120 +++++++++++++++++- .../db.userAccessFoundationMigration.test.ts | 6 +- .../test/migrationExecutionPolicy.test.ts | 4 +- 10 files changed, 244 insertions(+), 11 deletions(-) create mode 100644 control-api/src/migrations/userAccessFoundationDefinerTempShadowMigration.ts diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 4e19aa7aa7..901ef8fa0c 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.700", + "version": "0.1.701", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.700", + "version": "0.1.701", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 2d2de50fa0..43026ba2d3 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.700", + "version": "0.1.701", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/db.ts b/control-api/src/db.ts index 635d561e9b..eea1d18e5a 100644 --- a/control-api/src/db.ts +++ b/control-api/src/db.ts @@ -7,6 +7,7 @@ import { import { config } from './config.js' import { migrationSessionBoundsSql } from './migrations/migrationExecutionPolicy.js' import { applyPendingPr1Migrations } from './migrations/migrationRunner.js' +import { applyUserAccessFoundationDefinerTempShadowHardening } from './migrations/userAccessFoundationDefinerTempShadowMigration.js' import { rootLogger } from './observability/logger.js' import { applyCatalogUtf8OrderingSchema, @@ -6385,6 +6386,12 @@ export const CONTROL_API_MIGRATIONS: DbMigration[] = [ ], apply: applyComposableCatalogRevisionSchema, }, + { + // The access-foundation bodies are immutable; harden their installed + // SECURITY DEFINER search paths without replaying those historical bodies. + version: '012a_user_access_foundation_definer_temp_shadow_hardening', + apply: applyUserAccessFoundationDefinerTempShadowHardening, + }, { version: '0130_legacy_password_security_epoch_backfill', legacyVersions: [ diff --git a/control-api/src/migrations/migrationRunner.ts b/control-api/src/migrations/migrationRunner.ts index 5325541d2d..7b14d0de2d 100644 --- a/control-api/src/migrations/migrationRunner.ts +++ b/control-api/src/migrations/migrationRunner.ts @@ -8,6 +8,7 @@ export const PR1_MIGRATION_VERSIONS = Object.freeze([ '0127_catalog_utf8_ordering', '0128_composable_catalog_revisions', '0129_gfs_catalog_revision_components', + '012a_user_access_foundation_definer_temp_shadow_hardening', '0130_legacy_password_security_epoch_backfill', ] as const) diff --git a/control-api/src/migrations/userAccessFoundationDefinerTempShadowMigration.ts b/control-api/src/migrations/userAccessFoundationDefinerTempShadowMigration.ts new file mode 100644 index 0000000000..c3a807461f --- /dev/null +++ b/control-api/src/migrations/userAccessFoundationDefinerTempShadowMigration.ts @@ -0,0 +1,53 @@ +import type { DbClient } from '../db.js' + +/** + * Harden the installed access-foundation SECURITY DEFINER functions without + * changing their bodies, ownership, grants, or behavior. + */ +export async function applyUserAccessFoundationDefinerTempShadowHardening( + db: DbClient +): Promise { + await db.query(` + ALTER FUNCTION public.authorization_bump_user_revision(pg_catalog.uuid) + SET search_path = pg_catalog, public, pg_temp; + ALTER FUNCTION public.authorization_bump_team_revision(pg_catalog.uuid) + SET search_path = pg_catalog, public, pg_temp; + ALTER FUNCTION public.authorization_bump_subject_revision(pg_catalog.text, pg_catalog.text) + SET search_path = pg_catalog, public, pg_temp; + ALTER FUNCTION public.authorization_bump_user_row_revision() + SET search_path = pg_catalog, public, pg_temp; + ALTER FUNCTION public.authorization_bump_team_row_revision() + SET search_path = pg_catalog, public, pg_temp; + ALTER FUNCTION public.authorization_bump_workflow_run_revision() + SET search_path = pg_catalog, public, pg_temp; + ALTER FUNCTION public.authorization_bump_workflow_approval_revision() + SET search_path = pg_catalog, public, pg_temp; + ALTER FUNCTION public.authorization_bump_notification_revision() + SET search_path = pg_catalog, public, pg_temp; + ALTER FUNCTION public.authorization_bump_gfs_subject_revision() + SET search_path = pg_catalog, public, pg_temp; + ALTER FUNCTION public.authorization_bump_gfs_resource_component(pg_catalog.uuid) + SET search_path = pg_catalog, public, pg_temp; + ALTER FUNCTION public.authorization_bump_gfs_authority_revision() + SET search_path = pg_catalog, public, pg_temp; + ALTER FUNCTION public.authorization_bump_gfs_resource_subjects(pg_catalog.uuid) + SET search_path = pg_catalog, public, pg_temp; + ALTER FUNCTION public.authorization_bump_gfs_resource_revision() + SET search_path = pg_catalog, public, pg_temp; + ALTER FUNCTION public.authorization_bump_resource_revision( + pg_catalog.text, pg_catalog.text, pg_catalog.text + ) SET search_path = pg_catalog, public, pg_temp; + ALTER FUNCTION public.authorization_bump_catalog_revision() + SET search_path = pg_catalog, public, pg_temp; + ALTER FUNCTION public.authorization_bump_team_membership_revision() + SET search_path = pg_catalog, public, pg_temp; + ALTER FUNCTION public.authorization_bump_user_grant_revision() + SET search_path = pg_catalog, public, pg_temp; + ALTER FUNCTION public.authorization_bump_team_grant_revision() + SET search_path = pg_catalog, public, pg_temp; + ALTER FUNCTION public.authorization_bump_operational_resource_revision() + SET search_path = pg_catalog, public, pg_temp; + ALTER FUNCTION public.authorization_bump_operational_relationship_revision() + SET search_path = pg_catalog, public, pg_temp; + `) +} diff --git a/control-api/test/db.initDb.test.ts b/control-api/test/db.initDb.test.ts index 3b6551acbb..5267a58186 100644 --- a/control-api/test/db.initDb.test.ts +++ b/control-api/test/db.initDb.test.ts @@ -65,8 +65,8 @@ describe('db.initDb', () => { ).toBe(false) expect(sqls.some(sql => sql.includes('DROP TABLE IF EXISTS workflow_run_outputs'))).toBe(false) expect(sqls).not.toContain('ROLLBACK') - expect(sqls.filter(sql => sql === 'BEGIN')).toHaveLength(26) - expect(sqls.filter(sql => sql === 'COMMIT')).toHaveLength(26) + expect(sqls.filter(sql => sql === 'BEGIN')).toHaveLength(27) + expect(sqls.filter(sql => sql === 'COMMIT')).toHaveLength(27) expect(sqls[sqls.length - 2]).toBe('COMMIT') expect(sqls[sqls.length - 1]).toContain('SELECT pg_advisory_unlock') expect(clientRelease).toHaveBeenCalledTimes(1) diff --git a/control-api/test/db.migrationsOrder.test.ts b/control-api/test/db.migrationsOrder.test.ts index badfcc36f8..28c2d430af 100644 --- a/control-api/test/db.migrationsOrder.test.ts +++ b/control-api/test/db.migrationsOrder.test.ts @@ -67,6 +67,60 @@ describe('CONTROL_API_MIGRATIONS ordering invariant', () => { expect(currentCollisions).toEqual([]) }) + it('registers the narrow R56-B1 access-foundation definer hardening migration', async () => { + const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') + const version = '012a_user_access_foundation_definer_temp_shadow_hardening' + const migration = CONTROL_API_MIGRATIONS.find(candidate => candidate.version === version) + const versions = CONTROL_API_MIGRATIONS.map(candidate => candidate.version) + + expect(migration).toBeDefined() + expect(migration?.legacyVersions).toBeUndefined() + expect(versions.indexOf('0129_gfs_catalog_revision_components')).toBeLessThan( + versions.indexOf(version) + ) + expect(versions.indexOf(version)).toBeLessThan( + versions.indexOf('0130_legacy_password_security_epoch_backfill') + ) + if (!migration) return + + const query = vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }) + await migration.apply({ query } as never) + expect(query).toHaveBeenCalledOnce() + + const sql = query.mock.calls.map(([statement]) => String(statement)).join('\n') + const compactSql = sql.replace(/\s+/g, '') + const signatures = [ + 'authorization_bump_user_revision(pg_catalog.uuid)', + 'authorization_bump_team_revision(pg_catalog.uuid)', + 'authorization_bump_subject_revision(pg_catalog.text,pg_catalog.text)', + 'authorization_bump_user_row_revision()', + 'authorization_bump_team_row_revision()', + 'authorization_bump_workflow_run_revision()', + 'authorization_bump_workflow_approval_revision()', + 'authorization_bump_notification_revision()', + 'authorization_bump_gfs_subject_revision()', + 'authorization_bump_gfs_resource_component(pg_catalog.uuid)', + 'authorization_bump_gfs_authority_revision()', + 'authorization_bump_gfs_resource_subjects(pg_catalog.uuid)', + 'authorization_bump_gfs_resource_revision()', + 'authorization_bump_resource_revision(pg_catalog.text,pg_catalog.text,pg_catalog.text)', + 'authorization_bump_catalog_revision()', + 'authorization_bump_team_membership_revision()', + 'authorization_bump_user_grant_revision()', + 'authorization_bump_team_grant_revision()', + 'authorization_bump_operational_resource_revision()', + 'authorization_bump_operational_relationship_revision()', + ] + + expect(sql.match(/ALTER FUNCTION public\./g)).toHaveLength(signatures.length) + for (const signature of signatures) { + expect(compactSql).toContain( + `ALTERFUNCTIONpublic.${signature}SETsearch_path=pg_catalog,public,pg_temp;` + ) + } + expect(sql).not.toMatch(/\b(?:CREATE OR REPLACE|DROP|GRANT|REVOKE|OWNER TO)\b/i) + }) + it('recognizes all previously deployed feed migration versions without reapplying DDL', async () => { const { CONTROL_API_MIGRATIONS, initDb } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( diff --git a/control-api/test/db.realPostgresMigration.integration.test.ts b/control-api/test/db.realPostgresMigration.integration.test.ts index 25ebd9eb9f..7df143ffb1 100644 --- a/control-api/test/db.realPostgresMigration.integration.test.ts +++ b/control-api/test/db.realPostgresMigration.integration.test.ts @@ -41,6 +41,53 @@ const runtimeRoles = [ 'trace_maintenance_runtime', 'workflow_recipes_runtime', ] as const +const accessFoundationDefinerSignatures = [ + 'authorization_bump_user_revision(pg_catalog.uuid)', + 'authorization_bump_team_revision(pg_catalog.uuid)', + 'authorization_bump_subject_revision(pg_catalog.text, pg_catalog.text)', + 'authorization_bump_user_row_revision()', + 'authorization_bump_team_row_revision()', + 'authorization_bump_workflow_run_revision()', + 'authorization_bump_workflow_approval_revision()', + 'authorization_bump_notification_revision()', + 'authorization_bump_gfs_subject_revision()', + 'authorization_bump_gfs_resource_component(pg_catalog.uuid)', + 'authorization_bump_gfs_authority_revision()', + 'authorization_bump_gfs_resource_subjects(pg_catalog.uuid)', + 'authorization_bump_gfs_resource_revision()', + 'authorization_bump_resource_revision(pg_catalog.text, pg_catalog.text, pg_catalog.text)', + 'authorization_bump_catalog_revision()', + 'authorization_bump_team_membership_revision()', + 'authorization_bump_user_grant_revision()', + 'authorization_bump_team_grant_revision()', + 'authorization_bump_operational_resource_revision()', + 'authorization_bump_operational_relationship_revision()', +] as const +const accessFoundationDefinerNames = accessFoundationDefinerSignatures.map(signature => + signature.slice(0, signature.indexOf('(')) +) + +type AccessFoundationDefinerState = { + function_name: string + config: string[] | null + owner: string + acl: string[] | null +} + +async function readAccessFoundationDefinerState(pool: Pool) { + return pool.query( + `SELECT procedure.proname AS function_name, + procedure.proconfig AS config, + pg_get_userbyid(procedure.proowner) AS owner, + procedure.proacl AS acl + FROM pg_proc procedure + JOIN pg_namespace namespace ON namespace.oid = procedure.pronamespace + WHERE namespace.nspname = 'public' + AND procedure.proname = ANY($1::text[]) + ORDER BY procedure.proname`, + [accessFoundationDefinerNames] + ) +} function databaseUrl(baseUrl: string, database: string): string { const url = new URL(baseUrl) @@ -158,6 +205,14 @@ describeRealPostgres('control-api real Postgres migrations', () => { const connector = { connect: () => dbPool.connect() } await initDb(connector) + const freshAccessFoundationFunctions = await readAccessFoundationDefinerState(dbPool) + expect(freshAccessFoundationFunctions.rows.map(row => row.function_name)).toEqual( + [...accessFoundationDefinerNames].sort() + ) + for (const { config } of freshAccessFoundationFunctions.rows) { + expect(config).toEqual(['search_path=pg_catalog, public, pg_temp']) + } + const firstVersions = await dbPool.query<{ version: string }>( 'SELECT version FROM schema_migrations ORDER BY version' ) @@ -1936,9 +1991,55 @@ describeRealPostgres('control-api real Postgres migrations', () => { } }) - it('prevents temp-type shadowing in entity-change SECURITY DEFINER triggers', async () => { + it('prevents temp-type shadowing in access-foundation SECURITY DEFINER triggers', async () => { const { initDb } = await import('../src/db.js') - await initDb({ connect: () => dbPool.connect() }) + const connector = { connect: () => dbPool.connect() } + const hardeningVersion = '012a_user_access_foundation_definer_temp_shadow_hardening' + + const preUpgradeFunctions = await readAccessFoundationDefinerState(dbPool) + expect(preUpgradeFunctions.rows.map(row => row.function_name)).toEqual( + [...accessFoundationDefinerNames].sort() + ) + for (const { config } of preUpgradeFunctions.rows) { + expect(config).toEqual(['search_path=pg_catalog, public, pg_temp']) + } + + const originalOwnersAndGrants = preUpgradeFunctions.rows.map( + ({ function_name, owner, acl }) => ({ function_name, owner, acl }) + ) + + // Model an already-migrated pre-fix database: restore the old function + // settings and remove only the new forward-migration marker. + for (const signature of accessFoundationDefinerSignatures) { + await dbPool.query(`ALTER FUNCTION public.${signature} SET search_path = pg_catalog, public`) + } + const removedMarker = await dbPool.query( + `DELETE FROM schema_migrations WHERE version = $1 RETURNING version`, + [hardeningVersion] + ) + expect(removedMarker.rows).toEqual([{ version: hardeningVersion }]) + + await initDb(connector) + const migratedMarker = await dbPool.query<{ version: string }>( + `SELECT version FROM schema_migrations WHERE version = $1`, + [hardeningVersion] + ) + expect(migratedMarker.rows).toEqual([{ version: hardeningVersion }]) + + const upgradedFunctions = await readAccessFoundationDefinerState(dbPool) + expect(upgradedFunctions.rows.map(row => row.function_name)).toEqual( + [...accessFoundationDefinerNames].sort() + ) + for (const { config } of upgradedFunctions.rows) { + expect(config).toEqual(['search_path=pg_catalog, public, pg_temp']) + } + expect( + upgradedFunctions.rows.map(({ function_name, owner, acl }) => ({ + function_name, + owner, + acl, + })) + ).toEqual(originalOwnersAndGrants) const attackerRole = `entity_change_shadow_${randomBytes(5).toString('hex')}` const attackerPassword = randomBytes(24).toString('hex') @@ -1981,6 +2082,19 @@ describeRealPostgres('control-api real Postgres migrations', () => { resourceId, ]) expect(stored.rowCount).toBe(1) + const revision = await dbPool.query<{ revision: string }>( + `SELECT revision::text + FROM authorization_resource_revisions + WHERE environment_id = ( + SELECT environment_id + FROM authorization_catalog_environment + WHERE singleton = TRUE + ) + AND resource_type = 'gfs_resource' + AND resource_id = $1`, + [resourceId] + ) + expect(revision.rows).toEqual([{ revision: '1' }]) } finally { attacker.release() } @@ -2005,7 +2119,7 @@ describeRealPostgres('control-api real Postgres migrations', () => { `) expect(functionPaths.rows).toHaveLength(4) for (const { config } of functionPaths.rows) { - expect(config).toContain('search_path=pg_catalog, public, pg_temp') + expect(config).toEqual(['search_path=pg_catalog, public, pg_temp']) } } finally { await attackerPool?.end() diff --git a/control-api/test/db.userAccessFoundationMigration.test.ts b/control-api/test/db.userAccessFoundationMigration.test.ts index f5fa2ab2b5..e75da71aa9 100644 --- a/control-api/test/db.userAccessFoundationMigration.test.ts +++ b/control-api/test/db.userAccessFoundationMigration.test.ts @@ -212,10 +212,14 @@ describe('user-access foundation migration', () => { const gfsIndex = CONTROL_API_MIGRATIONS.findIndex( candidate => candidate.version === '0129_gfs_catalog_revision_components' ) + const hardeningIndex = CONTROL_API_MIGRATIONS.findIndex( + candidate => candidate.version === '012a_user_access_foundation_definer_temp_shadow_hardening' + ) const epochIndex = CONTROL_API_MIGRATIONS.findIndex( candidate => candidate.version === '0130_legacy_password_security_epoch_backfill' ) - expect(epochIndex).toBe(gfsIndex + 1) + expect(hardeningIndex).toBe(gfsIndex + 1) + expect(epochIndex).toBe(hardeningIndex + 1) const recordedVersions = CONTROL_API_MIGRATIONS.filter( candidate => candidate.version !== migration?.version diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index 21249a9b98..ffe0c9ce77 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -260,8 +260,8 @@ describe('D34 PR1 migration runner', () => { }) expect(applied).toEqual([...DEV_POST_0106_MIGRATION_VERSIONS, ...PR1_MIGRATION_VERSIONS]) - expect(queries.filter(({ sql }) => sql === 'BEGIN')).toHaveLength(25) - expect(queries.filter(({ sql }) => sql === 'COMMIT')).toHaveLength(25) + expect(queries.filter(({ sql }) => sql === 'BEGIN')).toHaveLength(26) + expect(queries.filter(({ sql }) => sql === 'COMMIT')).toHaveLength(26) expect(queries.filter(({ sql }) => sql === 'ROLLBACK')).toHaveLength(0) }) From fcc1a59397412208118f669f08b359dbb0502142 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:28:11 -0600 Subject: [PATCH 252/528] fix(control-api): harden only installed revision functions Class-check: access-foundation SECURITY DEFINER DDL -> 22 sites/20 signatures -> 19 live funcs Repro-test: Real PostgreSQL migration/temp-shadow suite (fails at 08ee47bd821b99acd9db6659c10dffbead7226c2 during initDb) --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- .../userAccessFoundationDefinerTempShadowMigration.ts | 4 ++-- control-api/test/db.migrationsOrder.test.ts | 3 ++- .../test/db.realPostgresMigration.integration.test.ts | 6 +++++- 5 files changed, 12 insertions(+), 7 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 901ef8fa0c..9ddf1f33f0 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.701", + "version": "0.1.702", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.701", + "version": "0.1.702", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 43026ba2d3..535bbc6c58 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.701", + "version": "0.1.702", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/migrations/userAccessFoundationDefinerTempShadowMigration.ts b/control-api/src/migrations/userAccessFoundationDefinerTempShadowMigration.ts index c3a807461f..897b09cc91 100644 --- a/control-api/src/migrations/userAccessFoundationDefinerTempShadowMigration.ts +++ b/control-api/src/migrations/userAccessFoundationDefinerTempShadowMigration.ts @@ -3,6 +3,8 @@ import type { DbClient } from '../db.js' /** * Harden the installed access-foundation SECURITY DEFINER functions without * changing their bodies, ownership, grants, or behavior. + * The obsolete authorization_bump_catalog_revision() routine is intentionally + * excluded because 0128_composable_catalog_revisions drops it before this runs. */ export async function applyUserAccessFoundationDefinerTempShadowHardening( db: DbClient @@ -37,8 +39,6 @@ export async function applyUserAccessFoundationDefinerTempShadowHardening( ALTER FUNCTION public.authorization_bump_resource_revision( pg_catalog.text, pg_catalog.text, pg_catalog.text ) SET search_path = pg_catalog, public, pg_temp; - ALTER FUNCTION public.authorization_bump_catalog_revision() - SET search_path = pg_catalog, public, pg_temp; ALTER FUNCTION public.authorization_bump_team_membership_revision() SET search_path = pg_catalog, public, pg_temp; ALTER FUNCTION public.authorization_bump_user_grant_revision() diff --git a/control-api/test/db.migrationsOrder.test.ts b/control-api/test/db.migrationsOrder.test.ts index 28c2d430af..1fc66790fc 100644 --- a/control-api/test/db.migrationsOrder.test.ts +++ b/control-api/test/db.migrationsOrder.test.ts @@ -104,7 +104,6 @@ describe('CONTROL_API_MIGRATIONS ordering invariant', () => { 'authorization_bump_gfs_resource_subjects(pg_catalog.uuid)', 'authorization_bump_gfs_resource_revision()', 'authorization_bump_resource_revision(pg_catalog.text,pg_catalog.text,pg_catalog.text)', - 'authorization_bump_catalog_revision()', 'authorization_bump_team_membership_revision()', 'authorization_bump_user_grant_revision()', 'authorization_bump_team_grant_revision()', @@ -112,6 +111,8 @@ describe('CONTROL_API_MIGRATIONS ordering invariant', () => { 'authorization_bump_operational_relationship_revision()', ] + // 0128_composable_catalog_revisions removes this trigger before 012a runs. + expect(signatures).not.toContain('authorization_bump_catalog_revision()') expect(sql.match(/ALTER FUNCTION public\./g)).toHaveLength(signatures.length) for (const signature of signatures) { expect(compactSql).toContain( diff --git a/control-api/test/db.realPostgresMigration.integration.test.ts b/control-api/test/db.realPostgresMigration.integration.test.ts index 7df143ffb1..4bb506ca29 100644 --- a/control-api/test/db.realPostgresMigration.integration.test.ts +++ b/control-api/test/db.realPostgresMigration.integration.test.ts @@ -56,7 +56,6 @@ const accessFoundationDefinerSignatures = [ 'authorization_bump_gfs_resource_subjects(pg_catalog.uuid)', 'authorization_bump_gfs_resource_revision()', 'authorization_bump_resource_revision(pg_catalog.text, pg_catalog.text, pg_catalog.text)', - 'authorization_bump_catalog_revision()', 'authorization_bump_team_membership_revision()', 'authorization_bump_user_grant_revision()', 'authorization_bump_team_grant_revision()', @@ -205,6 +204,11 @@ describeRealPostgres('control-api real Postgres migrations', () => { const connector = { connect: () => dbPool.connect() } await initDb(connector) + const obsoleteCatalogRevision = await dbPool.query<{ function_name: string | null }>( + `SELECT to_regprocedure('public.authorization_bump_catalog_revision()')::text + AS function_name` + ) + expect(obsoleteCatalogRevision.rows).toEqual([{ function_name: null }]) const freshAccessFoundationFunctions = await readAccessFoundationDefinerState(dbPool) expect(freshAccessFoundationFunctions.rows.map(row => row.function_name)).toEqual( [...accessFoundationDefinerNames].sort() From 999809c0ef2bb46012898645cbc4fe230ae993fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:16:02 -0600 Subject: [PATCH 253/528] fix(control-api): r55-m5 parse message-resolution at 2 KiB Finding: R55-M5 Class-check: RPC_HOST_ACCESS_OWN_BODY_PARSER_PATH -> 1 app parser gate; cover the root route and exact message-resolution sibling only. Repro-test: control-api/test/app.test.ts fails at fcc1a59397412208118f669f08b359dbb0502142 with 400 instead of parser-level 413. --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- control-api/src/app.ts | 5 +++-- control-api/test/app.test.ts | 21 +++++++++++++++++++++ 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 9ddf1f33f0..8cd4a6784f 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.702", + "version": "0.1.703", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.702", + "version": "0.1.703", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 535bbc6c58..67e4cf3ee1 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.702", + "version": "0.1.703", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/app.ts b/control-api/src/app.ts index 676086a2cb..f27950a283 100644 --- a/control-api/src/app.ts +++ b/control-api/src/app.ts @@ -61,7 +61,8 @@ import { } from './services/tracing/wrcInfrastructureBindingResolver.js' const TRACING_INTERNAL_PATH_PREFIX = '/api/v1/internal/tracing/' -const RPC_HOST_ACCESS_PATH = /^\/api\/v1\/rpc\/access\/users\/[^/]+\/mcp-hosts\/[^/]+\/?$/i +const RPC_HOST_ACCESS_OWN_BODY_PARSER_PATH = + /^\/api\/v1\/rpc\/access\/users\/[^/]+\/mcp-hosts\/[^/]+(?:\/message-resolution)?\/?$/i // Upload v2 part bodies are raw octet streams. They must reach the streaming // proxy untouched; parsing them as JSON would either reject the first binary // byte or buffer the whole part in the control plane. @@ -84,7 +85,7 @@ export function createApp(gateway: K8sGateway) { tracingInFlightLimiter(req, res, next) return } - if (req.method === 'POST' && RPC_HOST_ACCESS_PATH.test(req.path)) { + if (req.method === 'POST' && RPC_HOST_ACCESS_OWN_BODY_PARSER_PATH.test(req.path)) { next() return } diff --git a/control-api/test/app.test.ts b/control-api/test/app.test.ts index 4fb138913a..833d558ffd 100644 --- a/control-api/test/app.test.ts +++ b/control-api/test/app.test.ts @@ -150,6 +150,27 @@ describe('app router wiring', () => { .expect(413) }) + it('uses the dedicated 2 KiB parser for Host message-resolution requests', async () => { + const app = createApp(new MockGateway('mcp-server') as never) + const rpcToken = signRpcAccessToken({ + sub: 'user-1', + typ: 'user', + teamId: 'team-1', + role: 'member', + scopes: ['host:message:invoke'], + hostRefs: ['host-a'], + jti: 'app-message-resolution-body-limit', + }) + + await request(app) + .post('/api/v1/rpc/access/users/user-1/mcp-hosts/host-a/message-resolution') + .set('authorization', 'Bearer dev-rpc-proxy-token') + .set('x-service-token', 'rpc-proxy') + .set('x-rpc-access-token', rpcToken) + .send({ sessionId: 'x'.repeat(3_000) }) + .expect(413) + }) + it('rejects an HCC health transition when no server-verifiable Host binding source exists', async () => { const app = createApp(new MockGateway('mcp-server') as never) From 52fa7ce9fec0efa1b478efe6505a862f65ff4c71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:27:02 -0600 Subject: [PATCH 254/528] fix(auth): r55-h2 abandon Profile credentials on logout failure Finding: R55-H2 Class-check: /auth/logout -> 1 server route; logoutProfileUI -> 1 consumer; apiGet/apiSend -> all authenticated Profile wrappers reject pending local logout. Repro-test: auth route and Profile logout consumer tests fail at 999809c0e before cookie expiry, local credential suppression, and revocation warning. --- external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- external-rest-api/src/releaseManifest.ts | 2 +- external-rest-api/src/routes/auth.ts | 1 + external-rest-api/test/routes.auth.test.ts | 84 +++++++++++++++-- .../app/components/AuthContext/index.tsx | 21 ++++- .../__tests__/AuthContext.logout.test.tsx | 89 +++++++++++++++++++ .../components/__tests__/api.logout.test.ts | 50 +++++++++++ profile-ui/lib/api.ts | 68 ++++++++++++-- profile-ui/package-lock.json | 4 +- profile-ui/package.json | 2 +- 11 files changed, 306 insertions(+), 21 deletions(-) create mode 100644 profile-ui/app/components/__tests__/AuthContext.logout.test.tsx create mode 100644 profile-ui/app/components/__tests__/api.logout.test.ts diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index ff88a88560..d85209e604 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.108", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.108", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index d8dc7498ab..8a2e1361cb 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.107", + "version": "0.1.108", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index 3dc9bd1b9f..915c9aef53 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.10.0', - externalRestApiVersion: '0.1.107', + externalRestApiVersion: '0.1.108', rpcProxyVersion: '0.1.113', desktopVersion: '0.10.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/src/routes/auth.ts b/external-rest-api/src/routes/auth.ts index d78988e275..6184f1d174 100644 --- a/external-rest-api/src/routes/auth.ts +++ b/external-rest-api/src/routes/auth.ts @@ -174,6 +174,7 @@ export function createAuthRouter(): Router { res.status(200).json({ ok: true }) return } + clearProfileSessionCookie(req, res) next(error) } }) diff --git a/external-rest-api/test/routes.auth.test.ts b/external-rest-api/test/routes.auth.test.ts index ffd797aaff..ed7689004a 100644 --- a/external-rest-api/test/routes.auth.test.ts +++ b/external-rest-api/test/routes.auth.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import express from 'express' import request from 'supertest' +import { externalRestPublicErrorHandler } from '../src/app.js' import { ControlApiError } from '../src/controlApiClient.js' import { createAuthRouter } from '../src/routes/auth.js' @@ -8,6 +9,7 @@ const authServiceMock = vi.hoisted(() => ({ loginWithGoogle: vi.fn(), loginWithPassword: vi.fn(), requestPasswordReset: vi.fn(), + logoutUserSession: vi.fn(), })) vi.mock('../src/services/authService.js', () => authServiceMock) @@ -17,13 +19,7 @@ function buildApp() { app.set('trust proxy', 1) app.use(express.json()) app.use('/api/v1', createAuthRouter()) - app.use( - (err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { - res.status(500).json({ - error: err instanceof Error ? err.message : 'Unknown error', - }) - } - ) + app.use(externalRestPublicErrorHandler) return app } @@ -31,6 +27,7 @@ describe('routes/auth password-login', () => { beforeEach(() => { authServiceMock.loginWithPassword.mockReset() authServiceMock.requestPasswordReset.mockReset() + authServiceMock.logoutUserSession.mockReset() }) it('propagates invalid credentials as a 401 instead of a 500', async () => { @@ -143,4 +140,77 @@ describe('routes/auth password-login', () => { expect(limited.headers['x-ratelimit-remaining']).toBe('0') expect(limited.body).toMatchObject({ retryAfterSeconds: expect.any(Number) }) }) + + it.each([ + [429, 'rate_limited'], + [503, 'authority_unavailable'], + ] as const)( + 'expires the Profile cookie without claiming logout success when revocation returns %s', + async (status, code) => { + authServiceMock.logoutUserSession.mockRejectedValueOnce( + new ControlApiError('private upstream detail', status, { error: { code } }) + ) + + const response = await request(buildApp()) + .post('/api/v1/auth/logout') + .set('Cookie', 'profile_session=opaque-session') + .set('x-forwarded-proto', 'https') + .expect(status) + + expect(response.body.error.code).toBe(code) + expect(JSON.stringify(response.body)).not.toContain('private upstream detail') + expect(response.body.ok).toBeUndefined() + expect(authServiceMock.logoutUserSession).toHaveBeenCalledOnce() + const cookie = String(response.headers['set-cookie']) + expect(cookie).toContain('profile_session=') + expect(cookie).toContain('Expires=Thu, 01 Jan 1970 00:00:00 GMT') + expect(cookie).toContain('HttpOnly') + expect(cookie).toContain('Secure') + expect(cookie).toContain('SameSite=Lax') + expect(cookie).toContain('Path=/') + } + ) + + it('preserves successful logout and expires the Profile cookie', async () => { + authServiceMock.logoutUserSession.mockResolvedValueOnce(undefined) + + const response = await request(buildApp()) + .post('/api/v1/auth/logout') + .set('Cookie', 'profile_session=opaque-session') + .set('x-forwarded-proto', 'https') + .expect(200) + + expect(response.body).toEqual({ ok: true }) + expect(String(response.headers['set-cookie'])).toContain( + 'Expires=Thu, 01 Jan 1970 00:00:00 GMT' + ) + expect(String(response.headers['set-cookie'])).toContain('Secure') + }) + + it('keeps already-invalid logout idempotent and expires its cookie', async () => { + authServiceMock.logoutUserSession.mockRejectedValueOnce( + new ControlApiError('private upstream detail', 401, { error: 'Unauthorized' }) + ) + + const response = await request(buildApp()) + .post('/api/v1/auth/logout') + .set('Cookie', 'profile_session=opaque-session') + .set('x-forwarded-proto', 'https') + .expect(200) + + expect(response.body).toEqual({ ok: true }) + expect(String(response.headers['set-cookie'])).toContain( + 'Expires=Thu, 01 Jan 1970 00:00:00 GMT' + ) + }) + + it('does not call remote revocation or set a cookie when no session is present', async () => { + const response = await request(buildApp()).post('/api/v1/auth/logout').expect(200) + + expect(response.body).toEqual({ ok: true }) + expect(authServiceMock.logoutUserSession).not.toHaveBeenCalled() + expect(String(response.headers['set-cookie'])).toContain( + 'Expires=Thu, 01 Jan 1970 00:00:00 GMT' + ) + }) }) diff --git a/profile-ui/app/components/AuthContext/index.tsx b/profile-ui/app/components/AuthContext/index.tsx index 5762568563..ba2fd587f5 100644 --- a/profile-ui/app/components/AuthContext/index.tsx +++ b/profile-ui/app/components/AuthContext/index.tsx @@ -9,6 +9,7 @@ import React, { useRef, useState, } from 'react' +import { useToast } from '@components/Toast' import { clearToken, getMe, @@ -48,6 +49,7 @@ function meFromPasswordLoginResponse(me: PasswordLoginResponse['me']): Me { } export function AuthProvider({ children }: { children: React.ReactNode }) { + const { showToast } = useToast() const sessionExpiredHandledRef = useRef(false) const [authState, setAuthState] = useState({ isLoggedIn: false, @@ -107,9 +109,24 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const logout = useCallback(() => { sessionExpiredHandledRef.current = false resetProfileAccessCache(authState.me?.id) - void logoutProfileUI() + clearToken() setAuthState({ isLoggedIn: false, isLoading: false, me: null }) - }, [authState.me?.id]) + void logoutProfileUI() + .then(({ revocationConfirmed }) => { + if (!revocationConfirmed) { + showToast( + 'You are signed out here, but the server could not confirm that your session was revoked. The old session may remain valid until it expires.', + { tone: 'error', durationMs: 7000 } + ) + } + }) + .catch(() => { + showToast( + 'You are signed out here, but the server could not confirm that your session was revoked. The old session may remain valid until it expires.', + { tone: 'error', durationMs: 7000 } + ) + }) + }, [authState.me?.id, showToast]) const value = useMemo( () => ({ authState, login, logout, checkAuth }), diff --git a/profile-ui/app/components/__tests__/AuthContext.logout.test.tsx b/profile-ui/app/components/__tests__/AuthContext.logout.test.tsx new file mode 100644 index 0000000000..abd8b0cdfd --- /dev/null +++ b/profile-ui/app/components/__tests__/AuthContext.logout.test.tsx @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { AuthProvider, useAuth } from '@components/AuthContext' +import { ToastProvider } from '@components/Toast' + +const mocks = vi.hoisted(() => ({ + clearToken: vi.fn(), + getMe: vi.fn(), + isSilentApiError: vi.fn(), + loginWithPassword: vi.fn(), + logoutProfileUI: vi.fn(), + setGlobalAuthErrorHandler: vi.fn(), + resetProfileAccessCache: vi.fn(), +})) + +vi.mock('@lib/api', () => ({ + clearToken: mocks.clearToken, + getMe: mocks.getMe, + isSilentApiError: mocks.isSilentApiError, + loginWithPassword: mocks.loginWithPassword, + logoutProfileUI: mocks.logoutProfileUI, + setGlobalAuthErrorHandler: mocks.setGlobalAuthErrorHandler, +})) + +vi.mock('@lib/profileAccess', () => ({ resetProfileAccessCache: mocks.resetProfileAccessCache })) + +function AuthProbe() { + const { authState, logout } = useAuth() + return ( +
+ {authState.isLoggedIn ? 'signed-in' : 'signed-out'} + +
+ ) +} + +describe('Profile logout consumer', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getMe.mockResolvedValue({ id: 'user-1', email: 'user@example.invalid' }) + mocks.isSilentApiError.mockReturnValue(false) + }) + + afterEach(cleanup) + + it('stays signed out and explains unconfirmed revocation', async () => { + mocks.logoutProfileUI.mockResolvedValueOnce({ revocationConfirmed: false }) + render( + + + + + + ) + + await screen.findByText('signed-in') + fireEvent.click(screen.getByRole('button', { name: 'Log out' })) + + expect(screen.getByText('signed-out')).toBeInTheDocument() + await waitFor(() => + expect(screen.getByRole('status')).toHaveTextContent( + /server could not confirm that your session was revoked/i + ) + ) + }) + + it('handles an unexpected logout rejection without an unhandled promise', async () => { + mocks.logoutProfileUI.mockRejectedValueOnce(new Error('unexpected transport failure')) + render( + + + + + + ) + + await screen.findByText('signed-in') + fireEvent.click(screen.getByRole('button', { name: 'Log out' })) + + expect(screen.getByText('signed-out')).toBeInTheDocument() + await waitFor(() => + expect(screen.getByRole('status')).toHaveTextContent( + /server could not confirm that your session was revoked/i + ) + ) + }) +}) diff --git a/profile-ui/app/components/__tests__/api.logout.test.ts b/profile-ui/app/components/__tests__/api.logout.test.ts new file mode 100644 index 0000000000..140c23a4a4 --- /dev/null +++ b/profile-ui/app/components/__tests__/api.logout.test.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AuthExpiredError, apiGet, loginWithPassword, logoutProfileUI } from '@lib/api' + +describe('Profile logout client boundary', () => { + beforeEach(() => { + window.localStorage.clear() + vi.stubGlobal('fetch', vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it.each([ + ['503 response', () => Promise.resolve(new Response('', { status: 503 }))], + ['transport failure', () => Promise.reject(new TypeError('network unavailable'))], + ])('abandons local credentials after a logout %s', async (_caseName, response) => { + vi.mocked(fetch).mockImplementationOnce(response) + window.localStorage.setItem('external_session_token', 'legacy-token') + + await expect(logoutProfileUI()).resolves.toEqual({ revocationConfirmed: false }) + + expect(window.localStorage.getItem('external_session_token')).toBeNull() + await expect(apiGet('/api/v1/me')).rejects.toBeInstanceOf(AuthExpiredError) + expect(fetch).toHaveBeenCalledOnce() + }) + + it('restores session requests only after a successful new login', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce(new Response('', { status: 503 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ token: 'new-token', me: { id: 'user-1' } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ id: 'user-1' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) + + await logoutProfileUI() + await loginWithPassword('user@example.invalid', 'new-password') + await expect(apiGet('/api/v1/me')).resolves.toEqual({ id: 'user-1' }) + + expect(vi.mocked(fetch).mock.calls[2]?.[1]?.credentials).toBe('include') + }) +}) diff --git a/profile-ui/lib/api.ts b/profile-ui/lib/api.ts index d4bb04ff28..8d668364c8 100644 --- a/profile-ui/lib/api.ts +++ b/profile-ui/lib/api.ts @@ -34,6 +34,39 @@ type ApiRequestOptions = { silentUnauthorized?: boolean } +const PROFILE_LOGOUT_UNCONFIRMED_KEY = 'profile_logout_unconfirmed' +let logoutUnconfirmedInMemory = false + +function hasUnconfirmedLogout(): boolean { + if (typeof window === 'undefined') return false + if (logoutUnconfirmedInMemory) return true + try { + return window.localStorage.getItem(PROFILE_LOGOUT_UNCONFIRMED_KEY) === 'true' + } catch { + return false + } +} + +function markLogoutUnconfirmed(): void { + if (typeof window === 'undefined') return + logoutUnconfirmedInMemory = true + try { + window.localStorage.setItem(PROFILE_LOGOUT_UNCONFIRMED_KEY, 'true') + } catch { + // Auth state still signs out in memory when browser storage is unavailable. + } +} + +function clearUnconfirmedLogout(): void { + if (typeof window === 'undefined') return + logoutUnconfirmedInMemory = false + try { + window.localStorage.removeItem(PROFILE_LOGOUT_UNCONFIRMED_KEY) + } catch { + // A successful login remains usable for the current page even if storage is unavailable. + } +} + export class AuthExpiredError extends Error { silent = true status = 401 @@ -104,9 +137,17 @@ export function clearToken(): void { if (typeof window === 'undefined') return // Clear legacy browser-readable storage for users upgrading from the old // token flow. The active profile session is now an HttpOnly cookie. - window.localStorage.removeItem(EXTERNAL_SESSION_TOKEN_KEY) - window.localStorage.removeItem(EXTERNAL_SESSION_TOKEN_EXPIRES_AT_KEY) - document.cookie = `${EXTERNAL_SESSION_TOKEN_KEY}=; path=/; max-age=0; SameSite=Lax` + try { + window.localStorage.removeItem(EXTERNAL_SESSION_TOKEN_KEY) + window.localStorage.removeItem(EXTERNAL_SESSION_TOKEN_EXPIRES_AT_KEY) + } catch { + // Continue local logout even when browser storage is unavailable. + } + try { + document.cookie = `${EXTERNAL_SESSION_TOKEN_KEY}=; path=/; max-age=0; SameSite=Lax` + } catch { + // The server-owned HttpOnly cookie is expired by the logout response. + } } export async function apiGet( @@ -114,6 +155,13 @@ export async function apiGet( q: Record = {}, options: ApiRequestOptions = {} ) { + if (hasUnconfirmedLogout()) { + if (options.silentUnauthorized) { + clearToken() + throw new AuthExpiredError() + } + handleUnauthorized() + } const res = await fetch(`${EXTERNAL_REST_API_BASE_URL}${path}${query(q)}`, { cache: 'no-store', credentials: 'include', @@ -138,6 +186,7 @@ export async function apiSend( q: Record = {}, extraHeaders: Record = {} ) { + if (hasUnconfirmedLogout()) handleUnauthorized() const headers: Record = { 'Content-Type': 'application/json', ...extraHeaders, @@ -188,18 +237,27 @@ export async function loginWithPassword( throw new Error(`${res.status} ${res.statusText} - ${detail}`) } clearToken() + clearUnconfirmedLogout() return (await res.json()) as PasswordLoginResponse } -export async function logoutProfileUI(): Promise { +export async function logoutProfileUI(): Promise<{ revocationConfirmed: boolean }> { + markLogoutUnconfirmed() + let revocationConfirmed = false try { - await fetch(`${EXTERNAL_REST_API_BASE_URL}/api/v1/auth/logout`, { + const response = await fetch(`${EXTERNAL_REST_API_BASE_URL}/api/v1/auth/logout`, { method: 'POST', credentials: 'include', }) + revocationConfirmed = response.ok + } catch { + // The browser cannot expire an HttpOnly cookie without a server response. + // The local marker prevents that cookie from restoring or authorizing Profile state. } finally { clearToken() } + if (revocationConfirmed) clearUnconfirmedLogout() + return { revocationConfirmed } } export async function requestPasswordReset(email: string): Promise<{ requested: true }> { diff --git a/profile-ui/package-lock.json b/profile-ui/package-lock.json index 2f494bf80d..348c55153e 100644 --- a/profile-ui/package-lock.json +++ b/profile-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-profile-ui", - "version": "0.1.165", + "version": "0.1.166", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-profile-ui", - "version": "0.1.165", + "version": "0.1.166", "dependencies": { "@clerum/desktop-app-links": "file:../packages/desktop-app-links", "@clerum/frontend-components": "file:../packages/frontend-components", diff --git a/profile-ui/package.json b/profile-ui/package.json index 47e1f309ae..052c0e7887 100644 --- a/profile-ui/package.json +++ b/profile-ui/package.json @@ -1,6 +1,6 @@ { "name": "clerum-profile-ui", - "version": "0.1.165", + "version": "0.1.166", "private": true, "scripts": { "dev": "next dev", From 9f2c68e748665271ee89e810538a97229cf90af9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:31:02 -0600 Subject: [PATCH 255/528] fix(test): r55-l1 assert migration fixture failures Finding: R55-L1 Class-check: expected-failure run_fixture sites in test-control-api-migration-bounds.sh -> 6 masked status captures corrected; conditional failure assertions preserved. Repro-test: test-control-api-migration-bounds.sh passes normally; mutating deploy die exit 1 to 0 fails at the mismatched active migration Job assertion (previous head 52fa7ce9f). --- .../test-control-api-migration-bounds.sh | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/scripts/tests/test-control-api-migration-bounds.sh b/scripts/tests/test-control-api-migration-bounds.sh index 8afa88bdf6..d6bcb9c0f7 100755 --- a/scripts/tests/test-control-api-migration-bounds.sh +++ b/scripts/tests/test-control-api-migration-bounds.sh @@ -174,6 +174,16 @@ run_fixture() { bash "$DEPLOY_FILE" --overlay "$overlay" 2>&1 } +expect_fixture_failure() { + local description="$1" + shift + local output + if output="$("$@" 2>&1)"; then + fail "$description unexpectedly succeeded" + fi + printf '%s' "$output" +} + run_fixture success >/dev/null ruby -rjson -e ' job = JSON.parse(File.read(ARGV.fetch(0))) @@ -261,7 +271,8 @@ if run_fixture active-jobspec-mismatch >/dev/null; then fi mv "$fixture_root/jobspec-matched.json" "$capture" -status_output="$(run_fixture active-status-error || true)" +status_output="$(expect_fixture_failure 'existing migration Job status check' \ + run_fixture active-status-error)" [[ "$status_output" == *'could not determine existing migration Job status'* ]] || \ fail "existing migration Job status error did not fail closed" @@ -279,7 +290,8 @@ fi mv "$fixture_root/race-matched.json" "$capture" : >"$log_file" -timeout_output="$(run_fixture timeout-cleanup-succeeds || true)" +timeout_output="$(expect_fixture_failure 'migration client timeout' \ + run_fixture timeout-cleanup-succeeds)" grep -q 'delete job control-api-db-migrate.*--cascade=foreground.*--wait=false' "$log_file" || \ fail "client timeout did not issue foreground Job deletion" grep -Eq 'wait --for=delete --timeout=(59|60)s job/control-api-db-migrate' "$log_file" || \ @@ -290,20 +302,24 @@ grep -Eq 'wait --for=delete --timeout=(59|60)s pod -l job-name=control-api-db-mi [[ "$timeout_output" != *'fixture-timeout-cleanup-succeeds'* ]] || \ fail "migration diagnostics exposed a secret" -cleanup_output="$(run_fixture timeout-cleanup-fails || true)" +cleanup_output="$(expect_fixture_failure 'unproven migration cleanup' \ + run_fixture timeout-cleanup-fails)" [[ "$cleanup_output" == *'cleanup could not prove Job and pod termination within 60s'* ]] || \ fail "unproven migration Job cleanup did not fail distinctly" -delete_output="$(run_fixture timeout-delete-fails || true)" +delete_output="$(expect_fixture_failure 'failed migration Job delete' \ + run_fixture timeout-delete-fails)" [[ "$delete_output" == *'cleanup could not prove Job and pod termination within 60s'* ]] || \ fail "failed migration Job delete was masked" -job_wait_output="$(run_fixture timeout-job-wait-fails || true)" +job_wait_output="$(expect_fixture_failure 'failed migration Job deletion proof' \ + run_fixture timeout-job-wait-fails)" [[ "$job_wait_output" == *'cleanup could not prove Job and pod termination within 60s'* ]] || \ fail "failed migration Job deletion proof was masked" : >"$log_file" -run_fixture timeout-shared-deadline >/dev/null 2>&1 || true +expect_fixture_failure 'shared migration cleanup deadline' \ + run_fixture timeout-shared-deadline >/dev/null 2>&1 job_timeout="$(sed -nE 's/.*--for=delete --timeout=([0-9]+)s job\/.*/\1/p' "$log_file" | tail -1)" pod_timeout="$(sed -nE 's/.*--for=delete --timeout=([0-9]+)s pod .*/\1/p' "$log_file" | tail -1)" [ -n "$job_timeout" ] && [ -n "$pod_timeout" ] && [ "$pod_timeout" -lt "$job_timeout" ] || \ From 75c4e73cc773f8673dc61b772daf288d344faf5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:37:45 -0600 Subject: [PATCH 256/528] fix(auth): r5-m4 normalize retired login denial Class-check: public Google/password retired denials -> 2 routes -> canonical 401 Repro-test: test/routes.auth.test.ts retired password denial (fails at 9f2c68e748665271ee89e810538a97229cf90af9) --- external-rest-api/package-lock.json | 4 +-- external-rest-api/package.json | 2 +- external-rest-api/src/releaseManifest.ts | 2 +- external-rest-api/src/routes/auth.ts | 15 +++++++++ external-rest-api/test/routes.auth.test.ts | 39 ++++++++++++++++++++++ 5 files changed, 58 insertions(+), 4 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index d85209e604..d6f2927f00 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.108", + "version": "0.1.109", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.108", + "version": "0.1.109", "dependencies": { "cors": "^2.8.5", "express": "^4.21.2", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index 8a2e1361cb..dbf4d93e11 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.108", + "version": "0.1.109", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index 915c9aef53..d1c83bd414 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.10.0', - externalRestApiVersion: '0.1.108', + externalRestApiVersion: '0.1.109', rpcProxyVersion: '0.1.113', desktopVersion: '0.10.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/src/routes/auth.ts b/external-rest-api/src/routes/auth.ts index 6184f1d174..544e091628 100644 --- a/external-rest-api/src/routes/auth.ts +++ b/external-rest-api/src/routes/auth.ts @@ -30,6 +30,13 @@ function isControlApiStatus(error: unknown, status: number): error is ControlApi return error instanceof ControlApiError && error.status === status } +function isRetiredAccountLoginDenial(error: unknown): error is ControlApiError { + if (!isControlApiStatus(error, 403) || !error.body || typeof error.body !== 'object') { + return false + } + return 'error' in error.body && error.body.error === 'membership_not_found' +} + type LoginResponse = { token: string me: unknown @@ -83,6 +90,10 @@ export function createAuthRouter(): Router { return } if (isControlApiStatus(error, 403)) { + if (isRetiredAccountLoginDenial(error)) { + res.status(401).json({ error: 'Unauthorized' }) + return + } res.status(403).json({ error: 'membership_not_found' }) return } @@ -113,6 +124,10 @@ export function createAuthRouter(): Router { res.status(401).json({ error: 'Unauthorized' }) return } + if (isRetiredAccountLoginDenial(error)) { + res.status(401).json({ error: 'Unauthorized' }) + return + } if (message.includes('(403)')) { res.status(403).json({ error: 'Membership not found' }) return diff --git a/external-rest-api/test/routes.auth.test.ts b/external-rest-api/test/routes.auth.test.ts index ed7689004a..6b304192c6 100644 --- a/external-rest-api/test/routes.auth.test.ts +++ b/external-rest-api/test/routes.auth.test.ts @@ -43,6 +43,45 @@ describe('routes/auth password-login', () => { expect(res.body).toEqual({ error: 'Unauthorized' }) }) + it('keeps retired accounts indistinguishable from invalid credentials', async () => { + authServiceMock.loginWithPassword.mockRejectedValueOnce( + new ControlApiError('private upstream detail', 403, { error: 'membership_not_found' }) + ) + + const res = await request(buildApp()) + .post('/api/v1/auth/password-login') + .send({ email: 'retired@example.invalid', password: 'wrong-password' }) + + expect(res.status).toBe(401) + expect(res.body).toEqual({ error: 'Unauthorized' }) + }) + + it('keeps retired Google accounts indistinguishable across credential providers', async () => { + authServiceMock.loginWithGoogle.mockRejectedValueOnce( + new ControlApiError('private upstream detail', 403, { error: 'membership_not_found' }) + ) + + const res = await request(buildApp()) + .post('/api/v1/auth/google') + .send({ idToken: 'validly-shaped-google-token' }) + + expect(res.status).toBe(401) + expect(res.body).toEqual({ error: 'Unauthorized' }) + }) + + it('preserves the password-not-set response', async () => { + authServiceMock.loginWithPassword.mockRejectedValueOnce( + new ControlApiError('control-api error (409)', 409, { error: 'password_not_set' }) + ) + + const res = await request(buildApp()) + .post('/api/v1/auth/password-login') + .send({ email: 'active@example.invalid', password: 'password' }) + + expect(res.status).toBe(409) + expect(res.body).toEqual({ error: 'password_not_set' }) + }) + it('sets an HttpOnly profile session cookie and omits bearer token body for browser login', async () => { authServiceMock.loginWithPassword.mockResolvedValueOnce({ token: 'profile-session-jwt', From 2486a5c0779a6bcf29b0cbd3c875f97827db73eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:43:47 -0600 Subject: [PATCH 257/528] fix(control-api): r55-h1 classify catalog environment access Class-check: runtime relation inventory -> one omission -> existing SELECT only Repro-test: runtime-access contract script (fails at 75c4e73cc773f8673dc61b772daf288d344faf5c) --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- .../test/db.realPostgresMigration.integration.test.ts | 2 ++ deploy/scripts/control-api-runtime-access-profiles.tsv | 1 + scripts/tests/test-control-api-runtime-access-contract.sh | 7 ++++--- 5 files changed, 10 insertions(+), 6 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 8cd4a6784f..fab665aa2d 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.703", + "version": "0.1.704", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.703", + "version": "0.1.704", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 67e4cf3ee1..04b010bb39 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.703", + "version": "0.1.704", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/db.realPostgresMigration.integration.test.ts b/control-api/test/db.realPostgresMigration.integration.test.ts index 4bb506ca29..08e2451ba0 100644 --- a/control-api/test/db.realPostgresMigration.integration.test.ts +++ b/control-api/test/db.realPostgresMigration.integration.test.ts @@ -501,6 +501,7 @@ describeRealPostgres('control-api real Postgres migrations', () => { const exactControlApiRelations = [ 'administrative_events', 'agent_run_events', + 'authorization_catalog_environment', 'governed_event_read_v1', 'governed_event_stream', 'governed_approval_prompt_history', @@ -524,6 +525,7 @@ describeRealPostgres('control-api real Postgres migrations', () => { const expectedControlApiRelations: Record = { administrative_events: ['INSERT', 'SELECT'], agent_run_events: ['INSERT', 'SELECT'], + authorization_catalog_environment: ['SELECT'], governed_event_read_v1: ['SELECT'], governed_event_stream: ['INSERT', 'SELECT'], governed_approval_prompt_history: ['INSERT', 'SELECT'], diff --git a/deploy/scripts/control-api-runtime-access-profiles.tsv b/deploy/scripts/control-api-runtime-access-profiles.tsv index 8b4ab5cd72..4a14b04bcc 100644 --- a/deploy/scripts/control-api-runtime-access-profiles.tsv +++ b/deploy/scripts/control-api-runtime-access-profiles.tsv @@ -1,6 +1,7 @@ # public relationcontrol_api_runtime access profile administrative_events append agent_run_events append +authorization_catalog_environment read authorization_catalog_writer_components read authorization_resource_revisions upsert authorization_team_revisions upsert diff --git a/scripts/tests/test-control-api-runtime-access-contract.sh b/scripts/tests/test-control-api-runtime-access-contract.sh index 0c7d68925c..8543f99e2e 100755 --- a/scripts/tests/test-control-api-runtime-access-contract.sh +++ b/scripts/tests/test-control-api-runtime-access-contract.sh @@ -9,9 +9,10 @@ relation_count="$(awk -F '\t' '!/^[[:space:]]*(#|$)/ { count++ } END { print cou duplicate_count="$(awk -F '\t' '!/^[[:space:]]*(#|$)/ { seen[$1]++ } END { for (name in seen) if (seen[name] > 1) count++ } END { print count + 0 }' "$PROFILE_FILE")" invalid_count="$(awk -F '\t' '!/^[[:space:]]*(#|$)/ && (NF != 2 || $1 !~ /^[a-z][a-z0-9_]*$/ || $2 !~ /^(legacy_dml|upsert|append|read|link_lifecycle|none)$/) { count++ } END { print count + 0 }' "$PROFILE_FILE")" -# The frozen dev profile has 97 rows; synchronized PR1 contributes its -# accepted access relations and this target adds three entity-change relations. -if [[ "$relation_count" != "110" || "$duplicate_count" != "0" || "$invalid_count" != "0" ]] || \ +# The exact runtime profile includes the composable catalog environment reader +# and the accepted access relations layered over the frozen dev inventory. +if [[ "$relation_count" != "111" || "$duplicate_count" != "0" || "$invalid_count" != "0" ]] || \ + ! grep -qx $'authorization_catalog_environment\tread' "$PROFILE_FILE" || \ ! grep -qx $'dynamic_clients\tlegacy_dml' "$PROFILE_FILE" || \ ! grep -qx $'entity_change_feed\tnone' "$PROFILE_FILE" || \ ! grep -qx $'entity_change_outbox\tnone' "$PROFILE_FILE" || \ From c5a2cb61cc78c5464070bd2f97f28a6788b06d1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:44:37 -0600 Subject: [PATCH 258/528] test: r55-l2 align GCP relation census Class-check: GCP runtime profile census assertion -> one stale count -> align 111 Repro-test: isolated GCP census assertion (fails at 2486a5c07) --- scripts/tests/test-gcp-scripts.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/tests/test-gcp-scripts.sh b/scripts/tests/test-gcp-scripts.sh index 6f9e85ab0b..9d7d63547a 100755 --- a/scripts/tests/test-gcp-scripts.sh +++ b/scripts/tests/test-gcp-scripts.sh @@ -1497,7 +1497,7 @@ assert_control_api_runtime_access_contract_is_exact() { sequence_duplicate_count="$(awk -F '\t' '!/^[[:space:]]*(#|$)/ { seen[$1]++ } END { for (name in seen) if (seen[name] > 1) count++ } END { print count + 0 }' "$sequence_profile_file")" sequence_invalid_count="$(awk -F '\t' '!/^[[:space:]]*(#|$)/ && (NF != 2 || $1 !~ /^[a-z][a-z0-9_]*$/ || $2 !~ /^(legacy_rw|consume|none)$/) { count++ } END { print count + 0 }' "$sequence_profile_file")" - if [[ "$relation_count" == "97" && "$duplicate_count" == "0" && "$invalid_count" == "0" ]] && \ + if [[ "$relation_count" == "111" && "$duplicate_count" == "0" && "$invalid_count" == "0" ]] && \ grep -qx $'dynamic_clients\tlegacy_dml' "$profile_file" && \ grep -qx $'entity_change_feed\tnone' "$profile_file" && \ grep -qx $'entity_change_outbox\tnone' "$profile_file" && \ From 053636fd52ea1bcb1315e28083b04cd53dd08228 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:48:35 -0600 Subject: [PATCH 259/528] fix(control-api): r55-m8 dedupe MCP context relationships Class-check: Context MCP allowlist projection -> one mapper -> exact duplicate values deduped Repro-test: operationalAccessProjection duplicate allowlist (fails at c5a2cb61c) --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../access/operationalAccessProjection.ts | 2 +- ...Producers.realPostgres.integration.test.ts | 126 ++++++++++++++++++ .../test/operationalAccessProjection.test.ts | 29 ++++ 5 files changed, 159 insertions(+), 4 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index fab665aa2d..a74a93fe0f 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.704", + "version": "0.1.705", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.704", + "version": "0.1.705", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 04b010bb39..13c95663f9 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.704", + "version": "0.1.705", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/access/operationalAccessProjection.ts b/control-api/src/services/access/operationalAccessProjection.ts index 1115b0027f..223f2c8239 100644 --- a/control-api/src/services/access/operationalAccessProjection.ts +++ b/control-api/src/services/access/operationalAccessProjection.ts @@ -240,7 +240,7 @@ function contextRelationships(params: { observedGeneration: number | null relationshipNamespaces: ProjectionInput['relationshipNamespaces'] }): OperationalRelationshipRecord[] { - const relationships = stringArray(params.spec.mcpServers).map(server => + const relationships = [...new Set(stringArray(params.spec.mcpServers))].map(server => relationship({ ...params, sourceType: 'context', diff --git a/control-api/test/catalogProducers.realPostgres.integration.test.ts b/control-api/test/catalogProducers.realPostgres.integration.test.ts index d7d467391e..051f254a54 100644 --- a/control-api/test/catalogProducers.realPostgres.integration.test.ts +++ b/control-api/test/catalogProducers.realPostgres.integration.test.ts @@ -13,9 +13,11 @@ import { catalogKey, } from '../src/services/access/catalogContracts.js' import { requireCatalogProducer } from '../src/services/access/catalogProducers.js' +import { OperationalAccessIndex } from '../src/services/access/operationalAccessIndex.js' import { OPERATIONAL_SOURCE_FAMILIES, canonicalEnvironmentId, + projectOperationalObject, } from '../src/services/access/operationalAccessProjection.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL @@ -206,4 +208,128 @@ describeRealPostgres('catalog producer SQL on real PostgreSQL', () => { budget.close() } }) + + it('stages a producer-shaped Context with duplicate allowlist entries exactly once', async () => { + const index = new OperationalAccessIndex(databasePool) + const budget = AccessExecutionBudget.create('catalog') + const contextName = `r55-m8-${randomBytes(6).toString('hex')}` + const sourceId = `${config.contextsNamespace}/${contextName}` + const projection = projectOperationalObject({ + environmentId, + plural: 'contexts', + namespace: config.contextsNamespace, + object: { + metadata: { + name: contextName, + namespace: config.contextsNamespace, + uid: randomUUID(), + resourceVersion: '1', + generation: 1, + }, + spec: { + contextId: contextName, + mcpServers: ['server-a', 'server-a', 'server-b'], + sharedFileSystems: [ + { name: 'filesystem-a', mountPath: '/workspace/a' }, + { name: 'filesystem-a', mountPath: '/workspace/b' }, + ], + }, + }, + behaviorFingerprintKey: 'test-context-fingerprint-key', + relationshipNamespaces: { + context: config.contextsNamespace, + mcpServer: config.mcpServersNamespace, + sharedFilesystem: config.sharedFilesystemsNamespace, + }, + }) + + try { + const stagingGeneration = await index.beginRelist({ + environmentId, + sourceFamily: 'context', + budget, + }) + await index.stageRelistPage({ + environmentId, + sourceFamily: 'context', + stagingGeneration, + projections: [projection], + budget, + }) + + const staged = await databasePool.query<{ + relationship_type: string + target_id: string + behavior_attributes: Record + }>( + `SELECT relationship_type, target_id, behavior_attributes + FROM operational_relationships_staging + WHERE environment_id = $1 + AND source_family = 'context' + AND source_id = $2 + AND source_generation = $3 + ORDER BY relationship_type, target_id, behavior_attributes->>'mountPath'`, + [environmentId, sourceId, stagingGeneration] + ) + expect(staged.rows).toEqual([ + { + relationship_type: 'includes_mcp_server', + target_id: `${config.mcpServersNamespace}/server-a`, + behavior_attributes: {}, + }, + { + relationship_type: 'includes_mcp_server', + target_id: `${config.mcpServersNamespace}/server-b`, + behavior_attributes: {}, + }, + { + relationship_type: 'mounts_shared_filesystem', + target_id: `${config.sharedFilesystemsNamespace}/filesystem-a`, + behavior_attributes: { mountPath: '/workspace/a', readOnly: true }, + }, + { + relationship_type: 'mounts_shared_filesystem', + target_id: `${config.sharedFilesystemsNamespace}/filesystem-a`, + behavior_attributes: { mountPath: '/workspace/b', readOnly: true }, + }, + ]) + await index.promoteRelist({ + environmentId, + sourceFamily: 'context', + stagingGeneration, + resourceVersion: '1', + budget, + }) + + const promoted = await databasePool.query<{ relationship_type: string; target_id: string }>( + `SELECT relationship_type, target_id + FROM operational_resource_relationships + WHERE environment_id = $1 + AND source_family = 'context' + AND source_id = $2 + ORDER BY relationship_type, target_id`, + [environmentId, sourceId] + ) + expect(promoted.rows).toEqual([ + { + relationship_type: 'includes_mcp_server', + target_id: `${config.mcpServersNamespace}/server-a`, + }, + { + relationship_type: 'includes_mcp_server', + target_id: `${config.mcpServersNamespace}/server-b`, + }, + { + relationship_type: 'mounts_shared_filesystem', + target_id: `${config.sharedFilesystemsNamespace}/filesystem-a`, + }, + { + relationship_type: 'mounts_shared_filesystem', + target_id: `${config.sharedFilesystemsNamespace}/filesystem-a`, + }, + ]) + } finally { + budget.close() + } + }) }) diff --git a/control-api/test/operationalAccessProjection.test.ts b/control-api/test/operationalAccessProjection.test.ts index 13b7a9024f..13d35e638b 100644 --- a/control-api/test/operationalAccessProjection.test.ts +++ b/control-api/test/operationalAccessProjection.test.ts @@ -83,6 +83,35 @@ describe('operational access projection', () => { ]) }) + it('deduplicates repeated MCP allowlist entries but preserves distinct targets', () => { + const projection = projectOperationalObject({ + environmentId: 'test:cluster', + plural: 'contexts', + namespace: 'contexts', + object: { + ...contextObject([]), + spec: { + contextId: 'ctx-a', + mcpServers: ['server-a', 'server-a', 'server-b'], + sharedFileSystems: [], + }, + }, + behaviorFingerprintKey: 'test-key', + relationshipNamespaces: namespaces, + }) + const relationships = projection.relationships.filter( + relationship => relationship.relationshipType === 'includes_mcp_server' + ) + + expect(relationships.map(relationship => relationship.targetId).sort()).toEqual([ + 'mcp-server/server-a', + 'mcp-server/server-b', + ]) + expect( + new Set(relationships.map(relationship => relationship.relationshipInstanceId)).size + ).toBe(2) + }) + it('rejects over-budget relationship fan-out before projection', () => { expect(() => projectOperationalObject({ From 6d3d27b85fa974810fb30ecf4f79f0cba5f9904e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:05:23 -0600 Subject: [PATCH 260/528] fix(control-api): r55-h3 exclude idle expiry from authority revision Class-check: v2 session_revision SQL in catalog and resolver -> 2 sites -> keep idle expiry live-only Repro-test: mounted catalog continuation after activity (fails at fcc1a59397412208118f669f08b359dbb0502142) --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../services/access/accessAuthorityStore.ts | 3 +- .../access/catalogAuthorityContext.ts | 2 +- ...eContract.realPostgres.integration.test.ts | 110 ++++++++++++++++++ 5 files changed, 115 insertions(+), 6 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index a74a93fe0f..b87a814974 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.705", + "version": "0.1.706", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.705", + "version": "0.1.706", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 13c95663f9..ff1079edf4 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.705", + "version": "0.1.706", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/access/accessAuthorityStore.ts b/control-api/src/services/access/accessAuthorityStore.ts index d286c97859..6db63f3fae 100644 --- a/control-api/src/services/access/accessAuthorityStore.ts +++ b/control-api/src/services/access/accessAuthorityStore.ts @@ -145,8 +145,7 @@ export async function loadPrincipalAuthoritySnapshot(input: { CASE WHEN $2::text = 'v2' THEN COALESCE(( SELECT s.session_version::text || ':' || s.current_jti::text || ':' || - COALESCE(s.revoked_at::text, '') || ':' || s.idle_expires_at::text || ':' || - s.absolute_expires_at::text + COALESCE(s.revoked_at::text, '') || ':' || s.absolute_expires_at::text FROM external_user_sessions s WHERE s.sid::text = $3 AND s.user_id = u.id ), 'missing') diff --git a/control-api/src/services/access/catalogAuthorityContext.ts b/control-api/src/services/access/catalogAuthorityContext.ts index fac8d7a921..7153194b0d 100644 --- a/control-api/src/services/access/catalogAuthorityContext.ts +++ b/control-api/src/services/access/catalogAuthorityContext.ts @@ -129,7 +129,7 @@ export async function loadCatalogRequestContext(input: { WHEN $2::text = 'v2' THEN COALESCE(( SELECT session.session_version::text || ':' || session.current_jti::text || ':' || COALESCE(session.revoked_at::text, '') || ':' || - session.idle_expires_at::text || ':' || session.absolute_expires_at::text + session.absolute_expires_at::text FROM external_user_sessions session WHERE session.sid::text = $3 AND session.user_id = users.id ), 'missing') diff --git a/control-api/test/userAccessRuntimeContract.realPostgres.integration.test.ts b/control-api/test/userAccessRuntimeContract.realPostgres.integration.test.ts index be45c07b6c..fae67778fb 100644 --- a/control-api/test/userAccessRuntimeContract.realPostgres.integration.test.ts +++ b/control-api/test/userAccessRuntimeContract.realPostgres.integration.test.ts @@ -216,6 +216,116 @@ describeRealPostgres('user-access runtime role contract on real PostgreSQL', () expect(resolved.status).toBe('allowed') }) + it('keeps catalog continuation and resolver authority stable across normal session activity', async () => { + const teamIds = [randomUUID(), randomUUID()] + for (const [index, teamId] of teamIds.entries()) { + await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, $2)`, [ + teamId, + `Activity Revision ${index}`, + ]) + await databasePool.query( + `INSERT INTO team_members(team_id, user_id, role, status) + VALUES ($1, $2, 'member', 'active')`, + [teamId, userId] + ) + } + const issued = await createUserSession( + { userId, email: `${userId}@example.test`, authenticationMethods: ['pwd'] }, + { db: databasePool } + ) + const session: ExternalSessionAuthorityContext = { + contract: 'v2', + userId, + sid: issued.identity.sid, + jti: issued.identity.jti, + sessionVersion: issued.identity.sessionVersion, + } + const firstPage = await buildAccessCatalog( + { session, families: ['team'], limit: 1 }, + { transaction: runAsControlApiRuntime } + ) + expect(firstPage.items).toHaveLength(1) + expect(firstPage.nextCursor).toBeTruthy() + + const item = firstPage.items[0]! + const selectedPath = item.accessPaths.find(path => path.capabilities.includes('team.read')) + expect(selectedPath).toBeDefined() + const firstResolution = await resolveLiveAuthorization( + { + session, + requiredCapability: 'team.read', + resource: canonicalResourceIdentity(item.resource), + requestedAccessPathId: selectedPath!.accessPathId, + }, + { transaction: runAsControlApiRuntime } + ) + expect(firstResolution.status).toBe('allowed') + + const idleBefore = await databasePool.query<{ idle_expires_at: Date }>( + `SELECT idle_expires_at FROM external_user_sessions WHERE sid = $1`, + [session.sid] + ) + await databasePool.query( + `UPDATE external_user_sessions + SET last_used_at = clock_timestamp(), + idle_expires_at = LEAST(absolute_expires_at, clock_timestamp() + interval '14 days') + WHERE sid = $1`, + [session.sid] + ) + const idleAfter = await databasePool.query<{ idle_expires_at: Date }>( + `SELECT idle_expires_at FROM external_user_sessions WHERE sid = $1`, + [session.sid] + ) + expect(idleAfter.rows[0]!.idle_expires_at.getTime()).toBeGreaterThan( + idleBefore.rows[0]!.idle_expires_at.getTime() + ) + + const continued = await buildAccessCatalog( + { session, families: ['team'], limit: 1, cursor: firstPage.nextCursor }, + { transaction: runAsControlApiRuntime } + ) + expect(continued.complete).toBe(true) + expect(continued.items).toHaveLength(1) + const continuedResolution = await resolveLiveAuthorization( + { + session, + requiredCapability: 'team.read', + resource: canonicalResourceIdentity(item.resource), + requestedAccessPathId: selectedPath!.accessPathId, + }, + { transaction: runAsControlApiRuntime } + ) + expect(continuedResolution.status).toBe('allowed') + if (firstResolution.status !== 'allowed' || continuedResolution.status !== 'allowed') { + throw new Error('expected both activity-bound resolutions to be allowed') + } + expect(continuedResolution.authorizationRevision).toBe(firstResolution.authorizationRevision) + + await databasePool.query( + `UPDATE external_user_sessions + SET idle_expires_at = clock_timestamp() - interval '1 second' + WHERE sid = $1`, + [session.sid] + ) + await expect( + buildAccessCatalog( + { session, families: ['team'], limit: 1 }, + { transaction: runAsControlApiRuntime } + ) + ).rejects.toMatchObject({ code: 'session_not_live' }) + await expect( + resolveLiveAuthorization( + { + session, + requiredCapability: 'team.read', + resource: canonicalResourceIdentity(item.resource), + requestedAccessPathId: selectedPath!.accessPathId, + }, + { transaction: runAsControlApiRuntime } + ) + ).resolves.toMatchObject({ status: 'denied', code: 'session_not_live' }) + }) + it('denies the session tables to unrelated roles and PUBLIC', async () => { const publicPrivilege = await databasePool.query<{ allowed: boolean }>( `SELECT EXISTS ( From d693c04bc3dfcb472be5cd312d1ab4601c8c7f53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:08:17 -0600 Subject: [PATCH 261/528] fix(control-api): r5-l6 coalesce successor renewal during overlap Class-check: renewUserSession JTI paths -> 1 service -> coalesce one successor in active overlap Repro-test: userSessionService.test.ts (RED at 053636fd52ea1bcb1315e28083b04cd53dd08228) --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../src/services/auth/userSessionService.ts | 18 +++ ...baseClock.realPostgres.integration.test.ts | 120 ++++++++++++++++++ control-api/test/userSessionService.test.ts | 53 ++++++++ 5 files changed, 194 insertions(+), 3 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index b87a814974..dca27f6bb5 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.706", + "version": "0.1.707", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.706", + "version": "0.1.707", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index ff1079edf4..bc6abe9783 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.706", + "version": "0.1.707", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/auth/userSessionService.ts b/control-api/src/services/auth/userSessionService.ts index ff4ab8abc0..ed33d5a6f4 100644 --- a/control-api/src/services/auth/userSessionService.ts +++ b/control-api/src/services/auth/userSessionService.ts @@ -349,6 +349,24 @@ export async function renewUserSession( } } + const successorRenewalDuringOverlap = + claims.jti === row.current_jti && priorExpiry !== null && now <= priorExpiry + if (successorRenewalDuringOverlap) { + await db.query( + `UPDATE external_user_sessions + SET last_used_at = $2, + idle_expires_at = LEAST(absolute_expires_at, $3) + WHERE sid = $1 + AND revoked_at IS NULL`, + [row.sid, now, plusSeconds(now, USER_SESSION_IDLE_LIFETIME_SECONDS)] + ) + return { + token: tokenFromRow(row), + expiresInSeconds: USER_SESSION_V2_TTL_SECONDS, + identity: identityFromRow(row), + } + } + const nextJti = randomUUID() const idleExpiresAt = new Date( Math.min( diff --git a/control-api/test/userSessionDatabaseClock.realPostgres.integration.test.ts b/control-api/test/userSessionDatabaseClock.realPostgres.integration.test.ts index a477077d4b..48dab0af5d 100644 --- a/control-api/test/userSessionDatabaseClock.realPostgres.integration.test.ts +++ b/control-api/test/userSessionDatabaseClock.realPostgres.integration.test.ts @@ -4,6 +4,7 @@ import { Pool, type PoolClient } from 'pg' import { initDb } from '../src/db.js' import { createUserSession, + renewUserSession, validateUserSessionClaims, } from '../src/services/auth/userSessionService.js' import { verifyUserSessionV2Token } from '../src/utils/auth/userSessionV2Token.js' @@ -168,4 +169,123 @@ describeRealPostgres('user-session database clock authority on real PostgreSQL', validator.release() } }) + + it('coalesces chained renewal without displacing or extending the predecessor overlap', async () => { + const inTransaction = async (work: (client: PoolClient) => Promise): Promise => { + const client = await databasePool.connect() + try { + await client.query('BEGIN') + const result = await work(client) + await client.query('COMMIT') + return result + } catch (error) { + await client.query('ROLLBACK').catch(() => undefined) + throw error + } finally { + client.release() + } + } + const issued = await inTransaction(client => + createUserSession( + { userId, email: `${userId}@example.test`, authenticationMethods: ['password'] }, + { db: client } + ) + ) + const original = verifyUserSessionV2Token(issued.token) + if (!original) throw new Error('session producer did not issue a valid V2 representation') + + const firstRenewal = await inTransaction(client => renewUserSession(original, { db: client })) + if (!('token' in firstRenewal)) throw new Error('expected first renewal to issue a successor') + const successor = verifyUserSessionV2Token(firstRenewal.token) + if (!successor) throw new Error('first renewal did not issue a valid successor') + const initialState = await databasePool.query<{ + current_jti: string + prior_jti: string + prior_jti_expires_at: Date + }>( + `SELECT current_jti::text, prior_jti::text, prior_jti_expires_at + FROM external_user_sessions + WHERE sid = $1`, + [original.sid] + ) + const originalOverlapExpiry = initialState.rows[0]!.prior_jti_expires_at.getTime() + expect(initialState.rows[0]).toMatchObject({ + current_jti: successor.jti, + prior_jti: original.jti, + }) + + const coalesced = await inTransaction(client => renewUserSession(successor, { db: client })) + if (!('token' in coalesced)) throw new Error('expected immediate successor renewal to coalesce') + expect(coalesced.token).toBe(firstRenewal.token) + const afterCoalescing = await databasePool.query<{ + current_jti: string + prior_jti: string + prior_jti_expires_at: Date + idle_expires_at: Date + }>( + `SELECT current_jti::text, prior_jti::text, prior_jti_expires_at, idle_expires_at + FROM external_user_sessions + WHERE sid = $1`, + [original.sid] + ) + expect(afterCoalescing.rows[0]).toMatchObject({ + current_jti: successor.jti, + prior_jti: original.jti, + }) + expect(afterCoalescing.rows[0]!.prior_jti_expires_at.getTime()).toBe(originalOverlapExpiry) + + const delayedOriginal = await inTransaction(client => + renewUserSession(original, { db: client }) + ) + expect(delayedOriginal).toMatchObject({ token: firstRenewal.token }) + + await databasePool.query( + `UPDATE external_user_sessions + SET prior_jti_expires_at = clock_timestamp() - interval '1 second' + WHERE sid = $1`, + [original.sid] + ) + const outsideOverlap = await inTransaction(client => renewUserSession(original, { db: client })) + expect(outsideOverlap).toMatchObject({ status: 'revoked', reason: 'representation_reuse' }) + const revoked = await databasePool.query<{ revoked: boolean }>( + `SELECT revoked_at IS NOT NULL AS revoked + FROM external_user_sessions + WHERE sid = $1`, + [original.sid] + ) + expect(revoked.rows).toEqual([{ revoked: true }]) + + const concurrentIssued = await inTransaction(client => + createUserSession( + { userId, email: `${userId}@example.test`, authenticationMethods: ['password'] }, + { db: client } + ) + ) + const concurrentOriginal = verifyUserSessionV2Token(concurrentIssued.token) + if (!concurrentOriginal) + throw new Error('concurrent session producer returned an invalid token') + const concurrentRenewals = await Promise.all([ + inTransaction(client => renewUserSession(concurrentOriginal, { db: client })), + inTransaction(client => renewUserSession(concurrentOriginal, { db: client })), + ]) + const [firstConcurrent, secondConcurrent] = concurrentRenewals + if (!('token' in firstConcurrent) || !('token' in secondConcurrent)) { + throw new Error('expected concurrent renewals to return the same successor') + } + expect(secondConcurrent.token).toBe(firstConcurrent.token) + const concurrentSuccessor = verifyUserSessionV2Token(firstConcurrent.token) + if (!concurrentSuccessor) throw new Error('concurrent renewal returned an invalid token') + const concurrentState = await databasePool.query<{ + current_jti: string + prior_jti: string + }>( + `SELECT current_jti::text, prior_jti::text + FROM external_user_sessions + WHERE sid = $1`, + [concurrentOriginal.sid] + ) + expect(concurrentState.rows).toEqual([ + { current_jti: concurrentSuccessor.jti, prior_jti: concurrentOriginal.jti }, + ]) + }) }) diff --git a/control-api/test/userSessionService.test.ts b/control-api/test/userSessionService.test.ts index 9a01a83afc..335bd6f656 100644 --- a/control-api/test/userSessionService.test.ts +++ b/control-api/test/userSessionService.test.ts @@ -5,6 +5,7 @@ import { USER_SESSION_ABSOLUTE_LIFETIME_SECONDS, USER_SESSION_IDLE_LIFETIME_SECONDS, USER_SESSION_MAX_ACTIVE_PER_USER, + USER_SESSION_RENEWAL_OVERLAP_SECONDS, createUserSession, renewUserSession, revokeAllUserSessions, @@ -134,6 +135,58 @@ describe('user session state machine', () => { ).toHaveLength(1) }) + it('coalesces an immediate successor renewal without evicting its live predecessor', async () => { + const original = row() + let current = original + let rotations = 0 + const query = vi.fn(async (sql: string, values?: unknown[]) => { + if (sql.includes('SELECT s.sid')) return { rows: [current], rowCount: 1 } + if (sql.includes('clock_timestamp()')) return { rows: [{ db_now: now }], rowCount: 1 } + if (sql.includes('SET prior_jti = current_jti')) { + rotations += 1 + current = row({ + ...current, + prior_jti: current.current_jti, + prior_jti_expires_at: values?.[1], + current_jti: values?.[2], + current_issued_at: values?.[3], + last_used_at: values?.[3], + idle_expires_at: values?.[4], + }) + return { rows: [current], rowCount: 1 } + } + if (sql.includes('SET last_used_at = $2')) { + current = row({ + ...current, + last_used_at: values?.[1], + idle_expires_at: values?.[2], + }) + return { rows: [], rowCount: 1 } + } + return { rows: [], rowCount: 0 } + }) + + const first = await renewUserSession(claimsFor(original), { db: { query } }) + expect('token' in first).toBe(true) + if (!('token' in first)) throw new Error('expected the first renewal to issue a successor') + const successor = verifyUserSessionV2Token(first.token) + expect(successor).not.toBeNull() + + const second = await renewUserSession(claimsFor(current, String(current.current_jti)), { + db: { query }, + }) + expect('token' in second).toBe(true) + if (!('token' in second)) + throw new Error('expected the coalesced renewal to return its successor') + expect(second.token).toBe(first.token) + expect(rotations).toBe(1) + expect(current.current_jti).toBe(successor!.jti) + expect(current.prior_jti).toBe(original.current_jti) + expect(current.prior_jti_expires_at).toEqual( + new Date(now.getTime() + USER_SESSION_RENEWAL_OVERLAP_SECONDS * 1000) + ) + }) + it('revokes reuse outside overlap and never extends expired state', async () => { const reused = row({ prior_jti: randomUUID(), From 3b81511c9ffb63b34fbf3bd980b0c17bfddd50ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:02:02 -0600 Subject: [PATCH 262/528] fix(control-api): r55-m1 preserve semantic index equivalence Class-check: canonicalOnlineIndexDefinition -> 1 comparator in ensureOnlineIndex; all 25 planned indexes use it; no sibling comparator exists. Repro-test: control-api/test/migrationExecutionPolicy.test.ts (6 preservation assertions fail at d693c04bc3dfcb472be5cd312d1ab4601c8c7f53). --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../src/migrations/pr1OnlineIndexPlan.ts | 368 ++++++++++++++++-- ...ionPolicy.realPostgres.integration.test.ts | 91 +++++ .../test/migrationExecutionPolicy.test.ts | 60 +++ 5 files changed, 499 insertions(+), 26 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index dca27f6bb5..06e4c8ae21 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.707", + "version": "0.1.708", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.707", + "version": "0.1.708", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index bc6abe9783..97c4ade6a6 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.707", + "version": "0.1.708", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/migrations/pr1OnlineIndexPlan.ts b/control-api/src/migrations/pr1OnlineIndexPlan.ts index 8eb442da77..a8e764c8bf 100644 --- a/control-api/src/migrations/pr1OnlineIndexPlan.ts +++ b/control-api/src/migrations/pr1OnlineIndexPlan.ts @@ -207,30 +207,352 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr }, ]) +type IndexSqlToken = Readonly<{ + kind: 'identifier' | 'quoted_identifier' | 'string' | 'number' | 'operator' | 'punctuation' + value: string +}> + +function tokenizeIndexDefinition(value: string): IndexSqlToken[] { + const tokens: IndexSqlToken[] = [] + const operators = ['->>', '#>>', '::', '||', '->', '#>', '<=', '>=', '<>', '!=', '@>', '<@'] + let offset = 0 + + while (offset < value.length) { + const char = value[offset]! + if (/\s/.test(char)) { + offset += 1 + continue + } + if (value.startsWith('--', offset)) { + const newline = value.indexOf('\n', offset + 2) + offset = newline < 0 ? value.length : newline + 1 + continue + } + if (value.startsWith('/*', offset)) { + let depth = 1 + offset += 2 + while (offset < value.length && depth > 0) { + if (value.startsWith('/*', offset)) { + depth += 1 + offset += 2 + } else if (value.startsWith('*/', offset)) { + depth -= 1 + offset += 2 + } else { + offset += 1 + } + } + if (depth !== 0) throw new Error('Unterminated comment in online index definition') + continue + } + if (char === '"' || char === "'") { + const quote = char + const start = offset + offset += 1 + let closed = false + while (offset < value.length) { + if (value[offset] === quote) { + if (value[offset + 1] === quote) { + offset += 2 + continue + } + offset += 1 + closed = true + break + } + offset += 1 + } + if (!closed) throw new Error('Unterminated quoted token in online index definition') + tokens.push({ + kind: quote === '"' ? 'quoted_identifier' : 'string', + value: value.slice(start, offset), + }) + continue + } + if (/[A-Za-z_\u0080-\uffff]/.test(char)) { + const start = offset + offset += 1 + while (offset < value.length && /[A-Za-z0-9_$\u0080-\uffff]/.test(value[offset]!)) { + offset += 1 + } + tokens.push({ kind: 'identifier', value: value.slice(start, offset).toLowerCase() }) + continue + } + if (/[0-9]/.test(char)) { + const start = offset + offset += 1 + while (offset < value.length && /[A-Za-z0-9_.]/.test(value[offset]!)) offset += 1 + tokens.push({ kind: 'number', value: value.slice(start, offset) }) + continue + } + const operator = operators.find(candidate => value.startsWith(candidate, offset)) + if (operator) { + tokens.push({ kind: 'operator', value: operator }) + offset += operator.length + continue + } + tokens.push({ + kind: '()[],.;'.includes(char) ? 'punctuation' : 'operator', + value: char, + }) + offset += 1 + } + + return tokens +} + +function matchingParen(tokens: readonly IndexSqlToken[], start: number): number { + if (tokens[start]?.value !== '(') return -1 + let depth = 0 + for (let index = start; index < tokens.length; index += 1) { + if (tokens[index]!.value === '(') depth += 1 + else if (tokens[index]!.value === ')') { + depth -= 1 + if (depth === 0) return index + } + } + return -1 +} + +function removeWholeExpressionWrappers(tokens: readonly IndexSqlToken[]): IndexSqlToken[] { + let result = [...tokens] + while (result.length >= 2 && matchingParen(result, 0) === result.length - 1) { + result = result.slice(1, -1) + } + return result +} + +function splitTopLevel(tokens: readonly IndexSqlToken[]): IndexSqlToken[][] { + const values: IndexSqlToken[][] = [[]] + let depth = 0 + for (const token of tokens) { + if (token.value === '(') depth += 1 + else if (token.value === ')') depth -= 1 + if (token.value === ',' && depth === 0) values.push([]) + else values[values.length - 1]!.push(token) + } + return values +} + +function isSingleAtom(tokens: readonly IndexSqlToken[]): boolean { + if (tokens.length === 1) return true + return ( + tokens.length === 3 && + (tokens[0]!.kind === 'identifier' || + tokens[0]!.kind === 'quoted_identifier' || + tokens[0]!.kind === 'string' || + tokens[0]!.kind === 'number') && + tokens[1]!.value === '::' && + tokens[2]!.kind === 'identifier' + ) +} + +function normalizeKnownTextCoercions( + tokens: IndexSqlToken[], + inCatalogUtf8Bytes: boolean +): IndexSqlToken[] { + const result = [...tokens] + for (let index = 0; index + 2 < result.length; index += 1) { + const value = result[index]! + if (result[index + 1]!.value !== '::' || result[index + 2]!.value !== 'text') continue + + const before = result[index - 1]?.value + const after = result[index + 3]?.value + const knownCatalogColumn = + inCatalogUtf8Bytes && + value.kind === 'identifier' && + (value.value === 'recipe_namespace' || value.value === 'recipe_name') + // These are the exact unknown-literal-to-text coercions emitted by + // pg_get_indexdef for the fixed PR1 plan: JSON text keys, the slash + // separator, and workflow_runs.actor_type's text predicate. + const knownStringOperand = + value.kind === 'string' && + (((value.value === "'userId'" || value.value === "'teamId'") && + (before === '->>' || before === '?')) || + (value.value === "'/'" && (before === '||' || after === '||')) || + (value.value === "'user'" && before === '=' && result[index - 2]?.value === 'actor_type')) + + if (knownCatalogColumn || knownStringOperand) result.splice(index + 1, 2) + } + return result +} + +function normalizeExpression( + tokens: readonly IndexSqlToken[], + inCatalogUtf8Bytes = false +): IndexSqlToken[] { + let result = removeWholeExpressionWrappers(tokens) + + // Recurse into function arguments so catalog_utf8_bytes' deparser-added + // grouping can be compared without flattening a grouped arithmetic operand. + for (let index = 0; index + 1 < result.length; index += 1) { + if (result[index]!.kind !== 'identifier' || result[index + 1]!.value !== '(') continue + const end = matchingParen(result, index + 1) + if (end <= index + 1) continue + const isCatalogFunction = result[index]!.value === 'catalog_utf8_bytes' + const args = splitTopLevel(result.slice(index + 2, end)).map(argument => + normalizeExpression(argument, inCatalogUtf8Bytes || isCatalogFunction) + ) + const normalizedArgs = args.flatMap((arg, argumentIndex) => + argumentIndex === 0 ? arg : [{ kind: 'punctuation' as const, value: ',' }, ...arg] + ) + result.splice(index + 2, end - index - 2, ...normalizedArgs) + } + + // PostgreSQL may add parentheses around a single operand while deparsing an + // index expression. Remove only wrappers whose contents are an atomic token + // (or that atom's explicit cast); grouped binary expressions remain intact. + for (let index = result.length - 1; index >= 0; index -= 1) { + if (result[index]!.value !== '(') continue + const end = matchingParen(result, index) + if (end <= index) continue + const inner = result.slice(index + 1, end) + if (isSingleAtom(inner)) { + result.splice(index, end - index + 1, ...inner) + } + } + + result = normalizeKnownTextCoercions(result, inCatalogUtf8Bytes) + + // Normalize the deparser's left grouping for the repository's text + // concatenation catalog keys. Other grouped operators are deliberately kept. + let changed = true + while (changed) { + changed = false + let depth = 0 + for (let index = 0; index < result.length; index += 1) { + const token = result[index]! + if (token.value === '(') depth += 1 + else if (token.value === ')') depth -= 1 + else if (depth === 0 && token.value === '||') { + const leftStart = (() => { + let nested = 0 + for (let cursor = index - 1; cursor >= 0; cursor -= 1) { + if (result[cursor]!.value === ')') nested += 1 + else if (result[cursor]!.value === '(') { + if (nested === 0) return cursor + nested -= 1 + } else if (nested === 0 && result[cursor]!.value === '||') { + return cursor + 1 + } + } + return 0 + })() + if (result[leftStart]?.value !== '(') continue + const leftEnd = matchingParen(result, leftStart) + if (leftEnd !== index - 1) continue + const left = result.slice(leftStart + 1, leftEnd) + let innerDepth = 0 + const hasNestedConcat = left.some((candidate, candidateIndex) => { + if (candidate.value === '(') innerDepth += 1 + else if (candidate.value === ')') innerDepth -= 1 + return innerDepth === 0 && candidate.value === '||' && candidateIndex > 0 + }) + if (!hasNestedConcat) continue + result.splice(leftStart, leftEnd - leftStart + 1, ...left) + changed = true + break + } + } + } + + return result +} + +function normalizePredicate(tokens: readonly IndexSqlToken[]): IndexSqlToken[] { + const result = removeWholeExpressionWrappers(tokens) + for (let index = result.length - 1; index >= 0; index -= 1) { + if (result[index]!.value !== '(') continue + const end = matchingParen(result, index) + if (end <= index) continue + const inner = result.slice(index + 1, end) + let depth = 0 + const hasTopLevelBoolean = inner.some(token => { + if (token.value === '(') depth += 1 + else if (token.value === ')') depth -= 1 + return ( + depth === 0 && + token.kind === 'identifier' && + (token.value === 'and' || token.value === 'or') + ) + }) + const before = result[index - 1]?.value + const after = result[end + 1]?.value + const isPredicateTermBoundary = + (before === undefined || + before === 'where' || + before === 'and' || + before === 'or' || + before === '(') && + (after === undefined || after === 'and' || after === 'or' || after === ')') + if (isPredicateTermBoundary && !hasTopLevelBoolean) { + result.splice(index, end - index + 1, ...inner) + } + } + return normalizeExpression(result) +} + +function canonicalizeIndexDdl(tokens: IndexSqlToken[]): IndexSqlToken[] { + const result = [...tokens] + if (result.at(-1)?.value === ';') result.pop() + + const indexToken = result.findIndex(token => token.value === 'index') + if (indexToken >= 0) { + let modifierOffset = indexToken + 1 + if (result[modifierOffset]?.value === 'concurrently') { + result.splice(modifierOffset, 1) + } + if ( + result[modifierOffset]?.value === 'if' && + result[modifierOffset + 1]?.value === 'not' && + result[modifierOffset + 2]?.value === 'exists' + ) { + result.splice(modifierOffset, 3) + } + } + + const onToken = result.findIndex(token => token.value === 'on') + if (onToken >= 0) { + if (result[onToken + 1]?.value === 'public' && result[onToken + 2]?.value === '.') { + result.splice(onToken + 1, 2) + } + const usingToken = result.findIndex( + (token, index) => + index > onToken && token.value === 'using' && result[index + 1]?.value === 'btree' + ) + if (usingToken >= 0) result.splice(usingToken, 2) + + const keyListStart = result.findIndex((token, index) => index > onToken && token.value === '(') + const keyListEnd = keyListStart >= 0 ? matchingParen(result, keyListStart) : -1 + if (keyListStart >= 0 && keyListEnd > keyListStart) { + const parts = splitTopLevel(result.slice(keyListStart + 1, keyListEnd)).map(part => + normalizeExpression(removeWholeExpressionWrappers(part)) + ) + result.splice( + keyListStart + 1, + keyListEnd - keyListStart - 1, + ...parts.flatMap((part, index) => + index === 0 ? part : [{ kind: 'punctuation' as const, value: ',' }, ...part] + ) + ) + const adjustedKeyListEnd = + keyListStart + 1 + parts.reduce((sum, part) => sum + part.length, 0) + parts.length - 1 + const whereToken = result.findIndex( + (token, index) => index > adjustedKeyListEnd && token.value === 'where' + ) + if (whereToken >= 0) { + const predicate = normalizePredicate(result.slice(whereToken + 1)) + result.splice(whereToken + 1, result.length - whereToken - 1, ...predicate) + } + } + } + return result +} + export const canonicalOnlineIndexDefinition = (value: string): string => - value - .toLowerCase() - .replaceAll('"', '') - .replace(/\bconcurrently\b/g, '') - .replace(/\bif\s+not\s+exists\b/g, '') - .replace(/\bpublic\./g, '') - .replace(/\busing\s+btree\b/g, '') - .replace(/::text\b/g, '') - .replace(/;\s*$/, '') - // PostgreSQL's deparser adds redundant grouping around expressions and - // predicates. Remove it before operator spacing so casts around operands - // cannot reintroduce a formatting-only distinction beside an operator. - .replace(/[()]/g, ' ') - // pg_get_indexdef inserts spaces around expression operators even when - // the immutable migration body does not. Normalize only the operators - // used by the accepted index plan; keyword and identifier boundaries - // remain significant. - .replace(/\s*(->>|\?|\|\||=)\s*/g, '$1') - // The complete token order, commas, operators, table, and uniqueness - // remain, so different keys/includes/predicates still differ. - .replace(/\s*,\s*/g, ',') - .replace(/\s+/g, ' ') - .trim() + JSON.stringify( + canonicalizeIndexDdl(tokenizeIndexDefinition(value)).map(token => [token.kind, token.value]) + ) type IndexState = { table_name: string diff --git a/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts index 3b242248e5..917d7eb72e 100644 --- a/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts +++ b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts @@ -124,6 +124,97 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { expect(await versions(databasePool)).toContain(entry.migrationVersion) }) + it('reuses a deparsed partial index without rebuilding its physical index', async () => { + const entry = PR1_ONLINE_INDEX_PLAN.find( + index => index.name === 'workflow_runs_actor_catalog_idx' + )! + const before = await databasePool.query<{ oid: string }>( + 'SELECT $1::regclass::oid::text AS oid', + [entry.name] + ) + await databasePool.query('DELETE FROM schema_migrations WHERE version = $1', [ + entry.migrationVersion, + ]) + + await initDb({ connect: () => databasePool.connect() }) + + const after = await databasePool.query<{ oid: string }>( + 'SELECT $1::regclass::oid::text AS oid', + [entry.name] + ) + expect(after.rows[0]?.oid).toBe(before.rows[0]?.oid) + expect(await versions(databasePool)).toContain(entry.migrationVersion) + }) + + it('rejects same-name indexes with changed identity, grouping, predicate, or order', async () => { + const suffix = randomBytes(4).toString('hex') + const table = `d34_index_semantics_${suffix}` + await databasePool.query(` + CREATE TABLE ${table} ( + userid text, + "userId" text, + id integer, + a integer, + b integer, + c integer, + included_a text, + included_b text + ) + `) + const cases = [ + { + name: `d34_quoted_identity_${suffix}`, + expected: `(${quoteIdentifier('userId')})`, + actual: '(userid)', + }, + { + name: `d34_grouping_${suffix}`, + expected: '(((a + b) * c))', + actual: '((a + b * c))', + }, + { + name: `d34_predicate_${suffix}`, + expected: '(userid) WHERE userid IS NOT NULL', + actual: `(userid) WHERE userid <> ''`, + }, + { + name: `d34_key_order_${suffix}`, + expected: '(userid, id)', + actual: '(id, userid)', + }, + { + name: `d34_include_order_${suffix}`, + expected: '(userid) INCLUDE (included_a, included_b)', + actual: '(userid) INCLUDE (included_b, included_a)', + }, + ] + + for (const candidate of cases) { + await databasePool.query(`CREATE INDEX ${candidate.name} ON ${table} ${candidate.actual}`) + const entry: OnlineIndexDefinition = { + migrationVersion: '0125_user_access_foundation', + name: candidate.name, + table, + createSql: `CREATE INDEX CONCURRENTLY ${candidate.name} ON ${table} ${candidate.expected}`, + } + await expect(ensureOnlineIndex(databasePool, entry)).rejects.toThrow( + `Non-equivalent existing index: ${candidate.name}` + ) + } + + const uniqueName = `d34_unique_mismatch_${suffix}` + await databasePool.query(`CREATE INDEX ${uniqueName} ON ${table} (userid)`) + await expect( + ensureOnlineIndex(databasePool, { + migrationVersion: '0125_user_access_foundation', + name: uniqueName, + table, + unique: true, + createSql: `CREATE UNIQUE INDEX CONCURRENTLY ${uniqueName} ON ${table} (userid)`, + }) + ).rejects.toThrow(`Non-equivalent existing index: ${uniqueName}`) + }) + it('repairs an equivalent interrupted index and enforces the online bound', async () => { const name = `d34_interrupted_${randomBytes(4).toString('hex')}` const entry: OnlineIndexDefinition = { diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index ffe0c9ce77..6bb31f7547 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -364,6 +364,66 @@ describe('D34 PR1 migration runner', () => { }) describe('D34 online-index recovery', () => { + it.each([ + [ + 'quoted identifier case', + 'CREATE INDEX idx ON team_members ("userId")', + 'CREATE INDEX idx ON team_members (userid)', + ], + [ + 'significant expression grouping', + 'CREATE INDEX idx ON sample ((a + b) * c)', + 'CREATE INDEX idx ON sample (a + b * c)', + ], + [ + 'column casts', + 'CREATE INDEX idx ON sample (value::text)', + 'CREATE INDEX idx ON sample (value)', + ], + [ + 'quoted literal contents', + "CREATE INDEX idx ON sample ((payload ->> 'userId'))", + "CREATE INDEX idx ON sample ((payload ->> 'userid'))", + ], + ])('preserves %s in canonical definitions', (_case, left, right) => { + expect(canonicalOnlineIndexDefinition(left)).not.toBe(canonicalOnlineIndexDefinition(right)) + }) + + it('normalizes only harmless PostgreSQL index DDL decoration', () => { + expect( + canonicalOnlineIndexDefinition( + 'CREATE INDEX CONCURRENTLY IF NOT EXISTS idx ON sample (((a + b)))' + ) + ).toBe(canonicalOnlineIndexDefinition('CREATE INDEX idx ON public.sample USING btree (a+b)')) + }) + + it('retains significant predicate grouping across boolean operators', () => { + const grouped = `CREATE INDEX idx ON sample (value) + WHERE ((first_value = 1 OR second_value = 2) AND third_value = 3)` + const regrouped = `CREATE INDEX idx ON sample (value) + WHERE first_value = 1 OR (second_value = 2 AND third_value = 3)` + + expect(canonicalOnlineIndexDefinition(grouped)).not.toBe( + canonicalOnlineIndexDefinition(regrouped) + ) + + const precedence = `CREATE INDEX idx ON sample (value) + WHERE first_value = 1 OR second_value = 2 AND third_value = 3` + const explicitGrouping = `CREATE INDEX idx ON sample (value) + WHERE (first_value = 1 OR second_value = 2) AND third_value = 3` + expect(canonicalOnlineIndexDefinition(precedence)).not.toBe( + canonicalOnlineIndexDefinition(explicitGrouping) + ) + }) + + it('retains text casts except at the fixed deparser coercion seams', () => { + const castedColumn = 'CREATE INDEX idx ON sample (recipe_namespace::text)' + const uncastColumn = 'CREATE INDEX idx ON sample (recipe_namespace)' + expect(canonicalOnlineIndexDefinition(castedColumn)).not.toBe( + canonicalOnlineIndexDefinition(uncastColumn) + ) + }) + it('repairs only an equivalent invalid index and rejects a different definition', async () => { const entry = PR1_ONLINE_INDEX_PLAN[0]! const states = new Map( From f61d5dd921aeb0cc681bea7c6474b1391747cf2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:18:25 -0600 Subject: [PATCH 263/528] fix(control-api): repair R55-M2 revision-trigger migration prefix Class-check: 0125 FK-backed user/team revision functions -> 2 -> 0138 fixes both Repro-test: exact-head RealPG job 110675253793 (fails at c6bffa1c6198cae1bf0055827130a24753fddec9) --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- control-api/src/db.ts | 5 + ...ionRevisionDeleteCompatibilityMigration.ts | 43 +++ control-api/src/migrations/migrationRunner.ts | 48 +++- control-api/test/db.migrationsOrder.test.ts | 46 +++ .../test/migrationExecutionPolicy.test.ts | 262 +++++++++++++++++- ...atibility.realPostgres.integration.test.ts | 176 ++++++++++++ 8 files changed, 577 insertions(+), 9 deletions(-) create mode 100644 control-api/src/migrations/authorizationRevisionDeleteCompatibilityMigration.ts diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 06e4c8ae21..84b081e2d7 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.708", + "version": "0.1.709", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.708", + "version": "0.1.709", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 97c4ade6a6..e3e844ab1f 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.708", + "version": "0.1.709", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/db.ts b/control-api/src/db.ts index eea1d18e5a..85d56b5d69 100644 --- a/control-api/src/db.ts +++ b/control-api/src/db.ts @@ -5,6 +5,7 @@ import { createBoundedPgPoolForConnection, } from './boundedPgPool.js' import { config } from './config.js' +import { applyAuthorizationRevisionDeleteCompatibility } from './migrations/authorizationRevisionDeleteCompatibilityMigration.js' import { migrationSessionBoundsSql } from './migrations/migrationExecutionPolicy.js' import { applyPendingPr1Migrations } from './migrations/migrationRunner.js' import { applyUserAccessFoundationDefinerTempShadowHardening } from './migrations/userAccessFoundationDefinerTempShadowMigration.js' @@ -6401,6 +6402,10 @@ export const CONTROL_API_MIGRATIONS: DbMigration[] = [ ], apply: backfillLegacyPasswordSecurityEpochs, }, + { + version: '0138_authorization_revision_delete_compatibility', + apply: applyAuthorizationRevisionDeleteCompatibility, + }, ] async function consolidateWorkflowAllowedUsersToTriggers(db: DbClient): Promise { diff --git a/control-api/src/migrations/authorizationRevisionDeleteCompatibilityMigration.ts b/control-api/src/migrations/authorizationRevisionDeleteCompatibilityMigration.ts new file mode 100644 index 0000000000..d5f8d684c6 --- /dev/null +++ b/control-api/src/migrations/authorizationRevisionDeleteCompatibilityMigration.ts @@ -0,0 +1,43 @@ +import type { DbClient } from '../db.js' + +/** + * Forward repair for the 0125 identity-revision trigger behavior. + * + * The immutable 0125 body unconditionally upserts a revision. During a user or + * team delete cascade that can reference an identity already removed by the + * parent statement. Select only still-existing parents, matching the later + * composable-revision behavior without changing either function's ACL. + */ +export async function applyAuthorizationRevisionDeleteCompatibility(db: DbClient): Promise { + await db.query(` + CREATE OR REPLACE FUNCTION public.authorization_bump_user_revision(target_user_id UUID) + RETURNS VOID + LANGUAGE SQL + SECURITY DEFINER + SET search_path = pg_catalog, public, pg_temp + AS $$ + INSERT INTO authorization_user_revisions(user_id, revision, updated_at) + SELECT users.id, 1, clock_timestamp() + FROM users + WHERE users.id = target_user_id + ON CONFLICT (user_id) DO UPDATE + SET revision = authorization_user_revisions.revision + 1, + updated_at = clock_timestamp(); + $$; + + CREATE OR REPLACE FUNCTION public.authorization_bump_team_revision(target_team_id UUID) + RETURNS VOID + LANGUAGE SQL + SECURITY DEFINER + SET search_path = pg_catalog, public, pg_temp + AS $$ + INSERT INTO authorization_team_revisions(team_id, revision, updated_at) + SELECT teams.id, 1, clock_timestamp() + FROM teams + WHERE teams.id = target_team_id + ON CONFLICT (team_id) DO UPDATE + SET revision = authorization_team_revisions.revision + 1, + updated_at = clock_timestamp(); + $$; + `) +} diff --git a/control-api/src/migrations/migrationRunner.ts b/control-api/src/migrations/migrationRunner.ts index 7b14d0de2d..24c9569f05 100644 --- a/control-api/src/migrations/migrationRunner.ts +++ b/control-api/src/migrations/migrationRunner.ts @@ -10,6 +10,8 @@ export const PR1_MIGRATION_VERSIONS = Object.freeze([ '0129_gfs_catalog_revision_components', '012a_user_access_foundation_definer_temp_shadow_hardening', '0130_legacy_password_security_epoch_backfill', + // Executed immediately after 0125 by the runner, before the remaining PR1 migrations. + '0138_authorization_revision_delete_compatibility', ] as const) export const DEV_POST_0106_MIGRATION_VERSIONS = Object.freeze([ @@ -46,6 +48,10 @@ export type MigrationDescriptor = { apply: (db: DbClient) => Promise } +const AUTHORIZATION_REVISION_COMPATIBILITY_PREREQUISITE = '0125_user_access_foundation' +const AUTHORIZATION_REVISION_COMPATIBILITY_VERSION = + '0138_authorization_revision_delete_compatibility' + type ApplyPendingPr1MigrationsInput = { db: DbClient migrations: readonly MigrationDescriptor[] @@ -98,12 +104,43 @@ export async function applyPendingPr1Migrations({ for (const version of CLASSIFIED_POST_0106_MIGRATION_VERSIONS) { const migration = byVersion.get(version) if (!migration) throw new Error(`Missing registered post-0106 migration: ${version}`) - if (appliedVersions.has(version)) continue - - const isPr1Migration = expected.has(version) + const compatibilityMigration = + version === AUTHORIZATION_REVISION_COMPATIBILITY_PREREQUISITE + ? byVersion.get(AUTHORIZATION_REVISION_COMPATIBILITY_VERSION) + : undefined + if (version === AUTHORIZATION_REVISION_COMPATIBILITY_PREREQUISITE && !compatibilityMigration) { + throw new Error( + `Missing registered authorization revision compatibility migration: ${AUTHORIZATION_REVISION_COMPATIBILITY_VERSION}` + ) + } const acceptedLegacyVersion = migration.legacyVersions?.find(alias => appliedVersions.has(alias) ) + const currentMigrationApplied = appliedVersions.has(version) || Boolean(acceptedLegacyVersion) + if (currentMigrationApplied) { + const currentReceiptPending = !appliedVersions.has(version) + const compatibilityReceiptPending = + compatibilityMigration && !appliedVersions.has(compatibilityMigration.version) + if (currentReceiptPending || compatibilityReceiptPending) { + await runBoundedTransaction(db, async () => { + if (currentReceiptPending) await recordMigration(db, version) + if (compatibilityReceiptPending) { + await compatibilityMigration.apply(db) + await recordMigration(db, compatibilityMigration.version) + } + }) + if (currentReceiptPending) appliedVersions.add(version) + if (compatibilityReceiptPending) appliedVersions.add(compatibilityMigration.version) + } + continue + } + + const isPr1Migration = expected.has(version) + if (compatibilityMigration && appliedVersions.has(compatibilityMigration.version)) { + throw new Error( + `Team revision compatibility is recorded before its prerequisite: ${compatibilityMigration.version}` + ) + } if (isPr1Migration && !acceptedLegacyVersion) { await preparePr1Migration(db, version) } @@ -111,7 +148,12 @@ export async function applyPendingPr1Migrations({ await runBoundedTransaction(db, async () => { if (!acceptedLegacyVersion) await migration.apply(db) await recordMigration(db, version) + if (compatibilityMigration) { + await compatibilityMigration.apply(db) + await recordMigration(db, compatibilityMigration.version) + } }) appliedVersions.add(version) + if (compatibilityMigration) appliedVersions.add(compatibilityMigration.version) } } diff --git a/control-api/test/db.migrationsOrder.test.ts b/control-api/test/db.migrationsOrder.test.ts index 1fc66790fc..5e23a53d10 100644 --- a/control-api/test/db.migrationsOrder.test.ts +++ b/control-api/test/db.migrationsOrder.test.ts @@ -277,4 +277,50 @@ describe('CONTROL_API_MIGRATIONS ordering invariant', () => { /CREATE INDEX IF NOT EXISTS[\s\S]+?ON mcp_secret_rollback_permits\s*\(\s*expires_at\s*\)/i ) }) + + it('registers the narrow 0125 team-delete compatibility successor', async () => { + const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') + const predecessorIndex = CONTROL_API_MIGRATIONS.findIndex( + candidate => candidate.version === '0125_user_access_foundation' + ) + const migration = CONTROL_API_MIGRATIONS.find( + candidate => candidate.version === '0138_authorization_revision_delete_compatibility' + ) + + expect(predecessorIndex).toBeGreaterThanOrEqual(0) + expect(migration).toBeDefined() + expect(CONTROL_API_MIGRATIONS.at(-1)?.version).toBe( + '0138_authorization_revision_delete_compatibility' + ) + expect(migration?.legacyVersions).toBeUndefined() + if (!migration) return + + const query = vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }) + await migration.apply({ query } as never) + + expect(query).toHaveBeenCalledOnce() + const sql = String(query.mock.calls[0]?.[0]) + for (const functionName of [ + 'authorization_bump_user_revision', + 'authorization_bump_team_revision', + ]) { + expect(sql).toMatch( + new RegExp(`CREATE OR REPLACE FUNCTION public\\.${functionName}\\(target_.* UUID\\)`, 'i') + ) + expect(sql).toMatch( + new RegExp( + `${functionName}[\\s\\S]+?SELECT ${functionName.includes('user') ? 'users' : 'teams'}\\.id[\\s\\S]+FROM ${functionName.includes('user') ? 'users' : 'teams'}`, + 'i' + ) + ) + } + expect(sql).toMatch( + /CREATE OR REPLACE FUNCTION public\.authorization_bump_team_revision\(target_team_id UUID\)/i + ) + expect(sql).toMatch(/SECURITY DEFINER[\s\S]+SET search_path = pg_catalog, public, pg_temp/i) + expect(sql).toMatch(/SELECT teams\.id[\s\S]+FROM teams[\s\S]+WHERE teams\.id = target_team_id/i) + expect(sql).toMatch(/SELECT users\.id[\s\S]+FROM users[\s\S]+WHERE users\.id = target_user_id/i) + expect(sql).not.toMatch(/VALUES\s*\(target_(?:team|user)_id/i) + expect(sql).not.toMatch(/\b(?:GRANT|REVOKE|OWNER TO)\b/i) + }) }) diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index 6bb31f7547..4716568fe8 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -30,6 +30,20 @@ const FRESH_TABLE_INDEXES = Object.freeze([ 'invitation_delivery_commands_invitation_idx', ]) +const USER_ACCESS_FOUNDATION_VERSION = '0125_user_access_foundation' +const AUTHORIZATION_REVISION_COMPATIBILITY_VERSION = + '0138_authorization_revision_delete_compatibility' + +function expectedMigrationExecutionOrder(versions: readonly string[]): string[] { + const withoutCompatibility = versions.filter( + version => version !== AUTHORIZATION_REVISION_COMPATIBILITY_VERSION + ) + const foundationIndex = withoutCompatibility.indexOf(USER_ACCESS_FOUNDATION_VERSION) + expect(foundationIndex).toBeGreaterThanOrEqual(0) + withoutCompatibility.splice(foundationIndex + 1, 0, AUTHORIZATION_REVISION_COMPATIBILITY_VERSION) + return withoutCompatibility +} + describe('D34 migration execution policy', () => { it('classifies inherited parent migrations before the re-slotted PR1 migrations', () => { expect(DEV_POST_0106_MIGRATION_VERSIONS.slice(-6)).toEqual([ @@ -259,7 +273,12 @@ describe('D34 PR1 migration runner', () => { }, }) - expect(applied).toEqual([...DEV_POST_0106_MIGRATION_VERSIONS, ...PR1_MIGRATION_VERSIONS]) + expect(applied).toEqual( + expectedMigrationExecutionOrder([ + ...DEV_POST_0106_MIGRATION_VERSIONS, + ...PR1_MIGRATION_VERSIONS, + ]) + ) expect(queries.filter(({ sql }) => sql === 'BEGIN')).toHaveLength(26) expect(queries.filter(({ sql }) => sql === 'COMMIT')).toHaveLength(26) expect(queries.filter(({ sql }) => sql === 'ROLLBACK')).toHaveLength(0) @@ -358,8 +377,245 @@ describe('D34 PR1 migration runner', () => { }, }) - expect(applyOrder).toEqual([...DEV_POST_0106_MIGRATION_VERSIONS, ...PR1_MIGRATION_VERSIONS]) - expect(recorded).toEqual(applyOrder) + const expectedOrder = expectedMigrationExecutionOrder([ + ...DEV_POST_0106_MIGRATION_VERSIONS, + ...PR1_MIGRATION_VERSIONS, + ]) + expect(applyOrder).toEqual(expectedOrder) + expect(recorded).toEqual(expectedOrder) + }) + + it('commits 0125 and its deletion-compatible successor as one migration phase', async () => { + const events: string[] = [] + const transactionEvents: string[] = [] + let activeTransaction = 0 + let nextTransaction = 0 + const migrations = [ + ...DEV_POST_0106_MIGRATION_VERSIONS.map(version => ({ + version, + apply: vi.fn(async () => { + events.push(version) + transactionEvents.push(`${version}:apply:${activeTransaction}`) + }), + })), + ...PR1_MIGRATION_VERSIONS.map(version => ({ + version, + apply: vi.fn(async () => { + events.push(version) + transactionEvents.push(`${version}:apply:${activeTransaction}`) + }), + })), + ] + const db = { + query: vi.fn(async (sql: string, values?: unknown[]) => { + if (sql === 'BEGIN') { + activeTransaction = ++nextTransaction + } else if (sql === 'COMMIT' || sql === 'ROLLBACK') { + activeTransaction = 0 + } + if (sql.includes('FROM pg_class index_rel')) { + const entry = PR1_ONLINE_INDEX_PLAN.find(index => index.name === values?.[0]) + return { + rows: entry + ? [ + { + table_name: entry.table, + indisunique: Boolean(entry.unique), + indisvalid: true, + definition: entry.createSql, + }, + ] + : [], + rowCount: entry ? 1 : 0, + } + } + return { rows: [], rowCount: 0 } + }), + } + const applied = [...DEV_POST_0106_MIGRATION_VERSIONS] + const expectedOrder = expectedMigrationExecutionOrder(PR1_MIGRATION_VERSIONS) + + await applyPendingPr1Migrations({ + db, + migrations, + appliedVersions: new Set(applied), + recordMigration: async (_db, version) => { + events.push(`receipt:${version}`) + transactionEvents.push(`${version}:receipt:${activeTransaction}`) + }, + }) + + expect(events.filter(event => !event.startsWith('receipt:'))).toEqual(expectedOrder) + const parentIndex = events.indexOf(USER_ACCESS_FOUNDATION_VERSION) + const parentReceiptIndex = events.indexOf(`receipt:${USER_ACCESS_FOUNDATION_VERSION}`) + const compatibilityIndex = events.indexOf(AUTHORIZATION_REVISION_COMPATIBILITY_VERSION) + const compatibilityReceiptIndex = events.indexOf( + `receipt:${AUTHORIZATION_REVISION_COMPATIBILITY_VERSION}` + ) + expect(parentIndex).toBeLessThan(parentReceiptIndex) + expect(parentReceiptIndex).toBeLessThan(compatibilityIndex) + expect(compatibilityIndex).toBeLessThan(compatibilityReceiptIndex) + const phaseTransactionIds = [ + `${USER_ACCESS_FOUNDATION_VERSION}:apply`, + `${USER_ACCESS_FOUNDATION_VERSION}:receipt`, + `${AUTHORIZATION_REVISION_COMPATIBILITY_VERSION}:apply`, + `${AUTHORIZATION_REVISION_COMPATIBILITY_VERSION}:receipt`, + ].map(prefix => { + const event = transactionEvents.find(candidate => candidate.startsWith(`${prefix}:`)) + return event?.split(':').at(-1) + }) + expect(phaseTransactionIds).toHaveLength(4) + expect(new Set(phaseTransactionIds).size).toBe(1) + expect(phaseTransactionIds[0]).not.toBe('0') + expect(db.query.mock.calls.filter(([sql]) => sql === 'BEGIN')).toHaveLength( + PR1_MIGRATION_VERSIONS.length + ) + expect(db.query.mock.calls.filter(([sql]) => sql === 'COMMIT')).toHaveLength( + PR1_MIGRATION_VERSIONS.length + ) + }) + + it('repairs an applied 0125 prefix before preparing 0127 indexes', async () => { + const events: string[] = [] + const migrations = [ + ...DEV_POST_0106_MIGRATION_VERSIONS.map(version => ({ + version, + apply: vi.fn(async () => { + events.push(version) + }), + })), + ...PR1_MIGRATION_VERSIONS.map(version => ({ + version, + apply: vi.fn(async () => { + events.push(version) + }), + })), + ] + const db = { + query: vi.fn(async (sql: string, values?: unknown[]) => { + if (sql === 'BEGIN' || sql === 'COMMIT' || sql === 'ROLLBACK') { + events.push(sql) + } + if (sql.includes('FROM pg_class index_rel')) { + events.push('prepare:0127') + const entry = PR1_ONLINE_INDEX_PLAN.find(index => index.name === values?.[0]) + return { + rows: entry + ? [ + { + table_name: entry.table, + indisunique: Boolean(entry.unique), + indisvalid: true, + definition: entry.createSql, + }, + ] + : [], + rowCount: entry ? 1 : 0, + } + } + return { rows: [], rowCount: 0 } + }), + } + const appliedVersions = new Set([ + ...DEV_POST_0106_MIGRATION_VERSIONS, + USER_ACCESS_FOUNDATION_VERSION, + '0126_invitation_delivery_commands', + ...PR1_MIGRATION_VERSIONS.filter( + version => + version !== USER_ACCESS_FOUNDATION_VERSION && + version !== '0126_invitation_delivery_commands' && + version !== '0127_catalog_utf8_ordering' && + version !== AUTHORIZATION_REVISION_COMPATIBILITY_VERSION + ), + ]) + + await applyPendingPr1Migrations({ + db, + migrations, + appliedVersions, + recordMigration: async (_db, version) => { + events.push(`receipt:${version}`) + }, + }) + + expect(events.indexOf(AUTHORIZATION_REVISION_COMPATIBILITY_VERSION)).toBeLessThan( + events.indexOf('prepare:0127') + ) + expect(events.indexOf(`receipt:${AUTHORIZATION_REVISION_COMPATIBILITY_VERSION}`)).toBeLessThan( + events.indexOf('prepare:0127') + ) + expect(events.filter(event => event === 'BEGIN')).toHaveLength(3) + expect(events.filter(event => event === 'COMMIT')).toHaveLength(3) + const repairCommit = events.indexOf('COMMIT') + expect(repairCommit).toBeLessThan(events.indexOf('prepare:0127')) + }) + + it('preserves the 0125 alias when its 0138 successor is already recorded', async () => { + const foundation = { + version: USER_ACCESS_FOUNDATION_VERSION, + legacyVersions: ['0109_user_access_foundation'], + apply: vi.fn(async () => undefined), + } + const compatibility = { + version: AUTHORIZATION_REVISION_COMPATIBILITY_VERSION, + apply: vi.fn(async () => undefined), + } + const migrations = [ + ...DEV_POST_0106_MIGRATION_VERSIONS.map(version => ({ + version, + apply: vi.fn(async () => undefined), + })), + ...PR1_MIGRATION_VERSIONS.filter( + version => + version !== USER_ACCESS_FOUNDATION_VERSION && + version !== AUTHORIZATION_REVISION_COMPATIBILITY_VERSION + ).map(version => ({ version, apply: vi.fn(async () => undefined) })), + foundation, + compatibility, + ] + const appliedVersions = new Set([ + ...DEV_POST_0106_MIGRATION_VERSIONS, + ...PR1_MIGRATION_VERSIONS.filter(version => version !== USER_ACCESS_FOUNDATION_VERSION), + '0109_user_access_foundation', + ]) + const recorded: string[] = [] + + await applyPendingPr1Migrations({ + db: { query: vi.fn(async () => ({ rows: [], rowCount: 0 })) }, + migrations, + appliedVersions, + recordMigration: async (_db, version) => { + recorded.push(version) + }, + }) + + expect(recorded).toEqual([USER_ACCESS_FOUNDATION_VERSION]) + expect(foundation.apply).not.toHaveBeenCalled() + expect(compatibility.apply).not.toHaveBeenCalled() + expect(appliedVersions).toContain(USER_ACCESS_FOUNDATION_VERSION) + }) + + it('fails closed when 0138 is recorded without the 0125 prerequisite', async () => { + await expect( + applyPendingPr1Migrations({ + db: { query: vi.fn(async () => ({ rows: [], rowCount: 0 })) }, + migrations: [ + ...DEV_POST_0106_MIGRATION_VERSIONS.map(version => ({ + version, + apply: vi.fn(async () => undefined), + })), + ...PR1_MIGRATION_VERSIONS.map(version => ({ + version, + apply: vi.fn(async () => undefined), + })), + ], + appliedVersions: new Set([ + ...DEV_POST_0106_MIGRATION_VERSIONS, + AUTHORIZATION_REVISION_COMPATIBILITY_VERSION, + ]), + recordMigration: vi.fn(async () => undefined), + }) + ).rejects.toThrow('recorded before its prerequisite') }) }) diff --git a/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts b/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts index 0c2b6c8f5d..d5a339383c 100644 --- a/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts +++ b/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts @@ -107,4 +107,180 @@ describeRealPostgres('D34 previous-image writer compatibility on real PostgreSQL ) expect(Number(revision.rows[0]?.count ?? 0)).toBeGreaterThan(0) }) + + it('installs deletion-compatible revision functions on fresh initialization', async () => { + const functionState = await databasePool.query<{ + name: string + config: string[] | null + definition: string + }>(`SELECT proname AS name, proconfig AS config, pg_get_functiondef(oid) AS definition + FROM pg_proc + WHERE oid = ANY(ARRAY[ + 'public.authorization_bump_team_revision(uuid)'::regprocedure, + 'public.authorization_bump_user_revision(uuid)'::regprocedure + ]) + ORDER BY proname`) + expect(functionState.rows.map(row => row.name)).toEqual([ + 'authorization_bump_team_revision', + 'authorization_bump_user_revision', + ]) + for (const [name, parentTable, targetId] of [ + ['authorization_bump_team_revision', 'teams', 'target_team_id'], + ['authorization_bump_user_revision', 'users', 'target_user_id'], + ]) { + const state = functionState.rows.find(row => row.name === name) + expect(state?.config).toContain('search_path=pg_catalog, public, pg_temp') + expect(state?.definition).toMatch( + new RegExp(`SELECT ${parentTable}\\.id[\\s\\S]*FROM ${parentTable}`, 'i') + ) + expect(state?.definition).not.toMatch(new RegExp(`VALUES\\s*\\(${targetId}`, 'i')) + } + expect( + await databasePool.query( + `SELECT 1 FROM schema_migrations + WHERE version = '0138_authorization_revision_delete_compatibility'` + ) + ).toMatchObject({ rowCount: 1 }) + }) + + it('fixes an applied 0125 prefix before previous writers delete users or teams', async () => { + const writerSource = readFileSync( + new URL('../src/services/directory/teams.ts', import.meta.url) + ) + expect(createHash('sha256').update(writerSource).digest('hex')).toBe( + PRE_D34_TEAMS_WRITER_SHA256 + ) + + const userId = randomUUID() + const teamId = randomUUID() + await databasePool.query( + `INSERT INTO users(id, email, name) VALUES ($1, $2, 'D34 prefix repair')`, + [userId, `d34-prefix-${userId}@example.test`] + ) + await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, 'D34 prefix repair')`, [ + teamId, + ]) + await databasePool.query( + `INSERT INTO team_members(team_id, user_id, role, status) + VALUES ($1, $2, 'member', 'deleted')`, + [teamId, userId] + ) + + const before = await databasePool.query<{ + name: string + owner: string + acl: string[] | null + config: string[] | null + }>( + `SELECT proname AS name, pg_get_userbyid(proowner) AS owner, proacl AS acl, + proconfig AS config + FROM pg_proc + WHERE oid = ANY(ARRAY[ + 'public.authorization_bump_team_revision(uuid)'::regprocedure, + 'public.authorization_bump_user_revision(uuid)'::regprocedure + ]) + ORDER BY proname` + ) + expect(before.rows).toHaveLength(2) + + await databasePool.query(` + CREATE OR REPLACE FUNCTION public.authorization_bump_user_revision(target_user_id UUID) + RETURNS VOID + LANGUAGE SQL + SECURITY DEFINER + SET search_path = pg_catalog, public + AS $$ + INSERT INTO authorization_user_revisions(user_id, revision, updated_at) + VALUES(target_user_id, 1, clock_timestamp()) + ON CONFLICT (user_id) DO UPDATE + SET revision = authorization_user_revisions.revision + 1, + updated_at = clock_timestamp(); + $$; + + CREATE OR REPLACE FUNCTION public.authorization_bump_team_revision(target_team_id UUID) + RETURNS VOID + LANGUAGE SQL + SECURITY DEFINER + SET search_path = pg_catalog, public + AS $$ + INSERT INTO authorization_team_revisions(team_id, revision, updated_at) + VALUES(target_team_id, 1, clock_timestamp()) + ON CONFLICT (team_id) DO UPDATE + SET revision = authorization_team_revisions.revision + 1, + updated_at = clock_timestamp(); + $$; + `) + + const actualDb = await vi.importActual('../src/db.js') + vi.doMock('../src/db.js', () => ({ + ...actualDb, + pool: databasePool, + withTransaction: (work: (client: PoolClient) => Promise) => + withDatabaseTransaction(databasePool, async client => { + await client.query('SET LOCAL ROLE control_api_runtime') + return work(client) + }), + })) + const [{ adminDeleteTeam }, { adminDeleteUser }] = await Promise.all([ + import('../src/services/directory/teams.js'), + import('../src/services/directory/users.js'), + ]) + await expect(adminDeleteTeam(teamId)).rejects.toMatchObject({ code: '23503' }) + await expect(adminDeleteUser(userId)).rejects.toMatchObject({ code: '23503' }) + + await databasePool.query( + `DELETE FROM schema_migrations + WHERE version = '0138_authorization_revision_delete_compatibility'` + ) + await initDb({ connect: () => databasePool.connect() }) + + const repaired = await databasePool.query<{ + name: string + owner: string + acl: string[] | null + config: string[] | null + definition: string + }>( + `SELECT proname AS name, + pg_get_userbyid(proowner) AS owner, + proacl AS acl, + proconfig AS config, + pg_get_functiondef(oid) AS definition + FROM pg_proc + WHERE oid = ANY(ARRAY[ + 'public.authorization_bump_team_revision(uuid)'::regprocedure, + 'public.authorization_bump_user_revision(uuid)'::regprocedure + ]) + ORDER BY proname` + ) + expect(repaired.rows).toHaveLength(2) + for (const [name, parentTable, targetId] of [ + ['authorization_bump_team_revision', 'teams', 'target_team_id'], + ['authorization_bump_user_revision', 'users', 'target_user_id'], + ]) { + const current = repaired.rows.find(row => row.name === name) + const original = before.rows.find(row => row.name === name) + expect(current?.config).toContain('search_path=pg_catalog, public, pg_temp') + expect(current?.definition).toMatch( + new RegExp(`SELECT ${parentTable}\\.id[\\s\\S]*FROM ${parentTable}`, 'i') + ) + expect(current?.definition).not.toMatch(new RegExp(`VALUES\\s*\\(${targetId}`, 'i')) + expect(current?.owner).toBe(original?.owner) + expect(current?.acl).toEqual(original?.acl) + } + expect(await adminDeleteUser(userId)).toEqual({ ok: true, id: userId }) + expect(await adminDeleteTeam(teamId)).toEqual({ ok: true, id: teamId }) + expect(await databasePool.query(`SELECT 1 FROM users WHERE id = $1`, [userId])).toMatchObject({ + rowCount: 0, + }) + expect(await databasePool.query(`SELECT 1 FROM teams WHERE id = $1`, [teamId])).toMatchObject({ + rowCount: 0, + }) + expect( + await databasePool.query( + `SELECT 1 FROM schema_migrations + WHERE version = '0138_authorization_revision_delete_compatibility'` + ) + ).toMatchObject({ rowCount: 1 }) + }) }) From d71351459bf49a0489109cfd65603d7c5a608dbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:44:06 -0600 Subject: [PATCH 264/528] fix(control-api): r55-m3 serialize reciprocal retirements Class-check: authenticated managed-user retirement -> one producer -> lock actor/target UUIDs in order Repro-test: reciprocal retirement deadlock (fails at fcc1a59397412208118f669f08b359dbb0502142) --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../src/services/directory/membership.ts | 17 ++++ ...Authority.realPostgres.integration.test.ts | 88 +++++++++++++++++++ 4 files changed, 108 insertions(+), 3 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 84b081e2d7..37f4d33a7f 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.709", + "version": "0.1.710", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.709", + "version": "0.1.710", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index e3e844ab1f..ceb100fdc6 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.709", + "version": "0.1.710", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/directory/membership.ts b/control-api/src/services/directory/membership.ts index 0fe11c7892..0316c0d575 100644 --- a/control-api/src/services/directory/membership.ts +++ b/control-api/src/services/directory/membership.ts @@ -21,6 +21,7 @@ import { DesktopUserRetirementError, retireDesktopUser } from './users.js' export const INVITATION_TTL_HOURS = 48 const DRAFT_INVITATION_CLEANUP_HOURS = 24 const DRAFT_INVITATION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000 +const USER_LOCK_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i let draftInvitationCleanupTimer: ReturnType | null = null type InvitationRow = { @@ -2228,6 +2229,22 @@ export async function deleteManagedUserForUser( try { return await withTransaction(async db => { + // Reciprocal retirements must not each lock their own actor before the + // other request's target. Lock the existing principal pair in the same + // database UUID order before revalidating authority or locking teams. + // Filter malformed ids before the UUID cast so the indexed lookup does + // not change their existing later validation/error path. + const principalIds = [...new Set([normalizedManagerUserId, normalizedTargetUserId])] + .filter(id => USER_LOCK_UUID_PATTERN.test(id)) + .map(id => id.toLowerCase()) + await db.query( + `SELECT id + FROM users + WHERE id = ANY($1::uuid[]) + ORDER BY id + FOR UPDATE`, + [principalIds] + ) if (!(await hasValidManagedMutationAuthority(db, normalizedManagerUserId, authority))) { return { error: 'forbidden' as const } } diff --git a/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts b/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts index 054c682776..cf07fc03ec 100644 --- a/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts +++ b/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts @@ -578,4 +578,92 @@ describeRealPostgres('external team mutations final source authority on real Pos } }) } + + it('serializes reciprocal managed-user retirements before related membership locks', async () => { + const firstUserId = randomUUID() + const secondUserId = randomUUID() + const sharedTeamId = randomUUID() + const firstEmail = `reciprocal-first-${firstUserId}@example.test` + const secondEmail = `reciprocal-second-${secondUserId}@example.test` + await databasePool.query( + `INSERT INTO users(id, email, name, lifecycle_state, lifecycle_version) + VALUES ($1::uuid, $2, 'Reciprocal First', 'active', 1), + ($3::uuid, $4, 'Reciprocal Second', 'active', 1)`, + [firstUserId, firstEmail, secondUserId, secondEmail] + ) + await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1::uuid, 'Reciprocal')`, [ + sharedTeamId, + ]) + await databasePool.query( + `INSERT INTO team_members(team_id, user_id, role, status) + VALUES ($1::uuid, $2::uuid, 'admin', 'active'), + ($1::uuid, $3::uuid, 'admin', 'active')`, + [sharedTeamId, firstUserId, secondUserId] + ) + const [firstSession, secondSession] = await Promise.all([ + issueExternalUserSession( + { + contract: 'v1', + userId: firstUserId, + email: firstEmail, + teamId: sharedTeamId, + role: 'admin', + authGeneration: 1, + authenticationMethods: ['password'], + }, + { policy: runtimePolicy } + ), + issueExternalUserSession( + { + contract: 'v1', + userId: secondUserId, + email: secondEmail, + teamId: sharedTeamId, + role: 'admin', + authGeneration: 1, + authenticationMethods: ['password'], + }, + { policy: runtimePolicy } + ), + ]) + + let admitted = 0 + let releaseRequests!: () => void + const bothAuthenticated = new Promise(resolve => { + releaseRequests = resolve + }) + rateLimit.checkAndIncrement.mockImplementation(async () => { + admitted += 1 + if (admitted === 2) releaseRequests() + await bothAuthenticated + return { + allowed: true, + count: 1, + remaining: 9, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + } + }) + + const [firstResponse, secondResponse] = await Promise.all([ + request(app()) + .delete(`/external/members/${secondUserId}`) + .set('x-user-session-token', firstSession.token) + .set('Idempotency-Key', `reciprocal-${firstUserId}-${secondUserId}`) + .send({ reason: 'reciprocal retirement serialization' }), + request(app()) + .delete(`/external/members/${firstUserId}`) + .set('x-user-session-token', secondSession.token) + .set('Idempotency-Key', `reciprocal-${secondUserId}-${firstUserId}`) + .send({ reason: 'reciprocal retirement serialization' }), + ]) + + expect(admitted).toBe(2) + expect([firstResponse.status, secondResponse.status].sort((a, b) => a - b)).toEqual([200, 403]) + const remaining = await databasePool.query<{ id: string }>( + `SELECT id::text AS id FROM users WHERE id = ANY($1::uuid[]) ORDER BY id`, + [[firstUserId, secondUserId]] + ) + expect(remaining.rows).toHaveLength(1) + }) }) From 62d1f9373b39f067f661075ff3444d9514dfb958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:20:48 -0600 Subject: [PATCH 265/528] fix(control-api): r55-m4 bind revocation to live actor Class-check: public revocation routes -> 3 actor-aware callers + 5 trusted internal callers -> revalidate the authenticated actor before session or epoch mutation; V1 logout stays token-scoped Repro-test: userSessionService stale-actor probe (returns 1 at fcc1a59397412208118f669f08b359dbb0502142) --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- control-api/src/routes/external/auth.ts | 12 +- .../src/services/auth/userSessionService.ts | 24 +++- ...hangeRace.realPostgres.integration.test.ts | 104 +++++++++++++++- ...utes.externalSessionAtomicExchange.test.ts | 112 ++++++++++++++++++ 6 files changed, 249 insertions(+), 9 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 37f4d33a7f..4dabd04137 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.710", + "version": "0.1.711", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.710", + "version": "0.1.711", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index ceb100fdc6..0a15d68964 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.710", + "version": "0.1.711", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/routes/external/auth.ts b/control-api/src/routes/external/auth.ts index 5bf2dab21e..d8c766cb59 100644 --- a/control-api/src/routes/external/auth.ts +++ b/control-api/src/routes/external/auth.ts @@ -447,7 +447,9 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { ? await revokeUserSession( authentication.claims.userId, authentication.claims.sid, - 'logout' + 'logout', + undefined, + authentication.authorityContext ) : await revokeLegacyUserSession(token, authentication.claims, 'logout') return res.status(200).json({ revoked }) @@ -480,7 +482,9 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { const authentication = req.externalSessionAuthentication! const revoked = await revokeAllUserSessions( authentication.claims.userId, - 'user_revoked_all' + 'user_revoked_all', + undefined, + authentication.authorityContext ) return res.status(200).json({ revoked }) } catch { @@ -512,7 +516,9 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { const revoked = await revokeUserSession( authentication.claims.userId, String(req.params.sid || '').trim(), - 'user_revoked' + 'user_revoked', + undefined, + authentication.authorityContext ) return res.status(200).json({ revoked }) } catch { diff --git a/control-api/src/services/auth/userSessionService.ts b/control-api/src/services/auth/userSessionService.ts index ed33d5a6f4..54902af750 100644 --- a/control-api/src/services/auth/userSessionService.ts +++ b/control-api/src/services/auth/userSessionService.ts @@ -417,9 +417,13 @@ export async function revokeUserSession( userId: string, sid: string, reason: string, - db?: SessionDatabase + db?: SessionDatabase, + actorContext?: ExternalSessionAuthorityContext ): Promise { const work = async (transaction: SessionDatabase): Promise => { + if (actorContext && !(await isCurrentRevocationActor(transaction, userId, actorContext))) { + return false + } const locked = await transaction.query( `SELECT sid FROM external_user_sessions @@ -449,11 +453,15 @@ export async function revokeUserSession( export async function revokeAllUserSessions( userId: string, reason: string, - db?: SessionDatabase + db?: SessionDatabase, + actorContext?: ExternalSessionAuthorityContext ): Promise { const work = async (transaction: SessionDatabase) => { const user = await transaction.query(`SELECT id FROM users WHERE id = $1 FOR UPDATE`, [userId]) if ((user.rowCount ?? 0) === 0) return 0 + if (actorContext && !(await isCurrentRevocationActor(transaction, userId, actorContext))) { + return 0 + } const revokedAt = await loadDatabaseNow(transaction) await transaction.query( @@ -483,6 +491,18 @@ export async function revokeAllUserSessions( return db ? work(db) : withTransaction(work) } +async function isCurrentRevocationActor( + transaction: SessionDatabase, + targetUserId: string, + actorContext: ExternalSessionAuthorityContext +): Promise { + if (actorContext.userId !== targetUserId) return false + const authentication = await validateExternalSessionAuthorityContext(actorContext, { + db: transaction, + }) + return authentication.status === 'valid' && authentication.identity.userId === targetUserId +} + export async function validateExternalSessionAuthorityContext( context: ExternalSessionAuthorityContext, options: { db?: SessionDatabase } = {} diff --git a/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts b/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts index 293ba0a813..607ea06317 100644 --- a/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts +++ b/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts @@ -1,14 +1,18 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import bcrypt from 'bcryptjs' -import { randomBytes, randomUUID } from 'node:crypto' +import { createHash, randomBytes, randomUUID } from 'node:crypto' import { Pool, type PoolClient } from 'pg' import { type DbClient, initDb } from '../src/db.js' import type { AuthClaims } from '../src/profileTypes.js' import type { EffectiveUserAccessPolicy } from '../src/services/access/userAccessPolicy.js' import { exchangeLegacyExternalUserSession } from '../src/services/auth/externalSessionIssuance.js' import { + createUserSession, revokeAllUserSessions, + revokeLegacyUserSession, + revokeUserSession, validateLegacyUserSession, + validateUserSessionClaims, } from '../src/services/auth/userSessionService.js' import { verifyUserPassword } from '../src/services/directory/login.js' import { @@ -19,6 +23,7 @@ import { signExternalSessionToken, verifyExternalSessionToken, } from '../src/utils/auth/externalSessionAuthToken.js' +import { verifyUserSessionV2Token } from '../src/utils/auth/userSessionV2Token.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -546,4 +551,101 @@ describeRealPostgres('legacy replacement exchange serialization on real PostgreS ) expect(epoch.rows[0]?.reason).toBe('newer') }) + + it('does not let a stale authenticated actor revoke replacement sessions', async () => { + const legacySource = await principal('stale-v1-revocation-actor') + const staleLegacyAuthority = { + contract: 'v1' as const, + userId: legacySource.userId, + tokenHash: createHash('sha256').update(legacySource.token).digest('hex'), + issuedAt: legacySource.claims.iat!, + authGeneration: legacySource.claims.authGeneration!, + } + await revokeLegacyUserSession(legacySource.token, legacySource.claims, 'logout', databasePool) + const replacementAfterLegacyInvalidation = await createUserSession( + { + userId: legacySource.userId, + email: legacySource.email, + authenticationMethods: ['pwd'], + }, + { db: databasePool } + ) + + await expect( + revokeAllUserSessions( + legacySource.userId, + 'user_revoked_all', + databasePool, + staleLegacyAuthority + ) + ).resolves.toBe(0) + const replacementLegacyClaims = verifyUserSessionV2Token( + replacementAfterLegacyInvalidation.token + ) + expect(replacementLegacyClaims).not.toBeNull() + await expect( + validateUserSessionClaims(replacementLegacyClaims!, { db: databasePool, touch: false }) + ).resolves.toMatchObject({ status: 'valid' }) + + const v2Actor = await createUserSession( + { + userId: legacySource.userId, + email: legacySource.email, + authenticationMethods: ['pwd'], + }, + { db: databasePool } + ) + const v2Target = await createUserSession( + { + userId: legacySource.userId, + email: legacySource.email, + authenticationMethods: ['pwd'], + }, + { db: databasePool } + ) + const staleV2Authority = { + contract: 'v2' as const, + userId: legacySource.userId, + sid: v2Actor.identity.sid, + jti: v2Actor.identity.jti, + sessionVersion: v2Actor.identity.sessionVersion, + } + await revokeUserSession(legacySource.userId, v2Actor.identity.sid, 'logout', databasePool) + + await expect( + revokeUserSession( + legacySource.userId, + v2Target.identity.sid, + 'user_revoked', + databasePool, + staleV2Authority + ) + ).resolves.toBe(false) + const targetClaims = verifyUserSessionV2Token(v2Target.token) + expect(targetClaims).not.toBeNull() + await expect( + validateUserSessionClaims(targetClaims!, { db: databasePool, touch: false }) + ).resolves.toMatchObject({ status: 'valid' }) + + const currentActor = await createUserSession( + { + userId: legacySource.userId, + email: legacySource.email, + authenticationMethods: ['pwd'], + }, + { db: databasePool } + ) + await expect( + revokeUserSession(legacySource.userId, v2Target.identity.sid, 'user_revoked', databasePool, { + contract: 'v2', + userId: legacySource.userId, + sid: currentActor.identity.sid, + jti: currentActor.identity.jti, + sessionVersion: currentActor.identity.sessionVersion, + }) + ).resolves.toBe(true) + await expect( + validateUserSessionClaims(targetClaims!, { db: databasePool, touch: false }) + ).resolves.toMatchObject({ status: 'revoked' }) + }) }) diff --git a/control-api/test/routes.externalSessionAtomicExchange.test.ts b/control-api/test/routes.externalSessionAtomicExchange.test.ts index b919d85244..2d688235b8 100644 --- a/control-api/test/routes.externalSessionAtomicExchange.test.ts +++ b/control-api/test/routes.externalSessionAtomicExchange.test.ts @@ -4,6 +4,10 @@ import request from 'supertest' import { createExternalAuthRouter } from '../src/routes/external/auth.js' const sessions = vi.hoisted(() => ({ authenticateExternalUserSession: vi.fn() })) +const revocations = vi.hoisted(() => ({ + revokeAllUserSessions: vi.fn(), + revokeUserSession: vi.fn(), +})) const issuance = vi.hoisted(() => ({ exchangeLegacyExternalUserSession: vi.fn(), issueExternalUserSession: vi.fn(), @@ -16,6 +20,10 @@ vi.mock('../src/services/auth/externalSessionAuthentication.js', () => ({ ...sessions, renewExternalUserSession: vi.fn(), })) +vi.mock('../src/services/auth/userSessionService.js', async importOriginal => { + const actual = await importOriginal() + return { ...actual, ...revocations } +}) vi.mock('../src/services/auth/externalSessionIssuance.js', () => issuance) vi.mock('../src/services/rateLimiterService.js', () => limiter) vi.mock('../src/db.js', () => ({ pool: { query: database.query } })) @@ -61,6 +69,8 @@ describe('legacy external-session replacement exchange', () => { policy: { issueV1: true, switchCompatibility: true }, }) issuance.issueExternalUserSession.mockResolvedValue({ token: 'stale-token', contract: 'v1' }) + revocations.revokeAllUserSessions.mockResolvedValue(1) + revocations.revokeUserSession.mockResolvedValue(true) database.query.mockResolvedValue({ rows: [{ role: 'admin', lifecycle_version: 1 }], rowCount: 1, @@ -120,4 +130,106 @@ describe('legacy external-session replacement exchange', () => { expect(issuance.issueExternalUserSession).not.toHaveBeenCalled() }) + + it('binds revoke-all to the authenticated actor authority context', async () => { + const authorityContext = { + contract: 'v1' as const, + userId: claims.userId, + tokenHash: 'source-hash', + issuedAt: claims.iat, + authGeneration: 1, + } + sessions.authenticateExternalUserSession.mockResolvedValueOnce({ + status: 'authenticated', + contract: 'v1', + claims, + authorityContext, + policy: { issueV1: true, switchCompatibility: true }, + }) + + await request(app()) + .post('/external/auth/sessions/revoke-all') + .set('x-forwarded-for', '203.0.113.44') + .set('x-user-session-token', 'source-token') + .expect(200, { revoked: 1 }) + + expect(revocations.revokeAllUserSessions).toHaveBeenCalledWith( + claims.userId, + 'user_revoked_all', + undefined, + authorityContext + ) + }) + + it('binds targeted revocation to the authenticated v2 actor authority context', async () => { + const v2Claims = { + userId: claims.userId, + sid: 'source-session', + jti: 'source-jti', + } + const authorityContext = { + contract: 'v2' as const, + userId: claims.userId, + sid: v2Claims.sid, + jti: v2Claims.jti, + sessionVersion: 1, + } + sessions.authenticateExternalUserSession.mockResolvedValueOnce({ + status: 'authenticated', + contract: 'v2', + claims: v2Claims, + authorityContext, + policy: { issueV1: true, switchCompatibility: true }, + }) + + await request(app()) + .post('/external/auth/sessions/target-session/revoke') + .set('x-forwarded-for', '203.0.113.44') + .set('x-user-session-token', 'source-token') + .expect(200, { revoked: true }) + + expect(revocations.revokeUserSession).toHaveBeenCalledWith( + claims.userId, + 'target-session', + 'user_revoked', + undefined, + authorityContext + ) + }) + + it('binds v2 logout to the authenticated actor authority context', async () => { + const v2Claims = { + userId: claims.userId, + sid: 'source-session', + jti: 'source-jti', + } + const authorityContext = { + contract: 'v2' as const, + userId: claims.userId, + sid: v2Claims.sid, + jti: v2Claims.jti, + sessionVersion: 1, + } + sessions.authenticateExternalUserSession.mockResolvedValueOnce({ + status: 'authenticated', + contract: 'v2', + claims: v2Claims, + authorityContext, + policy: { issueV1: true, switchCompatibility: true }, + }) + + await request(app()) + .post('/external/auth/session/logout') + .set('x-forwarded-for', '203.0.113.44') + .set('x-user-session-token', 'source-token') + .expect(200, { revoked: true }) + + expect(revocations.revokeUserSession).toHaveBeenCalledWith( + claims.userId, + v2Claims.sid, + 'logout', + undefined, + authorityContext + ) + }) }) From ca759ca7cb6e6b5bc460daecba92b9d343c8002d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:51:05 -0600 Subject: [PATCH 266/528] test(control-api): r55-m10 cover ineligible sibling hydration Class-check: derived MCP hydration edge classes -> 2 CTEs -> add producer-backed eligibility controls Context-direct paths require an enabled, non-deleted Context; Host-derived paths require both endpoints. Repro-test: test/accessCatalogFamilies.realPostgres.integration.test.ts (exact PostgreSQL 16 run at 62d1f9373 pending; local DSN unavailable) --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- ...gFamilies.realPostgres.integration.test.ts | 90 +++++++++++++++++++ 3 files changed, 93 insertions(+), 3 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 4dabd04137..edbabaefec 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.711", + "version": "0.1.712", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.711", + "version": "0.1.712", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 0a15d68964..b51d1a6b43 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.711", + "version": "0.1.712", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts index b06334bf57..c1109420af 100644 --- a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts @@ -438,6 +438,96 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { }) } + it('excludes an ineligible sibling Host from selected MCP hydration', async () => { + const hostSource = operationalSourceSpecs.find(value => value.family === 'host')! + const disabledHost = fixture({ + plural: 'hosts', + namespace: config.hostsNamespace, + name: 'disabled-catalog-host', + uid: 'disabled-catalog-host-uid', + spec: { enabled: false, contextRef: 'catalog-context' }, + }) + kubernetesApi.put(disabledHost.plural, disabledHost.namespace, disabledHost.object) + await indexer.reconcileSource(hostSource) + await databasePool.query( + `INSERT INTO user_agents(user_id, agent_name) VALUES ($1, 'disabled-catalog-host')`, + [userId] + ) + + try { + const disabledEdge = await databasePool.query<{ relationship_instance_id: string }>( + `SELECT relationship_instance_id + FROM operational_resource_relationships + WHERE environment_id = $1 AND source_type = 'host' + AND source_id = $2 AND relationship_type = 'uses_context' + AND target_id = $3`, + [ + environmentId, + `${config.hostsNamespace}/disabled-catalog-host`, + `${config.contextsNamespace}/catalog-context`, + ] + ) + const enabledEdge = await databasePool.query<{ relationship_instance_id: string }>( + `SELECT relationship_instance_id + FROM operational_resource_relationships + WHERE environment_id = $1 AND source_type = 'host' + AND source_id = $2 AND relationship_type = 'uses_context' + AND target_id = $3`, + [ + environmentId, + `${config.hostsNamespace}/catalog-host`, + `${config.contextsNamespace}/catalog-context`, + ] + ) + const directContextEdge = await databasePool.query<{ relationship_instance_id: string }>( + `SELECT relationship_instance_id + FROM operational_resource_relationships + WHERE environment_id = $1 AND source_type = 'context' + AND source_id = $2 AND relationship_type = 'includes_mcp_server' + AND target_id = $3`, + [ + environmentId, + `${config.contextsNamespace}/catalog-context`, + `${config.mcpServersNamespace}/catalog-mcp`, + ] + ) + expect(disabledEdge.rows).toHaveLength(1) + expect(enabledEdge.rows).toHaveLength(1) + expect(directContextEdge.rows).toHaveLength(1) + + const catalog = await buildAccessCatalog( + { session, families: ['mcp_server'], limit: 100 }, + { transaction: transaction(databasePool) } + ) + const item = catalog.items.find( + value => value.resource.logicalId === `${config.mcpServersNamespace}/catalog-mcp` + ) + expect(catalog.complete).toBe(true) + expect(catalog.partialErrors).toEqual([]) + expect(item).toBeDefined() + const runtimeRefs = item!.accessPaths.flatMap(path => { + const runtime = path.behaviorDescriptors.runtime + return runtime.state === 'known' && runtime.value ? [runtime.value] : [] + }) + expect( + runtimeRefs.some(value => value.includes(enabledEdge.rows[0]!.relationship_instance_id)) + ).toBe(true) + expect( + runtimeRefs.some(value => value === directContextEdge.rows[0]!.relationship_instance_id) + ).toBe(true) + expect( + runtimeRefs.some(value => value.includes(disabledEdge.rows[0]!.relationship_instance_id)) + ).toBe(false) + } finally { + await databasePool.query( + `DELETE FROM user_agents WHERE user_id = $1 AND agent_name = 'disabled-catalog-host'`, + [userId] + ) + kubernetesApi.delete(disabledHost.plural, disabledHost.namespace, 'disabled-catalog-host') + await indexer.reconcileSource(hostSource) + } + }) + it('round-trips a catalog write path when capability selection excludes a read-only sibling', async () => { const catalog = await buildAccessCatalog( { session, families: ['gfs_resource'], limit: 10 }, From 52d5c7f06a5e34919887dcc2c3dbfc2c462dbf54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:02:24 -0600 Subject: [PATCH 267/528] fix(control-api): r55-m10 exclude ineligible derived routes Class-check: derived MCP hydration direct/Host edge CTEs -> 2 classes -> enforce source eligibility in both. Direct Context edges require eligible Context sources; Host paths require eligible Host and Context endpoints. Repro-test: accessCatalogFamilies.realPostgres.integration.test.ts (pre-fix source ca759ca7; hosted PG16 result pending) --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- .../services/access/catalogHydrationSql.ts | 20 +++++++++++++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index edbabaefec..5b43937c8a 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.712", + "version": "0.1.713", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.712", + "version": "0.1.713", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index b51d1a6b43..ea974ad603 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.712", + "version": "0.1.713", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/access/catalogHydrationSql.ts b/control-api/src/services/access/catalogHydrationSql.ts index c5047ca270..41350dcd19 100644 --- a/control-api/src/services/access/catalogHydrationSql.ts +++ b/control-api/src/services/access/catalogHydrationSql.ts @@ -228,11 +228,31 @@ export const DERIVED_OPERATIONAL_HYDRATION_SQL = ` WHEN $4::text = 'mcp_server' THEN 'includes_mcp_server' ELSE 'mounts_shared_filesystem' END + JOIN operational_resource_index source_resource + ON source_resource.environment_id = relationship.environment_id + AND source_resource.resource_type = 'context' + AND source_resource.logical_id = relationship.source_id + AND source_resource.enabled = TRUE + AND source_resource.deleted_at IS NULL + WHERE relationship.source_type = 'context' ), host_edges AS MATERIALIZED ( SELECT relationship.* FROM operational_resource_relationships relationship + JOIN operational_resource_index host_resource + ON host_resource.environment_id = relationship.environment_id + AND host_resource.resource_type = 'host' + AND host_resource.logical_id = relationship.source_id + AND host_resource.enabled = TRUE + AND host_resource.deleted_at IS NULL + JOIN operational_resource_index context_resource + ON context_resource.environment_id = relationship.environment_id + AND context_resource.resource_type = 'context' + AND context_resource.logical_id = relationship.target_id + AND context_resource.enabled = TRUE + AND context_resource.deleted_at IS NULL WHERE $4::text = 'mcp_server' AND relationship.environment_id = $3 + AND relationship.source_type = 'host' AND relationship.relationship_type = 'uses_context' AND relationship.target_type = 'context' AND relationship.target_id IN (SELECT source_id FROM direct_edges) From fc9a476ad37c1ff1b9eacb12762e38091840f53c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:48:05 -0600 Subject: [PATCH 268/528] fix(control-api): r55-m6 isolate bounded indexer budget Finding: R55-M6 Class-check: AccessExecutionBudget.create -> 8 production sites -> reconcileSource uses indexer; other 7 retain their catalog/action classes. Repro-test: test/operationalAccessIndexer.test.ts:139 (RED at 52d5c7f06a5e34919887dcc2c3dbfc2c462dbf54: 701 objects exhaust producerCalls before promotion). --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../services/access/accessExecutionBudget.ts | 22 ++- .../access/operationalAccessIndexer.ts | 2 +- .../test/accessExecutionBudget.test.ts | 24 +++ .../test/operationalAccessIndexer.test.ts | 166 ++++++++++++++++++ 6 files changed, 211 insertions(+), 9 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 5b43937c8a..f8172e68cd 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.713", + "version": "0.1.714", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.713", + "version": "0.1.714", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index ea974ad603..d9c2aed01b 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.713", + "version": "0.1.714", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/access/accessExecutionBudget.ts b/control-api/src/services/access/accessExecutionBudget.ts index c60536ac5b..d0ba3f9a23 100644 --- a/control-api/src/services/access/accessExecutionBudget.ts +++ b/control-api/src/services/access/accessExecutionBudget.ts @@ -22,7 +22,15 @@ export const ACCESS_EXECUTION_LIMIT_CLAMPS = Object.freeze({ responseBytes: 4 * 1024 * 1024, }) -export type AccessExecutionKind = 'catalog' | 'action' +export type AccessExecutionKind = 'catalog' | 'action' | 'indexer' + +const ACCESS_EXECUTION_CLASS_LIMITS: Readonly< + Record> +> = Object.freeze({ + catalog: Object.freeze({ producerCalls: 32, databaseStatements: 128 }), + action: Object.freeze({ producerCalls: 32, databaseStatements: 128 }), + indexer: Object.freeze({ producerCalls: 41, databaseStatements: 55 }), +}) export type AccessExecutionLimitName = keyof typeof ACCESS_EXECUTION_LIMIT_CLAMPS export type AccessExecutionLimits = Readonly> @@ -171,11 +179,15 @@ function positiveSafeInteger(value: unknown): value is number { } export function resolveAccessExecutionLimits( - requested: Partial = {} + requested: Partial = {}, + kind: AccessExecutionKind = 'action' ): AccessExecutionLimits { + const classLimits = ACCESS_EXECUTION_CLASS_LIMITS[kind] const values: Record = { ...ACCESS_EXECUTION_LIMIT_CLAMPS, + ...classLimits, } + const maximums = { ...ACCESS_EXECUTION_LIMIT_CLAMPS, ...classLimits } for (const key of Object.keys(ACCESS_EXECUTION_LIMIT_CLAMPS) as Array< keyof AccessExecutionLimits >) { @@ -184,7 +196,7 @@ export function resolveAccessExecutionLimits( if (!positiveSafeInteger(supplied)) { throw new AccessBudgetConfigurationError(key, 'must be a positive safe integer') } - if (supplied > ACCESS_EXECUTION_LIMIT_CLAMPS[key]) { + if (supplied > maximums[key]) { throw new AccessBudgetConfigurationError(key, 'exceeds the hard code clamp') } values[key] = supplied @@ -244,7 +256,7 @@ export class AccessExecutionBudget { parentSignal?: AbortSignal } = {} ): AccessExecutionBudget { - const limits = resolveAccessExecutionLimits(options.limits) + const limits = resolveAccessExecutionLimits(options.limits, kind) const teamGfsMembershipAdmissionLimit = options.teamGfsMembershipAdmissionLimit === undefined ? null @@ -261,7 +273,7 @@ export class AccessExecutionBudget { } const controller = new AbortController() const now = options.now ?? Date.now() - const deadlineMs = kind === 'catalog' ? limits.catalogDeadlineMs : limits.actionDeadlineMs + const deadlineMs = kind === 'action' ? limits.actionDeadlineMs : limits.catalogDeadlineMs const shared: SharedAccessBudgetState = { counters: createCounters(limits), semaphore: new AccessSemaphore(limits.producerConcurrency), diff --git a/control-api/src/services/access/operationalAccessIndexer.ts b/control-api/src/services/access/operationalAccessIndexer.ts index f49d916b6f..fc467e18fe 100644 --- a/control-api/src/services/access/operationalAccessIndexer.ts +++ b/control-api/src/services/access/operationalAccessIndexer.ts @@ -142,7 +142,7 @@ export class OperationalAccessIndexer { } async reconcileSource(spec: OperationalSourceSpec, parentSignal?: AbortSignal): Promise { - const budget = AccessExecutionBudget.create('catalog', { parentSignal }) + const budget = AccessExecutionBudget.create('indexer', { parentSignal }) try { const stagingGeneration = await this.index.beginRelist({ environmentId: this.environmentId, diff --git a/control-api/test/accessExecutionBudget.test.ts b/control-api/test/accessExecutionBudget.test.ts index 7498f80fac..1376327b8f 100644 --- a/control-api/test/accessExecutionBudget.test.ts +++ b/control-api/test/accessExecutionBudget.test.ts @@ -32,6 +32,30 @@ describe('AccessExecutionBudget', () => { budget.close() }) + it('isolates the bounded indexer ceiling from ordinary action requests', () => { + const indexer = AccessExecutionBudget.create('indexer') + expect(indexer.remaining('producerCalls')).toBe(41) + expect(indexer.remaining('databaseStatements')).toBe(55) + expect(() => indexer.charge({ kind: 'producerCalls', amount: 42 })).toThrow( + AccessBudgetExceededError + ) + expect(() => indexer.charge({ kind: 'databaseStatements', amount: 56 })).toThrow( + AccessBudgetExceededError + ) + indexer.close() + + const action = AccessExecutionBudget.create('action') + expect(action.remaining('producerCalls')).toBe(32) + expect(action.remaining('databaseStatements')).toBe(128) + expect(() => action.charge({ kind: 'producerCalls', amount: 33 })).toThrow( + AccessBudgetExceededError + ) + expect(() => action.charge({ kind: 'databaseStatements', amount: 129 })).toThrow( + AccessBudgetExceededError + ) + action.close() + }) + it('charges objects, bytes, paths, relationships, rows, memo, and response limits', () => { const budget = AccessExecutionBudget.create('catalog', { limits: { diff --git a/control-api/test/operationalAccessIndexer.test.ts b/control-api/test/operationalAccessIndexer.test.ts index 448413a256..e0ab63c7ae 100644 --- a/control-api/test/operationalAccessIndexer.test.ts +++ b/control-api/test/operationalAccessIndexer.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it, vi } from 'vitest' +import { K8sGateway } from '../src/k8s.js' +import { + type AccessExecutionBudget, + type AccessExecutionLimits, +} from '../src/services/access/accessExecutionBudget.js' import type { OperationalAccessIndex } from '../src/services/access/operationalAccessIndex.js' import { OperationalAccessIndexer } from '../src/services/access/operationalAccessIndexer.js' +import { TemporaryKubernetesApi } from './helpers/temporaryKubernetesApi.js' function hostObject(name: string, resourceVersion: string) { return { @@ -25,6 +31,67 @@ function fakeIndex() { } } +function meteredIndex() { + const observed: { + limits?: AccessExecutionLimits + beforePromotion?: { producerCalls: number; databaseStatements: number } + afterPromotion?: { producerCalls: number; databaseStatements: number } + } = {} + const charge = ( + budget: AccessExecutionBudget, + producerCalls: number, + databaseStatements: number + ) => { + budget.charge({ kind: 'producerCalls', amount: producerCalls }) + budget.charge({ kind: 'databaseStatements', amount: databaseStatements }) + } + const index = { + beginRelist: vi.fn(async ({ budget }: { budget: AccessExecutionBudget }) => { + observed.limits = budget.limits + charge(budget, 2, 4) + return 2 + }), + stageRelistPage: vi.fn(async ({ budget }: { budget: AccessExecutionBudget }) => + charge(budget, 2, 4) + ), + promoteRelist: vi.fn(async ({ budget }: { budget: AccessExecutionBudget }) => { + observed.beforePromotion = { + producerCalls: budget.remaining('producerCalls'), + databaseStatements: budget.remaining('databaseStatements'), + } + charge(budget, 9, 11) + observed.afterPromotion = { + producerCalls: budget.remaining('producerCalls'), + databaseStatements: budget.remaining('databaseStatements'), + } + }), + applyWatchProjection: vi.fn().mockResolvedValue(3), + recordWatchBookmark: vi.fn().mockResolvedValue(undefined), + markSourceState: vi.fn().mockResolvedValue(undefined), + } + return { index, observed } +} + +async function temporaryHostGateway(objectCount: number) { + const api = new TemporaryKubernetesApi() + await api.start() + const previousKubeconfig = process.env.KUBECONFIG + process.env.KUBECONFIG = api.kubeconfig() + const gateway = new K8sGateway() + for (let index = 0; index < objectCount; index += 1) { + api.put('hosts', 'mcp-host', hostObject(`host-${index}`, '10')) + } + return { + api, + gateway, + async close() { + if (previousKubeconfig === undefined) delete process.env.KUBECONFIG + else process.env.KUBECONFIG = previousKubeconfig + await api.close() + }, + } +} + describe('operational access indexer', () => { it('reconciles bounded Kubernetes pages into one atomic source generation', async () => { const index = fakeIndex() @@ -64,6 +131,105 @@ describe('operational access indexer', () => { expect(index.promoteRelist).toHaveBeenCalledOnce() }) + it.each([ + [700, 7], + [701, 8], + [1_000, 10], + ])( + 'reconstructs %i objects across %i pages before one atomic promotion', + async (count, pages) => { + const { index, observed } = meteredIndex() + const producer = await temporaryHostGateway(count) + try { + const indexer = new OperationalAccessIndexer( + producer.gateway, + index as unknown as OperationalAccessIndex, + { environmentId: 'test:cluster', behaviorFingerprintKey: 'test-key' } + ) + + await expect( + indexer.reconcileSource({ family: 'host', plural: 'hosts', namespace: 'mcp-host' }) + ).resolves.toBe('10') + + const pageRequests = producer.api.requests.filter( + value => value.plural === 'hosts' && !value.watch + ) + expect(pageRequests).toHaveLength(pages) + expect(pageRequests.every(value => value.limit === '100')).toBe(true) + expect(index.stageRelistPage).toHaveBeenCalledTimes(pages) + expect(index.stageRelistPage.mock.calls.map(([input]) => input.stagingGeneration)).toEqual( + Array(pages).fill(2) + ) + expect(index.promoteRelist).toHaveBeenCalledOnce() + expect(index.promoteRelist).toHaveBeenCalledWith( + expect.objectContaining({ stagingGeneration: 2, resourceVersion: '10' }) + ) + if (count === 1_000) { + expect(observed.limits).toMatchObject({ producerCalls: 41, databaseStatements: 55 }) + expect(observed.beforePromotion).toEqual({ producerCalls: 9, databaseStatements: 11 }) + expect(observed.afterPromotion).toEqual({ producerCalls: 0, databaseStatements: 0 }) + } + } finally { + await producer.close() + } + } + ) + + it('does not promote an over-capacity or cancelled source reconstruction', async () => { + const overCapacity = meteredIndex() + const overCapacityProducer = await temporaryHostGateway(1_001) + try { + const overCapacityIndexer = new OperationalAccessIndexer( + overCapacityProducer.gateway, + overCapacity.index as unknown as OperationalAccessIndex, + { environmentId: 'test:cluster', behaviorFingerprintKey: 'test-key' } + ) + await expect( + overCapacityIndexer.reconcileSource({ + family: 'host', + plural: 'hosts', + namespace: 'mcp-host', + }) + ).rejects.toMatchObject({ name: 'AccessBudgetExceededError', limit: 'objects' }) + expect(overCapacity.index.promoteRelist).not.toHaveBeenCalled() + } finally { + await overCapacityProducer.close() + } + + const controller = new AbortController() + const cancelled = meteredIndex() + const cancelledProducer = await temporaryHostGateway(100) + try { + const cancelledGateway = { + listResourcePage: async (...args: Parameters) => { + const page = await cancelledProducer.gateway.listResourcePage(...args) + controller.abort() + return page + }, + watchResource: cancelledProducer.gateway.watchResource.bind(cancelledProducer.gateway), + getResourceExact: cancelledProducer.gateway.getResourceExact.bind( + cancelledProducer.gateway + ), + } + const cancelledIndexer = new OperationalAccessIndexer( + cancelledGateway, + cancelled.index as unknown as OperationalAccessIndex, + { environmentId: 'test:cluster', behaviorFingerprintKey: 'test-key' } + ) + + await expect( + cancelledIndexer.reconcileSource( + { family: 'host', plural: 'hosts', namespace: 'mcp-host' }, + controller.signal + ) + ).rejects.toMatchObject({ name: 'AccessExecutionCancelledError' }) + expect(cancelled.index.stageRelistPage).not.toHaveBeenCalled() + expect(cancelled.index.promoteRelist).not.toHaveBeenCalled() + } finally { + await cancelledProducer.close() + } + }) + it('does not promote pages from inconsistent snapshot resource versions', async () => { const index = fakeIndex() const indexer = new OperationalAccessIndexer( From 474ee34a437c1ed48ad9bf886a2c5cdf0611e5e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:08:01 -0600 Subject: [PATCH 269/528] fix(control-api): r55-h4 admit complete public catalog work Class-check: AccessExecutionKind limits -> 3 classes; resize catalog only. Repro-test: control-api/test/routes.externalAccess.test.ts (fails at fc9a476: status 503). --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../services/access/accessExecutionBudget.ts | 6 +- .../test/accessExecutionBudget.test.ts | 38 +++- .../test/routes.externalAccess.test.ts | 177 ++++++++++++++++++ 5 files changed, 219 insertions(+), 8 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index f8172e68cd..14bdb5873e 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.714", + "version": "0.1.715", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.714", + "version": "0.1.715", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index d9c2aed01b..b6bae5c4c1 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.714", + "version": "0.1.715", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/access/accessExecutionBudget.ts b/control-api/src/services/access/accessExecutionBudget.ts index d0ba3f9a23..46df1bec56 100644 --- a/control-api/src/services/access/accessExecutionBudget.ts +++ b/control-api/src/services/access/accessExecutionBudget.ts @@ -3,8 +3,8 @@ export const ACCESS_EXECUTION_LIMIT_CLAMPS = Object.freeze({ catalogDeadlineMs: 5_000, actionDeadlineMs: 2_000, statementTimeoutMs: 2_500, - producerCalls: 32, - databaseStatements: 128, + producerCalls: 42, + databaseStatements: 1_493, producerConcurrency: 4, keyCandidatesPerCall: 101, objects: 1_000, @@ -27,7 +27,7 @@ export type AccessExecutionKind = 'catalog' | 'action' | 'indexer' const ACCESS_EXECUTION_CLASS_LIMITS: Readonly< Record> > = Object.freeze({ - catalog: Object.freeze({ producerCalls: 32, databaseStatements: 128 }), + catalog: Object.freeze({ producerCalls: 42, databaseStatements: 1_493 }), action: Object.freeze({ producerCalls: 32, databaseStatements: 128 }), indexer: Object.freeze({ producerCalls: 41, databaseStatements: 55 }), }) diff --git a/control-api/test/accessExecutionBudget.test.ts b/control-api/test/accessExecutionBudget.test.ts index 1376327b8f..d8fbfa8f86 100644 --- a/control-api/test/accessExecutionBudget.test.ts +++ b/control-api/test/accessExecutionBudget.test.ts @@ -27,9 +27,43 @@ describe('AccessExecutionBudget', () => { const budget = AccessExecutionBudget.create('catalog') expect(() => budget.assertPageSize(101)).toThrow(AccessBudgetConfigurationError) expect(() => budget.assertCursorBytes(16 * 1024 + 1)).toThrow(AccessBudgetConfigurationError) - expect(budget.remaining('producerCalls')).toBe(32) - expect(budget.remaining('databaseStatements')).toBe(128) + expect(budget.remaining('producerCalls')).toBe(42) + expect(budget.remaining('databaseStatements')).toBe(1_493) budget.close() + + const actionBudget = AccessExecutionBudget.create('action') + expect(actionBudget.remaining('producerCalls')).toBe(32) + expect(actionBudget.remaining('databaseStatements')).toBe(128) + actionBudget.close() + }) + + it('keeps each execution class within its own authorized producer and SQL ceiling', () => { + expect(resolveAccessExecutionLimits({ producerCalls: 42 }, 'catalog').producerCalls).toBe(42) + expect( + resolveAccessExecutionLimits({ databaseStatements: 1_493 }, 'catalog').databaseStatements + ).toBe(1_493) + expect(resolveAccessExecutionLimits({ producerCalls: 41 }, 'indexer').producerCalls).toBe(41) + expect( + resolveAccessExecutionLimits({ databaseStatements: 55 }, 'indexer').databaseStatements + ).toBe(55) + expect(() => + AccessExecutionBudget.create('catalog', { limits: { producerCalls: 43 } }) + ).toThrow(AccessBudgetConfigurationError) + expect(() => + AccessExecutionBudget.create('catalog', { limits: { databaseStatements: 1_494 } }) + ).toThrow(AccessBudgetConfigurationError) + expect(() => AccessExecutionBudget.create('action', { limits: { producerCalls: 33 } })).toThrow( + AccessBudgetConfigurationError + ) + expect(() => + AccessExecutionBudget.create('action', { limits: { databaseStatements: 129 } }) + ).toThrow(AccessBudgetConfigurationError) + expect(() => resolveAccessExecutionLimits({ producerCalls: 42 }, 'indexer')).toThrow( + AccessBudgetConfigurationError + ) + expect(() => resolveAccessExecutionLimits({ databaseStatements: 56 }, 'indexer')).toThrow( + AccessBudgetConfigurationError + ) }) it('isolates the bounded indexer ceiling from ordinary action requests', () => { diff --git a/control-api/test/routes.externalAccess.test.ts b/control-api/test/routes.externalAccess.test.ts index 9a057925f3..0766d4acbc 100644 --- a/control-api/test/routes.externalAccess.test.ts +++ b/control-api/test/routes.externalAccess.test.ts @@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({ validateV2: vi.fn(), resolvePolicy: vi.fn(), resolveAuthorization: vi.fn(), + buildCatalog: vi.fn(), })) const rateLimiter = vi.hoisted(() => ({ checkAndIncrement: vi.fn() })) @@ -37,6 +38,11 @@ vi.mock('../src/services/access/userAccessRuntimePolicy.js', () => ({ vi.mock('../src/services/access/liveAuthorizationResolver.js', () => ({ resolveLiveAuthorization: mocks.resolveAuthorization, })) +vi.mock('../src/services/access/accessCatalogCoordinator.js', async importOriginal => { + const actual = + await importOriginal() + return { ...actual, buildAccessCatalog: mocks.buildCatalog } +}) vi.mock('../src/services/rateLimiterService.js', () => rateLimiter) function effectivePolicy(overrides: Record = {}) { @@ -73,6 +79,12 @@ describe('external user-access contracts', () => { beforeEach(() => { vi.clearAllMocks() mocks.resolvePolicy.mockResolvedValue(effectivePolicy()) + mocks.buildCatalog.mockResolvedValue({ + complete: true, + items: [], + nextCursor: null, + partialErrors: [], + }) mocks.verifyV2.mockReturnValue(null) mocks.verifyV1.mockReturnValue({ userId: '10000000-0000-4000-8000-000000000001', @@ -206,6 +218,171 @@ describe('external user-access contracts', () => { expect(mocks.resolvePolicy).not.toHaveBeenCalled() }) + it('admits the mounted default twelve-family catalog at its complete producer cost', async () => { + const familyCount = 12 + mocks.verifyV1.mockReturnValue(null) + mocks.verifyV2.mockReturnValue({ + sub: '10000000-0000-4000-8000-000000000001', + sid: '20000000-0000-4000-8000-000000000002', + jti: '30000000-0000-4000-8000-000000000003', + sv: 1, + ver: 2, + typ: 'user_session', + email: 'user@example.test', + auth_time: 1_900_000_000, + amr: ['pwd'], + }) + mocks.validateV2.mockImplementation(async (_claims, options) => { + options.budget.charge({ + kind: 'producerCalls', + amount: options.touch === false ? 2 : 3, + }) + return { + status: 'valid', + identity: { + userId: '10000000-0000-4000-8000-000000000001', + email: 'user@example.test', + sid: '20000000-0000-4000-8000-000000000002', + jti: '30000000-0000-4000-8000-000000000003', + sessionVersion: 1, + }, + } + }) + mocks.resolvePolicy.mockImplementation(async options => { + if (options.catalogReadiness) { + options.budget.charge({ kind: 'producerCalls' }) + } + return effectivePolicy({ serveCatalog: true }) + }) + mocks.buildCatalog.mockImplementation(async (input, options) => { + const requestedFamilies = input.families?.length ?? familyCount + options.budget.charge({ + kind: 'producerCalls', + amount: 4 + 2 * requestedFamilies, + }) + return { + complete: true, + items: [], + nextCursor: null, + partialErrors: [], + } + }) + + const response = await request(app()) + .get('/external/access/catalog') + .set('x-user-session-token', 'signed-v2-synthetic') + + expect(mocks.buildCatalog).toHaveBeenCalledOnce() + expect(response.status).toBe(200) + expect(mocks.buildCatalog).toHaveBeenCalledWith( + expect.not.objectContaining({ families: expect.anything() }), + expect.objectContaining({ budget: expect.any(Object) }) + ) + expect(mocks.validateV2).toHaveBeenCalledTimes(2) + const catalogBudget = mocks.buildCatalog.mock.calls[0]?.[1].budget + expect(catalogBudget?.limits.producerCalls).toBe(42) + expect(catalogBudget?.remaining('producerCalls')).toBe(8) + }) + + it('preserves the eleven-family catalog control and rejects revoked authority before catalog work', async () => { + const familyList = + 'user,team,host,context,mcp_server,workflow_recipe,workflow_run,workflow_approval,notification,gfs_resource,shared_filesystem' + mocks.verifyV1.mockReturnValue(null) + mocks.verifyV2.mockReturnValue({ + sub: '10000000-0000-4000-8000-000000000001', + sid: '20000000-0000-4000-8000-000000000002', + jti: '30000000-0000-4000-8000-000000000003', + sv: 1, + ver: 2, + typ: 'user_session', + email: 'user@example.test', + auth_time: 1_900_000_000, + amr: ['pwd'], + }) + mocks.validateV2.mockImplementation(async (_claims, options) => { + options.budget.charge({ + kind: 'producerCalls', + amount: options.touch === false ? 2 : 3, + }) + return { + status: 'valid', + identity: { + userId: '10000000-0000-4000-8000-000000000001', + email: 'user@example.test', + sid: '20000000-0000-4000-8000-000000000002', + jti: '30000000-0000-4000-8000-000000000003', + sessionVersion: 1, + }, + } + }) + mocks.resolvePolicy.mockImplementation(async options => { + if (options.catalogReadiness) { + options.budget.charge({ kind: 'producerCalls' }) + } + return effectivePolicy({ serveCatalog: true }) + }) + mocks.buildCatalog.mockImplementation(async (input, options) => { + const requestedFamilies = input.families?.length ?? 12 + options.budget.charge({ + kind: 'producerCalls', + amount: 4 + 2 * requestedFamilies, + }) + return { complete: true, items: [], nextCursor: null, partialErrors: [] } + }) + + const elevenFamilyResponse = await request(app()) + .get('/external/access/catalog') + .query({ families: familyList }) + .set('x-user-session-token', 'signed-v2-synthetic') + + expect(elevenFamilyResponse.status).toBe(200) + + mocks.validateV2.mockResolvedValue({ status: 'revoked', reason: 'revoked' }) + mocks.buildCatalog.mockClear() + const revokedResponse = await request(app()) + .get('/external/access/catalog') + .set('x-user-session-token', 'signed-v2-synthetic') + + expect(revokedResponse.status).toBe(401) + expect(mocks.buildCatalog).not.toHaveBeenCalled() + }) + + it('stops the mounted catalog before producer work when readiness is unavailable', async () => { + mocks.verifyV1.mockReturnValue(null) + mocks.verifyV2.mockReturnValue({ + sub: '10000000-0000-4000-8000-000000000001', + sid: '20000000-0000-4000-8000-000000000002', + jti: '30000000-0000-4000-8000-000000000003', + sv: 1, + ver: 2, + typ: 'user_session', + email: 'user@example.test', + auth_time: 1_900_000_000, + amr: ['pwd'], + }) + mocks.validateV2.mockResolvedValue({ + status: 'valid', + identity: { + userId: '10000000-0000-4000-8000-000000000001', + email: 'user@example.test', + sid: '20000000-0000-4000-8000-000000000002', + jti: '30000000-0000-4000-8000-000000000003', + sessionVersion: 1, + }, + }) + mocks.resolvePolicy.mockImplementation(async options => { + if (options.catalogReadiness) throw new Error('readiness unavailable') + return effectivePolicy({ serveCatalog: true }) + }) + + const response = await request(app()) + .get('/external/access/catalog') + .set('x-user-session-token', 'signed-v2-synthetic') + + expect(response.status).toBe(503) + expect(mocks.buildCatalog).not.toHaveBeenCalled() + }) + it('uses the authoritative target schema for team management actions', async () => { const teamId = '40000000-0000-4000-8000-000000000004' const pathId = `ap1_${'A'.repeat(43)}` From a11b1931d39bdd24e918a0bf57526301136cfa18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:35:54 -0600 Subject: [PATCH 270/528] fix(control-api): r55-m9 reserve shadow completion budget Class-check: shadow child reservations -> 1 site -> increase only the shadow DB reserve. Repro-test: test/accessCatalogShadowCapacity.test.ts:56 fails at 474ee34 by starting with 108 statements. --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../services/access/accessCatalogShadow.ts | 2 +- ...logShadow.realPostgres.integration.test.ts | 148 ++++++++++++++++-- control-api/test/accessCatalogShadow.test.ts | 6 +- .../test/accessCatalogShadowCapacity.test.ts | 59 +++++++ 6 files changed, 199 insertions(+), 22 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 14bdb5873e..ec850709db 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.715", + "version": "0.1.716", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.715", + "version": "0.1.716", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index b6bae5c4c1..01d8168d1d 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.715", + "version": "0.1.716", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/access/accessCatalogShadow.ts b/control-api/src/services/access/accessCatalogShadow.ts index 9a34681603..36a2342b37 100644 --- a/control-api/src/services/access/accessCatalogShadow.ts +++ b/control-api/src/services/access/accessCatalogShadow.ts @@ -130,7 +130,7 @@ export async function compareAccessCatalogShadow( try { child = parent.child({ producerCalls: 8, - databaseStatements: 8, + databaseStatements: 109, objects: 200, decodedBytes: 2 * 1024 * 1024, accessPaths: 512, diff --git a/control-api/test/accessCatalogShadow.realPostgres.integration.test.ts b/control-api/test/accessCatalogShadow.realPostgres.integration.test.ts index 0aca3d23c7..6c5c3467fb 100644 --- a/control-api/test/accessCatalogShadow.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogShadow.realPostgres.integration.test.ts @@ -1,9 +1,12 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { randomBytes } from 'node:crypto' +import { randomBytes, randomUUID } from 'node:crypto' import { Pool } from 'pg' +import { type DbClient, initDb } from '../src/db.js' +import { buildAccessCatalog } from '../src/services/access/accessCatalogCoordinator.js' import { compareAccessCatalogShadow } from '../src/services/access/accessCatalogShadow.js' -import { runAccessDatabaseQuery } from '../src/services/access/accessDatabaseQuery.js' +import { withAccessDatabaseTransaction } from '../src/services/access/accessDatabaseQuery.js' import { AccessExecutionBudget } from '../src/services/access/accessExecutionBudget.js' +import type { ExternalSessionAuthorityContext } from '../src/services/auth/externalSessionAuthentication.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -20,6 +23,24 @@ function quoteIdentifier(value: string): string { describeRealPostgres('aggregate shadow physical statement budget on real PostgreSQL', () => { const database = `control_api_shadow_budget_${randomBytes(6).toString('hex')}` + const memberUserId = randomUUID() + const noMembershipUserId = randomUUID() + const memberTeamIds = Array.from({ length: 100 }, () => randomUUID()) + const resourceIds = Array.from({ length: 100 }, () => randomUUID()) + const memberSession: ExternalSessionAuthorityContext = { + contract: 'v1', + userId: memberUserId, + tokenHash: 'shadow-member-token', + issuedAt: Math.floor(Date.now() / 1_000), + authGeneration: 1, + } + const noMembershipSession: ExternalSessionAuthorityContext = { + contract: 'v1', + userId: noMembershipUserId, + tokenHash: 'shadow-no-membership-token', + issuedAt: Math.floor(Date.now() / 1_000), + authGeneration: 1, + } let adminPool: Pool let databasePool: Pool @@ -27,6 +48,46 @@ describeRealPostgres('aggregate shadow physical statement budget on real Postgre adminPool = new Pool({ connectionString: adminUrl }) await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) databasePool = new Pool({ connectionString: databaseUrl(adminUrl!, database) }) + await initDb({ connect: () => databasePool.connect() }) + await databasePool.query( + `INSERT INTO users(id, email, name) VALUES + ($1, $2, 'Shadow Member'), ($3, $4, 'Shadow Direct User')`, + [ + memberUserId, + `${memberUserId}@example.test`, + noMembershipUserId, + `${noMembershipUserId}@example.test`, + ] + ) + for (let index = 0; index < memberTeamIds.length; index += 1) { + const teamId = memberTeamIds[index]! + const resourceId = resourceIds[index]! + await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, $2)`, [ + teamId, + `Shadow Team ${index}`, + ]) + await databasePool.query( + `INSERT INTO team_members(team_id, user_id, role, status) + VALUES ($1, $2, 'member', 'active')`, + [teamId, memberUserId] + ) + await databasePool.query( + `INSERT INTO gfs_resources(resource_id, drive, name, kind) + VALUES ($1, $2, $3, 'directory')`, + [resourceId, `shadow-${database}`, `/member/${index}`] + ) + await databasePool.query( + `INSERT INTO gfs_grants(drive, resource_id, subject_type, subject_id, permissions) + VALUES ($1, $2, 'team', $3::text, ARRAY['read']::text[])`, + [`shadow-${database}`, resourceId, teamId] + ) + } + await databasePool.query( + `INSERT INTO gfs_grants(drive, resource_id, subject_type, subject_id, permissions) + VALUES ($1, $2, 'user', $3::text, ARRAY['read']::text[]), + ($1, $2, 'user', $4::text, ARRAY['read']::text[])`, + [`shadow-${database}`, resourceIds[0], memberUserId, noMembershipUserId] + ) }) afterAll(async () => { @@ -44,7 +105,7 @@ describeRealPostgres('aggregate shadow physical statement budget on real Postgre it('executes and charges a real shadow SQL statement only when capacity is reserved', async () => { const budget = AccessExecutionBudget.create('catalog', { - limits: { databaseStatements: 8 }, + limits: { databaseStatements: 109 }, }) let statements = 0 try { @@ -66,18 +127,15 @@ describeRealPostgres('aggregate shadow physical statement budget on real Postgre enabled: true, budget, buildCatalog: async (_input, options) => { - await runAccessDatabaseQuery( - { - query: async (text, values) => { - statements += 1 - return databasePool.query(text, values) - }, - }, + const query = await withAccessDatabaseTransaction( options.budget!, - 'SELECT 1 WHERE $1::int = 1', - [1], - { chargeRows: false } + async db => { + statements += 1 + return db.query('SELECT 1 WHERE $1::int = 1', [1]) + }, + { connectionPool: databasePool, mode: 'caller_configured' } ) + expect(query.rows).toHaveLength(1) return { contractVersion: '2', authorizationRevision: 'shadow', @@ -92,7 +150,69 @@ describeRealPostgres('aggregate shadow physical statement budget on real Postgre ) ).resolves.toBe('match') expect(statements).toBe(1) - expect(budget.remaining('databaseStatements')).toBe(7) + expect(budget.remaining('databaseStatements')).toBe(106) + } finally { + budget.close() + } + }) + + async function buildRealCatalog( + input: Parameters[0], + options: NonNullable[1]> + ) { + const budget = options.budget + if (!budget) throw new Error('shadow_budget_missing') + const transaction = (work: (db: DbClient) => Promise) => + withAccessDatabaseTransaction(budget, work, { + connectionPool: databasePool, + mode: 'caller_configured', + }) + return buildAccessCatalog(input, { ...options, transaction }) + } + + it('completes the 100-membership direct-GFS shadow and charges COMMIT', async () => { + const budget = AccessExecutionBudget.create('catalog', { + limits: { databaseStatements: 109 }, + teamGfsMembershipAdmissionLimit: 100, + }) + try { + await expect( + compareAccessCatalogShadow( + { + session: memberSession, + family: 'gfs_resource', + legacyLogicalIds: resourceIds, + legacyComplete: true, + }, + { enabled: true, budget, buildCatalog: buildRealCatalog } + ) + ).resolves.toBe('match') + expect(budget.remaining('databaseStatements')).toBe(0) + expect(42 - budget.remaining('producerCalls')).toBeLessThanOrEqual(6) + } finally { + budget.close() + } + }) + + it('completes a direct-GFS shadow for a user with no team memberships', async () => { + const budget = AccessExecutionBudget.create('catalog', { + limits: { databaseStatements: 109 }, + teamGfsMembershipAdmissionLimit: 100, + }) + try { + await expect( + compareAccessCatalogShadow( + { + session: noMembershipSession, + family: 'gfs_resource', + legacyLogicalIds: [resourceIds[0]!], + legacyComplete: true, + }, + { enabled: true, budget, buildCatalog: buildRealCatalog } + ) + ).resolves.toBe('match') + expect(budget.remaining('databaseStatements')).toBeGreaterThan(0) + expect(budget.remaining('databaseStatements')).toBeLessThan(109) } finally { budget.close() } diff --git a/control-api/test/accessCatalogShadow.test.ts b/control-api/test/accessCatalogShadow.test.ts index d2eafba183..d1dd554e15 100644 --- a/control-api/test/accessCatalogShadow.test.ts +++ b/control-api/test/accessCatalogShadow.test.ts @@ -93,9 +93,7 @@ describe('aggregate access shadow comparison', () => { }) it('reserves physical statement capacity for a database-consuming child build', async () => { - const budget = AccessExecutionBudget.create('catalog', { - limits: { databaseStatements: 8 }, - }) + const budget = AccessExecutionBudget.create('catalog') const query = vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }) try { await expect( @@ -114,7 +112,7 @@ describe('aggregate access shadow comparison', () => { ) ).resolves.toBe('match') expect(query).toHaveBeenCalledTimes(1) - expect(budget.remaining('databaseStatements')).toBe(7) + expect(budget.remaining('databaseStatements')).toBe(1_492) } finally { budget.close() } diff --git a/control-api/test/accessCatalogShadowCapacity.test.ts b/control-api/test/accessCatalogShadowCapacity.test.ts index f1025a73ab..a574db5beb 100644 --- a/control-api/test/accessCatalogShadowCapacity.test.ts +++ b/control-api/test/accessCatalogShadowCapacity.test.ts @@ -52,4 +52,63 @@ describe('aggregate access shadow scheduling capacity', () => { expect(deferred).toHaveLength(ACCESS_EXECUTION_LIMIT_CLAMPS.producerConcurrency) }) + + it('reserves all 109 shadow statements before starting comparison work', async () => { + const [{ AccessExecutionBudget }, { compareAccessCatalogShadow }] = await Promise.all([ + import('../src/services/access/accessExecutionBudget.js'), + import('../src/services/access/accessCatalogShadow.js'), + ]) + const session = { + contract: 'v1' as const, + userId: '10000000-0000-4000-8000-000000000001', + tokenHash: 'token-hash', + issuedAt: 1_900_000_000, + authGeneration: 1, + } + const tooSmall = AccessExecutionBudget.create('catalog', { + limits: { databaseStatements: 108 }, + }) + const unusedBuild = vi.fn(async () => ({ + complete: true, + items: [], + nextCursor: null, + partialErrors: [], + })) + try { + await expect( + compareAccessCatalogShadow( + { session, family: 'gfs_resource', legacyLogicalIds: [], legacyComplete: true }, + { enabled: true, budget: tooSmall, buildCatalog: unusedBuild } + ) + ).resolves.toBe('skipped_capacity') + expect(unusedBuild).not.toHaveBeenCalled() + expect(tooSmall.remaining('databaseStatements')).toBe(108) + } finally { + tooSmall.close() + } + + const exact = AccessExecutionBudget.create('catalog', { + limits: { databaseStatements: 109 }, + }) + const observedChildLimits: number[] = [] + try { + await expect( + compareAccessCatalogShadow( + { session, family: 'gfs_resource', legacyLogicalIds: [], legacyComplete: true }, + { + enabled: true, + budget: exact, + buildCatalog: vi.fn(async (_input, options) => { + observedChildLimits.push(options.budget!.limits.databaseStatements) + return { complete: true, items: [], nextCursor: null, partialErrors: [] } + }), + } + ) + ).resolves.toBe('match') + expect(observedChildLimits).toEqual([109]) + expect(exact.remaining('databaseStatements')).toBe(109) + } finally { + exact.close() + } + }) }) From f40fedd26a4cb74ded7f6701e7a76c6df5c32f7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:13:40 -0600 Subject: [PATCH 271/528] fix(control-api): r55-m7 resolve Context identity aliases Class-check: canonicalContextLogicalIdSql across Task 106 access -> 24 SQL call sites; catalog keys, hydration, live authority and graph-reference sites resolve aliases uniquely. The projection writer emits the internal alias edge; hydration and graph reads filter it. Repro-test: control-api/test/operationalAccessProjection.test.ts:62 fails at a11b1931d39bdd24e918a0bf57526301136cfa18 at the expected alias-edge assertion. --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../services/access/accessAuthorityStore.ts | 90 +++++- .../services/access/catalogHydrationSql.ts | 48 ++- .../src/services/access/catalogProducerSql.ts | 81 +++-- .../src/services/access/contextIdentitySql.ts | 31 ++ .../access/operationalAccessProjection.ts | 15 + .../access/operationalAccessReader.ts | 37 ++- ...gFamilies.realPostgres.integration.test.ts | 300 ++++++++++++++++++ .../test/accessLiveAuthorization.test.ts | 25 +- .../test/operationalAccessProjection.test.ts | 33 ++ 11 files changed, 593 insertions(+), 73 deletions(-) create mode 100644 control-api/src/services/access/contextIdentitySql.ts diff --git a/control-api/package-lock.json b/control-api/package-lock.json index ec850709db..923190604c 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.716", + "version": "0.1.717", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.716", + "version": "0.1.717", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 01d8168d1d..81522f6fbb 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.716", + "version": "0.1.717", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/access/accessAuthorityStore.ts b/control-api/src/services/access/accessAuthorityStore.ts index 6db63f3fae..816b7f3988 100644 --- a/control-api/src/services/access/accessAuthorityStore.ts +++ b/control-api/src/services/access/accessAuthorityStore.ts @@ -17,6 +17,7 @@ import { gfsPermissionsToCapabilities, normalizeAccessCapabilities, } from './capabilityRegistry.js' +import { canonicalContextLogicalIdSql } from './contextIdentitySql.js' import type { ValidatedOperationTarget } from './operationTarget.js' import type { OperationalResourceGraphResult } from './operationalAccessReader.js' import type { CanonicalResourceIdentity } from './resourceIdentity.js' @@ -340,7 +341,23 @@ async function loadSimpleOperationalGrantCandidates(input: { const result = await query( input.db, input.budget, - `WITH candidates AS ( + `WITH requested_context_references AS ( + SELECT $5::text AS logical_id, split_part($5::text, '/', 2) AS reference_name + UNION + SELECT alias_relationship.source_id, + split_part(alias_relationship.target_id, '/', 2) + FROM operational_resource_relationships alias_relationship + JOIN operational_resource_index alias_source + ON alias_source.environment_id = alias_relationship.environment_id + AND alias_source.resource_type = 'context' + AND alias_source.logical_id = alias_relationship.source_id + AND alias_source.provider_uid = alias_relationship.source_provider_uid + WHERE alias_relationship.environment_id = $6 + AND alias_relationship.source_type = 'context' + AND alias_relationship.source_id = $5 + AND alias_relationship.relationship_type = 'context_identity_alias' + AND alias_relationship.target_type = 'context' + ), candidates AS ( SELECT 'direct'::text AS kind, 'user_agents:' || ua.user_id || ':' || ua.agent_name AS grant_id, NULL::uuid AS team_id, NULL::text AS current_role @@ -357,14 +374,19 @@ async function loadSimpleOperationalGrantCandidates(input: { SELECT 'direct', 'user_contexts:' || uc.user_id || ':' || uc.context_id, NULL, NULL FROM user_contexts uc - WHERE $2::text = 'context' AND uc.user_id = $1 AND uc.context_id = $4 + JOIN requested_context_references requested + ON requested.reference_name = uc.context_id + WHERE $2::text = 'context' AND uc.user_id = $1 + AND ${canonicalContextLogicalIdSql('$6', "split_part(requested.logical_id, '/', 1) || '/' || uc.context_id")} = requested.logical_id UNION ALL SELECT 'team', 'team_contexts:' || tc.team_id || ':' || tc.context_id, tc.team_id, tm.role FROM team_contexts tc + JOIN requested_context_references requested + ON requested.reference_name = tc.context_id JOIN team_members tm ON tm.team_id = tc.team_id WHERE $2::text = 'context' AND tm.user_id = $1 AND tm.status = 'active' - AND tc.context_id = $4 + AND ${canonicalContextLogicalIdSql('$6', "split_part(requested.logical_id, '/', 1) || '/' || tc.context_id")} = requested.logical_id UNION ALL SELECT 'direct', ${directWorkflowTriggerGrantId}, NULL, NULL @@ -382,12 +404,14 @@ async function loadSimpleOperationalGrantCandidates(input: { ) SELECT * FROM candidates ORDER BY kind, team_id NULLS FIRST, grant_id - LIMIT $5`, + LIMIT $7`, [ input.userId, input.resource.type, identity.namespace, identity.name, + input.resource.logicalId, + input.graph.resource.environmentId, input.budget.limits.accessPaths + 1, ] ) @@ -484,27 +508,57 @@ async function loadDerivedOperationalCandidates(input: { ) : [] const hostIds = [...new Set(hostEdges.map(edge => edge.sourceId))] - const contextNames = contextIds.map(value => scopedName(value)?.name ?? '').filter(Boolean) const hostNames = hostIds.map(value => scopedName(value)?.name ?? '').filter(Boolean) - if (contextNames.length === 0 && hostNames.length === 0) return [] + if (contextIds.length === 0 && hostNames.length === 0) return [] const result = await query( input.db, input.budget, - `WITH context_names AS (SELECT UNNEST($2::text[]) AS name), + `WITH context_names AS (SELECT UNNEST($2::text[]) AS logical_id), + context_references AS MATERIALIZED ( + SELECT requested.logical_id, + split_part(requested.logical_id, '/', 2) AS reference_name + FROM context_names requested + UNION + SELECT requested.logical_id, + split_part(alias_relationship.target_id, '/', 2) + FROM context_names requested + JOIN operational_resource_relationships alias_relationship + ON alias_relationship.environment_id = $4 + AND alias_relationship.source_type = 'context' + AND alias_relationship.source_id = requested.logical_id + AND alias_relationship.relationship_type = 'context_identity_alias' + AND alias_relationship.target_type = 'context' + JOIN operational_resource_index alias_source + ON alias_source.environment_id = alias_relationship.environment_id + AND alias_source.resource_type = 'context' + AND alias_source.logical_id = alias_relationship.source_id + AND alias_source.provider_uid = alias_relationship.source_provider_uid + ), host_names AS (SELECT UNNEST($3::text[]) AS name), candidates AS ( SELECT 'direct'::text AS kind, 'user_contexts:' || uc.user_id || ':' || uc.context_id AS grant_id, NULL::uuid AS team_id, NULL::text AS current_role, - 'context'::text AS source_type, uc.context_id AS source_name - FROM user_contexts uc JOIN context_names requested ON requested.name = uc.context_id - WHERE uc.user_id = $1 + 'context'::text AS source_type, context_identity.canonical_id AS source_name + FROM user_contexts uc + JOIN context_references requested ON requested.reference_name = uc.context_id + CROSS JOIN LATERAL ( + SELECT ${canonicalContextLogicalIdSql('$4', "split_part(requested.logical_id, '/', 1) || '/' || uc.context_id")} + AS canonical_id + ) context_identity + WHERE uc.user_id = $1 AND context_identity.canonical_id = requested.logical_id UNION ALL SELECT 'team', 'team_contexts:' || tc.team_id || ':' || tc.context_id, - tc.team_id, tm.role, 'context', tc.context_id - FROM team_contexts tc JOIN context_names requested ON requested.name = tc.context_id + tc.team_id, tm.role, 'context', context_identity.canonical_id + FROM team_contexts tc + JOIN context_references requested ON requested.reference_name = tc.context_id + CROSS JOIN LATERAL ( + SELECT ${canonicalContextLogicalIdSql('$4', "split_part(requested.logical_id, '/', 1) || '/' || tc.context_id")} + AS canonical_id + ) context_identity JOIN team_members tm ON tm.team_id = tc.team_id WHERE tm.user_id = $1 AND tm.status = 'active' + AND context_identity.canonical_id = requested.logical_id UNION ALL SELECT 'direct', 'user_agents:' || ua.user_id || ':' || ua.agent_name, NULL, NULL, 'host', ua.agent_name @@ -519,15 +573,21 @@ async function loadDerivedOperationalCandidates(input: { ) SELECT * FROM candidates ORDER BY kind, team_id NULLS FIRST, grant_id, source_type, source_name - LIMIT $4`, - [input.userId, contextNames, hostNames, input.budget.limits.accessPaths + 1] + LIMIT $5`, + [ + input.userId, + contextIds, + hostNames, + input.graph.resource.environmentId, + input.budget.limits.accessPaths + 1, + ] ) const output: AuthorityCandidate[] = [] for (const row of result.rows as Record[]) { const sourceType = String(row.source_type) const sourceName = String(row.source_name) if (sourceType === 'context') { - const contextId = contextIds.find(value => scopedName(value)?.name === sourceName) + const contextId = contextIds.find(value => value === sourceName) if (!contextId) continue for (const edge of directEdges.filter(value => value.sourceId === contextId)) { const filesystemScope = diff --git a/control-api/src/services/access/catalogHydrationSql.ts b/control-api/src/services/access/catalogHydrationSql.ts index 41350dcd19..bc9357851b 100644 --- a/control-api/src/services/access/catalogHydrationSql.ts +++ b/control-api/src/services/access/catalogHydrationSql.ts @@ -1,3 +1,4 @@ +import { canonicalContextLogicalIdSql } from './contextIdentitySql.js' import { workflowTriggerGrantIdentitySql } from './workflowTriggerGrantIdentity.js' const directWorkflowTriggerGrantId = workflowTriggerGrantIdentitySql('direct', 'access_grant') @@ -92,9 +93,12 @@ export const SIMPLE_OPERATIONAL_HYDRATION_SQL = ` NULL, NULL FROM resources resource JOIN user_contexts access_grant - ON $4::text = 'context' - AND resource.logical_id = $6::text || '/' || access_grant.context_id - WHERE access_grant.user_id = $1 + ON $4::text = 'context' AND access_grant.user_id = $1 + CROSS JOIN LATERAL ( + SELECT ${canonicalContextLogicalIdSql('$3', "$6::text || '/' || access_grant.context_id")} + AS canonical_id + ) context_identity + WHERE resource.logical_id = context_identity.canonical_id UNION ALL SELECT resource.logical_id, 'team', 'team_contexts:' || access_grant.team_id || ':' || access_grant.context_id, @@ -102,10 +106,14 @@ export const SIMPLE_OPERATIONAL_HYDRATION_SQL = ` FROM resources resource JOIN team_contexts access_grant ON $4::text = 'context' - AND resource.logical_id = $6::text || '/' || access_grant.context_id JOIN team_members membership ON membership.team_id = access_grant.team_id AND membership.user_id = $1 AND membership.status = 'active' + CROSS JOIN LATERAL ( + SELECT ${canonicalContextLogicalIdSql('$3', "$6::text || '/' || access_grant.context_id")} + AS canonical_id + ) context_identity + WHERE resource.logical_id = context_identity.canonical_id UNION ALL SELECT resource.logical_id, 'direct', ${directWorkflowTriggerGrantId}, @@ -164,7 +172,10 @@ export const SIMPLE_OPERATIONAL_HYDRATION_SQL = ` ), relationships AS MATERIALIZED ( SELECT DISTINCT relationship.source_id AS logical_id, relationship.source_id, relationship.relationship_type, relationship.target_type, - relationship.target_id, relationship.relationship_instance_id, + CASE WHEN relationship.relationship_type = 'uses_context' + THEN ${canonicalContextLogicalIdSql('$3', 'relationship.target_id')} + ELSE relationship.target_id + END AS target_id, relationship.relationship_instance_id, relationship.behavior_attributes, relationship.source_type, relationship.source_provider_uid, relationship.source_resource_version FROM operational_resource_relationships relationship @@ -174,8 +185,14 @@ export const SIMPLE_OPERATIONAL_HYDRATION_SQL = ` (relationship.source_type = resource.resource_type AND relationship.source_id = resource.logical_id) OR (relationship.target_type = resource.resource_type - AND relationship.target_id = resource.logical_id) + AND (CASE WHEN relationship.relationship_type = 'uses_context' + THEN ${canonicalContextLogicalIdSql('$3', 'relationship.target_id')} + ELSE relationship.target_id + END) = resource.logical_id) ) + WHERE relationship.relationship_type <> 'context_identity_alias' + AND (relationship.relationship_type <> 'uses_context' + OR ${canonicalContextLogicalIdSql('$3', 'relationship.target_id')} IS NOT NULL) ORDER BY logical_id, relationship_type, target_type, target_id, relationship_instance_id LIMIT $8 @@ -236,8 +253,17 @@ export const DERIVED_OPERATIONAL_HYDRATION_SQL = ` AND source_resource.deleted_at IS NULL WHERE relationship.source_type = 'context' ), host_edges AS MATERIALIZED ( - SELECT relationship.* + SELECT relationship.environment_id, relationship.source_type, + relationship.source_id, relationship.relationship_type, + relationship.target_type, context_identity.canonical_id AS target_id, + relationship.relationship_instance_id, relationship.behavior_attributes, + relationship.source_family, relationship.source_provider_uid, + relationship.source_resource_version, relationship.source_generation, + relationship.observed_generation, relationship.content_bytes FROM operational_resource_relationships relationship + CROSS JOIN LATERAL ( + SELECT ${canonicalContextLogicalIdSql('$3', 'relationship.target_id')} AS canonical_id + ) context_identity JOIN operational_resource_index host_resource ON host_resource.environment_id = relationship.environment_id AND host_resource.resource_type = 'host' @@ -247,7 +273,7 @@ export const DERIVED_OPERATIONAL_HYDRATION_SQL = ` JOIN operational_resource_index context_resource ON context_resource.environment_id = relationship.environment_id AND context_resource.resource_type = 'context' - AND context_resource.logical_id = relationship.target_id + AND context_resource.logical_id = context_identity.canonical_id AND context_resource.enabled = TRUE AND context_resource.deleted_at IS NULL WHERE $4::text = 'mcp_server' @@ -255,7 +281,7 @@ export const DERIVED_OPERATIONAL_HYDRATION_SQL = ` AND relationship.source_type = 'host' AND relationship.relationship_type = 'uses_context' AND relationship.target_type = 'context' - AND relationship.target_id IN (SELECT source_id FROM direct_edges) + AND context_identity.canonical_id IN (SELECT source_id FROM direct_edges) ), paths AS MATERIALIZED ( SELECT * FROM ( SELECT edge.target_id AS logical_id, 'direct'::text AS kind, @@ -269,7 +295,7 @@ export const DERIVED_OPERATIONAL_HYDRATION_SQL = ` NULL::text AS host_edge_instance, NULL::jsonb AS host_edge_behavior FROM direct_edges edge JOIN user_contexts access_grant - ON edge.source_id = $6::text || '/' || access_grant.context_id + ON edge.source_id = ${canonicalContextLogicalIdSql('$3', "split_part(edge.source_id, '/', 1) || '/' || access_grant.context_id")} WHERE access_grant.user_id = $1 UNION ALL SELECT edge.target_id, 'team', @@ -280,7 +306,7 @@ export const DERIVED_OPERATIONAL_HYDRATION_SQL = ` edge.behavior_attributes, NULL, NULL, NULL, NULL FROM direct_edges edge JOIN team_contexts access_grant - ON edge.source_id = $6::text || '/' || access_grant.context_id + ON edge.source_id = ${canonicalContextLogicalIdSql('$3', "split_part(edge.source_id, '/', 1) || '/' || access_grant.context_id")} JOIN team_members membership ON membership.team_id = access_grant.team_id AND membership.user_id = $1 AND membership.status = 'active' diff --git a/control-api/src/services/access/catalogProducerSql.ts b/control-api/src/services/access/catalogProducerSql.ts index 91ea2059e6..73beb1650e 100644 --- a/control-api/src/services/access/catalogProducerSql.ts +++ b/control-api/src/services/access/catalogProducerSql.ts @@ -1,5 +1,6 @@ import type { CatalogFamily } from './catalogContracts.js' import { boundedKeyUnionSql, catalogTextAfterSql } from './catalogProducerSupport.js' +import { canonicalContextLogicalIdSql } from './contextIdentitySql.js' export const CATALOG_KEY_SQL: Readonly> = Object.freeze({ user: boundedKeyUnionSql([ @@ -47,26 +48,38 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f ]), context: boundedKeyUnionSql([ { - sql: `SELECT $6::text || '/' || uc.context_id AS logical_id, uc.context_id AS source_key + sql: `SELECT context_identity.canonical_id AS logical_id, + context_identity.canonical_id AS source_key FROM user_contexts uc + CROSS JOIN LATERAL ( + SELECT ${canonicalContextLogicalIdSql('$3', "$6::text || '/' || uc.context_id")} + AS canonical_id + ) context_identity JOIN operational_resource_index resource ON resource.environment_id = $3 AND resource.resource_type = 'context' - AND resource.logical_id = $6::text || '/' || uc.context_id + AND resource.logical_id = context_identity.canonical_id AND resource.enabled = TRUE AND resource.deleted_at IS NULL - WHERE uc.user_id = $1 AND ${catalogTextAfterSql('uc.context_id', '$7')}`, - orderBy: 'source_key', + WHERE uc.user_id = $1 + AND ${catalogTextAfterSql('context_identity.canonical_id', '$7')}`, + orderBy: 'logical_id', + duplicateCapable: true, }, { - sql: `SELECT $6::text || '/' || tc.context_id AS logical_id, tc.context_id AS source_key + sql: `SELECT context_identity.canonical_id AS logical_id, + context_identity.canonical_id AS source_key FROM team_contexts tc + CROSS JOIN LATERAL ( + SELECT ${canonicalContextLogicalIdSql('$3', "$6::text || '/' || tc.context_id")} + AS canonical_id + ) context_identity JOIN team_members tm ON tm.team_id = tc.team_id JOIN operational_resource_index resource ON resource.environment_id = $3 AND resource.resource_type = 'context' - AND resource.logical_id = $6::text || '/' || tc.context_id + AND resource.logical_id = context_identity.canonical_id AND resource.enabled = TRUE AND resource.deleted_at IS NULL WHERE tm.user_id = $1 AND tm.status = 'active' - AND ${catalogTextAfterSql('tc.context_id', '$7')}`, - orderBy: 'source_key', + AND ${catalogTextAfterSql('context_identity.canonical_id', '$7')}`, + orderBy: 'logical_id', duplicateCapable: true, }, ]), @@ -74,15 +87,19 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f { sql: `SELECT edge.target_id AS logical_id FROM user_contexts uc + CROSS JOIN LATERAL ( + SELECT ${canonicalContextLogicalIdSql('$3', "$6::text || '/' || uc.context_id")} + AS canonical_id + ) context_identity JOIN operational_resource_relationships edge ON edge.environment_id = $3 AND edge.source_type = 'context' - AND edge.source_id = $6::text || '/' || uc.context_id + AND edge.source_id = context_identity.canonical_id AND edge.relationship_type = 'includes_mcp_server' AND edge.target_type = 'mcp_server' JOIN operational_resource_index source_resource ON source_resource.environment_id = $3 AND source_resource.resource_type = 'context' - AND source_resource.logical_id = edge.source_id + AND source_resource.logical_id = context_identity.canonical_id AND source_resource.enabled = TRUE AND source_resource.deleted_at IS NULL JOIN operational_resource_index target_resource ON target_resource.environment_id = $3 AND target_resource.resource_type = 'mcp_server' @@ -94,16 +111,20 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f { sql: `SELECT edge.target_id AS logical_id FROM team_contexts tc + CROSS JOIN LATERAL ( + SELECT ${canonicalContextLogicalIdSql('$3', "$6::text || '/' || tc.context_id")} + AS canonical_id + ) context_identity JOIN team_members tm ON tm.team_id = tc.team_id JOIN operational_resource_relationships edge ON edge.environment_id = $3 AND edge.source_type = 'context' - AND edge.source_id = $6::text || '/' || tc.context_id + AND edge.source_id = context_identity.canonical_id AND edge.relationship_type = 'includes_mcp_server' AND edge.target_type = 'mcp_server' JOIN operational_resource_index source_resource ON source_resource.environment_id = $3 AND source_resource.resource_type = 'context' - AND source_resource.logical_id = edge.source_id + AND source_resource.logical_id = context_identity.canonical_id AND source_resource.enabled = TRUE AND source_resource.deleted_at IS NULL JOIN operational_resource_index target_resource ON target_resource.environment_id = $3 AND target_resource.resource_type = 'mcp_server' @@ -119,13 +140,17 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f JOIN operational_resource_relationships host_edge ON host_edge.environment_id = $3 AND host_edge.source_type = 'host' - AND host_edge.source_id = $5::text || '/' || ua.agent_name + AND host_edge.source_id = $5::text || '/' || ua.agent_name AND host_edge.relationship_type = 'uses_context' AND host_edge.target_type = 'context' + CROSS JOIN LATERAL ( + SELECT ${canonicalContextLogicalIdSql('$3', 'host_edge.target_id')} + AS canonical_id + ) context_identity JOIN operational_resource_relationships mcp_edge ON mcp_edge.environment_id = $3 AND mcp_edge.source_type = 'context' - AND mcp_edge.source_id = host_edge.target_id + AND mcp_edge.source_id = context_identity.canonical_id AND mcp_edge.relationship_type = 'includes_mcp_server' AND mcp_edge.target_type = 'mcp_server' JOIN operational_resource_index host_resource @@ -134,7 +159,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND host_resource.enabled = TRUE AND host_resource.deleted_at IS NULL JOIN operational_resource_index context_resource ON context_resource.environment_id = $3 AND context_resource.resource_type = 'context' - AND context_resource.logical_id = host_edge.target_id + AND context_resource.logical_id = context_identity.canonical_id AND context_resource.enabled = TRUE AND context_resource.deleted_at IS NULL JOIN operational_resource_index target_resource ON target_resource.environment_id = $3 AND target_resource.resource_type = 'mcp_server' @@ -150,13 +175,17 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f JOIN operational_resource_relationships host_edge ON host_edge.environment_id = $3 AND host_edge.source_type = 'host' - AND host_edge.source_id = $5::text || '/' || ta.agent_name + AND host_edge.source_id = $5::text || '/' || ta.agent_name AND host_edge.relationship_type = 'uses_context' AND host_edge.target_type = 'context' + CROSS JOIN LATERAL ( + SELECT ${canonicalContextLogicalIdSql('$3', 'host_edge.target_id')} + AS canonical_id + ) context_identity JOIN operational_resource_relationships mcp_edge ON mcp_edge.environment_id = $3 AND mcp_edge.source_type = 'context' - AND mcp_edge.source_id = host_edge.target_id + AND mcp_edge.source_id = context_identity.canonical_id AND mcp_edge.relationship_type = 'includes_mcp_server' AND mcp_edge.target_type = 'mcp_server' JOIN operational_resource_index host_resource @@ -165,7 +194,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND host_resource.enabled = TRUE AND host_resource.deleted_at IS NULL JOIN operational_resource_index context_resource ON context_resource.environment_id = $3 AND context_resource.resource_type = 'context' - AND context_resource.logical_id = host_edge.target_id + AND context_resource.logical_id = context_identity.canonical_id AND context_resource.enabled = TRUE AND context_resource.deleted_at IS NULL JOIN operational_resource_index target_resource ON target_resource.environment_id = $3 AND target_resource.resource_type = 'mcp_server' @@ -325,15 +354,19 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f { sql: `SELECT edge.target_id AS logical_id FROM user_contexts uc + CROSS JOIN LATERAL ( + SELECT ${canonicalContextLogicalIdSql('$3', "$6::text || '/' || uc.context_id")} + AS canonical_id + ) context_identity JOIN operational_resource_relationships edge ON edge.environment_id = $3 AND edge.source_type = 'context' - AND edge.source_id = $6::text || '/' || uc.context_id + AND edge.source_id = context_identity.canonical_id AND edge.relationship_type = 'mounts_shared_filesystem' AND edge.target_type = 'shared_filesystem' JOIN operational_resource_index source_resource ON source_resource.environment_id = $3 AND source_resource.resource_type = 'context' - AND source_resource.logical_id = edge.source_id + AND source_resource.logical_id = context_identity.canonical_id AND source_resource.enabled = TRUE AND source_resource.deleted_at IS NULL JOIN operational_resource_index target_resource ON target_resource.environment_id = $3 @@ -346,16 +379,20 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f { sql: `SELECT edge.target_id AS logical_id FROM team_contexts tc + CROSS JOIN LATERAL ( + SELECT ${canonicalContextLogicalIdSql('$3', "$6::text || '/' || tc.context_id")} + AS canonical_id + ) context_identity JOIN team_members tm ON tm.team_id = tc.team_id JOIN operational_resource_relationships edge ON edge.environment_id = $3 AND edge.source_type = 'context' - AND edge.source_id = $6::text || '/' || tc.context_id + AND edge.source_id = context_identity.canonical_id AND edge.relationship_type = 'mounts_shared_filesystem' AND edge.target_type = 'shared_filesystem' JOIN operational_resource_index source_resource ON source_resource.environment_id = $3 AND source_resource.resource_type = 'context' - AND source_resource.logical_id = edge.source_id + AND source_resource.logical_id = context_identity.canonical_id AND source_resource.enabled = TRUE AND source_resource.deleted_at IS NULL JOIN operational_resource_index target_resource ON target_resource.environment_id = $3 diff --git a/control-api/src/services/access/contextIdentitySql.ts b/control-api/src/services/access/contextIdentitySql.ts new file mode 100644 index 0000000000..d21c987318 --- /dev/null +++ b/control-api/src/services/access/contextIdentitySql.ts @@ -0,0 +1,31 @@ +/** + * Resolve one namespace-qualified Context reference to its canonical metadata.name identity. + * Both arguments are static SQL fragments owned by the caller; request data remains in binds. + */ +export function canonicalContextLogicalIdSql( + environmentExpression: string, + referenceExpression: string +): string { + return `(SELECT MIN(identity_candidate.canonical_id) + FROM ( + SELECT canonical_resource.logical_id AS canonical_id + FROM operational_resource_index canonical_resource + WHERE canonical_resource.environment_id = ${environmentExpression} + AND canonical_resource.resource_type = 'context' + AND canonical_resource.logical_id = ${referenceExpression} + UNION ALL + SELECT alias_relationship.source_id AS canonical_id + FROM operational_resource_relationships alias_relationship + JOIN operational_resource_index alias_source + ON alias_source.environment_id = alias_relationship.environment_id + AND alias_source.resource_type = 'context' + AND alias_source.logical_id = alias_relationship.source_id + AND alias_source.provider_uid = alias_relationship.source_provider_uid + WHERE alias_relationship.environment_id = ${environmentExpression} + AND alias_relationship.source_type = 'context' + AND alias_relationship.relationship_type = 'context_identity_alias' + AND alias_relationship.target_type = 'context' + AND alias_relationship.target_id = ${referenceExpression} + ) identity_candidate + HAVING COUNT(DISTINCT identity_candidate.canonical_id) = 1)` +} diff --git a/control-api/src/services/access/operationalAccessProjection.ts b/control-api/src/services/access/operationalAccessProjection.ts index 223f2c8239..520929dd0d 100644 --- a/control-api/src/services/access/operationalAccessProjection.ts +++ b/control-api/src/services/access/operationalAccessProjection.ts @@ -33,6 +33,7 @@ export type OperationalRelationshipRecord = Readonly<{ sourceId: string relationshipType: | 'uses_context' + | 'context_identity_alias' | 'includes_mcp_server' | 'mounts_shared_filesystem' | 'exposes_sandbox_app' @@ -367,6 +368,20 @@ export function projectOperationalObject(input: ProjectionInput): OperationalObj ) } } else if (family === 'context') { + const contextId = optionalBoundedString(spec.contextId, 253) + if (contextId && contextId !== name) { + relationships.push( + relationship({ + ...common, + sourceType: 'context', + sourceId: rootId, + relationshipType: 'context_identity_alias', + targetType: 'context', + targetId: logicalId('context', namespace, contextId), + instanceParts: ['context', rootId, 'identity-alias', contextId], + }) + ) + } relationships.push( ...contextRelationships({ ...common, diff --git a/control-api/src/services/access/operationalAccessReader.ts b/control-api/src/services/access/operationalAccessReader.ts index 76e26f4434..252665a854 100644 --- a/control-api/src/services/access/operationalAccessReader.ts +++ b/control-api/src/services/access/operationalAccessReader.ts @@ -2,6 +2,7 @@ import type { DbClient } from '../../db.js' import { runAccessDatabaseQuery } from './accessDatabaseQuery.js' import type { AccessExecutionBudget } from './accessExecutionBudget.js' import { revisionOfValues } from './authorizationRevision.js' +import { canonicalContextLogicalIdSql } from './contextIdentitySql.js' import type { OperationalRelationshipRecord, OperationalResourceType, @@ -194,20 +195,33 @@ export async function loadOperationalResourceGraph(input: { const relationshipResult = await budgetedQuery( input.db, input.budget, - `SELECT environment_id, source_type, source_id, relationship_type, - target_type, target_id, relationship_instance_id, behavior_attributes, + `SELECT relationship.environment_id, relationship.source_type, + relationship.source_id, relationship.relationship_type, + relationship.target_type, + CASE WHEN relationship.relationship_type = 'uses_context' + THEN ${canonicalContextLogicalIdSql('$1', 'relationship.target_id')} + ELSE relationship.target_id + END AS target_id, + relationship.relationship_instance_id, relationship.behavior_attributes, source_family, source_provider_uid, source_resource_version, observed_generation, content_bytes - FROM operational_resource_relationships - WHERE environment_id = $1 + FROM operational_resource_relationships relationship + WHERE relationship.environment_id = $1 + AND relationship.relationship_type <> 'context_identity_alias' + AND (relationship.relationship_type <> 'uses_context' + OR ${canonicalContextLogicalIdSql('$1', 'relationship.target_id')} IS NOT NULL) AND ( - (source_type = $2 AND source_id = $3) - OR (target_type = $2 AND target_id = $3) + (relationship.source_type = $2 AND relationship.source_id = $3) + OR (relationship.target_type = $2 AND + (CASE WHEN relationship.relationship_type = 'uses_context' + THEN ${canonicalContextLogicalIdSql('$1', 'relationship.target_id')} + ELSE relationship.target_id + END) = $3) OR ( $2 = 'mcp_server' - AND relationship_type = 'uses_context' - AND target_type = 'context' - AND target_id IN ( + AND relationship.relationship_type = 'uses_context' + AND relationship.target_type = 'context' + AND ${canonicalContextLogicalIdSql('$1', 'relationship.target_id')} IN ( SELECT mcp_edge.source_id FROM operational_resource_relationships mcp_edge WHERE mcp_edge.environment_id = $1 @@ -217,8 +231,9 @@ export async function loadOperationalResourceGraph(input: { ) ) ) - ORDER BY source_type, source_id, relationship_type, - target_type, target_id, relationship_instance_id + ORDER BY relationship.source_type, relationship.source_id, + relationship.relationship_type, relationship.target_type, + target_id, relationship.relationship_instance_id LIMIT $4`, [ input.environmentId, diff --git a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts index c1109420af..9e8bd7afb1 100644 --- a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts @@ -438,6 +438,306 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { }) } + it('resolves producer Context aliases to one canonical catalog and authority identity', async () => { + const suffix = randomBytes(5).toString('hex') + const contextName = `r55-m7-context-${suffix}` + const contextAlias = `r55-m7-wire-${suffix}` + const hostName = `r55-m7-host-${suffix}` + const mcpName = `r55-m7-mcp-${suffix}` + const sharedFilesystemName = `r55-m7-files-${suffix}` + const duplicateAliasName = `r55-m7-duplicate-${suffix}` + const nextContextAlias = `r55-m7-next-wire-${suffix}` + const contextUid = randomUUID() + const contextSource = operationalSourceSpecs.find(value => value.family === 'context')! + const hostSource = operationalSourceSpecs.find(value => value.family === 'host')! + const mcpSource = operationalSourceSpecs.find(value => value.family === 'mcp_server')! + const sharedFilesystemSource = operationalSourceSpecs.find( + value => value.family === 'shared_filesystem' + )! + let contextObject = fixture({ + plural: 'contexts', + namespace: config.contextsNamespace, + name: contextName, + uid: contextUid, + spec: { + contextId: contextAlias, + mcpServers: [mcpName], + sharedFileSystems: [{ name: sharedFilesystemName, mountPath: '/workspace/m7' }], + }, + }) + let hostObject = fixture({ + plural: 'hosts', + namespace: config.hostsNamespace, + name: hostName, + uid: randomUUID(), + spec: { contextRef: contextAlias }, + }) + const mcpObject = fixture({ + plural: 'mcpservers', + namespace: config.mcpServersNamespace, + name: mcpName, + uid: randomUUID(), + }) + const sharedFilesystemObject = fixture({ + plural: 'sharedfilesystems', + namespace: config.sharedFilesystemsNamespace, + name: sharedFilesystemName, + uid: randomUUID(), + }) + kubernetesApi.put(contextObject.plural, contextObject.namespace, contextObject.object) + kubernetesApi.put(hostObject.plural, hostObject.namespace, hostObject.object) + kubernetesApi.put(mcpObject.plural, mcpObject.namespace, mcpObject.object) + kubernetesApi.put( + sharedFilesystemObject.plural, + sharedFilesystemObject.namespace, + sharedFilesystemObject.object + ) + await indexer.reconcileSource(contextSource) + await indexer.reconcileSource(hostSource) + await indexer.reconcileSource(mcpSource) + await indexer.reconcileSource(sharedFilesystemSource) + await databasePool.query(`INSERT INTO user_contexts(user_id, context_id) VALUES ($1, $2)`, [ + userId, + contextAlias, + ]) + await databasePool.query(`INSERT INTO user_agents(user_id, agent_name) VALUES ($1, $2)`, [ + userId, + hostName, + ]) + + try { + const canonicalContextId = `${config.contextsNamespace}/${contextName}` + const contextCatalog = await buildAccessCatalog( + { session, families: ['context'], limit: 100 }, + { transaction: transaction(databasePool) } + ) + const contextItem = contextCatalog.items.find( + item => item.resource.logicalId === canonicalContextId + ) + expect(contextCatalog.complete).toBe(true) + expect(contextItem?.resource.logicalId).toBe(canonicalContextId) + expect(contextItem?.resource.providerUid).toBe(contextUid) + expect(contextItem?.accessPaths).toHaveLength(1) + expect(contextItem?.relationships).not.toContainEqual( + expect.objectContaining({ type: 'context_identity_alias' }) + ) + + const mcpCatalog = await buildAccessCatalog( + { session, families: ['mcp_server'], limit: 100 }, + { transaction: transaction(databasePool) } + ) + const mcpItem = mcpCatalog.items.find( + item => item.resource.logicalId === `${config.mcpServersNamespace}/${mcpName}` + ) + expect(mcpCatalog.complete).toBe(true) + expect(mcpItem?.accessPaths).toHaveLength(2) + for (const path of mcpItem!.accessPaths) { + await expect( + resolveLiveAuthorization( + { + session, + requiredCapability: 'mcp_server.read', + resource: canonicalResourceIdentity(mcpItem!.resource), + requestedAccessPathId: path.accessPathId, + }, + { transaction: transaction(databasePool), gateway } + ) + ).resolves.toEqual( + expect.objectContaining({ + status: 'allowed', + selectedPath: expect.objectContaining({ id: path.accessPathId }), + }) + ) + } + + const sharedFilesystemCatalog = await buildAccessCatalog( + { session, families: ['shared_filesystem'], limit: 100 }, + { transaction: transaction(databasePool) } + ) + const sharedFilesystemItem = sharedFilesystemCatalog.items.find( + item => + item.resource.logicalId === `${config.sharedFilesystemsNamespace}/${sharedFilesystemName}` + ) + expect(sharedFilesystemCatalog.complete).toBe(true) + expect(sharedFilesystemItem?.accessPaths).toHaveLength(1) + await expect( + resolveLiveAuthorization( + { + session, + requiredCapability: 'shared_filesystem.read', + resource: canonicalResourceIdentity(sharedFilesystemItem!.resource), + requestedAccessPathId: sharedFilesystemItem!.accessPaths[0].accessPathId, + }, + { transaction: transaction(databasePool), gateway } + ) + ).resolves.toEqual( + expect.objectContaining({ + status: 'allowed', + selectedPath: expect.objectContaining({ + id: sharedFilesystemItem!.accessPaths[0].accessPathId, + }), + }) + ) + + const duplicateAliasObject = fixture({ + plural: 'contexts', + namespace: config.contextsNamespace, + name: duplicateAliasName, + uid: randomUUID(), + spec: { contextId: contextAlias, mcpServers: [], sharedFileSystems: [] }, + }) + kubernetesApi.put( + duplicateAliasObject.plural, + duplicateAliasObject.namespace, + duplicateAliasObject.object + ) + await indexer.reconcileSource(contextSource) + const duplicateAliasCatalog = await buildAccessCatalog( + { session, families: ['context'], limit: 100 }, + { transaction: transaction(databasePool) } + ) + expect(duplicateAliasCatalog.items).toHaveLength(0) + const duplicateAliasMcpCatalog = await buildAccessCatalog( + { session, families: ['mcp_server'], limit: 100 }, + { transaction: transaction(databasePool) } + ) + expect( + duplicateAliasMcpCatalog.items.find( + item => item.resource.logicalId === `${config.mcpServersNamespace}/${mcpName}` + )?.accessPaths ?? [] + ).toHaveLength(0) + kubernetesApi.delete( + duplicateAliasObject.plural, + duplicateAliasObject.namespace, + duplicateAliasName + ) + await indexer.reconcileSource(contextSource) + + const nameCollisionObject = fixture({ + plural: 'contexts', + namespace: config.contextsNamespace, + name: contextAlias, + uid: randomUUID(), + spec: { mcpServers: [], sharedFileSystems: [] }, + }) + kubernetesApi.put( + nameCollisionObject.plural, + nameCollisionObject.namespace, + nameCollisionObject.object + ) + await indexer.reconcileSource(contextSource) + const nameCollisionCatalog = await buildAccessCatalog( + { session, families: ['context'], limit: 100 }, + { transaction: transaction(databasePool) } + ) + expect(nameCollisionCatalog.items).toHaveLength(0) + kubernetesApi.delete(nameCollisionObject.plural, nameCollisionObject.namespace, contextAlias) + await indexer.reconcileSource(contextSource) + + contextObject = fixture({ + plural: 'contexts', + namespace: config.contextsNamespace, + name: contextName, + uid: contextUid, + spec: { + contextId: nextContextAlias, + mcpServers: [mcpName], + sharedFileSystems: [{ name: sharedFilesystemName, mountPath: '/workspace/m7' }], + }, + }) + kubernetesApi.put(contextObject.plural, contextObject.namespace, contextObject.object) + await indexer.reconcileSource(contextSource) + const staleAliasCatalog = await buildAccessCatalog( + { session, families: ['context'], limit: 100 }, + { transaction: transaction(databasePool) } + ) + expect(staleAliasCatalog.items).toHaveLength(0) + + await databasePool.query( + `UPDATE user_contexts SET context_id = $3 WHERE user_id = $1 AND context_id = $2`, + [userId, contextAlias, nextContextAlias] + ) + hostObject = fixture({ + plural: 'hosts', + namespace: config.hostsNamespace, + name: hostName, + uid: String((hostObject.object.metadata as Record).uid), + spec: { contextRef: nextContextAlias }, + }) + kubernetesApi.put(hostObject.plural, hostObject.namespace, hostObject.object) + await indexer.reconcileSource(hostSource) + const refreshedCatalog = await buildAccessCatalog( + { session, families: ['context'], limit: 100 }, + { transaction: transaction(databasePool) } + ) + const refreshedContext = refreshedCatalog.items.find( + item => item.resource.logicalId === canonicalContextId + ) + expect(refreshedContext?.accessPaths).toHaveLength(1) + + const oldContextPathId = refreshedContext!.accessPaths[0].accessPathId + const recreatedUid = randomUUID() + contextObject = fixture({ + plural: 'contexts', + namespace: config.contextsNamespace, + name: contextName, + uid: recreatedUid, + spec: { + contextId: nextContextAlias, + mcpServers: [mcpName], + sharedFileSystems: [{ name: sharedFilesystemName, mountPath: '/workspace/m7' }], + }, + }) + kubernetesApi.put(contextObject.plural, contextObject.namespace, contextObject.object) + await indexer.reconcileSource(contextSource) + const recreatedCatalog = await buildAccessCatalog( + { session, families: ['context'], limit: 100 }, + { transaction: transaction(databasePool) } + ) + const recreatedContext = recreatedCatalog.items.find( + item => item.resource.logicalId === canonicalContextId + ) + expect(recreatedContext?.resource.providerUid).toBe(recreatedUid) + expect(recreatedContext?.accessPaths.map(path => path.accessPathId)).not.toContain( + oldContextPathId + ) + await expect( + resolveLiveAuthorization( + { + session, + requiredCapability: 'context.read', + resource: canonicalResourceIdentity(refreshedContext!.resource), + requestedAccessPathId: oldContextPathId, + }, + { transaction: transaction(databasePool), gateway } + ) + ).resolves.toEqual(expect.objectContaining({ status: 'access_path_stale' })) + } finally { + await databasePool.query( + `DELETE FROM user_contexts WHERE user_id = $1 AND context_id = ANY($2::text[])`, + [userId, [contextAlias, nextContextAlias]] + ) + await databasePool.query(`DELETE FROM user_agents WHERE user_id = $1 AND agent_name = $2`, [ + userId, + hostName, + ]) + kubernetesApi.delete(hostObject.plural, hostObject.namespace, hostName) + kubernetesApi.delete(contextObject.plural, contextObject.namespace, contextName) + kubernetesApi.delete('contexts', config.contextsNamespace, duplicateAliasName) + kubernetesApi.delete('contexts', config.contextsNamespace, contextAlias) + kubernetesApi.delete(mcpObject.plural, mcpObject.namespace, mcpName) + kubernetesApi.delete( + sharedFilesystemObject.plural, + sharedFilesystemObject.namespace, + sharedFilesystemName + ) + await indexer.reconcileSource(hostSource) + await indexer.reconcileSource(contextSource) + await indexer.reconcileSource(mcpSource) + await indexer.reconcileSource(sharedFilesystemSource) + } + }) + it('excludes an ineligible sibling Host from selected MCP hydration', async () => { const hostSource = operationalSourceSpecs.find(value => value.family === 'host')! const disabledHost = fixture({ diff --git a/control-api/test/accessLiveAuthorization.test.ts b/control-api/test/accessLiveAuthorization.test.ts index 19923f4050..4704b75c41 100644 --- a/control-api/test/accessLiveAuthorization.test.ts +++ b/control-api/test/accessLiveAuthorization.test.ts @@ -110,6 +110,20 @@ function fakeTransaction(options: FakeDbOptions = {}) { rowCount: 1, } } + if (text.includes('FROM operational_resource_relationships relationship')) { + return { rows: [], rowCount: 0 } + } + if (text.includes('SELECT * FROM candidates')) { + const rows = options.hostGrantRows ?? [ + { + kind: 'direct', + grant_id: `user_agents:${userId}:host-a`, + team_id: null, + current_role: null, + }, + ] + return { rows, rowCount: rows.length } + } if (text.includes('FROM operational_resource_index')) { return { rows: [ @@ -133,17 +147,6 @@ function fakeTransaction(options: FakeDbOptions = {}) { if (text.includes('FROM operational_resource_relationships')) { return { rows: [], rowCount: 0 } } - if (text.includes('WITH candidates AS')) { - const rows = options.hostGrantRows ?? [ - { - kind: 'direct', - grant_id: `user_agents:${userId}:host-a`, - team_id: null, - current_role: null, - }, - ] - return { rows, rowCount: rows.length } - } throw new Error(`Unexpected query: ${text.slice(0, 80)}`) }) const transaction = async (work: (db: DbClient) => Promise) => diff --git a/control-api/test/operationalAccessProjection.test.ts b/control-api/test/operationalAccessProjection.test.ts index 13d35e638b..88d2eab54b 100644 --- a/control-api/test/operationalAccessProjection.test.ts +++ b/control-api/test/operationalAccessProjection.test.ts @@ -59,6 +59,39 @@ describe('operational access projection', () => { ) }) + it('projects a supported Context alias without changing canonical identity', () => { + const projection = projectOperationalObject({ + environmentId: 'test:cluster', + plural: 'contexts', + namespace: 'contexts', + object: { + ...contextObject([]), + metadata: { + ...contextObject([]).metadata, + name: 'ctx-resource', + }, + spec: { + contextId: 'ctx-wire', + mcpServers: [], + sharedFileSystems: [], + }, + }, + behaviorFingerprintKey: 'test-key', + relationshipNamespaces: namespaces, + }) + + expect(projection.rootId).toBe('contexts/ctx-resource') + expect(projection.relationships).toContainEqual( + expect.objectContaining({ + sourceType: 'context', + sourceId: 'contexts/ctx-resource', + relationshipType: 'context_identity_alias', + targetType: 'context', + targetId: 'contexts/ctx-wire', + }) + ) + }) + it('preserves repeated filesystem relationship instances with different mount scopes', () => { const projection = projectOperationalObject({ environmentId: 'test:cluster', From 0f3a2f6ec625647789b87c6744f9b54fe19ad4ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:27:28 -0600 Subject: [PATCH 272/528] fix(control-api): r55-m7 preserve bounded Context alias hydration Finding: R55-M7 / RP-PROJ-2 Class-check: Context canonical identity -> simple hydration, derived grants, two keyset producers, graph reader; aliases remain collision-checked. Repro-test: accessCatalogPerformance.realPostgres.integration.test.ts (RED before query rewrite on the f40fedd-based correction worktree). --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../services/access/catalogHydrationSql.ts | 129 +++++++++++++++--- .../src/services/access/catalogProducerSql.ts | 4 +- .../access/operationalAccessReader.ts | 1 + ...gFamilies.realPostgres.integration.test.ts | 17 ++- 6 files changed, 126 insertions(+), 31 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 923190604c..898b45f247 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.717", + "version": "0.1.718", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.717", + "version": "0.1.718", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 81522f6fbb..a187bad9b4 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.717", + "version": "0.1.718", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/access/catalogHydrationSql.ts b/control-api/src/services/access/catalogHydrationSql.ts index bc9357851b..b520bfd9c6 100644 --- a/control-api/src/services/access/catalogHydrationSql.ts +++ b/control-api/src/services/access/catalogHydrationSql.ts @@ -169,30 +169,114 @@ export const SIMPLE_OPERATIONAL_HYDRATION_SQL = ` ) all_paths ORDER BY logical_id, kind, team_id NULLS FIRST, grant_id LIMIT $7 + ), outgoing_relationships AS MATERIALIZED ( + SELECT relationship.* + FROM operational_resource_relationships relationship + JOIN resources resource + ON relationship.environment_id = $3 + AND relationship.source_type = resource.resource_type + AND relationship.source_id = resource.logical_id + WHERE relationship.relationship_type <> 'context_identity_alias' + ), context_reference_ids AS MATERIALIZED ( + SELECT resource.logical_id AS reference_id + FROM resources resource + WHERE resource.resource_type = 'context' + UNION + SELECT alias_relationship.target_id AS reference_id + FROM resources resource + JOIN operational_resource_relationships alias_relationship + ON alias_relationship.environment_id = $3 + AND alias_relationship.source_type = 'context' + AND alias_relationship.source_id = resource.logical_id + AND alias_relationship.relationship_type = 'context_identity_alias' + AND alias_relationship.target_type = 'context' + JOIN operational_resource_index alias_source + ON alias_source.environment_id = alias_relationship.environment_id + AND alias_source.resource_type = 'context' + AND alias_source.logical_id = alias_relationship.source_id + AND alias_source.provider_uid = alias_relationship.source_provider_uid + WHERE resource.resource_type = 'context' + UNION + SELECT relationship.target_id AS reference_id + FROM outgoing_relationships relationship + WHERE relationship.relationship_type = 'uses_context' + ), context_identity_candidates AS MATERIALIZED ( + SELECT reference.reference_id, canonical_resource.logical_id AS canonical_id + FROM context_reference_ids reference + JOIN operational_resource_index canonical_resource + ON canonical_resource.environment_id = $3 + AND canonical_resource.resource_type = 'context' + AND canonical_resource.logical_id = reference.reference_id + UNION ALL + SELECT reference.reference_id, alias_relationship.source_id AS canonical_id + FROM context_reference_ids reference + JOIN operational_resource_relationships alias_relationship + ON alias_relationship.environment_id = $3 + AND alias_relationship.source_type = 'context' + AND alias_relationship.relationship_type = 'context_identity_alias' + AND alias_relationship.target_type = 'context' + AND alias_relationship.target_id = reference.reference_id + JOIN operational_resource_index alias_source + ON alias_source.environment_id = alias_relationship.environment_id + AND alias_source.resource_type = 'context' + AND alias_source.logical_id = alias_relationship.source_id + AND alias_source.provider_uid = alias_relationship.source_provider_uid + ), context_identities AS MATERIALIZED ( + SELECT candidate.reference_id, MIN(candidate.canonical_id) AS canonical_id + FROM context_identity_candidates candidate + GROUP BY candidate.reference_id + HAVING COUNT(DISTINCT candidate.canonical_id) = 1 ), relationships AS MATERIALIZED ( - SELECT DISTINCT relationship.source_id AS logical_id, relationship.source_id, + SELECT DISTINCT relationship.logical_id, relationship.source_id, relationship.relationship_type, relationship.target_type, - CASE WHEN relationship.relationship_type = 'uses_context' - THEN ${canonicalContextLogicalIdSql('$3', 'relationship.target_id')} - ELSE relationship.target_id - END AS target_id, relationship.relationship_instance_id, + relationship.target_id, relationship.relationship_instance_id, relationship.behavior_attributes, relationship.source_type, relationship.source_provider_uid, relationship.source_resource_version - FROM operational_resource_relationships relationship - JOIN resources resource - ON relationship.environment_id = $3 - AND ( - (relationship.source_type = resource.resource_type - AND relationship.source_id = resource.logical_id) - OR (relationship.target_type = resource.resource_type - AND (CASE WHEN relationship.relationship_type = 'uses_context' - THEN ${canonicalContextLogicalIdSql('$3', 'relationship.target_id')} + FROM ( + SELECT relationship.source_id AS logical_id, relationship.source_id, + relationship.relationship_type, relationship.target_type, + CASE WHEN relationship.relationship_type = 'uses_context' + THEN context_identity.canonical_id ELSE relationship.target_id - END) = resource.logical_id) - ) - WHERE relationship.relationship_type <> 'context_identity_alias' - AND (relationship.relationship_type <> 'uses_context' - OR ${canonicalContextLogicalIdSql('$3', 'relationship.target_id')} IS NOT NULL) + END AS target_id, relationship.relationship_instance_id, + relationship.behavior_attributes, relationship.source_type, + relationship.source_provider_uid, relationship.source_resource_version + FROM outgoing_relationships relationship + LEFT JOIN context_identities context_identity + ON relationship.relationship_type = 'uses_context' + AND context_identity.reference_id = relationship.target_id + WHERE relationship.relationship_type <> 'uses_context' + OR context_identity.canonical_id IS NOT NULL + UNION ALL + SELECT relationship.source_id AS logical_id, relationship.source_id, + relationship.relationship_type, relationship.target_type, + relationship.target_id, relationship.relationship_instance_id, + relationship.behavior_attributes, relationship.source_type, + relationship.source_provider_uid, relationship.source_resource_version + FROM operational_resource_relationships relationship + JOIN resources resource + ON relationship.environment_id = $3 + AND relationship.target_type = resource.resource_type + AND relationship.target_id = resource.logical_id + WHERE relationship.relationship_type <> 'context_identity_alias' + AND relationship.relationship_type <> 'uses_context' + UNION ALL + SELECT relationship.source_id AS logical_id, relationship.source_id, + relationship.relationship_type, relationship.target_type, + context_identity.canonical_id AS target_id, + relationship.relationship_instance_id, + relationship.behavior_attributes, relationship.source_type, + relationship.source_provider_uid, relationship.source_resource_version + FROM context_identities context_identity + JOIN operational_resource_relationships relationship + ON relationship.environment_id = $3 + AND relationship.target_type = 'context' + AND relationship.relationship_type = 'uses_context' + AND relationship.target_id = context_identity.reference_id + JOIN resources resource + ON resource.resource_type = 'context' + AND resource.logical_id = context_identity.canonical_id + ) relationship ORDER BY logical_id, relationship_type, target_type, target_id, relationship_instance_id LIMIT $8 @@ -259,7 +343,8 @@ export const DERIVED_OPERATIONAL_HYDRATION_SQL = ` relationship.relationship_instance_id, relationship.behavior_attributes, relationship.source_family, relationship.source_provider_uid, relationship.source_resource_version, relationship.source_generation, - relationship.observed_generation, relationship.content_bytes + relationship.observed_generation, relationship.content_bytes, + relationship.observed_at FROM operational_resource_relationships relationship CROSS JOIN LATERAL ( SELECT ${canonicalContextLogicalIdSql('$3', 'relationship.target_id')} AS canonical_id @@ -295,7 +380,7 @@ export const DERIVED_OPERATIONAL_HYDRATION_SQL = ` NULL::text AS host_edge_instance, NULL::jsonb AS host_edge_behavior FROM direct_edges edge JOIN user_contexts access_grant - ON edge.source_id = ${canonicalContextLogicalIdSql('$3', "split_part(edge.source_id, '/', 1) || '/' || access_grant.context_id")} + ON edge.source_id = ${canonicalContextLogicalIdSql('$3', "$6::text || '/' || access_grant.context_id")} WHERE access_grant.user_id = $1 UNION ALL SELECT edge.target_id, 'team', @@ -306,7 +391,7 @@ export const DERIVED_OPERATIONAL_HYDRATION_SQL = ` edge.behavior_attributes, NULL, NULL, NULL, NULL FROM direct_edges edge JOIN team_contexts access_grant - ON edge.source_id = ${canonicalContextLogicalIdSql('$3', "split_part(edge.source_id, '/', 1) || '/' || access_grant.context_id")} + ON edge.source_id = ${canonicalContextLogicalIdSql('$3', "$6::text || '/' || access_grant.context_id")} JOIN team_members membership ON membership.team_id = access_grant.team_id AND membership.user_id = $1 AND membership.status = 'active' diff --git a/control-api/src/services/access/catalogProducerSql.ts b/control-api/src/services/access/catalogProducerSql.ts index 73beb1650e..6230a270d9 100644 --- a/control-api/src/services/access/catalogProducerSql.ts +++ b/control-api/src/services/access/catalogProducerSql.ts @@ -60,7 +60,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND resource.logical_id = context_identity.canonical_id AND resource.enabled = TRUE AND resource.deleted_at IS NULL WHERE uc.user_id = $1 - AND ${catalogTextAfterSql('context_identity.canonical_id', '$7')}`, + AND ${catalogTextAfterSql("SPLIT_PART(context_identity.canonical_id, '/', 2)", '$7')}`, orderBy: 'logical_id', duplicateCapable: true, }, @@ -78,7 +78,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND resource.logical_id = context_identity.canonical_id AND resource.enabled = TRUE AND resource.deleted_at IS NULL WHERE tm.user_id = $1 AND tm.status = 'active' - AND ${catalogTextAfterSql('context_identity.canonical_id', '$7')}`, + AND ${catalogTextAfterSql("SPLIT_PART(context_identity.canonical_id, '/', 2)", '$7')}`, orderBy: 'logical_id', duplicateCapable: true, }, diff --git a/control-api/src/services/access/operationalAccessReader.ts b/control-api/src/services/access/operationalAccessReader.ts index 252665a854..38f8f1193c 100644 --- a/control-api/src/services/access/operationalAccessReader.ts +++ b/control-api/src/services/access/operationalAccessReader.ts @@ -219,6 +219,7 @@ export async function loadOperationalResourceGraph(input: { END) = $3) OR ( $2 = 'mcp_server' + AND relationship.source_type = 'host' AND relationship.relationship_type = 'uses_context' AND relationship.target_type = 'context' AND ${canonicalContextLogicalIdSql('$1', 'relationship.target_id')} IN ( diff --git a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts index 9e8bd7afb1..2c38f4164d 100644 --- a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts @@ -200,6 +200,7 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { let databasePool: Pool let gateway: K8sGateway let indexer: OperationalAccessIndexer + let initialWireRequestCount = 0 beforeAll(async () => { adminPool = new Pool({ connectionString: adminUrl }) @@ -324,6 +325,7 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { for (const source of operationalSourceSpecs) { await indexer.reconcileSource(source) } + initialWireRequestCount = kubernetesApi.requests.length }) afterAll(async () => { @@ -596,7 +598,9 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { { session, families: ['context'], limit: 100 }, { transaction: transaction(databasePool) } ) - expect(duplicateAliasCatalog.items).toHaveLength(0) + expect( + duplicateAliasCatalog.items.some(item => item.resource.logicalId === canonicalContextId) + ).toBe(false) const duplicateAliasMcpCatalog = await buildAccessCatalog( { session, families: ['mcp_server'], limit: 100 }, { transaction: transaction(databasePool) } @@ -630,7 +634,9 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { { session, families: ['context'], limit: 100 }, { transaction: transaction(databasePool) } ) - expect(nameCollisionCatalog.items).toHaveLength(0) + expect( + nameCollisionCatalog.items.some(item => item.resource.logicalId === canonicalContextId) + ).toBe(false) kubernetesApi.delete(nameCollisionObject.plural, nameCollisionObject.namespace, contextAlias) await indexer.reconcileSource(contextSource) @@ -651,7 +657,9 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { { session, families: ['context'], limit: 100 }, { transaction: transaction(databasePool) } ) - expect(staleAliasCatalog.items).toHaveLength(0) + expect( + staleAliasCatalog.items.some(item => item.resource.logicalId === canonicalContextId) + ).toBe(false) await databasePool.query( `UPDATE user_contexts SET context_id = $3 WHERE user_id = $1 AND context_id = $2`, @@ -1060,7 +1068,8 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { }) it('uses real Kubernetes list and exact-read wire boundaries', async () => { - const listRequests = kubernetesApi.requests.filter(request => !request.watch && !request.name) + const initialWireRequests = kubernetesApi.requests.slice(0, initialWireRequestCount) + const listRequests = initialWireRequests.filter(request => !request.watch && !request.name) expect(listRequests).toHaveLength(operationalSourceSpecs.length) expect(listRequests.map(request => `${request.namespace}/${request.plural}`).sort()).toEqual( operationalSourceSpecs.map(source => `${source.namespace}/${source.plural}`).sort() From 963b55b5f7287fde9e4c16255c5dd90b16e0a60d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:31:25 -0600 Subject: [PATCH 273/528] fix(control-api): r55-m2 replay the repaired migration prefix Finding: R55-M2 / RP-MIG-3 Class-check: migration prefix replay receipts -> migration policy and previous-image compatibility fixtures -> both 0138 companion receipt and initDb path covered. Repro-test: migrationExecutionPolicy.realPostgres.integration.test.ts (5 migration replay/prefix assertions fail at 0f3a2f6ec). --- control-api/package-lock.json | 4 +-- control-api/package.json | 2 +- ...ionPolicy.realPostgres.integration.test.ts | 27 ++++++++++--------- ...atibility.realPostgres.integration.test.ts | 2 +- 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 898b45f247..04738eb97c 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.718", + "version": "0.1.719", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.718", + "version": "0.1.719", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index a187bad9b4..4352eb5b38 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.718", + "version": "0.1.719", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts index 917d7eb72e..9156249c45 100644 --- a/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts +++ b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts @@ -52,6 +52,16 @@ async function versions(pool: Pool): Promise { return result.rows.map(row => row.version) } +async function removeMigrationReceiptForReplay(pool: Pool, version: string): Promise { + const versionsToRemove = + version === '0125_user_access_foundation' + ? [version, '0138_authorization_revision_delete_compatibility'] + : [version] + await pool.query('DELETE FROM schema_migrations WHERE version = ANY($1::text[])', [ + versionsToRemove, + ]) +} + describeRealPostgres('D34 migration execution on real PostgreSQL', () => { const database = `control_api_d34_${randomBytes(6).toString('hex')}` const connectionString = databaseUrl( @@ -110,9 +120,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { 'SELECT $1::regclass::oid::text AS oid', [entry.name] ) - await databasePool.query('DELETE FROM schema_migrations WHERE version = $1', [ - entry.migrationVersion, - ]) + await removeMigrationReceiptForReplay(databasePool, entry.migrationVersion) await initDb({ connect: () => databasePool.connect() }) @@ -132,9 +140,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { 'SELECT $1::regclass::oid::text AS oid', [entry.name] ) - await databasePool.query('DELETE FROM schema_migrations WHERE version = $1', [ - entry.migrationVersion, - ]) + await removeMigrationReceiptForReplay(databasePool, entry.migrationVersion) await initDb({ connect: () => databasePool.connect() }) @@ -307,9 +313,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { const entry = PR1_ONLINE_INDEX_PLAN[0]! await databasePool.query(`DROP INDEX ${entry.name}`) await databasePool.query(`CREATE INDEX ${entry.name} ON team_members (team_id)`) - await databasePool.query('DELETE FROM schema_migrations WHERE version = $1', [ - entry.migrationVersion, - ]) + await removeMigrationReceiptForReplay(databasePool, entry.migrationVersion) await expect(initDb({ connect: () => databasePool.connect() })).rejects.toThrow( `Non-equivalent existing index: ${entry.name}` @@ -409,6 +413,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { const appliedVersions = new Set([ ...DEV_POST_0106_MIGRATION_VERSIONS, ...PR1_MIGRATION_VERSIONS.slice(0, 3), + '0138_authorization_revision_delete_compatibility', ]) const recordTable = `d34_record_${randomBytes(4).toString('hex')}` await client.query(`CREATE TEMP TABLE ${recordTable}(version text PRIMARY KEY)`) @@ -456,9 +461,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { 'SELECT $1::regclass::oid::text AS oid', [entry.name] ) - await databasePool.query('DELETE FROM schema_migrations WHERE version = $1', [ - entry.migrationVersion, - ]) + await removeMigrationReceiptForReplay(databasePool, entry.migrationVersion) await databasePool.query( `INSERT INTO schema_migrations(version) VALUES ('0101_user_access_foundation') diff --git a/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts b/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts index d5a339383c..ae668f1806 100644 --- a/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts +++ b/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts @@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { createHash, randomBytes, randomUUID } from 'node:crypto' import { readFileSync } from 'node:fs' import { Pool, type PoolClient } from 'pg' +import { initDb } from '../src/db.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -49,7 +50,6 @@ describeRealPostgres('D34 previous-image writer compatibility on real PostgreSQL adminPool = new Pool({ connectionString: adminUrl }) await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) databasePool = new Pool({ connectionString }) - const { initDb } = await import('../src/db.js') await initDb({ connect: () => databasePool.connect() }) }) From 58ee105e7f8f0b75b85ba9b1dcae6acb98aa9ad4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:31:52 -0600 Subject: [PATCH 274/528] fix(control-api): r55-m9 exercise shadow budget in RealPG Finding: R55-M9 / RP-CAT-1 Class-check: shadow comparison child reservation -> one accessCatalogShadow reservation; producer and SQL statement budgets remain separate. Repro-test: accessCatalogShadowCapacity.test.ts was RED at 474ee34a; RealPG fixture at 963b55b5f failed setup before exercising COMMIT accounting. --- control-api/package-lock.json | 4 +-- control-api/package.json | 2 +- ...logShadow.realPostgres.integration.test.ts | 27 +++++++++++++++---- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 04738eb97c..7d9eca218c 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.719", + "version": "0.1.720", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.719", + "version": "0.1.720", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 4352eb5b38..1f7e743ce8 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.719", + "version": "0.1.720", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/accessCatalogShadow.realPostgres.integration.test.ts b/control-api/test/accessCatalogShadow.realPostgres.integration.test.ts index 6c5c3467fb..80e6231953 100644 --- a/control-api/test/accessCatalogShadow.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogShadow.realPostgres.integration.test.ts @@ -4,7 +4,10 @@ import { Pool } from 'pg' import { type DbClient, initDb } from '../src/db.js' import { buildAccessCatalog } from '../src/services/access/accessCatalogCoordinator.js' import { compareAccessCatalogShadow } from '../src/services/access/accessCatalogShadow.js' -import { withAccessDatabaseTransaction } from '../src/services/access/accessDatabaseQuery.js' +import { + runAccessDatabaseQuery, + withAccessDatabaseTransaction, +} from '../src/services/access/accessDatabaseQuery.js' import { AccessExecutionBudget } from '../src/services/access/accessExecutionBudget.js' import type { ExternalSessionAuthorityContext } from '../src/services/auth/externalSessionAuthentication.js' @@ -72,9 +75,15 @@ describeRealPostgres('aggregate shadow physical statement budget on real Postgre [teamId, memberUserId] ) await databasePool.query( - `INSERT INTO gfs_resources(resource_id, drive, name, kind) - VALUES ($1, $2, $3, 'directory')`, - [resourceId, `shadow-${database}`, `/member/${index}`] + `INSERT INTO gfs_resources(resource_id, drive, parent_resource_id, name, kind) + VALUES ($1, $2, CASE WHEN $4::integer = 0 THEN NULL ELSE $5::uuid END, $3, 'directory')`, + [ + resourceId, + `shadow-${database}`, + index === 0 ? '/' : `member-${index}`, + index, + resourceIds[0], + ] ) await databasePool.query( `INSERT INTO gfs_grants(drive, resource_id, subject_type, subject_id, permissions) @@ -131,7 +140,15 @@ describeRealPostgres('aggregate shadow physical statement budget on real Postgre options.budget!, async db => { statements += 1 - return db.query('SELECT 1 WHERE $1::int = 1', [1]) + return runAccessDatabaseQuery( + db, + options.budget!, + 'SELECT 1 WHERE $1::int = 1', + [1], + { + chargeProducer: false, + } + ) }, { connectionPool: databasePool, mode: 'caller_configured' } ) From 3294891dcea2c63a7456ba50d85f1fd0b3245722 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:42:12 -0600 Subject: [PATCH 275/528] test(control-api): set RPC Proxy verifier key in shared setup --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- control-api/test/jwtKeys.setup.ts | 17 +++++++++++++---- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 5328247b1a..bdffd4cced 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.721", + "version": "0.1.723", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.721", + "version": "0.1.723", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 456b84d1c4..26f9e95e69 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.721", + "version": "0.1.723", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/jwtKeys.setup.ts b/control-api/test/jwtKeys.setup.ts index 4dfe32278a..82b2370902 100644 --- a/control-api/test/jwtKeys.setup.ts +++ b/control-api/test/jwtKeys.setup.ts @@ -1,10 +1,10 @@ import { generateKeyPairSync } from 'node:crypto' /** - * Shared test fixture: real, randomly generated RSA keys for the three JWT - * slots so config-dependent suites exercise the non-dev path without implicit - * dev defaults and without writing to the local dev-key store. Tests that - * verify missing-key failures delete these variables explicitly. + * Shared test fixture: real, randomly generated RSA material for config- + * dependent suites so Control API and imported RPC Proxy code exercise the + * explicit-key path without implicit dev defaults or local dev-key storage. + * Tests that verify missing-key failures delete these variables explicitly. */ function testKey(): string { return generateKeyPairSync('rsa', { @@ -14,10 +14,19 @@ function testKey(): string { }).privateKey } +function testPublicKey(): string { + return generateKeyPairSync('rsa', { + modulusLength: 2048, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + }).publicKey +} + const testKeys: Record = { CONTROL_API_RPC_JWT_PRIVATE_KEY: testKey(), CONTROL_API_SESSION_JWT_PRIVATE_KEY: testKey(), CONTROL_API_ADMIN_JWT_PRIVATE_KEY: testKey(), + RPC_PROXY_JWT_PUBLIC_KEY: testPublicKey(), } export const JWT_TEST_KEY_ENV_NAMES = Object.keys(testKeys) as readonly string[] From 9720fc4c15642c700e9f47777ac71a3f5549bfcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:24:14 -0600 Subject: [PATCH 276/528] fix(ci): resolve latest-dev JWT policy in cross-service tests --- .github/workflows/ci-public.yml | 7 +++++++ desktop-app/test/httpClient.test.ts | 4 ++++ external-rest-api/src/releaseManifest.ts | 2 +- rpc-proxy/package-lock.json | 4 ++-- rpc-proxy/package.json | 2 +- 5 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-public.yml b/.github/workflows/ci-public.yml index 4ff270e45d..f27ee5bc2d 100644 --- a/.github/workflows/ci-public.yml +++ b/.github/workflows/ci-public.yml @@ -119,6 +119,13 @@ jobs: working-directory: ${{ matrix.service }} run: npm ci + # Cross-service integration tests import RPC Proxy source directly, whose + # config now loads the shared JWT policy package from its own node_modules. + - name: Install RPC Proxy dependencies for Control API tests + if: matrix.service == 'control-api' + working-directory: rpc-proxy + run: npm ci + - name: Verify Electron runtime if: matrix.service == 'desktop-app' working-directory: desktop-app diff --git a/desktop-app/test/httpClient.test.ts b/desktop-app/test/httpClient.test.ts index cb800db517..a8433e47d1 100644 --- a/desktop-app/test/httpClient.test.ts +++ b/desktop-app/test/httpClient.test.ts @@ -9,6 +9,10 @@ vi.mock('../src/config.js', () => ({ }, })) +// The test exercises the shared public-error contract, not External REST's +// process configuration. Its production config requires a deployment JWT key. +vi.mock('../../external-rest-api/src/config.js', () => ({ config: {} })) + function okResponse(body?: unknown): Response { return { ok: true, diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index c5b98849e1..3ae111446b 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -9,7 +9,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.10.0', externalRestApiVersion: '0.1.110', - rpcProxyVersion: '0.1.119', + rpcProxyVersion: '0.1.120', desktopVersion: '0.10.0', minimumDesktopVersion: '0.1.252', } diff --git a/rpc-proxy/package-lock.json b/rpc-proxy/package-lock.json index 866724bf67..f8af349617 100644 --- a/rpc-proxy/package-lock.json +++ b/rpc-proxy/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-rpc-proxy", - "version": "0.1.119", + "version": "0.1.120", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-rpc-proxy", - "version": "0.1.119", + "version": "0.1.120", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/rpc-proxy/package.json b/rpc-proxy/package.json index 2c3efd80c2..1270fb7c77 100644 --- a/rpc-proxy/package.json +++ b/rpc-proxy/package.json @@ -1,6 +1,6 @@ { "name": "clerum-rpc-proxy", - "version": "0.1.119", + "version": "0.1.120", "description": "External-facing RPC proxy for tenant-scoped MCP access", "main": "dist/main.js", "engines": { From 605e8f5c275051932b970fd611117ba8b75f3fe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:49:33 -0600 Subject: [PATCH 277/528] test: use synthetic auth fixture values accepted by public boundary --- external-rest-api/package-lock.json | 4 ++-- external-rest-api/package.json | 2 +- external-rest-api/src/releaseManifest.ts | 2 +- external-rest-api/test/routes.auth.test.ts | 24 +++++++++++-------- .../components/__tests__/api.logout.test.ts | 2 +- profile-ui/package-lock.json | 4 ++-- profile-ui/package.json | 2 +- 7 files changed, 22 insertions(+), 18 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 7c4c0c0c27..81b894be25 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.110", + "version": "0.1.111", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.110", + "version": "0.1.111", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index 728b11ef31..3cabb19d06 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.110", + "version": "0.1.111", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index 3ae111446b..30374f80b7 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.10.0', - externalRestApiVersion: '0.1.110', + externalRestApiVersion: '0.1.111', rpcProxyVersion: '0.1.120', desktopVersion: '0.10.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/test/routes.auth.test.ts b/external-rest-api/test/routes.auth.test.ts index 6b304192c6..87f3e03923 100644 --- a/external-rest-api/test/routes.auth.test.ts +++ b/external-rest-api/test/routes.auth.test.ts @@ -63,7 +63,7 @@ describe('routes/auth password-login', () => { const res = await request(buildApp()) .post('/api/v1/auth/google') - .send({ idToken: 'validly-shaped-google-token' }) + .send({ idToken: 'validly-shaped-test-token' }) expect(res.status).toBe(401) expect(res.body).toEqual({ error: 'Unauthorized' }) @@ -76,7 +76,7 @@ describe('routes/auth password-login', () => { const res = await request(buildApp()) .post('/api/v1/auth/password-login') - .send({ email: 'active@example.invalid', password: 'password' }) + .send({ email: 'active@example.invalid', password: 'valid-password' }) expect(res.status).toBe(409) expect(res.body).toEqual({ error: 'password_not_set' }) @@ -84,7 +84,7 @@ describe('routes/auth password-login', () => { it('sets an HttpOnly profile session cookie and omits bearer token body for browser login', async () => { authServiceMock.loginWithPassword.mockResolvedValueOnce({ - token: 'profile-session-jwt', + token: 'profile-session-test-token', me: { id: 'user-1', email: 'user@example.invalid', @@ -105,7 +105,9 @@ describe('routes/auth password-login', () => { expect(res.body.token).toBeUndefined() expect(res.body.me.email).toBe('user@example.invalid') - expect(String(res.headers['set-cookie'])).toContain('profile_session=profile-session-jwt') + expect(String(res.headers['set-cookie'])).toContain( + 'profile_session=profile-session-test-token' + ) expect(String(res.headers['set-cookie'])).toContain('HttpOnly') expect(String(res.headers['set-cookie'])).toContain('Max-Age=43200') expect(String(res.headers['set-cookie'])).toContain('Secure') @@ -114,7 +116,7 @@ describe('routes/auth password-login', () => { it('returns the bearer token body for non-browser Desktop App login', async () => { authServiceMock.loginWithPassword.mockResolvedValueOnce({ - token: 'desktop-session-jwt', + token: 'desktop-session-test-token', me: { id: 'user-1', email: 'user@example.invalid', @@ -131,25 +133,27 @@ describe('routes/auth password-login', () => { .send({ email: 'user@example.invalid', password: 'correct-password' }) .expect(200) - expect(res.body.token).toBe('desktop-session-jwt') + expect(res.body.token).toBe('desktop-session-test-token') expect(res.body.me.email).toBe('user@example.invalid') - expect(String(res.headers['set-cookie'])).toContain('profile_session=desktop-session-jwt') + expect(String(res.headers['set-cookie'])).toContain( + 'profile_session=desktop-session-test-token' + ) }) it('delegates Google verification behind the Control API limiter with trusted client IP', async () => { authServiceMock.loginWithGoogle.mockResolvedValueOnce({ - token: 'google-session-jwt', + token: 'google-session-test-token', me: { id: 'user-1', email: 'user@example.test' }, }) const response = await request(buildApp()) .post('/api/v1/auth/google') .set('x-forwarded-for', '198.51.100.52') - .send({ idToken: 'opaque-google-token' }) + .send({ idToken: 'opaque-test-token' }) expect(response.status).toBe(200) expect(authServiceMock.loginWithGoogle).toHaveBeenCalledWith( - { idToken: 'opaque-google-token' }, + { idToken: 'opaque-test-token' }, '198.51.100.52' ) }) diff --git a/profile-ui/app/components/__tests__/api.logout.test.ts b/profile-ui/app/components/__tests__/api.logout.test.ts index 140c23a4a4..50a0f0c40d 100644 --- a/profile-ui/app/components/__tests__/api.logout.test.ts +++ b/profile-ui/app/components/__tests__/api.logout.test.ts @@ -29,7 +29,7 @@ describe('Profile logout client boundary', () => { vi.mocked(fetch) .mockResolvedValueOnce(new Response('', { status: 503 })) .mockResolvedValueOnce( - new Response(JSON.stringify({ token: 'new-token', me: { id: 'user-1' } }), { + new Response(JSON.stringify({ token: 'new-test-token', me: { id: 'user-1' } }), { status: 200, headers: { 'content-type': 'application/json' }, }) diff --git a/profile-ui/package-lock.json b/profile-ui/package-lock.json index 348c55153e..21e2516b4a 100644 --- a/profile-ui/package-lock.json +++ b/profile-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-profile-ui", - "version": "0.1.166", + "version": "0.1.167", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-profile-ui", - "version": "0.1.166", + "version": "0.1.167", "dependencies": { "@clerum/desktop-app-links": "file:../packages/desktop-app-links", "@clerum/frontend-components": "file:../packages/frontend-components", diff --git a/profile-ui/package.json b/profile-ui/package.json index 052c0e7887..a7555ae59e 100644 --- a/profile-ui/package.json +++ b/profile-ui/package.json @@ -1,6 +1,6 @@ { "name": "clerum-profile-ui", - "version": "0.1.166", + "version": "0.1.167", "private": true, "scripts": { "dev": "next dev", From 4e67e1dacc4b25655cac2f6f33c3841ea5e31ed0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:32:37 -0600 Subject: [PATCH 278/528] chore(sync): integrate latest dev into user access foundation --- .cursor/rules/minikube-t0-t1-t2.mdc | 47 +- .cursor/skills/minikube-t0-t1-t2/SKILL.md | 47 +- .cursor/skills/minikube-t0-t1-t2/reference.md | 78 +- AGENTS.md | 68 +- CLAUDE.md | 6 +- charts/clerum-crds/crds/mcpserver.yaml | 5 +- control-api/package-lock.json | 23 +- control-api/package.json | 3 +- .../__tests__/callbackMcpMembership.test.ts | 7 +- .../oauth/__tests__/callbackRemoteIss.test.ts | 8 +- control-api/src/oauth/callback.ts | 373 ++- control-api/src/oauth/cimd.ts | 31 +- control-api/src/oauth/cimdIdentity.ts | 43 + control-api/src/oauth/dcr.ts | 75 + control-api/src/oauth/discovery.ts | 184 +- control-api/src/oauth/discoveryHttpStatus.ts | 1 + control-api/src/oauth/dynamicClientStore.ts | 53 + control-api/src/oauth/mcpServerOAuthSpec.ts | 95 +- .../src/oauth/perServerRegistration.ts | 62 + control-api/src/oauth/remoteCallback.ts | 158 ++ .../remoteMcpDiscoverErrorMapping.test.ts | 4 + control-api/src/routes/admin/remoteMcp.ts | 375 ++- .../src/routes/external/oauthCallback.ts | 108 +- control-api/src/routes/internal/oauth.ts | 120 +- control-api/src/routes/mcpOauth.ts | 6 +- ...onnection.realPostgres.integration.test.ts | 15 +- ...cingModel.realPostgres.integration.test.ts | 26 +- ...Integrity.realPostgres.integration.test.ts | 15 +- ...eContract.realPostgres.integration.test.ts | 26 +- ....realPostgresMigration.integration.test.ts | 28 +- control-api/test/devSigningKeys.test.ts | 2 +- ...hangeFeed.realPostgres.integration.test.ts | 24 +- control-api/test/fixtures/legacyOAuthGrant.ts | 28 +- control-api/test/fixtures/perServerInstall.ts | 108 + .../test/fixtures/remoteOAuthDiscovery.ts | 107 +- .../wire/remoteMcp.discover.atlassian.json | 80 + ...oteMcp.discover.atlassianUnconfigured.json | 79 + .../remoteMcp.discover.dcrConfidential.json | 41 + .../wire/remoteMcp.discover.dropbox.json | 11 + ...remoteMcp.discover.issuerPublicSuffix.json | 11 + .../wire/remoteMcp.discover.linear.json | 32 + ...remoteMcp.discover.linearUnconfigured.json | 31 + .../wire/remoteMcp.discover.notion.json | 41 + ...teMcp.discover.preRegisteredPerServer.json | 34 + .../wire/remoteMcp.install.atlassian.json | 13 + ...emoteMcp.install.callbackUnconfigured.json | 6 + ...moteMcp.install.cimdWithoutIssBinding.json | 7 + .../wire/remoteMcp.install.clientIdInUse.json | 8 + ...remoteMcp.install.dcrRedirectMismatch.json | 9 + .../wire/remoteMcp.install.linear.json | 13 + ...oteMcp.install.preRegisteredPerServer.json | 14 + ...remoteMcp.install.preRegisteredShared.json | 14 + ...nEvidence.realPostgres.integration.test.ts | 22 +- ...Migration.realPostgres.integration.test.ts | 22 +- ...ciliation.realPostgres.integration.test.ts | 24 +- .../gfsCopy.realPostgres.integration.test.ts | 24 +- ...eaderRole.realPostgres.integration.test.ts | 35 +- ...Readiness.realPostgres.integration.test.ts | 22 +- ...Integrity.realPostgres.integration.test.ts | 20 +- ...Finalizer.realPostgres.integration.test.ts | 36 +- ...adSession.realPostgres.integration.test.ts | 30 +- .../test/helpers.realPostgresTeardown.test.ts | 449 ++++ .../test/helpers/realPostgresTeardown.ts | 104 + .../test/oauth.authorizeUrl.generic.test.ts | 45 +- .../test/oauth.authorizeUrl.remote.test.ts | 51 +- .../test/oauth.callback.background.test.ts | 48 +- .../test/oauth.callback.generic.test.ts | 9 +- control-api/test/oauth.callback.mcp.test.ts | 10 +- .../test/oauth.callback.publicClient.test.ts | 12 +- .../test/oauth.callback.remote.test.ts | 14 +- ...uth.callback.reservedRemoteSegment.test.ts | 22 +- control-api/test/oauth.callback.test.ts | 10 +- .../oauth.callbackRemotePerServer.test.ts | 532 ++++ control-api/test/oauth.cimdIdentity.test.ts | 44 + .../test/oauth.dcr.redirectUris.test.ts | 152 ++ control-api/test/oauth.dcr.test.ts | 46 + ...lIdentity.realPostgres.integration.test.ts | 27 +- ...ForServer.realPostgres.integration.test.ts | 22 +- .../test/oauth.discovery.properties.test.ts | 57 +- .../test/oauth.discovery.sameSite.test.ts | 304 +++ control-api/test/oauth.discovery.test.ts | 20 +- ...lIdentity.realPostgres.integration.test.ts | 75 +- ...LaneFence.realPostgres.integration.test.ts | 22 +- ...isconnect.realPostgres.integration.test.ts | 22 +- .../oauth.mcpServerOAuthSpec.generic.test.ts | 37 +- .../oauth.mcpServerOAuthSpec.lane.test.ts | 4 +- .../oauth.mcpServerOAuthSpec.remote.test.ts | 75 +- .../test/oauth.mcpServerOAuthSpec.test.ts | 60 +- ...hTeardown.realPostgres.integration.test.ts | 43 +- ...rCallback.realPostgres.integration.test.ts | 432 +++ control-api/test/oauth.pkce.test.ts | 7 +- ...veRefresh.realPostgres.integration.test.ts | 22 +- ...lIdentity.realPostgres.integration.test.ts | 22 +- ...freshLock.realPostgres.integration.test.ts | 14 +- control-api/test/oauth.remoteCallback.test.ts | 250 ++ .../test/oauth.remoteSpecCoherence.test.ts | 216 +- ...tityFence.realPostgres.integration.test.ts | 22 +- ...redGrants.realPostgres.integration.test.ts | 22 +- ...tsListing.realPostgres.integration.test.ts | 22 +- .../test/oauth.tokenHelper.generic.test.ts | 43 +- .../test/oauth.tokenHelper.remote.test.ts | 48 +- ...ualLedger.realPostgres.integration.test.ts | 15 +- ...ateNotify.realPostgres.integration.test.ts | 44 +- ...ualLedger.realPostgres.integration.test.ts | 17 +- ...icyReview.realPostgres.integration.test.ts | 21 +- ...lessQuota.realPostgres.integration.test.ts | 32 +- .../realPostgresAdminCleanup.guard.test.ts | 758 ++++++ .../test/realPostgresCleanup.contract.test.ts | 774 ++++++ .../test/realPostgresTeardown.guard.test.ts | 309 +++ .../test/routes.adminRemoteMcp.test.ts | 838 +++++- .../routes.adminRemoteMcp.wireGoldens.test.ts | 325 +++ ...ailClosed.realPostgres.integration.test.ts | 35 +- ...mitStress.realPostgres.integration.test.ts | 37 +- ...GrantRace.realPostgres.integration.test.ts | 31 +- ...lHostRace.realPostgres.integration.test.ts | 31 +- ...ntsExists.realPostgres.integration.test.ts | 20 +- ...LaneFence.realPostgres.integration.test.ts | 22 +- ...rRequired.realPostgres.integration.test.ts | 22 +- ...utes.oauthCallback.remotePerServer.test.ts | 443 ++++ ...essMemory.realPostgres.integration.test.ts | 35 +- .../test/routes.registryInstall.oauth.test.ts | 4 +- ...Uninstall.realPostgres.integration.test.ts | 25 +- ...onnectors.realPostgres.integration.test.ts | 20 +- ...grantGate.realPostgres.integration.test.ts | 20 +- ...uthDelete.realPostgres.integration.test.ts | 11 +- ...onCatalog.realPostgres.integration.test.ts | 15 +- ...Lifecycle.realPostgres.integration.test.ts | 15 +- ...tionOAuth.realPostgres.integration.test.ts | 15 +- ...hRejected.realPostgres.integration.test.ts | 15 +- ...ceInviter.realPostgres.integration.test.ts | 11 +- ...etirement.realPostgres.integration.test.ts | 9 +- ...edemption.realPostgres.integration.test.ts | 17 +- ...n.refresh.realPostgres.integration.test.ts | 19 +- ...onnection.realPostgres.integration.test.ts | 15 +- ...talogSync.realPostgres.integration.test.ts | 21 +- ...orization.realPostgres.integration.test.ts | 15 +- ...alization.realPostgres.integration.test.ts | 15 +- ...edemption.realPostgres.integration.test.ts | 15 +- ...emptStore.realPostgres.integration.test.ts | 15 +- ...ackPermit.realPostgres.integration.test.ts | 53 +- ...lIdentity.realPostgres.integration.test.ts | 114 +- ...alization.realPostgres.integration.test.ts | 20 +- ...teLimiter.realPostgres.integration.test.ts | 29 +- ...aturation.realPostgres.integration.test.ts | 35 +- ...gSyncCron.realPostgres.integration.test.ts | 15 +- .../AddRemoteServerWizard/index.tsx | 183 +- .../components/AddRemoteServerWizard/types.ts | 21 +- control-ui/components/McpServerTable.tsx | 53 +- .../__tests__/AddRemoteServerWizard.test.tsx | 255 +- .../__tests__/McpServerTable.test.tsx | 59 + ...a-recorder-connector-create-access.spec.ts | 5 +- .../e2e/qa-recorder-connector-edit.spec.ts | 5 +- control-ui/lib/__tests__/remoteMcp.test.ts | 200 +- control-ui/lib/remoteMcp.ts | 153 +- control-ui/lib/remoteMcp.types.ts | 53 + control-ui/package-lock.json | 4 +- control-ui/package.json | 2 +- .../remoteMcpDiscovery.projection.test.ts | 172 +- .../test/fixtures/remoteMcpDiscovery.ts | 175 +- control-ui/test/fixtures/remoteMcpWire.ts | 127 + .../configmaps/control-api-config.yaml | 5 +- .../src/__tests__/chatMessageMerge.test.ts | 224 +- desktop-app/src/chatMessageMerge.ts | 265 +- desktop-app/src/types.ts | 9 + ...orkflow-snippet-runtime-happy-path.test.ts | 372 ++- desktop-app/ui/src/App.tsx | 10 + .../ui/src/__tests__/App.deepLinks.test.tsx | 19 +- .../src/components/GfsImagePreview/Body.tsx | 100 +- .../GfsImagePreview/GfsImagePreview.test.tsx | 6 + .../GfsImagePreviewBody.test.tsx | 57 + .../src/components/GfsImagePreview/index.tsx | 4 +- .../src/components/GfsImagePreview/types.ts | 21 +- .../components/agents/AnnotationCanvas.tsx | 89 +- .../ui/src/components/agents/ChatThread.tsx | 154 +- .../ui/src/components/agents/NudgeArea.tsx | 115 +- .../AgentWorkspace.chatScroll.test.tsx | 8 + .../__tests__/AnnotationCanvas.test.tsx | 49 +- .../ChatThread.renderSemantics.test.tsx | 10 +- .../__tests__/ChatThread.resend.test.tsx | 349 +++ .../agents/__tests__/ComposerPanel.test.tsx | 1 + .../chatThread.attachmentPreview.test.tsx | 224 ++ .../chatThread.connectFailure.test.tsx | 10 +- .../__tests__/chatThread.toolSteps.test.tsx | 10 +- .../agents/__tests__/nudgeArea.test.tsx | 139 + .../ChatComposerStateContext/types.ts | 6 + .../ui/src/contexts/NotificationsContext.tsx | 4 +- .../hooks/__tests__/sessionAdapter.test.ts | 471 +++- ...ingChatSelection.characterization.test.tsx | 8 +- .../hooks/domain/desktopEnvironmentHandoff.ts | 2 +- .../ui/src/hooks/domain/useAuthController.ts | 2 +- .../__tests__/chatMessageAttachments.test.ts | 23 + .../src/lib/__tests__/composerResend.test.ts | 327 +++ desktop-app/ui/src/lib/base64Size.ts | 5 + .../ui/src/lib/chatMessageAttachments.ts | 41 + desktop-app/ui/src/lib/composerResend.ts | 330 +++ desktop-app/ui/src/lib/imageClipboard.ts | 51 + .../ui/src/lib/imageHeaderDimensions.ts | 1 + desktop-app/ui/src/styles.css | 107 +- docs/deploy/minikube.md | 120 +- docs/get-started/quickstart.md | 10 +- docs/testing/e2e-guide.md | 8 +- docs/testing/minikube-t2-runbook.md | 64 +- .../workflow-network-policy-regressions.md | 113 + external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- external-rest-api/src/releaseManifest.ts | 2 +- .../routes/__tests__/oauthCallback.test.ts | 143 +- external-rest-api/src/routes/oauthCallback.ts | 48 +- scripts/dev/repo-intake-packet.sh | 14 +- scripts/e2e/e2e-lib.sh | 2 +- scripts/e2e/e2e-plugin-workload-sdk.sh | 2 +- scripts/e2e/image-capabilities-fixture.mjs | 2 +- scripts/e2e/playwright-dev.sh | 2 +- scripts/minikube-profiles/branch-profile.sh | 1545 +++++++++++ scripts/minikube-profiles/branch.mk | 85 + scripts/minikube/context-identity.sh | 121 + scripts/minikube/t2-common.sh | 129 +- scripts/minikube/t2.sh | 10 +- scripts/tests/lib/minikube-fixture-repo.sh | 5 +- .../tests/test-branch-profile-lifecycle.sh | 1606 ++++++++++++ .../test-minikube-build-images-hardening.sh | 7 + .../tests/test-minikube-mutation-boundary.sh | 3 + .../test-minikube-sync-auth-key-durable.sh | 2 + scripts/tests/test-minikube-sync-auth-key.sh | 2 + scripts/tests/test-minikube-t2-contract.sh | 37 +- scripts/tests/test-minikube-t2-lock-race.sh | 1 + .../tests/test-minikube-t2-proxy-runtime.sh | 3 +- scripts/tests/test-minikube-t2-scenarios.sh | 107 + scripts/tests/test-repo-intake-packet.sh | 53 +- .../attachment-reconciliation-provenance.md | 46 + workflow-recipes/package-lock.json | 4 +- workflow-recipes/package.json | 2 +- workflow-recipes/src/k8sClient.test.ts | 144 + workflow-recipes/src/k8sClient.ts | 150 +- .../src/reconciler/__tests__/captureLogger.ts | 22 +- .../src/reconciler/deleteOutcome.ts | 54 + .../oauthBrokerDeleteLedger.property.test.ts | 369 +++ .../oauthBrokerDeleteLedger.test.ts | 257 ++ .../src/reconciler/oauthBrokerDeleteLedger.ts | 176 ++ .../src/reconciler/reconciler.test.ts | 2313 ++++++++++++++++- .../src/reconciler/resourceBuilder.test.ts | 26 + .../src/reconciler/resourceBuilder.ts | 35 + .../src/reconciler/secretWatcher.test.ts | 67 + .../src/reconciler/secretWatcher.ts | 15 +- .../reconciler/workflowRecipeReconciler.ts | 495 +++- .../src/workflow/crashRecovery.test.ts | 57 +- .../src/workflow/crashRecovery.ts | 42 +- .../workflow/networkPolicyFactory.gfs.test.ts | 57 + .../src/workflow/networkPolicyFactory.ts | 48 + .../workflow/pluginWorkloadSdkProvisioner.ts | 39 +- workflow-recipes/src/workflow/runtimePlan.ts | 2 + ...lowReconciler.codexScopeProvenance.test.ts | 47 + ...flowReconciler.codexUncertainScope.test.ts | 4 + .../workflowReconciler.grokWrcFlag.test.ts | 38 +- .../src/workflow/workflowReconciler.test.ts | 309 ++- .../src/workflow/workflowReconciler.ts | 913 ++++++- .../unit/workflow/workflowReconciler.test.ts | 2110 ++++++++++++++- 257 files changed, 25757 insertions(+), 2272 deletions(-) create mode 100644 control-api/src/oauth/cimdIdentity.ts create mode 100644 control-api/src/oauth/perServerRegistration.ts create mode 100644 control-api/src/oauth/remoteCallback.ts create mode 100644 control-api/test/fixtures/perServerInstall.ts create mode 100644 control-api/test/fixtures/wire/remoteMcp.discover.atlassian.json create mode 100644 control-api/test/fixtures/wire/remoteMcp.discover.atlassianUnconfigured.json create mode 100644 control-api/test/fixtures/wire/remoteMcp.discover.dcrConfidential.json create mode 100644 control-api/test/fixtures/wire/remoteMcp.discover.dropbox.json create mode 100644 control-api/test/fixtures/wire/remoteMcp.discover.issuerPublicSuffix.json create mode 100644 control-api/test/fixtures/wire/remoteMcp.discover.linear.json create mode 100644 control-api/test/fixtures/wire/remoteMcp.discover.linearUnconfigured.json create mode 100644 control-api/test/fixtures/wire/remoteMcp.discover.notion.json create mode 100644 control-api/test/fixtures/wire/remoteMcp.discover.preRegisteredPerServer.json create mode 100644 control-api/test/fixtures/wire/remoteMcp.install.atlassian.json create mode 100644 control-api/test/fixtures/wire/remoteMcp.install.callbackUnconfigured.json create mode 100644 control-api/test/fixtures/wire/remoteMcp.install.cimdWithoutIssBinding.json create mode 100644 control-api/test/fixtures/wire/remoteMcp.install.clientIdInUse.json create mode 100644 control-api/test/fixtures/wire/remoteMcp.install.dcrRedirectMismatch.json create mode 100644 control-api/test/fixtures/wire/remoteMcp.install.linear.json create mode 100644 control-api/test/fixtures/wire/remoteMcp.install.preRegisteredPerServer.json create mode 100644 control-api/test/fixtures/wire/remoteMcp.install.preRegisteredShared.json create mode 100644 control-api/test/helpers.realPostgresTeardown.test.ts create mode 100644 control-api/test/helpers/realPostgresTeardown.ts create mode 100644 control-api/test/oauth.callbackRemotePerServer.test.ts create mode 100644 control-api/test/oauth.cimdIdentity.test.ts create mode 100644 control-api/test/oauth.dcr.redirectUris.test.ts create mode 100644 control-api/test/oauth.discovery.sameSite.test.ts create mode 100644 control-api/test/oauth.perServerCallback.realPostgres.integration.test.ts create mode 100644 control-api/test/oauth.remoteCallback.test.ts create mode 100644 control-api/test/realPostgresAdminCleanup.guard.test.ts create mode 100644 control-api/test/realPostgresCleanup.contract.test.ts create mode 100644 control-api/test/realPostgresTeardown.guard.test.ts create mode 100644 control-api/test/routes.adminRemoteMcp.wireGoldens.test.ts create mode 100644 control-api/test/routes.oauthCallback.remotePerServer.test.ts create mode 100644 control-ui/test/fixtures/remoteMcpWire.ts create mode 100644 desktop-app/ui/src/components/agents/__tests__/ChatThread.resend.test.tsx create mode 100644 desktop-app/ui/src/components/agents/__tests__/chatThread.attachmentPreview.test.tsx create mode 100644 desktop-app/ui/src/lib/__tests__/composerResend.test.ts create mode 100644 desktop-app/ui/src/lib/base64Size.ts create mode 100644 desktop-app/ui/src/lib/composerResend.ts create mode 100644 desktop-app/ui/src/lib/imageClipboard.ts create mode 100644 docs/testing/workflow-network-policy-regressions.md create mode 100755 scripts/minikube-profiles/branch-profile.sh create mode 100644 scripts/minikube-profiles/branch.mk create mode 100755 scripts/minikube/context-identity.sh create mode 100755 scripts/tests/test-branch-profile-lifecycle.sh create mode 100644 work-tracker/specs/attachment-reconciliation-provenance.md create mode 100644 workflow-recipes/src/reconciler/deleteOutcome.ts create mode 100644 workflow-recipes/src/reconciler/oauthBrokerDeleteLedger.property.test.ts create mode 100644 workflow-recipes/src/reconciler/oauthBrokerDeleteLedger.test.ts create mode 100644 workflow-recipes/src/reconciler/oauthBrokerDeleteLedger.ts diff --git a/.cursor/rules/minikube-t0-t1-t2.mdc b/.cursor/rules/minikube-t0-t1-t2.mdc index 40ad9fe99f..8aa4a34e3d 100644 --- a/.cursor/rules/minikube-t0-t1-t2.mdc +++ b/.cursor/rules/minikube-t0-t1-t2.mdc @@ -31,24 +31,35 @@ repository — never mix its PRs or clusters into this lane. - `make minikube-pre-gate-sync` is NOT T2. - Product E2E scripts (e.g. `scripts/e2e/e2e-hcc-rollout-readiness.sh`), CI, unit suites, and Playwright are separate lanes; none stands in for T2. -- Health/Playwright are opt-in via `T2_HEALTHCHECK_COMMAND` / - `T2_PLAYWRIGHT_COMMAND` (`T2_REQUIRE_PLAYWRIGHT=true` refuses `NOT_RUN`). +- Health is mandatory and bounded for `targeted-sync` via + `T2_HEALTHCHECK_COMMAND`. After `T2_PREFLIGHT_PASS` the orchestrator still + fail-louds `PROFILE_UNHEALTHY` if that command is unset — planner PASS is + not T2. Health stays opt-in for bootstrap, full reconcile, and + already-synced. Playwright is opt-in via `T2_PLAYWRIGHT_COMMAND` + (`T2_REQUIRE_PLAYWRIGHT=true` refuses `NOT_RUN`). ## Branch-profile UI port-forwards -First-hand (do not search): `.local-notes/minikube-profiles/branch.mk`. +First-hand: `scripts/minikube-profiles/branch.mk`. Implementation: `branch-profile.sh` in that directory. HARD DENY: `ls`/`cat` `~/.cache/clerum/minikube-profiles/`. ```bash MINIKUBE_PROFILE= \ - make -f .local-notes/minikube-profiles/branch.mk branch-profile-pf + make -f scripts/minikube-profiles/branch.mk branch-profile-pf MINIKUBE_PROFILE= \ - make -f .local-notes/minikube-profiles/branch.mk branch-profile-health + make -f scripts/minikube-profiles/branch.mk branch-profile-health ``` Host-side hold for Control UI / Desktop. Profile-owned random ports only -(never `:3000` / `:8090`). Run on the host, not a sandboxed agent shell. +(never `:3000` / `:8090`). Run on the host, not a sandboxed agent shell: +`make ... branch-profile-pf` that prints `PF` lines and exits 0 can still +leave registered-but-dead pidfiles when the runner reaps `nohup kubectl` +children. The planner then fail-louds `PORT_FORWARD_CONFLICT` + +`DEVELOPMENT_SCOPE_REQUIRED` **before** a transition. +`T2_PORT_FORWARD_COMMAND` does not skip that pre-transition check. Restore +with a lasting host `branch-profile-pf` + `branch-profile-health`, then +re-enter `make minikube-t2`. Not a new profile. Do not `ls`/`cat` the cache. `make minikube-pf-all-bg` is a gate refresh only — it does not replace `branch-profile-pf`. Do not kill this lane's forwards. `branch-profile-pf-health` stops PFs on EXIT. Inner `pre-gate-sync` may use @@ -90,5 +101,29 @@ once after an in-run sync, before Health/Playwright (`PortForwards=` evidence). generation-based wait, so the harness still judges reader readiness. - Evidence lives under ignored `.local-notes/infra/runs/`; never commit it. +## Host npm vs cluster Ready + +`[real-pg-preflight] PASS ... packages=2` only proves host `vitest`+`pg` in +`control-api` and `gfs-controller`. It does **not** cover `pre-gate-sync` +`run_if_changed`. `sh: vitest: command not found` + +`make[1]: *** [minikube-pre-gate-sync] Error 127` after `T2_PREFLIGHT_PASS` +and Ready deployments is a host `npm ci` gap in the named directory — not +GFS, not a new profile, not `REAL_PG_SUITE_FAILED`. Cluster Ready ≠ host +runner. `run_if_changed` dirs: `packages/workflow-runtime-core`, +`packages/network-policy-core` (`node --test`), `control-api`, +`external-rest-api`, `rpc-proxy`, `mcp-host`, `host-context-controller`, +`workflow-recipes`, `packages/workflow-sdk`, +`workflow-approval-request-reader`, `control-ui`, `desktop-app` (Node 24 + +`verify:electron`; no `--ignore-scripts`). `gfs-controller` is not on that +list. `npm ci` the named dir **and every remaining** package that declares +dependencies but has no `node_modules` (zero-dependency `node --test` +packages legitimately have none), then re-enter `make minikube-t2` on the +same HEAD/profile. `PROFILE_METADATA_MISSING` surfaces as +`PROFILE_OWNERSHIP_MISMATCH`: restore `profile.env` or stop and ask +(`scripts/minikube-profiles/branch.mk` never regenerates metadata for an +existing profile directory), never invent ports. Do not edit tracked files while `make minikube-t2` +is running: T0 fixtures fail with `fixture mutated the host checkout +working tree`, and T2 refuses a dirty tree (`DEVELOPMENT_SCOPE_REQUIRED`). + Full workflow: skill `.cursor/skills/minikube-t0-t1-t2/SKILL.md`. Deep contract: `docs/testing/minikube-t2-runbook.md`. diff --git a/.cursor/skills/minikube-t0-t1-t2/SKILL.md b/.cursor/skills/minikube-t0-t1-t2/SKILL.md index 79f2ce73f5..757d27e0fd 100644 --- a/.cursor/skills/minikube-t0-t1-t2/SKILL.md +++ b/.cursor/skills/minikube-t0-t1-t2/SKILL.md @@ -19,10 +19,12 @@ Before running anything, verify ALL of these: - [ ] Clean development branch descended from current `origin/dev` (`git status --porcelain` empty of unexpected changes; not a protected - branch). + branch). T2 fails closed on a dirty tree. Do not edit tracked files + after `make minikube-t2` starts (T0 fixtures fail + `fixture mutated the host checkout working tree`). - [ ] Identify the branch-owned `MINIKUBE_PROFILE` for THIS worktree. Reuse it. Do NOT create a new profile because HEAD, gate, or command changed. - Resolve it with the primary checkout `.local-notes/minikube-profiles/branch.mk`; + Resolve it with this worktree's `scripts/minikube-profiles/branch.mk`; profile identity is stable for canonical worktree + branch, while the pre-gate marker—not a profile-name SHA—proves exact-HEAD freshness. The marker must also match the image manifest's exact `imagesGeneratedAt` @@ -34,6 +36,14 @@ Before running anything, verify ALL of these: - [ ] Docker resolves to a local Unix socket or loopback TCP endpoint. The harness pins that endpoint into an empty task-local config; do not use a remote Docker context or copy ambient registry credentials into it. +- [ ] Host runners exist before `make minikube-t2`. T1 preflight + (`packages=2`) only checks `control-api` and `gfs-controller` for + `node_modules/.bin/vitest` + `pg`. `pre-gate-sync` later runs host + `npm test` in each changed package (see `reference.md` Host npm + section). `sh: vitest: command not found` / Error 127 after planner + PASS and Ready deployments is a missing `npm ci` in that directory — + not GFS and not a new profile. Install every remaining pre-gate + package in one pass, then re-enter T2. - [ ] Mutating image acquisition/builds use the public Make target/orchestrator and inherit its exact profile lease. Do not call `build-images.sh` or `pull-images.sh` directly; `--verify-only` is the read-only exception. @@ -44,12 +54,18 @@ Before running anything, verify ALL of these: - [ ] Never read `~/.cache/clerum/minikube-profiles/` directly (HARD DENY — it holds private profile state). The harness reads it for you. - [ ] Hold Control UI / Desktop PFs on the host via the first-hand helper - (do not search `.local-notes/` for it): - `MINIKUBE_PROFILE= make -f .local-notes/minikube-profiles/branch.mk branch-profile-pf` + (use this worktree's helper, not a cache path): + `MINIKUBE_PROFILE= make -f scripts/minikube-profiles/branch.mk branch-profile-pf` then `branch-profile-health`. Implementation: - `.local-notes/minikube-profiles/branch-profile.sh`. Do not replace + `scripts/minikube-profiles/branch-profile.sh`. Do not replace that hold with `make minikube-pf-all-bg`. Do not start UI PFs from a - sandboxed agent shell. Do not kill this lane's `branch-profile-pf`. + sandboxed agent shell. A `make ... branch-profile-pf` that prints `PF` + lines and exits 0 can still leave registered-but-dead pidfiles when the + runner reaps `nohup kubectl` children. The planner then fail-louds + `PORT_FORWARD_CONFLICT` + `DEVELOPMENT_SCOPE_REQUIRED` before a + transition; `T2_PORT_FORWARD_COMMAND` has not run yet. Restore with a + lasting host hold + `branch-profile-health`, then re-enter T2. Do not + kill this lane's `branch-profile-pf`. `branch-profile-pf-health` stops PFs on EXIT — not a lasting hold. - [ ] Run `make minikube-t2` from a host terminal with `T2_PORT_FORWARD_COMMAND` set to that `branch-profile-pf` command. @@ -113,8 +129,10 @@ transition = full-reconcile (deploy/* or charts/* changed, OR a required script between runs. transition = targeted-sync (service-only diff)? - └── make minikube-t2 performs the targeted deploy; record it as a - targeted sync, never as a full reconcile. + └── make minikube-t2 with T2_HEALTHCHECK_COMMAND set to the affected + service's profile-owned user-facing journey. Record as targeted + sync, never as a full reconcile. T2_PREFLIGHT_PASS then + PROFILE_UNHEALTHY means the command was missing — not a T2 verdict. ``` Rules that override any shortcut idea: @@ -189,7 +207,12 @@ Report to the user exactly these lane statuses plus HEAD, profile, and the evidence path. `SKIPPED` is legitimate only for T0/T1 previously green on the same HEAD (say so explicitly). `T2_HEALTHCHECK_COMMAND` is mandatory for `targeted-sync` and bounded by `T2_HEALTHCHECK_TIMEOUT_SECONDS` (default 120s); -it is optional for other transitions. `T2_PLAYWRIGHT_COMMAND` remains opt-in +it is optional for other transitions. For `workflow-recipes`, combine +`branch-profile-health` with an in-pod +`kubectl --context= -n control-plane exec deploy/workflow-recipes` +`wget` of container `/health` on the Deployment `http` port. Do not invent a +host port or write a loopback URL into the public tree. +`T2_PLAYWRIGHT_COMMAND` remains opt-in (`T2_REQUIRE_PLAYWRIGHT=true` refuses a missing journey). Evidence stays under the ignored `.local-notes/infra/runs/`. Never commit it. @@ -216,8 +239,10 @@ observe a newer same-binding access token but must never refresh/reissue or consume the Host refresh-token lineage. Do not widen the command, switch clusters, reset PVCs, or delete locks with a live owner. A T1 `next:` line is not permission to operate Docker -(`docker run`, `docker desktop restart`, port probes). Code-by-code -guidance is in `reference.md`. +(`docker run`, `docker desktop restart`, port probes). +`sh: vitest: command not found` / `minikube-pre-gate-sync` Error 127 is a +host `npm ci` gap (install the named dir and every remaining pre-gate +package), not a cluster repair. Code-by-code guidance is in `reference.md`. The active profile lock is `$T2_LOCK_ROOT/.lock`; stale reclaim uses the sibling `$T2_LOCK_ROOT/.reclaim`. A killed reclaimer can leave the diff --git a/.cursor/skills/minikube-t0-t1-t2/reference.md b/.cursor/skills/minikube-t0-t1-t2/reference.md index 239ce976ba..fe60d8e6ac 100644 --- a/.cursor/skills/minikube-t0-t1-t2/reference.md +++ b/.cursor/skills/minikube-t0-t1-t2/reference.md @@ -14,12 +14,13 @@ Companion to `SKILL.md`. Source of truth: `scripts/minikube/t2.sh`, - **Do not re-run full `make minikube-t2` to "close T2"** when T0 and T1 are already green on the same HEAD — use `make minikube-t2-runtime`. - **Do not derive a new profile from the current HEAD.** Resolve the profile - through the primary checkout helper. Worktree + branch own profile identity; + through this worktree's `scripts/minikube-profiles/branch.mk`. Worktree + + branch own profile identity; the exact-head marker owns deployed freshness and `ports.env` is persistent. - **Do not `ls`/`cat` `~/.cache/clerum/minikube-profiles/`.** Private profile state (ports, pids, markers). The harness is the only reader. - **Do not hunt for UI port-forwards.** First-hand hold: - `MINIKUBE_PROFILE= make -f .local-notes/minikube-profiles/branch.mk branch-profile-pf` + `MINIKUBE_PROFILE= make -f scripts/minikube-profiles/branch.mk branch-profile-pf` (implementation `branch-profile.sh` beside it). Run on the host, not a sandboxed agent shell. `make minikube-pf-all-bg` is a gate refresh only. `branch-profile-pf-health` stops PFs on EXIT. Never shared `:3000`/`:8090`. @@ -35,6 +36,15 @@ Companion to `SKILL.md`. Source of truth: `scripts/minikube/t2.sh`, `scripts/tests/lib/minikube-fixture-repo.sh`, set `T2_PROJECT_DIR` to that fixture, and prove the host checkout is unchanged. This is mandatory for detached-head CI runs; restoring a branch in `trap cleanup` is not isolation. + Also do not edit tracked files in the live T2 worktree while + `make minikube-t2` is running. Fixtures hash host `status --porcelain` at + init (`minikube_test_assert_host_unchanged`) and fail with + `fixture mutated the host checkout working tree`. Observed collateral: + `test-minikube-build-section-headers.sh` then reports + `build-images.sh: No such file or directory` (rc=127) because the temp + fixture repo was removed. The orchestrator also fails closed on a dirty + tree (`DEVELOPMENT_SCOPE_REQUIRED` / `worktree is dirty`). Finish the + edit, commit or stash, then re-enter T2. - **Do not run mutating `build-images.sh` or `pull-images.sh` directly.** Use the public Make target or T2 orchestrator so the exact worktree/profile/context lease is inherited. Only `--verify-only` is @@ -69,17 +79,71 @@ Companion to `SKILL.md`. Source of truth: `scripts/minikube/t2.sh`, `docker run` probes, `docker desktop restart` (destroys the Minikube container and the owned profile), published-port experiments, and any invented networking path between plan and verdict. +- **Do not treat `sh: vitest: command not found` / `minikube-pre-gate-sync` + Error 127 as GFS, `PROFILE_UNHEALTHY`, or a product bug.** Planner PASS + plus Ready deployments only prove the cluster. `pre-gate-sync` + `run_if_changed` then runs **host** `npm test` in the changed package. + `LOCAL_DEPENDENCY_MISSING` / `[real-pg-preflight] PASS packages=2` only + checked `control-api` and `gfs-controller`. Install every remaining + pre-gate package before re-entering T2; installing only the failing + directory dies on the next `run_if_changed`. - **Do not let NP08 refresh or reissue the Host lineage.** It may observe a fresher persisted access token only when its Host/recipe binding exactly matches the mounted token. Refresh tokens remain owned by mcp-host. +## Host npm vs cluster Ready (observed 2026-09-25) + +`[real-pg-preflight] PASS node=24 workers=1 packages=2` is **not** a host-runner +certificate for `pre-gate-sync`. `scripts/e2e/real-postgres-local-preflight.sh` +only requires executable `node_modules/.bin/vitest` plus resolvable `vitest` +and `pg` in `control-api` and `gfs-controller`. + +After planner PASS, image inventory OK, and every required Deployment Ready, +`pre-gate-sync` `run_if_changed` still runs **host** `npm test` in each changed +directory (`scripts/minikube/pre-gate-sync.sh`): + +- `packages/workflow-runtime-core` (`npm test && npm run build`) +- `packages/network-policy-core` (`node --test`, not vitest) +- `control-api` +- `external-rest-api` +- `rpc-proxy` +- `mcp-host` +- `host-context-controller` +- `workflow-recipes` +- `packages/workflow-sdk` +- `workflow-approval-request-reader` +- `control-ui` +- `desktop-app` (Node 24 + `npm run verify:electron`; do not `--ignore-scripts`) + +`gfs-controller` is **not** on this list. Observed aborts: `external-rest-api` +then `rpc-proxy` (`sh: vitest: command not found` → +`make[1]: *** [minikube-pre-gate-sync] Error 127` → +`make: *** [minikube-t2] Error 2`). That signature has **no** T2 stable code +and no `next:` line. Cluster Ready ≠ host runner. + +Repair on the same HEAD and owned profile: `npm ci` in the named directory +**and every remaining** package that declares dependencies but has no +`node_modules` (zero-dependency `node --test` packages legitimately have +none). Installing only the failing package dies on the next `run_if_changed`. Then +re-enter `make minikube-t2`. T1 has not run if this abort is after bootstrap. +Forbidden: new profile, PVC reset, `docker desktop restart`, `docker run` +probes, treating Error 127 as GFS/`PROFILE_UNHEALTHY`. + +`PROFILE_METADATA_MISSING` (unreadable `profile.env` / ports) is raised as +`PROFILE_OWNERSHIP_MISMATCH`. `branch-profile-start` only creates metadata +when the profile directory does not exist; it never regenerates metadata for +an existing directory, derived or adopted, and the helper refuses with +`PROFILE_METADATA_MISSING`. Restore `profile.env` from a backup or stop and +ask. Never invent ports or `ls`/`cat` `~/.cache/clerum/minikube-profiles/`. + ## Stable failure codes | Code | Meaning | Safe next step | | --- | --- | --- | -| `LOCAL_DEPENDENCY_MISSING` | Node/package-local dependencies, Python, Docker, kubectl, Minikube, or another required local tool is unavailable. | Repair the named local prerequisite first. The T1 preflight runs before expensive T0 work and never auto-installs. | +| `LOCAL_DEPENDENCY_MISSING` | Node/package-local dependencies, Python, Docker, kubectl, Minikube, or another required local tool is unavailable. T1 preflight (`packages=2`) only checks `control-api` and `gfs-controller` for host `vitest`+`pg`. | Repair the named local prerequisite (`npm ci` in that package). Then `npm ci` every remaining pre-gate package in the Host npm section before re-entering T2. The preflight never auto-installs and never covers `external-rest-api` / `rpc-proxy` / `mcp-host` / … | +| `minikube-pre-gate-sync` Error 127 / `sh: vitest: command not found` | Host `npm test` in a `run_if_changed` package after planner PASS and Ready deployments. **Not** a T2 stable code. | `npm ci` the named dir **and every remaining** pre-gate package, then `make minikube-t2` on the same HEAD/profile. Not GFS, not a new profile. | | `UNSUPPORTED_T1_CONCURRENCY` | A caller attempted more than one T1 worker. | Remove the override or set `VITEST_MAX_WORKERS=1`; T1 role/fixture mutation is serial by contract. | -| `PROFILE_OWNERSHIP_MISMATCH` | Profile metadata, context, worktree, or branch does not match this lane. | Resolve through the canonical helper. Never adopt, kill, or mutate a foreign/ambiguous profile. | +| `PROFILE_OWNERSHIP_MISMATCH` | Profile metadata, context, worktree, or branch does not match this lane. Includes wrapped `PROFILE_METADATA_MISSING` (unreadable `profile.env` / ports). | Resolve through this worktree's `scripts/minikube-profiles/branch.mk` helper. When metadata is missing for an existing profile directory, restore `profile.env` or stop and ask: `branch-profile-start` creates metadata only for a directory that does not exist. Never invent ports, adopt a foreign profile, or `ls`/`cat` `~/.cache/clerum/minikube-profiles/`. | | `PROFILE_LOCK_REQUIRED` | A mutating child did not inherit the exact parent lease. | Invoke the public Make target/orchestrator; do not fabricate or bypass its lock token. | | `DOCKER_ENDPOINT_REQUIRED` / `DOCKER_ENDPOINT_UNSAFE` / `DOCKER_ENDPOINT_MISMATCH` | Docker did not resolve exactly one approved local endpoint, or isolation selected a different endpoint. | Select a local Docker Desktop/rootless context or set an explicit local `DOCKER_HOST`; do not weaken the endpoint check. | | `DOCKER_CONFIG_REQUIRED` / `REGISTRY_AUTH_REQUIRED` | A Docker operation lacks its empty task-local config, or a private pull lacks an explicit readable auth config. | Use the canonical Make/orchestrator path; for a genuinely private source, pass only the intended config directory through `MINIKUBE_DOCKER_AUTH_CONFIG`. | @@ -89,9 +153,9 @@ Companion to `SKILL.md`. Source of truth: `scripts/minikube/t2.sh`, | `BOOTSTRAP_REQUIRED` | Profile missing/uninitialized, or the planner produced no transition. Standalone preflight refuses to bootstrap. | Run `MINIKUBE_PROFILE= CONTROL_API_REAL_PG_CONTEXT= make minikube-t2`; its internal planner makes `full-bootstrap` reachable. | | `HEAD_MARKER_MISMATCH` | Pre-gate marker does not match this worktree/HEAD; the final T2 preflight selected something other than `already-synced`. | Re-run the full target on this HEAD so `pre-gate-sync` updates the marker. Never hand-edit the marker. | | `IMAGE_MANIFEST_MISMATCH` | Deployed image provenance or the manifest `generated` stamp does not match the exact marker/worktree state. | Re-run the full target for the same owned profile; do not relabel or hand-edit the manifest. | -| `PORT_FORWARD_CONFLICT` | A port-forward for this profile is owned by a process not recorded for it (or not a real `kubectl`). | Identify the foreign owner; if it belongs to another lane, stop. Do not kill this lane's `branch-profile-pf`. Restore UI PFs with `make -f .local-notes/minikube-profiles/branch.mk branch-profile-pf`. | +| `PORT_FORWARD_CONFLICT` | A port-forward for this profile is owned by a process not recorded for it, is not a real `kubectl`, or the registered pidfile is stale/dead/ambiguous. A sandboxed `make ... branch-profile-pf` that exits 0 can leave registered-but-dead records; the planner then wraps this as `DEVELOPMENT_SCOPE_REQUIRED` **before** a transition. `T2_PORT_FORWARD_COMMAND` does not skip that check. | Identify a foreign owner and stop if it is another lane. Do not kill this lane's `branch-profile-pf`. Restore with a lasting host `branch-profile-pf` + `branch-profile-health`, then re-enter `make minikube-t2`. Not a new profile. | | `PROFILE_BUSY` | Profile lock held. Live owner PID → genuinely busy. No valid owner PID → orphaned lock. | Live owner: wait or coordinate; never remove. Orphan: verify no T2 process owns the profile, then remove ONLY `$T2_LOCK_ROOT/.lock` and retry. Never remove the lock root. | -| `DEVELOPMENT_SCOPE_REQUIRED` | Preflight/final preflight failed a precondition, or T2-only mode was attempted without `already-synced`. | Repair the first reported condition; if T2-only was refused, run full `make minikube-t2`. | +| `DEVELOPMENT_SCOPE_REQUIRED` | Preflight/final preflight failed a precondition (including `PORT_FORWARD_CONFLICT` on a stale pidfile before a transition was selected), T2-only mode was attempted without `already-synced`, or the worktree is dirty (`worktree is dirty`). | Repair the **first** reported condition. Stale PF: lasting host hold, then re-enter T2. If dirty: commit or stash, do not edit during the run, then re-enter T2. If T2-only was refused, run full `make minikube-t2`. | | `CERTIFICATION_REQUIRED` | Runtime-only was requested without valid exact-head T0/T1 evidence for the full ownership tuple. | Run the full target once; do not hand-create or copy evidence. | | `SECRET_MISSING` / `CONFIGMAP_MISSING` | A required named runtime input is absent or unreadable. | Reconcile the same owned profile through the full target; never invent values. | | `ZERO_TESTS_EXECUTED` | A lane was configured off or executed nothing (including a required-but-missing Playwright journey). | Re-run with the lane enabled, or supply the required `T2_PLAYWRIGHT_COMMAND`. | @@ -99,7 +163,7 @@ Companion to `SKILL.md`. Source of truth: `scripts/minikube/t2.sh`, | `REAL_PG_REPORT_INCOMPLETE` | Vitest JSON did not prove the exact expected physical files. | Repair test selection/reporter output; do not accept suite counters as file identity. | | `REAL_PG_SUITE_FAILED` | Reporter, test assertions, process exit, or parent result contract failed. | Iterate with standalone T1; once green, run full T2 for final exact-head lane evidence. If Darwin 27 reports `ECONNRESET` in `routes.externalGfsRateLimitStress.realPostgres.integration.test.ts`, check the suite's shared `http.Agent`: it must cap `maxSockets` below macOS `kern.ipc.somaxconn` (the proven value is 64). An unbounded 481-request burst is reset before Express accepts it; this is a test-socket defect, not a PostgreSQL or Minikube failure. | | `POSTGRES_NOT_READY` | PostgreSQL precondition failed, or a PVC reset was requested without the exact expected UID. | Fix DB readiness; never guess a PVC UID. | -| `PROFILE_UNHEALTHY` | A required deployment is unready in a fail-loud check (`T2_PLAN_MODE=false`: standalone preflight or the final exact-head T2), the mandatory targeted-sync health command is missing, or a supplied user-facing health command failed or timed out. The orchestrator planner never emits this merely for an unready deployment — it selects `full-reconcile` instead. | For a targeted sync, supply the affected service's profile-owned `T2_HEALTHCHECK_COMMAND`; otherwise repair the reported health failure. Re-run on the same HEAD and profile. Do not run manual repair scripts between runs. | +| `PROFILE_UNHEALTHY` | A required deployment is unready in a fail-loud check (`T2_PLAN_MODE=false`: standalone preflight or the final exact-head T2), the mandatory targeted-sync health command is missing, or a supplied user-facing health command failed or timed out. The orchestrator planner never emits this merely for an unready deployment — it selects `full-reconcile` instead. | For a targeted sync, supply the affected service's profile-owned `T2_HEALTHCHECK_COMMAND` **on the same `make minikube-t2` invocation** (planner `T2_PREFLIGHT_PASS` is not enough). For `workflow-recipes`: `branch-profile-health` plus in-pod `wget` of `deploy/workflow-recipes` `/health`. Otherwise repair the reported health failure. Re-run on the same HEAD and profile. Do not run manual repair scripts between runs. | | `NP08_HCC_AUTHORIZATION_FAILED` | The deployed Host-to-HCC authorization journey or cleanup failed after T0/T1 passed. | Repair the first NP08 failure, then use `minikube-t2-runtime` on the exact same profile/HEAD; never refresh/reissue in the test. | | `PLAYWRIGHT_FAILED` | The opt-in user-visible journey failed after runtime and lane evidence passed. | Repair the journey and retry with `minikube-t2-runtime` on the same exact tuple. | diff --git a/AGENTS.md b/AGENTS.md index f09a3117f2..c281fe11a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,8 +7,8 @@ iterations. Do not create a new profile merely because the current commit, gate, or test command changes. Preserve the verified profile by passing its explicit `MINIKUBE_PROFILE` value into the next operation. -Resolve that profile through the primary checkout -`.local-notes/minikube-profiles/branch.mk`; do not derive a profile from the +Resolve that profile through this worktree's +`scripts/minikube-profiles/branch.mk`; do not derive a profile from the current `HEAD` or invent ports. Profile identity is stable for the canonical worktree path plus branch, while deployed freshness is owned by the exact `gitHead` + `worktreeId` + `clusterFingerprint` marker. A creation SHA in @@ -18,7 +18,8 @@ ambiguous ownership metadata must fail closed instead of regenerating ports. Before reusing a profile, record and compare all of the following: -- active worktree path, branch, `HEAD`, and `origin/dev`; +- active worktree path, branch, `HEAD`, and `origin/dev` (the value the T2 + lease owner pinned at lane start, not a later re-read of the ref); - target Minikube profile and explicit Kubernetes context; - the pre-gate state marker: `worktreeId`, `gitHead`, `clusterFingerprint`, and the exact `imagesGeneratedAt` value from the image manifest; @@ -34,29 +35,35 @@ random port mapping already recorded for that profile. ## Branch-profile UI port-forwards -First-hand entry point (gitignored helper at repo root — do not search for -it). Implementation is `.local-notes/minikube-profiles/branch-profile.sh`. +First-hand entry point in this worktree: +`scripts/minikube-profiles/branch.mk`. Implementation is +`scripts/minikube-profiles/branch-profile.sh`. Do not `ls`/`cat` `~/.cache/clerum/minikube-profiles/`. ```bash MINIKUBE_PROFILE= \ - make -f .local-notes/minikube-profiles/branch.mk branch-profile-pf + make -f scripts/minikube-profiles/branch.mk branch-profile-pf MINIKUBE_PROFILE= \ - make -f .local-notes/minikube-profiles/branch.mk branch-profile-health + make -f scripts/minikube-profiles/branch.mk branch-profile-health ``` This is the host-side hold for Control UI / Desktop. Run it on the host, not -from a sandboxed agent shell. `make minikube-pf-all-bg` is a gate refresh -only and must not replace `branch-profile-pf`. Do not kill this lane's -forwards. `branch-profile-pf-health` starts then stops PFs on EXIT — do not -use it as the lasting hold. Inner `pre-gate-sync` may use -`--skip-port-forwards`; never pass that globally into `make minikube-t2`. -`pre-gate-sync` can restart every deployment, which leaves the host hold -bound to terminated pods. Run `make minikube-t2` from a host terminal with -`T2_PORT_FORWARD_COMMAND` set to the `branch-profile-pf` command above; T2 -runs it once after an in-run sync, before Health and Playwright, and records -`PortForwards=` in the evidence. +from a sandboxed agent shell. A `make ... branch-profile-pf` that prints `PF` +lines and exits 0 can still leave registered-but-dead pidfiles when the +runner reaps `nohup kubectl` children. The planner then fail-louds +`PORT_FORWARD_CONFLICT` + `DEVELOPMENT_SCOPE_REQUIRED` before a transition; +`T2_PORT_FORWARD_COMMAND` has not run yet. Restore with a lasting host hold +and `branch-profile-health`, then re-enter `make minikube-t2`. Not a new +profile. `make minikube-pf-all-bg` is a gate refresh only and must not +replace `branch-profile-pf`. Do not kill this lane's forwards. +`branch-profile-pf-health` starts then stops PFs on EXIT — do not use it as +the lasting hold. Inner `pre-gate-sync` may use `--skip-port-forwards`; never +pass that globally into `make minikube-t2`. `pre-gate-sync` can restart every +deployment, which leaves the host hold bound to terminated pods. Run +`make minikube-t2` from a host terminal with `T2_PORT_FORWARD_COMMAND` set to +the `branch-profile-pf` command above; T2 runs it once after an in-run sync, +before Health and Playwright, and records `PortForwards=` in the evidence. Port-forwards are owned by atomic `0600` records bound to the exact profile, context, canonical worktree, namespace, Service, local/remote ports, PID, @@ -109,7 +116,7 @@ marker to the current worktree, rebuilds only the known affected image selectors, and restarts only their deployments. A fresh profile, an explicit forced sync, or an unmapped runtime path must fall back to the established complete image build. A service-only update may use the targeted path when the -profile is already healthy; record it as a *targeted sync* and prove the +profile is already healthy; record it as a _targeted sync_ and prove the affected deployment is Ready plus its user-facing health endpoint. Do not report that as a full reconcile or as T2. @@ -236,7 +243,21 @@ silently skipping the suites. The JSON reporter must be complete and green, must identify the exact selected physical files, and the Vitest process must also exit zero; a green reporter cannot hide a teardown, worker, OOM, signal, or partial-selection failure. Run the local Node/package/Docker preflight -before expensive T0 work. T1 is serial by safety contract +before expensive T0 work. That preflight (`packages=2`) only checks host +`vitest`+`pg` in `control-api` and `gfs-controller`. `pre-gate-sync` later +runs host `npm test` in each changed package (`packages/workflow-runtime-core`, +`packages/network-policy-core`, `control-api`, `external-rest-api`, +`rpc-proxy`, `mcp-host`, `host-context-controller`, `workflow-recipes`, +`packages/workflow-sdk`, `workflow-approval-request-reader`, `control-ui`, +and `desktop-app`; `gfs-controller` is not on that list, and the +minikube-t0-t1-t2 skill `reference.md` carries the same list). +`sh: vitest: command not found` / +`minikube-pre-gate-sync` Error 127 after planner PASS and Ready deployments +is a missing host `npm ci` in that directory — not GFS and not a license +for a new profile. Install every remaining pre-gate package, then re-enter +`make minikube-t2`. Do not edit tracked files while that run is live: T0 +fixtures fail `fixture mutated the host checkout working tree`, and T2 +refuses a dirty tree. T1 is serial by safety contract (`VITEST_MAX_WORKERS=1`, no file parallelism); do not widen it for speed. Suites that drop or rewrite cluster-global roles must use the harness throwaway Postgres 16, never the shared `control-postgres`. @@ -254,7 +275,14 @@ CI, Control UI/Desktop Playwright, and product E2E scripts such as `scripts/e2e/e2e-hcc-rollout-readiness.sh` are separate evidence lanes. One lane does not stand in for another. User-facing health is mandatory and bounded for a `targeted-sync` transition via `T2_HEALTHCHECK_COMMAND`; it remains -opt-in for bootstrap, full reconcile, and already-synced runs. Playwright is +opt-in for bootstrap, full reconcile, and already-synced runs. A planner +`T2_PREFLIGHT_PASS` / `transition=targeted-sync` followed by +`PROFILE_UNHEALTHY` (`targeted sync requires a profile-owned user-facing +health command`) means the command was missing — re-enter `make minikube-t2` +with it set. For `workflow-recipes`, use `branch-profile-health` plus an +in-pod `kubectl --context= -n control-plane exec deploy/workflow-recipes` +`wget` of container `/health` on the Deployment `http` port. Do not invent a +host port or write a loopback URL into the public tree. Playwright is opt-in via `T2_PLAYWRIGHT_COMMAND` (`T2_REQUIRE_PLAYWRIGHT=true` refuses `NOT_RUN`). Private operational state, generated ports, profile metadata, logs, and evidence belong under the ignored diff --git a/CLAUDE.md b/CLAUDE.md index c2439a64f3..41ccb44f51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,9 +13,9 @@ canonical `make minikube-t2-preflight`, `make minikube-t2`, and `make minikube-t2-runtime` entry points and the ownership, secret-safety, and evidence rules in `AGENTS.md` and `docs/testing/minikube-t2-runbook.md`. Hold Control UI / Desktop port-forwards on the host with -`make -f .local-notes/minikube-profiles/branch.mk branch-profile-pf` -(`AGENTS.md` § Branch-profile UI port-forwards). Do not search for that -helper and do not use `make minikube-pf-all-bg` as the hold. +`make -f scripts/minikube-profiles/branch.mk branch-profile-pf` +(`AGENTS.md` § Branch-profile UI port-forwards). Do not use +`make minikube-pf-all-bg` as the hold. `make minikube-t2-preflight` is a planner, not a lane verdict. A T2 verdict is produced only by the final exact-head preflight inside `make minikube-t2` (or `make minikube-t2-runtime` after T0 and T1 are already green on the same HEAD). diff --git a/charts/clerum-crds/crds/mcpserver.yaml b/charts/clerum-crds/crds/mcpserver.yaml index ad11483a0c..d0e952f40c 100644 --- a/charts/clerum-crds/crds/mcpserver.yaml +++ b/charts/clerum-crds/crds/mcpserver.yaml @@ -667,8 +667,9 @@ spec: # baked (source absent) AND generic get the slug narrow, since a # generic client_id IS spec.oauth.id and must be a callback-path-safe slug. # 'remote' is reserved: baked/generic callbacks route by /oauth-callback/, - # and /oauth-callback/remote is the single stable redirect every remote - # client registers, so a baked/generic server with that id would share it. + # and /oauth-callback/remote is the shared redirect of remote clients whose + # AS advertises RFC 9207 (the others register /oauth-callback/remote/[/]), + # so a baked/generic server with that id would share it. - rule: "!has(self.oauth) || (has(self.oauth.source) && self.oauth.source == 'remote') || !has(self.oauth.id) || (self.oauth.id.matches('^[a-z0-9-]{1,63}$') && self.oauth.id != 'remote')" message: "baked/generic spec.oauth.id must match ^[a-z0-9-]{1,63}$ and must not be 'remote' (reserved for the remote OAuth callback)" # BAKED-REQ: baked (no source) requires the classic adapter quartet. diff --git a/control-api/package-lock.json b/control-api/package-lock.json index bdffd4cced..f91636e539 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.723", + "version": "0.1.724", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.723", + "version": "0.1.724", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", @@ -26,6 +26,7 @@ "pg": "^8.13.1", "pino": "^10.3.1", "prom-client": "^15.1.3", + "tldts": "^7.4.16", "yaml": "^2.8.3", "zod": "^4.3.6" }, @@ -3728,6 +3729,24 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.16.tgz", + "integrity": "sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==", + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.16" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.16.tgz", + "integrity": "sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==", + "license": "MIT" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", diff --git a/control-api/package.json b/control-api/package.json index 26f9e95e69..50219ce5d1 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.723", + "version": "0.1.724", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { @@ -35,6 +35,7 @@ "pg": "^8.13.1", "pino": "^10.3.1", "prom-client": "^15.1.3", + "tldts": "^7.4.16", "yaml": "^2.8.3", "zod": "^4.3.6" }, diff --git a/control-api/src/oauth/__tests__/callbackMcpMembership.test.ts b/control-api/src/oauth/__tests__/callbackMcpMembership.test.ts index acf802ebe6..c4c1e14e4b 100644 --- a/control-api/src/oauth/__tests__/callbackMcpMembership.test.ts +++ b/control-api/src/oauth/__tests__/callbackMcpMembership.test.ts @@ -56,10 +56,13 @@ function signedMcpState(): string { function input(): CallbackInput { return { - oauthClientId: OAUTH_CLIENT_ID, + target: { + kind: 'client', + id: OAUTH_CLIENT_ID, + redirectUri: 'https://example.test/oauth/callback', + }, code: 'auth-code-single-use', state: signedMcpState(), - redirectUri: 'https://example.test/oauth/callback', } } diff --git a/control-api/src/oauth/__tests__/callbackRemoteIss.test.ts b/control-api/src/oauth/__tests__/callbackRemoteIss.test.ts index 6231c9ab1e..adc46424fb 100644 --- a/control-api/src/oauth/__tests__/callbackRemoteIss.test.ts +++ b/control-api/src/oauth/__tests__/callbackRemoteIss.test.ts @@ -5,7 +5,6 @@ import { type CallbackDeps, type CallbackInput, type McpServerOAuthSubject, - REMOTE_CALLBACK_CLIENT_SEGMENT, handleOAuthCallback, } from '../callback.js' // State is derived from the REAL producer (T1): never hand-write the signed @@ -77,12 +76,11 @@ function signedMcpState(): string { function input(overrides: Partial = {}): CallbackInput { return { - // Stable remote callback: the URL segment is the reserved constant, the real - // client id rides the signed state. - oauthClientId: REMOTE_CALLBACK_CLIENT_SEGMENT, + // Shared remote callback: the URL carries no client id, the real one rides the + // signed state. + target: { kind: 'remote-shared', origin: 'https://callback.example.test' }, code: 'auth-code-single-use', state: signedMcpState(), - redirectUri: 'https://callback.example.test/api/v1/oauth-callback/remote', ...overrides, } } diff --git a/control-api/src/oauth/callback.ts b/control-api/src/oauth/callback.ts index 33b7b30a26..d309219405 100644 --- a/control-api/src/oauth/callback.ts +++ b/control-api/src/oauth/callback.ts @@ -2,11 +2,13 @@ import type { DbClient } from '../db.js' import type { PinnedTransport } from '../http/pinnedFetch.js' import { pinnedFetch } from '../http/pinnedFetch.js' import type { DnsResolver } from '../http/validateMcpServerSpec.js' +import { rootLogger } from '../observability/logger.js' import { getDynamicClient } from './dynamicClientStore.js' import { type RemoteOAuthSpecIncoherence, RemoteOAuthSpecIncoherentError, } from './mcpServerOAuthSpec.js' +import { buildPerServerRedirectUri, resolvePerServerRegistration } from './perServerRegistration.js' import { deriveCodeVerifier } from './pkce.js' import { type GenericAdapterConfig, @@ -18,20 +20,22 @@ import { isKnownOAuthProvider, parseRemoteTokenResponse, } from './providers.js' +import { + InvalidRemoteRedirectUriInputError, + REMOTE_CALLBACK_CLIENT_SEGMENT, + buildRemoteRedirectUri, + remoteCallbackVariant, +} from './remoteCallback.js' import type { OAuthMcpStateClaims } from './state.js' import { signOAuthState, verifyOAuthStateSignature } from './state.js' import { bootstrapSharedOAuthGrant, setUserGrantBackground, upsertOAuthGrant } from './store.js' -/** - * Reserved last URL segment of the STABLE remote OAuth callback - * (`/api/v1/oauth-callback/remote`). The remote lane (CIMD/DCR) registers ONE - * fixed redirect_uri, so the URL segment is a constant and the real client - * binding rides the signed state (`subjectKind:'mcp'` + `mcpServerName`, both - * re-resolved authoritatively). `cimd.ts` derives `REMOTE_CALLBACK_PATH` from - * this. Kept here (not in `cimd.ts`) to avoid an import cycle - * (callback → cimd → external/oauthCallback → callback). - */ -export const REMOTE_CALLBACK_CLIENT_SEGMENT = 'remote' +// Reserved last URL segment of the shared remote OAuth callback. Defined in +// `remoteCallback.ts` (the single redirect-URI builder); re-exported for the existing +// importers of this module. +export { REMOTE_CALLBACK_CLIENT_SEGMENT } + +const log = rootLogger.child({ module: 'oauth-callback' }) /** * Discriminator for WHERE an mcp-server's OAuth client credentials live @@ -68,6 +72,11 @@ export interface RemoteClientRouting { supportsRefresh: boolean /** RFC 9207 issuer to validate the callback `iss` against, when advertised. */ issForCallback?: string + /** + * The pinned AS issuer. Without RFC 9207 an AS may still send `iss`; when it does, + * it must match this value rather than being ignored. + */ + issuer?: string } /** @@ -211,26 +220,55 @@ export interface McpServerOAuthReader { export class SecretNotFoundError extends Error {} export class RecipeNotFoundError extends Error {} +/** + * Which callback URL the provider redirected to. Decided by the route from the path + * shape alone; the handler then checks it against the signed state and the CR. + * + * - `client` — `/oauth-callback/` (recipe, baked, generic). + * - `remote-shared` — `/oauth-callback/remote`: every remote server whose AS + * returns RFC 9207 `iss`, which is what binds the code there. + * - `remote-per-server` — `/oauth-callback/remote/[/]`: a + * remote server whose AS does not return `iss`, where the URI + * itself is the binding. Both segments are already validated. + * + * The remote kinds carry an origin, not a URI: the redirect URI replayed on the token + * exchange is rebuilt from the signed state and the CR once they have been checked, + * never from the request path. + */ +export type CallbackTarget = + | { + kind: 'client' + id: string + /** Public URL registered for this client; replayed on the token POST. */ + redirectUri: string + } + | { + kind: 'remote-shared' + /** Origin of the shared callback (the configured base URL, else the request Host). */ + origin: string + } + | { + kind: 'remote-per-server' + serverName: string + installNonce?: string + /** The configured public origin; null when none is configured (fails closed). */ + origin: string | null + } + export interface CallbackInput { - /** - * oauthClientId from the callback URL path. The (recipeNamespace, recipeName) - * are no longer carried in the URL — they are recovered from the signed state - * — but this id rides the stable path and is cross-checked against the claims - * for defence in depth. - */ - oauthClientId: string + target: CallbackTarget /** OAuth `code` query parameter from the provider redirect. */ code: string /** Signed state value (we re-verify before doing anything). */ state: string - /** Public URL we registered with the provider; included in the token POST. */ - redirectUri: string /** - * RFC 9207 `iss` from the authorization-response redirect (`req.query.iss`); - * validated on the remote lane against the pinned `issForCallback`. Absent on - * the baked lane (baked ASes do not advertise `iss`), where it is ignored. + * RFC 9207 `iss` exactly as parsed from the query (`req.query.iss`): a string, an + * array when repeated, an object for bracket syntax, or undefined. Kept raw because + * the two remote variants read it differently: the shared callback treats anything + * but a string as absent, while the per-server callback rejects a malformed value + * instead of letting it pass as "no iss". Ignored on the `client` target. */ - iss?: string + iss?: unknown } export interface CallbackDeps { @@ -328,6 +366,11 @@ export type CallbackResult = * closed BEFORE the single-use code is exchanged. No value fields. */ | { kind: 'issuer_binding_required' } + /** + * A per-server remote callback with no usable configured public origin: its redirect + * URI cannot be rebuilt byte-for-byte, so the code is not exchanged. + */ + | { kind: 'callback_base_url_unconfigured' } | { kind: 'unknown_oauth_client' } | { kind: 'recipe_not_found' } /** mcp subject: the McpServer named in the signed state does not exist / is not OAuth. */ @@ -355,6 +398,8 @@ export type CallbackResult = | { kind: 'provider_token_exchange_failed'; status: number; body: string } | { kind: 'provider_response_invalid'; detail: string } +const BINDING_MISMATCH = { kind: 'invalid_state', reason: 'binding_mismatch' } as const + /** * Run the full callback flow. Pure relative to its `CallbackDeps` so tests * inject stub readers / fetch / db. @@ -365,34 +410,45 @@ export async function handleOAuthCallback( ): Promise { // ─── 1. Verify state signature, recover binding from the claims ─────── // The callback URL no longer carries the subject coordinates — they are - // recovered from the signed, unforgeable state. The oauthClientId still rides - // the stable URL path, so cross-check it against the claims for defence in - // depth. The subject identity itself needs no such check: the HMAC guarantees - // it, and every authorize-url minter only signs its own namespace's states. + // recovered from the signed, unforgeable state. Whatever the URL does carry is + // cross-checked against the claims: the subject identity itself needs no such + // check (the HMAC guarantees it), but the URL is where the AS delivered the code, + // and on the per-server remote callback that location IS the mix-up defence. const verified = verifyOAuthStateSignature(deps.stateSecret, input.state) if (verified.kind !== 'ok') { return { kind: 'invalid_state', reason: verified.kind } } const claims = verified.claims - // Defence-in-depth: the URL segment must equal the signed client id — EXCEPT on - // the stable remote callback (`/oauth-callback/remote`), where CIMD/DCR register - // one fixed redirect_uri and the segment is the reserved constant, not the - // client id. There the binding rides the HMAC state (`subjectKind:'mcp'` + - // `mcpServerName`) and is re-checked below against the freshly-resolved subject - // (`subject.decl.id === claims.oauthClientId`). Recipe subjects always enforce - // the segment==id check (the reserved segment is mcp-only). - const isRemoteStableSegment = - claims.subjectKind === 'mcp' && input.oauthClientId === REMOTE_CALLBACK_CLIENT_SEGMENT - if (!isRemoteStableSegment && claims.oauthClientId !== input.oauthClientId) { - return { kind: 'invalid_state', reason: 'binding_mismatch' } - } + const target = input.target - // Dispatch by the signed subject. mcp subjects go to their own handler; the - // recipe path below is unchanged (byte-identical grant persistence). if (claims.subjectKind === 'mcp') { - return handleMcpOAuthCallback(claims, input, deps, isRemoteStableSegment) + // `/oauth-callback/`: the segment must be the signed client id. + if (target.kind === 'client' && claims.oauthClientId !== target.id) return BINDING_MISMATCH + // `/remote//…`: a code delivered to one server's URI must never be + // attributed to another, and this is decided before the CR the state names is even + // read — an AS that redirects to `a` with `b`'s state learns nothing about `b`. + if (target.kind === 'remote-per-server' && claims.mcpServerName !== target.serverName) { + return BINDING_MISMATCH + } + return handleMcpOAuthCallback(claims, input, deps) } + // Recipe subject. The per-server URI only exists for remote mcp servers. A recipe + // client may legitimately be named `remote` (operator-authored id), so the shared + // path keeps the segment==id rule for it, over the same bytes it always used. + let recipeRedirectUri: string + let segmentId: string + if (target.kind === 'client') { + segmentId = target.id + recipeRedirectUri = target.redirectUri + } else if (target.kind === 'remote-shared') { + segmentId = REMOTE_CALLBACK_CLIENT_SEGMENT + recipeRedirectUri = buildRemoteRedirectUri({ origin: target.origin, variant: 'shared' }) + } else { + return BINDING_MISMATCH + } + if (claims.oauthClientId !== segmentId) return BINDING_MISMATCH + const recipeNamespace = claims.recipeNamespace const recipeName = claims.recipeName const oauthClientId = claims.oauthClientId @@ -416,7 +472,13 @@ export async function handleOAuthCallback( if (!clientDecl) return { kind: 'unknown_oauth_client' } // ─── 3-4. Read secrets + exchange code ──────────────────────────────── - const exchanged = await exchangeAuthCode(clientDecl, recipeNamespace, undefined, input, deps) + const exchanged = await exchangeAuthCode( + clientDecl, + recipeNamespace, + undefined, + { code: input.code, state: input.state, redirectUri: recipeRedirectUri }, + deps + ) if (exchanged.kind !== 'ok') return exchanged const { provider, parsed } = exchanged @@ -492,8 +554,7 @@ export async function handleOAuthCallback( async function handleMcpOAuthCallback( claims: OAuthMcpStateClaims, input: CallbackInput, - deps: CallbackDeps, - isRemoteStableSegment: boolean + deps: CallbackDeps ): Promise { // Fail closed: an mcp state cannot be processed without an mcp reader wired. if (!deps.mcpServerReader) return { kind: 'server_not_found' } @@ -511,54 +572,39 @@ async function handleMcpOAuthCallback( if (!subject) return { kind: 'server_not_found' } // Defence in depth: the resolved server's declared oauthClient must match the - // client id bound in the signed state. Check against `claims.oauthClientId` - // (NOT `input.oauthClientId`): on the stable remote callback the URL segment is - // the reserved `remote` constant, not the client id — the state carries the real - // one. For the baked mcp lane `claims.oauthClientId === input.oauthClientId` - // (enforced above), so this stays equivalent. - if (subject.decl.id !== claims.oauthClientId) { - return { kind: 'invalid_state', reason: 'binding_mismatch' } - } - // The segment==id waiver above is only sound for remote subjects, whose issuer - // check below replaces it. A baked/generic state delivered here would bypass - // both, leaving the AS's own redirect_uri comparison as the only binding — and - // the generic lane exists precisely for ASes that may not enforce it. A - // non-remote server that nonetheless owns the reserved id (e.g. written straight - // to the apiserver) fails closed here instead of completing consent. - if (isRemoteStableSegment && !subject.decl.remote) { - return { kind: 'invalid_state', reason: 'binding_mismatch' } - } + // client id bound in the signed state. Checked against `claims.oauthClientId` + // because on the remote callbacks the URL carries no client id at all. + if (subject.decl.id !== claims.oauthClientId) return BINDING_MISMATCH - // ─── RFC 9207 issuer check (remote lane only) ───────────────────────────── - // Authorization-server mix-up defence: an attacker who controls one AS in a - // multi-AS deployment can trick a client into sending a code minted by the - // honest AS to the attacker's token endpoint (or vice versa). RFC 9207 binds - // the authorization response to its issuer via the `iss` query param; we pin - // the honest issuer at discovery (`issForCallback`) and require the callback's - // `iss` to match it here — BEFORE the single-use code is ever exchanged. - // - // FAIL CLOSED for every remote subject. The stable remote callback - // (`/oauth-callback/remote`) is SHARED across all remote subjects (CIMD/DCR/ - // pre-registered register ONE fixed redirect_uri), so whether the check runs - // must NOT be governed by the resolved AS's own advertised metadata: a - // malicious installed AS whose discovery omitted `iss` (⇒ `issForCallback` - // absent) could relay the user to an honest AS sharing this redirect/state/PKCE - // and have the honest code attributed to itself. So a remote subject with no - // pinned issuer can never satisfy the defence — reject rather than bind a code - // we cannot attribute. The baked/generic lanes have no `decl.remote`; they are - // unaffected (this is a remote-lane defence only). - if (subject.decl.remote) { - const expectedIss = subject.decl.remote.issForCallback - if (typeof expectedIss !== 'string' || expectedIss.length === 0) { - // No issuer pinned at install → the mix-up defence is unsatisfiable for this - // server. (An install-time guard would surface this to the operator earlier - // instead of the user at consent — tracked as a follow-up.) - return { kind: 'issuer_binding_required' } - } - // Fail closed on any deviation, INCLUDING an absent/empty callback `iss`. - if (input.iss !== expectedIss) { - return { kind: 'issuer_mismatch' } - } + // The callback URL must be the one this server's lane registers, in both + // directions: remote servers only ever register a remote URI, and the remote + // URIs waive the segment==id rule on the strength of checks (issuer, per-server + // binding) that only exist for remote servers. A baked/generic server that owns + // the reserved id, or a remote server reached through `/oauth-callback/`, + // fails closed instead of leaving the AS's own redirect_uri comparison — which + // the generic lane cannot rely on — as the only binding. + const target = input.target + const remote = subject.decl.remote + const reachedOnRemoteUri = target.kind !== 'client' + if (reachedOnRemoteUri !== Boolean(remote)) return BINDING_MISMATCH + + let redirectUri: string + if (target.kind === 'client') { + redirectUri = target.redirectUri + } else if (target.kind === 'remote-shared') { + const rejected = checkSharedCallbackIssuer(remote as RemoteClientRouting, input.iss) + if (rejected) return rejected + redirectUri = buildRemoteRedirectUri({ origin: target.origin, variant: 'shared' }) + } else { + const perServer = await checkPerServerCallback( + subject, + claims.mcpServerName, + target, + input.iss, + deps.db + ) + if (perServer.kind !== 'ok') return perServer + redirectUri = perServer.redirectUri } // ─── Membership guards run BEFORE the token exchange (R3-L1) ────────────── @@ -596,7 +642,7 @@ async function handleMcpOAuthCallback( subject.decl, subject.namespace, { name: claims.mcpServerName, crUid: subject.crUid }, - input, + { code: input.code, state: input.state, redirectUri }, deps ) if (exchanged.kind !== 'ok') return exchanged @@ -609,10 +655,10 @@ async function handleMcpOAuthCallback( recipeNamespace: subject.namespace, recipeName: claims.mcpServerName, contextId: contextRef, - // Grant coordinate = the resolved client id (`oauth.id`), NEVER the URL - // segment: on the stable remote callback `input.oauthClientId` is the literal - // `remote`. `subject.decl.id === claims.oauthClientId` (checked above), and it - // matches what the token broker keys by (`resolveServerOAuth`, D4). + // Grant coordinate = the resolved client id (`oauth.id`), NEVER the URL: the + // remote callbacks carry no client id in it. `subject.decl.id === + // claims.oauthClientId` (checked above), and it matches what the token broker + // keys by (`resolveServerOAuth`, D4). oauthClientId: subject.decl.id, // Bootstrapper = the signed initiator. Audit-only, first-wins on conflict. bootstrappedByUserId: claims.userId, @@ -657,6 +703,111 @@ async function handleMcpOAuthCallback( } } +/** + * RFC 9207 check of the shared remote callback. The shared URI carries no per-server + * binding, so `iss` is the only thing that attributes a code to the AS that minted it: + * pinned at discovery (`issForCallback`) and required to match exactly, before the + * single-use code is exchanged. + * + * Fails closed when the server pinned no issuer. The shared callback serves every + * remote server, so whether the check runs must not depend on the resolved AS's own + * metadata: a malicious installed AS that omitted `iss` could otherwise relay the user + * to an honest AS sharing this redirect/state/PKCE and have the honest code attributed + * to itself. Such a server belongs on its per-server URI instead. + */ +function checkSharedCallbackIssuer( + remote: RemoteClientRouting, + rawIss: unknown +): { kind: 'issuer_binding_required' } | { kind: 'issuer_mismatch' } | null { + const expectedIss = remote.issForCallback + if (typeof expectedIss !== 'string' || expectedIss.length === 0) { + return { kind: 'issuer_binding_required' } + } + // Anything but a string (repeated or bracketed parameter) reads as absent, and + // absent never matches a pinned issuer. + const iss = typeof rawIss === 'string' ? rawIss : undefined + return iss === expectedIss ? null : { kind: 'issuer_mismatch' } +} + +type PerServerCheckResult = + | { kind: 'ok'; redirectUri: string } + | { kind: 'invalid_state'; reason: 'binding_mismatch' } + | { kind: 'issuer_mismatch' } + | { kind: 'callback_base_url_unconfigured' } + +/** + * Checks of the per-server remote callback (`/remote/[/]`), + * for an AS that does not return RFC 9207 `iss`. The server name was already matched + * to the signed state before the CR was read; here: + * + * 1. the CR must use the per-server variant — a server whose AS returns `iss` is + * bound by it on the shared callback and must never be reachable here; + * 2. the URI must be the one this installation registered: for DCR, the nonce of the + * row bound to this CR; for pre-registered, no nonce (the mode comes from the CR); + * 3. an `iss` the AS sends anyway must be the pinned issuer — but it cannot be + * required, which is why this variant exists; + * 4. the redirect URI for the exchange is rebuilt from the signed server name and the + * stored registration, not from the request path. + */ +async function checkPerServerCallback( + subject: McpServerOAuthSubject, + serverName: string, + target: Extract, + rawIss: unknown, + db: DbClient +): Promise { + const remote = subject.decl.remote as RemoteClientRouting + if (remoteCallbackVariant(remote) !== 'per-server') return BINDING_MISMATCH + + const registration = await resolvePerServerRegistration(db, subject, serverName) + if (!registration) return BINDING_MISMATCH + const expectedNonce = registration.mode === 'dcr' ? registration.installNonce : undefined + if (target.installNonce !== expectedNonce) return BINDING_MISMATCH + + const issRejection = perServerIssRejection(rawIss, remote.issuer) + if (issRejection) { + // Only the server and the reason: the query also carries `code` and `state`, + // which the log redaction does not cover. + log.warn({ serverName, reason: issRejection }, 'per-server oauth callback iss rejected') + return { kind: 'issuer_mismatch' } + } + + if (target.origin === null) return { kind: 'callback_base_url_unconfigured' } + try { + return { + kind: 'ok', + redirectUri: buildPerServerRedirectUri(target.origin, serverName, registration), + } + } catch (err) { + if (err instanceof InvalidRemoteRedirectUriInputError) { + return { kind: 'callback_base_url_unconfigured' } + } + throw err + } +} + +/** + * Validate an `iss` the AS sent on a per-server callback. Absent is fine; a present + * value must be a single non-empty string equal to the pinned issuer. A repeated, + * bracketed or empty parameter is rejected rather than read as absent, so an attacker + * who can append to the query cannot turn a wrong issuer into "no issuer". + */ +function perServerIssRejection( + rawIss: unknown, + pinnedIssuer: string | undefined +): 'iss_malformed' | 'iss_mismatch' | null { + if (rawIss === undefined) return null + if (typeof rawIss !== 'string' || rawIss.length === 0) return 'iss_malformed' + return rawIss === pinnedIssuer ? null : 'iss_mismatch' +} + +/** The authorization-code grant a callback redeems, with the redirect URI to replay. */ +interface AuthCodeGrant { + code: string + state: string + redirectUri: string +} + type ExchangeAuthCodeResult = | { kind: 'ok'; provider: GrantProviderLabel; parsed: ParsedTokenResponse } | { kind: 'unknown_oauth_client' } @@ -676,21 +827,21 @@ async function exchangeAuthCode( secretNamespace: string, /** mcp-server owner (dynamic-client coordinate + installation); undefined for the recipe lane. */ owner: RemoteCredentialOwner | undefined, - input: CallbackInput, + grant: AuthCodeGrant, deps: CallbackDeps ): Promise { // Remote lane (`source:'remote'`): discovery-derived endpoint ⇒ IP-pinned POST // (DEC-17), public token client, credentials per `secretSource`. Byte-identical // baked path is below (unchanged). if (decl.remote) { - return exchangeRemoteAuthCode(decl, secretNamespace, owner, input, deps) + return exchangeRemoteAuthCode(decl, secretNamespace, owner, grant, deps) } // Generic self-hosted lane (`source:'generic'`): knob-configured request over // the same IP-pinned POST (DEC-17). Credentials per `secretSource` (public or // k8s-secret). Baked path is below (unchanged). if (decl.generic) { - return exchangeGenericAuthCode(decl, secretNamespace, owner, input, deps) + return exchangeGenericAuthCode(decl, secretNamespace, owner, grant, deps) } // Baked/recipe lane. clientIdRef is always present here (remote is the only @@ -744,7 +895,7 @@ async function exchangeAuthCode( // PKCE (DEC-1): re-derive the verifier from the EXACT round-tripped state // string — the same signed value the authorize URL derived its challenge from. const codeVerifier = adapter.usesPkce - ? deriveCodeVerifier(deps.stateSecret, input.state) + ? deriveCodeVerifier(deps.stateSecret, grant.state) : undefined try { @@ -754,10 +905,10 @@ async function exchangeAuthCode( // closed, no token) instead of an opaque 500 — symmetric with tokenHelper's // wrapped `buildRefreshRequest`. const tokenRequest = adapter.buildTokenRequest({ - code: input.code, + code: grant.code, clientId, clientSecret, - redirectUri: input.redirectUri, + redirectUri: grant.redirectUri, codeVerifier, }) const response = await deps.fetchFn(tokenRequest.url, { @@ -786,7 +937,7 @@ async function exchangeRemoteAuthCode( decl: OAuthClientDecl, secretNamespace: string, owner: RemoteCredentialOwner | undefined, - input: CallbackInput, + grant: AuthCodeGrant, deps: CallbackDeps ): Promise { // `decl.remote` presence is the branch guard in the caller. @@ -796,14 +947,14 @@ async function exchangeRemoteAuthCode( // PKCE (DEC-1 / §6 #3): the remote lane is ALWAYS S256 — re-derive the verifier // from the exact round-tripped state, same as the baked PKCE adapters. - const codeVerifier = deriveCodeVerifier(deps.stateSecret, input.state) + const codeVerifier = deriveCodeVerifier(deps.stateSecret, grant.state) const tokenRequest = buildRemoteTokenRequest( remote.tokenEndpoint, { - code: input.code, + code: grant.code, clientId: credResult.cred.clientId, clientSecret: credResult.cred.clientSecret, - redirectUri: input.redirectUri, + redirectUri: grant.redirectUri, codeVerifier, }, remote.resource @@ -846,7 +997,7 @@ async function exchangeGenericAuthCode( decl: OAuthClientDecl, secretNamespace: string, owner: RemoteCredentialOwner | undefined, - input: CallbackInput, + grant: AuthCodeGrant, deps: CallbackDeps ): Promise { // `decl.generic` presence is the branch guard in the caller. @@ -858,7 +1009,7 @@ async function exchangeGenericAuthCode( // PKCE gated on the knob: re-derive the verifier from the exact round-tripped // state (the authorize URL derived its challenge from the same value). const codeVerifier = generic.usePkce - ? deriveCodeVerifier(deps.stateSecret, input.state) + ? deriveCodeVerifier(deps.stateSecret, grant.state) : undefined let tokenRequest: ReturnType @@ -866,10 +1017,10 @@ async function exchangeGenericAuthCode( // The build can throw (e.g. tokenAuthMethod=basic on a secret-less client) — // turn that into a typed fail-closed result, never an opaque 500. tokenRequest = adapter.buildTokenRequest({ - code: input.code, + code: grant.code, clientId: credResult.cred.clientId, clientSecret: credResult.cred.clientSecret, - redirectUri: input.redirectUri, + redirectUri: grant.redirectUri, codeVerifier, }) } catch (err) { diff --git a/control-api/src/oauth/cimd.ts b/control-api/src/oauth/cimd.ts index 3116c22d2d..d5f6d53877 100644 --- a/control-api/src/oauth/cimd.ts +++ b/control-api/src/oauth/cimd.ts @@ -2,7 +2,8 @@ import { Router } from 'express' import { config } from '../config.js' import { type Logger, rootLogger } from '../observability/logger.js' import { normalizeConfiguredOrigin } from '../routes/external/oauthCallback.js' -import { REMOTE_CALLBACK_CLIENT_SEGMENT } from './callback.js' +import { CIMD_PUBLIC_PATH, CIMD_ROUTE_PATH } from './cimdIdentity.js' +import { REMOTE_CALLBACK_PATH, buildRemoteRedirectUri } from './remoteCallback.js' /** * Client ID Metadata Document (CIMD, SEP-991), served by control-api as the @@ -21,7 +22,12 @@ export interface CimdDocument { /** SEP-991: the client_id IS this document's own public URL (byte-identical to the fetch URL). */ client_id: string client_name: string - /** Stable remote callback segment; server disambiguation rides the signed `state` (C1.5). */ + /** + * Only the SHARED remote callback: this one platform identity is accepted by every AS + * that trusts the document, so listing per-server URIs here would let any such AS + * redirect to any server and void the per-server separation. CIMD is therefore only + * offered against an AS that returns `iss` (RFC 9207). + */ redirect_uris: readonly string[] /** Public client — no client_secret. */ token_endpoint_auth_method: 'none' @@ -30,31 +36,24 @@ export interface CimdDocument { application_type: 'web' } -/** Path the CIMD document is served at, relative to the `/api/v1` mount in app.ts. */ -export const CIMD_ROUTE_PATH = '/.well-known/evenfire-mcp-client' +export { CIMD_ROUTE_PATH } -/** Full public path of the served document (what `client_id` must equal, minus origin). */ -const CIMD_PUBLIC_PATH = `/api/v1${CIMD_ROUTE_PATH}` - -/** - * Stable remote-lane callback segment. C1.5 honours this exact oauthClientId, and - * C2 DCR (`dcr.ts`) derives its `redirect_uris` from the same constant so the CIMD - * document and a dynamically-registered client advertise a byte-identical callback. - */ -export const REMOTE_CALLBACK_PATH = `/api/v1/oauth-callback/${REMOTE_CALLBACK_CLIENT_SEGMENT}` +// The shared remote callback path, defined by the single redirect-URI builder +// (`remoteCallback.ts`). Re-exported for the existing importers of this module. +export { REMOTE_CALLBACK_PATH } /** * Build the frozen CIMD document for a given public `origin` (scheme://host, no * trailing slash — as produced by `normalizeConfiguredOrigin`). `client_id` is the - * document's own URL (SEP-991); `redirect_uris` carries the stable remote callback - * segment. The result is `Object.freeze`d so no caller can mutate the served + * document's own URL (SEP-991); `redirect_uris` carries only the shared remote + * callback, never a per-server URI (see {@link CimdDocument.redirect_uris}). The result is `Object.freeze`d so no caller can mutate the served * identity in place (S-6). */ export function buildCimdDocument(origin: string): Readonly { return Object.freeze({ client_id: `${origin}${CIMD_PUBLIC_PATH}`, client_name: 'Evenfire', - redirect_uris: Object.freeze([`${origin}${REMOTE_CALLBACK_PATH}`]), + redirect_uris: Object.freeze([buildRemoteRedirectUri({ origin, variant: 'shared' })]), token_endpoint_auth_method: 'none', grant_types: Object.freeze(['authorization_code', 'refresh_token']), response_types: Object.freeze(['code']), diff --git a/control-api/src/oauth/cimdIdentity.ts b/control-api/src/oauth/cimdIdentity.ts new file mode 100644 index 0000000000..37c34c89b1 --- /dev/null +++ b/control-api/src/oauth/cimdIdentity.ts @@ -0,0 +1,43 @@ +/** + * The platform CIMD client identity, without the router. Kept import-free so the + * coherence reader (`mcpServerOAuthSpec.ts`) can recognise the CIMD client_id without + * importing `cimd.ts`, which reaches the callback route and would close an import + * cycle back to the reader. + */ + +/** Path the CIMD document is served at, relative to the `/api/v1` mount in app.ts. */ +export const CIMD_ROUTE_PATH = '/.well-known/evenfire-mcp-client' + +/** Full public path of the served document (what `client_id` must equal, minus origin). */ +export const CIMD_PUBLIC_PATH = `/api/v1${CIMD_ROUTE_PATH}` + +// The CRD caps `spec.oauth.id` at 512: a longer client_id could never be written to the +// McpServer, so an install would only discover it after minting a client at the AS. +// Refusing it up front also bounds the work spent on attacker-supplied input. +export const MAX_CLIENT_ID_LENGTH = 512 + +const CIMD_PUBLIC_PATH_LOWER = CIMD_PUBLIC_PATH.toLowerCase() + +/** + * Whether a client_id names the platform CIMD document, on ANY origin. + * + * Matching the path rather than the currently configured origin is deliberate: a CR + * written under an earlier public origin, or one that reaches us through an alias host, + * still carries the one platform-wide identity whose document lists only the shared + * callback. Express routes this path case-insensitively and with a trailing slash, so + * those spellings are matched too. A false positive only rejects an id nobody but us + * (or an attacker imitating us) would mint. Plain comparisons only: the input is + * third-party data, and a backtracking pattern over a long run of `/` is quadratic. + */ +export function isCimdClientId(value: unknown): boolean { + if (typeof value !== 'string' || value.length === 0) return false + if (value.length > MAX_CLIENT_ID_LENGTH) return false + let parsed: URL + try { + parsed = new URL(value) + } catch { + return false + } + const path = parsed.pathname.toLowerCase() + return path === CIMD_PUBLIC_PATH_LOWER || path === `${CIMD_PUBLIC_PATH_LOWER}/` +} diff --git a/control-api/src/oauth/dcr.ts b/control-api/src/oauth/dcr.ts index d3e06e9be4..1de458b219 100644 --- a/control-api/src/oauth/dcr.ts +++ b/control-api/src/oauth/dcr.ts @@ -1,7 +1,9 @@ import { type PinnedFetchError, type PinnedTransport, pinnedFetch } from '../http/pinnedFetch.js' import type { DnsResolver, ValidationError } from '../http/validateMcpServerSpec.js' import { type Logger, rootLogger } from '../observability/logger.js' +import { MAX_CLIENT_ID_LENGTH } from './cimdIdentity.js' import type { DiscoveryResult } from './discovery.js' +import type { RemoteCallbackVariant } from './remoteCallback.js' /** * Dynamic Client Registration client (RFC 7591), spec 02 C2 / D-5 / DEC-18. @@ -59,6 +61,11 @@ export interface DcrRegistrationResponse { /** RFC 7592 management endpoint. */ registration_client_uri?: string token_endpoint_auth_method?: string + /** + * RFC 7591 §3.2.1: the AS echoes the registered metadata, including the redirect + * URIs it actually accepted. Untrusted — check it with {@link verifyDcrRedirectUris}. + */ + redirect_uris?: unknown [k: string]: unknown } @@ -72,6 +79,11 @@ export interface DcrRegistrationResponse { * In-memory only — used solely for the cleanup DELETE, never persisted or logged. */ export interface DcrMintHandle { + /** + * Set when a client WAS minted at the AS, with or without a management handle, so + * the caller can tell "cleaned up" from "left behind" when the handle is missing. + */ + minted?: true registrationClientUri?: string registrationAccessToken?: string } @@ -259,6 +271,22 @@ export async function registerDynamicClient( const assigned = parsed as DcrRegistrationResponse + // An oversized client_id is refused before anything compares, stores or logs it. A + // client WAS minted, so the RFC 7592 handle goes back for cleanup. + if (Buffer.byteLength(assigned.client_id, 'utf8') > MAX_CLIENT_ID_LENGTH) { + return { + ok: false, + error: { + kind: 'invalid_response', + url: endpoint, + detail: `registration response client_id exceeds ${MAX_CLIENT_ID_LENGTH} bytes`, + minted: true, + registrationClientUri: stringOrUndefined(assigned.registration_client_uri), + registrationAccessToken: stringOrUndefined(assigned.registration_access_token), + }, + } + } + // Fail-closed on an auth method we cannot present. The AS's assignment wins over // what we requested; a bare absence (undefined/null) means it honored our request. // The AS response is untrusted, so we reject anything that is NOT a presentable @@ -282,6 +310,7 @@ export async function registerDynamicClient( detail: `authorization server assigned token_endpoint_auth_method "${effectiveAuthMethod}", which control-api cannot present`, // A client WAS minted (2xx + client_id) but is unusable — expose the RFC 7592 // handle so the caller can clean it up. + minted: true, registrationClientUri: assigned.registration_client_uri, registrationAccessToken: assigned.registration_access_token, }, @@ -308,6 +337,7 @@ export async function registerDynamicClient( detail: 'confidential registration returned no client_secret', // A client WAS minted (2xx + client_id) but is unusable — expose the RFC 7592 // handle so the caller can clean it up. + minted: true, registrationClientUri: assigned.registration_client_uri, registrationAccessToken: assigned.registration_access_token, }, @@ -321,3 +351,48 @@ export async function registerDynamicClient( effectiveClientMode, } } + +function stringOrUndefined(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +export type DcrRedirectUrisCheck = + | { ok: true } + | { ok: false; reason: 'redirect_uris_missing' | 'redirect_uris_mismatch' } + +/** + * Check the `redirect_uris` a registration response reports against the ones we sent. + * + * per-server: REQUIRED and exactly equal (same strings, no normalization). The + * per-server redirect URI is the only mix-up defence there, so a client + * the AS registered with a different or unreported URI cannot be trusted + * to redirect only to this installation. + * shared: the response `iss` is the defence, so an ABSENT (or `null`) field is + * tolerated (ASes that omit the echo keep working); a PRESENT one that + * differs still fails. + * + * Comparison is set equality over exact strings (RFC 7591 gives the array no order). + * Pure; the caller turns a failure into an install failure with RFC 7592 cleanup. + */ +export function verifyDcrRedirectUris(input: { + variant: RemoteCallbackVariant + requested: readonly string[] + response: Pick +}): DcrRedirectUrisCheck { + const reported = input.response.redirect_uris + // JSON `null` is how some serializers spell an omitted field, so it counts as absent. + if (reported === undefined || reported === null) { + return input.variant === 'shared' + ? { ok: true } + : { ok: false, reason: 'redirect_uris_missing' } + } + if ( + !Array.isArray(reported) || + reported.length !== input.requested.length || + !reported.every(uri => typeof uri === 'string' && input.requested.includes(uri)) || + !input.requested.every(uri => reported.includes(uri)) + ) { + return { ok: false, reason: 'redirect_uris_mismatch' } + } + return { ok: true } +} diff --git a/control-api/src/oauth/discovery.ts b/control-api/src/oauth/discovery.ts index 8f26b4e077..f94667d51d 100644 --- a/control-api/src/oauth/discovery.ts +++ b/control-api/src/oauth/discovery.ts @@ -1,3 +1,4 @@ +import { getDomain } from 'tldts' import { type PinnedFetchError, type PinnedResponse, @@ -74,6 +75,12 @@ export interface SelectRegistrationModeInput { cimdSupported: boolean tokenEndpointAuthMethods: string[] hasRegistrationEndpoint: boolean + /** + * Whether the AS returns `iss` on the authorization response (RFC 9207) — compute it + * with {@link advertisesIssBinding}. Required, with no default, so a caller cannot + * silently fall back to offering CIMD against an AS without it. + */ + issBindingSupported: boolean } /** @@ -84,14 +91,136 @@ export interface SelectRegistrationModeInput { * ordering is an invariant with a property test (T2): CIMD requires the DOUBLE * condition (`cimdSupported` AND `none` ∈ auth methods — a public client); DCR * requires a registration endpoint; otherwise the operator registers manually. + * + * Without RFC 9207 CIMD is never offered, even when the AS supports it: such a server + * needs a redirect URI of its own, and the CIMD document is one platform-wide identity + * that can only list the shared callback. A CIMD-only AS without `iss` therefore + * resolves to `manual` (the operator pre-registers a client). */ export function selectRegistrationMode(input: SelectRegistrationModeInput): RegistrationMode { if (input.hasPreRegisteredClient) return 'pre-registered' - if (input.cimdSupported && input.tokenEndpointAuthMethods.includes('none')) return 'cimd' + if ( + input.issBindingSupported && + input.cimdSupported && + input.tokenEndpointAuthMethods.includes('none') + ) { + return 'cimd' + } if (input.hasRegistrationEndpoint) return 'dcr' return 'manual' } +/** + * The metadata-derived inputs of {@link selectRegistrationMode}, in one place so a + * caller asking "what would this AS resolve to" reads the metadata exactly as + * discovery does. + */ +export function registrationModeInputs( + as: AuthorizationServerMetadata, + opts: { hasPreRegisteredClient: boolean } +): SelectRegistrationModeInput { + return { + hasPreRegisteredClient: opts.hasPreRegisteredClient, + cimdSupported: as.client_id_metadata_document_supported === true, + tokenEndpointAuthMethods: as.token_endpoint_auth_methods_supported ?? [], + hasRegistrationEndpoint: typeof as.registration_endpoint === 'string', + issBindingSupported: advertisesIssBinding(as), + } +} + +/** + * RFC 9207: the AS advertises that it returns `iss` on the authorization response. + * The ONE expression behind both `issForCallback` and the registration-mode choice, so + * the callback variant and the offered modes cannot disagree about the same AS. + * Strictly `=== true`: the metadata is untrusted and a truthy non-boolean is not an + * advertisement. + */ +export function advertisesIssBinding(as: AuthorizationServerMetadata): boolean { + return as.authorization_response_iss_parameter_supported === true +} + +// ─── Same-site rule for AS endpoints (per-server callback only) ───────────── + +/** + * Registrable domain (eTLD+1, Public Suffix List including its private section) of a + * URL, or `null` when it has none. + * + * The host is taken from WHATWG `new URL()` — the same parse `pinnedFetch` connects + * with — and only the hostname is handed to `tldts`. Passing the raw URL would let the + * two parsers disagree: `https://evil.com\@honest.com/t` is `evil.com` to WHATWG (and + * to the socket) but `honest.com` to `tldts`. A URL carrying userinfo is refused + * outright (`null`) since no AS endpoint legitimately has one. `allowPrivateDomains` + * keeps shared-hosting suffixes (`github.io`, `herokuapp.com`) as public suffixes, so + * two tenants of one platform are two sites; a bare public suffix or an IP literal has + * no registrable domain and yields `null`. + */ +export function registrableSite(url: string): string | null { + let parsed: URL + try { + parsed = new URL(url) + } catch { + return null + } + if (parsed.username !== '' || parsed.password !== '') return null + return getDomain(parsed.hostname, { allowPrivateDomains: true }) +} + +export interface AsEndpointSites { + issuer: string + authorization: string + token: string + registration?: string +} + +export type AsEndpointSiteCheck = + | { ok: true } + | { ok: false; field: keyof AsEndpointSites; detail: string } + +/** + * Every AS endpoint must share the issuer's registrable domain. Applies when the AS + * does not return `iss` (RFC 9207): the callback then cannot tell which AS produced a + * code, so a hostile metadata document that borrows an honest AS's authorize and + * registration endpoints but names its own token endpoint would have us register a + * client at the honest AS and redeem its codes at the attacker. With `iss` the response + * is bound to the issuer and this rule is not needed. + * + * Only the endpoints we send to are checked. `revocation_endpoint` is neither used nor + * pinned on the CR, so an off-site or malformed one must not block an install; whatever + * starts sending refresh tokens to it must first pin it on the CR and add it here (and + * to the runtime coherence rule). A `null` site is never equal to anything, including + * another `null`. Pure: callers pass the endpoints they are about to trust (discovery + * output, or the values pinned on a CR). + */ +export function checkAsEndpointsSameSite(endpoints: AsEndpointSites): AsEndpointSiteCheck { + const issuerSite = registrableSite(endpoints.issuer) + if (issuerSite === null) { + return { + ok: false, + field: 'issuer', + detail: + 'issuer has no registrable domain (public suffix, IP literal, userinfo or invalid URL)', + } + } + const fields: Array> = [ + 'authorization', + 'token', + 'registration', + ] + for (const field of fields) { + const url = endpoints[field] + if (url === undefined) continue + const site = registrableSite(url) + if (site !== issuerSite) { + return { + ok: false, + field, + detail: `${field} endpoint ${site === null ? 'has no registrable domain' : `is on site "${site}"`}, issuer is on site "${issuerSite}"`, + } + } + } + return { ok: true } +} + // ─── Transport quirks derived from metadata (D-8, pure) ───────────────────── export interface RemoteQuirks { @@ -153,6 +282,8 @@ export type DiscoveryError = | { kind: 'issuer_mismatch'; detail: string } /** Response arrived with a non-identity `content-encoding` (fail-closed, never mis-parse). */ | { kind: 'content_encoding_rejected'; url: string; encoding: string } + /** Without RFC 9207, an AS endpoint is not on the issuer's registrable domain. */ + | { kind: 'as_endpoints_cross_site'; field: string; detail: string } export interface DiscoveryResult { prm: ProtectedResourceMetadata @@ -496,16 +627,17 @@ async function resolvePrm( * Full RFC 9728 → 8414 discovery for a remote MCP server URL. Returns the pinned * endpoints, `resource` (RFC 8707), issuer/`iss` handling (RFC 9207), the * registration mode (D-3) and transport quirks (D-8). Fail-closed if the AS does - * not advertise S256 (invariant 3). + * not advertise S256 (invariant 3), and — when it does not return `iss` (RFC 9207) — + * if any of its endpoints leaves the issuer's registrable domain + * (`as_endpoints_cross_site`), before anything is registered against it. * * `hasPreRegisteredClient` reflects operator config (a pre-registered Secret), - * defaulting to false — the C1 pilots (Notion/Canva/Linear/Sentry) have none and - * resolve to CIMD. + * defaulting to false. * - * Mirror: control-ui hand-copies this projection (the `detected` shape the remoteMcp - * route builds from this result) in test/fixtures/__tests__/remoteMcpDiscovery.contract.test.ts - * (projectDetected), because control-api is not importable there. A shape change here - * must be reflected in that copy or its fixtures certify a stale shape. + * Contract with control-ui: the `/discover` and install responses built from this + * result are pinned as wire goldens by test/routes.adminRemoteMcp.wireGoldens.test.ts, + * which control-ui's fixtures consume (control-api is not importable there). A shape + * change here must regenerate those goldens or control-ui certifies a stale shape. */ export async function discoverRemoteOAuth( mcpUrl: string, @@ -621,12 +753,33 @@ export async function discoverRemoteOAuth( } } - const registrationMode = selectRegistrationMode({ - hasPreRegisteredClient: opts.hasPreRegisteredClient ?? false, - cimdSupported: as.client_id_metadata_document_supported === true, - tokenEndpointAuthMethods: as.token_endpoint_auth_methods_supported ?? [], - hasRegistrationEndpoint: typeof as.registration_endpoint === 'string', - }) + const issBindingSupported = advertisesIssBinding(as) + if (!issBindingSupported) { + const sameSite = checkAsEndpointsSameSite({ + issuer: as.issuer, + authorization: as.authorization_endpoint, + token: as.token_endpoint, + ...(typeof as.registration_endpoint === 'string' + ? { registration: as.registration_endpoint } + : {}), + }) + if (!sameSite.ok) { + log.warn( + { discovery: 'as_endpoints_cross_site', field: sameSite.field }, + 'authorization server endpoints are not on the issuer site' + ) + return { + ok: false, + error: { kind: 'as_endpoints_cross_site', field: sameSite.field, detail: sameSite.detail }, + } + } + } + + const registrationMode = selectRegistrationMode( + registrationModeInputs(as, { + hasPreRegisteredClient: opts.hasPreRegisteredClient ?? false, + }) + ) return { ok: true, @@ -635,8 +788,7 @@ export async function discoverRemoteOAuth( as, resource: prm.resource, issuer: as.issuer, - issForCallback: - as.authorization_response_iss_parameter_supported === true ? as.issuer : undefined, + issForCallback: issBindingSupported ? as.issuer : undefined, endpoints: { authorization: as.authorization_endpoint, token: as.token_endpoint, diff --git a/control-api/src/oauth/discoveryHttpStatus.ts b/control-api/src/oauth/discoveryHttpStatus.ts index ae99210ec4..96d5efcd81 100644 --- a/control-api/src/oauth/discoveryHttpStatus.ts +++ b/control-api/src/oauth/discoveryHttpStatus.ts @@ -26,6 +26,7 @@ export function discoveryHttpStatus(error: DiscoveryError): number { case 'redirect_blocked': case 'prm_resource_mismatch': case 'issuer_mismatch': + case 'as_endpoints_cross_site': return 400 default: { const _exhaustive: never = error diff --git a/control-api/src/oauth/dynamicClientStore.ts b/control-api/src/oauth/dynamicClientStore.ts index 6f72127777..0dbac61097 100644 --- a/control-api/src/oauth/dynamicClientStore.ts +++ b/control-api/src/oauth/dynamicClientStore.ts @@ -246,6 +246,59 @@ export async function getDynamicClient( } } +/** + * Whether any dynamic client of `issuer` in this server namespace carries `clientId`, + * pending, bound or legacy alike: every row stands for a client that may still be live + * at its AS (teardown deletes the row). A client_id is only unique within its AS, so a + * row of another issuer is another client. Reads no encrypted column. + */ +export async function isDynamicClientIdRegistered( + db: DbClient, + input: { serverNamespace: string; issuer: string; clientId: string; ownerKind?: 'mcpserver' } +): Promise { + const result = await db.query( + `SELECT 1 + FROM dynamic_clients + WHERE owner_kind = $1 AND server_namespace = $2 AND client_id = $3 AND issuer = $4 + LIMIT 1`, + [resolveOwnerKind(input), input.serverNamespace, input.clientId, input.issuer] + ) + return result.rows.length > 0 +} + +/** The installation identity of a dynamic client, without any credential material. */ +export interface DynamicClientBinding { + clientId: string + /** Install nonce the client's redirect URI was registered with; undefined = legacy row. */ + installId?: string + /** metadata.uid of the McpServer the row is bound to; undefined = pending or legacy. */ + crUid?: string +} + +/** + * Read which installation a server's dynamic client belongs to. Reads no encrypted + * column, so the consent paths can check the binding without decrypting a secret they + * may never use. + */ +export async function getDynamicClientBinding( + db: DbClient, + key: DynamicClientKey +): Promise { + const result = await db.query( + `SELECT client_id, install_id, cr_uid + FROM dynamic_clients + WHERE owner_kind = $1 AND server_namespace = $2 AND server_name = $3`, + [resolveOwnerKind(key), key.serverNamespace, key.serverName] + ) + if (result.rows.length === 0) return null + const row = result.rows[0] as Pick + return { + clientId: row.client_id, + installId: row.install_id ?? undefined, + crUid: row.cr_uid ?? undefined, + } +} + /** * Metadata-only view of a `dynamic_clients` row whose confidential secret is at * or near expiry — enough for the DCR lifecycle decision (§5) without touching diff --git a/control-api/src/oauth/mcpServerOAuthSpec.ts b/control-api/src/oauth/mcpServerOAuthSpec.ts index 07727e1ba8..0cf1b98108 100644 --- a/control-api/src/oauth/mcpServerOAuthSpec.ts +++ b/control-api/src/oauth/mcpServerOAuthSpec.ts @@ -18,7 +18,10 @@ import type { RemoteClientRouting, ServerOAuthSecretSource, } from './callback.js' +import { isCimdClientId } from './cimdIdentity.js' +import { checkAsEndpointsSameSite } from './discovery.js' import { MAX_EXTRA_AUTHORIZE_PARAMS, MAX_EXTRA_AUTHORIZE_PARAM_VALUE_LEN } from './genericKnobs.js' +import { remoteCallbackVariant } from './remoteCallback.js' import type { OAuthGrantKey } from './store.js' const log = rootLogger.child({ module: 'mcp-server-oauth-spec' }) @@ -37,6 +40,8 @@ export interface McpServerOAuthDecl { clientMode?: unknown authorizationEndpoint?: unknown tokenEndpoint?: unknown + registrationEndpoint?: unknown + issuer?: unknown resource?: unknown bearerInBody?: unknown supportsRefresh?: unknown @@ -230,13 +235,20 @@ export interface ResolvedServerOAuthSubject { crUid?: string } -export type RemoteOAuthSpecIncoherence = 'public_client_with_secret_refs' | 'bearer_in_body' +export type RemoteOAuthSpecIncoherence = + | 'public_client_with_secret_refs' + | 'bearer_in_body' + | 'cimd_without_issuer_binding' + | 'as_endpoints_cross_site' /** * A remote `spec.oauth` the admission rules reject but that reached the apiserver - * anyway (CRD applied after the object, or a write that bypassed validation). The - * runtime cannot honor it, so consent, authorize-URL minting and token issuance - * refuse it with this specific error instead of degrading to a generic failure. + * anyway (CRD applied after the object, or a write that bypassed validation, e.g. by + * kubectl/GitOps). The phase using it refuses it with this specific error instead of + * degrading to a generic failure: the CRD-mirror reasons (`public_client_with_secret_refs`, + * `bearer_in_body`) in consent and token issuance alike; the per-server callback + * reasons (`cimd_without_issuer_binding`, `as_endpoints_cross_site`) only in consent + * (authorize-URL minting and the callback), so grants already obtained keep serving. */ export class RemoteOAuthSpecIncoherentError extends Error { readonly code = 'remote_oauth_spec_incoherent' @@ -246,7 +258,20 @@ export class RemoteOAuthSpecIncoherentError extends Error { } } -function remoteOAuthSpecIncoherence(oauth: McpServerOAuthDecl): RemoteOAuthSpecIncoherence | null { +/** + * Where a remote server's `spec.oauth` is being used: + * - `consent` — authorize-URL minting and the OAuth callback, where a new code is + * issued and redeemed; + * - `token` — serving and refreshing a grant already obtained (user-token, the + * reactive refresh and the proactive cron). + * Required at every call site, with no default, so no reader silently picks a phase. + */ +export type RemoteOAuthCoherencePhase = 'consent' | 'token' + +function remoteOAuthSpecIncoherence( + oauth: McpServerOAuthDecl, + phase: RemoteOAuthCoherencePhase +): RemoteOAuthSpecIncoherence | null { if ( oauth.clientMode === 'public' && (oauth.clientIdRef != null || oauth.clientSecretRef != null) @@ -254,23 +279,64 @@ function remoteOAuthSpecIncoherence(oauth: McpServerOAuthDecl): RemoteOAuthSpecI return 'public_client_with_secret_refs' } if (oauth.bearerInBody === true) return 'bearer_in_body' + // The per-server rules defend the authorization response (which AS minted a code, and + // where it is redeemed). A grant already obtained is sealed to this CR's uid and its + // endpoints are immutable, so a refresh only ever returns to the token endpoint that + // issued it: blocking it would strand existing grants without closing any mix-up. + if (phase === 'consent' && remoteCallbackVariant(oauth) === 'per-server') { + // The CIMD document lists only the shared callback, so its client can never be + // bound to one server's redirect URI; without `iss` nothing else ties its codes to + // this server. + if (isCimdClientId(oauth.id)) return 'cimd_without_issuer_binding' + if (!asEndpointsOnIssuerSite(oauth)) return 'as_endpoints_cross_site' + } return null } +/** + * The install-time same-site rule, re-applied to the endpoints pinned on a per-server + * CR. The install already enforces it, but a CR written straight to the apiserver + * skips the install, and without `iss` a token endpoint off the issuer's site would + * redeem our codes. An endpoint that is not a string is left to the routing reader, + * which fails closed on it; a missing issuer fails here, since per-server consent + * cannot be anchored without one. + */ +function asEndpointsOnIssuerSite(oauth: McpServerOAuthDecl): boolean { + const optional = (value: unknown): string | undefined => + typeof value === 'string' && value.length > 0 ? value : undefined + const authorization = optional(oauth.authorizationEndpoint) + const token = optional(oauth.tokenEndpoint) + if (authorization === undefined || token === undefined) return true + const registration = optional(oauth.registrationEndpoint) + return checkAsEndpointsSameSite({ + issuer: typeof oauth.issuer === 'string' ? oauth.issuer : '', + authorization, + token, + ...(registration ? { registration } : {}), + }).ok +} + /** * Runtime mirror of the remote-carril coherence rules of the McpServer CRD - * (REMOTE-SECRET-PAIRING for `public`, REMOTE-BEARER-HEADER). Throws + * (REMOTE-SECRET-PAIRING for `public`, REMOTE-BEARER-HEADER) and of the install's + * per-server callback rules (no CIMD client and same-site AS endpoints when the AS + * does not return `iss`, which the CRD cannot express). Throws * {@link RemoteOAuthSpecIncoherentError} for a remote server whose shape no - * install path produces; a no-op for every other lane. + * install path produces; a no-op for every other lane. The CRD mirrors apply in both + * phases; the per-server callback rules only in `consent` (see + * {@link RemoteOAuthCoherencePhase}). * * Deliberately NOT applied by {@link resolveServerOAuth}: that coordinate backs * revoke and the grant-existence sweep, and an incoherent server must still be * disconnectable and must not push the sweep into its fail-open branch. */ -export function assertRemoteOAuthSpecCoherent(server: McpServerOAuthSpecInput): void { +export function assertRemoteOAuthSpecCoherent( + server: McpServerOAuthSpecInput, + phase: RemoteOAuthCoherencePhase +): void { const oauth = server.spec?.oauth if (!oauth || readOAuthLane(oauth) !== 'remote') return - const reason = remoteOAuthSpecIncoherence(oauth) + const reason = remoteOAuthSpecIncoherence(oauth, phase) if (!reason) return const name = server.metadata?.name log.warn( @@ -278,8 +344,9 @@ export function assertRemoteOAuthSpecCoherent(server: McpServerOAuthSpecInput): event: 'remote_oauth_spec_incoherent', mcpServerName: typeof name === 'string' ? name : undefined, reason, + phase, }, - 'remote mcp-server oauth spec is incoherent; refusing consent and token issuance' + 'remote mcp-server oauth spec is incoherent; refusing it in this phase' ) throw new RemoteOAuthSpecIncoherentError(reason) } @@ -298,6 +365,8 @@ function extractRemoteRouting(oauth: McpServerOAuthDecl): RemoteClientRouting | typeof oauth.issForCallback === 'string' && oauth.issForCallback.length > 0 ? oauth.issForCallback : undefined + const issuer = + typeof oauth.issuer === 'string' && oauth.issuer.length > 0 ? oauth.issuer : undefined return { authorizationEndpoint, tokenEndpoint, @@ -306,6 +375,7 @@ function extractRemoteRouting(oauth: McpServerOAuthDecl): RemoteClientRouting | bearerInBody: oauth.bearerInBody === true, supportsRefresh: oauth.supportsRefresh === true, issForCallback, + ...(issuer ? { issuer } : {}), } } @@ -435,7 +505,8 @@ function normalizeScopes(scopes: unknown): string[] | undefined { * secret (K8s Secret or `getDynamicClient`) per `secretSource`. */ export function resolveServerOAuthSubject( - server: McpServerOAuthSpecInput + server: McpServerOAuthSpecInput, + phase: RemoteOAuthCoherencePhase ): ResolvedServerOAuthSubject | null { const oauth = server.spec?.oauth if (!oauth || typeof oauth.id !== 'string' || oauth.id.length === 0) return null @@ -449,7 +520,7 @@ export function resolveServerOAuthSubject( // ─── Remote lane (`source:'remote'`) ───────────────────────────────────── if (lane === 'remote') { - assertRemoteOAuthSpecCoherent(server) + assertRemoteOAuthSpecCoherent(server, phase) const remote = extractRemoteRouting(oauth) if (!remote) return null const secretSource = resolveRemoteSecretSource(oauth, remote.clientMode) diff --git a/control-api/src/oauth/perServerRegistration.ts b/control-api/src/oauth/perServerRegistration.ts new file mode 100644 index 0000000000..027ee05537 --- /dev/null +++ b/control-api/src/oauth/perServerRegistration.ts @@ -0,0 +1,62 @@ +import type { DbClient } from '../db.js' +import type { McpServerOAuthSubject } from './callback.js' +import { getDynamicClientBinding } from './dynamicClientStore.js' +import { buildRemoteRedirectUri, isValidInstallNonce } from './remoteCallback.js' + +/** + * How a per-server remote client is registered at its AS, which decides the shape of + * its redirect URI: a DCR client registered `/remote//`, a + * pre-registered one `/remote/`. + */ +export type PerServerRegistration = + | { mode: 'pre-registered' } + | { mode: 'dcr'; installNonce: string } + +/** + * Resolve the registration of a per-server remote server, shared by the authorize-URL + * mint and the callback so the URI sent to the AS and the one accepted back cannot + * disagree. + * + * The mode comes from the CR, never from whether a `dynamic_clients` row exists: a CR + * with both secret refs is pre-registered; anything else is DCR and must have a row. + * Deriving it from the row would let a public CR without refs (written by GitOps, or + * whose row was deleted) be treated as pre-registered and accept a nonce-less URI — + * a public pre-registered client, which the per-server variant cannot protect because + * its codes stay redeemable after a same-name reinstall. + * + * A DCR registration is only returned for a row bound to this very CR (`cr_uid`), with + * a valid nonce and the CR's client id. A pending row, a legacy row without a nonce, or + * a row of a previous installation of the same name yields null: consent fails closed. + */ +export async function resolvePerServerRegistration( + db: DbClient, + subject: Pick, + serverName: string +): Promise { + if (subject.decl.secretSource?.kind === 'k8s-secret') return { mode: 'pre-registered' } + if (subject.crUid === undefined) return null + const row = await getDynamicClientBinding(db, { + serverNamespace: subject.namespace, + serverName, + }) + if (!row || row.crUid !== subject.crUid || row.clientId !== subject.decl.id) return null + if (!isValidInstallNonce(row.installId)) return null + return { mode: 'dcr', installNonce: row.installId } +} + +/** The per-server redirect URI of a resolved registration. Throws like its builder. */ +export function buildPerServerRedirectUri( + origin: string, + serverName: string, + registration: PerServerRegistration +): string { + return registration.mode === 'dcr' + ? buildRemoteRedirectUri({ + origin, + variant: 'per-server', + mode: 'dcr', + serverName, + installNonce: registration.installNonce, + }) + : buildRemoteRedirectUri({ origin, variant: 'per-server', mode: 'pre-registered', serverName }) +} diff --git a/control-api/src/oauth/remoteCallback.ts b/control-api/src/oauth/remoteCallback.ts new file mode 100644 index 0000000000..e0cddf09ad --- /dev/null +++ b/control-api/src/oauth/remoteCallback.ts @@ -0,0 +1,158 @@ +import { RFC1123_RE } from '../http/rfc1123.js' + +/** + * Redirect-URI policy for the remote MCP-OAuth lane (CIMD / DCR / pre-registered). + * + * Two callback variants exist, and which one a server uses is decided solely by + * whether its authorization server advertises RFC 9207 (`iss` on the authorization + * response): + * + * - `shared` — every server lands on ONE stable callback. Safe only because the + * callback checks the response `iss` against the pinned issuer; the + * URL itself carries no per-server binding. + * - `per-server` — without `iss`, the redirect URI itself is the mix-up defence: each + * server (and, for DCR, each installation) registers a distinct URI, + * so an AS can only ever deliver a code to the server it was issued + * for. + * + * Accepted residual: per-server URIs extend the shared one (`/remote/…`), so + * the separation relies on the AS comparing redirect URIs exactly (RFC 9700 §4.1.3). An + * AS that accepted the platform CIMD client (registered for `/remote` only), sent no + * `iss`, AND matched redirect URIs by prefix would deliver a code for that client to a + * per-server URI, where the callback cannot tell it apart. No AS meeting all three is + * known; moving the per-server root would not help against string-prefix matching unless + * it shared no prefix with `/oauth-callback/remote`, and the URI shape is fixed once + * operators register it. + * + * This module is the single place that derives the variant and builds the URI, so the + * value registered at the AS, sent on authorize and replayed on the token exchange + * cannot drift apart. It deliberately imports nothing from the OAuth modules: both + * `callback.ts` and `cimd.ts` depend on it, and `cimd.ts` already reaches + * `callback.ts` through `routes/external/oauthCallback.ts`, so any OAuth import here + * would risk closing a cycle. + */ + +/** Reserved last URL segment of the shared remote callback. */ +export const REMOTE_CALLBACK_CLIENT_SEGMENT = 'remote' + +/** Public path of the shared remote callback; per-server URIs extend it. */ +export const REMOTE_CALLBACK_PATH = `/api/v1/oauth-callback/${REMOTE_CALLBACK_CLIENT_SEGMENT}` + +export type RemoteCallbackVariant = 'shared' | 'per-server' + +/** + * Derive the callback variant from a remote `spec.oauth` block. Takes the block (not + * the CR) so the install can call it on the block it is about to write, before the CR + * exists, and the runtime paths on the block read back from the CR — one derivation + * for both. `issForCallback` is third-party-derived CR data, hence `unknown`. + */ +export function remoteCallbackVariant(oauth: { issForCallback?: unknown }): RemoteCallbackVariant { + return typeof oauth.issForCallback === 'string' && oauth.issForCallback.length > 0 + ? 'shared' + : 'per-server' +} + +/** A server name usable as a callback path segment: an RFC 1123 label (the K8s name rule). */ +export function isValidRemoteServerNameSegment(value: unknown): value is string { + return typeof value === 'string' && RFC1123_RE.test(value) +} + +// Lowercase only: the install nonce is minted by `randomUUID()` (always lowercase) and +// is compared byte-for-byte against the stored `install_id`, so an uppercase spelling +// can never be legitimate and accepting it would give one nonce two valid URIs. +const INSTALL_NONCE_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ + +/** An installation nonce usable as a callback path segment: a canonical lowercase UUID. */ +export function isValidInstallNonce(value: unknown): value is string { + return typeof value === 'string' && INSTALL_NONCE_RE.test(value) +} + +// A per-server URI is compared byte-for-byte by the AS and replayed on the token +// exchange, so its origin must already be in canonical form: no path, trailing slash, +// query, fragment or userinfo, and no spelling `new URL` would normalize differently. +export function isBareOrigin(origin: unknown): origin is string { + if (typeof origin !== 'string' || origin.length === 0) return false + try { + return new URL(origin).origin === origin + } catch { + return false + } +} + +export type RemoteRedirectUriInput = + | { origin: string; variant: 'shared' } + | { + origin: string + variant: 'per-server' + mode: 'dcr' + serverName: string + installNonce: string + } + | { origin: string; variant: 'per-server'; mode: 'pre-registered'; serverName: string } + +export class InvalidRemoteRedirectUriInputError extends Error { + constructor(message: string) { + super(message) + this.name = 'InvalidRemoteRedirectUriInputError' + } +} + +/** + * Build the remote redirect URI. + * + * shared → `${origin}/api/v1/oauth-callback/remote` + * per-server, DCR → `${origin}/api/v1/oauth-callback/remote//` + * per-server, pre-registered → `${origin}/api/v1/oauth-callback/remote/` + * + * DCR carries the installation nonce because a DCR client outlives its uninstall at + * the AS (RFC 7592 deletion is best-effort): without it, a later server reusing the + * name would receive codes minted for the old, still-consented client. A pre-registered + * client is confidential and its secret is deleted with the CR, so an old code is + * useless and the URI stays stable for the operator to register once. + * + * Segments are validated and then interpolated verbatim — no `encodeURIComponent` — + * because the validators admit only characters that are already URL-safe, and the + * callback route must see the exact same bytes. Throws on any input that does not fit + * the table (invalid segment, missing/extra nonce, or a mode without a per-server URI + * such as CIMD), since a malformed redirect URI must never reach an AS. + */ +export function buildRemoteRedirectUri(input: RemoteRedirectUriInput): string { + // The shared URI keeps its historical origin handling (callers may fall back to the + // request Host); the per-server variant is only ever built from a configured origin. + if (input.variant === 'shared') { + return `${input.origin}${REMOTE_CALLBACK_PATH}` + } + if (input.variant !== 'per-server') { + throw new InvalidRemoteRedirectUriInputError( + `unknown callback variant "${String((input as { variant: unknown }).variant)}"` + ) + } + if (!isBareOrigin(input.origin)) { + throw new InvalidRemoteRedirectUriInputError( + 'a per-server redirect URI requires a bare origin (scheme://host[:port])' + ) + } + if (!isValidRemoteServerNameSegment(input.serverName)) { + throw new InvalidRemoteRedirectUriInputError('serverName is not a valid RFC 1123 label') + } + const base = `${input.origin}${REMOTE_CALLBACK_PATH}/${input.serverName}` + if (input.mode === 'dcr') { + if (!isValidInstallNonce(input.installNonce)) { + throw new InvalidRemoteRedirectUriInputError( + 'a per-server DCR redirect URI requires a lowercase UUID installNonce' + ) + } + return `${base}/${input.installNonce}` + } + if (input.mode === 'pre-registered') { + if ((input as { installNonce?: unknown }).installNonce !== undefined) { + throw new InvalidRemoteRedirectUriInputError( + 'a per-server pre-registered redirect URI carries no installNonce' + ) + } + return base + } + throw new InvalidRemoteRedirectUriInputError( + `registration mode "${String((input as { mode: unknown }).mode)}" has no per-server redirect URI` + ) +} diff --git a/control-api/src/routes/admin/__tests__/remoteMcpDiscoverErrorMapping.test.ts b/control-api/src/routes/admin/__tests__/remoteMcpDiscoverErrorMapping.test.ts index 1d52d37fb2..03653bf6e9 100644 --- a/control-api/src/routes/admin/__tests__/remoteMcpDiscoverErrorMapping.test.ts +++ b/control-api/src/routes/admin/__tests__/remoteMcpDiscoverErrorMapping.test.ts @@ -105,6 +105,10 @@ describe('discoveryHttpStatus — DiscoveryError → HTTP status decision table' status: 400, }, { error: { kind: 'issuer_mismatch', detail: 'issuer != base' }, status: 400 }, + { + error: { kind: 'as_endpoints_cross_site', field: 'token', detail: 'token on another site' }, + status: 400, + }, ] for (const { error, status } of cases) { diff --git a/control-api/src/routes/admin/remoteMcp.ts b/control-api/src/routes/admin/remoteMcp.ts index 06307fb27b..51ba1f08e1 100644 --- a/control-api/src/routes/admin/remoteMcp.ts +++ b/control-api/src/routes/admin/remoteMcp.ts @@ -10,15 +10,23 @@ import { RFC1123_RE } from '../../http/rfc1123.js' import { validateOAuthEndpointUrl } from '../../http/validateMcpServerSpec.js' import { K8sGateway } from '../../k8s.js' import { type UiAuthedRequest } from '../../middleware/controlUIAuth.js' -import { REMOTE_CALLBACK_PATH, buildCimdDocument } from '../../oauth/cimd.js' +import { buildCimdDocument } from '../../oauth/cimd.js' +import { MAX_CLIENT_ID_LENGTH, isCimdClientId } from '../../oauth/cimdIdentity.js' import { type DcrDeps, type DcrMintHandle, buildDcrRequest, registerDynamicClient, + verifyDcrRedirectUris, } from '../../oauth/dcr.js' import { bestEffortRfc7592Delete } from '../../oauth/dcrCleanup.js' -import { type DiscoveryResult, discoverRemoteOAuth } from '../../oauth/discovery.js' +import { + type DiscoveryResult, + advertisesIssBinding, + discoverRemoteOAuth, + registrationModeInputs, + selectRegistrationMode, +} from '../../oauth/discovery.js' import { discoveryHttpStatus } from '../../oauth/discoveryHttpStatus.js' import { type DynamicClientKey, @@ -29,10 +37,18 @@ import { deleteDynamicClientOwnedByInstall, getDynamicClient, insertDynamicClientPending, + isDynamicClientIdRegistered, reclaimOrphanDynamicClient, } from '../../oauth/dynamicClientStore.js' import { deriveOAuthEncryptionKey } from '../../oauth/encryption.js' import { probeMcpTransport } from '../../oauth/mcpTransportProbe.js' +import { + InvalidRemoteRedirectUriInputError, + type RemoteCallbackVariant, + buildRemoteRedirectUri, + isValidRemoteServerNameSegment, + remoteCallbackVariant, +} from '../../oauth/remoteCallback.js' import { rootLogger } from '../../observability/logger.js' import { attachServerToContext } from '../../services/contextAllowlist.js' import { K8sNotFoundError } from '../../services/resourceService.js' @@ -73,6 +89,18 @@ const BASE = '/admin/mcp-servers/remote' const log = rootLogger.child({ module: 'admin-remote-mcp' }) +/** + * A minted DCR client is being abandoned without an RFC 7592 handle: it stays + * registered at the AS. Logged the same way on every abandon path, so an abandoned + * install is never mistaken for a cleaned-up one. + */ +function logDcrRevokeLocalOnly(serverName: string, hasRegistrationClientUri: boolean): void { + log.info( + { event: 'remote_oauth_dcr_revoke_local_only', serverName, hasRegistrationClientUri }, + 'dcr revoke skipped: no RFC 7592 management handle, the minted client remains at the AS' + ) +} + // `discoveryHttpStatus` moved to `oauth/discoveryHttpStatus.js` (H-5) so the remote // and generic discover endpoints share one mapping. Re-exported here so existing // importers (and the mapping's own test) keep resolving it from this module. @@ -89,17 +117,13 @@ const installBodySchema = z.object({ contextRef: z.string().min(1), baseUrl: z.string().min(1), mode: z.enum(['cimd', 'pre-registered', 'dcr']), - clientId: z.string().min(1).optional(), + clientId: z.string().min(1).max(MAX_CLIENT_ID_LENGTH).optional(), clientSecret: z.string().min(1).optional(), grantScope: z.enum(['user', 'context']).optional(), }) type InstallBody = z.infer -function isValidK8sName(name: string): boolean { - return RFC1123_RE.test(name) && name.length <= 253 -} - /** * Fenced-delete preconditions from a just-created object, or undefined when the * apiserver response carried no complete identity. A rollback bound by @@ -248,6 +272,156 @@ function deriveDcrClientMode(result: DiscoveryResult): 'public' | 'confidential' return Array.isArray(methods) && methods.includes('none') ? 'public' : 'confidential' } +/** + * The callback variant an install from this discovery result writes. Derived from the + * `spec.oauth` block the install builds — not from the discovery fields directly — so + * the install and the runtime (which reads the block back from the CR) share one + * derivation. The builder options do not influence the variant. + */ +function callbackVariantOf(discovery: DiscoveryResult): RemoteCallbackVariant { + return remoteCallbackVariant( + buildRemoteOAuthSpec(discovery, { clientMode: 'public', grantScope: 'user' }) + ) +} + +/** + * The AS offers CIMD to a public client and would resolve to it, but for the missing + * RFC 9207 advertisement. Evaluated through the real mode selector so the answer can + * never disagree with discovery. + */ +function cimdBlockedOnlyByMissingIssBinding(discovery: DiscoveryResult): boolean { + if (advertisesIssBinding(discovery.as)) return false + const withIssBinding = { + ...registrationModeInputs(discovery.as, { hasPreRegisteredClient: false }), + issBindingSupported: true, + } + return selectRegistrationMode(withIssBinding) === 'cimd' +} + +// Stand-ins handed to the redirect-URI builder to render a template; they are replaced +// by placeholders, so the prefix the operator copies still comes from the one builder. +const TEMPLATE_SERVER_NAME = 'server-name' +const TEMPLATE_INSTALL_NONCE = '00000000-0000-0000-0000-000000000000' + +export interface RemoteCallbackPreview { + /** + * Whether a public callback base URL is configured (for `per-server`, one that is a + * bare origin). `false` blocks only a `per-server` install (503); a `shared` install + * still proceeds, falling back to the request Host at consent, and simply has no URI + * to show here. + */ + configured: boolean + variant: RemoteCallbackVariant + /** + * The redirect URI an install would register. Per-server URIs carry the literal + * placeholders `{serverName}` and, for DCR, `{installId}` (minted at install time). + */ + redirectUriTemplate?: string +} + +/** The `/discover` view of the callback an install from this result would use. */ +function previewRemoteCallback(discovery: DiscoveryResult): RemoteCallbackPreview { + const variant = callbackVariantOf(discovery) + const origin = normalizeConfiguredOrigin(config.oauthCallbackBaseUrl) + if (origin === null) return { configured: false, variant } + if (variant === 'shared') { + return { + configured: true, + variant, + redirectUriTemplate: buildRemoteRedirectUri({ origin, variant: 'shared' }), + } + } + const dcr = discovery.registrationMode === 'dcr' + const suffix = dcr + ? `/${TEMPLATE_SERVER_NAME}/${TEMPLATE_INSTALL_NONCE}` + : `/${TEMPLATE_SERVER_NAME}` + let uri: string + try { + uri = dcr + ? buildRemoteRedirectUri({ + origin, + variant, + mode: 'dcr', + serverName: TEMPLATE_SERVER_NAME, + installNonce: TEMPLATE_INSTALL_NONCE, + }) + : buildRemoteRedirectUri({ + origin, + variant, + mode: 'pre-registered', + serverName: TEMPLATE_SERVER_NAME, + }) + } catch (err) { + // A configured base URL that is not a bare origin cannot anchor a per-server URI. + if (err instanceof InvalidRemoteRedirectUriInputError) return { configured: false, variant } + throw err + } + return { + configured: true, + variant, + redirectUriTemplate: `${uri.slice(0, -suffix.length)}${dcr ? '/{serverName}/{installId}' : '/{serverName}'}`, + } +} + +/** + * Hosts (with any non-default port) of the AS endpoints an install would trust, shown + * before a per-server install so a different port is not hidden from the operator. + */ +function asEndpointHostsOf(discovery: DiscoveryResult): { + authorization: string + token: string + registration?: string +} { + const { authorization, token, registration } = discovery.endpoints + return { + authorization: new URL(authorization).host, + token: new URL(token).host, + ...(registration ? { registration: new URL(registration).host } : {}), + } +} + +export type PreRegisteredClientIdConflict = 'cimd_client' | 'remote_server' | 'dynamic_client' + +/** + * Why a pre-registered client_id cannot back a per-server install, or null. A + * per-server client must belong to exactly one server: its one registered redirect URI + * is what binds its codes to that server, and a client shared with another server (or + * one of our DCR clients, or the platform CIMD identity, which lists the shared + * callback) would let the AS deliver a code for one server to another. A client_id is + * only unique within its AS, so servers and DCR clients of another issuer never + * conflict: that AS holds a different client, with its own redirect URIs. Issuers are + * compared exactly, as discovery pins them (RFC 8414: the metadata `issuer` must equal + * the URL it was fetched from). Check-then-create without a fence: an admin-only action + * on a confidential client, where a lost race still leaves each server with its own + * redirect URI. + */ +async function preRegisteredClientIdConflict( + gateway: K8sGateway, + db: DbClient, + namespace: string, + issuer: string, + clientId: string +): Promise { + if (isCimdClientId(clientId)) return 'cimd_client' + const servers = (await gateway.listResource('mcpservers', namespace)) as Array<{ + spec?: { oauth?: { source?: unknown; id?: unknown; issuer?: unknown } } + }> + if ( + servers.some( + s => + s?.spec?.oauth?.source === 'remote' && + s.spec.oauth.id === clientId && + s.spec.oauth.issuer === issuer + ) + ) { + return 'remote_server' + } + if (await isDynamicClientIdRegistered(db, { serverNamespace: namespace, issuer, clientId })) { + return 'dynamic_client' + } + return null +} + const REMOTE_MCP_EGRESS_PROXY_IMAGE = process.env.CONTROL_API_REMOTE_MCP_EGRESS_PROXY_IMAGE || 'clerum/nginx-egress-proxy:0.1.0' const REMOTE_MCP_PROXY_PORT = 3000 @@ -372,8 +546,10 @@ export function createAdminRemoteMcpRouter( ) } + const callback = previewRemoteCallback(r) res.status(200).json({ transport, + callback, detected: { registrationMode: r.registrationMode, // DCR is available in C2. Echo the resolved client mode (derived from the @@ -392,6 +568,10 @@ export function createAdminRemoteMcpRouter( resource: r.resource, issuer: r.issuer, ...(r.issForCallback ? { issForCallback: r.issForCallback } : {}), + // Without `iss` the same-site rule is all that ties these hosts to the + // issuer, and it cannot tell two hosts of one registrable domain apart; the + // operator sees them before trusting them. + ...(callback.variant === 'per-server' ? { asEndpointHosts: asEndpointHostsOf(r) } : {}), scopes: derivedScopes(r), quirks: r.quirks, }, @@ -412,13 +592,15 @@ export function createAdminRemoteMcpRouter( } const body: InstallBody = parsed.data - if (!isValidK8sName(body.serverName)) { + // The server name becomes a segment of its per-server redirect URI, so it is held + // to the same rule the callback route applies to that segment. + if (!isValidRemoteServerNameSegment(body.serverName)) { res.status(400).json({ - error: 'invalid serverName: must be a valid K8s name (RFC 1123 label, max 253 chars)', + error: 'invalid serverName: must be a valid K8s name (RFC 1123 label, max 63 chars)', }) return } - if (!isValidK8sName(body.contextRef)) { + if (!RFC1123_RE.test(body.contextRef)) { res.status(400).json({ error: 'invalid contextRef' }) return } @@ -497,6 +679,14 @@ export function createAdminRemoteMcpRouter( // (D-4: discovery is authoritative). CIMD ⇒ AS must offer CIMD; DCR ⇒ AS must // offer a registration endpoint (registrationMode 'dcr'). if (body.mode === 'cimd' && discovery.registrationMode !== 'cimd') { + if (cimdBlockedOnlyByMissingIssBinding(discovery)) { + const fallbackMode = discovery.registrationMode === 'dcr' ? 'dcr' : 'pre-registered' + res.status(400).json({ + error: 'mode_unsupported', + message: `this authorization server supports CIMD but not RFC 9207; install with mode "${fallbackMode}"`, + }) + return + } if (discovery.registrationMode === 'dcr') { res.status(400).json({ error: 'mode_unsupported', @@ -558,29 +748,95 @@ export function createAdminRemoteMcpRouter( ) } - // RFC 9207 issuer binding is MANDATORY on the remote lane. Every remote mode - // (dcr/cimd/pre-registered) lands on the SHARED remote callback - // (`/oauth-callback/remote`), whose mix-up defence fails closed when no issuer is - // pinned (`issuer_binding_required`, R3-H2). `discovery.issForCallback` is set iff - // the AS advertised `authorization_response_iss_parameter_supported`. Reject at - // install — BEFORE any AS registration (DCR saga step 0) or K8s/store write — so - // the operator learns now instead of the user hitting a 400 at consent against a - // server that can never complete OAuth (the regression this closes, R3F-H1). - // Ordered AFTER the bearerInBody/mode-match/transport admission checks so their - // more specific rejections keep precedence; still before any write. - if (!discovery.issForCallback) { + // Callback variant, fixed before any AS registration or write: the shared callback + // when the AS returns `iss` (RFC 9207), otherwise a redirect URI of this server's + // own, which is then the only mix-up defence. + const callbackVariant = callbackVariantOf(discovery) + + // Defensive: mode selection never resolves CIMD without RFC 9207, and the mode + // check above rejects a CIMD request against such an AS. Kept so a regression + // there can never install the platform CIMD identity — which lists only the + // shared callback — onto a server with no issuer binding. + if (body.mode === 'cimd' && callbackVariant !== 'shared') { log.warn( { event: 'remote_oauth_issuer_binding_unsupported', serverName: body.serverName }, - 'remote install rejected: authorization server does not advertise RFC 9207 (authorization_response_iss_parameter_supported)' + 'remote install rejected: cimd requires an authorization server that advertises RFC 9207' ) res.status(422).json({ error: 'issuer_binding_required', message: - 'the authorization server does not advertise RFC 9207 (authorization_response_iss_parameter_supported); a remote OAuth install requires it', + 'the authorization server does not advertise RFC 9207 (authorization_response_iss_parameter_supported); a CIMD install requires it', }) return } + // Minted before anything else that needs it: a DCR client registers a redirect + // URI carrying this nonce, and every saga compensation deletes only the row that + // carries it, so a concurrent install/reinstall of the same name that reclaimed + // the row is never destroyed (R3-H1). + const installId = randomUUID() + + // The effective redirect URI. Per-server fails closed without a configured public + // origin: falling back to the request Host (as the shared callback still does) + // would register a URI the AS compares byte-for-byte against whatever Host the + // admin happened to use. Shared keeps its historical handling: DCR and CIMD + // require the origin below, pre-registered resolves it at consent. + const callbackOrigin = normalizeConfiguredOrigin(config.oauthCallbackBaseUrl) + let redirectUri: string | undefined + if (callbackVariant === 'per-server') { + try { + if (callbackOrigin === null) throw new InvalidRemoteRedirectUriInputError('no origin') + redirectUri = + body.mode === 'dcr' + ? buildRemoteRedirectUri({ + origin: callbackOrigin, + variant: 'per-server', + mode: 'dcr', + serverName: body.serverName, + installNonce: installId, + }) + : buildRemoteRedirectUri({ + origin: callbackOrigin, + variant: 'per-server', + mode: 'pre-registered', + serverName: body.serverName, + }) + } catch (err) { + if (!(err instanceof InvalidRemoteRedirectUriInputError)) throw err + log.error( + { event: 'remote_oauth_per_server_callback_unconfigured', serverName: body.serverName }, + 'per-server remote install requires a configured public callback base URL (bare origin)' + ) + res.status(503).json({ error: 'callback_base_url_unconfigured' }) + return + } + } else if (callbackOrigin !== null) { + redirectUri = buildRemoteRedirectUri({ origin: callbackOrigin, variant: 'shared' }) + } + + if (body.mode === 'pre-registered' && callbackVariant === 'per-server') { + const conflict = await preRegisteredClientIdConflict( + gateway, + db, + config.mcpServersNamespace, + discovery.issuer, + body.clientId as string + ) + if (conflict !== null) { + log.warn( + { event: 'remote_install_client_id_in_use', serverName: body.serverName, conflict }, + 'remote pre-registered install rejected: client_id already backs another client' + ) + res.status(409).json({ + error: 'oauth_client_id_in_use', + conflict, + message: + 'this client_id already backs another client; register a separate OAuth client for this server', + }) + return + } + } + // Client mode: pre-registered/CIMD are fixed by the mode; DCR derives it from // the AS auth methods (public iff `none`, else confidential) — NOT the body. const clientMode: 'public' | 'confidential' = @@ -616,10 +872,6 @@ export function createAdminRemoteMcpRouter( serverNamespace: targetNs, serverName, } - // Install-ownership token minted at the start of DCR step 0. Every compensation - // deletes ONLY the row carrying this install_id, so a concurrent install/reinstall - // of the same name that reclaimed the row is never destroyed (R3-H1). - const installId = randomUUID() let dcrRegistered = false let dcrClientId: string | undefined let dcrRegistrationClientUri: string | undefined @@ -639,10 +891,7 @@ export function createAdminRemoteMcpRouter( ) } else { // AS returned no RFC 7592 management endpoint — local delete is all we can do. - log.info( - { event: 'remote_oauth_dcr_rollback_local_only', serverName, namespace: targetNs }, - 'dcr rollback: no RFC 7592 registration_client_uri, local store delete only' - ) + logDcrRevokeLocalOnly(serverName, Boolean(dcrRegistrationClientUri)) } } @@ -670,10 +919,9 @@ export function createAdminRemoteMcpRouter( return } - // redirect_uris single source of truth: the CIMD remote callback (never a - // re-hardcoded path). Fail closed if no public callback origin is configured. - const origin = normalizeConfiguredOrigin(config.oauthCallbackBaseUrl) - if (origin === null) { + // The client registers exactly the redirect URI built above; a DCR client also + // needs it on the shared callback, so fail closed if no origin is configured. + if (redirectUri === undefined) { log.error( { event: 'remote_oauth_dcr_callback_unconfigured', serverName }, 'dcr install requires a configured public callback base URL' @@ -684,7 +932,7 @@ export function createAdminRemoteMcpRouter( const dcrRequest = buildDcrRequest(discovery, { clientMode, - redirectUris: [`${origin}${REMOTE_CALLBACK_PATH}`], + redirectUris: [redirectUri], scopes: derivedScopes(discovery), }) const dcrOutcome = await registerDynamicClient(dcrDeps, registrationEndpoint, dcrRequest) @@ -696,14 +944,17 @@ export function createAdminRemoteMcpRouter( // these variants carry the handle; non-2xx errors mint nothing. if ( (dcrError.kind === 'auth_method_unsupported' || dcrError.kind === 'invalid_response') && - dcrError.registrationClientUri && - dcrError.registrationAccessToken + dcrError.minted ) { - await bestEffortRfc7592Delete( - dcrDeps, - dcrError.registrationClientUri, - dcrError.registrationAccessToken - ) + if (dcrError.registrationClientUri && dcrError.registrationAccessToken) { + await bestEffortRfc7592Delete( + dcrDeps, + dcrError.registrationClientUri, + dcrError.registrationAccessToken + ) + } else { + logDcrRevokeLocalOnly(serverName, Boolean(dcrError.registrationClientUri)) + } } if (dcrError.kind === 'auth_method_unsupported') { log.warn( @@ -722,6 +973,7 @@ export function createAdminRemoteMcpRouter( const { registrationAccessToken: _t, registrationClientUri: _u, + minted: _m, ...safeDetail } = dcrError as typeof dcrError & Partial res.status(400).json({ error: 'dcr_registration_failed', detail: safeDetail }) @@ -748,8 +1000,40 @@ export function createAdminRemoteMcpRouter( mintedRegistrationClientUri, mintedRegistrationAccessToken ) + return } + logDcrRevokeLocalOnly(serverName, Boolean(mintedRegistrationClientUri)) + } + // Refuse a client we cannot trust to redirect only to this installation, before + // anything is persisted. Per-server: the registered URI is the mix-up defence, so + // the AS must report exactly the one we asked for. Any variant: a client_id equal + // to the platform CIMD identity would be indistinguishable from that shared + // client downstream. + const redirectCheck = verifyDcrRedirectUris({ + variant: callbackVariant, + requested: dcrRequest.redirect_uris, + response: registration, + }) + const rejection = !redirectCheck.ok + ? redirectCheck.reason + : isCimdClientId(registration.client_id) + ? 'client_id_is_cimd_identity' + : null + if (rejection !== null) { + await revokeMintedClient() + log.warn( + { + event: 'remote_oauth_dcr_registration_rejected', + serverName, + variant: callbackVariant, + reason: rejection, + }, + 'dcr registration response rejected; revoked the minted client' + ) + res.status(400).json({ error: 'dcr_registration_failed', detail: { kind: rejection } }) + return } + // A public client carries no secret; never persist one the AS may have echoed // alongside a `none` downgrade. const dcrCredentials: UpsertDynamicClientInput = { @@ -1179,6 +1463,11 @@ export function createAdminRemoteMcpRouter( contextUpdated: true, clientMode: effectiveClientMode, registrationMode: discovery.registrationMode, + callbackVariant, + // What the AS must hold for consent to work — the operator registers it for a + // pre-registered client. Absent only for a shared pre-registered install with no + // configured origin, whose URI is resolved from the request at consent. + ...(redirectUri ? { redirectUri } : {}), ...(clientSecretName ? { clientSecretName } : {}), }) }) diff --git a/control-api/src/routes/external/oauthCallback.ts b/control-api/src/routes/external/oauthCallback.ts index 1872093d38..5b33957a33 100644 --- a/control-api/src/routes/external/oauthCallback.ts +++ b/control-api/src/routes/external/oauthCallback.ts @@ -1,10 +1,12 @@ -import { Router } from 'express' +import { type NextFunction, type Request, type Response, Router } from 'express' import { config } from '../../config.js' import { pool } from '../../db.js' import { K8sGateway } from '../../k8s.js' import { + type CallbackTarget, type McpServerOAuthReader, type McpServerOAuthSubject, + REMOTE_CALLBACK_CLIENT_SEGMENT, RecipeNotFoundError, type RecipeReader, type RecipeWithOAuthClients, @@ -18,6 +20,7 @@ import { type McpServerOAuthSpecInput, resolveServerOAuthSubject, } from '../../oauth/mcpServerOAuthSpec.js' +import { isValidInstallNonce, isValidRemoteServerNameSegment } from '../../oauth/remoteCallback.js' import { getUserContexts } from '../../services/directory/index.js' import { K8sNotFoundError } from '../../services/resourceService.js' @@ -88,7 +91,7 @@ export function createOAuthCallbackRouter(gateway: K8sGateway): Router { if (err instanceof K8sNotFoundError) return null throw err } - const resolved = resolveServerOAuthSubject(server) + const resolved = resolveServerOAuthSubject(server, 'consent') if (!resolved) return null // `resolved.crUid` seals grants written by this consent to the CR's identity: // the uid is the apiserver's, unique per object, so a same-name reinstall's @@ -97,29 +100,26 @@ export function createOAuthCallbackRouter(gateway: K8sGateway): Router { }, } - // Stable callback path: only the oauthClientId rides the URL (one registered - // redirect URI per provider client). The recipe (namespace, name) is recovered - // from the signed state inside handleOAuthCallback, so the URI no longer churns - // per recipe instance / catalog version. No namespace check is needed here: the - // recipe namespace comes from the unforgeable state, and both authorize-url - // minters only sign sandbox-namespace states. - router.get('/oauth-callback/:oauthClientId', async (req, res, next) => { + async function completeCallback( + req: Request, + res: Response, + next: NextFunction, + target: CallbackTarget + ): Promise { try { - const { oauthClientId } = req.params const code = typeof req.query.code === 'string' ? req.query.code : '' const state = typeof req.query.state === 'string' ? req.query.state : '' - // RFC 9207 issuer — validated on the remote lane against the pinned - // `issForCallback` inside handleOAuthCallback (AS mix-up defence). - const iss = typeof req.query.iss === 'string' ? req.query.iss : undefined - if (!code || !state) { return res.status(400).json({ error: 'missing_code_or_state' }) } - - const redirectUri = buildPublicCallbackUrl(req, oauthClientId, config.oauthCallbackBaseUrl) + // The desktop deep link and the not-configured body name the integration by the + // URL's client id; the remote callbacks have none, so both variants report the + // reserved `remote` (the desktop routes remote consents by `mcpServerName`). + const integrationId = target.kind === 'client' ? target.id : REMOTE_CALLBACK_CLIENT_SEGMENT const result = await handleOAuthCallback( - { oauthClientId, code, state, redirectUri, iss }, + // `iss` goes through raw: each remote variant decides how a malformed value reads. + { target, code, state, iss: req.query.iss }, { db: { query: (text, values) => pool.query(text, values) }, recipeReader, @@ -140,10 +140,11 @@ export function createOAuthCallbackRouter(gateway: K8sGateway): Router { .status(200) .type('html') .send( - renderSuccessHtml(result.provider, oauthClientId, { + renderSuccessHtml(result.provider, integrationId, { backgroundRequested: result.backgroundRequested, backgroundEnabled: result.backgroundEnabled, source: result.source, + // From the signed state, never from the URL segment. mcpServerName: result.mcpServerName, }) ) @@ -158,6 +159,8 @@ export function createOAuthCallbackRouter(gateway: K8sGateway): Router { // issuer at install, so the shared remote callback cannot attribute the // code. Fail closed — same 400 class, distinct error for operator triage. return res.status(400).json({ error: 'issuer_binding_required' }) + case 'callback_base_url_unconfigured': + return res.status(503).json({ error: 'callback_base_url_unconfigured' }) case 'unknown_oauth_client': return res.status(400).json({ error: 'unknown_oauth_client' }) case 'recipe_not_found': @@ -173,7 +176,7 @@ export function createOAuthCallbackRouter(gateway: K8sGateway): Router { .status(409) .json({ error: 'remote_oauth_spec_incoherent', reason: result.reason }) case 'secret_missing': - return res.status(503).json(integrationNotConfigured(oauthClientId, result.secret)) + return res.status(503).json(integrationNotConfigured(integrationId, result.secret)) case 'unsupported_provider': return res.status(500).json({ error: 'unsupported_provider', provider: result.provider }) case 'provider_token_exchange_failed': @@ -186,6 +189,55 @@ export function createOAuthCallbackRouter(gateway: K8sGateway): Router { } catch (err) { next(err) } + } + + // Per-server remote callback, for an AS that does not return RFC 9207 `iss`: the URI + // itself binds the code to one server (and, for DCR, one installation). Public like + // its sibling — authentication is the signed state plus that binding. The segments + // are re-validated here with the same rules the public gateway applies: this route is + // also reachable without the gateway, and an unvalidated segment must never reach + // the handler. + router.get('/oauth-callback/remote/:serverName/:installNonce?', (req, res, next) => { + const { serverName, installNonce } = req.params as { + serverName: string + installNonce?: string + } + if ( + !isValidRemoteServerNameSegment(serverName) || + (installNonce !== undefined && !isValidInstallNonce(installNonce)) + ) { + res.status(404).json({ error: 'Not Found' }) + return + } + void completeCallback(req, res, next, { + kind: 'remote-per-server', + serverName, + installNonce, + // Configured origin only: a per-server URI anchored on the request Host would + // differ from the one registered at the AS whenever the Host differs. + origin: normalizeConfiguredOrigin(config.oauthCallbackBaseUrl), + }) + }) + + // `/oauth-callback/`: one registered redirect URI per provider client, + // stable across recipe versions — the recipe (namespace, name) is recovered from the + // signed state, and both authorize-url minters only sign sandbox-namespace states. + // The reserved `remote` segment is the shared callback of every remote server whose + // AS returns RFC 9207 `iss`. + router.get('/oauth-callback/:oauthClientId', (req, res, next) => { + const { oauthClientId } = req.params + const target: CallbackTarget = + oauthClientId === REMOTE_CALLBACK_CLIENT_SEGMENT + ? { + kind: 'remote-shared', + origin: resolveCallbackOrigin(req, config.oauthCallbackBaseUrl), + } + : { + kind: 'client', + id: oauthClientId, + redirectUri: buildPublicCallbackUrl(req, oauthClientId, config.oauthCallbackBaseUrl), + } + void completeCallback(req, res, next, target) }) return router @@ -205,6 +257,20 @@ export function normalizeConfiguredOrigin(configuredBaseUrl?: string): string | return configuredBaseUrl.replace(/\/+$/, '') } +/** + * Origin of the callback URLs that tolerate an unconfigured base URL (the per-client + * and shared remote callbacks): the configured public base URL, else the request Host. + */ +export function resolveCallbackOrigin( + req: { protocol: string; get: (h: string) => string | undefined }, + configuredBaseUrl?: string +): string { + return ( + normalizeConfiguredOrigin(configuredBaseUrl) ?? + `${req.protocol}://${req.get('host') ?? 'localhost'}` + ) +} + export function buildPublicCallbackUrl( req: { protocol: string; get: (h: string) => string | undefined }, oauthClientId: string, @@ -220,9 +286,7 @@ export function buildPublicCallbackUrl( // request Host is an internal hostname, so prefer an explicitly configured // public base URL (CONTROL_API_OAUTH_CALLBACK_BASE_URL). Fall back to the // request Host for local/dev where none is set. - const origin = - normalizeConfiguredOrigin(configuredBaseUrl) ?? - `${req.protocol}://${req.get('host') ?? 'localhost'}` + const origin = resolveCallbackOrigin(req, configuredBaseUrl) return `${origin}/api/v1/oauth-callback/${encodeURIComponent(oauthClientId)}` } diff --git a/control-api/src/routes/internal/oauth.ts b/control-api/src/routes/internal/oauth.ts index 5ed8d33db3..f6a91c959d 100644 --- a/control-api/src/routes/internal/oauth.ts +++ b/control-api/src/routes/internal/oauth.ts @@ -1,4 +1,4 @@ -import { Router } from 'express' +import { type Request, Router } from 'express' import { config } from '../../config.js' import { pool } from '../../db.js' import { K8sGateway } from '../../k8s.js' @@ -8,7 +8,6 @@ import { buildAuthorizeUrl } from '../../oauth/authorizeUrlHelper.js' import { type McpServerOAuthReader, type McpServerOAuthSubject, - REMOTE_CALLBACK_CLIENT_SEGMENT, RecipeNotFoundError, type RecipeReader, type RecipeWithOAuthClients, @@ -16,23 +15,45 @@ import { type SecretReader, } from '../../oauth/callback.js' import { deriveOAuthEncryptionKey } from '../../oauth/encryption.js' -import { integrationNotConfigured, isSecretNotFound } from '../../oauth/integrationNotConfigured.js' +import { + type IntegrationNotConfiguredBody, + integrationNotConfigured, + isSecretNotFound, +} from '../../oauth/integrationNotConfigured.js' import { type McpServerOAuthSpecInput, RemoteOAuthSpecIncoherentError, + type ResolvedServerOAuthSubject, buildMcpServerGrantKey, resolveServerOAuth, resolveServerOAuthSubject, } from '../../oauth/mcpServerOAuthSpec.js' +import { + buildPerServerRedirectUri, + resolvePerServerRegistration, +} from '../../oauth/perServerRegistration.js' import { getAccessTokenReactive } from '../../oauth/reactiveTokenHelper.js' +import { + buildRemoteRedirectUri, + isBareOrigin, + isValidRemoteServerNameSegment, + remoteCallbackVariant, +} from '../../oauth/remoteCallback.js' import { deleteOAuthGrant } from '../../oauth/store.js' import { getUserContexts } from '../../services/directory/index.js' import { K8sNotFoundError } from '../../services/resourceService.js' -import { buildPublicCallbackUrl } from '../external/oauthCallback.js' +import { + buildPublicCallbackUrl, + normalizeConfiguredOrigin, + resolveCallbackOrigin, +} from '../external/oauthCallback.js' // DNS-1123 subdomain — the shape a k8s resource name takes. Reject anything else // up front so a malformed `mcpServerName` becomes a 400 rather than surfacing a -// non-404 apiserver error as a 500. (Mirrors routes/mcpOauth.ts.) +// non-404 apiserver error as a 500. (Mirrors routes/mcpOauth.ts.) Deliberately wider +// than the per-server callback segment (an RFC 1123 label): these routes serve every +// OAuth lane, and only a per-server remote server needs its name to fit in a URL +// segment — that narrower rule is applied where its redirect URI is built. const K8S_NAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/ function isValidK8sName(name: string): boolean { return name.length > 0 && name.length <= 253 && K8S_NAME_RE.test(name) @@ -134,12 +155,82 @@ export function createInternalOAuthRouter(gateway: K8sGateway): Router { if (err instanceof K8sNotFoundError) return null throw err } - const resolved = resolveServerOAuthSubject(server) + const resolved = resolveServerOAuthSubject(server, 'consent') if (!resolved) return null return { namespace: config.mcpServersNamespace, ...resolved } }, } + /** + * The redirect URI an mcp-server's authorize URL carries — the one its client + * registered at the AS, which the callback replays on the token exchange: + * - baked/generic: `/oauth-callback/`; + * - remote, shared (AS returns RFC 9207 `iss`): `/oauth-callback/remote`; + * - remote, per-server: `/oauth-callback/remote/[/]`, the nonce + * read from the `dynamic_clients` row bound to this CR. + * Per-server fails closed without a configured bare origin: the AS compares the URI + * byte-for-byte, and a request-Host fallback would depend on how the caller reached + * us. Minting for a server whose callback would reject the code is refused here + * rather than after the user has consented. + */ + async function mintRedirectUri( + req: Request, + subject: ResolvedServerOAuthSubject, + mcpServerName: string + ): Promise<{ ok: true; redirectUri: string } | { ok: false; status: number; body: object }> { + const remote = subject.decl.remote + if (!remote) { + return { + ok: true, + redirectUri: buildPublicCallbackUrl(req, subject.decl.id, config.oauthCallbackBaseUrl), + } + } + if (remoteCallbackVariant(remote) === 'shared') { + return { + ok: true, + redirectUri: buildRemoteRedirectUri({ + origin: resolveCallbackOrigin(req, config.oauthCallbackBaseUrl), + variant: 'shared', + }), + } + } + // Only a CR written outside the install can carry a name that is not a label; its + // callback route would never match, so there is no URI to mint. + if (!isValidRemoteServerNameSegment(mcpServerName)) { + return { ok: false, status: 400, body: { error: 'invalid_request' } } + } + const origin = normalizeConfiguredOrigin(config.oauthCallbackBaseUrl) + if (origin === null || !isBareOrigin(origin)) { + req.log?.error( + { event: 'remote_oauth_per_server_callback_unconfigured', mcpServerName }, + 'per-server remote consent requires a configured public callback base URL (bare origin)' + ) + return { ok: false, status: 503, body: { error: 'callback_base_url_unconfigured' } } + } + const registration = await resolvePerServerRegistration( + { query: (text, values) => pool.query(text, values) }, + { namespace: config.mcpServersNamespace, decl: subject.decl, crUid: subject.crUid }, + mcpServerName + ) + if (!registration) { + // Same contract as any other missing client credential, but there is no Secret to + // create: the DCR client is only (re)registered by installing the server again. + return { + ok: false, + status: 503, + body: { + error: 'integration_not_configured', + integration: subject.decl.id, + hint: `reinstall the remote MCP server ${mcpServerName} to register its OAuth client again`, + } satisfies IntegrationNotConfiguredBody, + } + } + return { + ok: true, + redirectUri: buildPerServerRedirectUri(origin, mcpServerName, registration), + } + } + // ── U5: mint a fresh authorize-URL for an OAuth mcp-server, on click ────── // // rpc-proxy fronts this from the desktop "Connect " surface. The @@ -194,7 +285,7 @@ export function createInternalOAuthRouter(gateway: K8sGateway): Router { let subject: ReturnType = null let incoherent: RemoteOAuthSpecIncoherentError | undefined try { - subject = resolveServerOAuthSubject(server) + subject = resolveServerOAuthSubject(server, 'consent') } catch (err) { if (!(err instanceof RemoteOAuthSpecIncoherentError)) throw err incoherent = err @@ -246,18 +337,9 @@ export function createInternalOAuthRouter(gateway: K8sGateway): Router { if (!resolved) return res.status(400).json({ error: 'not_oauth_server' }) const oauthClientId = resolved.decl.id - // Remote lane registers ONE stable redirect_uri (`/oauth-callback/remote`), - // so the callback URL segment is the reserved constant, NOT the client id — - // the real binding rides the signed state. Baked keeps the per-client - // segment. The exchange re-derives the same redirect_uri from this segment. - const callbackSegment = resolved.decl.remote - ? REMOTE_CALLBACK_CLIENT_SEGMENT - : oauthClientId - const redirectUri = buildPublicCallbackUrl( - req, - callbackSegment, - config.oauthCallbackBaseUrl - ) + const redirect = await mintRedirectUri(req, resolved, mcpServerName) + if (!redirect.ok) return res.status(redirect.status).json(redirect.body) + const redirectUri = redirect.redirectUri const result = await buildAuthorizeUrl( { diff --git a/control-api/src/routes/mcpOauth.ts b/control-api/src/routes/mcpOauth.ts index 16115e619a..744c742f84 100644 --- a/control-api/src/routes/mcpOauth.ts +++ b/control-api/src/routes/mcpOauth.ts @@ -105,7 +105,7 @@ export function normalizeMcpServerOwnerDecl( // IDENTICALLY to the mint + callback (D4 — no drift). The remote decl carries the // pinned routing + secretSource that `getAccessToken` branches on. if (lane === 'remote') { - const resolved = resolveServerOAuthSubject(server) + const resolved = resolveServerOAuthSubject(server, 'token') if (!resolved) return null return { metadata: ownerMetadata(server), spec: { oauthClients: [resolved.decl] } } } @@ -114,7 +114,7 @@ export function normalizeMcpServerOwnerDecl( // gets the pinned `generic` routing + secretSource that `getAccessToken` // branches on. if (lane === 'generic') { - const resolved = resolveServerOAuthSubject(server) + const resolved = resolveServerOAuthSubject(server, 'token') if (!resolved) return null return { metadata: ownerMetadata(server), spec: { oauthClients: [resolved.decl] } } } @@ -475,7 +475,7 @@ export function createMcpOauthRouter(gateway: K8sGateway): Router { } // Checked up front, not only through the refresh reader: a still-fresh // access token is served without ever reading the owner declaration. - assertRemoteOAuthSpecCoherent(server) + assertRemoteOAuthSpecCoherent(server, 'token') // Bifurcate by grantScope read from the server. The KEY comes from the // shared derivation (`buildMcpServerGrantKey`, D4) so the mint, the diff --git a/control-api/test/db.codexSubscriptionConnection.realPostgres.integration.test.ts b/control-api/test/db.codexSubscriptionConnection.realPostgres.integration.test.ts index d101e4bfc1..1ec520c5d8 100644 --- a/control-api/test/db.codexSubscriptionConnection.realPostgres.integration.test.ts +++ b/control-api/test/db.codexSubscriptionConnection.realPostgres.integration.test.ts @@ -48,12 +48,15 @@ describeRealPostgres('Codex subscription connection on real PostgreSQL', () => { }, 60_000) afterAll(async () => { - await pool?.end() - if (adminPool) { - await adminPool - .query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) - .catch(() => undefined) - await adminPool.end() + try { + await pool?.end() + if (adminPool) { + await adminPool + .query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) + .catch(() => undefined) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/db.listGrantsReferencingModel.realPostgres.integration.test.ts b/control-api/test/db.listGrantsReferencingModel.realPostgres.integration.test.ts index 3974ec3edc..811477dd4c 100644 --- a/control-api/test/db.listGrantsReferencingModel.realPostgres.integration.test.ts +++ b/control-api/test/db.listGrantsReferencingModel.realPostgres.integration.test.ts @@ -3,6 +3,7 @@ import { randomBytes } from 'node:crypto' import { Pool } from 'pg' import { initDb } from '../src/db.js' import { listGrantsReferencingModel } from '../src/services/pluginWorkloadSdkDb.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' // FIX C (T1/T3): exercise the REAL jsonb `@>` containment of // listGrantsReferencingModel against a real Postgres — the risky part the unit @@ -63,17 +64,20 @@ describeRealPostgres('listGrantsReferencingModel jsonb containment on real Postg }) afterAll(async () => { - await dbPool?.end() - if (!adminPool) return - await adminPool.query( - `SELECT pg_terminate_backend(pid) - FROM pg_stat_activity - WHERE datname = $1 - AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) - await adminPool.end() + try { + await endPoolAndWaitForClients(dbPool) + if (!adminPool) return + await adminPool.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 + AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) + } finally { + await adminPool?.end() + } }) async function insertGrant( diff --git a/control-api/test/db.llmProviderAttemptConnectionIntegrity.realPostgres.integration.test.ts b/control-api/test/db.llmProviderAttemptConnectionIntegrity.realPostgres.integration.test.ts index bb915dd363..a3e6ad43bb 100644 --- a/control-api/test/db.llmProviderAttemptConnectionIntegrity.realPostgres.integration.test.ts +++ b/control-api/test/db.llmProviderAttemptConnectionIntegrity.realPostgres.integration.test.ts @@ -75,12 +75,15 @@ describeRealPostgres('0114 llm_provider_attempts connection integrity on real Po }, 60_000) afterAll(async () => { - await pool?.end() - if (adminPool) { - await adminPool - .query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) - .catch(() => undefined) - await adminPool.end() + try { + await pool?.end() + if (adminPool) { + await adminPool + .query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) + .catch(() => undefined) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/db.pluginWorkloadSdkRuntimeContract.realPostgres.integration.test.ts b/control-api/test/db.pluginWorkloadSdkRuntimeContract.realPostgres.integration.test.ts index 91599a8b39..ce82ffae5e 100644 --- a/control-api/test/db.pluginWorkloadSdkRuntimeContract.realPostgres.integration.test.ts +++ b/control-api/test/db.pluginWorkloadSdkRuntimeContract.realPostgres.integration.test.ts @@ -8,6 +8,7 @@ import { } from '../src/services/pluginWorkloadSdkFinalization.js' import { tokenUsagePayload } from '../src/services/tracing/usageProjection.js' import type { LlmUsageEvent } from '../src/services/usageEvents.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -157,17 +158,20 @@ describeRealPostgres('Plugin Workload SDK runtime-contract upgrade on real Postg }) afterAll(async () => { - await dbPool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) - FROM pg_stat_activity - WHERE datname = $1 - AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) - await adminPool.end() + try { + await endPoolAndWaitForClients(dbPool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 + AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/db.realPostgresMigration.integration.test.ts b/control-api/test/db.realPostgresMigration.integration.test.ts index 08e2451ba0..8f8b06fcc2 100644 --- a/control-api/test/db.realPostgresMigration.integration.test.ts +++ b/control-api/test/db.realPostgresMigration.integration.test.ts @@ -30,6 +30,7 @@ import { PostgresGovernedSessionReplayRepository } from '../src/services/tracing import { projectAcceptedUsageEvents } from '../src/services/tracing/usageProjection.js' import { ingestUsageEventsInTransaction } from '../src/services/usageEvents.js' import { signRpcAccessToken } from '../src/utils/auth/rpcAuthToken.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' import './realPostgres.requirement.ts' type PrivilegeExpectation = Record> @@ -185,17 +186,20 @@ describeRealPostgres('control-api real Postgres migrations', () => { }) afterAll(async () => { - await dbPool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) - FROM pg_stat_activity - WHERE datname = $1 - AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) - await adminPool.end() + try { + await endPoolAndWaitForClients(dbPool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 + AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) + } + } finally { + await adminPool?.end() } }) @@ -2128,7 +2132,7 @@ describeRealPostgres('control-api real Postgres migrations', () => { expect(config).toEqual(['search_path=pg_catalog, public, pg_temp']) } } finally { - await attackerPool?.end() + await endPoolAndWaitForClients(attackerPool) await dbPool.query(`DROP OWNED BY ${quoteIdent(attackerRole)}`) await adminPool.query(`DROP ROLE IF EXISTS ${quoteIdent(attackerRole)}`) } diff --git a/control-api/test/devSigningKeys.test.ts b/control-api/test/devSigningKeys.test.ts index 374e430a89..5c10a4a63d 100644 --- a/control-api/test/devSigningKeys.test.ts +++ b/control-api/test/devSigningKeys.test.ts @@ -353,7 +353,7 @@ describe('devSigningKeys persistence contract', () => { const expected = createPublicKey(signing).export({ type: 'spki', format: 'pem' }).toString() const fd = openSync(join(store, 'rpc.public.pem'), 'r') try { - expect(fstatSync(fd).mode & 0o777).toBe(0o644) + expect(fstatSync(fd).mode & 0o777).toBe(0o644 & ~process.umask()) expect(readFileSync(fd, 'utf8').trim()).toBe(expected.trim()) } finally { closeSync(fd) diff --git a/control-api/test/entityChangeFeed.realPostgres.integration.test.ts b/control-api/test/entityChangeFeed.realPostgres.integration.test.ts index 96f3e11725..a0adee818f 100644 --- a/control-api/test/entityChangeFeed.realPostgres.integration.test.ts +++ b/control-api/test/entityChangeFeed.realPostgres.integration.test.ts @@ -3,6 +3,7 @@ import { randomBytes, randomUUID } from 'node:crypto' import { Pool } from 'pg' import { initDb } from '../src/db.js' import { DbSeedResourceStore, seedRootDirectories } from '../src/gfs/seedResources.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' import './realPostgres.requirement.ts' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL @@ -39,16 +40,19 @@ describeRealPostgres('entity change feed real PostgreSQL contract', () => { }) afterAll(async () => { - await instancePool?.end() - await replicaPool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) - await adminPool.end() + try { + await endPoolAndWaitForClients(instancePool) + await endPoolAndWaitForClients(replicaPool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/fixtures/legacyOAuthGrant.ts b/control-api/test/fixtures/legacyOAuthGrant.ts index 738ab0eff7..4ed516d5d8 100644 --- a/control-api/test/fixtures/legacyOAuthGrant.ts +++ b/control-api/test/fixtures/legacyOAuthGrant.ts @@ -19,7 +19,11 @@ import { vi } from 'vitest' import { config } from '../../src/config.js' import type { DbClient } from '../../src/db.js' import type { PinnedTransport } from '../../src/http/pinnedFetch.js' -import { type McpServerOAuthSubject, handleOAuthCallback } from '../../src/oauth/callback.js' +import { + type CallbackTarget, + type McpServerOAuthSubject, + handleOAuthCallback, +} from '../../src/oauth/callback.js' import { type DiscoveryResult, discoverRemoteOAuth } from '../../src/oauth/discovery.js' import { type McpServerOAuthSpecInput, @@ -164,9 +168,13 @@ function tokenResponse(tokens: ConsentTokens) { } } -function consentInput(name: string, userId: string) { +function consentInput(name: string, userId: string, target?: CallbackTarget) { return { - oauthClientId: OAUTH_ID, + target: target ?? { + kind: 'client' as const, + id: OAUTH_ID, + redirectUri: `https://control.example.com/api/v1/oauth-callback/${OAUTH_ID}`, + }, code: `code-${name}-${userId}`, state: signOAuthState(STATE_SECRET, { subjectKind: 'mcp', @@ -176,7 +184,6 @@ function consentInput(name: string, userId: string) { grantKind: 'user', background: false, }), - redirectUri: `https://control.example.com/api/v1/oauth-callback/${OAUTH_ID}`, } } @@ -211,7 +218,7 @@ export async function legacyConsent( async read(serverName): Promise { const cr = (await gateway.getResource('mcpservers', serverName, NS)) as ServerCR const { uid: _preIdentityPodNeverReadIt, ...metadata } = cr.metadata - const resolved = resolveServerOAuthSubject({ ...cr, metadata }) + const resolved = resolveServerOAuthSubject({ ...cr, metadata }, 'consent') return resolved ? { namespace: NS, ...resolved } : null }, }, @@ -239,8 +246,15 @@ export async function currentConsent( /** RFC 9207 `iss` of the authorization response; the remote lane requires it. */ iss?: string ): Promise { + // Each lane's own callback URL, as its authorize URL registers it: a remote server + // always consents on a remote URI (the shared one here, as `iss` is supplied). + const cr = (await gateway.getResource('mcpservers', name, NS)) as ServerCR + const target: CallbackTarget | undefined = + (cr.spec as { oauth?: { source?: unknown } }).oauth?.source === 'remote' + ? { kind: 'remote-shared', origin: 'https://control.example.com' } + : undefined const result = await handleOAuthCallback( - { ...consentInput(name, userId), iss }, + { ...consentInput(name, userId, target), iss }, { db, recipeReader: { read: async () => null }, @@ -248,7 +262,7 @@ export async function currentConsent( mcpServerReader: { async read(serverName): Promise { const cr = (await gateway.getResource('mcpservers', serverName, NS)) as ServerCR - const resolved = resolveServerOAuthSubject(cr) + const resolved = resolveServerOAuthSubject(cr, 'consent') return resolved ? { namespace: NS, ...resolved } : null }, }, diff --git a/control-api/test/fixtures/perServerInstall.ts b/control-api/test/fixtures/perServerInstall.ts new file mode 100644 index 0000000000..abb76cc486 --- /dev/null +++ b/control-api/test/fixtures/perServerInstall.ts @@ -0,0 +1,108 @@ +/** + * Remote MCP servers installed through the REAL admin install route, for the consent + * (authorize-url + callback) suites of the per-server callback variant. + * + * Everything the consent path reads is produced by its real producer (T1): discovery + * by `discoverRemoteOAuth` over the probe fixtures, the CR and its `metadata.uid` by + * the install saga on a `MockGateway`, the `dynamic_clients` row — install nonce and + * `cr_uid` binding included — by the saga's own store calls on whatever `DbClient` the + * suite passes (the in-memory harness or a real Postgres). The RFC 7591 registration + * response is the only external fixture (a live DCR POST would create a client at a + * third party); the AS echoes the `redirect_uris` actually POSTed. + */ +import express from 'express' +import request from 'supertest' +import type { DbClient } from '../../src/db.js' +import type { PinnedTransport } from '../../src/http/pinnedFetch.js' +import type { K8sGateway } from '../../src/k8s.js' +import { type DiscoveryResult, discoverRemoteOAuth } from '../../src/oauth/discovery.js' +import { probeMcpTransport } from '../../src/oauth/mcpTransportProbe.js' +import { createAdminRemoteMcpRouter } from '../../src/routes/admin/remoteMcp.js' +import type { MockGateway } from '../mockGateway.js' +import { + DCR_PUBLIC_REGISTRATION_RESPONSE, + type RecordedDcrCall, + makeDcrTransport, + makeDiscoveryTransport, +} from './remoteOAuthDiscovery.js' + +export const PUBLIC_IP = '93.184.216.34' +const resolvePublic = async () => [PUBLIC_IP] + +/** Run the real discovery client over a probe fixture. */ +export async function discoverPilot( + pilot: Parameters[0] +): Promise { + const outcome = await discoverRemoteOAuth(pilot.mcpUrl, { + transport: makeDiscoveryTransport(pilot), + resolveDns: resolvePublic, + }) + if (!outcome.ok) throw new Error(`fixture discovery failed: ${outcome.error.kind}`) + return outcome.result +} + +/** Documented T1 substitution: add RFC 9207 to a pilot's AS metadata. */ +export function withRfc9207(pilot: T): T { + const as = JSON.parse(pilot.as.json) + as.authorization_response_iss_parameter_supported = true + return { ...pilot, as: { ...pilot.as, json: JSON.stringify(as) } } +} + +// A tokenless POST `initialize` answered with a Bearer challenge: transport alive. +const ALIVE_TRANSPORT: PinnedTransport = async ({ method }) => + method === 'POST' + ? { status: 401, headers: { 'www-authenticate': 'Bearer realm="OAuth"' }, bodyText: '' } + : { status: 404, headers: {}, bodyText: '' } + +const aliveProbe: typeof probeMcpTransport = (baseUrl, deps, opts) => + probeMcpTransport( + baseUrl, + { ...deps, transport: ALIVE_TRANSPORT, resolveDns: resolvePublic }, + opts + ) + +export interface InstallOutcome { + status: number + body: Record + /** Every call the DCR transport received (empty for a pre-registered install). */ + dcrCalls: RecordedDcrCall[] +} + +/** Seed the Context the install attaches the server to. */ +export async function seedContext(gateway: MockGateway, namespace: string, name: string) { + await gateway.createResource( + 'contexts', + { metadata: { name }, spec: { contextId: name } }, + namespace + ) +} + +/** + * POST /admin/mcp-servers/remote through the real router. `discovery` is what the + * install's server-side discovery resolves; for DCR the AS assigns `registration`. + */ +export async function installRemoteServer(opts: { + gateway: MockGateway + db: DbClient + discovery: DiscoveryResult + body: Record + registration?: Record +}): Promise { + const { transport, calls } = makeDcrTransport({ + registrationEndpoint: opts.discovery.endpoints.registration, + responseJson: JSON.stringify(opts.registration ?? DCR_PUBLIC_REGISTRATION_RESPONSE), + redirectUris: 'requested', + }) + const app = express() + app.use(express.json()) + app.use( + createAdminRemoteMcpRouter(opts.gateway as unknown as K8sGateway, { + db: opts.db, + discover: async () => ({ ok: true, result: opts.discovery }), + probe: aliveProbe, + dcr: { transport, resolveDns: resolvePublic }, + }) + ) + const res = await request(app).post('/admin/mcp-servers/remote').send(opts.body) + return { status: res.status, body: res.body as Record, dcrCalls: calls } +} diff --git a/control-api/test/fixtures/remoteOAuthDiscovery.ts b/control-api/test/fixtures/remoteOAuthDiscovery.ts index b397c800ed..9966223b63 100644 --- a/control-api/test/fixtures/remoteOAuthDiscovery.ts +++ b/control-api/test/fixtures/remoteOAuthDiscovery.ts @@ -43,8 +43,11 @@ export const NOTION_AS_JSON = export const LINEAR_PRM_JSON = '{"resource":"https://mcp.linear.app/mcp","authorization_servers":["https://mcp.linear.app"],"scopes_supported":["read","write"],"bearer_methods_supported":["header"]}' // AS: GET https://mcp.linear.app/.well-known/oauth-authorization-server → 200 +// Re-probed 2026-09-29: Linear now advertises RFC 9207 +// (`authorization_response_iss_parameter_supported: true`), which the 2026-09-20 bytes +// did not carry. Byte-identical otherwise. export const LINEAR_AS_JSON = - '{"issuer":"https://mcp.linear.app","authorization_endpoint":"https://mcp.linear.app/authorize","token_endpoint":"https://mcp.linear.app/token","registration_endpoint":"https://mcp.linear.app/register","scopes_supported":["read","write","openid","email"],"response_types_supported":["code"],"response_modes_supported":["query"],"grant_types_supported":["authorization_code","refresh_token","urn:ietf:params:oauth:grant-type:jwt-bearer"],"authorization_grant_profiles_supported":["urn:ietf:params:oauth:grant-profile:id-jag"],"token_endpoint_auth_methods_supported":["client_secret_basic","client_secret_post","none"],"revocation_endpoint":"https://mcp.linear.app/token","code_challenge_methods_supported":["S256"],"client_id_metadata_document_supported":true,"resource":"https://mcp.linear.app/mcp","resource_metadata":"https://mcp.linear.app/.well-known/oauth-protected-resource/mcp"}' + '{"issuer":"https://mcp.linear.app","authorization_endpoint":"https://mcp.linear.app/authorize","token_endpoint":"https://mcp.linear.app/token","registration_endpoint":"https://mcp.linear.app/register","scopes_supported":["read","write","openid","email"],"response_types_supported":["code"],"response_modes_supported":["query"],"grant_types_supported":["authorization_code","refresh_token","urn:ietf:params:oauth:grant-type:jwt-bearer"],"authorization_grant_profiles_supported":["urn:ietf:params:oauth:grant-profile:id-jag"],"token_endpoint_auth_methods_supported":["client_secret_basic","client_secret_post","none"],"revocation_endpoint":"https://mcp.linear.app/token","code_challenge_methods_supported":["S256"],"authorization_response_iss_parameter_supported":true,"client_id_metadata_document_supported":true,"resource":"https://mcp.linear.app/mcp","resource_metadata":"https://mcp.linear.app/.well-known/oauth-protected-resource/mcp"}' // ─── Sentry (PRM SOLO path-suffixed; root → 404; 401 trae WWW-Authenticate) ─ // 401 header emitted identically on GET and POST of https://mcp.sentry.dev/mcp @@ -202,6 +205,67 @@ export const VERCEL_PILOT: PilotFixture = { }, } +// ─── Atlassian v2 + Dropbox — ASes WITHOUT RFC 9207 (sondeo en vivo 2026-09-29) ── +// +// The exact bytes both servers returned on 2026-09-29 (curl, `Accept: application/json`). +// Neither AS advertises `authorization_response_iss_parameter_supported`, so both take +// the per-server callback path and the same-site rule applies to their endpoints. +// +// PROVENANCE (curl, 2026-09-29): +// GET https://mcp.atlassian.com/v2/mcp → 401 www-authenticate: Bearer resource_metadata="https://mcp.atlassian.com/.well-known/oauth-protected-resource/v2/mcp" +// GET https://mcp.atlassian.com/.well-known/oauth-protected-resource/v2/mcp → 200 +// GET https://auth.atlassian.com/.well-known/oauth-authorization-server/VCeDsk8ZHncYF1g234fKtc4lNipbBhu3 → 200 +// GET https://auth.atlassian.com/VCeDsk8ZHncYF1g234fKtc4lNipbBhu3/.well-known/openid-configuration → 404 +// GET https://mcp.dropbox.com/mcp → 401 www-authenticate: Bearer resource_metadata="https://mcp.dropbox.com/.well-known/oauth-protected-resource/mcp", error="invalid_token", error_description="missing bearer token" +// GET https://mcp.dropbox.com/.well-known/oauth-protected-resource/mcp → 200 +// GET https://www.dropbox.com/.well-known/oauth-authorization-server → 200 +// +// Atlassian: CIMD + DCR + `none`, every endpoint on atlassian.com (same site as the +// issuer) → DCR, never CIMD. Dropbox: token and revocation endpoints on +// api.dropboxapi.com while the issuer is www.dropbox.com → cross-site. + +export const ATLASSIAN_V2_WWW_AUTHENTICATE = + 'Bearer resource_metadata="https://mcp.atlassian.com/.well-known/oauth-protected-resource/v2/mcp"' +export const ATLASSIAN_V2_PRM_JSON = + '{"resource":"https://mcp.atlassian.com/v2/mcp","authorization_servers":["https://auth.atlassian.com/VCeDsk8ZHncYF1g234fKtc4lNipbBhu3"],"bearer_methods_supported":["header"],"scopes_supported":["read:me","read:account","offline_access","email","read:jira:agent-interface","write:jira:agent-interface","search:jira:agent-interface","delete:jira:agent-interface","manage:jira:agent-interface","read:confluence:agent-interface","write:confluence:agent-interface","search:confluence:agent-interface","search:rovo:agent-interface","search:code:agent-interface","read:all:twg","write:all:twg","read:goals:agent-interface","write:goals:agent-interface","read:projects:agent-interface","write:projects:agent-interface","read:bitbucket:agent-interface","write:bitbucket:agent-interface","read:loom:agent-interface","write:loom:agent-interface","read:talent:agent-interface","write:talent:agent-interface","read:jira-align:agent-interface","write:jira-align:agent-interface","read:teams:agent-interface","write:teams:agent-interface","read:artifacts:agent-interface","write:artifacts:agent-interface","read:capacity-planning:agent-interface","write:capacity-planning:agent-interface","read:focus:agent-interface","write:focus:agent-interface","read:assets:agent-interface","write:assets:agent-interface"],"resource_documentation":"https://www.atlassian.com/platform/remote-mcp-server"}' +export const ATLASSIAN_V2_AS_JSON = + '{"authorization_endpoint":"https://auth.atlassian.com/authorize","client_id_metadata_document_supported":true,"code_challenge_methods_supported":["S256"],"grant_types_supported":["authorization_code","client_credentials","refresh_token","urn:ietf:params:oauth:grant-type:token-revoke","urn:ietf:params:oauth:grant-type:jwt-bearer","urn:ietf:params:oauth:grant-type:token-exchange"],"issuer":"https://auth.atlassian.com/VCeDsk8ZHncYF1g234fKtc4lNipbBhu3","jwks_uri":"https://auth.atlassian.com/.well-known/jwks.json","pushed_authorization_request_endpoint":"https://auth.atlassian.com/oauth/par","registration_endpoint":"https://auth.atlassian.com/VCeDsk8ZHncYF1g234fKtc4lNipbBhu3/dcr/register","response_types_supported":["code","token"],"revocation_endpoint":"https://auth.atlassian.com/oauth/revoke","token_endpoint":"https://auth.atlassian.com/oauth/token","token_endpoint_auth_methods_supported":["none","client_secret_post","client_secret_basic","private_key_jwt"]}' + +export const ATLASSIAN_V2_PILOT: PilotFixture = { + name: 'atlassian-v2', + mcpUrl: 'https://mcp.atlassian.com/v2/mcp', + wwwAuthenticate: ATLASSIAN_V2_WWW_AUTHENTICATE, + prm: { + url: 'https://mcp.atlassian.com/.well-known/oauth-protected-resource/v2/mcp', + json: ATLASSIAN_V2_PRM_JSON, + }, + as: { + url: 'https://auth.atlassian.com/.well-known/oauth-authorization-server/VCeDsk8ZHncYF1g234fKtc4lNipbBhu3', + json: ATLASSIAN_V2_AS_JSON, + }, +} + +export const DROPBOX_WWW_AUTHENTICATE = + 'Bearer resource_metadata="https://mcp.dropbox.com/.well-known/oauth-protected-resource/mcp", error="invalid_token", error_description="missing bearer token"' +export const DROPBOX_PRM_JSON = + '{"resource":"https://mcp.dropbox.com/mcp","authorization_servers":["https://www.dropbox.com"],"scopes_supported":["account_info.read","file_requests.read","file_requests.write","files.content.read","files.content.write","files.metadata.read","files.metadata.write","sharing.read","sharing.write"]}' +export const DROPBOX_AS_JSON = + '{"issuer": "https://www.dropbox.com", "authorization_endpoint": "https://www.dropbox.com/oauth2/authorize", "token_endpoint": "https://api.dropboxapi.com/oauth2/token", "revocation_endpoint": "https://api.dropboxapi.com/2/auth/token/revoke", "scopes_supported": ["account_info.read", "contacts.read", "contacts.write", "files.metadata.read", "files.content.read", "files.content.write", "files.permanent_delete", "files.metadata.write", "sharing.read", "sharing.write", "file_requests.read", "file_requests.write", "openid", "profile", "email", "account_info.write", "backup.read", "backup.write", "dash/content.read", "dash/content.write", "team_info.read", "team_info.write", "sessions.list", "sessions.modify", "members.read", "members.write", "members.delete", "groups.read", "groups.write", "events.read", "team_data.governance.read", "team_data.governance.write", "team_data.member", "team_data.content.read", "team_data.content.write", "team_data.team_space", "files.team_metadata.write", "events.write", "files.team_metadata.read"], "response_types_supported": ["code"], "grant_types_supported": ["authorization_code", "client_credentials", "refresh_token", "device_code"], "code_challenge_methods_supported": ["plain", "S256"], "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic", "none"], "registration_endpoint": "https://www.dropbox.com/oauth2/register"}' + +export const DROPBOX_PILOT: PilotFixture = { + name: 'dropbox', + mcpUrl: 'https://mcp.dropbox.com/mcp', + wwwAuthenticate: DROPBOX_WWW_AUTHENTICATE, + prm: { + url: 'https://mcp.dropbox.com/.well-known/oauth-protected-resource/mcp', + json: DROPBOX_PRM_JSON, + }, + as: { + url: 'https://www.dropbox.com/.well-known/oauth-authorization-server', + json: DROPBOX_AS_JSON, + }, +} + /** * Build a {@link PinnedTransport}-shaped mock from a pilot fixture: a 401 (with * WWW-Authenticate) on the MCP probe when the fixture has a hint, 200+JSON on the @@ -353,6 +417,16 @@ export function makeDcrTransport(opts: { responseJson: string status?: number managementUri?: string + /** + * How the AS reports `redirect_uris` in the registration response: + * - `'requested'`: echo the `redirect_uris` of the request actually POSTed, as an + * RFC 7591 §3.2.1 AS does. Needed wherever the requested URI is only known at + * install time (the per-server DCR URI carries a fresh install nonce). + * - `'omit'`: drop the field. + * - a string array: report these instead (an AS that registered something else). + * Unset: `responseJson` verbatim. + */ + redirectUris?: 'requested' | 'omit' | string[] }): { transport: PinnedTransport; calls: RecordedDcrCall[] } { const registrationEndpoint = opts.registrationEndpoint ?? DCR_REGISTRATION_ENDPOINT const status = opts.status ?? 201 @@ -360,10 +434,21 @@ export function makeDcrTransport(opts: { const transport: PinnedTransport = async ({ url, method, headers, body }) => { calls.push({ url, method, headers, body }) if (url === registrationEndpoint && method === 'POST') { + let bodyText = opts.responseJson + if (opts.redirectUris !== undefined) { + const response = JSON.parse(opts.responseJson) as Record + if (opts.redirectUris === 'omit') delete response.redirect_uris + else if (opts.redirectUris === 'requested') + response.redirect_uris = ( + JSON.parse(body ?? '{}') as { redirect_uris?: unknown } + ).redirect_uris + else response.redirect_uris = opts.redirectUris + bodyText = JSON.stringify(response) + } return { status, headers: { 'content-type': 'application/json' }, - bodyText: opts.responseJson, + bodyText, } } if (method === 'DELETE') { @@ -558,6 +643,24 @@ export function makeInMemoryDynamicClientsDb(): { } return { rows: [], rowCount: 0 } } + if ( + text.includes('FROM dynamic_clients') && + text.includes('client_id = $3') && + text.includes('AND issuer = $4') + ) { + // Existence by (issuer, client_id) within a namespace (pre-registered uniqueness). + // Matched on the issuer predicate too: a query that dropped it must not be + // answered as if it still filtered by issuer. + const [owner_kind, server_namespace, client_id, issuer] = values + const hit = [...rows.values()].some( + row => + row.owner_kind === owner_kind && + row.server_namespace === server_namespace && + row.client_id === client_id && + row.issuer === issuer + ) + return hit ? { rows: [{ '?column?': 1 }], rowCount: 1 } : { rows: [], rowCount: 0 } + } if (text.includes('FROM dynamic_clients')) { const [owner_kind, server_namespace, server_name] = values const row = rows.get(keyOf(owner_kind, server_namespace, server_name)) diff --git a/control-api/test/fixtures/wire/remoteMcp.discover.atlassian.json b/control-api/test/fixtures/wire/remoteMcp.discover.atlassian.json new file mode 100644 index 0000000000..111aefbdd3 --- /dev/null +++ b/control-api/test/fixtures/wire/remoteMcp.discover.atlassian.json @@ -0,0 +1,80 @@ +{ + "status": 200, + "body": { + "transport": { + "status": "alive", + "probedUrl": "https://mcp.atlassian.com/v2/mcp", + "httpStatus": 401, + "challenge": true + }, + "callback": { + "configured": true, + "variant": "per-server", + "redirectUriTemplate": "https://control.example.com/api/v1/oauth-callback/remote/{serverName}/{installId}" + }, + "detected": { + "registrationMode": "dcr", + "dcr": { + "available": true, + "clientMode": "public", + "supportsRefresh": true + }, + "endpoints": { + "authorization": "https://auth.atlassian.com/authorize", + "token": "https://auth.atlassian.com/oauth/token", + "registration": "https://auth.atlassian.com/VCeDsk8ZHncYF1g234fKtc4lNipbBhu3/dcr/register" + }, + "resource": "https://mcp.atlassian.com/v2/mcp", + "issuer": "https://auth.atlassian.com/VCeDsk8ZHncYF1g234fKtc4lNipbBhu3", + "asEndpointHosts": { + "authorization": "auth.atlassian.com", + "token": "auth.atlassian.com", + "registration": "auth.atlassian.com" + }, + "scopes": [ + "read:me", + "read:account", + "offline_access", + "email", + "read:jira:agent-interface", + "write:jira:agent-interface", + "search:jira:agent-interface", + "delete:jira:agent-interface", + "manage:jira:agent-interface", + "read:confluence:agent-interface", + "write:confluence:agent-interface", + "search:confluence:agent-interface", + "search:rovo:agent-interface", + "search:code:agent-interface", + "read:all:twg", + "write:all:twg", + "read:goals:agent-interface", + "write:goals:agent-interface", + "read:projects:agent-interface", + "write:projects:agent-interface", + "read:bitbucket:agent-interface", + "write:bitbucket:agent-interface", + "read:loom:agent-interface", + "write:loom:agent-interface", + "read:talent:agent-interface", + "write:talent:agent-interface", + "read:jira-align:agent-interface", + "write:jira-align:agent-interface", + "read:teams:agent-interface", + "write:teams:agent-interface", + "read:artifacts:agent-interface", + "write:artifacts:agent-interface", + "read:capacity-planning:agent-interface", + "write:capacity-planning:agent-interface", + "read:focus:agent-interface", + "write:focus:agent-interface", + "read:assets:agent-interface", + "write:assets:agent-interface" + ], + "quirks": { + "bearerInBody": false, + "supportsRefresh": true + } + } + } +} diff --git a/control-api/test/fixtures/wire/remoteMcp.discover.atlassianUnconfigured.json b/control-api/test/fixtures/wire/remoteMcp.discover.atlassianUnconfigured.json new file mode 100644 index 0000000000..e44d70b78f --- /dev/null +++ b/control-api/test/fixtures/wire/remoteMcp.discover.atlassianUnconfigured.json @@ -0,0 +1,79 @@ +{ + "status": 200, + "body": { + "transport": { + "status": "alive", + "probedUrl": "https://mcp.atlassian.com/v2/mcp", + "httpStatus": 401, + "challenge": true + }, + "callback": { + "configured": false, + "variant": "per-server" + }, + "detected": { + "registrationMode": "dcr", + "dcr": { + "available": true, + "clientMode": "public", + "supportsRefresh": true + }, + "endpoints": { + "authorization": "https://auth.atlassian.com/authorize", + "token": "https://auth.atlassian.com/oauth/token", + "registration": "https://auth.atlassian.com/VCeDsk8ZHncYF1g234fKtc4lNipbBhu3/dcr/register" + }, + "resource": "https://mcp.atlassian.com/v2/mcp", + "issuer": "https://auth.atlassian.com/VCeDsk8ZHncYF1g234fKtc4lNipbBhu3", + "asEndpointHosts": { + "authorization": "auth.atlassian.com", + "token": "auth.atlassian.com", + "registration": "auth.atlassian.com" + }, + "scopes": [ + "read:me", + "read:account", + "offline_access", + "email", + "read:jira:agent-interface", + "write:jira:agent-interface", + "search:jira:agent-interface", + "delete:jira:agent-interface", + "manage:jira:agent-interface", + "read:confluence:agent-interface", + "write:confluence:agent-interface", + "search:confluence:agent-interface", + "search:rovo:agent-interface", + "search:code:agent-interface", + "read:all:twg", + "write:all:twg", + "read:goals:agent-interface", + "write:goals:agent-interface", + "read:projects:agent-interface", + "write:projects:agent-interface", + "read:bitbucket:agent-interface", + "write:bitbucket:agent-interface", + "read:loom:agent-interface", + "write:loom:agent-interface", + "read:talent:agent-interface", + "write:talent:agent-interface", + "read:jira-align:agent-interface", + "write:jira-align:agent-interface", + "read:teams:agent-interface", + "write:teams:agent-interface", + "read:artifacts:agent-interface", + "write:artifacts:agent-interface", + "read:capacity-planning:agent-interface", + "write:capacity-planning:agent-interface", + "read:focus:agent-interface", + "write:focus:agent-interface", + "read:assets:agent-interface", + "write:assets:agent-interface" + ], + "quirks": { + "bearerInBody": false, + "supportsRefresh": true + } + } + } +} diff --git a/control-api/test/fixtures/wire/remoteMcp.discover.dcrConfidential.json b/control-api/test/fixtures/wire/remoteMcp.discover.dcrConfidential.json new file mode 100644 index 0000000000..ee5dd865cd --- /dev/null +++ b/control-api/test/fixtures/wire/remoteMcp.discover.dcrConfidential.json @@ -0,0 +1,41 @@ +{ + "status": 200, + "body": { + "transport": { + "status": "alive", + "probedUrl": "https://mcp.notion.com/mcp", + "httpStatus": 401, + "challenge": true + }, + "callback": { + "configured": true, + "variant": "per-server", + "redirectUriTemplate": "https://control.example.com/api/v1/oauth-callback/remote/{serverName}/{installId}" + }, + "detected": { + "registrationMode": "dcr", + "dcr": { + "available": true, + "clientMode": "confidential", + "supportsRefresh": true + }, + "endpoints": { + "authorization": "https://mcp.notion.com/authorize", + "token": "https://mcp.notion.com/token", + "registration": "https://mcp.notion.com/register" + }, + "resource": "https://mcp.notion.com", + "issuer": "https://mcp.notion.com", + "asEndpointHosts": { + "authorization": "mcp.notion.com", + "token": "mcp.notion.com", + "registration": "mcp.notion.com" + }, + "scopes": ["default"], + "quirks": { + "bearerInBody": false, + "supportsRefresh": true + } + } + } +} diff --git a/control-api/test/fixtures/wire/remoteMcp.discover.dropbox.json b/control-api/test/fixtures/wire/remoteMcp.discover.dropbox.json new file mode 100644 index 0000000000..bd95adc923 --- /dev/null +++ b/control-api/test/fixtures/wire/remoteMcp.discover.dropbox.json @@ -0,0 +1,11 @@ +{ + "status": 400, + "body": { + "error": "discovery_failed", + "detail": { + "kind": "as_endpoints_cross_site", + "field": "token", + "detail": "token endpoint is on site \"dropboxapi.com\", issuer is on site \"dropbox.com\"" + } + } +} diff --git a/control-api/test/fixtures/wire/remoteMcp.discover.issuerPublicSuffix.json b/control-api/test/fixtures/wire/remoteMcp.discover.issuerPublicSuffix.json new file mode 100644 index 0000000000..bab9656c8a --- /dev/null +++ b/control-api/test/fixtures/wire/remoteMcp.discover.issuerPublicSuffix.json @@ -0,0 +1,11 @@ +{ + "status": 400, + "body": { + "error": "discovery_failed", + "detail": { + "kind": "as_endpoints_cross_site", + "field": "issuer", + "detail": "issuer has no registrable domain (public suffix, IP literal, userinfo or invalid URL)" + } + } +} diff --git a/control-api/test/fixtures/wire/remoteMcp.discover.linear.json b/control-api/test/fixtures/wire/remoteMcp.discover.linear.json new file mode 100644 index 0000000000..1a76791ba0 --- /dev/null +++ b/control-api/test/fixtures/wire/remoteMcp.discover.linear.json @@ -0,0 +1,32 @@ +{ + "status": 200, + "body": { + "transport": { + "status": "alive", + "probedUrl": "https://mcp.linear.app/mcp", + "httpStatus": 401, + "challenge": true + }, + "callback": { + "configured": true, + "variant": "shared", + "redirectUriTemplate": "https://control.example.com/api/v1/oauth-callback/remote" + }, + "detected": { + "registrationMode": "cimd", + "endpoints": { + "authorization": "https://mcp.linear.app/authorize", + "token": "https://mcp.linear.app/token", + "registration": "https://mcp.linear.app/register" + }, + "resource": "https://mcp.linear.app/mcp", + "issuer": "https://mcp.linear.app", + "issForCallback": "https://mcp.linear.app", + "scopes": ["read", "write"], + "quirks": { + "bearerInBody": false, + "supportsRefresh": true + } + } + } +} diff --git a/control-api/test/fixtures/wire/remoteMcp.discover.linearUnconfigured.json b/control-api/test/fixtures/wire/remoteMcp.discover.linearUnconfigured.json new file mode 100644 index 0000000000..f38b0482af --- /dev/null +++ b/control-api/test/fixtures/wire/remoteMcp.discover.linearUnconfigured.json @@ -0,0 +1,31 @@ +{ + "status": 200, + "body": { + "transport": { + "status": "alive", + "probedUrl": "https://mcp.linear.app/mcp", + "httpStatus": 401, + "challenge": true + }, + "callback": { + "configured": false, + "variant": "shared" + }, + "detected": { + "registrationMode": "cimd", + "endpoints": { + "authorization": "https://mcp.linear.app/authorize", + "token": "https://mcp.linear.app/token", + "registration": "https://mcp.linear.app/register" + }, + "resource": "https://mcp.linear.app/mcp", + "issuer": "https://mcp.linear.app", + "issForCallback": "https://mcp.linear.app", + "scopes": ["read", "write"], + "quirks": { + "bearerInBody": false, + "supportsRefresh": true + } + } + } +} diff --git a/control-api/test/fixtures/wire/remoteMcp.discover.notion.json b/control-api/test/fixtures/wire/remoteMcp.discover.notion.json new file mode 100644 index 0000000000..02fa418364 --- /dev/null +++ b/control-api/test/fixtures/wire/remoteMcp.discover.notion.json @@ -0,0 +1,41 @@ +{ + "status": 200, + "body": { + "transport": { + "status": "alive", + "probedUrl": "https://mcp.notion.com/mcp", + "httpStatus": 401, + "challenge": true + }, + "callback": { + "configured": true, + "variant": "per-server", + "redirectUriTemplate": "https://control.example.com/api/v1/oauth-callback/remote/{serverName}/{installId}" + }, + "detected": { + "registrationMode": "dcr", + "dcr": { + "available": true, + "clientMode": "public", + "supportsRefresh": true + }, + "endpoints": { + "authorization": "https://mcp.notion.com/authorize", + "token": "https://mcp.notion.com/token", + "registration": "https://mcp.notion.com/register" + }, + "resource": "https://mcp.notion.com", + "issuer": "https://mcp.notion.com", + "asEndpointHosts": { + "authorization": "mcp.notion.com", + "token": "mcp.notion.com", + "registration": "mcp.notion.com" + }, + "scopes": ["default"], + "quirks": { + "bearerInBody": false, + "supportsRefresh": true + } + } + } +} diff --git a/control-api/test/fixtures/wire/remoteMcp.discover.preRegisteredPerServer.json b/control-api/test/fixtures/wire/remoteMcp.discover.preRegisteredPerServer.json new file mode 100644 index 0000000000..febe1cff2c --- /dev/null +++ b/control-api/test/fixtures/wire/remoteMcp.discover.preRegisteredPerServer.json @@ -0,0 +1,34 @@ +{ + "status": 200, + "body": { + "transport": { + "status": "alive", + "probedUrl": "https://mcp.linear.app/mcp", + "httpStatus": 401, + "challenge": true + }, + "callback": { + "configured": true, + "variant": "per-server", + "redirectUriTemplate": "https://control.example.com/api/v1/oauth-callback/remote/{serverName}" + }, + "detected": { + "registrationMode": "manual", + "endpoints": { + "authorization": "https://mcp.linear.app/authorize", + "token": "https://mcp.linear.app/token" + }, + "resource": "https://mcp.linear.app/mcp", + "issuer": "https://mcp.linear.app", + "asEndpointHosts": { + "authorization": "mcp.linear.app", + "token": "mcp.linear.app" + }, + "scopes": ["read", "write"], + "quirks": { + "bearerInBody": false, + "supportsRefresh": true + } + } + } +} diff --git a/control-api/test/fixtures/wire/remoteMcp.install.atlassian.json b/control-api/test/fixtures/wire/remoteMcp.install.atlassian.json new file mode 100644 index 0000000000..1fcb8c33ff --- /dev/null +++ b/control-api/test/fixtures/wire/remoteMcp.install.atlassian.json @@ -0,0 +1,13 @@ +{ + "status": 201, + "body": { + "serverName": "atlassian", + "namespace": "mcp-server", + "contextRef": "research", + "contextUpdated": true, + "clientMode": "public", + "registrationMode": "dcr", + "callbackVariant": "per-server", + "redirectUri": "https://control.example.com/api/v1/oauth-callback/remote/atlassian/5b0f3c2e-8d1a-4c6f-9e2b-7a4d1f0c9e31" + } +} diff --git a/control-api/test/fixtures/wire/remoteMcp.install.callbackUnconfigured.json b/control-api/test/fixtures/wire/remoteMcp.install.callbackUnconfigured.json new file mode 100644 index 0000000000..2d03d344d4 --- /dev/null +++ b/control-api/test/fixtures/wire/remoteMcp.install.callbackUnconfigured.json @@ -0,0 +1,6 @@ +{ + "status": 503, + "body": { + "error": "callback_base_url_unconfigured" + } +} diff --git a/control-api/test/fixtures/wire/remoteMcp.install.cimdWithoutIssBinding.json b/control-api/test/fixtures/wire/remoteMcp.install.cimdWithoutIssBinding.json new file mode 100644 index 0000000000..6a7f3e4251 --- /dev/null +++ b/control-api/test/fixtures/wire/remoteMcp.install.cimdWithoutIssBinding.json @@ -0,0 +1,7 @@ +{ + "status": 400, + "body": { + "error": "mode_unsupported", + "message": "this authorization server supports CIMD but not RFC 9207; install with mode \"dcr\"" + } +} diff --git a/control-api/test/fixtures/wire/remoteMcp.install.clientIdInUse.json b/control-api/test/fixtures/wire/remoteMcp.install.clientIdInUse.json new file mode 100644 index 0000000000..17f34deda2 --- /dev/null +++ b/control-api/test/fixtures/wire/remoteMcp.install.clientIdInUse.json @@ -0,0 +1,8 @@ +{ + "status": 409, + "body": { + "error": "oauth_client_id_in_use", + "conflict": "remote_server", + "message": "this client_id already backs another client; register a separate OAuth client for this server" + } +} diff --git a/control-api/test/fixtures/wire/remoteMcp.install.dcrRedirectMismatch.json b/control-api/test/fixtures/wire/remoteMcp.install.dcrRedirectMismatch.json new file mode 100644 index 0000000000..b64124a91e --- /dev/null +++ b/control-api/test/fixtures/wire/remoteMcp.install.dcrRedirectMismatch.json @@ -0,0 +1,9 @@ +{ + "status": 400, + "body": { + "error": "dcr_registration_failed", + "detail": { + "kind": "redirect_uris_mismatch" + } + } +} diff --git a/control-api/test/fixtures/wire/remoteMcp.install.linear.json b/control-api/test/fixtures/wire/remoteMcp.install.linear.json new file mode 100644 index 0000000000..7adfb916b6 --- /dev/null +++ b/control-api/test/fixtures/wire/remoteMcp.install.linear.json @@ -0,0 +1,13 @@ +{ + "status": 201, + "body": { + "serverName": "linear", + "namespace": "mcp-server", + "contextRef": "research", + "contextUpdated": true, + "clientMode": "public", + "registrationMode": "cimd", + "callbackVariant": "shared", + "redirectUri": "https://control.example.com/api/v1/oauth-callback/remote" + } +} diff --git a/control-api/test/fixtures/wire/remoteMcp.install.preRegisteredPerServer.json b/control-api/test/fixtures/wire/remoteMcp.install.preRegisteredPerServer.json new file mode 100644 index 0000000000..4bb72a6d5b --- /dev/null +++ b/control-api/test/fixtures/wire/remoteMcp.install.preRegisteredPerServer.json @@ -0,0 +1,14 @@ +{ + "status": 201, + "body": { + "serverName": "linear-pre", + "namespace": "mcp-server", + "contextRef": "research", + "contextUpdated": true, + "clientMode": "confidential", + "registrationMode": "pre-registered", + "callbackVariant": "per-server", + "redirectUri": "https://control.example.com/api/v1/oauth-callback/remote/linear-pre", + "clientSecretName": "linear-pre-oauth-client" + } +} diff --git a/control-api/test/fixtures/wire/remoteMcp.install.preRegisteredShared.json b/control-api/test/fixtures/wire/remoteMcp.install.preRegisteredShared.json new file mode 100644 index 0000000000..6bd0761c01 --- /dev/null +++ b/control-api/test/fixtures/wire/remoteMcp.install.preRegisteredShared.json @@ -0,0 +1,14 @@ +{ + "status": 201, + "body": { + "serverName": "linear-pre", + "namespace": "mcp-server", + "contextRef": "research", + "contextUpdated": true, + "clientMode": "confidential", + "registrationMode": "pre-registered", + "callbackVariant": "shared", + "redirectUri": "https://control.example.com/api/v1/oauth-callback/remote", + "clientSecretName": "linear-pre-oauth-client" + } +} diff --git a/control-api/test/gfsAuditDecisionEvidence.realPostgres.integration.test.ts b/control-api/test/gfsAuditDecisionEvidence.realPostgres.integration.test.ts index 5a13e3d5e2..547f095d5a 100644 --- a/control-api/test/gfsAuditDecisionEvidence.realPostgres.integration.test.ts +++ b/control-api/test/gfsAuditDecisionEvidence.realPostgres.integration.test.ts @@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { randomBytes } from 'node:crypto' import { Pool, type PoolClient } from 'pg' import { CONTROL_API_MIGRATIONS, initDb } from '../src/db.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -49,15 +50,18 @@ describeRealPostgres('GFS typed audit decision evidence', () => { }, 60_000) afterAll(async () => { - await pool?.end() - if (!adminPool) return - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) - await adminPool.end() + try { + await endPoolAndWaitForClients(pool) + if (!adminPool) return + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) + } finally { + await adminPool?.end() + } }) it('is additive, idempotent, and constrains the evidence vocabulary', async () => { diff --git a/control-api/test/gfsBlobMigration.realPostgres.integration.test.ts b/control-api/test/gfsBlobMigration.realPostgres.integration.test.ts index c04b504253..56f6f57772 100644 --- a/control-api/test/gfsBlobMigration.realPostgres.integration.test.ts +++ b/control-api/test/gfsBlobMigration.realPostgres.integration.test.ts @@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { randomBytes, randomUUID } from 'node:crypto' import { Pool } from 'pg' import { initDb } from '../src/db.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -35,15 +36,18 @@ describeRealPostgres('GFS immutable blob migration on real PostgreSQL', () => { }, 60_000) afterAll(async () => { - await pool?.end() - if (!adminPool) return - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) - await adminPool.end() + try { + await endPoolAndWaitForClients(pool) + if (!adminPool) return + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) + } finally { + await adminPool?.end() + } }) it('applies 0071_gfs_immutable_blob_generations exactly once with named constraints, indexes, and writer ACL', async () => { diff --git a/control-api/test/gfsBlobReconciliation.realPostgres.integration.test.ts b/control-api/test/gfsBlobReconciliation.realPostgres.integration.test.ts index 9b66ae892a..7c73b0a77b 100644 --- a/control-api/test/gfsBlobReconciliation.realPostgres.integration.test.ts +++ b/control-api/test/gfsBlobReconciliation.realPostgres.integration.test.ts @@ -11,6 +11,7 @@ import type { GfsMetrics } from '../../gfs-controller/src/metrics.js' import { BlobStore } from '../../gfs-controller/src/storage/blobStore.js' import { resolveBlobKeyPath, resolveBlobPath } from '../../gfs-controller/src/storage/paths.js' import { initDb } from '../src/db.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' // This suite closes the reconciler's HIGH coverage gap: reconcileExpiredBlobs is // the sole backstop for the write/copy paths' swallowed cleanup errors, yet its @@ -101,16 +102,19 @@ describeRealPostgres('GFS blob reconciliation on real PostgreSQL + on-disk BlobS }) afterAll(async () => { - await pool?.end() - await rm(blobRoot, { recursive: true, force: true }).catch(() => undefined) - if (!adminPool) return - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) - await adminPool.end() + try { + await endPoolAndWaitForClients(pool) + await rm(blobRoot, { recursive: true, force: true }).catch(() => undefined) + if (!adminPool) return + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) + } finally { + await adminPool?.end() + } }) it('reaps an orphaned generation blob: deletes the real bytes AND removes the manifest row', async () => { diff --git a/control-api/test/gfsCopy.realPostgres.integration.test.ts b/control-api/test/gfsCopy.realPostgres.integration.test.ts index 50a7399a0e..90fe1e84b6 100644 --- a/control-api/test/gfsCopy.realPostgres.integration.test.ts +++ b/control-api/test/gfsCopy.realPostgres.integration.test.ts @@ -11,6 +11,7 @@ import { PgResourceStore } from '../../gfs-controller/src/db/resourceStore.js' import { GfsWriteService, PgTransactor } from '../../gfs-controller/src/db/writeStore.js' import { BlobStore } from '../../gfs-controller/src/storage/blobStore.js' import { initDb } from '../src/db.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -154,16 +155,19 @@ describeRealPostgres('GFS Copy publication on real PostgreSQL and filesystem blo }, 60_000) afterAll(async () => { - await pool?.end() - await rm(blobRoot, { recursive: true, force: true }).catch(() => undefined) - if (!adminPool) return - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) - await adminPool.end() + try { + await endPoolAndWaitForClients(pool) + await rm(blobRoot, { recursive: true, force: true }).catch(() => undefined) + if (!adminPool) return + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) + } finally { + await adminPool?.end() + } }) it('publishes a recursive copy atomically, preserves the source, and records success in the publication transaction', async () => { diff --git a/control-api/test/gfsReaderRole.realPostgres.integration.test.ts b/control-api/test/gfsReaderRole.realPostgres.integration.test.ts index e212bfd453..6b8b4b0a97 100644 --- a/control-api/test/gfsReaderRole.realPostgres.integration.test.ts +++ b/control-api/test/gfsReaderRole.realPostgres.integration.test.ts @@ -3,6 +3,7 @@ import { randomBytes } from 'node:crypto' import { Pool } from 'pg' import { createPermissionStoreProbe } from '../../gfs-controller/src/authz/storeProbe.js' import { CONTROL_API_MIGRATIONS, initDb } from '../src/db.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -62,20 +63,23 @@ describeRealPostgres('GFS reader and writer login isolation', () => { }, 60_000) afterAll(async () => { - await readerPool?.end() - await writerPool?.end() - await pool?.query('ALTER ROLE gfs_controller NOLOGIN NOINHERIT').catch(() => undefined) - await pool?.query('ALTER ROLE gfs_controller_reader NOLOGIN NOINHERIT').catch(() => undefined) - await pool?.query(`DROP ROLE IF EXISTS ${quoteIdent(inheritedRole)}`).catch(() => undefined) - await pool?.end() - if (!adminPool) return - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + try { + await endPoolAndWaitForClients(readerPool) + await endPoolAndWaitForClients(writerPool) + await pool?.query('ALTER ROLE gfs_controller NOLOGIN NOINHERIT').catch(() => undefined) + await pool?.query('ALTER ROLE gfs_controller_reader NOLOGIN NOINHERIT').catch(() => undefined) + await pool?.query(`DROP ROLE IF EXISTS ${quoteIdent(inheritedRole)}`).catch(() => undefined) + await endPoolAndWaitForClients(pool) + if (!adminPool) return + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) + } finally { + await adminPool?.end() + } }) it('authenticates distinct DSNs as the exact NOINHERIT runtime identities', async () => { @@ -180,7 +184,10 @@ describeRealPostgres('GFS reader and writer login isolation', () => { rows: [{ current_user: 'gfs_controller_reader' }], }) } finally { - await Promise.all([freshWriter.end(), freshReader.end()]) + await Promise.all([ + endPoolAndWaitForClients(freshWriter), + endPoolAndWaitForClients(freshReader), + ]) } }) diff --git a/control-api/test/gfsReadiness.realPostgres.integration.test.ts b/control-api/test/gfsReadiness.realPostgres.integration.test.ts index c91d4fe910..a230e7648d 100644 --- a/control-api/test/gfsReadiness.realPostgres.integration.test.ts +++ b/control-api/test/gfsReadiness.realPostgres.integration.test.ts @@ -4,6 +4,7 @@ import { Pool } from 'pg' import type { PoolClient } from 'pg' import { createPermissionStoreProbe } from '../../gfs-controller/src/authz/storeProbe.js' import { CONTROL_API_MIGRATIONS, assertDbReady, initDb } from '../src/db.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -59,15 +60,18 @@ describeRealPostgres('GFS Phase 0 real PostgreSQL readiness', () => { }, 60_000) afterAll(async () => { - await pool?.end() - if (!adminPool) return - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) - await adminPool.end() + try { + await endPoolAndWaitForClients(pool) + if (!adminPool) return + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) + } finally { + await adminPool?.end() + } }) it('fails Control API readiness while the latest migration is absent', async () => { diff --git a/control-api/test/gfsStructuralIntegrity.realPostgres.integration.test.ts b/control-api/test/gfsStructuralIntegrity.realPostgres.integration.test.ts index cea3690ea5..e847d07929 100644 --- a/control-api/test/gfsStructuralIntegrity.realPostgres.integration.test.ts +++ b/control-api/test/gfsStructuralIntegrity.realPostgres.integration.test.ts @@ -11,6 +11,7 @@ import { import { initDb } from '../src/db.js' import type { GfsCaller } from '../src/routes/gfs/grants.js' import { applyResourcePatch } from '../src/routes/gfs/resources.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -153,15 +154,18 @@ describeRealPostgres('GFS Phase 0 real PostgreSQL integrity', () => { }, 60_000) afterAll(async () => { - await pool?.end() - if (!adminPool) return - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + try { + await endPoolAndWaitForClients(pool) + if (!adminPool) return + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) + } finally { + await adminPool?.end() + } }) it('serializes create/delete and makes delete observe the committed child', async () => { diff --git a/control-api/test/gfsUploadFinalizer.realPostgres.integration.test.ts b/control-api/test/gfsUploadFinalizer.realPostgres.integration.test.ts index ec09b7c18a..ef5a221a52 100644 --- a/control-api/test/gfsUploadFinalizer.realPostgres.integration.test.ts +++ b/control-api/test/gfsUploadFinalizer.realPostgres.integration.test.ts @@ -15,6 +15,7 @@ import type { UploadSessionRow, } from '../../gfs-controller/src/upload/uploadSession.js' import { initDb } from '../src/db.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -113,21 +114,26 @@ describeRealPostgres('GFS upload finalizer on real PostgreSQL + BlobStore', () = }, 60_000) afterAll(async () => { - await pool?.query('DELETE FROM gfs_resources WHERE drive = $1', [drive]).catch(() => undefined) - await pool - ?.query('DELETE FROM gfs_upload_sessions WHERE drive = $1', [drive]) - .catch(() => undefined) - await pool?.end() - await rm(storageRoot, { recursive: true, force: true }).catch(() => undefined) - await rm(blobRoot, { recursive: true, force: true }).catch(() => undefined) - if (!adminPool) return - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) - await adminPool.end() + try { + await pool + ?.query('DELETE FROM gfs_resources WHERE drive = $1', [drive]) + .catch(() => undefined) + await pool + ?.query('DELETE FROM gfs_upload_sessions WHERE drive = $1', [drive]) + .catch(() => undefined) + await endPoolAndWaitForClients(pool) + await rm(storageRoot, { recursive: true, force: true }).catch(() => undefined) + await rm(blobRoot, { recursive: true, force: true }).catch(() => undefined) + if (!adminPool) return + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) + } finally { + await adminPool?.end() + } }) async function seedUpload(): Promise<{ upload: UploadSessionRow; uploadPart: UploadPartRow }> { diff --git a/control-api/test/gfsUploadSession.realPostgres.integration.test.ts b/control-api/test/gfsUploadSession.realPostgres.integration.test.ts index 5af683795e..88939d6cf1 100644 --- a/control-api/test/gfsUploadSession.realPostgres.integration.test.ts +++ b/control-api/test/gfsUploadSession.realPostgres.integration.test.ts @@ -19,6 +19,7 @@ import { type UploadSessionServiceDeps, } from '../../gfs-controller/src/upload/uploadSession.js' import { initDb } from '../src/db.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -159,19 +160,22 @@ describeRealPostgres('GFS Upload v2 session engine on real PostgreSQL', () => { }) afterAll(async () => { - await pool - ?.query('DELETE FROM gfs_upload_sessions WHERE drive = $1', [drive]) - .catch(() => undefined) - await pool?.end() - await rm(tempRoot, { recursive: true, force: true }).catch(() => undefined) - if (!adminPool) return - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) - await adminPool.end() + try { + await pool + ?.query('DELETE FROM gfs_upload_sessions WHERE drive = $1', [drive]) + .catch(() => undefined) + await endPoolAndWaitForClients(pool) + await rm(tempRoot, { recursive: true, force: true }).catch(() => undefined) + if (!adminPool) return + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) + } finally { + await adminPool?.end() + } }) it('serializes concurrent idempotent creates to one session and rejects a changed fingerprint', async () => { diff --git a/control-api/test/helpers.realPostgresTeardown.test.ts b/control-api/test/helpers.realPostgresTeardown.test.ts new file mode 100644 index 0000000000..a0572825cc --- /dev/null +++ b/control-api/test/helpers.realPostgresTeardown.test.ts @@ -0,0 +1,449 @@ +import { describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' +import net from 'node:net' +import type { AddressInfo, Socket } from 'node:net' +import { Pool } from 'pg' +import type { PoolClient } from 'pg' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' + +// pg-pool resolves end() once its `_clients` list is empty, while each +// client.end() it started is still in flight; a pg Client sets `_ended` and +// emits 'end' when its connection has closed. The fakes keep that ordering: +// end() resolves at once and the test decides when each client finishes +// closing. +class FakeClient extends EventEmitter { + onClosed?: () => void + + constructor(public _ended = false) { + super() + } + + finishClosing(): void { + this._ended = true + this.emit('end') + this.onClosed?.() + } +} + +class FakePool extends EventEmitter { + readonly end = vi.fn(async () => { + if (!Array.isArray(this._clients)) return + for (const client of this._clients.splice(0) as FakeClient[]) { + if (client._ended) this.emit('remove', client) + else client.onClosed = () => void this.emit('remove', client) + } + }) + + get totalCount(): number { + return Array.isArray(this._clients) ? this._clients.length : 0 + } + + constructor(readonly _clients: unknown) { + super() + } +} + +async function settledAfterMicrotasks(promise: Promise): Promise { + let settled = false + void promise.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + for (let i = 0; i < 10; i += 1) await Promise.resolve() + return settled +} + +describe('endPoolAndWaitForClients', () => { + it('resolves only after every client held at end() has ended', async () => { + const alreadyEnded = new FakeClient(true) + const a = new FakeClient() + const b = new FakeClient() + const pool = new FakePool([alreadyEnded, a, b]) + const done = endPoolAndWaitForClients(pool as unknown as Pool) + + expect(await settledAfterMicrotasks(done)).toBe(false) + expect(pool.end).toHaveBeenCalledTimes(1) + expect(a.listenerCount('end')).toBe(1) + expect(b.listenerCount('end')).toBe(1) + expect(alreadyEnded.listenerCount('end')).toBe(0) + + a.finishClosing() + expect(await settledAfterMicrotasks(done)).toBe(false) + + b.finishClosing() + await expect(done).resolves.toBeUndefined() + expect(a.listenerCount('end')).toBe(0) + expect(b.listenerCount('end')).toBe(0) + }) + + it('resolves after end() when the pool holds no client', async () => { + const pool = new FakePool([]) + await expect(endPoolAndWaitForClients(pool as unknown as Pool)).resolves.toBeUndefined() + expect(pool.end).toHaveBeenCalledTimes(1) + }) + + it('rejects when end() rejects', async () => { + const client = new FakeClient() + const pool = new FakePool([client]) + pool.end.mockRejectedValueOnce(new Error('Called end on pool more than once')) + await expect(endPoolAndWaitForClients(pool as unknown as Pool)).rejects.toThrow( + 'Called end on pool more than once' + ) + expect(pool.end).toHaveBeenCalledTimes(1) + expect(client.listenerCount('end')).toBe(0) + }) + + it.each(['client closure', 'pool.end()'] as const)( + 'rejects within five seconds when %s never settles and removes its listeners and timer', + async phase => { + vi.useFakeTimers() + try { + const client = new FakeClient() + const pool = new FakePool([client]) + if (phase === 'pool.end()') { + pool.end.mockImplementationOnce(() => new Promise(() => {})) + } + const done = endPoolAndWaitForClients(pool as unknown as Pool) + const errorListeners = pool.listeners('error') + expect(errorListeners).toHaveLength(1) + const outcome = done.then( + () => undefined, + (error: unknown) => error + ) + + await vi.advanceTimersByTimeAsync(4_999) + expect(await settledAfterMicrotasks(done)).toBe(false) + expect(client.listenerCount('end')).toBe(1) + + await vi.advanceTimersByTimeAsync(1) + expect(await settledAfterMicrotasks(done)).toBe(true) + expect(await outcome).toMatchObject({ + message: 'Timed out after 5000ms waiting for pg pool clients to close', + }) + expect(pool.end).toHaveBeenCalledTimes(1) + expect(client.listenerCount('end')).toBe(0) + expect(pool.listeners('error')).toEqual(errorListeners) + expect(vi.getTimerCount()).toBe(0) + client.finishClosing() + } finally { + vi.useRealTimers() + } + } + ) + + it('clears the deadline after a successful close or an end() rejection', async () => { + vi.useFakeTimers() + try { + const pool = new FakePool([]) + await endPoolAndWaitForClients(pool as unknown as Pool) + expect(vi.getTimerCount()).toBe(0) + + pool.end.mockRejectedValueOnce(new Error('pool end failed')) + await expect(endPoolAndWaitForClients(pool as unknown as Pool)).rejects.toThrow( + 'pool end failed' + ) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('throws before end() when pg-pool no longer keeps _clients as an array', async () => { + const pool = new FakePool(new Set([new FakeClient()])) + await expect(endPoolAndWaitForClients(pool as unknown as Pool)).rejects.toThrow( + 'pg-pool internals changed: _clients is not an array' + ) + expect(pool.end).not.toHaveBeenCalled() + }) + + it('does nothing for a pool that was never created', async () => { + await expect(endPoolAndWaitForClients(undefined)).resolves.toBeUndefined() + await expect(endPoolAndWaitForClients(null)).resolves.toBeUndefined() + }) + + it('rejects an unexpected pool error during end and preserves the earlier observer', async () => { + const pool = new FakePool([]) + const earlierObserver = vi.fn() + const error = Object.assign(new Error('unexpected pool failure'), { code: 'XX000' }) + pool.on('error', earlierObserver) + pool.end.mockImplementationOnce(async () => { + pool.emit('error', error) + }) + + await expect(endPoolAndWaitForClients(pool as unknown as Pool)).rejects.toBe(error) + expect(earlierObserver).toHaveBeenCalledWith(error) + expect(pool.listeners('error')).toHaveLength(2) + expect(pool.listeners('error')[0]).toBe(earlierObserver) + }) + + it('keeps prior real-pool listeners, exposes unexpected errors, and adds no observer on a repeated end', async () => { + const pool = new Pool({ host: '127.0.0.1', port: 1, user: 'unused', database: 'unused' }) + const earlierObserver = vi.fn() + pool.on('error', earlierObserver) + await endPoolAndWaitForClients(pool) + const listeners = pool.listeners('error') + expect(listeners).toHaveLength(2) + expect(listeners[0]).toBe(earlierObserver) + + await expect(endPoolAndWaitForClients(pool)).rejects.toThrow( + 'Called end on pool more than once' + ) + expect(pool.listeners('error')).toEqual(listeners) + + const expected = Object.assign(new Error('backend terminated during teardown'), { + code: '57P01', + }) + expect(() => pool.emit('error', expected)).not.toThrow() + expect(earlierObserver).toHaveBeenLastCalledWith(expected) + const unexpected = Object.assign(new Error('unexpected pool failure'), { code: 'XX000' }) + expect(() => pool.emit('error', unexpected)).toThrow(unexpected) + expect(earlierObserver).toHaveBeenLastCalledWith(unexpected) + expect(pool.listeners('error')).toEqual(listeners) + }) + + it('ends a real pg Pool that never connected', async () => { + // Never connects: nothing checks a client out before end(). + const pool = new Pool({ host: '127.0.0.1', port: 1, user: 'unused', database: 'unused' }) + expect(pool.totalCount).toBe(0) + await endPoolAndWaitForClients(pool) + expect(pool.ended).toBe(true) + }) +}) + +// A PostgreSQL wire-protocol stub, enough for a real pg Pool to connect, check +// clients out and end them. It answers the startup message with +// AuthenticationOk + ReadyForQuery, never answers a query, and closes a +// connection a per-connection delay after that connection's Terminate, so a +// test decides when and in which order each client finishes closing. +// `dropEveryConnection` destroys each socket as soon as it is accepted, so a +// client is left in the middle of connect(). +interface PgWireStub { + readonly port: number + /** Terminate messages received, across every connection. */ + terminates(): number + close(): Promise +} + +const AUTHENTICATION_OK = Buffer.from([0x52, 0, 0, 0, 8, 0, 0, 0, 0]) +const READY_FOR_QUERY_IDLE = Buffer.from([0x5a, 0, 0, 0, 5, 0x49]) +const TERMINATE = 0x58 + +async function startPgWireStub( + options: { closeDelayMsByConnection: readonly (number | null)[] } | { dropEveryConnection: true } +): Promise { + let accepted = 0 + let terminates = 0 + const sockets = new Set() + const server = net.createServer({ allowHalfOpen: true }, (socket: Socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + const index = accepted + accepted += 1 + if ('dropEveryConnection' in options) { + socket.destroy() + return + } + const closeDelayMs = options.closeDelayMsByConnection[index] + if (closeDelayMs === undefined) { + socket.destroy(new Error(`pg wire stub: no close delay for connection ${index}`)) + return + } + let pending = Buffer.alloc(0) + let startupDone = false + socket.on('data', (chunk: Buffer) => { + pending = Buffer.concat([pending, chunk]) + for (;;) { + if (!startupDone) { + // The startup message has no type byte: Int32 length, then the body. + if (pending.length < 4) return + const length = pending.readInt32BE(0) + if (pending.length < length) return + pending = pending.subarray(length) + startupDone = true + socket.write(Buffer.concat([AUTHENTICATION_OK, READY_FOR_QUERY_IDLE])) + continue + } + // Every later message: Byte1 type, Int32 length (counts itself). + if (pending.length < 5) return + const type = pending[0] + const length = pending.readInt32BE(1) + if (pending.length < 1 + length) return + pending = pending.subarray(1 + length) + if (type === TERMINATE) { + terminates += 1 + // null deliberately holds this connection until fixture cleanup so + // a test can forward a late client error after the helper returns. + if (closeDelayMs !== null) setTimeout(() => socket.end(), closeDelayMs) + } + } + }) + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + return { + port, + terminates: () => terminates, + close: () => { + for (const socket of sockets) socket.destroy() + return new Promise((resolve, reject) => + server.close(err => (err ? reject(err) : resolve())) + ) + }, + } +} + +function stubPool(port: number): Pool { + return new Pool({ + host: '127.0.0.1', + port, + user: 'stub', + database: 'stub', + password: 'stub', + max: 2, + }) +} + +// The client's own TCP socket. Its 'close' is the independent witness that the +// connection is really gone, as opposed to anything the pool reports. +function socketOf(client: PoolClient): Socket { + return (client as unknown as { connection: { stream: Socket } }).connection.stream +} + +function socketClosed(client: PoolClient): Promise { + return new Promise(resolve => socketOf(client).once('close', () => resolve())) +} + +async function settleWithin(promise: Promise, ms: number): Promise<'resolved' | 'timeout'> { + let timer: NodeJS.Timeout | undefined + const timeout = new Promise<'timeout'>(resolve => { + timer = setTimeout(() => resolve('timeout'), ms) + }) + try { + return await Promise.race([promise.then(() => 'resolved' as const), timeout]) + } finally { + clearTimeout(timer) + } +} + +describe('endPoolAndWaitForClients against a real pg Pool and a PG wire stub', () => { + it.each(['57P01', 'XX000'])( + 'handles a removed client forwarding %s after the helper returns', + async code => { + const stub = await startPgWireStub({ closeDelayMsByConnection: [null, 0] }) + const pool = stubPool(stub.port) + const emit = vi.spyOn(pool, 'emit') + try { + const removed = await pool.connect() + const held = await pool.connect() + held.release() + removed.release(true) + expect(pool.totalCount).toBe(1) + + await endPoolAndWaitForClients(pool) + + expect(pool.ended).toBe(true) + expect(pool.totalCount).toBe(0) + expect(socketOf(held).destroyed).toBe(true) + expect(socketOf(removed).destroyed).toBe(false) + emit.mockClear() + const error = Object.assign(new Error('late PostgreSQL client error'), { code }) + // This is the real client's existing pg-pool idleListener. The emit + // spy passes through and adds no pool error listener that could hide + // missing helper protection. Unexpected errors must still escape. + const forward = (): void => { + removed.emit('error', error) + } + if (code === '57P01') expect(forward).not.toThrow() + else expect(forward).toThrow(error) + + const forwarded = emit.mock.calls.filter(([event]) => event === 'error') + expect(forwarded).toHaveLength(1) + expect(forwarded[0]?.[1]).toBe(error) + expect(forwarded[0]?.[2]).toBe(removed) + } finally { + emit.mockRestore() + await stub.close() + } + } + ) + + it('resolves when a client still connecting at end() fails to connect', async () => { + const stub = await startPgWireStub({ dropEveryConnection: true }) + try { + const pool = stubPool(stub.port) + // Settled into a value at once, so the rejection is observed, not left + // unhandled while the helper runs. The error text depends on whether the + // platform reports the drop as a reset or as a close, so only the + // rejection itself is asserted. + const queryOutcome = pool.query('SELECT 1').then( + () => 'resolved' as const, + (error: unknown) => error + ) + // The query's client is in the pool's list while it is still connecting. + expect(pool.totalCount).toBe(1) + + const outcome = await settleWithin(endPoolAndWaitForClients(pool), 2000) + + expect(await queryOutcome).toBeInstanceOf(Error) + expect(outcome).toBe('resolved') + expect(pool.ended).toBe(true) + } finally { + await stub.close() + } + }) + + it('resolves only after every idle client has closed its socket', async () => { + const stub = await startPgWireStub({ closeDelayMsByConnection: [40, 120] }) + try { + const pool = stubPool(stub.port) + const a = await pool.connect() + const b = await pool.connect() + const events: string[] = [] + const aClosed = socketClosed(a).then(() => void events.push('a-closed')) + const bClosed = socketClosed(b).then(() => void events.push('b-closed')) + a.release() + b.release() + expect(pool.totalCount).toBe(2) + expect(pool.idleCount).toBe(2) + + await endPoolAndWaitForClients(pool) + events.push('helper') + await Promise.all([aClosed, bClosed]) + + expect(events).toEqual(['a-closed', 'b-closed', 'helper']) + expect(stub.terminates()).toBe(2) + } finally { + await stub.close() + } + }) + + it('waits for the client it holds, not for one the pool removed before end()', async () => { + const stub = await startPgWireStub({ closeDelayMsByConnection: [20, 300] }) + try { + const pool = stubPool(stub.port) + const a = await pool.connect() + const b = await pool.connect() + const events: string[] = [] + const aClosed = socketClosed(a) + const bClosed = socketClosed(b).then(() => void events.push('b-closed')) + b.release() + // release(true) destroys the client: the pool drops it from its list now + // and emits 'remove' once its connection closes, 20 ms later. + a.release(true) + expect(pool.totalCount).toBe(1) + + await endPoolAndWaitForClients(pool) + events.push('helper') + await Promise.all([aClosed, bClosed]) + + expect(events).toEqual(['b-closed', 'helper']) + expect(stub.terminates()).toBe(2) + } finally { + await stub.close() + } + }) +}) diff --git a/control-api/test/helpers/realPostgresTeardown.ts b/control-api/test/helpers/realPostgresTeardown.ts new file mode 100644 index 0000000000..c8875cf397 --- /dev/null +++ b/control-api/test/helpers/realPostgresTeardown.ts @@ -0,0 +1,104 @@ +import type { Pool } from 'pg' + +/** + * The part of a pg Client this helper reads. `_ended` and the `end` event are + * pg 8.x internals and public API respectively; see the comment below. + */ +interface ClosingClient { + readonly _ended: boolean + once(event: 'end', listener: () => void): unknown + off(event: 'end', listener: () => void): unknown +} + +// A removed client's pg-pool idleListener can still forward an error after +// end() returns. Keep this shared observer on the ended pool for that late +// teardown phase. Only PostgreSQL's expected backend-termination code is +// handled; an unexpected error still escapes unchanged, including when an +// earlier listener merely observed it. This function holds no pool reference. +function handlePoolTeardownError(error: unknown): void { + if (error !== null && typeof error === 'object' && 'code' in error && error.code === '57P01') + return + throw error +} + +/** + * Ends a pg pool and resolves only after every client it held at end() has + * closed its connection. + * + * pg-pool's end() resolves as soon as its client list is empty, while each + * client.end() it started is still in flight. A real-Postgres teardown that + * then runs pg_terminate_backend can reach a client that is still closing; the + * 57P01 it receives is re-emitted on the pool and, with no pool `error` + * listener, fails the run as an unhandled error. + * + * The helper snapshots the clients the pool holds before end() and waits for + * each one by identity. Counting pool `remove` events against `totalCount` is + * wrong both ways (pg-pool 3.13.0, `pg-pool/index.js`): + * - a client still connecting is in `_clients` (:242), but a failed connect + * (:271-274) or a rejected onConnect (:294-295) drops it without `_remove`, + * so no `remove` is ever emitted and the wait never ends; + * - a client removed before end() (release(true), expiry, idle timeout) is no + * longer counted, yet `_remove` (:172-188) still emits its `remove` when its + * connection closes, which stands in for a client still closing. + * + * pg 8.20.0 `pg/lib/client.js` sets `_ended` (:184) and emits `end` (:202-204) + * from the connection's `end` handler (:179), which `pg/lib/connection.js` + * emits on the socket's `close` (:60-62). That handler runs for a connection + * that closed and for a connect that failed (:191-196), so every client in the + * snapshot either has `_ended` set already or emits `end` exactly once. + * `_clients` is private to pg-pool: if it is not an array the helper throws, + * before ending the pool, rather than wait on something it cannot read. + * + * Scope: a client the pool had already removed before end() is not in the + * snapshot and is not waited for. Before ending the pool, the helper installs + * one retained error observer for the 57P01 such a client can still forward. + * Earlier listeners are preserved; unexpected errors remain visible/failing. + * + * The five-second deadline covers both pool.end() and client closure, below + * the ordinary ten-second test hook limit. A stuck close rejects explicitly; + * it does not authorize terminating a backend whose client is still open. + * + * A pool that was never created (setup failed first) is left alone, as the + * `pool?.end()` it replaces did. + */ +export async function endPoolAndWaitForClients(pool: Pool | null | undefined): Promise { + if (!pool) return + const clients = (pool as unknown as { _clients: unknown })._clients + if (!Array.isArray(clients)) { + throw new Error('pg-pool internals changed: _clients is not an array') + } + if (!pool.listeners('error').includes(handlePoolTeardownError)) { + pool.on('error', handlePoolTeardownError) + } + const listeners: Array<[ClosingClient, () => void]> = [] + const closed = (clients as ClosingClient[]).map( + client => + new Promise(resolve => { + if (client._ended) { + resolve() + return + } + const onEnd = (): void => resolve() + listeners.push([client, onEnd]) + client.once('end', onEnd) + }) + ) + let timer: NodeJS.Timeout | undefined + try { + await Promise.race([ + (async () => { + await pool.end() + await Promise.all(closed) + })(), + new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error('Timed out after 5000ms waiting for pg pool clients to close')), + 5_000 + ) + }), + ]) + } finally { + clearTimeout(timer) + for (const [client, onEnd] of listeners) client.off('end', onEnd) + } +} diff --git a/control-api/test/oauth.authorizeUrl.generic.test.ts b/control-api/test/oauth.authorizeUrl.generic.test.ts index f57612dabe..b141692a24 100644 --- a/control-api/test/oauth.authorizeUrl.generic.test.ts +++ b/control-api/test/oauth.authorizeUrl.generic.test.ts @@ -17,29 +17,32 @@ const CLIENT_ID = 'my-generic-client' const REDIRECT_URI = 'https://control.example.com/api/v1/oauth-callback/my-generic-client' function genericSubject(overrides: Record = {}): McpServerOAuthSubject { - const resolved = resolveServerOAuthSubject({ - spec: { - contextRef: 'ctx-A', - oauth: { - source: 'generic', - id: CLIENT_ID, - authorizationEndpoint: 'https://idp.example.com/authorize', - tokenEndpoint: 'https://idp.example.com/token', - resource: 'https://api.example.com', - tokenRequestFormat: 'form', - tokenAuthMethod: 'body', - scopeSeparator: 'comma', - sendScope: true, - usePkce: true, - includeResponseType: true, - supportsRefresh: true, - grantScope: 'user', - scopes: ['read', 'write'], - extraAuthorizeParams: { audience: 'aud-1' }, - ...overrides, + const resolved = resolveServerOAuthSubject( + { + spec: { + contextRef: 'ctx-A', + oauth: { + source: 'generic', + id: CLIENT_ID, + authorizationEndpoint: 'https://idp.example.com/authorize', + tokenEndpoint: 'https://idp.example.com/token', + resource: 'https://api.example.com', + tokenRequestFormat: 'form', + tokenAuthMethod: 'body', + scopeSeparator: 'comma', + sendScope: true, + usePkce: true, + includeResponseType: true, + supportsRefresh: true, + grantScope: 'user', + scopes: ['read', 'write'], + extraAuthorizeParams: { audience: 'aud-1' }, + ...overrides, + }, }, }, - }) + 'consent' + ) if (!resolved) throw new Error('fixture: generic resolve returned null') return { namespace: MCP_NS, ...resolved } } diff --git a/control-api/test/oauth.authorizeUrl.remote.test.ts b/control-api/test/oauth.authorizeUrl.remote.test.ts index 8cd9cfa156..59f9e3e118 100644 --- a/control-api/test/oauth.authorizeUrl.remote.test.ts +++ b/control-api/test/oauth.authorizeUrl.remote.test.ts @@ -1,13 +1,18 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeAll, describe, expect, it, vi } from 'vitest' import { type BuildAuthorizeUrlDeps, buildAuthorizeUrl } from '../src/oauth/authorizeUrlHelper.js' import type { McpServerOAuthReader, McpServerOAuthSubject } from '../src/oauth/callback.js' +import { type DiscoveryResult, discoverRemoteOAuth } from '../src/oauth/discovery.js' import { resolveServerOAuthSubject } from '../src/oauth/mcpServerOAuthSpec.js' +import { buildRemoteOAuthSpec } from '../src/routes/admin/remoteMcp.js' +import { PILOTS, makeDiscoveryTransport } from './fixtures/remoteOAuthDiscovery.js' /** * C4/DEC-23 — the remote authorize-URL mint. The URL is built from the pinned * discovery-derived `authorizationEndpoint` with the PUBLIC client_id (`oauth.id`), * mandatory PKCE S256, and the RFC 8707 `resource`. No secret material rides the - * URL and no K8s Secret is read. Subject decl comes from the REAL resolver. + * URL and no K8s Secret is read. The CR is the install's own output (T1): the real + * discovery over the Linear probe (CIMD with RFC 9207) through `buildRemoteOAuthSpec`, + * resolved by the REAL resolver. */ const STATE_SECRET = 'test-state-hmac-secret-32-bytes-padding' @@ -15,25 +20,27 @@ const MCP_NS = 'mcp-server' const CLIENT_ID = 'https://control.example.com/api/v1/.well-known/evenfire-mcp-client' const REDIRECT_URI = 'https://control.example.com/api/v1/oauth-callback/remote' +let linear: DiscoveryResult + +beforeAll(async () => { + const outcome = await discoverRemoteOAuth(PILOTS.linear.mcpUrl, { + transport: makeDiscoveryTransport(PILOTS.linear), + resolveDns: async () => ['93.184.216.34'], + }) + if (!outcome.ok) throw new Error(`fixture discovery failed: ${outcome.error.kind}`) + linear = outcome.result +}) + function remoteSubject(): McpServerOAuthSubject { - const resolved = resolveServerOAuthSubject({ - spec: { - contextRef: 'ctx-A', - oauth: { - source: 'remote', - id: CLIENT_ID, - clientMode: 'public', - authorizationEndpoint: 'https://mcp.notion.com/authorize', - tokenEndpoint: 'https://mcp.notion.com/token', - issuer: 'https://mcp.notion.com', - resource: 'https://mcp.notion.com', - grantScope: 'user', - scopes: ['read', 'write'], - bearerInBody: false, - supportsRefresh: true, - }, - }, + const oauth = buildRemoteOAuthSpec(linear, { + clientMode: 'public', + grantScope: 'user', + cimdClientId: CLIENT_ID, }) + const resolved = resolveServerOAuthSubject( + { spec: { contextRef: 'ctx-A', oauth: oauth as unknown as Record } }, + 'consent' + ) if (!resolved) throw new Error('fixture: remote resolve returned null') return { namespace: MCP_NS, ...resolved } } @@ -52,7 +59,7 @@ describe('buildAuthorizeUrl — remote mcp subject (DEC-23)', () => { const result = await buildAuthorizeUrl( { subjectKind: 'mcp', - mcpServerName: 'notion-remote', + mcpServerName: 'linear-remote', oauthClientId: CLIENT_ID, userId: 'user-9', grantKind: 'user', @@ -65,13 +72,13 @@ describe('buildAuthorizeUrl — remote mcp subject (DEC-23)', () => { expect(result.kind).toBe('ok') if (result.kind !== 'ok') return const url = new URL(result.authorizeUrl) - expect(`${url.origin}${url.pathname}`).toBe('https://mcp.notion.com/authorize') + expect(`${url.origin}${url.pathname}`).toBe('https://mcp.linear.app/authorize') expect(url.searchParams.get('response_type')).toBe('code') expect(url.searchParams.get('client_id')).toBe(CLIENT_ID) expect(url.searchParams.get('redirect_uri')).toBe(REDIRECT_URI) expect(url.searchParams.get('code_challenge_method')).toBe('S256') expect(url.searchParams.get('code_challenge')).toBeTruthy() - expect(url.searchParams.get('resource')).toBe('https://mcp.notion.com') + expect(url.searchParams.get('resource')).toBe('https://mcp.linear.app/mcp') expect(url.searchParams.get('scope')).toBe('read write') expect(url.searchParams.get('state')).toBeTruthy() // No secret material, and the client-id Secret was never read (public client). diff --git a/control-api/test/oauth.callback.background.test.ts b/control-api/test/oauth.callback.background.test.ts index 57232299bb..e4b3e86879 100644 --- a/control-api/test/oauth.callback.background.test.ts +++ b/control-api/test/oauth.callback.background.test.ts @@ -1,16 +1,21 @@ -import { describe, it, expect, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { handleOAuthCallback } from '../src/oauth/callback.js' -import { renderSuccessHtml } from '../src/routes/external/oauthCallback.js' import { signOAuthState } from '../src/oauth/state.js' import * as store from '../src/oauth/store.js' +import { renderSuccessHtml } from '../src/routes/external/oauthCallback.js' const SECRET = 'x'.repeat(40) const recipe = { - spec: { oauthClients: [ - { id: 'google-gmail', provider: 'google', - clientIdRef: { name: 's', key: 'client-id' }, - clientSecretRef: { name: 's', key: 'client-secret' } }, - ] }, + spec: { + oauthClients: [ + { + id: 'google-gmail', + provider: 'google', + clientIdRef: { name: 's', key: 'client-id' }, + clientSecretRef: { name: 's', key: 'client-secret' }, + }, + ], + }, } function deps(tokenJson: object) { return { @@ -23,11 +28,15 @@ function deps(tokenJson: object) { } } const input = (background: boolean) => ({ - oauthClientId: 'google-gmail', - code: 'code', redirectUri: 'https://api/cb', + target: { kind: 'client' as const, id: 'google-gmail', redirectUri: 'https://api/cb' }, + code: 'code', state: signOAuthState(SECRET, { - recipeNamespace: 'sandbox-recipes', recipeName: 'leadforge', userId: 'user-1', - oauthClientId: 'google-gmail', grantKind: 'user', background, + recipeNamespace: 'sandbox-recipes', + recipeName: 'leadforge', + userId: 'user-1', + oauthClientId: 'google-gmail', + grantKind: 'user', + background, }), }) @@ -35,14 +44,20 @@ describe('handleOAuthCallback background', () => { it('sets background=true when consented AND a refresh token is returned', async () => { const spy = vi.spyOn(store, 'setUserGrantBackground').mockResolvedValue() vi.spyOn(store, 'upsertOAuthGrant').mockResolvedValue() - const res = await handleOAuthCallback(input(true), deps({ access_token: 'a', refresh_token: 'r', expires_in: 3600 })) + const res = await handleOAuthCallback( + input(true), + deps({ access_token: 'a', refresh_token: 'r', expires_in: 3600 }) + ) expect(res.kind).toBe('ok') if (res.kind === 'ok') { expect(res.backgroundRequested).toBe(true) expect(res.backgroundEnabled).toBe(true) } - expect(spy).toHaveBeenCalledWith(expect.anything(), - expect.objectContaining({ userId: 'user-1', oauthClientId: 'google-gmail' }), true) + expect(spy).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ userId: 'user-1', oauthClientId: 'google-gmail' }), + true + ) spy.mockRestore() }) @@ -62,7 +77,10 @@ describe('handleOAuthCallback background', () => { it('never sets background for a non-background (plain) connect', async () => { const spy = vi.spyOn(store, 'setUserGrantBackground').mockResolvedValue() vi.spyOn(store, 'upsertOAuthGrant').mockResolvedValue() - const res = await handleOAuthCallback(input(false), deps({ access_token: 'a', refresh_token: 'r' })) + const res = await handleOAuthCallback( + input(false), + deps({ access_token: 'a', refresh_token: 'r' }) + ) expect(res.kind).toBe('ok') if (res.kind === 'ok') { expect(res.backgroundRequested).toBe(false) diff --git a/control-api/test/oauth.callback.generic.test.ts b/control-api/test/oauth.callback.generic.test.ts index 15cbbf2dfa..e36f438d27 100644 --- a/control-api/test/oauth.callback.generic.test.ts +++ b/control-api/test/oauth.callback.generic.test.ts @@ -63,7 +63,7 @@ function genericCr(confidential: boolean) { function genericReader(confidential: boolean): McpServerOAuthReader { return { read: vi.fn(async () => { - const resolved = resolveServerOAuthSubject(genericCr(confidential)) + const resolved = resolveServerOAuthSubject(genericCr(confidential), 'consent') if (!resolved) return null return { namespace: MCP_NS, ...resolved } as McpServerOAuthSubject }), @@ -94,7 +94,11 @@ function recordingTransport(responseJson: string, status = 200) { function genericInput(): CallbackInput { return { - oauthClientId: CLIENT_ID, + target: { + kind: 'client', + id: CLIENT_ID, + redirectUri: 'https://control.example.com/api/v1/oauth-callback/my-generic-client', + }, code: 'AUTH_CODE', state: signOAuthState(STATE_SECRET, { subjectKind: 'mcp', @@ -104,7 +108,6 @@ function genericInput(): CallbackInput { grantKind: 'user', background: false, } as Parameters[1]), - redirectUri: 'https://control.example.com/api/v1/oauth-callback/my-generic-client', } } diff --git a/control-api/test/oauth.callback.mcp.test.ts b/control-api/test/oauth.callback.mcp.test.ts index 8834c80373..59121351bf 100644 --- a/control-api/test/oauth.callback.mcp.test.ts +++ b/control-api/test/oauth.callback.mcp.test.ts @@ -40,10 +40,9 @@ function mcpState(overrides: Partial[1]> = {}) function buildInput(overrides: Partial = {}): CallbackInput { return { - oauthClientId: 'google-drive', + target: { kind: 'client', id: 'google-drive', redirectUri: REDIRECT_URI }, code: 'AUTH_CODE', state: mcpState(), - redirectUri: REDIRECT_URI, ...overrides, } } @@ -70,7 +69,7 @@ function gdriveSubject( }, }, } - const resolved = resolveServerOAuthSubject(rawServer) + const resolved = resolveServerOAuthSubject(rawServer, 'consent') if (!resolved) throw new Error('fixture: resolveServerOAuthSubject returned null') return { namespace: MCP_NS, ...resolved } } @@ -241,7 +240,10 @@ describe('handleOAuthCallback — mcp subject (U5)', () => { it('rejects when the callback-path oauthClientId disagrees with the signed state', async () => { const { deps } = buildDeps({ subject: gdriveSubject() }) - const result = await handleOAuthCallback(buildInput({ oauthClientId: 'other' }), deps) + const result = await handleOAuthCallback( + buildInput({ target: { kind: 'client', id: 'other', redirectUri: REDIRECT_URI } }), + deps + ) expect(result.kind).toBe('invalid_state') }) diff --git a/control-api/test/oauth.callback.publicClient.test.ts b/control-api/test/oauth.callback.publicClient.test.ts index 9f4262e035..94b2da42e1 100644 --- a/control-api/test/oauth.callback.publicClient.test.ts +++ b/control-api/test/oauth.callback.publicClient.test.ts @@ -87,7 +87,11 @@ it('public client: exchanges without reading or sending a client_secret', async }) const result = await handleOAuthCallback( - { oauthClientId: 'salesforce', code: 'AUTH_CODE', state, redirectUri: REDIRECT_URI }, + { + target: { kind: 'client', id: 'salesforce', redirectUri: REDIRECT_URI }, + code: 'AUTH_CODE', + state, + }, deps ) @@ -152,7 +156,11 @@ describe('confidential client (control) still reads + sends client_secret', () = background: false, }) const result = await handleOAuthCallback( - { oauthClientId: 'salesforce', code: 'AUTH_CODE', state, redirectUri: REDIRECT_URI }, + { + target: { kind: 'client', id: 'salesforce', redirectUri: REDIRECT_URI }, + code: 'AUTH_CODE', + state, + }, deps ) expect(result.kind).toBe('ok') diff --git a/control-api/test/oauth.callback.remote.test.ts b/control-api/test/oauth.callback.remote.test.ts index 85f21b36b8..56e5d3d4fc 100644 --- a/control-api/test/oauth.callback.remote.test.ts +++ b/control-api/test/oauth.callback.remote.test.ts @@ -77,7 +77,7 @@ const REMOTE_CLIENT_ID = remoteServerCr().spec.oauth.id function remoteReader(): McpServerOAuthReader { return { read: vi.fn(async () => { - const resolved = resolveServerOAuthSubject(remoteServerCr()) + const resolved = resolveServerOAuthSubject(remoteServerCr(), 'consent') if (!resolved) return null return { namespace: MCP_NS, ...resolved } as McpServerOAuthSubject }), @@ -122,10 +122,9 @@ function remoteState() { function remoteInput(): CallbackInput { return { - oauthClientId: 'remote', // stable segment, not the client id + target: { kind: 'remote-shared', origin: 'https://control.example.com' }, code: 'AUTH_CODE', state: remoteState(), - redirectUri: 'https://control.example.com/api/v1/oauth-callback/remote', // RFC 9207 issuer echoed by the authorization response; matches the pinned // `issForCallback` so the mix-up defence passes and the exchange proceeds. iss: 'https://mcp.sentry.dev', @@ -196,7 +195,7 @@ describe('baked mcp exchange stays on fetchFn (byte-identical, T5c)', () => { }, }, } - const resolved = resolveServerOAuthSubject(bakedServer) + const resolved = resolveServerOAuthSubject(bakedServer, 'consent') if (!resolved) throw new Error('fixture: baked resolve returned null') const subject = { namespace: MCP_NS, ...resolved } as McpServerOAuthSubject @@ -226,7 +225,11 @@ describe('baked mcp exchange stays on fetchFn (byte-identical, T5c)', () => { } const input: CallbackInput = { - oauthClientId: 'google-drive', + target: { + kind: 'client', + id: 'google-drive', + redirectUri: 'https://control.example.com/api/v1/oauth-callback/google-drive', + }, code: 'AUTH_CODE', state: signOAuthState(STATE_SECRET, { subjectKind: 'mcp', @@ -236,7 +239,6 @@ describe('baked mcp exchange stays on fetchFn (byte-identical, T5c)', () => { grantKind: 'user', background: false, } as Parameters[1]), - redirectUri: 'https://control.example.com/api/v1/oauth-callback/google-drive', } const result = await handleOAuthCallback(input, deps) diff --git a/control-api/test/oauth.callback.reservedRemoteSegment.test.ts b/control-api/test/oauth.callback.reservedRemoteSegment.test.ts index c4a80f76f2..fcc132554c 100644 --- a/control-api/test/oauth.callback.reservedRemoteSegment.test.ts +++ b/control-api/test/oauth.callback.reservedRemoteSegment.test.ts @@ -200,7 +200,7 @@ const TOKEN_RESPONSE = JSON.stringify({ */ async function deliverState(lane: Lane, serverName: string, segment: string) { const { gw, cr } = await installServer(lane, serverName) - const resolved = resolveServerOAuthSubject(cr) + const resolved = resolveServerOAuthSubject(cr, 'consent') if (!resolved) throw new Error('installed CR must resolve to an OAuth subject') const mcpServerReader: McpServerOAuthReader = { read: vi.fn(async () => ({ namespace: MCP_NS, ...resolved }) as McpServerOAuthSubject), @@ -258,10 +258,17 @@ async function deliverState(lane: Lane, serverName: string, segment: string) { const result = await handleOAuthCallback( { - oauthClientId: segment, + // The route maps the reserved segment to the shared remote callback. + target: + segment === REMOTE_CALLBACK_CLIENT_SEGMENT + ? { kind: 'remote-shared', origin: 'https://control.example.com' } + : { + kind: 'client', + id: segment, + redirectUri: `https://control.example.com/api/v1/oauth-callback/${segment}`, + }, code: 'AUTH_CODE', state, - redirectUri: `https://control.example.com/api/v1/oauth-callback/${segment}`, }, deps ) @@ -385,7 +392,11 @@ describe('handleOAuthCallback — recipe clients are outside the reservation (RP ) const db = { query: vi.fn().mockResolvedValue({ rows: [], rowCount: 1 }) } const result = await handleOAuthCallback( - { oauthClientId: REMOTE_CALLBACK_CLIENT_SEGMENT, code: 'AUTH_CODE', state, redirectUri }, + { + target: { kind: 'remote-shared', origin: 'https://control.example.com' }, + code: 'AUTH_CODE', + state, + }, { db: db as unknown as CallbackDeps['db'], recipeReader, @@ -398,6 +409,9 @@ describe('handleOAuthCallback — recipe clients are outside the reservation (RP expect(result.kind).toBe('ok') expect(fetchFn).toHaveBeenCalledTimes(1) + // The recipe exchange replays the exact URI its authorize URL carried. + const tokenInit = (fetchFn.mock.calls[0] as unknown as [string, RequestInit])[1] + expect(new URLSearchParams(String(tokenInit.body)).get('redirect_uri')).toBe(redirectUri) expect(String(db.query.mock.calls[0]?.[0])).toContain('INSERT INTO oauth_grants') }) }) diff --git a/control-api/test/oauth.callback.test.ts b/control-api/test/oauth.callback.test.ts index ab96fe4249..2e3679ab07 100644 --- a/control-api/test/oauth.callback.test.ts +++ b/control-api/test/oauth.callback.test.ts @@ -24,7 +24,7 @@ function buildInput( grantKind: 'user' | 'service' = 'user' ): CallbackInput { return { - oauthClientId: 'salesforce', + target: { kind: 'client', id: 'salesforce', redirectUri: REDIRECT_URI }, code: 'AUTH_CODE_FROM_PROVIDER', state: signOAuthState(STATE_SECRET, { recipeNamespace: 'sandbox-recipes', @@ -34,7 +34,6 @@ function buildInput( grantKind, background: false, }), - redirectUri: REDIRECT_URI, ...overrides, } } @@ -206,7 +205,12 @@ describe('handleOAuthCallback (O4.1)', () => { it('rejects when the path oauthClientId does not match the signed state', async () => { const { deps } = buildDeps({ recipe: recipeWithSalesforce() }) // State signed for salesforce; the callback path claims a different client. - const result = await handleOAuthCallback(buildInput({ oauthClientId: 'attacker-client' }), deps) + const result = await handleOAuthCallback( + buildInput({ + target: { kind: 'client', id: 'attacker-client', redirectUri: REDIRECT_URI }, + }), + deps + ) expect(result.kind).toBe('invalid_state') }) diff --git a/control-api/test/oauth.callbackRemotePerServer.test.ts b/control-api/test/oauth.callbackRemotePerServer.test.ts new file mode 100644 index 0000000000..c4f7212b1f --- /dev/null +++ b/control-api/test/oauth.callbackRemotePerServer.test.ts @@ -0,0 +1,532 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { randomUUID } from 'node:crypto' +import { config } from '../src/config.js' +import type { PinnedRawResponse, PinnedTransportInput } from '../src/http/pinnedFetch.js' +import { + type CallbackDeps, + type CallbackTarget, + type McpServerOAuthSubject, + handleOAuthCallback, +} from '../src/oauth/callback.js' +import type { DiscoveryResult } from '../src/oauth/discovery.js' +import { getDynamicClientBinding } from '../src/oauth/dynamicClientStore.js' +import { deriveOAuthEncryptionKey } from '../src/oauth/encryption.js' +import { + type McpServerOAuthSpecInput, + resolveServerOAuthSubject, +} from '../src/oauth/mcpServerOAuthSpec.js' +import { signOAuthState } from '../src/oauth/state.js' +import { + discoverPilot, + installRemoteServer, + seedContext, + withRfc9207, +} from './fixtures/perServerInstall.js' +import { + ATLASSIAN_V2_PILOT, + PILOTS, + dcrPilot, + makeInMemoryDynamicClientsDb, +} from './fixtures/remoteOAuthDiscovery.js' +import { MockGateway } from './mockGateway.js' + +/** + * Callback of the per-server remote variant (`/oauth-callback/remote/ + * [/]`), used when the AS does not return RFC 9207 `iss`. There the + * redirect URI is the mix-up defence, so the handler must: + * - refuse a code delivered to one server's URI with another server's state BEFORE + * reading any CR or exchanging the code; + * - accept only the URI this installation registered (DCR: the nonce of the row bound + * to this CR; pre-registered: no nonce), and only for a per-server CR; + * - validate an `iss` the AS sends anyway, rejecting a malformed one; + * - replay on the token exchange the URI rebuilt from the signed state and the CR. + * + * T1: servers are installed through the real admin install route (CR, uid, row and + * nonce from the saga); discovery by the real client over probe fixtures; the state by + * the real signer. The AS token endpoint is the only network edge, recorded here. + */ + +vi.mock('node:dns/promises', () => ({ + lookup: vi.fn(async () => [{ address: '93.184.216.34', family: 4 }]), +})) + +// Capture what the callback logs when it rejects an `iss`, to pin that no request data +// (code, state, the query) reaches the log. +const logged = vi.hoisted(() => ({ warns: [] as unknown[][] })) +vi.mock('../src/observability/logger.js', async importActual => { + const actual = await importActual() + const root = actual.rootLogger + const wrapped = Object.create(root) as typeof root + wrapped.child = ((bindings: Record, options?: unknown) => { + const child = root.child(bindings, options as never) + if (bindings?.module === 'oauth-callback') { + const warn = child.warn.bind(child) + child.warn = ((...args: unknown[]) => { + logged.warns.push(args) + return (warn as (...a: unknown[]) => void)(...args) + }) as typeof child.warn + } + return child + }) as unknown as typeof root.child + return { ...actual, rootLogger: wrapped } +}) + +const NS = config.mcpServersNamespace +const ORIGIN = 'https://control.example.com' +const CONTEXT = 'ctx-a' +const STATE_SECRET = 'test-state-hmac-secret-32-bytes-padding' +const KEY = deriveOAuthEncryptionKey(config.oauthEncryptionKey) + +// Atlassian v2: CIMD + DCR, no RFC 9207, one site → per-server DCR (public). +let atlassian: DiscoveryResult +// Notion: no RFC 9207 → per-server when pre-registered. +let notion: DiscoveryResult +// A DCR AS that returns RFC 9207 → shared. +let sharedDcr: DiscoveryResult + +beforeAll(async () => { + atlassian = await discoverPilot(ATLASSIAN_V2_PILOT) + notion = await discoverPilot(PILOTS.notion) + sharedDcr = await discoverPilot(withRfc9207(dcrPilot('public'))) +}) + +let gateway: MockGateway +let db: ReturnType['db'] +let savedBaseUrl: string + +beforeEach(async () => { + savedBaseUrl = config.oauthCallbackBaseUrl + config.oauthCallbackBaseUrl = ORIGIN + gateway = new MockGateway(NS) + await seedContext(gateway, NS, CONTEXT) + db = makeInMemoryDynamicClientsDb().db + logged.warns.length = 0 +}) + +afterEach(() => { + config.oauthCallbackBaseUrl = savedBaseUrl +}) + +interface Installed { + name: string + redirectUri: string + /** Last path segment of a DCR redirect URI; undefined for pre-registered. */ + nonce?: string +} + +async function installDcr( + name: string, + discovery = atlassian, + baseUrl = ATLASSIAN_V2_PILOT.mcpUrl +): Promise { + const res = await installRemoteServer({ + gateway, + db, + discovery, + body: { serverName: name, contextRef: CONTEXT, baseUrl, mode: 'dcr' }, + }) + if (res.status !== 201) throw new Error(`install failed: ${JSON.stringify(res.body)}`) + const redirectUri = String(res.body.redirectUri) + return { name, redirectUri, nonce: redirectUri.split('/').pop() } +} + +async function installPreRegistered(name: string): Promise { + const res = await installRemoteServer({ + gateway, + db, + discovery: notion, + body: { + serverName: name, + contextRef: CONTEXT, + baseUrl: PILOTS.notion.mcpUrl, + mode: 'pre-registered', + clientId: `client-${name}`, + clientSecret: `secret-${name}`, + }, + }) + if (res.status !== 201) throw new Error(`install failed: ${JSON.stringify(res.body)}`) + return { name, redirectUri: String(res.body.redirectUri) } +} + +async function oauthOf(name: string): Promise> { + const cr = (await gateway.getResource('mcpservers', name, NS)) as { + spec: { oauth: Record } + } + return cr.spec.oauth +} + +/** The state the authorize-url mint signs for this server: its CR's client id. */ +async function stateFor(name: string): Promise { + return signOAuthState(STATE_SECRET, { + subjectKind: 'mcp', + mcpServerName: name, + userId: 'user-9', + oauthClientId: String((await oauthOf(name)).id), + grantKind: 'user', + background: false, + } as Parameters[1]) +} + +function perServer( + serverName: string, + installNonce?: string, + origin: string | null = ORIGIN +): CallbackTarget { + return { kind: 'remote-per-server', serverName, installNonce, origin } +} + +function harness() { + const tokenPosts: { url: string; body: string }[] = [] + const pinnedTransport = vi.fn(async (input: PinnedTransportInput): Promise => { + tokenPosts.push({ url: input.url, body: input.body ?? '' }) + return { + status: 200, + headers: { 'content-type': 'application/json' }, + bodyText: JSON.stringify({ access_token: 'AT', refresh_token: 'RT', expires_in: 3600 }), + } + }) + // The production subject reader (routes/external/oauthCallback.ts) over the gateway. + const read = vi.fn(async (name: string): Promise => { + const cr = (await gateway.getResource('mcpservers', name, NS)) as McpServerOAuthSpecInput + const resolved = resolveServerOAuthSubject(cr, 'consent') + return resolved ? { namespace: NS, ...resolved } : null + }) + const recipeRead = vi.fn(async () => null) + const deps: CallbackDeps = { + db, + recipeReader: { read: recipeRead }, + secretReader: { + async read(name, namespace) { + const raw = (await gateway.getSecret(name, namespace)) as { data?: Record } + return Object.fromEntries( + Object.entries(raw.data ?? {}).map(([k, v]) => [k, Buffer.from(v, 'base64').toString()]) + ) + }, + }, + mcpServerReader: { read }, + userContextsReader: async () => ({ contextIds: [CONTEXT] }), + fetchFn: (async () => { + throw new Error('the remote lane must not use fetchFn') + }) as unknown as typeof fetch, + stateSecret: STATE_SECRET, + encryptionKey: KEY, + resolveDns: async () => ['93.184.216.34'], + pinnedTransport, + } + return { deps, read, recipeRead, tokenPosts } +} + +function redirectUriOf(post: { body: string }): string | null { + return new URLSearchParams(post.body).get('redirect_uri') +} + +describe('per-server callback — success', () => { + it('DCR: the registered /remote// exchanges, replaying that exact URI', async () => { + const srv = await installDcr('atlassian') + const h = harness() + + const result = await handleOAuthCallback( + { target: perServer(srv.name, srv.nonce), code: 'CODE', state: await stateFor(srv.name) }, + h.deps + ) + + expect(result).toMatchObject({ kind: 'ok', source: 'mcp', mcpServerName: 'atlassian' }) + expect(h.tokenPosts).toHaveLength(1) + expect(redirectUriOf(h.tokenPosts[0])).toBe(srv.redirectUri) + }) + + it('pre-registered: /remote/ exchanges with the confidential secret', async () => { + const srv = await installPreRegistered('hubspot') + const h = harness() + + const result = await handleOAuthCallback( + { target: perServer(srv.name), code: 'CODE', state: await stateFor(srv.name) }, + h.deps + ) + + expect(result.kind).toBe('ok') + expect(h.tokenPosts).toHaveLength(1) + expect(redirectUriOf(h.tokenPosts[0])).toBe(`${ORIGIN}/api/v1/oauth-callback/remote/hubspot`) + expect(redirectUriOf(h.tokenPosts[0])).toBe(srv.redirectUri) + expect(new URLSearchParams(h.tokenPosts[0].body).get('client_secret')).toBe('secret-hubspot') + }) +}) + +describe('per-server callback — a code for one server never binds to another (I2)', () => { + it('DCR: /remote/a/ with the state of b → binding_mismatch, b never read, no exchange', async () => { + const a = await installDcr('server-a') + await installDcr('server-b') + const h = harness() + + const result = await handleOAuthCallback( + { target: perServer(a.name, a.nonce), code: 'CODE', state: await stateFor('server-b') }, + h.deps + ) + + expect(result).toEqual({ kind: 'invalid_state', reason: 'binding_mismatch' }) + expect(h.read).not.toHaveBeenCalled() + expect(h.tokenPosts).toHaveLength(0) + }) + + it('pre-registered: /remote/a with the state of b → binding_mismatch, b never read, no exchange', async () => { + const a = await installPreRegistered('server-a') + await installPreRegistered('server-b') + const h = harness() + + const result = await handleOAuthCallback( + { target: perServer(a.name), code: 'CODE', state: await stateFor('server-b') }, + h.deps + ) + + expect(result).toEqual({ kind: 'invalid_state', reason: 'binding_mismatch' }) + expect(h.read).not.toHaveBeenCalled() + expect(h.tokenPosts).toHaveLength(0) + }) + + it('a recipe state on a per-server URI → binding_mismatch, no recipe read', async () => { + const a = await installPreRegistered('server-a') + const h = harness() + const state = signOAuthState(STATE_SECRET, { + recipeNamespace: config.sandboxNamespace, + recipeName: 'server-a', + userId: 'user-9', + oauthClientId: 'client-server-a', + grantKind: 'user', + background: false, + }) + + const result = await handleOAuthCallback( + { target: perServer(a.name), code: 'CODE', state }, + h.deps + ) + + expect(result).toEqual({ kind: 'invalid_state', reason: 'binding_mismatch' }) + expect(h.recipeRead).not.toHaveBeenCalled() + expect(h.tokenPosts).toHaveLength(0) + }) +}) + +describe('per-server callback — only the URI this installation registered', () => { + it('DCR with a nonce that is not the bound row’s → binding_mismatch, no exchange', async () => { + const srv = await installDcr('atlassian') + const h = harness() + + const result = await handleOAuthCallback( + { target: perServer(srv.name, randomUUID()), code: 'CODE', state: await stateFor(srv.name) }, + h.deps + ) + + expect(result).toEqual({ kind: 'invalid_state', reason: 'binding_mismatch' }) + expect(h.tokenPosts).toHaveLength(0) + }) + + it('DCR on the nonce-less URI → binding_mismatch (a DCR client never registered it)', async () => { + const srv = await installDcr('atlassian') + const h = harness() + + const result = await handleOAuthCallback( + { target: perServer(srv.name), code: 'CODE', state: await stateFor(srv.name) }, + h.deps + ) + + expect(result).toEqual({ kind: 'invalid_state', reason: 'binding_mismatch' }) + expect(h.tokenPosts).toHaveLength(0) + }) + + it('pre-registered with a nonce → binding_mismatch (the operator registered /remote/)', async () => { + const srv = await installPreRegistered('hubspot') + const h = harness() + + const result = await handleOAuthCallback( + { target: perServer(srv.name, randomUUID()), code: 'CODE', state: await stateFor(srv.name) }, + h.deps + ) + + expect(result).toEqual({ kind: 'invalid_state', reason: 'binding_mismatch' }) + expect(h.tokenPosts).toHaveLength(0) + }) + + // I6: an AS that returns `iss` is bound on the shared callback; reaching it through a + // per-server URI would skip the issuer check. + it('a shared (RFC 9207) server on a per-server URI → binding_mismatch, even with the right iss', async () => { + const srv = await installDcr('shared-dcr', sharedDcr, dcrPilot('public').mcpUrl) + expect(srv.redirectUri).toBe(`${ORIGIN}/api/v1/oauth-callback/remote`) + const h = harness() + const iss = String((await oauthOf(srv.name)).issForCallback) + + for (const target of [perServer(srv.name), perServer(srv.name, randomUUID())]) { + const result = await handleOAuthCallback( + { target, code: 'CODE', state: await stateFor(srv.name), iss }, + h.deps + ) + expect(result).toEqual({ kind: 'invalid_state', reason: 'binding_mismatch' }) + } + expect(h.tokenPosts).toHaveLength(0) + }) + + // I6 with the shared server's REAL bound nonce: the nonce and row checks pass, so only + // the variant check stands between this request and an exchange with no `iss` at all. + it('a shared (RFC 9207) server on its own bound nonce, no iss → binding_mismatch, no exchange', async () => { + const srv = await installDcr('shared-dcr', sharedDcr, dcrPilot('public').mcpUrl) + const row = await getDynamicClientBinding(db, { serverNamespace: NS, serverName: srv.name }) + expect(row?.installId).toMatch(/^[0-9a-f-]{36}$/) + const h = harness() + + const result = await handleOAuthCallback( + { + target: perServer(srv.name, row?.installId), + code: 'CODE', + state: await stateFor(srv.name), + }, + h.deps + ) + + expect(result).toEqual({ kind: 'invalid_state', reason: 'binding_mismatch' }) + expect(h.tokenPosts).toHaveLength(0) + }) + + // A CR deleted and recreated outside the uninstall (kubectl, GitOps) leaves the old + // row behind with the same nonce and client id; only its cr_uid no longer matches. + it('DCR: a CR recreated over a surviving row → binding_mismatch on the old nonce, no exchange', async () => { + const srv = await installDcr('atlassian') + const cr = (await gateway.getResource('mcpservers', srv.name, NS)) as { + metadata: { name: string; uid: string; labels?: Record } + spec: Record + } + await gateway.deleteResource('mcpservers', srv.name, NS) + await gateway.createResource( + 'mcpservers', + { metadata: { name: cr.metadata.name, labels: cr.metadata.labels }, spec: cr.spec }, + NS + ) + const recreated = (await gateway.getResource('mcpservers', srv.name, NS)) as { + metadata: { uid: string } + } + expect(recreated.metadata.uid).not.toBe(cr.metadata.uid) + const row = await getDynamicClientBinding(db, { serverNamespace: NS, serverName: srv.name }) + expect(row?.installId).toBe(srv.nonce) + expect(row?.crUid).toBe(cr.metadata.uid) + const h = harness() + + const result = await handleOAuthCallback( + { target: perServer(srv.name, srv.nonce), code: 'CODE', state: await stateFor(srv.name) }, + h.deps + ) + + expect(result).toEqual({ kind: 'invalid_state', reason: 'binding_mismatch' }) + expect(h.tokenPosts).toHaveLength(0) + }) + + // The CR's client id rewritten in place (same uid): the row still belongs to this CR + // but was registered for another client, so its nonce binds nothing. + it('DCR: a bound row registered for another client id → binding_mismatch, no exchange', async () => { + const srv = await installDcr('atlassian') + const cr = (await gateway.getResource('mcpservers', srv.name, NS)) as { + metadata: { uid: string; resourceVersion?: string } + spec: { oauth: Record } & Record + } + await gateway.updateResource( + 'mcpservers', + srv.name, + { + metadata: { uid: cr.metadata.uid, resourceVersion: cr.metadata.resourceVersion }, + spec: { ...cr.spec, oauth: { ...cr.spec.oauth, id: 'rewritten-client-id' } }, + }, + NS + ) + const h = harness() + + const result = await handleOAuthCallback( + { target: perServer(srv.name, srv.nonce), code: 'CODE', state: await stateFor(srv.name) }, + h.deps + ) + + expect(result).toEqual({ kind: 'invalid_state', reason: 'binding_mismatch' }) + expect(h.tokenPosts).toHaveLength(0) + }) + + it('a remote server on /oauth-callback/ → binding_mismatch, no exchange', async () => { + const srv = await installDcr('atlassian') + const h = harness() + const id = String((await oauthOf(srv.name)).id) + + const result = await handleOAuthCallback( + { + target: { kind: 'client', id, redirectUri: `${ORIGIN}/api/v1/oauth-callback/${id}` }, + code: 'CODE', + state: await stateFor(srv.name), + }, + h.deps + ) + + expect(result).toEqual({ kind: 'invalid_state', reason: 'binding_mismatch' }) + expect(h.tokenPosts).toHaveLength(0) + }) + + it.each([ + ['no configured origin', null], + ['a configured origin that is not bare', `${ORIGIN}/base`], + ])('%s → callback_base_url_unconfigured, no exchange', async (_label, origin) => { + const srv = await installDcr('atlassian') + const h = harness() + + const result = await handleOAuthCallback( + { + target: perServer(srv.name, srv.nonce, origin), + code: 'CODE', + state: await stateFor(srv.name), + }, + h.deps + ) + + expect(result).toEqual({ kind: 'callback_base_url_unconfigured' }) + expect(h.tokenPosts).toHaveLength(0) + }) +}) + +describe('per-server callback — an iss the AS sends anyway (I8)', () => { + async function deliverWithIss(iss: unknown) { + const srv = await installDcr('atlassian') + const h = harness() + const result = await handleOAuthCallback( + { + target: perServer(srv.name, srv.nonce), + code: 'CODE-VALUE', + state: await stateFor(srv.name), + iss, + }, + h.deps + ) + return { result, h } + } + + it('equal to the pinned issuer → exchanges', async () => { + const { result, h } = await deliverWithIss(atlassian.issuer) + expect(result.kind).toBe('ok') + expect(h.tokenPosts).toHaveLength(1) + }) + + it('absent → exchanges (the AS does not implement RFC 9207)', async () => { + const { result, h } = await deliverWithIss(undefined) + expect(result.kind).toBe('ok') + expect(h.tokenPosts).toHaveLength(1) + }) + + it.each<[string, () => unknown]>([ + ['a different issuer', () => 'https://evil.example.test'], + ['an empty value', () => ''], + ['a repeated parameter', () => [atlassian.issuer, 'https://evil.example.test']], + ['a repeated parameter with equal values', () => [atlassian.issuer, atlassian.issuer]], + ['a bracketed parameter', () => ({ a: 'b' })], + ])('%s → issuer_mismatch, no exchange, log carries only server and reason', async (_l, iss) => { + const { result, h } = await deliverWithIss(iss()) + + expect(result).toEqual({ kind: 'issuer_mismatch' }) + expect(h.tokenPosts).toHaveLength(0) + expect(logged.warns).toHaveLength(1) + const [fields] = logged.warns[0] as [Record] + expect(Object.keys(fields).sort()).toEqual(['reason', 'serverName']) + expect(fields.serverName).toBe('atlassian') + expect(JSON.stringify(logged.warns[0])).not.toContain('CODE-VALUE') + }) +}) diff --git a/control-api/test/oauth.cimdIdentity.test.ts b/control-api/test/oauth.cimdIdentity.test.ts new file mode 100644 index 0000000000..02e08fec44 --- /dev/null +++ b/control-api/test/oauth.cimdIdentity.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { buildCimdDocument } from '../src/oauth/cimd.js' +import { MAX_CLIENT_ID_LENGTH, isCimdClientId } from '../src/oauth/cimdIdentity.js' + +describe('isCimdClientId', () => { + it('matches the served CIMD client_id and its routed spellings, on any origin', () => { + const served = buildCimdDocument('https://control.example.com').client_id + expect(isCimdClientId(served)).toBe(true) + expect(isCimdClientId(`${served}/`)).toBe(true) + expect(isCimdClientId(served.toUpperCase())).toBe(true) + expect(isCimdClientId(buildCimdDocument('https://old.example.org').client_id)).toBe(true) + }) + + it('rejects other ids, non-strings and non-URLs', () => { + for (const value of [ + 'client-abc', + '', + undefined, + 42, + 'https://control.example.com/.well-known/evenfire-mcp-client', + 'https://control.example.com/api/v1/.well-known/evenfire-mcp-client/extra', + ]) { + expect(isCimdClientId(value)).toBe(false) + } + }) + + it('the cap is the CRD maxLength of spec.oauth.id (512): at it still matches, above it never', () => { + expect(MAX_CLIENT_ID_LENGTH).toBe(512) + const served = buildCimdDocument('https://control.example.com').client_id + const atCap = `${served}?${'x'.repeat(MAX_CLIENT_ID_LENGTH - served.length - 1)}` + expect(atCap.length).toBe(MAX_CLIENT_ID_LENGTH) + expect(isCimdClientId(atCap)).toBe(true) + expect(isCimdClientId(`${atCap}x`)).toBe(false) + }) + + // A run of slashes before a non-slash made the trailing-slash pattern backtrack + // quadratically on attacker-supplied ids (a DCR response or an operator client_id). + it('answers a 200k-slash id quickly', () => { + const hostile = `https://as.example.com/${'/'.repeat(200_000)}a` + const started = performance.now() + expect(isCimdClientId(hostile)).toBe(false) + expect(performance.now() - started).toBeLessThan(200) + }) +}) diff --git a/control-api/test/oauth.dcr.redirectUris.test.ts b/control-api/test/oauth.dcr.redirectUris.test.ts new file mode 100644 index 0000000000..d66ee6bd8c --- /dev/null +++ b/control-api/test/oauth.dcr.redirectUris.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from 'vitest' +import { + type DcrRegistrationResponse, + buildDcrRequest, + registerDynamicClient, + verifyDcrRedirectUris, +} from '../src/oauth/dcr.js' +import { type DiscoveryResult, discoverRemoteOAuth } from '../src/oauth/discovery.js' +import { buildRemoteRedirectUri } from '../src/oauth/remoteCallback.js' +import { + ATLASSIAN_V2_PILOT, + DCR_PUBLIC_REGISTRATION_RESPONSE, + makeDcrTransport, + makeDiscoveryTransport, +} from './fixtures/remoteOAuthDiscovery.js' + +/** + * `redirect_uris` echoed by the AS on a registration response, checked per callback + * variant: per-server requires it and requires it exact; shared only fails when it is + * present and different. + * + * The request comes from the real builders (discovery → `buildRemoteRedirectUri` → + * `buildDcrRequest`) and the response goes through the real parser + * (`registerDynamicClient`), so the verifier sees exactly the shapes it will see in the + * install. The response body itself is the RFC 7591 §3.2.1 fixture the DCR suite uses + * (a live registration would create a real client at a third-party AS), with only + * `redirect_uris` varied. + */ + +const PUBLIC_IP = async () => ['93.184.216.34'] +const ORIGIN = 'https://control.example.com' +const NONCE = '0b8f6a52-5c7d-4e21-9f3a-1d2c3b4a5e6f' + +async function atlassianDiscovery(): Promise { + const outcome = await discoverRemoteOAuth(ATLASSIAN_V2_PILOT.mcpUrl, { + transport: makeDiscoveryTransport(ATLASSIAN_V2_PILOT), + resolveDns: PUBLIC_IP, + }) + if (!outcome.ok) throw new Error(`discovery failed: ${outcome.error.kind}`) + return outcome.result +} + +const PER_SERVER_URI = buildRemoteRedirectUri({ + origin: ORIGIN, + variant: 'per-server', + mode: 'dcr', + serverName: 'atlassian', + installNonce: NONCE, +}) +const SHARED_URI = buildRemoteRedirectUri({ origin: ORIGIN, variant: 'shared' }) + +/** Register through the real client and return the parsed response it hands back. */ +async function registeredResponse( + requestedUri: string, + echoed: unknown | 'omit' +): Promise<{ requested: string[]; response: DcrRegistrationResponse }> { + const discovery = await atlassianDiscovery() + const request = buildDcrRequest(discovery, { + clientMode: 'public', + redirectUris: [requestedUri], + }) + const body: Record = { ...DCR_PUBLIC_REGISTRATION_RESPONSE } + if (echoed === 'omit') delete body.redirect_uris + else body.redirect_uris = echoed + const endpoint = discovery.endpoints.registration + if (!endpoint) throw new Error('fixture AS has no registration endpoint') + const { transport } = makeDcrTransport({ + registrationEndpoint: endpoint, + responseJson: JSON.stringify(body), + }) + const outcome = await registerDynamicClient( + { transport, resolveDns: PUBLIC_IP }, + endpoint, + request + ) + if (!outcome.ok) throw new Error(`registration failed: ${outcome.error.kind}`) + return { requested: request.redirect_uris, response: outcome.response } +} + +describe('verifyDcrRedirectUris — per-server (required, exact)', () => { + it('exact echo → ok', async () => { + const r = await registeredResponse(PER_SERVER_URI, [PER_SERVER_URI]) + expect(verifyDcrRedirectUris({ variant: 'per-server', ...r })).toEqual({ ok: true }) + }) + + it('absent → redirect_uris_missing', async () => { + const r = await registeredResponse(PER_SERVER_URI, 'omit') + expect(verifyDcrRedirectUris({ variant: 'per-server', ...r })).toEqual({ + ok: false, + reason: 'redirect_uris_missing', + }) + }) + + const mismatches: Array<{ label: string; echoed: unknown }> = [ + { label: 'the shared URI instead', echoed: [SHARED_URI] }, + { + label: 'another installation nonce', + echoed: [PER_SERVER_URI.replace(NONCE, '1'.repeat(8) + NONCE.slice(8))], + }, + { label: 'a normalized-looking variant (trailing slash)', echoed: [`${PER_SERVER_URI}/`] }, + { label: 'a case-changed variant', echoed: [PER_SERVER_URI.toUpperCase()] }, + { label: 'an extra URI', echoed: [PER_SERVER_URI, SHARED_URI] }, + { label: 'the right URI duplicated', echoed: [PER_SERVER_URI, PER_SERVER_URI] }, + { label: 'an empty list', echoed: [] }, + { label: 'a bare string', echoed: PER_SERVER_URI }, + { label: 'a non-string element', echoed: [42] }, + ] + for (const { label, echoed } of mismatches) { + it(`${label} → redirect_uris_mismatch`, async () => { + const r = await registeredResponse(PER_SERVER_URI, echoed) + expect(verifyDcrRedirectUris({ variant: 'per-server', ...r })).toEqual({ + ok: false, + reason: 'redirect_uris_mismatch', + }) + }) + } +}) + +describe('verifyDcrRedirectUris — per-server treats null as missing', () => { + it('null → redirect_uris_missing', async () => { + const r = await registeredResponse(PER_SERVER_URI, null) + expect(verifyDcrRedirectUris({ variant: 'per-server', ...r })).toEqual({ + ok: false, + reason: 'redirect_uris_missing', + }) + }) +}) + +describe('verifyDcrRedirectUris — shared (tolerates absence, rejects a different echo)', () => { + it('null → ok (same as absent)', async () => { + const r = await registeredResponse(SHARED_URI, null) + expect(verifyDcrRedirectUris({ variant: 'shared', ...r })).toEqual({ ok: true }) + }) + + it('absent → ok (ASes that do not echo keep working)', async () => { + const r = await registeredResponse(SHARED_URI, 'omit') + expect(verifyDcrRedirectUris({ variant: 'shared', ...r })).toEqual({ ok: true }) + }) + + it('exact echo → ok', async () => { + const r = await registeredResponse(SHARED_URI, [SHARED_URI]) + expect(verifyDcrRedirectUris({ variant: 'shared', ...r })).toEqual({ ok: true }) + }) + + it('present and different → redirect_uris_mismatch', async () => { + const r = await registeredResponse(SHARED_URI, ['https://attacker.example/cb']) + expect(verifyDcrRedirectUris({ variant: 'shared', ...r })).toEqual({ + ok: false, + reason: 'redirect_uris_mismatch', + }) + }) +}) diff --git a/control-api/test/oauth.dcr.test.ts b/control-api/test/oauth.dcr.test.ts index 45500170d2..bf259be9a3 100644 --- a/control-api/test/oauth.dcr.test.ts +++ b/control-api/test/oauth.dcr.test.ts @@ -88,6 +88,52 @@ describe('registerDynamicClient (RFC 7591, effectful via injected pinned transpo redirectUris: REDIRECT_URIS, }) + it('an oversized client_id → invalid_response carrying the RFC 7592 handle for cleanup', async () => { + const { transport } = makeDcrTransport({ + responseJson: JSON.stringify({ + ...DCR_CONFIDENTIAL_REGISTRATION_RESPONSE, + client_id: `https://as.example.com/${'/'.repeat(200_000)}a`, + }), + }) + const started = performance.now() + const outcome = await registerDynamicClient( + { transport, resolveDns: PUBLIC_IP }, + DCR_REGISTRATION_ENDPOINT, + confReq + ) + expect(performance.now() - started).toBeLessThan(200) + expect(outcome.ok).toBe(false) + if (outcome.ok) return + expect(outcome.error).toMatchObject({ + kind: 'invalid_response', + minted: true, + registrationClientUri: DCR_CONFIDENTIAL_REGISTRATION_RESPONSE.registration_client_uri, + registrationAccessToken: DCR_CONFIDENTIAL_REGISTRATION_RESPONSE.registration_access_token, + }) + }) + + it('client_id length: 512 bytes accepted, 513 → invalid_response (the CRD caps spec.oauth.id at 512)', async () => { + for (const [length, ok] of [ + [512, true], + [513, false], + ] as const) { + const { transport } = makeDcrTransport({ + responseJson: JSON.stringify({ + ...DCR_PUBLIC_REGISTRATION_RESPONSE, + client_id: 'c'.repeat(length), + }), + }) + const outcome = await registerDynamicClient( + { transport, resolveDns: PUBLIC_IP }, + DCR_REGISTRATION_ENDPOINT, + publicReq + ) + expect(outcome.ok).toBe(ok) + if (!outcome.ok) + expect(outcome.error).toMatchObject({ kind: 'invalid_response', minted: true }) + } + }) + it('public: parses the RFC 7591 response and returns the assigned client_id', async () => { const { transport, calls } = makeDcrTransport({ responseJson: JSON.stringify(DCR_PUBLIC_REGISTRATION_RESPONSE), diff --git a/control-api/test/oauth.dcrCredential.installIdentity.realPostgres.integration.test.ts b/control-api/test/oauth.dcrCredential.installIdentity.realPostgres.integration.test.ts index 7338b07094..034ca64eb3 100644 --- a/control-api/test/oauth.dcrCredential.installIdentity.realPostgres.integration.test.ts +++ b/control-api/test/oauth.dcrCredential.installIdentity.realPostgres.integration.test.ts @@ -45,6 +45,7 @@ import { dcrPilot, makeDiscoveryTransport, } from './fixtures/remoteOAuthDiscovery.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' import { MockGateway } from './mockGateway.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL @@ -115,15 +116,18 @@ describeRealPostgres( }) afterAll(async () => { - await dbPool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) - await adminPool.end() + try { + await endPoolAndWaitForClients(dbPool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) + } + } finally { + await adminPool?.end() } }) @@ -196,7 +200,7 @@ describeRealPostgres( const cr = (await gateway.getResource('mcpservers', name, NS)) as Parameters< typeof resolveServerOAuthSubject >[0] - const resolved = resolveServerOAuthSubject(cr) + const resolved = resolveServerOAuthSubject(cr, 'consent') return resolved ? { namespace: NS, ...resolved } : null }, } @@ -208,7 +212,7 @@ describeRealPostgres( transport: ReturnType['transport'] ) { const input: CallbackInput = { - oauthClientId: 'remote', + target: { kind: 'remote-shared', origin: 'https://control.example.com' }, code: 'AUTH_CODE', state: signOAuthState(STATE_SECRET, { subjectKind: 'mcp', @@ -218,7 +222,6 @@ describeRealPostgres( grantKind: 'user', background: false, } as Parameters[1]), - redirectUri: 'https://control.example.com/api/v1/oauth-callback/remote', iss: discovery.issuer, } const deps: CallbackDeps = { diff --git a/control-api/test/oauth.deleteOAuthGrantsForServer.realPostgres.integration.test.ts b/control-api/test/oauth.deleteOAuthGrantsForServer.realPostgres.integration.test.ts index 9f44ae5756..71017d311a 100644 --- a/control-api/test/oauth.deleteOAuthGrantsForServer.realPostgres.integration.test.ts +++ b/control-api/test/oauth.deleteOAuthGrantsForServer.realPostgres.integration.test.ts @@ -11,6 +11,7 @@ import { oauthGrantExists, upsertOAuthGrant, } from '../src/oauth/store.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' /** * R1-L3 (T4) — the server-teardown purge (DEC-R2) observed as SURVIVING ROWS, not @@ -54,15 +55,18 @@ describeRealPostgres('deleteOAuthGrantsForServer — full server-scoped wipe (re }) afterAll(async () => { - await dbPool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) - await adminPool.end() + try { + await endPoolAndWaitForClients(dbPool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/oauth.discovery.properties.test.ts b/control-api/test/oauth.discovery.properties.test.ts index 1c92718aec..dede253ce6 100644 --- a/control-api/test/oauth.discovery.properties.test.ts +++ b/control-api/test/oauth.discovery.properties.test.ts @@ -4,6 +4,7 @@ import { type AuthorizationServerMetadata, type ProtectedResourceMetadata, type RegistrationMode, + advertisesIssBinding, deriveQuirks, selectRegistrationMode, } from '../src/oauth/discovery.js' @@ -17,13 +18,17 @@ import { PILOTS } from './fixtures/remoteOAuthDiscovery.js' // Reference implementation of the normative order, kept deliberately independent // of the production one so the property catches a reordering in either direction. +// Without RFC 9207 CIMD is never a candidate (its single platform identity can only +// list the shared callback, which relies on `iss`). function expectedMode(input: { hasPreRegisteredClient: boolean cimdSupported: boolean tokenEndpointAuthMethods: string[] hasRegistrationEndpoint: boolean + issBindingSupported: boolean }): RegistrationMode { if (input.hasPreRegisteredClient) return 'pre-registered' + if (!input.issBindingSupported) return input.hasRegistrationEndpoint ? 'dcr' : 'manual' if (input.cimdSupported && input.tokenEndpointAuthMethods.includes('none')) return 'cimd' if (input.hasRegistrationEndpoint) return 'dcr' return 'manual' @@ -39,17 +44,20 @@ describe('selectRegistrationMode — order pre-reg > CIMD > DCR > manual (T2)', fc.boolean(), authMethodArb, fc.boolean(), + fc.boolean(), ( hasPreRegisteredClient, cimdSupported, tokenEndpointAuthMethods, - hasRegistrationEndpoint + hasRegistrationEndpoint, + issBindingSupported ) => { const input = { hasPreRegisteredClient, cimdSupported, tokenEndpointAuthMethods, hasRegistrationEndpoint, + issBindingSupported, } expect(selectRegistrationMode(input)).toBe(expectedMode(input)) } @@ -58,20 +66,55 @@ describe('selectRegistrationMode — order pre-reg > CIMD > DCR > manual (T2)', }) it('pre-registered wins over everything else', () => { + fc.assert( + fc.property( + fc.boolean(), + authMethodArb, + fc.boolean(), + fc.boolean(), + (cimd, methods, reg, iss) => { + expect( + selectRegistrationMode({ + hasPreRegisteredClient: true, + cimdSupported: cimd, + tokenEndpointAuthMethods: methods, + hasRegistrationEndpoint: reg, + issBindingSupported: iss, + }) + ).toBe('pre-registered') + } + ) + ) + }) + + it('never selects CIMD without RFC 9207', () => { fc.assert( fc.property(fc.boolean(), authMethodArb, fc.boolean(), (cimd, methods, reg) => { expect( selectRegistrationMode({ - hasPreRegisteredClient: true, + hasPreRegisteredClient: false, cimdSupported: cimd, tokenEndpointAuthMethods: methods, hasRegistrationEndpoint: reg, + issBindingSupported: false, }) - ).toBe('pre-registered') + ).not.toBe('cimd') }) ) }) + it('CIMD-only AS (no registration endpoint) without RFC 9207 → manual', () => { + expect( + selectRegistrationMode({ + hasPreRegisteredClient: false, + cimdSupported: true, + tokenEndpointAuthMethods: ['none'], + hasRegistrationEndpoint: false, + issBindingSupported: false, + }) + ).toBe('manual') + }) + it('CIMD requires BOTH cimdSupported AND `none` (never one alone)', () => { // cimdSupported without `none` → not cimd. expect( @@ -80,6 +123,7 @@ describe('selectRegistrationMode — order pre-reg > CIMD > DCR > manual (T2)', cimdSupported: true, tokenEndpointAuthMethods: ['client_secret_post'], hasRegistrationEndpoint: true, + issBindingSupported: true, }) ).toBe('dcr') // `none` without cimdSupported → not cimd. @@ -89,11 +133,13 @@ describe('selectRegistrationMode — order pre-reg > CIMD > DCR > manual (T2)', cimdSupported: false, tokenEndpointAuthMethods: ['none'], hasRegistrationEndpoint: true, + issBindingSupported: true, }) ).toBe('dcr') }) - it('the 4 real pilots all select CIMD', () => { + it('the 4 real pilots: CIMD only where the AS returns `iss` (Linear, Sentry)', () => { + const expected = { notion: 'dcr', linear: 'cimd', sentry: 'cimd', canva: 'dcr' } as const for (const key of ['notion', 'linear', 'sentry', 'canva'] as const) { const as = JSON.parse(PILOTS[key].as.json) as AuthorizationServerMetadata expect( @@ -102,8 +148,9 @@ describe('selectRegistrationMode — order pre-reg > CIMD > DCR > manual (T2)', cimdSupported: as.client_id_metadata_document_supported === true, tokenEndpointAuthMethods: as.token_endpoint_auth_methods_supported ?? [], hasRegistrationEndpoint: typeof as.registration_endpoint === 'string', + issBindingSupported: advertisesIssBinding(as), }) - ).toBe('cimd') + ).toBe(expected[key]) } }) }) diff --git a/control-api/test/oauth.discovery.sameSite.test.ts b/control-api/test/oauth.discovery.sameSite.test.ts new file mode 100644 index 0000000000..85c53ee69d --- /dev/null +++ b/control-api/test/oauth.discovery.sameSite.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, it, vi } from 'vitest' +import fc from 'fast-check' +import type { PinnedTransport } from '../src/http/pinnedFetch.js' +import { + type AsEndpointSites, + type DiscoveryDeps, + checkAsEndpointsSameSite, + discoverRemoteOAuth, + registrableSite, +} from '../src/oauth/discovery.js' +import { + ATLASSIAN_V2_AS_JSON, + ATLASSIAN_V2_PILOT, + DROPBOX_PILOT, + PILOTS, + type PilotFixture, + VERCEL_PILOT, + makeDiscoveryTransport, +} from './fixtures/remoteOAuthDiscovery.js' + +/** + * Without RFC 9207 the callback cannot tell which AS produced a code, so discovery + * requires every AS endpoint to share the issuer's registrable domain (PSL, private + * section included). With RFC 9207 the rule does not apply. + * + * The honest cases run on real probe bytes (Atlassian v2, Dropbox, Vercel — no 9207). + * The hostile cases cannot be probed (no real AS serves them), so they are the real + * Atlassian bytes with ONE documented field swapped, the way a malicious metadata + * document would borrow an honest AS's endpoints. + */ + +const PUBLIC_IP = ['93.184.216.34'] + +interface RecordedRequest { + url: string + method?: string +} + +/** Wrap a fixture transport, recording every request that reaches the socket layer. */ +function recording(base: PinnedTransport): { + transport: PinnedTransport + calls: RecordedRequest[] +} { + const calls: RecordedRequest[] = [] + const transport: PinnedTransport = async input => { + calls.push({ url: input.url, method: input.method }) + return base(input) + } + return { transport, calls } +} + +function deps(transport: PinnedTransport): DiscoveryDeps { + return { transport, resolveDns: vi.fn(async () => PUBLIC_IP) } +} + +/** The real Atlassian v2 pilot with its AS metadata rewritten by `mutate`. */ +function atlassianWith(mutate: (as: Record) => void): PilotFixture { + const as = JSON.parse(ATLASSIAN_V2_AS_JSON) as Record + mutate(as) + return { ...ATLASSIAN_V2_PILOT, as: { ...ATLASSIAN_V2_PILOT.as, json: JSON.stringify(as) } } +} + +async function discover(pilot: PilotFixture) { + const { transport, calls } = recording(makeDiscoveryTransport(pilot)) + const outcome = await discoverRemoteOAuth(pilot.mcpUrl, deps(transport)) + return { outcome, calls } +} + +describe('discovery same-site rule — real ASes without RFC 9207', () => { + it('Atlassian v2 (CIMD + DCR, all on atlassian.com) → DCR, never CIMD', async () => { + const { outcome } = await discover(ATLASSIAN_V2_PILOT) + expect(outcome.ok).toBe(true) + if (!outcome.ok) return + expect(outcome.result.issForCallback).toBeUndefined() + expect(outcome.result.registrationMode).toBe('dcr') + }) + + it('Vercel (token on api.vercel.com, issuer vercel.com) is one site → accepted', async () => { + const { outcome } = await discover(VERCEL_PILOT) + expect(outcome.ok).toBe(true) + if (outcome.ok) expect(outcome.result.registrationMode).toBe('dcr') + }) + + it('Dropbox (token on api.dropboxapi.com, issuer www.dropbox.com) → as_endpoints_cross_site', async () => { + const { outcome, calls } = await discover(DROPBOX_PILOT) + expect(outcome.ok).toBe(false) + if (!outcome.ok) { + expect(outcome.error).toMatchObject({ kind: 'as_endpoints_cross_site', field: 'token' }) + } + // Discovery never reaches the registration endpoint (nor POSTs anything). + expect(calls.some(c => c.url === 'https://www.dropbox.com/oauth2/register')).toBe(false) + expect(calls.some(c => c.method === 'POST')).toBe(false) + }) +}) + +describe('discovery same-site rule — mixed metadata (mix-up via a borrowed AS)', () => { + it('authorize + registration on the honest AS, token on another site → rejected before any registration', async () => { + const pilot = atlassianWith(as => { + as.token_endpoint = 'https://attacker.example/oauth/token' + }) + const { outcome, calls } = await discover(pilot) + expect(outcome.ok).toBe(false) + if (!outcome.ok) { + expect(outcome.error).toMatchObject({ kind: 'as_endpoints_cross_site', field: 'token' }) + } + expect(calls.some(c => c.url.includes('/dcr/register'))).toBe(false) + expect(calls.some(c => c.method === 'POST')).toBe(false) + }) + + it('the same mixed metadata WITH RFC 9207 is not subject to the rule (unchanged behaviour)', async () => { + const pilot = atlassianWith(as => { + as.token_endpoint = 'https://attacker.example/oauth/token' + as.authorization_response_iss_parameter_supported = true + }) + const { outcome } = await discover(pilot) + expect(outcome.ok).toBe(true) + if (outcome.ok) { + expect(outcome.result.issForCallback).toBe(JSON.parse(ATLASSIAN_V2_AS_JSON).issuer) + expect(outcome.result.endpoints.token).toBe('https://attacker.example/oauth/token') + } + }) + + it('registration endpoint on another site → rejected', async () => { + const { outcome } = await discover( + atlassianWith(as => { + as.registration_endpoint = 'https://attacker.example/register' + }) + ) + expect(!outcome.ok && outcome.error).toMatchObject({ + kind: 'as_endpoints_cross_site', + field: 'registration', + }) + }) + + // Nothing sends to the revocation endpoint and it is not pinned on the CR, so it is + // not trusted with anything; an off-site or relative one must not block the install. + for (const revocation of ['https://attacker.example/revoke', '/oauth/revoke']) { + it(`revocation endpoint ${JSON.stringify(revocation)} → ignored, discovery succeeds`, async () => { + const { outcome } = await discover( + atlassianWith(as => { + as.revocation_endpoint = revocation + }) + ) + expect(outcome.ok).toBe(true) + }) + } + + it('authorization endpoint on another site → rejected', async () => { + const { outcome } = await discover( + atlassianWith(as => { + as.authorization_endpoint = 'https://attacker.example/authorize' + }) + ) + expect(!outcome.ok && outcome.error).toMatchObject({ + kind: 'as_endpoints_cross_site', + field: 'authorization', + }) + }) + + it('token `https://evil.com\\@auth.atlassian.com/…` (parser differential) → rejected', async () => { + // WHATWG — and therefore the pinned socket — reads the host as `evil.com`. The + // site must come from that same parse, never from a second parser over the raw URL. + const { outcome } = await discover( + atlassianWith(as => { + as.token_endpoint = 'https://evil.com\\@auth.atlassian.com/oauth/token' + }) + ) + expect(outcome.ok).toBe(false) + if (!outcome.ok) expect(outcome.error.kind).toBe('as_endpoints_cross_site') + }) + + it('token endpoint with userinfo on the honest host → rejected', async () => { + const { outcome } = await discover( + atlassianWith(as => { + as.token_endpoint = 'https://user:pass@auth.atlassian.com/oauth/token' + }) + ) + expect(outcome.ok).toBe(false) + if (!outcome.ok) { + expect(outcome.error).toMatchObject({ kind: 'as_endpoints_cross_site', field: 'token' }) + } + }) +}) + +/** The endpoints of one AS, positionally: issuer, authorization, token, registration. */ +function sites( + issuer: string, + authorization: string, + token: string, + registration?: string +): AsEndpointSites { + return { issuer, authorization, token, ...(registration ? { registration } : {}) } +} + +describe('registrableSite / checkAsEndpointsSameSite (PSL semantics)', () => { + it('private PSL section: two github.io tenants are two sites', () => { + expect(registrableSite('https://a.github.io/x')).toBe('a.github.io') + expect( + checkAsEndpointsSameSite( + sites('https://a.github.io', 'https://a.github.io/authorize', 'https://b.github.io/token') + ) + ).toMatchObject({ ok: false, field: 'token' }) + }) + + it('subdomains of one registrable domain are one site (api.vercel.com ~ vercel.com)', () => { + expect( + checkAsEndpointsSameSite( + sites( + 'https://vercel.com', + 'https://vercel.com/oauth/authorize', + 'https://api.vercel.com/login/oauth/token', + 'https://api.vercel.com/login/oauth/register' + ) + ) + ).toEqual({ ok: true }) + }) + + it('a host that is itself a public suffix has no site; null never equals null', () => { + expect(registrableSite('https://github.io/token')).toBeNull() + expect( + checkAsEndpointsSameSite( + sites('https://github.io', 'https://github.io/authorize', 'https://github.io/token') + ) + ).toMatchObject({ ok: false, field: 'issuer' }) + expect( + checkAsEndpointsSameSite( + sites( + 'https://honest.example.com', + 'https://honest.example.com/authorize', + 'https://herokuapp.com/token' + ) + ) + ).toMatchObject({ ok: false, field: 'token' }) + }) + + it('the site is taken from the WHATWG host, the one the pinned socket connects to', () => { + expect(registrableSite('https://evil.com\\@honest.com/token')).toBe('evil.com') + // WHATWG decodes these hosts to `api.vercel.com`; `tldts` over the raw string does + // not, so only the hostname-first parse yields the site the request really goes to. + expect(registrableSite('https://api%2Evercel.com/token')).toBe('vercel.com') + expect(registrableSite('https://api\u3002vercel.com/token')).toBe('vercel.com') + }) + + it('any userinfo, an IP literal or an unparsable URL has no site', () => { + expect(registrableSite('https://user@honest.com/token')).toBeNull() + expect(registrableSite('https://user:pw@honest.com/token')).toBeNull() + expect(registrableSite('https://93.184.216.34/token')).toBeNull() + expect(registrableSite('not a url')).toBeNull() + }) +}) + +describe('I3 — without RFC 9207 discovery never selects CIMD (T2, through discoverRemoteOAuth)', () => { + // Arbitrary AS capabilities layered on the real Notion bytes. The `iss` flag is + // fuzzed over non-boolean truthy values too: only a literal `true` is an advertisement. + const issFlagArb = fc.constantFrom(undefined, true, false, 'true', 1) + const cimdFlagArb = fc.constantFrom(undefined, true, false) + const methodsArb = fc.subarray(['none', 'client_secret_basic', 'client_secret_post']) + + it('registrationMode is cimd only when the AS literally advertises iss === true', async () => { + await fc.assert( + fc.asyncProperty( + issFlagArb, + cimdFlagArb, + methodsArb, + fc.boolean(), + async (issFlag, cimdFlag, methods, hasRegistration) => { + const as = JSON.parse(PILOTS.notion.as.json) as Record + delete as.authorization_response_iss_parameter_supported + delete as.client_id_metadata_document_supported + if (issFlag !== undefined) as.authorization_response_iss_parameter_supported = issFlag + if (cimdFlag !== undefined) as.client_id_metadata_document_supported = cimdFlag + as.token_endpoint_auth_methods_supported = methods + if (!hasRegistration) delete as.registration_endpoint + const pilot: PilotFixture = { + ...PILOTS.notion, + as: { ...PILOTS.notion.as, json: JSON.stringify(as) }, + } + const outcome = await discoverRemoteOAuth( + pilot.mcpUrl, + deps(makeDiscoveryTransport(pilot)) + ) + expect(outcome.ok).toBe(true) + if (!outcome.ok) return + const iss = issFlag === true + expect(outcome.result.issForCallback !== undefined).toBe(iss) + if (!iss) expect(outcome.result.registrationMode).not.toBe('cimd') + const expectedCimd = iss && cimdFlag === true && methods.includes('none') + expect(outcome.result.registrationMode === 'cimd').toBe(expectedCimd) + } + ), + { numRuns: 300 } + ) + }) +}) + +// Guards the recording helper itself: a transport that never records would make the +// "no POST / no registration fetch" assertions above vacuous. +describe('recording transport sanity', () => { + it('records the discovery GETs', async () => { + const { calls } = await discover(ATLASSIAN_V2_PILOT) + expect(calls.length).toBeGreaterThan(0) + expect(calls.every(c => c.method !== 'POST')).toBe(true) + }) +}) diff --git a/control-api/test/oauth.discovery.test.ts b/control-api/test/oauth.discovery.test.ts index cb1252df66..214cd71760 100644 --- a/control-api/test/oauth.discovery.test.ts +++ b/control-api/test/oauth.discovery.test.ts @@ -29,16 +29,20 @@ function deps(transport: PinnedTransport, resolveDns = publicDns()): DiscoveryDe return { transport, resolveDns } } -describe('discoverRemoteOAuth — 4 CIMD pilots (T1 real fixtures)', () => { +describe('discoverRemoteOAuth — 4 CIMD-capable pilots (T1 real fixtures)', () => { + // All 4 advertise CIMD + `none`, but CIMD is only offered when the AS also returns + // `iss` (RFC 9207): Linear and Sentry do; Notion and Canva do not and fall back to + // their registration endpoint (DCR). + const expectedMode = { notion: 'dcr', linear: 'cimd', sentry: 'cimd', canva: 'dcr' } as const for (const key of ['notion', 'linear', 'sentry', 'canva'] as const) { const pilot = PILOTS[key] - it(`${key}: resolves PRM→AS, selects CIMD, pins endpoints`, async () => { + it(`${key}: resolves PRM→AS, selects ${expectedMode[key]}, pins endpoints`, async () => { const outcome = await discoverRemoteOAuth(pilot.mcpUrl, deps(makeDiscoveryTransport(pilot))) expect(outcome.ok).toBe(true) if (!outcome.ok) return const r = outcome.result - // D-3: all 4 pilots resolve to CIMD (cimd_supported + `none`). - expect(r.registrationMode).toBe('cimd') + expect(r.registrationMode).toBe(expectedMode[key]) + expect(r.issForCallback !== undefined).toBe(expectedMode[key] === 'cimd') // RFC 8707: resource is the PRM resource verbatim. expect(r.resource).toBe(JSON.parse(pilot.prm.json).resource) // Endpoints pinned from the AS metadata. @@ -52,13 +56,19 @@ describe('discoverRemoteOAuth — 4 CIMD pilots (T1 real fixtures)', () => { }) } - it('sentry: exposes issForCallback (RFC 9207); others do not', async () => { + it('sentry and linear expose issForCallback (RFC 9207); notion does not', async () => { const sentry = await discoverRemoteOAuth( PILOTS.sentry.mcpUrl, deps(makeDiscoveryTransport(PILOTS.sentry)) ) expect(sentry.ok && sentry.result.issForCallback).toBe('https://mcp.sentry.dev') + const linear = await discoverRemoteOAuth( + PILOTS.linear.mcpUrl, + deps(makeDiscoveryTransport(PILOTS.linear)) + ) + expect(linear.ok && linear.result.issForCallback).toBe('https://mcp.linear.app') + const notion = await discoverRemoteOAuth( PILOTS.notion.mcpUrl, deps(makeDiscoveryTransport(PILOTS.notion)) diff --git a/control-api/test/oauth.dynamicClientStore.installIdentity.realPostgres.integration.test.ts b/control-api/test/oauth.dynamicClientStore.installIdentity.realPostgres.integration.test.ts index 463e04f867..56d4ff01ca 100644 --- a/control-api/test/oauth.dynamicClientStore.installIdentity.realPostgres.integration.test.ts +++ b/control-api/test/oauth.dynamicClientStore.installIdentity.realPostgres.integration.test.ts @@ -13,10 +13,12 @@ import { deleteDynamicClientOwnedByInstall, getDynamicClient, insertDynamicClientPending, + isDynamicClientIdRegistered, reclaimOrphanDynamicClient, upsertDynamicClient, } from '../src/oauth/dynamicClientStore.js' import { deriveOAuthEncryptionKey } from '../src/oauth/encryption.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' /** * Install-identity store primitives, observed as SURVIVING ROWS and returned @@ -135,15 +137,18 @@ describeRealPostgres('dynamicClientStore install identity (real Postgres)', () = }) afterAll(async () => { - await dbPool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) - await adminPool.end() + try { + await endPoolAndWaitForClients(dbPool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) + } + } finally { + await adminPool?.end() } }) @@ -196,6 +201,58 @@ describeRealPostgres('dynamicClientStore install identity (real Postgres)', () = expect(after?.registrationClientUri).toBe(`https://as.example.com/reg/${name}`) }) + it('isDynamicClientIdRegistered finds a client_id in pending, bound and legacy rows of the namespace only', async () => { + const tag = randomUUID().slice(0, 8) + const pending = `srv-idp-${tag}` + const bound = `srv-idb-${tag}` + const legacy = `srv-idl-${tag}` + await insertDynamicClientPending(db, KEY, { + ...baseCreds(pending, { clientId: `cid-p-${tag}` }), + installId: randomUUID(), + }) + const boundInstall = randomUUID() + await insertDynamicClientPending(db, KEY, { + ...baseCreds(bound, { clientId: `cid-b-${tag}` }), + installId: boundInstall, + }) + await bindDynamicClientToResource( + db, + { serverNamespace: NS, serverName: bound }, + boundInstall, + `uid-${tag}` + ) + await upsertDynamicClient(db, KEY, baseCreds(legacy, { clientId: `cid-l-${tag}` })) + + const issuer = 'https://as.example.com' + for (const clientId of [`cid-p-${tag}`, `cid-b-${tag}`, `cid-l-${tag}`]) { + expect(await isDynamicClientIdRegistered(db, { serverNamespace: NS, issuer, clientId })).toBe( + true + ) + } + expect( + await isDynamicClientIdRegistered(db, { + serverNamespace: NS, + issuer, + clientId: `cid-none-${tag}`, + }) + ).toBe(false) + expect( + await isDynamicClientIdRegistered(db, { + serverNamespace: `${NS}-other`, + issuer, + clientId: `cid-b-${tag}`, + }) + ).toBe(false) + // A client_id is only unique within its AS: the same id at another issuer is not ours. + expect( + await isDynamicClientIdRegistered(db, { + serverNamespace: NS, + issuer: 'https://other-as.example.org', + clientId: `cid-b-${tag}`, + }) + ).toBe(false) + }) + const keyOf = (serverName: string) => ({ serverNamespace: NS, serverName }) /** diff --git a/control-api/test/oauth.legacyGrantLaneFence.realPostgres.integration.test.ts b/control-api/test/oauth.legacyGrantLaneFence.realPostgres.integration.test.ts index 2796e439d7..13c5500a3a 100644 --- a/control-api/test/oauth.legacyGrantLaneFence.realPostgres.integration.test.ts +++ b/control-api/test/oauth.legacyGrantLaneFence.realPostgres.integration.test.ts @@ -53,6 +53,7 @@ import { remoteDiscovery, remoteOAuth, } from './fixtures/legacyOAuthGrant.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' import { MockGateway } from './mockGateway.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL @@ -92,15 +93,18 @@ describeRealPostgres( }) afterAll(async () => { - await dbPool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) - await adminPool.end() + try { + await endPoolAndWaitForClients(dbPool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/oauth.mcpDisconnect.realPostgres.integration.test.ts b/control-api/test/oauth.mcpDisconnect.realPostgres.integration.test.ts index c3a03147e2..c929c2ccbf 100644 --- a/control-api/test/oauth.mcpDisconnect.realPostgres.integration.test.ts +++ b/control-api/test/oauth.mcpDisconnect.realPostgres.integration.test.ts @@ -12,6 +12,7 @@ import { oauthGrantExists, upsertOAuthGrant, } from '../src/oauth/store.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' import { MockGateway } from './mockGateway.js' // T1/T4 — the disconnect composition (buildMcpServerGrantKey → deleteOAuthGrant) @@ -72,15 +73,18 @@ describeRealPostgres('mcp-server grant disconnect composition (real Postgres)', }) afterAll(async () => { - await dbPool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) - await adminPool.end() + try { + await endPoolAndWaitForClients(dbPool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/oauth.mcpServerOAuthSpec.generic.test.ts b/control-api/test/oauth.mcpServerOAuthSpec.generic.test.ts index d63c3ae2be..6cf28da99e 100644 --- a/control-api/test/oauth.mcpServerOAuthSpec.generic.test.ts +++ b/control-api/test/oauth.mcpServerOAuthSpec.generic.test.ts @@ -43,7 +43,7 @@ function serverFrom(oauth: Record, contextRef = 'ctx-a'): McpSe describe('resolveServerOAuthSubject — generic lane (DEC-28)', () => { it('public (neither ref) → secretSource:public, no refs, provider label generic', () => { - const r = resolveServerOAuthSubject(serverFrom({ ...KNOBS })) + const r = resolveServerOAuthSubject(serverFrom({ ...KNOBS }), 'consent') expect(r).not.toBeNull() expect(r?.decl.id).toBe('my-generic-client') expect(r?.decl.provider).toBe('generic') @@ -70,7 +70,8 @@ describe('resolveServerOAuthSubject — generic lane (DEC-28)', () => { ...KNOBS, clientIdRef: { name: 'gen-creds', key: 'client_id' }, clientSecretRef: { name: 'gen-creds', key: 'client_secret' }, - }) + }), + 'consent' ) expect(r?.decl.clientIdRef).toEqual({ name: 'gen-creds', key: 'client_id' }) expect(r?.decl.clientSecretRef).toEqual({ name: 'gen-creds', key: 'client_secret' }) @@ -88,7 +89,8 @@ describe('resolveServerOAuthSubject — generic lane (DEC-28)', () => { refreshEndpoint: 'https://idp.example.com/refresh', resource: 'https://api.example.com', extraAuthorizeParams: { audience: 'aud-1', dropped: 42 }, - }) + }), + 'consent' ) expect(r?.decl.generic?.refreshEndpoint).toBe('https://idp.example.com/refresh') expect(r?.decl.generic?.resource).toBe('https://api.example.com') @@ -101,7 +103,10 @@ describe('resolveServerOAuthSubject — generic lane (DEC-28)', () => { // the runtime reader must apply the same key cap (genericKnobs: 16). const many: Record = {} for (let i = 0; i < 20; i++) many[`k${String(i).padStart(2, '0')}`] = `v${i}` - const r = resolveServerOAuthSubject(serverFrom({ ...KNOBS, extraAuthorizeParams: many })) + const r = resolveServerOAuthSubject( + serverFrom({ ...KNOBS, extraAuthorizeParams: many }), + 'consent' + ) expect(Object.keys(r?.decl.generic?.extraAuthorizeParams ?? {}).length).toBe(16) }) @@ -110,7 +115,8 @@ describe('resolveServerOAuthSubject — generic lane (DEC-28)', () => { serverFrom({ ...KNOBS, extraAuthorizeParams: { ok: 'short', tooLong: 'x'.repeat(1025) }, - }) + }), + 'consent' ) const params = r?.decl.generic?.extraAuthorizeParams ?? {} // The over-long value is dropped, not truncated or forwarded. @@ -120,28 +126,34 @@ describe('resolveServerOAuthSubject — generic lane (DEC-28)', () => { it('half-declared refs (only one) fail closed to null', () => { expect( - resolveServerOAuthSubject(serverFrom({ ...KNOBS, clientIdRef: { name: 'x', key: 'y' } })) + resolveServerOAuthSubject( + serverFrom({ ...KNOBS, clientIdRef: { name: 'x', key: 'y' } }), + 'consent' + ) ).toBeNull() expect( - resolveServerOAuthSubject(serverFrom({ ...KNOBS, clientSecretRef: { name: 'x', key: 'y' } })) + resolveServerOAuthSubject( + serverFrom({ ...KNOBS, clientSecretRef: { name: 'x', key: 'y' } }), + 'consent' + ) ).toBeNull() }) it('malformed wire enum fails closed to null', () => { expect( - resolveServerOAuthSubject(serverFrom({ ...KNOBS, tokenRequestFormat: 'xml' })) + resolveServerOAuthSubject(serverFrom({ ...KNOBS, tokenRequestFormat: 'xml' }), 'consent') ).toBeNull() expect( - resolveServerOAuthSubject(serverFrom({ ...KNOBS, tokenAuthMethod: 'header' })) + resolveServerOAuthSubject(serverFrom({ ...KNOBS, tokenAuthMethod: 'header' }), 'consent') ).toBeNull() expect( - resolveServerOAuthSubject(serverFrom({ ...KNOBS, scopeSeparator: 'semicolon' })) + resolveServerOAuthSubject(serverFrom({ ...KNOBS, scopeSeparator: 'semicolon' }), 'consent') ).toBeNull() }) it('missing required endpoint fails closed to null', () => { const { tokenEndpoint: _drop, ...noToken } = KNOBS - expect(resolveServerOAuthSubject(serverFrom(noToken))).toBeNull() + expect(resolveServerOAuthSubject(serverFrom(noToken), 'consent')).toBeNull() }) it('resolveServerOAuth keys the grant coordinate by oauth.id (grantScope respected)', () => { @@ -166,7 +178,8 @@ describe('baked lane is unaffected by the generic branch', () => { clientSecretRef: { name: 'creds', key: 'client-secret' }, scopes: ['a'], backgroundAccess: true, - }) + }), + 'consent' ) expect(r?.decl.provider).toBe('google') expect('generic' in (r?.decl ?? {})).toBe(false) diff --git a/control-api/test/oauth.mcpServerOAuthSpec.lane.test.ts b/control-api/test/oauth.mcpServerOAuthSpec.lane.test.ts index 1f5616dcf2..35dade64be 100644 --- a/control-api/test/oauth.mcpServerOAuthSpec.lane.test.ts +++ b/control-api/test/oauth.mcpServerOAuthSpec.lane.test.ts @@ -55,8 +55,8 @@ describe('legacyProvider on the resolved coordinate', () => { source => { const oauth = { ...bakedOAuth('google', 'user'), source } const baked = bakedOAuth('google', 'user') - expect(resolveServerOAuthSubject(server(oauth))?.decl).toEqual( - resolveServerOAuthSubject(server(baked))?.decl + expect(resolveServerOAuthSubject(server(oauth), 'consent')?.decl).toEqual( + resolveServerOAuthSubject(server(baked), 'consent')?.decl ) expect(normalizeMcpServerOwnerDecl(server(oauth))?.spec?.oauthClients).toEqual( normalizeMcpServerOwnerDecl(server(baked))?.spec?.oauthClients diff --git a/control-api/test/oauth.mcpServerOAuthSpec.remote.test.ts b/control-api/test/oauth.mcpServerOAuthSpec.remote.test.ts index 0cfe8873b8..027fe7609e 100644 --- a/control-api/test/oauth.mcpServerOAuthSpec.remote.test.ts +++ b/control-api/test/oauth.mcpServerOAuthSpec.remote.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'vitest' -import type { DiscoveryResult } from '../src/oauth/discovery.js' +import { beforeAll, describe, expect, it } from 'vitest' +import { type DiscoveryResult, discoverRemoteOAuth } from '../src/oauth/discovery.js' import { type McpServerOAuthSpecInput, resolveServerOAuth, @@ -7,6 +7,7 @@ import { } from '../src/oauth/mcpServerOAuthSpec.js' import { buildRemoteOAuthSpec } from '../src/routes/admin/remoteMcp.js' import { normalizeMcpServerOwnerDecl } from '../src/routes/mcpOauth.js' +import { PILOTS, makeDiscoveryTransport } from './fixtures/remoteOAuthDiscovery.js' /** * C4/DEC-23: `resolveServerOAuthSubject` gains the remote lane. T1 — every remote @@ -24,27 +25,23 @@ const CIMD_SELF = 'https://control.example.com/api/v1/.well-known/evenfire-mcp-c // `token: '…'` literal as a materialized credential; deriving the endpoint from // an interpolated origin keeps the value out of that shape (the guard exempts // interpolated values). Do not inline this back to a plain string literal. -const NOTION = 'https://mcp.notion.com' +const LINEAR = 'https://mcp.linear.app' + +// The real discovery over the Linear probe: CIMD with RFC 9207, the only kind of AS a +// CIMD install targets, so every mode below is built from a producer-emittable result. +let linear: DiscoveryResult + +beforeAll(async () => { + const outcome = await discoverRemoteOAuth(PILOTS.linear.mcpUrl, { + transport: makeDiscoveryTransport(PILOTS.linear), + resolveDns: async () => ['93.184.216.34'], + }) + if (!outcome.ok) throw new Error(`fixture discovery failed: ${outcome.error.kind}`) + linear = outcome.result +}) function discovery(): DiscoveryResult { - return { - prm: { resource: 'https://mcp.notion.com' }, - as: { - issuer: 'https://mcp.notion.com', - authorization_endpoint: 'https://mcp.notion.com/authorize', - token_endpoint: 'https://mcp.notion.com/token', - scopes_supported: ['read', 'write'], - }, - resource: 'https://mcp.notion.com', - issuer: 'https://mcp.notion.com', - endpoints: { - authorization: `${NOTION}/authorize`, - token: `${NOTION}/token`, - registration: `${NOTION}/register`, - }, - registrationMode: 'cimd', - quirks: { bearerInBody: false, supportsRefresh: true }, - } + return linear } /** Wrap a producer-emitted `spec.oauth` into a CR shape the resolver reads. */ @@ -59,7 +56,10 @@ describe('resolveServerOAuthSubject — remote lane (real-producer fixtures, T1) grantScope: 'user', cimdClientId: CIMD_SELF, }) - const r = resolveServerOAuthSubject(serverFrom(oauth as unknown as Record)) + const r = resolveServerOAuthSubject( + serverFrom(oauth as unknown as Record), + 'consent' + ) expect(r).not.toBeNull() expect(r?.decl.id).toBe(CIMD_SELF) expect(r?.decl.provider).toBe('remote') @@ -67,9 +67,9 @@ describe('resolveServerOAuthSubject — remote lane (real-producer fixtures, T1) expect(r?.decl.clientSecretRef).toBeUndefined() expect(r?.decl.secretSource).toEqual({ kind: 'public' }) expect(r?.decl.remote).toMatchObject({ - authorizationEndpoint: 'https://mcp.notion.com/authorize', - tokenEndpoint: 'https://mcp.notion.com/token', - resource: 'https://mcp.notion.com', + authorizationEndpoint: `${LINEAR}/authorize`, + tokenEndpoint: `${LINEAR}/token`, + resource: `${LINEAR}/mcp`, clientMode: 'public', supportsRefresh: true, bearerInBody: false, @@ -82,7 +82,10 @@ describe('resolveServerOAuthSubject — remote lane (real-producer fixtures, T1) grantScope: 'user', dynamicClientId: 'dcr-pub-123', }) - const r = resolveServerOAuthSubject(serverFrom(oauth as unknown as Record)) + const r = resolveServerOAuthSubject( + serverFrom(oauth as unknown as Record), + 'consent' + ) expect(r?.decl.id).toBe('dcr-pub-123') expect(r?.decl.secretSource).toEqual({ kind: 'public' }) }) @@ -93,7 +96,10 @@ describe('resolveServerOAuthSubject — remote lane (real-producer fixtures, T1) grantScope: 'user', dynamicClientId: 'dcr-conf-xyz', }) - const r = resolveServerOAuthSubject(serverFrom(oauth as unknown as Record)) + const r = resolveServerOAuthSubject( + serverFrom(oauth as unknown as Record), + 'consent' + ) expect(r?.decl.id).toBe('dcr-conf-xyz') expect(r?.decl.clientIdRef).toBeUndefined() expect(r?.decl.secretSource).toEqual({ kind: 'dcr-store' }) @@ -106,7 +112,10 @@ describe('resolveServerOAuthSubject — remote lane (real-producer fixtures, T1) clientSecretName: 'srv-oauth-client', preRegisteredClientId: 'client-abc', }) - const r = resolveServerOAuthSubject(serverFrom(oauth as unknown as Record)) + const r = resolveServerOAuthSubject( + serverFrom(oauth as unknown as Record), + 'consent' + ) expect(r?.decl.id).toBe('client-abc') expect(r?.decl.clientIdRef).toEqual({ name: 'srv-oauth-client', key: 'client_id' }) expect(r?.decl.clientSecretRef).toEqual({ name: 'srv-oauth-client', key: 'client_secret' }) @@ -124,7 +133,7 @@ describe('resolveServerOAuthSubject — remote lane (real-producer fixtures, T1) cimdClientId: CIMD_SELF, }) as unknown as Record delete oauth.tokenEndpoint - expect(resolveServerOAuthSubject(serverFrom(oauth))).toBeNull() + expect(resolveServerOAuthSubject(serverFrom(oauth), 'consent')).toBeNull() }) it('remote resolves the grant coordinate too (resolveServerOAuth keys by oauth.id)', () => { @@ -151,7 +160,7 @@ describe('baked lane stays byte-identical (no source)', () => { } it('returns exactly the confidential-K8s decl (no remote/secretSource keys)', () => { - const r = resolveServerOAuthSubject(serverFrom(BAKED)) + const r = resolveServerOAuthSubject(serverFrom(BAKED), 'consent') expect(r?.decl).toEqual({ id: 'google-drive', provider: 'google', @@ -166,7 +175,7 @@ describe('baked lane stays byte-identical (no source)', () => { it('baked without clientSecretRef still fails closed (confidential-only)', () => { const { clientSecretRef: _drop, ...pub } = BAKED - expect(resolveServerOAuthSubject(serverFrom(pub))).toBeNull() + expect(resolveServerOAuthSubject(serverFrom(pub), 'consent')).toBeNull() }) }) @@ -178,12 +187,12 @@ describe('normalizeMcpServerOwnerDecl (refresh reader) does not diverge from the cimdClientId: CIMD_SELF, }) const normalized = normalizeMcpServerOwnerDecl({ - metadata: { name: 'notion-remote', namespace: 'mcp-server' }, + metadata: { name: 'linear-remote', namespace: 'mcp-server' }, spec: { oauth: oauth as never }, }) const decl = normalized?.spec?.oauthClients?.[0] expect(decl?.id).toBe(CIMD_SELF) - expect(decl?.remote?.tokenEndpoint).toBe('https://mcp.notion.com/token') + expect(decl?.remote?.tokenEndpoint).toBe(`${LINEAR}/token`) expect(decl?.secretSource).toEqual({ kind: 'public' }) }) diff --git a/control-api/test/oauth.mcpServerOAuthSpec.test.ts b/control-api/test/oauth.mcpServerOAuthSpec.test.ts index b7a7e5ee44..8a14f7a6ae 100644 --- a/control-api/test/oauth.mcpServerOAuthSpec.test.ts +++ b/control-api/test/oauth.mcpServerOAuthSpec.test.ts @@ -33,7 +33,10 @@ const FULL_OAUTH = { describe('resolveServerOAuthSubject — fail-closed branches (U5)', () => { it('returns the full decl + routing for a well-formed context server', () => { - const r = resolveServerOAuthSubject(server({ ...FULL_OAUTH, grantScope: 'context' }, 'ctx-A')) + const r = resolveServerOAuthSubject( + server({ ...FULL_OAUTH, grantScope: 'context' }, 'ctx-A'), + 'consent' + ) expect(r).not.toBeNull() expect(r?.decl).toEqual({ id: 'google-drive', @@ -48,62 +51,83 @@ describe('resolveServerOAuthSubject — fail-closed branches (U5)', () => { }) it('returns null when spec.oauth is absent', () => { - expect(resolveServerOAuthSubject(server(undefined))).toBeNull() + expect(resolveServerOAuthSubject(server(undefined), 'consent')).toBeNull() }) it('returns null when id is missing or empty', () => { - expect(resolveServerOAuthSubject(server({ ...FULL_OAUTH, id: undefined }))).toBeNull() - expect(resolveServerOAuthSubject(server({ ...FULL_OAUTH, id: '' }))).toBeNull() + expect( + resolveServerOAuthSubject(server({ ...FULL_OAUTH, id: undefined }), 'consent') + ).toBeNull() + expect(resolveServerOAuthSubject(server({ ...FULL_OAUTH, id: '' }), 'consent')).toBeNull() }) it('returns null when provider is missing or empty', () => { - expect(resolveServerOAuthSubject(server({ ...FULL_OAUTH, provider: undefined }))).toBeNull() - expect(resolveServerOAuthSubject(server({ ...FULL_OAUTH, provider: '' }))).toBeNull() + expect( + resolveServerOAuthSubject(server({ ...FULL_OAUTH, provider: undefined }), 'consent') + ).toBeNull() + expect(resolveServerOAuthSubject(server({ ...FULL_OAUTH, provider: '' }), 'consent')).toBeNull() }) it('returns null when clientIdRef (or its key) is missing', () => { - expect(resolveServerOAuthSubject(server({ ...FULL_OAUTH, clientIdRef: undefined }))).toBeNull() expect( - resolveServerOAuthSubject(server({ ...FULL_OAUTH, clientIdRef: { name: 'creds' } })) + resolveServerOAuthSubject(server({ ...FULL_OAUTH, clientIdRef: undefined }), 'consent') + ).toBeNull() + expect( + resolveServerOAuthSubject( + server({ ...FULL_OAUTH, clientIdRef: { name: 'creds' } }), + 'consent' + ) ).toBeNull() }) it('returns null when clientSecretRef (or its key) is missing', () => { expect( - resolveServerOAuthSubject(server({ ...FULL_OAUTH, clientSecretRef: undefined })) + resolveServerOAuthSubject(server({ ...FULL_OAUTH, clientSecretRef: undefined }), 'consent') ).toBeNull() expect( resolveServerOAuthSubject( - server({ ...FULL_OAUTH, clientSecretRef: { key: 'client-secret' } }) + server({ ...FULL_OAUTH, clientSecretRef: { key: 'client-secret' } }), + 'consent' ) ).toBeNull() }) it('defaults grantScope to "user" for missing / non-"context" values', () => { - expect(resolveServerOAuthSubject(server({ ...FULL_OAUTH }))?.grantScope).toBe('user') + expect(resolveServerOAuthSubject(server({ ...FULL_OAUTH }), 'consent')?.grantScope).toBe('user') expect( - resolveServerOAuthSubject(server({ ...FULL_OAUTH, grantScope: 'garbage' }))?.grantScope + resolveServerOAuthSubject(server({ ...FULL_OAUTH, grantScope: 'garbage' }), 'consent') + ?.grantScope ).toBe('user') expect( - resolveServerOAuthSubject(server({ ...FULL_OAUTH, grantScope: 'context' }))?.grantScope + resolveServerOAuthSubject(server({ ...FULL_OAUTH, grantScope: 'context' }), 'consent') + ?.grantScope ).toBe('context') }) it('normalizes an empty / absent contextRef to undefined', () => { - expect(resolveServerOAuthSubject(server({ ...FULL_OAUTH }, ''))?.contextRef).toBeUndefined() - expect(resolveServerOAuthSubject(server({ ...FULL_OAUTH }))?.contextRef).toBeUndefined() - expect(resolveServerOAuthSubject(server({ ...FULL_OAUTH }, 'ctx-Z'))?.contextRef).toBe('ctx-Z') + expect( + resolveServerOAuthSubject(server({ ...FULL_OAUTH }, ''), 'consent')?.contextRef + ).toBeUndefined() + expect( + resolveServerOAuthSubject(server({ ...FULL_OAUTH }), 'consent')?.contextRef + ).toBeUndefined() + expect( + resolveServerOAuthSubject(server({ ...FULL_OAUTH }, 'ctx-Z'), 'consent')?.contextRef + ).toBe('ctx-Z') }) it('drops non-string scope entries (never emits a non-string scope)', () => { - const r = resolveServerOAuthSubject(server({ ...FULL_OAUTH, scopes: ['ok', 5, null, 'yes'] })) + const r = resolveServerOAuthSubject( + server({ ...FULL_OAUTH, scopes: ['ok', 5, null, 'yes'] }), + 'consent' + ) expect(r?.decl.scopes).toEqual(['ok', 'yes']) }) // Cross-check the two producers agree on oauthClientId + grantScope + contextRef. it('agrees with resolveServerOAuth on id / grantScope / contextRef', () => { const input = server({ ...FULL_OAUTH, grantScope: 'context' }, 'ctx-A') - const subject = resolveServerOAuthSubject(input) + const subject = resolveServerOAuthSubject(input, 'consent') const resolved = resolveServerOAuth(input) expect(subject?.decl.id).toBe(resolved?.oauthClientId) expect(subject?.grantScope).toBe(resolved?.grantScope) diff --git a/control-api/test/oauth.mcpServerOAuthTeardown.realPostgres.integration.test.ts b/control-api/test/oauth.mcpServerOAuthTeardown.realPostgres.integration.test.ts index 28262eb4e1..a9885ae92a 100644 --- a/control-api/test/oauth.mcpServerOAuthTeardown.realPostgres.integration.test.ts +++ b/control-api/test/oauth.mcpServerOAuthTeardown.realPostgres.integration.test.ts @@ -19,6 +19,7 @@ import { } from '../src/oauth/store.js' import { rootLogger } from '../src/observability/logger.js' import { makeDcrTransport } from './fixtures/remoteOAuthDiscovery.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' /** * R3-H5 (T1/T3/T4) — the uninstall OAuth teardown fenced by the CR uid, observed as @@ -59,15 +60,18 @@ describeRealPostgres('teardownMcpServerOAuthState — fenced by cr_uid (real Pos }) afterAll(async () => { - await dbPool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) - await adminPool.end() + try { + await endPoolAndWaitForClients(dbPool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) + } + } finally { + await adminPool?.end() } }) @@ -274,15 +278,18 @@ describeRealPostgres('grant cr_uid sealing (D-T3, real Postgres)', () => { }) afterAll(async () => { - await dbPool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) - await adminPool.end() + try { + await endPoolAndWaitForClients(dbPool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/oauth.perServerCallback.realPostgres.integration.test.ts b/control-api/test/oauth.perServerCallback.realPostgres.integration.test.ts new file mode 100644 index 0000000000..adddbf8a9e --- /dev/null +++ b/control-api/test/oauth.perServerCallback.realPostgres.integration.test.ts @@ -0,0 +1,432 @@ +/** + * Per-server remote callback end to end, against a real Postgres: the `dynamic_clients` + * row that binds a DCR redirect URI to one installation is SQL state, so the checks + * that read it are certified on the real store. + * + * - I4, the redirect-URI triad: the `redirect_uris` the install POSTs to the AS, the + * `redirect_uri` of the authorize URL, the path Express routes the AS redirect to, + * and the `redirect_uri` replayed on the token POST are one and the same string. + * - I11, same-name reinstall: a code sent to the previous installation's URI (old + * nonce) is refused; so is any URI of a server whose row carries no nonce. + * - I16, DCR vs pre-registered comes from the CR: a public CR without secret refs and + * without a row is never treated as pre-registered, and a row registered for + * another client id is never used. + * + * Real producers (T1): discovery by `discoverRemoteOAuth` over probe fixtures; CR, + * uid, row and nonce by the real install route; the state by the real authorize-url + * mint; teardown by the uninstall's own store call. The AS registration response and + * the token endpoint are the only external edges. Context membership is answered on the + * app pool as the sibling route suites do (it is not what this suite certifies). + * + * CONTROL_API_REAL_PG_ADMIN_URL=postgres://:@:5432 npm test -- \ + * test/oauth.perServerCallback.realPostgres.integration.test.ts + */ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { randomUUID } from 'node:crypto' +import { Pool } from 'pg' +import request from 'supertest' +import { createApp } from '../src/app.js' +import { config } from '../src/config.js' +import type { DbClient } from '../src/db.js' +import { initDb } from '../src/db.js' +import type { DiscoveryResult } from '../src/oauth/discovery.js' +import { + claimDeleteDynamicClientForResource, + upsertDynamicClient, +} from '../src/oauth/dynamicClientStore.js' +import { deriveOAuthEncryptionKey } from '../src/oauth/encryption.js' +import { buildRemoteOAuthSpec } from '../src/routes/admin/remoteMcp.js' +import { discoverPilot, installRemoteServer, seedContext } from './fixtures/perServerInstall.js' +import { + ATLASSIAN_V2_PILOT, + DCR_PUBLIC_REGISTRATION_RESPONSE, + PILOTS, +} from './fixtures/remoteOAuthDiscovery.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' +import { MockGateway } from './mockGateway.js' + +const testDb = vi.hoisted(() => ({ + pool: undefined as import('pg').Pool | undefined, + context: 'ctx-a', +})) + +vi.mock('../src/db.js', async () => { + const actual = await vi.importActual('../src/db.js') + const current = () => { + if (!testDb.pool) throw new Error('test pool not initialised') + return testDb.pool + } + return { + ...actual, + pool: { + query: (text: string, values?: unknown[]) => { + if (text.includes('FROM user_contexts')) { + return Promise.resolve({ rows: [{ context_id: testDb.context }], rowCount: 1 }) + } + return current().query(text, values) + }, + connect: () => current().connect(), + }, + } +}) + +vi.mock('../src/services/rateLimiterService.js', () => ({ + checkAndIncrement: vi.fn().mockResolvedValue({ + allowed: true, + backendAvailable: true, + remaining: 59, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + count: 1, + }), +})) + +vi.mock('node:dns/promises', () => ({ + lookup: vi.fn(async () => [{ address: '93.184.216.34', family: 4 }]), +})) + +const tokenEndpoint = vi.hoisted(() => ({ posts: [] as { url: string; body: string }[] })) +vi.mock('../src/http/pinnedFetch.js', async () => { + const actual = await vi.importActual( + '../src/http/pinnedFetch.js' + ) + return { + ...actual, + pinnedFetch: (url: string, field: string, options: Parameters[2]) => + options?.transport + ? actual.pinnedFetch(url, field, options) + : actual.pinnedFetch(url, field, { + ...options, + resolveDns: async () => ['93.184.216.34'], + transport: async input => { + tokenEndpoint.posts.push({ url: input.url, body: input.body ?? '' }) + return { + status: 200, + headers: { 'content-type': 'application/json' }, + bodyText: JSON.stringify({ + access_token: 'AT', + refresh_token: 'RT', + expires_in: 3600, + }), + } + }, + }), + } +}) + +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const describeRealPostgres = adminUrl ? describe : describe.skip + +function databaseUrl(baseUrl: string, database: string): string { + const url = new URL(baseUrl) + url.pathname = `/${database}` + return url.toString() +} + +const NS = config.mcpServersNamespace +const ORIGIN = 'https://control.example.com' + +/** ORIGIN as a literal inside a RegExp (its dots must not match any character). */ +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} +const CONTEXT = 'ctx-a' +const KEY = deriveOAuthEncryptionKey(config.oauthEncryptionKey) +const DCR_CLIENT_ID = DCR_PUBLIC_REGISTRATION_RESPONSE.client_id + +describeRealPostgres('per-server remote callback (real Postgres)', () => { + const database = `control_api_per_server_cb_${randomUUID().replace(/-/g, '')}` + let adminPool: Pool + let dbPool: Pool + let db: DbClient + let atlassian: DiscoveryResult + let notion: DiscoveryResult + let gateway: MockGateway + let app: ReturnType + let savedBaseUrl: string + + beforeAll(async () => { + if (!adminUrl) throw new Error('CONTROL_API_REAL_PG_ADMIN_URL is required') + adminPool = new Pool({ connectionString: adminUrl }) + await adminPool.query(`CREATE DATABASE "${database.replace(/"/g, '""')}"`) + dbPool = new Pool({ connectionString: databaseUrl(adminUrl, database) }) + await initDb({ connect: () => dbPool.connect() }) + db = { query: (text, values) => dbPool.query(text, values) } + testDb.pool = dbPool + atlassian = await discoverPilot(ATLASSIAN_V2_PILOT) + notion = await discoverPilot(PILOTS.notion) + }) + + afterAll(async () => { + try { + testDb.pool = undefined + if (dbPool) await endPoolAndWaitForClients(dbPool) + if (!adminPool) return + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) + } finally { + await adminPool?.end() + } + }) + + beforeEach(async () => { + await dbPool.query('DELETE FROM dynamic_clients') + await dbPool.query('DELETE FROM oauth_grants') + savedBaseUrl = config.oauthCallbackBaseUrl + config.oauthCallbackBaseUrl = ORIGIN + gateway = new MockGateway(NS) + await seedContext(gateway, NS, CONTEXT) + app = createApp(gateway as never) + tokenEndpoint.posts.length = 0 + }) + + afterEach(() => { + config.oauthCallbackBaseUrl = savedBaseUrl + }) + + async function installAtlassian(name: string) { + const res = await installRemoteServer({ + gateway, + db, + discovery: atlassian, + body: { + serverName: name, + contextRef: CONTEXT, + baseUrl: ATLASSIAN_V2_PILOT.mcpUrl, + mode: 'dcr', + }, + }) + expect(res.status).toBe(201) + const post = res.dcrCalls.find(c => c.method === 'POST') + const registered = (JSON.parse(post?.body ?? '{}') as { redirect_uris?: string[] }) + .redirect_uris + return { registered, reported: String(res.body.redirectUri) } + } + + /** The authorize URL the real mint returns; its state is what the AS round-trips. */ + async function mint(name: string): Promise<{ redirectUri: string; state: string }> { + const res = await request(app) + .post('/api/v1/internal/mcp-oauth/authorize-url') + .set('Authorization', 'Bearer dev-rpc-proxy-token') + .set('x-service-token', 'rpc-proxy') + .send({ mcpServerName: name, userId: 'user-9' }) + if (res.status !== 200) + throw new Error(`mint failed: ${res.status} ${JSON.stringify(res.body)}`) + const url = new URL(res.body.authorizeUrl) + return { + redirectUri: String(url.searchParams.get('redirect_uri')), + state: String(url.searchParams.get('state')), + } + } + + /** The AS redirect: GET the redirect URI's path with the code and the state. */ + function asRedirect(path: string, state: string) { + return request(app).get(path).query({ code: 'CODE', state }) + } + + it('I4: DCR registration, authorize, routed callback and token POST share one redirect URI', async () => { + const { registered, reported } = await installAtlassian('atlassian') + expect(registered).toHaveLength(1) + const [registeredUri] = registered as string[] + + const minted = await mint('atlassian') + const routedPath = new URL(minted.redirectUri).pathname + const res = await asRedirect(routedPath, minted.state) + + expect(res.status).toBe(200) + expect(res.text).toContain('mcpServerName=atlassian') + expect(tokenEndpoint.posts).toHaveLength(1) + const replayed = new URLSearchParams(tokenEndpoint.posts[0].body).get('redirect_uri') + expect(minted.redirectUri).toBe(registeredUri) + expect(`${ORIGIN}${routedPath}`).toBe(registeredUri) + expect(replayed).toBe(registeredUri) + expect(reported).toBe(registeredUri) + expect(registeredUri).toMatch( + new RegExp(`^${escapeRegExp(ORIGIN)}/api/v1/oauth-callback/remote/atlassian/[0-9a-f-]{36}$`) + ) + const grants = await dbPool.query('SELECT recipe_name FROM oauth_grants') + expect(grants.rows).toEqual([{ recipe_name: 'atlassian' }]) + }) + + it('I4 pre-registered: install report, authorize, routed callback and token POST agree', async () => { + const install = await installRemoteServer({ + gateway, + db, + discovery: notion, + body: { + serverName: 'notion', + contextRef: CONTEXT, + baseUrl: PILOTS.notion.mcpUrl, + mode: 'pre-registered', + clientId: 'client-notion', + clientSecret: 'secret-notion', + }, + }) + expect(install.status).toBe(201) + const registered = String(install.body.redirectUri) + + const minted = await mint('notion') + const res = await asRedirect(new URL(minted.redirectUri).pathname, minted.state) + + expect(res.status).toBe(200) + expect(minted.redirectUri).toBe(registered) + expect(new URLSearchParams(tokenEndpoint.posts[0].body).get('redirect_uri')).toBe(registered) + expect(registered).toBe(`${ORIGIN}/api/v1/oauth-callback/remote/notion`) + }) + + it('I11: after a same-name reinstall, a code sent to the old nonce is refused', async () => { + const first = await installAtlassian('atlassian') + const oldUri = first.reported + // Uninstall: the CR goes, and the teardown claims the row bound to its uid. + const oldCr = (await gateway.getResource('mcpservers', 'atlassian', NS)) as { + metadata: { uid: string } + } + await gateway.deleteResource('mcpservers', 'atlassian', NS) + const { deleted } = await claimDeleteDynamicClientForResource( + db, + KEY, + { serverNamespace: NS, serverName: 'atlassian' }, + oldCr.metadata.uid + ) + expect(deleted).toBe(true) + const second = await installAtlassian('atlassian') + expect(second.reported).not.toBe(oldUri) + + const minted = await mint('atlassian') + expect(minted.redirectUri).toBe(second.reported) + const stale = await asRedirect(new URL(oldUri).pathname, minted.state) + + expect(stale.status).toBe(400) + expect(stale.body).toEqual({ error: 'invalid_state', reason: 'binding_mismatch' }) + expect(tokenEndpoint.posts).toHaveLength(0) + + const fresh = await asRedirect(new URL(second.reported).pathname, minted.state) + expect(fresh.status).toBe(200) + }) + + it('I11: a row without an install nonce binds no URI (legacy row, never sealed)', async () => { + await gateway.createResource( + 'mcpservers', + { + metadata: { name: 'legacy' }, + spec: { + contextRef: CONTEXT, + auth: { type: 'oauth' }, + oauth: buildRemoteOAuthSpec(atlassian, { + clientMode: 'public', + grantScope: 'user', + dynamicClientId: DCR_CLIENT_ID, + }), + }, + }, + NS + ) + // The only producer of a nonce-less row: the pre-install-identity upsert. + await upsertDynamicClient(db, KEY, { + serverNamespace: NS, + serverName: 'legacy', + issuer: atlassian.issuer, + clientId: DCR_CLIENT_ID, + clientMode: 'public', + }) + const row = await dbPool.query( + `SELECT install_id, cr_uid FROM dynamic_clients WHERE server_name = 'legacy'` + ) + expect(row.rows).toEqual([{ install_id: null, cr_uid: null }]) + + const mintRes = await request(app) + .post('/api/v1/internal/mcp-oauth/authorize-url') + .set('Authorization', 'Bearer dev-rpc-proxy-token') + .set('x-service-token', 'rpc-proxy') + .send({ mcpServerName: 'legacy', userId: 'user-9' }) + expect(mintRes.status).toBe(503) + + const state = await signedStateFor('legacy') + for (const path of ['/remote/legacy', `/remote/legacy/${randomUUID()}`]) { + const res = await asRedirect(`/api/v1/oauth-callback${path}`, state) + expect(res.status).toBe(400) + expect(res.body).toEqual({ error: 'invalid_state', reason: 'binding_mismatch' }) + } + expect(tokenEndpoint.posts).toHaveLength(0) + }) + + it('I16: a public CR without refs and without a row is not pre-registered → binding_mismatch', async () => { + await gateway.createResource( + 'mcpservers', + { + metadata: { name: 'gitops' }, + spec: { + contextRef: CONTEXT, + auth: { type: 'oauth' }, + oauth: buildRemoteOAuthSpec(atlassian, { + clientMode: 'public', + grantScope: 'user', + dynamicClientId: DCR_CLIENT_ID, + }), + }, + }, + NS + ) + + const res = await asRedirect( + '/api/v1/oauth-callback/remote/gitops', + await signedStateFor('gitops') + ) + + expect(res.status).toBe(400) + expect(res.body).toEqual({ error: 'invalid_state', reason: 'binding_mismatch' }) + expect(tokenEndpoint.posts).toHaveLength(0) + }) + + it('I16: a bound row registered for another client id → binding_mismatch', async () => { + const { reported } = await installAtlassian('atlassian') + const minted = await mint('atlassian') + // A write that bypassed the install (and the CRD's immutability) points the CR at + // another client; the row still holds the client its nonce was registered for. + await gateway.mutateResource( + 'mcpservers', + 'atlassian', + current => ({ + spec: { + ...(current.spec as Record), + oauth: { + ...(current.spec as { oauth: Record }).oauth, + id: 'another-client', + }, + }, + }), + NS + ) + + const res = await asRedirect(new URL(reported).pathname, await signedStateFor('atlassian')) + + expect(minted.redirectUri).toBe(reported) + expect(res.status).toBe(400) + expect(res.body).toEqual({ error: 'invalid_state', reason: 'binding_mismatch' }) + expect(tokenEndpoint.posts).toHaveLength(0) + }) + + /** + * A state for a server the mint refuses (no usable registration). Signed by the same + * signer the mint uses, with the CR's current client id — what a state minted just + * before the row went away would carry. + */ + async function signedStateFor(name: string): Promise { + const { signOAuthState } = await import('../src/oauth/state.js') + const cr = (await gateway.getResource('mcpservers', name, NS)) as { + spec: { oauth: { id: string } } + } + return signOAuthState(config.oauthStateHmacSecret, { + subjectKind: 'mcp', + mcpServerName: name, + userId: 'user-9', + oauthClientId: cr.spec.oauth.id, + grantKind: 'user', + background: false, + } as Parameters[1]) + } +}) diff --git a/control-api/test/oauth.pkce.test.ts b/control-api/test/oauth.pkce.test.ts index a7d8c78c4c..39d950f189 100644 --- a/control-api/test/oauth.pkce.test.ts +++ b/control-api/test/oauth.pkce.test.ts @@ -181,11 +181,14 @@ async function roundTrip(provider: OAuthProvider): Promise<{ const cbResult = await handleOAuthCallback( { - oauthClientId: 'oauth1', + target: { + kind: 'client', + id: 'oauth1', + redirectUri: 'https://control.example.com/api/v1/oauth-callback/oauth1', + }, code: 'AUTH_CODE', // The EXACT round-tripped state string. state: state as string, - redirectUri: 'https://control.example.com/api/v1/oauth-callback/oauth1', }, deps ) diff --git a/control-api/test/oauth.proactiveRefresh.realPostgres.integration.test.ts b/control-api/test/oauth.proactiveRefresh.realPostgres.integration.test.ts index cbfe2a0030..91acf0a2e5 100644 --- a/control-api/test/oauth.proactiveRefresh.realPostgres.integration.test.ts +++ b/control-api/test/oauth.proactiveRefresh.realPostgres.integration.test.ts @@ -32,6 +32,7 @@ import { } from '../src/oauth/store.js' import { getAccessToken } from '../src/oauth/tokenHelper.js' import { type McpServerResource, normalizeMcpServerOwnerDecl } from '../src/routes/mcpOauth.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' import { MockGateway } from './mockGateway.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL @@ -71,15 +72,18 @@ describeRealPostgres('oauth proactive refresh — enumeration + claim (real Post }) afterAll(async () => { - await dbPool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) - await adminPool.end() + try { + await endPoolAndWaitForClients(dbPool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/oauth.reactiveRefresh.installIdentity.realPostgres.integration.test.ts b/control-api/test/oauth.reactiveRefresh.installIdentity.realPostgres.integration.test.ts index fcd00fd403..3582dadca3 100644 --- a/control-api/test/oauth.reactiveRefresh.installIdentity.realPostgres.integration.test.ts +++ b/control-api/test/oauth.reactiveRefresh.installIdentity.realPostgres.integration.test.ts @@ -26,6 +26,7 @@ import { resultToOutcome } from '../src/oauth/proactiveRefreshPolicy.js' import { getAccessTokenReactive } from '../src/oauth/reactiveTokenHelper.js' import { getOAuthGrant, upsertOAuthGrant } from '../src/oauth/store.js' import { type McpServerResource, normalizeMcpServerOwnerDecl } from '../src/routes/mcpOauth.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' import { MockGateway } from './mockGateway.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL @@ -85,15 +86,18 @@ describeRealPostgres( }) afterAll(async () => { - await dbPool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) - await adminPool.end() + try { + await endPoolAndWaitForClients(dbPool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/oauth.reactiveRefreshLock.realPostgres.integration.test.ts b/control-api/test/oauth.reactiveRefreshLock.realPostgres.integration.test.ts index 51183fe6f1..e1402cfe4f 100644 --- a/control-api/test/oauth.reactiveRefreshLock.realPostgres.integration.test.ts +++ b/control-api/test/oauth.reactiveRefreshLock.realPostgres.integration.test.ts @@ -36,6 +36,7 @@ import { } from '../src/oauth/store.js' import { getAccessToken } from '../src/oauth/tokenHelper.js' import { type McpServerResource, normalizeMcpServerOwnerDecl } from '../src/routes/mcpOauth.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' import { MockGateway } from './mockGateway.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL @@ -63,7 +64,6 @@ describeRealPostgres('oauth reactive refresh — row-lock serialization (real Po let adminPool: Pool let dbPool: Pool let db: DbClient - const clientClosures: Promise[] = [] beforeAll(async () => { if (!adminUrl) throw new Error('CONTROL_API_REAL_PG_ADMIN_URL is required') @@ -72,19 +72,19 @@ describeRealPostgres('oauth reactive refresh — row-lock serialization (real Po dbPool = new Pool({ connectionString: databaseUrl(adminUrl, database) }) // pg-pool can resolve end() before its clients' asynchronous socket closes. // Register physical closure before initDb creates the first connection. - dbPool.on('connect', client => { - clientClosures.push(new Promise(resolve => client.once('end', resolve))) - }) await initDb({ connect: () => dbPool.connect() }) db = { query: (text, values) => dbPool.query(text, values) } }) afterAll(async () => { try { - await dbPool?.end() - await Promise.all(clientClosures) + await endPoolAndWaitForClients(dbPool) if (adminPool) { - // A remaining connection must fail DROP rather than be force-terminated. + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) } } finally { diff --git a/control-api/test/oauth.remoteCallback.test.ts b/control-api/test/oauth.remoteCallback.test.ts new file mode 100644 index 0000000000..5659bd0a27 --- /dev/null +++ b/control-api/test/oauth.remoteCallback.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it, vi } from 'vitest' +import { buildCimdDocument } from '../src/oauth/cimd.js' +import { type DiscoveryResult, discoverRemoteOAuth } from '../src/oauth/discovery.js' +import { + InvalidRemoteRedirectUriInputError, + REMOTE_CALLBACK_PATH, + type RemoteRedirectUriInput, + buildRemoteRedirectUri, + isValidInstallNonce, + isValidRemoteServerNameSegment, + remoteCallbackVariant, +} from '../src/oauth/remoteCallback.js' +import { buildRemoteOAuthSpec } from '../src/routes/admin/remoteMcp.js' +import { + ATLASSIAN_V2_PILOT, + PILOTS, + type PilotFixture, + makeDiscoveryTransport, +} from './fixtures/remoteOAuthDiscovery.js' + +const ORIGIN = 'https://evenfire.example.com' +const NONCE = '3f1c2b7e-9a4d-4c1e-8b2a-6d5e4f3a2b1c' + +async function discovered(pilot: PilotFixture): Promise { + const outcome = await discoverRemoteOAuth(pilot.mcpUrl, { + transport: makeDiscoveryTransport(pilot), + resolveDns: vi.fn(async () => ['93.184.216.34']), + }) + if (!outcome.ok) throw new Error(`discovery failed: ${outcome.error.kind}`) + return outcome.result +} + +describe('remoteCallbackVariant', () => { + it('non-empty issForCallback → shared; absent, empty or non-string → per-server', () => { + expect(remoteCallbackVariant({ issForCallback: 'https://mcp.sentry.dev' })).toBe('shared') + expect(remoteCallbackVariant({})).toBe('per-server') + expect(remoteCallbackVariant({ issForCallback: undefined })).toBe('per-server') + expect(remoteCallbackVariant({ issForCallback: '' })).toBe('per-server') + expect(remoteCallbackVariant({ issForCallback: true })).toBe('per-server') + expect(remoteCallbackVariant({ issForCallback: 1 })).toBe('per-server') + }) +}) + +// Partial (unit-level) coverage of I15: the variant derived from the block the real +// producer builds, and stable across a JSON round-trip. The round-trip is NOT the CR the +// install writes and reads back from the apiserver — that composability +// (variant(install block) === variant(written CR)) is asserted with the install saga in +// phase 2. +describe('I15 (partial, unit) — remoteCallbackVariant on the block the install builds', () => { + const cases: Array<{ pilot: PilotFixture; variant: 'shared' | 'per-server' }> = [ + { pilot: PILOTS.sentry, variant: 'shared' }, + { pilot: PILOTS.linear, variant: 'shared' }, + { pilot: PILOTS.notion, variant: 'per-server' }, + { pilot: ATLASSIAN_V2_PILOT, variant: 'per-server' }, + ] + for (const { pilot, variant } of cases) { + it(`${pilot.name}: ${variant} on the install block (JSON round-trip stable)`, async () => { + const block = buildRemoteOAuthSpec(await discovered(pilot), { + clientMode: 'public', + grantScope: 'user', + dynamicClientId: 'client-from-as', + }) + expect(remoteCallbackVariant(block)).toBe(variant) + // Round-trip only; not a substitute for reading the written CR. + const fromCr = JSON.parse(JSON.stringify(block)) as Record + expect(remoteCallbackVariant(fromCr)).toBe(remoteCallbackVariant(block)) + }) + } +}) + +describe('buildRemoteRedirectUri — decision table', () => { + it('shared → the stable shared path (any mode)', () => { + expect(buildRemoteRedirectUri({ origin: ORIGIN, variant: 'shared' })).toBe( + `${ORIGIN}/api/v1/oauth-callback/remote` + ) + expect(REMOTE_CALLBACK_PATH).toBe('/api/v1/oauth-callback/remote') + }) + + it('per-server DCR → /remote//', () => { + expect( + buildRemoteRedirectUri({ + origin: ORIGIN, + variant: 'per-server', + mode: 'dcr', + serverName: 'atlassian', + installNonce: NONCE, + }) + ).toBe(`${ORIGIN}/api/v1/oauth-callback/remote/atlassian/${NONCE}`) + }) + + it('per-server pre-registered → /remote/', () => { + expect( + buildRemoteRedirectUri({ + origin: ORIGIN, + variant: 'per-server', + mode: 'pre-registered', + serverName: 'slack', + }) + ).toBe(`${ORIGIN}/api/v1/oauth-callback/remote/slack`) + }) + + it('per-server accepts a bare origin with an explicit non-default port', () => { + expect( + buildRemoteRedirectUri({ + origin: 'http://localhost:8080', + variant: 'per-server', + mode: 'pre-registered', + serverName: 'slack', + }) + ).toBe('http://localhost:8080/api/v1/oauth-callback/remote/slack') + }) + + it('shared keeps its historical origin handling (no origin validation)', () => { + expect(buildRemoteRedirectUri({ origin: `${ORIGIN}/base`, variant: 'shared' })).toBe( + `${ORIGIN}/base/api/v1/oauth-callback/remote` + ) + }) + + it('the CIMD document lists exactly the shared URI from the same builder', () => { + expect(buildCimdDocument(ORIGIN).redirect_uris).toEqual([ + buildRemoteRedirectUri({ origin: ORIGIN, variant: 'shared' }), + ]) + }) + + const invalid: Array<{ label: string; input: unknown }> = [ + { + label: 'per-server CIMD (no per-server URI exists)', + input: { origin: ORIGIN, variant: 'per-server', mode: 'cimd', serverName: 'notion' }, + }, + { + label: 'per-server DCR without a nonce', + input: { origin: ORIGIN, variant: 'per-server', mode: 'dcr', serverName: 'notion' }, + }, + { + label: 'per-server pre-registered WITH a nonce', + input: { + origin: ORIGIN, + variant: 'per-server', + mode: 'pre-registered', + serverName: 'slack', + installNonce: NONCE, + }, + }, + { + label: 'uppercase UUID nonce', + input: { + origin: ORIGIN, + variant: 'per-server', + mode: 'dcr', + serverName: 'notion', + installNonce: NONCE.toUpperCase(), + }, + }, + { + label: 'per-server without an origin', + input: { origin: '', variant: 'per-server', mode: 'pre-registered', serverName: 'slack' }, + }, + ...[ + `${ORIGIN}/`, + `${ORIGIN}/base`, + `${ORIGIN}?q=1`, + `${ORIGIN}#f`, + 'https://user:pw@evenfire.example.com', + 'https://Evenfire.Example.com', + 'https://evenfire.example.com:443', + 'evenfire.example.com', + 'not a url', + ].map(origin => ({ + label: `per-server with a non-bare origin ${JSON.stringify(origin)}`, + input: { origin, variant: 'per-server', mode: 'pre-registered', serverName: 'slack' }, + })), + { label: 'unknown variant', input: { origin: ORIGIN, variant: 'other' } }, + ] + for (const { label, input } of invalid) { + it(`throws: ${label}`, () => { + expect(() => buildRemoteRedirectUri(input as RemoteRedirectUriInput)).toThrow( + InvalidRemoteRedirectUriInputError + ) + }) + } + + for (const serverName of [ + '..', + '%2F', + 'a/b', + 'a%2Fb', + 'Notion', + 'my_server', + '-lead', + 'trail-', + '', + 'a'.repeat(64), + ]) { + it(`throws on a non-RFC-1123 serverName ${JSON.stringify(serverName)}`, () => { + expect(() => + buildRemoteRedirectUri({ + origin: ORIGIN, + variant: 'per-server', + mode: 'pre-registered', + serverName, + }) + ).toThrow(InvalidRemoteRedirectUriInputError) + }) + } +}) + +describe('segment validators', () => { + it('isValidRemoteServerNameSegment accepts RFC 1123 labels only', () => { + for (const ok of ['notion', 'a', 'atlassian-v2', 'a'.repeat(63), '0abc']) { + expect(isValidRemoteServerNameSegment(ok)).toBe(true) + } + for (const bad of [ + '..', + '.', + '%2F', + '%2e%2e', + '%252F', + 'a/b', + 'A', + 'a_b', + '-a', + 'a-', + '', + 'a'.repeat(64), + 'a\n', + 42, + undefined, + ]) { + expect(isValidRemoteServerNameSegment(bad)).toBe(false) + } + }) + + it('isValidInstallNonce accepts only canonical lowercase UUIDs', () => { + expect(isValidInstallNonce(NONCE)).toBe(true) + expect(isValidInstallNonce(crypto.randomUUID())).toBe(true) + for (const bad of [ + NONCE.toUpperCase(), + `${NONCE}\n`, + `{${NONCE}}`, + NONCE.replace(/-/g, ''), + '..', + '%2F', + '', + undefined, + 42, + ]) { + expect(isValidInstallNonce(bad)).toBe(false) + } + }) +}) diff --git a/control-api/test/oauth.remoteSpecCoherence.test.ts b/control-api/test/oauth.remoteSpecCoherence.test.ts index 4b3a25e71a..4db1605124 100644 --- a/control-api/test/oauth.remoteSpecCoherence.test.ts +++ b/control-api/test/oauth.remoteSpecCoherence.test.ts @@ -14,6 +14,8 @@ import { buildRemoteOAuthSpec } from '../src/routes/admin/remoteMcp.js' import { normalizeMcpServerOwnerDecl } from '../src/routes/mcpOauth.js' import { issueMcpHostControlJwt } from '../src/utils/auth/mcpHostJwtToken.js' import { + ATLASSIAN_V2_PILOT, + DROPBOX_PILOT, PILOTS, type PilotFixture, makeDiscoveryTransport, @@ -77,12 +79,26 @@ function bodyOnlyPilot(): PilotFixture { return { ...base, prm: { ...base.prm, json: JSON.stringify(prm) } } } +/** Dropbox as if it advertised RFC 9207: the only way discovery accepts its cross-site AS. */ +function dropboxWithRfc9207(): PilotFixture { + const as = { + ...JSON.parse(DROPBOX_PILOT.as.json), + authorization_response_iss_parameter_supported: true, + } + return { ...DROPBOX_PILOT, as: { ...DROPBOX_PILOT.as, json: JSON.stringify(as) } } +} + let headerDiscovery: DiscoveryResult let bodyDiscovery: DiscoveryResult +// Real Atlassian v2: CIMD + DCR, no RFC 9207, every endpoint on atlassian.com. +let perServerDiscovery: DiscoveryResult +let crossSiteDiscovery: DiscoveryResult beforeAll(async () => { headerDiscovery = await discover(PILOTS.sentry) bodyDiscovery = await discover(bodyOnlyPilot()) + perServerDiscovery = await discover(ATLASSIAN_V2_PILOT) + crossSiteDiscovery = await discover(dropboxWithRfc9207()) }) function publicWithRefsOAuth(): Record { @@ -103,13 +119,97 @@ function bearerInBodyOAuth(): Record { }) as unknown as Record } +/** + * The CIMD identity on a server whose AS returns no `iss`. The builder emits it for any + * discovery result; only the install refuses it, so it persists through a direct write. + */ +function cimdWithoutIssuerBindingOAuth(): Record { + return buildRemoteOAuthSpec(perServerDiscovery, { + clientMode: 'public', + grantScope: 'user', + cimdClientId: CIMD_SELF, + }) as unknown as Record +} + +/** + * Dropbox's real endpoints (token on dropboxapi.com, issuer on dropbox.com) pinned on a + * per-server CR. Discovery only accepts them with RFC 9207, so the per-server shape is + * that output without `issForCallback` — the edit a direct write would make. + */ +function crossSitePerServerOAuth(): Record { + const oauth = buildRemoteOAuthSpec(crossSiteDiscovery, { + clientMode: 'public', + grantScope: 'user', + dynamicClientId: 'dyn-dropbox-public', + }) as unknown as Record + delete oauth.issForCallback + return oauth +} + +/** The Atlassian per-server DCR shape, coherent until a test edits it. */ +function perServerDcrOAuth(): Record { + return buildRemoteOAuthSpec(perServerDiscovery, { + clientMode: 'public', + grantScope: 'user', + dynamicClientId: 'dyn-atlassian-public', + }) as unknown as Record +} + +/** A per-server CR written without `issuer`: no site to anchor the endpoints to. */ +function perServerWithoutIssuerOAuth(): Record { + const oauth = perServerDcrOAuth() + delete oauth.issuer + return oauth +} + +/** A per-server CR whose registration endpoint was moved off the issuer's site. */ +function crossSiteRegistrationOAuth(): Record { + return { + ...perServerDcrOAuth(), + registrationEndpoint: 'https://register.example.net/oauth/register', + } +} + const INCOHERENT = [ + // CRD mirrors: refused in consent and token issuance alike. { label: 'public + secret refs', oauth: publicWithRefsOAuth, reason: 'public_client_with_secret_refs', + consentOnly: false, + }, + { + label: 'bearerInBody: true', + oauth: bearerInBodyOAuth, + reason: 'bearer_in_body', + consentOnly: false, + }, + // Per-server callback rules: refused at consent only; a grant already obtained is + // sealed to the CR's uid and refreshes against the token endpoint that issued it. + { + label: 'CIMD client without RFC 9207', + oauth: cimdWithoutIssuerBindingOAuth, + reason: 'cimd_without_issuer_binding', + consentOnly: true, + }, + { + label: 'per-server AS endpoints off the issuer site', + oauth: crossSitePerServerOAuth, + reason: 'as_endpoints_cross_site', + consentOnly: true, + }, + { + label: 'per-server CR without an issuer', + oauth: perServerWithoutIssuerOAuth, + reason: 'as_endpoints_cross_site', + consentOnly: true, + }, + { + label: 'per-server registration endpoint off the issuer site', + oauth: crossSiteRegistrationOAuth, + reason: 'as_endpoints_cross_site', + consentOnly: true, }, - { label: 'bearerInBody: true', oauth: bearerInBodyOAuth, reason: 'bearer_in_body' }, ] as const function crFrom( @@ -136,17 +236,49 @@ describe('resolver mirror of the remote CRD coherence rules', () => { expect(bearerInBodyOAuth().bearerInBody).toBe(true) }) + it('the per-server fixtures really are per-server (no issForCallback) and cross-site', () => { + expect(cimdWithoutIssuerBindingOAuth().issForCallback).toBeUndefined() + const crossSite = crossSitePerServerOAuth() + expect(crossSite.issForCallback).toBeUndefined() + expect(new URL(crossSite.tokenEndpoint as string).hostname).toBe('api.dropboxapi.com') + expect(new URL(crossSite.issuer as string).hostname).toMatch(/dropbox\.com$/) + // The edits below are the only incoherence in their shapes: the base is coherent. + const base = perServerDcrOAuth() + expect(base.issForCallback).toBeUndefined() + expect(resolveServerOAuthSubject(crFrom('srv', base), 'consent')?.decl.id).toBe(base.id) + expect(typeof base.registrationEndpoint).toBe('string') + }) + for (const c of INCOHERENT) { - it(`${c.label}: resolveServerOAuthSubject throws remote_oauth_spec_incoherent`, () => { - const err = thrownBy(() => resolveServerOAuthSubject(crFrom('srv', c.oauth()))) + it(`${c.label}: resolveServerOAuthSubject(consent) throws remote_oauth_spec_incoherent`, () => { + const err = thrownBy(() => resolveServerOAuthSubject(crFrom('srv', c.oauth()), 'consent')) expect(err?.code).toBe('remote_oauth_spec_incoherent') expect(err?.reason).toBe(c.reason) }) - it(`${c.label}: the refresh reader (normalizeMcpServerOwnerDecl) refuses it too`, () => { - const err = thrownBy(() => normalizeMcpServerOwnerDecl(crFrom('srv', c.oauth()) as never)) - expect(err?.code).toBe('remote_oauth_spec_incoherent') - }) + if (c.consentOnly) { + it(`${c.label}: resolveServerOAuthSubject(token) still resolves the subject`, () => { + const r = resolveServerOAuthSubject(crFrom('srv', c.oauth()), 'token') + expect(r?.decl.id).toBe(c.oauth().id) + expect(r?.decl.remote?.tokenEndpoint).toBe(c.oauth().tokenEndpoint) + }) + + it(`${c.label}: the refresh reader (normalizeMcpServerOwnerDecl) keeps accepting it`, () => { + const decl = normalizeMcpServerOwnerDecl(crFrom('srv', c.oauth()) as never)?.spec + ?.oauthClients?.[0] + expect(decl?.remote?.tokenEndpoint).toBe(c.oauth().tokenEndpoint) + }) + } else { + it(`${c.label}: resolveServerOAuthSubject(token) throws too`, () => { + const err = thrownBy(() => resolveServerOAuthSubject(crFrom('srv', c.oauth()), 'token')) + expect(err?.reason).toBe(c.reason) + }) + + it(`${c.label}: the refresh reader (normalizeMcpServerOwnerDecl) refuses it too`, () => { + const err = thrownBy(() => normalizeMcpServerOwnerDecl(crFrom('srv', c.oauth()) as never)) + expect(err?.code).toBe('remote_oauth_spec_incoherent') + }) + } it(`${c.label}: resolveServerOAuth still yields the grant coordinate (revoke, sweep)`, () => { const r = resolveServerOAuth(crFrom('srv', c.oauth())) @@ -163,15 +295,39 @@ describe('resolver mirror of the remote CRD coherence rules', () => { clientSecretName: 'srv-oauth-client', preRegisteredClientId: PRE_REGISTERED_CLIENT_ID, }) as unknown as Record - expect(resolveServerOAuthSubject(crFrom('a', preRegistered))?.decl.secretSource?.kind).toBe( - 'k8s-secret' - ) + expect( + resolveServerOAuthSubject(crFrom('a', preRegistered), 'consent')?.decl.secretSource?.kind + ).toBe('k8s-secret') const cimd = buildRemoteOAuthSpec(headerDiscovery, { clientMode: 'public', grantScope: 'user', cimdClientId: CIMD_SELF, }) as unknown as Record - expect(resolveServerOAuthSubject(crFrom('b', cimd))?.decl.secretSource?.kind).toBe('public') + expect(resolveServerOAuthSubject(crFrom('b', cimd), 'consent')?.decl.secretSource?.kind).toBe( + 'public' + ) + // Per-server DCR on a same-site AS without RFC 9207 (what an Atlassian install writes). + const perServerDcr = buildRemoteOAuthSpec(perServerDiscovery, { + clientMode: 'public', + grantScope: 'user', + dynamicClientId: 'dyn-atlassian-public', + }) as unknown as Record + expect(perServerDcr.issForCallback).toBeUndefined() + const resolved = resolveServerOAuthSubject(crFrom('c', perServerDcr), 'consent') + expect(resolved?.decl.secretSource?.kind).toBe('public') + // The pinned issuer rides the routing: the per-server callback checks a present `iss`. + expect(resolved?.decl.remote?.issuer).toBe(perServerDiscovery.issuer) + // A per-server server whose client is pre-registered keeps resolving too. + const perServerPreRegistered = buildRemoteOAuthSpec(perServerDiscovery, { + clientMode: 'confidential', + grantScope: 'user', + clientSecretName: 'srv-oauth-client', + preRegisteredClientId: PRE_REGISTERED_CLIENT_ID, + }) as unknown as Record + expect( + resolveServerOAuthSubject(crFrom('d', perServerPreRegistered), 'consent')?.decl.secretSource + ?.kind + ).toBe('k8s-secret') }) }) @@ -244,7 +400,7 @@ describe('routes refuse an incoherent remote server with 409 remote_oauth_spec_i expect(res.body).toEqual({ error: 'context_membership_denied' }) }) - it(`${c.label}: POST /mcp-oauth/user-token → 409 even with a fresh stored token`, async () => { + it(`${c.label}: POST /mcp-oauth/user-token → ${c.consentOnly ? '200, the stored token is served' : '409 even with a fresh stored token'}`, async () => { const oauth = c.oauth() const uid = await seed('srv', oauth) mockPoolQuery.mockImplementation((sql: unknown) => { @@ -282,9 +438,14 @@ describe('routes refuse an incoherent remote server with 409 remote_oauth_spec_i .post('/api/v1/mcp-oauth/user-token') .set('Authorization', `Bearer ${token}`) .send({ mcpServerName: 'srv', userId: 'user-1' }) - expect(res.status).toBe(409) - expect(res.body).toEqual({ error: 'remote_oauth_spec_incoherent', reason: c.reason }) - expect(JSON.stringify(res.body)).not.toContain('LIVE-ACCESS') + if (c.consentOnly) { + expect(res.status).toBe(200) + expect(JSON.stringify(res.body)).toContain('LIVE-ACCESS') + } else { + expect(res.status).toBe(409) + expect(res.body).toEqual({ error: 'remote_oauth_spec_incoherent', reason: c.reason }) + expect(JSON.stringify(res.body)).not.toContain('LIVE-ACCESS') + } }) it(`${c.label}: GET /oauth-callback/remote → 409, nothing persisted`, async () => { @@ -310,6 +471,31 @@ describe('routes refuse an incoherent remote server with 409 remote_oauth_spec_i expect(grantWrites).toHaveLength(0) }) + // The per-server route reads the same subject: the incoherence is refused before any + // binding check or exchange, whichever remote callback the AS used. + it(`${c.label}: GET /oauth-callback/remote/srv → 409, nothing persisted`, async () => { + const oauth = c.oauth() + await seed('srv', oauth) + const state = signOAuthState(config.oauthStateHmacSecret, { + subjectKind: 'mcp', + mcpServerName: 'srv', + userId: 'user-1', + oauthClientId: oauth.id, + grantKind: 'user', + background: false, + } as Parameters[1]) + const res = await request(app) + .get('/api/v1/oauth-callback/remote/srv') + .query({ code: 'AUTH_CODE', state }) + expect(res.status).toBe(409) + expect(res.body).toEqual({ error: 'remote_oauth_spec_incoherent', reason: c.reason }) + const grantWrites = mockPoolQuery.mock.calls.filter( + ([sql]) => + typeof sql === 'string' && /INSERT INTO oauth_grants|UPDATE oauth_grants/.test(sql) + ) + expect(grantWrites).toHaveLength(0) + }) + it(`${c.label}: DELETE /internal/mcp-oauth/grant still revokes (204)`, async () => { await seed('srv', c.oauth()) const res = await request(app) diff --git a/control-api/test/oauth.store.installIdentityFence.realPostgres.integration.test.ts b/control-api/test/oauth.store.installIdentityFence.realPostgres.integration.test.ts index 3d14de756d..e17309be4f 100644 --- a/control-api/test/oauth.store.installIdentityFence.realPostgres.integration.test.ts +++ b/control-api/test/oauth.store.installIdentityFence.realPostgres.integration.test.ts @@ -34,6 +34,7 @@ import { oauthGrantExists, upsertOAuthGrant, } from '../src/oauth/store.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' import { MockGateway } from './mockGateway.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL @@ -116,15 +117,18 @@ describeRealPostgres('grant readers fenced by the live installation uid (real Po }) afterAll(async () => { - await dbPool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) - await adminPool.end() + try { + await endPoolAndWaitForClients(dbPool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/oauth.store.sharedGrants.realPostgres.integration.test.ts b/control-api/test/oauth.store.sharedGrants.realPostgres.integration.test.ts index 1a3de3ba8d..9e88e75878 100644 --- a/control-api/test/oauth.store.sharedGrants.realPostgres.integration.test.ts +++ b/control-api/test/oauth.store.sharedGrants.realPostgres.integration.test.ts @@ -12,6 +12,7 @@ import { refreshOAuthGrantTokens, upsertOAuthGrant, } from '../src/oauth/store.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' // T1/T5 — shared-identity grant semantics derived from the REAL producer // (real ON CONFLICT DO NOTHING + CHECK constraints), not a hand-built fixture. @@ -48,15 +49,18 @@ describeRealPostgres('oauth store — shared grants (real Postgres)', () => { }) afterAll(async () => { - await dbPool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) - await adminPool.end() + try { + await endPoolAndWaitForClients(dbPool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/oauth.store.userGrantsListing.realPostgres.integration.test.ts b/control-api/test/oauth.store.userGrantsListing.realPostgres.integration.test.ts index 09f0bc614f..d0e86ba5cb 100644 --- a/control-api/test/oauth.store.userGrantsListing.realPostgres.integration.test.ts +++ b/control-api/test/oauth.store.userGrantsListing.realPostgres.integration.test.ts @@ -11,6 +11,7 @@ import { listUserOAuthGrants, upsertOAuthGrant, } from '../src/oauth/store.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' // T1/T3/T4/T5 (spec 04 U1/U3) — the owner-generalised grant listing exercised // against grants written by the REAL producer (`upsertOAuthGrant`), never a @@ -54,15 +55,18 @@ describeRealPostgres('listUserOAuthGrants / listUserGrantsForServer (real Postgr }) afterAll(async () => { - await dbPool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) - await adminPool.end() + try { + await endPoolAndWaitForClients(dbPool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/oauth.tokenHelper.generic.test.ts b/control-api/test/oauth.tokenHelper.generic.test.ts index 6090d80050..487b6d6487 100644 --- a/control-api/test/oauth.tokenHelper.generic.test.ts +++ b/control-api/test/oauth.tokenHelper.generic.test.ts @@ -19,28 +19,31 @@ const VALIDATED_IP = '93.184.216.34' const CLIENT_ID = 'my-generic-client' function genericOwnerDecl(supportsRefresh: boolean, refreshEndpoint?: string) { - const resolved = resolveServerOAuthSubject({ - spec: { - contextRef: 'ctx-A', - oauth: { - source: 'generic', - id: CLIENT_ID, - authorizationEndpoint: 'https://idp.example.com/authorize', - tokenEndpoint: TOKEN_ENDPOINT, - refreshEndpoint, - resource: 'https://api.example.com', - tokenRequestFormat: 'form', - tokenAuthMethod: 'body', - scopeSeparator: 'space', - sendScope: true, - usePkce: true, - includeResponseType: true, - supportsRefresh, - grantScope: 'user', - scopes: ['read'], + const resolved = resolveServerOAuthSubject( + { + spec: { + contextRef: 'ctx-A', + oauth: { + source: 'generic', + id: CLIENT_ID, + authorizationEndpoint: 'https://idp.example.com/authorize', + tokenEndpoint: TOKEN_ENDPOINT, + refreshEndpoint, + resource: 'https://api.example.com', + tokenRequestFormat: 'form', + tokenAuthMethod: 'body', + scopeSeparator: 'space', + sendScope: true, + usePkce: true, + includeResponseType: true, + supportsRefresh, + grantScope: 'user', + scopes: ['read'], + }, }, }, - }) + 'token' + ) if (!resolved) throw new Error('fixture: generic resolve returned null') return { spec: { oauthClients: [resolved.decl] } } } diff --git a/control-api/test/oauth.tokenHelper.remote.test.ts b/control-api/test/oauth.tokenHelper.remote.test.ts index 9f83edc787..eafcb50d9d 100644 --- a/control-api/test/oauth.tokenHelper.remote.test.ts +++ b/control-api/test/oauth.tokenHelper.remote.test.ts @@ -1,8 +1,11 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeAll, describe, expect, it, vi } from 'vitest' import type { PinnedRawResponse, PinnedTransportInput } from '../src/http/pinnedFetch.js' +import { type DiscoveryResult, discoverRemoteOAuth } from '../src/oauth/discovery.js' import { resolveServerOAuthSubject } from '../src/oauth/mcpServerOAuthSpec.js' import * as store from '../src/oauth/store.js' import { getAccessToken } from '../src/oauth/tokenHelper.js' +import { buildRemoteOAuthSpec } from '../src/routes/admin/remoteMcp.js' +import { PILOTS, makeDiscoveryTransport } from './fixtures/remoteOAuthDiscovery.js' /** * C4/DEC-17 + D-8 — the REMOTE refresh goes through the IP-pinned transport (never @@ -16,25 +19,34 @@ const TOKEN_ENDPOINT = 'https://mcp.notion.com/token' const VALIDATED_IP = '93.184.216.34' const CLIENT_ID = 'https://control.example.com/api/v1/.well-known/evenfire-mcp-client' +// Real Notion discovery: CIMD without RFC 9207. A CIMD client there (the shape a CR +// installed before the per-server callback carries) is refused at consent, but a grant it +// already holds keeps refreshing: the refresh reader resolves in the `token` phase. +let notion: DiscoveryResult + +beforeAll(async () => { + const outcome = await discoverRemoteOAuth(PILOTS.notion.mcpUrl, { + transport: makeDiscoveryTransport(PILOTS.notion), + resolveDns: async () => [VALIDATED_IP], + }) + if (!outcome.ok) throw new Error(`fixture discovery failed: ${outcome.error.kind}`) + notion = outcome.result +}) + function remoteOwnerDecl(supportsRefresh: boolean) { - const resolved = resolveServerOAuthSubject({ - spec: { - contextRef: 'ctx-A', - oauth: { - source: 'remote', - id: CLIENT_ID, - clientMode: 'public', - authorizationEndpoint: 'https://mcp.notion.com/authorize', - tokenEndpoint: TOKEN_ENDPOINT, - issuer: 'https://mcp.notion.com', - resource: 'https://mcp.notion.com', - grantScope: 'user', - scopes: ['read'], - bearerInBody: false, - supportsRefresh, - }, - }, + const oauth = buildRemoteOAuthSpec(notion, { + clientMode: 'public', + grantScope: 'user', + cimdClientId: CLIENT_ID, }) + expect(oauth.issForCallback).toBeUndefined() + expect(oauth.tokenEndpoint).toBe(TOKEN_ENDPOINT) + // `supportsRefresh` follows the AS metadata (true for Notion); the `false` case is the + // same block with the flag flipped, as for an AS that advertises no refresh grant. + const resolved = resolveServerOAuthSubject( + { spec: { contextRef: 'ctx-A', oauth: { ...oauth, supportsRefresh } } }, + 'token' + ) if (!resolved) throw new Error('fixture: remote resolve returned null') return { spec: { oauthClients: [resolved.decl] } } } diff --git a/control-api/test/pluginWorkloadSdkCodexDualLedger.realPostgres.integration.test.ts b/control-api/test/pluginWorkloadSdkCodexDualLedger.realPostgres.integration.test.ts index ce5157e492..317c8387bc 100644 --- a/control-api/test/pluginWorkloadSdkCodexDualLedger.realPostgres.integration.test.ts +++ b/control-api/test/pluginWorkloadSdkCodexDualLedger.realPostgres.integration.test.ts @@ -230,12 +230,15 @@ describeRealPostgres('Plugin Workload SDK Codex dual ledger on real PostgreSQL', }, 60_000) afterAll(async () => { - await pool?.end() - if (adminPool) { - await adminPool - .query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) - .catch(() => undefined) - await adminPool.end() + try { + await pool?.end() + if (adminPool) { + await adminPool + .query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) + .catch(() => undefined) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/pluginWorkloadSdkGrantUpdateNotify.realPostgres.integration.test.ts b/control-api/test/pluginWorkloadSdkGrantUpdateNotify.realPostgres.integration.test.ts index 0fbb3eb9e5..284c0f5fa1 100644 --- a/control-api/test/pluginWorkloadSdkGrantUpdateNotify.realPostgres.integration.test.ts +++ b/control-api/test/pluginWorkloadSdkGrantUpdateNotify.realPostgres.integration.test.ts @@ -2,6 +2,7 @@ import { afterAll, beforeAll, expect, vi, describe as vitestDescribe } from 'vit import { it } from 'vitest' import { randomBytes, randomUUID } from 'node:crypto' import { Pool, type PoolClient } from 'pg' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' // issue #375 M3 (jozer review): controllable PASSTHROUGH seam. Everything stays // real (the actual append implementation runs) until a test arms `failNext`, @@ -106,28 +107,31 @@ describeRealPostgres( afterAll(async () => { try { - await listenClient?.query(`UNLISTEN ${sdk.PLUGIN_WORKLOAD_SDK_GRANT_UPDATE_CHANNEL}`) - } catch { - /* teardown */ - } - listenClient?.release() - await listenPool?.end() - await db?.pool.end() - await db?.rateLimitPool.end() - if (previousPgConnectionString === undefined) { - delete process.env.CONTROL_API_PG_CONNECTION_STRING - } else { - process.env.CONTROL_API_PG_CONNECTION_STRING = previousPgConnectionString - } - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) + try { + await listenClient?.query(`UNLISTEN ${sdk.PLUGIN_WORKLOAD_SDK_GRANT_UPDATE_CHANNEL}`) + } catch { + /* teardown */ + } + listenClient?.release() + await endPoolAndWaitForClients(listenPool) + await endPoolAndWaitForClients(db?.pool) + await endPoolAndWaitForClients(db?.rateLimitPool) + if (previousPgConnectionString === undefined) { + delete process.env.CONTROL_API_PG_CONNECTION_STRING + } else { + process.env.CONTROL_API_PG_CONNECTION_STRING = previousPgConnectionString + } + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/pluginWorkloadSdkGrokDualLedger.realPostgres.integration.test.ts b/control-api/test/pluginWorkloadSdkGrokDualLedger.realPostgres.integration.test.ts index a52001d73e..025f2259e7 100644 --- a/control-api/test/pluginWorkloadSdkGrokDualLedger.realPostgres.integration.test.ts +++ b/control-api/test/pluginWorkloadSdkGrokDualLedger.realPostgres.integration.test.ts @@ -122,13 +122,16 @@ describeRealPostgres('Plugin Workload SDK Grok dual ledger on real PostgreSQL', }, 60_000) afterAll(async () => { - config.grokSubscriptionEnabled = previousFlag - await pool?.end() - if (adminPool) { - await adminPool - .query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) - .catch(() => undefined) - await adminPool.end() + try { + config.grokSubscriptionEnabled = previousFlag + await pool?.end() + if (adminPool) { + await adminPool + .query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) + .catch(() => undefined) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/pluginWorkloadSdkPolicyReview.realPostgres.integration.test.ts b/control-api/test/pluginWorkloadSdkPolicyReview.realPostgres.integration.test.ts index 31129675c7..d72386580b 100644 --- a/control-api/test/pluginWorkloadSdkPolicyReview.realPostgres.integration.test.ts +++ b/control-api/test/pluginWorkloadSdkPolicyReview.realPostgres.integration.test.ts @@ -6,6 +6,7 @@ import { addPluginWorkloadSdkPolicyReviewProvenance, repairPluginWorkloadSdkLegacyGrantPolicies, } from '../src/services/pluginWorkloadSdkSchema.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -33,17 +34,21 @@ describeRealPostgres('Plugin Workload SDK policy review provenance on real Postg }) afterAll(async () => { - await dbPool?.end() - if (!adminPool) return - await adminPool.query( - `SELECT pg_terminate_backend(pid) + try { + await endPoolAndWaitForClients(dbPool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) + } + } finally { + await adminPool?.end() + } }) it('re-fences complete-looking active rows without provenance and never reactivates them', async () => { diff --git a/control-api/test/pluginWorkloadSdkSteplessQuota.realPostgres.integration.test.ts b/control-api/test/pluginWorkloadSdkSteplessQuota.realPostgres.integration.test.ts index bbcc366dec..8f572010e6 100644 --- a/control-api/test/pluginWorkloadSdkSteplessQuota.realPostgres.integration.test.ts +++ b/control-api/test/pluginWorkloadSdkSteplessQuota.realPostgres.integration.test.ts @@ -3,6 +3,7 @@ import { randomBytes, randomUUID } from 'node:crypto' import { Pool } from 'pg' import type { PluginWorkloadSdkFamily } from '../src/services/pluginWorkloadSdkDb.js' import type { McpHostAccessClaims } from '../src/utils/auth/mcpHostJwtToken.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' /** * Stepless / SDK-only quota regression on real PostgreSQL (issue #348, @@ -109,23 +110,26 @@ describeRealPostgres( }) afterAll(async () => { - await db?.pool.end() - await db?.rateLimitPool.end() - if (previousPgConnectionString === undefined) { - delete process.env.CONTROL_API_PG_CONNECTION_STRING - } else { - process.env.CONTROL_API_PG_CONNECTION_STRING = previousPgConnectionString - } - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) + try { + await endPoolAndWaitForClients(db?.pool) + await endPoolAndWaitForClients(db?.rateLimitPool) + if (previousPgConnectionString === undefined) { + delete process.env.CONTROL_API_PG_CONNECTION_STRING + } else { + process.env.CONTROL_API_PG_CONNECTION_STRING = previousPgConnectionString + } + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/realPostgresAdminCleanup.guard.test.ts b/control-api/test/realPostgresAdminCleanup.guard.test.ts new file mode 100644 index 0000000000..c166322658 --- /dev/null +++ b/control-api/test/realPostgresAdminCleanup.guard.test.ts @@ -0,0 +1,758 @@ +import { describe, expect, it } from 'vitest' +import { readFileSync, readdirSync } from 'node:fs' +import { dirname, join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' +import * as ts from 'typescript' + +const testDir = dirname(fileURLToPath(import.meta.url)) +type PoolBinding = ts.Symbol | ts.NewExpression +type Callback = ts.ArrowFunction | ts.FunctionExpression | ts.FunctionDeclaration +interface Problem { + file: string + line: number + reason: string +} +interface Audit { + file: string + hooks: number + callbacks: number + protectedCallbacks: number + adminPools: number + problems: Problem[] +} + +function unwrap(expression: ts.Expression): ts.Expression { + while ( + ts.isParenthesizedExpression(expression) || + ts.isAsExpression(expression) || + ts.isTypeAssertionExpression(expression) || + ts.isNonNullExpression(expression) || + ts.isSatisfiesExpression(expression) + ) + expression = expression.expression + return expression +} + +function memberReceiver(expression: ts.Expression, name: string): ts.Expression | undefined { + expression = unwrap(expression) + if (ts.isPropertyAccessExpression(expression) && expression.name.text === name) + return expression.expression + if ( + ts.isElementAccessExpression(expression) && + ts.isStringLiteralLike(expression.argumentExpression) && + expression.argumentExpression.text === name + ) + return expression.expression + return undefined +} + +function descendants(node: ts.Node): ts.Node[] { + const found: ts.Node[] = [] + function visit(child: ts.Node): void { + found.push(child) + ts.forEachChild(child, visit) + } + visit(node) + return found +} + +function sqlPrefix(expression: ts.Expression): string { + expression = unwrap(expression) + if (ts.isStringLiteralLike(expression)) return expression.text + if (ts.isTemplateExpression(expression)) return expression.head.text + if ( + ts.isBinaryExpression(expression) && + expression.operatorToken.kind === ts.SyntaxKind.PlusToken + ) { + return sqlPrefix(expression.left) + } + return '' +} + +// This is a conservative local control-flow proof, not a receiver-name rule. +// Pools must come from pg constructors; admin identity comes from their bound +// CREATE/DROP DATABASE queries. A close must be awaited in a guaranteed outer +// finally, before anything there could fail, covering preceding cleanup. +// Unresolved imports, receivers, callbacks or equivalent flow fail explicitly. +// No dependency source, original module or database operation is executed. +function auditSource(file: string, source: string): Audit { + const ast = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) + const options: ts.CompilerOptions = { noLib: true, noResolve: true } + const program = ts.createProgram([file], options, { + ...ts.createCompilerHost(options), + getSourceFile: name => (name === file ? ast : undefined), + readFile: name => (name === file ? source : undefined), + fileExists: name => name === file, + }) + const checker = program.getTypeChecker() + const nodes = descendants(ast) + const problems: Problem[] = [] + const problem = (node: ts.Node, reason: string): void => { + problems.push({ + file, + line: ast.getLineAndCharacterOfPosition(node.getStart(ast)).line + 1, + reason, + }) + } + for (const diagnostic of program.getSyntacticDiagnostics(ast)) { + problems.push({ + file, + line: ast.getLineAndCharacterOfPosition(diagnostic.start ?? 0).line + 1, + reason: `PARSE: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')}`, + }) + } + const writes = new Map() + function recordWrite(identifier: ts.Identifier): void { + const symbol = checker.getSymbolAtLocation(identifier) + if (symbol) writes.set(symbol, (writes.get(symbol) ?? 0) + 1) + } + for (const node of nodes) { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) + recordWrite(node.name) + if (ts.isFunctionDeclaration(node) && node.name) recordWrite(node.name) + if ( + ts.isBinaryExpression(node) && + ts.isIdentifier(node.left) && + node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + node.operatorToken.kind <= ts.SyntaxKind.LastAssignment + ) + recordWrite(node.left) + } + function imported( + expression: ts.Expression, + module: string, + name: string, + seen = new Set() + ): boolean { + expression = unwrap(expression) + if (!ts.isIdentifier(expression)) return false + const symbol = checker.getSymbolAtLocation(expression) + if (!symbol || seen.has(symbol) || (writes.get(symbol) ?? 0) > 1) return false + seen.add(symbol) + return ( + symbol.declarations?.some(declaration => { + if (ts.isImportSpecifier(declaration)) { + if ((writes.get(symbol) ?? 0) !== 0) return false + const origin = declaration.parent.parent.parent + return ( + (declaration.propertyName ?? declaration.name).text === name && + ts.isImportDeclaration(origin) && + ts.isStringLiteral(origin.moduleSpecifier) && + origin.moduleSpecifier.text === module + ) + } + return ( + ts.isVariableDeclaration(declaration) && + !!declaration.initializer && + imported(declaration.initializer, module, name, seen) + ) + }) ?? false + ) + } + const isPoolCreation = (expression: ts.Expression): expression is ts.NewExpression => { + expression = unwrap(expression) + return ts.isNewExpression(expression) && imported(expression.expression, 'pg', 'Pool') + } + const constructed = new Set() + const creationSite = new Map() + let currentCallback: Callback | undefined + function enclosingFunction(node: ts.Node): Callback | undefined { + for (let parent = node.parent; parent; parent = parent.parent) { + if ( + ts.isArrowFunction(parent) || + ts.isFunctionExpression(parent) || + ts.isFunctionDeclaration(parent) + ) + return parent + } + return undefined + } + for (const node of nodes) { + let identifier: ts.Identifier | undefined + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.initializer && + isPoolCreation(node.initializer) + ) + identifier = node.name + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isIdentifier(node.left) && + isPoolCreation(node.right) + ) + identifier = node.left + if (identifier) { + const symbol = checker.getSymbolAtLocation(identifier) + if (symbol) { + constructed.add(symbol) + creationSite.set(symbol, node) + } + } + } + for (const symbol of constructed) if (writes.get(symbol) !== 1) constructed.delete(symbol) + function poolBinding( + expression: ts.Expression, + seen = new Set() + ): PoolBinding | undefined { + expression = unwrap(expression) + if (isPoolCreation(expression)) return expression + if (!ts.isIdentifier(expression)) return undefined + const symbol = checker.getSymbolAtLocation(expression) + if (!symbol || seen.has(symbol)) return undefined + if ((writes.get(symbol) ?? 0) > 1) return undefined + if (constructed.has(symbol)) return symbol + seen.add(symbol) + const declaration = symbol.valueDeclaration + if (!declaration || !ts.isVariableDeclaration(declaration) || !declaration.initializer) + return undefined + const alias = poolBinding(declaration.initializer, seen) + if (!alias || !('getName' in alias)) return alias + const created = creationSite.get(alias) + const scope = enclosingFunction(declaration) + // A top-level alias cannot capture a mutable pool that is assigned only + // when beforeAll runs. Aliases inside the actual cleanup callback, or + // after construction in the same executing scope, are resolvable. + return created && + ((scope === enclosingFunction(created) && declaration.pos > created.pos) || + (ts.isVariableDeclaration(created) && declaration.pos > created.pos) || + (currentCallback && + scope === currentCallback && + beforeAllScopes.has(enclosingFunction(created)!))) + ? alias + : undefined + } + const calls = nodes.filter(ts.isCallExpression) + const beforeAllScopes = new Set() + for (const call of calls) { + if (!imported(call.expression, 'vitest', 'beforeAll') || !call.arguments[0]) continue + const setup = callback(call.arguments[0]) + if (setup) beforeAllScopes.add(setup) + } + const administrative = new Set() + const administrativeOrigin = new Map() + function resolvedSqlPrefix(expression: ts.Expression, seen = new Set()): string { + expression = unwrap(expression) + if (!ts.isIdentifier(expression)) return sqlPrefix(expression) + const symbol = checker.getSymbolAtLocation(expression) + if (!symbol || seen.has(symbol) || writes.get(symbol) !== 1) return '' + seen.add(symbol) + const declaration = symbol.valueDeclaration + return declaration && + ts.isVariableDeclaration(declaration) && + declaration.initializer && + ts.isVariableDeclarationList(declaration.parent) && + declaration.parent.flags & ts.NodeFlags.Const + ? resolvedSqlPrefix(declaration.initializer, seen) + : '' + } + for (const call of calls) { + const receiver = memberReceiver(call.expression, 'query') + if ( + !receiver || + !call.arguments[0] || + !/^\s*(?:CREATE|DROP)\s+DATABASE\b/i.test(resolvedSqlPrefix(call.arguments[0])) + ) + continue + const pool = poolBinding(receiver) + if (pool) { + administrative.add(pool) + administrativeOrigin.set(pool, call) + } else problem(call, 'UNRESOLVED administrative query receiver') + } + function callback(expression: ts.Expression, seen = new Set()): Callback | undefined { + expression = unwrap(expression) + if (ts.isArrowFunction(expression) || ts.isFunctionExpression(expression)) return expression + if (!ts.isIdentifier(expression)) return undefined + const symbol = checker.getSymbolAtLocation(expression) + if (!symbol || seen.has(symbol) || (writes.get(symbol) ?? 0) > 1) return undefined + seen.add(symbol) + const declaration = symbol.valueDeclaration + if (declaration && ts.isFunctionDeclaration(declaration)) return declaration + return declaration && ts.isVariableDeclaration(declaration) && declaration.initializer + ? callback(declaration.initializer, seen) + : undefined + } + const isEnd = (call: ts.CallExpression, pool: PoolBinding): boolean => { + const receiver = memberReceiver(call.expression, 'end') + return !!receiver && poolBinding(receiver) === pool + } + function startsWithClose(expression: ts.Expression, pool: PoolBinding): boolean { + expression = unwrap(expression) + if (!ts.isCallExpression(expression)) return false + if (isEnd(expression, pool)) return expression.arguments.length === 0 + // pg Pool.end is invoked before a recovery chain such as .catch(...) + // evaluates its arguments. Recovery does not bypass the administrative + // close; it preserves the fixture's existing error-handling semantics. + const receiver = memberReceiver(expression.expression, 'catch') + return !!receiver && startsWithClose(receiver, pool) + } + for (const node of nodes) { + if (!ts.isBinaryExpression(node) || node.operatorToken.kind !== ts.SyntaxKind.EqualsToken) + continue + const receiver = memberReceiver(node.left, 'end') + const pool = receiver && poolBinding(receiver) + if (pool && administrative.has(pool)) + problem(node, 'UNRESOLVED administrative end method reassignment') + } + const contains = (container: ts.Node, node: ts.Node): boolean => + container.pos <= node.pos && container.end >= node.end + function unshadowedUndefined(expression: ts.Expression): boolean { + expression = unwrap(expression) + return ( + ts.isIdentifier(expression) && + expression.text === 'undefined' && + !checker.getSymbolAtLocation(expression)?.declarations?.length + ) + } + function presentWhenPoolExists(expression: ts.Expression, pool: PoolBinding): boolean { + expression = unwrap(expression) + if (poolBinding(expression) === pool) return true + return ( + ts.isBinaryExpression(expression) && + (expression.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken || + expression.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsToken) && + poolBinding(expression.left) === pool && + (expression.right.kind === ts.SyntaxKind.NullKeyword || unshadowedUndefined(expression.right)) + ) + } + function pureExpression(expression: ts.Expression): boolean { + expression = unwrap(expression) + if ( + ts.isStringLiteralLike(expression) || + ts.isNumericLiteral(expression) || + expression.kind === ts.SyntaxKind.TrueKeyword || + expression.kind === ts.SyntaxKind.FalseKeyword || + expression.kind === ts.SyntaxKind.NullKeyword || + ts.isArrowFunction(expression) || + ts.isFunctionExpression(expression) + ) + return true + if (ts.isIdentifier(expression)) { + if (unshadowedUndefined(expression)) return true + const symbol = checker.getSymbolAtLocation(expression) + if (!symbol?.declarations?.length) return false + return symbol.declarations.every( + declaration => + !ts.isVariableDeclaration(declaration) || + enclosingFunction(declaration) !== currentCallback || + declaration.pos < expression.pos + ) + } + if (ts.isArrayLiteralExpression(expression)) + return expression.elements.every( + element => !ts.isSpreadElement(element) && pureExpression(element) + ) + if (ts.isObjectLiteralExpression(expression)) { + return expression.properties.every(property => { + if (ts.isSpreadAssignment(property)) return false + if (property.name && ts.isComputedPropertyName(property.name)) { + const key = unwrap(property.name.expression) + if (!ts.isStringLiteralLike(key) && !ts.isNumericLiteral(key)) return false + } + if (ts.isPropertyAssignment(property)) return pureExpression(property.initializer) + if (ts.isShorthandPropertyAssignment(property)) return pureExpression(property.name) + return ( + ts.isMethodDeclaration(property) || + ts.isGetAccessorDeclaration(property) || + ts.isSetAccessorDeclaration(property) + ) + }) + } + return false + } + function safePrefix(statement: ts.Statement): boolean { + if (ts.isEmptyStatement(statement) || ts.isFunctionDeclaration(statement)) return true + if (ts.isBlock(statement)) return statement.statements.every(safePrefix) + if (ts.isVariableStatement(statement)) + return statement.declarationList.declarations.every( + declaration => + ts.isIdentifier(declaration.name) && + (!declaration.initializer || pureExpression(declaration.initializer)) + ) + if (ts.isExpressionStatement(statement)) return pureExpression(statement.expression) + if (ts.isIfStatement(statement)) { + const condition = unwrap(statement.expression) + if (condition.kind === ts.SyntaxKind.FalseKeyword) + return !statement.elseStatement || safePrefix(statement.elseStatement) + if (condition.kind === ts.SyntaxKind.TrueKeyword) return safePrefix(statement.thenStatement) + } + return false + } + function safeWhenAbsent(expression: ts.Expression, pool: PoolBinding): boolean { + expression = unwrap(expression) + if (!ts.isCallExpression(expression)) return false + if (isEnd(expression, pool)) { + const member = expression.expression + if ( + (ts.isPropertyAccessExpression(member) || ts.isElementAccessExpression(member)) && + member.questionDotToken + ) + return true + const created = 'getName' in pool ? creationSite.get(pool) : pool + return !!created && (ts.isNewExpression(created) || ts.isVariableDeclaration(created)) + } + const receiver = memberReceiver(expression.expression, 'catch') + return !!receiver && safeWhenAbsent(receiver, pool) + } + function guaranteedClose( + statement: ts.Statement, + pool: PoolBinding, + knownPresent = false + ): boolean { + if (ts.isBlock(statement)) return guaranteedSequence(statement.statements, pool, knownPresent) + if (ts.isIfStatement(statement) && presentWhenPoolExists(statement.expression, pool)) + return guaranteedClose(statement.thenStatement, pool, true) + if (!ts.isExpressionStatement(statement) || !ts.isAwaitExpression(statement.expression)) + return false + const expression = unwrap(statement.expression.expression) + return startsWithClose(expression, pool) && (knownPresent || safeWhenAbsent(expression, pool)) + } + function guaranteedSequence( + statements: ts.NodeArray, + pool: PoolBinding, + knownPresent = false + ): boolean { + for (const statement of statements) { + if (ts.isEmptyStatement(statement)) continue + if ( + ts.isVariableStatement(statement) && + statement.declarationList.declarations.every( + declaration => + declaration.initializer && + ts.isIdentifier(unwrap(declaration.initializer)) && + poolBinding(declaration.initializer) === pool + ) + ) + continue + return guaranteedClose(statement, pool, knownPresent) + } + return false + } + let hooks = 0 + let callbacks = 0 + let protectedCallbacks = 0 + let adminPools = 0 + const closedByCallbacks = new Map() + for (const call of calls) { + const registered = imported(call.expression, 'vitest', 'afterAll') + const apparent = ts.isIdentifier(call.expression) && call.expression.text === 'afterAll' + if (!registered && !apparent) continue + hooks += 1 + if (!registered) { + problem(call, 'UNRESOLVED afterAll registration') + continue + } + const cleanup = call.arguments[0] && callback(call.arguments[0]) + if (!cleanup || !cleanup.body || !ts.isBlock(cleanup.body)) { + problem(call, 'UNRESOLVED afterAll callback') + continue + } + callbacks += 1 + const callbackProblemsBefore = problems.length + if ( + cleanup.parameters.some( + parameter => !ts.isIdentifier(parameter.name) || parameter.initializer + ) + ) { + problem(call, 'UNPROVEN callback parameter evaluation before cleanup') + } + currentCallback = cleanup + const body = cleanup.body + const localNodes: ts.Node[] = [] + function visitCleanup(node: ts.Node): void { + if (node !== body && ts.isFunctionLike(node)) return + localNodes.push(node) + ts.forEachChild(node, visitCleanup) + } + visitCleanup(body) + const subjects = new Set() + for (const node of localNodes) { + if (!ts.isIdentifier(node)) continue + const pool = poolBinding(node) + if (pool && administrative.has(pool)) subjects.add(pool) + } + if (subjects.size === 0) { + problem(call, 'UNRESOLVED administrative pool identity') + continue + } + adminPools += subjects.size + for (const pool of subjects) { + const poolProblemsBefore = problems.length + const ends = localNodes.filter(ts.isCallExpression).filter(end => isEnd(end, pool)) + if (ends.length !== 1) { + problem(call, `Expected one administrative close, found ${ends.length}`) + continue + } + const outer = body.statements.find( + statement => + ts.isTryStatement(statement) && + statement.finallyBlock && + guaranteedSequence(statement.finallyBlock.statements, pool) + ) + if (!outer || !ts.isTryStatement(outer) || !outer.finallyBlock) { + problem(call, 'No guaranteed administrative close in an outer finally') + continue + } + const unprotected = localNodes + .filter(ts.isAwaitExpression) + .filter( + awaited => + !contains(outer.tryBlock, awaited) && + !(outer.catchClause && contains(outer.catchClause, awaited)) && + !(contains(outer.finallyBlock!, awaited) && contains(awaited, ends[0]!)) + ) + for (const awaited of unprotected) + problem(awaited, 'Async cleanup is outside the administrative close guarantee') + const before = body.statements.slice(0, body.statements.indexOf(outer)) + if (before.some(statement => !safePrefix(statement))) { + problem( + call, + 'UNPROVEN potentially throwing statement before the outer cleanup try/finally' + ) + } + if (problems.length === poolProblemsBefore) + closedByCallbacks.set(pool, (closedByCallbacks.get(pool) ?? 0) + 1) + } + if (problems.length === callbackProblemsBefore) protectedCallbacks += 1 + } + if (administrative.size > 0 && hooks === 0) + problem(ast, 'Administrative pool fixture has no resolved afterAll callback') + if (constructed.size > 0 && administrative.size === 0) + problem(ast, 'UNRESOLVED administrative cleanup for constructed pg pools') + for (const pool of administrative) { + const closures = closedByCallbacks.get(pool) ?? 0 + if (closures !== 1) + problem( + administrativeOrigin.get(pool)!, + `Expected one globally guaranteed administrative callback close, found ${closures}` + ) + } + return { file, hooks, callbacks, protectedCallbacks, adminPools, problems } +} + +function discoverSuites(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }) + .flatMap(entry => { + const path = join(directory, entry.name) + return entry.isDirectory() + ? discoverSuites(path) + : entry.isFile() && + entry.name.includes('realPostgres') && + entry.name.endsWith('.integration.test.ts') + ? [path] + : [] + }) + .sort() +} + +function fixture(body: string): string { + return [ + "import { afterAll as teardown, beforeAll } from 'vitest'", + "import { Pool as PgPool } from 'pg'", + 'const administrative = new PgPool()', + 'const appPool = new PgPool()', + 'beforeAll(async () => { await administrative.query(`CREATE DATABASE fixture`) })', + 'const owner = administrative', + `teardown(async () => { ${body} })`, + ].join('\n') +} + +describe('real PostgreSQL administrative cleanup guard', () => { + it('proves administrative closure for every physically discovered real-PG afterAll callback', () => { + const files = discoverSuites(testDir) + const audits = files.map(path => + auditSource(relative(testDir, path), readFileSync(path, 'utf8')) + ) + // Reviewed inventory at 0a895015: 64 physical integrations / 65 hooks. + // New suites are discovered automatically; deliberate removals require + // explicit review of this lower-bound witness instead of silently passing. + expect(files.length).toBeGreaterThanOrEqual(64) + expect(new Set(files).size).toBe(files.length) + expect(audits.flatMap(audit => audit.problems)).toEqual([]) + expect(audits.reduce((sum, audit) => sum + audit.hooks, 0)).toBeGreaterThanOrEqual(65) + expect(audits.reduce((sum, audit) => sum + audit.callbacks, 0)).toBe( + audits.reduce((sum, audit) => sum + audit.hooks, 0) + ) + expect( + audits + .filter(audit => audit.callbacks > 0) + .every(audit => audit.adminPools >= audit.callbacks) + ).toBe(true) + expect(audits.reduce((sum, audit) => sum + audit.protectedCallbacks, 0)).toBe( + audits.reduce((sum, audit) => sum + audit.callbacks, 0) + ) + }) + + it.each([ + 'try { await appPool.query(`SELECT 1`) } finally { await administrative?.end() }', + "try { await appPool.query(`SELECT 1`) } finally { await (owner as PgPool)?.['end']() }", + 'try { await appPool.query(`SELECT 1`) } finally { if (owner) await owner.end() }', + 'try { await appPool.query(`SELECT 1`) } finally { const closing = owner; await closing?.end() }', + 'const request = { end() {} }; try { request.end(); await appPool.query(`SELECT 1`) } finally { await administrative.end() }', + 'try { await appPool.query(`SELECT 1`) } finally { await administrative?.end().catch(() => undefined) }', + 'try { await appPool.query(`SELECT 1`) } catch (error) { await appPool.query(`SELECT 2`); throw error } finally { await administrative.end() }', + 'if (false) { const previous = 1 } try { await appPool.query(`SELECT 1`) } finally { await administrative.end() }', + ])('accepts a provably safe binding and close: %s', body => { + const audit = auditSource('fixture.realPostgres.test.ts', fixture(body)) + expect(audit.callbacks).toBe(1) + expect(audit.adminPools).toBe(1) + expect(audit.problems).toEqual([]) + }) + + it.each([ + 'await appPool.query(`SELECT 1`); await administrative.end()', + 'await appPool.query(`SELECT 1`); try {} finally { await administrative.end() }', + 'try { await appPool.query(`SELECT 1`) } finally { if (false) await administrative.end() }', + 'try { await appPool.query(`SELECT 1`) } finally { await appPool.query(`SELECT 2`); await administrative.end() }', + 'try { await appPool.query(`SELECT 1`) } finally { const administrative = appPool; await administrative.end() }', + 'try { await appPool.query(`SELECT 1`) } finally { const request = { end() {} }; request.end() }', + 'try { await appPool.query(`SELECT 1`) } finally { const { end } = administrative; await end() }', + 'try { await appPool.query(`SELECT 1`) } finally { administrative.end() }', + 'if (true) return; try { await appPool.query(`SELECT 1`) } finally { await administrative.end() }', + 'administrative = appPool; try { await appPool.query(`SELECT 1`) } finally { await administrative.end() }', + 'administrative.end = async () => {}; try { await appPool.query(`SELECT 1`) } finally { await administrative.end() }', + 'try { await appPool.query(`SELECT 1`) } finally { await appPool.query(`SELECT 2`).catch(() => administrative.end()) }', + 'beforeThatCanThrow(); try { await appPool.query(`SELECT 1`) } finally { await administrative.end() }', + 'const value = sourceWithGetter.property; try { await appPool.query(`SELECT 1`) } finally { await administrative.end() }', + 'prepareCleanup(sourceWithGetter.property); try { await appPool.query(`SELECT 1`) } finally { await administrative.end() }', + 'const undefined = owner; try { await appPool.query(`SELECT 1`) } finally { if (owner !== undefined) await owner.end() }', + ])('rejects an unproven or unrelated administrative close: %s', body => { + expect( + auditSource('fixture.realPostgres.test.ts', fixture(body)).problems.length + ).toBeGreaterThan(0) + }) + + it('fails on parse errors, unknown pool construction and unresolved callback references', () => { + expect( + auditSource('fixture.realPostgres.test.ts', 'const broken = (').problems.length + ).toBeGreaterThan(0) + expect( + auditSource( + 'fixture.realPostgres.test.ts', + fixture('try {} finally { await administrative.end() }').replace( + 'new PgPool()', + 'makePool()' + ) + ).problems.length + ).toBeGreaterThan(0) + expect( + auditSource( + 'fixture.realPostgres.test.ts', + fixture('try {} finally { await administrative.end() }').replace( + 'teardown(async () => {', + 'teardown(unknownCallback); void (async () => {' + ) + ).problems.length + ).toBeGreaterThan(0) + }) + + it('rejects premature mutable aliases and unsafe absence handling while accepting cleanup-local aliases', () => { + const prefix = [ + "import { afterAll, beforeAll } from 'vitest'", + "import { Pool } from 'pg'", + 'let administrative: Pool', + 'beforeAll(async () => { administrative = new Pool(); await administrative.query(`CREATE DATABASE fixture`) })', + ].join('\n') + const early = `${prefix}\nconst owner = administrative\nafterAll(async () => { try { await administrative.query(\`DROP DATABASE fixture\`) } finally { await owner?.end() } })` + expect(auditSource('fixture.realPostgres.test.ts', early).problems.length).toBeGreaterThan(0) + const absent = `${prefix}\nafterAll(async () => { try { await administrative.query(\`DROP DATABASE fixture\`) } finally { await administrative.end() } })` + expect(auditSource('fixture.realPostgres.test.ts', absent).problems.length).toBeGreaterThan(0) + const local = `${prefix}\nafterAll(async () => { const owner = administrative; try { await administrative.query(\`DROP DATABASE fixture\`) } finally { await owner?.end() } })` + expect(auditSource('fixture.realPostgres.test.ts', local).problems).toEqual([]) + const late = [ + "import { afterAll } from 'vitest'", + "import { Pool } from 'pg'", + 'let administrative: Pool', + 'afterAll(async () => { const owner = administrative; administrative = new Pool(); try { await administrative.query(`DROP DATABASE fixture`) } finally { await owner?.end() } })', + ].join('\n') + expect(auditSource('fixture.realPostgres.test.ts', late).problems.length).toBeGreaterThan(0) + }) + + it.each([ + [ + 'end argument evaluation', + fixture( + 'try { await appPool.query(`SELECT 1`) } finally { await administrative.end(beforeCloseThatThrows()) }' + ), + ], + [ + 'omitted second administrative pool', + [ + "import { afterAll, beforeAll } from 'vitest'", + "import { Pool } from 'pg'", + 'const administrative = new Pool(); const forgotten = new Pool()', + 'beforeAll(async () => { await administrative.query(`CREATE DATABASE fixture`); await forgotten.query(`CREATE DATABASE other_fixture`) })', + 'afterAll(async () => { try { await administrative.query(`DROP DATABASE fixture`) } finally { await administrative.end() } })', + ].join('\n'), + ], + [ + 'SQL constant without a cleanup hook', + [ + "import { Pool } from 'pg'", + 'const administrative = new Pool()', + "const sql = 'DROP DATABASE fixture'", + 'administrative.query(sql)', + ].join('\n'), + ], + [ + 'reassigned callback', + [ + "import { afterAll, beforeAll } from 'vitest'", + "import { Pool } from 'pg'", + 'const administrative = new Pool()', + 'beforeAll(async () => { await administrative.query(`CREATE DATABASE fixture`) })', + 'let cleanup = async () => { try { await administrative.query(`DROP DATABASE fixture`) } finally { await administrative.end() } }', + 'cleanup = async () => {}', + 'afterAll(cleanup)', + ].join('\n'), + ], + [ + 'reassigned hook alias', + [ + "import { afterAll, beforeAll } from 'vitest'", + "import { Pool } from 'pg'", + 'const administrative = new Pool()', + 'beforeAll(async () => { await administrative.query(`CREATE DATABASE fixture`) })', + 'let register = afterAll', + 'register = (_callback: unknown) => {}', + 'register(async () => { try { await administrative.query(`DROP DATABASE fixture`) } finally { await administrative.end() } })', + ].join('\n'), + ], + [ + 'reassigned named function callback', + [ + "import { afterAll, beforeAll } from 'vitest'", + "import { Pool } from 'pg'", + 'const administrative = new Pool()', + 'beforeAll(async () => { await administrative.query(`CREATE DATABASE fixture`) })', + 'async function cleanup() { try { await administrative.query(`DROP DATABASE fixture`) } finally { await administrative.end() } }', + 'cleanup = async () => {}', + 'afterAll(cleanup)', + ].join('\n'), + ], + ])('rejects reviewed unsafe shape: %s', (_name, source) => { + expect( + auditSource('fixture.realPostgres.integration.test.ts', source).problems.length + ).toBeGreaterThan(0) + }) + + it('resolves immutable administrative SQL and stable named callback aliases', () => { + const source = [ + "import { afterAll as register, beforeAll } from 'vitest'", + "import { Pool } from 'pg'", + 'const administrative = new Pool()', + "const createSql = 'CREATE DATABASE fixture'", + "const dropSql = 'DROP DATABASE fixture'", + 'beforeAll(async () => { await administrative.query(createSql) })', + 'const cleanup = async () => { try { await administrative.query(dropSql) } finally { await administrative.end() } }', + 'register(cleanup)', + ].join('\n') + const audit = auditSource('fixture.realPostgres.integration.test.ts', source) + expect(audit.callbacks).toBe(1) + expect(audit.adminPools).toBe(1) + expect(audit.protectedCallbacks).toBe(1) + expect(audit.problems).toEqual([]) + }) +}) diff --git a/control-api/test/realPostgresCleanup.contract.test.ts b/control-api/test/realPostgresCleanup.contract.test.ts new file mode 100644 index 0000000000..9f47adecf7 --- /dev/null +++ b/control-api/test/realPostgresCleanup.contract.test.ts @@ -0,0 +1,774 @@ +import { describe, expect, it, vi } from 'vitest' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { Script } from 'node:vm' +import * as ts from 'typescript' + +const testDir = dirname(fileURLToPath(import.meta.url)) +const suites = [ + 'services.adminAuthDelete.realPostgres.integration.test.ts', + 'services.controlAdminReplaceInviter.realPostgres.integration.test.ts', + 'services.directory.userRetirement.realPostgres.integration.test.ts', +] + +// This test-only contract probe executes each actual source callback, not a +// copied teardown model. Imports, beforeAll and business tests never run; the +// administrative/query collaborators below cannot contact a real database. +function afterAllCallback( + file: string, + collaborators: Record +): () => Promise { + const source = readFileSync(join(testDir, file), 'utf8') + const ast = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) + const callbacks: ts.ArrowFunction[] = [] + function visit(node: ts.Node): void { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'afterAll' + ) { + const callback = node.arguments[0] + if (callback && ts.isArrowFunction(callback)) callbacks.push(callback) + } + ts.forEachChild(node, visit) + } + visit(ast) + // Do not put AST nodes in failure output: their parents contain unrelated + // fixture data. The witness is exactly one asynchronous source callback. + expect(callbacks.length).toBe(1) + const callback = callbacks[0]! + expect(callback.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword)).toBe( + true + ) + const emitted = ts.transpileModule(`(${callback.getText(ast)})`, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None }, + }).outputText + const hook = new Script(emitted, { filename: `${file}:afterAll-contract` }).runInNewContext( + { + querySpy: undefined, + connectSpy: undefined, + corePoolConnectSpy: undefined, + database: 'teardown_contract', + quoteIdent: (value: string) => `"${value}"`, + ...collaborators, + }, + { timeout: 1_000 } + ) as () => Promise + expect(typeof hook).toBe('function') + return hook +} + +describe.each(suites)('%s teardown contract', file => { + it('closes the administrative pool after the client-close helper rejects without waiting or dropping', async () => { + const testPool = {} + const error = new Error('client-close helper failed') + const helper = vi.fn().mockRejectedValueOnce(error) + const wait = vi.fn() + const adminPool = { end: vi.fn().mockResolvedValue(undefined), query: vi.fn() } + const hook = afterAllCallback(file, { + testPool, + adminPool, + endPoolAndWaitForClients: helper, + waitForDatabaseConnectionsToClose: wait, + }) + + await expect(hook()).rejects.toBe(error) + + expect(helper).toHaveBeenCalledExactlyOnceWith(testPool) + expect(adminPool.end).toHaveBeenCalledExactlyOnceWith() + expect(wait).not.toHaveBeenCalled() + expect(adminPool.query).not.toHaveBeenCalled() + }) + + it('waits for client closure, then server closure, then drops before closing the admin pool', async () => { + const events: string[] = [] + const testPool = {} + const helper = vi.fn(async () => { + events.push('clients-closed') + }) + const wait = vi.fn(async () => { + events.push('server-closed') + }) + const adminPool = { + query: vi.fn(async () => { + events.push('drop') + }), + end: vi.fn(async () => { + events.push('admin-closed') + }), + } + const hook = afterAllCallback(file, { + testPool, + adminPool, + endPoolAndWaitForClients: helper, + waitForDatabaseConnectionsToClose: wait, + }) + + await expect(hook()).resolves.toBeUndefined() + + expect(helper).toHaveBeenCalledExactlyOnceWith(testPool) + expect(wait).toHaveBeenCalledExactlyOnceWith(adminPool, 'teardown_contract') + expect(adminPool.query).toHaveBeenCalledExactlyOnceWith( + 'DROP DATABASE IF EXISTS "teardown_contract"' + ) + expect(adminPool.end).toHaveBeenCalledExactlyOnceWith() + expect(events).toEqual(['clients-closed', 'server-closed', 'drop', 'admin-closed']) + }) + + it('still closes the test pool when no administrative pool was created', async () => { + const testPool = {} + const helper = vi.fn().mockResolvedValue(undefined) + const wait = vi.fn() + const hook = afterAllCallback(file, { + testPool, + adminPool: undefined, + endPoolAndWaitForClients: helper, + waitForDatabaseConnectionsToClose: wait, + }) + + await expect(hook()).resolves.toBeUndefined() + + expect(helper).toHaveBeenCalledExactlyOnceWith(testPool) + expect(wait).not.toHaveBeenCalled() + }) +}) + +describe('gfsStructuralIntegrity.realPostgres.integration.test.ts teardown contract', () => { + const file = 'gfsStructuralIntegrity.realPostgres.integration.test.ts' + + it('closes the admin pool after helper rejection without terminating or dropping', async () => { + const pool = {} + const error = new Error('GFS client-close helper failed') + const helper = vi.fn().mockRejectedValueOnce(error) + const adminPool = { end: vi.fn().mockResolvedValue(undefined), query: vi.fn() } + const hook = afterAllCallback(file, { pool, adminPool, endPoolAndWaitForClients: helper }) + + await expect(hook()).rejects.toBe(error) + + expect(helper).toHaveBeenCalledExactlyOnceWith(pool) + expect(adminPool.end).toHaveBeenCalledExactlyOnceWith() + expect(adminPool.query).not.toHaveBeenCalled() + }) + + it('closes clients before termination and DROP, then closes the admin pool', async () => { + const events: string[] = [] + const pool = {} + const helper = vi.fn(async () => { + events.push('clients-closed') + }) + const adminPool = { + query: vi + .fn() + .mockImplementationOnce(async () => { + events.push('terminate') + }) + .mockImplementationOnce(async () => { + events.push('drop') + }), + end: vi.fn(async () => { + events.push('admin-closed') + }), + } + const hook = afterAllCallback(file, { pool, adminPool, endPoolAndWaitForClients: helper }) + + await expect(hook()).resolves.toBeUndefined() + + expect(helper).toHaveBeenCalledExactlyOnceWith(pool) + expect(adminPool.query).toHaveBeenCalledTimes(2) + expect(adminPool.query).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('SELECT pg_terminate_backend(pid)'), + ['teardown_contract'] + ) + expect(adminPool.query.mock.calls[0]?.[0]).toContain('pid <> pg_backend_pid()') + expect(adminPool.query).toHaveBeenNthCalledWith( + 2, + 'DROP DATABASE IF EXISTS "teardown_contract"' + ) + expect(adminPool.end).toHaveBeenCalledExactlyOnceWith() + expect(events).toEqual(['clients-closed', 'terminate', 'drop', 'admin-closed']) + }) + + it('still closes the GFS pool when no administrative pool was created', async () => { + const pool = {} + const helper = vi.fn().mockResolvedValue(undefined) + const hook = afterAllCallback(file, { + pool, + adminPool: undefined, + endPoolAndWaitForClients: helper, + }) + + await expect(hook()).resolves.toBeUndefined() + + expect(helper).toHaveBeenCalledExactlyOnceWith(pool) + }) + + it.each(['termination', 'DROP'] as const)( + 'closes the admin pool when %s SQL fails', + async phase => { + const pool = {} + const error = new Error(`GFS ${phase} SQL failed`) + const helper = vi.fn().mockResolvedValue(undefined) + const query = vi.fn() + if (phase === 'DROP') query.mockResolvedValueOnce(undefined) + query.mockRejectedValueOnce(error) + const adminPool = { end: vi.fn().mockResolvedValue(undefined), query } + const hook = afterAllCallback(file, { pool, adminPool, endPoolAndWaitForClients: helper }) + + await expect(hook()).rejects.toBe(error) + + expect(helper).toHaveBeenCalledExactlyOnceWith(pool) + expect(query).toHaveBeenCalledTimes(phase === 'termination' ? 1 : 2) + expect(query).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('SELECT pg_terminate_backend(pid)'), + ['teardown_contract'] + ) + if (phase === 'DROP') { + expect(query).toHaveBeenNthCalledWith(2, 'DROP DATABASE IF EXISTS "teardown_contract"') + } + expect(adminPool.end).toHaveBeenCalledExactlyOnceWith() + } + ) +}) + +describe('services.mcpSecretRollbackPermit.realPostgres.integration.test.ts teardown contract', () => { + const file = 'services.mcpSecretRollbackPermit.realPostgres.integration.test.ts' + + it('closes the admin pool on helper rejection without probing, terminating or dropping', async () => { + const pool = {} + const error = new Error('rollback-permit client-close helper failed') + const helper = vi.fn().mockRejectedValueOnce(error) + const adminPool = { end: vi.fn().mockResolvedValue(undefined), query: vi.fn() } + const hook = afterAllCallback(file, { + pool, + adminPool, + endPoolAndWaitForClients: helper, + setImmediate, + }) + + await expect(hook()).rejects.toBe(error) + + expect(helper).toHaveBeenCalledExactlyOnceWith(pool) + expect(adminPool.query).not.toHaveBeenCalled() + expect(adminPool.end).toHaveBeenCalledExactlyOnceWith() + }) + + it.each(['quiescent', 'still active'] as const)( + 'closes clients before observing %s backends and cleaning the database', + async state => { + const events: string[] = [] + const pool = {} + const helper = vi.fn(async () => { + events.push('clients-closed') + }) + const count = state === 'quiescent' ? '0' : '1' + const adminPool = { + query: vi.fn(async (sql: string) => { + events.push( + sql.includes('COUNT(*)') + ? 'probe' + : sql.includes('pg_terminate_backend') + ? 'terminate' + : 'drop' + ) + return { rows: [{ count }] } + }), + end: vi.fn(async () => { + events.push('admin-closed') + }), + } + const hook = afterAllCallback(file, { + pool, + adminPool, + endPoolAndWaitForClients: helper, + setImmediate, + }) + + await expect(hook()).resolves.toBeUndefined() + + expect(helper).toHaveBeenCalledExactlyOnceWith(pool) + const probes = state === 'quiescent' ? 1 : 50 + expect(adminPool.query).toHaveBeenCalledTimes(probes + (state === 'quiescent' ? 1 : 2)) + expect(adminPool.query).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('SELECT COUNT(*)::text AS count'), + ['teardown_contract'] + ) + if (state === 'still active') { + expect(adminPool.query).toHaveBeenNthCalledWith( + 51, + expect.stringContaining('SELECT pg_terminate_backend(pid)'), + ['teardown_contract'] + ) + } + expect(adminPool.query).toHaveBeenLastCalledWith( + 'DROP DATABASE IF EXISTS "teardown_contract"' + ) + expect(adminPool.end).toHaveBeenCalledExactlyOnceWith() + expect(events).toEqual([ + 'clients-closed', + ...Array(probes).fill('probe'), + ...(state === 'still active' ? ['terminate'] : []), + 'drop', + 'admin-closed', + ]) + } + ) + + it('closes the permit pool even when no admin pool exists', async () => { + const pool = {} + const helper = vi.fn().mockResolvedValue(undefined) + const hook = afterAllCallback(file, { + pool, + adminPool: undefined, + endPoolAndWaitForClients: helper, + setImmediate, + }) + + await expect(hook()).resolves.toBeUndefined() + + expect(helper).toHaveBeenCalledExactlyOnceWith(pool) + }) + + it.each(['probe', 'termination', 'DROP'] as const)( + 'closes the admin pool if %s SQL rejects', + async phase => { + const pool = {} + const error = new Error(`rollback-permit ${phase} SQL failed`) + const helper = vi.fn().mockResolvedValue(undefined) + const query = vi.fn() + if (phase === 'termination') { + for (let i = 0; i < 50; i += 1) query.mockResolvedValueOnce({ rows: [{ count: '1' }] }) + } else if (phase === 'DROP') { + query.mockResolvedValueOnce({ rows: [{ count: '0' }] }) + } + query.mockRejectedValueOnce(error) + const adminPool = { end: vi.fn().mockResolvedValue(undefined), query } + const hook = afterAllCallback(file, { + pool, + adminPool, + endPoolAndWaitForClients: helper, + setImmediate, + }) + + await expect(hook()).rejects.toBe(error) + + expect(helper).toHaveBeenCalledExactlyOnceWith(pool) + expect(query).toHaveBeenCalledTimes(phase === 'probe' ? 1 : phase === 'termination' ? 51 : 2) + expect(query).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('SELECT COUNT(*)::text AS count'), + ['teardown_contract'] + ) + if (phase === 'termination') { + expect(query).toHaveBeenLastCalledWith( + expect.stringContaining('SELECT pg_terminate_backend(pid)'), + ['teardown_contract'] + ) + } else if (phase === 'DROP') { + expect(query).toHaveBeenLastCalledWith('DROP DATABASE IF EXISTS "teardown_contract"') + } + expect(adminPool.end).toHaveBeenCalledExactlyOnceWith() + } + ) +}) + +describe('gfsReaderRole.realPostgres.integration.test.ts teardown contract', () => { + const file = 'gfsReaderRole.realPostgres.integration.test.ts' + + // Only collaborator markers are constructed here. Role/SQL/helper order is + // supplied by the real source callback, never implemented by this fixture. + function readerTeardown() { + const readerPool = { identity: 'reader' } + const writerPool = { identity: 'writer' } + const pool = { query: vi.fn().mockResolvedValue(undefined) } + const helper = vi.fn().mockResolvedValue(undefined) + const adminPool = { + query: vi.fn().mockResolvedValue(undefined), + end: vi.fn().mockResolvedValue(undefined), + } + const collaborators = { + readerPool, + writerPool, + pool, + adminPool, + inheritedRole: 'teardown_inherited_role', + endPoolAndWaitForClients: helper, + } + return { readerPool, writerPool, pool, helper, adminPool, collaborators } + } + + it.each(['reader', 'writer', 'core'] as const)( + 'closes the admin pool if the %s helper rejects', + async phase => { + const fixture = readerTeardown() + const error = new Error(`${phase} client-close helper failed`) + const events: string[] = [] + const orderedPools = [fixture.readerPool, fixture.writerPool, fixture.pool] + const failedIndex = phase === 'reader' ? 0 : phase === 'writer' ? 1 : 2 + fixture.helper.mockImplementation(async candidate => { + if (candidate === orderedPools[failedIndex]) { + events.push('helper-failed') + throw error + } + }) + fixture.pool.query.mockImplementation(async () => { + events.push('role-cleanup') + }) + fixture.adminPool.end.mockImplementation(async () => { + events.push('admin-closed') + }) + const hook = afterAllCallback(file, fixture.collaborators) + + await expect(hook()).rejects.toBe(error) + + expect(fixture.helper).toHaveBeenCalledTimes(failedIndex + 1) + for (let i = 0; i <= failedIndex; i += 1) + expect(fixture.helper.mock.calls[i]?.[0]).toBe(orderedPools[i]) + expect(fixture.pool.query).toHaveBeenCalledTimes(phase === 'core' ? 3 : 0) + expect(fixture.adminPool.query).not.toHaveBeenCalled() + expect(fixture.adminPool.end).toHaveBeenCalledExactlyOnceWith() + expect(events.slice(events.indexOf('helper-failed') + 1)).toEqual(['admin-closed']) + } + ) + + it('preserves role isolation and helper/termination/DROP ordering', async () => { + const fixture = readerTeardown() + const events: string[] = [] + fixture.helper.mockImplementation(async candidate => { + events.push( + candidate === fixture.readerPool + ? 'reader-closed' + : candidate === fixture.writerPool + ? 'writer-closed' + : 'core-closed' + ) + }) + fixture.pool.query.mockImplementation(async () => { + events.push('role-cleanup') + }) + fixture.adminPool.query + .mockImplementationOnce(async () => { + events.push('terminate') + }) + .mockImplementationOnce(async () => { + events.push('drop') + }) + fixture.adminPool.end.mockImplementation(async () => { + events.push('admin-closed') + }) + const hook = afterAllCallback(file, fixture.collaborators) + + await expect(hook()).resolves.toBeUndefined() + + expect(fixture.helper).toHaveBeenCalledTimes(3) + expect(fixture.helper.mock.calls[0]?.[0]).toBe(fixture.readerPool) + expect(fixture.helper.mock.calls[1]?.[0]).toBe(fixture.writerPool) + expect(fixture.helper.mock.calls[2]?.[0]).toBe(fixture.pool) + expect(fixture.pool.query).toHaveBeenCalledTimes(3) + expect(fixture.pool.query).toHaveBeenNthCalledWith( + 1, + 'ALTER ROLE gfs_controller NOLOGIN NOINHERIT' + ) + expect(fixture.pool.query).toHaveBeenNthCalledWith( + 2, + 'ALTER ROLE gfs_controller_reader NOLOGIN NOINHERIT' + ) + expect(fixture.pool.query).toHaveBeenNthCalledWith( + 3, + 'DROP ROLE IF EXISTS "teardown_inherited_role"' + ) + expect(fixture.adminPool.query).toHaveBeenCalledTimes(2) + expect(fixture.adminPool.query).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('SELECT pg_terminate_backend(pid)'), + ['teardown_contract'] + ) + expect(fixture.adminPool.query.mock.calls[0]?.[0]).toContain('pid <> pg_backend_pid()') + expect(fixture.adminPool.query).toHaveBeenNthCalledWith( + 2, + 'DROP DATABASE IF EXISTS "teardown_contract"' + ) + expect(fixture.adminPool.end).toHaveBeenCalledExactlyOnceWith() + expect(events).toEqual([ + 'reader-closed', + 'writer-closed', + 'role-cleanup', + 'role-cleanup', + 'role-cleanup', + 'core-closed', + 'terminate', + 'drop', + 'admin-closed', + ]) + }) + + it('closes every pool and attempts role isolation without an admin pool', async () => { + const fixture = readerTeardown() + const hook = afterAllCallback(file, { ...fixture.collaborators, adminPool: undefined }) + + await expect(hook()).resolves.toBeUndefined() + + expect(fixture.helper).toHaveBeenCalledTimes(3) + expect(fixture.helper.mock.calls[0]?.[0]).toBe(fixture.readerPool) + expect(fixture.helper.mock.calls[1]?.[0]).toBe(fixture.writerPool) + expect(fixture.helper.mock.calls[2]?.[0]).toBe(fixture.pool) + expect(fixture.pool.query).toHaveBeenCalledTimes(3) + expect(fixture.adminPool.query).not.toHaveBeenCalled() + expect(fixture.adminPool.end).not.toHaveBeenCalled() + }) + + it.each(['termination', 'DROP'] as const)( + 'closes the admin pool when reader cleanup %s SQL rejects', + async phase => { + const fixture = readerTeardown() + const error = new Error(`reader cleanup ${phase} SQL failed`) + if (phase === 'DROP') fixture.adminPool.query.mockResolvedValueOnce(undefined) + fixture.adminPool.query.mockRejectedValueOnce(error) + const hook = afterAllCallback(file, fixture.collaborators) + + await expect(hook()).rejects.toBe(error) + + expect(fixture.helper).toHaveBeenCalledTimes(3) + expect(fixture.pool.query).toHaveBeenCalledTimes(3) + expect(fixture.adminPool.query).toHaveBeenCalledTimes(phase === 'termination' ? 1 : 2) + expect(fixture.adminPool.query).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('SELECT pg_terminate_backend(pid)'), + ['teardown_contract'] + ) + if (phase === 'DROP') + expect(fixture.adminPool.query).toHaveBeenLastCalledWith( + 'DROP DATABASE IF EXISTS "teardown_contract"' + ) + expect(fixture.adminPool.end).toHaveBeenCalledExactlyOnceWith() + } + ) + + it('preserves the existing best-effort role cleanup while still attempting every isolation query', async () => { + const fixture = readerTeardown() + fixture.pool.query.mockRejectedValue(new Error('role cleanup unavailable')) + const hook = afterAllCallback(file, fixture.collaborators) + + await expect(hook()).resolves.toBeUndefined() + + expect(fixture.pool.query).toHaveBeenCalledTimes(3) + expect(fixture.pool.query).toHaveBeenNthCalledWith( + 1, + 'ALTER ROLE gfs_controller NOLOGIN NOINHERIT' + ) + expect(fixture.pool.query).toHaveBeenNthCalledWith( + 2, + 'ALTER ROLE gfs_controller_reader NOLOGIN NOINHERIT' + ) + expect(fixture.pool.query).toHaveBeenNthCalledWith( + 3, + 'DROP ROLE IF EXISTS "teardown_inherited_role"' + ) + expect(fixture.helper).toHaveBeenCalledTimes(3) + expect(fixture.adminPool.query).toHaveBeenCalledTimes(2) + expect(fixture.adminPool.end).toHaveBeenCalledExactlyOnceWith() + }) +}) + +describe('actual spy-restoration failure cleanup', () => { + it.each([ + { file: suites[0]!, spy: 'querySpy' }, + { file: suites[0]!, spy: 'connectSpy' }, + { file: suites[1]!, spy: 'querySpy' }, + { file: suites[1]!, spy: 'connectSpy' }, + { file: suites[2]!, spy: 'corePoolConnectSpy' }, + ])('$file closes the admin pool when $spy restoration throws', async ({ file, spy }) => { + const error = new Error('spy restoration failed') + const restorations = { + querySpy: { mockRestore: vi.fn() }, + connectSpy: { mockRestore: vi.fn() }, + corePoolConnectSpy: { mockRestore: vi.fn() }, + } + restorations[spy as keyof typeof restorations].mockRestore.mockImplementation(() => { + throw error + }) + const helper = vi.fn().mockResolvedValue(undefined) + const wait = vi.fn() + const adminPool = { end: vi.fn().mockResolvedValue(undefined), query: vi.fn() } + const hook = afterAllCallback(file, { + ...restorations, + testPool: {}, + adminPool, + endPoolAndWaitForClients: helper, + waitForDatabaseConnectionsToClose: wait, + }) + + await expect(hook()).rejects.toBe(error) + + expect( + restorations[spy as keyof typeof restorations].mockRestore + ).toHaveBeenCalledExactlyOnceWith() + expect(helper).not.toHaveBeenCalled() + expect(wait).not.toHaveBeenCalled() + expect(adminPool.query).not.toHaveBeenCalled() + expect(adminPool.end).toHaveBeenCalledExactlyOnceWith() + }) +}) + +describe('db.codexSubscriptionConnection.realPostgres.integration.test.ts teardown contract', () => { + const file = 'db.codexSubscriptionConnection.realPostgres.integration.test.ts' + + it('closes the admin pool if the existing plain pool end rejects, without dropping', async () => { + const error = new Error('Codex pool end failed') + const pool = { end: vi.fn().mockRejectedValueOnce(error) } + const adminPool = { query: vi.fn(), end: vi.fn().mockResolvedValue(undefined) } + const hook = afterAllCallback(file, { pool, adminPool }) + + await expect(hook()).rejects.toBe(error) + + expect(pool.end).toHaveBeenCalledExactlyOnceWith() + expect(adminPool.query).not.toHaveBeenCalled() + expect(adminPool.end).toHaveBeenCalledExactlyOnceWith() + }) + + it('preserves the intentional DROP recovery and still closes the admin pool', async () => { + const pool = { end: vi.fn().mockResolvedValue(undefined) } + const adminPool = { + query: vi.fn().mockRejectedValueOnce(new Error('DROP failed')), + end: vi.fn().mockResolvedValue(undefined), + } + const hook = afterAllCallback(file, { pool, adminPool }) + + await expect(hook()).resolves.toBeUndefined() + + expect(pool.end).toHaveBeenCalledExactlyOnceWith() + expect(adminPool.query).toHaveBeenCalledExactlyOnceWith( + 'DROP DATABASE IF EXISTS "teardown_contract"' + ) + expect(adminPool.end).toHaveBeenCalledExactlyOnceWith() + }) + + it('drops and closes the admin pool when no application pool was created', async () => { + const adminPool = { + query: vi.fn().mockResolvedValue(undefined), + end: vi.fn().mockResolvedValue(undefined), + } + const hook = afterAllCallback(file, { pool: undefined, adminPool }) + + await expect(hook()).resolves.toBeUndefined() + + expect(adminPool.query).toHaveBeenCalledExactlyOnceWith( + 'DROP DATABASE IF EXISTS "teardown_contract"' + ) + expect(adminPool.end).toHaveBeenCalledExactlyOnceWith() + }) + + it('still ends the application pool without an admin pool', async () => { + const pool = { end: vi.fn().mockResolvedValue(undefined) } + const hook = afterAllCallback(file, { pool, adminPool: undefined }) + + await expect(hook()).resolves.toBeUndefined() + + expect(pool.end).toHaveBeenCalledExactlyOnceWith() + }) +}) + +describe('routes.externalGfsRateLimitFailClosed.realPostgres.integration.test.ts teardown contract', () => { + const file = 'routes.externalGfsRateLimitFailClosed.realPostgres.integration.test.ts' + function routeTeardown() { + // process is a local collaborator: these callbacks never alter the real + // test runner's environment or execute the original module/beforeAll. + const env = { RESTORE_PRESENT: 'temporary', RESTORE_ABSENT: 'temporary' } as Record< + string, + string + > + const previousEnv = new Map([ + ['RESTORE_PRESENT', 'original'], + ['RESTORE_ABSENT', undefined], + ]) + const corePool = { identity: 'core' } + const limiterPool = { identity: 'limiter' } + const helper = vi.fn().mockResolvedValue(undefined) + const adminPool = { + query: vi.fn().mockResolvedValue(undefined), + end: vi.fn().mockResolvedValue(undefined), + } + const collaborators = { + process: { env }, + envKeys: [...previousEnv.keys()], + previousEnv, + corePool, + limiterPool, + adminPool, + endPoolAndWaitForClients: helper, + } + return { env, previousEnv, corePool, limiterPool, helper, adminPool, collaborators } + } + + it('restores the environment and preserves existing helper recovery before SQL', async () => { + const fixture = routeTeardown() + fixture.helper.mockRejectedValue(new Error('caught client-close failure')) + const hook = afterAllCallback(file, fixture.collaborators) + + await expect(hook()).resolves.toBeUndefined() + + expect(fixture.env).toEqual({ RESTORE_PRESENT: 'original' }) + expect(fixture.helper).toHaveBeenCalledTimes(2) + expect(fixture.helper.mock.calls[0]?.[0]).toBe(fixture.corePool) + expect(fixture.helper.mock.calls[1]?.[0]).toBe(fixture.limiterPool) + expect(fixture.adminPool.query).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('SELECT pg_terminate_backend(pid)'), + ['teardown_contract'] + ) + expect(fixture.adminPool.query).toHaveBeenNthCalledWith( + 2, + 'DROP DATABASE IF EXISTS "teardown_contract"' + ) + expect(fixture.adminPool.end).toHaveBeenCalledExactlyOnceWith() + }) + + it.each(['termination', 'DROP'] as const)( + 'restores the environment and closes admin on %s rejection', + async phase => { + const fixture = routeTeardown() + const error = new Error(`route ${phase} failed`) + if (phase === 'DROP') fixture.adminPool.query.mockResolvedValueOnce(undefined) + fixture.adminPool.query.mockRejectedValueOnce(error) + const hook = afterAllCallback(file, fixture.collaborators) + + await expect(hook()).rejects.toBe(error) + + expect(fixture.env).toEqual({ RESTORE_PRESENT: 'original' }) + expect(fixture.helper).toHaveBeenCalledTimes(2) + expect(fixture.adminPool.query).toHaveBeenCalledTimes(phase === 'termination' ? 1 : 2) + expect(fixture.adminPool.end).toHaveBeenCalledExactlyOnceWith() + } + ) + + it('closes admin and performs no helper/SQL work if environment restoration throws', async () => { + const fixture = routeTeardown() + const error = new Error('environment restoration failed') + vi.spyOn(fixture.previousEnv, 'get').mockImplementation(() => { + throw error + }) + const hook = afterAllCallback(file, fixture.collaborators) + + await expect(hook()).rejects.toBe(error) + + expect(fixture.helper).not.toHaveBeenCalled() + expect(fixture.adminPool.query).not.toHaveBeenCalled() + expect(fixture.adminPool.end).toHaveBeenCalledExactlyOnceWith() + }) + + it('restores the environment and closes both application pools without admin', async () => { + const fixture = routeTeardown() + fixture.helper.mockRejectedValue(new Error('caught client-close failure')) + const hook = afterAllCallback(file, { ...fixture.collaborators, adminPool: undefined }) + + await expect(hook()).resolves.toBeUndefined() + + expect(fixture.env).toEqual({ RESTORE_PRESENT: 'original' }) + expect(fixture.helper).toHaveBeenCalledTimes(2) + expect(fixture.adminPool.query).not.toHaveBeenCalled() + expect(fixture.adminPool.end).not.toHaveBeenCalled() + }) +}) diff --git a/control-api/test/realPostgresTeardown.guard.test.ts b/control-api/test/realPostgresTeardown.guard.test.ts new file mode 100644 index 0000000000..904b243370 --- /dev/null +++ b/control-api/test/realPostgresTeardown.guard.test.ts @@ -0,0 +1,309 @@ +import { describe, expect, it } from 'vitest' +import { readFileSync, readdirSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import * as ts from 'typescript' + +// A real-Postgres suite that ends a pg pool with a plain `.end()` and later runs +// pg_terminate_backend against the same database can reach a client that is +// still closing: pg-pool's end() resolves before each client's connection has +// closed, and the 57P01 the terminate sends that client is re-emitted on the +// pool as an unhandled error. Such a pool must be ended through +// endPoolAndWaitForClients. The only pool a suite may still end with a plain +// `.end()` is the one that runs the terminate: its own connection is excluded +// by `pid <> pg_backend_pid()` and nothing terminates it afterwards. +// +// The scan uses TypeScript's local bindings, without loading dependencies or +// following imports. It recognizes named pg Pool imports, typed/constructed +// local pools, aliases, and destructured methods. Formatting, computed 'end' +// access, and callback arguments do not change a pool's identity. HTTP/stream +// ends are outside this rule. It does not resolve dynamic receivers, factory +// returns without a Pool type, or cross-file/data-flow aliases. Terminate SQL +// must contain a literal pg_terminate_backend in a direct query call. +// The helper's retained error observer is proven behaviorally with a real pg +// Pool and its removed-client forwarding listener in the helper test suite. + +const testDir = dirname(fileURLToPath(import.meta.url)) +const REAL_POSTGRES_SUITE = /\.realPostgres.*\.test\.ts$/ +type PoolBinding = ts.Symbol | ts.NewExpression + +interface ScannedSuite { + file: string + terminators: string[] + poolEnds: number + terminateQueries: number + parseErrors: string[] + unresolvedTerminators: string[] + violations: string[] +} + +function unwrap(expression: ts.Expression): ts.Expression { + while ( + ts.isParenthesizedExpression(expression) || + ts.isAsExpression(expression) || + ts.isTypeAssertionExpression(expression) || + ts.isNonNullExpression(expression) || + ts.isSatisfiesExpression(expression) + ) + expression = expression.expression + return expression +} + +function memberReceiver(expression: ts.Expression, name: string): ts.Expression | undefined { + expression = unwrap(expression) + if (ts.isPropertyAccessExpression(expression) && expression.name.text === name) { + return expression.expression + } + if ( + ts.isElementAccessExpression(expression) && + ts.isStringLiteralLike(expression.argumentExpression) && + expression.argumentExpression.text === name + ) + return expression.expression + return undefined +} + +function scanSource(file: string, source: string): ScannedSuite { + const ast = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) + const options: ts.CompilerOptions = { noLib: true, noResolve: true } + const host: ts.CompilerHost = { + ...ts.createCompilerHost(options), + getSourceFile: name => (name === file ? ast : undefined), + readFile: name => (name === file ? source : undefined), + fileExists: name => name === file, + } + const program = ts.createProgram([file], options, host) + const checker = program.getTypeChecker() + const importedPool = (node: ts.Node): boolean => + checker.getSymbolAtLocation(node)?.declarations?.some(declaration => { + if ( + !ts.isImportSpecifier(declaration) || + (declaration.propertyName ?? declaration.name).text !== 'Pool' + ) + return false + const importedFrom = declaration.parent.parent.parent + return ( + ts.isImportDeclaration(importedFrom) && + ts.isStringLiteral(importedFrom.moduleSpecifier) && + importedFrom.moduleSpecifier.text === 'pg' + ) + }) ?? false + const poolType = (type: ts.TypeNode): boolean => + ts.isTypeReferenceNode(type) + ? importedPool(type.typeName) + : ts.isUnionTypeNode(type) && type.types.some(poolType) + + function poolBinding( + expression: ts.Expression, + seen = new Set() + ): PoolBinding | undefined { + expression = unwrap(expression) + if (ts.isNewExpression(expression) && importedPool(expression.expression)) return expression + if (!ts.isIdentifier(expression)) return undefined + const symbol = checker.getSymbolAtLocation(expression) + if (!symbol || seen.has(symbol)) return undefined + seen.add(symbol) + const declaration = symbol.valueDeclaration + if (!declaration || (!ts.isVariableDeclaration(declaration) && !ts.isParameter(declaration))) + return undefined + if (declaration.initializer) { + const initial = unwrap(declaration.initializer) + if (ts.isNewExpression(initial) && importedPool(initial.expression)) return symbol + const alias = poolBinding(initial, seen) + if (alias) return alias + } + return declaration.type && poolType(declaration.type) ? symbol : undefined + } + + function methodPool( + expression: ts.Expression, + method: string, + seen = new Set() + ): PoolBinding | undefined { + expression = unwrap(expression) + const receiver = memberReceiver(expression, method) + if (receiver) return poolBinding(receiver) + if (!ts.isIdentifier(expression)) return undefined + const symbol = checker.getSymbolAtLocation(expression) + if (!symbol || seen.has(symbol)) return undefined + seen.add(symbol) + const declaration = symbol.valueDeclaration + if (declaration && ts.isVariableDeclaration(declaration) && declaration.initializer) { + return methodPool(declaration.initializer, method, seen) + } + if ( + declaration && + ts.isBindingElement(declaration) && + ts.isObjectBindingPattern(declaration.parent) + ) { + const property = declaration.propertyName ?? declaration.name + const variable = declaration.parent.parent + if ( + (ts.isIdentifier(property) || ts.isStringLiteralLike(property)) && + property.text === method && + ts.isVariableDeclaration(variable) && + variable.initializer + ) + return poolBinding(variable.initializer) + } + return undefined + } + + const calls: ts.CallExpression[] = [] + function visit(node: ts.Node): void { + if (ts.isCallExpression(node)) calls.push(node) + ts.forEachChild(node, visit) + } + visit(ast) + const location = (node: ts.Node): string => + `${file}:${ast.getLineAndCharacterOfPosition(node.getStart(ast)).line + 1}` + const bindingName = (binding: PoolBinding): string => + 'getName' in binding ? binding.getName() : '(new Pool)' + const terminationCalls = calls.filter( + call => + memberReceiver(call.expression, 'query') && + call.arguments.some(arg => + ts.isStringLiteralLike(arg) + ? arg.text.includes('pg_terminate_backend') + : ts.isTemplateExpression(arg) && + [arg.head, ...arg.templateSpans.map(span => span.literal)].some(part => + part.text.includes('pg_terminate_backend') + ) + ) + ) + const terminators = new Set() + const unresolvedTerminators: string[] = [] + for (const call of terminationCalls) { + const pool = methodPool(call.expression, 'query') + if (pool) terminators.add(pool) + else unresolvedTerminators.push(location(call)) + } + const ends = calls.flatMap(call => { + const pool = methodPool(call.expression, 'end') + return pool ? [{ call, pool }] : [] + }) + return { + file, + terminators: [...terminators].map(bindingName), + terminateQueries: terminationCalls.length, + poolEnds: ends.length, + parseErrors: program + .getSyntacticDiagnostics(ast) + .map(diagnostic => ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')), + unresolvedTerminators, + violations: ends + .filter(({ pool }) => !terminators.has(pool)) + .map(({ call, pool }) => `${location(call)} ${bindingName(pool)}.end()`), + } +} + +function scanSuites(): ScannedSuite[] { + return readdirSync(testDir) + .filter(file => REAL_POSTGRES_SUITE.test(file)) + .sort() + .flatMap(file => { + const source = readFileSync(join(testDir, file), 'utf8') + const suite = scanSource(file, source) + return suite.terminateQueries > 0 || suite.parseErrors.length > 0 ? [suite] : [] + }) +} + +describe('real-Postgres teardown guard', () => { + it('ends every pool other than the terminating one through endPoolAndWaitForClients', () => { + const suites = scanSuites() + + // Witnesses: the scan reached suites that terminate backends, in each one + // it identified the pool that runs the terminate, and it matched plain + // `.end()` calls at all (the terminating pools' own). Without them, an + // empty violation list would say nothing. + expect(suites.length).toBeGreaterThan(0) + expect(suites.filter(suite => suite.terminators.length === 0).map(suite => suite.file)).toEqual( + [] + ) + expect(suites.flatMap(suite => suite.parseErrors)).toEqual([]) + expect(suites.flatMap(suite => suite.unresolvedTerminators)).toEqual([]) + expect(suites.reduce((sum, suite) => sum + suite.poolEnds, 0)).toBeGreaterThan(0) + + expect(suites.flatMap(suite => suite.violations)).toEqual([]) + }) + + it('reports optional-chained, cast and chained ends, and allows the terminating pool', () => { + const source = [ + "import { Pool } from 'pg'", + 'let adminPool: Pool, dbPool: Pool, pool: Pool, corePool: Pool', + 'await adminPool.query(`SELECT pg_terminate_backend(pid) FROM pg_stat_activity`)', + 'await dbPool.end()', + 'await pool?.end()', + 'await (dbPool as Pool).end()', + 'await corePool?.end().catch(() => undefined)', + 'await adminPool?.end()', + 'await adminPool.end()', + ].join('\n') + + const suite = scanSource('fixture.realPostgres.test.ts', source) + + // Witness: the terminate query named the pool that is allowed a plain end. + expect(suite.terminators).toEqual(['adminPool']) + expect(suite.violations).toEqual([ + 'fixture.realPostgres.test.ts:4 dbPool.end()', + 'fixture.realPostgres.test.ts:5 pool.end()', + 'fixture.realPostgres.test.ts:6 dbPool.end()', + 'fixture.realPostgres.test.ts:7 corePool.end()', + ]) + }) + + it.each([ + ['a split receiver', 'await appPool\n .end()'], + ['split arguments', 'await appPool.end(\n)'], + ['a computed method', "await appPool['end']()"], + ['a callback', 'appPool.end(() => {})'], + ['an optional callback', 'appPool?.end(() => {})'], + ['a destructured method', 'const { end } = appPool; end()'], + ['a renamed destructured method', 'const { end: close } = appPool; close()'], + ['a method alias', 'const close = appPool.end; close()'], + ['a shadowed administrative name', '{ const adminPool = appPool; await adminPool.end() }'], + ])('rejects a pool end through %s', (_name, end) => { + const source = [ + "import { Pool } from 'pg'", + 'let adminPool: Pool', + 'let appPool: Pool', + 'await adminPool.query(`SELECT pg_terminate_backend(pid) FROM pg_stat_activity`)', + end, + ].join('\n') + + expect(scanSource('fixture.realPostgres.test.ts', source).violations).toHaveLength(1) + }) + + it('allows aliases of the terminating pool and real HTTP request ends', () => { + const source = [ + "import { Pool as PgPool } from 'pg'", + "import * as http from 'node:http'", + 'const terminator = new PgPool()', + 'await terminator\n .query(`SELECT pg_terminate_backend(pid) FROM pg_stat_activity`)', + 'const administrativeAlias = terminator', + 'await administrativeAlias.end(() => {})', + 'const req = http.request({})', + 'req.end()', + "req['end']('body')", + 'req.end(() => {})', + ].join('\n') + + const suite = scanSource('fixture.realPostgres.test.ts', source) + expect(suite.parseErrors).toEqual([]) + expect(suite.terminators).toEqual(['terminator']) + expect(suite.poolEnds).toBe(1) + expect(suite.violations).toEqual([]) + }) + + it('reports parse errors and an unresolved terminating receiver instead of passing vacuously', () => { + const invalid = scanSource('fixture.realPostgres.test.ts', 'const pool = new Pool(') + expect(invalid.parseErrors.length).toBeGreaterThan(0) + const unresolved = scanSource( + 'fixture.realPostgres.test.ts', + 'pool.query(`SELECT pg_terminate_backend(pid)`)' + ) + expect(unresolved.terminateQueries).toBe(1) + expect(unresolved.unresolvedTerminators).toEqual(['fixture.realPostgres.test.ts:1']) + expect(unresolved.terminators).toEqual([]) + }) +}) diff --git a/control-api/test/routes.adminRemoteMcp.test.ts b/control-api/test/routes.adminRemoteMcp.test.ts index c6439a4dac..a0df75838a 100644 --- a/control-api/test/routes.adminRemoteMcp.test.ts +++ b/control-api/test/routes.adminRemoteMcp.test.ts @@ -21,12 +21,14 @@ import { } from '../src/oauth/dynamicClientStore.js' import { decryptOAuthSecret, deriveOAuthEncryptionKey } from '../src/oauth/encryption.js' import { probeMcpTransport } from '../src/oauth/mcpTransportProbe.js' +import { remoteCallbackVariant } from '../src/oauth/remoteCallback.js' import { type AdminRemoteMcpDeps, createAdminRemoteMcpRouter, } from '../src/routes/admin/remoteMcp.js' import { K8sNotFoundError } from '../src/services/resourceService.js' import { + ATLASSIAN_V2_PILOT, DCR_BASIC_REGISTRATION_RESPONSE, DCR_CONFIDENTIAL_REGISTRATION_RESPONSE, DCR_PUBLIC_REGISTRATION_RESPONSE, @@ -92,10 +94,14 @@ const inconclusiveProbe = boundProbe(async () => ({ status: 503, headers: {}, bo // discovery client run against the 2026-09-25 Vercel probe fixtures. let vercelResult: DiscoveryResult -function makeApp(gateway: MockGateway, probe: typeof probeMcpTransport = genericProbe) { +function makeApp( + gateway: MockGateway, + probe: typeof probeMcpTransport = genericProbe, + deps: AdminRemoteMcpDeps = {} +) { const app = express() app.use(express.json()) - app.use(createAdminRemoteMcpRouter(gateway as unknown as K8sGateway, { probe })) + app.use(createAdminRemoteMcpRouter(gateway as unknown as K8sGateway, { probe, ...deps })) app.use( (err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(500).json({ error: err instanceof Error ? err.message : 'unknown' }) @@ -131,6 +137,20 @@ let rfc9207Result: DiscoveryResult // real producer (T1) by documented substitution of the Notion PRM's // `bearer_methods_supported` to `["body"]` — the only field changed. let bodyBearerResult: DiscoveryResult +// Real Linear DiscoveryResult (T1): the 2026-09-29 Linear probe advertises CIMD + `none` +// AND RFC 9207, so it still resolves to CIMD on the shared callback. +let linearResult: DiscoveryResult +// An AS that genuinely offers NO registration endpoint, derived from the real producer +// (T1) by documented subtraction: the real Linear pilot minus `registration_endpoint`. +// Linear keeps CIMD + RFC 9207, so discovery resolves to `cimd`, never `dcr`. +let noDcrResult: DiscoveryResult + +/** Documented T1 subtraction: drop `registration_endpoint` from a pilot's AS metadata. */ +function withoutRegistrationEndpoint(pilot: T): T { + const as = JSON.parse(pilot.as.json) + delete as.registration_endpoint + return { ...pilot, as: { ...pilot.as, json: JSON.stringify(as) } } +} /** * Documented T1 substitution: add `authorization_response_iss_parameter_supported` @@ -190,6 +210,22 @@ beforeAll(async () => { if (!bodyBearerOutcome.ok) throw new Error(`body-bearer fixture discovery failed: ${bodyBearerOutcome.error.kind}`) bodyBearerResult = bodyBearerOutcome.result + + const linearOutcome = await actual.discoverRemoteOAuth(PILOTS.linear.mcpUrl, { + transport: makeDiscoveryTransport(PILOTS.linear), + resolveDns: async () => ['93.184.216.34'], + }) + if (!linearOutcome.ok) + throw new Error(`linear fixture discovery failed: ${linearOutcome.error.kind}`) + linearResult = linearOutcome.result + + const noDcrOutcome = await actual.discoverRemoteOAuth(PILOTS.linear.mcpUrl, { + transport: makeDiscoveryTransport(withoutRegistrationEndpoint(PILOTS.linear)), + resolveDns: async () => ['93.184.216.34'], + }) + if (!noDcrOutcome.ok) + throw new Error(`no-DCR fixture discovery failed: ${noDcrOutcome.error.kind}`) + noDcrResult = noDcrOutcome.result }) beforeEach(() => { @@ -210,20 +246,41 @@ describe('POST /admin/mcp-servers/remote/discover (dry-run)', () => { expect(discoverRemoteOAuth).not.toHaveBeenCalled() }) - it('returns the "Detected" prefill for a CIMD server → 200', async () => { - mockDiscovery(notionResult) + it('returns the "Detected" prefill for a CIMD server (Linear: CIMD + RFC 9207) → 200', async () => { + expect(linearResult.registrationMode).toBe('cimd') + mockDiscovery(linearResult) const res = await request(makeApp(gatewayWithContext())) .post('/admin/mcp-servers/remote/discover') - .send({ baseUrl: 'https://mcp.notion.com/mcp' }) + .send({ baseUrl: 'https://mcp.linear.app/mcp' }) expect(res.status).toBe(200) expect(res.body.detected.registrationMode).toBe('cimd') - expect(res.body.detected.endpoints.token).toBe('https://mcp.notion.com/token') - expect(res.body.detected.resource).toBe('https://mcp.notion.com') + expect(res.body.detected.endpoints.token).toBe('https://mcp.linear.app/token') + expect(res.body.detected.resource).toBe('https://mcp.linear.app/mcp') + expect(res.body.detected.issForCallback).toBe('https://mcp.linear.app') expect(res.body.detected.quirks).toEqual({ bearerInBody: false, supportsRefresh: true }) // No DCR marker for a CIMD server. expect(res.body.detected.dcr).toBeUndefined() }) + it('CIMD-capable AS WITHOUT RFC 9207 (Notion) is detected as DCR, not CIMD → 200', async () => { + // Notion advertises CIMD + `none` but no `iss`: CIMD's single platform identity can + // only list the shared callback, so discovery falls back to its registration endpoint. + expect(notionResult.registrationMode).toBe('dcr') + mockDiscovery(notionResult) + const res = await request(makeApp(gatewayWithContext())) + .post('/admin/mcp-servers/remote/discover') + .send({ baseUrl: 'https://mcp.notion.com/mcp' }) + expect(res.status).toBe(200) + expect(res.body.detected.registrationMode).toBe('dcr') + expect(res.body.detected.issForCallback).toBeUndefined() + expect(res.body.detected.endpoints.token).toBe('https://mcp.notion.com/token') + expect(res.body.detected.dcr).toEqual({ + available: true, + clientMode: 'public', + supportsRefresh: true, + }) + }) + it('marks DCR mode available (C2) with resolved clientMode + supportsRefresh → 200', async () => { mockDiscovery({ ...notionResult, registrationMode: 'dcr' }) const res = await request(makeApp(gatewayWithContext())) @@ -345,39 +402,63 @@ describe('POST /admin/mcp-servers/remote (install saga)', () => { expect(ctx.spec.mcpServers).toContain('notion-remote') }) - // R3F-H1 (regression of R3-H2): the shared remote callback fails closed without a - // pinned issuer, so an install against an AS that does NOT advertise RFC 9207 would - // create a server that can never complete OAuth (user hits 400 at consent). The - // install must reject up front. `notionResult` is the REAL Notion discovery (no - // `authorization_response_iss_parameter_supported`), derived from the real producer — - // NOT hand-authored. Fails at parent a835d7130 (install proceeds, 201 + CR). - it('rejects a remote install against an AS without RFC 9207 → 422, no CR, no Secret', async () => { - // Self-check the T1 fixture: real Notion genuinely lacks the issuer pin. + // Before the per-server callback, an AS without RFC 9207 was refused outright (422 + // `issuer_binding_required`, R3F-H1): the shared callback has no mix-up defence without + // `iss`. Now such an install proceeds on a redirect URI of the server's own. A + // pre-registered client gets `/remote/` (no nonce: the client is + // confidential and its secret dies with the CR), returned so the operator can register + // it. `notionResult` is the REAL Notion discovery (no RFC 9207). + it('pre-registered against an AS without RFC 9207 → 201 on a per-server redirect URI', async () => { expect(notionResult.issForCallback).toBeUndefined() mockDiscovery(notionResult) const gw = gatewayWithContext('ctx-a') - const createResourceSpy = vi.spyOn(gw, 'createResource') - const createSecretSpy = vi.spyOn(gw, 'createSecret') + const { db } = makeInMemoryDynamicClientsDb() + + const res = await request(makeApp(gw, genericProbe, { db })) + .post('/admin/mcp-servers/remote') + .send({ + serverName: 'no-rfc9207-remote', + contextRef: 'ctx-a', + baseUrl: 'https://mcp.notion.com/mcp', + mode: 'pre-registered', + clientId: 'client-abc', + clientSecret: 'fixture-client-secret', + }) + + expect(res.status).toBe(201) + expect(res.body.callbackVariant).toBe('per-server') + expect(res.body.redirectUri).toBe( + 'https://control.example.com/api/v1/oauth-callback/remote/no-rfc9207-remote' + ) + const cr = (await gw.getResource('mcpservers', 'no-rfc9207-remote', NS)) as { + spec: { oauth: Record } + } + expect(cr.spec.oauth.issForCallback).toBeUndefined() + expect(cr.spec.oauth.id).toBe('client-abc') + const ctx = (await gw.getResource('contexts', 'ctx-a', NS)) as { + spec: { mcpServers?: string[] } + } + expect(ctx.spec.mcpServers).toContain('no-rfc9207-remote') + }) + // The CIMD identity lists only the shared callback, so a CIMD install on an AS without + // RFC 9207 stays refused. Discovery never resolves `cimd` there; this mutant of its + // real output (Notion, no RFC 9207, mode forced to `cimd`) stands for a regression in + // mode selection, which the defensive gate must still stop before any write. + it('CIMD against an AS without RFC 9207 (mode-selection regression) → 422, no CR', async () => { + expect(notionResult.issForCallback).toBeUndefined() + mockDiscovery({ ...notionResult, registrationMode: 'cimd' }) + const gw = gatewayWithContext('ctx-a') + const createResourceSpy = vi.spyOn(gw, 'createResource') const res = await request(makeApp(gw)).post('/admin/mcp-servers/remote').send({ - serverName: 'no-rfc9207-remote', + serverName: 'cimd-no-iss', contextRef: 'ctx-a', baseUrl: 'https://mcp.notion.com/mcp', - mode: 'pre-registered', - clientId: 'client-abc', - clientSecret: 'shhh-secret', + mode: 'cimd', }) - - // Observable outcome (T4): rejected before any write. expect(res.status).toBe(422) expect(res.body.error).toBe('issuer_binding_required') expect(createResourceSpy).not.toHaveBeenCalled() - expect(createSecretSpy).not.toHaveBeenCalled() - await expect(gw.getResource('mcpservers', 'no-rfc9207-remote', NS)).rejects.toThrow() - const ctx = (await gw.getResource('contexts', 'ctx-a', NS)) as { - spec: { mcpServers?: string[] } - } - expect(ctx.spec.mcpServers ?? []).not.toContain('no-rfc9207-remote') }) it('body-bearer resource: rejects at admission and creates no CR (the runtime cannot honor bearerInBody)', async () => { @@ -447,13 +528,15 @@ describe('POST /admin/mcp-servers/remote (install saga)', () => { }) it('rejects mode "dcr" when server-side discovery resolves a non-DCR AS → 400 mode_unsupported', async () => { - // The AS actually offers CIMD (not DCR); the operator's "dcr" request loses. - mockDiscovery(notionResult) + // The AS offers CIMD but no registration endpoint; the operator's "dcr" request loses. + expect(noDcrResult.endpoints.registration).toBeUndefined() + expect(noDcrResult.registrationMode).toBe('cimd') + mockDiscovery(noDcrResult) const gw = gatewayWithContext('ctx-a') const res = await request(makeApp(gw)).post('/admin/mcp-servers/remote').send({ serverName: 'stripe-remote', contextRef: 'ctx-a', - baseUrl: 'https://mcp.notion.com/mcp', + baseUrl: 'https://mcp.linear.app/mcp', mode: 'dcr', }) expect(res.status).toBe(400) @@ -705,6 +788,8 @@ describe('POST /admin/mcp-servers/remote — DCR install saga (C2)', () => { const PUBLIC_IP = async () => ['93.184.216.34'] // The DCR request needs a configured public callback origin for redirect_uris. let savedCallbackBaseUrl: string + // Origin the live Vercel DCR captures were registered under (their echoed redirect_uris). + const CAPTURED_DCR_ORIGIN = 'http://127.0.0.1:8090' // Real DCR DiscoveryResults, DERIVED FROM THE REAL PRODUCER by documented // subtraction (DEC-19): the actual discovery client is run against the Notion @@ -774,15 +859,16 @@ describe('POST /admin/mcp-servers/remote — DCR install saga (C2)', () => { return app } - // R3F-H1: a DCR AS without RFC 9207 must be rejected BEFORE the DCR registration - // POST (saga step 0), so no throwaway client is minted at the AS, no dynamic_clients - // row is written, and no CR is created. Fails at parent a835d7130 (install registers - // + persists + creates the CR). `dcrNoIssResult` is producer-derived (no iss), T1. - it('rejects a DCR install against an AS without RFC 9207 → 422, no DCR registration, no row, no CR', async () => { + // Before the per-server callback, a DCR AS without RFC 9207 was refused before + // registering (422, R3F-H1). Now the client registers a redirect URI of this + // installation's own — `/remote//` — and the install succeeds. + // `dcrNoIssResult` is producer-derived (no iss), T1; the AS echoes what we POSTed. + it('DCR against an AS without RFC 9207 → 201, registers /remote//', async () => { vi.mocked(discoverRemoteOAuth).mockResolvedValue({ ok: true, result: dcrNoIssResult }) const { db, rows } = makeInMemoryDynamicClientsDb() const { transport, calls } = makeDcrTransport({ responseJson: JSON.stringify(DCR_PUBLIC_REGISTRATION_RESPONSE), + redirectUris: 'requested', }) const gw = gatewayWithContext('ctx-a') const res = await request( @@ -796,13 +882,17 @@ describe('POST /admin/mcp-servers/remote — DCR install saga (C2)', () => { mode: 'dcr', }) - // Observable outcome (T4): rejected before any AS registration or write. - expect(res.status).toBe(422) - expect(res.body.error).toBe('issuer_binding_required') - // No DCR POST to the AS, no persisted dynamic_clients row, no CR. - expect(calls).toHaveLength(0) - expect(rows.size).toBe(0) - await expect(gw.getResource('mcpservers', 'no-rfc9207-dcr', NS)).rejects.toThrow() + expect(res.status).toBe(201) + expect(res.body.callbackVariant).toBe('per-server') + const stored = [...rows.values()][0] + expect(res.body.redirectUri).toBe( + `https://control.example.com/api/v1/oauth-callback/remote/no-rfc9207-dcr/${stored.install_id}` + ) + // The one DCR POST registered exactly the URI the install reports. + const posts = calls.filter(c => c.method === 'POST') + expect(posts).toHaveLength(1) + expect(JSON.parse(posts[0].body ?? '{}').redirect_uris).toEqual([res.body.redirectUri]) + await expect(gw.getResource('mcpservers', 'no-rfc9207-dcr', NS)).resolves.toBeTruthy() }) // T3(a) — fails at parent 9e4677652: DCR install returns 400 dcr_not_available there. @@ -904,6 +994,10 @@ describe('POST /admin/mcp-servers/remote — DCR install saga (C2)', () => { // triggered the Vercel downgrade in production. vi.mocked(discoverRemoteOAuth).mockResolvedValue({ ok: true, result: dcrConfidentialResult }) const { db, rows } = makeInMemoryDynamicClientsDb() + // The captured body echoes the redirect URI registered under the dev origin it was + // captured with; the shared variant rejects an echo that differs from the request, + // so the install runs under that same origin. + config.oauthCallbackBaseUrl = CAPTURED_DCR_ORIGIN const { transport } = makeDcrTransport({ responseJson: DCR_VERCEL_DOWNGRADE_REGISTRATION_JSON, }) @@ -947,6 +1041,10 @@ describe('POST /admin/mcp-servers/remote — DCR install saga (C2)', () => { it('public DCR where the AS echoes a stray client_secret → 201 public, secret DISCARDED (not persisted)', async () => { vi.mocked(discoverRemoteOAuth).mockResolvedValue({ ok: true, result: dcrPublicResult }) const { db, rows } = makeInMemoryDynamicClientsDb() + // The captured body echoes the redirect URI registered under the dev origin it was + // captured with; the shared variant rejects an echo that differs from the request, + // so the install runs under that same origin. + config.oauthCallbackBaseUrl = CAPTURED_DCR_ORIGIN const { transport } = makeDcrTransport({ responseJson: DCR_PUBLIC_WITH_ECHOED_SECRET_REGISTRATION_JSON, }) @@ -1807,3 +1905,655 @@ describe('POST /admin/mcp-servers/remote — DCR install saga (C2)', () => { expect(deleteUrls).toEqual([MINTED_URI]) }) }) + +// ─── Per-server callback for an AS without RFC 9207 ───────────────────────── +describe('POST /admin/mcp-servers/remote — per-server callback (AS without RFC 9207)', () => { + const PUBLIC_IP = async () => ['93.184.216.34'] + const ORIGIN = 'https://control.example.com' + /** ORIGIN as a literal inside a RegExp (its dots must not match any character). */ + const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const CIMD_SELF_CLIENT_ID = `${ORIGIN}/api/v1/.well-known/evenfire-mcp-client` + const ATLASSIAN_REGISTRATION = + 'https://auth.atlassian.com/VCeDsk8ZHncYF1g234fKtc4lNipbBhu3/dcr/register' + const NONCE_RE = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' + let savedCallbackBaseUrl: string + + // All producer-derived (T1): the real discovery client over real probe fixtures, with + // documented substitutions only where named. + let atlassianResult: DiscoveryResult + // Notion minus CIMD, confidential, no RFC 9207 → per-server DCR-confidential. Its + // registration responses carry an RFC 7592 handle, so the cleanup DELETE is observable. + let dcrConfNoIssResult: DiscoveryResult + // Same AS with RFC 9207 → shared DCR-confidential. + let dcrConfSharedResult: DiscoveryResult + // Linear minus its registration endpoint and minus RFC 9207: CIMD-only without `iss` → + // `manual`, i.e. the operator must pre-register. + let cimdOnlyNoIssResult: DiscoveryResult + + function withoutRfc9207(pilot: T): T { + const as = JSON.parse(pilot.as.json) + delete as.authorization_response_iss_parameter_supported + return { ...pilot, as: { ...pilot.as, json: JSON.stringify(as) } } + } + + async function discoverPilot(pilot: Parameters[0]) { + const actual = await vi.importActual( + '../src/oauth/discovery.js' + ) + const outcome = await actual.discoverRemoteOAuth(pilot.mcpUrl, { + transport: makeDiscoveryTransport(pilot), + resolveDns: PUBLIC_IP, + }) + if (!outcome.ok) throw new Error(`fixture discovery failed: ${outcome.error.kind}`) + return outcome.result + } + + beforeAll(async () => { + atlassianResult = await discoverPilot(ATLASSIAN_V2_PILOT) + dcrConfNoIssResult = await discoverPilot(dcrPilot('confidential')) + dcrConfSharedResult = await discoverPilot(withRfc9207(dcrPilot('confidential'))) + cimdOnlyNoIssResult = await discoverPilot( + withoutRfc9207(withoutRegistrationEndpoint(PILOTS.linear)) + ) + }) + + beforeEach(() => { + vi.mocked(discoverRemoteOAuth).mockReset() + savedCallbackBaseUrl = config.oauthCallbackBaseUrl + config.oauthCallbackBaseUrl = ORIGIN + }) + afterEach(() => { + config.oauthCallbackBaseUrl = savedCallbackBaseUrl + }) + + function app(gw: MockGateway, deps: AdminRemoteMcpDeps) { + return makeApp(gw, genericProbe, deps) + } + + it('fixtures: Atlassian is CIMD+DCR without RFC 9207 on one site → dcr; the others as named', () => { + expect(atlassianResult.issForCallback).toBeUndefined() + expect(atlassianResult.as.client_id_metadata_document_supported).toBe(true) + expect(atlassianResult.registrationMode).toBe('dcr') + expect(dcrConfNoIssResult.issForCallback).toBeUndefined() + expect(dcrConfSharedResult.issForCallback).toBeTruthy() + expect(cimdOnlyNoIssResult.registrationMode).toBe('manual') + }) + + // I7 — the e2e origin of this change: Atlassian v2 returned 422 issuer_binding_required. + it('Atlassian (CIMD+DCR, no RFC 9207, same site) → 201 by DCR on /remote//', async () => { + mockDiscovery(atlassianResult) + const { db, rows } = makeInMemoryDynamicClientsDb() + const { transport, calls } = makeDcrTransport({ + registrationEndpoint: ATLASSIAN_REGISTRATION, + responseJson: JSON.stringify(DCR_PUBLIC_REGISTRATION_RESPONSE), + redirectUris: 'requested', + }) + const gw = gatewayWithContext('ctx-a') + const res = await request(app(gw, { db, dcr: { transport, resolveDns: PUBLIC_IP } })) + .post('/admin/mcp-servers/remote') + .send({ + serverName: 'atlassian', + contextRef: 'ctx-a', + baseUrl: 'https://mcp.atlassian.com/v2/mcp', + mode: 'dcr', + }) + + expect(res.status).toBe(201) + expect(res.body).toMatchObject({ + registrationMode: 'dcr', + clientMode: 'public', + callbackVariant: 'per-server', + }) + expect(res.body.redirectUri).toMatch( + new RegExp(`^${escapeRegExp(ORIGIN)}/api/v1/oauth-callback/remote/atlassian/${NONCE_RE}$`) + ) + // The nonce is this installation's install_id (what the callback will check). + const stored = [...rows.values()][0] + expect(res.body.redirectUri.endsWith(`/${stored.install_id}`)).toBe(true) + // I4 (partial): the DCR POST registered exactly the URI the 201 reports. + const post = calls.find(c => c.method === 'POST') + expect(JSON.parse(post?.body ?? '{}').redirect_uris).toEqual([res.body.redirectUri]) + + const cr = (await gw.getResource('mcpservers', 'atlassian', NS)) as { + spec: { oauth: Record } + } + expect(cr.spec.oauth.issForCallback).toBeUndefined() + expect(cr.spec.oauth.id).toBe(DCR_PUBLIC_REGISTRATION_RESPONSE.client_id) + }) + + // I15 — one derivation: the variant the install decided before registering equals the + // variant the runtime will derive from the CR the saga actually wrote. + const I15_CASES = [ + { label: 'shared CIMD (Linear)', mode: 'cimd', result: () => linearResult }, + { label: 'shared DCR', mode: 'dcr', result: () => dcrConfSharedResult }, + { label: 'per-server DCR', mode: 'dcr', result: () => dcrConfNoIssResult }, + { label: 'shared pre-registered', mode: 'pre-registered', result: () => rfc9207Result }, + { label: 'per-server pre-registered', mode: 'pre-registered', result: () => notionResult }, + ] as const + for (const c of I15_CASES) { + it(`I15 ${c.label}: variant(install block) === variant(written CR)`, async () => { + mockDiscovery(c.result()) + const { db } = makeInMemoryDynamicClientsDb() + const { transport } = makeDcrTransport({ + responseJson: JSON.stringify(DCR_CONFIDENTIAL_REGISTRATION_RESPONSE), + redirectUris: 'requested', + }) + const gw = gatewayWithContext('ctx-a') + const res = await request(app(gw, { db, dcr: { transport, resolveDns: PUBLIC_IP } })) + .post('/admin/mcp-servers/remote') + .send({ + serverName: 'i15-server', + contextRef: 'ctx-a', + baseUrl: 'https://mcp.notion.com/mcp', + mode: c.mode, + ...(c.mode === 'pre-registered' ? { clientId: 'cid-i15', clientSecret: 's' } : {}), + }) + expect(res.status).toBe(201) + const cr = (await gw.getResource('mcpservers', 'i15-server', NS)) as { + spec: { oauth: Record } + } + const expected = c.result().issForCallback ? 'shared' : 'per-server' + expect(res.body.callbackVariant).toBe(expected) + expect(remoteCallbackVariant(cr.spec.oauth)).toBe(res.body.callbackVariant) + }) + } + + // I12 — the registration response must report the per-server URI we asked for. + const PER_SERVER_REJECTIONS = [ + { + label: 'a different redirect URI (the shared one)', + redirectUris: [`${ORIGIN}/api/v1/oauth-callback/remote`] as string[], + kind: 'redirect_uris_mismatch', + }, + { label: 'no redirect_uris', redirectUris: 'omit' as const, kind: 'redirect_uris_missing' }, + ] + for (const c of PER_SERVER_REJECTIONS) { + it(`I12 per-server DCR response with ${c.label} → 400, no CR, no row, RFC 7592 DELETE sent`, async () => { + mockDiscovery(dcrConfNoIssResult) + const { db, rows } = makeInMemoryDynamicClientsDb() + const { transport, calls } = makeDcrTransport({ + responseJson: JSON.stringify(DCR_CONFIDENTIAL_REGISTRATION_RESPONSE), + redirectUris: c.redirectUris, + }) + const gw = gatewayWithContext('ctx-a') + const res = await request(app(gw, { db, dcr: { transport, resolveDns: PUBLIC_IP } })) + .post('/admin/mcp-servers/remote') + .send({ + serverName: 'i12-server', + contextRef: 'ctx-a', + baseUrl: 'https://mcp.notion.com/mcp', + mode: 'dcr', + }) + expect(res.status).toBe(400) + expect(res.body).toEqual({ error: 'dcr_registration_failed', detail: { kind: c.kind } }) + expect(rows.size).toBe(0) + await expect(gw.getResource('mcpservers', 'i12-server', NS)).rejects.toThrow() + const deletes = calls.filter(call => call.method === 'DELETE') + expect(deletes.map(d => d.url)).toEqual([ + DCR_CONFIDENTIAL_REGISTRATION_RESPONSE.registration_client_uri, + ]) + // The management bearer never reaches the response. + expect(JSON.stringify(res.body)).not.toContain( + DCR_CONFIDENTIAL_REGISTRATION_RESPONSE.registration_access_token + ) + }) + } + + it('I12 shared DCR response reporting a different redirect URI → 400, no CR, no row, DELETE sent', async () => { + mockDiscovery(dcrConfSharedResult) + const { db, rows } = makeInMemoryDynamicClientsDb() + const { transport, calls } = makeDcrTransport({ + responseJson: JSON.stringify(DCR_CONFIDENTIAL_REGISTRATION_RESPONSE), + redirectUris: ['https://elsewhere.example.com/api/v1/oauth-callback/remote'], + }) + const gw = gatewayWithContext('ctx-a') + const res = await request(app(gw, { db, dcr: { transport, resolveDns: PUBLIC_IP } })) + .post('/admin/mcp-servers/remote') + .send({ + serverName: 'i12-shared', + contextRef: 'ctx-a', + baseUrl: 'https://mcp.notion.com/mcp', + mode: 'dcr', + }) + expect(res.status).toBe(400) + expect(res.body.detail).toEqual({ kind: 'redirect_uris_mismatch' }) + expect(rows.size).toBe(0) + await expect(gw.getResource('mcpservers', 'i12-shared', NS)).rejects.toThrow() + expect(calls.filter(call => call.method === 'DELETE')).toHaveLength(1) + }) + + it('I12 shared DCR response without redirect_uris is tolerated → 201', async () => { + mockDiscovery(dcrConfSharedResult) + const { db, rows } = makeInMemoryDynamicClientsDb() + const { transport } = makeDcrTransport({ + responseJson: JSON.stringify(DCR_CONFIDENTIAL_REGISTRATION_RESPONSE), + redirectUris: 'omit', + }) + const gw = gatewayWithContext('ctx-a') + const res = await request(app(gw, { db, dcr: { transport, resolveDns: PUBLIC_IP } })) + .post('/admin/mcp-servers/remote') + .send({ + serverName: 'i12-omit', + contextRef: 'ctx-a', + baseUrl: 'https://mcp.notion.com/mcp', + mode: 'dcr', + }) + expect(res.status).toBe(201) + expect(res.body.redirectUri).toBe(`${ORIGIN}/api/v1/oauth-callback/remote`) + expect(rows.size).toBe(1) + }) + + for (const c of [ + { label: 'per-server', result: () => dcrConfNoIssResult }, + { label: 'shared', result: () => dcrConfSharedResult }, + ]) { + it(`I12 ${c.label} DCR assigning the platform CIMD client_id → 400, no CR, no row, DELETE sent`, async () => { + mockDiscovery(c.result()) + const { db, rows } = makeInMemoryDynamicClientsDb() + const { transport, calls } = makeDcrTransport({ + responseJson: JSON.stringify({ + ...DCR_CONFIDENTIAL_REGISTRATION_RESPONSE, + client_id: CIMD_SELF_CLIENT_ID, + }), + redirectUris: 'requested', + }) + const gw = gatewayWithContext('ctx-a') + const res = await request(app(gw, { db, dcr: { transport, resolveDns: PUBLIC_IP } })) + .post('/admin/mcp-servers/remote') + .send({ + serverName: 'i12-cimd', + contextRef: 'ctx-a', + baseUrl: 'https://mcp.notion.com/mcp', + mode: 'dcr', + }) + expect(res.status).toBe(400) + expect(res.body.detail).toEqual({ kind: 'client_id_is_cimd_identity' }) + expect(rows.size).toBe(0) + await expect(gw.getResource('mcpservers', 'i12-cimd', NS)).rejects.toThrow() + expect(calls.filter(call => call.method === 'DELETE')).toHaveLength(1) + }) + } + + it('DCR response with an oversized client_id → 400, no CR, no row, RFC 7592 DELETE sent', async () => { + mockDiscovery(dcrConfNoIssResult) + const { db, rows } = makeInMemoryDynamicClientsDb() + const { transport, calls } = makeDcrTransport({ + responseJson: JSON.stringify({ + ...DCR_CONFIDENTIAL_REGISTRATION_RESPONSE, + client_id: `https://as.example.com/${'/'.repeat(200_000)}a`, + }), + redirectUris: 'requested', + }) + const gw = gatewayWithContext('ctx-a') + const res = await request(app(gw, { db, dcr: { transport, resolveDns: PUBLIC_IP } })) + .post('/admin/mcp-servers/remote') + .send({ + serverName: 'huge-client-id', + contextRef: 'ctx-a', + baseUrl: 'https://mcp.notion.com/mcp', + mode: 'dcr', + }) + expect(res.status).toBe(400) + expect(res.body.error).toBe('dcr_registration_failed') + expect(res.body.detail.kind).toBe('invalid_response') + expect(rows.size).toBe(0) + await expect(gw.getResource('mcpservers', 'huge-client-id', NS)).rejects.toThrow() + expect(calls.filter(call => call.method === 'DELETE')).toHaveLength(1) + }) + + it('DCR oversized client_id without an RFC 7592 handle → 400, no DELETE, internal flag not echoed', async () => { + mockDiscovery(dcrConfNoIssResult) + const { db, rows } = makeInMemoryDynamicClientsDb() + const { + registration_client_uri: _u, + registration_access_token: _t, + ...noHandle + } = DCR_CONFIDENTIAL_REGISTRATION_RESPONSE + const { transport, calls } = makeDcrTransport({ + responseJson: JSON.stringify({ ...noHandle, client_id: 'c'.repeat(513) }), + redirectUris: 'requested', + }) + const gw = gatewayWithContext('ctx-a') + const res = await request(app(gw, { db, dcr: { transport, resolveDns: PUBLIC_IP } })) + .post('/admin/mcp-servers/remote') + .send({ + serverName: 'huge-no-handle', + contextRef: 'ctx-a', + baseUrl: 'https://mcp.notion.com/mcp', + mode: 'dcr', + }) + expect(res.status).toBe(400) + expect(res.body.detail.kind).toBe('invalid_response') + expect(res.body.detail.minted).toBeUndefined() + expect(calls.filter(call => call.method === 'DELETE')).toHaveLength(0) + expect(rows.size).toBe(0) + }) + + it('pre-registered clientId longer than the CRD cap (512) → 400 before discovery', async () => { + const gw = gatewayWithContext('ctx-a') + const res = await request(app(gw, {})) + .post('/admin/mcp-servers/remote') + .send({ + serverName: 'long-cid', + contextRef: 'ctx-a', + baseUrl: 'https://mcp.notion.com/mcp', + mode: 'pre-registered', + clientId: 'c'.repeat(513), + clientSecret: 's', + }) + expect(res.status).toBe(400) + expect(res.body.error).toBe('invalid_request') + expect(discoverRemoteOAuth).not.toHaveBeenCalled() + }) + + // I17 — a per-server pre-registered client belongs to exactly one server. + async function installPreRegistered( + gw: MockGateway, + deps: AdminRemoteMcpDeps, + serverName: string, + clientId: string, + result: DiscoveryResult = notionResult + ) { + mockDiscovery(result) + return request(app(gw, deps)).post('/admin/mcp-servers/remote').send({ + serverName, + contextRef: 'ctx-a', + baseUrl: 'https://mcp.notion.com/mcp', + mode: 'pre-registered', + clientId, + clientSecret: 'fixture-pre-reg-secret', + }) + } + + it('I17 client_id already used by another remote server → 409, no Secret, no CR', async () => { + const gw = gatewayWithContext('ctx-a') + const { db } = makeInMemoryDynamicClientsDb() + // The first server is written by the real saga (T1), not seeded by hand. + expect((await installPreRegistered(gw, { db }, 'first-server', 'shared-cid')).status).toBe(201) + const res = await installPreRegistered(gw, { db }, 'second-server', 'shared-cid') + expect(res.status).toBe(409) + expect(res.body).toMatchObject({ error: 'oauth_client_id_in_use', conflict: 'remote_server' }) + expect(res.body.message).toMatch(/separate OAuth client/) + await expect(gw.getResource('mcpservers', 'second-server', NS)).rejects.toThrow() + await expect(gw.getSecret('second-server-oauth-client', NS)).rejects.toThrow() + }) + + it('I17 client_id of a live DCR client → 409 (dynamic_client), no Secret, no CR', async () => { + const gw = gatewayWithContext('ctx-a') + const { db, rows } = makeInMemoryDynamicClientsDb() + const { transport } = makeDcrTransport({ + responseJson: JSON.stringify(DCR_CONFIDENTIAL_REGISTRATION_RESPONSE), + redirectUris: 'requested', + }) + // A real DCR install leaves the live dynamic_clients row (T1). + mockDiscovery(dcrConfNoIssResult) + const dcr = await request(app(gw, { db, dcr: { transport, resolveDns: PUBLIC_IP } })) + .post('/admin/mcp-servers/remote') + .send({ + serverName: 'dcr-server', + contextRef: 'ctx-a', + baseUrl: 'https://mcp.notion.com/mcp', + mode: 'dcr', + }) + expect(dcr.status).toBe(201) + const dcrClientId = [...rows.values()][0].client_id as string + expect(dcrClientId).toBe(DCR_CONFIDENTIAL_REGISTRATION_RESPONSE.client_id) + // With the CR still present the CR check answers first; remove it the way a + // `kubectl delete` does (the row, and the client at the AS, stay behind). + await gw.deleteResource('mcpservers', 'dcr-server', NS) + expect(rows.size).toBe(1) + + const res = await installPreRegistered(gw, { db }, 'pre-server', dcrClientId) + expect(res.status).toBe(409) + expect(res.body).toMatchObject({ error: 'oauth_client_id_in_use', conflict: 'dynamic_client' }) + expect(res.body.message).toMatch(/separate OAuth client/) + await expect(gw.getResource('mcpservers', 'pre-server', NS)).rejects.toThrow() + await expect(gw.getSecret('pre-server-oauth-client', NS)).rejects.toThrow() + }) + + it('I17 client_id equal to the platform CIMD identity (any origin) → 409 (cimd_client)', async () => { + const gw = gatewayWithContext('ctx-a') + const { db } = makeInMemoryDynamicClientsDb() + for (const clientId of [ + CIMD_SELF_CLIENT_ID, + 'https://old-origin.example.org/api/v1/.well-known/evenfire-mcp-client', + ]) { + const res = await installPreRegistered(gw, { db }, 'cimd-pre', clientId) + expect(res.status).toBe(409) + expect(res.body).toMatchObject({ error: 'oauth_client_id_in_use', conflict: 'cimd_client' }) + expect(res.body.message).toMatch(/separate OAuth client/) + } + await expect(gw.getResource('mcpservers', 'cimd-pre', NS)).rejects.toThrow() + }) + + // A client_id is only unique within its AS: the same string at another provider is + // another client, registered there with its own redirect URI. + function atOtherProvider(result: DiscoveryResult): DiscoveryResult { + return JSON.parse( + JSON.stringify(result).split('https://mcp.notion.com').join('https://auth.other-provider.io') + ) as DiscoveryResult + } + + it('I17 the same client_id at another provider (remote server) → 201', async () => { + const gw = gatewayWithContext('ctx-a') + const { db } = makeInMemoryDynamicClientsDb() + const other = atOtherProvider(notionResult) + expect(other.issuer).toBe('https://auth.other-provider.io') + expect((await installPreRegistered(gw, { db }, 'first-server', 'shared-cid')).status).toBe(201) + const res = await installPreRegistered(gw, { db }, 'second-server', 'shared-cid', other) + expect(res.status).toBe(201) + }) + + it('I17 the same client_id as a DCR client at another provider → 201', async () => { + const gw = gatewayWithContext('ctx-a') + const { db, rows } = makeInMemoryDynamicClientsDb() + const { transport } = makeDcrTransport({ + responseJson: JSON.stringify(DCR_CONFIDENTIAL_REGISTRATION_RESPONSE), + redirectUris: 'requested', + }) + mockDiscovery(dcrConfNoIssResult) + const dcr = await request(app(gw, { db, dcr: { transport, resolveDns: PUBLIC_IP } })) + .post('/admin/mcp-servers/remote') + .send({ + serverName: 'dcr-server', + contextRef: 'ctx-a', + baseUrl: 'https://mcp.notion.com/mcp', + mode: 'dcr', + }) + expect(dcr.status).toBe(201) + const dcrClientId = [...rows.values()][0].client_id as string + await gw.deleteResource('mcpservers', 'dcr-server', NS) + + const res = await installPreRegistered( + gw, + { db }, + 'pre-server', + dcrClientId, + atOtherProvider(notionResult) + ) + expect(res.status).toBe(201) + }) + + // The conflict is keyed on the AS issuer, never on the protected resource: with the + // two distinct, only the issuer can find the first server's client. + it('I17 conflicts on the issuer even when the resource differs from it → 409', async () => { + const gw = gatewayWithContext('ctx-a') + const { db } = makeInMemoryDynamicClientsDb() + const distinctResource: DiscoveryResult = { + ...notionResult, + resource: 'https://mcp.notion.com/mcp', + } + expect(distinctResource.resource).not.toBe(distinctResource.issuer) + expect( + (await installPreRegistered(gw, { db }, 'first-server', 'shared-cid', distinctResource)) + .status + ).toBe(201) + const res = await installPreRegistered( + gw, + { db }, + 'second-server', + 'shared-cid', + distinctResource + ) + expect(res.status).toBe(409) + expect(res.body).toMatchObject({ error: 'oauth_client_id_in_use', conflict: 'remote_server' }) + }) + + it('I17 a different client_id at the same provider → 201', async () => { + const gw = gatewayWithContext('ctx-a') + const { db } = makeInMemoryDynamicClientsDb() + expect((await installPreRegistered(gw, { db }, 'first-server', 'cid-one')).status).toBe(201) + expect((await installPreRegistered(gw, { db }, 'second-server', 'cid-two')).status).toBe(201) + }) + + it('I17 does not constrain a shared (RFC 9207) pre-registered install → 201', async () => { + const gw = gatewayWithContext('ctx-a') + const { db } = makeInMemoryDynamicClientsDb() + expect( + (await installPreRegistered(gw, { db }, 'a-shared', 'cid-x', rfc9207Result)).status + ).toBe(201) + expect( + (await installPreRegistered(gw, { db }, 'b-shared', 'cid-x', rfc9207Result)).status + ).toBe(201) + }) + + // Per-server origin fails closed; shared keeps its historical behaviour. + for (const baseUrl of ['', 'https://control.example.com/some/prefix']) { + it(`per-server install with callback base URL ${JSON.stringify(baseUrl)} → 503, nothing written or minted`, async () => { + config.oauthCallbackBaseUrl = baseUrl + const gw = gatewayWithContext('ctx-a') + const { db, rows } = makeInMemoryDynamicClientsDb() + const { transport, calls } = makeDcrTransport({ + responseJson: JSON.stringify(DCR_CONFIDENTIAL_REGISTRATION_RESPONSE), + redirectUris: 'requested', + }) + mockDiscovery(dcrConfNoIssResult) + const dcr = await request(app(gw, { db, dcr: { transport, resolveDns: PUBLIC_IP } })) + .post('/admin/mcp-servers/remote') + .send({ + serverName: 'no-origin-dcr', + contextRef: 'ctx-a', + baseUrl: 'https://mcp.notion.com/mcp', + mode: 'dcr', + }) + expect(dcr.status).toBe(503) + expect(dcr.body).toEqual({ error: 'callback_base_url_unconfigured' }) + expect(calls).toHaveLength(0) + expect(rows.size).toBe(0) + + const pre = await installPreRegistered(gw, { db }, 'no-origin-pre', 'cid-no-origin') + expect(pre.status).toBe(503) + expect(pre.body).toEqual({ error: 'callback_base_url_unconfigured' }) + await expect(gw.getSecret('no-origin-pre-oauth-client', NS)).rejects.toThrow() + await expect(gw.getResource('mcpservers', 'no-origin-pre', NS)).rejects.toThrow() + }) + } + + it('shared pre-registered install without a callback base URL still succeeds (no redirectUri reported)', async () => { + config.oauthCallbackBaseUrl = '' + const gw = gatewayWithContext('ctx-a') + const res = await installPreRegistered(gw, {}, 'shared-no-origin', 'cid-y', rfc9207Result) + expect(res.status).toBe(201) + expect(res.body.callbackVariant).toBe('shared') + expect(res.body.redirectUri).toBeUndefined() + }) + + // mode_unsupported names the missing RFC 9207 and the mode that does work. + for (const c of [ + { label: 'DCR available (Notion)', result: () => notionResult, next: 'dcr' }, + { + label: 'no DCR (Linear minus registration)', + result: () => cimdOnlyNoIssResult, + next: 'pre-registered', + }, + ]) { + it(`CIMD requested on a CIMD AS without RFC 9207, ${c.label} → 400 naming "${c.next}"`, async () => { + mockDiscovery(c.result()) + const gw = gatewayWithContext('ctx-a') + const res = await request(app(gw, {})).post('/admin/mcp-servers/remote').send({ + serverName: 'cimd-req', + contextRef: 'ctx-a', + baseUrl: 'https://mcp.notion.com/mcp', + mode: 'cimd', + }) + expect(res.status).toBe(400) + expect(res.body).toEqual({ + error: 'mode_unsupported', + message: `this authorization server supports CIMD but not RFC 9207; install with mode "${c.next}"`, + }) + }) + } + + it('CIMD requested on a DCR-only AS keeps the generic message', async () => { + mockDiscovery(dcrConfNoIssResult) + const gw = gatewayWithContext('ctx-a') + const res = await request(app(gw, {})).post('/admin/mcp-servers/remote').send({ + serverName: 'cimd-req2', + contextRef: 'ctx-a', + baseUrl: 'https://mcp.notion.com/mcp', + mode: 'cimd', + }) + expect(res.status).toBe(400) + expect(res.body.message).toBe( + 'this authorization server requires dynamic client registration; install with mode "dcr"' + ) + }) + + describe('/discover callback preview', () => { + async function discoverWith(result: DiscoveryResult) { + mockDiscovery(result) + return request(makeApp(gatewayWithContext())) + .post('/admin/mcp-servers/remote/discover') + .send({ baseUrl: 'https://mcp.example.com/mcp' }) + } + + it('per-server DCR (Atlassian): template with {serverName}/{installId} + AS endpoint hosts', async () => { + const res = await discoverWith(atlassianResult) + expect(res.status).toBe(200) + expect(res.body.callback).toEqual({ + configured: true, + variant: 'per-server', + redirectUriTemplate: `${ORIGIN}/api/v1/oauth-callback/remote/{serverName}/{installId}`, + }) + const atlassianHost = 'auth.atlassian.com' + expect(res.body.detected.asEndpointHosts).toEqual({ + authorization: atlassianHost, + token: atlassianHost, + registration: atlassianHost, + }) + }) + + it('per-server pre-registered (CIMD-only AS without RFC 9207): template /remote/{serverName}', async () => { + const res = await discoverWith(cimdOnlyNoIssResult) + expect(res.body.callback).toEqual({ + configured: true, + variant: 'per-server', + redirectUriTemplate: `${ORIGIN}/api/v1/oauth-callback/remote/{serverName}`, + }) + const linearHost = 'mcp.linear.app' + expect(res.body.detected.asEndpointHosts).toEqual({ + authorization: linearHost, + token: linearHost, + }) + }) + + it('shared (Linear, RFC 9207): the real shared URI, no endpoint hosts', async () => { + const res = await discoverWith(linearResult) + expect(res.body.callback).toEqual({ + configured: true, + variant: 'shared', + redirectUriTemplate: `${ORIGIN}/api/v1/oauth-callback/remote`, + }) + expect(res.body.detected.asEndpointHosts).toBeUndefined() + }) + + for (const baseUrl of ['', 'https://control.example.com/some/prefix']) { + it(`per-server with callback base URL ${JSON.stringify(baseUrl)} → configured:false, no template`, async () => { + config.oauthCallbackBaseUrl = baseUrl + const res = await discoverWith(atlassianResult) + expect(res.status).toBe(200) + expect(res.body.callback).toEqual({ configured: false, variant: 'per-server' }) + }) + } + }) +}) diff --git a/control-api/test/routes.adminRemoteMcp.wireGoldens.test.ts b/control-api/test/routes.adminRemoteMcp.wireGoldens.test.ts new file mode 100644 index 0000000000..d0e0c0192d --- /dev/null +++ b/control-api/test/routes.adminRemoteMcp.wireGoldens.test.ts @@ -0,0 +1,325 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import express from 'express' +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import request from 'supertest' +import { config } from '../src/config.js' +import type { PinnedTransport } from '../src/http/pinnedFetch.js' +import type { K8sGateway } from '../src/k8s.js' +import { discoverRemoteOAuth } from '../src/oauth/discovery.js' +import { probeMcpTransport } from '../src/oauth/mcpTransportProbe.js' +import { + type AdminRemoteMcpDeps, + createAdminRemoteMcpRouter, +} from '../src/routes/admin/remoteMcp.js' +import { + ATLASSIAN_V2_PILOT, + DCR_PUBLIC_REGISTRATION_RESPONSE, + DROPBOX_PILOT, + PILOTS, + type PilotFixture, + dcrPilot, + makeDcrTransport, + makeDiscoveryTransport, + makeInMemoryDynamicClientsDb, +} from './fixtures/remoteOAuthDiscovery.js' +import { MockGateway } from './mockGateway.js' + +/** + * Golden wire bodies of the remote MCP admin routes (`/admin/mcp-servers/remote`), + * consumed verbatim by control-ui's wizard tests (control-ui/test/fixtures/ + * remoteMcpWire.ts). control-ui cannot import control-api, so instead of retyping the + * response shape there, every golden here is the body the real router returns: real + * discovery and transport probe over the recorded probe bytes, the real install saga + * over a MockGateway and the in-memory dynamic_clients harness. + * + * A producer change fails the equality below and forces the golden to move. To + * regenerate after an intended change: `UPDATE_WIRE_GOLDENS=1 npx vitest run `, + * `npx prettier --write test/fixtures/wire/remoteMcp.*.json`, then review the JSON diff. + */ + +vi.mock('node:dns/promises', () => ({ + lookup: vi.fn(async () => [{ address: '93.184.216.34', family: 4 }]), +})) + +// The install nonce is the only non-deterministic byte in these bodies (it ends the +// per-server DCR redirect URI); pinning it keeps the golden byte-stable. +const FIXED_INSTALL_ID = '5b0f3c2e-8d1a-4c6f-9e2b-7a4d1f0c9e31' +vi.mock('node:crypto', async importOriginal => { + const actual = await importOriginal() + return { ...actual, randomUUID: () => FIXED_INSTALL_ID } +}) + +const ORIGIN = 'https://control.example.com' +const PUBLIC_IP = async () => ['93.184.216.34'] +const UPDATE = process.env.UPDATE_WIRE_GOLDENS === '1' +const WIRE_DIR = new URL('./fixtures/wire/', import.meta.url) + +type WireCapture = { status: number; body: unknown } + +function golden(name: string, actual: WireCapture): WireCapture { + const file = new URL(`remoteMcp.${name}.json`, WIRE_DIR) + if (UPDATE) { + mkdirSync(WIRE_DIR, { recursive: true }) + writeFileSync(file, `${JSON.stringify(actual, null, 2)}\n`) + } + return JSON.parse(readFileSync(file, 'utf8')) as WireCapture +} + +/** Documented subtraction on the recorded AS bytes: drop the named metadata fields. */ +function withoutAsFields(pilot: PilotFixture, fields: string[]): PilotFixture { + const as = JSON.parse(pilot.as.json) as Record + for (const field of fields) delete as[field] + return { ...pilot, name: `${pilot.name}-minus`, as: { ...pilot.as, json: JSON.stringify(as) } } +} + +function appFor( + pilot: PilotFixture, + gateway: MockGateway, + extra: Omit = {} +) { + const transport: PinnedTransport = makeDiscoveryTransport(pilot) + const deps: AdminRemoteMcpDeps = { + discover: (url, discoveryDeps, opts) => + discoverRemoteOAuth(url, { ...discoveryDeps, transport, resolveDns: PUBLIC_IP }, opts), + probe: (baseUrl, probeDeps, opts) => + probeMcpTransport(baseUrl, { ...probeDeps, transport, resolveDns: PUBLIC_IP }, opts), + ...extra, + } + const app = express() + app.use(express.json()) + app.use(createAdminRemoteMcpRouter(gateway as unknown as K8sGateway, deps)) + return app +} + +function gatewayWithContext(): MockGateway { + const gw = new MockGateway(config.mcpServersNamespace) + void gw.createResource( + 'contexts', + { metadata: { name: 'research' }, spec: { contextId: 'research' } }, + config.mcpServersNamespace + ) + return gw +} + +async function discover(pilot: PilotFixture): Promise { + const res = await request(appFor(pilot, gatewayWithContext())) + .post('/admin/mcp-servers/remote/discover') + .send({ baseUrl: pilot.mcpUrl }) + return { status: res.status, body: res.body } +} + +async function install( + pilot: PilotFixture, + body: Record, + opts: { gateway?: MockGateway; deps?: Omit } = {} +): Promise { + const res = await request(appFor(pilot, opts.gateway ?? gatewayWithContext(), opts.deps)) + .post('/admin/mcp-servers/remote') + .send({ contextRef: 'research', baseUrl: pilot.mcpUrl, ...body }) + return { status: res.status, body: res.body } +} + +// CIMD-only AS without RFC 9207 and without DCR: the operator must pre-register a client, +// on a per-server redirect URI. Real Linear bytes minus the two fields. +const PRE_REGISTERED_PER_SERVER_PILOT = withoutAsFields(PILOTS.linear, [ + 'registration_endpoint', + 'authorization_response_iss_parameter_supported', +]) +// The same AS with RFC 9207 but neither CIMD nor DCR: pre-registered on the shared +// callback. Paired with the case above it is what the wizard sees when the AS starts +// returning `iss` between discover and install. +const PRE_REGISTERED_SHARED_PILOT = withoutAsFields(PILOTS.linear, [ + 'registration_endpoint', + 'client_id_metadata_document_supported', +]) +// An AS whose issuer is itself a public suffix (a shared-hosting apex, here the PSL +// private entry `github.io`), so it has no registrable domain to anchor the same-site +// rule. Real Notion bytes (no RFC 9207) with the AS moved to that host: the PRM points +// its authorization server there and every AS metadata URL follows; the resource stays. +const ISSUER_ON_PUBLIC_SUFFIX_PILOT = ((): PilotFixture => { + const moved = (json: string) => json.split('https://mcp.notion.com').join('https://github.io') + return { + ...PILOTS.notion, + name: 'issuer-public-suffix', + prm: { + ...PILOTS.notion.prm, + json: PILOTS.notion.prm.json.replace( + '"authorization_servers":["https://mcp.notion.com"]', + '"authorization_servers":["https://github.io"]' + ), + }, + as: { + url: 'https://github.io/.well-known/oauth-authorization-server', + json: moved(PILOTS.notion.as.json), + }, + } +})() + +const ATLASSIAN_REGISTRATION = JSON.parse(ATLASSIAN_V2_PILOT.as.json) + .registration_endpoint as string + +let savedCallbackBaseUrl: string +beforeAll(() => { + savedCallbackBaseUrl = config.oauthCallbackBaseUrl + config.oauthCallbackBaseUrl = ORIGIN +}) +afterAll(() => { + config.oauthCallbackBaseUrl = savedCallbackBaseUrl +}) + +describe('remote MCP wire goldens — POST /discover', () => { + const CASES: Array<{ name: string; pilot: () => PilotFixture; status: number }> = [ + { name: 'discover.linear', pilot: () => PILOTS.linear, status: 200 }, + { name: 'discover.notion', pilot: () => PILOTS.notion, status: 200 }, + { name: 'discover.atlassian', pilot: () => ATLASSIAN_V2_PILOT, status: 200 }, + { name: 'discover.dcrConfidential', pilot: () => dcrPilot('confidential'), status: 200 }, + { + name: 'discover.preRegisteredPerServer', + pilot: () => PRE_REGISTERED_PER_SERVER_PILOT, + status: 200, + }, + { name: 'discover.dropbox', pilot: () => DROPBOX_PILOT, status: 400 }, + ] + for (const c of CASES) { + it(`${c.name} equals the router's response`, async () => { + const actual = await discover(c.pilot()) + expect(actual.status).toBe(c.status) + expect(actual).toEqual(golden(c.name, actual)) + }) + } + + const UNCONFIGURED_CASES: Array<{ name: string; pilot: () => PilotFixture }> = [ + { name: 'discover.atlassianUnconfigured', pilot: () => ATLASSIAN_V2_PILOT }, + { name: 'discover.linearUnconfigured', pilot: () => PILOTS.linear }, + ] + for (const c of UNCONFIGURED_CASES) { + it(`${c.name}: no callback base URL configured`, async () => { + config.oauthCallbackBaseUrl = '' + try { + const actual = await discover(c.pilot()) + expect(actual.status).toBe(200) + expect(actual).toEqual(golden(c.name, actual)) + } finally { + config.oauthCallbackBaseUrl = ORIGIN + } + }) + } + + it('discover.issuerPublicSuffix: issuer on a public suffix, no RFC 9207 → 400', async () => { + const actual = await discover(ISSUER_ON_PUBLIC_SUFFIX_PILOT) + expect(actual.status).toBe(400) + expect(actual).toEqual(golden('discover.issuerPublicSuffix', actual)) + }) +}) + +describe('remote MCP wire goldens — POST / (install)', () => { + it('install.atlassian: per-server DCR 201', async () => { + const { db } = makeInMemoryDynamicClientsDb() + const { transport } = makeDcrTransport({ + registrationEndpoint: ATLASSIAN_REGISTRATION, + responseJson: JSON.stringify(DCR_PUBLIC_REGISTRATION_RESPONSE), + redirectUris: 'requested', + }) + const actual = await install( + ATLASSIAN_V2_PILOT, + { serverName: 'atlassian', mode: 'dcr' }, + { deps: { db, dcr: { transport, resolveDns: PUBLIC_IP } } } + ) + expect(actual.status).toBe(201) + expect(actual).toEqual(golden('install.atlassian', actual)) + }) + + it('install.linear: shared CIMD 201', async () => { + const actual = await install(PILOTS.linear, { serverName: 'linear', mode: 'cimd' }) + expect(actual.status).toBe(201) + expect(actual).toEqual(golden('install.linear', actual)) + }) + + it('install.preRegisteredPerServer: per-server pre-registered 201', async () => { + const { db } = makeInMemoryDynamicClientsDb() + const actual = await install( + PRE_REGISTERED_PER_SERVER_PILOT, + { + serverName: 'linear-pre', + mode: 'pre-registered', + clientId: 'pre-registered-client', + clientSecret: 'pre-registered-secret', + }, + { deps: { db } } + ) + expect(actual.status).toBe(201) + expect(actual).toEqual(golden('install.preRegisteredPerServer', actual)) + }) + + it('install.preRegisteredShared: shared pre-registered 201', async () => { + const actual = await install(PRE_REGISTERED_SHARED_PILOT, { + serverName: 'linear-pre', + mode: 'pre-registered', + clientId: 'pre-registered-client', + clientSecret: 'pre-registered-secret', + }) + expect(actual.status).toBe(201) + expect(actual).toEqual(golden('install.preRegisteredShared', actual)) + }) + + it('install.clientIdInUse: a second per-server server on the same client_id → 409', async () => { + const { db } = makeInMemoryDynamicClientsDb() + const gateway = gatewayWithContext() + const body = { + mode: 'pre-registered', + clientId: 'shared-client', + clientSecret: 'pre-registered-secret', + } + const first = await install( + PRE_REGISTERED_PER_SERVER_PILOT, + { ...body, serverName: 'first' }, + { gateway, deps: { db } } + ) + expect(first.status).toBe(201) + const actual = await install( + PRE_REGISTERED_PER_SERVER_PILOT, + { ...body, serverName: 'second' }, + { gateway, deps: { db } } + ) + expect(actual.status).toBe(409) + expect(actual).toEqual(golden('install.clientIdInUse', actual)) + }) + + it('install.dcrRedirectMismatch: the AS registered another redirect URI → 400', async () => { + const { db } = makeInMemoryDynamicClientsDb() + const { transport } = makeDcrTransport({ + registrationEndpoint: ATLASSIAN_REGISTRATION, + responseJson: JSON.stringify(DCR_PUBLIC_REGISTRATION_RESPONSE), + redirectUris: [`${ORIGIN}/api/v1/oauth-callback/remote`], + }) + const actual = await install( + ATLASSIAN_V2_PILOT, + { serverName: 'atlassian', mode: 'dcr' }, + { deps: { db, dcr: { transport, resolveDns: PUBLIC_IP } } } + ) + expect(actual.status).toBe(400) + expect(actual).toEqual(golden('install.dcrRedirectMismatch', actual)) + }) + + it('install.cimdWithoutIssBinding: CIMD requested on an AS without RFC 9207 → 400', async () => { + const actual = await install(PILOTS.notion, { serverName: 'notion', mode: 'cimd' }) + expect(actual.status).toBe(400) + expect(actual).toEqual(golden('install.cimdWithoutIssBinding', actual)) + }) + + it('install.callbackUnconfigured: per-server without a callback base URL → 503', async () => { + config.oauthCallbackBaseUrl = '' + try { + const { db } = makeInMemoryDynamicClientsDb() + const actual = await install( + ATLASSIAN_V2_PILOT, + { serverName: 'atlassian', mode: 'dcr' }, + { deps: { db } } + ) + expect(actual.status).toBe(503) + expect(actual).toEqual(golden('install.callbackUnconfigured', actual)) + } finally { + config.oauthCallbackBaseUrl = ORIGIN + } + }) +}) diff --git a/control-api/test/routes.externalGfsRateLimitFailClosed.realPostgres.integration.test.ts b/control-api/test/routes.externalGfsRateLimitFailClosed.realPostgres.integration.test.ts index 15b17de039..062388e9bb 100644 --- a/control-api/test/routes.externalGfsRateLimitFailClosed.realPostgres.integration.test.ts +++ b/control-api/test/routes.externalGfsRateLimitFailClosed.realPostgres.integration.test.ts @@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { randomBytes, randomUUID } from 'node:crypto' import { Pool, type PoolClient } from 'pg' import request from 'supertest' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' // The external GFS limiter's Postgres backend, against real PostgreSQL at the // production pool bounds: the CORE_POOL_* and RATE_LIMIT_POOL_* variables are @@ -99,7 +100,7 @@ describeRealPostgres('external GFS rate limiter backend (real PostgreSQL)', () = limiterPool.on('error', () => {}) const migratePool = new Pool({ connectionString }) await dbMod.initDb({ connect: () => migratePool.connect() }) - await migratePool.end() + await endPoolAndWaitForClients(migratePool) const appMod = await import('../src/app.js') const configMod = await import('../src/config.js') @@ -114,21 +115,25 @@ describeRealPostgres('external GFS rate limiter backend (real PostgreSQL)', () = }, 60_000) afterAll(async () => { - for (const key of envKeys) { - const value = previousEnv.get(key) - if (value === undefined) delete process.env[key] - else process.env[key] = value - } - await corePool?.end().catch(() => {}) - await limiterPool?.end().catch(() => {}) - if (!adminPool) return - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + try { + for (const key of envKeys) { + const value = previousEnv.get(key) + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + await endPoolAndWaitForClients(corePool).catch(() => {}) + await endPoolAndWaitForClients(limiterPool).catch(() => {}) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) + } + } finally { + await adminPool?.end() + } }) it('U1: the limiter pool runs with synchronous_commit off and a 3 s statement timeout; the core pool keeps the defaults', async () => { diff --git a/control-api/test/routes.externalGfsRateLimitStress.realPostgres.integration.test.ts b/control-api/test/routes.externalGfsRateLimitStress.realPostgres.integration.test.ts index 773f5c5c54..e0d55bec44 100644 --- a/control-api/test/routes.externalGfsRateLimitStress.realPostgres.integration.test.ts +++ b/control-api/test/routes.externalGfsRateLimitStress.realPostgres.integration.test.ts @@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs' import http from 'node:http' import type { AddressInfo } from 'node:net' import { Pool } from 'pg' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' // Concurrent users at the external GFS rate limits, through the production // Express app against real PostgreSQL. Config, the core and limiter pools, both limiters, @@ -181,7 +182,7 @@ describeRealPostgres('external GFS rate limits under concurrent load (real Postg limiterPool.on('error', () => {}) const migratePool = new Pool({ connectionString }) await dbMod.initDb({ connect: () => migratePool.connect() }) - await migratePool.end() + await endPoolAndWaitForClients(migratePool) const appMod = await import('../src/app.js') const configMod = await import('../src/config.js') @@ -221,22 +222,26 @@ describeRealPostgres('external GFS rate limits under concurrent load (real Postg }) afterAll(async () => { - vi.useRealTimers() - for (const key of envKeys) { - const value = previousEnv.get(key) - if (value === undefined) delete process.env[key] - else process.env[key] = value - } - await corePool?.end().catch(() => {}) - await limiterPool?.end().catch(() => {}) - if (!adminPool) return - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + try { + vi.useRealTimers() + for (const key of envKeys) { + const value = previousEnv.get(key) + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + await endPoolAndWaitForClients(corePool).catch(() => {}) + await endPoolAndWaitForClients(limiterPool).catch(() => {}) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) + } + } finally { + await adminPool?.end() + } }) /** diff --git a/control-api/test/routes.llmModelGrantRace.realPostgres.integration.test.ts b/control-api/test/routes.llmModelGrantRace.realPostgres.integration.test.ts index a07bc463c0..a721da6d61 100644 --- a/control-api/test/routes.llmModelGrantRace.realPostgres.integration.test.ts +++ b/control-api/test/routes.llmModelGrantRace.realPostgres.integration.test.ts @@ -2,6 +2,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } import { randomBytes } from 'node:crypto' import { Pool } from 'pg' import request from 'supertest' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' // R1-H3 fase 2 (llm-model reductor ↔ grant upsert) — regression test for the // TOCTOU race the per-MODEL advisory lock closes on the grant seam (mini-spec @@ -137,7 +138,7 @@ describeRealPostgres('llm-model reductor ↔ grant upsert serialization (R1-H3 f limiterPool.on('error', () => {}) const migratePool = new Pool({ connectionString }) await dbMod.initDb({ connect: () => migratePool.connect() }) - await migratePool.end() + await endPoolAndWaitForClients(migratePool) racePool = new Pool({ connectionString }) racePool.on('error', () => {}) @@ -166,19 +167,23 @@ describeRealPostgres('llm-model reductor ↔ grant upsert serialization (R1-H3 f }, 60_000) afterAll(async () => { - if (previousPgEnv === undefined) delete process.env.CONTROL_API_PG_CONNECTION_STRING - else process.env.CONTROL_API_PG_CONNECTION_STRING = previousPgEnv - await racePool?.end().catch(() => {}) - await corePool?.end().catch(() => {}) - await limiterPool?.end().catch(() => {}) - if (!adminPool) return - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + try { + if (previousPgEnv === undefined) delete process.env.CONTROL_API_PG_CONNECTION_STRING + else process.env.CONTROL_API_PG_CONNECTION_STRING = previousPgEnv + await endPoolAndWaitForClients(racePool).catch(() => {}) + await endPoolAndWaitForClients(corePool).catch(() => {}) + await endPoolAndWaitForClients(limiterPool).catch(() => {}) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) + } + } finally { + await adminPool?.end() + } }) beforeEach(async () => { diff --git a/control-api/test/routes.llmModelHostRace.realPostgres.integration.test.ts b/control-api/test/routes.llmModelHostRace.realPostgres.integration.test.ts index df7adaaeb1..2604bc9dac 100644 --- a/control-api/test/routes.llmModelHostRace.realPostgres.integration.test.ts +++ b/control-api/test/routes.llmModelHostRace.realPostgres.integration.test.ts @@ -2,6 +2,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } import { randomBytes } from 'node:crypto' import { Pool } from 'pg' import request from 'supertest' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' // R1-H3 fase 1 (host↔model) — regression test for the TOCTOU race the advisory // lock closes (mini-spec §7 / T3 / T4). It drives the race through the REAL route @@ -120,7 +121,7 @@ describeRealPostgres('llm-model reductor ↔ host referencer serialization (R1-H limiterPool.on('error', () => {}) const migratePool = new Pool({ connectionString }) await dbMod.initDb({ connect: () => migratePool.connect() }) - await migratePool.end() + await endPoolAndWaitForClients(migratePool) const appMod = await import('../src/app.js') const configMod = await import('../src/config.js') @@ -170,19 +171,23 @@ describeRealPostgres('llm-model reductor ↔ host referencer serialization (R1-H }, 60_000) afterAll(async () => { - if (previousPgEnv === undefined) delete process.env.CONTROL_API_PG_CONNECTION_STRING - else process.env.CONTROL_API_PG_CONNECTION_STRING = previousPgEnv - // Drain the module pool's connections first so DROP DATABASE has no live users. - await corePool?.end().catch(() => {}) - await limiterPool?.end().catch(() => {}) - if (!adminPool) return - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + try { + if (previousPgEnv === undefined) delete process.env.CONTROL_API_PG_CONNECTION_STRING + else process.env.CONTROL_API_PG_CONNECTION_STRING = previousPgEnv + // Drain the module pool's connections first so DROP DATABASE has no live users. + await endPoolAndWaitForClients(corePool).catch(() => {}) + await endPoolAndWaitForClients(limiterPool).catch(() => {}) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) + } + } finally { + await adminPool?.end() + } }) beforeEach(async () => { diff --git a/control-api/test/routes.mcpOauth.grantsExists.realPostgres.integration.test.ts b/control-api/test/routes.mcpOauth.grantsExists.realPostgres.integration.test.ts index 46d5dfa9a6..a1ab0f17b2 100644 --- a/control-api/test/routes.mcpOauth.grantsExists.realPostgres.integration.test.ts +++ b/control-api/test/routes.mcpOauth.grantsExists.realPostgres.integration.test.ts @@ -11,6 +11,7 @@ import { upsertOAuthGrant, } from '../src/oauth/store.js' import { resolveBatchGrantExistence } from '../src/routes/mcpOauth.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' import { MockGateway } from './mockGateway.js' // T1 — the batch grant-existence resolver (mini-spec 13 §4.1) exercised against @@ -51,15 +52,18 @@ describeRealPostgres('resolveBatchGrantExistence (real Postgres)', () => { }) afterAll(async () => { - await dbPool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + try { + await endPoolAndWaitForClients(dbPool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/routes.mcpOauth.legacyGrantLaneFence.realPostgres.integration.test.ts b/control-api/test/routes.mcpOauth.legacyGrantLaneFence.realPostgres.integration.test.ts index 815764a158..2b24080b89 100644 --- a/control-api/test/routes.mcpOauth.legacyGrantLaneFence.realPostgres.integration.test.ts +++ b/control-api/test/routes.mcpOauth.legacyGrantLaneFence.realPostgres.integration.test.ts @@ -37,6 +37,7 @@ import { legacyConsent, reinstallServer, } from './fixtures/legacyOAuthGrant.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' import { MockGateway } from './mockGateway.js' // The broker reads grants through the app's shared pool; point it at this suite's @@ -113,16 +114,19 @@ describeRealPostgres('legacy grants seen by the broker, gate and sweep (real Pos }) afterAll(async () => { - testDb.pool = undefined - await dbPool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + try { + testDb.pool = undefined + await endPoolAndWaitForClients(dbPool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/routes.mcpServerUninstall.repairRequired.realPostgres.integration.test.ts b/control-api/test/routes.mcpServerUninstall.repairRequired.realPostgres.integration.test.ts index 451a8c9320..fcb92d63d3 100644 --- a/control-api/test/routes.mcpServerUninstall.repairRequired.realPostgres.integration.test.ts +++ b/control-api/test/routes.mcpServerUninstall.repairRequired.realPostgres.integration.test.ts @@ -5,6 +5,7 @@ import { Pool } from 'pg' import request from 'supertest' import type { DbClient } from '../src/db.js' import type { K8sGateway } from '../src/k8s.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' import { MockGateway } from './mockGateway.js' /** @@ -81,16 +82,19 @@ describeRealPostgres('generic McpServer uninstall — repair_required (real Post }) afterAll(async () => { - dbHolder.target = null - await dbPool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + try { + dbHolder.target = null + await endPoolAndWaitForClients(dbPool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/routes.oauthCallback.remotePerServer.test.ts b/control-api/test/routes.oauthCallback.remotePerServer.test.ts new file mode 100644 index 0000000000..1bb92060c4 --- /dev/null +++ b/control-api/test/routes.oauthCallback.remotePerServer.test.ts @@ -0,0 +1,443 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import request from 'supertest' +import { createApp } from '../src/app.js' +import { config } from '../src/config.js' +import type { DiscoveryResult } from '../src/oauth/discovery.js' +import { claimDeleteDynamicClientForResource } from '../src/oauth/dynamicClientStore.js' +import { deriveOAuthEncryptionKey } from '../src/oauth/encryption.js' +import { signOAuthState } from '../src/oauth/state.js' +import { buildRemoteOAuthSpec } from '../src/routes/admin/remoteMcp.js' +import { + discoverPilot, + installRemoteServer, + seedContext, + withRfc9207, +} from './fixtures/perServerInstall.js' +import { + ATLASSIAN_V2_PILOT, + PILOTS, + dcrPilot, + makeInMemoryDynamicClientsDb, +} from './fixtures/remoteOAuthDiscovery.js' +import { MockGateway } from './mockGateway.js' + +/** + * The per-server remote callback and authorize-url through the real app (`createApp`): + * the public route `GET /api/v1/oauth-callback/remote/:serverName/:installNonce?`, its + * segment validation, what it reports back (the desktop deep link and the + * not-configured body name the integration `remote`, as the shared callback does), and + * the redirect URI the mint puts in the authorize URL. + * + * T1: servers are installed by the real admin install route over the same + * `dynamic_clients` harness the app's pool is pointed at; discovery by the real client. + * The AS token endpoint is the only network edge, answered by a recording transport. + */ + +const dbHolder = vi.hoisted(() => ({ + db: undefined as undefined | { query: (text: string, values?: unknown[]) => Promise }, + context: 'ctx-a', +})) + +vi.mock('../src/db.js', async importActual => { + const actual = await importActual() + return { + ...actual, + pool: { + query: (text: string, values?: unknown[]) => { + // Membership is the only non-OAuth read on these paths. + if (text.includes('FROM user_contexts')) { + return Promise.resolve({ rows: [{ context_id: dbHolder.context }], rowCount: 1 }) + } + if (!dbHolder.db) throw new Error('test db not initialised') + return dbHolder.db.query(text, values) + }, + }, + } +}) + +vi.mock('../src/services/rateLimiterService.js', () => ({ + checkAndIncrement: vi.fn().mockResolvedValue({ + allowed: true, + backendAvailable: true, + remaining: 59, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + count: 1, + }), +})) + +vi.mock('node:dns/promises', () => ({ + lookup: vi.fn(async () => [{ address: '93.184.216.34', family: 4 }]), +})) + +const tokenEndpoint = vi.hoisted(() => ({ posts: [] as { url: string; body: string }[] })) +vi.mock('../src/http/pinnedFetch.js', async importActual => { + const actual = await importActual() + return { + ...actual, + // Only calls that would reach the real network (the callback route injects no + // transport) are answered here; callers that bring their own keep it. + pinnedFetch: (url: string, field: string, options: Parameters[2]) => + options?.transport + ? actual.pinnedFetch(url, field, options) + : actual.pinnedFetch(url, field, { + ...options, + resolveDns: async () => ['93.184.216.34'], + transport: async input => { + tokenEndpoint.posts.push({ url: input.url, body: input.body ?? '' }) + return { + status: 200, + headers: { 'content-type': 'application/json' }, + bodyText: JSON.stringify({ + access_token: 'AT', + refresh_token: 'RT', + expires_in: 3600, + }), + } + }, + }), + } +}) + +const NS = config.mcpServersNamespace +const ORIGIN = 'https://control.example.com' +const CONTEXT = 'ctx-a' +const KEY = deriveOAuthEncryptionKey(config.oauthEncryptionKey) +const NONCE = '0f8fad5b-d9cb-469f-a165-70867728950e' + +let atlassian: DiscoveryResult +let notion: DiscoveryResult +let notionWithIss: DiscoveryResult +let sharedDcr: DiscoveryResult + +beforeAll(async () => { + atlassian = await discoverPilot(ATLASSIAN_V2_PILOT) + notion = await discoverPilot(PILOTS.notion) + notionWithIss = await discoverPilot(withRfc9207(PILOTS.notion)) + sharedDcr = await discoverPilot(withRfc9207(dcrPilot('public'))) +}) + +let gateway: MockGateway +let app: ReturnType +let db: ReturnType['db'] +let savedBaseUrl: string + +beforeEach(async () => { + savedBaseUrl = config.oauthCallbackBaseUrl + config.oauthCallbackBaseUrl = ORIGIN + gateway = new MockGateway(NS) + await seedContext(gateway, NS, CONTEXT) + db = makeInMemoryDynamicClientsDb().db + dbHolder.db = db + app = createApp(gateway as never) + tokenEndpoint.posts.length = 0 +}) + +afterEach(() => { + config.oauthCallbackBaseUrl = savedBaseUrl + dbHolder.db = undefined +}) + +async function install( + name: string, + mode: 'dcr' | 'pre-registered', + discovery: DiscoveryResult, + baseUrl: string +): Promise { + const res = await installRemoteServer({ + gateway, + db, + discovery, + body: { + serverName: name, + contextRef: CONTEXT, + baseUrl, + mode, + ...(mode === 'pre-registered' + ? { clientId: `client-${name}`, clientSecret: `secret-${name}` } + : {}), + }, + }) + if (res.status !== 201) throw new Error(`install failed: ${JSON.stringify(res.body)}`) + return String(res.body.redirectUri) +} + +async function oauthOf(name: string): Promise> { + const cr = (await gateway.getResource('mcpservers', name, NS)) as { + spec: { oauth: Record } + } + return cr.spec.oauth +} + +async function stateFor(name: string): Promise { + return signOAuthState(config.oauthStateHmacSecret, { + subjectKind: 'mcp', + mcpServerName: name, + userId: 'user-9', + oauthClientId: String((await oauthOf(name)).id), + grantKind: 'user', + background: false, + } as Parameters[1]) +} + +function deepLinkOf(html: string): URL { + const match = /url=([^"]+)"/.exec(html) + if (!match) throw new Error('no deep link in the success page') + return new URL(match[1].replace(/&/g, '&')) +} + +function mint(name: string) { + return request(app) + .post('/api/v1/internal/mcp-oauth/authorize-url') + .set('Authorization', 'Bearer dev-rpc-proxy-token') + .set('x-service-token', 'rpc-proxy') + .send({ mcpServerName: name, userId: 'user-9' }) +} + +function redirectUriOfMint(res: { body: { authorizeUrl?: string } }): string | null { + return new URL(String(res.body.authorizeUrl)).searchParams.get('redirect_uri') +} + +describe('GET /api/v1/oauth-callback/remote/:serverName/:installNonce?', () => { + it.each([ + ['an uppercase server name', '/remote/Atlassian'], + ['an underscore in the name', '/remote/atl_assian'], + ['a name longer than an RFC 1123 label', `/remote/${'a'.repeat(64)}`], + ['a trailing hyphen', '/remote/atlassian-'], + ['an encoded traversal', '/remote/..%2Fadmin'], + ['a dotted (subdomain) name', '/remote/atlassian.v2'], + ['a double-encoded slash', '/remote/a%252Fb'], + ['an uppercase nonce', `/remote/atlassian/${NONCE.toUpperCase()}`], + ['a nonce that is not a UUID', '/remote/atlassian/not-a-uuid'], + ])('%s → 404 before any CR is read', async (_label, path) => { + const read = vi.spyOn(gateway, 'getResource') + const res = await request(app) + .get(`/api/v1/oauth-callback${path}`) + .query({ code: 'CODE', state: 'STATE' }) + expect(res.status).toBe(404) + expect(res.body).toEqual({ error: 'Not Found' }) + expect(read).not.toHaveBeenCalled() + }) + + it('I2: /remote/a/ with the state of b → 400 binding_mismatch, b never read, no exchange', async () => { + const a = await install('server-a', 'dcr', atlassian, ATLASSIAN_V2_PILOT.mcpUrl) + await install('server-b', 'dcr', atlassian, ATLASSIAN_V2_PILOT.mcpUrl) + const stateOfB = await stateFor('server-b') + const read = vi.spyOn(gateway, 'getResource') + + const res = await request(app).get(new URL(a).pathname).query({ code: 'CODE', state: stateOfB }) + + expect(res.status).toBe(400) + expect(res.body).toEqual({ error: 'invalid_state', reason: 'binding_mismatch' }) + expect(read).not.toHaveBeenCalledWith('mcpservers', 'server-b', NS) + expect(tokenEndpoint.posts).toHaveLength(0) + }) + + it('is public: valid segments with no service token reach the handler', async () => { + const res = await request(app).get(`/api/v1/oauth-callback/remote/atlassian/${NONCE}`) + expect(res.status).toBe(400) + expect(res.body).toEqual({ error: 'missing_code_or_state' }) + }) + + it('per-server DCR: success page deep link names clientId=remote and the signed server', async () => { + const redirectUri = await install('atlassian', 'dcr', atlassian, ATLASSIAN_V2_PILOT.mcpUrl) + + const res = await request(app) + .get(new URL(redirectUri).pathname) + .query({ code: 'CODE', state: await stateFor('atlassian') }) + + expect(res.status).toBe(200) + const link = deepLinkOf(res.text) + expect(link.searchParams.get('clientId')).toBe('remote') + expect(link.searchParams.get('mcpServerName')).toBe('atlassian') + expect(link.searchParams.get('source')).toBe('mcp') + expect(tokenEndpoint.posts).toHaveLength(1) + expect(new URLSearchParams(tokenEndpoint.posts[0].body).get('redirect_uri')).toBe(redirectUri) + }) + + it('shared: success page deep link names clientId=remote (unchanged)', async () => { + await install('shared-dcr', 'dcr', sharedDcr, dcrPilot('public').mcpUrl) + + const res = await request(app) + .get('/api/v1/oauth-callback/remote') + .query({ + code: 'CODE', + state: await stateFor('shared-dcr'), + iss: String((await oauthOf('shared-dcr')).issForCallback), + }) + + expect(res.status).toBe(200) + expect(deepLinkOf(res.text).searchParams.get('clientId')).toBe('remote') + }) + + it.each([ + ['per-server', () => notion, (name: string) => `/api/v1/oauth-callback/remote/${name}`], + ['shared', () => notionWithIss, () => '/api/v1/oauth-callback/remote'], + ])( + '%s pre-registered with its client Secret gone → 503 naming the integration "remote"', + async (_label, discovery, path) => { + await install('hubspot', 'pre-registered', discovery(), PILOTS.notion.mcpUrl) + const oauth = await oauthOf('hubspot') + const secretName = (oauth.clientSecretRef as { name: string }).name + await gateway.deleteSecret(secretName, NS) + + const res = await request(app) + .get(path('hubspot')) + .query({ + code: 'CODE', + state: await stateFor('hubspot'), + ...(oauth.issForCallback ? { iss: String(oauth.issForCallback) } : {}), + }) + + expect(res.status).toBe(503) + expect(res.body).toMatchObject({ error: 'integration_not_configured', integration: 'remote' }) + expect(tokenEndpoint.posts).toHaveLength(0) + } + ) + + it('no configured public origin → 503 callback_base_url_unconfigured, no exchange', async () => { + const redirectUri = await install('atlassian', 'dcr', atlassian, ATLASSIAN_V2_PILOT.mcpUrl) + config.oauthCallbackBaseUrl = '' + + const res = await request(app) + .get(new URL(redirectUri).pathname) + .query({ code: 'CODE', state: await stateFor('atlassian') }) + + expect(res.status).toBe(503) + expect(res.body).toEqual({ error: 'callback_base_url_unconfigured' }) + expect(tokenEndpoint.posts).toHaveLength(0) + }) + + it('a repeated iss reaches the per-server check raw → 400 issuer_mismatch', async () => { + const redirectUri = await install('atlassian', 'dcr', atlassian, ATLASSIAN_V2_PILOT.mcpUrl) + const state = await stateFor('atlassian') + + const res = await request(app).get( + `${new URL(redirectUri).pathname}?code=CODE&state=${encodeURIComponent(state)}` + + `&iss=${encodeURIComponent(atlassian.issuer)}&iss=${encodeURIComponent(atlassian.issuer)}` + ) + + expect(res.status).toBe(400) + expect(res.body).toEqual({ error: 'issuer_mismatch' }) + expect(tokenEndpoint.posts).toHaveLength(0) + }) + + it('shared keeps reading a repeated iss as absent → 400 issuer_mismatch (unchanged)', async () => { + await install('shared-dcr', 'dcr', sharedDcr, dcrPilot('public').mcpUrl) + const iss = String((await oauthOf('shared-dcr')).issForCallback) + const state = await stateFor('shared-dcr') + + const res = await request(app).get( + `/api/v1/oauth-callback/remote?code=CODE&state=${encodeURIComponent(state)}` + + `&iss=${encodeURIComponent(iss)}&iss=${encodeURIComponent(iss)}` + ) + + expect(res.status).toBe(400) + expect(res.body).toEqual({ error: 'issuer_mismatch' }) + }) +}) + +describe('POST /api/v1/internal/mcp-oauth/authorize-url — remote redirect URI', () => { + it('per-server DCR: the authorize redirect_uri is the URI the install registered', async () => { + const registered = await install('atlassian', 'dcr', atlassian, ATLASSIAN_V2_PILOT.mcpUrl) + + const res = await mint('atlassian') + + expect(res.status).toBe(200) + expect(redirectUriOfMint(res)).toBe(registered) + expect(registered).toMatch(/\/api\/v1\/oauth-callback\/remote\/atlassian\/[0-9a-f-]{36}$/) + }) + + it('per-server pre-registered: /remote/, the URI the install reported', async () => { + const registered = await install('hubspot', 'pre-registered', notion, PILOTS.notion.mcpUrl) + + const res = await mint('hubspot') + + expect(res.status).toBe(200) + expect(redirectUriOfMint(res)).toBe(`${ORIGIN}/api/v1/oauth-callback/remote/hubspot`) + expect(redirectUriOfMint(res)).toBe(registered) + }) + + it('shared: the stable /remote URI (unchanged)', async () => { + const registered = await install('shared-dcr', 'dcr', sharedDcr, dcrPilot('public').mcpUrl) + + const res = await mint('shared-dcr') + + expect(res.status).toBe(200) + expect(redirectUriOfMint(res)).toBe(`${ORIGIN}/api/v1/oauth-callback/remote`) + expect(redirectUriOfMint(res)).toBe(registered) + }) + + it('shared without a configured origin keeps falling back to the request Host', async () => { + await install('shared-dcr', 'dcr', sharedDcr, dcrPilot('public').mcpUrl) + config.oauthCallbackBaseUrl = '' + + const res = await mint('shared-dcr').set('Host', 'cp.internal.test') + + expect(res.status).toBe(200) + expect(redirectUriOfMint(res)).toBe('http://cp.internal.test/api/v1/oauth-callback/remote') + }) + + it.each([ + ['unset', ''], + ['not a bare origin', `${ORIGIN}/base/`], + ])('per-server with the callback base URL %s → 503, no URL minted', async (_l, baseUrl) => { + await install('atlassian', 'dcr', atlassian, ATLASSIAN_V2_PILOT.mcpUrl) + config.oauthCallbackBaseUrl = baseUrl + + const res = await mint('atlassian') + + expect(res.status).toBe(503) + expect(res.body).toEqual({ error: 'callback_base_url_unconfigured' }) + }) + + it('per-server DCR whose row is gone → 503 integration_not_configured, no URL minted', async () => { + await install('atlassian', 'dcr', atlassian, ATLASSIAN_V2_PILOT.mcpUrl) + const cr = (await gateway.getResource('mcpservers', 'atlassian', NS)) as { + metadata: { uid: string } + } + const { deleted } = await claimDeleteDynamicClientForResource( + db, + KEY, + { serverNamespace: NS, serverName: 'atlassian' }, + cr.metadata.uid + ) + expect(deleted).toBe(true) + + const res = await mint('atlassian') + + expect(res.status).toBe(503) + expect(res.body).toEqual({ + error: 'integration_not_configured', + integration: String((await oauthOf('atlassian')).id), + hint: 'reinstall the remote MCP server atlassian to register its OAuth client again', + }) + expect(res.body.authorizeUrl).toBeUndefined() + }) + + // A CR can only carry a non-label name when written outside the install; its callback + // route would never match, so there is nothing to mint. + it('per-server server whose name is not an RFC 1123 label → 400 invalid_request', async () => { + await gateway.createResource( + 'mcpservers', + { + metadata: { name: 'atlassian.v2' }, + spec: { + contextRef: CONTEXT, + auth: { type: 'oauth' }, + oauth: buildRemoteOAuthSpec(atlassian, { + clientMode: 'public', + grantScope: 'user', + dynamicClientId: 'dyn-public-6f1c2a', + }), + }, + }, + NS + ) + + const res = await mint('atlassian.v2') + + expect(res.status).toBe(400) + expect(res.body).toEqual({ error: 'invalid_request' }) + }) +}) diff --git a/control-api/test/routes.rateLimitProcessMemory.realPostgres.integration.test.ts b/control-api/test/routes.rateLimitProcessMemory.realPostgres.integration.test.ts index dfc8bc6c4c..08f9eddb87 100644 --- a/control-api/test/routes.rateLimitProcessMemory.realPostgres.integration.test.ts +++ b/control-api/test/routes.rateLimitProcessMemory.realPostgres.integration.test.ts @@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { randomBytes, randomUUID } from 'node:crypto' import { Pool, type PoolClient } from 'pg' import request from 'supertest' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' // rateLimitMiddleware's 'process-memory' mode against real PostgreSQL: with // every limiter pool connection held, a real route's limiter cannot count in @@ -96,7 +97,7 @@ describeRealPostgres('rateLimitMiddleware process-memory mode (real PostgreSQL)' limiterPool.on('error', () => {}) const migratePool = new Pool({ connectionString }) await dbMod.initDb({ connect: () => migratePool.connect() }) - await migratePool.end() + await endPoolAndWaitForClients(migratePool) const appMod = await import('../src/app.js') const configMod = await import('../src/config.js') @@ -111,21 +112,25 @@ describeRealPostgres('rateLimitMiddleware process-memory mode (real PostgreSQL)' }, 60_000) afterAll(async () => { - for (const key of envKeys) { - const value = previousEnv.get(key) - if (value === undefined) delete process.env[key] - else process.env[key] = value - } - await corePool?.end().catch(() => {}) - await limiterPool?.end().catch(() => {}) - if (!adminPool) return - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + try { + for (const key of envKeys) { + const value = previousEnv.get(key) + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + await endPoolAndWaitForClients(corePool).catch(() => {}) + await endPoolAndWaitForClients(limiterPool).catch(() => {}) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) + } + } finally { + await adminPool?.end() + } }) async function seedUser(): Promise { diff --git a/control-api/test/routes.registryInstall.oauth.test.ts b/control-api/test/routes.registryInstall.oauth.test.ts index 9e6e1d1919..ff9330de68 100644 --- a/control-api/test/routes.registryInstall.oauth.test.ts +++ b/control-api/test/routes.registryInstall.oauth.test.ts @@ -722,7 +722,7 @@ describe('generic carril install (S3-B4)', () => { await expect(gw.getSecret('my-idp-oauth-client', 'mcp-server')).rejects.toThrow() // inv.3: the resolver (real producer→consumer) reads the CR back as public generic. - const subject = resolveServerOAuthSubject({ spec: server.spec }) + const subject = resolveServerOAuthSubject({ spec: server.spec }, 'consent') expect(subject?.decl.provider).toBe('generic') expect(subject?.decl.secretSource).toEqual({ kind: 'public' }) }) @@ -761,7 +761,7 @@ describe('generic carril install (S3-B4)', () => { } expect(Object.keys(secret.data ?? {}).sort()).toEqual(['client_id', 'client_secret']) - const subject = resolveServerOAuthSubject({ spec: server.spec }) + const subject = resolveServerOAuthSubject({ spec: server.spec }, 'consent') expect(subject?.decl.secretSource).toEqual({ kind: 'k8s-secret', clientIdRef: { name: 'my-idp-oauth-client', key: 'client_id' }, diff --git a/control-api/test/routes.registryUninstall.realPostgres.integration.test.ts b/control-api/test/routes.registryUninstall.realPostgres.integration.test.ts index 03718ee535..bb47429089 100644 --- a/control-api/test/routes.registryUninstall.realPostgres.integration.test.ts +++ b/control-api/test/routes.registryUninstall.realPostgres.integration.test.ts @@ -5,6 +5,7 @@ import { Pool } from 'pg' import request from 'supertest' import type { DbClient } from '../src/db.js' import type { K8sGateway } from '../src/k8s.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' import { MockGateway } from './mockGateway.js' /** @@ -137,16 +138,19 @@ describeRealPostgres('registry uninstall tears OAuth grants down (real Postgres) }) afterAll(async () => { - dbHolder.target = null - await dbPool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + try { + dbHolder.target = null + await endPoolAndWaitForClients(dbPool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) + } + } finally { + await adminPool?.end() } }) @@ -227,7 +231,8 @@ describeRealPostgres('registry uninstall tears OAuth grants down (real Postgres) const resolved = resolveServerOAuthSubject( (await gw.getResource('mcpservers', name, NS)) as Parameters< typeof resolveServerOAuthSubject - >[0] + >[0], + 'consent' ) return resolved ? { namespace: NS, ...resolved } : null }, diff --git a/control-api/test/services.access.mcpConnectors.realPostgres.integration.test.ts b/control-api/test/services.access.mcpConnectors.realPostgres.integration.test.ts index 84fe8874bf..deaf2d265b 100644 --- a/control-api/test/services.access.mcpConnectors.realPostgres.integration.test.ts +++ b/control-api/test/services.access.mcpConnectors.realPostgres.integration.test.ts @@ -7,6 +7,7 @@ import { initDb } from '../src/db.js' import { deriveOAuthEncryptionKey } from '../src/oauth/encryption.js' import { bootstrapSharedOAuthGrant, upsertOAuthGrant } from '../src/oauth/store.js' import { resolveConnectorsForAgents } from '../src/services/access/mcpInvocable.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' import { MockGateway } from './mockGateway.js' // T1 — the connectors classifier exercised end-to-end against grant state built @@ -45,15 +46,18 @@ describeRealPostgres('resolveConnectorsForAgents tri-state (real Postgres)', () }) afterAll(async () => { - await dbPool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + try { + await endPoolAndWaitForClients(dbPool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/services.access.mcpInvocable.grantGate.realPostgres.integration.test.ts b/control-api/test/services.access.mcpInvocable.grantGate.realPostgres.integration.test.ts index 868aa44b0a..474b23a821 100644 --- a/control-api/test/services.access.mcpInvocable.grantGate.realPostgres.integration.test.ts +++ b/control-api/test/services.access.mcpInvocable.grantGate.realPostgres.integration.test.ts @@ -7,6 +7,7 @@ import { initDb } from '../src/db.js' import { deriveOAuthEncryptionKey } from '../src/oauth/encryption.js' import { bootstrapSharedOAuthGrant, upsertOAuthGrant } from '../src/oauth/store.js' import { resolveInvocableMcpServersForContexts } from '../src/services/access/mcpInvocable.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' import { MockGateway } from './mockGateway.js' // T1 — the rpc-proxy grant-presence gate exercised end-to-end against grant @@ -44,15 +45,18 @@ describeRealPostgres('mcpInvocable grant-presence gate (real Postgres)', () => { }) afterAll(async () => { - await dbPool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + try { + await endPoolAndWaitForClients(dbPool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/services.adminAuthDelete.realPostgres.integration.test.ts b/control-api/test/services.adminAuthDelete.realPostgres.integration.test.ts index 601ff4df6a..e442fa9d33 100644 --- a/control-api/test/services.adminAuthDelete.realPostgres.integration.test.ts +++ b/control-api/test/services.adminAuthDelete.realPostgres.integration.test.ts @@ -3,6 +3,7 @@ import { randomBytes, randomUUID } from 'node:crypto' import { Pool } from 'pg' import { initDb, pool } from '../src/db.js' import { deleteControlAdmin } from '../src/services/adminAuthService.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' import './realPostgres.requirement.ts' import { waitForDatabaseConnectionsToClose } from './realPostgresCleanup.ts' @@ -73,15 +74,15 @@ describeRealPostgres('deleteControlAdmin on real PostgreSQL', () => { }, 60_000) afterAll(async () => { - querySpy?.mockRestore() - connectSpy?.mockRestore() - if (!adminPool) return try { - await testPool?.end() + querySpy?.mockRestore() + connectSpy?.mockRestore() + await endPoolAndWaitForClients(testPool) + if (!adminPool) return await waitForDatabaseConnectionsToClose(adminPool, database) await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) } finally { - await adminPool.end() + await adminPool?.end() } }) diff --git a/control-api/test/services.codexSubscriptionCatalog.realPostgres.integration.test.ts b/control-api/test/services.codexSubscriptionCatalog.realPostgres.integration.test.ts index 79b9b0c6f5..824a51361c 100644 --- a/control-api/test/services.codexSubscriptionCatalog.realPostgres.integration.test.ts +++ b/control-api/test/services.codexSubscriptionCatalog.realPostgres.integration.test.ts @@ -40,12 +40,15 @@ describeRealPostgres('Codex subscription catalog on real PostgreSQL', () => { }, 60_000) afterAll(async () => { - await pool?.end() - if (adminPool) { - await adminPool - .query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) - .catch(() => undefined) - await adminPool.end() + try { + await pool?.end() + if (adminPool) { + await adminPool + .query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) + .catch(() => undefined) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/services.codexSubscriptionLifecycle.realPostgres.integration.test.ts b/control-api/test/services.codexSubscriptionLifecycle.realPostgres.integration.test.ts index 0481a20504..281301928b 100644 --- a/control-api/test/services.codexSubscriptionLifecycle.realPostgres.integration.test.ts +++ b/control-api/test/services.codexSubscriptionLifecycle.realPostgres.integration.test.ts @@ -143,12 +143,15 @@ describeRealPostgres('Codex subscription grant lifecycle on real PostgreSQL', () }, 60_000) afterAll(async () => { - await pool?.end() - if (adminPool) { - await adminPool - .query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) - .catch(() => undefined) - await adminPool.end() + try { + await pool?.end() + if (adminPool) { + await adminPool + .query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) + .catch(() => undefined) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/services.codexSubscriptionOAuth.realPostgres.integration.test.ts b/control-api/test/services.codexSubscriptionOAuth.realPostgres.integration.test.ts index 6594ec4742..fb4b4ee222 100644 --- a/control-api/test/services.codexSubscriptionOAuth.realPostgres.integration.test.ts +++ b/control-api/test/services.codexSubscriptionOAuth.realPostgres.integration.test.ts @@ -51,12 +51,15 @@ describeRealPostgres('Codex subscription OAuth on real PostgreSQL', () => { }, 60_000) afterAll(async () => { - await pool?.end() - if (adminPool) { - await adminPool - .query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) - .catch(() => undefined) - await adminPool.end() + try { + await pool?.end() + if (adminPool) { + await adminPool + .query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) + .catch(() => undefined) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/services.codexSubscriptionRefreshRejected.realPostgres.integration.test.ts b/control-api/test/services.codexSubscriptionRefreshRejected.realPostgres.integration.test.ts index ea884386ab..d2f1f228db 100644 --- a/control-api/test/services.codexSubscriptionRefreshRejected.realPostgres.integration.test.ts +++ b/control-api/test/services.codexSubscriptionRefreshRejected.realPostgres.integration.test.ts @@ -104,12 +104,15 @@ describeRealPostgres('Codex refresh rejected by the vendor on real PostgreSQL (# }, 60_000) afterAll(async () => { - await pool?.end() - if (adminPool) { - await adminPool - .query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) - .catch(() => undefined) - await adminPool.end() + try { + await pool?.end() + if (adminPool) { + await adminPool + .query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) + .catch(() => undefined) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/services.controlAdminReplaceInviter.realPostgres.integration.test.ts b/control-api/test/services.controlAdminReplaceInviter.realPostgres.integration.test.ts index 59ca266182..e41744ffd5 100644 --- a/control-api/test/services.controlAdminReplaceInviter.realPostgres.integration.test.ts +++ b/control-api/test/services.controlAdminReplaceInviter.realPostgres.integration.test.ts @@ -23,6 +23,7 @@ import { createSilentInvitationForTeams, } from '../src/services/directory/membership.js' import { retireDesktopUser } from '../src/services/directory/users.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' import './realPostgres.requirement.ts' import { waitForDatabaseConnectionsToClose } from './realPostgresCleanup.ts' @@ -180,15 +181,15 @@ describeRealPostgres('control admin replace-inviter invitations on real PostgreS }, 60_000) afterAll(async () => { - querySpy?.mockRestore() - connectSpy?.mockRestore() - if (!adminPool) return try { - await testPool?.end() + querySpy?.mockRestore() + connectSpy?.mockRestore() + await endPoolAndWaitForClients(testPool) + if (!adminPool) return await waitForDatabaseConnectionsToClose(adminPool, database) await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) } finally { - await adminPool.end() + await adminPool?.end() } }) diff --git a/control-api/test/services.directory.userRetirement.realPostgres.integration.test.ts b/control-api/test/services.directory.userRetirement.realPostgres.integration.test.ts index 245b4d5d1a..9e6c8a4ee2 100644 --- a/control-api/test/services.directory.userRetirement.realPostgres.integration.test.ts +++ b/control-api/test/services.directory.userRetirement.realPostgres.integration.test.ts @@ -4,6 +4,7 @@ import { Pool } from 'pg' import { initDb, pool } from '../src/db.js' import { retireDesktopUser } from '../src/services/directory/users.js' import { gfsDesktopOperatorLinkService } from '../src/services/gfsDesktopOperatorLinkService.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' import { waitForDatabaseConnectionsToClose } from './realPostgresCleanup.ts' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL @@ -56,14 +57,14 @@ describeRealPostgres('retireDesktopUser on real PostgreSQL', () => { }, 60_000) afterAll(async () => { - corePoolConnectSpy?.mockRestore() - if (!adminPool) return try { - await testPool?.end() + corePoolConnectSpy?.mockRestore() + await endPoolAndWaitForClients(testPool) + if (!adminPool) return await waitForDatabaseConnectionsToClose(adminPool, database) await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) } finally { - await adminPool.end() + await adminPool?.end() } }) diff --git a/control-api/test/services.grokProviderAttemptRedemption.realPostgres.integration.test.ts b/control-api/test/services.grokProviderAttemptRedemption.realPostgres.integration.test.ts index e64ff7ff9b..e70a03596d 100644 --- a/control-api/test/services.grokProviderAttemptRedemption.realPostgres.integration.test.ts +++ b/control-api/test/services.grokProviderAttemptRedemption.realPostgres.integration.test.ts @@ -62,13 +62,16 @@ describeRealPostgres('Grok ticket redemption on real PostgreSQL', () => { }, 60_000) afterAll(async () => { - config.grokSubscriptionEnabled = previousFlag - await pool?.end() - if (adminPool) { - await adminPool - .query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) - .catch(() => undefined) - await adminPool.end() + try { + config.grokSubscriptionEnabled = previousFlag + await pool?.end() + if (adminPool) { + await adminPool + .query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) + .catch(() => undefined) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/services.grokProviderAttemptRedemption.refresh.realPostgres.integration.test.ts b/control-api/test/services.grokProviderAttemptRedemption.refresh.realPostgres.integration.test.ts index 0e060a87e5..dcaaccdb9f 100644 --- a/control-api/test/services.grokProviderAttemptRedemption.refresh.realPostgres.integration.test.ts +++ b/control-api/test/services.grokProviderAttemptRedemption.refresh.realPostgres.integration.test.ts @@ -78,14 +78,17 @@ describeRealPostgres('Grok ticket redemption with token refresh on real PostgreS }, 60_000) afterAll(async () => { - config.grokSubscriptionEnabled = previousFlag - config.oauthEncryptionKey = previousEncryptionKey - await pool?.end() - if (adminPool) { - await adminPool - .query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) - .catch(() => undefined) - await adminPool.end() + try { + config.grokSubscriptionEnabled = previousFlag + config.oauthEncryptionKey = previousEncryptionKey + await pool?.end() + if (adminPool) { + await adminPool + .query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) + .catch(() => undefined) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/services.grokSubscriptionConnection.realPostgres.integration.test.ts b/control-api/test/services.grokSubscriptionConnection.realPostgres.integration.test.ts index 02df77e63e..83e672125e 100644 --- a/control-api/test/services.grokSubscriptionConnection.realPostgres.integration.test.ts +++ b/control-api/test/services.grokSubscriptionConnection.realPostgres.integration.test.ts @@ -149,12 +149,15 @@ describeRealPostgres('Grok subscription connection on real PostgreSQL', () => { }, 60_000) afterAll(async () => { - await pool?.end() - if (adminPool) { - await adminPool - .query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) - .catch(() => undefined) - await adminPool.end() + try { + await pool?.end() + if (adminPool) { + await adminPool + .query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) + .catch(() => undefined) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/services.llmCatalogSync.realPostgres.integration.test.ts b/control-api/test/services.llmCatalogSync.realPostgres.integration.test.ts index bb3998d82d..bb7e852744 100644 --- a/control-api/test/services.llmCatalogSync.realPostgres.integration.test.ts +++ b/control-api/test/services.llmCatalogSync.realPostgres.integration.test.ts @@ -5,6 +5,7 @@ import type { ImageInputCapability } from '@clerum/llm-providers' import { initDb } from '../src/db.js' import { type CatalogSyncResult, syncDiscoveredModels } from '../src/services/llmCatalogSync.js' import { loadStub, trimSnapshot, withModalities } from './helpers/modelsDevFixtures.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' import './realPostgres.requirement.ts' // R1-M2: run `syncDiscoveredModels` against a REAL PostgreSQL. The in-memory @@ -211,17 +212,21 @@ describeRealPostgres('syncDiscoveredModels on real PostgreSQL (#654 image_input) }) afterAll(async () => { - await dbPool?.end() - if (!adminPool) return - await adminPool.query( - `SELECT pg_terminate_backend(pid) + try { + await endPoolAndWaitForClients(dbPool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) + } + } finally { + await adminPool?.end() + } }) it('counts exactly the enabled rows whose image_input changed and republishes once', async () => { diff --git a/control-api/test/services.llmProviderAttemptAuthorization.realPostgres.integration.test.ts b/control-api/test/services.llmProviderAttemptAuthorization.realPostgres.integration.test.ts index f5ca923ea5..350d7912c6 100644 --- a/control-api/test/services.llmProviderAttemptAuthorization.realPostgres.integration.test.ts +++ b/control-api/test/services.llmProviderAttemptAuthorization.realPostgres.integration.test.ts @@ -140,12 +140,15 @@ describeRealPostgres('Codex provider-attempt authorization on real PostgreSQL', }, 60_000) afterAll(async () => { - await pool?.end() - if (adminPool) { - await adminPool - .query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) - .catch(() => undefined) - await adminPool.end() + try { + await pool?.end() + if (adminPool) { + await adminPool + .query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) + .catch(() => undefined) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/services.llmProviderAttemptFinalization.realPostgres.integration.test.ts b/control-api/test/services.llmProviderAttemptFinalization.realPostgres.integration.test.ts index 481a82d32e..467d2757f8 100644 --- a/control-api/test/services.llmProviderAttemptFinalization.realPostgres.integration.test.ts +++ b/control-api/test/services.llmProviderAttemptFinalization.realPostgres.integration.test.ts @@ -90,12 +90,15 @@ describeRealPostgres('Codex attempt finalization on real PostgreSQL', () => { }, 60_000) afterAll(async () => { - await pool?.end() - if (adminPool) { - await adminPool - .query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) - .catch(() => undefined) - await adminPool.end() + try { + await pool?.end() + if (adminPool) { + await adminPool + .query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) + .catch(() => undefined) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/services.llmProviderAttemptRedemption.realPostgres.integration.test.ts b/control-api/test/services.llmProviderAttemptRedemption.realPostgres.integration.test.ts index 544bcba64c..b624b15edc 100644 --- a/control-api/test/services.llmProviderAttemptRedemption.realPostgres.integration.test.ts +++ b/control-api/test/services.llmProviderAttemptRedemption.realPostgres.integration.test.ts @@ -54,12 +54,15 @@ describeRealPostgres('Codex ticket redemption on real PostgreSQL', () => { }, 60_000) afterAll(async () => { - await pool?.end() - if (adminPool) { - await adminPool - .query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) - .catch(() => undefined) - await adminPool.end() + try { + await pool?.end() + if (adminPool) { + await adminPool + .query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) + .catch(() => undefined) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/services.llmProviderAttemptStore.realPostgres.integration.test.ts b/control-api/test/services.llmProviderAttemptStore.realPostgres.integration.test.ts index a9db18729a..8c1b52cad1 100644 --- a/control-api/test/services.llmProviderAttemptStore.realPostgres.integration.test.ts +++ b/control-api/test/services.llmProviderAttemptStore.realPostgres.integration.test.ts @@ -58,12 +58,15 @@ describeRealPostgres('LLM provider-attempt ledger on real PostgreSQL', () => { }, 60_000) afterAll(async () => { - await pool?.end() - if (adminPool) { - await adminPool - .query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) - .catch(() => undefined) - await adminPool.end() + try { + await pool?.end() + if (adminPool) { + await adminPool + .query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) + .catch(() => undefined) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/services.mcpSecretRollbackPermit.realPostgres.integration.test.ts b/control-api/test/services.mcpSecretRollbackPermit.realPostgres.integration.test.ts index 7ede251326..fb75ed3f4b 100644 --- a/control-api/test/services.mcpSecretRollbackPermit.realPostgres.integration.test.ts +++ b/control-api/test/services.mcpSecretRollbackPermit.realPostgres.integration.test.ts @@ -8,6 +8,7 @@ import { issueMcpSecretRollbackPermit, releaseMcpSecretRollbackPermitClaim, } from '../src/services/mcpSecretRollbackPermitService.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -43,37 +44,37 @@ describeRealPostgres('MCP Secret rollback permits on real PostgreSQL', () => { }, 60_000) afterAll(async () => { - if (pool) { - // A concurrent consume test opens several pg clients. pg@8 may emit a - // late idle-client error while their sockets finish closing, so retain a - // pool-level listener through teardown and wait for the backend rows to - // disappear before using the administrative fallback below. - pool.on('error', () => {}) - await pool.end() - } - if (!adminPool) return - let activeConnections = 0 - for (let attempt = 0; attempt < 50; attempt += 1) { - const active = await adminPool.query<{ count: string }>( - `SELECT COUNT(*)::text AS count + try { + // A concurrent consume test opens several pg clients. Wait for every one + // of them to close before touching their backends, then wait for + // the backend rows to disappear before using the administrative fallback + // below. + await endPoolAndWaitForClients(pool) + if (!adminPool) return + let activeConnections = 0 + for (let attempt = 0; attempt < 50; attempt += 1) { + const active = await adminPool.query<{ count: string }>( + `SELECT COUNT(*)::text AS count FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - activeConnections = Number(active.rows[0]?.count ?? 0) - if (activeConnections === 0) break - await new Promise(resolve => setImmediate(resolve)) - } - if (activeConnections > 0) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) + [database] + ) + activeConnections = Number(active.rows[0]?.count ?? 0) + if (activeConnections === 0) break + await new Promise(resolve => setImmediate(resolve)) + } + if (activeConnections > 0) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) + [database] + ) + } + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) + } finally { + await adminPool?.end() } - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) - await adminPool.end() }) it('grants the runtime role the exact DML needed to issue and consume a permit', async () => { diff --git a/control-api/test/services.oauthProactiveRefreshCron.installIdentity.realPostgres.integration.test.ts b/control-api/test/services.oauthProactiveRefreshCron.installIdentity.realPostgres.integration.test.ts index 7fd0c27052..179069a3fe 100644 --- a/control-api/test/services.oauthProactiveRefreshCron.installIdentity.realPostgres.integration.test.ts +++ b/control-api/test/services.oauthProactiveRefreshCron.installIdentity.realPostgres.integration.test.ts @@ -28,14 +28,17 @@ import type { K8sGateway } from '../src/k8s.js' import { buildCimdDocument } from '../src/oauth/cimd.js' import { type DiscoveryResult, discoverRemoteOAuth } from '../src/oauth/discovery.js' import { deriveOAuthEncryptionKey } from '../src/oauth/encryption.js' +import { resolveServerOAuthSubject } from '../src/oauth/mcpServerOAuthSpec.js' import { bootstrapSharedOAuthGrant, getOAuthGrant } from '../src/oauth/store.js' import { buildRemoteOAuthSpec } from '../src/routes/admin/remoteMcp.js' import { runProactiveRefreshSweep } from '../src/services/oauthProactiveRefreshCron.js' import { + DROPBOX_PILOT, PILOTS, type PilotFixture, makeDiscoveryTransport, } from './fixtures/remoteOAuthDiscovery.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' import { MockGateway } from './mockGateway.js' const VALIDATED_IP = '93.184.216.34' @@ -121,15 +124,18 @@ describeRealPostgres('proactive refresh sweep — installation identity (real Po }) afterAll(async () => { - await dbPool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + try { + await endPoolAndWaitForClients(dbPool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) + } + } finally { + await adminPool?.end() } }) @@ -266,6 +272,98 @@ describeRealPostgres('proactive refresh sweep — installation identity (real Po expect(rows).toEqual([{ has_rt: true, cr_uid: null }]) }) + // A per-server CR (no RFC 9207) holding the platform CIMD client, or with AS endpoints + // off the issuer's site, is refused at consent. A grant it already holds is sealed to + // its uid and its endpoints are immutable, so the refresh returns to the token + // endpoint that issued it: the sweep keeps refreshing it instead of stranding it. + it('keeps refreshing grants of per-server CRs that consent refuses (CIMD without iss, cross-site)', async () => { + await dbPool.query('DELETE FROM oauth_grants') + const gateway = new MockGateway(NS) + + // Notion (real): CIMD without RFC 9207 — `discovery` of this suite. + expect(discovery.issForCallback).toBeUndefined() + const cimdNoIss = buildRemoteOAuthSpec(discovery, { + clientMode: 'public', + grantScope: 'context', + cimdClientId: oauthId(), + }) + // Dropbox (real): discovery accepts its cross-site AS only with RFC 9207, so the + // per-server shape is that output without `issForCallback` (a direct write). + const dropbox9207: PilotFixture = { + ...DROPBOX_PILOT, + as: { + ...DROPBOX_PILOT.as, + json: JSON.stringify({ + ...JSON.parse(DROPBOX_PILOT.as.json), + authorization_response_iss_parameter_supported: true, + }), + }, + } + const dropboxOutcome = await discoverRemoteOAuth(dropbox9207.mcpUrl, { + transport: makeDiscoveryTransport(dropbox9207), + resolveDns: async () => [VALIDATED_IP], + }) + if (!dropboxOutcome.ok) + throw new Error(`fixture discovery failed: ${dropboxOutcome.error.kind}`) + const crossSite = buildRemoteOAuthSpec(dropboxOutcome.result, { + clientMode: 'public', + grantScope: 'context', + dynamicClientId: 'dyn-dropbox-public', + }) + delete crossSite.issForCallback + + const cases = [ + { name: 'cron-cimd-noiss', oauth: cimdNoIss, reason: 'cimd_without_issuer_binding' }, + { name: 'cron-cross-site', oauth: crossSite, reason: 'as_endpoints_cross_site' }, + ] + for (const c of cases) { + await gateway.createResource( + 'mcpservers', + { + metadata: { name: c.name }, + spec: { contextRef: CONTEXT, auth: { type: 'oauth' }, oauth: c.oauth }, + }, + NS + ) + const cr = (await gateway.getResource('mcpservers', c.name, NS)) as { + metadata: { uid: string } + spec: Record + } + // Consent refuses it (the other half of the invariant). + let consentError: { reason?: unknown } | undefined + try { + resolveServerOAuthSubject(cr, 'consent') + } catch (err) { + consentError = err as { reason?: unknown } + } + expect(consentError?.reason).toBe(c.reason) + const { inserted } = await bootstrapSharedOAuthGrant(db, KEY, { + ownerKind: 'mcpserver', + recipeNamespace: NS, + recipeName: c.name, + contextId: CONTEXT, + oauthClientId: c.oauth.id as string, + bootstrappedByUserId: 'user-1', + provider: 'remote', + accessToken: `AT-${c.name}`, + refreshToken: `RT-${c.name}`, + accessTokenExpiresInSec: IN_WINDOW_SEC, + crUid: cr.metadata.uid, + }) + expect(inserted).toBe(true) + } + + tokenEndpoint.posts.length = 0 + const summary = await sweep(gateway) + + expect(summary.candidates).toBe(2) + expect(summary.outcomes.ok).toBe(2) + expect(summary.outcomes.error).toBe(0) + expect(tokenEndpoint.posts.map(p => p.url).sort()).toEqual( + [cimdNoIss.tokenEndpoint, crossSite.tokenEndpoint].sort() + ) + }) + // Invariant 8: a remote CR the CRD now rejects (public + secret refs, or // bearerInBody: true) may still exist from a write that bypassed the install // route. The sweep must not spend its refresh token; the row counts as `error`. diff --git a/control-api/test/services.pluginWorkloadSdkFinalization.realPostgres.integration.test.ts b/control-api/test/services.pluginWorkloadSdkFinalization.realPostgres.integration.test.ts index 82d293d6b6..2731cb8e85 100644 --- a/control-api/test/services.pluginWorkloadSdkFinalization.realPostgres.integration.test.ts +++ b/control-api/test/services.pluginWorkloadSdkFinalization.realPostgres.integration.test.ts @@ -12,6 +12,7 @@ import { type PromptBridgeFinalizationInput, finalizePromptBridgeInTransaction, } from '../src/services/pluginWorkloadSdkFinalization.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -70,17 +71,20 @@ describeRealPostgres('Plugin Workload SDK finalization on real PostgreSQL', () = }) afterAll(async () => { - await dbPool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) + try { + await endPoolAndWaitForClients(dbPool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}"`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/services.rateLimiter.realPostgres.integration.test.ts b/control-api/test/services.rateLimiter.realPostgres.integration.test.ts index be88291000..ee027ec91f 100644 --- a/control-api/test/services.rateLimiter.realPostgres.integration.test.ts +++ b/control-api/test/services.rateLimiter.realPostgres.integration.test.ts @@ -10,6 +10,7 @@ import { acquireRateLimitConcurrencyLease, checkAndIncrementWithQuery, } from '../src/services/rateLimiterService.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -46,18 +47,22 @@ describeRealPostgres('rate limiter atomicity on real PostgreSQL', () => { }, 60_000) afterAll(async () => { - await pool?.query('DELETE FROM rate_limit_buckets WHERE bucket_key = ANY($1)', [ - [bucketKey, preAdmissionKey, messageAdmissionKey], - ]) - await pool?.end() - if (!adminPool) return - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) - await adminPool.end() + try { + await pool?.query('DELETE FROM rate_limit_buckets WHERE bucket_key = ANY($1)', [ + [bucketKey, preAdmissionKey, messageAdmissionKey], + ]) + await endPoolAndWaitForClients(pool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) + } + } finally { + await adminPool?.end() + } }) it('returns distinct post-increment counts under concurrent requests', async () => { diff --git a/control-api/test/services.rateLimiterPoolSaturation.realPostgres.integration.test.ts b/control-api/test/services.rateLimiterPoolSaturation.realPostgres.integration.test.ts index b1e44cf280..2c790031a1 100644 --- a/control-api/test/services.rateLimiterPoolSaturation.realPostgres.integration.test.ts +++ b/control-api/test/services.rateLimiterPoolSaturation.realPostgres.integration.test.ts @@ -3,6 +3,7 @@ import { createHash, randomBytes } from 'node:crypto' import { performance } from 'node:perf_hooks' import { Pool, type PoolClient } from 'pg' import request from 'supertest' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' // The limiter's upserts run on their own pool (db.ts `rateLimitPool`, max 6, // 5 s acquire), separate from the core pool (max 10, 2 s acquire). @@ -114,7 +115,7 @@ describeRealPostgres('rate limiter under pool saturation', () => { limiterPool.on('error', () => {}) const migratePool = new Pool({ connectionString }) await dbMod.initDb({ connect: () => migratePool.connect() }) - await migratePool.end() + await endPoolAndWaitForClients(migratePool) const appMod = await import('../src/app.js') const configMod = await import('../src/config.js') @@ -133,21 +134,25 @@ describeRealPostgres('rate limiter under pool saturation', () => { }, 60_000) afterAll(async () => { - for (const key of poolEnvKeys) { - const value = previousEnv.get(key) - if (value === undefined) delete process.env[key] - else process.env[key] = value - } - await corePool?.end().catch(() => {}) - await limiterPool?.end().catch(() => {}) - if (!adminPool) return - await adminPool.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + try { + for (const key of poolEnvKeys) { + const value = previousEnv.get(key) + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + await endPoolAndWaitForClients(corePool).catch(() => {}) + await endPoolAndWaitForClients(limiterPool).catch(() => {}) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) + } + } finally { + await adminPool?.end() + } }) it('runs against the production pool bounds', () => { diff --git a/control-api/test/services.subscriptionCatalogSyncCron.realPostgres.integration.test.ts b/control-api/test/services.subscriptionCatalogSyncCron.realPostgres.integration.test.ts index 3f42266b2e..1802ab1b20 100644 --- a/control-api/test/services.subscriptionCatalogSyncCron.realPostgres.integration.test.ts +++ b/control-api/test/services.subscriptionCatalogSyncCron.realPostgres.integration.test.ts @@ -304,12 +304,15 @@ describeRealPostgres('Subscription catalog sync cron on real PostgreSQL', () => }, 60_000) afterAll(async () => { - await pool?.end() - if (adminPool) { - await adminPool - .query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) - .catch(() => undefined) - await adminPool.end() + try { + await pool?.end() + if (adminPool) { + await adminPool + .query(`DROP DATABASE IF EXISTS ${quoteIdent(database)}`) + .catch(() => undefined) + } + } finally { + await adminPool?.end() } }) diff --git a/control-ui/components/AddRemoteServerWizard/index.tsx b/control-ui/components/AddRemoteServerWizard/index.tsx index 8d6ead4ec1..9c9336c57e 100644 --- a/control-ui/components/AddRemoteServerWizard/index.tsx +++ b/control-ui/components/AddRemoteServerWizard/index.tsx @@ -4,9 +4,11 @@ import React, { useEffect, useMemo, useState } from 'react' import { CreateFlowPanel } from '@components/CreateFlowPanel' import { CreateStepFlow } from '@components/CreateStepFlow' import { useToast } from '@components/Toast' +import { IconCopy } from '@components/icons' import { Button, Field, SelectInput, TextInput } from '@components/ui' import { getContexts, isSilentApiError } from '@lib/api' import type { ContextResource } from '@lib/api' +import { copyTextToClipboard } from '@lib/clipboard' import { contextResourceName } from '@lib/contextIdentity' import { buildRemoteInstallRequest, @@ -19,11 +21,18 @@ import { installRemoteServer, mapRemoteDiscoverError, mapRemoteInstallError, + remoteCallbackBlocker, + remotePreRegisteredRedirectUri, requiresPreRegisteredCredentials, shouldWarnNoRefresh, transportBlocksContinue, } from '@lib/remoteMcp' -import type { RemoteDetected, RemoteGrantScope, RemoteTransportProbe } from '@lib/remoteMcp.types' +import type { + RemoteCallbackPreview, + RemoteDetected, + RemoteGrantScope, + RemoteTransportProbe, +} from '@lib/remoteMcp.types' import { GRANT_SCOPE_OPTIONS, REGISTRATION_MODE_HINT, @@ -31,7 +40,12 @@ import { REMOTE_WIZARD_STEPS, REMOTE_WIZARD_STEP_DETAILS, } from './constants' -import type { AddRemoteServerWizardProps } from './types' +import type { + AddRemoteServerWizardProps, + AsEndpointHostsSummaryProps, + InstalledRedirectUri, + RedirectUriCopyProps, +} from './types' const STEP_TITLE_ID = 'add-remote-server-step-title' @@ -60,6 +74,8 @@ export function AddRemoteServerWizard({ // MCP transport probe result for the current URL. A `dead` probe clears // `detected` and holds the wizard on step 0; the others never block. const [transport, setTransport] = useState(null) + // Callback preview for the current detection; undefined on an older control-api. + const [callback, setCallback] = useState(undefined) // Step 1 — configuration const [clientId, setClientId] = useState('') @@ -69,6 +85,9 @@ export function AddRemoteServerWizard({ // Step 2 — install const [installing, setInstalling] = useState(false) const [installError, setInstallError] = useState('') + // A pre-registered install holds here so the operator can copy the redirect URI + // the AS must hold before leaving the wizard. + const [installed, setInstalled] = useState(null) useEffect(() => { let cancelled = false @@ -105,11 +124,20 @@ export function AddRemoteServerWizard({ const inconclusiveTransport = transport?.status === 'inconclusive' ? transport : null const aliveTransport = transport?.status === 'alive' ? transport : null + const callbackBlocker = detected ? remoteCallbackBlocker({ detected, callback }) : '' + const preRegisteredRedirectUri = needsCredentials + ? remotePreRegisteredRedirectUri(callback, serverName) + : null + const asEndpointHosts = detected?.asEndpointHosts + const credentialsComplete = !needsCredentials || (clientId.trim().length > 0 && clientSecret.length > 0) - const step1Valid = Boolean(detected) && identifiersValid && credentialsComplete + const step1Valid = + Boolean(detected) && identifiersValid && credentialsComplete && callbackBlocker === '' function canSelectStep(target: number): boolean { + // Once installed, going back would offer a second install of the same server. + if (installed) return target === 2 if (target === 0) return true if (target === 1) return Boolean(detected) && identifiersValid return step1Valid @@ -127,16 +155,28 @@ export function AddRemoteServerWizard({ function resetDetection() { setDetected(null) setTransport(null) + setCallback(undefined) setDiscoverError('') } + async function copyRedirectUri(uri: string) { + const ok = await copyTextToClipboard(uri) + showToast(ok ? 'Redirect URI copied.' : 'Copy failed — select and copy the URI manually.', { + tone: ok ? 'success' : 'error', + }) + } + async function runDetect() { setDetecting(true) setDiscoverError('') setTransport(null) + setCallback(undefined) try { - const { detected: nextDetected, transport: nextTransport } = - await discoverRemoteServer(trimmedBaseUrl) + const { + detected: nextDetected, + transport: nextTransport, + callback: nextCallback, + } = await discoverRemoteServer(trimmedBaseUrl) if (transportBlocksContinue(nextTransport)) { // Dead MCP transport: never install a URL the probe proved dead. Hold on // step 0, surface the reason (and any canonical URL suggestion), and keep @@ -147,6 +187,7 @@ export function AddRemoteServerWizard({ } setDetected(nextDetected) setTransport(nextTransport ?? null) + setCallback(nextCallback) setStep(1) } catch (e) { if (isSilentApiError(e)) return @@ -173,10 +214,18 @@ export function AddRemoteServerWizard({ }) const res = await installRemoteServer(body) showToast(`Remote server ${res.serverName} installed.`, { tone: 'success' }) + if (res.registrationMode === 'pre-registered' && res.redirectUri) { + setInstalled({ + redirectUri: res.redirectUri, + changedSincePreview: + preRegisteredRedirectUri !== null && preRegisteredRedirectUri !== res.redirectUri, + }) + return + } onInstalled() } catch (e) { if (isSilentApiError(e)) return - setInstallError(mapRemoteInstallError(e)) + setInstallError(mapRemoteInstallError(e, { callbackVariant: callback?.variant })) } finally { setInstalling(false) } @@ -189,7 +238,7 @@ export function AddRemoteServerWizard({
{ event.preventDefault() - if (step === 2) void runInstall() + if (step === 2 && !installed) void runInstall() }} > {REGISTRATION_MODE_HINT[detected.registrationMode]}

+ {callbackBlocker ? ( +
+ {callbackBlocker} +
+ ) : null} + + {asEndpointHosts ? ( + + ) : null} + + {needsCredentials && preRegisteredRedirectUri ? ( + + + + ) : null} + {needsCredentials ? (
) : null} - {step === 2 && detected && installMode ? ( + {step === 2 && installed ? ( +
+

+ {serverName} is installed. Users can connect once this redirect URI is registered in + the provider’s OAuth client. +

+ {installed.changedSincePreview ? ( +
+ The authorization server changed since detection, so this redirect URI differs + from the one shown before. Register this one. +
+ ) : null} + + + +
+ ) : null} + + {step === 2 && !installed && detected && installMode ? (
@@ -456,8 +542,16 @@ export function AddRemoteServerWizard({ Token endpoint {detected.endpoints.token}
+ {preRegisteredRedirectUri ? ( +
+ Redirect URI + {preRegisteredRedirectUri} +
+ ) : null}
+ {asEndpointHosts ? : null} + {noRefresh ? (
Reminder: this server does not support token refresh — users will re-authorize @@ -480,14 +574,21 @@ export function AddRemoteServerWizard({ ) : null}
- + {installed ? ( + + ) : null} + {installed ? null : ( + + )} {step === 0 ? ( detected ? ( @@ -518,7 +619,7 @@ export function AddRemoteServerWizard({ ) : null} - {step === 2 ? ( + {step === 2 && !installed ? ( +
+ ) +} + +function AsEndpointHostsSummary({ hosts, withExplanation = false }: AsEndpointHostsSummaryProps) { + return ( + <> + {withExplanation ? ( +
+ This authorization server does not identify itself in its OAuth responses (RFC 9207), so + the connector trusts these hosts because they share the issuer’s domain. Confirm they + belong to the provider before installing. +
+ ) : null} +
+
+ Authorization host + {hosts.authorization} +
+
+ Token host + {hosts.token} +
+ {hosts.registration ? ( +
+ Registration host + {hosts.registration} +
+ ) : null} +
+ + ) +} diff --git a/control-ui/components/AddRemoteServerWizard/types.ts b/control-ui/components/AddRemoteServerWizard/types.ts index 9ec15e9806..346b4951b2 100644 --- a/control-ui/components/AddRemoteServerWizard/types.ts +++ b/control-ui/components/AddRemoteServerWizard/types.ts @@ -1,5 +1,5 @@ import type { ReactNode } from 'react' -import type { RemoteGrantScope } from '../../lib/remoteMcp.types' +import type { RemoteAsEndpointHosts, RemoteGrantScope } from '../../lib/remoteMcp.types' export type AddRemoteServerWizardProps = { /** Rendered create-page header (icon, title, back). */ @@ -15,3 +15,22 @@ export type GrantScopeOption = { label: string description: string } + +/** A pre-registered install that succeeded; the wizard holds to show its redirect URI. */ +export type InstalledRedirectUri = { + /** The URI control-api reported in the 201 — authoritative over the preview. */ + redirectUri: string + /** The 201 URI differs from the one previewed before install. */ + changedSincePreview: boolean +} + +export type RedirectUriCopyProps = { + uri: string + onCopy: (uri: string) => void +} + +export type AsEndpointHostsSummaryProps = { + hosts: RemoteAsEndpointHosts + /** Explain why the hosts are shown (the configuration step; the confirm step omits it). */ + withExplanation?: boolean +} diff --git a/control-ui/components/McpServerTable.tsx b/control-ui/components/McpServerTable.tsx index 45c611cfcd..466a2e165e 100644 --- a/control-ui/components/McpServerTable.tsx +++ b/control-ui/components/McpServerTable.tsx @@ -22,6 +22,7 @@ import { TableHeaderRow } from './TableHeaderRow' import type { TableHeaderColumn } from './TableHeaderRow/types' import { TablePanelHeader } from './TablePanelHeader' import { IconRefresh } from './icons' +import { Button } from './ui' const ENABLED_TOOLTIP = 'Enabled controls whether this server is available to agents.' type ConnectorSortKey = 'name' | 'description' | 'managed' | 'enabled' | 'status' @@ -275,35 +276,31 @@ export function McpServerTable({ {isInitialLoad ? 'Connectors' : `Connectors (${filteredRows.length})`} } - titleActions={ + secondaryActions={ onCreate || onAddRemote ? ( - - ) : null + <> + {onCreate ? ( + + ) : null} + {onAddRemote ? ( + + ) : null} + + ) : undefined } subtitle="Browse connector deployments and agent access." actionsClassName="cu-table-panel__actions--mcp" diff --git a/control-ui/components/__tests__/AddRemoteServerWizard.test.tsx b/control-ui/components/__tests__/AddRemoteServerWizard.test.tsx index 57d1132dd9..0edb96a004 100644 --- a/control-ui/components/__tests__/AddRemoteServerWizard.test.tsx +++ b/control-ui/components/__tests__/AddRemoteServerWizard.test.tsx @@ -1,20 +1,28 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react' import * as api from '../../lib/api' +import * as clipboard from '../../lib/clipboard' import * as remoteMcp from '../../lib/remoteMcp' -import type { - DiscoverRemoteResponse, - RemoteDetected, - RemoteTransportProbe, -} from '../../lib/remoteMcp.types' +import type { DiscoverRemoteResponse, RemoteTransportProbe } from '../../lib/remoteMcp.types' import { buildContextResource } from '../../test/fixtures/contextResource' import { - DCR_PUBLIC_DETECTED, - NOTION_DETECTED, - NOTION_TRANSPORT_ALIVE, TRANSPORT_INCONCLUSIVE_TIMEOUT, VERCEL_TRANSPORT_DEAD, } from '../../test/fixtures/remoteMcpDiscovery' +import { + ATLASSIAN_DISCOVER, + ATLASSIAN_DISCOVER_UNCONFIGURED, + CALLBACK_UNCONFIGURED_FAILURE, + LINEAR_DISCOVER, + LINEAR_DISCOVER_UNCONFIGURED, + LINEAR_INSTALLED, + NOTION_DISCOVER, + OLDER_CONTROL_API_DCR_DISCOVER, + PRE_REGISTERED_PER_SERVER_DISCOVER, + PRE_REGISTERED_PER_SERVER_INSTALLED, + PRE_REGISTERED_SHARED_INSTALLED, + apiErrorFrom, +} from '../../test/fixtures/remoteMcpWire' import { AddRemoteServerWizard } from '../AddRemoteServerWizard' import { ToastProvider } from '../Toast' @@ -38,16 +46,23 @@ vi.mock('../../lib/api', async importOriginal => { } }) +// The wizard copies through this helper; the test asserts what it is handed. +vi.mock('../../lib/clipboard', () => ({ copyTextToClipboard: vi.fn(async () => true) })) + const discoverMock = vi.mocked(remoteMcp.discoverRemoteServer) +const copyMock = vi.mocked(clipboard.copyTextToClipboard) const installMock = vi.mocked(remoteMcp.installRemoteServer) const getContextsMock = vi.mocked(api.getContexts) -/** The discover response shape control-api returns: `detected` plus the probe. */ +/** + * A captured control-api discover body (goldens from the real router), optionally with + * the transport probe swapped for another probe outcome of the same producer. + */ function discovered( - detected: RemoteDetected, + body: DiscoverRemoteResponse, transport?: RemoteTransportProbe ): DiscoverRemoteResponse { - return { detected, transport } + return transport ? { ...body, transport } : body } afterEach(() => { @@ -57,6 +72,10 @@ afterEach(() => { getContextsMock.mockResolvedValue({ items: [buildContextResource({ metadata: { name: 'research', resourceVersion: 'rv1' } })], }) + // mockClear keeps queued *Once values; reset so an unused one cannot leak into the + // next test, then restore the default successful copy. + copyMock.mockReset() + copyMock.mockResolvedValue(true) }) function renderWizard(props?: Partial[0]>) { @@ -92,7 +111,7 @@ function clickDetect() { describe('AddRemoteServerWizard', () => { it('detects a server and advances to the configuration step showing the detected values', async () => { - discoverMock.mockResolvedValue(discovered(NOTION_DETECTED)) + discoverMock.mockResolvedValue(discovered(NOTION_DISCOVER)) renderWizard() await fillIdentity() clickDetect() @@ -107,7 +126,7 @@ describe('AddRemoteServerWizard', () => { }) it('D-4: editing the URL after a detect invalidates it and blocks proceeding until re-detect', async () => { - discoverMock.mockResolvedValue(discovered(NOTION_DETECTED)) + discoverMock.mockResolvedValue(discovered(NOTION_DISCOVER)) renderWizard() await fillIdentity() clickDetect() @@ -145,9 +164,7 @@ describe('AddRemoteServerWizard', () => { }) it('shows client credential fields only for the manual (pre-registered) mode', async () => { - // manual has no real probed pilot; this is UI state, not a producer fixture. - const manualDetected: RemoteDetected = { ...NOTION_DETECTED, registrationMode: 'manual' } - discoverMock.mockResolvedValue(discovered(manualDetected)) + discoverMock.mockResolvedValue(discovered(PRE_REGISTERED_PER_SERVER_DISCOVER)) renderWizard() await fillIdentity() clickDetect() @@ -159,15 +176,15 @@ describe('AddRemoteServerWizard', () => { }) it('does not show credential fields for cimd or dcr modes', async () => { - discoverMock.mockResolvedValue(discovered(NOTION_DETECTED)) // cimd + discoverMock.mockResolvedValue(discovered(LINEAR_DISCOVER)) // cimd renderWizard() - await fillIdentity() + await fillIdentity('https://mcp.linear.app/mcp', 'linear') clickDetect() - await screen.findByText('https://mcp.notion.com/authorize') + await screen.findByText('https://mcp.linear.app/authorize') expect(screen.queryByLabelText(/Client ID/)).not.toBeInTheDocument() cleanup() - discoverMock.mockResolvedValue(discovered(DCR_PUBLIC_DETECTED)) // dcr + discoverMock.mockResolvedValue(discovered(NOTION_DISCOVER)) // dcr renderWizard() await fillIdentity() clickDetect() @@ -176,9 +193,13 @@ describe('AddRemoteServerWizard', () => { }) it('D-8: shows the no-refresh warning banner iff supportsRefresh is false', async () => { - const noRefresh: RemoteDetected = { - ...NOTION_DETECTED, - quirks: { bearerInBody: false, supportsRefresh: false }, + // No recorded pilot lacks refresh; only the quirk the banner reads is changed. + const noRefresh: DiscoverRemoteResponse = { + ...NOTION_DISCOVER, + detected: { + ...NOTION_DISCOVER.detected, + quirks: { bearerInBody: false, supportsRefresh: false }, + }, } discoverMock.mockResolvedValue(discovered(noRefresh)) renderWizard() @@ -187,7 +208,7 @@ describe('AddRemoteServerWizard', () => { expect(await screen.findByText(/does not support token refresh/i)).toBeInTheDocument() cleanup() - discoverMock.mockResolvedValue(discovered(NOTION_DETECTED)) // supportsRefresh true + discoverMock.mockResolvedValue(discovered(NOTION_DISCOVER)) // supportsRefresh true renderWizard() await fillIdentity() clickDetect() @@ -196,19 +217,12 @@ describe('AddRemoteServerWizard', () => { }) it('installs successfully: sends the request, fires onInstalled, and toasts', async () => { - discoverMock.mockResolvedValue(discovered(NOTION_DETECTED)) - installMock.mockResolvedValue({ - serverName: 'notion-remote', - namespace: 'mcp-server', - contextRef: 'research', - contextUpdated: true, - clientMode: 'public', - registrationMode: 'cimd', - }) + discoverMock.mockResolvedValue(discovered(LINEAR_DISCOVER)) + installMock.mockResolvedValue(LINEAR_INSTALLED) const { onInstalled } = renderWizard() - await fillIdentity() + await fillIdentity('https://mcp.linear.app/mcp', 'linear') clickDetect() - await screen.findByText('https://mcp.notion.com/authorize') + await screen.findByText('https://mcp.linear.app/authorize') fireEvent.click(screen.getByRole('button', { name: 'Continue' })) // → Confirm fireEvent.click(await screen.findByRole('button', { name: 'Install remote server' })) @@ -216,9 +230,9 @@ describe('AddRemoteServerWizard', () => { await waitFor(() => expect(installMock).toHaveBeenCalledTimes(1)) expect(installMock).toHaveBeenCalledWith( expect.objectContaining({ - serverName: 'notion-remote', + serverName: 'linear', contextRef: 'research', - baseUrl: 'https://mcp.notion.com/mcp', + baseUrl: 'https://mcp.linear.app/mcp', mode: 'cimd', grantScope: 'user', }) @@ -228,19 +242,21 @@ describe('AddRemoteServerWizard', () => { }) it('shows a mapped install error inline', async () => { - discoverMock.mockResolvedValue(discovered(NOTION_DETECTED)) - installMock.mockRejectedValue( - Object.assign(new Error('503'), { code: 'callback_base_url_unconfigured', body: {} }) - ) + // Shared CIMD on a deployment without a callback base URL: detect does not block, + // the install answers 503. + discoverMock.mockResolvedValue(discovered(LINEAR_DISCOVER_UNCONFIGURED)) + installMock.mockRejectedValue(apiErrorFrom(CALLBACK_UNCONFIGURED_FAILURE)) const { onInstalled } = renderWizard() - await fillIdentity() + await fillIdentity('https://mcp.linear.app/mcp', 'linear') clickDetect() - await screen.findByText('https://mcp.notion.com/authorize') + await screen.findByText('https://mcp.linear.app/authorize') fireEvent.click(screen.getByRole('button', { name: 'Continue' })) fireEvent.click(await screen.findByRole('button', { name: 'Install remote server' })) - expect(await screen.findByText(/callback url is not configured/i)).toBeInTheDocument() + const error = await screen.findByText(/callback url is not configured/i) + expect(error).toHaveTextContent('CONTROL_API_OAUTH_CALLBACK_BASE_URL') + expect(error).not.toHaveTextContent(/redirect URI of its own/i) expect(onInstalled).not.toHaveBeenCalled() }) @@ -249,7 +265,7 @@ describe('AddRemoteServerWizard', () => { it('C5 transport dead: holds on step 0, alerts, offers the suggestion, and never shows Continue', async () => { // Repro for issue 26-09-25: OAuth valid but MCP transport dead at the typed // path. Against the parent head the wizard advances to step 1 instead. - discoverMock.mockResolvedValue(discovered(NOTION_DETECTED, VERCEL_TRANSPORT_DEAD)) + discoverMock.mockResolvedValue(discovered(NOTION_DISCOVER, VERCEL_TRANSPORT_DEAD)) renderWizard() await fillIdentity('https://mcp.vercel.com/mcp', 'vercel-mcp') clickDetect() @@ -275,7 +291,7 @@ describe('AddRemoteServerWizard', () => { }) it('C5 transport inconclusive: advances and shows a status banner on the config and confirm steps', async () => { - discoverMock.mockResolvedValue(discovered(NOTION_DETECTED, TRANSPORT_INCONCLUSIVE_TIMEOUT)) + discoverMock.mockResolvedValue(discovered(NOTION_DISCOVER, TRANSPORT_INCONCLUSIVE_TIMEOUT)) renderWizard() await fillIdentity() clickDetect() @@ -294,7 +310,7 @@ describe('AddRemoteServerWizard', () => { }) it('C5 transport alive: advances with no transport banner and shows the reachable summary row', async () => { - discoverMock.mockResolvedValue(discovered(NOTION_DETECTED, NOTION_TRANSPORT_ALIVE)) + discoverMock.mockResolvedValue(discovered(NOTION_DISCOVER)) renderWizard() await fillIdentity() clickDetect() @@ -308,7 +324,7 @@ describe('AddRemoteServerWizard', () => { }) it('C5 install transport_unreachable: shows the mapped error inline with the suggested URL', async () => { - discoverMock.mockResolvedValue(discovered(NOTION_DETECTED, NOTION_TRANSPORT_ALIVE)) + discoverMock.mockResolvedValue(discovered(NOTION_DISCOVER)) installMock.mockRejectedValue( Object.assign(new Error('400'), { code: 'transport_unreachable', @@ -334,4 +350,147 @@ describe('AddRemoteServerWizard', () => { expect(screen.getByText(/https:\/\/mcp\.vercel\.com\//)).toBeInTheDocument() expect(onInstalled).not.toHaveBeenCalled() }) + + // ── Per-server callback (AS without RFC 9207) ────────────────────────────── + + async function detectPreRegisteredPerServer(name = 'linear-pre') { + discoverMock.mockResolvedValue(discovered(PRE_REGISTERED_PER_SERVER_DISCOVER)) + const utils = renderWizard() + await fillIdentity('https://mcp.linear.app/mcp', name) + clickDetect() + await screen.findByLabelText(/Client ID/) + return utils + } + + function perServerUri(name: string): string { + const template = PRE_REGISTERED_PER_SERVER_DISCOVER.callback?.redirectUriTemplate ?? '' + return template.replace('{serverName}', name) + } + + it('pre-registered per-server: shows the redirect URI for this server name, with copy', async () => { + await detectPreRegisteredPerServer('hubspot') + const uri = screen.getByRole('textbox', { name: 'Redirect URI' }) + expect(uri).toHaveValue(perServerUri('hubspot')) + expect(uri).toHaveAttribute('readonly') + expect(screen.getByRole('button', { name: 'Copy redirect URI' })).toBeInTheDocument() + }) + + it('pre-registered per-server: Copy hands the shown URI to the clipboard and confirms', async () => { + await detectPreRegisteredPerServer('hubspot') + fireEvent.click(screen.getByRole('button', { name: 'Copy redirect URI' })) + + await waitFor(() => expect(copyMock).toHaveBeenCalledWith(perServerUri('hubspot'))) + expect(copyMock).toHaveBeenCalledTimes(1) + expect(await screen.findByText('Redirect URI copied.')).toBeInTheDocument() + }) + + it('pre-registered per-server: a failed copy tells the operator to copy it by hand', async () => { + copyMock.mockResolvedValueOnce(false) + await detectPreRegisteredPerServer('hubspot') + fireEvent.click(screen.getByRole('button', { name: 'Copy redirect URI' })) + + expect( + await screen.findByText('Copy failed — select and copy the URI manually.') + ).toBeInTheDocument() + }) + + it('pre-registered per-server: after the 201 holds on the URI until Done', async () => { + installMock.mockResolvedValue(PRE_REGISTERED_PER_SERVER_INSTALLED) + const { onInstalled } = await detectPreRegisteredPerServer() + fireEvent.change(screen.getByLabelText(/Client ID/), { target: { value: 'cid' } }) + fireEvent.change(screen.getByLabelText(/Client secret/), { target: { value: 'secret' } }) + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + fireEvent.click(await screen.findByRole('button', { name: 'Install remote server' })) + + await waitFor(() => expect(installMock).toHaveBeenCalledTimes(1)) + expect(installMock).toHaveBeenCalledWith( + expect.objectContaining({ mode: 'pre-registered', clientId: 'cid' }) + ) + const uri = await screen.findByRole('textbox', { name: 'Redirect URI' }) + expect(uri).toHaveValue(PRE_REGISTERED_PER_SERVER_INSTALLED.redirectUri) + expect(screen.queryByText(/differs from the one shown before/i)).not.toBeInTheDocument() + // The URI the operator copies after install is the one control-api reported. + fireEvent.click(screen.getByRole('button', { name: 'Copy redirect URI' })) + await waitFor(() => + expect(copyMock).toHaveBeenCalledWith(PRE_REGISTERED_PER_SERVER_INSTALLED.redirectUri) + ) + expect(onInstalled).not.toHaveBeenCalled() + expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument() + + // Enter in the read-only URI field must not submit a second install. + fireEvent.submit(uri.closest('form') as HTMLFormElement) + expect(installMock).toHaveBeenCalledTimes(1) + + fireEvent.click(screen.getByRole('button', { name: 'Done' })) + expect(onInstalled).toHaveBeenCalledTimes(1) + }) + + it('pre-registered: the 201 URI wins when the AS changed since detection', async () => { + installMock.mockResolvedValue(PRE_REGISTERED_SHARED_INSTALLED) + await detectPreRegisteredPerServer() + fireEvent.change(screen.getByLabelText(/Client ID/), { target: { value: 'cid' } }) + fireEvent.change(screen.getByLabelText(/Client secret/), { target: { value: 'secret' } }) + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + fireEvent.click(await screen.findByRole('button', { name: 'Install remote server' })) + + const uri = await screen.findByRole('textbox', { name: 'Redirect URI' }) + expect(uri).toHaveValue(PRE_REGISTERED_SHARED_INSTALLED.redirectUri) + expect(PRE_REGISTERED_SHARED_INSTALLED.redirectUri).not.toBe(perServerUri('linear-pre')) + expect(screen.getByText(/differs from the one shown before/i)).toBeInTheDocument() + }) + + it('an AS without RFC 9207 behind an older control-api (no callback preview) is blocked', async () => { + discoverMock.mockResolvedValue(OLDER_CONTROL_API_DCR_DISCOVER) + renderWizard() + await fillIdentity() + clickDetect() + + expect(await screen.findByRole('alert')).toHaveTextContent(/update control-api/i) + expect( + screen.queryByRole('region', { name: 'Authorization server hosts' }) + ).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled() + }) + + it('per-server without a configured callback base URL is blocked before install', async () => { + discoverMock.mockResolvedValue(discovered(ATLASSIAN_DISCOVER_UNCONFIGURED)) + renderWizard() + await fillIdentity('https://mcp.atlassian.com/v2/mcp', 'atlassian') + clickDetect() + + expect(await screen.findByRole('alert')).toHaveTextContent(/callback URL is not configured/i) + expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled() + expect(screen.getByRole('button', { name: /Confirm/ })).toBeDisabled() + }) + + it('without RFC 9207, shows the AS endpoint hosts on the configure and confirm steps', async () => { + discoverMock.mockResolvedValue(discovered(ATLASSIAN_DISCOVER)) + renderWizard() + await fillIdentity('https://mcp.atlassian.com/v2/mcp', 'atlassian') + clickDetect() + + const hosts = await screen.findByRole('region', { name: 'Authorization server hosts' }) + expect(hosts).toHaveTextContent('Token host') + expect(hosts).toHaveTextContent('auth.atlassian.com') + expect(screen.getByText(/does not identify itself in its OAuth responses/i)).toBeInTheDocument() + // DCR registers its own URI: nothing for the operator to copy. + expect(screen.queryByRole('textbox', { name: 'Redirect URI' })).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + await screen.findByRole('button', { name: 'Install remote server' }) + expect(screen.getByRole('region', { name: 'Authorization server hosts' })).toHaveTextContent( + 'auth.atlassian.com' + ) + }) + + it('with RFC 9207 (shared callback), shows no endpoint hosts', async () => { + discoverMock.mockResolvedValue(discovered(LINEAR_DISCOVER)) + renderWizard() + await fillIdentity('https://mcp.linear.app/mcp', 'linear') + clickDetect() + await screen.findByText('https://mcp.linear.app/authorize') + expect( + screen.queryByRole('region', { name: 'Authorization server hosts' }) + ).not.toBeInTheDocument() + }) }) diff --git a/control-ui/components/__tests__/McpServerTable.test.tsx b/control-ui/components/__tests__/McpServerTable.test.tsx index 91457ff6a3..67e3839642 100644 --- a/control-ui/components/__tests__/McpServerTable.test.tsx +++ b/control-ui/components/__tests__/McpServerTable.test.tsx @@ -569,3 +569,62 @@ describe('McpServerTable — row actions kebab', () => { ).toBeInTheDocument() }) }) + +// ───────────────────────────────────────────────────────────────────────────── +// Header actions +// ───────────────────────────────────────────────────────────────────────────── +describe('McpServerTable — header actions', () => { + function renderWithActions(options: { loading?: boolean; empty?: boolean } = {}) { + const onCreate = vi.fn() + const onAddRemote = vi.fn() + const onInstallFromRegistry = vi.fn() + render( + + ) + return { onCreate, onAddRemote, onInstallFromRegistry } + } + + it('offers create, add remote and marketplace as header buttons, not a kebab menu', () => { + const { onCreate, onAddRemote, onInstallFromRegistry } = renderWithActions() + + expect(screen.queryByRole('button', { name: 'Connector actions' })).not.toBeInTheDocument() + + const create = screen.getByRole('button', { name: 'Create connector' }) + const addRemote = screen.getByRole('button', { name: 'Add remote server' }) + const marketplace = screen.getByRole('button', { name: 'Install from Marketplace' }) + // One primary CTA; the other two are secondary buttons of the same size. + expect(marketplace).toHaveClass('cu-btn--primary') + for (const secondary of [create, addRemote]) { + expect(secondary).not.toHaveClass('cu-btn--primary') + expect(secondary).toHaveClass('cu-btn', 'cu-btn--sm', 'cu-btn--mcp-install') + } + + fireEvent.click(create) + fireEvent.click(addRemote) + fireEvent.click(marketplace) + expect(onCreate).toHaveBeenCalledTimes(1) + expect(onAddRemote).toHaveBeenCalledTimes(1) + expect(onInstallFromRegistry).toHaveBeenCalledTimes(1) + }) + + it('disables every header CTA during the initial load', () => { + renderWithActions({ loading: true, empty: true }) + + for (const name of ['Create connector', 'Add remote server', 'Install from Marketplace']) { + expect(screen.getByRole('button', { name })).toBeDisabled() + } + }) + + it('omits a header button whose handler is not provided', () => { + render() + + expect(screen.getByRole('button', { name: 'Add remote server' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Create connector' })).not.toBeInTheDocument() + }) +}) diff --git a/control-ui/e2e/qa-recorder-connector-create-access.spec.ts b/control-ui/e2e/qa-recorder-connector-create-access.spec.ts index c61deed974..1d150084c3 100644 --- a/control-ui/e2e/qa-recorder-connector-create-access.spec.ts +++ b/control-ui/e2e/qa-recorder-connector-create-access.spec.ts @@ -35,9 +35,8 @@ async function continueWizard(page: Page): Promise { async function openCreateConnectorWizard(page: Page): Promise { await page.getByRole('link', { name: 'Installed Connectors', exact: true }).click() await expect(page).toHaveURL(/\/connectors$/, { timeout: 20_000 }) - // Creation lives behind the "Connector actions" kebab (lowercase c menuitem). - await page.getByRole('button', { name: 'Connector actions' }).click() - await page.getByRole('menuitem', { name: 'Create connector', exact: true }).click() + // Creation is a header button next to "Add remote server" and the Marketplace CTA. + await page.getByRole('button', { name: 'Create connector', exact: true }).click() await expect(page).toHaveURL(/\/connectors\/new$/, { timeout: 20_000 }) } diff --git a/control-ui/e2e/qa-recorder-connector-edit.spec.ts b/control-ui/e2e/qa-recorder-connector-edit.spec.ts index 28f9e31d3d..f9a51dac0d 100644 --- a/control-ui/e2e/qa-recorder-connector-edit.spec.ts +++ b/control-ui/e2e/qa-recorder-connector-edit.spec.ts @@ -54,9 +54,8 @@ async function createEmptyContext(page: Page, contextName: string): Promise { await page.getByRole('link', { name: 'Installed Connectors', exact: true }).click() await expect(page).toHaveURL(/\/connectors$/, { timeout: 20_000 }) - // Creation lives behind the "Connector actions" kebab (lowercase c menuitem). - await page.getByRole('button', { name: 'Connector actions' }).click() - await page.getByRole('menuitem', { name: 'Create connector', exact: true }).click() + // Creation is a header button next to "Add remote server" and the Marketplace CTA. + await page.getByRole('button', { name: 'Create connector', exact: true }).click() await expect(page).toHaveURL(/\/connectors\/new$/, { timeout: 20_000 }) await page.getByPlaceholder('my-mcp-server').fill(connectorName) diff --git a/control-ui/lib/__tests__/remoteMcp.test.ts b/control-ui/lib/__tests__/remoteMcp.test.ts index 2d6ebe38f0..37f437609b 100644 --- a/control-ui/lib/__tests__/remoteMcp.test.ts +++ b/control-ui/lib/__tests__/remoteMcp.test.ts @@ -1,12 +1,30 @@ import { describe, expect, it } from 'vitest' import { - DCR_CONFIDENTIAL_DETECTED, - DCR_PUBLIC_DETECTED, - NOTION_DETECTED, NOTION_TRANSPORT_ALIVE, TRANSPORT_INCONCLUSIVE_TIMEOUT, VERCEL_TRANSPORT_DEAD, } from '../../test/fixtures/remoteMcpDiscovery' +import { + ATLASSIAN_DISCOVER, + ATLASSIAN_DISCOVER_UNCONFIGURED, + CALLBACK_UNCONFIGURED_FAILURE, + CIMD_WITHOUT_ISS_BINDING_FAILURE, + CLIENT_ID_IN_USE_FAILURE, + DCR_CONFIDENTIAL_DETECTED, + DCR_REDIRECT_MISMATCH_FAILURE, + DROPBOX_DISCOVER_FAILURE, + ISSUER_PUBLIC_SUFFIX_DISCOVER_FAILURE, + LINEAR_DETECTED, + LINEAR_DISCOVER, + LINEAR_DISCOVER_UNCONFIGURED, + NOTION_DETECTED, + NOTION_DISCOVER, + OLDER_CONTROL_API_DCR_DISCOVER, + OLDER_CONTROL_API_LINEAR_DISCOVER, + PRE_REGISTERED_PER_SERVER_DETECTED, + PRE_REGISTERED_PER_SERVER_DISCOVER, + apiErrorFrom, +} from '../../test/fixtures/remoteMcpWire' import { buildRemoteInstallRequest, describeDiscoveryError, @@ -17,11 +35,14 @@ import { installModeForRegistration, mapRemoteDiscoverError, mapRemoteInstallError, + remoteCallbackBlocker, + remoteCallbackVariant, + remotePreRegisteredRedirectUri, requiresPreRegisteredCredentials, shouldWarnNoRefresh, transportBlocksContinue, } from '../remoteMcp' -import type { RemoteDetected, RemoteTransportProbe } from '../remoteMcp.types' +import type { RemoteTransportProbe } from '../remoteMcp.types' describe('installModeForRegistration (D-3/D-7)', () => { it('maps each registration mode to its install mode', () => { @@ -65,11 +86,10 @@ describe('shouldWarnNoRefresh (D-8)', () => { describe('displayClientMode', () => { it('derives the confirm-step client mode from the resolved install mode', () => { - expect(displayClientMode(NOTION_DETECTED)).toBe('public') // cimd - expect(displayClientMode(DCR_PUBLIC_DETECTED)).toBe('public') + expect(displayClientMode(LINEAR_DETECTED)).toBe('public') // cimd + expect(displayClientMode(NOTION_DETECTED)).toBe('public') // dcr, `none` offered expect(displayClientMode(DCR_CONFIDENTIAL_DETECTED)).toBe('confidential') - const manual: RemoteDetected = { ...NOTION_DETECTED, registrationMode: 'manual' } - expect(displayClientMode(manual)).toBe('confidential') // pre-registered + expect(displayClientMode(PRE_REGISTERED_PER_SERVER_DETECTED)).toBe('confidential') }) }) @@ -92,6 +112,78 @@ describe('getRemoteServerNameError', () => { }) }) +describe('remoteCallbackVariant', () => { + it('reads the backend callback preview', () => { + expect(remoteCallbackVariant(LINEAR_DISCOVER)).toBe('shared') + expect(remoteCallbackVariant(NOTION_DISCOVER)).toBe('per-server') + expect(remoteCallbackVariant(PRE_REGISTERED_PER_SERVER_DISCOVER)).toBe('per-server') + }) + + it('on a control-api without the preview, falls back to the RFC 9207 rule', () => { + expect(remoteCallbackVariant(OLDER_CONTROL_API_LINEAR_DISCOVER)).toBe('shared') + expect(remoteCallbackVariant(OLDER_CONTROL_API_DCR_DISCOVER)).toBe('per-server') + }) +}) + +describe('remoteCallbackBlocker', () => { + it('does not block a configured shared or per-server install', () => { + expect(remoteCallbackBlocker(LINEAR_DISCOVER)).toBe('') + expect(remoteCallbackBlocker(ATLASSIAN_DISCOVER)).toBe('') + expect(remoteCallbackBlocker(PRE_REGISTERED_PER_SERVER_DISCOVER)).toBe('') + }) + + it('blocks a per-server install when the callback base URL is not configured', () => { + expect(remoteCallbackBlocker(ATLASSIAN_DISCOVER_UNCONFIGURED)).toMatch( + /callback URL is not configured/i + ) + }) + + it('does not block a shared install without a configured base URL', () => { + // control-api decides per mode there (a shared CIMD/DCR install still answers 503). + expect(LINEAR_DISCOVER_UNCONFIGURED.callback).toEqual({ configured: false, variant: 'shared' }) + expect(remoteCallbackBlocker(LINEAR_DISCOVER_UNCONFIGURED)).toBe('') + }) + + it('on an older control-api, blocks every install against an AS without RFC 9207', () => { + expect(OLDER_CONTROL_API_DCR_DISCOVER.detected.registrationMode).toBe('dcr') + expect(remoteCallbackBlocker(OLDER_CONTROL_API_DCR_DISCOVER)).toMatch(/update control-api/i) + expect(remoteCallbackBlocker(OLDER_CONTROL_API_LINEAR_DISCOVER)).toBe('') + }) +}) + +describe('remotePreRegisteredRedirectUri', () => { + const perServer = PRE_REGISTERED_PER_SERVER_DISCOVER.callback + + it('fills the server name into the per-server template', () => { + expect(remotePreRegisteredRedirectUri(perServer, 'hubspot')).toBe( + perServer?.redirectUriTemplate?.replace('{serverName}', 'hubspot') + ) + expect(remotePreRegisteredRedirectUri(perServer, 'hubspot')).toMatch( + /\/api\/v1\/oauth-callback\/remote\/hubspot$/ + ) + }) + + it('shows nothing until the name is valid', () => { + expect(remotePreRegisteredRedirectUri(perServer, '')).toBeNull() + expect(remotePreRegisteredRedirectUri(perServer, 'Bad_Name')).toBeNull() + expect(remotePreRegisteredRedirectUri(perServer, '-x')).toBeNull() + }) + + it('returns the shared URI as-is, whatever the name', () => { + expect(remotePreRegisteredRedirectUri(LINEAR_DISCOVER.callback, '')).toBe( + LINEAR_DISCOVER.callback?.redirectUriTemplate + ) + }) + + it('never shows a DCR template (control-api registers it) nor an unconfigured one', () => { + expect(remotePreRegisteredRedirectUri(ATLASSIAN_DISCOVER.callback, 'atlassian')).toBeNull() + expect( + remotePreRegisteredRedirectUri(ATLASSIAN_DISCOVER_UNCONFIGURED.callback, 'atlassian') + ).toBeNull() + expect(remotePreRegisteredRedirectUri(undefined, 'atlassian')).toBeNull() + }) +}) + describe('getRemoteBaseUrlError', () => { it('requires a URL', () => { expect(getRemoteBaseUrlError('')).toMatch(/required/i) @@ -174,6 +266,7 @@ describe('describeDiscoveryError', () => { 'redirect_blocked', 'prm_resource_mismatch', 'issuer_mismatch', + 'as_endpoints_cross_site', ] as const const texts = kinds.map(describeDiscoveryError) expect(new Set(texts).size).toBe(kinds.length) @@ -184,6 +277,19 @@ describe('describeDiscoveryError', () => { }) describe('mapRemoteDiscoverError', () => { + it('maps the Dropbox cross-site failure naming the offending endpoint', () => { + const copy = mapRemoteDiscoverError(apiErrorFrom(DROPBOX_DISCOVER_FAILURE)) + expect(copy).toMatch(/token endpoint is not on the issuer's domain/i) + expect(copy).toMatch(/RFC 9207/) + }) + + it('maps an issuer without a registrable domain to its own copy', () => { + const copy = mapRemoteDiscoverError(apiErrorFrom(ISSUER_PUBLIC_SUFFIX_DISCOVER_FAILURE)) + expect(copy).toMatch(/issuer has no registrable domain/i) + expect(copy).not.toMatch(/endpoint is not on/i) + expect(copy).not.toBe(mapRemoteDiscoverError(apiErrorFrom(DROPBOX_DISCOVER_FAILURE))) + }) + it('maps a discovery_failed detail kind', () => { const err = Object.assign(new Error('502 ...'), { status: 502, @@ -233,12 +339,80 @@ describe('mapRemoteInstallError', () => { expect(mapRemoteInstallError(err)).toContain('invalid_response') }) - it('maps callback_base_url_unconfigured to plain copy', () => { - const err = Object.assign(new Error('503'), { - code: 'callback_base_url_unconfigured', - body: {}, + it('maps callback_base_url_unconfigured by the previewed callback variant', () => { + const err = apiErrorFrom(CALLBACK_UNCONFIGURED_FAILURE) + const perServer = mapRemoteInstallError(err, { callbackVariant: 'per-server' }) + expect(perServer).toMatch(/callback url is not configured/i) + expect(perServer).toMatch(/redirect URI of its own/i) + // Shared CIMD/DCR without an origin answer the same 503; so does an unknown variant. + for (const copy of [ + mapRemoteInstallError(err, { callbackVariant: 'shared' }), + mapRemoteInstallError(err), + ]) { + expect(copy).toMatch(/callback url is not configured/i) + expect(copy).toMatch(/CONTROL_API_OAUTH_CALLBACK_BASE_URL/) + expect(copy).not.toMatch(/redirect URI of its own/i) + } + }) + + it('uses the server message when CIMD is refused for lack of RFC 9207', () => { + expect(mapRemoteInstallError(apiErrorFrom(CIMD_WITHOUT_ISS_BINDING_FAILURE))).toBe( + 'this authorization server supports CIMD but not RFC 9207; install with mode "dcr"' + ) + }) + + it('maps a per-server client_id conflict to "register a separate client"', () => { + const copy = mapRemoteInstallError(apiErrorFrom(CLIENT_ID_IN_USE_FAILURE)) + expect(copy).toMatch(/another remote server/i) + expect(copy).toMatch(/separate OAuth client/i) + }) + + it('maps each oauth_client_id_in_use conflict to its own copy', () => { + const copies = ['remote_server', 'dynamic_client', 'cimd_client', undefined].map(conflict => + mapRemoteInstallError( + Object.assign(new Error('409'), { + code: 'oauth_client_id_in_use', + body: { error: 'oauth_client_id_in_use', conflict }, + }) + ) + ) + expect(new Set(copies).size).toBe(copies.length) + }) + + it('maps a refused DCR redirect URI without leaking the raw kind', () => { + const copy = mapRemoteInstallError(apiErrorFrom(DCR_REDIRECT_MISMATCH_FAILURE)) + expect(copy).toMatch(/different redirect URI/i) + expect(copy).toMatch(/nothing was installed/i) + expect(copy).not.toContain('redirect_uris_mismatch') + }) + + it('gives each refused-DCR kind its own copy', () => { + const kinds = ['redirect_uris_mismatch', 'redirect_uris_missing', 'client_id_is_cimd_identity'] + const copies = kinds.map(kind => + mapRemoteInstallError( + Object.assign(new Error('400'), { + code: 'dcr_registration_failed', + body: { error: 'dcr_registration_failed', detail: { kind } }, + }) + ) + ) + expect(new Set(copies).size).toBe(kinds.length) + for (const [i, copy] of copies.entries()) expect(copy).not.toContain(kinds[i]) + }) + + it('maps issuer_binding_required to the server message, else neutral copy', () => { + // No golden: the router now answers a CIMD request against an AS without RFC 9207 + // with mode_unsupported first, so this 422 is only a defensive branch there. + const withMessage = Object.assign(new Error('422'), { + code: 'issuer_binding_required', + body: { error: 'issuer_binding_required', message: 'a CIMD install requires it' }, + }) + expect(mapRemoteInstallError(withMessage)).toBe('a CIMD install requires it') + const bare = Object.assign(new Error('422'), { + code: 'issuer_binding_required', + body: { error: 'issuer_binding_required' }, }) - expect(mapRemoteInstallError(err)).toMatch(/callback url is not configured/i) + expect(mapRemoteInstallError(bare)).toMatch(/does not advertise RFC 9207/) }) it('maps a re-run discovery_failed at install', () => { diff --git a/control-ui/lib/remoteMcp.ts b/control-ui/lib/remoteMcp.ts index 3b35c9c6a7..6e41c139b1 100644 --- a/control-ui/lib/remoteMcp.ts +++ b/control-ui/lib/remoteMcp.ts @@ -7,7 +7,11 @@ import type { InstallRemoteRequest, InstallRemoteResponse, InstallTransportUnreachableDetail, + RemoteCallbackPreview, + RemoteCallbackVariant, + RemoteClientIdConflict, RemoteClientMode, + RemoteDcrRejectionKind, RemoteDetected, RemoteDiscoveryErrorKind, RemoteInstallMode, @@ -146,6 +150,55 @@ export function getRemoteServerNameError(name: string): string { return '' } +/** + * The callback variant an install would use. Authority is the backend `callback` + * preview; a control-api older than it is read with the backend's own rule (shared + * iff the AS returns `iss`), which that version can only install on the shared path. + */ +export function remoteCallbackVariant( + discovery: Pick +): RemoteCallbackVariant { + if (discovery.callback) return discovery.callback.variant + return discovery.detected.issForCallback ? 'shared' : 'per-server' +} + +/** + * Why the detected configuration cannot be installed from here, or '' when it can. + * A per-server install needs a configured callback origin (control-api answers 503 + * otherwise). A control-api older than the callback preview has no per-server + * callback at all and rejects every install against an AS without RFC 9207. + */ +export function remoteCallbackBlocker( + discovery: Pick +): string { + if (remoteCallbackVariant(discovery) !== 'per-server') return '' + const { callback } = discovery + if (!callback) { + return 'This authorization server does not support RFC 9207, and this control-api version can only install servers that do. Update control-api to install it with a redirect URI of its own.' + } + if (!callback.configured) { + return 'The public OAuth callback URL is not configured on this deployment (CONTROL_API_OAUTH_CALLBACK_BASE_URL, a bare origin). This authorization server needs a redirect URI of its own, so it cannot be installed until that is set.' + } + return '' +} + +/** + * The redirect URI the operator registers at the provider for a pre-registered + * client, or null when there is nothing to show yet. The `{serverName}` placeholder + * is filled only with a name that passes the wizard's validation, and a template + * still carrying `{installId}` is DCR-only (registered by control-api itself). + */ +export function remotePreRegisteredRedirectUri( + callback: RemoteCallbackPreview | undefined, + serverName: string +): string | null { + const template = callback?.configured ? callback.redirectUriTemplate : undefined + if (!template || template.includes('{installId}')) return null + if (!template.includes('{serverName}')) return template + if (getRemoteServerNameError(serverName) !== '') return null + return template.split('{serverName}').join(serverName) +} + /** * Client-side FORM validation for the remote wizard's base URL (UX only — control-api * returns the authoritative 422 and its SSRF guard is what blocks a hostile target). @@ -190,8 +243,10 @@ function asCodedError(error: unknown): CodedError { /** Human copy for a `discovery_failed` detail kind (used by discover + install). */ export function describeDiscoveryError( - kind: RemoteDiscoveryErrorKind | string | undefined + kind: RemoteDiscoveryErrorKind | string | undefined, + field?: string ): string { + if (kind === 'as_endpoints_cross_site') return describeCrossSite(field) switch (kind) { case 'fetch_failed': return "Couldn't reach the server to read its OAuth metadata. Check the URL and that the host is publicly reachable." @@ -216,15 +271,73 @@ export function describeDiscoveryError( } } +/** + * Without RFC 9207 every AS endpoint must share the issuer's registrable domain. + * `field` names what failed: the issuer itself (a public suffix, IP literal or URL + * with credentials has no domain to compare against) or one endpoint, which is then + * either on another domain or has none. + */ +function describeCrossSite(field: string | undefined): string { + const why = + 'It does not identify itself in OAuth responses (RFC 9207), so the connector only trusts endpoints on the issuer’s own domain, and this server cannot be installed.' + if (field === 'issuer') { + return `The authorization server's issuer has no registrable domain of its own (it is a shared hosting suffix, an IP address, or carries credentials). ${why}` + } + const subject = field ? `${field} endpoint is` : 'endpoints are' + return `The authorization server's ${subject} not on the issuer's domain. ${why}` +} + function discoveryDetailKind(body: Record | undefined): string | undefined { + return detailString(body, 'kind') +} + +function detailString( + body: Record | undefined, + key: 'kind' | 'field' +): string | undefined { const detail = body?.detail if (detail && typeof detail === 'object') { - const kind = (detail as { kind?: unknown }).kind - if (typeof kind === 'string') return kind + const value = (detail as Record)[key] + if (typeof value === 'string') return value } return undefined } +function describeDiscoveryFailure(body: Record | undefined): string { + return describeDiscoveryError(discoveryDetailKind(body), detailString(body, 'field')) +} + +/** Copy for a DCR response refused after the client was minted (and revoked). */ +function describeDcrRejection(kind: RemoteDcrRejectionKind): string { + switch (kind) { + case 'redirect_uris_mismatch': + return 'The authorization server registered a different redirect URI than the one requested, so the new client was discarded and nothing was installed.' + case 'redirect_uris_missing': + return "The authorization server did not confirm the redirect URI it registered, which this server's own callback requires, so the new client was discarded and nothing was installed." + case 'client_id_is_cimd_identity': + return "The authorization server returned this platform's CIMD identity as the new client ID, so the client was discarded and nothing was installed." + } +} + +const DCR_REJECTION_KINDS: readonly string[] = [ + 'redirect_uris_mismatch', + 'redirect_uris_missing', + 'client_id_is_cimd_identity', +] satisfies readonly RemoteDcrRejectionKind[] + +function describeClientIdConflict(conflict: unknown): string { + switch (conflict as RemoteClientIdConflict) { + case 'remote_server': + return 'This client ID is already used by another remote server. Register a separate OAuth client for this server at the provider, with its own redirect URI.' + case 'dynamic_client': + return 'This client ID belongs to a client this platform registered dynamically. Register a separate OAuth client for this server at the provider.' + case 'cimd_client': + return "This client ID is the platform's CIMD identity, which cannot back a single server. Register a separate OAuth client for this server at the provider." + default: + return 'This client ID already backs another client. Register a separate OAuth client for this server at the provider.' + } +} + /** * Map a discover-step failure (from `discoverRemoteServer`) to UI copy. Covers * the typed `discovery_failed` union and the kernel §4 pre-check 400 (whose @@ -233,15 +346,21 @@ function discoveryDetailKind(body: Record | undefined): string export function mapRemoteDiscoverError(error: unknown): string { const e = asCodedError(error) if (e.code === 'discovery_failed') { - return describeDiscoveryError(discoveryDetailKind(e.body)) + return describeDiscoveryFailure(e.body) } // Kernel §4 400: `{ error: '', errors }`. formatApiError surfaces the // message in `.message`; fall back to it (or a generic line). return e.message?.trim() || 'Could not detect the remote server. Check the URL and try again.' } -/** Map an install-step failure to UI copy across the documented error codes. */ -export function mapRemoteInstallError(error: unknown): string { +/** + * Map an install-step failure to UI copy across the documented error codes. + * `callbackVariant` is the variant the wizard previewed, when control-api reported one. + */ +export function mapRemoteInstallError( + error: unknown, + context: { callbackVariant?: RemoteCallbackVariant } = {} +): string { const e = asCodedError(error) const code = e.code const body = e.body @@ -254,18 +373,32 @@ export function mapRemoteInstallError(error: unknown): string { return serverMessage || 'The requested registration mode is not supported by this server.' case 'auth_method_unsupported': return "The authorization server assigned a client authentication method this platform cannot present, so this server can't be installed automatically." - case 'dcr_registration_failed': + case 'dcr_registration_failed': { + const kind = discoveryDetailKind(body) + if (kind && DCR_REJECTION_KINDS.includes(kind)) { + return describeDcrRejection(kind as RemoteDcrRejectionKind) + } return `Dynamic client registration failed at the authorization server${ - discoveryDetailKind(body) ? ` (${discoveryDetailKind(body)})` : '' + kind ? ` (${kind})` : '' }.` + } case 'dcr_persist_failed': return 'The client was registered but its credentials could not be stored. Nothing was installed — please try again.' case 'callback_base_url_unconfigured': - return 'The public OAuth callback URL is not configured on this deployment. Set it before installing a remote OAuth server.' + return context.callbackVariant === 'per-server' + ? 'The public OAuth callback URL is not configured on this deployment (CONTROL_API_OAUTH_CALLBACK_BASE_URL, a bare origin). This authorization server needs a redirect URI of its own, so set it before installing.' + : 'The public OAuth callback URL is not configured on this deployment. Set CONTROL_API_OAUTH_CALLBACK_BASE_URL (a bare origin) before installing this server.' + case 'issuer_binding_required': + return ( + serverMessage || + 'This authorization server does not advertise RFC 9207, which this install mode requires.' + ) + case 'oauth_client_id_in_use': + return describeClientIdConflict(body?.conflict) case 'discovery_failed': // D-7: install re-runs discovery server-side, so a discover-time failure // reappears here with the same detail kind. - return describeDiscoveryError(discoveryDetailKind(body)) + return describeDiscoveryFailure(body) case 'invalid_request': return 'The install request was rejected as invalid. Reload the page and try again.' case 'transport_unreachable': { diff --git a/control-ui/lib/remoteMcp.types.ts b/control-ui/lib/remoteMcp.types.ts index 6f55b095b8..1bf4ad5b6f 100644 --- a/control-ui/lib/remoteMcp.types.ts +++ b/control-ui/lib/remoteMcp.types.ts @@ -36,6 +36,18 @@ export interface RemoteDetectedEndpoints { registration?: string } +/** + * Hosts (hostname plus any non-default port) of the AS endpoints an install + * would trust. Reported only for a + * `per-server` callback (no RFC 9207 `iss`), where the same-site rule is all that + * ties them to the issuer and cannot tell two hosts of one domain apart. + */ +export interface RemoteAsEndpointHosts { + authorization: string + token: string + registration?: string +} + export interface RemoteDetectedQuirks { /** D-8: token goes in the request body, not the Authorization header. */ bearerInBody: boolean @@ -51,10 +63,33 @@ export interface RemoteDetected { resource: string issuer: string issForCallback?: string + asEndpointHosts?: RemoteAsEndpointHosts scopes: string[] quirks: RemoteDetectedQuirks } +/** + * Which OAuth callback an install would register: the shared one when the AS + * returns `iss` (RFC 9207), otherwise one of the server's own. + */ +export type RemoteCallbackVariant = 'shared' | 'per-server' + +/** The `/discover` view of the callback an install from this result would use. */ +export interface RemoteCallbackPreview { + /** + * Whether the deployment has a usable public callback base URL. `false` blocks every + * `per-server` install. On `shared` only a pre-registered install proceeds (its URI + * falls back to the request Host at consent); shared CIMD and DCR still answer 503. + */ + configured: boolean + variant: RemoteCallbackVariant + /** + * Redirect URI the install would register. Per-server URIs carry the literal + * placeholders `{serverName}` and, for DCR, `{installId}` (minted at install). + */ + redirectUriTemplate?: string +} + export interface DiscoverRemoteRequest { baseUrl: string } @@ -97,6 +132,8 @@ export interface DiscoverRemoteResponse { * control-api older than the probe (a missing `transport` never blocks). */ transport?: RemoteTransportProbe + /** Absent on a control-api older than the per-server callback. */ + callback?: RemoteCallbackPreview } export interface InstallRemoteRequest { @@ -118,9 +155,24 @@ export interface InstallRemoteResponse { contextUpdated: true clientMode: RemoteClientMode registrationMode: RemoteRegistrationMode + callbackVariant?: RemoteCallbackVariant + /** + * The redirect URI the AS must hold. Authoritative over the `/discover` preview: + * the AS may have changed its RFC 9207 support in between. + */ + redirectUri?: string clientSecretName?: string } +/** Why a DCR registration response was refused after the client was minted. */ +export type RemoteDcrRejectionKind = + | 'redirect_uris_mismatch' + | 'redirect_uris_missing' + | 'client_id_is_cimd_identity' + +/** What already uses a pre-registered client_id (409 `oauth_client_id_in_use`). */ +export type RemoteClientIdConflict = 'cimd_client' | 'remote_server' | 'dynamic_client' + /** * Detail of the new install-time `400 { error: 'transport_unreachable' }`, * emitted before any write when the MCP transport probe finds the typed path @@ -148,3 +200,4 @@ export type RemoteDiscoveryErrorKind = | 'redirect_blocked' | 'prm_resource_mismatch' | 'issuer_mismatch' + | 'as_endpoints_cross_site' diff --git a/control-ui/package-lock.json b/control-ui/package-lock.json index 9779516e5b..f991f67299 100644 --- a/control-ui/package-lock.json +++ b/control-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-ui", - "version": "0.1.704", + "version": "0.1.708", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-ui", - "version": "0.1.704", + "version": "0.1.708", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/frontend-components": "file:../packages/frontend-components", diff --git a/control-ui/package.json b/control-ui/package.json index 583ca11e2c..94179ab195 100644 --- a/control-ui/package.json +++ b/control-ui/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-ui", - "version": "0.1.704", + "version": "0.1.708", "private": true, "scripts": { "dev": "NEXT_IGNORE_INCORRECT_LOCKFILE=1 next dev", diff --git a/control-ui/test/fixtures/__tests__/remoteMcpDiscovery.projection.test.ts b/control-ui/test/fixtures/__tests__/remoteMcpDiscovery.projection.test.ts index bb8f0e5c68..d197c54d73 100644 --- a/control-ui/test/fixtures/__tests__/remoteMcpDiscovery.projection.test.ts +++ b/control-ui/test/fixtures/__tests__/remoteMcpDiscovery.projection.test.ts @@ -1,137 +1,77 @@ import { describe, expect, it } from 'vitest' -import type { RemoteDetected, RemoteTransportProbe } from '../../../lib/remoteMcp.types' +import type { RemoteTransportProbe } from '../../../lib/remoteMcp.types' import { - CANVA_AS_JSON, - CANVA_DETECTED, - CANVA_PRM_JSON, - DCR_CONFIDENTIAL_AS_JSON, - DCR_CONFIDENTIAL_DETECTED, - DCR_PUBLIC_AS_JSON, - DCR_PUBLIC_DETECTED, type InitializeProbeObservation, - LINEAR_AS_JSON, - LINEAR_DETECTED, - LINEAR_PRM_JSON, - NOTION_AS_JSON, - NOTION_DETECTED, NOTION_MCP_INITIALIZE, - NOTION_PRM_JSON, NOTION_TRANSPORT_ALIVE, - SENTRY_AS_JSON, - SENTRY_DETECTED, - SENTRY_PRM_JSON, VERCEL_MCP_INITIALIZE, VERCEL_ROOT_INITIALIZE, VERCEL_ROOT_PRM_JSON, VERCEL_TRANSPORT_DEAD, } from '../remoteMcpDiscovery' +import { + ALL_DISCOVER_BODIES, + ATLASSIAN_DISCOVER, + LINEAR_DISCOVER, + NOTION_DISCOVER, + PRE_REGISTERED_PER_SERVER_DISCOVER, +} from '../remoteMcpWire' /** - * This is NOT a cross-service contract test — control-api is not importable from - * control-ui, so nothing here fails when the real producer drifts. The producer - * contract for the discover response (`res.body.detected`) is pinned by control-api - * `test/routes.adminRemoteMcp.test.ts`. What this file guarantees is narrower: each - * hand-derived `detected` fixture equals a LOCAL re-projection of the verbatim raw - * probe bytes, so a fixture cannot silently encode an invented shape. - * - * DRIFT LIMIT: projectDetected is a HAND MIRROR of the producer logic - * (`discoverRemoteOAuth` + `selectRegistrationMode`/`deriveQuirks` in - * control-api/src/oauth/discovery.ts, shaped into `detected` by the remoteMcp.ts - * route). control-api is not importable from control-ui, so a change to that logic - * does NOT fail this test on its own — it only catches fixture-vs-copy drift. Any - * edit to the producer projection must be reflected here, or these fixtures certify - * a stale shape. The producer carries the reverse pointer. + * The `/discover` bodies are control-api's own responses (golden wire files captured + * from the real router), so they are not re-projected here. What this block pins is + * the part of that contract the wizard's decisions rest on; a golden that moves in a + * way the wizard does not handle fails here, next to the fixture, rather than as a + * confusing wizard test failure. */ -function projectDetected(prmJson: string, asJson: string): RemoteDetected { - const prm = JSON.parse(prmJson) as Record - const as = JSON.parse(asJson) as Record - - const authMethods = (as.token_endpoint_auth_methods_supported as string[] | undefined) ?? [] - const hasRegistration = typeof as.registration_endpoint === 'string' - // D-3, dry-run (no pre-registered client): cimd > dcr > manual. - const registrationMode = - as.client_id_metadata_document_supported === true && authMethods.includes('none') - ? 'cimd' - : hasRegistration - ? 'dcr' - : 'manual' - - const prmScopes = prm.scopes_supported as string[] | undefined - const asScopes = as.scopes_supported as string[] | undefined - const scopes = prmScopes ?? asScopes ?? [] - - const bearerMethods = prm.bearer_methods_supported as string[] | undefined - const bearerInBody = Array.isArray(bearerMethods) - ? bearerMethods.includes('body') && !bearerMethods.includes('header') - : false - const grantTypes = as.grant_types_supported as string[] | undefined - const supportsRefresh = grantTypes?.includes('refresh_token') ?? false +describe('remote MCP `/discover` goldens carry the callback contract the wizard relies on', () => { + it('the pilots resolve as the backend decides today', () => { + expect(LINEAR_DISCOVER.detected.registrationMode).toBe('cimd') + expect(LINEAR_DISCOVER.callback?.variant).toBe('shared') + // Notion offers CIMD but not RFC 9207, so it can only install by DCR. + expect(NOTION_DISCOVER.detected.registrationMode).toBe('dcr') + expect(NOTION_DISCOVER.callback?.variant).toBe('per-server') + expect(ATLASSIAN_DISCOVER.detected.registrationMode).toBe('dcr') + expect(PRE_REGISTERED_PER_SERVER_DISCOVER.detected.registrationMode).toBe('manual') + }) - const detected: RemoteDetected = { - registrationMode: registrationMode as RemoteDetected['registrationMode'], - endpoints: { - authorization: as.authorization_endpoint as string, - token: as.token_endpoint as string, - ...(hasRegistration ? { registration: as.registration_endpoint as string } : {}), - }, - resource: prm.resource as string, - issuer: as.issuer as string, - ...(as.authorization_response_iss_parameter_supported === true - ? { issForCallback: as.issuer as string } - : {}), - scopes, - quirks: { bearerInBody, supportsRefresh }, - } - if (registrationMode === 'dcr') { - detected.dcr = { - available: true, - clientMode: authMethods.includes('none') ? 'public' : 'confidential', - supportsRefresh, - } - } - return detected -} + for (const { name, body } of ALL_DISCOVER_BODIES) { + it(`${name}: without RFC 9207 never CIMD, and the variant follows issForCallback`, () => { + const hasIss = Boolean(body.detected.issForCallback) + expect(body.callback?.variant).toBe(hasIss ? 'shared' : 'per-server') + if (!hasIss) expect(body.detected.registrationMode).not.toBe('cimd') + }) -describe('remote MCP `detected` fixtures match the real probe bytes', () => { - const cases: Array<{ name: string; prm: string; as: string; fixture: RemoteDetected }> = [ - { name: 'notion', prm: NOTION_PRM_JSON, as: NOTION_AS_JSON, fixture: NOTION_DETECTED }, - { name: 'linear', prm: LINEAR_PRM_JSON, as: LINEAR_AS_JSON, fixture: LINEAR_DETECTED }, - { name: 'sentry', prm: SENTRY_PRM_JSON, as: SENTRY_AS_JSON, fixture: SENTRY_DETECTED }, - { name: 'canva', prm: CANVA_PRM_JSON, as: CANVA_AS_JSON, fixture: CANVA_DETECTED }, - { - name: 'dcr-public', - prm: NOTION_PRM_JSON, - as: DCR_PUBLIC_AS_JSON, - fixture: DCR_PUBLIC_DETECTED, - }, - { - name: 'dcr-confidential', - prm: NOTION_PRM_JSON, - as: DCR_CONFIDENTIAL_AS_JSON, - fixture: DCR_CONFIDENTIAL_DETECTED, - }, - ] + it(`${name}: endpoint hosts are reported iff per-server, and match the endpoints`, () => { + const hosts = body.detected.asEndpointHosts + if (body.callback?.variant !== 'per-server') { + expect(hosts).toBeUndefined() + return + } + const { authorization, token, registration } = body.detected.endpoints + expect(hosts).toEqual({ + authorization: new URL(authorization).host, + token: new URL(token).host, + ...(registration ? { registration: new URL(registration).host } : {}), + }) + }) - for (const { name, prm, as, fixture } of cases) { - it(`${name}: fixture equals the producer projection of the raw bytes`, () => { - expect(fixture).toEqual(projectDetected(prm, as)) + it(`${name}: the redirect URI template carries the placeholders its mode needs`, () => { + const callback = body.callback + if (!callback?.configured) { + expect(callback?.redirectUriTemplate).toBeUndefined() + return + } + const template = callback.redirectUriTemplate ?? '' + if (callback.variant === 'shared') { + expect(template).not.toMatch(/\{/) + } else if (body.detected.registrationMode === 'dcr') { + expect(template).toMatch(/\/\{serverName\}\/\{installId\}$/) + } else { + expect(template).toMatch(/\/\{serverName\}$/) + } }) } - - it('the four CIMD pilots resolve to cimd and carry a registration endpoint', () => { - for (const fixture of [NOTION_DETECTED, LINEAR_DETECTED, SENTRY_DETECTED, CANVA_DETECTED]) { - expect(fixture.registrationMode).toBe('cimd') - expect(fixture.endpoints.registration).toBeTruthy() - expect(fixture.dcr).toBeUndefined() - } - }) - - it('only sentry advertises issForCallback (RFC 9207)', () => { - expect(SENTRY_DETECTED.issForCallback).toBe('https://mcp.sentry.dev') - expect(NOTION_DETECTED.issForCallback).toBeUndefined() - expect(LINEAR_DETECTED.issForCallback).toBeUndefined() - expect(CANVA_DETECTED.issForCallback).toBeUndefined() - }) }) /** diff --git a/control-ui/test/fixtures/remoteMcpDiscovery.ts b/control-ui/test/fixtures/remoteMcpDiscovery.ts index 53002b08ad..394d2ef7dd 100644 --- a/control-ui/test/fixtures/remoteMcpDiscovery.ts +++ b/control-ui/test/fixtures/remoteMcpDiscovery.ts @@ -1,22 +1,16 @@ /** - * Producer fixtures — the `detected` prefill POST /admin/mcp-servers/remote/discover - * returns (spec 19 §C5). control-ui cannot run control-api's discovery in this - * package (its deps — pino, @clerum/image-policy — are not installed here), so the - * `detected` objects below are DERIVED FROM THE REAL SERVER PROBE BYTES: the raw - * PRM/AS JSON strings are copied verbatim from control-api's own T1 fixtures - * (control-api/test/fixtures/remoteOAuthDiscovery.ts — live probes 2026-09-20) and - * the projection that turns those bytes into `detected` is the one the producer - * applies (control-api/src/oauth/discovery.ts + src/routes/admin/remoteMcp.ts). + * Raw probe bytes of the remote MCP pilots (PRM + AS metadata), copied verbatim from + * control-api's own T1 fixtures (control-api/test/fixtures/remoteOAuthDiscovery.ts — + * live probes 2026-09-20, Linear re-probed 2026-09-29), plus the MCP transport probe + * fixtures. The generic-lane prefill fixtures project these bytes. * - * The colocated contract test re-parses the verbatim raw bytes and asserts that - * every `detected` field equals the real server field under the producer's - * documented rule, so a hand-typed value that drifts from the real bytes fails. - * This mirrors the repo's existing producer-fixture pattern (test/fixtures/ - * contextResource.ts + its *.contract.test.ts). + * The remote wizard's `/discover` and install bodies do NOT live here: they are the + * producer's own responses, loaded from control-api's golden wire files by the sibling + * `remoteMcpWire.ts`. */ -import type { RemoteDetected, RemoteTransportProbe } from '../../lib/remoteMcp.types' +import type { RemoteTransportProbe } from '../../lib/remoteMcp.types' -// ─── Verbatim real probe bytes (control-api T1 fixtures, 2026-09-20) ────────── +// ─── Verbatim real probe bytes (control-api T1 fixtures) ────────────────────── export const NOTION_PRM_JSON = '{"resource":"https://mcp.notion.com","authorization_servers":["https://mcp.notion.com"],"scopes_supported":["default"],"bearer_methods_supported":["header"],"resource_name":"Notion MCP (Beta)"}' @@ -25,8 +19,9 @@ export const NOTION_AS_JSON = export const LINEAR_PRM_JSON = '{"resource":"https://mcp.linear.app/mcp","authorization_servers":["https://mcp.linear.app"],"scopes_supported":["read","write"],"bearer_methods_supported":["header"]}' +// Re-probed 2026-09-29: Linear now advertises RFC 9207. export const LINEAR_AS_JSON = - '{"issuer":"https://mcp.linear.app","authorization_endpoint":"https://mcp.linear.app/authorize","token_endpoint":"https://mcp.linear.app/token","registration_endpoint":"https://mcp.linear.app/register","scopes_supported":["read","write","openid","email"],"response_types_supported":["code"],"response_modes_supported":["query"],"grant_types_supported":["authorization_code","refresh_token","urn:ietf:params:oauth:grant-type:jwt-bearer"],"authorization_grant_profiles_supported":["urn:ietf:params:oauth:grant-profile:id-jag"],"token_endpoint_auth_methods_supported":["client_secret_basic","client_secret_post","none"],"revocation_endpoint":"https://mcp.linear.app/token","code_challenge_methods_supported":["S256"],"client_id_metadata_document_supported":true,"resource":"https://mcp.linear.app/mcp","resource_metadata":"https://mcp.linear.app/.well-known/oauth-protected-resource/mcp"}' + '{"issuer":"https://mcp.linear.app","authorization_endpoint":"https://mcp.linear.app/authorize","token_endpoint":"https://mcp.linear.app/token","registration_endpoint":"https://mcp.linear.app/register","scopes_supported":["read","write","openid","email"],"response_types_supported":["code"],"response_modes_supported":["query"],"grant_types_supported":["authorization_code","refresh_token","urn:ietf:params:oauth:grant-type:jwt-bearer"],"authorization_grant_profiles_supported":["urn:ietf:params:oauth:grant-profile:id-jag"],"token_endpoint_auth_methods_supported":["client_secret_basic","client_secret_post","none"],"revocation_endpoint":"https://mcp.linear.app/token","code_challenge_methods_supported":["S256"],"authorization_response_iss_parameter_supported":true,"client_id_metadata_document_supported":true,"resource":"https://mcp.linear.app/mcp","resource_metadata":"https://mcp.linear.app/.well-known/oauth-protected-resource/mcp"}' export const SENTRY_PRM_JSON = '{"resource":"https://mcp.sentry.dev/mcp","authorization_servers":["https://mcp.sentry.dev"],"scopes_supported":["org:read","project:write","team:write","event:write"],"bearer_methods_supported":["header"]}' @@ -38,150 +33,14 @@ export const CANVA_PRM_JSON = export const CANVA_AS_JSON = '{"issuer":"https://mcp.canva.com","authorization_endpoint":"https://mcp.canva.com/authorize","token_endpoint":"https://mcp.canva.com/token","registration_endpoint":"https://mcp.canva.com/register","response_types_supported":["code"],"response_modes_supported":["query"],"grant_types_supported":["authorization_code","refresh_token","urn:ietf:params:oauth:grant-type:jwt-bearer"],"token_endpoint_auth_methods_supported":["client_secret_basic","client_secret_post","none"],"revocation_endpoint":"https://mcp.canva.com/token","code_challenge_methods_supported":["plain","S256"],"client_id_metadata_document_supported":true,"authorization_grant_profiles_supported":["urn:ietf:params:oauth:grant-profile:id-jag"]}' -/** - * DCR-forced AS bytes — same documented subtraction control-api uses (DEC-19): - * removing `client_id_metadata_document_supported` from the real Notion AS pushes - * the mode selector off CIMD onto DCR (Notion advertises a `/register` endpoint); - * additionally dropping `none` from the auth methods forces DCR-confidential. - * Nothing is invented — the removed fields are exactly the ones Notion emits. - */ -function subtractedNotionAs(mutate: (as: Record) => void): string { - const as = JSON.parse(NOTION_AS_JSON) as Record - mutate(as) - return JSON.stringify(as) -} -export const DCR_PUBLIC_AS_JSON = subtractedNotionAs(as => { - delete as.client_id_metadata_document_supported -}) -export const DCR_CONFIDENTIAL_AS_JSON = subtractedNotionAs(as => { - delete as.client_id_metadata_document_supported - as.token_endpoint_auth_methods_supported = ['client_secret_basic', 'client_secret_post'] -}) - -// Per-provider origin for the endpoint URLs below. The public-boundary CI guard -// (scripts/tests/test-minikube-t2-public-boundary.sh) reads a quoted -// `token: '…'` literal as a materialized credential; deriving the endpoint from -// an interpolated origin keeps the value out of that shape (the guard exempts -// interpolated values). Do not inline these back to plain string literals. -const NOTION = 'https://mcp.notion.com' -const LINEAR = 'https://mcp.linear.app' -const SENTRY = 'https://mcp.sentry.dev' -const CANVA = 'https://mcp.canva.com' - -// ─── `detected` fixtures (hand-derived; locked to the bytes by the contract test) ─ - -/** Notion — CIMD (public), refresh supported, no `iss`. */ -export const NOTION_DETECTED: RemoteDetected = { - registrationMode: 'cimd', - endpoints: { - authorization: `${NOTION}/authorize`, - token: `${NOTION}/token`, - registration: `${NOTION}/register`, - }, - resource: 'https://mcp.notion.com', - issuer: 'https://mcp.notion.com', - scopes: ['default'], - quirks: { bearerInBody: false, supportsRefresh: true }, -} - -/** Linear — CIMD, resource carries a `/mcp` path. */ -export const LINEAR_DETECTED: RemoteDetected = { - registrationMode: 'cimd', - endpoints: { - authorization: `${LINEAR}/authorize`, - token: `${LINEAR}/token`, - registration: `${LINEAR}/register`, - }, - resource: 'https://mcp.linear.app/mcp', - issuer: 'https://mcp.linear.app', - scopes: ['read', 'write'], - quirks: { bearerInBody: false, supportsRefresh: true }, -} - -/** Sentry — CIMD, advertises RFC 9207 `iss` (issForCallback present). */ -export const SENTRY_DETECTED: RemoteDetected = { - registrationMode: 'cimd', - endpoints: { - authorization: `${SENTRY}/oauth/authorize`, - token: `${SENTRY}/oauth/token`, - registration: `${SENTRY}/oauth/register`, - }, - resource: 'https://mcp.sentry.dev/mcp', - issuer: 'https://mcp.sentry.dev', - issForCallback: 'https://mcp.sentry.dev', - scopes: ['org:read', 'project:write', 'team:write', 'event:write'], - quirks: { bearerInBody: false, supportsRefresh: true }, -} - -/** Canva — CIMD; AS has no scopes_supported, so scopes come from the PRM. */ -export const CANVA_DETECTED: RemoteDetected = { - registrationMode: 'cimd', - endpoints: { - authorization: `${CANVA}/authorize`, - token: `${CANVA}/token`, - registration: `${CANVA}/register`, - }, - resource: 'https://mcp.canva.com', - issuer: 'https://mcp.canva.com', - scopes: [ - 'profile:read', - 'design:meta:read', - 'design:content:write', - 'design:content:read', - 'folder:read', - 'folder:write', - 'brandtemplate:content:read', - 'brandtemplate:meta:read', - 'brandtemplate:content:write', - 'comment:write', - 'comment:read', - 'asset:read', - 'asset:write', - 'brandkit:read', - 'help:answers:read', - 'help:answers:write', - ], - quirks: { bearerInBody: false, supportsRefresh: true }, -} - -/** DCR-public (Notion minus CIMD support). */ -export const DCR_PUBLIC_DETECTED: RemoteDetected = { - registrationMode: 'dcr', - dcr: { available: true, clientMode: 'public', supportsRefresh: true }, - endpoints: { - authorization: `${NOTION}/authorize`, - token: `${NOTION}/token`, - registration: `${NOTION}/register`, - }, - resource: 'https://mcp.notion.com', - issuer: 'https://mcp.notion.com', - scopes: ['default'], - quirks: { bearerInBody: false, supportsRefresh: true }, -} - -/** DCR-confidential (Notion minus CIMD support and minus `none`). */ -export const DCR_CONFIDENTIAL_DETECTED: RemoteDetected = { - registrationMode: 'dcr', - dcr: { available: true, clientMode: 'confidential', supportsRefresh: true }, - endpoints: { - authorization: `${NOTION}/authorize`, - token: `${NOTION}/token`, - registration: `${NOTION}/register`, - }, - resource: 'https://mcp.notion.com', - issuer: 'https://mcp.notion.com', - scopes: ['default'], - quirks: { bearerInBody: false, supportsRefresh: true }, -} - // ─── MCP transport probe fixtures (live probes 2026-09-25) ──────────────────── // -// control-ui cannot run control-api's `mcpTransportProbe.ts`, so — as with the -// `detected` fixtures above — the RemoteTransportProbe values below are DERIVED -// FROM THE REAL PROBE OUTPUTS: the verbatim root-PRM bytes plus the observed -// `initialize` HTTP statuses. The colocated contract test re-applies the -// producer's decision table + canonical-URL suggestion rule to those raw inputs -// and asserts each fixture equals the projection, so a drifted value fails. +// control-ui cannot run control-api's `mcpTransportProbe.ts`, so the +// RemoteTransportProbe values below are DERIVED FROM THE REAL PROBE OUTPUTS: the +// verbatim root-PRM bytes plus the observed `initialize` HTTP statuses. The colocated +// contract test re-applies the producer's decision table + canonical-URL suggestion +// rule to those raw inputs and asserts each fixture equals the projection, so a +// drifted value fails. /** Vercel serves OAuth PRM per-path; the root `resource` is the canonical URL. */ export const VERCEL_ROOT_PRM_JSON = diff --git a/control-ui/test/fixtures/remoteMcpWire.ts b/control-ui/test/fixtures/remoteMcpWire.ts new file mode 100644 index 0000000000..0d797d5d80 --- /dev/null +++ b/control-ui/test/fixtures/remoteMcpWire.ts @@ -0,0 +1,127 @@ +/** + * Producer fixtures — the bodies control-api's remote MCP admin routes return + * (`POST /admin/mcp-servers/remote/discover` and `POST /admin/mcp-servers/remote`). + * + * control-api is not importable from this package, so these are the golden wire files + * control-api's own route test captures from the real router (real discovery and + * transport probe over recorded probe bytes, the real install saga): + * control-api/test/routes.adminRemoteMcp.wireGoldens.test.ts → + * control-api/test/fixtures/wire/remoteMcp.*.json. Loading the same bytes means a + * producer change breaks the golden there before it can drift here. + */ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { formatApiError } from '../../lib/api' +import type { + DiscoverRemoteResponse, + InstallRemoteResponse, + RemoteDetected, +} from '../../lib/remoteMcp.types' + +export type WireCapture = { status: number; body: T } + +const WIRE_DIR = resolve(__dirname, '../../../control-api/test/fixtures/wire') + +function loadGolden(name: string): WireCapture { + return JSON.parse(readFileSync(resolve(WIRE_DIR, `remoteMcp.${name}.json`), 'utf8')) +} + +// ─── /discover 200 ──────────────────────────────────────────────────────────── + +/** Linear: CIMD + RFC 9207 → CIMD on the shared callback. */ +export const LINEAR_DISCOVER = loadGolden('discover.linear').body +/** Notion: CIMD + DCR without RFC 9207 → public DCR on a per-server callback. */ +export const NOTION_DISCOVER = loadGolden('discover.notion').body +/** Atlassian v2: same as Notion, with the issuer under a path of auth.atlassian.com. */ +export const ATLASSIAN_DISCOVER = loadGolden('discover.atlassian').body +/** Atlassian v2 on a deployment without a public callback base URL. */ +export const ATLASSIAN_DISCOVER_UNCONFIGURED = loadGolden( + 'discover.atlassianUnconfigured' +).body +/** Linear on a deployment without a public callback base URL (shared, not blocking). */ +export const LINEAR_DISCOVER_UNCONFIGURED = loadGolden( + 'discover.linearUnconfigured' +).body +/** Notion minus CIMD and `none` → confidential DCR, per-server. */ +export const DCR_CONFIDENTIAL_DISCOVER = loadGolden( + 'discover.dcrConfidential' +).body +/** Linear minus registration and RFC 9207 → pre-registered client, per-server. */ +export const PRE_REGISTERED_PER_SERVER_DISCOVER = loadGolden( + 'discover.preRegisteredPerServer' +).body + +export const ALL_DISCOVER_BODIES: ReadonlyArray<{ name: string; body: DiscoverRemoteResponse }> = [ + { name: 'linear', body: LINEAR_DISCOVER }, + { name: 'notion', body: NOTION_DISCOVER }, + { name: 'atlassian', body: ATLASSIAN_DISCOVER }, + { name: 'atlassian-unconfigured', body: ATLASSIAN_DISCOVER_UNCONFIGURED }, + { name: 'linear-unconfigured', body: LINEAR_DISCOVER_UNCONFIGURED }, + { name: 'dcr-confidential', body: DCR_CONFIDENTIAL_DISCOVER }, + { name: 'pre-registered-per-server', body: PRE_REGISTERED_PER_SERVER_DISCOVER }, +] + +// ─── /discover from a control-api older than the per-server callback ───────── +// +// That version answered the same body minus `callback` and `detected.asEndpointHosts`, +// so it is modelled by removing exactly those two fields from a current golden. Only +// servers whose registration mode did not change between the versions are modelled: +// Linear (CIMD with RFC 9207) and the confidential-DCR Notion variant (no CIMD, so the +// older selector also resolved DCR). Notion and Canva as recorded would have resolved +// CIMD there, so their current goldens cannot stand for the older backend. + +function asOlderControlApi(body: DiscoverRemoteResponse): DiscoverRemoteResponse { + const { callback: _callback, detected, ...rest } = body + const { asEndpointHosts: _hosts, ...olderDetected } = detected + return { ...rest, detected: olderDetected } +} + +export const OLDER_CONTROL_API_LINEAR_DISCOVER = asOlderControlApi(LINEAR_DISCOVER) +/** An AS without RFC 9207: the older control-api answers 422 to every install mode. */ +export const OLDER_CONTROL_API_DCR_DISCOVER = asOlderControlApi(DCR_CONFIDENTIAL_DISCOVER) + +export const LINEAR_DETECTED: RemoteDetected = LINEAR_DISCOVER.detected +export const NOTION_DETECTED: RemoteDetected = NOTION_DISCOVER.detected +export const ATLASSIAN_DETECTED: RemoteDetected = ATLASSIAN_DISCOVER.detected +export const DCR_CONFIDENTIAL_DETECTED: RemoteDetected = DCR_CONFIDENTIAL_DISCOVER.detected +export const PRE_REGISTERED_PER_SERVER_DETECTED: RemoteDetected = + PRE_REGISTERED_PER_SERVER_DISCOVER.detected + +// ─── install 201 ────────────────────────────────────────────────────────────── + +/** Per-server DCR; `redirectUri` ends in the (pinned) install nonce. */ +export const ATLASSIAN_INSTALLED = loadGolden('install.atlassian').body +export const LINEAR_INSTALLED = loadGolden('install.linear').body +/** Installed as `linear-pre` from {@link PRE_REGISTERED_PER_SERVER_DISCOVER}. */ +export const PRE_REGISTERED_PER_SERVER_INSTALLED = loadGolden( + 'install.preRegisteredPerServer' +).body + +/** + * Same server name, but the AS started returning `iss` (RFC 9207) after the + * {@link PRE_REGISTERED_PER_SERVER_DISCOVER} detection: the install lands on the + * shared callback, so its redirect URI differs from the per-server preview. + */ +export const PRE_REGISTERED_SHARED_INSTALLED = loadGolden( + 'install.preRegisteredShared' +).body + +// ─── errors ─────────────────────────────────────────────────────────────────── + +/** Dropbox: token endpoint on dropboxapi.com, issuer on dropbox.com, no RFC 9207. */ +export const DROPBOX_DISCOVER_FAILURE = loadGolden('discover.dropbox') +/** Issuer on a public suffix (`github.io`): no registrable domain to compare against. */ +export const ISSUER_PUBLIC_SUFFIX_DISCOVER_FAILURE = loadGolden('discover.issuerPublicSuffix') +export const CLIENT_ID_IN_USE_FAILURE = loadGolden('install.clientIdInUse') +export const DCR_REDIRECT_MISMATCH_FAILURE = loadGolden('install.dcrRedirectMismatch') +export const CIMD_WITHOUT_ISS_BINDING_FAILURE = loadGolden('install.cimdWithoutIssBinding') +export const CALLBACK_UNCONFIGURED_FAILURE = loadGolden('install.callbackUnconfigured') + +/** + * The error `apiSend` throws for a captured failure: the real `formatApiError` over + * the golden body, so `.code`/`.body`/`.status` are what the wizard really receives. + */ +export function apiErrorFrom(capture: WireCapture): Error { + const res = { status: capture.status, statusText: 'Error' } as Response + return formatApiError(res, JSON.stringify(capture.body)) +} diff --git a/deploy/overlays/minikube/configmaps/control-api-config.yaml b/deploy/overlays/minikube/configmaps/control-api-config.yaml index 91c9fa842e..3bf2fdead3 100644 --- a/deploy/overlays/minikube/configmaps/control-api-config.yaml +++ b/deploy/overlays/minikube/configmaps/control-api-config.yaml @@ -41,7 +41,10 @@ data: # Locally the user consents in their own browser, so the callback must land on # the port-forwarded loopback. Register this EXACT literal as the redirect URI # in each provider's OAuth app: the CONTROL_API_OAUTH_CALLBACK_BASE_URL value - # below, suffixed with /api/v1/oauth-callback/. + # below, suffixed with /api/v1/oauth-callback/ (baked/generic). Remote + # servers use /api/v1/oauth-callback/remote when their AS advertises RFC 9207, + # otherwise /api/v1/oauth-callback/remote/ (pre-registered; DCR adds + # / and registers it automatically). The wizard shows the exact URI. # (Distinct from RPC_PROXY_OAUTH_CALLBACK_BASE_URL, which is the legacy # recipe/embed flow and uses `localhost` — do not conflate them.) CONTROL_API_OAUTH_CALLBACK_BASE_URL: 'http://127.0.0.1:8090' diff --git a/desktop-app/src/__tests__/chatMessageMerge.test.ts b/desktop-app/src/__tests__/chatMessageMerge.test.ts index df7533f1db..d6ebe1bded 100644 --- a/desktop-app/src/__tests__/chatMessageMerge.test.ts +++ b/desktop-app/src/__tests__/chatMessageMerge.test.ts @@ -2,7 +2,225 @@ import { describe, expect, it } from 'vitest' import fc from 'fast-check' import { mergeAuthoritativeServerMessages, messageServerTurnNumber } from '../chatMessageMerge.js' import { type ServerTurn, turnsToChatMessages } from '../serverTurnAdapter.js' -import type { ChatMessage } from '../types.js' +import type { ChatMessage, ChatMessageAttachment } from '../types.js' + +const imageWithBytes: ChatMessageAttachment = { + id: 'local-image', + type: 'uploaded_file', + label: 'chart.png', + filename: 'chart.png', + mimeType: 'image/png', + encoding: 'base64', + dataBase64: 'AQID', + sizeBytes: 3, +} + +describe('mergeAuthoritativeServerMessages image byte gap-fill', () => { + it('never duplicates same-name logical images across reconciliation or a repeated merge', () => { + fc.assert( + fc.property( + fc.array(fc.constantFrom('chart.png', 'photo.png'), { minLength: 1, maxLength: 8 }), + names => { + const localImages: ChatMessageAttachment[] = names.map((label, index) => ({ + id: `local-${index}`, + type: 'uploaded_file', + label, + filename: label, + mimeType: 'image/png', + encoding: 'base64', + dataBase64: Buffer.from([index]).toString('base64'), + sizeBytes: 1, + })) + const serverImages: ChatMessageAttachment[] = names.map((label, index) => ({ + id: `parsed:uploaded_file:${index}:${label}`, + type: 'uploaded_file', + label, + })) + const server: ChatMessage = { + id: 'turn-1-user', + role: 'user', + content: 'Analyze', + timestamp: 2, + serverTurnNumber: 1, + task_id: 'task-1', + attachments: [ + { id: 'server-plugin', type: 'plugin', label: 'profits/revenue' }, + ...serverImages, + ], + } + const local: ChatMessage = { + id: 'optimistic-user', + role: 'user', + content: 'Analyze', + timestamp: 1, + task_id: 'task-1', + attachments: localImages, + } + const [merged] = mergeAuthoritativeServerMessages([local], [server], { + activeTaskIds: new Set(['task-1']), + }) + const uploaded = merged?.attachments?.filter( + attachment => attachment.type === 'uploaded_file' + ) + expect(uploaded).toHaveLength(names.length) + expect(uploaded?.map(attachment => attachment.dataBase64)).toEqual( + localImages.map(attachment => attachment.dataBase64) + ) + expect(merged?.attachments?.[0]).toEqual(server.attachments?.[0]) + expect(mergeAuthoritativeServerMessages([merged!], [server])[0]?.attachments).toEqual( + merged?.attachments + ) + } + ), + { numRuns: 250 } + ) + }) + + it('keeps server reference chips and enriches a matching image during live-task replacement', () => { + const serverPlugin: ChatMessageAttachment = { + id: 'server-plugin', + type: 'plugin', + label: 'profits/revenue', + } + const serverImage: ChatMessageAttachment = { + id: 'parsed-uploaded-file-chart', + type: 'uploaded_file', + label: 'chart.png', + } + const merged = mergeAuthoritativeServerMessages( + [ + { + id: 'optimistic-user', + role: 'user', + content: 'Analyze the chart', + timestamp: 1, + task_id: 'task-1', + attachments: [imageWithBytes, { id: 'local-plugin', type: 'plugin', label: 'revenue' }], + }, + ], + [ + { + id: 'turn-1-user', + role: 'user', + content: 'Analyze the chart', + timestamp: 2, + serverTurnNumber: 1, + task_id: 'task-1', + attachments: [serverPlugin, serverImage], + }, + ], + { activeTaskIds: new Set(['task-1']) } + ) + + expect(merged).toHaveLength(1) + expect(merged[0]?.attachments).toEqual([ + serverPlugin, + { + ...serverImage, + filename: 'chart.png', + mimeType: 'image/png', + encoding: 'base64', + dataBase64: 'AQID', + sizeBytes: 3, + }, + ]) + expect(mergeAuthoritativeServerMessages(merged, [merged[0]!])).toEqual(merged) + }) + + it('matches an uploaded file by ID when its server label differs from the local name', () => { + const serverImage: ChatMessageAttachment = { + id: imageWithBytes.id, + type: 'uploaded_file', + label: 'server chart label', + } + const merged = mergeAuthoritativeServerMessages( + [ + { + id: 'optimistic-user', + role: 'user', + content: 'Chart', + timestamp: 1, + task_id: 'task-id', + attachments: [imageWithBytes], + }, + ], + [ + { + id: 'turn-1-user', + role: 'user', + content: 'Chart', + timestamp: 2, + serverTurnNumber: 1, + task_id: 'task-id', + attachments: [serverImage], + }, + ], + { activeTaskIds: new Set(['task-id']) } + ) + expect(merged[0]?.attachments).toEqual([ + { + ...serverImage, + filename: 'chart.png', + mimeType: 'image/png', + encoding: 'base64', + dataBase64: 'AQID', + sizeBytes: 3, + }, + ]) + }) + + it('keeps an unproven local image on its own idle echo', () => { + const serverPlugin: ChatMessageAttachment = { + id: 'server-plugin', + type: 'plugin', + label: 'profits/revenue', + } + const merged = mergeAuthoritativeServerMessages( + [ + { id: 'turn-1-user', role: 'user', content: 'first', timestamp: 1, serverTurnNumber: 1 }, + { + id: 'optimistic-user', + role: 'user', + content: 'Analyze the chart', + timestamp: 2, + task_id: 'settled-task', + attachments: [ + imageWithBytes, + { id: 'local-only-label', type: 'agent_file', label: 'notes.md' }, + ], + }, + { + id: 'turn-2-user', + role: 'user', + content: 'Analyze the chart', + timestamp: 3, + serverTurnNumber: 2, + }, + ], + [ + { id: 'turn-1-user', role: 'user', content: 'first', timestamp: 4, serverTurnNumber: 1 }, + { + id: 'turn-2-user', + role: 'user', + content: 'Analyze the chart', + timestamp: 5, + serverTurnNumber: 2, + attachments: [serverPlugin], + }, + ], + { activeTaskIds: new Set() } + ) + + expect(merged.map(message => message.id)).toEqual([ + 'turn-1-user', + 'optimistic-user', + 'turn-2-user', + ]) + expect(merged[1]?.attachments).toContainEqual(imageWithBytes) + expect(merged[2]?.attachments).toEqual([serverPlugin]) + expect(merged[2]?.task_id).toBeUndefined() + }) +}) describe('mergeAuthoritativeServerMessages', () => { it('preserves a task-backed in-flight message outside the authoritative turn', () => { @@ -134,7 +352,7 @@ describe('mergeAuthoritativeServerMessages', () => { expect(merged.map(message => message.id)).toEqual(['turn-1-user', 'turn-2-user']) }) - it('replaces completed task-backed echoes inside their authoritative turn', () => { + it('does not attribute an unscoped completed task ID to an authoritative turn', () => { const merged = mergeAuthoritativeServerMessages( [ { @@ -171,7 +389,7 @@ describe('mergeAuthoritativeServerMessages', () => { ) expect(merged.map(message => message.id)).toEqual(['turn-4-user', 'turn-4-assistant']) - expect(merged.every(message => message.task_id === 'completed-task')).toBe(true) + expect(merged.every(message => message.task_id === undefined)).toBe(true) }) it('preserves turnless system messages inside an authoritative range', () => { diff --git a/desktop-app/src/chatMessageMerge.ts b/desktop-app/src/chatMessageMerge.ts index db84161495..e3dcb41a1a 100644 --- a/desktop-app/src/chatMessageMerge.ts +++ b/desktop-app/src/chatMessageMerge.ts @@ -1,4 +1,4 @@ -import type { ChatMessage } from './types.js' +import type { ChatMessage, ChatMessageAttachment } from './types.js' export function messageServerTurnNumber(message: Pick) { if (message.serverTurnNumber !== undefined) return message.serverTurnNumber @@ -14,6 +14,197 @@ function serverSlotKey(message: Pick = { + uploaded_file: { identified: 'enrich', contentEcho: 'retain' }, + global_file: { identified: 'enrich', contentEcho: 'retain' }, + agent_file: { identified: 'enrich', contentEcho: 'retain' }, + response_file: { identified: 'enrich', contentEcho: 'unique' }, + plugin: { identified: 'server', contentEcho: 'retain' }, + connector: { identified: 'server', contentEcho: 'retain' }, +} + +function attachmentIdentitiesConflict( + server: ChatMessageAttachment, + local: ChatMessageAttachment +): boolean { + if (server.type === 'global_file' && local.type === 'global_file') { + return Boolean( + (server.gfsUri && local.gfsUri && server.gfsUri !== local.gfsUri) || + (server.version !== undefined && + local.version !== undefined && + server.version !== local.version) + ) + } + if (server.type === 'agent_file' && local.type === 'agent_file') { + return Boolean( + server.filesystemName && + local.filesystemName && + server.path && + local.path && + (server.filesystemName !== local.filesystemName || server.path !== local.path) + ) + } + return false +} + +function sameStructuredAttachmentIdentity( + server: ChatMessageAttachment, + local: ChatMessageAttachment +): boolean { + if (server.type === 'global_file' && local.type === 'global_file') { + return Boolean(server.gfsUri && server.gfsUri === local.gfsUri) + } + if (server.type === 'agent_file' && local.type === 'agent_file') { + return Boolean( + server.filesystemName && + server.path && + server.filesystemName === local.filesystemName && + server.path === local.path + ) + } + return false +} + +function enrichIdentifiedAttachment( + server: ChatMessageAttachment, + local: ChatMessageAttachment +): ChatMessageAttachment { + if (server.type === 'global_file') { + if ( + !local.gfsUri || + !Number.isSafeInteger(local.version) || + !Number.isSafeInteger(local.bytes) || + (server.version !== undefined && server.version !== local.version) + ) { + return server + } + return { + ...server, + gfsUri: server.gfsUri ?? local.gfsUri, + drive: server.drive ?? local.drive, + resourceId: server.resourceId ?? local.resourceId, + version: server.version ?? local.version, + bytes: server.bytes ?? local.bytes, + } + } + if (server.type === 'agent_file') { + if (!local.filesystemName || !local.path) return server + return { + ...server, + filesystemName: server.filesystemName ?? local.filesystemName, + path: server.path ?? local.path, + } + } + if (!local.dataBase64) return server + return { + ...server, + filename: server.filename ?? local.filename, + mimeType: server.mimeType ?? local.mimeType, + encoding: server.encoding ?? local.encoding, + dataBase64: server.dataBase64 ?? local.dataBase64, + sizeBytes: server.sizeBytes ?? local.sizeBytes, + } +} + +/** Keep server chips in order while pairing names only within an identified turn. */ +function mergeAttachmentChips( + server: ChatMessageAttachment[] | undefined, + local: ChatMessageAttachment[] | undefined +): ChatMessageAttachment[] | undefined { + if (!server?.length) return local + if (!local?.length) return server + + const matchedLocalIndexes = new Set() + const localIndexByServerIndex = new Map() + const pair = (serverIndex: number, predicate: (attachment: ChatMessageAttachment) => boolean) => { + const localIndex = local.findIndex( + (attachment, index) => + !matchedLocalIndexes.has(index) && + ATTACHMENT_RULES[attachment.type].identified === 'enrich' && + !attachmentIdentitiesConflict(server[serverIndex]!, attachment) && + predicate(attachment) + ) + if (localIndex < 0) return + matchedLocalIndexes.add(localIndex) + localIndexByServerIndex.set(serverIndex, localIndex) + } + + // Stable IDs win even when the server changes a display label. + server.forEach((attachment, index) => { + pair(index, candidate => candidate.type === attachment.type && candidate.id === attachment.id) + }) + server.forEach((attachment, index) => { + if (localIndexByServerIndex.has(index)) return + pair(index, candidate => sameStructuredAttachmentIdentity(attachment, candidate)) + }) + // Parsed server IDs differ from optimistic IDs. Pair the remaining chips by + // type + name in order, including collisions such as two "photo.png" images. + server.forEach((attachment, index) => { + if (localIndexByServerIndex.has(index)) return + const name = attachmentName(attachment) + if (name) { + pair( + index, + candidate => candidate.type === attachment.type && attachmentName(candidate) === name + ) + } + }) + + const merged = server.map((attachment, index) => { + const localIndex = localIndexByServerIndex.get(index) + const match = localIndex === undefined ? undefined : local[localIndex] + return match ? enrichIdentifiedAttachment(attachment, match) : attachment + }) + return [ + ...merged, + ...local.filter( + (attachment, index) => + !matchedLocalIndexes.has(index) && ATTACHMENT_RULES[attachment.type].identified === 'enrich' + ), + ] +} + +function contentEchoIsUnique( + local: ChatMessage, + authoritative: ChatMessage[], + existing: ChatMessage[] +): boolean { + const serverSlots = new Map() + for (const row of [...existing, ...authoritative]) { + if (messageServerTurnNumber(row) !== undefined) serverSlots.set(serverSlotKey(row), row) + } + const matchingSlots = [...serverSlots.values()].filter( + row => row.role === local.role && row.content === local.content + ) + const matchingLocals = existing.filter( + row => + messageServerTurnNumber(row) === undefined && + row.role === local.role && + row.content === local.content && + !row.isError && + !row.preserveLocal + ) + return matchingSlots.length === 1 && matchingLocals.length === 1 +} + +function contentEchoMayCollapse(local: ChatMessage, unique: boolean): boolean { + if ( + local.attachments?.some( + attachment => ATTACHMENT_RULES[attachment.type].contentEcho === 'retain' || !unique + ) + ) { + return false + } + return unique || !local.toolSteps?.length +} + function preferredServerMessage( server: ChatMessage, local: ChatMessage | undefined, @@ -23,7 +214,7 @@ function preferredServerMessage( return { ...server, task_id: local.task_id ?? server.task_id, - attachments: server.attachments?.length ? server.attachments : local.attachments, + attachments: mergeAttachmentChips(server.attachments, local.attachments), toolSteps: server.toolSteps?.length ? server.toolSteps : local.toolSteps, } } @@ -135,16 +326,17 @@ export function mergeAuthoritativeServerMessages( const removedIndexes = new Set() const removedIndexByMessage = new Map() const localByServerMessage = new Map() - // Side metadata (toolSteps/attachments/tokens via copyLocalMetadata) of a - // collapsed idle echo, keyed by the authoritative row it merges onto. Kept - // SEPARATE from localByServerMessage: the collapse is not a positional 1↔1 - // replacement, so it must not drive the local anchor index nor the single-claim - // filter — it only contributes metadata to a server row that always survives - // (§6.2, R2-M1). + const identifiedServerMessages = new Set() + // Eligible output artifacts and tool steps from a uniquely owned idle echo. + // A content-only collapse cannot establish attachment or task identity. const collapsedEchoMetadataByServerMessage = new Map() const consumedLocalMessages = new Set() - const markLocalReplacement = (serverMessage: ChatMessage, localMessage: ChatMessage) => { + const markLocalReplacement = ( + serverMessage: ChatMessage, + localMessage: ChatMessage, + identified: boolean + ) => { const index = existing.indexOf(localMessage) if (index < 0 || consumedLocalMessages.has(localMessage)) return consumedLocalMessages.add(localMessage) @@ -152,18 +344,12 @@ export function mergeAuthoritativeServerMessages( removedIndexes.add(index) removedIndexByMessage.set(localMessage, index) localByServerMessage.set(serverMessage, localMessage) + if (identified) identifiedServerMessages.add(serverMessage) } - // Collapse sibling of markLocalReplacement: evicts a turnless idle echo from the - // output WITHOUT registering localByServerMessage (no positional 1↔1 anchoring), - // but DOES merge its side metadata (toolSteps/attachments/tokens via - // copyLocalMetadata) onto the authoritative row of its slot — the same echoRow - // its content matched. The server row never changes content or presence, so this - // can never reintroduce R1-B1 (property #1 still guards every numbered server - // row, §6.1) and never rewrites content (loss-safety of text, §6.2). It is no - // longer pure drop-only: it is drop of the local ∩ merge of its metadata into the - // surviving authoritative row, so toolSteps/attachments the optimistic bubble - // accumulated are not lost when the reconciled server row lacks them (R2-M1). + // Remove a proven unique text echo without treating it as an identified + // replacement. Only attachment classes allowed by the decision table reach + // this path; its metadata cannot become a positional anchor or a task ID. const dropLocalEcho = (localMessage: ChatMessage, echoRow: ChatMessage) => { const index = existing.indexOf(localMessage) if (index < 0 || consumedLocalMessages.has(localMessage)) return @@ -183,7 +369,7 @@ export function mergeAuthoritativeServerMessages( messageServerTurnNumber(local) !== undefined && serverSlotKey(local) === slot ) - if (sameSlot) markLocalReplacement(serverMessage, sameSlot) + if (sameSlot) markLocalReplacement(serverMessage, sameSlot, true) } const serverTurnAllowedForTurnlessLocal = ( @@ -250,7 +436,7 @@ export function mergeAuthoritativeServerMessages( for (const [message, matches] of liveLocalExactMatches) { if (matches.length === 1 && exactMatchClaimants.get(matches[0]!) === 1) { - markLocalReplacement(matches[0]!, message) + markLocalReplacement(matches[0]!, message, true) } } @@ -275,7 +461,7 @@ export function mergeAuthoritativeServerMessages( // this role is free to host the idle optimistic (candidateTurns empty) AND it is // bracketed on both sides by numbered turns (slot saturation — the core case is // the consecutive same-role sandwich Q = P + 1), the bubble is the residual echo - // of an already-materialised numbered turn. We drop it (drop-only) ONLY when its + // of an already-materialised numbered turn. We collapse it ONLY when its // content matches the AUTHORITATIVE incoming row of the same role that fills the // neighbour's (turn, role) slot — NOT the numbered neighbour taken from // `existing`, which can be stale: the row that actually lands in that output slot @@ -329,7 +515,12 @@ export function mergeAuthoritativeServerMessages( : matchesAuthoritative(previousAuthoritative) ? previousAuthoritative : undefined - if (echoRow) dropLocalEcho(message, echoRow) + if ( + echoRow && + contentEchoMayCollapse(message, contentEchoIsUnique(message, authoritative, existing)) + ) { + dropLocalEcho(message, echoRow) + } } continue } @@ -341,31 +532,25 @@ export function mergeAuthoritativeServerMessages( serverTurnAllowedForTurnlessLocal(serverMessage, message, index) ) if (!candidates.length) continue - markLocalReplacement(candidates[0]!, message) + // A free positional slot can dedupe plain optimistic text, but it does + // not establish ownership for attachments or tool steps. + if (message.attachments?.length || message.toolSteps?.length) continue + markLocalReplacement(candidates[0]!, message, false) } const hydratedReplacements = authoritative.map(message => { const local = localByServerMessage.get(message) - let hydrated = preferredServerMessage(message, local, { copyLocalMetadata: Boolean(local) }) - // Merge the DURABLE side metadata of a collapsed idle echo onto its - // authoritative slot row (§6.2, R2-M1). Bounded to attachments/toolSteps only, - // gap-filling and non-destructive: whatever `hydrated` already supplies wins, so - // the row only gains fields the reconciled server turn lacked. It deliberately - // does NOT use preferredServerMessage/copyLocalMetadata here: that path also - // copies `task_id` (echo identity), which is correct for D-1 replacement (the - // server IS the live task's echo) but WRONG for an idle collapse — the task is - // done and the collapse is by content coincidence, not identity. Tagging a - // historical server row with a done task's id leaks identity and would break the - // task_id-based dedupe consumers rely on (the exact class the ':602' stale- - // metadata guard forbids). `content` is never touched either (loss-safety, §6.1). - // This does NOT affect localAnchorIndex: the collapse has no positional anchor. + let hydrated = preferredServerMessage(message, local, { + copyLocalMetadata: identifiedServerMessages.has(message), + }) + // A unique content-only collapse may carry output artifacts and tool steps. + // Images and input references never reach this path, and no task ID or + // positional anchor is copied from the echo. const collapsedEcho = collapsedEchoMetadataByServerMessage.get(message) if (collapsedEcho) { hydrated = { ...hydrated, - attachments: hydrated.attachments?.length - ? hydrated.attachments - : collapsedEcho.attachments, + attachments: mergeAttachmentChips(hydrated.attachments, collapsedEcho.attachments), toolSteps: hydrated.toolSteps?.length ? hydrated.toolSteps : collapsedEcho.toolSteps, } } diff --git a/desktop-app/src/types.ts b/desktop-app/src/types.ts index 45fde5d7d6..052c7a84b1 100644 --- a/desktop-app/src/types.ts +++ b/desktop-app/src/types.ts @@ -1300,6 +1300,15 @@ export interface ChatMessageAttachment { encoding?: 'base64' dataBase64?: string sizeBytes?: number + /** Reference identity survives server-turn display parsing for Resend. */ + filesystemName?: string + path?: string + drive?: string + resourceId?: string + gfsUri?: string + /** Listed GFS identity required to resend a structured global-file reference. */ + version?: number + bytes?: number } export interface ChatFile { diff --git a/desktop-app/test/e2e-playwright/workflow-snippet-runtime-happy-path.test.ts b/desktop-app/test/e2e-playwright/workflow-snippet-runtime-happy-path.test.ts index 217c557962..0a78ef8fd1 100644 --- a/desktop-app/test/e2e-playwright/workflow-snippet-runtime-happy-path.test.ts +++ b/desktop-app/test/e2e-playwright/workflow-snippet-runtime-happy-path.test.ts @@ -33,6 +33,8 @@ import { const CONTROL_API = process.env.CONTROL_API_BASE_URL || 'http://127.0.0.1:8090' const CONTROL_UI = process.env.CONTROL_UI_BASE_URL || process.env.CONTROL_UI_URL || 'http://127.0.0.1:3000' +const CONTROL_UI_ORIGIN = new URL(CONTROL_UI).origin +const CONTROL_UI_API = `${CONTROL_UI_ORIGIN}/control-api` const ADMIN_USERNAME = process.env.E2E_ADMIN_USERNAME || process.env.ADMIN_USER || 'admin' const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD || process.env.ADMIN_PASSWORD || process.env.ADMIN_PASS @@ -48,6 +50,9 @@ const WORKLOAD_SECRET_ENV = 'CONFIG_TOKEN' const WORKLOAD_SECRET_VALUE = 'workflow-config-token-e2e' const RUN_MAX_DURATION_SECONDS = 600 const RUN_TTL_SECONDS_AFTER_FINISHED = 7200 +const REGISTRY_ENTRY_VERSION = '1.0.0' + +let publishedRegistryEntryName = '' type K8sWorkflowRecipe = { status?: { workflowExecution?: { phase?: string; message?: string } } @@ -84,6 +89,10 @@ function sqlLiteral(value: string): string { return `'${value.replace(/'/g, "''")}'` } +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + function kubectl(args: string[], input?: string, timeout = 30_000): string { return execFileSync('kubectl', ['--context', K8S_CONTEXT, ...args], { encoding: 'utf-8', @@ -249,9 +258,10 @@ function buildSnippetRecipeManifest(name: string): Record { } } -async function controlUiLogin(page: Page): Promise { +async function controlUiLogin(page: Page): Promise { const password = requireAdminPassword() - await page.goto(CONTROL_UI) + // E2E_GUARDIAN_ENTRY_POINT: enter the public Control UI root before UI login. + await page.goto('/') const usernameInput = page.getByLabel('Username') const passwordInput = page.getByLabel('Password') await expect(usernameInput).toBeVisible({ timeout: 20_000 }) @@ -261,57 +271,239 @@ async function controlUiLogin(page: Page): Promise { const signInButton = page.getByRole('button', { name: /^Sign in$/ }) await expect(signInButton).toBeEnabled({ timeout: 10_000 }) await signInButton.click() - await expect(page.getByText('Workflow Recipes', { exact: false })).toBeVisible({ + await expect(page).toHaveURL(/\/agents$/, { timeout: 25_000 }) + await expect(page.getByRole('link', { name: 'Installed Plugins', exact: true })).toBeVisible({ timeout: 25_000, }) - const token = await page.evaluate(() => localStorage.getItem('controlUiAdminToken') ?? '') - expect(token, 'Control UI should persist admin token after login').toBeTruthy() - return token + const session = await page.request.get(`${CONTROL_UI_API}/api/v1/admin/auth/me`) + expect(session.status(), 'Control UI login should establish an authenticated session').toBe(200) + const authenticated = (await session.json()) as { me?: { id?: string } } + expect( + authenticated.me?.id, + 'Control UI session should identify the signed-in admin' + ).toBeTruthy() +} + +async function openInstalledPluginsFromSidebar(page: Page): Promise { + const pluginsLink = page.getByRole('link', { name: 'Installed Plugins', exact: true }) + await pluginsLink.click() + await expect(page).toHaveURL(/\/plugins$/, { timeout: 30_000 }) + await expect(page.getByRole('button', { name: 'Install Plugin', exact: true })).toBeVisible({ + timeout: 30_000, + }) + await expect( + page.getByText('Select a plugin to view status, run history, and actions.') + ).toBeVisible({ timeout: 30_000 }) +} + +async function openInstalledPluginFromSidebar(page: Page, recipeName: string): Promise { + await openInstalledPluginsFromSidebar(page) + const search = page.getByLabel('Search plugins', { exact: true }) + await expect(search).toBeEnabled({ timeout: 30_000 }) + await search.fill(recipeName) + const openPlugin = page.getByRole('link', { name: `Open ${recipeName}`, exact: true }) + await expect(openPlugin).toBeVisible({ timeout: 30_000 }) + await openPlugin.click() + await expect(page).toHaveURL( + new RegExp(`/plugins/${RECIPE_NS}/${escapeRegExp(recipeName)}/workloads$`), + { timeout: 30_000 } + ) + await expect(page.getByRole('heading', { name: recipeName, exact: true })).toBeVisible({ + timeout: 30_000, + }) +} + +async function grantPluginUserFromDetail(page: Page, userEmail: string): Promise { + await page.getByRole('tab', { name: /^Members/ }).click() + await expect(page).toHaveURL(/\/members$/, { timeout: 30_000 }) + const triggerUsers = page.getByTestId('workflow-access-trigger-users') + await expect(triggerUsers).toBeVisible({ timeout: 30_000 }) + await page.getByRole('button', { name: 'Add member', exact: true }).click() + const dialog = page.getByRole('dialog', { name: 'Add member', exact: true }) + await expect(dialog).toBeVisible({ timeout: 10_000 }) + const search = dialog.getByRole('textbox', { name: 'Search members...' }) + await expect(search).toBeVisible({ timeout: 20_000 }) + await search.fill(userEmail) + const option = dialog.getByRole('option').filter({ hasText: userEmail }) + await expect(option).toBeVisible({ timeout: 20_000 }) + const optionAccessibleName = await option.getAttribute('aria-label') + expect(optionAccessibleName, `${userEmail} option should expose its member identity`).toContain( + userEmail + ) + const selectedMemberLabel = optionAccessibleName!.replace( + new RegExp(`,\\s*${escapeRegExp(userEmail)}$`), + '' + ) + await option.click() + await expect(option).toHaveAttribute('aria-selected', 'true', { timeout: 10_000 }) + await dialog.getByRole('button', { name: 'Add member', exact: true }).click() + await expect(triggerUsers.getByText(userEmail)).toBeVisible({ timeout: 20_000 }) + const memberActions = triggerUsers.getByRole('button', { + name: `Actions for member trigger access: ${selectedMemberLabel}`, + exact: true, + }) + await expect(memberActions).toBeVisible({ timeout: 20_000 }) + await memberActions.click() + const removeMemberAccess = page.getByRole('menuitem', { + name: 'Remove member trigger access', + exact: true, + }) + await expect(removeMemberAccess).toBeVisible({ timeout: 10_000 }) + await page.keyboard.press('Escape') + await expect(removeMemberAccess).toBeHidden({ timeout: 10_000 }) +} + +async function publishSnippetRegistryEntry(page: Page, recipeName: string): Promise { + // Plugin publishing is intentionally absent from ControlUI. This package + // fixture reuses the browser's UI-created session; installation itself + // remains a visible user journey. + const response = await page.request.post(`${CONTROL_UI_API}/api/v1/admin/registry/entries`, { + headers: { Origin: CONTROL_UI_ORIGIN }, + data: { + name: recipeName, + version: REGISTRY_ENTRY_VERSION, + entryType: 'recipe', + description: `Layer 3A snippet runtime journey for ${recipeName}`, + author: 'e2e-test', + origin: 'human-authored', + category: 'workflow', + tags: ['e2e', 'snippet', 'layer3a'], + contentCreatorTag: 'community', + configCreatorTag: 'community', + visibility: 'private', + recipe: JSON.stringify(buildSnippetRecipeManifest(recipeName)), + }, + }) + expect(response.status(), 'publish snippet plugin package').toBe(201) + const published = (await response.json()) as { name?: unknown } + expect(published.name, 'published plugin package should return its scoped name').toContain( + recipeName + ) + publishedRegistryEntryName = String(published.name) +} + +async function provisionSnippetSecretsThroughControlUi( + page: Page, + recipeName: string +): Promise { + const secretName = `${recipeName}-coingecko-api` + await page.getByRole('tab', { name: 'Secrets', exact: true }).click() + await expect(page).toHaveURL(/\/secrets$/, { timeout: 30_000 }) + const addSecret = page.getByRole('button', { name: `Add recipe secret ${secretName}` }) + await expect(addSecret).toBeVisible({ timeout: 30_000 }) + await addSecret.click() + await expect(page).toHaveURL(/\/secrets\/new\?/, { timeout: 30_000 }) + await expect(page.getByRole('heading', { name: 'Create recipe secret' })).toBeVisible({ + timeout: 30_000, + }) + await expect(page.locator('#recipe-secret-name')).toHaveValue(secretName) + await expect(page.getByLabel('Owner recipe')).toHaveValue(recipeName) + await page.getByRole('button', { name: 'Continue', exact: true }).click() + + const keyInputs = page.getByPlaceholder('API_KEY') + const valueInputs = page.getByPlaceholder('secret value') + await expect(keyInputs).toHaveCount(2, { timeout: 30_000 }) + await expect(valueInputs).toHaveCount(2, { timeout: 30_000 }) + + const expectedSecretKeys = [SNIPPET_SECRET_KEY, WORKLOAD_SECRET_KEY].sort() + await expect + .poll( + async () => { + const currentKeys = await Promise.all((await keyInputs.all()).map(key => key.inputValue())) + return [...currentKeys].sort() + }, + { + timeout: 30_000, + message: 'Secrets form should expose exactly the two recipe key inputs', + } + ) + .toEqual(expectedSecretKeys) + + // The public Secrets form contract pairs key/value rows by array order. A + // non-assertion inputValue snapshot binds each value through its matching + // key rather than choosing a row by fixed position. + const keyLocators = await keyInputs.all() + const valueLocators = await valueInputs.all() + const keyValues = await Promise.all(keyLocators.map(key => key.inputValue())) + expect([...keyValues].sort()).toEqual(expectedSecretKeys) + const secretInputRowByKey = new Map() + keyValues.forEach((keyValue, index) => { + const keyInput = keyLocators[index] + const valueInput = valueLocators[index] + if (keyInput && valueInput) secretInputRowByKey.set(keyValue, { keyInput, valueInput }) + }) + for (const [keyValue, { keyInput, valueInput }] of secretInputRowByKey) { + await expect(keyInput).toBeVisible() + await expect(valueInput).toBeVisible() + if (keyValue === SNIPPET_SECRET_KEY) await valueInput.fill(SNIPPET_SECRET_VALUE) + if (keyValue === WORKLOAD_SECRET_KEY) await valueInput.fill(WORKLOAD_SECRET_VALUE) + } + + const createResponse = page.waitForResponse( + response => { + if (!response.url().includes('/admin/recipe-secrets')) return false + if (response.request().method() !== 'POST') return false + return response.request().postDataJSON()?.name === secretName + }, + { timeout: 30_000 } + ) + await page.getByRole('button', { name: 'Create secret', exact: true }).click() + const created = await createResponse + expect(created.status()).toBe(201) + await expect(page).toHaveURL(/\/secrets\/recipe$/, { timeout: 30_000 }) + + await openInstalledPluginFromSidebar(page, recipeName) } async function installRecipeFromControlUi( page: Page, recipeName: string, userEmail: string -): Promise { - const adminToken = await controlUiLogin(page) - await page.goto(`${CONTROL_UI}/workflow-recipes`) - await page.getByRole('button', { name: 'Install Recipe' }).click() - - const editor = page.locator('textarea').first() - await expect(editor).toBeVisible({ timeout: 15_000 }) - await editor.fill(JSON.stringify(buildSnippetRecipeManifest(recipeName), null, 2)) - await page.getByRole('button', { name: 'Validate' }).click() - await expect(page.getByText(/Configuration & Secrets/i)).toBeVisible({ timeout: 15_000 }) - const secretInput = page.getByPlaceholder(`Enter value for ${SNIPPET_SECRET_ALIAS}`) - await expect(secretInput).toBeVisible({ timeout: 15_000 }) - await secretInput.fill(SNIPPET_SECRET_VALUE) - const workloadSecretInput = page.getByPlaceholder(`Enter value for ${WORKLOAD_SECRET_ENV}`) - await expect(workloadSecretInput).toBeVisible({ timeout: 15_000 }) - await workloadSecretInput.fill(WORKLOAD_SECRET_VALUE) - - const userPicker = page.getByLabel('Pick a user to grant trigger access') - await expect(userPicker).toBeVisible({ timeout: 20_000 }) - const optionValue = await userPicker - .locator('option') - .filter({ hasText: userEmail }) - .first() - .getAttribute('value') - expect(optionValue, `${userEmail} should be selectable in Control UI grants panel`).toBeTruthy() - await userPicker.selectOption(optionValue!) - await page.getByRole('button', { name: 'Grant user' }).click() - await expect(page.getByTestId('workflow-access-trigger-users')).toContainText(userEmail, { - timeout: 10_000, +): Promise { + await controlUiLogin(page) + await publishSnippetRegistryEntry(page, recipeName) + + await openInstalledPluginsFromSidebar(page) + await page.getByRole('button', { name: 'Install Plugin', exact: true }).click() + await expect(page).toHaveURL(/\/marketplace\/org\/entries$/, { timeout: 30_000 }) + const entrySearch = page.getByLabel('Search entries', { exact: true }) + await expect(entrySearch).toBeEnabled({ timeout: 30_000 }) + await entrySearch.fill(recipeName) + const entryActions = page.getByRole('button', { + name: `Actions for ${publishedRegistryEntryName} v${REGISTRY_ENTRY_VERSION}`, }) + await expect(entryActions).toBeVisible({ timeout: 30_000 }) + await entryActions.click() + await page.getByRole('menuitem', { name: 'Install', exact: true }).click() + await expect(page).toHaveURL(/\/marketplace\/install\?/, { timeout: 30_000 }) + await expect( + page + .getByRole('heading', { name: 'Install a private plugin', exact: true }) + .or(page.getByRole('heading', { name: 'Install from Marketplace', exact: true })) + ).toBeVisible({ timeout: 30_000 }) - await page.getByRole('button', { name: 'Deploy Recipe' }).click() - await expect(page.locator('textarea')).toHaveCount(0, { timeout: 45_000 }) - await expect(page.getByRole('link', { name: `Open ${recipeName}` })).toBeVisible({ - timeout: 45_000, + await page.getByRole('button', { name: 'Continue', exact: true }).click() + await expect(page.getByText('No external egress declarations were found.')).toBeVisible({ + timeout: 30_000, }) + await page.getByRole('button', { name: 'Continue', exact: true }).click() + await expect( + page.getByText( + `Recipe ${publishedRegistryEntryName} v${REGISTRY_ENTRY_VERSION} will be installed` + ) + ).toBeVisible({ timeout: 30_000 }) + await page.getByRole('button', { name: 'Install plugin', exact: true }).click() + await expect(page).toHaveURL(new RegExp(`/plugins/${RECIPE_NS}/${escapeRegExp(recipeName)}$`), { + timeout: 60_000, + }) + await expect(page.getByRole('heading', { name: recipeName, exact: true })).toBeVisible({ + timeout: 30_000, + }) + + await provisionSnippetSecretsThroughControlUi(page, recipeName) + await grantPluginUserFromDetail(page, userEmail) assertWorkflowRecipeSecretMaterialized(recipeName) assertInstalledRecipeDoesNotContainSecretValues(recipeName) - return adminToken } function assertWorkflowRecipeSecretMaterialized(recipeName: string): void { @@ -344,25 +536,8 @@ async function grantExistingRecipeUserFromControlUi( recipeName: string, userEmail: string ): Promise { - await page.goto( - `${CONTROL_UI}/workflow-recipes/${encodeURIComponent(RECIPE_NS)}/${encodeURIComponent(recipeName)}?edit=1` - ) - await expect(page.getByText(`Edit Recipe: ${recipeName}`)).toBeVisible({ timeout: 30_000 }) - await page.getByRole('button', { name: 'Validate' }).click() - await expect(page.getByText(/Validation passed/i)).toBeVisible({ timeout: 15_000 }) - const userPicker = page.getByLabel('Pick a user to grant trigger access') - await expect(userPicker).toBeVisible({ timeout: 20_000 }) - const optionValue = await userPicker - .locator('option') - .filter({ hasText: userEmail }) - .first() - .getAttribute('value') - expect(optionValue, `${userEmail} should be selectable when editing grants`).toBeTruthy() - await userPicker.selectOption(optionValue!) - await page.getByRole('button', { name: 'Grant user' }).click() - await expect(page.getByTestId('workflow-access-trigger-users')).toContainText(userEmail, { - timeout: 15_000, - }) + await openInstalledPluginFromSidebar(page, recipeName) + await grantPluginUserFromDetail(page, userEmail) } async function waitForRecipeWorkflowPhase( @@ -403,18 +578,15 @@ async function waitForRecipeWorkflowPhase( return last } -async function waitForAdminRecipeActive(adminToken: string, name: string): Promise { +async function waitForAdminRecipeActive(page: Page, name: string): Promise { await expect .poll( async () => { - const res = await apiRequest( - 'GET', - `${CONTROL_API}/api/v1/admin/workflows/${RECIPE_NS}/${name}`, - undefined, - { Authorization: `Bearer ${adminToken}` } + const res = await page.request.get( + `${CONTROL_UI_API}/api/v1/admin/workflows/${RECIPE_NS}/${name}` ) - if (res.status !== 200) return `http-${res.status}` - const parsed = JSON.parse(res.body) as { status?: { phase?: string } } + if (res.status() !== 200) return `http-${res.status()}` + const parsed = (await res.json()) as { status?: { phase?: string } } return parsed.status?.phase ?? '' }, { @@ -629,16 +801,36 @@ async function downloadArtifactFromControlUiRun( recipeName: string, runId: string ): Promise { - await page.goto( - `${CONTROL_UI}/workflow-recipes/${encodeURIComponent(RECIPE_NS)}/${encodeURIComponent(recipeName)}/runs/${encodeURIComponent(runId)}` + await openInstalledPluginFromSidebar(page, recipeName) + await page.getByRole('tab', { name: /^Runs/ }).click() + await expect(page).toHaveURL(/\/runs$/, { timeout: 30_000 }) + const openRun = page.getByRole('link', { + name: `Open run ${runId.slice(0, 8)}`, + exact: true, + }) + await expect(openRun).toBeVisible({ timeout: 30_000 }) + await openRun.click() + await expect(page).toHaveURL( + new RegExp( + `^${escapeRegExp(CONTROL_UI_ORIGIN)}/plugins/${RECIPE_NS}/${escapeRegExp( + recipeName + )}/runs/${escapeRegExp(runId)}$` + ), + { timeout: 30_000 } ) await expect(page.getByTestId('artifacts-panel')).toBeVisible({ timeout: 60_000 }) const artifactRow = page.getByTestId('artifact-row').filter({ hasText: ARTIFACT_NAME }) await expect(artifactRow).toBeVisible({ timeout: 15_000 }) + await artifactRow + .getByRole('button', { name: `Actions for artifact ${ARTIFACT_NAME}`, exact: true }) + .click() + const downloadAction = page.getByRole('menuitem', { name: 'Download', exact: true }) + await expect(downloadAction).toBeVisible() + await expect(downloadAction).toBeEnabled() return JSON.parse( await downloadTextFromButton( page, - artifactRow.getByTestId('artifact-download'), + downloadAction, ARTIFACT_NAME, `${runId.slice(0, 8)}-${ARTIFACT_NAME}` ) @@ -741,6 +933,20 @@ function cleanupRecipe(name: string): void { throw new Error(`refusing to clean non-E2E recipe name ${name}`) } + try { + kubectl([ + '-n', + RECIPE_NS, + 'delete', + 'secret', + `${name}-coingecko-api`, + '--ignore-not-found=true', + ]) + } catch { + // The recipe cleanup below remains the primary failure; a stale test-owned + // credential can be removed by the later cleanup pass if this race loses. + } + try { const workflowNames = kubectl([ '-n', @@ -817,16 +1023,36 @@ function cleanupRecipe(name: string): void { } } +async function cleanupPublishedRegistryEntry(page: Page): Promise { + if (!publishedRegistryEntryName) return + const response = await page.request.delete( + `${CONTROL_UI_API}/api/v1/admin/registry/entries/${encodeURIComponent( + publishedRegistryEntryName + )}/versions/${encodeURIComponent(REGISTRY_ENTRY_VERSION)}`, + { headers: { Origin: CONTROL_UI_ORIGIN } } + ) + expect([200, 204, 404]).toContain(response.status()) + publishedRegistryEntryName = '' +} + test.describe('Layer 3A snippet runtime user flow', () => { test.slow() test.describe.configure({ timeout: 900_000 }) + test.use({ baseURL: CONTROL_UI }) test.beforeAll(async () => { ensureWorkflowTriggerFixturesSeeded() await clearSession() }) - test.afterAll(async () => { + test.afterEach(async ({ page }) => { + // Clean up through the shared UI session before its browser context is disposed. + cleanupRecipe(RECIPE_NAME) + await cleanupPublishedRegistryEntry(page) + }) + + test.afterAll(() => { + // Keep this idempotent second pass for failures during beforeAll or fixture setup. cleanupRecipe(RECIPE_NAME) }) @@ -838,9 +1064,9 @@ test.describe('Layer 3A snippet runtime user flow', () => { apiRequest('GET', `${EXT_API}/health`).then(res => expect(res.status).toBe(200)), ]) - const adminToken = await installRecipeFromControlUi(page, RECIPE_NAME, E2E_EMAIL) + await installRecipeFromControlUi(page, RECIPE_NAME, E2E_EMAIL) await waitForRecipeWorkflowPhase(RECIPE_NAME, 'completed', 300_000) - await waitForAdminRecipeActive(adminToken, RECIPE_NAME) + await waitForAdminRecipeActive(page, RECIPE_NAME) const { userId, userToken } = await loginAs(E2E_EMAIL) const { app, page: desktopPage } = await launchAndLogin(E2E_EMAIL) diff --git a/desktop-app/ui/src/App.tsx b/desktop-app/ui/src/App.tsx index 191708402a..d1a19e27c9 100644 --- a/desktop-app/ui/src/App.tsx +++ b/desktop-app/ui/src/App.tsx @@ -2507,6 +2507,7 @@ export function App() { handleDenyNotification: vm.handleDenyNotification, handleRefreshPendingApprovals: vm.handleRefreshPendingApprovals, handleDecidePendingApproval: vm.handleDecidePendingApproval, + pushToast: vm.pushToast, }), [ vm.clearNotifications, @@ -2524,6 +2525,7 @@ export function App() { vm.pendingApprovalActionId, vm.pendingApprovals, vm.pendingApprovalsLoading, + vm.pushToast, vm.removeNotification, vm.resolveApprovalNotification, vm.toasts, @@ -2573,6 +2575,12 @@ export function App() { ] ) + // Stable focus-request bumper shared through ChatComposerStateContext: the + // composer focuses (and scrolls) itself when the id increments (TASK-42). + const requestComposerFocus = React.useCallback(() => { + setComposerFocusRequestId(value => value + 1) + }, []) + const chatComposerStateValue = React.useMemo( () => ({ activeChatId: vm.activeChatId, @@ -2583,6 +2591,7 @@ export function App() { failedAgentSend: vm.failedAgentSend, activeMessageCount: vm.activeMessages.length, composerFocusRequestId, + requestComposerFocus, }), [ vm.activeChatId, @@ -2593,6 +2602,7 @@ export function App() { vm.failedAgentSend, vm.activeMessages.length, composerFocusRequestId, + requestComposerFocus, ] ) diff --git a/desktop-app/ui/src/__tests__/App.deepLinks.test.tsx b/desktop-app/ui/src/__tests__/App.deepLinks.test.tsx index b0052cdc0f..8d96d1424c 100644 --- a/desktop-app/ui/src/__tests__/App.deepLinks.test.tsx +++ b/desktop-app/ui/src/__tests__/App.deepLinks.test.tsx @@ -1805,13 +1805,11 @@ describe('App deep-link orchestration', () => { // path the real app-picker uses — no deep-link confirm ceremony. Defaults to // `ns/app`; pass a distinct appRef to launch another app the sidebar offers. async function launchAppFromSidebar(appRef = 'ns/app'): Promise { - await waitFor(() => - expect(sidebarHarness.props?.availableSandboxUiApps?.some(a => a.appRef === appRef)).toBe( - true - ) - ) - const app = sidebarHarness.props?.availableSandboxUiApps?.find(a => a.appRef === appRef) - if (!app) throw new Error(`${appRef} was not available to the sidebar`) + const app = await waitFor(() => { + const available = sidebarHarness.props?.availableSandboxUiApps?.find(a => a.appRef === appRef) + if (!available) throw new Error(`${appRef} was not available to the sidebar`) + return available + }) await act(async () => { sidebarHarness.props?.onOpenSandboxUiApp?.(app) await Promise.resolve() @@ -2205,15 +2203,16 @@ describe('App deep-link orchestration', () => { const pendingLocation = createDeferred<{ appRef: string; routePath?: string }>() getSandboxUiLocation.mockReturnValueOnce(pendingLocation.promise) await launchAppFromSidebar('ns/app-b') // deactivates A, reads (deferred) - await waitFor(() => expect(getSandboxUiLocation).toHaveBeenCalledTimes(1)) + await waitFor(() => expect(getSandboxUiLocation).toHaveBeenCalled()) await act(async () => { pendingLocation.resolve({ appRef: 'ns/app-b', routePath: '/b/route' }) await Promise.resolve() await Promise.resolve() }) - // Liveness witness (M4): A's deactivation read genuinely ran. - expect(getSandboxUiLocation).toHaveBeenCalledTimes(1) + // Liveness witness (M4): A's deactivation read genuinely ran. A later + // read may also occur while the incoming app activates. + expect(getSandboxUiLocation).toHaveBeenCalled() // Observable: A keeps its OWN route — the foreign read must not clear it. // (Pre-fix, the mismatch fell back to undefined → setter CLEARED '/a/own'.) const appA = currentController.workspaceTabs.tabs.find(tab => tab.id === outgoingId) diff --git a/desktop-app/ui/src/components/GfsImagePreview/Body.tsx b/desktop-app/ui/src/components/GfsImagePreview/Body.tsx index d8ebab4198..0f175053b7 100644 --- a/desktop-app/ui/src/components/GfsImagePreview/Body.tsx +++ b/desktop-app/ui/src/components/GfsImagePreview/Body.tsx @@ -2,8 +2,10 @@ import { useEffect, useId, useRef, useState } from 'react' import { Button, StatusBanner } from '@components/Common' import { IconCopy } from '@components/SidebarNav/icons' import { GFS_IMAGE_PREVIEW_MAX_BYTES } from '@constants/gfsImagePreview' +import { estimateBase64DecodedLength } from '@lib/base64Size' import { describeGfsReadError } from '@lib/gfsGrantErrors' import { assertGfsImagePreviewSize } from '@lib/gfsImagePreview' +import { copyImageBlobToClipboard } from '@lib/imageClipboard' import type { GfsImagePreviewBodyProps } from './types' /** @@ -17,6 +19,7 @@ export function GfsImagePreviewBody({ byteLength, fileName, gfsUri, + dataBase64, mimeType, onDownloadError, titleId, @@ -44,15 +47,34 @@ export function GfsImagePreviewBody({ const loadPreview = async () => { try { + // Exactly one source is allowed (see GfsImagePreviewSource). Reject + // both missing and ambiguous sources before reading either one. + if ((dataBase64 === undefined) === (gfsUri === undefined)) { + throw new Error('Image preview requires exactly one source') + } // The listed size is a skip HINT (fail fast without a round-trip); the // download itself is independently bounded so a wrong listed size cannot // materialize an oversized payload. assertGfsImagePreviewSize(byteLength) - const { bytes } = await window.clerum.gfs.downloadPreview( - gfsUri, - GFS_IMAGE_PREVIEW_MAX_BYTES - ) - assertGfsImagePreviewSize(bytes.byteLength) + let bytes: ArrayBuffer + if (dataBase64 !== undefined) { + // Inline source (chat image attachments): the bytes already sit on + // the attachment, so decode locally instead of a GFS round-trip. + // Still guard the DECODED length — a lying `byteLength` hint must + // not materialize an oversized blob. + assertGfsImagePreviewSize(estimateBase64DecodedLength(dataBase64)) + bytes = decodeBase64ToArrayBuffer(dataBase64) + assertGfsImagePreviewSize(bytes.byteLength) + } else { + const downloaded = await window.clerum.gfs.downloadPreview( + // Non-null by the source guard above (dataBase64 is absent → gfsUri + // is present). + gfsUri!, + GFS_IMAGE_PREVIEW_MAX_BYTES + ) + bytes = downloaded.bytes + assertGfsImagePreviewSize(bytes.byteLength) + } if (!active) return const blob = new Blob([bytes], { type: mimeType }) setSourceBlob(blob) @@ -82,7 +104,7 @@ export function GfsImagePreviewBody({ active = false if (objectUrl) URL.revokeObjectURL(objectUrl) } - }, [byteLength, gfsUri, mimeType]) + }, [byteLength, gfsUri, dataBase64, mimeType]) useEffect(() => { mountedRef.current = true @@ -105,40 +127,17 @@ export function GfsImagePreviewBody({ async function copyImageToClipboard(): Promise { if (!sourceBlob || !mountedRef.current) return try { - if (typeof ClipboardItem !== 'undefined' && navigator.clipboard?.write) { - let clipboardBlob: Blob | null = sourceBlob - if (!sourceBlob.type.includes('png')) { - clipboardBlob = await convertBlobToPng(sourceBlob) - } - if (!mountedRef.current) return - if (clipboardBlob) { - await navigator.clipboard.write([new ClipboardItem({ 'image/png': clipboardBlob })]) - if (!mountedRef.current) return - markCopyState('copied') - return - } - } - if (navigator.clipboard?.writeText) { - const dataUrl = await blobToDataUrl(sourceBlob) - if (!mountedRef.current) return - await navigator.clipboard.writeText(dataUrl) - if (!mountedRef.current) return - markCopyState('copied') - } else { - markCopyState('error') - } + const copied = await copyImageBlobToClipboard(sourceBlob, () => mountedRef.current) + if (copied && mountedRef.current) markCopyState('copied') } catch { - markCopyState('error') + if (mountedRef.current) markCopyState('error') } } return ( <>
- - {fileName} - -
+
- {headerActions} + + {fileName} +
+
{headerActions}
{previewError ? : null} @@ -177,29 +179,11 @@ export function GfsImagePreviewBody({ ) } -async function convertBlobToPng(blob: Blob): Promise { - if (typeof createImageBitmap === 'undefined') return null - try { - const bitmap = await createImageBitmap(blob) - const canvas = document.createElement('canvas') - canvas.width = bitmap.width - canvas.height = bitmap.height - const ctx = canvas.getContext('2d') - if (!ctx) return null - ctx.drawImage(bitmap, 0, 0) - return await new Promise(resolve => canvas.toBlob(resolve, 'image/png')) - } catch { - return null +function decodeBase64ToArrayBuffer(dataBase64: string): ArrayBuffer { + const binary = window.atob(dataBase64) + const bytes = new Uint8Array(binary.length) + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index) } -} - -async function blobToDataUrl(blob: Blob): Promise { - const result = await new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onload = () => resolve(reader.result) - reader.onerror = () => reject(reader.error ?? new Error('Could not read the image')) - reader.readAsDataURL(blob) - }) - if (typeof result !== 'string') throw new Error('Could not read the image') - return result + return bytes.buffer } diff --git a/desktop-app/ui/src/components/GfsImagePreview/GfsImagePreview.test.tsx b/desktop-app/ui/src/components/GfsImagePreview/GfsImagePreview.test.tsx index 64eba54213..3f256b5a85 100644 --- a/desktop-app/ui/src/components/GfsImagePreview/GfsImagePreview.test.tsx +++ b/desktop-app/ui/src/components/GfsImagePreview/GfsImagePreview.test.tsx @@ -80,6 +80,12 @@ describe('GfsImagePreview layout', () => { const closeButton = screen.getByRole('button', { name: 'Close image preview' }) expect(closeButton.classList.contains('ui-button')).toBe(true) expect(closeButton.classList.contains('da-gfs-image-preview-dialog__close')).toBe(true) + const headerMain = screen + .getByRole('dialog', { name: 'diagram.png' }) + .querySelector('.da-gfs-image-preview-dialog__header-main') + expect(headerMain?.contains(copyButton)).toBe(true) + expect(headerMain?.firstElementChild).toBe(copyButton) + expect(headerMain?.contains(closeButton)).toBe(false) expect((modal as HTMLElement).style.left).toBe('74px') expect((modal as HTMLElement).style.right).toBe('0px') diff --git a/desktop-app/ui/src/components/GfsImagePreview/GfsImagePreviewBody.test.tsx b/desktop-app/ui/src/components/GfsImagePreview/GfsImagePreviewBody.test.tsx index 11300b94ab..41de06616e 100644 --- a/desktop-app/ui/src/components/GfsImagePreview/GfsImagePreviewBody.test.tsx +++ b/desktop-app/ui/src/components/GfsImagePreview/GfsImagePreviewBody.test.tsx @@ -52,6 +52,63 @@ describe('GfsImagePreviewBody', () => { expect(screen.queryByRole('presentation')).toBeNull() }) + it('renders an inline base64 source without a GFS download (chat attachments)', async () => { + // BUG-176: chat image attachments already carry their bytes — the body must + // decode them locally instead of reaching for `gfs.downloadPreview`. + const downloadPreview = vi.fn(async () => ({ bytes: new Uint8Array([9]).buffer })) + Object.defineProperty(window, 'clerum', { + configurable: true, + value: { gfs: { downloadPreview } }, + }) + + render( + + ) + + const img = await screen.findByAltText('Preview of Screenshot 2026-09-21 at 13.32.30.png') + expect(img.getAttribute('src')).toBe('blob:gfs-image-preview') + expect(downloadPreview).not.toHaveBeenCalled() + }) + + it('rejects missing and ambiguous sources before decoding or downloading', async () => { + const downloadPreview = stubDownload(new Uint8Array([1]).buffer) + const atobSpy = vi.spyOn(window, 'atob') + render() + expect(await screen.findByText('Image preview requires exactly one source')).toBeTruthy() + cleanup() + render( + + ) + expect(await screen.findByText('Image preview requires exactly one source')).toBeTruthy() + expect(downloadPreview).not.toHaveBeenCalled() + expect(atobSpy).not.toHaveBeenCalled() + }) + + it('rejects oversized inline base64 before calling atob even with a small size hint', async () => { + const atobSpy = vi.spyOn(window, 'atob') + render( + + ) + expect(await screen.findByText(/Image previews are limited to 10 MB/)).toBeTruthy() + expect(atobSpy).not.toHaveBeenCalled() + }) + it('renders the heading at the requested level (a page uses h2)', async () => { stubDownload(new Uint8Array([1, 2, 3]).buffer) render( diff --git a/desktop-app/ui/src/components/GfsImagePreview/index.tsx b/desktop-app/ui/src/components/GfsImagePreview/index.tsx index c19e9838d0..5c4603839b 100644 --- a/desktop-app/ui/src/components/GfsImagePreview/index.tsx +++ b/desktop-app/ui/src/components/GfsImagePreview/index.tsx @@ -16,6 +16,7 @@ export function GfsImagePreview({ byteLength, fileName, gfsUri, + dataBase64, mimeType, onClose, reloadVersion, @@ -82,6 +83,7 @@ export function GfsImagePreview({ byteLength={byteLength} fileName={fileName} gfsUri={gfsUri} + dataBase64={dataBase64} mimeType={mimeType} onDownloadError={onDownloadError} titleId={titleId} @@ -95,4 +97,4 @@ export function GfsImagePreview({ } export { GfsImagePreviewBody } from './Body' -export type { GfsImagePreviewProps } from './types' +export type { GfsImagePreviewProps, GfsImagePreviewSource } from './types' diff --git a/desktop-app/ui/src/components/GfsImagePreview/types.ts b/desktop-app/ui/src/components/GfsImagePreview/types.ts index 8e2827353f..21255a7fba 100644 --- a/desktop-app/ui/src/components/GfsImagePreview/types.ts +++ b/desktop-app/ui/src/components/GfsImagePreview/types.ts @@ -1,15 +1,29 @@ import type { ReactNode } from 'react' +/** + * Where the preview bytes come from (exactly one): + * - `gfsUri` — fetched from GFS by URI (Files/preview surfaces). + * - `dataBase64` — supplied inline as base64 (chat image attachments already + * carry their bytes; no GFS round-trip exists for them). + * + * Modeled as two optional props (not a discriminated union) so a caller can + * destructure and forward them losslessly; `GfsImagePreviewBody` guards the + * exactly-one contract at runtime. + */ +export type GfsImagePreviewSource = { + gfsUri?: string + dataBase64?: string +} + export type GfsImagePreviewProps = { byteLength: number fileName: string - gfsUri: string mimeType: string onClose: () => void reloadVersion?: number unavailable?: boolean onDownloadError?: (error: unknown) => void -} +} & GfsImagePreviewSource /** * The de-modalized image preview (spec 18 §3.B.2): the `` + its fetch, @@ -21,10 +35,9 @@ export type GfsImagePreviewProps = { export type GfsImagePreviewBodyProps = { byteLength: number fileName: string - gfsUri: string mimeType: string onDownloadError?: (error: unknown) => void titleId?: string headerActions?: ReactNode headingLevel?: 2 | 3 -} +} & GfsImagePreviewSource diff --git a/desktop-app/ui/src/components/agents/AnnotationCanvas.tsx b/desktop-app/ui/src/components/agents/AnnotationCanvas.tsx index 1c0ba77d11..e7f73d2836 100644 --- a/desktop-app/ui/src/components/agents/AnnotationCanvas.tsx +++ b/desktop-app/ui/src/components/agents/AnnotationCanvas.tsx @@ -1,6 +1,8 @@ import React, { useCallback, useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { Button, IconButton } from '@components/Common' +import { IconCopy, IconEdit } from '@components/SidebarNav/icons' +import { copyImageBlobToClipboard } from '@lib/imageClipboard' import type { ComposerImageAttachment } from '../../uiTypes' const COLOR_INPUT_FALLBACK = `#${'0'.repeat(6)}` @@ -44,9 +46,25 @@ export function AnnotationCanvas({ attachment, onSave, onClose }: AnnotationCanv const [annotationBrushPx, setAnnotationBrushPx] = useState(5) const [annotationBusy, setAnnotationBusy] = useState(false) const [annotationError, setAnnotationError] = useState(null) + const [copyBusy, setCopyBusy] = useState(false) + const [copyState, setCopyState] = useState<'idle' | 'copied' | 'error'>('idle') const annotationCanvasRef = useRef(null) const annotationPointerActiveRef = useRef(false) const annotationLastPointRef = useRef<{ x: number; y: number } | null>(null) + const mountedRef = useRef(true) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + + useEffect(() => { + if (copyState === 'idle') return + const timeout = window.setTimeout(() => setCopyState('idle'), 2000) + return () => window.clearTimeout(timeout) + }, [copyState]) const loadImage = useCallback((src: string) => { return new Promise((resolve, reject) => { @@ -193,6 +211,29 @@ export function AnnotationCanvas({ attachment, onSave, onClose }: AnnotationCanv } }, [onSave, attachment, readBlobAsDataUrl]) + const handleCopyImage = useCallback(async () => { + if (copyBusy) return + setCopyBusy(true) + setCopyState('idle') + try { + const binary = window.atob(attachment.dataBase64) + const buffer = new ArrayBuffer(binary.length) + const bytes = new Uint8Array(buffer) + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index) + } + const copied = await copyImageBlobToClipboard( + new Blob([buffer], { type: attachment.mimeType }), + () => mountedRef.current + ) + if (copied && mountedRef.current) setCopyState('copied') + } catch { + if (mountedRef.current) setCopyState('error') + } finally { + if (mountedRef.current) setCopyBusy(false) + } + }, [attachment.dataBase64, attachment.mimeType, copyBusy]) + return createPortal(
{!previewIsAnnotating ? ( - + <> + + + ) : ( <>
+ {resendSource ? ( + handleResendMessage(resendSource)} + aria-label={ + message.role === 'user' ? 'Resend message' : 'Resend prompt' + } + label={ + message.role === 'user' ? 'Resend message' : 'Resend prompt' + } + size="xs" + variant="ghost" + > + + + ) : null} (() => tracker.get(key)) + // TASK-228: tasks the user explicitly dismissed. Keyed by task identity so a + // dismissal hides every later tier of the SAME task (tracker updates keep + // arriving) while a NEW task in the same chat starts fresh with its nudges. + const [dismissedTaskIds, setDismissedTaskIds] = useState>(() => new Set()) const { isWindowVisible } = useBrowserWindowState() const tier = useTaskTier(state) @@ -57,6 +66,15 @@ export function NudgeArea({ agentRef, chatId, onStartNewChat, onRefreshState }: return tracker.subscribe(key, setState) }, [tracker, key]) + const dismissNudge = useCallback((taskIdentity: string) => { + setDismissedTaskIds(previous => { + if (previous.has(taskIdentity)) return previous + const next = new Set(previous) + next.add(taskIdentity) + return next + }) + }, []) + if (!state) return null if (state.status === 'completed' || state.status === 'cancelled' || state.status === 'failed') return null @@ -67,23 +85,46 @@ export function NudgeArea({ agentRef, chatId, onStartNewChat, onRefreshState }: // window too. if (state.status === 'suspended' || state.pendingApproval) return null if (tier === 'T1' || tier === 'T2') return null + // Degenerate-taskId fallback (PR #859 review): keying an id-less task's + // dismissal to the CHAT key would suppress every future task in the chat, + // contradicting "a new task starts fresh". `startedAt` stays per-task. + const taskIdentity = state.taskId || `${key}@${state.startedAt}` + if (dismissedTaskIds.has(taskIdentity)) return null + + const dismissButton = ( + dismissNudge(taskIdentity)} + > + + + ) if (tier === 'T3') { return (
- - This task is taking longer than usual. You can keep working in another chat — we'll let - you know here when it's done. - - +
+ + This task is taking longer than usual. You can keep working in another chat — we'll let + you know here when it's done. + + {dismissButton} +
+
+ +
) } @@ -94,10 +135,15 @@ export function NudgeArea({ agentRef, chatId, onStartNewChat, onRefreshState }: : 'Your agent is still working. Your system will notify you when it finishes.' return (
- - {text} - - +
+ + {text} + + {dismissButton} +
+
+ +
) } @@ -105,19 +151,24 @@ export function NudgeArea({ agentRef, chatId, onStartNewChat, onRefreshState }: // T5 return (
- - Your agent is still working. If you think something might be stuck, you can refresh the - state. - - - +
+ + Your agent is still working. If you think something might be stuck, you can refresh the + state. + + {dismissButton} +
+
+ + +
) } diff --git a/desktop-app/ui/src/components/agents/__tests__/AgentWorkspace.chatScroll.test.tsx b/desktop-app/ui/src/components/agents/__tests__/AgentWorkspace.chatScroll.test.tsx index 1413e92d25..f79d33a9d5 100644 --- a/desktop-app/ui/src/components/agents/__tests__/AgentWorkspace.chatScroll.test.tsx +++ b/desktop-app/ui/src/components/agents/__tests__/AgentWorkspace.chatScroll.test.tsx @@ -60,6 +60,13 @@ vi.mock('@hooks/domain/useConnectorsController', async importActual => ({ }), })) vi.mock('../ComposerPanel', () => ({ ComposerPanel: () => null })) +vi.mock('@contexts/ChatComposerStateContext', () => ({ + useChatComposerStateContext: () => ({ + composerImageAttachments: [], + composerReferenceAttachments: [], + requestComposerFocus: vi.fn(), + }), +})) vi.mock('../ContextWindowIndicator', () => ({ ContextWindowIndicator: () => null })) vi.mock('../InFlightAssistantPlaceholder', () => ({ InFlightAssistantPlaceholder: () => null })) vi.mock('../NudgeArea', () => ({ NudgeArea: () => null })) @@ -178,6 +185,7 @@ function WorkspaceHarness({ pendingApprovalsLoading: false, pendingApprovalActionId: null, toasts: [], + pushToast: vi.fn(), markNotificationsRead: vi.fn(), clearNotifications: vi.fn(), removeNotification: vi.fn(), diff --git a/desktop-app/ui/src/components/agents/__tests__/AnnotationCanvas.test.tsx b/desktop-app/ui/src/components/agents/__tests__/AnnotationCanvas.test.tsx index a3d0c5f2c7..1413ebdcea 100644 --- a/desktop-app/ui/src/components/agents/__tests__/AnnotationCanvas.test.tsx +++ b/desktop-app/ui/src/components/agents/__tests__/AnnotationCanvas.test.tsx @@ -1,7 +1,8 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import type { ComposerImageAttachment } from '../../../uiTypes' -import { commitAnnotatedPreview } from '../AnnotationCanvas' +import { AnnotationCanvas, commitAnnotatedPreview } from '../AnnotationCanvas' const attachment = (previewDataUrl: string): ComposerImageAttachment => ({ id: 'att-1', @@ -57,3 +58,49 @@ describe('commitAnnotatedPreview', () => { expect(revoke).not.toHaveBeenCalled() }) }) + +describe('AnnotationCanvas copy action', () => { + const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard') + + afterEach(() => { + cleanup() + vi.unstubAllGlobals() + if (originalClipboard) { + Object.defineProperty(navigator, 'clipboard', originalClipboard) + } else { + Reflect.deleteProperty(navigator, 'clipboard') + } + }) + + it('copies the attached image bytes and confirms success', async () => { + const write = vi.fn(async () => undefined) + const clipboardItems: Array> = [] + vi.stubGlobal( + 'ClipboardItem', + class { + constructor(data: Record) { + clipboardItems.push(data) + } + } + ) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { write }, + }) + + render( + + ) + + expect(screen.getByRole('button', { name: 'Annotate' }).querySelector('svg')).not.toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Copy image to clipboard' })) + + await waitFor(() => expect(write).toHaveBeenCalledTimes(1)) + expect(clipboardItems[0]?.['image/png']?.size).toBe(3) + expect(screen.getByRole('button', { name: 'Copied image to clipboard' })).toBeTruthy() + }) +}) diff --git a/desktop-app/ui/src/components/agents/__tests__/ChatThread.renderSemantics.test.tsx b/desktop-app/ui/src/components/agents/__tests__/ChatThread.renderSemantics.test.tsx index 7305b9b1bd..db316c97a8 100644 --- a/desktop-app/ui/src/components/agents/__tests__/ChatThread.renderSemantics.test.tsx +++ b/desktop-app/ui/src/components/agents/__tests__/ChatThread.renderSemantics.test.tsx @@ -6,7 +6,12 @@ import type { AgentChatMessage } from '../../../uiTypes' import { ChatThread } from '../ChatThread' const navigationValue = { selectedAgent: 'agent-x', handleSelectChatAgent: vi.fn() } -const notificationsValue = { decideApproval: vi.fn() } +const notificationsValue = { decideApproval: vi.fn(), pushToast: vi.fn() } +const composerStateValue = { + composerImageAttachments: [] as never[], + composerReferenceAttachments: [] as never[], + requestComposerFocus: vi.fn(), +} const chatListValue = { chatList: [], chatListLoading: false, @@ -30,6 +35,9 @@ vi.mock('@contexts/NavigationContext', () => ({ vi.mock('@contexts/NotificationsContext', () => ({ useNotificationsContext: () => notificationsValue, })) +vi.mock('@contexts/ChatComposerStateContext', () => ({ + useChatComposerStateContext: () => composerStateValue, +})) vi.mock('@contexts/ChatListContext', () => ({ useChatListContext: () => chatListValue, })) diff --git a/desktop-app/ui/src/components/agents/__tests__/ChatThread.resend.test.tsx b/desktop-app/ui/src/components/agents/__tests__/ChatThread.resend.test.tsx new file mode 100644 index 0000000000..080c49df58 --- /dev/null +++ b/desktop-app/ui/src/components/agents/__tests__/ChatThread.resend.test.tsx @@ -0,0 +1,349 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { + getComposerDraft, + resetComposerDraftStore, + setComposerDraft, +} from '@lib/composerDraftStore' +import { mergeAuthoritativeServerMessages } from '../../../../../src/chatMessageMerge' +import { turnsToChatMessages } from '../../../hooks/sessionAdapter' +import { buildLoadedChatSemanticModels } from '../../../lib/chatMessageSemantics' +import type { AgentChatMessage, ComposerImageAttachment } from '../../../uiTypes' +import { ChatThread } from '../ChatThread' + +const navigationValue = { selectedAgent: 'agent-x', handleSelectChatAgent: vi.fn() } +const pushToast = vi.fn() +const notificationsValue = { decideApproval: vi.fn(), pushToast } +const chatListValue = { + chatList: [], + chatListLoading: false, + sessionStateByChatId: {}, +} +const addComposerImageAttachments = vi.fn() +const addComposerReferenceAttachments = vi.fn() +const actionsValue = { + chatEndRef: { current: null }, + handleSelectChat: vi.fn(), + handleRenameChat: vi.fn(), + handleDeleteChat: vi.fn(), + handleAddComposerImageAttachments: addComposerImageAttachments, + handleAddComposerReferenceAttachments: addComposerReferenceAttachments, +} +const runtimeValue = { cancelTask: vi.fn() } +const requestComposerFocus = vi.fn() +const composerStateValue = { + composerImageAttachments: [] as ComposerImageAttachment[], + composerReferenceAttachments: [] as never[], +} +let messages: AgentChatMessage[] = [] +let groupedMessages: Array<{ role: 'user' | 'assistant' | 'system'; items: AgentChatMessage[] }> = + [] + +vi.mock('@contexts/NavigationContext', () => ({ + useNavigationContext: () => navigationValue, +})) +vi.mock('@contexts/NotificationsContext', () => ({ + useNotificationsContext: () => notificationsValue, +})) +vi.mock('@contexts/ChatComposerStateContext', () => ({ + useChatComposerStateContext: () => ({ + composerImageAttachments: composerStateValue.composerImageAttachments, + composerReferenceAttachments: composerStateValue.composerReferenceAttachments, + requestComposerFocus, + }), +})) +vi.mock('@contexts/ChatListContext', () => ({ + useChatListContext: () => chatListValue, +})) +vi.mock('@contexts/AgentChatActionsContext', () => ({ + useAgentChatActionsContext: () => actionsValue, +})) +vi.mock('@contexts/McpRuntimeContext', () => ({ + useMcpRuntimeContext: () => runtimeValue, +})) +vi.mock('@contexts/ChatThreadStateContext', () => ({ + useChatThreadStateContext: () => ({ + activeMessages: messages, + groupedMessages, + chatMessagesLoading: false, + hasOlderMessages: false, + olderMessagesLoading: false, + handleLoadOlderMessages: vi.fn(), + activeChatId: 'chat-1', + activityByMessageId: {}, + progressByMessageId: {}, + localSearchQuery: '', + localSearchCurrentMatch: null, + semanticModelsByMessageId: new Map( + buildLoadedChatSemanticModels(messages).map(model => [model.messageId, model]) + ), + }), +})) +vi.mock('../InFlightAssistantPlaceholder', () => ({ InFlightAssistantPlaceholder: () => null })) +vi.mock('../NudgeArea', () => ({ NudgeArea: () => null })) +vi.mock('../ChatStateBadge', () => ({ ChatStateBadge: () => null })) +vi.mock('@components/MessageArtifactActions', () => ({ MessageArtifactActions: () => null })) + +const PNG_BASE64 = 'aGVsbG8=' + +function userMessageWithAttachments(): AgentChatMessage { + return { + id: 'user-1', + role: 'user', + content: 'Summarize the chart with the plugin', + timestamp: 1, + attachments: [ + { + id: 'img-1', + type: 'uploaded_file', + label: 'chart.png', + addedOrder: 0, + filename: 'chart.png', + mimeType: 'image/png', + encoding: 'base64', + dataBase64: PNG_BASE64, + sizeBytes: 5, + }, + { + id: 'chip-plugin', + type: 'plugin', + label: 'profits/revenue', + addedOrder: 1, + }, + ], + } +} + +function renderWithErrorReply(user: AgentChatMessage) { + const errorReply: AgentChatMessage = { + id: 'assistant-error', + role: 'assistant', + content: 'The agent could not finish this request.', + timestamp: 2, + isError: true, + errorCode: 'LLM_MODEL_OVERLOADED', + } + messages = [user, errorReply] + groupedMessages = [ + { role: 'user', items: [user] }, + { role: 'assistant', items: [errorReply] }, + ] + render() +} + +afterEach(() => { + cleanup() + messages = [] + groupedMessages = [] + composerStateValue.composerImageAttachments = [] + composerStateValue.composerReferenceAttachments = [] + resetComposerDraftStore() + vi.clearAllMocks() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('ChatThread resend action (TASK-42)', () => { + it('restores the original prompt and attachments from an assistant error', () => { + renderWithErrorReply(userMessageWithAttachments()) + + expect(screen.queryByRole('button', { name: 'Resend message' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Resend prompt' })) + + expect(getComposerDraft('chat-1')).toBe('Summarize the chart with the plugin') + expect(addComposerImageAttachments).toHaveBeenCalledTimes(1) + expect(addComposerImageAttachments.mock.calls[0]![0]).toEqual([ + expect.objectContaining({ + name: 'chart.png', + mimeType: 'image/png', + dataBase64: PNG_BASE64, + sizeBytes: 5, + previewDataUrl: `data:image/png;base64,${PNG_BASE64}`, + }), + ]) + expect(addComposerReferenceAttachments).toHaveBeenCalledTimes(1) + expect(addComposerReferenceAttachments.mock.calls[0]![0]).toEqual([ + { + id: 'plugin:profits:revenue', + type: 'plugin', + namespace: 'profits', + name: 'revenue', + label: 'revenue', + }, + ]) + // A long-thread user must notice the repopulated composer. + expect(requestComposerFocus).toHaveBeenCalledTimes(1) + }) + + it('hides resend on successful user and assistant messages', () => { + const user = userMessageWithAttachments() + const assistant: AgentChatMessage = { + id: 'assistant-1', + role: 'assistant', + content: 'Here is the summary you asked for.', + timestamp: 2, + attachments: [ + { + id: 'generated-1', + type: 'response_file', + label: 'summary.md', + filename: 'summary.md', + mimeType: 'text/markdown', + encoding: 'base64', + dataBase64: 'IyBzdW1tYXJ5', + }, + ], + } + messages = [user, assistant] + groupedMessages = [ + { role: 'user', items: [user] }, + { role: 'assistant', items: [assistant] }, + ] + + render() + + expect(screen.queryByRole('button', { name: 'Resend prompt' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Resend message' })).toBeNull() + }) + + it('hides resend on an error with no preceding user prompt', () => { + const assistant: AgentChatMessage = { + id: 'assistant-only', + role: 'assistant', + content: 'A reply with no user turn before it.', + timestamp: 1, + isError: true, + } + messages = [assistant] + groupedMessages = [{ role: 'assistant', items: [assistant] }] + + render() + + expect(screen.queryByRole('button', { name: 'Resend prompt' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Resend message' })).toBeNull() + }) + + it('offers resend on an assistant error bubble (the most common retry case)', () => { + const user = userMessageWithAttachments() + const errorReply: AgentChatMessage = { + id: 'assistant-error', + role: 'assistant', + content: 'LLM blew up', + timestamp: 2, + isError: true, + errorCode: 'LLM_MODEL_OVERLOADED', + } + messages = [user, errorReply] + groupedMessages = [ + { role: 'user', items: [user] }, + { role: 'assistant', items: [errorReply] }, + ] + + render() + + fireEvent.click(screen.getByRole('button', { name: 'Resend prompt' })) + expect(getComposerDraft('chat-1')).toBe('Summarize the chart with the plugin') + expect(addComposerImageAttachments).toHaveBeenCalledTimes(1) + }) + + describe('dirty-composer contract (PR #859 review: refuse like handleRecoverFailedAgentSend)', () => { + it('refuses with an error toast when the composer holds draft text', () => { + setComposerDraft('chat-1', 'a half-written draft') + renderWithErrorReply(userMessageWithAttachments()) + + fireEvent.click(screen.getByRole('button', { name: 'Resend prompt' })) + + expect(pushToast).toHaveBeenCalledTimes(1) + expect(pushToast.mock.calls[0]![0]).toBe( + 'Keep or clear the current draft before resending this message.' + ) + expect(pushToast.mock.calls[0]![1]).toBe('error') + // The dirty draft is untouched and nothing was populated. + expect(getComposerDraft('chat-1')).toBe('a half-written draft') + expect(addComposerImageAttachments).not.toHaveBeenCalled() + expect(addComposerReferenceAttachments).not.toHaveBeenCalled() + expect(requestComposerFocus).not.toHaveBeenCalled() + }) + + it('refuses with an error toast when the composer holds pending attachments', () => { + composerStateValue.composerImageAttachments = [ + { + id: 'pending-1', + name: 'already-there.png', + mimeType: 'image/png', + dataBase64: 'cGVuZGluZw==', + sizeBytes: 7, + previewDataUrl: 'data:image/png;base64,cGVuZGluZw==', + }, + ] + renderWithErrorReply(userMessageWithAttachments()) + + fireEvent.click(screen.getByRole('button', { name: 'Resend prompt' })) + + expect(pushToast).toHaveBeenCalledTimes(1) + expect(pushToast.mock.calls[0]![1]).toBe('error') + expect(addComposerImageAttachments).not.toHaveBeenCalled() + expect(addComposerReferenceAttachments).not.toHaveBeenCalled() + expect(requestComposerFocus).not.toHaveBeenCalled() + }) + }) + + it("surfaces a warn toast when original attachments can't be restored (PR #859 review)", () => { + const user: AgentChatMessage = { + id: 'user-legacy', + role: 'user', + content: 'Analyze these', + timestamp: 1, + attachments: [ + { id: 'legacy-1', type: 'uploaded_file', label: 'old-shot.png', addedOrder: 0 }, + { id: 'legacy-2', type: 'uploaded_file', label: 'older-shot.png', addedOrder: 1 }, + ], + } + renderWithErrorReply(user) + + fireEvent.click(screen.getByRole('button', { name: 'Resend prompt' })) + + // The resend itself still happens (text restored, focus nudged)… + expect(getComposerDraft('chat-1')).toBe('Analyze these') + expect(requestComposerFocus).toHaveBeenCalledTimes(1) + expect(addComposerImageAttachments).not.toHaveBeenCalled() + // …but the loss is reported, not silent. + expect(pushToast).toHaveBeenCalledTimes(1) + expect(pushToast.mock.calls[0]![0]).toBe( + "2 attachments from the original message couldn't be restored." + ) + expect(pushToast.mock.calls[0]![1]).toBe('warn') + }) + + it('warns when a legacy label-only image survives reconciliation beside server references', () => { + const local: AgentChatMessage = { + id: 'optimistic-user', + role: 'user', + content: 'Analyze this', + timestamp: 1, + attachments: [{ id: 'legacy-image', type: 'uploaded_file', label: 'old-chart.png' }], + } + const incoming = turnsToChatMessages([ + { + number: 10, + user_input: [ + 'Analyze this', + 'USER-ATTACHED CONTEXT: The user selected these capabilities/files for this message.', + 'Plugins: profits/revenue. Use workflow tools.', + ].join('\n'), + started_at: new Date(2).toISOString(), + }, + ]) + const merged = mergeAuthoritativeServerMessages([local], incoming) + expect(merged.map(message => message.id)).toEqual(['optimistic-user', 'turn-10-user']) + expect(merged[0]?.attachments?.map(attachment => attachment.type)).toEqual(['uploaded_file']) + expect(merged[1]?.attachments?.map(attachment => attachment.type)).toEqual(['plugin']) + renderWithErrorReply(merged[0]!) + fireEvent.click(screen.getByRole('button', { name: 'Resend prompt' })) + expect(pushToast).toHaveBeenCalledWith( + "1 attachment from the original message couldn't be restored.", + 'warn' + ) + }) +}) diff --git a/desktop-app/ui/src/components/agents/__tests__/ComposerPanel.test.tsx b/desktop-app/ui/src/components/agents/__tests__/ComposerPanel.test.tsx index 2e9b9aaef9..d4332f2152 100644 --- a/desktop-app/ui/src/components/agents/__tests__/ComposerPanel.test.tsx +++ b/desktop-app/ui/src/components/agents/__tests__/ComposerPanel.test.tsx @@ -35,6 +35,7 @@ const composerState: ChatComposerStateContextValue = { activeChatId: null, activeMessageCount: 0, composerFocusRequestId: 0, + requestComposerFocus: vi.fn(), } const draftState = { value: '', set: vi.fn() } diff --git a/desktop-app/ui/src/components/agents/__tests__/chatThread.attachmentPreview.test.tsx b/desktop-app/ui/src/components/agents/__tests__/chatThread.attachmentPreview.test.tsx new file mode 100644 index 0000000000..92164adc41 --- /dev/null +++ b/desktop-app/ui/src/components/agents/__tests__/chatThread.attachmentPreview.test.tsx @@ -0,0 +1,224 @@ +// @vitest-environment jsdom +/** + * BUG-176 — a user-attached image in a SENT message must be viewable. The + * inert chip (image icon + filename) gains a preview trigger that opens the + * shared `GfsImagePreview` modal with the attachment's inline base64 bytes + * (no GFS round-trip). Legacy parsed `[Attached images]` chips (labels only, + * no bytes) stay inert. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { buildLoadedChatSemanticModels } from '../../../lib/chatMessageSemantics' +import type { AgentChatMessage, TaskProgress } from '../../../uiTypes' +// vi.mock calls are hoisted above this import, so ChatThread binds the mocks. +import { ChatThread } from '../ChatThread' + +const navValue = { selectedAgent: 'agent-x', handleSelectChatAgent: vi.fn() } +const notificationsValue = { decideApproval: vi.fn(), pushToast: vi.fn() } +const composerStateValue = { + composerImageAttachments: [] as never[], + composerReferenceAttachments: [] as never[], + requestComposerFocus: vi.fn(), +} +const chatListValue = { chatList: [], chatListLoading: false, sessionStateByChatId: {} } +const actionsValue = { + chatEndRef: { current: null }, + handleSelectChat: vi.fn(), + handleRenameChat: vi.fn(), + handleDeleteChat: vi.fn(), +} +const mcpRuntimeValue = { cancelTask: vi.fn() } +let messages: AgentChatMessage[] = [] + +vi.mock('@contexts/NavigationContext', () => ({ useNavigationContext: () => navValue })) +vi.mock('@contexts/NotificationsContext', () => ({ + useNotificationsContext: () => notificationsValue, +})) +vi.mock('@contexts/ChatComposerStateContext', () => ({ + useChatComposerStateContext: () => composerStateValue, +})) +vi.mock('@contexts/ChatListContext', () => ({ useChatListContext: () => chatListValue })) +vi.mock('@contexts/AgentChatActionsContext', () => ({ + useAgentChatActionsContext: () => actionsValue, +})) +vi.mock('@contexts/McpRuntimeContext', () => ({ useMcpRuntimeContext: () => mcpRuntimeValue })) +vi.mock('@contexts/ChatThreadStateContext', () => ({ + useChatThreadStateContext: () => ({ + activeMessages: messages, + groupedMessages: [{ role: 'user', items: messages }], + chatMessagesLoading: false, + hasOlderMessages: false, + olderMessagesLoading: false, + handleLoadOlderMessages: vi.fn(), + activeChatId: 'chat-1', + activityByMessageId: {}, + progressByMessageId: {} as Record, + localSearchQuery: '', + localSearchCurrentMatch: null, + semanticModelsByMessageId: new Map( + buildLoadedChatSemanticModels(messages).map(model => [model.messageId, model]) + ), + }), +})) +vi.mock('../InFlightAssistantPlaceholder', () => ({ InFlightAssistantPlaceholder: () => null })) +vi.mock('../NudgeArea', () => ({ NudgeArea: () => null })) +vi.mock('../ChatStateBadge', () => ({ ChatStateBadge: () => null })) +vi.mock('@components/MessageArtifactActions', () => ({ MessageArtifactActions: () => null })) + +const actEnvGlobal = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +actEnvGlobal.IS_REACT_ACT_ENVIRONMENT = true + +/** base64 of bytes [1, 2, 3] — small enough to decode synchronously. */ +const PNG_BASE64 = 'AQID' + +beforeEach(() => { + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: vi.fn(() => 'blob:attachment-preview'), + }) + Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() }) +}) + +afterEach(() => { + cleanup() + messages = [] + vi.clearAllMocks() + vi.restoreAllMocks() +}) + +describe('ChatThread uploaded-image attachment preview (BUG-176)', () => { + it('opens the shared GfsImagePreview modal when the sent-image chip is clicked', async () => { + messages = [ + { + id: 'msg-1', + role: 'user', + content: 'What is in this screenshot?', + timestamp: 1, + attachments: [ + { + id: 'image-1', + type: 'uploaded_file', + label: 'Screenshot 2026-09-21 at 13.32.30.png', + tooltip: 'Uploaded File - 1 KB', + filename: 'Screenshot 2026-09-21 at 13.32.30.png', + mimeType: 'image/png', + encoding: 'base64', + dataBase64: PNG_BASE64, + sizeBytes: 3, + }, + ], + }, + ] + render() + + // The chip body is a real button (same trigger contract as the composer). + const trigger = screen.getByRole('button', { name: /Screenshot 2026-09-21/i }) + expect(trigger.classList.contains('message-attachment-preview-trigger')).toBe(true) + expect(screen.queryByRole('dialog')).toBeNull() + + fireEvent.click(trigger) + + const dialog = await screen.findByRole('dialog') + expect(dialog.getAttribute('aria-modal')).toBe('true') + const heading = screen.getByRole('heading', { name: /Screenshot 2026-09-21/i, level: 3 }) + expect(heading).toBeTruthy() + const img = await screen.findByAltText(/Preview of Screenshot 2026-09-21/i) + expect(img.getAttribute('src')).toBe('blob:attachment-preview') + }) + + it('closes the preview from the modal close button', async () => { + messages = [ + { + id: 'msg-2', + role: 'user', + content: 'and this one?', + timestamp: 1, + attachments: [ + { + id: 'image-2', + type: 'uploaded_file', + label: 'photo.png', + filename: 'photo.png', + mimeType: 'image/png', + encoding: 'base64', + dataBase64: PNG_BASE64, + sizeBytes: 3, + }, + ], + }, + ] + render() + fireEvent.click(screen.getByRole('button', { name: /photo\.png/i })) + expect(await screen.findByRole('dialog')).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Close image preview' })) + + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('keeps chips inert when the attachment carries no image bytes (parsed history)', () => { + messages = [ + { + id: 'msg-3', + role: 'user', + content: 'legacy', + timestamp: 1, + attachments: [ + { + id: 'parsed:uploaded_file:0:old-shot.png', + type: 'uploaded_file', + label: 'old-shot.png', + }, + { + id: 'pdf-1', + type: 'uploaded_file', + label: 'contract.pdf', + mimeType: 'application/pdf', + }, + ], + }, + ] + const { container } = render() + + expect(screen.getByText('old-shot.png')).toBeTruthy() + expect(screen.getByText('contract.pdf')).toBeTruthy() + // No trigger button and no preview modal for byte-less chips. + expect(container.querySelector('.message-attachment-preview-trigger')).toBeNull() + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('estimates byteLength from base64 at 3/4, not raw length, when sizeBytes is absent (PR #859 review)', async () => { + // base64 length 10,519,760 exceeds the 10,485,760-byte preview limit, so + // the old raw-length fallback would early-skip the preview; the accurate + // 3/4 estimate (7,889,820 bytes) is under the limit and must render. + // Multiple of 4 so atob accepts it; decodes to 7,889,820 null bytes. + const base64Length = 10 * 1024 * 1024 + 34_000 + messages = [ + { + id: 'msg-4', + role: 'user', + content: 'big one', + timestamp: 1, + attachments: [ + { + id: 'image-4', + type: 'uploaded_file', + label: 'big.png', + filename: 'big.png', + mimeType: 'image/png', + encoding: 'base64', + dataBase64: 'A'.repeat(base64Length), + }, + ], + }, + ] + render() + + fireEvent.click(screen.getByRole('button', { name: /big\.png/i })) + + // Under the accurate estimate the preview opens instead of the size error. + const dialog = await screen.findByRole('dialog') + expect(dialog).toBeTruthy() + expect(await screen.findByAltText(/Preview of big\.png/i)).toBeTruthy() + }) +}) diff --git a/desktop-app/ui/src/components/agents/__tests__/chatThread.connectFailure.test.tsx b/desktop-app/ui/src/components/agents/__tests__/chatThread.connectFailure.test.tsx index 0573203575..5a775fa22f 100644 --- a/desktop-app/ui/src/components/agents/__tests__/chatThread.connectFailure.test.tsx +++ b/desktop-app/ui/src/components/agents/__tests__/chatThread.connectFailure.test.tsx @@ -13,7 +13,12 @@ import { ChatThread } from '../ChatThread' // for a `role: 'user'` group whose message id has an entry in `progressByMessageId`. // The context values are mutated per-test; the mock factories read them lazily. const navigationValue = { selectedAgent: 'agent-x', handleSelectChatAgent: vi.fn() } -const notificationsValue = { decideApproval: vi.fn() } +const notificationsValue = { decideApproval: vi.fn(), pushToast: vi.fn() } +const composerStateValue = { + composerImageAttachments: [] as never[], + composerReferenceAttachments: [] as never[], + requestComposerFocus: vi.fn(), +} const chatListValue = { chatList: [], chatListLoading: false, sessionStateByChatId: {} } const actionsValue = { chatEndRef: { current: null }, @@ -33,6 +38,9 @@ vi.mock('@contexts/NavigationContext', () => ({ useNavigationContext: () => navi vi.mock('@contexts/NotificationsContext', () => ({ useNotificationsContext: () => notificationsValue, })) +vi.mock('@contexts/ChatComposerStateContext', () => ({ + useChatComposerStateContext: () => composerStateValue, +})) vi.mock('@contexts/ChatListContext', () => ({ useChatListContext: () => chatListValue })) vi.mock('@contexts/AgentChatActionsContext', () => ({ useAgentChatActionsContext: () => actionsValue, diff --git a/desktop-app/ui/src/components/agents/__tests__/chatThread.toolSteps.test.tsx b/desktop-app/ui/src/components/agents/__tests__/chatThread.toolSteps.test.tsx index 0013f2e1c0..2964d93c66 100644 --- a/desktop-app/ui/src/components/agents/__tests__/chatThread.toolSteps.test.tsx +++ b/desktop-app/ui/src/components/agents/__tests__/chatThread.toolSteps.test.tsx @@ -20,7 +20,12 @@ import { ChatThread } from '../ChatThread' // ── context stubs (ChatThread reads everything from 6 contexts) ── const navValue = { selectedAgent: 'agent-x', handleSelectChatAgent: vi.fn() } -const notificationsValue = { resolveApprovalNotification: vi.fn() } +const notificationsValue = { resolveApprovalNotification: vi.fn(), pushToast: vi.fn() } +const composerStateValue = { + composerImageAttachments: [] as never[], + composerReferenceAttachments: [] as never[], + requestComposerFocus: vi.fn(), +} const chatListValue = { chatList: [], chatListLoading: false, sessionStateByChatId: {} } const actionsValue = { chatEndRef: { current: null }, @@ -54,6 +59,9 @@ vi.mock('@contexts/NavigationContext', () => ({ useNavigationContext: () => navV vi.mock('@contexts/NotificationsContext', () => ({ useNotificationsContext: () => notificationsValue, })) +vi.mock('@contexts/ChatComposerStateContext', () => ({ + useChatComposerStateContext: () => composerStateValue, +})) vi.mock('@contexts/ChatListContext', () => ({ useChatListContext: () => chatListValue })) vi.mock('@contexts/AgentChatActionsContext', () => ({ useAgentChatActionsContext: () => actionsValue, diff --git a/desktop-app/ui/src/components/agents/__tests__/nudgeArea.test.tsx b/desktop-app/ui/src/components/agents/__tests__/nudgeArea.test.tsx index 5f4c936e90..5c8c4ad5c3 100644 --- a/desktop-app/ui/src/components/agents/__tests__/nudgeArea.test.tsx +++ b/desktop-app/ui/src/components/agents/__tests__/nudgeArea.test.tsx @@ -183,3 +183,142 @@ describe('NudgeArea (D.5b)', () => { expect(container.firstChild).toBeNull() }) }) + +describe('NudgeArea dismissal (TASK-228)', () => { + /** Live, key-aware fake: emits flow through `emit` so later tiers of a task + * (and new tasks) reach an already-mounted NudgeArea. */ + function liveKeyedTracker(byKey: Map) { + const listeners = new Map void>() + const tracker = { + get: (key: string) => byKey.get(key), + subscribe: (key: string, fn: (s: TaskState) => void) => { + listeners.set(key, fn) + const s = byKey.get(key) + if (s) fn(s) + return () => { + listeners.delete(key) + } + }, + start: vi.fn(), + rejoinIfRunning: vi.fn(), + ack: vi.fn(), + cancel: vi.fn(async () => undefined), + setCallbacks: vi.fn(), + } as unknown as TaskTracker + const emit = (key: string, next: TaskState) => { + byKey.set(key, next) + listeners.get(key)?.(next) + } + return { tracker, emit } + } + + function nudgeElement(tracker: TaskTracker, chatId = 'c1') { + return ( + + + + ) + } + + it('renders a dismiss control on every tier', () => { + for (const ageMs of [150_000, 400_000, 1_000_000]) { + const { unmount } = renderNudge(task(ageMs)) + expect(screen.getByRole('button', { name: /dismiss this suggestion/i })).toBeTruthy() + unmount() + } + }) + + it('removes the card on dismiss and keeps it hidden for later tiers of the same task', () => { + const byKey = new Map([['agent-x::c1', task(150_000)]]) + const { tracker, emit } = liveKeyedTracker(byKey) + const { container } = render(nudgeElement(tracker)) + expect(screen.getByText(/taking longer than usual/i)).toBeTruthy() + + act(() => { + screen + .getByRole('button', { name: /dismiss this suggestion/i }) + .dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(container.firstChild).toBeNull() + + // Same task ages into T4 → the tracker keeps emitting, but the dismissal + // is keyed to the task, so no later tier may re-show the card. + act(() => { + emit('agent-x::c1', task(400_000)) + }) + expect(container.firstChild).toBeNull() + }) + + it('shows the nudge again for a NEW task in the same chat', () => { + const first = task(150_000) + const byKey = new Map([['agent-x::c1', first]]) + const { tracker, emit } = liveKeyedTracker(byKey) + const { container } = render(nudgeElement(tracker)) + + act(() => { + screen + .getByRole('button', { name: /dismiss this suggestion/i }) + .dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(container.firstChild).toBeNull() + + // A different task id → fresh nudges. + act(() => { + emit('agent-x::c1', task(150_000, { taskId: 't2', userMessageId: 'm2' })) + }) + expect(screen.getByText(/taking longer than usual/i)).toBeTruthy() + }) + + it('keeps a dismissal across a chat switch away and back (same task)', () => { + const byKey = new Map([['agent-x::c1', task(150_000)]]) + const { tracker } = liveKeyedTracker(byKey) + const { container, rerender } = render(nudgeElement(tracker, 'c1')) + expect(screen.getByText(/taking longer than usual/i)).toBeTruthy() + + act(() => { + screen + .getByRole('button', { name: /dismiss this suggestion/i }) + .dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(container.firstChild).toBeNull() + + // Switch to c2 (no task) and back to c1 — the same task is still running + // and must stay dismissed. + act(() => { + rerender(nudgeElement(tracker, 'c2')) + }) + expect(container.firstChild).toBeNull() + act(() => { + rerender(nudgeElement(tracker, 'c1')) + }) + expect(container.firstChild).toBeNull() + }) + + it('does not let a dismissal of a degenerate (empty) task id leak onto the next task in the chat (PR #859 review)', () => { + // `taskId` is typed non-optional, but a degenerate '' must not key the + // dismissal to the CHAT: the next task (also id-less, later `startedAt`) + // starts fresh instead of inheriting the suppression. + const first = task(150_000, { taskId: '' }) + const byKey = new Map([['agent-x::c1', first]]) + const { tracker, emit } = liveKeyedTracker(byKey) + const { container } = render(nudgeElement(tracker)) + expect(screen.getByText(/taking longer than usual/i)).toBeTruthy() + + act(() => { + screen + .getByRole('button', { name: /dismiss this suggestion/i }) + .dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(container.firstChild).toBeNull() + + act(() => { + emit('agent-x::c1', task(150_000, { taskId: '', startedAt: first.startedAt + 5_000 })) + }) + expect(screen.getByText(/taking longer than usual/i)).toBeTruthy() + }) +}) diff --git a/desktop-app/ui/src/contexts/ChatComposerStateContext/types.ts b/desktop-app/ui/src/contexts/ChatComposerStateContext/types.ts index b9188ee81e..20619985ec 100644 --- a/desktop-app/ui/src/contexts/ChatComposerStateContext/types.ts +++ b/desktop-app/ui/src/contexts/ChatComposerStateContext/types.ts @@ -20,6 +20,12 @@ export interface ChatComposerStateContextValue { failedAgentSend: FailedAgentSend | null activeMessageCount: number composerFocusRequestId: number + /** + * Bumps `composerFocusRequestId` so the mounted composer focuses (and + * scrolls) itself. Stable identity — safe in deps. Used by surfaces outside + * the composer (e.g. TASK-42 resend) to draw attention to a repopulated draft. + */ + requestComposerFocus: () => void } export interface ChatComposerStateProviderProps { diff --git a/desktop-app/ui/src/contexts/NotificationsContext.tsx b/desktop-app/ui/src/contexts/NotificationsContext.tsx index e5f9638f14..7c664b124b 100644 --- a/desktop-app/ui/src/contexts/NotificationsContext.tsx +++ b/desktop-app/ui/src/contexts/NotificationsContext.tsx @@ -1,7 +1,7 @@ import { createContext, useContext } from 'react' import type { PendingWorkflowApproval } from '../../../src/types' import type { ApprovalDecisionTarget } from '../hooks/domain/approvalDecision' -import type { AppNotification, ToastMessage } from '../uiTypes' +import type { AppNotification, ToastMessage, Tone } from '../uiTypes' export interface NotificationsContextValue { notifications: AppNotification[] @@ -11,6 +11,8 @@ export interface NotificationsContextValue { pendingApprovalsLoading: boolean pendingApprovalActionId: string | null toasts: ToastMessage[] + /** Pushes onto the app toast stack (same controller the status bar uses). */ + pushToast: (message: string, tone: Tone, options?: { durationMs?: number }) => void markNotificationsRead: () => void clearNotifications: () => void removeNotification: (notificationId: string) => void diff --git a/desktop-app/ui/src/hooks/__tests__/sessionAdapter.test.ts b/desktop-app/ui/src/hooks/__tests__/sessionAdapter.test.ts index 1117ff5a24..af8ae94395 100644 --- a/desktop-app/ui/src/hooks/__tests__/sessionAdapter.test.ts +++ b/desktop-app/ui/src/hooks/__tests__/sessionAdapter.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest' import { mergeAuthoritativeServerMessages } from '../../../../src/chatMessageMerge' import type { ChatMessage } from '../../../../src/types' +import { + buildChatMessageAttachments, + buildResponseFileAttachments, +} from '../../lib/chatMessageAttachments' +import { buildComposerRequestContent } from '../../lib/composerReferencesPrompt' +import { buildComposerResendDraft } from '../../lib/composerResend' +import type { ComposerReferenceAttachment } from '../../uiTypes' import { turnsToChatMessages } from '../sessionAdapter' describe('turnsToChatMessages', () => { @@ -50,7 +57,7 @@ describe('turnsToChatMessages', () => { ]) }) - it('merges an image-only optimistic turn using producer-shaped attachments', () => { + it('keeps an image-only optimistic turn without a proved server identity', () => { const existing: ChatMessage[] = [ { id: 'optimistic-user', @@ -85,10 +92,470 @@ describe('turnsToChatMessages', () => { const merged = mergeAuthoritativeServerMessages(existing, incoming) - expect(merged.map(message => message.id)).toEqual(['turn-7-user', 'turn-7-assistant']) + expect(merged.map(message => message.id)).toEqual([ + 'optimistic-user', + 'turn-7-user', + 'turn-7-assistant', + ]) expect(merged[0]?.attachments).toMatchObject([{ type: 'uploaded_file', label: 'photo.png' }]) }) + it('reconciles two same-name server image chips without duplicates or resend warnings', () => { + const incoming = turnsToChatMessages([ + { + number: 8, + user_input: [ + 'Analyze charts', + '[Attached images]', + '- chart.png', + '- chart.png', + 'USER-ATTACHED CONTEXT: The user selected these capabilities/files for this message.', + 'Plugins: profits/revenue. Use workflow tools.', + ].join('\n'), + started_at: new Date(120_003).toISOString(), + }, + ]) + const existing: ChatMessage[] = [ + { + ...incoming[0]!, + timestamp: 1, + attachments: [ + { + id: 'first', + type: 'uploaded_file', + label: 'chart.png', + filename: 'chart.png', + mimeType: 'image/png', + encoding: 'base64', + dataBase64: 'AQ==', + sizeBytes: 1, + }, + { + id: 'second', + type: 'uploaded_file', + label: 'chart.png', + filename: 'chart.png', + mimeType: 'image/png', + encoding: 'base64', + dataBase64: 'Ag==', + sizeBytes: 1, + }, + ], + }, + ] + const merged = mergeAuthoritativeServerMessages(existing, incoming) + const user = merged.find(message => message.id === 'turn-8-user')! + expect(user.attachments?.map(attachment => attachment.type)).toEqual([ + 'uploaded_file', + 'uploaded_file', + 'plugin', + ]) + expect( + user.attachments?.filter(attachment => attachment.type === 'uploaded_file') + ).toMatchObject([ + { label: 'chart.png', dataBase64: 'AQ==' }, + { label: 'chart.png', dataBase64: 'Ag==' }, + ]) + const draft = buildComposerResendDraft(user) + expect(draft.imageAttachments.map(image => image.dataBase64)).toEqual(['AQ==', 'Ag==']) + expect(draft.unrestorable).toEqual([]) + }) + + it('retains agent file identity and flags a server global-file label without a URI', () => { + const references: ComposerReferenceAttachment[] = [ + { + id: 'agent-file', + type: 'agent_file', + contextId: 'ctx-1', + filesystemName: 'shared-fs', + path: 'notes/todo.md', + kind: 'file', + label: 'todo.md', + }, + { + id: 'global-file', + type: 'global_file', + drive: 'drive-7', + resourceId: 'res-9', + gfsUri: 'gfs://drive-7/res-9', + label: 'Report', + version: 3, + bytes: 4096, + }, + ] + const [serverUser] = turnsToChatMessages([ + { + number: 9, + user_input: buildComposerRequestContent('Read these files', references), + started_at: new Date(120_004).toISOString(), + }, + ]) + expect(serverUser?.content).toBe('Read these files') + expect(serverUser?.attachments).toMatchObject([ + { type: 'agent_file', label: 'todo.md', filesystemName: 'shared-fs', path: 'notes/todo.md' }, + { + type: 'global_file', + label: 'Report', + }, + ]) + const draft = buildComposerResendDraft(serverUser!) + expect(draft.referenceAttachments).toMatchObject([ + { type: 'agent_file', filesystemName: 'shared-fs', path: 'notes/todo.md' }, + ]) + expect(draft.referenceAttachments).toHaveLength(1) + expect(draft.unrestorable).toEqual([{ type: 'global_file', label: 'Report' }]) + }) + + it('retains two versioned same-label global files on their identified server turn', () => { + const references: ComposerReferenceAttachment[] = [ + { + id: 'first-global', + type: 'global_file', + label: 'Report', + drive: 'drive-7', + resourceId: 'first', + gfsUri: 'gfs://drive-7/first', + version: 3, + bytes: 4096, + }, + { + id: 'second-global', + type: 'global_file', + label: 'Report', + drive: 'drive-7', + resourceId: 'second', + gfsUri: 'gfs://drive-7/second', + version: 8, + bytes: 8192, + }, + ] + const incoming = turnsToChatMessages([ + { + number: 9, + user_input: buildComposerRequestContent('Compare both reports', references), + started_at: new Date(9).toISOString(), + }, + ]) + expect(incoming[0]?.attachments).toMatchObject([ + { type: 'global_file', label: 'Report' }, + { type: 'global_file', label: 'Report' }, + ]) + const local: ChatMessage = { + ...incoming[0]!, + attachments: buildChatMessageAttachments([], references), + } + const once = mergeAuthoritativeServerMessages([local], incoming) + const twice = mergeAuthoritativeServerMessages(once, incoming) + expect(twice).toEqual(once) + expect(once).toHaveLength(1) + expect(once[0]?.attachments).toHaveLength(2) + const draft = buildComposerResendDraft(once[0]!) + expect(draft.referenceAttachments).toMatchObject([ + { type: 'global_file', gfsUri: 'gfs://drive-7/first', version: 3, bytes: 4096 }, + { type: 'global_file', gfsUri: 'gfs://drive-7/second', version: 8, bytes: 8192 }, + ]) + expect(draft.unrestorable).toEqual([]) + }) + + it('does not resend references from a same-text idle echo onto a context-free server turn', () => { + const incoming = turnsToChatMessages([ + { number: 1, user_input: 'first', started_at: new Date(1).toISOString() }, + { number: 2, user_input: 'repeat', started_at: new Date(3).toISOString() }, + ]) + const localEcho: ChatMessage = { + id: 'idle-echo', + role: 'user', + content: 'repeat', + timestamp: 2, + task_id: 'settled-task', + attachments: [ + { id: 'plugin', type: 'plugin', label: 'profits/revenue' }, + { + id: 'agent-file', + type: 'agent_file', + label: 'todo.md', + filesystemName: 'shared-fs', + path: 'notes/todo.md', + }, + { + id: 'global-file', + type: 'global_file', + label: 'Report', + gfsUri: 'gfs://drive-7/res-9', + drive: 'drive-7', + resourceId: 'res-9', + }, + { + id: 'image', + type: 'uploaded_file', + label: 'chart.png', + filename: 'chart.png', + mimeType: 'image/png', + encoding: 'base64', + dataBase64: 'AQ==', + sizeBytes: 1, + }, + ], + } + const merged = mergeAuthoritativeServerMessages( + [incoming[0]!, localEcho, incoming[1]!], + incoming, + { activeTaskIds: new Set() } + ) + expect(merged.map(message => message.id)).toEqual(['turn-1-user', 'idle-echo', 'turn-2-user']) + const serverUser = merged[2]! + expect(serverUser.attachments).toBeUndefined() + const draft = buildComposerResendDraft(serverUser) + expect(draft.content).toBe('repeat') + expect(draft.imageAttachments).toHaveLength(0) + expect(draft.referenceAttachments).toEqual([]) + expect(draft.unrestorable).toEqual([]) + expect(buildComposerResendDraft(merged[1]!).imageAttachments[0]?.dataBase64).toBe('AQ==') + }) + + it('does not move either image across ambiguous same-text prompts, including on repeated reconciliation', () => { + const incoming = turnsToChatMessages([ + { + number: 1, + user_input: 'repeat\n\n[Attached images]\n- first.png', + started_at: new Date(1).toISOString(), + }, + { + number: 2, + user_input: 'repeat\n\n[Attached images]\n- second.png', + started_at: new Date(3).toISOString(), + }, + ]) + const localEcho: ChatMessage = { + id: 'second-local-echo', + role: 'user', + content: 'repeat', + timestamp: 2, + attachments: [ + { + id: 'second-image', + type: 'uploaded_file', + label: 'second.png', + filename: 'second.png', + mimeType: 'image/png', + encoding: 'base64', + dataBase64: 'Ag==', + sizeBytes: 1, + }, + ], + } + const firstLocal: ChatMessage = { + ...incoming[0]!, + attachments: [ + { + id: 'first-image', + type: 'uploaded_file', + label: 'first.png', + filename: 'first.png', + mimeType: 'image/png', + encoding: 'base64', + dataBase64: 'AQ==', + sizeBytes: 1, + }, + ], + } + const once = mergeAuthoritativeServerMessages([firstLocal, localEcho, incoming[1]!], incoming, { + activeTaskIds: new Set(), + }) + const twice = mergeAuthoritativeServerMessages(once, incoming, { activeTaskIds: new Set() }) + expect(twice).toEqual(once) + expect(once.map(message => message.id)).toEqual([ + 'turn-1-user', + 'second-local-echo', + 'turn-2-user', + ]) + expect( + buildComposerResendDraft(once[0]!).imageAttachments.map(image => image.dataBase64) + ).toEqual(['AQ==']) + expect(buildComposerResendDraft(once[2]!).imageAttachments).toEqual([]) + expect(buildComposerResendDraft(once[1]!).imageAttachments[0]?.dataBase64).toBe('Ag==') + }) + + it('keeps same-name bytes on their own prompt when only a content match exists', () => { + const incoming = turnsToChatMessages([ + { + number: 2, + user_input: 'repeat\n\n[Attached images]\n- shared.png', + started_at: new Date(3).toISOString(), + }, + ]) + const firstImage = buildChatMessageAttachments( + [ + { + id: 'first-image', + name: 'shared.png', + mimeType: 'image/png', + dataBase64: 'AQ==', + sizeBytes: 1, + previewDataUrl: 'data:image/png;base64,AQ==', + }, + ], + [] + )[0]! + const secondImage = buildChatMessageAttachments( + [ + { + id: 'second-image', + name: 'shared.png', + mimeType: 'image/png', + dataBase64: 'Ag==', + sizeBytes: 1, + previewDataUrl: 'data:image/png;base64,Ag==', + }, + ], + [] + )[0]! + expect(firstImage.dataBase64).not.toBe(secondImage.dataBase64) + const firstTurn: ChatMessage = { + id: 'turn-1-user', + role: 'user', + content: 'repeat', + timestamp: 1, + serverTurnNumber: 1, + attachments: [firstImage], + } + const localEcho: ChatMessage = { + id: 'second-prompt-idle-echo', + role: 'user', + content: 'repeat', + timestamp: 2, + attachments: [secondImage], + } + const existing = [firstTurn, localEcho, incoming[0]!] + const once = mergeAuthoritativeServerMessages(existing, incoming, { + activeTaskIds: new Set(), + }) + const twice = mergeAuthoritativeServerMessages(once, incoming, { + activeTaskIds: new Set(), + }) + expect(twice).toEqual(once) + expect(once.map(message => message.id)).toEqual([ + 'turn-1-user', + 'second-prompt-idle-echo', + 'turn-2-user', + ]) + expect(buildComposerResendDraft(once[0]!).imageAttachments[0]?.dataBase64).toBe('AQ==') + expect(buildComposerResendDraft(once[1]!).imageAttachments[0]?.dataBase64).toBe('Ag==') + expect(buildComposerResendDraft(once[2]!).imageAttachments).toEqual([]) + expect(once[2]?.attachments).toMatchObject([{ type: 'uploaded_file', label: 'shared.png' }]) + }) + + it('recovers image bytes when the local and server message have the same turn identity', () => { + const incoming = turnsToChatMessages([ + { + number: 2, + user_input: 'repeat\n\n[Attached images]\n- shared.png', + started_at: new Date(3).toISOString(), + }, + ]) + const local: ChatMessage = { + ...incoming[0]!, + attachments: buildChatMessageAttachments( + [ + { + id: 'same-turn-image', + name: 'shared.png', + mimeType: 'image/png', + dataBase64: 'Ag==', + sizeBytes: 1, + previewDataUrl: 'data:image/png;base64,Ag==', + }, + ], + [] + ), + } + const merged = mergeAuthoritativeServerMessages([local], incoming) + expect(merged.map(message => message.id)).toEqual(['turn-2-user']) + expect(merged[0]?.attachments).toMatchObject([ + { type: 'uploaded_file', label: 'shared.png', dataBase64: 'Ag==' }, + ]) + expect(buildComposerResendDraft(merged[0]!).imageAttachments[0]?.dataBase64).toBe('Ag==') + expect(mergeAuthoritativeServerMessages(merged, incoming)).toEqual(merged) + }) + + it('collapses a settled assistant echo with producer-built response file bytes', () => { + const incoming = turnsToChatMessages([ + { number: 1, user_input: 'first', response: 'one', started_at: new Date(1).toISOString() }, + { number: 2, user_input: 'second', response: 'done', started_at: new Date(3).toISOString() }, + ]) + const responseFile = buildResponseFileAttachments({ + attachments: [ + { + id: 'result', + kind: 'file', + filename: 'result.txt', + mimeType: 'text/plain', + encoding: 'base64', + dataBase64: 'b2s=', + sizeBytes: 2, + }, + ], + })[0]! + const localEcho: ChatMessage = { + id: 'settled-assistant-echo', + role: 'assistant', + content: 'done', + timestamp: 2, + attachments: [responseFile], + } + const once = mergeAuthoritativeServerMessages( + [incoming[1]!, localEcho, incoming[3]!], + incoming, + { activeTaskIds: new Set() } + ) + expect(once.filter(message => message.role === 'assistant')).toHaveLength(2) + expect(once.some(message => message.id === 'settled-assistant-echo')).toBe(false) + expect(once.find(message => message.id === 'turn-2-assistant')?.attachments).toEqual([ + responseFile, + ]) + expect(mergeAuthoritativeServerMessages(once, incoming)).toEqual(once) + }) + + it('keeps a response-file echo local when two assistant turns share its text', () => { + const incoming = turnsToChatMessages([ + { number: 1, user_input: 'first', response: 'done', started_at: new Date(1).toISOString() }, + { number: 2, user_input: 'second', response: 'done', started_at: new Date(3).toISOString() }, + ]) + const responseFile = buildResponseFileAttachments({ + attachments: [ + { + id: 'result', + kind: 'file', + filename: 'result.txt', + mimeType: 'text/plain', + encoding: 'base64', + dataBase64: 'b2s=', + sizeBytes: 2, + }, + ], + })[0]! + const localEcho: ChatMessage = { + id: 'ambiguous-assistant-echo', + role: 'assistant', + content: 'done', + timestamp: 2, + attachments: [responseFile], + } + const once = mergeAuthoritativeServerMessages( + [incoming[1]!, localEcho, incoming[3]!], + incoming, + { activeTaskIds: new Set() } + ) + expect(once.filter(message => message.role === 'assistant').map(message => message.id)).toEqual( + ['turn-1-assistant', 'ambiguous-assistant-echo', 'turn-2-assistant'] + ) + expect(once.find(message => message.id === 'ambiguous-assistant-echo')?.attachments).toEqual([ + responseFile, + ]) + expect(once.find(message => message.id === 'turn-2-assistant')?.attachments).toBeUndefined() + expect(mergeAuthoritativeServerMessages(once, incoming)).toEqual(once) + }) + it('preserves turn order for multi-turn transcripts', () => { const msgs = turnsToChatMessages([ { number: 1, user_input: 'a', response: 'A', started_at: '2026-04-22T10:00:00Z' }, diff --git a/desktop-app/ui/src/hooks/domain/__tests__/pendingChatSelection.characterization.test.tsx b/desktop-app/ui/src/hooks/domain/__tests__/pendingChatSelection.characterization.test.tsx index 7e0a7078a4..4cd6b56a85 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/pendingChatSelection.characterization.test.tsx +++ b/desktop-app/ui/src/hooks/domain/__tests__/pendingChatSelection.characterization.test.tsx @@ -203,7 +203,9 @@ describe('pendingChatSelection effect', () => { }) await waitFor(() => expect(clerum.chat.getIndex.mock.calls.length).toBeGreaterThan(1)) - const sendPromise = act(async () => { + // Keep the index pending while the send finishes, and close this act scope + // before waiting for progress or resolving the list in another scope. + await act(async () => { await result.current.handleSendAgentMessage('keep latest from stealing this chat') }) await waitFor(() => expect(clerum.hasProgressHandler('task-latest-race')).toBe(true)) @@ -219,7 +221,6 @@ describe('pendingChatSelection effect', () => { }) }) - await sendPromise expect(result.current.activeChatId).toBe(createdChatId) expect(clerum.rpc.loadSessionMessages).not.toHaveBeenCalledWith( 'agent-x', @@ -332,7 +333,7 @@ describe('pendingChatSelection effect', () => { await waitFor(() => expect(clerum.chat.getIndex).toHaveBeenCalled()) - const sendPromise = act(async () => { + await act(async () => { await result.current.handleSendAgentMessage('keep this visible') }) await waitFor(() => expect(clerum.hasProgressHandler('task-race')).toBe(true)) @@ -348,7 +349,6 @@ describe('pendingChatSelection effect', () => { }) }) - await sendPromise expect(result.current.activeChatId).toBe(createdChatId) expect(clerum.rpc.loadSessionMessages).not.toHaveBeenCalledWith( 'agent-x', diff --git a/desktop-app/ui/src/hooks/domain/desktopEnvironmentHandoff.ts b/desktop-app/ui/src/hooks/domain/desktopEnvironmentHandoff.ts index 00a08344c0..6a2f5ee489 100644 --- a/desktop-app/ui/src/hooks/domain/desktopEnvironmentHandoff.ts +++ b/desktop-app/ui/src/hooks/domain/desktopEnvironmentHandoff.ts @@ -104,7 +104,7 @@ function decideSavedEnvironment( if (!linkedRpcOrigin) { if (sameRestOrigin.length > 1) return { kind: 'reject', reason: 'ambiguous' } - if (sameRestOrigin.length === 1) return { kind: 'select', option: sameRestOrigin[0] } + if (sameRestOrigin.length === 1) return { kind: 'select', option: sameRestOrigin[0]! } return { kind: 'setup' } } diff --git a/desktop-app/ui/src/hooks/domain/useAuthController.ts b/desktop-app/ui/src/hooks/domain/useAuthController.ts index 57bf8cf024..e837f00b97 100644 --- a/desktop-app/ui/src/hooks/domain/useAuthController.ts +++ b/desktop-app/ui/src/hooks/domain/useAuthController.ts @@ -454,7 +454,7 @@ export function useAuthController({ setStatus, onSessionNeedsLoad }: UseAuthCont return } - const selectedState = await handleSelectRuntimeConfig(restOriginMatches.saved[0].id) + const selectedState = await handleSelectRuntimeConfig(restOriginMatches.saved[0]!.id) if (!selectedState) return setPendingDesktopEnvironmentSetup(null) try { diff --git a/desktop-app/ui/src/lib/__tests__/chatMessageAttachments.test.ts b/desktop-app/ui/src/lib/__tests__/chatMessageAttachments.test.ts index b7aedb3d9d..e98f4dbae1 100644 --- a/desktop-app/ui/src/lib/__tests__/chatMessageAttachments.test.ts +++ b/desktop-app/ui/src/lib/__tests__/chatMessageAttachments.test.ts @@ -152,6 +152,29 @@ describe('chat message attachments', () => { ).toEqual(['first', 'logo.png', 'second']) }) + it('keeps the image bytes on uploaded_file display attachments (BUG-176 chip preview)', () => { + const images: ComposerImageAttachment[] = [ + { + id: 'image:shot', + name: 'shot.png', + mimeType: 'image/png', + dataBase64: 'AQID', + sizeBytes: 3, + previewDataUrl: 'blob:shot', + }, + ] + + expect(buildChatMessageAttachments(images, [])[0]).toMatchObject({ + type: 'uploaded_file', + label: 'shot.png', + filename: 'shot.png', + mimeType: 'image/png', + encoding: 'base64', + dataBase64: 'AQID', + sizeBytes: 3, + }) + }) + it('builds downloadable generated-file attachments from task responses', () => { const attachments = buildResponseFileAttachments({ attachments: [ diff --git a/desktop-app/ui/src/lib/__tests__/composerResend.test.ts b/desktop-app/ui/src/lib/__tests__/composerResend.test.ts new file mode 100644 index 0000000000..038e249f6e --- /dev/null +++ b/desktop-app/ui/src/lib/__tests__/composerResend.test.ts @@ -0,0 +1,327 @@ +import { describe, expect, it } from 'vitest' +import type { ChatMessageAttachment } from '../../../../src/types' +import { buildChatMessageAttachments } from '../chatMessageAttachments' +import { + buildComposerResendDraft, + findNearestPrecedingUserMessage, + parseStructuredResendReferences, +} from '../composerResend' + +function uploadedFileChip(overrides: Partial = {}): ChatMessageAttachment { + return { + id: 'img-1', + type: 'uploaded_file', + label: 'chart.png', + addedOrder: 0, + filename: 'chart.png', + mimeType: 'image/png', + encoding: 'base64', + dataBase64: 'aGVsbG8=', + sizeBytes: 5, + ...overrides, + } +} + +describe('parseStructuredResendReferences', () => { + it('extracts full-identity references from a USER-ATTACHED CONTEXT block', () => { + const content = [ + 'Analyze this.', + '', + 'USER-ATTACHED CONTEXT: The user selected these capabilities/files for this message.', + 'Plugins: profits/revenue. Use workflow tools for these plugin names.', + 'Connectors: github-x. Use MCP tools.', + 'Agent Files: shared-fs/notes/todo.md. Inspect these paths.', + 'Global Files: Report (gfs://drive-7/res-9). Resolve each gfs:// URI.', + ].join('\n') + + const structured = parseStructuredResendReferences(content) + + expect(structured.plugin).toEqual([{ namespace: 'profits', name: 'revenue' }]) + expect(structured.connector).toEqual([{ name: 'github-x' }]) + expect(structured.agentFile).toEqual([{ filesystemName: 'shared-fs', path: 'notes/todo.md' }]) + expect(structured.globalFile).toEqual([ + { label: 'Report', drive: 'drive-7', resourceId: 'res-9', gfsUri: 'gfs://drive-7/res-9' }, + ]) + }) + + it('returns empty collections when the content carries no context block', () => { + const structured = parseStructuredResendReferences('Just a plain message') + expect(structured).toEqual({ plugin: [], connector: [], agentFile: [], globalFile: [] }) + }) +}) + +describe('buildComposerResendDraft', () => { + it('re-attaches an uploaded file with its bytes and preserves the visible text', () => { + const draft = buildComposerResendDraft({ + content: 'Summarize the chart', + attachments: [uploadedFileChip()], + }) + + expect(draft.content).toBe('Summarize the chart') + expect(draft.imageAttachments).toHaveLength(1) + expect(draft.imageAttachments[0]).toMatchObject({ + name: 'chart.png', + mimeType: 'image/png', + dataBase64: 'aGVsbG8=', + sizeBytes: 5, + previewDataUrl: 'data:image/png;base64,aGVsbG8=', + }) + expect(draft.referenceAttachments).toEqual([]) + expect(draft.unrestorable).toEqual([]) + }) + + it('re-applies plugin indicators from the structured context block with full identity', () => { + const content = [ + 'Run the analysis', + 'USER-ATTACHED CONTEXT: The user selected these capabilities/files for this message.', + 'Plugins: profits/revenue, ops/deploy. Use workflow tools.', + 'Connectors: github-x. Use MCP tools.', + 'Agent Files: shared-fs/notes/todo.md. Inspect these paths.', + 'Global Files: Report (gfs://drive-7/res-9). Resolve each gfs:// URI.', + ].join('\n') + + const draft = buildComposerResendDraft({ content, attachments: [] }) + + expect(draft.content).toBe('Run the analysis') + expect(draft.imageAttachments).toEqual([]) + expect(draft.referenceAttachments).toEqual([ + { + id: 'plugin:profits:revenue', + type: 'plugin', + namespace: 'profits', + name: 'revenue', + label: 'revenue', + }, + { + id: 'plugin:ops:deploy', + type: 'plugin', + namespace: 'ops', + name: 'deploy', + label: 'deploy', + }, + { id: 'connector:github-x', type: 'connector', name: 'github-x', label: 'github-x' }, + { + id: 'agent-file:resend:shared-fs:notes/todo.md', + type: 'agent_file', + contextId: '', + filesystemName: 'shared-fs', + path: 'notes/todo.md', + kind: 'file', + label: 'todo.md', + }, + ]) + expect(draft.unrestorable).toEqual([{ type: 'global_file', label: 'Report' }]) + }) + + it('restores a local global-file chip only with its versioned send identity', () => { + const draft = buildComposerResendDraft({ + content: 'Read the report', + attachments: buildChatMessageAttachments( + [], + [ + { + id: 'global-local', + type: 'global_file', + label: 'Report', + drive: 'drive-7', + resourceId: 'res-9', + gfsUri: 'gfs://drive-7/res-9', + version: 3, + bytes: 4096, + }, + ] + ), + }) + expect(draft.referenceAttachments).toMatchObject([ + { type: 'global_file', gfsUri: 'gfs://drive-7/res-9', version: 3, bytes: 4096 }, + ]) + expect(draft.unrestorable).toEqual([]) + }) + + it('does not combine a chip version with a URI found only in prompt text', () => { + const draft = buildComposerResendDraft({ + content: [ + 'Read the report', + 'USER-ATTACHED CONTEXT: The user selected these capabilities/files for this message.', + 'Global Files: Report (gfs://drive-7/res-9). Resolve each gfs:// URI.', + ].join('\n'), + attachments: [ + { id: 'global-local', type: 'global_file', label: 'Report', version: 3, bytes: 4096 }, + ], + }) + expect(draft.referenceAttachments).toEqual([]) + expect(draft.unrestorable).toEqual([{ type: 'global_file', label: 'Report' }]) + }) + + it('keeps the structured plugin identity when the message also carries label chips', () => { + // Locally optimistic messages store chips (label = recipe name only) while + // the server-authoritative content embeds "Plugins: ns/name". Both present: + // the structured identity must win over the bare chip label. + const content = [ + 'Run it', + 'USER-ATTACHED CONTEXT: The user selected these capabilities/files for this message.', + 'Plugins: profits/revenue. Use workflow tools.', + ].join('\n') + const draft = buildComposerResendDraft({ + content, + attachments: [{ id: 'chip-1', type: 'plugin', label: 'revenue', addedOrder: 0 }], + }) + + expect(draft.referenceAttachments).toEqual([ + { + id: 'plugin:profits:revenue', + type: 'plugin', + namespace: 'profits', + name: 'revenue', + label: 'revenue', + }, + ]) + }) + + it('re-applies a plugin indicator from a chip-only message, splitting ns/name labels', () => { + const draft = buildComposerResendDraft({ + content: 'Run it', + attachments: [{ id: 'chip-1', type: 'plugin', label: 'profits/revenue', addedOrder: 0 }], + }) + + expect(draft.referenceAttachments).toEqual([ + { + id: 'plugin:profits:revenue', + type: 'plugin', + namespace: 'profits', + name: 'revenue', + label: 'revenue', + }, + ]) + }) + + it('warns when a name-only plugin chip has no recoverable namespace', () => { + const draft = buildComposerResendDraft({ + content: 'Run it', + attachments: [{ id: 'chip-1', type: 'plugin', label: 'revenue', addedOrder: 0 }], + }) + + expect(draft.referenceAttachments).toEqual([]) + expect(draft.unrestorable).toEqual([{ type: 'plugin', label: 'revenue' }]) + }) + + it('warns when file chips lack the path or URI needed to resend them', () => { + const draft = buildComposerResendDraft({ + content: 'Read these', + attachments: [ + { id: 'file', type: 'agent_file', label: 'todo.md' }, + { id: 'global', type: 'global_file', label: 'Report' }, + ], + }) + expect(draft.referenceAttachments).toEqual([]) + expect(draft.unrestorable).toEqual([ + { type: 'agent_file', label: 'todo.md' }, + { type: 'global_file', label: 'Report' }, + ]) + }) + + it('reports legacy label-only uploaded files as unrestorable instead of dropping them silently', () => { + const draft = buildComposerResendDraft({ + content: 'Analyze', + attachments: [{ id: 'legacy-1', type: 'uploaded_file', label: 'photo.jpg', addedOrder: 0 }], + }) + + expect(draft.imageAttachments).toEqual([]) + expect(draft.unrestorable).toEqual([{ type: 'uploaded_file', label: 'photo.jpg' }]) + }) + + it('never re-applies response_file artifacts (they belong to the old reply, not the prompt)', () => { + const draft = buildComposerResendDraft({ + content: 'Build the report', + attachments: [ + { + id: 'gen-1', + type: 'response_file', + label: 'report.md', + filename: 'report.md', + mimeType: 'text/markdown', + encoding: 'base64', + dataBase64: 'IyByZXBvcnQ=', + }, + ], + }) + + expect(draft.imageAttachments).toEqual([]) + expect(draft.referenceAttachments).toEqual([]) + expect(draft.unrestorable).toEqual([]) + }) + + it('uses the parsed legacy markers when the message carries no attachments array', () => { + const content = [ + 'Please analyze the attached image(s).', + '[Attached images]', + '- screenshot.png', + ].join('\n') + + const draft = buildComposerResendDraft({ content, attachments: [] }) + + expect(draft.content).toBe('Please analyze the attached image(s).') + expect(draft.imageAttachments).toEqual([]) + expect(draft.unrestorable).toEqual([{ type: 'uploaded_file', label: 'screenshot.png' }]) + }) + + it('rejects non-image uploaded files for the composer (picker accepts jpeg/png only)', () => { + const draft = buildComposerResendDraft({ + content: 'Here', + attachments: [ + uploadedFileChip({ + id: 'doc-1', + label: 'notes.txt', + filename: 'notes.txt', + mimeType: 'text/plain', + }), + ], + }) + + expect(draft.imageAttachments).toEqual([]) + expect(draft.unrestorable).toEqual([{ type: 'uploaded_file', label: 'notes.txt' }]) + }) +}) + +describe('findNearestPrecedingUserMessage', () => { + type RoleItem = { role: 'user' | 'assistant' | 'system'; content: string; attachments: [] } + const userA: RoleItem = { role: 'user', content: 'first prompt', attachments: [] } + const assistantA: RoleItem = { role: 'assistant', content: 'first reply', attachments: [] } + const userB: RoleItem = { role: 'user', content: 'second prompt', attachments: [] } + const assistantB: RoleItem = { role: 'assistant', content: 'second reply', attachments: [] } + const groups: Array<{ role: 'user' | 'assistant' | 'system'; items: RoleItem[] }> = [ + { role: 'user', items: [userA] }, + { role: 'assistant', items: [assistantA] }, + { role: 'user', items: [userB] }, + { role: 'assistant', items: [assistantB] }, + ] + + it('returns the last user message of the nearest preceding user group', () => { + expect(findNearestPrecedingUserMessage(groups, 3)).toBe(userB) + }) + + it('skips consecutive assistant groups when walking back', () => { + const followUp: RoleItem = { role: 'assistant', content: 'follow-up', attachments: [] } + const multi: Array<{ role: 'user' | 'assistant' | 'system'; items: RoleItem[] }> = [ + { role: 'user', items: [userA] }, + { role: 'assistant', items: [assistantA] }, + { role: 'assistant', items: [followUp] }, + ] + expect(findNearestPrecedingUserMessage(multi, 2)).toBe(userA) + }) + + it('returns the last item when a user group holds several messages', () => { + const burstFirst: RoleItem = { role: 'user', content: 'part 1', attachments: [] } + const burstLast: RoleItem = { role: 'user', content: 'part 2 (trigger)', attachments: [] } + const withBurst: Array<{ role: 'user' | 'assistant' | 'system'; items: RoleItem[] }> = [ + { role: 'user', items: [burstFirst, burstLast] }, + { role: 'assistant', items: [assistantA] }, + ] + expect(findNearestPrecedingUserMessage(withBurst, 1)).toBe(burstLast) + }) + + it('returns null when no user group precedes the assistant reply', () => { + const assistantOnly = [{ role: 'assistant' as const, items: [assistantA] }] + expect(findNearestPrecedingUserMessage(assistantOnly, 0)).toBeNull() + }) +}) diff --git a/desktop-app/ui/src/lib/base64Size.ts b/desktop-app/ui/src/lib/base64Size.ts new file mode 100644 index 0000000000..5092104f32 --- /dev/null +++ b/desktop-app/ui/src/lib/base64Size.ts @@ -0,0 +1,5 @@ +/** Upper bound for untrusted base64; padding makes the estimate exact for valid input. */ +export function estimateBase64DecodedLength(dataBase64: string): number { + const padding = dataBase64.endsWith('==') ? 2 : dataBase64.endsWith('=') ? 1 : 0 + return Math.floor((dataBase64.length * 3) / 4) - padding +} diff --git a/desktop-app/ui/src/lib/chatMessageAttachments.ts b/desktop-app/ui/src/lib/chatMessageAttachments.ts index e89c8aeaa8..4776a9b391 100644 --- a/desktop-app/ui/src/lib/chatMessageAttachments.ts +++ b/desktop-app/ui/src/lib/chatMessageAttachments.ts @@ -61,9 +61,30 @@ function createParsedAttachment( type, label: normalizedLabel, addedOrder: index, + ...(type === 'agent_file' ? parseAgentFileIdentity(label) : {}), + ...(type === 'global_file' ? parseGlobalFileIdentity(label) : {}), } } +function parseAgentFileIdentity( + value: string +): Pick { + const slash = value.indexOf('/') + return slash > 0 && slash < value.length - 1 + ? { filesystemName: value.slice(0, slash), path: value.slice(slash + 1) } + : {} +} + +function parseGlobalFileIdentity( + value: string +): Pick { + const gfsUri = value.match(/(?:^|\()(gfs:\/\/[^)\s]+)\)?$/i)?.[1] + const uriParts = gfsUri?.match(/^gfs:\/\/([^/]+)\/(.+)$/i) + return gfsUri && uriParts?.[1] && uriParts[2] + ? { gfsUri, drive: uriParts[1], resourceId: uriParts[2] } + : {} +} + function inferLegacyContextAttachmentType(label: string): ChatMessageAttachment['type'] { const normalizedLabel = label.trim().toLowerCase() if (normalizedLabel.startsWith('mcp-') || normalizedLabel.endsWith('-remote')) { @@ -148,6 +169,18 @@ export function buildChatMessageAttachments( ? 'Global File' : 'Agent File', addedOrder: attachmentOrder(attachment, index), + ...(attachment.type === 'agent_file' + ? { filesystemName: attachment.filesystemName, path: attachment.path } + : {}), + ...(attachment.type === 'global_file' + ? { + gfsUri: attachment.gfsUri, + drive: attachment.drive, + resourceId: attachment.resourceId, + version: attachment.version, + bytes: attachment.bytes, + } + : {}), } }) const imageItems = imageAttachments.map((attachment, index): ChatMessageAttachment => { @@ -158,6 +191,14 @@ export function buildChatMessageAttachments( label: attachment.name, tooltip: formatUploadedFileTooltip(attachment), addedOrder: attachmentOrder(attachment, fallbackIndex), + // BUG-176: keep the image bytes on the display attachment so the + // sent-message chip can reopen the shared image preview. Persisted with + // the user message (same inline-base64 contract as response_file). + filename: attachment.name, + mimeType: attachment.mimeType, + encoding: 'base64', + dataBase64: attachment.dataBase64, + sizeBytes: attachment.sizeBytes, } }) return [...referenceItems, ...imageItems].sort( diff --git a/desktop-app/ui/src/lib/composerResend.ts b/desktop-app/ui/src/lib/composerResend.ts new file mode 100644 index 0000000000..6512adc8d2 --- /dev/null +++ b/desktop-app/ui/src/lib/composerResend.ts @@ -0,0 +1,330 @@ +/** + * TASK-42 — resend draft builder. + * + * Turns a sent USER message back into composer input: the visible text for the + * draft, inline image bytes re-attached as composer image attachments, and + * plugin/connector/agent-file/global-file indicators re-applied as composer + * references. + * + * Fidelity ladder (a message can carry its inputs in two places): + * 1. Persisted attachment chips carry uploaded image bytes and structured + * references. A global file also needs its listed version and byte size + * for the current structured send contract. + * 2. A legacy raw `USER-ATTACHED CONTEXT:` block can supply missing plugin + * and agent-file identity. A global-file URI without version and size is + * unrestorable, so the composer never shows a chip that send will omit. + * + * `response_file` attachments are never re-applied: they are artifacts the + * previous reply generated, not inputs of the resent prompt. + */ +import type { ChatMessageAttachment } from '../../../src/types' +import type { + AgentChatMessage, + ComposerImageAttachment, + ComposerReferenceAttachment, +} from '../uiTypes' +import { estimateBase64DecodedLength } from './base64Size' +import { parseChatMessageDisplay } from './chatMessageAttachments' + +export type ComposerResendDraft = { + /** Visible text for the composer draft (context block stripped). */ + content: string + imageAttachments: ComposerImageAttachment[] + referenceAttachments: ComposerReferenceAttachment[] + /** Chips that could not be rebuilt (e.g. legacy label-only uploaded files). */ + unrestorable: Array<{ type: ChatMessageAttachment['type']; label: string }> +} + +type StructuredPlugin = { namespace: string; name: string } +type StructuredConnector = { name: string } +type StructuredAgentFile = { filesystemName: string; path: string } +type StructuredGlobalFile = { + label: string + drive: string + resourceId: string + gfsUri: string +} + +type StructuredReferences = { + plugin: StructuredPlugin[] + connector: StructuredConnector[] + agentFile: StructuredAgentFile[] + globalFile: StructuredGlobalFile[] +} + +const USER_ATTACHED_CONTEXT_MARKER = 'USER-ATTACHED CONTEXT:' + +/** Mirrors the legacy list format: first sentence, comma-separated items. */ +function splitAttachmentItems(value: string): string[] { + const segment = (value.split(/\.\s+/)[0] ?? '').replace(/\.\s*$/, '') + return segment + .split(',') + .map(item => item.trim()) + .filter(Boolean) +} + +function parseStructuredGlobalFile(item: string): StructuredGlobalFile { + const labeled = item.match(/^(.*?)\s+\((gfs:\/\/[^)]+)\)$/i) + if (labeled?.[1] && labeled[2]) { + return parseGlobalFileFromUri(labeled[1].trim(), labeled[2]) + } + if (/^gfs:\/\/[^/]+\//i.test(item)) { + return parseGlobalFileFromUri('', item) + } + return { label: item, drive: '', resourceId: '', gfsUri: '' } +} + +function parseGlobalFileFromUri(label: string, gfsUri: string): StructuredGlobalFile { + const uri = gfsUri.match(/^gfs:\/\/([^/]+)\/(.+)$/i) + if (!uri?.[1] || uri[2] === undefined) { + return { label, drive: '', resourceId: '', gfsUri } + } + return { label, drive: uri[1], resourceId: uri[2], gfsUri } +} + +function pathBasename(path: string): string { + return path.split('/').filter(Boolean).pop() ?? path +} + +/** + * Extracts full-identity references from the `USER-ATTACHED CONTEXT:` block a + * sent message embeds (the same block `parseChatMessageDisplay` strips for + * display). Older chips may need this fallback when their structured identity + * was not persisted. + */ +export function parseStructuredResendReferences(content: string): StructuredReferences { + const result: StructuredReferences = { plugin: [], connector: [], agentFile: [], globalFile: [] } + const lines = content.split('\n') + let markerIndex = lines.findIndex(line => line.trim().startsWith(USER_ATTACHED_CONTEXT_MARKER)) + if (markerIndex === -1) return result + for (let index = markerIndex + 1; index < lines.length; index += 1) { + const line = (lines[index] ?? '').trim() + if (line.startsWith('Plugins:')) { + for (const item of splitAttachmentItems(line.slice('Plugins:'.length))) { + const qualified = item.match(/^([^/\s]+)\/([^/\s]+)$/) + result.plugin.push( + qualified?.[1] && qualified[2] + ? { namespace: qualified[1], name: qualified[2] } + : { namespace: '', name: item } + ) + } + } else if (line.startsWith('Connectors:')) { + for (const item of splitAttachmentItems(line.slice('Connectors:'.length))) { + result.connector.push({ name: item }) + } + } else if (line.startsWith('Agent Files:')) { + for (const item of splitAttachmentItems(line.slice('Agent Files:'.length))) { + const slash = item.indexOf('/') + result.agentFile.push( + slash > 0 + ? { filesystemName: item.slice(0, slash), path: item.slice(slash + 1) } + : { filesystemName: '', path: item } + ) + } + } else if (line.startsWith('Global Files:')) { + for (const item of splitAttachmentItems(line.slice('Global Files:'.length))) { + result.globalFile.push(parseStructuredGlobalFile(item)) + } + } + } + return result +} + +function buildResendImageAttachment( + chip: ChatMessageAttachment, + index: number +): ComposerImageAttachment | null { + if (chip.type !== 'uploaded_file') return null + if (chip.encoding !== 'base64') return null + const dataBase64 = typeof chip.dataBase64 === 'string' ? chip.dataBase64 : '' + if (!dataBase64) return null + const mimeType = + chip.mimeType === 'image/jpeg' || chip.mimeType === 'image/png' ? chip.mimeType : null + if (!mimeType) return null + return { + id: `resend-image:${chip.id}:${index}`, + ...(chip.addedOrder !== undefined ? { addedOrder: chip.addedOrder } : {}), + name: chip.filename || chip.label, + mimeType, + dataBase64, + sizeBytes: chip.sizeBytes ?? estimateBase64DecodedLength(dataBase64), + previewDataUrl: `data:${mimeType};base64,${dataBase64}`, + } +} + +function buildPluginReference( + label: string, + structured: StructuredPlugin[] +): Extract | null { + const nameMatches = structured.filter(entry => entry.namespace && entry.name === label) + const match = + structured.find(entry => entry.namespace && `${entry.namespace}/${entry.name}` === label) ?? + (nameMatches.length === 1 ? nameMatches[0] : null) ?? + null + if (match) { + return { + id: `plugin:${match.namespace}:${match.name}`, + type: 'plugin', + namespace: match.namespace, + name: match.name, + label: match.name, + } + } + const qualified = label.match(/^([^/\s]+)\/([^/\s]+)$/) + if (qualified?.[1] && qualified[2]) { + return { + id: `plugin:${qualified[1]}:${qualified[2]}`, + type: 'plugin', + namespace: qualified[1], + name: qualified[2], + label: qualified[2], + } + } + return null +} + +function buildConnectorReference( + label: string, + structured: StructuredConnector[] +): Extract { + const name = structured.find(entry => entry.name === label)?.name ?? label + return { id: `connector:${name}`, type: 'connector', name, label: name } +} + +function buildAgentFileReference( + chip: ChatMessageAttachment, + structured: StructuredAgentFile[] +): Extract | null { + const label = chip.label + const embedded = + chip.filesystemName && chip.path + ? { filesystemName: chip.filesystemName, path: chip.path } + : null + const basenameMatches = structured.filter( + entry => entry.filesystemName && pathBasename(entry.path) === label + ) + const match = + embedded ?? + structured.find( + entry => + entry.filesystemName && + `${entry.filesystemName}/${entry.path.replace(/^\/+/, '')}` === label + ) ?? + (basenameMatches.length === 1 ? basenameMatches[0] : null) ?? + null + if (match) { + const normalizedPath = match.path.replace(/^\/+|\/+$/g, '') + return { + id: `agent-file:resend:${match.filesystemName}:${normalizedPath}`, + type: 'agent_file', + contextId: '', + filesystemName: match.filesystemName, + path: normalizedPath, + kind: normalizedPath ? 'file' : 'directory', + label: pathBasename(normalizedPath) || match.filesystemName, + } + } + return null +} + +function buildGlobalFileReference( + chip: ChatMessageAttachment +): Extract | null { + const label = chip.label + const match = chip.gfsUri ? parseGlobalFileFromUri(label, chip.gfsUri) : null + // URI, version, and size must come from the same persisted chip. Combining + // a local version with a URI parsed from prompt text could name another file. + if ( + match?.drive && + match.resourceId && + Number.isSafeInteger(chip.version) && + chip.version! >= 0 && + Number.isSafeInteger(chip.bytes) && + chip.bytes! >= 0 + ) { + return { + id: `global-file:resend:${match.gfsUri}`, + type: 'global_file', + resourceId: match.resourceId, + drive: match.drive, + gfsUri: match.gfsUri, + label: match.label || pathBasename(match.gfsUri), + version: chip.version!, + bytes: chip.bytes!, + } + } + return null +} + +/** + * Builds the composer repopulation payload for a sent user message. Chip + * resolution mirrors the thread's display precedence: the persisted + * `attachments` array wins when present, otherwise the chips parsed from the + * legacy/`USER-ATTACHED CONTEXT` content markers. + */ +export function buildComposerResendDraft( + message: Pick +): ComposerResendDraft { + const parsed = parseChatMessageDisplay(message.content) + const chips = message.attachments?.length ? message.attachments : parsed.attachments + const structured = parseStructuredResendReferences(message.content) + const imageAttachments: ComposerImageAttachment[] = [] + const referenceAttachments: ComposerReferenceAttachment[] = [] + const unrestorable: ComposerResendDraft['unrestorable'] = [] + + for (const [index, chip] of chips.entries()) { + if (chip.type === 'uploaded_file') { + const image = buildResendImageAttachment(chip, index) + if (image) { + imageAttachments.push(image) + } else { + unrestorable.push({ type: chip.type, label: chip.label }) + } + continue + } + if (chip.type === 'response_file') continue + if (chip.type === 'plugin') { + const reference = buildPluginReference(chip.label, structured.plugin) + if (reference) referenceAttachments.push(reference) + else unrestorable.push({ type: chip.type, label: chip.label }) + continue + } + if (chip.type === 'connector') { + referenceAttachments.push(buildConnectorReference(chip.label, structured.connector)) + continue + } + if (chip.type === 'agent_file') { + const reference = buildAgentFileReference(chip, structured.agentFile) + if (reference) referenceAttachments.push(reference) + else unrestorable.push({ type: chip.type, label: chip.label }) + continue + } + if (chip.type === 'global_file') { + const reference = buildGlobalFileReference(chip) + if (reference) referenceAttachments.push(reference) + else unrestorable.push({ type: chip.type, label: chip.label }) + } + } + + return { content: parsed.content, imageAttachments, referenceAttachments, unrestorable } +} + +/** + * TASK-42 resend rule for ASSISTANT messages: "resending" a reply re-issues the + * prompt that produced it. Walks the rendered groups backwards from `groupIndex` + * to the nearest user group and returns its LAST message (the turn trigger); + * `null` when the reply has no preceding user message to re-issue. + */ +export function findNearestPrecedingUserMessage< + T extends Pick, +>( + groups: Array<{ role: 'user' | 'assistant' | 'system'; items: T[] }>, + groupIndex: number +): T | null { + for (let index = groupIndex - 1; index >= 0; index -= 1) { + const group = groups[index] + if (!group || group.role !== 'user') continue + return group.items.at(-1) ?? null + } + return null +} diff --git a/desktop-app/ui/src/lib/imageClipboard.ts b/desktop-app/ui/src/lib/imageClipboard.ts new file mode 100644 index 0000000000..049ce0da61 --- /dev/null +++ b/desktop-app/ui/src/lib/imageClipboard.ts @@ -0,0 +1,51 @@ +/** Copy image bytes as PNG when the browser supports image clipboard writes. */ +export async function copyImageBlobToClipboard( + sourceBlob: Blob, + isActive: () => boolean = () => true +): Promise { + if (typeof ClipboardItem !== 'undefined' && navigator.clipboard?.write) { + const clipboardBlob = + sourceBlob.type === 'image/png' ? sourceBlob : await convertBlobToPng(sourceBlob) + if (!isActive()) return false + if (clipboardBlob) { + await navigator.clipboard.write([new ClipboardItem({ 'image/png': clipboardBlob })]) + return true + } + } + + if (navigator.clipboard?.writeText) { + const dataUrl = await blobToDataUrl(sourceBlob) + if (!isActive()) return false + await navigator.clipboard.writeText(dataUrl) + return true + } + + throw new Error('Image clipboard unavailable') +} + +async function convertBlobToPng(blob: Blob): Promise { + if (typeof createImageBitmap === 'undefined') return null + try { + const bitmap = await createImageBitmap(blob) + const canvas = document.createElement('canvas') + canvas.width = bitmap.width + canvas.height = bitmap.height + const ctx = canvas.getContext('2d') + if (!ctx) return null + ctx.drawImage(bitmap, 0, 0) + return await new Promise(resolve => canvas.toBlob(resolve, 'image/png')) + } catch { + return null + } +} + +async function blobToDataUrl(blob: Blob): Promise { + const result = await new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result) + reader.onerror = () => reject(reader.error ?? new Error('Could not read the image')) + reader.readAsDataURL(blob) + }) + if (typeof result !== 'string') throw new Error('Could not read the image') + return result +} diff --git a/desktop-app/ui/src/lib/imageHeaderDimensions.ts b/desktop-app/ui/src/lib/imageHeaderDimensions.ts index a0ab258d2d..0ae2284f29 100644 --- a/desktop-app/ui/src/lib/imageHeaderDimensions.ts +++ b/desktop-app/ui/src/lib/imageHeaderDimensions.ts @@ -56,6 +56,7 @@ function readJpegDimensions(bytes: Uint8Array): ImageHeaderDimensions | null { while (pos < bytes.length && bytes[pos] === 0xff) pos += 1 if (pos >= bytes.length) return null const marker = bytes[pos] + if (marker === undefined) return null pos += 1 if (marker === 0x00) return null if (marker === 0xd9) break diff --git a/desktop-app/ui/src/styles.css b/desktop-app/ui/src/styles.css index a1d6057d05..7f8abca234 100644 --- a/desktop-app/ui/src/styles.css +++ b/desktop-app/ui/src/styles.css @@ -7141,6 +7141,33 @@ h3 { padding-inline: var(--space-1); } +/* BUG-176: sent-message image chips open the shared GfsImagePreview modal. + * Same trigger contract as the composer's chip (transparent inline-flex + * button inside the chip frame, zoom-in cursor, keyboard focus ring). */ +.message-attachment-preview-trigger { + appearance: none; + display: inline-flex; + align-items: center; + gap: var(--space-1); + min-width: 0; + flex: 1 1 auto; + height: 100%; + border: none; + background: transparent; + color: inherit; + padding: 0; + cursor: zoom-in; + font: inherit; + line-height: 1; + text-align: left; +} + +.message-attachment-preview-trigger:focus-visible { + outline: 2px solid rgba(var(--focus-rgb), 0.56); + outline-offset: 2px; + border-radius: var(--radius-sm); +} + .chat-bubble.assistant { padding: 0; border-color: transparent; @@ -8035,6 +8062,21 @@ h3 { outline-offset: 2px; } +/* Match the Settings menu hover across both themes. Highlight the chip frame + only while its preview button is hovered, leaving the remove action alone. */ +.message-attachment-chip:has(> .message-attachment-preview-trigger:hover), +.composer-attachment-chip:has(> .composer-attachment-preview-trigger:hover) { + background: var(--list-item-hover-bg); + border-color: rgba(var(--accent-rgb), 0.34); + color: var(--text); +} + +.message-attachment-preview-trigger:hover:enabled, +.composer-attachment-preview-trigger:hover:enabled { + background: transparent; + color: inherit; +} + .composer-attachment-chip img { width: 28px; height: 28px; @@ -8257,13 +8299,17 @@ h3 { padding: var(--space-5); } +:root[data-theme='light'] .composer-image-preview-overlay { + background: rgba(0, 0, 0, 0.72); +} + .composer-image-preview-dialog { position: relative; width: min(96vw, 1120px); max-height: 92vh; border-radius: var(--radius-md); border: 1px solid rgba(var(--edge-rgb), 0.45); - background: rgba(var(--surface-rgb), 0.97); + background: var(--panel); box-shadow: var(--shadow-hard); padding: var(--space-4); display: grid; @@ -8305,7 +8351,7 @@ h3 { max-height: calc(92vh - 120px); object-fit: contain; border-radius: var(--radius-sm); - background: rgba(var(--surface-alt-rgb), 0.45); + background: var(--media-surface); } .composer-image-preview-canvas { @@ -8313,7 +8359,7 @@ h3 { width: 100%; max-height: calc(92vh - 170px); border-radius: var(--radius-sm); - background: rgba(var(--surface-alt-rgb), 0.45); + background: var(--media-surface); touch-action: none; cursor: crosshair; } @@ -14512,20 +14558,52 @@ pre { } /* D.5b — time-aware nudges (rendered below the in-flight progress) */ +/* TASK-228: the nudge renders as one contained card — banner + dismiss (×) in + * a header row, mini-summary and the tier action in a footer row — instead of + * loosely stacked rows. */ .nudge-area { display: flex; flex-direction: column; - gap: var(--space-1-5); + gap: var(--space-2); margin-top: var(--space-2); /* Cap the width so the nudge reads as a contained card instead of stretching * full-width with the chat (and growing on larger windows). */ max-width: 460px; + padding: var(--space-2); + border: 1px solid var(--control-border); + border-radius: var(--radius-control); + background: var(--panel); +} + +.nudge-area__header { + display: flex; + align-items: flex-start; + gap: var(--space-1-5); + min-width: 0; } -/* The nudge's call-to-action: a clear soft button anchored bottom-right of the - * capped card rather than a faint full-width row. */ +.nudge-area__header .status-banner { + flex: 1 1 auto; + min-width: 0; +} + +.nudge-area__dismiss { + flex: 0 0 auto; + margin: calc(-1 * var(--space-0-5)) calc(-1 * var(--space-1)) 0 0; +} + +.nudge-area__footer { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-1-5) var(--space-2); + min-width: 0; +} + +/* The nudge's call-to-action: anchored bottom-right of the card, level with + * the mini-summary when one is present (T5), alone otherwise (T3). */ .nudge-area-action { - align-self: flex-end; + margin-left: auto; } .nudge-mini-summary { @@ -15635,6 +15713,21 @@ pre { gap: var(--space-2); } +.da-gfs-image-preview-dialog__header-main { + display: flex; + align-items: center; + gap: var(--space-2); + min-width: 0; +} + +.da-gfs-image-preview-dialog__header-main .da-gfs-preview-title { + flex: 0 1 auto; +} + +.da-gfs-image-preview-dialog__header-main .da-gfs-image-preview-dialog__copy { + flex: 0 0 auto; +} + .da-gfs-image-preview-dialog__close.ui-button, .da-gfs-markdown-preview-dialog__close.ui-button, .da-gfs-video-preview-dialog__close.ui-button { diff --git a/docs/deploy/minikube.md b/docs/deploy/minikube.md index d28d00063f..d68f1db62c 100644 --- a/docs/deploy/minikube.md +++ b/docs/deploy/minikube.md @@ -163,8 +163,8 @@ open http://localhost:3000 `make minikube-setup` seeds the default test user and agent/context access. `npm run ui` keeps the required API port-forwards open while it runs the local frontends. For a branch-owned profile, the host-side hold for Control UI / Desktop is -the first-hand entry point (gitignored helper at repo root — do not search -for it). Implementation: `.local-notes/minikube-profiles/branch-profile.sh`. +the first-hand entry point `scripts/minikube-profiles/branch.mk`. +Implementation: `scripts/minikube-profiles/branch-profile.sh`. HARD DENY: do not `ls`/`cat` `~/.cache/clerum/minikube-profiles/`. Profile-owned random ports only (never shared `:3000`/`:8090`). `make minikube-pf-all-bg` is a gate refresh only; it must not replace @@ -175,10 +175,10 @@ it as the lasting hold. ```bash MINIKUBE_PROFILE= \ - make -f .local-notes/minikube-profiles/branch.mk branch-profile-pf + make -f scripts/minikube-profiles/branch.mk branch-profile-pf MINIKUBE_PROFILE= \ - make -f .local-notes/minikube-profiles/branch.mk branch-profile-health + make -f scripts/minikube-profiles/branch.mk branch-profile-health ``` `make test-e2e-vitest` and `make test-e2e-all` install `tests/e2e` @@ -194,6 +194,109 @@ unless `E2E_WAIT_FULL_STACK=true`. The software-creation suites require a special model and approval-policy profile; run them explicitly with `E2E_RUN_SOFTWARE_CREATION=1`. +### Branch-profile guards (`branch-profile.sh`) + +`minikube -p ` and `kubectl --context=` address a cluster by +context name alone, so `scripts/minikube-profiles/branch-profile.sh` checks, +before each action touches minikube, a cluster or Docker, that the context +named after the profile is this machine's Minikube profile. Every guard fails +closed with its own token; none has a fallback. + +**Guard matrix.** Rows are the actions `branch.mk` exposes. "Context" guards +apply only when a kubeconfig context named after the profile exists: a missing +context is allowed, because `minikube start` creates it and nothing can reach a +cluster through it. + +| Action | Required commands (checked before dispatch) | Local endpoint | Profile known to minikube | Cluster identity | Other guards | +|---|---|---|---|---|---| +| `start` | minikube kubectl helm python3 node | yes | yes | after `minikube start` | Docker ready, ports free; an EXIT/INT/TERM trap around `minikube start` restores the global current-context if minikube moved it onto the profile, even when the start fails | +| `status` | minikube kubectl python3 node | yes | no | when the cluster answers | | +| `pf` | minikube kubectl python3 node curl | yes | no | required; an unreachable cluster is an error | port-forward ownership records | +| `health` | minikube kubectl python3 node curl | yes | no | required; an unreachable cluster is an error | | +| `pf-health` | minikube kubectl python3 node curl | as `pf`, then `health` | no | as `pf` | an EXIT trap stops the forwards it started | +| `stop` | minikube kubectl python3 node | yes | yes | when the cluster answers | clears verified port-forward records before `minikube stop` | +| `delete` | minikube kubectl python3 node | yes | yes | when the cluster answers | `CONFIRM_DELETE=`; clears verified records before `minikube delete` | +| `setup` | minikube kubectl helm perl python3 node | yes | yes | no (hands the profile to `full-setup.sh`) | `CONFIRM_PROFILE=`, Docker ready; re-copies the script and deploy shims from the current tree on every run | +| `preflight` | its own check: git minikube kubectl helm shasum | no | no | no | Docker ready, ports free | +| `prepare-shims` | its own check: git shasum perl | no | no | no | shim symlink and rewrite checks | +| `stop-pf` | none beyond the common ones | no | no | no | port-forward ownership records | + +Every action also needs `git`, `awk` and `shasum` (profile resolution), and +refuses a shared or protected profile name, a `HOST` other than `127.0.0.1`, +and an unscoped or symlinked `CACHE_ROOT`. "Cluster identity" means a node +labelled `minikube.k8s.io/name=` whose InternalIP is the address +`minikube -p ip` reports. "When the cluster answers" means only when +`kubectl --context= cluster-info` succeeds: a stopped profile cannot be +asked who it is, so `stop` and `delete` still run for it. + +For `stop` and `delete`, the guard row above means that the identity check runs +only when the API answers, and a refusal requires a readable contradicting node +identity. `start`, `status`, `pf`, and `health` require a complete identity +whenever their strict check runs. A failed or timed-out IP/node observation is +unknown, not a foreign match, so the recovery action may continue. + +**Recovery identity decision table.** + +| API reachability | Minikube IP | Node identity | Result | +|---|---|---|---| +| unreachable | not called | not called | continue profile stop or teardown | +| reachable | fails or times out | not called | warn that identity is unobservable and continue | +| reachable | readable | fails or times out | warn that identity is unobservable and continue | +| reachable | readable | matches this profile | continue | +| reachable | readable | names another profile | `BRANCH_PROFILE_CONTEXT_IDENTITY`; no records are cleared and the profile command does not run | + +**Kubeconfig read.** The context guards read the kubeconfig only; they never +contact a cluster. + +| Kubeconfig state | Result | +|---|---| +| `kubectl config get-contexts` fails | `BRANCH_PROFILE_KUBECONFIG_UNREADABLE` | +| no context named after the profile | the context guards pass | +| `kubectl config view --minify --context=` fails (the context names an undefined cluster or user) | `BRANCH_PROFILE_CONTEXT_DANGLING` | +| the context's cluster has an empty server | `BRANCH_PROFILE_CONTEXT_DANGLING` | +| the server is not local (a public address, or a DNS name other than `localhost` and `*.minikube`) | `BRANCH_PROFILE_REMOTE_CONTEXT` | +| the server is loopback, `localhost`, `*.minikube`, or a private or link-local IP address | the next guard runs | + +**Profile-list decision table.** "Profile known to minikube" reads +`minikube profile list -o json` through +`minikube_profile_list_names` (`scripts/minikube/context-identity.sh`). + +| `minikube profile list -o json` | Parser status | Result | +|---|---|---| +| no context named after the profile | not called | proceed | +| the command fails or times out | not called | `BRANCH_PROFILE_MINIKUBE_PROFILES_UNREADABLE` | +| output is not `{"invalid": [...], "valid": [...]}` with a non-empty string `Name` in every entry | 2 | `BRANCH_PROFILE_MINIKUBE_PROFILES_UNREADABLE` | +| the profile is listed as valid or invalid | 0 | proceed | +| the profile is not listed | 1 | `BRANCH_PROFILE_UNKNOWN_MINIKUBE_PROFILE` | + +An unreadable list is a refusal, never an empty list. + +**Refusal tokens.** + +| Token or message | Meaning | +|---|---| +| `missing required command(s): ` | a command in the action's row above is not on `PATH`; every missing one is named | +| `BRANCH_PROFILE_SHARED_CONTEXT` | the profile name is shared or protected | +| `HOST must be 127.0.0.1` | branch-profile forwards bind only `127.0.0.1` | +| `CACHE_ROOT ...` | the profile cache root is unscoped, a symlink, not a directory, or contains a control character | +| `PROFILE_METADATA_MISSING` | `profile.env` of an existing profile is missing or unreadable; it is never regenerated | +| `PROFILE_PORTS_MISSING`, `PROFILE_PORTS_INVALID` | `ports.env` is missing or disagrees with its port base and `HOST` | +| `BRANCH_PROFILE_KUBECONFIG_UNREADABLE` | the kubeconfig contexts cannot be read | +| `BRANCH_PROFILE_CONTEXT_DANGLING` | the profile's context has no resolvable cluster or no server | +| `BRANCH_PROFILE_REMOTE_CONTEXT` | the profile's context points at a non-local API server | +| `BRANCH_PROFILE_MINIKUBE_PROFILES_UNREADABLE` | `minikube profile list` failed or printed no readable lists | +| `BRANCH_PROFILE_UNKNOWN_MINIKUBE_PROFILE` | a context named after the profile exists, but minikube lists no such profile | +| `BRANCH_PROFILE_CONTEXT_IDENTITY` | strict actions refuse an unreadable identity or one that names another profile; recovery actions refuse only a readable identity that names another profile | +| `CONTEXT_IDENTITY_UNAVAILABLE`, `CONTEXT_IDENTITY_API_INVALID` | `scripts/minikube/context-identity.sh` is missing or lacks a predicate | +| `PORT_FORWARD_OWNER_UNAVAILABLE`, `PORT_FORWARD_OWNER_API_INVALID`, `PORT_FORWARD_OWNER_BINDING_INVALID` | `scripts/minikube/port-forward-owner.sh` is missing or incomplete, or a record would bind another host | +| `PORT_FORWARD_OWNERSHIP_ERROR` | a port-forward process does not match its record; it is never signalled | +| `BRANCH_PROFILE_SHIM_SYMLINK`, `BRANCH_PROFILE_SHIM_REWRITE_FAILED`, `BRANCH_PROFILE_SHIM_REWRITE_NOOP` | a shim path is a symlink or escapes the cache, or a shim rewrite failed or matched nothing | +| `refusing delete. Re-run with CONFIRM_DELETE=` | `delete` without its confirmation | +| `refusing setup. Re-run with CONFIRM_PROFILE=` | `setup` without its confirmation | + +When a context guard refuses, the context it names may belong to another +session or cluster: stop and ask before renaming or removing it. + ### Options ```bash @@ -503,18 +606,17 @@ kubectl rollout restart deployment/rpc-proxy -n rpc-proxy --context clerum-test ## Port Forwards for the Desktop App On a branch-owned profile, do not use the shared `:3000`/`:8090` mapping -below. First-hand entry point (gitignored helper at repo root — do not -search for it): +below. First-hand entry point `scripts/minikube-profiles/branch.mk`: ```bash MINIKUBE_PROFILE= \ - make -f .local-notes/minikube-profiles/branch.mk branch-profile-pf + make -f scripts/minikube-profiles/branch.mk branch-profile-pf MINIKUBE_PROFILE= \ - make -f .local-notes/minikube-profiles/branch.mk branch-profile-health + make -f scripts/minikube-profiles/branch.mk branch-profile-health ``` -Implementation: `.local-notes/minikube-profiles/branch-profile.sh`. +Implementation: `scripts/minikube-profiles/branch-profile.sh`. HARD DENY: do not `ls`/`cat` `~/.cache/clerum/minikube-profiles/`. `make minikube-pf-all-bg` is a gate refresh only; it must not replace `branch-profile-pf`. `branch-profile-pf-health` starts PFs then STOPS them diff --git a/docs/get-started/quickstart.md b/docs/get-started/quickstart.md index d7d6758b39..366dee0c28 100644 --- a/docs/get-started/quickstart.md +++ b/docs/get-started/quickstart.md @@ -122,17 +122,17 @@ The headless path exercises the production JWT chain end to end. With `make minikube-pf-all` holding port-forwards in another terminal (control-api :8090, external-rest-api :8091, rpc-proxy :8094, mcp-host :8080) on the shared `clerum-test` profile. For a branch-owned profile, first-hand -entry point (gitignored helper at repo root — do not search for it): +entry point `scripts/minikube-profiles/branch.mk`: ```bash MINIKUBE_PROFILE= \ - make -f .local-notes/minikube-profiles/branch.mk branch-profile-pf + make -f scripts/minikube-profiles/branch.mk branch-profile-pf MINIKUBE_PROFILE= \ - make -f .local-notes/minikube-profiles/branch.mk branch-profile-health + make -f scripts/minikube-profiles/branch.mk branch-profile-health ``` -Implementation: `.local-notes/minikube-profiles/branch-profile.sh`. +Implementation: `scripts/minikube-profiles/branch-profile.sh`. HARD DENY: do not `ls`/`cat` `~/.cache/clerum/minikube-profiles/`. This is the host-side hold for Control UI / Desktop. Profile-owned random ports only (never shared `:3000`/`:8090`). `make minikube-pf-all-bg` is a @@ -202,7 +202,7 @@ Details: [Connect Telegram](../how-to/connect-telegram.md). | `minikube start` fails on memory | Raise Docker Desktop to ≥10 GB RAM / 6 CPUs — or, if you can't spare it, `MINIKUBE_MEMORY=9216 MINIKUBE_IMAGE_TAG=latest make minikube-setup` (stock Docker Desktop's ~9.9 GB is just under the 10 GB default) | | Pods `Pending` early on | Calico is still coming up — wait, then `make minikube-status` | | postgres CrashLoopBackOff after cold start | `make minikube-setup ARGS="--reset-db --skip-build"` | -| Port-forwards die | Shared profile: re-run `make minikube-pf-all` (it holds them open; Ctrl-C stops). Branch-owned profile: `MINIKUBE_PROFILE= make -f .local-notes/minikube-profiles/branch.mk branch-profile-pf` (do not use `branch-profile-pf-health` as the lasting hold; do not replace it with `make minikube-pf-all-bg`) | +| Port-forwards die | Shared profile: re-run `make minikube-pf-all` (it holds them open; Ctrl-C stops). Branch-owned profile: `MINIKUBE_PROFILE= make -f scripts/minikube-profiles/branch.mk branch-profile-pf` (do not use `branch-profile-pf-health` as the lasting hold; do not replace it with `make minikube-pf-all-bg`) | ## Next steps diff --git a/docs/testing/e2e-guide.md b/docs/testing/e2e-guide.md index 63a28fd212..b5d5829958 100644 --- a/docs/testing/e2e-guide.md +++ b/docs/testing/e2e-guide.md @@ -117,9 +117,9 @@ make minikube-pf-all ``` For Control UI / Desktop on a branch-owned profile, the first-hand entry -point (gitignored helper at repo root — do not search for it) is the +point `scripts/minikube-profiles/branch.mk` is the host-side hold. Implementation: -`.local-notes/minikube-profiles/branch-profile.sh`. +`scripts/minikube-profiles/branch-profile.sh`. HARD DENY: do not `ls`/`cat` `~/.cache/clerum/minikube-profiles/`. Profile-owned random ports only (never shared `:3000`/`:8090`). `make minikube-pf-all-bg` is a gate refresh only; it must not replace @@ -130,10 +130,10 @@ it as the lasting hold. ```bash MINIKUBE_PROFILE= \ - make -f .local-notes/minikube-profiles/branch.mk branch-profile-pf + make -f scripts/minikube-profiles/branch.mk branch-profile-pf MINIKUBE_PROFILE= \ - make -f .local-notes/minikube-profiles/branch.mk branch-profile-health + make -f scripts/minikube-profiles/branch.mk branch-profile-health ``` With shared-profile (`make minikube-pf-all`) port-forwards held, verify the diff --git a/docs/testing/minikube-t2-runbook.md b/docs/testing/minikube-t2-runbook.md index 5cff480ad8..97103dfb39 100644 --- a/docs/testing/minikube-t2-runbook.md +++ b/docs/testing/minikube-t2-runbook.md @@ -7,7 +7,8 @@ URLs, DSNs, credentials, customer data, or raw runtime logs. ## Scope and entry points The contract is local-development-only. It requires a clean development -branch descended from the current `origin/dev`, a generated profile owned by +branch descended from the current `origin/dev` (the commit pinned when the lane +takes its profile lease; see Ownership and concurrency), a generated profile owned by that canonical worktree path plus branch, and an explicit Kubernetes context for that profile. Exact-HEAD freshness is proved by the deployed marker and gate evidence, not by reallocating a profile after every commit. @@ -46,30 +47,35 @@ matches HEAD and the current source fingerprint. `make minikube-pre-gate-sync` reconciles the profile; it does not emit a T2 verdict. The profile helper that generated the profile remains the source of truth for -the profile metadata and random localhost port mapping. Resolve it from the -primary checkout `.local-notes/minikube-profiles/branch.mk`. A legacy creation +the profile metadata and random localhost port mapping. Resolve it from +this worktree's `scripts/minikube-profiles/branch.mk`. A legacy creation SHA is historical metadata; it does not override stable worktree+branch ownership. Persisted `ports.env` is allocated once. Missing, corrupt, or ambiguous metadata fails closed; never regenerate or copy another lane's ports. -First-hand entry point (gitignored helper at repo root — do not search for -it): +First-hand entry point `scripts/minikube-profiles/branch.mk`: ```bash MINIKUBE_PROFILE= \ - make -f .local-notes/minikube-profiles/branch.mk branch-profile-pf + make -f scripts/minikube-profiles/branch.mk branch-profile-pf MINIKUBE_PROFILE= \ - make -f .local-notes/minikube-profiles/branch.mk branch-profile-health + make -f scripts/minikube-profiles/branch.mk branch-profile-health ``` -Implementation: `.local-notes/minikube-profiles/branch-profile.sh`. +Implementation: `scripts/minikube-profiles/branch-profile.sh`. HARD DENY: do not `ls`/`cat` `~/.cache/clerum/minikube-profiles/`. This is the host-side hold for Control UI / Desktop. Profile-owned random ports only (never shared `:3000`/`:8090`). `make minikube-pf-all-bg` is a gate refresh only; it must not replace `branch-profile-pf`. Do not start UI -PFs from a sandboxed agent shell (hooks/PATH; runners clean children). Run -the make target on the host. Do not kill this lane's `branch-profile-pf`. +PFs from a sandboxed agent shell (hooks/PATH; runners clean children). A +`make ... branch-profile-pf` that prints `PF` lines and exits 0 can still +leave registered-but-dead pidfiles; the planner then fail-louds +`PORT_FORWARD_CONFLICT` + `DEVELOPMENT_SCOPE_REQUIRED` before a transition. +`T2_PORT_FORWARD_COMMAND` does not skip that pre-transition check. Restore +with a lasting host hold + `branch-profile-health`, then re-enter +`make minikube-t2`. Run the make target on the host. Do not kill this +lane's `branch-profile-pf`. Inner `pre-gate-sync` may use `--skip-port-forwards`; never pass that globally into `make minikube-t2`. `branch-profile-pf-health` starts PFs then STOPS them on EXIT — do not use it as the lasting hold. @@ -79,10 +85,9 @@ STOPS them on EXIT — do not use it as the lasting hold. pod it resolved at start, so after an in-run sync the host hold points at terminated pods and the Health/Playwright journeys fail against it. Run `make minikube-t2` from a host terminal with -`T2_PORT_FORWARD_COMMAND='MINIKUBE_PROFILE= make -f .local-notes/minikube-profiles/branch.mk branch-profile-pf'`. -T2 runs that command from its own working directory, so in a worktree without -a local `.local-notes/` pass the absolute path of the main checkout's -`branch.mk`. T2 runs it once, after NP-08 and before Health, only when +`T2_PORT_FORWARD_COMMAND='MINIKUBE_PROFILE= make -f scripts/minikube-profiles/branch.mk branch-profile-pf'`. +T2 runs that command from the worktree, so `scripts/minikube-profiles/branch.mk` +resolves here. T2 runs it once, after NP-08 and before Health, only when `pre-gate-sync` ran in this invocation, and records `PortForwards=PASS`, `SKIPPED` (already synced), `NOT_RUN` (no registered hold and no command) or `FAIL` (`PORT_FORWARD_CONFLICT`: the command failed, or a registered hold @@ -152,6 +157,17 @@ serialize through the atomic sibling directory concurrent loser fails closed and never removes either the stale directory or the winner's replacement lock. +The lease owner also pins `origin/dev`. It records the commit it resolved as +`ORIGIN_DEV` in the lock record and exports it as `T2_PINNED_ORIGIN_DEV`; every +child of the lease validates ancestry against that pin instead of re-reading +the remote-tracking ref, which a `git fetch` in any worktree of the repository +moves. A move is reported as `origin/dev moved during the lane` and does not +invalidate the lane. The pin is honored only under an inherited lease, must be +a full commit SHA present in the repository, and must still be an ancestor of +the local `origin/dev`: a force-push of `dev` during the lane, a lease child +without a pin, or a pin that differs from the owner's record fails with +`DEVELOPMENT_SCOPE_REQUIRED` or `PROFILE_OWNERSHIP_MISMATCH`. + The pre-gate marker must contain the current worktree identifier, exact `HEAD`, cluster fingerprint, image coordinate, and the exact `imagesGeneratedAt` value from the image manifest. A mismatch—including a new image acquisition at the @@ -172,8 +188,8 @@ in the final preflight after optional journeys; planner mode does not certify it Health and Playwright commands inherit the parent's opaque lease token, profile, explicit context, repository, and lock root for that invocation only. A nested mutation wrapper revalidates the full repository/branch/HEAD/profile/ -context/worktree/lock-key binding and live owner before using the lease. A -missing token or mismatched binding fails; the journey cannot acquire a second +context/worktree/lock-key/`origin/dev` binding and live owner before using the +lease. A missing token or mismatched binding fails; the journey cannot acquire a second lease or redirect an inherited lease to another profile. Hermetic coverage is `bash scripts/tests/test-minikube-t2-proxy-runtime.sh`. @@ -280,6 +296,16 @@ builds, Minikube image operations, Minikube status/docker-env, Kubernetes node inventory, and targeted health commands all have validated finite deadlines and process-group cleanup on timeout or interrupt. +Host `npm test` is a separate precondition from cluster Ready. The T1 +preflight (`[real-pg-preflight] PASS ... packages=2`) only checks +`control-api` and `gfs-controller` for host `vitest`+`pg`. After planner PASS +and Ready deployments, `pre-gate-sync` still runs host `npm test` in each +changed package listed in `scripts/minikube/pre-gate-sync.sh` (`run_if_changed`). +`sh: vitest: command not found` / `minikube-pre-gate-sync` Error 127 is a +missing host `npm ci` in that directory — not GFS and not a new profile. +Install every remaining pre-gate package, then re-enter `make minikube-t2`. +See `.cursor/skills/minikube-t0-t1-t2/reference.md` (Host npm vs cluster Ready). + The local Real PostgreSQL lane resolves the `control-postgres` Secret using the explicit context, constructs its admin DSN only in process memory, and passes it only to the shared-server suites. Suites @@ -372,7 +398,9 @@ non-T2 scope guard. image manifest is current, PostgreSQL and required namespaces/Services are present, deployments are Ready, and no foreign `kubectl port-forward` owns this profile. A targeted sync requires a bounded user-facing journey via - `T2_HEALTHCHECK_COMMAND`; other transitions may leave it opt-in. Control + `T2_HEALTHCHECK_COMMAND` on the same `make minikube-t2` invocation + (planner `T2_PREFLIGHT_PASS` then `PROFILE_UNHEALTHY` means the command + was missing); other transitions may leave it opt-in. Control UI/Desktop Playwright remains opt-in via `T2_PLAYWRIGHT_COMMAND`. Both are recorded as separate evidence statuses (`NOT_RUN` when optional; `T2_REQUIRE_PLAYWRIGHT=true` refuses a missing journey). Product E2E scripts @@ -448,7 +476,7 @@ failure still uses the full target. Each run writes a sanitized `evidence.json` below the ignored local path `.local-notes/infra/runs/-/`. It contains branch/`HEAD`, -`origin/dev`, merge-base, worktree path, profile/context identifiers, +the `origin/dev` pinned at lane start, merge-base, worktree path, profile/context identifiers, fingerprint and image-manifest references, phase timestamps, lane statuses, test counts, and references to local log files. It never contains Secret values, DSNs, tokens, kubeconfig data, private URLs, user data, screenshots, or diff --git a/docs/testing/workflow-network-policy-regressions.md b/docs/testing/workflow-network-policy-regressions.md new file mode 100644 index 0000000000..a430a6cf16 --- /dev/null +++ b/docs/testing/workflow-network-policy-regressions.md @@ -0,0 +1,113 @@ +# Workflow NetworkPolicy regression contracts + +PR #855 repeatedly repaired one caller while another caller changed the meaning +of the same state or deleted the same resource outside its owner. Finding-local +red/green and mutation checks were useful, but mocks of production consumers +left the scheduler, finalizer and cleanup boundaries untested. + +The regression suites below exercise those boundaries. A focused test command +is validation evidence; a class check separately inventories every producer, +consumer and caller of the affected behavior. + +## Marker ownership + +The marker represents three independent observations: apply (`A`), run-lane +prune (`P`) and removal of the legacy internet policy (`L`). + +| Writer | A | P | L | +| ------------------------------------ | --------- | --------- | -------- | +| Full reconcile with a policy summary | observed | observed | observed | +| SDK result without a runtime summary | retained | retained | retained | +| Apply retry | observed | retained | retained | +| Legacy delete retry | retained | retained | observed | +| Terminal run | converged | retained | retained | +| Successful complete SDK cleanup | converged | converged | removed | + +Missing runtime information does not prove cleanup happened. The complete +cleanup writer is permitted only after cleanup succeeds. A pending delete or +failed cleanup retains the published facts. An omitted condition group retains +it; an empty group removes it. + +Historical combined `RetryPending` is decoded only when its message exactly +matches the old writer's frozen output. Other reasons retain their current +meaning. Historical `PrunePending` did not distinguish ordinary prune failure +from legacy-delete failure; it needs an actual full reconcile observation before +the current legacy retry fact can be established. It is not guessed into a +`DeletePending` condition. + +## Resource deletion and scheduling + +Live reconcile, SDK cleanup and retry use the same recipe-UID-keyed legacy +policy state. Success or 404 records removal and clears backoff. Other outcomes +retain pending state with a 60-second initial window, doubling to one hour. +The labelled cleanup sweep excludes the exact legacy name and sends it through +this same owner. A pending cleanup carries the remaining window to its caller. + +Finalizer intent is explicit. It permits the last removal attempt inside a +live backoff window because deleting the recipe also removes its future retry +opportunity. Process-local state is forgotten after cleanup completes. + +When only legacy removal remains, its timer queues that operation without +running the full workflow pipeline. It checks the current object after the queue +wait, checks identity again after the asynchronous DELETE, and publishes only +the owned condition group with UID and resourceVersion. Conflicts retry this +same operation. Replacement, deletion, stop or timer replacement cancels stale +queued work. + +A successful conditions patch also produces an ordinary Kubernetes watch +event. Terminal runs keep their existing best-effort compute cleanup on that +event. The bridge test therefore asserts no additional teardown during failed +backoff windows, one ordinary terminal cleanup after recovery, and no new +legacy timer, legacy DELETE or publication loop. + +## Required behavioral witnesses + +| Boundary | Witness | +| ---------------------------------- | --------------------------------------------------------------------- | +| SDK without runtime or cleanup | pending facts survive; cleanup has zero calls | +| Complete SDK cleanup | cleanup succeeds before facts are cleared | +| Labelled legacy policy | one central DELETE; no sweep bypass or repeated 404 | +| Live cleanup within backoff | no extra DELETE; exact remaining delay retained | +| Finalizer | last chance inside the window; 2xx and 404 map to observed gone | +| Timer through real watcher and WRC | pod, transport and GFS effects remain unchanged during failed windows | +| Recovery watch event | one ordinary terminal cleanup, then no retry or status loop | +| Queued and in-flight replacement | stale UID causes no new-object status patch or queued resource action | +| Version conflict | newest apply/prune facts survive; successful DELETE is not repeated | +| Structured failure logging | exact error object reaches the existing logger | + +The controller fixtures mock Kubernetes at its API boundary while retaining +the real watcher, recipe queue and WRC consumer. The property-model ledger test +remains useful, but does not replace finalizer-to-ledger tests. + +Relevant suites: + +- `workflow-recipes/src/reconciler/reconciler.test.ts` +- `workflow-recipes/src/k8sClient.test.ts` +- `workflow-recipes/tests/unit/workflow/workflowReconciler.test.ts` +- `workflow-recipes/src/reconciler/oauthBrokerDeleteLedger.property.test.ts` + +## Auxiliary recovery contracts + +Profile stop/delete distinguish an unreadable identity from a readable +contradiction. They retain local endpoint, known-profile, ownership and +confirmation checks. The hermetic lifecycle suite uses runtime stubs, verifies +actual operation/record effects and asserts that its source checkout is unchanged. +It does not establish evidence on a real Minikube profile. + +PostgreSQL teardown waits for held clients by identity with a bounded deadline. +Already-removed clients can still forward an administrative termination error +through their pool, so the shared teardown boundary must retain its error +observer after it returns. Unexpected errors remain failures. The AST guard +tracks actual local pool bindings and preserves HTTP/stream `end` calls; its +documented dynamic and cross-file limits are not a proof of every possible +JavaScript call form. + +Relevant suites: + +- `scripts/tests/test-branch-profile-lifecycle.sh` +- `control-api/test/helpers.realPostgresTeardown.test.ts` +- `control-api/test/realPostgresTeardown.guard.test.ts` + +Source inspection, local tests, isolated real PostgreSQL, CI, Minikube T0/T1/T2, +development and production are separate evidence lanes. Passing one does not +certify the others or authorize deployment. diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 81b894be25..1b351481ef 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.111", + "version": "0.1.112", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.111", + "version": "0.1.112", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index 3cabb19d06..e08b2fd040 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.111", + "version": "0.1.112", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index 30374f80b7..1b257b9a4f 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.10.0', - externalRestApiVersion: '0.1.111', + externalRestApiVersion: '0.1.112', rpcProxyVersion: '0.1.120', desktopVersion: '0.10.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/src/routes/__tests__/oauthCallback.test.ts b/external-rest-api/src/routes/__tests__/oauthCallback.test.ts index 98a69acf57..8d19b38097 100644 --- a/external-rest-api/src/routes/__tests__/oauthCallback.test.ts +++ b/external-rest-api/src/routes/__tests__/oauthCallback.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import http from 'node:http' +import type { AddressInfo } from 'node:net' import request from 'supertest' import { createApp } from '../../app.js' import * as client from '../../controlApiClient.js' @@ -67,3 +69,142 @@ describe('oauth-callback passthrough', () => { spy.mockRestore() }) }) + +// supertest resolves the URL through WHATWG URL, which collapses `%2e%2e` dot +// segments before sending. A proxy or a hand-crafted request does not, so the +// rejection cases go out over a raw socket with the path exactly as written. +async function rawGet(path: string): Promise<{ status: number }> { + const server = createApp().listen(0) + try { + await new Promise(resolve => server.once('listening', resolve)) + const { port } = server.address() as AddressInfo + return await new Promise((resolve, reject) => { + const req = http.request({ host: '127.0.0.1', port, path, method: 'GET' }, res => { + res.resume() + res.on('end', () => resolve({ status: res.statusCode ?? 0 })) + }) + req.on('error', reject) + req.end() + }) + } finally { + await new Promise(resolve => server.close(() => resolve())) + } +} + +describe('oauth-callback passthrough — per-server remote route', () => { + const NONCE = '3f2b8c1e-9a4d-4e7f-8b21-5c6d7e8f9a0b' + + afterEach(() => { + vi.restoreAllMocks() + }) + + function mockControlApi() { + return vi + .spyOn(client, 'controlApiPassthroughGet') + .mockResolvedValue({ status: 200, contentType: 'text/html', body: 'ok' }) + } + + it('forwards a one-segment per-server callback to the exact path with the raw query', async () => { + const spy = mockControlApi() + + const res = await request(createApp()).get( + '/api/v1/oauth-callback/remote/atlassian?state=S&code=C&scope=a%20b' + ) + + expect(res.status).toBe(200) + expect(res.text).toBe('ok') + expect(spy).toHaveBeenCalledTimes(1) + expect(spy).toHaveBeenCalledWith( + '/oauth-callback/remote/atlassian', + '?state=S&code=C&scope=a%20b' + ) + spy.mockRestore() + }) + + it('forwards a two-segment (serverName + installNonce) callback to the exact path', async () => { + const spy = mockControlApi() + + const res = await request(createApp()).get( + `/api/v1/oauth-callback/remote/my-server-1/${NONCE}?state=S&code=C` + ) + + expect(res.status).toBe(200) + expect(spy).toHaveBeenCalledTimes(1) + expect(spy).toHaveBeenCalledWith( + `/oauth-callback/remote/my-server-1/${NONCE}`, + '?state=S&code=C' + ) + spy.mockRestore() + }) + + it('preserves a repeated iss verbatim so control-api can reject it', async () => { + const spy = mockControlApi() + + await request(createApp()).get( + `/api/v1/oauth-callback/remote/atlassian/${NONCE}?code=C&state=S&iss=a&iss=b` + ) + + expect(spy).toHaveBeenCalledWith( + `/oauth-callback/remote/atlassian/${NONCE}`, + '?code=C&state=S&iss=a&iss=b' + ) + spy.mockRestore() + }) + + it('accepts a 63-char server name (RFC 1123 label upper bound)', async () => { + const spy = mockControlApi() + const name = `a${'b'.repeat(61)}c` + + const res = await request(createApp()).get(`/api/v1/oauth-callback/remote/${name}?code=C`) + + expect(res.status).toBe(200) + expect(spy).toHaveBeenCalledWith(`/oauth-callback/remote/${name}`, '?code=C') + spy.mockRestore() + }) + + it('keeps the shared remote callback on the one-segment route', async () => { + const spy = mockControlApi() + + await request(createApp()).get( + '/api/v1/oauth-callback/remote?code=C&state=S&iss=https%3A%2F%2Fas' + ) + + expect(spy).toHaveBeenCalledWith( + '/oauth-callback/remote', + '?code=C&state=S&iss=https%3A%2F%2Fas' + ) + spy.mockRestore() + }) + + const rejected: Array<[string, string]> = [ + ['encoded slash traversal', '/api/v1/oauth-callback/remote/..%2Fadmin?code=C'], + [ + 'encoded slash traversal after a valid name', + `/api/v1/oauth-callback/remote/foo/..%2F..%2Fadmin?code=C`, + ], + ['encoded dot-dot', '/api/v1/oauth-callback/remote/%2e%2e?code=C'], + ['encoded dot-dot as nonce', '/api/v1/oauth-callback/remote/foo/%2e%2e?code=C'], + ['double-encoded slash', '/api/v1/oauth-callback/remote/foo%252Fbar?code=C'], + ['double-encoded slash as nonce', `/api/v1/oauth-callback/remote/foo/${NONCE}%252F?code=C`], + ['uppercase UUID nonce', `/api/v1/oauth-callback/remote/foo/${NONCE.toUpperCase()}?code=C`], + ['non-UUID nonce', '/api/v1/oauth-callback/remote/foo/not-a-uuid?code=C'], + ['UUID without hyphens', `/api/v1/oauth-callback/remote/foo/${NONCE.replace(/-/g, '')}?code=C`], + ['uppercase server name', '/api/v1/oauth-callback/remote/Atlassian?code=C'], + ['leading hyphen', '/api/v1/oauth-callback/remote/-foo?code=C'], + ['trailing hyphen', '/api/v1/oauth-callback/remote/foo-?code=C'], + ['64-char server name', `/api/v1/oauth-callback/remote/${'a'.repeat(64)}?code=C`], + ['empty server name before a nonce', `/api/v1/oauth-callback/remote//${NONCE}?code=C`], + ['dot in server name', '/api/v1/oauth-callback/remote/foo.bar?code=C'], + ['underscore in server name', '/api/v1/oauth-callback/remote/foo_bar?code=C'], + ['extra third segment', `/api/v1/oauth-callback/remote/foo/${NONCE}/extra?code=C`], + ] + + it.each(rejected)('404s without contacting control-api: %s', async (_label, url) => { + const spy = mockControlApi() + + const res = await rawGet(url) + + expect(res.status).toBe(404) + expect(spy).not.toHaveBeenCalled() + }) +}) diff --git a/external-rest-api/src/routes/oauthCallback.ts b/external-rest-api/src/routes/oauthCallback.ts index 354d21ad87..bd2f47f89f 100644 --- a/external-rest-api/src/routes/oauthCallback.ts +++ b/external-rest-api/src/routes/oauthCallback.ts @@ -1,6 +1,18 @@ -import { Router } from 'express' +import { type Request, Router } from 'express' import { controlApiPassthroughGet } from '../controlApiClient.js' +// Same shape control-api accepts for a remote MCP server name (a K8s resource +// name): lowercase RFC 1123 label, 1-63 chars, no leading/trailing hyphen. +const REMOTE_SERVER_NAME_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/ +// Lowercase only: control-api mints the nonce with crypto.randomUUID(), which is +// always lowercase, so any other casing is not a redirect URI it ever registered. +const INSTALL_NONCE_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ + +function rawQueryOf(req: Request): string { + const queryStart = req.originalUrl.indexOf('?') + return queryStart >= 0 ? req.originalUrl.slice(queryStart) : '' +} + /** * PUBLIC OAuth callback. The provider (Google, …) redirects the user's browser * here after consent — there is no Clerum session or bearer token; authentication @@ -10,19 +22,43 @@ import { controlApiPassthroughGet } from '../controlApiClient.js' * response, including the HTML success page that bounces to clerum://oauth-completed. * * This is the only public, unauthenticated route in this gateway, so it MUST stay a - * thin passthrough: never read or trust anything beyond the path oauthClientId and - * the opaque query string. + * thin passthrough: never read or trust anything beyond the validated path segments + * and the opaque query string. */ export function createOauthCallbackRouter(): Router { const router = Router() + // Per-server remote callback (/remote/[/]). The forward + // carries this gateway's service token, so an unvalidated segment (e.g. a decoded + // `../admin`) would turn a public URL into an authenticated GET against any + // control-api path. Segments are therefore allow-listed before anything is sent, + // and the forwarded path is rebuilt only from them. + router.get('/oauth-callback/remote/:serverName/:installNonce?', async (req, res, next) => { + const serverName = String(req.params.serverName) + const installNonce = req.params.installNonce + if ( + !REMOTE_SERVER_NAME_RE.test(serverName) || + (installNonce !== undefined && !INSTALL_NONCE_RE.test(installNonce)) + ) { + res.status(404).json({ error: 'Not Found' }) + return + } + try { + const path = + `/oauth-callback/remote/${encodeURIComponent(serverName)}` + + (installNonce !== undefined ? `/${encodeURIComponent(installNonce)}` : '') + const result = await controlApiPassthroughGet(path, rawQueryOf(req)) + res.status(result.status).type(result.contentType).send(result.body) + } catch (err) { + next(err) + } + }) + router.get('/oauth-callback/:oauthClientId', async (req, res, next) => { try { - const queryStart = req.originalUrl.indexOf('?') - const rawQueryString = queryStart >= 0 ? req.originalUrl.slice(queryStart) : '' const result = await controlApiPassthroughGet( `/oauth-callback/${encodeURIComponent(String(req.params.oauthClientId))}`, - rawQueryString + rawQueryOf(req) ) res.status(result.status).type(result.contentType).send(result.body) } catch (err) { diff --git a/scripts/dev/repo-intake-packet.sh b/scripts/dev/repo-intake-packet.sh index a9c181afc7..2db8e6f16f 100755 --- a/scripts/dev/repo-intake-packet.sh +++ b/scripts/dev/repo-intake-packet.sh @@ -256,8 +256,8 @@ WORKTREE_COUNT="$(printf '%s\n' "${WORKTREE_PORCELAIN}" | grep -c '^worktree ' | PRUNABLE_COUNT="$(printf '%s\n' "${WORKTREE_PORCELAIN}" | grep -c '^prunable' || true)" PRIMARY_CHECKOUT="$(resolve_primary_checkout)" -LOCAL_PROFILE_HELPER="${REPO_ROOT}/.local-notes/minikube-profiles/branch.mk" -PRIMARY_PROFILE_HELPER="${PRIMARY_CHECKOUT}/.local-notes/minikube-profiles/branch.mk" +LOCAL_PROFILE_HELPER="${REPO_ROOT}/scripts/minikube-profiles/branch.mk" +PRIMARY_PROFILE_HELPER="${PRIMARY_CHECKOUT}/scripts/minikube-profiles/branch.mk" LOCAL_HELPER_EXISTS="no" PRIMARY_HELPER_EXISTS="no" PROFILE_HELPER_EXISTS="no" @@ -266,13 +266,13 @@ PROFILE_OWNER_SCRIPT="${SCRIPT_ROOT}/scripts/minikube/profile-owner.sh" PROFILE_OWNER_AVAILABLE="no" [[ -f "${LOCAL_PROFILE_HELPER}" ]] && LOCAL_HELPER_EXISTS="yes" -if [[ -f "${PRIMARY_PROFILE_HELPER}" ]]; then - PRIMARY_HELPER_EXISTS="yes" - PROFILE_HELPER_EXISTS="yes" - PROFILE_HELPER_COMMAND="make -f ${PRIMARY_PROFILE_HELPER} branch-profile-info" -elif [[ "${LOCAL_HELPER_EXISTS}" == "yes" ]]; then +[[ -f "${PRIMARY_PROFILE_HELPER}" ]] && PRIMARY_HELPER_EXISTS="yes" +if [[ "${LOCAL_HELPER_EXISTS}" == "yes" ]]; then PROFILE_HELPER_EXISTS="yes" PROFILE_HELPER_COMMAND="make -f ${LOCAL_PROFILE_HELPER} branch-profile-info" +elif [[ "${PRIMARY_HELPER_EXISTS}" == "yes" ]]; then + PROFILE_HELPER_EXISTS="yes" + PROFILE_HELPER_COMMAND="make -f ${PRIMARY_PROFILE_HELPER} branch-profile-info" else BLOCKERS+=("missing_branch_profile_helper") fi diff --git a/scripts/e2e/e2e-lib.sh b/scripts/e2e/e2e-lib.sh index 3eead40610..834495c436 100755 --- a/scripts/e2e/e2e-lib.sh +++ b/scripts/e2e/e2e-lib.sh @@ -44,7 +44,7 @@ is_branch_scoped_e2e_context() { } # A branch-scoped context is only valid with the port-forward map generated by -# clerum/.local-notes/minikube-profiles/branch.mk for that exact context. A +# scripts/minikube-profiles/branch.mk for that exact context. A # non-empty URL is not sufficient: another profile can be healthy on the same # machine and would otherwise make a Desktop run appear to validate the wrong # cluster. diff --git a/scripts/e2e/e2e-plugin-workload-sdk.sh b/scripts/e2e/e2e-plugin-workload-sdk.sh index c8902e19a5..9ccace63fc 100755 --- a/scripts/e2e/e2e-plugin-workload-sdk.sh +++ b/scripts/e2e/e2e-plugin-workload-sdk.sh @@ -130,7 +130,7 @@ load_branch_profile_ports() { local ports_env="${CLERUM_PROFILE_PORTS_ENV:-${E2E_PROFILE_PORTS_ENV:-}}" if [[ "$ctx" =~ ^clerum-(codex|detached)- ]] || [[ "$ctx" =~ ^clerum-.+-[0-9a-f]{7,8}$ ]]; then if [ -z "$ports_env" ]; then - die "Branch-owned context ${ctx} requires ports.env generated by clerum/.local-notes/minikube-profiles/branch.mk; set CLERUM_PROFILE_PORTS_ENV or E2E_PROFILE_PORTS_ENV before running this gate" + die "Branch-owned context ${ctx} requires ports.env generated by scripts/minikube-profiles/branch.mk; set CLERUM_PROFILE_PORTS_ENV or E2E_PROFILE_PORTS_ENV before running this gate" fi elif [ -z "$ports_env" ] && [ -n "$ctx" ]; then ports_env="${HOME}/.cache/clerum/minikube-profiles/${ctx}/ports.env" diff --git a/scripts/e2e/image-capabilities-fixture.mjs b/scripts/e2e/image-capabilities-fixture.mjs index dd6e97f0f2..f477b21cc7 100644 --- a/scripts/e2e/image-capabilities-fixture.mjs +++ b/scripts/e2e/image-capabilities-fixture.mjs @@ -168,7 +168,7 @@ async function main() { const canonical = fs.realpathSync(path.dirname(commonDir)) if (path.basename(canonical) !== 'evenfire') throw new Error('Canonical Evenfire checkout required') - const branchMake = path.join(canonical, '.local-notes/minikube-profiles/branch.mk') + const branchMake = path.join(root, 'scripts/minikube-profiles/branch.mk') const info = command('make', ['-f', branchMake, 'branch-profile-info']) if (!info.includes(`profile: ${profile}\n`) || !info.includes(`repo: ${root}\n`)) throw new Error('Branch helper identity mismatch') diff --git a/scripts/e2e/playwright-dev.sh b/scripts/e2e/playwright-dev.sh index e72c03a350..cf70e54429 100755 --- a/scripts/e2e/playwright-dev.sh +++ b/scripts/e2e/playwright-dev.sh @@ -2,7 +2,7 @@ # Launches the Desktop App Playwright E2E suite against the selected cluster. # Assumes branch-profile port-forwards are running for the same cluster. The # active URLs are read from the caller environment (normally ports.env emitted -# by Clerum's .local-notes/minikube-profiles/branch.mk); fixed shared defaults +# by Clerum's scripts/minikube-profiles/branch.mk); fixed shared defaults # are retained only for the unscoped clerum-test profile. # # Visual modes: diff --git a/scripts/minikube-profiles/branch-profile.sh b/scripts/minikube-profiles/branch-profile.sh new file mode 100755 index 0000000000..7bc2727454 --- /dev/null +++ b/scripts/minikube-profiles/branch-profile.sh @@ -0,0 +1,1545 @@ +#!/usr/bin/env bash +set -euo pipefail + +ACTION="${1:-info}" +HOST="${HOST:-127.0.0.1}" +CACHE_ROOT="${CACHE_ROOT:-${HOME}/.cache/clerum/minikube-profiles}" +MINIKUBE_MEMORY="${MINIKUBE_MEMORY:-10240}" +MINIKUBE_CPUS="${MINIKUBE_CPUS:-6}" +MINIKUBE_CNI="${MINIKUBE_CNI:-calico}" +MINIKUBE_DRIVER="${MINIKUBE_DRIVER:-docker}" +CONFIRM_DELETE="${CONFIRM_DELETE:-}" +CONFIRM_PROFILE="${CONFIRM_PROFILE:-}" +ARGS="${ARGS:-}" +BRANCH_PROFILE_PROFILE="${BRANCH_PROFILE_PROFILE:-}" +MINIKUBE_PROFILE_SELECTION="${MINIKUBE_PROFILE:-}" +KUBECTL_REQUEST_TIMEOUT="${BRANCH_PROFILE_KUBECTL_REQUEST_TIMEOUT:-10s}" +MINIKUBE_STATUS_TIMEOUT_SECONDS="${BRANCH_PROFILE_MINIKUBE_STATUS_TIMEOUT_SECONDS:-60}" +MINIKUBE_START_TIMEOUT_SECONDS="${BRANCH_PROFILE_MINIKUBE_START_TIMEOUT_SECONDS:-900}" +MINIKUBE_STOP_TIMEOUT_SECONDS="${BRANCH_PROFILE_MINIKUBE_STOP_TIMEOUT_SECONDS:-180}" +MINIKUBE_DELETE_TIMEOUT_SECONDS="${BRANCH_PROFILE_MINIKUBE_DELETE_TIMEOUT_SECONDS:-300}" +EXPLICIT_PROFILE="" + +PROFILE_EXISTS=false +PROFILE_SCHEMA_VERSION=2 +PROFILE_STATE=prospective-v2 +STATE_LOCK_DIR="" +STATE_TEMP_PROFILE="" +STATE_TEMP_PORTS="" +STATE_NEW_CACHE_DIR=false +PORT_FORWARD_OWNER_LOADED=false +CONTEXT_IDENTITY_LOADED=false +START_BEFORE_CONTEXT="" +START_CONTEXT_RESTORE_PENDING=false + +die() { + printf 'ERROR: %s\n' "$*" >&2 + exit 1 +} + +require_command() { + local missing=() + local bin + for bin in "$@"; do + if ! command -v "${bin}" >/dev/null 2>&1; then + missing+=("${bin}") + fi + done + if (( ${#missing[@]} > 0 )); then + printf 'ERROR: missing required command(s): %s\n' "${missing[*]}" >&2 + exit 1 + fi +} + +validate_seconds() { + local name="$1" value="$2" maximum="$3" + [[ "${value}" =~ ^[1-9][0-9]*$ && "${value}" -le "${maximum}" ]] || + die "${name} must be an integer from 1 to ${maximum}" +} + +run_bounded() { + local label="$1" timeout_seconds="$2" + shift 2 + [[ -f "${DEADLINE_RUNNER}" && -r "${DEADLINE_RUNNER}" && ! -L "${DEADLINE_RUNNER}" ]] || + die "bounded process runner is unavailable: ${DEADLINE_RUNNER}" + node "${DEADLINE_RUNNER}" \ + --timeout-seconds "${timeout_seconds}" \ + --heartbeat-seconds 20 --kill-grace-seconds 5 \ + --label "branch-profile-${label}" -- "$@" +} + +record_value() { + local payload="$1" key="$2" + awk -F= -v wanted="${key}" '$1 == wanted { sub(/^[^=]*=/, ""); print; exit }' \ + <<<"${payload}" +} + +file_value() { + local file="$1" key="$2" + awk -F= -v wanted="${key}" '$1 == wanted { sub(/^[^=]*=/, ""); print; exit }' \ + "${file}" 2>/dev/null || true +} + +validate_host() { + # ports.env URLs and every port-forward ownership record are bound to + # 127.0.0.1 (kubectl port-forward --address=127.0.0.1). Accepting another + # loopback spelling here would publish URLs no forward listens on. + [[ "${HOST}" == 127.0.0.1 ]] || + die "HOST must be 127.0.0.1 (the only address branch-profile forwards bind), got: ${HOST}" + URL_HOST=127.0.0.1 +} + +normalize_cache_root() { + [[ -n "${CACHE_ROOT}" && "${CACHE_ROOT}" != / ]] || die 'CACHE_ROOT must be a scoped directory' + [[ "${CACHE_ROOT}" != *$'\n'* && "${CACHE_ROOT}" != *$'\r'* && "${CACHE_ROOT}" != *$'\t'* ]] || + die 'CACHE_ROOT contains a control character' + if [[ "${CACHE_ROOT}" != /* ]]; then + CACHE_ROOT="${REPO_DIR}/${CACHE_ROOT}" + fi + CACHE_ROOT="${CACHE_ROOT%/}" + if [[ -d "${CACHE_ROOT}" ]]; then + [[ ! -L "${CACHE_ROOT}" ]] || die "CACHE_ROOT must not be a symlink: ${CACHE_ROOT}" + CACHE_ROOT="$(cd -- "${CACHE_ROOT}" && pwd -P)" + elif [[ -e "${CACHE_ROOT}" || -L "${CACHE_ROOT}" ]]; then + die "CACHE_ROOT is not a directory: ${CACHE_ROOT}" + fi +} + +# Refuses by name the kube contexts shared across sessions or clusters: the +# Clerum dev and production contexts, the default minikube and Docker Desktop +# contexts, GKE contexts under their generated gke___ +# name (covered by *gke*) and the protected names profile-owner.sh also +# refuses. Matching is case-insensitive. A name list cannot recognise a renamed +# remote context, so this is only the first barrier: the profile must also be +# owned (profile-owner.sh) and in the clerum-* namespace, and before any +# minikube -p or kubectl --context call its kube context must point at a local +# API server (require_local_context_endpoint) and, once the cluster answers, +# identify this Minikube profile (require_profile_cluster_identity). +refuse_shared_profile_name() { + local name="$1" normalized + normalized="$(printf '%s' "${name}" | LC_ALL=C tr '[:upper:]' '[:lower:]')" + case "${normalized}" in + clerum | clerum-dev | clerum-test | minikube | docker-desktop | default | *gke* | *prod* | *staging*) + die "BRANCH_PROFILE_SHARED_CONTEXT: refusing shared or protected profile: ${name}" ;; + esac +} + +validate_profile_name() { + [[ ${#PROFILE} -le 63 && "${PROFILE}" =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]] || + die "resolver returned an unsafe profile name: ${PROFILE}" + refuse_shared_profile_name "${PROFILE}" + # An explicitly selected profile (MINIKUBE_PROFILE / BRANCH_PROFILE_PROFILE) + # has already passed profile-owner.sh's ownership check (repo, branch and + # owner id). It may predate the hashed naming, so only the local clerum-* + # namespace is required of it. + if [[ -n "${EXPLICIT_PROFILE}" && "${PROFILE}" == "${EXPLICIT_PROFILE}" ]]; then + [[ "${PROFILE}" =~ ^clerum-[a-z0-9][a-z0-9._-]*$ ]] || + die "explicit profile is outside the local clerum-* namespace: ${PROFILE}" + return 0 + fi + # A profile the resolver derives on its own is always + # clerum-- (profile_owner_stable_profile in + # scripts/minikube/profile-owner.sh), so it must carry the hex suffix. + [[ "${PROFILE}" =~ ^clerum-[a-z0-9][a-z0-9._-]*-[0-9a-f]{7,8}$ ]] || + die "resolver returned a profile outside the branch-scoped clerum-- namespace: ${PROFILE}; select an adopted profile explicitly with MINIKUBE_PROFILE=" +} + +set_profile_paths() { + CACHE_DIR="${CACHE_ROOT}/${PROFILE}" + PIDS_DIR="${CACHE_DIR}/pids" + LOGS_DIR="${CACHE_DIR}/logs" + SHIMS_DIR="${CACHE_DIR}/scripts/minikube" + DEPLOY_SHIM_DIR="${CACHE_DIR}/deploy" + SHIM_ENV="${CACHE_DIR}/shims.env" + PROFILE_ENV="${CACHE_DIR}/profile.env" + PORTS_ENV="${CACHE_DIR}/ports.env" +} + +assign_generated_ports() { + local hash_hex + hash_hex="$(printf '%s' "${PROFILE}" | shasum | awk '{print substr($1,1,8)}' | tr '[:lower:]' '[:upper:]')" + [[ "${hash_hex}" =~ ^[0-9A-F]{8}$ ]] || die 'unable to derive the stable port allocation' + PORT_BASE=$((20000 + (16#${hash_hex} % 19000))) + CONTROL_UI_PORT=$((PORT_BASE + 0)) + PROFILE_UI_PORT=$((PORT_BASE + 1)) + MCP_HOST_PORT=$((PORT_BASE + 80)) + REGISTRY_API_PORT=$((PORT_BASE + 85)) + CONTROL_API_PORT=$((PORT_BASE + 90)) + EXTERNAL_REST_API_PORT=$((PORT_BASE + 91)) + MEMBER_REGISTRATION_SERVICE_PORT=$((PORT_BASE + 92)) + RPC_PROXY_PORT=$((PORT_BASE + 94)) + WORKFLOW_APPROVAL_READER_PORT=$((PORT_BASE + 98)) + CONTROL_UI_URL="http://${URL_HOST}:${CONTROL_UI_PORT}" + PROFILE_UI_URL="http://${URL_HOST}:${PROFILE_UI_PORT}" + PROFILE_UI_BASE_URL="${PROFILE_UI_URL}" + CONTROL_API_URL="http://${URL_HOST}:${CONTROL_API_PORT}" + EXTERNAL_REST_API_URL="http://${URL_HOST}:${EXTERNAL_REST_API_PORT}" + MEMBER_REGISTRATION_SERVICE_URL="http://${URL_HOST}:${MEMBER_REGISTRATION_SERVICE_PORT}" + RPC_PROXY_URL="http://${URL_HOST}:${RPC_PROXY_PORT}" + REGISTRY_API_URL="http://${URL_HOST}:${REGISTRY_API_PORT}" + WORKFLOW_APPROVAL_READER_URL="http://${URL_HOST}:${WORKFLOW_APPROVAL_READER_PORT}" + MCP_HOST_URL="http://${URL_HOST}:${MCP_HOST_PORT}" +} + +load_validated_ports() { + local expected actual key offset url_key port_key expected_url + local -a port_specs=( + CONTROL_UI_PORT:0 + PROFILE_UI_PORT:1 + MCP_HOST_PORT:80 + REGISTRY_API_PORT:85 + CONTROL_API_PORT:90 + EXTERNAL_REST_API_PORT:91 + MEMBER_REGISTRATION_SERVICE_PORT:92 + RPC_PROXY_PORT:94 + WORKFLOW_APPROVAL_READER_PORT:98 + ) + local -a url_specs=( + CONTROL_UI_URL:CONTROL_UI_PORT + PROFILE_UI_URL:PROFILE_UI_PORT + PROFILE_UI_BASE_URL:PROFILE_UI_PORT + CONTROL_API_URL:CONTROL_API_PORT + EXTERNAL_REST_API_URL:EXTERNAL_REST_API_PORT + MEMBER_REGISTRATION_SERVICE_URL:MEMBER_REGISTRATION_SERVICE_PORT + RPC_PROXY_URL:RPC_PROXY_PORT + REGISTRY_API_URL:REGISTRY_API_PORT + WORKFLOW_APPROVAL_READER_URL:WORKFLOW_APPROVAL_READER_PORT + MCP_HOST_URL:MCP_HOST_PORT + ) + + PORT_BASE="$(file_value "${PORTS_ENV}" PORT_BASE)" + [[ "${PORT_BASE}" =~ ^[0-9]+$ && "${PORT_BASE}" -ge 20000 && "${PORT_BASE}" -le 38999 ]] || + die "PROFILE_PORTS_INVALID: invalid PORT_BASE in ${PORTS_ENV}" + + for expected in "${port_specs[@]}"; do + key="${expected%%:*}" + offset="${expected#*:}" + actual="$(file_value "${PORTS_ENV}" "${key}")" + [[ "${actual}" =~ ^[0-9]+$ && "${actual}" -eq $((PORT_BASE + offset)) ]] || + die "PROFILE_PORTS_INVALID: ${key} does not match PORT_BASE in ${PORTS_ENV}" + printf -v "${key}" '%s' "${actual}" + done + + for expected in "${url_specs[@]}"; do + url_key="${expected%%:*}" + port_key="${expected#*:}" + expected_url="http://${URL_HOST}:${!port_key}" + actual="$(file_value "${PORTS_ENV}" "${url_key}")" + [[ "${actual}" == "${expected_url}" || "${actual}" == "${expected_url}/" ]] || + die "PROFILE_PORTS_INVALID: ${url_key} does not match ${port_key} and HOST in ${PORTS_ENV}" + printf -v "${url_key}" '%s' "${actual}" + done +} + +apply_resolution() { + local payload="$1" resolved_repo resolved_branch resolved_profile_env resolved_ports_env + PROFILE_SCHEMA_VERSION="$(record_value "${payload}" PROFILE_SCHEMA_VERSION)" + WORKTREE_ID="$(record_value "${payload}" WORKTREE_ID)" + OWNER_ID="$(record_value "${payload}" OWNER_ID)" + CREATED_HEAD="$(record_value "${payload}" CREATED_HEAD)" + PROFILE="$(record_value "${payload}" PROFILE)" + resolved_repo="$(record_value "${payload}" REPO_DIR)" + resolved_branch="$(record_value "${payload}" BRANCH)" + resolved_profile_env="$(record_value "${payload}" PROFILE_ENV)" + resolved_ports_env="$(record_value "${payload}" PORTS_ENV)" + + [[ "${PROFILE_SCHEMA_VERSION}" == 1 || "${PROFILE_SCHEMA_VERSION}" == 2 ]] || + die 'resolver omitted a supported profile schema' + [[ "${WORKTREE_ID}" =~ ^[0-9a-f]{40}$ && "${OWNER_ID}" =~ ^[0-9a-f]{40}$ ]] || + die 'resolver returned malformed owner identity' + [[ "${resolved_repo}" == "${REPO_DIR}" && "${resolved_branch}" == "${BRANCH}" ]] || + die 'resolver returned ownership for another worktree or branch' + validate_profile_name + set_profile_paths + [[ -d "${CACHE_DIR}" && ! -L "${CACHE_DIR}" ]] || + die "resolver selected an unsafe profile directory: ${CACHE_DIR}" + [[ "${resolved_profile_env}" == "${PROFILE_ENV}" && "${resolved_ports_env}" == "${PORTS_ENV}" ]] || + die 'resolver returned profile state outside the selected profile directory' + PROFILE_EXISTS=true + PROFILE_STATE="existing-v${PROFILE_SCHEMA_VERSION}" + load_validated_ports +} + +resolve_profile() { + local output status=0 identity_output + local -a resolve_args=( + resolve + --repo-dir "${REPO_DIR}" + --branch "${BRANCH}" + --profile-root "${CACHE_ROOT}" + ) + if [[ -n "${EXPLICIT_PROFILE}" ]]; then + resolve_args+=(--profile "${EXPLICIT_PROFILE}") + fi + + output="$("${PROFILE_OWNER_SCRIPT}" "${resolve_args[@]}" 2>&1)" || status=$? + if (( status == 0 )); then + apply_resolution "${output}" + return 0 + fi + # Missing metadata for an existing profile directory has one safe next step. + # branch-profile-start only creates metadata when the directory does not + # exist, so pointing at it here would send the operator into this refusal + # again, or, without the explicit selection, into a brand-new profile. + if [[ "${output}" == *PROFILE_METADATA_MISSING:* ]]; then + printf '%s\n' "${output}" >&2 + die "PROFILE_METADATA_MISSING: profile metadata for ${EXPLICIT_PROFILE:-this branch} is missing or unreadable; restore profile.env from a backup or stop and ask. branch-profile-start only creates metadata when the profile directory does not exist and never regenerates it for an existing one" + fi + if [[ -n "${EXPLICIT_PROFILE}" || ${status} -ne 3 || "${output}" != *PROFILE_NOT_FOUND:* ]]; then + printf '%s\n' "${output}" >&2 + die 'profile ownership resolution failed closed' + fi + + identity_output="$("${PROFILE_OWNER_SCRIPT}" identity \ + --repo-dir "${REPO_DIR}" --branch "${BRANCH}" --created-head "${HEAD}")" || + die 'unable to derive the stable schema-v2 profile identity' + PROFILE_SCHEMA_VERSION="$(record_value "${identity_output}" PROFILE_SCHEMA_VERSION)" + WORKTREE_ID="$(record_value "${identity_output}" WORKTREE_ID)" + OWNER_ID="$(record_value "${identity_output}" OWNER_ID)" + CREATED_HEAD="$(record_value "${identity_output}" CREATED_HEAD)" + PROFILE="$(record_value "${identity_output}" PROFILE)" + [[ "${PROFILE_SCHEMA_VERSION}" == 2 && "${CREATED_HEAD}" == "${HEAD}" ]] || + die 'stable identity output is incomplete' + [[ "${WORKTREE_ID}" =~ ^[0-9a-f]{40}$ && "${OWNER_ID}" =~ ^[0-9a-f]{40}$ ]] || + die 'stable identity output is malformed' + validate_profile_name + set_profile_paths + if [[ -e "${CACHE_DIR}" || -L "${CACHE_DIR}" ]]; then + die "stable profile path already exists but was not safely resolvable: ${CACHE_DIR}" + fi + assign_generated_ports +} + +render_ports() { + local destination="$1" + cat >"${destination}" <"${destination}" +} + +validate_profile_pair() { + local profile_env="$1" ports_env="$2" output + if ! output="$("${PROFILE_OWNER_SCRIPT}" validate \ + --repo-dir "${REPO_DIR}" --branch "${BRANCH}" --profile "${PROFILE}" \ + --profile-env "${profile_env}" --ports-env "${ports_env}" 2>&1)"; then + printf '%s\n' "${output}" >&2 + return 1 + fi +} + +full_created_head() { + local historical="${CREATED_HEAD}" full + if [[ "${historical}" =~ ^[0-9a-f]{40}$ ]]; then + printf '%s\n' "${historical}" + return 0 + fi + [[ "${historical}" =~ ^[0-9a-f]{7,40}$ ]] || return 1 + full="$(git -C "${REPO_DIR}" rev-parse "${historical}^{commit}" 2>/dev/null || true)" + [[ "${full}" =~ ^[0-9a-f]{40}$ && "${full}" == "${historical}"* ]] || return 1 + printf '%s\n' "${full}" +} + +cleanup_state_write() { + if [[ -n "${STATE_TEMP_PROFILE}" && "${STATE_TEMP_PROFILE}" == "${CACHE_DIR}"/.profile.env.* ]]; then + rm -f -- "${STATE_TEMP_PROFILE}" + fi + if [[ -n "${STATE_TEMP_PORTS}" && "${STATE_TEMP_PORTS}" == "${CACHE_DIR}"/.ports.env.* ]]; then + rm -f -- "${STATE_TEMP_PORTS}" + fi + STATE_TEMP_PROFILE="" + STATE_TEMP_PORTS="" + if [[ "${STATE_NEW_CACHE_DIR}" == true && -d "${CACHE_DIR}" && ! -L "${CACHE_DIR}" ]]; then + rmdir -- "${CACHE_DIR}" 2>/dev/null || true + fi + STATE_NEW_CACHE_DIR=false + if [[ -n "${STATE_LOCK_DIR}" && "${STATE_LOCK_DIR}" == "${CACHE_ROOT}"/.branch-profile-*.lock ]]; then + rmdir -- "${STATE_LOCK_DIR}" 2>/dev/null || true + fi + STATE_LOCK_DIR="" +} + +persist_state() { + local created_head compatibility_sha prior_exit_trap + # A caller such as cmd_pf_health may already own an EXIT cleanup trap. The + # state publication needs a temporary trap for its atomic metadata lock, but + # must restore the caller's lifecycle cleanup before returning. + prior_exit_trap="$(trap -p EXIT)" + created_head="$(full_created_head)" || + die "cannot expand historical CREATED_HEAD=${CREATED_HEAD}; refusing schema-v2 migration" + compatibility_sha="${created_head:0:8}" + if [[ "${PROFILE_EXISTS}" == true ]]; then + local persisted_sha + persisted_sha="$(file_value "${PROFILE_ENV}" SHA_SHORT)" + if [[ -n "${persisted_sha}" ]]; then + persisted_sha="$(printf '%s' "${persisted_sha}" | tr '[:upper:]' '[:lower:]')" + [[ "${persisted_sha}" =~ ^[0-9a-f]{7,40}$ && "${created_head}" == "${persisted_sha}"* ]] || + die 'persisted SHA_SHORT is not compatible with historical CREATED_HEAD' + compatibility_sha="${persisted_sha}" + fi + fi + + mkdir -p -- "${CACHE_ROOT}" + [[ -d "${CACHE_ROOT}" && ! -L "${CACHE_ROOT}" ]] || die "unsafe profile root: ${CACHE_ROOT}" + CACHE_ROOT="$(cd -- "${CACHE_ROOT}" && pwd -P)" + set_profile_paths + STATE_LOCK_DIR="${CACHE_ROOT}/.branch-profile-${OWNER_ID}.lock" + mkdir -- "${STATE_LOCK_DIR}" 2>/dev/null || + die "profile state is busy; lock already exists: ${STATE_LOCK_DIR}" + trap cleanup_state_write EXIT + + if [[ "${PROFILE_EXISTS}" == true ]]; then + [[ -d "${CACHE_DIR}" && ! -L "${CACHE_DIR}" ]] || die "unsafe profile directory: ${CACHE_DIR}" + [[ -f "${PORTS_ENV}" && ! -L "${PORTS_ENV}" ]] || die "PROFILE_PORTS_MISSING: ${PORTS_ENV}" + [[ ! -L "${PIDS_DIR}" && ! -L "${LOGS_DIR}" ]] || + die 'profile runtime directories must not be symlinks' + else + [[ ! -e "${CACHE_DIR}" && ! -L "${CACHE_DIR}" ]] || + die "profile state appeared concurrently; rerun resolution: ${CACHE_DIR}" + mkdir -- "${CACHE_DIR}" + STATE_NEW_CACHE_DIR=true + fi + + STATE_TEMP_PROFILE="$(mktemp "${CACHE_DIR}/.profile.env.XXXXXX")" + render_profile_v2 "${STATE_TEMP_PROFILE}" "${created_head}" "${compatibility_sha}" + if [[ "${PROFILE_EXISTS}" == true ]]; then + case "${PROFILE_STATE}" in + existing-v1|existing-v2) ;; + *) die "refusing metadata replacement from unexpected state: ${PROFILE_STATE}" ;; + esac + validate_profile_pair "${PROFILE_ENV}" "${PORTS_ENV}" || + die 'persisted profile state changed or failed ownership validation before migration' + validate_profile_pair "${STATE_TEMP_PROFILE}" "${PORTS_ENV}" || + die 'schema-v2 metadata candidate failed public ownership validation' + # Existing validated v1/v2 metadata is migrated/refreshed by atomic rename. + # ports.env remains immutable. + mv -f -- "${STATE_TEMP_PROFILE}" "${PROFILE_ENV}" + STATE_TEMP_PROFILE="" + else + STATE_TEMP_PORTS="$(mktemp "${CACHE_DIR}/.ports.env.XXXXXX")" + render_ports "${STATE_TEMP_PORTS}" + validate_profile_pair "${STATE_TEMP_PROFILE}" "${STATE_TEMP_PORTS}" || + die 'new profile state failed public ownership validation' + [[ ! -e "${PORTS_ENV}" && ! -L "${PORTS_ENV}" && + ! -e "${PROFILE_ENV}" && ! -L "${PROFILE_ENV}" ]] || + die "refusing to replace concurrently published new profile state: ${CACHE_DIR}" + ln -- "${STATE_TEMP_PORTS}" "${PORTS_ENV}" || + die "unable to install ports.env exactly once: ${PORTS_ENV}" + if ! ln -- "${STATE_TEMP_PROFILE}" "${PROFILE_ENV}"; then + if [[ -f "${PORTS_ENV}" && "${PORTS_ENV}" -ef "${STATE_TEMP_PORTS}" ]]; then + rm -f -- "${PORTS_ENV}" + fi + die "unable to publish profile.env without replacing a concurrent owner: ${PROFILE_ENV}" + fi + rm -f -- "${STATE_TEMP_PORTS}" + STATE_TEMP_PORTS="" + rm -f -- "${STATE_TEMP_PROFILE}" + STATE_TEMP_PROFILE="" + fi + validate_profile_pair "${PROFILE_ENV}" "${PORTS_ENV}" || + die 'persisted profile state failed final public ownership validation' + load_validated_ports + [[ ! -L "${PIDS_DIR}" && ! -L "${LOGS_DIR}" ]] || + die 'profile runtime directories must not be symlinks' + mkdir -p -- "${PIDS_DIR}" "${LOGS_DIR}" + [[ -d "${PIDS_DIR}" && ! -L "${PIDS_DIR}" && + -d "${LOGS_DIR}" && ! -L "${LOGS_DIR}" ]] || + die 'profile runtime directories could not be created safely' + + PROFILE_EXISTS=true + PROFILE_SCHEMA_VERSION=2 + PROFILE_STATE=existing-v2 + CREATED_HEAD="${created_head}" + STATE_NEW_CACHE_DIR=false + cleanup_state_write + if [[ -n "${prior_exit_trap}" ]]; then + eval "${prior_exit_trap}" + else + trap - EXIT + fi +} + +resolve_explicit_profile_override() { + if [[ -n "${BRANCH_PROFILE_PROFILE}" && -n "${MINIKUBE_PROFILE_SELECTION}" && + "${BRANCH_PROFILE_PROFILE}" != "${MINIKUBE_PROFILE_SELECTION}" ]]; then + die "explicit profile selectors disagree (BRANCH_PROFILE_PROFILE=${BRANCH_PROFILE_PROFILE}, MINIKUBE_PROFILE=${MINIKUBE_PROFILE_SELECTION})" + fi + EXPLICIT_PROFILE="${BRANCH_PROFILE_PROFILE:-${MINIKUBE_PROFILE_SELECTION}}" + # Refuse a shared context by name before the resolver reads anything for it. + if [[ -n "${EXPLICIT_PROFILE}" ]]; then + refuse_shared_profile_name "${EXPLICIT_PROFILE}" + fi +} + +init_profile() { + local invocation_dir repository_root + require_command git awk shasum + invocation_dir="$(pwd -P)" + repository_root="$(git -C "${invocation_dir}" rev-parse --show-toplevel 2>/dev/null || true)" + [[ -n "${repository_root}" && -d "${repository_root}" ]] || + die "unable to resolve the active Git worktree root from ${invocation_dir}" + REPO_DIR="$(cd -- "${repository_root}" && pwd -P)" + BRANCH="$(git -C "${REPO_DIR}" branch --show-current 2>/dev/null || true)" + HEAD="$(git -C "${REPO_DIR}" rev-parse HEAD 2>/dev/null || true)" + [[ "${HEAD}" =~ ^[0-9a-f]{40}$ ]] || die "unable to resolve a full git HEAD in ${REPO_DIR}" + [[ -n "${BRANCH}" ]] || die 'branch profiles require a named branch; detached HEAD is unsupported' + SHA_SHORT="${HEAD:0:8}" + DIRTY=false + if [[ -n "$(git -C "${REPO_DIR}" status --porcelain 2>/dev/null || true)" ]]; then + DIRTY=true + fi + + validate_host + [[ "${KUBECTL_REQUEST_TIMEOUT}" =~ ^[1-9][0-9]*(ms|s|m)$ ]] || + die "invalid BRANCH_PROFILE_KUBECTL_REQUEST_TIMEOUT: ${KUBECTL_REQUEST_TIMEOUT}" + normalize_cache_root + PROFILE_OWNER_SCRIPT="${REPO_DIR}/scripts/minikube/profile-owner.sh" + PORT_FORWARD_OWNER_SCRIPT="${REPO_DIR}/scripts/minikube/port-forward-owner.sh" + CONTEXT_IDENTITY_SCRIPT="${REPO_DIR}/scripts/minikube/context-identity.sh" + DOCKER_CLI_ENV_SCRIPT="${REPO_DIR}/scripts/minikube/docker-cli-env.sh" + DEADLINE_RUNNER="${REPO_DIR}/scripts/minikube/run-with-deadline.mjs" + [[ -f "${PROFILE_OWNER_SCRIPT}" && -x "${PROFILE_OWNER_SCRIPT}" && ! -L "${PROFILE_OWNER_SCRIPT}" ]] || + die "public profile resolver is unavailable: ${PROFILE_OWNER_SCRIPT}" + validate_seconds BRANCH_PROFILE_MINIKUBE_STATUS_TIMEOUT_SECONDS \ + "${MINIKUBE_STATUS_TIMEOUT_SECONDS}" 300 + validate_seconds BRANCH_PROFILE_MINIKUBE_START_TIMEOUT_SECONDS \ + "${MINIKUBE_START_TIMEOUT_SECONDS}" 3600 + validate_seconds BRANCH_PROFILE_MINIKUBE_STOP_TIMEOUT_SECONDS \ + "${MINIKUBE_STOP_TIMEOUT_SECONDS}" 600 + validate_seconds BRANCH_PROFILE_MINIKUBE_DELETE_TIMEOUT_SECONDS \ + "${MINIKUBE_DELETE_TIMEOUT_SECONDS}" 900 + resolve_explicit_profile_override + resolve_profile +} + +require_profile_confirmation() { + local action="$1" + if [[ "${CONFIRM_PROFILE}" != "${PROFILE}" ]]; then + printf 'ERROR: refusing %s. Re-run with CONFIRM_PROFILE=%s\n' "${action}" "${PROFILE}" >&2 + exit 1 + fi +} + +print_summary() { + printf 'repo: %s\n' "${REPO_DIR}" + printf 'branch: %s\n' "${BRANCH}" + printf 'sha: %s\n' "${SHA_SHORT}" + printf 'dirty: %s\n' "${DIRTY}" + printf 'profile: %s\n' "${PROFILE}" + printf 'profile_state: %s\n' "${PROFILE_STATE}" + printf 'profile_schema: %s\n' "${PROFILE_SCHEMA_VERSION}" + printf 'created_head: %s\n' "${CREATED_HEAD}" + printf 'cache_dir: %s\n' "${CACHE_DIR}" + printf 'port_base: %s\n' "${PORT_BASE}" + printf '\nports:\n' + printf ' control-ui: %s\n' "${CONTROL_UI_PORT}" + printf ' profile-ui: %s\n' "${PROFILE_UI_PORT}" + printf ' control-api: %s\n' "${CONTROL_API_PORT}" + printf ' external-rest-api: %s\n' "${EXTERNAL_REST_API_PORT}" + printf ' member-registration-service: %s\n' "${MEMBER_REGISTRATION_SERVICE_PORT}" + printf ' rpc-proxy: %s\n' "${RPC_PROXY_PORT}" + printf ' registry-api: %s\n' "${REGISTRY_API_PORT}" + printf ' workflow-approval-reader: %s\n' "${WORKFLOW_APPROVAL_READER_PORT}" + printf ' mcp-host: %s\n' "${MCP_HOST_PORT}" +} + +check_docker_ready() { + [[ -f "${DOCKER_CLI_ENV_SCRIPT}" && -x "${DOCKER_CLI_ENV_SCRIPT}" && ! -L "${DOCKER_CLI_ENV_SCRIPT}" ]] || + die "bounded Docker probe is unavailable: ${DOCKER_CLI_ENV_SCRIPT}" + "${DOCKER_CLI_ENV_SCRIPT}" --check-info || + die 'Docker daemon is not reachable through the isolated bounded probe' +} + +check_port_free() { + local name="$1" + local port="$2" + if (echo >"/dev/tcp/${HOST}/${port}") >/dev/null 2>&1; then + printf 'ERROR: %s port %s is already listening on %s\n' "${name}" "${port}" "${HOST}" >&2 + return 1 + fi +} + +check_all_ports_free() { + check_port_free control-ui "${CONTROL_UI_PORT}" + check_port_free profile-ui "${PROFILE_UI_PORT}" + check_port_free control-api "${CONTROL_API_PORT}" + check_port_free external-rest-api "${EXTERNAL_REST_API_PORT}" + check_port_free member-registration-service "${MEMBER_REGISTRATION_SERVICE_PORT}" + check_port_free rpc-proxy "${RPC_PROXY_PORT}" + check_port_free registry-api "${REGISTRY_API_PORT}" + check_port_free workflow-approval-reader "${WORKFLOW_APPROVAL_READER_PORT}" + check_port_free mcp-host "${MCP_HOST_PORT}" +} + +cluster_reachable() { + kubectl "--context=${PROFILE}" "--request-timeout=${KUBECTL_REQUEST_TIMEOUT}" \ + cluster-info >/dev/null 2>&1 +} + +require_existing_profile() { + [[ "${PROFILE_EXISTS}" == true ]] || + die "${ACTION} requires persisted, validated profile state; run branch-profile-preflight or branch-profile-start first" +} + +load_port_forward_owner() { + [[ "${PORT_FORWARD_OWNER_LOADED}" == true ]] && return 0 + if [[ ! -f "${PORT_FORWARD_OWNER_SCRIPT}" || ! -r "${PORT_FORWARD_OWNER_SCRIPT}" || -L "${PORT_FORWARD_OWNER_SCRIPT}" ]]; then + die "PORT_FORWARD_OWNER_UNAVAILABLE: refusing PID mutation without ${PORT_FORWARD_OWNER_SCRIPT}" + fi + + # Public helper API (loaded only for PF actions): write a bound pidfile, + # validate its live owner, and stop only the process proven by that record. + # shellcheck source=/dev/null + . "${PORT_FORWARD_OWNER_SCRIPT}" + local function_name + for function_name in \ + pf_owner_record_process \ + pf_owner_cleanup_record \ + pf_owner_read_record \ + pf_owner_record_matches \ + pf_owner_process_state \ + pf_owner_process_start \ + pf_owner_process_command \ + pf_owner_command_matches \ + pf_owner_abort_child \ + pf_owner_pause \ + pf_owner_remove_dead_record; do + declare -F "${function_name}" >/dev/null || + die "PORT_FORWARD_OWNER_API_INVALID: missing ${function_name} in ${PORT_FORWARD_OWNER_SCRIPT}" + done + [[ "${HOST}" == 127.0.0.1 ]] || + die 'PORT_FORWARD_OWNER_BINDING_INVALID: PF ownership records require HOST=127.0.0.1' + PORT_FORWARD_OWNER_LOADED=true +} + +load_context_identity() { + [[ "${CONTEXT_IDENTITY_LOADED}" == true ]] && return 0 + require_command kubectl python3 + if [[ ! -f "${CONTEXT_IDENTITY_SCRIPT}" || ! -r "${CONTEXT_IDENTITY_SCRIPT}" || -L "${CONTEXT_IDENTITY_SCRIPT}" ]]; then + die "CONTEXT_IDENTITY_UNAVAILABLE: refusing to address a cluster without ${CONTEXT_IDENTITY_SCRIPT}" + fi + + # The predicates the T2 lane applies in t2_context_check and + # t2_profile_context_identity_check (scripts/minikube/t2-common.sh). + # shellcheck source=/dev/null + . "${CONTEXT_IDENTITY_SCRIPT}" + local function_name + for function_name in kube_endpoint_host kube_endpoint_is_local minikube_nodes_identify_profile \ + minikube_profile_list_names; do + declare -F "${function_name}" >/dev/null || + die "CONTEXT_IDENTITY_API_INVALID: missing ${function_name} in ${CONTEXT_IDENTITY_SCRIPT}" + done + CONTEXT_IDENTITY_LOADED=true +} + +# kube_context_server : prints the API server of the kubeconfig +# context named , or nothing when there is no such context. It reads +# the kubeconfig only and never contacts a cluster. Each refusal names its own +# cause: an unreadable kubeconfig is BRANCH_PROFILE_KUBECONFIG_UNREADABLE, and +# a context whose cluster kubectl cannot resolve (`view --minify` fails when +# the context names a cluster or user the kubeconfig does not define) or whose +# cluster has no server is BRANCH_PROFILE_CONTEXT_DANGLING. Neither is a +# remote endpoint; BRANCH_PROFILE_REMOTE_CONTEXT is left to the endpoint check. +kube_context_server() { + local context="$1" contexts server + contexts="$(kubectl config get-contexts -o name)" || + die 'BRANCH_PROFILE_KUBECONFIG_UNREADABLE: unable to read the kubeconfig contexts; refusing to run minikube or kubectl until kubectl can read the kubeconfig' + grep -Fqx -- "${context}" <<<"${contexts}" || return 0 + server="$(kubectl config view --raw --minify "--context=${context}" -o 'jsonpath={.clusters[0].cluster.server}')" || + die "BRANCH_PROFILE_CONTEXT_DANGLING: kube context ${context} is in the kubeconfig, but kubectl could not resolve its cluster (the cluster or user it names is not defined); refusing to run minikube or kubectl against it. It may belong to another session: stop and ask before renaming or removing it" + [[ -n "${server}" ]] || + die "BRANCH_PROFILE_CONTEXT_DANGLING: kube context ${context} has no API server in the kubeconfig; refusing to run minikube or kubectl against it. It may belong to another session: stop and ask before renaming or removing it" + printf '%s' "${server}" +} + +# minikube -p and kubectl --context address a cluster by context name alone, and +# `minikube -p

delete` on a profile minikube does not know removes the +# kubeconfig context of that name. So a context named after the profile must +# point at this machine before either runs. A missing context is allowed: +# minikube start creates it, and kubectl cannot reach a cluster through it. +require_local_context_endpoint() { + load_context_identity + local server + server="$(kube_context_server "${PROFILE}")" || exit 1 + [[ -n "${server}" ]] || return 0 + kube_endpoint_is_local "${server}" || + die "BRANCH_PROFILE_REMOTE_CONTEXT: kube context ${PROFILE} points at a non-local API server ($(kube_endpoint_host "${server}")); refusing to run minikube or kubectl against it. That context may belong to a cluster in use: stop and ask before renaming or removing it" +} + +# A local endpoint alone admits another local cluster, or one on a private +# address, whose context carries the profile's name. For a profile minikube +# does not know, `minikube -p

delete` removes that context and +# `minikube start -p

` creates the profile and rewrites it. The node +# identity check below cannot guard these: stop and delete address a profile +# that may not answer, start only reaches it after minikube start, and setup +# hands the profile to full-setup.sh. So when a context named after the +# profile exists, minikube must list the profile (valid or invalid) before +# start, setup, stop or delete touch Docker, the profile state or minikube. An +# unreadable profile list is a refusal, never an empty one. A missing context +# is allowed for the reason given above require_local_context_endpoint: a +# brand-new profile has none, and start creates it. +require_context_profile_known_to_minikube() { + load_context_identity + local server profiles status=0 + server="$(kube_context_server "${PROFILE}")" || exit 1 + [[ -n "${server}" ]] || return 0 + profiles="$(run_bounded minikube-profile-list "${MINIKUBE_STATUS_TIMEOUT_SECONDS}" \ + minikube profile list -o json)" || + die "BRANCH_PROFILE_MINIKUBE_PROFILES_UNREADABLE: minikube profile list failed; refusing to run minikube -p ${PROFILE} while kube context ${PROFILE} exists" + minikube_profile_list_names "${profiles}" "${PROFILE}" || status=$? + case "${status}" in + 0) ;; + 1) die "BRANCH_PROFILE_UNKNOWN_MINIKUBE_PROFILE: minikube lists no profile ${PROFILE}, but kube context ${PROFILE} exists and points at $(kube_endpoint_host "${server}"); refusing to run minikube -p ${PROFILE}, which would act on that context. It may belong to another local cluster: stop and ask before renaming or removing it" ;; + *) die "BRANCH_PROFILE_MINIKUBE_PROFILES_UNREADABLE: minikube profile list printed no readable profile lists; refusing to run minikube -p ${PROFILE} while kube context ${PROFILE} exists" ;; + esac +} + +# A local endpoint is necessary but not sufficient: the context may name +# another local Minikube profile, or a LAN cluster on a private address. The +# cluster must have a node labelled minikube.k8s.io/name= at the IP +# `minikube -p ip` reports. +profile_cluster_identity() { + local allow_unobservable="${1:-false}" expected_ip nodes_json + load_context_identity + if ! expected_ip="$(run_bounded minikube-ip "${MINIKUBE_STATUS_TIMEOUT_SECONDS}" minikube -p "${PROFILE}" ip)"; then + if [[ "${allow_unobservable}" == true ]]; then + printf 'WARN: cluster identity is unobservable for profile %s: minikube did not report an IP; continuing recovery\n' \ + "${PROFILE}" >&2 + return 2 + fi + die "BRANCH_PROFILE_CONTEXT_IDENTITY: minikube did not report an IP for profile ${PROFILE}" + fi + if ! nodes_json="$(kubectl "--context=${PROFILE}" "--request-timeout=${KUBECTL_REQUEST_TIMEOUT}" get nodes -o json)"; then + if [[ "${allow_unobservable}" == true ]]; then + printf 'WARN: cluster identity is unobservable for profile %s: unable to read its nodes; continuing recovery\n' \ + "${PROFILE}" >&2 + return 2 + fi + die "BRANCH_PROFILE_CONTEXT_IDENTITY: unable to read the nodes of kube context ${PROFILE}" + fi + minikube_nodes_identify_profile "${nodes_json}" "${PROFILE}" "${expected_ip}" || + die "BRANCH_PROFILE_CONTEXT_IDENTITY: kube context ${PROFILE} does not identify Minikube profile ${PROFILE} at ${expected_ip:-}; refusing to use it" +} + +require_profile_cluster_identity() { + profile_cluster_identity false +} + +# Stop and delete are recovery actions for a profile minikube already lists. +# A readable node identity that names another profile still refuses, but an +# IP or node observation that fails within its deadline is unknown, not foreign. +refuse_readable_foreign_profile_cluster_identity() { + local status=0 + profile_cluster_identity true || status=$? + (( status == 2 )) || return "${status}" +} + +probe_service() { + local namespace="$1" service="$2" output + if ! output="$(kubectl "--context=${PROFILE}" "--request-timeout=${KUBECTL_REQUEST_TIMEOUT}" \ + -n "${namespace}" get svc "${service}" --ignore-not-found -o name)"; then + printf 'ERROR: unable to inspect service %s/%s\n' \ + "${namespace}" "${service}" >&2 + return 1 + fi + [[ -n "${output}" ]] || return 3 +} + +select_mcp_service() { + local status=0 + probe_service mcp-host chatllm || status=$? + if (( status == 0 )); then + printf 'chatllm\n' + return 0 + fi + (( status == 3 )) || return "${status}" + status=0 + probe_service mcp-host mcp-host || status=$? + if (( status == 0 )); then + printf 'mcp-host\n' + return 0 + fi + (( status == 3 )) || return "${status}" + return 3 +} + +# Sets STOP_PF_OUTCOME to absent, stopped (the library verified and stopped or +# cleared the record) or retired (PID reuse proven; nothing signalled). +stop_own_pf() { + local name="$1" namespace="$2" service="$3" local_port="$4" remote_port="$5" + local pidfile="${PIDS_DIR}/${name}.pid" state actual_start + STOP_PF_OUTCOME="" + if [[ ! -e "${pidfile}" && ! -L "${pidfile}" ]]; then + STOP_PF_OUTCOME=absent + return 0 + fi + if pf_owner_cleanup_record "${pidfile}" "${PROFILE}" "${PROFILE}" "${REPO_DIR}" \ + "${namespace}" "${service}" "${local_port}" "${remote_port}"; then + STOP_PF_OUTCOME=stopped + return 0 + fi + # PID reuse is the only case retired here: the record is this worktree's own + # binding, and its PID now names a live process that started at a different + # time, so the recorded kubectl no longer exists. That process is never + # signalled; only the stale record is dropped. A foreign binding, an + # unreadable record, or a live process whose start still matches the record + # is kept, because nothing proves it stale. + if pf_owner_read_record "${pidfile}" 2>/dev/null && + pf_owner_record_matches "${PROFILE}" "${PROFILE}" "${REPO_DIR}" \ + "${namespace}" "${service}" "${local_port}" "${remote_port}"; then + state="$(pf_owner_process_state "${PF_OWNER_RECORD_PID}")" + if [[ "${state}" == live ]] && + actual_start="$(pf_owner_process_start "${PF_OWNER_RECORD_PID}")" && + [[ "${actual_start}" != "${PF_OWNER_RECORD_START}" ]]; then + printf 'retiring %s record: pid=%s now belongs to a process started %s, not the recorded kubectl started %s; that process was not signalled\n' \ + "${name}" "${PF_OWNER_RECORD_PID}" "${actual_start}" "${PF_OWNER_RECORD_START}" + pf_owner_remove_dead_record "${pidfile}" || return 1 + STOP_PF_OUTCOME=retired + return 0 + fi + fi + printf 'ERROR: kept port-forward record %s: it could not be verified as this worktree'"'"'s kubectl forward (reason above) and no process was signalled. Confirm its PID is not a port-forward you still need, then remove the file by hand.\n' \ + "${pidfile}" >&2 + return 1 +} + +port_forward_record_is_live() { + local pidfile="$1" namespace="$2" service="$3" local_port="$4" remote_port="$5" + local state actual_start command_line + pf_owner_read_record "${pidfile}" || return 1 + pf_owner_record_matches "${PROFILE}" "${PROFILE}" "${REPO_DIR}" \ + "${namespace}" "${service}" "${local_port}" "${remote_port}" || return 1 + state="$(pf_owner_process_state "${PF_OWNER_RECORD_PID}")" + [[ "${state}" == live ]] || return 1 + actual_start="$(pf_owner_process_start "${PF_OWNER_RECORD_PID}")" || return 1 + [[ "${actual_start}" == "${PF_OWNER_RECORD_START}" ]] || return 1 + command_line="$(pf_owner_process_command "${PF_OWNER_RECORD_PID}")" || return 1 + pf_owner_command_matches "${command_line}" "${PROFILE}" "${namespace}" \ + "${service}" "${local_port}" "${remote_port}" || return 1 + actual_start="$(pf_owner_process_start "${PF_OWNER_RECORD_PID}")" || return 1 + [[ "${actual_start}" == "${PF_OWNER_RECORD_START}" ]] +} + +start_pf() { + local name="$1" + local namespace="$2" + local service="$3" + local local_port="$4" + local remote_port="$5" + local required="$6" + local log_file="${LOGS_DIR}/${name}.log" + local pid_file="${PIDS_DIR}/${name}.pid" + local service_status=0 pid + + probe_service "${namespace}" "${service}" || service_status=$? + if (( service_status == 3 )); then + if [[ "${required}" == true ]]; then + printf 'FAIL %-32s required service absent (%s/%s)\n' "${name}" "${namespace}" "${service}" >&2 + return 1 + fi + printf 'SKIP %-32s optional service absent (%s/%s)\n' "${name}" "${namespace}" "${service}" + return 0 + fi + (( service_status == 0 )) || return "${service_status}" + + # start_pf runs under `|| failed++` in cmd_pf, where set -e is suspended, so + # every step that can fail returns explicitly instead of relying on errexit. + stop_own_pf "${name}" "${namespace}" "${service}" "${local_port}" "${remote_port}" || return 1 + check_port_free "${name}" "${local_port}" || return 1 + nohup kubectl "--context=${PROFILE}" -n "${namespace}" port-forward --address=127.0.0.1 "svc/${service}" "${local_port}:${remote_port}" >"${log_file}" 2>&1 &2 + pf_owner_abort_child "${pid}" "${PROFILE}" "${namespace}" "${service}" \ + "${local_port}" "${remote_port}" || true + return 1 + fi + if ! port_forward_record_is_live "${pid_file}" "${namespace}" "${service}" \ + "${local_port}" "${remote_port}"; then + pf_owner_cleanup_record "${pid_file}" "${PROFILE}" "${PROFILE}" "${REPO_DIR}" \ + "${namespace}" "${service}" "${local_port}" "${remote_port}" || true + printf 'ERROR: %s port-forward did not remain live with verified ownership\n' "${name}" >&2 + return 1 + fi + printf 'PF %-32s pid=%s %s:%s -> %s/%s:%s\n' "${name}" "${pid}" "${HOST}" "${local_port}" "${namespace}" "${service}" "${remote_port}" +} + +check_health() { + local name="$1" + local namespace="$2" + local service="$3" + local pid_name="$4" + local url="$5" + local required="$6" + local local_port="$7" + local remote_port="$8" + local pidfile="${PIDS_DIR}/${pid_name}.pid" + local service_status=0 + + probe_service "${namespace}" "${service}" || service_status=$? + if (( service_status == 3 )); then + if [[ "${required}" == true ]]; then + printf 'FAIL %-32s required service absent (%s/%s)\n' "${name}" "${namespace}" "${service}" >&2 + return 1 + fi + printf 'SKIP %-32s optional service absent (%s/%s)\n' "${name}" "${namespace}" "${service}" + return 0 + fi + (( service_status == 0 )) || return "${service_status}" + + if [[ ! -f "${pidfile}" || -L "${pidfile}" ]] || + ! port_forward_record_is_live "${pidfile}" "${namespace}" "${service}" \ + "${local_port}" "${remote_port}"; then + printf 'FAIL %-32s missing or unverified forward (%s)\n' "${name}" "${pidfile}" >&2 + return 1 + fi + + if curl -sf -m 5 "${url}" >/dev/null 2>&1; then + printf 'OK %-32s %s\n' "${name}" "${url}" + else + printf 'FAIL %-32s %s\n' "${name}" "${url}" >&2 + return 1 + fi +} + +cmd_info() { + print_summary + printf '\nnext local checks:\n' + printf ' make -f scripts/minikube-profiles/branch.mk branch-profile-preflight\n' + printf ' make -f scripts/minikube-profiles/branch.mk branch-profile-start\n' + printf ' make -f scripts/minikube-profiles/branch.mk branch-profile-status\n' +} + +cmd_resolve() { + printf 'PROFILE=%s\n' "${PROFILE}" + printf 'PROFILE_EXISTS=%s\n' "${PROFILE_EXISTS}" + printf 'PROFILE_STATE=%s\n' "${PROFILE_STATE}" + printf 'PROFILE_SCHEMA_VERSION=%s\n' "${PROFILE_SCHEMA_VERSION}" + printf 'WORKTREE_ID=%s\n' "${WORKTREE_ID}" + printf 'OWNER_ID=%s\n' "${OWNER_ID}" + printf 'CREATED_HEAD=%s\n' "${CREATED_HEAD}" + printf 'REPO_DIR=%s\n' "${REPO_DIR}" + printf 'BRANCH=%s\n' "${BRANCH}" + printf 'PROFILE_ENV=%s\n' "${PROFILE_ENV}" + printf 'PORTS_ENV=%s\n' "${PORTS_ENV}" +} + +cmd_preflight() { + require_command git minikube kubectl helm shasum + check_docker_ready + check_all_ports_free + persist_state + print_summary + printf '\npreflight: OK\n' + printf 'state written:\n' + printf ' %s\n' "${PROFILE_ENV}" + printf ' %s\n' "${PORTS_ENV}" +} + +# perl -0pi exits 0 when its pattern matches nothing, so an upstream edit to a +# shimmed script would leave the shim un-rewritten and the profile bound to the +# wrong paths or context with no error. Fail unless the rewrite changed the file. +rewrite_shim() { + local file="$1" expr="$2" label="$3" + local before after + before="$(shasum "${file}")" || die "BRANCH_PROFILE_SHIM_REWRITE_FAILED: ${label}" + perl -0pi -e "${expr}" "${file}" || die "BRANCH_PROFILE_SHIM_REWRITE_FAILED: ${label}" + after="$(shasum "${file}")" || die "BRANCH_PROFILE_SHIM_REWRITE_FAILED: ${label}" + [[ "${before}" != "${after}" ]] || + die "BRANCH_PROFILE_SHIM_REWRITE_NOOP: ${label} matched nothing; the upstream script changed" +} + +cmd_prepare_shims() { + require_command git shasum perl + persist_state + # rm -rf and cp -R below resolve every parent component, so a symlinked + # scripts/ directory would redirect the delete and the copy outside the + # profile. CACHE_DIR itself was verified as a real directory by persist_state. + local shims_parent="${CACHE_DIR}/scripts" + if [[ -L "${shims_parent}" || -L "${SHIMS_DIR}" || -L "${DEPLOY_SHIM_DIR}" || -L "${SHIM_ENV}" ]]; then + die "BRANCH_PROFILE_SHIM_SYMLINK: refusing symlinked shim paths under ${CACHE_DIR}" + fi + mkdir -p "${shims_parent}" + [[ -d "${shims_parent}" && ! -L "${shims_parent}" && + "$(cd -- "${shims_parent}" && pwd -P)" == "${CACHE_DIR}/scripts" ]] || + die "BRANCH_PROFILE_SHIM_SYMLINK: ${shims_parent} does not resolve inside ${CACHE_DIR}" + umask 077 + rm -rf "${SHIMS_DIR}" "${DEPLOY_SHIM_DIR}" + cp -R "${REPO_DIR}/scripts/minikube" "${SHIMS_DIR}" + cp -R "${REPO_DIR}/deploy" "${DEPLOY_SHIM_DIR}" + + local file + while IFS= read -r file; do + perl -0pi -e 's#PROJECT_DIR="\$\(cd "\$SCRIPT_DIR/\.\./\.\." && pwd\)"#PROJECT_DIR="\${CLERUM_PROJECT_DIR:-\$(cd \"\$SCRIPT_DIR/../..\" && pwd)}"#g; s#PROJECT_DIR="\$\(cd "\$\{SCRIPT_DIR\}/\.\./\.\." && pwd\)"#PROJECT_DIR="\${CLERUM_PROJECT_DIR:-\$(cd \"\${SCRIPT_DIR}/../..\" && pwd)}"#g; s#PROFILE="clerum-test"#PROFILE="\${MINIKUBE_PROFILE:-clerum-test}"#g; s#--context=clerum-test#--context=\${PROFILE}#g' "${file}" || + die "BRANCH_PROFILE_SHIM_REWRITE_FAILED: ${file}" + done < <(find "${SHIMS_DIR}" -type f -name '*.sh' | sort) + + rewrite_shim "${SHIMS_DIR}/generate-keys.sh" 's#OUTPUT="\$\{PROJECT_DIR\}/deploy/minikube/secrets/jwt-signing-keys.yaml"#OUTPUT="\${BRANCH_PROFILE_DEPLOY_DIR:-\${PROJECT_DIR}/deploy}/minikube/secrets/jwt-signing-keys.yaml"#g' "generate-keys.sh" + rewrite_shim "${SHIMS_DIR}/seed-test-data.sh" 's#REPO_ROOT="\$\(cd "\$\{SCRIPT_DIR\}/\.\./\.\." && pwd\)"#REPO_ROOT="\${CLERUM_PROJECT_DIR:-\$(cd \"\${SCRIPT_DIR}/../..\" && pwd)}"#g' "seed-test-data.sh REPO_ROOT" + # One call per substitution, so an upstream edit that stops matching one of them + # fails on its own instead of hiding behind the other. The earlier combined + # expression carried two alternatives that matched nothing in the current upstream. + rewrite_shim "${SHIMS_DIR}/seed-test-data.sh" 's#: "\$\{CONTEXT:=clerum-test\}"#: "\${CONTEXT:=\${MINIKUBE_PROFILE:-clerum-test}}"#g' "seed-test-data.sh CONTEXT default" + # The seed authorizes only the context it runs against. Default the allowlist + # to CONTEXT only when CONTEXT is this validated branch profile, baked in as a + # literal; any other CONTEXT (clerum, clerum-dev, ...) must not authorize + # itself. PROFILE passed validate_profile_name, so it holds no perl or shell + # metacharacters. + local allowlist_line allowlist_expr + allowlist_line='if [ "\$CONTEXT" = "'"${PROFILE}"'" ]; then : "\${ALLOWED_CONTEXTS:=\$CONTEXT}"; fi' + printf -v allowlist_expr 's#\nexport ADMIN_PASSWORD E2E_DEV_LOGIN_EMAIL CONTEXT ALLOWED_CONTEXTS #\n%s\nexport ADMIN_PASSWORD E2E_DEV_LOGIN_EMAIL CONTEXT ALLOWED_CONTEXTS #g' \ + "${allowlist_line}" + rewrite_shim "${SHIMS_DIR}/seed-test-data.sh" "${allowlist_expr}" "seed-test-data.sh ALLOWED_CONTEXTS" + rewrite_shim "${SHIMS_DIR}/build-images.sh" 's#MANIFEST_FILE="\$\{PROJECT_DIR\}/deploy/minikube/\.image-manifest.json"#MANIFEST_FILE="\${BRANCH_PROFILE_DEPLOY_DIR:-\${PROJECT_DIR}/deploy}/minikube/.image-manifest.json"#g' "build-images.sh" + # full-setup.sh resolves BRANCH_PROFILE_DEPLOY_DIR natively (ACTIVE_MINIKUBE_*), + # so it needs no path rewrite; rewrite_shim would fail on it as a no-op. + + chmod +x "${SHIMS_DIR}"/*.sh + cat >"${SHIM_ENV}" </dev/null 2>&1; then + printf 'ERROR: local shims still contain hardcoded clerum-test references\n' >&2 + grep -R -n 'PROFILE="clerum-test"\|--context=clerum-test' "${SHIMS_DIR}" >&2 || true + exit 1 + fi + + printf 'local shims prepared for profile: %s\n' "${PROFILE}" + printf 'scripts: %s\n' "${SHIMS_DIR}" + printf 'deploy copy: %s\n' "${DEPLOY_SHIM_DIR}" + printf 'env: %s\n' "${SHIM_ENV}" +} + +# restore_global_context: undoes a move of the global kubectl current-context +# onto ${PROFILE} made by minikube start, back to START_BEFORE_CONTEXT (or +# unset, when none was set). A context other than the profile was chosen by +# another session meanwhile and is left alone. It runs at most once per start +# (START_CONTEXT_RESTORE_PENDING), so the EXIT trap does not repeat a restore +# the success path already made. A failed restore prints its error and returns +# 1; the caller decides the exit status. +restore_global_context() { + [[ "${START_CONTEXT_RESTORE_PENDING}" == true ]] || return 0 + START_CONTEXT_RESTORE_PENDING=false + local after_context + after_context="$(kubectl config current-context 2>/dev/null || true)" + [[ "${after_context}" == "${PROFILE}" && "${START_BEFORE_CONTEXT}" != "${PROFILE}" ]] || return 0 + printf 'WARN: minikube moved the kubectl current-context from %s to %s despite --keep-context (its kubeconfig endpoint repair ignores the flag); restoring it\n' \ + "${START_BEFORE_CONTEXT:-}" "${after_context}" >&2 + if [[ -n "${START_BEFORE_CONTEXT}" ]]; then + if ! kubectl config use-context "${START_BEFORE_CONTEXT}" >/dev/null; then + printf 'ERROR: could not restore the kubectl current-context to %s; it is still %s\n' \ + "${START_BEFORE_CONTEXT}" "${after_context}" >&2 + return 1 + fi + printf 'restored the kubectl current-context to %s\n' "${START_BEFORE_CONTEXT}" >&2 + else + if ! kubectl config unset current-context >/dev/null; then + printf 'ERROR: could not clear the kubectl current-context minikube set; it is still %s\n' \ + "${after_context}" >&2 + return 1 + fi + printf 'cleared the kubectl current-context minikube set; none was set before the start\n' >&2 + fi +} + +# EXIT trap around minikube start. A failed restore turns an exit status of 0 +# into 1, and never replaces a non-zero status (the failed start's own code). +restore_global_context_on_exit() { + local status=$? + if ! restore_global_context && (( status == 0 )); then + status=1 + fi + exit "${status}" +} + +cmd_start() { + require_local_context_endpoint + require_context_profile_known_to_minikube + check_docker_ready + check_all_ports_free + persist_state + # Every call this script makes names --context=${PROFILE} and verifies the + # cluster identity through it, so the global current-context never steers + # it. That context is shared with every other session on this machine, + # though, and minikube v1.38.1 moves it onto the profile on a restart despite + # --keep-context: UpdateEndpoint repairs the kubeconfig entry (the docker + # driver publishes the API server on a new host port) with KeepContext false. + # Put the previous context back so no other session is steered onto this + # cluster; a context another session chose meanwhile is left alone. A start + # that fails or is interrupted may have moved it too, so the restore is armed + # in an EXIT trap for the duration of minikube start (INT and TERM exit + # through it) and disarmed once it has run. + START_BEFORE_CONTEXT="$(kubectl config current-context 2>/dev/null || true)" + START_CONTEXT_RESTORE_PENDING=true + trap restore_global_context_on_exit EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + printf 'starting minikube profile: %s\n' "${PROFILE}" + run_bounded minikube-start "${MINIKUBE_START_TIMEOUT_SECONDS}" minikube start \ + -p "${PROFILE}" \ + --keep-context \ + --memory="${MINIKUBE_MEMORY}" \ + --cpus="${MINIKUBE_CPUS}" \ + --cni="${MINIKUBE_CNI}" \ + --driver="${MINIKUBE_DRIVER}" + restore_global_context || exit 1 + trap - EXIT INT TERM + run_bounded minikube-status "${MINIKUBE_STATUS_TIMEOUT_SECONDS}" \ + minikube -p "${PROFILE}" status + require_profile_cluster_identity + kubectl "--context=${PROFILE}" "--request-timeout=${KUBECTL_REQUEST_TIMEOUT}" cluster-info + kubectl "--context=${PROFILE}" "--request-timeout=${KUBECTL_REQUEST_TIMEOUT}" get nodes +} + +cmd_status() { + require_existing_profile + require_local_context_endpoint + printf 'profile: %s\n\n' "${PROFILE}" + # `minikube status` exits non-zero for a stopped cluster. Report that state + # and the reachability line, then return the status code so a stopped + # profile is still visible to callers. + local minikube_status=0 + run_bounded minikube-status "${MINIKUBE_STATUS_TIMEOUT_SECONDS}" \ + minikube -p "${PROFILE}" status || minikube_status=$? + printf '\n' + if cluster_reachable; then + require_profile_cluster_identity + kubectl "--context=${PROFILE}" "--request-timeout=${KUBECTL_REQUEST_TIMEOUT}" get nodes + printf '\n' + kubectl "--context=${PROFILE}" "--request-timeout=${KUBECTL_REQUEST_TIMEOUT}" get deploy -A + printf '\n' + kubectl "--context=${PROFILE}" "--request-timeout=${KUBECTL_REQUEST_TIMEOUT}" get sts -A + else + printf 'cluster not reachable for context %s\n' "${PROFILE}" + (( minikube_status != 0 )) || minikube_status=1 + fi + return "${minikube_status}" +} + +cmd_pf() { + require_existing_profile + load_port_forward_owner + require_local_context_endpoint + if ! cluster_reachable; then + printf 'ERROR: cluster not reachable for context %s\n' "${PROFILE}" >&2 + exit 1 + fi + require_profile_cluster_identity + persist_state + + # Start every forward before judging the set: stopping at the first failure + # hides which of the others were healthy and leaves a partial hold behind + # with no summary. Each failure is counted and the command fails at the end. + local failed=0 + start_pf control-ui control-plane control-ui "${CONTROL_UI_PORT}" 3000 true || failed=$((failed + 1)) + start_pf profile-ui profiles profile-ui "${PROFILE_UI_PORT}" 3001 false || failed=$((failed + 1)) + start_pf control-api control-plane control-api "${CONTROL_API_PORT}" 8090 true || failed=$((failed + 1)) + start_pf external-rest-api profiles external-rest-api "${EXTERNAL_REST_API_PORT}" 8091 true || failed=$((failed + 1)) + start_pf member-registration-service profiles member-registration-service "${MEMBER_REGISTRATION_SERVICE_PORT}" 8092 false || failed=$((failed + 1)) + start_pf rpc-proxy rpc-proxy rpc-proxy "${RPC_PROXY_PORT}" 8094 true || failed=$((failed + 1)) + start_pf registry-api registry registry-api "${REGISTRY_API_PORT}" 8085 false || failed=$((failed + 1)) + start_pf workflow-approval-reader channels workflow-approval-request-reader "${WORKFLOW_APPROVAL_READER_PORT}" 8098 false || failed=$((failed + 1)) + local mcp_service mcp_status=0 + mcp_service="$(select_mcp_service)" || mcp_status=$? + if (( mcp_status == 3 )); then + printf 'SKIP %-32s optional service absent (mcp-host/chatllm or mcp-host/mcp-host)\n' mcp-host + elif (( mcp_status == 0 )); then + start_pf mcp-host mcp-host "${mcp_service}" "${MCP_HOST_PORT}" 8080 false || failed=$((failed + 1)) + else + printf 'ERROR: unable to select the mcp-host service (status %s)\n' "${mcp_status}" >&2 + failed=$((failed + 1)) + fi + printf '\nlogs: %s\n' "${LOGS_DIR}" + printf 'pids: %s\n' "${PIDS_DIR}" + if (( failed > 0 )); then + printf 'ERROR: %s port-forward(s) failed to start\n' "${failed}" >&2 + return 1 + fi +} + +cmd_health() { + require_existing_profile + load_port_forward_owner + require_local_context_endpoint + if ! cluster_reachable; then + printf 'ERROR: cluster not reachable for context %s\n' "${PROFILE}" >&2 + exit 1 + fi + require_profile_cluster_identity + + # Probe every service so one dead forward does not hide the state of the + # rest; the command still fails when any probe failed. + local failed=0 + check_health control-ui control-plane control-ui control-ui "${CONTROL_UI_URL}" true "${CONTROL_UI_PORT}" 3000 || failed=$((failed + 1)) + check_health profile-ui profiles profile-ui profile-ui "${PROFILE_UI_URL}" false "${PROFILE_UI_PORT}" 3001 || failed=$((failed + 1)) + check_health control-api control-plane control-api control-api "${CONTROL_API_URL%/}/health" true "${CONTROL_API_PORT}" 8090 || failed=$((failed + 1)) + check_health external-rest-api profiles external-rest-api external-rest-api "${EXTERNAL_REST_API_URL%/}/health" true "${EXTERNAL_REST_API_PORT}" 8091 || failed=$((failed + 1)) + check_health member-registration-service profiles member-registration-service member-registration-service "${MEMBER_REGISTRATION_SERVICE_URL%/}/health" false "${MEMBER_REGISTRATION_SERVICE_PORT}" 8092 || failed=$((failed + 1)) + check_health rpc-proxy rpc-proxy rpc-proxy rpc-proxy "${RPC_PROXY_URL%/}/health" true "${RPC_PROXY_PORT}" 8094 || failed=$((failed + 1)) + check_health registry-api registry registry-api registry-api "${REGISTRY_API_URL%/}/health" false "${REGISTRY_API_PORT}" 8085 || failed=$((failed + 1)) + check_health workflow-approval-reader channels workflow-approval-request-reader workflow-approval-reader "${WORKFLOW_APPROVAL_READER_URL%/}/health" false "${WORKFLOW_APPROVAL_READER_PORT}" 8098 || failed=$((failed + 1)) + local mcp_service mcp_status=0 + mcp_service="$(select_mcp_service)" || mcp_status=$? + if (( mcp_status == 3 )); then + printf 'SKIP %-32s optional service absent (mcp-host/chatllm or mcp-host/mcp-host)\n' mcp-host + elif (( mcp_status == 0 )); then + check_health mcp-host mcp-host "${mcp_service}" mcp-host \ + "${MCP_HOST_URL%/}/v1/runtime/health" false "${MCP_HOST_PORT}" 8080 || failed=$((failed + 1)) + else + printf 'ERROR: unable to select the mcp-host service (status %s)\n' "${mcp_status}" >&2 + failed=$((failed + 1)) + fi + if (( failed > 0 )); then + printf 'ERROR: %s health check(s) failed\n' "${failed}" >&2 + return 1 + fi +} + +cmd_pf_health() { + trap cmd_stop_pf EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + cmd_pf + sleep 2 + cmd_health +} + +resolve_stop_pf_binding() { + local name="$1" pidfile="$2" + case "${name}" in + control-ui) + STOP_PF_NAMESPACE=control-plane + STOP_PF_SERVICE=control-ui + STOP_PF_LOCAL_PORT="${CONTROL_UI_PORT}" + STOP_PF_REMOTE_PORT=3000 + ;; + profile-ui) + STOP_PF_NAMESPACE=profiles + STOP_PF_SERVICE=profile-ui + STOP_PF_LOCAL_PORT="${PROFILE_UI_PORT}" + STOP_PF_REMOTE_PORT=3001 + ;; + control-api) + STOP_PF_NAMESPACE=control-plane + STOP_PF_SERVICE=control-api + STOP_PF_LOCAL_PORT="${CONTROL_API_PORT}" + STOP_PF_REMOTE_PORT=8090 + ;; + external-rest-api) + STOP_PF_NAMESPACE=profiles + STOP_PF_SERVICE=external-rest-api + STOP_PF_LOCAL_PORT="${EXTERNAL_REST_API_PORT}" + STOP_PF_REMOTE_PORT=8091 + ;; + member-registration-service) + STOP_PF_NAMESPACE=profiles + STOP_PF_SERVICE=member-registration-service + STOP_PF_LOCAL_PORT="${MEMBER_REGISTRATION_SERVICE_PORT}" + STOP_PF_REMOTE_PORT=8092 + ;; + rpc-proxy) + STOP_PF_NAMESPACE=rpc-proxy + STOP_PF_SERVICE=rpc-proxy + STOP_PF_LOCAL_PORT="${RPC_PROXY_PORT}" + STOP_PF_REMOTE_PORT=8094 + ;; + registry-api) + STOP_PF_NAMESPACE=registry + STOP_PF_SERVICE=registry-api + STOP_PF_LOCAL_PORT="${REGISTRY_API_PORT}" + STOP_PF_REMOTE_PORT=8085 + ;; + workflow-approval-reader) + STOP_PF_NAMESPACE=channels + STOP_PF_SERVICE=workflow-approval-request-reader + STOP_PF_LOCAL_PORT="${WORKFLOW_APPROVAL_READER_PORT}" + STOP_PF_REMOTE_PORT=8098 + ;; + mcp-host) + pf_owner_read_record "${pidfile}" || { + printf 'ERROR: refusing MCP PID record without exact ownership fields: %s\n' "${pidfile}" >&2 + return 1 + } + case "${PF_OWNER_RECORD_SERVICE}" in + chatllm|mcp-host) STOP_PF_SERVICE="${PF_OWNER_RECORD_SERVICE}" ;; + *) + printf 'ERROR: refusing unknown MCP service binding in %s\n' "${pidfile}" >&2 + return 1 + ;; + esac + STOP_PF_NAMESPACE=mcp-host + STOP_PF_LOCAL_PORT="${MCP_HOST_PORT}" + STOP_PF_REMOTE_PORT=8080 + ;; + *) + printf 'ERROR: refusing unknown port-forward pidfile name: %s\n' "${name}" >&2 + return 1 + ;; + esac +} + +cmd_stop_pf() { + require_existing_profile + load_port_forward_owner + [[ ! -L "${PIDS_DIR}" ]] || + die "refusing symlinked port-forward PID directory: ${PIDS_DIR}" + if [[ ! -d "${PIDS_DIR}" ]]; then + printf 'No runner pid dir found: %s\n' "${PIDS_DIR}" + return 0 + fi + shopt -s nullglob + local pidfiles=("${PIDS_DIR}"/*.pid) + if (( ${#pidfiles[@]} == 0 )); then + printf 'No runner port-forwards found in %s\n' "${PIDS_DIR}" + return 0 + fi + local pidfile name + # Attempt every record, so one record that cannot be verified (an unknown + # name, an unreadable MCP binding, or a record stop_own_pf cannot prove is + # this worktree's) does not leave the verified ones running; the kept + # records are listed and fail the call. + local -a kept=() + for pidfile in "${pidfiles[@]}"; do + name="$(basename "${pidfile}" .pid)" + if ! resolve_stop_pf_binding "${name}" "${pidfile}"; then + kept+=("${pidfile}") + continue + fi + if ! stop_own_pf "${name}" "${STOP_PF_NAMESPACE}" "${STOP_PF_SERVICE}" \ + "${STOP_PF_LOCAL_PORT}" "${STOP_PF_REMOTE_PORT}"; then + kept+=("${pidfile}") + continue + fi + case "${STOP_PF_OUTCOME}" in + stopped | absent) printf 'stopped or cleared verified %s record\n' "${name}" ;; + retired) printf 'retired stale %s record after PID reuse\n' "${name}" ;; + *) + printf 'ERROR: unexpected stop outcome for %s: %s\n' "${pidfile}" "${STOP_PF_OUTCOME:-}" >&2 + kept+=("${pidfile}") + ;; + esac + done + if (( ${#kept[@]} > 0 )); then + printf 'ERROR: %s port-forward record(s) kept:\n' "${#kept[@]}" >&2 + printf ' %s\n' "${kept[@]}" >&2 + return 1 + fi +} + +# Runs cmd_stop_pf in a subshell so that a refusal inside it (including a die) +# is reported without preventing the minikube operation that follows. +stop_pf_before_minikube() { + local status=0 + ( cmd_stop_pf ) || status=$? + return "${status}" +} + +report_kept_pf_records() { + local operation="$1" + printf 'ERROR: minikube %s ran for profile %s, but port-forward records listed above were kept; the next T2 preflight will refuse them with PORT_FORWARD_CONFLICT until they are removed\n' \ + "${operation}" "${PROFILE}" >&2 +} + +cmd_stop() { + require_existing_profile + require_local_context_endpoint + require_context_profile_known_to_minikube + # minikube listing the profile does not prove the context reaches its + # cluster. A cluster that answers with another readable identity refuses + # before any record is cleared or minikube -p runs. A stopped profile, or + # one whose identity cannot be observed within its deadline, can still be + # recovered because minikube already proved it owns that profile name. + if cluster_reachable; then + refuse_readable_foreign_profile_cluster_identity + fi + # Clear the verified port-forward records first: a stopped cluster leaves + # records naming dead kubectl processes, and the next T2 preflight refuses + # them with PORT_FORWARD_CONFLICT. A record that cannot be verified is kept + # and reported, and must not strand the profile running: the stop still + # happens and the command fails at the end. + local pf_status=0 minikube_status=0 + stop_pf_before_minikube || pf_status=$? + printf 'stopping minikube profile: %s\n' "${PROFILE}" + run_bounded minikube-stop "${MINIKUBE_STOP_TIMEOUT_SECONDS}" \ + minikube -p "${PROFILE}" stop || minikube_status=$? + if (( pf_status != 0 )); then + report_kept_pf_records stop + fi + if (( minikube_status != 0 )); then + return "${minikube_status}" + fi + (( pf_status == 0 )) || return 1 +} + +cmd_setup() { + require_existing_profile + require_profile_confirmation setup + require_local_context_endpoint + require_context_profile_known_to_minikube + check_docker_ready + persist_state + # Re-copied on every setup: copies left by an earlier tree would deploy its + # manifests with its guard logic next to images built from this tree. + cmd_prepare_shims + local -a setup_args=() + if [[ -n "${ARGS}" ]]; then + read -r -a setup_args <<<"${ARGS}" + fi + printf 'running isolated setup for profile: %s\n' "${PROFILE}" + printf 'using local script copy: %s\n' "${SHIMS_DIR}/full-setup.sh" + MINIKUBE_PROFILE="${PROFILE}" \ + CLERUM_PROJECT_DIR="${REPO_DIR}" \ + BRANCH_PROFILE_DEPLOY_DIR="${DEPLOY_SHIM_DIR}" \ + "${SHIMS_DIR}/full-setup.sh" ${setup_args[@]+"${setup_args[@]}"} +} + +cmd_delete() { + require_existing_profile + if [[ "${CONFIRM_DELETE}" != "${PROFILE}" ]]; then + printf 'ERROR: refusing delete. Re-run with CONFIRM_DELETE=%s\n' "${PROFILE}" >&2 + exit 1 + fi + require_local_context_endpoint + require_context_profile_known_to_minikube + # As in cmd_stop: only a readable contradicting identity refuses. An + # unobservable identity is recovery evidence for an invalid profile, not a + # foreign-cluster match. + if cluster_reachable; then + refuse_readable_foreign_profile_cluster_identity + fi + # The registry (pids/*.pid) outlives the cluster; clear the port-forward + # records so the deleted profile does not leave PORT_FORWARD_CONFLICT behind. + # As in cmd_stop, a kept record is reported and fails the command, but does + # not block the confirmed delete. + local pf_status=0 minikube_status=0 + stop_pf_before_minikube || pf_status=$? + printf 'deleting minikube profile: %s\n' "${PROFILE}" + run_bounded minikube-delete "${MINIKUBE_DELETE_TIMEOUT_SECONDS}" \ + minikube -p "${PROFILE}" delete || minikube_status=$? + if (( pf_status != 0 )); then + report_kept_pf_records delete + fi + if (( minikube_status != 0 )); then + return "${minikube_status}" + fi + (( pf_status == 0 )) || return 1 +} + +cmd_e2e_plan() { + printf 'Profile: %s\n\n' "${PROFILE}" + printf 'Profile UI browser E2E:\n' + printf ' PROFILE_UI_BASE_URL="%s" \\\n' "${PROFILE_UI_BASE_URL}" + printf ' CONTROL_API_BASE_URL="%s" \\\n' "${CONTROL_API_URL}" + printf ' EXTERNAL_REST_API_BASE_URL="%s" \\\n' "${EXTERNAL_REST_API_URL}" + printf ' KUBECONTEXT="%s" \\\n' "${PROFILE}" + printf ' bash scripts/e2e/playwright-dev.sh telegram-approval-medium-verification.test.ts\n\n' + printf 'Workflow triggers:\n' + printf ' KUBECONTEXT="%s" \\\n' "${PROFILE}" + printf ' K8S_CONTEXT="%s" \\\n' "${PROFILE}" + printf ' E2E_CONTROL_API_URL="%s" \\\n' "${CONTROL_API_URL}" + printf ' E2E_EXTERNAL_REST_API_URL="%s" \\\n' "${EXTERNAL_REST_API_URL}" + printf ' bash scripts/e2e/e2e-workflow-triggers.sh\n\n' + printf 'Workflow approvals:\n' + printf ' KUBECONTEXT="%s" \\\n' "${PROFILE}" + printf ' K8S_CONTEXT="%s" \\\n' "${PROFILE}" + printf ' E2E_CONTROL_API_URL="%s" \\\n' "${CONTROL_API_URL}" + printf ' E2E_EXTERNAL_REST_API_URL="%s" \\\n' "${EXTERNAL_REST_API_URL}" + printf ' bash scripts/e2e/e2e-workflow-approvals.sh\n\n' + printf 'This target only prints commands. Ask before running cluster-backed E2E.\n' +} + +cmd_sync_plan() { + printf 'Local shim setup is available for %s.\n\n' "${PROFILE}" + printf 'Prepare shims:\n' + printf ' make -f scripts/minikube-profiles/branch.mk branch-profile-prepare-shims\n\n' + printf 'Run full setup only with explicit profile confirmation:\n' + printf ' CONFIRM_PROFILE="%s" make -f scripts/minikube-profiles/branch.mk branch-profile-setup\n\n' "${PROFILE}" + printf 'Incremental pre-gate sync remains disabled until minikube-deploy-all is shimmed end-to-end.\n' +} + +# require_action_commands: the one table of the commands each action runs, +# checked before init_profile and dispatch so a missing command is named up +# front instead of surfacing as `command not found` after other commands have +# run. run_bounded runs every bounded call through node; the context guards use +# minikube, kubectl and python3; pf and health probe through curl; start and +# setup install charts with helm, and setup rewrites the shims with perl. Every +# action also runs init_profile's git, awk and shasum. Actions outside the +# table keep their own checks (preflight, prepare-shims) or run none of these. +require_action_commands() { + local -a needed + case "${ACTION}" in + start) needed=(minikube kubectl helm python3 node) ;; + status) needed=(minikube kubectl python3 node) ;; + pf | pf-health | health) needed=(minikube kubectl python3 node curl) ;; + stop | delete) needed=(minikube kubectl python3 node) ;; + setup) needed=(minikube kubectl helm perl python3 node) ;; + *) return 0 ;; + esac + require_command git awk shasum "${needed[@]}" +} + +require_action_commands +init_profile + +case "${ACTION}" in + resolve) cmd_resolve ;; + info) cmd_info ;; + preflight) cmd_preflight ;; + prepare-shims) cmd_prepare_shims ;; + start) cmd_start ;; + status) cmd_status ;; + pf) cmd_pf ;; + pf-health) cmd_pf_health ;; + health) cmd_health ;; + stop-pf) cmd_stop_pf ;; + stop) cmd_stop ;; + setup) cmd_setup ;; + delete) cmd_delete ;; + e2e-plan) cmd_e2e_plan ;; + sync-plan) cmd_sync_plan ;; + *) + printf 'ERROR: unknown branch profile action: %s\n' "${ACTION}" >&2 + exit 1 + ;; +esac diff --git a/scripts/minikube-profiles/branch.mk b/scripts/minikube-profiles/branch.mk new file mode 100644 index 0000000000..4ef18628fa --- /dev/null +++ b/scripts/minikube-profiles/branch.mk @@ -0,0 +1,85 @@ +SHELL := /bin/bash + +BRANCH_PROFILE_SCRIPT := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))branch-profile.sh + +HOST ?= 127.0.0.1 +CACHE_ROOT ?= $(HOME)/.cache/clerum/minikube-profiles +MINIKUBE_MEMORY ?= 10240 +MINIKUBE_CPUS ?= 6 +MINIKUBE_CNI ?= calico +MINIKUBE_DRIVER ?= docker +CONFIRM_DELETE ?= +CONFIRM_PROFILE ?= +BRANCH_PROFILE_PROFILE ?= +ARGS ?= + +.DEFAULT_GOAL := branch-profile-info + +# Exported, not interpolated into the recipe: a value containing a quote, `$` +# or a backtick would otherwise be re-parsed by the shell. +export HOST CACHE_ROOT MINIKUBE_MEMORY MINIKUBE_CPUS MINIKUBE_CNI MINIKUBE_DRIVER +export CONFIRM_DELETE CONFIRM_PROFILE BRANCH_PROFILE_PROFILE ARGS + +define run_branch_profile +@"$(BRANCH_PROFILE_SCRIPT)" "$(1)" +endef + +.PHONY: branch-profile-resolve +branch-profile-resolve: ## Resolve the unique worktree/branch-owned profile without mutating profile state + $(call run_branch_profile,resolve) + +.PHONY: branch-profile-info +branch-profile-info: + $(call run_branch_profile,info) + +.PHONY: branch-profile-preflight +branch-profile-preflight: + $(call run_branch_profile,preflight) + +.PHONY: branch-profile-start +branch-profile-start: + $(call run_branch_profile,start) + +.PHONY: branch-profile-status +branch-profile-status: + $(call run_branch_profile,status) + +.PHONY: branch-profile-pf +branch-profile-pf: + $(call run_branch_profile,pf) + +.PHONY: branch-profile-pf-health +branch-profile-pf-health: + $(call run_branch_profile,pf-health) + +.PHONY: branch-profile-health +branch-profile-health: + $(call run_branch_profile,health) + +.PHONY: branch-profile-stop-pf +branch-profile-stop-pf: + $(call run_branch_profile,stop-pf) + +.PHONY: branch-profile-stop +branch-profile-stop: + $(call run_branch_profile,stop) + +.PHONY: branch-profile-delete +branch-profile-delete: + $(call run_branch_profile,delete) + +.PHONY: branch-profile-e2e-plan +branch-profile-e2e-plan: + $(call run_branch_profile,e2e-plan) + +.PHONY: branch-profile-sync-plan +branch-profile-sync-plan: + $(call run_branch_profile,sync-plan) + +.PHONY: branch-profile-prepare-shims +branch-profile-prepare-shims: + $(call run_branch_profile,prepare-shims) + +.PHONY: branch-profile-setup +branch-profile-setup: + $(call run_branch_profile,setup) diff --git a/scripts/minikube/context-identity.sh b/scripts/minikube/context-identity.sh new file mode 100755 index 0000000000..e9ef7827a9 --- /dev/null +++ b/scripts/minikube/context-identity.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# Pure predicates that bind a kube context to a local Minikube profile. +# +# Shared by the T2 lane (t2-common.sh) and the branch profile helper +# (scripts/minikube-profiles/branch-profile.sh) so both apply the same rule. +# Every input is an argument, nothing is printed on a negative answer, and no +# function here contacts a cluster: callers fetch the kubeconfig endpoint, the +# node list, the Minikube IP and the Minikube profile list themselves, with +# their own timeouts. + +# kube_endpoint_host +# Prints the lowercased host of an API server URL (brackets stripped from an +# IPv6 literal, port and path dropped). +kube_endpoint_host() { + local host="${1#*://}" + host="${host%%/*}" + if [[ "${host}" == \[*\]* ]]; then + host="${host#\[}" + host="${host%%\]*}" + else + host="${host%%:*}" + fi + printf '%s' "${host}" | tr '[:upper:]' '[:lower:]' +} + +# kube_endpoint_is_local +# Succeeds when the API server is on this machine or a private network: +# loopback, localhost, *.minikube, or a private/loopback/link-local IP literal. +# A DNS name other than localhost and *.minikube is not local. +kube_endpoint_is_local() { + local host + host="$(kube_endpoint_host "${1:-}")" + [[ -n "${host}" ]] || return 1 + if [[ "${host}" == 127.0.0.1 || "${host}" == localhost || "${host}" == ::1 || "${host}" == *.minikube ]]; then + return 0 + fi + python3 - "${host}" <<'PY' +import ipaddress +import sys + +try: + address = ipaddress.ip_address(sys.argv[1]) +except ValueError: + raise SystemExit(1) +raise SystemExit(0 if (address.is_private or address.is_loopback or address.is_link_local) else 1) +PY +} + +# minikube_nodes_identify_profile +# Succeeds when `kubectl get nodes -o json` output contains a node labelled +# minikube.k8s.io/name= whose InternalIP is , the address that +# `minikube -p ip` reports. A localhost endpoint alone does not prove +# the context belongs to this profile: kubeconfig can point at another local +# Minikube profile, and the private-address allowlist admits LAN clusters. +minikube_nodes_identify_profile() { + local nodes_json="${1:-}" profile="${2:-}" expected_ip="${3:-}" + [[ -n "${nodes_json}" && -n "${profile}" && -n "${expected_ip}" ]] || return 1 + python3 - "${nodes_json}" "${profile}" "${expected_ip}" <<'PY' +import json +import sys + +try: + payload = json.loads(sys.argv[1]) +except ValueError: + raise SystemExit(1) +profile = sys.argv[2] +expected_ip = sys.argv[3] +for node in payload.get("items", []): + metadata = node.get("metadata") or {} + labels = metadata.get("labels") or {} + if labels.get("minikube.k8s.io/name") != profile: + continue + for address in (node.get("status") or {}).get("addresses", []): + if address.get("type") == "InternalIP" and address.get("address") == expected_ip: + raise SystemExit(0) +raise SystemExit(1) +PY +} + +# minikube_profile_list_names +# Answers whether `minikube profile list -o json` output lists , as a +# valid or an invalid profile. Returns 0 when it does, 1 when it does not, and +# 2 when the output is not {"invalid": [...], "valid": [...]} with an object +# carrying a non-empty string "Name" in every entry: an error body, a missing +# list or an unnamed entry cannot say which profiles exist, so it is never read +# as "not listed". +minikube_profile_list_names() { + local profiles_json="${1:-}" profile="${2:-}" + [[ -n "${profiles_json}" && -n "${profile}" ]] || return 2 + python3 - "${profiles_json}" "${profile}" <<'PY' +import json +import sys + + +def listed_names(payload): + if not isinstance(payload, dict): + return None + names = [] + for key in ("valid", "invalid"): + entries = payload.get(key) + if not isinstance(entries, list): + return None + for entry in entries: + if not isinstance(entry, dict): + return None + name = entry.get("Name") + if not isinstance(name, str) or not name: + return None + names.append(name) + return names + + +try: + names = listed_names(json.loads(sys.argv[1])) +except (ValueError, RecursionError): + names = None +if names is None: + raise SystemExit(2) +raise SystemExit(0 if sys.argv[2] in names else 1) +PY +} diff --git a/scripts/minikube/t2-common.sh b/scripts/minikube/t2-common.sh index 5b3bb32dba..45b6c096a3 100755 --- a/scripts/minikube/t2-common.sh +++ b/scripts/minikube/t2-common.sh @@ -17,6 +17,8 @@ if [ -z "$T2_SCRIPT_DIR" ]; then T2_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SO . "$T2_SCRIPT_DIR/profile-readiness.sh" # shellcheck source=pre-gate-marker.sh . "$T2_SCRIPT_DIR/pre-gate-marker.sh" +# shellcheck source=context-identity.sh +. "$T2_SCRIPT_DIR/context-identity.sh" T2_PROJECT_DIR="$T2_PROJECT_DIR" if [ -z "$T2_PROJECT_DIR" ]; then T2_PROJECT_DIR="$(cd -- "$T2_SCRIPT_DIR/../.." && pwd -P)"; fi T2_PROFILE="$T2_PROFILE" @@ -222,6 +224,65 @@ t2_canonical_path() { (cd -- "$1" 2>/dev/null && pwd -P) } +# refs/remotes/origin/dev is shared by every worktree of this repository, so a +# fetch from any of them moves it while a lane runs. The lease owner reads it +# once (t2_lock_acquire exports T2_PINNED_ORIGIN_DEV) and every child of that +# lease validates against the pin. The pin is honored only under an inherited +# lease: an exported pin cannot certify a branch against an older dev, and a +# lease child that lost the pin fails instead of re-reading the moved ref. +# +# A child the lease owner starts without handing it the lease (T0 in t2.sh +# run_t0) must not inherit the pin either, or it is refused as an unleased pin. +# t2_run_outside_lease starts it without T2_PINNED_ORIGIN_DEV; the arguments +# are those of env(1), so NAME=VALUE assignments may precede the command. +t2_run_outside_lease() { + env -u T2_PINNED_ORIGIN_DEV "$@" +} + +t2_resolve_origin_dev() { + local pin="${T2_PINNED_ORIGIN_DEV:-}" current + current="$(git -C "$T2_PROJECT_DIR" rev-parse --verify origin/dev 2>/dev/null || true)" + if [ "${T2_SKIP_LOCK:-}" != true ]; then + if [ -n "$pin" ]; then + T2_NEXT_COMMAND='unset T2_PINNED_ORIGIN_DEV; the lease owner pins origin/dev for its children' + t2_fail DEVELOPMENT_SCOPE_REQUIRED 'T2_PINNED_ORIGIN_DEV is only honored under an inherited T2 lease' + return 1 + fi + T2_ORIGIN_DEV="$current" + return 0 + fi + if [ -z "$pin" ]; then + T2_NEXT_COMMAND='run this child from the T2 lease owner, which exports T2_PINNED_ORIGIN_DEV' + t2_fail DEVELOPMENT_SCOPE_REQUIRED 'an inherited T2 lease carries no pinned origin/dev' + return 1 + fi + if [[ ! "$pin" =~ ^[0-9a-f]{40}$ ]]; then + T2_NEXT_COMMAND='run this child from the T2 lease owner, which exports the full origin/dev SHA' + t2_fail DEVELOPMENT_SCOPE_REQUIRED 'T2_PINNED_ORIGIN_DEV is not a full commit SHA' + return 1 + fi + if ! git -C "$T2_PROJECT_DIR" cat-file -e "$pin^{commit}" 2>/dev/null; then + T2_NEXT_COMMAND='run this child from the T2 lease owner of this repository' + t2_fail DEVELOPMENT_SCOPE_REQUIRED 'T2_PINNED_ORIGIN_DEV is not a commit in this repository' + return 1 + fi + if [ -z "$current" ]; then + T2_NEXT_COMMAND='fetch origin/dev and run from a named development branch' + t2_fail DEVELOPMENT_SCOPE_REQUIRED 'branch, HEAD, or origin/dev could not be resolved' + return 1 + fi + if ! git -C "$T2_PROJECT_DIR" merge-base --is-ancestor "$pin" "$current"; then + T2_NEXT_COMMAND='origin/dev was rewritten during the lane; merge the current origin/dev, then re-run T2' + t2_fail DEVELOPMENT_SCOPE_REQUIRED 'the pinned origin/dev is not an ancestor of the current origin/dev' + return 1 + fi + if [ "$current" != "$pin" ]; then + printf '[minikube-t2] origin/dev moved during the lane: pinned %s, local ref now %s; validating against the pin\n' \ + "$pin" "$current" >&2 + fi + T2_ORIGIN_DEV="$pin" +} + t2_repo_metadata() { local actual_root remote_url actual_root="$(git -C "$T2_PROJECT_DIR" rev-parse --show-toplevel 2>/dev/null || true)" @@ -241,7 +302,7 @@ t2_repo_metadata() { fi T2_BRANCH="$(git -C "$T2_PROJECT_DIR" branch --show-current 2>/dev/null || true)" T2_HEAD="$(git -C "$T2_PROJECT_DIR" rev-parse --verify HEAD 2>/dev/null || true)" - T2_ORIGIN_DEV="$(git -C "$T2_PROJECT_DIR" rev-parse --verify origin/dev 2>/dev/null || true)" + t2_resolve_origin_dev || return 1 if [ -z "$T2_BRANCH" ] || [ -z "$T2_HEAD" ] || [ -z "$T2_ORIGIN_DEV" ]; then T2_NEXT_COMMAND='fetch origin/dev and run from a named development branch' t2_fail DEVELOPMENT_SCOPE_REQUIRED 'branch, HEAD, or origin/dev could not be resolved' @@ -290,7 +351,7 @@ t2_profile_scope() { "$T2_PROJECT_DIR" "$T2_BRANCH" "$T2_PROFILE"; then case "$PROFILE_OWNER_ERROR_CODE" in PROFILE_METADATA_MISSING) - T2_NEXT_COMMAND='resolve or generate profile.env with the branch profile helper, then retry' ;; + T2_NEXT_COMMAND='restore profile.env from a backup or stop and ask; the branch profile helper never regenerates metadata for an existing profile directory' ;; PROFILE_PORTS_MISSING) T2_NEXT_COMMAND='restore the persisted profile-owned ports.env; never regenerate adopted ports' ;; PROFILE_PORTS_INVALID) @@ -311,37 +372,15 @@ t2_context_check() { T2_NEXT_COMMAND='select the explicit Kubernetes context generated with the profile' t2_fail DEVELOPMENT_SCOPE_REQUIRED "Kubernetes context is unavailable: $T2_CONTEXT" fi - local endpoint host + local endpoint endpoint="$(t2_kc config view --raw --minify -o jsonpath='{.clusters[0].cluster.server}' 2>/dev/null || true)" if [ -z "$endpoint" ]; then T2_NEXT_COMMAND='select a Kubernetes context with a resolvable local cluster endpoint' t2_fail DEVELOPMENT_SCOPE_REQUIRED "Kubernetes context endpoint is unavailable: $T2_CONTEXT" fi - host="${endpoint#*://}" - host="${host%%/*}" - if [[ "$host" == \[*\]* ]]; then - host="${host#\[}" - host="${host%%\]*}" - else - host="${host%%:*}" - fi - host="$(printf '%s' "$host" | tr '[:upper:]' '[:lower:]')" - if [[ "$host" == 127.0.0.1 || "$host" == localhost || "$host" == ::1 || "$host" == *.minikube ]]; then - return 0 - fi - if ! python3 - "$host" <<'PY' -import ipaddress -import sys - -try: - address = ipaddress.ip_address(sys.argv[1]) -except ValueError: - raise SystemExit(1) -raise SystemExit(0 if (address.is_private or address.is_loopback or address.is_link_local) else 1) -PY - then + if ! kube_endpoint_is_local "$endpoint"; then T2_NEXT_COMMAND='select the generated branch-owned Minikube context, not a remote cluster context' - t2_fail DEVELOPMENT_SCOPE_REQUIRED "Kubernetes context endpoint is not local: $host" + t2_fail DEVELOPMENT_SCOPE_REQUIRED "Kubernetes context endpoint is not local: $(kube_endpoint_host "$endpoint")" return 1 fi } @@ -362,24 +401,7 @@ t2_profile_context_identity_check() { return 1 fi nodes_json="$(t2_kc get nodes -o json 2>/dev/null || true)" - if [ -z "$nodes_json" ] || ! python3 - "$nodes_json" "$T2_PROFILE" "$expected_ip" <<'PY' -import json -import sys - -payload = json.loads(sys.argv[1]) -profile = sys.argv[2] -expected_ip = sys.argv[3] -for node in payload.get("items", []): - metadata = node.get("metadata") or {} - labels = metadata.get("labels") or {} - if labels.get("minikube.k8s.io/name") != profile: - continue - for address in (node.get("status") or {}).get("addresses", []): - if address.get("type") == "InternalIP" and address.get("address") == expected_ip: - raise SystemExit(0) -raise SystemExit(1) -PY - then + if ! minikube_nodes_identify_profile "$nodes_json" "$T2_PROFILE" "$expected_ip"; then T2_NEXT_COMMAND='select the kube-context generated for this exact branch-owned Minikube profile' t2_fail DEVELOPMENT_SCOPE_REQUIRED "Kubernetes context does not identify Minikube profile $T2_PROFILE at $expected_ip" return 1 @@ -1237,6 +1259,11 @@ t2_lock_process_matches() { t2_lock_acquire() { local process_start reclaim_dir="" t2_lock_profile_id_check || return 1 + if [ -z "${T2_ORIGIN_DEV:-}" ]; then + T2_NEXT_COMMAND='resolve repository metadata (t2_repo_metadata) before acquiring the profile lock' + t2_fail DEVELOPMENT_SCOPE_REQUIRED 'the lease owner has no origin/dev to pin' + return 1 + fi mkdir -p "$T2_LOCK_ROOT" T2_LOCK_DIR="$T2_LOCK_ROOT/$T2_PROFILE.lock" T2_LOCK_KEY="$(t2_lock_key)" @@ -1310,6 +1337,7 @@ PROFILE=$T2_PROFILE CONTEXT=$T2_CONTEXT WORKTREE_ID=$T2_WORKTREE_ID LOCK_KEY=$T2_LOCK_KEY +ORIGIN_DEV=$T2_ORIGIN_DEV TOKEN=$T2_LOCK_TOKEN PID=$$ PROCESS_START=$process_start @@ -1318,6 +1346,9 @@ EOF if [ -n "$reclaim_dir" ]; then rmdir "$reclaim_dir" 2>/dev/null || true fi + # Children of this lease validate against the origin/dev read here, not + # against the shared remote-tracking ref (see t2_resolve_origin_dev). + export T2_PINNED_ORIGIN_DEV="$T2_ORIGIN_DEV" T2_LOCK_HELD=true T2_LOCK_RELEASED=false } @@ -1339,7 +1370,7 @@ t2_lock_validate_inherited() { t2_fail PROFILE_LOCK_REQUIRED 'inherited profile lock token is missing or does not match' return 1 fi - for owner_key in REPOSITORY BRANCH HEAD PROFILE CONTEXT WORKTREE_ID LOCK_KEY; do + for owner_key in REPOSITORY BRANCH HEAD PROFILE CONTEXT WORKTREE_ID LOCK_KEY ORIGIN_DEV; do case "$owner_key" in REPOSITORY) expected_pid="$T2_PROJECT_DIR" ;; BRANCH) expected_pid="$T2_BRANCH" ;; @@ -1348,8 +1379,12 @@ t2_lock_validate_inherited() { CONTEXT) expected_pid="$T2_CONTEXT" ;; WORKTREE_ID) expected_pid="$T2_WORKTREE_ID" ;; LOCK_KEY) expected_pid="$T2_LOCK_KEY" ;; + ORIGIN_DEV) expected_pid="${T2_PINNED_ORIGIN_DEV:-}" ;; esac - if [ "$(t2_lock_owner_value "$owner_key" || true)" != "$expected_pid" ]; then + # An empty pin never matches, even against a lock written before the + # owner recorded ORIGIN_DEV. + if [ -z "$expected_pid" ] && [ "$owner_key" = ORIGIN_DEV ] || + [ "$(t2_lock_owner_value "$owner_key" || true)" != "$expected_pid" ]; then T2_NEXT_COMMAND='re-run the operation from the worktree/profile that owns the T2 lock' t2_fail PROFILE_OWNERSHIP_MISMATCH "profile lock owner does not match $owner_key" return 1 diff --git a/scripts/minikube/t2.sh b/scripts/minikube/t2.sh index d9bec234cb..4b55eb2e3e 100755 --- a/scripts/minikube/t2.sh +++ b/scripts/minikube/t2.sh @@ -93,13 +93,15 @@ run_t0() { bash -n "$SCRIPT_DIR/t2-common.sh" "$SCRIPT_DIR/t2-preflight.sh" "$SCRIPT_DIR/t2.sh" \ "$SCRIPT_DIR/t2-setup-handoff.sh" \ "$T2_PROJECT_DIR/scripts/tests/test-minikube-t2-setup-handoff.sh" - T0_PROJECT_DIR="$T2_PROJECT_DIR" T0_ORIGIN_DEV="$T2_ORIGIN_DEV" T0_HEAD="$T2_HEAD" \ + # T0 does not hold the lease, so its children must not inherit the pinned + # origin/dev (see t2_run_outside_lease); T0 receives it as T0_ORIGIN_DEV. + t2_run_outside_lease T0_PROJECT_DIR="$T2_PROJECT_DIR" T0_ORIGIN_DEV="$T2_ORIGIN_DEV" T0_HEAD="$T2_HEAD" \ bash "$T2_PROJECT_DIR/scripts/minikube/t0.sh" if [ -n "$T2_T0_COMMAND" ]; then - bash -c "$T2_T0_COMMAND" + t2_run_outside_lease bash -c "$T2_T0_COMMAND" fi - bash "$T2_PROJECT_DIR/scripts/tests/test-minikube-t2-contract.sh" - bash "$T2_PROJECT_DIR/scripts/tests/test-minikube-t2-setup-handoff.sh" + t2_run_outside_lease bash "$T2_PROJECT_DIR/scripts/tests/test-minikube-t2-contract.sh" + t2_run_outside_lease bash "$T2_PROJECT_DIR/scripts/tests/test-minikube-t2-setup-handoff.sh" T2_T0_STATUS=PASS t2_evidence_write T0 PASS "syntax, ShellCheck when available, affected package test/build/typecheck, contract, and diff checks passed; duration=$((SECONDS - phase_started_seconds))s" } diff --git a/scripts/tests/lib/minikube-fixture-repo.sh b/scripts/tests/lib/minikube-fixture-repo.sh index 836f1e7bc9..f72f3c303a 100755 --- a/scripts/tests/lib/minikube-fixture-repo.sh +++ b/scripts/tests/lib/minikube-fixture-repo.sh @@ -28,6 +28,9 @@ minikube_test_fixture_repo_init() { MINIKUBE_TEST_PROJECT_DIR="$(cd -- "${repo}" && pwd -P)" MINIKUBE_TEST_BRANCH="$(git -C "${MINIKUBE_TEST_PROJECT_DIR}" branch --show-current)" MINIKUBE_TEST_HEAD="$(git -C "${MINIKUBE_TEST_PROJECT_DIR}" rev-parse --verify HEAD)" + # The origin/dev a T2 lease owner pins: hand-written owner.env fixtures record + # it as ORIGIN_DEV and lease children inherit it as T2_PINNED_ORIGIN_DEV. + MINIKUBE_TEST_ORIGIN_DEV="$(git -C "${MINIKUBE_TEST_PROJECT_DIR}" rev-parse --verify origin/dev)" MINIKUBE_TEST_WORKTREE_ID="$(printf '%s' "${MINIKUBE_TEST_PROJECT_DIR}" | shasum | awk '{print $1}')" MINIKUBE_TEST_LOCK_KEY="$(printf '%s\0%s\0%s\0%s\0%s' \ "${MINIKUBE_TEST_PROJECT_DIR}" "${MINIKUBE_TEST_BRANCH}" \ @@ -38,7 +41,7 @@ minikube_test_fixture_repo_init() { export MINIKUBE_TEST_HOST_ROOT MINIKUBE_TEST_HOST_HEAD export MINIKUBE_TEST_HOST_BRANCH MINIKUBE_TEST_HOST_STATUS_HASH export MINIKUBE_TEST_PROJECT_DIR MINIKUBE_TEST_BRANCH MINIKUBE_TEST_HEAD - export MINIKUBE_TEST_WORKTREE_ID MINIKUBE_TEST_LOCK_KEY + export MINIKUBE_TEST_WORKTREE_ID MINIKUBE_TEST_LOCK_KEY MINIKUBE_TEST_ORIGIN_DEV } minikube_test_assert_host_unchanged() { diff --git a/scripts/tests/test-branch-profile-lifecycle.sh b/scripts/tests/test-branch-profile-lifecycle.sh new file mode 100755 index 0000000000..aa8af1335a --- /dev/null +++ b/scripts/tests/test-branch-profile-lifecycle.sh @@ -0,0 +1,1606 @@ +#!/usr/bin/env bash +# Behaviour scenarios for scripts/minikube-profiles/branch-profile.sh. +# +# The real script runs against a fixture Git repository. kubectl, minikube, +# helm, docker and curl are PATH stubs that append every call to a log, and +# CACHE_ROOT is a temporary directory, so no cluster, Docker daemon or shared +# kubeconfig is touched. A port-forward stub process presents the exact kubectl +# argv to ps(1) (exec -a) so the ownership library can record and stop it. +# +# Every scenario asserts the exit code, the stub call log and the pidfile state. +# Every negative assertion is paired with a positive witness proving the path +# under test actually ran. +set -euo pipefail + +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)" +# BRANCH_PROFILE_LIFECYCLE_SCRIPT lets a mutation run point this suite at a +# modified copy of the script; by default the checked-in script is tested. +BRANCH_PROFILE="${BRANCH_PROFILE_LIFECYCLE_SCRIPT:-${ROOT}/scripts/minikube-profiles/branch-profile.sh}" +[[ -f "${BRANCH_PROFILE}" ]] || { printf 'FAIL: script under test is missing: %s\n' "${BRANCH_PROFILE}" >&2; exit 1; } + +# Ambient selectors from the caller's shell must not leak into the fixture. +unset MINIKUBE_PROFILE BRANCH_PROFILE_PROFILE HOST CONFIRM_DELETE CONFIRM_PROFILE \ + ARGS KUBECONFIG ALLOWED_CONTEXTS CONTEXT DOCKER_CONTEXT PF_STARTUP_DELAY + +tmp="$(mktemp -d "${TMPDIR:-/tmp}/branch-profile-lifecycle.XXXXXX")" +tmp="$(cd -- "${tmp}" && pwd -P)" +state="${tmp}/state" +stub_bin="${tmp}/bin" +cache_root="${tmp}/cache" +out_dir="${tmp}/out" +mkdir -p "${state}" "${stub_bin}" "${cache_root}" "${out_dir}" + +kill_recorded_processes() { + local file pid + for file in "${state}/pf-pids" "${state}/foreign-pids"; do + [[ -f "${file}" ]] || continue + while IFS= read -r pid; do + [[ "${pid}" =~ ^[0-9]+$ ]] || continue + kill -KILL "${pid}" 2>/dev/null || true + # Reap our own children quietly; kubectl stubs are not children here. + wait "${pid}" 2>/dev/null || true + done <"${file}" + done +} +cleanup() { + kill_recorded_processes + rm -rf "${tmp}" +} +trap cleanup EXIT + +ASSERTIONS=0 +fail() { + printf 'FAIL: %s\n' "$*" >&2 + if [[ -n "${BP_OUT:-}" && -f "${BP_OUT}" ]]; then + printf -- '--- last branch-profile output (%s, rc=%s) ---\n' "${BP_OUT##*/}" "${BP_RC:-?}" >&2 + sed -n '1,60p' "${BP_OUT}" >&2 + fi + if [[ -f "${state}/calls.log" ]]; then + printf -- '--- stub call log ---\n' >&2 + sed -n '1,60p' "${state}/calls.log" >&2 + fi + exit 1 +} +ok() { ASSERTIONS=$((ASSERTIONS + 1)); } + +assert_rc() { + local expected="$1" label="$2" + [[ "${BP_RC}" == "${expected}" ]] || fail "${label}: expected exit ${expected}, got ${BP_RC}" + ok +} +assert_rc_nonzero() { + local label="$1" + [[ "${BP_RC}" != 0 ]] || fail "${label}: expected a non-zero exit, got 0" + ok +} +assert_output_has() { + local needle="$1" label="$2" + grep -Fq -- "${needle}" "${BP_OUT}" || fail "${label}: output lacks '${needle}'" + ok +} +assert_output_lacks() { + local needle="$1" label="$2" + if grep -Fq -- "${needle}" "${BP_OUT}"; then fail "${label}: output unexpectedly has '${needle}'"; fi + ok +} +assert_log_has() { + local needle="$1" label="$2" + grep -Fq -- "${needle}" "${state}/calls.log" || fail "${label}: stub log lacks '${needle}'" + ok +} +assert_log_lacks() { + local needle="$1" label="$2" + if grep -Fq -- "${needle}" "${state}/calls.log"; then fail "${label}: stub log unexpectedly has '${needle}'"; fi + ok +} +# assert_log_order

delete` for a profile minikube does not know removes the +# kubeconfig context of that name. A context with the profile's name whose API +# server is not local is refused before any minikube or kubectl --context call. +# The witness is the local kubeconfig read that decided it. The remote server +# is a public address: Python's ipaddress counts the documentation ranges +# (TEST-NET-1/2/3) as private, so the shared predicate admits them as local. +remote_server=https://8.8.8.8:6443 +for action in start status pf pf-health health stop setup delete; do + reset_state + write_kube_contexts "${profile}" "${remote_server}" + bp "remote-context-${action}" "${action}" "CONFIRM_DELETE=${profile}" "CONFIRM_PROFILE=${profile}" + assert_rc 1 "${action} of a profile whose context is remote" + assert_output_has "BRANCH_PROFILE_REMOTE_CONTEXT: kube context ${profile} points at a non-local API server (8.8.8.8)" \ + "${action} of a profile whose context is remote" + assert_log_has "kubectl config view --raw --minify --context=${profile}" \ + "${action} read the context's server from the local kubeconfig" + assert_no_calls_to minikube "${action} of a profile whose context is remote must not call minikube" + assert_log_lacks 'kubectl --context=' "${action} of a profile whose context is remote must not address the cluster" + assert_no_calls_to docker "${action} of a profile whose context is remote must not reach Docker" +done + +# A DNS name other than localhost and *.minikube is the predicate's other +# remote branch. The name is reserved by RFC 2606 and never resolves. +for action in start delete; do + reset_state + write_kube_contexts "${profile}" https://API.Remote-Cluster.example:6443 + bp "remote-dns-context-${action}" "${action}" "CONFIRM_DELETE=${profile}" + assert_rc 1 "${action} of a profile whose context is a remote DNS name" + assert_output_has "BRANCH_PROFILE_REMOTE_CONTEXT: kube context ${profile} points at a non-local API server (api.remote-cluster.example)" \ + "${action} of a profile whose context is a remote DNS name" + assert_log_has "kubectl config view --raw --minify --context=${profile}" \ + "${action} read the DNS-named context's server from the local kubeconfig" + assert_no_calls_to minikube "${action} of a profile whose context is a remote DNS name must not call minikube" + assert_log_lacks 'kubectl --context=' "${action} of a profile whose context is a remote DNS name must not address the cluster" +done + +# Each refusal of the kubeconfig read names its own cause. An unreadable +# kubeconfig and a dangling context (a context whose cluster is not defined, +# or whose cluster has no server) are not remote endpoints: reporting them as +# BRANCH_PROFILE_REMOTE_CONTEXT sends the operator after a cluster that does +# not exist. The remote loops above are the witness that a non-local endpoint +# still refuses as BRANCH_PROFILE_REMOTE_CONTEXT. +for action in start status pf pf-health health stop setup delete; do + reset_state + : >"${state}/get-contexts-fails" + bp "kubeconfig-unreadable-${action}" "${action}" "CONFIRM_DELETE=${profile}" "CONFIRM_PROFILE=${profile}" + assert_rc 1 "${action} of a profile whose kubeconfig cannot be read" + assert_output_has 'BRANCH_PROFILE_KUBECONFIG_UNREADABLE: unable to read the kubeconfig contexts' \ + "${action} of a profile whose kubeconfig cannot be read" + assert_output_lacks 'BRANCH_PROFILE_REMOTE_CONTEXT' \ + "${action} of a profile whose kubeconfig cannot be read is not a remote context" + assert_log_has 'kubectl config get-contexts -o name' "${action} tried to read the kubeconfig contexts" + assert_log_lacks 'kubectl config view' "${action} stopped at the unreadable context list" + assert_no_calls_to minikube "${action} of a profile whose kubeconfig cannot be read must not call minikube" + assert_log_lacks 'kubectl --context=' "${action} of a profile whose kubeconfig cannot be read must not address the cluster" + assert_no_calls_to docker "${action} of a profile whose kubeconfig cannot be read must not reach Docker" + + for dangling in missing-cluster empty-server; do + reset_state + write_kube_contexts "${profile}" "@${dangling}" + bp "context-${dangling}-${action}" "${action}" "CONFIRM_DELETE=${profile}" "CONFIRM_PROFILE=${profile}" + assert_rc 1 "${action} of a profile whose context is dangling (${dangling})" + if [[ "${dangling}" == missing-cluster ]]; then + assert_output_has "BRANCH_PROFILE_CONTEXT_DANGLING: kube context ${profile} is in the kubeconfig, but kubectl could not resolve its cluster" \ + "${action} of a profile whose context names an undefined cluster" + else + assert_output_has "BRANCH_PROFILE_CONTEXT_DANGLING: kube context ${profile} has no API server" \ + "${action} of a profile whose context has an empty server" + fi + assert_output_lacks 'BRANCH_PROFILE_REMOTE_CONTEXT' \ + "${action} of a profile whose context is dangling (${dangling}) is not a remote context" + assert_log_has "kubectl config view --raw --minify --context=${profile}" \ + "${action} read the dangling context (${dangling}) from the local kubeconfig" + assert_no_calls_to minikube "${action} of a profile whose context is dangling (${dangling}) must not call minikube" + assert_log_lacks 'kubectl --context=' "${action} of a profile whose context is dangling (${dangling}) must not address the cluster" + assert_no_calls_to docker "${action} of a profile whose context is dangling (${dangling}) must not reach Docker" + done +done + +# The reported shape: an adopted clerum-* profile named like a remote cluster's +# context. delete must refuse without running `minikube -p clerum-prd delete`. +write_owned_profile_copy clerum-prd +reset_state +write_kube_contexts clerum-prd "${remote_server}" +bp adopted-remote-delete delete MINIKUBE_PROFILE=clerum-prd CONFIRM_DELETE=clerum-prd +assert_rc 1 'delete of an adopted profile whose context is remote' +assert_output_has 'BRANCH_PROFILE_REMOTE_CONTEXT: kube context clerum-prd points at a non-local API server' \ + 'delete of an adopted profile whose context is remote' +assert_log_has 'kubectl config view --raw --minify --context=clerum-prd' \ + 'delete of an adopted profile read its context from the local kubeconfig' +assert_no_calls_to minikube 'delete of an adopted profile whose context is remote must not call minikube' +rm -rf "${cache_root:?}/clerum-prd" + +# A private address passes the endpoint check (Minikube's own network is +# private), so a cluster that answers must also identify itself as this +# profile: a node labelled minikube.k8s.io/name= at `minikube ip`. +for action in start status pf health; do + reset_state + write_kube_contexts "${profile}" https://192.168.1.50:8443 + printf 'lan-cluster\n' >"${state}/node-label" + bp "foreign-identity-${action}" "${action}" + assert_rc 1 "${action} of a profile whose context is another cluster" + assert_output_has "BRANCH_PROFILE_CONTEXT_IDENTITY: kube context ${profile} does not identify Minikube profile ${profile} at 192.168.49.2" \ + "${action} of a profile whose context is another cluster" + # Witness: the identity was read from the cluster and from minikube. + assert_log_has "minikube -p ${profile} ip" "${action} asked minikube for the profile's IP" + assert_log_has "kubectl --context=${profile} --request-timeout=10s get nodes -o json" \ + "${action} read the node identity" + assert_log_lacks 'port-forward' "${action} of a profile whose context is another cluster must not forward" + assert_log_lacks 'get deploy' "${action} of a profile whose context is another cluster must not list workloads" + assert_no_calls_to curl "${action} of a profile whose context is another cluster must not probe services" + if [[ "${action}" == start ]]; then + assert_log_has "minikube start -p ${profile}" 'start started the profile before checking its identity' + assert_log_lacks 'cluster-info' 'start of a profile whose context is another cluster stops before cluster-info' + fi +done + +# The default fixture context is local and identifies the profile: the checks +# ran and admitted it. +reset_state +bp local-identity-status status +assert_rc 0 'status of a profile whose context is this local Minikube' +assert_log_has "kubectl config view --raw --minify --context=${profile}" 'status read the context from the local kubeconfig' +assert_log_has "kubectl --context=${profile} --request-timeout=10s get nodes -o json" 'status verified the node identity' +assert_log_has "kubectl --context=${profile} --request-timeout=10s get deploy -A" 'status went on to list workloads' + +# === status =================================================================== +reset_state +bp status-running status +assert_rc 0 'status of a running profile' +assert_log_has "minikube -p ${profile} status" 'status of a running profile' +assert_log_has "kubectl --context=${profile} --request-timeout=10s get deploy -A" 'status lists deployments' + +reset_state +rm -f "${state}/reachable" +printf '7\n' >"${state}/minikube-status-rc" +bp status-stopped status +assert_rc 7 'status of a stopped profile returns the minikube status code' +assert_output_has "cluster not reachable for context ${profile}" 'status of a stopped profile' +assert_log_has "minikube -p ${profile} status" 'status of a stopped profile ran minikube status' +assert_log_lacks 'get deploy' 'status of a stopped profile must not list deployments' + +reset_state +: >"${state}/fail-get-deploy" +bp status-kubectl-fails status +assert_rc_nonzero 'status must fail when listing deployments fails' +assert_log_has "kubectl --context=${profile} --request-timeout=10s get deploy -A" 'status reached the failing deployment listing' + +# === pf / health / stop-pf ==================================================== +reset_state +bp pf-all pf +assert_rc 0 'pf with every required service present' +for name in control-ui control-api external-rest-api rpc-proxy; do + assert_file "${pids_dir}/${name}.pid" "pf records ${name}" +done +assert_no_file "${pids_dir}/profile-ui.pid" 'pf skips an absent optional service' +assert_output_has 'SKIP profile-ui' 'pf reports the skipped optional service' +assert_log_count 'port-forward --address=127.0.0.1' 4 'pf starts one forward per present service' +pf_owner_read_record "${pids_dir}/control-ui.pid" || fail 'control-ui record is unreadable' +control_ui_pid="${PF_OWNER_RECORD_PID}" +assert_alive "${control_ui_pid}" 'the recorded control-ui forward is live' +# The global current-context names another session's cluster (reset_state); +# pf never reads or changes it: the identity check runs through +# --context= before the first forward, and every cluster call names it. +assert_log_order "kubectl --context=${profile} --request-timeout=10s get nodes -o json" 'port-forward --address=127.0.0.1' \ + 'pf verifies the cluster identity before the first forward' +cluster_calls="$(grep '^kubectl ' "${state}/calls.log" | grep -v '^kubectl config ' || true)" +[[ -n "${cluster_calls}" ]] || fail 'pf made no cluster call' +ok +unscoped="$(grep -Fv -- "kubectl --context=${profile} " <<<"${cluster_calls}" || true)" +[[ -z "${unscoped}" ]] || fail "pf made cluster calls without --context=${profile}: ${unscoped}" +ok +assert_log_lacks 'kubectl config current-context' 'pf never reads the global context' +assert_log_lacks 'kubectl config use-context' 'pf never changes the global context' +[[ "$(cat "${state}/current-context")" == other-session-context ]] || fail 'pf changed the global context' +ok + +reset_state +bp health-ok health +assert_rc 0 'health with every probe answering' +assert_log_count 'curl ' 4 'health probes every present service' + +reset_state +printf '%s\n' "http://127.0.0.1:$(awk -F= '$1 == "CONTROL_API_PORT" { print $2; exit }' "${profile_dir}/ports.env")/health" \ + >"${state}/curl-fail" +bp health-one-fails health +assert_rc 1 'health with one failing probe' +assert_output_has '1 health check(s) failed' 'health counts the failing probe' +assert_log_count 'curl ' 4 'health still probes every service after one failure' + +reset_state +bp stop-pf-owned stop-pf +assert_rc 0 'stop-pf of verified records' +assert_output_has 'stopped or cleared verified control-ui record' 'stop-pf reports each verified record' +for name in control-ui control-api external-rest-api rpc-proxy; do + assert_no_file "${pids_dir}/${name}.pid" "stop-pf removes ${name}" +done +assert_dead "${control_ui_pid}" 'stop-pf terminates the verified control-ui forward' + +reset_state +printf '%s\n' control-plane/control-ui control-plane/control-api profiles/external-rest-api >"${state}/services" +bp pf-required-absent pf +assert_rc 1 'pf with a required service absent' +assert_output_has 'FAIL rpc-proxy' 'pf names the absent required service' +assert_output_has '1 port-forward(s) failed to start' 'pf counts the failure' +for name in control-ui control-api external-rest-api; do + assert_file "${pids_dir}/${name}.pid" "pf still starts ${name} after another forward failed" +done +bp stop-pf-after-partial stop-pf +assert_rc 0 'stop-pf after a partial pf' + +# === pf-health clears its own forwards on exit ================================ +reset_state +bp pf-health pf-health +assert_rc 0 'pf-health' +assert_log_count 'port-forward --address=127.0.0.1' 4 'pf-health starts every forward' +assert_log_count 'curl ' 4 'pf-health probes every forward' +for name in control-ui control-api external-rest-api rpc-proxy; do + assert_no_file "${pids_dir}/${name}.pid" "pf-health clears ${name} on exit" +done + +# === stop / delete ============================================================ +reset_state +bp stop-pf-before-stop pf +assert_rc 0 'pf before stop' +bp stop-clean stop +assert_rc 0 'stop with verified records' +assert_log_has "minikube -p ${profile} stop pidfiles=0" 'stop clears every record before minikube stop' + +reset_state +printf '12345\n' >"${pids_dir}/control-ui.pid" +bp stop-unverified stop +assert_rc 1 'stop with an unverifiable record' +assert_log_has "minikube -p ${profile} stop" 'stop still stops minikube when a record cannot be verified' +assert_output_has "${pids_dir}/control-ui.pid" 'stop names the pidfile to retire by hand' +assert_file "${pids_dir}/control-ui.pid" 'stop keeps the unverifiable record' + +reset_state +bp delete-unconfirmed delete +assert_rc 1 'delete without confirmation' +assert_output_has "Re-run with CONFIRM_DELETE=${profile}" 'delete without confirmation names the confirmation' +assert_log_lacks 'minikube' 'delete without confirmation must not call minikube' +assert_file "${pids_dir}/control-ui.pid" 'delete without confirmation leaves records alone' + +reset_state +bp delete-unverified delete "CONFIRM_DELETE=${profile}" +assert_rc 1 'delete with an unverifiable record' +assert_log_has "minikube -p ${profile} delete" 'delete still deletes the profile when a record cannot be verified' +assert_output_has "${pids_dir}/control-ui.pid" 'delete names the pidfile to retire by hand' +assert_file "${pids_dir}/control-ui.pid" 'delete keeps the unverifiable record' +rm -f "${pids_dir}/control-ui.pid" + +reset_state +bp delete-pf pf +assert_rc 0 'pf before delete' +bp delete-clean delete "CONFIRM_DELETE=${profile}" +assert_rc 0 'delete with verified records' +assert_log_has "minikube -p ${profile} delete pidfiles=0" 'delete clears every record before minikube delete' + +# A failed minikube stop or delete is the command's exit code, even when every +# port-forward record was cleared. +reset_state +: >"${state}/minikube-stop-fails" +bp stop-minikube-fails stop +assert_rc 9 'stop returns the exit code of a failed minikube stop' +assert_log_has "minikube -p ${profile} stop pidfiles=0" 'stop ran the failing minikube stop with no records left' + +reset_state +: >"${state}/minikube-delete-fails" +bp delete-minikube-fails delete "CONFIRM_DELETE=${profile}" +assert_rc 11 'delete returns the exit code of a failed minikube delete' +assert_log_has "minikube -p ${profile} delete pidfiles=0" 'delete ran the failing minikube delete with no records left' + +# A die inside cmd_stop_pf (here: a symlinked pids directory) is contained by +# the subshell in stop_pf_before_minikube: minikube stop still runs and the +# command fails. +reset_state +mv "${pids_dir}" "${tmp}/pids-aside" +mkdir -p "${tmp}/pids-target" +ln -s "${tmp}/pids-target" "${pids_dir}" +bp stop-pf-dies stop +assert_rc_nonzero 'stop must fail when clearing the port-forward records dies' +# Witness: the die that the subshell contained. +assert_output_has "refusing symlinked port-forward PID directory: ${pids_dir}" 'stop reached the dying stop-pf' +assert_log_has "minikube -p ${profile} stop" 'a die in stop-pf must not prevent minikube stop' +rm -f "${pids_dir}" +mv "${tmp}/pids-aside" "${pids_dir}" + +# === stop / delete: the context must name a profile minikube knows ============ +# A local endpoint does not prove that the context named after the profile is +# this profile's: another local cluster, or one on a private address, passes +# the endpoint check, and `minikube -p

delete` on a profile minikube does +# not know removes the kubeconfig context of that name. A stopped profile +# cannot be asked who it is, so stop and delete ask minikube which profiles it +# has and refuse a context whose profile it does not list, before clearing any +# port-forward record. The witness is the profile list read that decided. +for action in stop delete; do + reset_state + bp "unknown-profile-${action}-pf" pf + assert_rc 0 "pf before ${action} of a profile minikube does not know" + write_minikube_profiles clerum-another-local-profile + : >"${state}/calls.log" + bp "unknown-profile-${action}" "${action}" "CONFIRM_DELETE=${profile}" + assert_rc 1 "${action} of a profile minikube does not know" + assert_output_has "BRANCH_PROFILE_UNKNOWN_MINIKUBE_PROFILE: minikube lists no profile ${profile}" \ + "${action} of a profile minikube does not know" + assert_log_has 'minikube profile list -o json' "${action} read minikube's profiles" + assert_log_lacks "minikube -p ${profile}" "${action} of a profile minikube does not know must not run minikube -p" + assert_log_lacks 'kubectl --context=' "${action} of a profile minikube does not know must not address the cluster" + assert_file "${pids_dir}/control-ui.pid" "${action} of a profile minikube does not know keeps the port-forward records" + bp "unknown-profile-${action}-stop-pf" stop-pf + assert_rc 0 "stop-pf after the refused ${action}" +done + +# A profile list that cannot be read is a refusal, not an empty list. +for action in stop delete; do + reset_state + : >"${state}/minikube-profile-list-fails" + bp "profile-list-fails-${action}" "${action}" "CONFIRM_DELETE=${profile}" + assert_rc 1 "${action} when minikube profile list fails" + assert_output_has 'BRANCH_PROFILE_MINIKUBE_PROFILES_UNREADABLE: minikube profile list failed' \ + "${action} when minikube profile list fails" + assert_log_has 'minikube profile list -o json' "${action} tried to read minikube's profiles" + assert_log_lacks "minikube -p ${profile}" "${action} when minikube profile list fails must not run minikube -p" +done + +# Output that is not the {"invalid": [...], "valid": [...]} shape, with a +# string Name on every entry, is refused too, even when it mentions the +# profile: an error body, a missing list, an entry without a name. +unreadable_lists=( + 'not json' + '{"error":{"Advice":"x"}}' + '[]' + "{\"valid\":[{\"Name\":\"${profile}\"}]}" + "{\"invalid\":null,\"valid\":[{\"Name\":\"${profile}\"}]}" + "{\"invalid\":[],\"valid\":[{\"Name\":\"${profile}\"},{\"Status\":\"Running\"}]}" + "{\"invalid\":[],\"valid\":[{\"Name\":\"${profile}\"},\"${profile}\"]}" +) +for index in "${!unreadable_lists[@]}"; do + reset_state + printf '%s\n' "${unreadable_lists[${index}]}" >"${state}/minikube-profile-list" + bp "profile-list-unreadable-${index}" delete "CONFIRM_DELETE=${profile}" + assert_rc 1 "delete with unreadable profile list #${index}" + assert_output_has 'BRANCH_PROFILE_MINIKUBE_PROFILES_UNREADABLE: minikube profile list printed no readable profile lists' \ + "delete with unreadable profile list #${index}" + assert_log_has 'minikube profile list -o json' "delete with unreadable profile list #${index} read minikube's profiles" + assert_log_lacks "minikube -p ${profile}" "delete with unreadable profile list #${index} must not run minikube -p" +done + +# A known profile that is stopped (its cluster does not answer) is still +# stopped and deleted; so is one minikube lists as invalid. +for action in stop delete; do + reset_state + rm -f "${state}/reachable" + printf '{"invalid":[],"valid":[{"Name":"%s","Status":"Stopped"}]}\n' "${profile}" \ + >"${state}/minikube-profile-list" + bp "stopped-known-${action}" "${action}" "CONFIRM_DELETE=${profile}" + assert_rc 0 "${action} of a stopped profile minikube knows" + assert_log_has 'minikube profile list -o json' "${action} of a stopped profile read minikube's profiles" + assert_log_has "minikube -p ${profile} ${action} pidfiles=0" "${action} of a stopped profile minikube knows ran" +done +reset_state +printf '{"invalid":[{"Name":"%s","Status":"Unknown"}],"valid":[]}\n' "${profile}" \ + >"${state}/minikube-profile-list" +for action in stop delete; do + : >"${state}/calls.log" + bp "invalid-known-${action}" "${action}" "CONFIRM_DELETE=${profile}" + assert_rc 0 "${action} of a profile minikube lists as invalid" + assert_log_has 'minikube profile list -o json' "${action} of an invalid profile read minikube's profiles" + assert_log_has "minikube -p ${profile} ${action} pidfiles=0" "${action} of a profile minikube lists as invalid ran" +done + +# Without a context named after the profile there is nothing of another +# cluster's to remove, so minikube is not asked and delete runs. +reset_state +write_kube_contexts '' '' +write_minikube_profiles +bp no-context-delete delete "CONFIRM_DELETE=${profile}" +assert_rc 0 'delete of a profile without a kube context' +# Witness: the kubeconfig read that found no context. +assert_log_has 'kubectl config get-contexts -o name' 'delete without a kube context read the kubeconfig' +assert_log_lacks 'minikube profile list' 'delete without a kube context must not need minikube profile list' +assert_log_has "minikube -p ${profile} delete pidfiles=0" 'delete of a profile without a kube context ran' + +# === stop / delete: a cluster that answers must identify the profile ========= +# minikube listing the profile does not prove that the context named after it +# reaches that profile's cluster: another local cluster, or a LAN cluster on a +# private address, can sit behind it. When the cluster answers, stop and +# delete check the node identity, as start, status, pf and health do, before +# clearing any port-forward record or running minikube -p. A stopped profile +# cannot answer, so an unreachable cluster is still stopped and deleted. +for action in stop delete; do + reset_state + bp "foreign-identity-${action}-pf" pf + assert_rc 0 "pf before ${action} of a profile whose reachable cluster is another" + printf 'lan-cluster\n' >"${state}/node-label" + foreign_pid_record_before="$(shasum -a 256 "${pids_dir}/control-ui.pid" | awk '{print $1}')" + : >"${state}/calls.log" + bp "foreign-identity-${action}" "${action}" "CONFIRM_DELETE=${profile}" + assert_output_has 'BRANCH_PROFILE_CONTEXT_IDENTITY' "${action} of a profile whose reachable cluster is another" + assert_output_has "BRANCH_PROFILE_CONTEXT_IDENTITY: kube context ${profile} does not identify Minikube profile ${profile} at 192.168.49.2" \ + "${action} of a profile whose reachable cluster is another" + assert_rc 1 "${action} of a profile whose reachable cluster is another" + # Witness: the cluster answered, and the identity was read from it and from + # minikube. + assert_log_has "kubectl --context=${profile} --request-timeout=10s cluster-info" \ + "${action} asked whether the cluster answers" + assert_log_has "minikube -p ${profile} ip" "${action} asked minikube for the profile's IP" + assert_log_has "kubectl --context=${profile} --request-timeout=10s get nodes -o json" \ + "${action} read the node identity" + assert_log_lacks "minikube -p ${profile} ${action}" \ + "${action} of a profile whose reachable cluster is another must not run minikube ${action}" + assert_file "${pids_dir}/control-ui.pid" \ + "${action} of a profile whose reachable cluster is another keeps the port-forward records" + [[ "$(shasum -a 256 "${pids_dir}/control-ui.pid" | awk '{print $1}')" == "${foreign_pid_record_before}" ]] || + fail "${action} of a foreign cluster changed the port-forward record" + ok + assert_log_lacks 'port-forward' \ + "${action} of a profile whose reachable cluster is another must not stop a forward through the API" + rm -f "${state}/node-label" + bp "foreign-identity-${action}-stop-pf" stop-pf + assert_rc 0 "stop-pf after the refused ${action}" +done + +# An invalid profile can still have a reachable API context. If minikube +# cannot report its IP, the identity is unobservable rather than foreign: +# recovery continues to the confirmed stop/delete after a bounded attempt. +for action in stop delete; do + reset_state + printf '{"invalid":[{"Name":"%s","Status":"Unknown"}],"valid":[]}\n' "${profile}" \ + >"${state}/minikube-profile-list" + : >"${state}/minikube-ip-fails" + bp "invalid-ip-fails-${action}" "${action}" "CONFIRM_DELETE=${profile}" + assert_rc 0 "${action} of an invalid profile whose IP cannot be read" + assert_output_has "cluster identity is unobservable for profile ${profile}" \ + "${action} reports that unreadable identity is not a foreign-cluster refusal" + assert_log_has "kubectl --context=${profile} --request-timeout=10s cluster-info" \ + "${action} proved the API was reachable before treating identity as unobservable" + assert_log_has "minikube -p ${profile} ip" "${action} attempted to read the invalid profile's IP" + assert_log_lacks 'get nodes -o json' "${action} must not invent a readable node identity without an IP" + assert_log_has "minikube -p ${profile} ${action} pidfiles=0" \ + "${action} recovered an invalid profile whose IP cannot be read" +done + +# A failed node inspection is also unobservable. It must not be collapsed into +# a foreign-cluster mismatch: no readable node evidence contradicts the profile. +for action in stop delete; do + reset_state + : >"${state}/fail-get-nodes" + bp "identity-nodes-fail-${action}" "${action}" "CONFIRM_DELETE=${profile}" + assert_rc 0 "${action} when the node identity inspection fails" + assert_output_has 'cluster identity is unobservable' \ + "${action} reports the failed inspection without calling it foreign" + assert_output_lacks 'does not identify Minikube profile' \ + "${action} does not hide a failed inspection as a foreign mismatch" + assert_log_has "minikube -p ${profile} ip" "${action} read the expected profile IP" + assert_log_has "kubectl --context=${profile} --request-timeout=10s get nodes -o json" \ + "${action} attempted the node inspection" + assert_log_has "minikube -p ${profile} ${action} pidfiles=0" \ + "${action} recovers when the node inspection fails" +done + +# The identity attempt itself is finite: a hanging `minikube ip` is cut off by +# the bounded runner, then recovery reaches stop/delete instead of stranding +# the invalid profile until an unbounded child exits. +for action in stop delete; do + reset_state + printf '{"invalid":[{"Name":"%s","Status":"Unknown"}],"valid":[]}\n' "${profile}" \ + >"${state}/minikube-profile-list" + printf '30\n' >"${state}/minikube-ip-hang-seconds" + timeout_started="${SECONDS}" + bp "invalid-ip-timeout-${action}" "${action}" "CONFIRM_DELETE=${profile}" \ + "BRANCH_PROFILE_MINIKUBE_STATUS_TIMEOUT_SECONDS=1" + timeout_elapsed=$((SECONDS - timeout_started)) + assert_rc 0 "${action} after a finite minikube-ip timeout" + (( timeout_elapsed <= 8 )) || + fail "${action} took ${timeout_elapsed}s to bound a 30s minikube-ip hang" + ok + assert_log_has "minikube -p ${profile} ip" "${action} entered the hanging IP read" + assert_log_has "minikube -p ${profile} ${action} pidfiles=0" \ + "${action} recovered after the bounded IP timeout" +done + +for action in stop delete; do + reset_state + bp "own-identity-${action}" "${action}" "CONFIRM_DELETE=${profile}" + assert_rc 0 "${action} of a profile whose reachable cluster identifies it" + assert_log_order "kubectl --context=${profile} --request-timeout=10s get nodes -o json" \ + "minikube -p ${profile} ${action} pidfiles=0" \ + "${action} checked the node identity before minikube ${action}" +done + +for action in stop delete; do + reset_state + rm -f "${state}/reachable" + bp "unreachable-identity-${action}" "${action}" "CONFIRM_DELETE=${profile}" + assert_rc 0 "${action} of a profile whose cluster does not answer" + # Witness: the reachability probe ran and found no cluster. + assert_log_has "kubectl --context=${profile} --request-timeout=10s cluster-info" \ + "${action} of a profile whose cluster does not answer asked whether it answers" + assert_log_lacks 'get nodes -o json' \ + "${action} of a profile whose cluster does not answer must not read the node identity" + assert_log_has "minikube -p ${profile} ${action} pidfiles=0" \ + "${action} of a profile whose cluster does not answer still ran minikube ${action}" +done + +# === start / setup: the same check before minikube creates or deploys ======== +# `minikube start -p

` for a profile minikube does not know creates that +# profile and writes the kubeconfig context of its name, and setup hands the +# profile to full-setup.sh, which starts and deploys it. start checks the node +# identity only after minikube start has run, and setup never does, so a +# local context named after an unknown profile is refused before either: +# after setup's CONFIRM_PROFILE refusal and the endpoint check, and before the +# Docker probe, any state or shim write, minikube and full-setup.sh. +for action in start setup; do + reset_state + write_minikube_profiles clerum-another-local-profile + bp "unknown-profile-${action}" "${action}" "CONFIRM_PROFILE=${profile}" + assert_rc 1 "${action} of a profile minikube does not know" + assert_output_has "BRANCH_PROFILE_UNKNOWN_MINIKUBE_PROFILE: minikube lists no profile ${profile}" \ + "${action} of a profile minikube does not know" + assert_log_has 'minikube profile list -o json' "${action} read minikube's profiles" + assert_log_lacks 'minikube start' "${action} of a profile minikube does not know must not run minikube start" + assert_log_lacks "minikube -p ${profile}" "${action} of a profile minikube does not know must not run minikube -p" + assert_log_lacks 'kubectl --context=' "${action} of a profile minikube does not know must not address the cluster" + assert_no_calls_to docker "${action} of a profile minikube does not know must not reach Docker" + assert_output_lacks 'running isolated setup' "${action} of a profile minikube does not know must not run full-setup.sh" + + reset_state + : >"${state}/minikube-profile-list-fails" + bp "profile-list-fails-${action}" "${action}" "CONFIRM_PROFILE=${profile}" + assert_rc 1 "${action} when minikube profile list fails" + assert_output_has 'BRANCH_PROFILE_MINIKUBE_PROFILES_UNREADABLE: minikube profile list failed' \ + "${action} when minikube profile list fails" + assert_log_has 'minikube profile list -o json' "${action} tried to read minikube's profiles" + assert_log_lacks 'minikube start' "${action} when minikube profile list fails must not run minikube start" + assert_no_calls_to docker "${action} when minikube profile list fails must not reach Docker" +done + +reset_state +printf '{"error":{"Advice":"x"}}\n' >"${state}/minikube-profile-list" +bp profile-list-unreadable-start start +assert_rc 1 'start with an unreadable profile list' +assert_output_has 'BRANCH_PROFILE_MINIKUBE_PROFILES_UNREADABLE: minikube profile list printed no readable profile lists' \ + 'start with an unreadable profile list' +assert_log_has 'minikube profile list -o json' "start with an unreadable profile list read minikube's profiles" +assert_log_lacks 'minikube start' 'start with an unreadable profile list must not run minikube start' + +# A brand-new profile has neither a minikube profile nor a kube context: +# minikube is not asked and start creates it. +reset_state +write_kube_contexts '' '' +write_minikube_profiles +bp new-profile-start start +assert_rc 0 'start of a brand-new profile' +# Witness: the kubeconfig read that found no context. +assert_log_has 'kubectl config get-contexts -o name' 'start of a brand-new profile read the kubeconfig' +assert_log_lacks 'minikube profile list' 'start of a brand-new profile must not need minikube profile list' +assert_log_has "minikube start -p ${profile} --keep-context" 'start of a brand-new profile ran minikube start' + +# A stopped profile minikube knows is started, after the profile list read. +reset_state +printf '{"invalid":[],"valid":[{"Name":"%s","Status":"Stopped"}]}\n' "${profile}" \ + >"${state}/minikube-profile-list" +bp stopped-known-start start +assert_rc 0 'start of a stopped profile minikube knows' +assert_log_order 'minikube profile list -o json' "minikube start -p ${profile}" \ + 'start of a stopped profile minikube knows read the profile list before minikube start' + +# setup of a profile minikube knows gets past the check: the Docker probe it +# runs next is made to fail, so full-setup.sh is not reached. +reset_state +: >"${state}/docker-info-fails" +bp known-profile-setup setup "CONFIRM_PROFILE=${profile}" +assert_rc 1 'setup of a profile minikube knows, with Docker unreachable' +# Witness: the refusal that comes after the check. +assert_output_has 'Docker daemon is not reachable through the isolated bounded probe' \ + 'setup of a profile minikube knows reached the Docker probe' +assert_log_order 'minikube profile list -o json' 'docker info' \ + 'setup of a profile minikube knows read the profile list before the Docker probe' +assert_output_lacks 'BRANCH_PROFILE_UNKNOWN_MINIKUBE_PROFILE' 'setup of a profile minikube knows is not refused as unknown' +assert_output_lacks 'running isolated setup' 'setup with Docker unreachable must not run full-setup.sh' + +# An unconfirmed setup is refused before minikube is asked. +reset_state +bp setup-unconfirmed setup +assert_rc 1 'setup without confirmation' +assert_output_has "Re-run with CONFIRM_PROFILE=${profile}" 'setup without confirmation names the confirmation' +assert_no_calls_to minikube 'setup without confirmation must not call minikube' + +# === stop-pf attempts every record ============================================ +# record_pf_pids : PF_PIDS gets the recorded PID of each named forward. +record_pf_pids() { + local name + PF_PIDS=() + for name in "$@"; do + pf_owner_read_record "${pids_dir}/${name}.pid" || fail "${name} record is unreadable" + PF_PIDS+=("${PF_OWNER_RECORD_PID}") + done + ok +} + +# An unknown pidfile name is kept and reported, and does not stop the verified +# records from being stopped before minikube stop runs. +reset_state +bp stop-stray-pf pf +assert_rc 0 'pf before a stop with a stray record' +record_pf_pids control-ui control-api external-rest-api rpc-proxy +printf '12345\n' >"${pids_dir}/stray.pid" +bp stop-stray stop +assert_rc_nonzero 'stop with a stray record among verified ones' +# pidfiles=1: only the stray record was left when minikube stop ran. +assert_log_has "minikube -p ${profile} stop pidfiles=1" 'stop cleared the verified records before minikube stop' +assert_output_has 'stopped or cleared verified rpc-proxy record' 'stop reached the verified records' +for name in control-ui control-api external-rest-api rpc-proxy; do + assert_no_file "${pids_dir}/${name}.pid" "stop removes the verified ${name} record next to a stray one" +done +for pid in "${PF_PIDS[@]}"; do + assert_dead "${pid}" 'stop terminates every verified forward next to a stray record' +done +assert_file "${pids_dir}/stray.pid" 'stop keeps the stray record' +assert_output_has " ${pids_dir}/stray.pid" 'stop lists the kept stray record' +rm -f "${pids_dir}/stray.pid" + +# The first record in glob order cannot be verified; every later verified +# record is still stopped and removed. +reset_state +bp stop-pf-first-unverified-pf pf +assert_rc 0 'pf before a stop-pf whose first record is unverifiable' +record_pf_pids control-api +orphaned_control_api_pid="${PF_PIDS[0]}" +record_pf_pids control-ui external-rest-api rpc-proxy +printf '12345\n' >"${pids_dir}/control-api.pid" +bp stop-pf-first-unverified stop-pf +assert_rc 1 'stop-pf whose first record is unverifiable' +for name in control-ui external-rest-api rpc-proxy; do + assert_no_file "${pids_dir}/${name}.pid" "stop-pf removes ${name} after an unverifiable first record" +done +for pid in "${PF_PIDS[@]}"; do + assert_dead "${pid}" 'stop-pf terminates the verified forwards after an unverifiable first record' +done +assert_file "${pids_dir}/control-api.pid" 'stop-pf keeps the unverifiable first record' +assert_output_has " ${pids_dir}/control-api.pid" 'stop-pf lists the kept first record' +kill -KILL "${orphaned_control_api_pid}" 2>/dev/null || true +rm -f "${pids_dir}/control-api.pid" + +# === stop-pf keeps records it cannot prove are stale ========================== +reset_state +write_control_ui_record "$(dead_pid)" 'Mon Jan 1 00:00:00 2024' "${other_worktree}" +bp stop-pf-foreign-worktree stop-pf +assert_rc 1 'stop-pf with another worktree record' +assert_output_has 'belongs to a different profile, context, worktree, service, or port binding' \ + 'stop-pf ran the ownership check on the foreign record' +assert_output_lacks 'retiring' 'stop-pf must not retire another worktree record' +assert_output_lacks 'stopped or cleared verified control-ui record' 'stop-pf must not report a foreign record as cleared' +assert_file "${pids_dir}/control-ui.pid" 'stop-pf keeps another worktree record' +rm -f "${pids_dir}/control-ui.pid" + +# Same PID-reuse shape as the retirement below (live PID, different start +# time), but the record is bound to another worktree: the binding check in +# stop_own_pf must keep it, because nothing proves the other worktree's +# forward is gone. +reset_state +start_foreign_process +write_control_ui_record "${FOREIGN_PID}" 'Mon Jan 1 00:00:00 2024' "${other_worktree}" +bp stop-pf-foreign-worktree-live stop-pf +assert_rc 1 'stop-pf with a live other-worktree record whose start differs' +assert_output_has 'belongs to a different profile, context, worktree, service, or port binding' \ + 'stop-pf ran the ownership check on the live other-worktree record' +assert_output_has " ${pids_dir}/control-ui.pid" 'stop-pf lists the kept other-worktree record' +assert_output_lacks 'retiring' 'stop-pf must not retire a live other-worktree record' +assert_file "${pids_dir}/control-ui.pid" 'stop-pf keeps a live other-worktree record' +assert_alive "${FOREIGN_PID}" 'stop-pf never signals the process behind another worktree record' +rm -f "${pids_dir}/control-ui.pid" + +# pf must not start a forward over a record it could not stop: start_pf runs +# under `|| failed++`, where errexit is suspended, so a failed stop_own_pf has +# to return explicitly or the control-ui forward is launched anyway. +reset_state +write_control_ui_record "$(dead_pid)" 'Mon Jan 1 00:00:00 2024' "${other_worktree}" +bp pf-over-foreign-record pf +assert_rc 1 'pf with another worktree control-ui record' +assert_output_has 'belongs to a different profile, context, worktree, service, or port binding' \ + 'pf ran the ownership check on the other-worktree control-ui record' +assert_output_has '1 port-forward(s) failed to start' 'pf counts the control-ui forward it refused to start' +# Witness: the other required forwards were launched in the same run. +assert_log_count 'port-forward --address=127.0.0.1' 3 'pf starts every other present service' +assert_log_count 'port-forward --address=127.0.0.1 svc/control-ui' 0 \ + 'pf must not launch a control-ui forward over a record it could not stop' +pf_owner_read_record "${pids_dir}/control-ui.pid" || fail 'the other-worktree control-ui record is unreadable' +[[ "${PF_OWNER_RECORD_WORKTREE}" == "${other_worktree}" ]] || + fail "pf replaced the other-worktree control-ui record (worktree=${PF_OWNER_RECORD_WORKTREE})" +ok +rm -f "${pids_dir}/control-ui.pid" +bp pf-over-foreign-record-cleanup stop-pf +assert_rc 0 'stop-pf clears the forwards pf started next to the foreign record' + +reset_state +start_foreign_process +write_control_ui_record "${FOREIGN_PID}" "$(pf_owner_process_start "${FOREIGN_PID}")" "${repo}" +bp stop-pf-live-foreign stop-pf +assert_rc 1 'stop-pf with a live non-kubectl process under an exact-start record' +assert_output_has 'does not have the exact recorded kubectl argv' 'stop-pf ran the argv check' +assert_output_lacks 'retiring' 'stop-pf must not retire a record whose process start still matches' +assert_file "${pids_dir}/control-ui.pid" 'stop-pf keeps the record of a live process it cannot verify' +assert_alive "${FOREIGN_PID}" 'stop-pf never signals a process it cannot verify' +rm -f "${pids_dir}/control-ui.pid" + +reset_state +start_foreign_process +write_control_ui_record "${FOREIGN_PID}" 'Mon Jan 1 00:00:00 2024' "${repo}" +bp stop-pf-pid-reuse stop-pf +assert_rc 0 'stop-pf with a reused PID' +assert_output_has "retiring control-ui record: pid=${FOREIGN_PID}" 'stop-pf reports the PID-reuse retirement' +assert_output_lacks 'stopped or cleared verified control-ui record' 'a retired record is not reported as verified' +assert_no_file "${pids_dir}/control-ui.pid" 'stop-pf retires the reused-PID record' +assert_alive "${FOREIGN_PID}" 'stop-pf does not signal the process that reused the PID' + +# === start restores a global context minikube switched ======================== +reset_state +bp start-keeps-context start +assert_rc 0 'start that keeps the global context' +assert_log_has "minikube start -p ${profile} --keep-context" 'start runs minikube start' +assert_log_has "minikube -p ${profile} status" 'start checks status after a clean start' +[[ "$(cat "${state}/current-context")" == other-session-context ]] || fail 'start changed the global context' +ok +assert_log_lacks 'kubectl config use-context' 'start that keeps the global context restores nothing' + +# minikube v1.38.1 repairs the kubeconfig endpoint of a restarted profile with +# KeepContext false, so the switch is expected on every restart: start puts the +# previous context back and continues, because every call it makes names +# --context= and the cluster identity is checked through it. +reset_state +: >"${state}/start-switches-context" +bp start-switches-context start +assert_rc 0 'start after minikube switched the global context' +assert_output_has "WARN: minikube moved the kubectl current-context from other-session-context to ${profile}" \ + 'start reports the switch and its cause' +assert_log_has 'kubectl config use-context other-session-context' 'start restores the previous global context' +[[ "$(cat "${state}/current-context")" == other-session-context ]] || + fail "start left the global context on $(cat "${state}/current-context")" +ok +assert_log_order 'kubectl config use-context other-session-context' "minikube -p ${profile} status" \ + 'start restores the global context before it continues' +assert_log_has "kubectl --context=${profile} --request-timeout=10s get nodes -o json" \ + 'start verifies the cluster identity through --context after the switch' + +reset_state +: >"${state}/start-switches-context" +: >"${state}/use-context-fails" +bp start-restore-fails start +assert_rc 1 'start whose restore of the global context fails' +assert_log_has 'kubectl config use-context other-session-context' 'start attempted the restore' +assert_output_has "ERROR: could not restore the kubectl current-context to other-session-context; it is still ${profile}" \ + 'start reports the failed restore' +assert_log_lacks "minikube -p ${profile} status" 'start stops when the global context stays on the profile' + +reset_state +: >"${state}/current-context" +: >"${state}/start-switches-context" +bp start-switches-unset-context start +assert_rc 0 'start after minikube set a global context where none was set' +assert_log_has 'kubectl config unset current-context' 'start clears the context minikube set' +[[ ! -s "${state}/current-context" ]] || fail "start left the global context on $(cat "${state}/current-context")" +ok +assert_log_has "minikube -p ${profile} status" 'start continues after clearing the context' + +# Another session may switch the global context while minikube runs; start +# only undoes a switch onto its own profile, so that choice is left alone. +reset_state +printf 'third-session-context\n' >"${state}/start-switches-context" +bp start-other-session-switch start +assert_rc 0 'start while another session switched the global context' +assert_log_has "minikube -p ${profile} status" 'start ran past the global-context check' +assert_log_lacks 'kubectl config use-context' 'start does not overwrite the other session context' +assert_log_lacks 'kubectl config unset' 'start does not clear the other session context' +[[ "$(cat "${state}/current-context")" == third-session-context ]] || + fail "start replaced the other session context with $(cat "${state}/current-context")" +ok + +# A minikube start that fails after moving the global context is still undone +# (R4-L7): the restore runs from an EXIT trap installed around minikube start, +# and the failed start's own exit code is the command's. +reset_state +: >"${state}/start-switches-context" +: >"${state}/minikube-start-fails" +bp start-fails-switched start +# Witness: the failing minikube start ran and its exit code came back. +assert_rc 7 'start returns the exit code of a failed minikube start' +assert_log_has "minikube start -p ${profile} --keep-context" 'the failing start ran minikube start' +[[ "$(cat "${state}/current-context")" == other-session-context ]] || + fail "failed start left the global context on $(cat "${state}/current-context")" +ok +assert_log_has 'kubectl config use-context other-session-context' 'failed start restores the previous global context' +assert_output_has "WARN: minikube moved the kubectl current-context from other-session-context to ${profile}" \ + 'failed start reports the switch it undid' +assert_log_lacks "minikube -p ${profile} status" 'a failed start does not go on to minikube status' + +# The same with no previous context: the one minikube set is cleared. +reset_state +: >"${state}/current-context" +: >"${state}/start-switches-context" +: >"${state}/minikube-start-fails" +bp start-fails-switched-unset start +assert_rc 7 'start returns the exit code of a failed minikube start when no context was set' +assert_log_has "minikube start -p ${profile} --keep-context" 'the failing start ran minikube start with no context set' +[[ ! -s "${state}/current-context" ]] || + fail "failed start left the global context on $(cat "${state}/current-context")" +ok +assert_log_has 'kubectl config unset current-context' 'failed start clears the context minikube set' + +# A restore that fails in the trap is reported, but the failed start's exit +# code is not replaced by it. +reset_state +: >"${state}/start-switches-context" +: >"${state}/minikube-start-fails" +: >"${state}/use-context-fails" +bp start-fails-restore-fails start +assert_rc 7 'a failed restore does not mask the exit code of a failed minikube start' +assert_log_has 'kubectl config use-context other-session-context' 'failed start attempted the restore' +assert_output_has "ERROR: could not restore the kubectl current-context to other-session-context; it is still ${profile}" \ + 'failed start reports the failed restore' + +# A context another session chose while the failing start ran is left alone. +reset_state +printf 'third-session-context\n' >"${state}/start-switches-context" +: >"${state}/minikube-start-fails" +bp start-fails-other-session-switch start +assert_rc 7 'failed start while another session switched the global context' +# Witness: the trap read the global context after the failed start. +assert_log_count 'kubectl config current-context' 2 'failed start read the global context before and after minikube start' +assert_log_lacks 'kubectl config use-context' 'failed start does not overwrite the other session context' +assert_log_lacks 'kubectl config unset' 'failed start does not clear the other session context' +[[ "$(cat "${state}/current-context")" == third-session-context ]] || + fail "failed start replaced the other session context with $(cat "${state}/current-context")" +ok + +# === each action checks every command it needs before dispatch =============== +# run_bounded runs through node, the context guards through kubectl, python3 +# and minikube, and pf/health through curl. A missing one used to surface as +# `command not found` in the middle of an action, after other commands had +# already run. Each action now names every missing command before it does +# anything. The cases run with a PATH that holds the stubs, the real node and +# python3, and a mirror of the host PATH without them, minus exactly one. +host_node="$(command -v node)" || fail 'node is required to run this suite' +host_python3="$(command -v python3)" || fail 'python3 is required to run this suite' +mirror_bin="${tmp}/mirror-bin" +mkdir -p "${mirror_bin}" +IFS=: read -r -a host_path_dirs <<<"${PATH}" +for host_dir in "${host_path_dirs[@]}"; do + [[ "${host_dir}" == /* && -d "${host_dir}" && "${host_dir}" != "${stub_bin}" ]] || continue + mirror_links=() + for candidate in "${host_dir}"/*; do + name="${candidate##*/}" + case "${name}" in node | python3 | kubectl | minikube | curl | helm | docker) continue ;; esac + [[ -f "${candidate}" && -x "${candidate}" && ! -e "${mirror_bin}/${name}" && ! -L "${mirror_bin}/${name}" ]] || continue + mirror_links+=("${candidate}") + done + (( ${#mirror_links[@]} == 0 )) || ln -s "${mirror_links[@]}" "${mirror_bin}/" +done +[[ -x "${mirror_bin}/bash" && -x "${mirror_bin}/git" && -x "${mirror_bin}/shasum" ]] || + fail 'the host PATH mirror lacks bash, git or shasum' +ok + +# path_without : prints a PATH with the stubs, the real node and python3 +# and the host mirror, without . +path_without() { + local omit="$1" dir="${tmp}/path-without-$1" bin + rm -rf "${dir}" + mkdir -p "${dir}" + for bin in kubectl minikube curl helm docker; do + [[ "${bin}" == "${omit}" ]] || ln -s "${stub_bin}/${bin}" "${dir}/${bin}" + done + [[ "${omit}" == node ]] || ln -s "${host_node}" "${dir}/node" + [[ "${omit}" == python3 ]] || ln -s "${host_python3}" "${dir}/python3" + printf '%s:%s' "${dir}" "${mirror_bin}" +} + +for action in pf pf-health health status start stop delete setup; do + case "${action}" in + pf | pf-health | health) needed='node minikube kubectl python3 curl' ;; + *) needed='node minikube kubectl python3' ;; + esac + for bin in ${needed}; do + reset_state + bp "missing-${bin}-${action}" "${action}" "PATH=$(path_without "${bin}")" \ + "CONFIRM_DELETE=${profile}" "CONFIRM_PROFILE=${profile}" + assert_output_has "missing required command(s): ${bin}" "${action} without ${bin}" + assert_rc 1 "${action} without ${bin}" + assert_no_calls_to minikube "${action} without ${bin} must not call minikube" + assert_no_calls_to kubectl "${action} without ${bin} must not call kubectl" + assert_no_calls_to docker "${action} without ${bin} must not reach Docker" + done +done + +# Witnesses that the table is per action: status and stop do not need curl, +# and still run to completion without it. +reset_state +bp status-without-curl status "PATH=$(path_without curl)" +assert_rc 0 'status without curl' +assert_log_has "minikube -p ${profile} status" 'status without curl ran minikube status' +reset_state +bp stop-without-curl stop "PATH=$(path_without curl)" +assert_rc 0 'stop without curl' +assert_log_has "minikube -p ${profile} stop pidfiles=0" 'stop without curl ran minikube stop' + +# === prepare-shims: symlinks and the seed allowlist =========================== +reset_state +bp prepare-shims prepare-shims +assert_rc 0 'prepare-shims' +shims_dir="${profile_dir}/scripts/minikube" +assert_file "${shims_dir}/seed-test-data.sh" 'prepare-shims copies the seed script' +assert_file "${profile_dir}/shims.env" 'prepare-shims writes shims.env' + +seed_project="${tmp}/seed-project" +mkdir -p "${seed_project}/scripts/e2e" +cat >"${seed_project}/scripts/e2e/load-dotenv.sh" <<'EOF_DOTENV' +dotenv_load_canonical_root() { return 0; } +EOF_DOTENV +cat >"${seed_project}/scripts/e2e/admin-credentials.sh" <<'EOF_ADMIN' +e2e_resolve_admin_password() { printf 'fixture-admin-password\n'; } +EOF_ADMIN +cat >"${seed_project}/scripts/e2e/seed-e2e-data.sh" <<'EOF_SEED' +printf 'CONTEXT=%s ALLOWED_CONTEXTS=%s\n' "${CONTEXT}" "${ALLOWED_CONTEXTS:-}" >>"${SEED_LOG:?}" +EOF_SEED +seed_log="${tmp}/seed.log" +run_seed_shim() { + : >"${seed_log}" + env CLERUM_PROJECT_DIR="${seed_project}" SEED_LOG="${seed_log}" SEED_PROFILE=minimal \ + CONTEXT="$1" bash "${shims_dir}/seed-test-data.sh" >"${out_dir}/seed-$1.log" 2>&1 || + fail "seed shim failed for CONTEXT=$1" +} +run_seed_shim "${profile}" +grep -Fqx "CONTEXT=${profile} ALLOWED_CONTEXTS=${profile}" "${seed_log}" || + fail "seed shim did not authorize the branch profile itself: $(cat "${seed_log}")" +ok +for foreign_context in clerum clerum-dev; do + run_seed_shim "${foreign_context}" + # Witness: the seed ran and recorded this context; the allowlist stayed empty. + grep -Fqx "CONTEXT=${foreign_context} ALLOWED_CONTEXTS=" "${seed_log}" || + fail "seed shim authorized ${foreign_context}: $(cat "${seed_log}")" + ok +done + +outside="${tmp}/outside" +mkdir -p "${outside}/minikube" +printf 'keep\n' >"${outside}/minikube/keep.txt" +rm -rf "${profile_dir}/scripts" +ln -s "${outside}" "${profile_dir}/scripts" +bp prepare-shims-symlink prepare-shims +assert_rc 1 'prepare-shims with a symlinked scripts directory' +assert_output_has 'BRANCH_PROFILE_SHIM_SYMLINK' 'prepare-shims names the symlink refusal' +assert_file "${outside}/minikube/keep.txt" 'prepare-shims must not delete through a symlinked scripts directory' +rm -f "${profile_dir}/scripts" + +# === setup: always runs the current scripts and manifests (J3) ================ +# Shims prepared on one tree must not be reused by a setup on a newer tree: +# setup deploys with the copies it runs, while the images come from REPO_DIR. +# The fixture's full-setup.sh is replaced by a stub that names its tree and +# prints the deploy file it was handed, for tree A and then tree B. +write_fixture_tree() { + cat >"${repo}/scripts/minikube/full-setup.sh" <"${repo}/deploy/minikube/fixture.txt" +} +reset_state +write_fixture_tree A +bp shims-tree-a prepare-shims +assert_rc 0 'prepare-shims on tree A' +write_fixture_tree B +bp setup-tree-b setup "CONFIRM_PROFILE=${profile}" +assert_rc 0 'setup on tree B after prepare-shims on tree A' +# Witness: setup reached the script it runs. +assert_output_has 'running isolated setup' 'setup on tree B ran full-setup.sh' +assert_output_has 'fixture full-setup tree=B deploy=fixture deploy file B' \ + 'setup on tree B runs tree B script and manifests' +assert_output_lacks 'tree=A' 'setup on tree B must not run the tree A copy' +cp "${ROOT}/scripts/minikube/full-setup.sh" "${repo}/scripts/minikube/full-setup.sh" +printf 'fixture deploy file\n' >"${repo}/deploy/minikube/fixture.txt" + +minikube_test_assert_host_unchanged || + fail 'the lifecycle fixture changed the host checkout HEAD, branch, or working tree' +ok +(( ASSERTIONS >= 1000 )) || fail "expected at least 1000 assertions, ran ${ASSERTIONS}" +printf 'PASS: branch-profile lifecycle scenarios (%s assertions)\n' "${ASSERTIONS}" diff --git a/scripts/tests/test-minikube-build-images-hardening.sh b/scripts/tests/test-minikube-build-images-hardening.sh index 64541a1e9e..eab5c03422 100755 --- a/scripts/tests/test-minikube-build-images-hardening.sh +++ b/scripts/tests/test-minikube-build-images-hardening.sh @@ -141,6 +141,7 @@ prepare_fixture() { "$FIXTURE/deploy/minikube" "$FIXTURE/control-api" cp \ "$ROOT/scripts/minikube/build-images.sh" \ + "$ROOT/scripts/minikube/context-identity.sh" \ "$ROOT/scripts/minikube/docker-cli-env.sh" \ "$ROOT/scripts/minikube/image-mode.sh" \ "$ROOT/scripts/minikube/port-forward-owner.sh" \ @@ -174,6 +175,8 @@ prepare_lease() { lock_key="$(printf '%s\0%s\0%s\0%s\0%s' \ "$FIXTURE" "$branch" "$head" "$PROFILE" "$PROFILE" | shasum | awk '{print $1}')" lock_dir="$LOCK_ROOT/$PROFILE.lock" + # The fixture has no origin/dev; the owner pins its only commit. + LEASE_ORIGIN_DEV="$head" mkdir -p "$lock_dir" { printf 'REPOSITORY=%s\n' "$FIXTURE" @@ -183,6 +186,7 @@ prepare_lease() { printf 'CONTEXT=%s\n' "$PROFILE" printf 'WORKTREE_ID=%s\n' "$worktree_id" printf 'LOCK_KEY=%s\n' "$lock_key" + printf 'ORIGIN_DEV=%s\n' "$LEASE_ORIGIN_DEV" printf '%s=%s\n' TOKEN "$LEASE_VALUE" printf 'PID=%s\n' "$$" printf 'PROCESS_START=unavailable\n' @@ -215,6 +219,7 @@ run_build() { "T2_PROJECT_DIR=$FIXTURE" "T2_PROFILE=$PROFILE" "T2_CONTEXT=$PROFILE" + "T2_PINNED_ORIGIN_DEV=$LEASE_ORIGIN_DEV" "${LEASE_ENV_KEY}=$LEASE_VALUE" ) ;; @@ -223,6 +228,7 @@ run_build() { "T2_PROJECT_DIR=$FIXTURE" "T2_PROFILE=$PROFILE" "T2_CONTEXT=$PROFILE" + "T2_PINNED_ORIGIN_DEV=$LEASE_ORIGIN_DEV" "${LEASE_ENV_KEY}=wrong-value" ) ;; @@ -231,6 +237,7 @@ run_build() { "T2_PROJECT_DIR=$FIXTURE" "T2_PROFILE=$PROFILE" T2_CONTEXT=foreign-context + "T2_PINNED_ORIGIN_DEV=$LEASE_ORIGIN_DEV" "${LEASE_ENV_KEY}=$LEASE_VALUE" ) ;; diff --git a/scripts/tests/test-minikube-mutation-boundary.sh b/scripts/tests/test-minikube-mutation-boundary.sh index dd308c1c1a..04354d1485 100755 --- a/scripts/tests/test-minikube-mutation-boundary.sh +++ b/scripts/tests/test-minikube-mutation-boundary.sh @@ -41,12 +41,15 @@ PROFILE=${PROFILE} CONTEXT=${PROFILE} WORKTREE_ID=${WORKTREE_ID} LOCK_KEY=${LOCK_KEY} +ORIGIN_DEV=${MINIKUBE_TEST_ORIGIN_DEV} TOKEN=${TOKEN} PID=${owner_pid} PROCESS_START=unavailable EOF } write_owner +# Lease children inherit the owner's pinned origin/dev. +export T2_PINNED_ORIGIN_DEV="${MINIKUBE_TEST_ORIGIN_DEV}" # GNU Make may execute recipe lines containing $(MAKE) during a dry run. Force # the dry-run flag into the child environment so with-t2-mutation-lock.sh diff --git a/scripts/tests/test-minikube-sync-auth-key-durable.sh b/scripts/tests/test-minikube-sync-auth-key-durable.sh index a30c97c999..e4f4b08b46 100755 --- a/scripts/tests/test-minikube-sync-auth-key-durable.sh +++ b/scripts/tests/test-minikube-sync-auth-key-durable.sh @@ -43,11 +43,13 @@ PROFILE=fake CONTEXT=fake WORKTREE_ID=${T2_TEST_WORKTREE_ID} LOCK_KEY=${T2_TEST_LOCK_KEY} +ORIGIN_DEV=${MINIKUBE_TEST_ORIGIN_DEV} TOKEN=${T2_LOCK_TOKEN} PID=$$ PROCESS_START=${T2_PROCESS_START} EOF export T2_PROJECT_DIR="${MINIKUBE_TEST_PROJECT_DIR}" T2_PROFILE=fake T2_CONTEXT=fake T2_LOCK_ROOT T2_LOCK_TOKEN +export T2_PINNED_ORIGIN_DEV="${MINIKUBE_TEST_ORIGIN_DEV}" # The discovery helper resolves target-key provenance from a sanitized view of # the exact ConfigMaps and Secrets referenced by candidate containers. These diff --git a/scripts/tests/test-minikube-sync-auth-key.sh b/scripts/tests/test-minikube-sync-auth-key.sh index f7f0850944..45dc790265 100755 --- a/scripts/tests/test-minikube-sync-auth-key.sh +++ b/scripts/tests/test-minikube-sync-auth-key.sh @@ -43,11 +43,13 @@ PROFILE=fake CONTEXT=fake WORKTREE_ID=${T2_TEST_WORKTREE_ID} LOCK_KEY=${T2_TEST_LOCK_KEY} +ORIGIN_DEV=${MINIKUBE_TEST_ORIGIN_DEV} TOKEN=${T2_LOCK_TOKEN} PID=$$ PROCESS_START=${T2_PROCESS_START} EOF export T2_PROJECT_DIR="${MINIKUBE_TEST_PROJECT_DIR}" T2_PROFILE=fake T2_CONTEXT=fake T2_LOCK_ROOT T2_LOCK_TOKEN +export T2_PINNED_ORIGIN_DEV="${MINIKUBE_TEST_ORIGIN_DEV}" cat >"${TMP_DIR}/sleep" <<'SH' #!/usr/bin/env bash diff --git a/scripts/tests/test-minikube-t2-contract.sh b/scripts/tests/test-minikube-t2-contract.sh index 8c460964b8..591378a4be 100755 --- a/scripts/tests/test-minikube-t2-contract.sh +++ b/scripts/tests/test-minikube-t2-contract.sh @@ -26,6 +26,7 @@ for file in "$MINIKUBE_DIR/profile-readiness.sh" "$ROOT/scripts/tests/test-minik "$ROOT/scripts/tests/lib/minikube-fixture-repo.sh" \ "$ROOT/scripts/tests/test-minikube-t2-public-boundary.sh" \ "$ROOT/scripts/tests/test-minikube-t2-scenarios.sh" \ + "$ROOT/scripts/tests/test-branch-profile-lifecycle.sh" \ "$ROOT/scripts/tests/test-minikube-t2-proxy-runtime.sh" \ "$ROOT/scripts/tests/test-minikube-t2-control-api-runtime.sh" \ "$ROOT/scripts/tests/test-minikube-image-capability-fixture.sh" \ @@ -100,6 +101,22 @@ for identity_file in "$COMMON" "$ROOT/scripts/minikube/sync-auth-key.sh" "$ROOT/ grep -Fq 't2_worktree_id' "$identity_file" done grep -Fq 'bash "$T2_PROJECT_DIR/scripts/tests/test-minikube-t2-contract.sh"' "$T2" +# run_t0 runs under the lease owner but does not hold the lease, so its children +# must not inherit the pinned origin/dev: t2_resolve_origin_dev refuses a pin +# without T2_SKIP_LOCK=true. Every child starts through t2_run_outside_lease; +# T0 receives origin/dev explicitly as T0_ORIGIN_DEV. +# Continuation lines are joined so a wrapped command is checked as one line. +run_t0_body="$(awk '/^run_t0\(\) \{/{f=1} !f{next} sub(/\\$/, ""){buf=buf $0; next} {print buf $0; buf=""} /^\}/{exit}' "$T2")" +[ -n "$run_t0_body" ] || { printf 'FAIL: run_t0 not found in t2.sh\n' >&2; exit 1; } +unwrapped_t0_children="$(printf '%s\n' "$run_t0_body" | grep -E '^[[:space:]]*(bash |T0_PROJECT_DIR=)' | grep -Ev '^[[:space:]]*bash -n ' || true)" +[ -z "$unwrapped_t0_children" ] || { + printf 'FAIL: run_t0 starts a child without t2_run_outside_lease:\n%s\n' "$unwrapped_t0_children" >&2 + exit 1 +} +[ "$(printf '%s\n' "$run_t0_body" | grep -Ec '^[[:space:]]*t2_run_outside_lease ')" -eq 4 ] || { + printf 'FAIL: run_t0 must start t0.sh, T2_T0_COMMAND, the contract test and the setup-handoff test through t2_run_outside_lease\n' >&2 + exit 1 +} required_codes="DEVELOPMENT_SCOPE_REQUIRED PROFILE_OWNERSHIP_MISMATCH PROFILE_BUSY PROFILE_LOCK_REQUIRED HEAD_MARKER_MISMATCH IMAGE_MANIFEST_MISMATCH BOOTSTRAP_REQUIRED CERTIFICATION_REQUIRED SECRET_MISSING CONFIGMAP_MISSING POSTGRES_NOT_READY REAL_PG_REQUIRED_BUT_UNAVAILABLE REAL_PG_SUITE_FAILED REAL_PG_REPORT_INCOMPLETE UNSUPPORTED_T1_CONCURRENCY ZERO_TESTS_EXECUTED PORT_FORWARD_CONFLICT NP08_HCC_AUTHORIZATION_FAILED PLAYWRIGHT_FAILED" for code in $required_codes; do @@ -430,6 +447,7 @@ bash -c ' source "$1" parsed="$(t2_get_name control-plane/control-postgres)" test "$parsed" = "control-plane control-postgres" + T2_ORIGIN_DEV=0123456789abcdef0123456789abcdef01234567 t2_lock_acquire t2_lock_release ' bash "$COMMON" @@ -448,6 +466,7 @@ T2_WORKTREE_ID=contract-worktree \ bash -c ' common="$1" source "$common" + T2_ORIGIN_DEV=0123456789abcdef0123456789abcdef01234567 t2_lock_acquire token="$T2_LOCK_TOKEN" export T2_PROJECT_DIR T2_BRANCH T2_HEAD T2_ORIGIN_DEV T2_MERGE_BASE @@ -481,7 +500,7 @@ EOF if T2_LOCK_ROOT="$tmp/locks" MINIKUBE_PROFILE=busy-profile T2_CONTEXT=busy-profile \ T2_PROJECT_DIR="$ROOT" T2_BRANCH=test/minikube-contract T2_HEAD=0123456789abcdef \ T2_ORIGIN_DEV=0123456789abcdef T2_MERGE_BASE=0123456789abcdef \ - bash -c 'source "$1"; t2_lock_acquire' bash "$COMMON" 2>"$tmp/busy.err"; then + bash -c 'source "$1"; T2_ORIGIN_DEV=0123456789abcdef0123456789abcdef01234567; t2_lock_acquire' bash "$COMMON" 2>"$tmp/busy.err"; then echo 'FAIL: live profile owner was replaced' >&2 exit 1 fi @@ -491,13 +510,27 @@ mkdir -p "$tmp/locks/empty-profile.lock" if T2_LOCK_ROOT="$tmp/locks" MINIKUBE_PROFILE=empty-profile T2_CONTEXT=empty-profile \ T2_PROJECT_DIR="$ROOT" T2_BRANCH=test/minikube-contract T2_HEAD=0123456789abcdef \ T2_ORIGIN_DEV=0123456789abcdef T2_MERGE_BASE=0123456789abcdef \ - bash -c 'source "$1"; t2_lock_acquire' bash "$COMMON" 2>"$tmp/empty.err"; then + bash -c 'source "$1"; T2_ORIGIN_DEV=0123456789abcdef0123456789abcdef01234567; t2_lock_acquire' bash "$COMMON" 2>"$tmp/empty.err"; then echo 'FAIL: an ownerless profile lock was reclaimed' >&2 exit 1 fi grep -Fq 'PROFILE_BUSY' "$tmp/empty.err" grep -Fq 'orphaned' "$tmp/empty.err" +# The lease owner pins origin/dev for its children, so it cannot take the +# lease without one. +if T2_LOCK_ROOT="$tmp/locks" MINIKUBE_PROFILE=clerum-unpinned T2_CONTEXT=clerum-unpinned \ + T2_PROJECT_DIR="$ROOT" T2_BRANCH=test/minikube-contract T2_HEAD=0123456789abcdef \ + bash -c 'source "$1"; t2_lock_acquire' bash "$COMMON" 2>"$tmp/unpinned.err"; then + echo 'FAIL: a lease owner without origin/dev acquired the lock' >&2 + exit 1 +fi +grep -Fq 'DEVELOPMENT_SCOPE_REQUIRED: the lease owner has no origin/dev to pin' "$tmp/unpinned.err" +if [ -e "$tmp/locks/clerum-unpinned.lock" ]; then + echo 'FAIL: an unpinned lease owner left a lock behind' >&2 + exit 1 +fi + cert_root="$tmp/certifications" mkdir -p "$cert_root/prior" "$cert_root/current" CERT_FILE="$cert_root/prior/evidence.json" EXPECTED_REPOSITORY="$ROOT" \ diff --git a/scripts/tests/test-minikube-t2-lock-race.sh b/scripts/tests/test-minikube-t2-lock-race.sh index dcd81dc03b..d52b657b6b 100755 --- a/scripts/tests/test-minikube-t2-lock-race.sh +++ b/scripts/tests/test-minikube-t2-lock-race.sh @@ -50,6 +50,7 @@ source "${TEST_COMMON}" T2_PROJECT_DIR="${TEST_PROJECT_DIR}" T2_BRANCH=feat/lock-race T2_HEAD=1111111111111111111111111111111111111111 +T2_ORIGIN_DEV=2222222222222222222222222222222222222222 T2_PROFILE=race-profile T2_CONTEXT=race-profile T2_WORKTREE_ID=lock-race-worktree diff --git a/scripts/tests/test-minikube-t2-proxy-runtime.sh b/scripts/tests/test-minikube-t2-proxy-runtime.sh index cf33740596..e564f3d814 100755 --- a/scripts/tests/test-minikube-t2-proxy-runtime.sh +++ b/scripts/tests/test-minikube-t2-proxy-runtime.sh @@ -142,11 +142,12 @@ ps() { case "$*" in *lstart=*) printf 'Tue Sep 15 01:00:00 2026\n' ;; *state=*) printf 'S\n' ;; *) return 1 ;; esac } export -f ps +T2_ORIGIN_DEV="$MINIKUBE_TEST_ORIGIN_DEV" t2_lock_acquire run_healthcheck_if_requested run_playwright_if_requested cp "$T2_LOCK_DIR/owner.env" "$tmp/owner-original" -for key in TOKEN REPOSITORY BRANCH HEAD PROFILE CONTEXT WORKTREE_ID LOCK_KEY PROCESS_START PID; do +for key in TOKEN REPOSITORY BRANCH HEAD PROFILE CONTEXT WORKTREE_ID LOCK_KEY ORIGIN_DEV PROCESS_START PID; do python3 - "$tmp/owner-original" "$T2_LOCK_DIR/owner.env" "$key" <<'PY' import sys from pathlib import Path diff --git a/scripts/tests/test-minikube-t2-scenarios.sh b/scripts/tests/test-minikube-t2-scenarios.sh index fe94946767..5b7861ef27 100755 --- a/scripts/tests/test-minikube-t2-scenarios.sh +++ b/scripts/tests/test-minikube-t2-scenarios.sh @@ -546,4 +546,111 @@ bash "$ROOT/scripts/tests/test-minikube-t2-process-owner.sh" grep -Fq 'REUSE_DB=true' "$ROOT/scripts/minikube/t2.sh" grep -Fq 'CONTROL_DB_RESET_PVC_UID' "$ROOT/scripts/minikube/t2.sh" +# The lease owner pins origin/dev once. A fetch from any worktree that shares +# this repository moves refs/remotes/origin/dev while a lane runs; a lease +# child must keep validating against the pin instead of the moved ref. +expect_message() { + local expected="$1" message="$2" label="$3" output="$4" + shift 4 + expect_code "$expected" "$label" "$output" "$@" + grep -Fq "$message" "$tmp/$output" || { + printf '%s\n' "$(sed -n '1,20p' "$tmp/$output")" >&2 + fail "$label did not report: $message" + } +} +dev_tree="$(git -C "$repo" rev-parse "$base_sha^{tree}")" +dev_next="$(git -C "$repo" commit-tree "$dev_tree" -p "$base_sha" -m dev-next)" +dev_forced="$(git -C "$repo" commit-tree "$dev_tree" -p "$base_sha" -m dev-forced)" +missing_sha="$(printf 'e%.0s' {1..40})" +pin_env=(env -u T2_PINNED_ORIGIN_DEV -u T2_SKIP_LOCK "${repo_env[@]}") +print_origin_dev='source "$1"; t2_repo_metadata; printf "ORIGIN_DEV=%s\n" "$T2_ORIGIN_DEV"' +git -C "$repo" update-ref refs/remotes/origin/dev "$dev_next" + +"${pin_env[@]}" T2_SKIP_LOCK=true T2_PINNED_ORIGIN_DEV="$base_sha" \ + bash -c "$print_origin_dev" bash "$COMMON" >"$tmp/pin-moved.out" 2>&1 || { + printf '%s\n' "$(sed -n '1,20p' "$tmp/pin-moved.out")" >&2 + fail 'a lease child must validate against the pinned origin/dev after the local ref moved' +} +grep -Fqx "ORIGIN_DEV=$base_sha" "$tmp/pin-moved.out" || fail 'a lease child must adopt the pinned origin/dev' +grep -Fq "origin/dev moved during the lane: pinned $base_sha, local ref now $dev_next" "$tmp/pin-moved.out" || + fail 'a lease child must report that the local origin/dev moved' + +expect_message DEVELOPMENT_SCOPE_REQUIRED 'origin/dev is not an ancestor of HEAD' unpinned-moved-dev unpinned-moved-dev \ + "${pin_env[@]}" bash -c "$print_origin_dev" bash "$COMMON" +expect_message DEVELOPMENT_SCOPE_REQUIRED 'T2_PINNED_ORIGIN_DEV is not a full commit SHA' short-pin short-pin \ + "${pin_env[@]}" T2_SKIP_LOCK=true T2_PINNED_ORIGIN_DEV=abc123 bash -c "$print_origin_dev" bash "$COMMON" +expect_message DEVELOPMENT_SCOPE_REQUIRED 'T2_PINNED_ORIGIN_DEV is not a commit in this repository' missing-pin missing-pin \ + "${pin_env[@]}" T2_SKIP_LOCK=true T2_PINNED_ORIGIN_DEV="$missing_sha" bash -c "$print_origin_dev" bash "$COMMON" +expect_message DEVELOPMENT_SCOPE_REQUIRED 'the pinned origin/dev is not an ancestor of the current origin/dev' head-as-pin head-as-pin \ + "${pin_env[@]}" T2_SKIP_LOCK=true T2_PINNED_ORIGIN_DEV="$feature_sha" bash -c "$print_origin_dev" bash "$COMMON" +expect_message DEVELOPMENT_SCOPE_REQUIRED 'T2_PINNED_ORIGIN_DEV is only honored under an inherited T2 lease' pin-without-lease pin-without-lease \ + "${pin_env[@]}" T2_PINNED_ORIGIN_DEV="$base_sha" bash -c "$print_origin_dev" bash "$COMMON" +expect_message DEVELOPMENT_SCOPE_REQUIRED 'an inherited T2 lease carries no pinned origin/dev' lease-without-pin lease-without-pin \ + "${pin_env[@]}" T2_SKIP_LOCK=true bash -c "$print_origin_dev" bash "$COMMON" + +git -C "$repo" update-ref refs/remotes/origin/dev "$dev_forced" +expect_message DEVELOPMENT_SCOPE_REQUIRED 'the pinned origin/dev is not an ancestor of the current origin/dev' force-pushed-dev force-pushed-dev \ + "${pin_env[@]}" T2_SKIP_LOCK=true T2_PINNED_ORIGIN_DEV="$dev_next" bash -c "$print_origin_dev" bash "$COMMON" + +git -C "$repo" update-ref refs/remotes/origin/dev "$base_sha" +"${pin_env[@]}" T2_SKIP_LOCK=true T2_PINNED_ORIGIN_DEV="$base_sha" \ + bash -c "$print_origin_dev" bash "$COMMON" >"$tmp/pin-current.out" 2>&1 || { + printf '%s\n' "$(sed -n '1,20p' "$tmp/pin-current.out")" >&2 + fail 'a lease child whose pin equals the local origin/dev must pass' +} +grep -Fqx "ORIGIN_DEV=$base_sha" "$tmp/pin-current.out" || fail 'a lease child must adopt a pin equal to the local ref' +grep -Fq 'origin/dev moved' "$tmp/pin-current.out" && fail 'a pin equal to the local origin/dev must not report a move' + +# The lease owner records the pin in owner.env and exports it; an inherited +# lease whose pin differs from the owner's, or is empty, is refused. +# The children are require-t2-mutation-lock.sh, the boundary that validates a +# lease without resolving repository metadata again. +"${pin_env[@]}" T2_LOCK_ROOT="$tmp/pin-locks" bash -c ' + common="$1" base="$2" other="$3" out="$4" child="$5" print="$6" + source "$common" + t2_repo_metadata + t2_lock_acquire + grep -Fqx "ORIGIN_DEV=$base" "$T2_LOCK_DIR/owner.env" || { echo "owner.env does not record the pinned origin/dev" >&2; exit 11; } + [ "$(bash -c '\''printf %s "$T2_PINNED_ORIGIN_DEV"'\'')" = "$base" ] || { echo "the lease owner does not export the pinned origin/dev" >&2; exit 12; } + # T0 runs under the owner without holding the lease (no T2_SKIP_LOCK): a child + # that inherits the pin is refused, which is how the first lane on 6ca44cbeb + # died inside T0. t2_run_outside_lease starts such a child without the pin. + if bash -c "$print" bash "$common" >"$out/unleased-leak.out" 2>&1; then + echo "an unleased child that inherits the pin was accepted" >&2; exit 20 + fi + grep -Fq "T2_PINNED_ORIGIN_DEV is only honored under an inherited T2 lease" "$out/unleased-leak.out" || { cat "$out/unleased-leak.out" >&2; exit 21; } + t2_run_outside_lease bash -c "$print" bash "$common" >"$out/unleased.out" 2>&1 || + { cat "$out/unleased.out" >&2; echo "a child started through t2_run_outside_lease was refused" >&2; exit 22; } + grep -Fqx "ORIGIN_DEV=$base" "$out/unleased.out" || { cat "$out/unleased.out" >&2; exit 23; } + [ "$(t2_run_outside_lease bash -c '\''printf %s "${T2_PINNED_ORIGIN_DEV-unset}"'\'')" = unset ] || + { echo "t2_run_outside_lease passed the pin to its child" >&2; exit 24; } + T2_LOCK_TOKEN="$T2_LOCK_TOKEN" bash "$child" || + { echo "an inherited lease carrying the owner pin was refused" >&2; exit 13; } + if T2_LOCK_TOKEN="$T2_LOCK_TOKEN" T2_PINNED_ORIGIN_DEV="$other" bash "$child" 2>"$out/pin-mismatch.err"; then + echo "an inherited lease carrying another pin was accepted" >&2; exit 14 + fi + grep -Fq "PROFILE_OWNERSHIP_MISMATCH: profile lock owner does not match ORIGIN_DEV" "$out/pin-mismatch.err" || { cat "$out/pin-mismatch.err" >&2; exit 15; } + if T2_LOCK_TOKEN="$T2_LOCK_TOKEN" T2_PINNED_ORIGIN_DEV= bash "$child" 2>"$out/pin-empty.err"; then + echo "an inherited lease without a pin was accepted" >&2; exit 16 + fi + grep -Fq "PROFILE_OWNERSHIP_MISMATCH: profile lock owner does not match ORIGIN_DEV" "$out/pin-empty.err" || { cat "$out/pin-empty.err" >&2; exit 17; } + # A lock record without ORIGIN_DEV must not match an empty pin either. + grep -v "^ORIGIN_DEV=" "$T2_LOCK_DIR/owner.env" >"$out/owner-unpinned.env" + cp "$out/owner-unpinned.env" "$T2_LOCK_DIR/owner.env" + if T2_LOCK_TOKEN="$T2_LOCK_TOKEN" T2_PINNED_ORIGIN_DEV= bash "$child" 2>"$out/pin-unrecorded.err"; then + echo "a lease without a pin was accepted against a lock record without ORIGIN_DEV" >&2; exit 18 + fi + grep -Fq "PROFILE_OWNERSHIP_MISMATCH: profile lock owner does not match ORIGIN_DEV" "$out/pin-unrecorded.err" || { cat "$out/pin-unrecorded.err" >&2; exit 19; } + t2_lock_release 0 +' bash "$COMMON" "$base_sha" "$dev_next" "$tmp" "$ROOT/scripts/minikube/require-t2-mutation-lock.sh" "$print_origin_dev" || + fail 'the lease owner must record and export the pinned origin/dev, its lease children must match it, and unleased children must not inherit it' +[ ! -e "$tmp/pin-locks/$profile.lock" ] || fail 'the pinned lease was not released' + +# branch-profile.sh lifecycle (preflight, start, status, pf, health, pf-health, +# stop-pf, stop, delete, prepare-shims) is exercised end to end against a +# fixture repository with PATH stubs for kubectl, minikube, helm, docker and +# curl; it asserts exit codes, the stub call log and pidfile state, including +# that pf never launches a forward over a record start_pf could not stop. +bash "$ROOT/scripts/tests/test-branch-profile-lifecycle.sh" + printf 'PASS: local Minikube T0/T1/T2 scenario checks\n' diff --git a/scripts/tests/test-repo-intake-packet.sh b/scripts/tests/test-repo-intake-packet.sh index eed3fa56c1..a17396f333 100755 --- a/scripts/tests/test-repo-intake-packet.sh +++ b/scripts/tests/test-repo-intake-packet.sh @@ -84,6 +84,10 @@ run_packet_with_profile() { ) } +# A bare "yes"/"no"/"dev" matches any line of the packet, so each value is +# asserted on its own label row, formatted exactly as the packet's kv() prints it. +kv_row() { printf '%-30s %s' "$1:" "$2"; } + payload_value() { local payload="$1" key="$2" awk -F= -v wanted="${key}" '$1 == wanted { sub(/^[^=]*=/, ""); print; exit }' <<<"${payload}" @@ -121,8 +125,8 @@ git -C "${MAIN_REPO}" config user.email "test@example.invalid" git -C "${MAIN_REPO}" config user.name "Clerum Test" printf '{"scripts":{"test":"true"}}\n' >"${MAIN_REPO}/package.json" -mkdir -p "${MAIN_REPO}/.local-notes/minikube-profiles" -printf '# test helper\n' >"${MAIN_REPO}/.local-notes/minikube-profiles/branch.mk" +mkdir -p "${MAIN_REPO}/scripts/minikube-profiles" +printf '# worktree-local helper (scripts/minikube-profiles)\n' >"${MAIN_REPO}/scripts/minikube-profiles/branch.mk" git -C "${MAIN_REPO}" add package.json git -C "${MAIN_REPO}" commit -q -m "initial" git -C "${MAIN_REPO}" update-ref refs/remotes/origin/dev HEAD @@ -141,12 +145,12 @@ fi assert_contains "${PRIMARY_OUTPUT}" "repo_root:" "primary output includes repo root label" assert_contains "${PRIMARY_OUTPUT}" "${MAIN_REPO}" "primary output includes primary repo path" assert_contains "${PRIMARY_OUTPUT}" "branch:" "primary output includes branch label" -assert_contains "${PRIMARY_OUTPUT}" "dev" "primary output reports branch" +assert_contains "${PRIMARY_OUTPUT}" "$(kv_row branch dev)" "primary output reports branch" assert_contains "${PRIMARY_OUTPUT}" "profile_helper_exists:" "primary output includes profile helper label" -assert_contains "${PRIMARY_OUTPUT}" "yes" "primary output finds profile helper" +assert_contains "${PRIMARY_OUTPUT}" "$(kv_row profile_helper_exists yes)" "primary output finds profile helper" assert_contains "${PRIMARY_OUTPUT}" "profile_owner_resolver:" "primary output includes profile owner resolver label" assert_contains "${PRIMARY_OUTPUT}" "profile_cache_state:" "primary output includes profile cache state" -assert_contains "${PRIMARY_OUTPUT}" "absent" "primary output reports an uninitialized isolated profile root" +assert_contains "${PRIMARY_OUTPUT}" "$(kv_row profile_cache_state absent)" "primary output reports an uninitialized isolated profile root" assert_not_contains "${PRIMARY_OUTPUT}" "SENSITIVE_MARKER_DO_NOT_PRINT" "primary output does not print file contents" assert_not_contains "${PRIMARY_OUTPUT}" "local-sensitive.txt" "primary output does not print local sensitive filenames" assert_not_contains "${PRIMARY_OUTPUT}" "unbound variable" "primary output has no shell unbound-variable warnings" @@ -272,16 +276,39 @@ assert_contains "${DETACHED_OUTPUT}" "repo_root:" "detached output includes repo assert_contains "${DETACHED_OUTPUT}" "${DETACHED_WT}" "detached output uses current worktree root" assert_contains "${DETACHED_OUTPUT}" "primary_checkout:" "detached output includes primary checkout label" assert_contains "${DETACHED_OUTPUT}" "${MAIN_REPO}" "detached output resolves primary checkout" -assert_contains "${DETACHED_OUTPUT}" "detached:" "detached output includes detached label" -assert_contains "${DETACHED_OUTPUT}" "yes" "detached output reports detached state" -assert_contains "${DETACHED_OUTPUT}" "profile_helper_local:" "detached output includes local helper label" -assert_contains "${DETACHED_OUTPUT}" "no" "detached output tolerates missing local .local-notes" -assert_contains "${DETACHED_OUTPUT}" "profile_helper_primary:" "detached output includes primary helper label" -assert_contains "${DETACHED_OUTPUT}" "yes" "detached output finds primary .local-notes helper" +assert_contains "${DETACHED_OUTPUT}" "$(kv_row detached yes)" "detached output reports detached state" +assert_contains "${DETACHED_OUTPUT}" "$(kv_row profile_helper_local no)" "detached output tolerates missing local scripts helper" +assert_contains "${DETACHED_OUTPUT}" "$(kv_row profile_helper_primary yes)" "detached output finds primary scripts helper" +assert_contains "${DETACHED_OUTPUT}" \ + "$(kv_row profile_helper_command "make -f ${MAIN_REPO}/scripts/minikube-profiles/branch.mk branch-profile-info")" \ + "detached output falls back to the primary scripts helper when the worktree has none" assert_not_contains "${DETACHED_OUTPUT}" "unbound variable" "detached output has no shell unbound-variable warnings" +# Precedence: when both the worktree and the primary checkout carry the helper, +# the packet must point at the worktree's own copy, because that is the +# branch-profile code the branch under test ships. +LOCAL_HELPER_WT="${TMP_ROOT}/local-helper" +git -C "${MAIN_REPO}" worktree add -q -b feat/local-helper "${LOCAL_HELPER_WT}" HEAD +mkdir -p "${LOCAL_HELPER_WT}/scripts/minikube-profiles" +printf '# worktree-owned helper\n' >"${LOCAL_HELPER_WT}/scripts/minikube-profiles/branch.mk" +LOCAL_HELPER_ROOT="${TMP_ROOT}/local-helper-profiles" +mkdir -p "${LOCAL_HELPER_ROOT}" +LOCAL_HELPER_OUTPUT="$(run_packet "${LOCAL_HELPER_WT}" "${LOCAL_HELPER_ROOT}" 2>&1)" +LOCAL_HELPER_STATUS=$? +if [[ "${LOCAL_HELPER_STATUS}" -eq 0 || "${LOCAL_HELPER_STATUS}" -eq 2 ]]; then + pass "repo intake packet runs in a worktree that carries its own helper" +else + fail "repo intake packet runs in a worktree that carries its own helper" + echo "${LOCAL_HELPER_OUTPUT}" +fi +assert_contains "${LOCAL_HELPER_OUTPUT}" "$(kv_row profile_helper_local yes)" "local-helper output finds the worktree helper" +assert_contains "${LOCAL_HELPER_OUTPUT}" "$(kv_row profile_helper_primary yes)" "local-helper output also finds the primary helper" +assert_contains "${LOCAL_HELPER_OUTPUT}" \ + "$(kv_row profile_helper_command "make -f ${LOCAL_HELPER_WT}/scripts/minikube-profiles/branch.mk branch-profile-info")" \ + "local-helper output prefers the worktree helper over the primary one" + # A run that skipped its assertions must not exit 0. -if (( PASSED < 44 )); then - fail "expected at least 44 passing assertions, got ${PASSED}" +if (( PASSED < 46 )); then + fail "expected at least 46 passing assertions, got ${PASSED}" fi exit "${FAIL}" diff --git a/work-tracker/specs/attachment-reconciliation-provenance.md b/work-tracker/specs/attachment-reconciliation-provenance.md new file mode 100644 index 0000000000..3c7a2714d1 --- /dev/null +++ b/work-tracker/specs/attachment-reconciliation-provenance.md @@ -0,0 +1,46 @@ +# Attachment reconciliation provenance + +## Identity evidence + +An authoritative server message owns its turn number and role. A local message +belongs to that turn only when it has the same server turn number and role (or +the corresponding deterministic `turn-N-role` ID), or when one active task ID +binds exactly one eligible local message to exactly one server message. These +are **identified replacements**. The server owns visible text and parsed chips; +the identified local message may supply metadata that the server omitted. + +An idle, turnless message between numbered turns is a **content-only echo**. +Equal role, text, or filename is not turn identity. It can be collapsed only +under the adjacent authoritative text rule, and attachment transfer additionally +follows the table below. A unique text match means exactly one server slot of +that role and text across the available existing and incoming messages, and +exactly one eligible turnless claimant. If either count exceeds one, the match +is ambiguous. A partial server page does not erase competing existing slots. + +## Ownership decision table + +| Attachment class | Identified replacement | Unique content-only echo | Ambiguous or unproved echo | +| -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | +| Uploaded images with bytes | Keep server chips and fill matching image bytes from the same-turn local message; append local-only byte-bearing uploads. Pair repeated names by occurrence only _inside_ this proven turn. | Keep the local message and its image. Server display chips have no turn-scoped byte identity. | Keep the local message and its image. | +| Legacy label-only uploads | Keep server chips; retain unmatched local labels for Resend's unrestorable warning. | Keep the local message if its label has no independently proven server owner. | Keep local. | +| Global-file references | The server's complete URI/version/size wins. Otherwise enrich its matching display chip from the identified local reference; keep a local-only complete reference when the server omitted it. Never synthesize version or size from a label or URI. | Keep the local reference on its message; server labels are display-only. | Keep local. | +| Agent-file references | The server's complete filesystem/path wins; otherwise enrich a matching display chip from the identified local reference, or keep a local-only structured reference. | Keep the local reference on its message; an independent server identity stays on the server turn. | Keep local. | +| `response_file` output artifacts | Keep server artifacts; fill or retain local artifacts belonging to the identified assistant turn. They are never Resend inputs. | Collapse and transfer only when role and text identify one server slot and one local claimant, with no competing historical slot. | Keep the local assistant message and artifact. | +| Plugin and connector references | Server-parsed chips are authoritative; local metadata cannot overwrite a server reference. | Keep local references on their message; text coincidence cannot make them actionable on a server turn. | Keep local. | +| Other non-reference metadata, including tool steps | Server data wins; missing fields may come from the identified local message. | Transfer only with a unique content match and no retained attachment class; otherwise keep local. | Keep local. | + +When a class says **keep local**, do not collapse its message: dropping the row +would lose the attachment's ownership and any unrestorable warning. A plain +text echo with no owned metadata may still collapse under the adjacent text +rule. A filename, parsed list-position ID, or equal chip count never proves +that two image byte streams or two global-file references are the same object. + +## Repeated reconciliation + +Server chips keep their order and identity. Enrichment fills absent fields +without replacing complete server identity. Pair each local attachment at most +once, including duplicate names; do not append a second copy of a matched +attachment. A second reconciliation with the same server page must leave the +same attachments on the same logical turns. A retained local row may be merged +later only if stronger turn identity arrives. Resend reads only the surviving +message's own byte-bearing images and complete references. diff --git a/workflow-recipes/package-lock.json b/workflow-recipes/package-lock.json index 2b9c3013c7..90496da1a5 100644 --- a/workflow-recipes/package-lock.json +++ b/workflow-recipes/package-lock.json @@ -1,12 +1,12 @@ { "name": "workload-recipes-controller", - "version": "0.1.405", + "version": "0.1.460", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "workload-recipes-controller", - "version": "0.1.405", + "version": "0.1.460", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@aws-sdk/client-s3": "^3.1009.0", diff --git a/workflow-recipes/package.json b/workflow-recipes/package.json index c43d9d0b31..ac7b81a9ad 100644 --- a/workflow-recipes/package.json +++ b/workflow-recipes/package.json @@ -1,6 +1,6 @@ { "name": "workload-recipes-controller", - "version": "0.1.405", + "version": "0.1.460", "description": "Workload Recipes Controller (WRC) - WorkloadRecipe lifecycle management and MCP server interface", "main": "dist/main.js", "engines": { diff --git a/workflow-recipes/src/k8sClient.test.ts b/workflow-recipes/src/k8sClient.test.ts index 3a5a2d96ce..812aa1b6be 100644 --- a/workflow-recipes/src/k8sClient.test.ts +++ b/workflow-recipes/src/k8sClient.test.ts @@ -17,8 +17,35 @@ import { workflowRecipeFromWatchObject, } from './k8sClient' import { captureLogger } from './reconciler/__tests__/captureLogger' +import { buildOAuthBrokerTokenSecret } from './reconciler/resourceBuilder' +import type { SecretEventType, SecretLike } from './reconciler/secretWatcher' import type { WorkflowRecipeCRD } from './types' +// startSecretWatch builds one SecretWatcher per namespace and hands it to a +// K8sSecretWatchLoop. Capturing that hand-off lets a test drive the REAL +// watcher instances the client constructed, so the callback wiring is covered +// end to end instead of being replaced by a hand-injected callback. +const secretLoops = vi.hoisted( + () => + [] as Array<{ + namespace: string + handler: { handleEvent: (type: SecretEventType, secret: SecretLike) => void } + }> +) +vi.mock('./k8sSecretWatchLoop', () => ({ + K8sSecretWatchLoop: class { + constructor( + _kc: unknown, + namespace: string, + handler: { handleEvent: (type: SecretEventType, secret: SecretLike) => void } + ) { + secretLoops.push({ namespace, handler }) + } + async start(): Promise {} + stop(): void {} + }, +})) + function makeWorkflowRecipe(overrides: Partial = {}): WorkflowRecipeCRD { return { apiVersion: 'clerum.io/v1alpha1', @@ -1526,10 +1553,12 @@ describe('workload status refresh loop helpers', () => { type InternalWatcher = { recipes: Map reconciler: { reconcile: typeof reconcile } + transientRetries: Map; attempts: number }> handleRecipeEvent: (type: string, recipe: WorkflowRecipeCRD) => Promise } const internal = Object.create(WorkflowRecipeWatcher.prototype) as InternalWatcher internal.recipes = new Map([[cached.metadata.name, cached]]) + internal.transientRetries = new Map() internal.reconciler = { reconcile } await internal.handleRecipeEvent('MODIFIED', statusOnly) @@ -3250,3 +3279,118 @@ describe('WorkflowRecipeWatcher wiring — grant-update listener lifecycle (issu expect(poolEnd).toHaveBeenCalledTimes(1) }) }) + +describe('handleOAuthBrokerTokenAdded', () => { + type BrokerAddedInternal = { + config: { sandboxNamespace: string } + reconciler: { invalidateOAuthBrokerSecretLedger: (recipeName: string) => void } + recipes: Map + eventQueue: { enqueue: (key: string, task: () => Promise) => unknown } + handleOAuthBrokerTokenAdded: (namespace: string, recipeName: string) => void + } + + function recipeWith(name: string, backgroundAccess: boolean): WorkflowRecipeCRD { + const recipe = makeWorkflowRecipe({ metadata: { name, namespace: 'sandbox-recipes' } }) + recipe.spec = { + ...recipe.spec, + oauthClients: backgroundAccess + ? [ + { + id: 'gmail', + provider: 'google', + clientIdRef: { name: 'creds', key: 'client-id' }, + clientSecretRef: { name: 'creds', key: 'client-secret' }, + scopes: ['https://www.googleapis.com/auth/gmail.readonly'], + backgroundAccess: true, + }, + ] + : [], + } as WorkflowRecipeCRD['spec'] + return recipe + } + + function setup() { + const watcher = new WorkflowRecipeWatcher( + { + makeApiClient: vi.fn(() => ({})), + } as unknown as import('@kubernetes/client-node').KubeConfig, + null + ) + const internal = watcher as unknown as BrokerAddedInternal + const invalidate = vi.fn() + const enqueue = vi.fn() + internal.reconciler = { invalidateOAuthBrokerSecretLedger: invalidate } + internal.eventQueue = { enqueue } + internal.recipes = new Map([ + ['no-bg', recipeWith('no-bg', false)], + ['with-bg', recipeWith('with-bg', true)], + ]) + return { internal, invalidate, enqueue } + } + + it('re-arms the ledger and re-reconciles a recipe that must not have a broker token', () => { + const { internal, invalidate, enqueue } = setup() + internal.handleOAuthBrokerTokenAdded(internal.config.sandboxNamespace, 'no-bg') + expect(invalidate).toHaveBeenCalledWith('no-bg') + expect(enqueue).toHaveBeenCalledTimes(1) + expect(enqueue.mock.calls[0]?.[0]).toBe('no-bg') + }) + + it('only invalidates for an opted-in recipe: a watch reconnect replays ADDED for every token', () => { + const { internal, invalidate, enqueue } = setup() + internal.handleOAuthBrokerTokenAdded(internal.config.sandboxNamespace, 'with-bg') + expect(invalidate).toHaveBeenCalledWith('with-bg') + expect(enqueue).not.toHaveBeenCalled() + }) + + it('re-arms the ledger and enqueues nothing for a recipe not yet cached', () => { + // The recipe's own ADDED event is queued already; that its pass then + // reaps the re-armed token is pinned in reconciler.test.ts by the B3 + // ledger test "deletes the Secret again after ADDED invalidation". + const { internal, invalidate, enqueue } = setup() + internal.handleOAuthBrokerTokenAdded(internal.config.sandboxNamespace, 'not-cached') + // Liveness witness: the handler ran and armed the ledger for that recipe. + expect(invalidate).toHaveBeenCalledWith('not-cached') + expect(enqueue).not.toHaveBeenCalled() + }) + + it('startSecretWatch wires each namespace watcher to the handler with its own namespace', async () => { + const { internal, invalidate, enqueue } = setup() + secretLoops.length = 0 + await (internal as unknown as { startSecretWatch: () => Promise }).startSecretWatch() + + // The Secret WRC itself writes, not a hand-written copy of its labels. + const brokerToken = buildOAuthBrokerTokenSecret( + 'no-bg', + 'jwt', + internal.config.sandboxNamespace + ) + + // Liveness witness: the sandbox-namespace watcher exists and reaches the handler. + const sandbox = secretLoops.find(l => l.namespace === internal.config.sandboxNamespace) + expect(sandbox).toBeDefined() + sandbox?.handler.handleEvent('ADDED', brokerToken) + expect(invalidate).toHaveBeenCalledWith('no-bg') + expect(enqueue).toHaveBeenCalledTimes(1) + + // Every other namespace's watcher is bound to ITS namespace, so the handler ignores it. + const others = secretLoops.filter(l => l.namespace !== internal.config.sandboxNamespace) + expect(others.length).toBeGreaterThan(0) + for (const other of others) { + other.handler.handleEvent('ADDED', brokerToken) + } + expect(invalidate).toHaveBeenCalledTimes(1) + expect(enqueue).toHaveBeenCalledTimes(1) + }) + + it('ignores a same-named Secret outside the sandbox namespace', () => { + const { internal, invalidate, enqueue } = setup() + // Liveness witness: the same call in the sandbox namespace does act. + internal.handleOAuthBrokerTokenAdded(internal.config.sandboxNamespace, 'no-bg') + expect(invalidate).toHaveBeenCalledTimes(1) + expect(enqueue).toHaveBeenCalledTimes(1) + internal.handleOAuthBrokerTokenAdded('mcp-server', 'no-bg') + expect(invalidate).toHaveBeenCalledTimes(1) + expect(enqueue).toHaveBeenCalledTimes(1) + }) +}) diff --git a/workflow-recipes/src/k8sClient.ts b/workflow-recipes/src/k8sClient.ts index 3239a9ff6f..f7b6eead66 100644 --- a/workflow-recipes/src/k8sClient.ts +++ b/workflow-recipes/src/k8sClient.ts @@ -701,7 +701,11 @@ export class WorkflowRecipeWatcher implements WorkflowRecipeProvider { // result or DELETE. private transientRetries = new Map< string, - { timer: ReturnType; attempts: number } + { + timer: ReturnType + attempts: number + recipeUid?: string + } >() private static readonly TRANSIENT_RETRY_MAX_MS = 60_000 @@ -907,8 +911,11 @@ export class WorkflowRecipeWatcher implements WorkflowRecipeProvider { ]), ] for (const namespace of watchedNamespaces) { - const handler = new SecretWatcher(this.secretReverseIndex, name => - this.triggerSecretDrivenReconcile(name) + const handler = new SecretWatcher( + this.secretReverseIndex, + name => this.triggerSecretDrivenReconcile(name), + 10_000, + recipeName => this.handleOAuthBrokerTokenAdded(namespace, recipeName) ) const loop = new K8sSecretWatchLoop(this.kc, namespace, handler) this.secretWatchLoops.push(loop) @@ -916,6 +923,36 @@ export class WorkflowRecipeWatcher implements WorkflowRecipeProvider { } } + /** + * B3(a): a broker token Secret appeared. The token lives only in the sandbox + * namespace, so an ADDED for a same-named Secret elsewhere is ignored. + * + * The delete ledger is always re-armed. A recipe with no backgroundAccess + * client must not have the token at all, so it is also re-reconciled to reap + * it: the broker rotation loop skips such recipes and the ownership backstop + * only covers recipes that reference named Secrets, so nothing else would + * issue the delete before the next CRD event. An opted-in recipe legitimately + * owns its token and only invalidates, because a watch reconnect replays + * ADDED for every broker token and must not force a reconcile storm. + * + * A recipe not yet in the cache is only re-armed, not enqueued. At startup + * `startWatch` returns once the watch is open, without waiting for the + * queued recipe ADDED events to run, and the Secret watch starts right + * after, so a token ADDED can land first. The recipe's own queued event then reconciles with + * the ledger armed. A pass that returns before the token reap leaves the + * token to the next one that reaches it: after a CRD event, a recipe watch + * restart replaying ADDED, or a Secret watch reconnect replaying the token + * ADDED once the recipe is cached. + */ + private handleOAuthBrokerTokenAdded(namespace: string, recipeName: string): void { + if (namespace !== this.config.sandboxNamespace) return + this.reconciler.invalidateOAuthBrokerSecretLedger(recipeName) + const recipe = this.recipes.get(recipeName) + if (recipe && !recipeHasBackgroundAccessClient(recipe)) { + this.triggerSecretDrivenReconcile(recipeName) + } + } + /** * Enqueue a re-reconcile for a recipe whose referenced Secret's key-set * just changed. The cached recipe object is reused — no extra GET against @@ -1022,8 +1059,13 @@ export class WorkflowRecipeWatcher implements WorkflowRecipeProvider { options: { forceReconcile?: boolean } = {} ): Promise { if (type === 'ADDED' || type === 'MODIFIED') { + const scheduledUid = this.transientRetries.get(recipe.metadata.name)?.recipeUid + if (scheduledUid && scheduledUid !== recipe.metadata.uid) { + this.clearTransientRetry(recipe.metadata.name) + } let dbSyncAttempted = false if (recipe.metadata.deletionTimestamp) { + this.clearTransientRetry(recipe.metadata.name) // Resource is being deleted — cleanup cross-namespace resources, then remove finalizer. createLogger('wrc', recipe.metadata.name).info('Cleaning up recipe finalizer', { name: recipe.metadata.name, @@ -1148,7 +1190,8 @@ export class WorkflowRecipeWatcher implements WorkflowRecipeProvider { this.scheduleTransientRetry( recipe.metadata.name, result.requeueAfterMs, - result.requeueFixedInterval ?? false + result.requeueFixedInterval ?? false, + result.requeueOperation ) } else { this.clearTransientRetry(recipe.metadata.name) @@ -1233,17 +1276,38 @@ export class WorkflowRecipeWatcher implements WorkflowRecipeProvider { * * Exposed for tests via the watcher's event path. */ - private scheduleTransientRetry(name: string, baseMs: number, fixedInterval = false): void { + private scheduleTransientRetry( + name: string, + baseMs: number, + fixedInterval = false, + operation?: ReconcileResult['requeueOperation'] + ): void { const existing = this.transientRetries.get(name) // Fixed-interval (progress) requeues reset the backoff counter; transient // (error) requeues grow it. const attempts = fixedInterval ? 0 : (existing?.attempts ?? 0) if (existing) clearTimeout(existing.timer) + const owner = operation ? this.recipes.get(name) : undefined + if (operation && (!owner?.metadata.uid || owner.metadata.deletionTimestamp)) { + this.transientRetries.delete(name) + return + } const delay = fixedInterval ? baseMs : Math.min(baseMs * 2 ** attempts, WorkflowRecipeWatcher.TRANSIENT_RETRY_MAX_MS) const timer = setTimeout(() => { if (this.stopped) return + if (operation && owner) { + void this.eventQueue.enqueue(name, () => + this.retryPendingLegacyNetworkPolicyDelete( + name, + owner.metadata.namespace, + owner.metadata.uid!, + timer + ) + ) + return + } const cached = this.recipes.get(name) if (!cached) { this.transientRetries.delete(name) @@ -1268,7 +1332,81 @@ export class WorkflowRecipeWatcher implements WorkflowRecipeProvider { // reconcile advances past the waiting state once the awaited Pod succeeds, // and the 240s readiness deadline / dbRunProcessor max-duration paths // terminate any run permanently stuck in deploying, so this is bounded. - this.transientRetries.set(name, { timer, attempts: fixedInterval ? 0 : attempts + 1 }) + this.transientRetries.set(name, { + timer, + attempts: fixedInterval ? 0 : attempts + 1, + ...(operation ? { recipeUid: owner!.metadata.uid } : {}), + }) + } + + /** + * Runs after the recipe's queue wait. The API read is authoritative for UID, + * deletion and policy markers; this callback never invokes the full recipe + * pipeline, token reap or terminal runtime teardown. + */ + private async retryPendingLegacyNetworkPolicyDelete( + name: string, + namespace: string, + uid: string, + timer: ReturnType + ): Promise { + const current = () => !this.stopped && this.transientRetries.get(name)?.timer === timer + if (!current()) return + try { + const apiObject = (await this.customApi.getNamespacedCustomObject({ + group: CRD_GROUP, + version: CRD_VERSION, + namespace, + plural: WORKFLOWRECIPE_PLURAL, + name, + })) as WorkflowRecipeWatchObject + if (!current()) return + if ( + apiObject.metadata.name !== name || + apiObject.metadata.namespace !== namespace || + apiObject.metadata.uid !== uid || + apiObject.metadata.deletionTimestamp + ) { + this.clearTransientRetry(name) + return + } + const latest = workflowRecipeFromWatchObject(apiObject, namespace) + const result = await this.reconciler.retryPendingLegacyNetworkPolicyDeleteOnly(latest) + if (!current()) return + const cached = this.recipes.get(name) + if (!cached || cached.metadata.uid !== uid || cached.metadata.deletionTimestamp) { + this.clearTransientRetry(name) + return + } + if (result.requeueAfterMs) { + this.scheduleTransientRetry( + name, + result.requeueAfterMs, + true, + 'legacy-network-policy-delete' + ) + } else { + this.clearTransientRetry(name) + } + } catch (error) { + if (!current()) return + if (getErrorCode(error) === 404) { + this.clearTransientRetry(name) + return + } + createLogger('wrc', name).error('Legacy NetworkPolicy delete retry failed', { + name, + err: error, + }) + // A status conflict or transient API failure keeps this same narrow + // operation on the existing bounded error backoff; it cannot replay runtime cleanup. + this.scheduleTransientRetry( + name, + TRANSIENT_REQUEUE_BASE_MS, + false, + 'legacy-network-policy-delete' + ) + } } private clearTransientRetry(name: string): void { diff --git a/workflow-recipes/src/reconciler/__tests__/captureLogger.ts b/workflow-recipes/src/reconciler/__tests__/captureLogger.ts index f880e1face..cdfa3df8cc 100644 --- a/workflow-recipes/src/reconciler/__tests__/captureLogger.ts +++ b/workflow-recipes/src/reconciler/__tests__/captureLogger.ts @@ -1,8 +1,10 @@ -import { vi } from 'vitest' +import { type Mock, vi } from 'vitest' import * as logging from '../../observability/logger' +type CapturedLevel = 'debug' | 'info' | 'warn' | 'error' + /** Observe the service logging boundary without replacing other log levels. */ -export function captureLogger(level: 'info' | 'warn' | 'error') { +export function captureLogger(level: CapturedLevel) { const original = logging.createLogger const calls = vi.fn() const factory = vi.spyOn(logging, 'createLogger').mockImplementation((...args) => ({ @@ -14,3 +16,19 @@ export function captureLogger(level: 'info' | 'warn' | 'error') { } return calls } + +/** + * Observe several levels through one spy on the logger factory; a second + * `captureLogger` call would replace the first one's implementation. + */ +export function captureLoggerLevels( + levels: readonly L[] +): { calls: Record; restore: () => void } { + const original = logging.createLogger + const calls = Object.fromEntries(levels.map(level => [level, vi.fn()])) as Record + const factory = vi.spyOn(logging, 'createLogger').mockImplementation((...args) => ({ + ...original(...args), + ...calls, + })) + return { calls, restore: () => factory.mockRestore() } +} diff --git a/workflow-recipes/src/reconciler/deleteOutcome.ts b/workflow-recipes/src/reconciler/deleteOutcome.ts new file mode 100644 index 0000000000..a66f500aab --- /dev/null +++ b/workflow-recipes/src/reconciler/deleteOutcome.ts @@ -0,0 +1,54 @@ +import { getErrorCode } from './k8sErrors' + +/** + * Observed result of a delete against the apiserver. + * Record "gone" only after DELETE 200 or DELETE 404 — never after a swallowed 403/5xx. + */ +export type DeleteOutcome = + | { kind: 'deleted' } + | { kind: 'gone' } + | { kind: 'failed'; code?: number; error: unknown } + +export async function observeNamespacedDelete( + deleteFn: () => Promise +): Promise { + try { + await deleteFn() + return { kind: 'deleted' } + } catch (error: unknown) { + const code = getErrorCode(error) + if (code === 404) return { kind: 'gone' } + return { kind: 'failed', code, error } + } +} + +export function shouldRecordDelete(outcome: DeleteOutcome): boolean { + switch (outcome.kind) { + case 'deleted': + case 'gone': + return true + case 'failed': + return false + default: { + const _exhaustive: never = outcome + return _exhaustive + } + } +} + +export function deleteOutcomeFields(outcome: DeleteOutcome): { + outcome: DeleteOutcome['kind'] + code?: number +} { + switch (outcome.kind) { + case 'deleted': + case 'gone': + return { outcome: outcome.kind } + case 'failed': + return { outcome: outcome.kind, code: outcome.code } + default: { + const _exhaustive: never = outcome + return _exhaustive + } + } +} diff --git a/workflow-recipes/src/reconciler/oauthBrokerDeleteLedger.property.test.ts b/workflow-recipes/src/reconciler/oauthBrokerDeleteLedger.property.test.ts new file mode 100644 index 0000000000..c405aa949e --- /dev/null +++ b/workflow-recipes/src/reconciler/oauthBrokerDeleteLedger.property.test.ts @@ -0,0 +1,369 @@ +import { describe, expect, it } from 'vitest' +import fc from 'fast-check' +import { + OAUTH_BROKER_NP_TTL_MS, + OAuthBrokerDeleteLedger, + type OAuthBrokerLedgerRecipe, +} from './oauthBrokerDeleteLedger' + +/** + * Observable invariants of the ledger, checked over random event sequences. + * The harness does not rebuild the ledger's entries: it records only what a + * caller can see (which events happened, in which order) and asserts + * implications on the answers. Cases no invariant speaks about stay free. + * "Current uid" is the uid of the live recipe object under the name. + * + * I1 No token ADDED since the process started, or since the finalizer + * observed the token gone: no Secret delete. A finalizer start + * (`forgetRecipe`) alone does not clear the token-seen bit. + * I2 Token seen, and armed (an ADDED since the last write for the current + * uid, or no write for it since the last finalizer start or + * recreation): a pass at or above the watermark deletes. + * I3 A pass below the highest generation that provisioned the token for + * the current uid does not delete it. + * I4 A delete whose DELETE was sent before an ADDED, or before the + * finalizer observed the token gone, records nothing; otherwise it is + * recorded, across a finalizer start too. + * I5 The NetworkPolicy side skips a covered pass until the TTL expires, + * and deletes for a different uid or a higher generation. + * I6 Idempotence (#759 anti-storm): after a recorded delete or + * provisioning for the current uid, with no ADDED since, a pass at or + * below the watermark does not delete. + * I7 The finalizer's token DELETE result: 2xx/404 with no ADDED in flight + * clears the token-seen bit; a raced ADDED, a failed DELETE or one + * never sent keeps it. + */ + +const NAME = 'r' +const MAX_GENERATION = 6 + +type FinalizeOutcome = 'deleted' | 'gone' | 'failed' | 'notAttempted' + +type Op = + | { kind: 'provision'; generation: number } + | { kind: 'pass'; generation: number } + | { kind: 'beginDelete'; generation: number } + | { kind: 'endDelete'; failed: boolean } + | { kind: 'invalidateSecret' } + | { kind: 'addedElsewhere' } + | { kind: 'forgetRecipe' } + | { kind: 'recreate' } + | { kind: 'finalize'; outcome: FinalizeOutcome; racedAdded: boolean } + +const generationArb = fc.integer({ min: 0, max: MAX_GENERATION }) + +const opArb: fc.Arbitrary = fc.oneof( + generationArb.map(generation => ({ kind: 'provision' as const, generation })), + generationArb.map(generation => ({ kind: 'pass' as const, generation })), + generationArb.map(generation => ({ kind: 'beginDelete' as const, generation })), + fc.boolean().map(failed => ({ kind: 'endDelete' as const, failed })), + fc.constant({ kind: 'invalidateSecret' as const }), + fc.constant({ kind: 'addedElsewhere' as const }), + fc.constant({ kind: 'forgetRecipe' as const }), + fc.constant({ kind: 'recreate' as const }), + fc + .record({ + outcome: fc.constantFrom('deleted', 'gone', 'failed', 'notAttempted'), + racedAdded: fc.boolean(), + }) + .map(({ outcome, racedAdded }) => ({ kind: 'finalize' as const, outcome, racedAdded })) +) + +function ref(generation: number, uid: string): OAuthBrokerLedgerRecipe { + return { name: NAME, uid, generation } +} + +/** What a caller observed; never the ledger's internal entry. */ +interface Observed { + uid: string + /** Every uid the name has carried, for the records-nothing check. */ + uids: string[] + tokenSeen: boolean + /** Highest generation recorded or provisioned for the current uid since the last finalizer start. */ + watermark: number + /** Highest generation provisioned for the current uid since the last finalizer start. */ + provisioned: number + /** An ADDED, a finalizer start or a recreation landed after the last write for the current uid. */ + armed: boolean + inFlight?: { uid: string; generation: number; epoch: number; raced: boolean } +} + +function freshObserved(): Observed { + return { + uid: 'uid-1', + uids: ['uid-1'], + tokenSeen: false, + watermark: -1, + provisioned: -1, + armed: true, + } +} + +/** The ledger no longer holds a write for the current uid. */ +function forgetCurrentUid(seen: Observed): void { + seen.watermark = -1 + seen.provisioned = -1 + seen.armed = true +} + +/** A write (recorded delete or provisioning) for the current uid. */ +function noteWrite(seen: Observed, generation: number, provisioned: boolean): void { + if (provisioned) seen.provisioned = Math.max(seen.provisioned, generation) + // A write below the watermark leaves the ledger's entry as it is. + if (generation < seen.watermark) return + seen.watermark = generation + seen.armed = false +} + +function allAnswers(ledger: OAuthBrokerDeleteLedger, seen: Observed): boolean[] { + const answers: boolean[] = [] + for (const uid of [...seen.uids, 'uid-never']) { + for (let generation = 0; generation <= MAX_GENERATION; generation++) { + answers.push(ledger.shouldDeleteSecret(ref(generation, uid))) + } + } + return answers +} + +function checkPass(ledger: OAuthBrokerDeleteLedger, seen: Observed, generation: number): void { + const answer = ledger.shouldDeleteSecret(ref(generation, seen.uid)) + const where = `gen${generation} observed=${JSON.stringify(seen)}` + if (!seen.tokenSeen) expect(answer, `I1 ${where}`).toBe(false) + if (generation < seen.provisioned) expect(answer, `I3 ${where}`).toBe(false) + if (seen.tokenSeen && seen.armed && generation >= seen.watermark) { + expect(answer, `I2 ${where}`).toBe(true) + } + if (!seen.armed && generation <= seen.watermark) { + expect(answer, `I6 ${where}`).toBe(false) + } +} + +function added(ledger: OAuthBrokerDeleteLedger, seen: Observed): void { + ledger.invalidateSecret(NAME) + seen.tokenSeen = true + seen.armed = true + if (seen.inFlight) seen.inFlight.raced = true +} + +function step(ledger: OAuthBrokerDeleteLedger, seen: Observed, op: Op): void { + switch (op.kind) { + case 'provision': + // The per-recipe queue serialises passes for one name: the recreated + // recipe cannot provision while the old uid's pass holds a DELETE. + if (seen.inFlight && seen.inFlight.uid !== seen.uid) return + ledger.noteSecretProvisioned(ref(op.generation, seen.uid)) + noteWrite(seen, op.generation, true) + return + case 'pass': + checkPass(ledger, seen, op.generation) + return + case 'beginDelete': + // The WRC sends the DELETE only when the ledger allows it, and the + // per-recipe queue keeps one in flight. + if (seen.inFlight || !ledger.shouldDeleteSecret(ref(op.generation, seen.uid))) return + seen.inFlight = { + uid: seen.uid, + generation: op.generation, + epoch: ledger.secretEpoch(NAME), + raced: false, + } + return + case 'endDelete': { + const inFlight = seen.inFlight + if (!inFlight) return + seen.inFlight = undefined + // A failed DELETE is never recorded. + if (op.failed) return + const before = allAnswers(ledger, seen) + const recorded = ledger.recordSecretDelete( + ref(inFlight.generation, inFlight.uid), + inFlight.epoch + ) + if (inFlight.raced) { + expect(recorded, `I4 raced gen${inFlight.generation}`).toBe(false) + expect(allAnswers(ledger, seen), `I4 records nothing gen${inFlight.generation}`).toEqual( + before + ) + return + } + // Liveness witness for I4: an undisturbed delete is recorded. + expect(recorded, `I4 undisturbed gen${inFlight.generation}`).toBe(true) + if (inFlight.uid === seen.uid) { + noteWrite(seen, inFlight.generation, false) + } else { + // The old uid's entry replaced whatever the name held; the current + // uid has no write in the ledger any more. + forgetCurrentUid(seen) + } + return + } + case 'invalidateSecret': + added(ledger, seen) + return + case 'addedElsewhere': + ledger.invalidateSecret('other') + return + case 'forgetRecipe': + // The finalizer starts: both entries go, the token-seen bit stays, and + // an in-flight DELETE is not raced (the epoch did not move). + ledger.forgetRecipe(NAME) + forgetCurrentUid(seen) + return + case 'recreate': { + // The recipe is recreated under the same name, whether or not its + // finalizer ran in this process. + const uid = `uid-${seen.uids.length + 1}` + seen.uids.push(uid) + seen.uid = uid + forgetCurrentUid(seen) + return + } + case 'finalize': { + ledger.forgetRecipe(NAME) + forgetCurrentUid(seen) + if (op.outcome === 'notAttempted') return + const epochBeforeDelete = ledger.secretEpoch(NAME) + if (op.racedAdded) added(ledger, seen) + if (op.outcome === 'failed') return + const cleared = ledger.noteSecretGone(NAME, epochBeforeDelete) + expect(cleared, `I7 ${op.outcome} racedAdded=${op.racedAdded}`).toBe(!op.racedAdded) + if (!cleared) return + seen.tokenSeen = false + // An ensure DELETE in flight read a non-zero epoch; it no longer matches. + if (seen.inFlight) seen.inFlight.raced = true + return + } + } +} + +describe('OAuthBrokerDeleteLedger observable properties', () => { + it('I1-I4, I6 and I7 hold over provision, pass, delete, ADDED, finalizer and recreation sequences', () => { + fc.assert( + fc.property(fc.array(opArb, { maxLength: 40 }), ops => { + const ledger = new OAuthBrokerDeleteLedger() + const seen = freshObserved() + for (const op of ops) { + step(ledger, seen, op) + for (let generation = 0; generation <= MAX_GENERATION; generation++) { + checkPass(ledger, seen, generation) + } + } + }), + { numRuns: 5000 } + ) + }) + + it('I2/I3 are reachable: an ADDED after provisioning arms the watermark and nothing below it', () => { + fc.assert( + fc.property(fc.integer({ min: 1, max: MAX_GENERATION }), provisioned => { + const ledger = new OAuthBrokerDeleteLedger() + ledger.noteSecretProvisioned(ref(provisioned, 'uid-1')) + ledger.invalidateSecret(NAME) + for (let generation = 0; generation <= MAX_GENERATION; generation++) { + expect(ledger.shouldDeleteSecret(ref(generation, 'uid-1'))).toBe( + generation >= provisioned + ) + } + }), + { numRuns: 500 } + ) + }) + + it('I6/I2 are reachable: a recorded delete disarms its generation, a recreation re-arms every generation', () => { + fc.assert( + fc.property(generationArb, recorded => { + const ledger = new OAuthBrokerDeleteLedger() + ledger.invalidateSecret(NAME) + expect(ledger.recordSecretDelete(ref(recorded, 'uid-1'), ledger.secretEpoch(NAME))).toBe( + true + ) + for (let generation = 0; generation <= MAX_GENERATION; generation++) { + expect(ledger.shouldDeleteSecret(ref(generation, 'uid-1'))).toBe(generation > recorded) + expect(ledger.shouldDeleteSecret(ref(generation, 'uid-2'))).toBe(true) + } + }), + { numRuns: 500 } + ) + }) + + it('I5 the NetworkPolicy side honours the TTL and a uid or generation change', () => { + fc.assert( + fc.property( + fc.record({ + uid: fc.constantFrom('uid-1', 'uid-2'), + generation: generationArb, + recordedAt: fc.integer({ min: 0, max: 10 * OAUTH_BROKER_NP_TTL_MS }), + }), + fc.record({ + uid: fc.constantFrom('uid-1', 'uid-2'), + generation: generationArb, + elapsed: fc.oneof( + fc.integer({ min: 0, max: OAUTH_BROKER_NP_TTL_MS - 1 }), + fc.integer({ min: OAUTH_BROKER_NP_TTL_MS, max: 2 * OAUTH_BROKER_NP_TTL_MS }) + ), + }), + (recorded, query) => { + const ledger = new OAuthBrokerDeleteLedger() + ledger.recordPolicyDelete(ref(recorded.generation, recorded.uid), recorded.recordedAt) + const answer = ledger.shouldDeletePolicy( + ref(query.generation, query.uid), + recorded.recordedAt + query.elapsed + ) + const covered = + query.uid === recorded.uid && + query.generation <= recorded.generation && + query.elapsed < OAUTH_BROKER_NP_TTL_MS + expect(answer, `I5 ${JSON.stringify({ recorded, query })}`).toBe(!covered) + } + ), + { numRuns: 5000 } + ) + }) + + it('I5 over recordPolicyDelete sequences: a late lower generation keeps the watermark, a new uid replaces it', () => { + fc.assert( + fc.property( + fc.array( + fc.record({ + uid: fc.constantFrom('uid-1', 'uid-2'), + generation: generationArb, + advance: fc.integer({ min: 0, max: OAUTH_BROKER_NP_TTL_MS / 2 }), + }), + { minLength: 1, maxLength: 12 } + ), + fc.integer({ min: 0, max: 2 * OAUTH_BROKER_NP_TTL_MS }), + (records, elapsed) => { + const ledger = new OAuthBrokerDeleteLedger() + let now = 0 + let observed: { uid: string; generation: number; at: number } | undefined + for (const record of records) { + now += record.advance + ledger.recordPolicyDelete(ref(record.generation, record.uid), now) + observed = + observed !== undefined && observed.uid === record.uid + ? { + uid: record.uid, + generation: Math.max(observed.generation, record.generation), + at: now, + } + : { uid: record.uid, generation: record.generation, at: now } + for (const uid of ['uid-1', 'uid-2']) { + for (let generation = 0; generation <= MAX_GENERATION; generation++) { + const at = now + elapsed + const covered = + uid === observed.uid && + generation <= observed.generation && + at < observed.at + OAUTH_BROKER_NP_TTL_MS + expect( + ledger.shouldDeletePolicy(ref(generation, uid), at), + `I5 seq ${JSON.stringify({ records, elapsed, uid, generation })}` + ).toBe(!covered) + } + } + } + } + ), + { numRuns: 2000 } + ) + }) +}) diff --git a/workflow-recipes/src/reconciler/oauthBrokerDeleteLedger.test.ts b/workflow-recipes/src/reconciler/oauthBrokerDeleteLedger.test.ts new file mode 100644 index 0000000000..c7db5ff9b1 --- /dev/null +++ b/workflow-recipes/src/reconciler/oauthBrokerDeleteLedger.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it } from 'vitest' +import { + OAUTH_BROKER_NP_TTL_MS, + OAuthBrokerDeleteLedger, + type OAuthBrokerLedgerRecipe, +} from './oauthBrokerDeleteLedger' + +function ref(name: string, generation: number, uid = `uid-${name}`): OAuthBrokerLedgerRecipe { + return { name, uid, generation } +} + +function recordSecret(ledger: OAuthBrokerDeleteLedger, recipe: OAuthBrokerLedgerRecipe): void { + expect(ledger.recordSecretDelete(recipe, ledger.secretEpoch(recipe.name))).toBe(true) +} + +/** + * The Secret watch observed the recipe's token (an ADDED, or the relist that + * replays one). Every Secret-side case below starts here: without it the + * ledger answers false for every pass, so a false would prove nothing. + */ +function tokenSeen(ledger: OAuthBrokerDeleteLedger, name: string): void { + ledger.invalidateSecret(name) +} + +describe('OAuthBrokerDeleteLedger', () => { + it('never deletes a Secret it never saw a token for', () => { + const ledger = new OAuthBrokerDeleteLedger() + for (const generation of [0, 1, 4]) { + expect(ledger.shouldDeleteSecret(ref('r', generation))).toBe(false) + } + // A recipe recreated under the same name does not bypass it either. + expect(ledger.shouldDeleteSecret(ref('r', 1, 'uid-recreated'))).toBe(false) + + // Liveness witness: the token ADDED arms the delete, for that recipe only. + tokenSeen(ledger, 'r') + expect(ledger.shouldDeleteSecret(ref('r', 4))).toBe(true) + expect(ledger.shouldDeleteSecret(ref('r2', 4))).toBe(false) + }) + + it('remembers a Secret delete for the recorded generation only', () => { + const ledger = new OAuthBrokerDeleteLedger() + tokenSeen(ledger, 'r') + expect(ledger.shouldDeleteSecret(ref('r', 4))).toBe(true) + recordSecret(ledger, ref('r', 4)) + expect(ledger.shouldDeleteSecret(ref('r', 4))).toBe(false) + expect(ledger.shouldDeleteSecret(ref('r', 5))).toBe(true) + }) + + it('keeps the highest generation per recipe: an older generation arriving late is skipped', () => { + const ledger = new OAuthBrokerDeleteLedger() + tokenSeen(ledger, 'r') + recordSecret(ledger, ref('r', 4)) + recordSecret(ledger, ref('r', 5)) + expect(ledger.shouldDeleteSecret(ref('r', 5))).toBe(false) + expect(ledger.shouldDeleteSecret(ref('r', 4))).toBe(false) + expect(ledger.shouldDeleteSecret(ref('r', 6))).toBe(true) + + ledger.recordPolicyDelete(ref('r', 4), 0) + ledger.recordPolicyDelete(ref('r', 5), 0) + expect(ledger.shouldDeletePolicy(ref('r', 5), 1)).toBe(false) + expect(ledger.shouldDeletePolicy(ref('r', 4), 1)).toBe(false) + expect(ledger.shouldDeletePolicy(ref('r', 6), 1)).toBe(true) + }) + + it('an older generation recorded late does not lower the recorded generation', () => { + const ledger = new OAuthBrokerDeleteLedger() + tokenSeen(ledger, 'r') + recordSecret(ledger, ref('r', 5)) + recordSecret(ledger, ref('r', 3)) + expect(ledger.shouldDeleteSecret(ref('r', 4))).toBe(false) + // Liveness witness: the watermark is 5, not unset. + expect(ledger.shouldDeleteSecret(ref('r', 6))).toBe(true) + + ledger.recordPolicyDelete(ref('r', 5), 0) + ledger.recordPolicyDelete(ref('r', 3), 0) + expect(ledger.shouldDeletePolicy(ref('r', 4), 1)).toBe(false) + }) + + it('a recipe recreated under the same name (new uid) deletes again at any generation', () => { + const ledger = new OAuthBrokerDeleteLedger() + tokenSeen(ledger, 'r') + recordSecret(ledger, ref('r', 5, 'uid-old')) + ledger.recordPolicyDelete(ref('r', 5, 'uid-old'), 0) + expect(ledger.shouldDeleteSecret(ref('r', 1, 'uid-old'))).toBe(false) + expect(ledger.shouldDeletePolicy(ref('r', 1, 'uid-old'), 1)).toBe(false) + + expect(ledger.shouldDeleteSecret(ref('r', 1, 'uid-new'))).toBe(true) + expect(ledger.shouldDeletePolicy(ref('r', 1, 'uid-new'), 1)).toBe(true) + + // Recording the recreated recipe replaces the old uid's higher generation. + recordSecret(ledger, ref('r', 1, 'uid-new')) + expect(ledger.shouldDeleteSecret(ref('r', 1, 'uid-new'))).toBe(false) + expect(ledger.shouldDeleteSecret(ref('r', 2, 'uid-new'))).toBe(true) + }) + + it('does not let one recipe suppress another', () => { + const ledger = new OAuthBrokerDeleteLedger() + tokenSeen(ledger, 'a') + tokenSeen(ledger, 'a-b') + tokenSeen(ledger, 'b') + recordSecret(ledger, ref('a', 1)) + expect(ledger.shouldDeleteSecret(ref('a', 1))).toBe(false) + expect(ledger.shouldDeleteSecret(ref('a-b', 1))).toBe(true) + expect(ledger.shouldDeleteSecret(ref('b', 1))).toBe(true) + }) + + it('expires the NetworkPolicy entry after the TTL', () => { + const ledger = new OAuthBrokerDeleteLedger() + ledger.recordPolicyDelete(ref('r', 4), 1_000) + expect(ledger.shouldDeletePolicy(ref('r', 4), 1_000 + OAUTH_BROKER_NP_TTL_MS - 1)).toBe(false) + expect(ledger.shouldDeletePolicy(ref('r', 4), 1_000 + OAUTH_BROKER_NP_TTL_MS)).toBe(true) + }) + + it('never saw a token: the NetworkPolicy side still deletes, it is not tied to the Secret', () => { + const ledger = new OAuthBrokerDeleteLedger() + expect(ledger.shouldDeleteSecret(ref('r', 4))).toBe(false) + expect(ledger.shouldDeletePolicy(ref('r', 4), 1)).toBe(true) + }) + + it('invalidateSecret re-arms only the Secret side; the NetworkPolicy TTL survives', () => { + const ledger = new OAuthBrokerDeleteLedger() + tokenSeen(ledger, 'r') + recordSecret(ledger, ref('r', 4)) + ledger.recordPolicyDelete(ref('r', 4), 0) + expect(ledger.shouldDeleteSecret(ref('r', 4))).toBe(false) + expect(ledger.shouldDeletePolicy(ref('r', 4), 1)).toBe(false) + + ledger.invalidateSecret('r') + expect(ledger.shouldDeleteSecret(ref('r', 4))).toBe(true) + expect(ledger.shouldDeletePolicy(ref('r', 4), 1)).toBe(false) + }) + + it('R2-L1: an ADDED after a newer generation provisioned the token does not re-arm an older pass', () => { + const ledger = new OAuthBrokerDeleteLedger() + tokenSeen(ledger, 'r') + // gen4 has no backgroundAccess: the Secret is reaped and recorded. + recordSecret(ledger, ref('r', 4)) + // gen5 turns backgroundAccess on and the token is issued. + ledger.noteSecretProvisioned(ref('r', 5)) + // The token's watch ADDED. + ledger.invalidateSecret('r') + + // A queued pass still carrying the gen4 object must not delete the live token. + expect(ledger.shouldDeleteSecret(ref('r', 4))).toBe(false) + // Liveness witness: a newer generation that drops backgroundAccess reaps it. + expect(ledger.shouldDeleteSecret(ref('r', 6))).toBe(true) + }) + + it('R2-L1: invalidateSecret keeps the watermark: below it stays skipped, at it re-arms', () => { + const ledger = new OAuthBrokerDeleteLedger() + tokenSeen(ledger, 'r') + recordSecret(ledger, ref('r', 5)) + ledger.invalidateSecret('r') + expect(ledger.shouldDeleteSecret(ref('r', 4))).toBe(false) + expect(ledger.shouldDeleteSecret(ref('r', 5))).toBe(true) + + // Recording the re-armed delete disarms it again. + recordSecret(ledger, ref('r', 5)) + expect(ledger.shouldDeleteSecret(ref('r', 5))).toBe(false) + }) + + it('does not record a Secret delete when an invalidation landed after the epoch was read', () => { + const ledger = new OAuthBrokerDeleteLedger() + const epochBeforeDelete = ledger.secretEpoch('r') + ledger.invalidateSecret('r') + expect(ledger.recordSecretDelete(ref('r', 4), epochBeforeDelete)).toBe(false) + expect(ledger.shouldDeleteSecret(ref('r', 4))).toBe(true) + + // Liveness witness: with a fresh epoch the same record is accepted. + expect(ledger.recordSecretDelete(ref('r', 4), ledger.secretEpoch('r'))).toBe(true) + expect(ledger.shouldDeleteSecret(ref('r', 4))).toBe(false) + }) + + it('an invalidation for one recipe does not reject another recipe record', () => { + const ledger = new OAuthBrokerDeleteLedger() + tokenSeen(ledger, 'r2') + const epochBeforeDelete = ledger.secretEpoch('r2') + ledger.invalidateSecret('r') + expect(ledger.recordSecretDelete(ref('r2', 4), epochBeforeDelete)).toBe(true) + expect(ledger.shouldDeleteSecret(ref('r2', 4))).toBe(false) + }) + + it('forgetRecipe drops both entries for the named recipe and no other, and keeps the token seen', () => { + const ledger = new OAuthBrokerDeleteLedger() + tokenSeen(ledger, 'r') + tokenSeen(ledger, 'r2') + recordSecret(ledger, ref('r', 4)) + ledger.recordPolicyDelete(ref('r', 4), 0) + recordSecret(ledger, ref('r2', 4)) + ledger.recordPolicyDelete(ref('r2', 4), 0) + + ledger.forgetRecipe('r') + // Deleting the recipe does not delete its token: the watermark went, the + // epoch stayed, so a generation below the old watermark deletes. + expect(ledger.shouldDeleteSecret(ref('r', 3))).toBe(true) + expect(ledger.shouldDeleteSecret(ref('r', 1, 'uid-recreated'))).toBe(true) + expect(ledger.shouldDeletePolicy(ref('r', 4), 1)).toBe(true) + // The other recipe keeps both entries. + expect(ledger.shouldDeleteSecret(ref('r2', 4))).toBe(false) + expect(ledger.shouldDeletePolicy(ref('r2', 4), 1)).toBe(false) + }) + + it('Table B: noteSecretGone with the epoch unchanged clears the token-seen bit until the next ADDED', () => { + const ledger = new OAuthBrokerDeleteLedger() + tokenSeen(ledger, 'r') + tokenSeen(ledger, 'r2') + ledger.forgetRecipe('r') + const epochBeforeDelete = ledger.secretEpoch('r') + expect(epochBeforeDelete).toBeGreaterThan(0) + expect(ledger.noteSecretGone('r', epochBeforeDelete)).toBe(true) + + expect(ledger.secretEpoch('r')).toBe(0) + expect(ledger.shouldDeleteSecret(ref('r', 1, 'uid-recreated'))).toBe(false) + // Liveness witness: another recipe is untouched, and the next ADDED re-arms. + expect(ledger.shouldDeleteSecret(ref('r2', 1))).toBe(true) + tokenSeen(ledger, 'r') + expect(ledger.shouldDeleteSecret(ref('r', 1, 'uid-recreated'))).toBe(true) + }) + + it('Table B: noteSecretGone after an ADDED raced the finalizer DELETE clears nothing', () => { + const ledger = new OAuthBrokerDeleteLedger() + tokenSeen(ledger, 'r') + ledger.forgetRecipe('r') + const epochBeforeDelete = ledger.secretEpoch('r') + // The recreated recipe's token ADDED lands while the DELETE is in flight. + ledger.invalidateSecret('r') + expect(ledger.noteSecretGone('r', epochBeforeDelete)).toBe(false) + expect(ledger.secretEpoch('r')).toBeGreaterThan(epochBeforeDelete) + expect(ledger.shouldDeleteSecret(ref('r', 1, 'uid-recreated'))).toBe(true) + }) + + it('Table B: a finalizer DELETE that failed or was never sent keeps the token seen', () => { + const ledger = new OAuthBrokerDeleteLedger() + tokenSeen(ledger, 'r') + recordSecret(ledger, ref('r', 4, 'uid-old')) + const epochBeforeForget = ledger.secretEpoch('r') + // Finalizer start; the DELETE then fails (or is never sent), so + // noteSecretGone is not called. + ledger.forgetRecipe('r') + expect(ledger.secretEpoch('r')).toBe(epochBeforeForget) + expect(ledger.shouldDeleteSecret(ref('r', 1, 'uid-recreated'))).toBe(true) + }) + + it('a DELETE in flight across a finalizer start that followed an ADDED is recorded against the old uid only', () => { + const ledger = new OAuthBrokerDeleteLedger() + ledger.invalidateSecret('r') + const epochBeforeDelete = ledger.secretEpoch('r') + ledger.forgetRecipe('r') + // The epoch did not move, so the in-flight DELETE of the old recipe is + // recorded for its own uid. + expect(ledger.recordSecretDelete(ref('r', 4, 'uid-old'), epochBeforeDelete)).toBe(true) + expect(ledger.shouldDeleteSecret(ref('r', 4, 'uid-old'))).toBe(false) + + // A recipe recreated under the same name (new uid) still deletes. + expect(ledger.shouldDeleteSecret(ref('r', 1, 'uid-new'))).toBe(true) + }) +}) diff --git a/workflow-recipes/src/reconciler/oauthBrokerDeleteLedger.ts b/workflow-recipes/src/reconciler/oauthBrokerDeleteLedger.ts new file mode 100644 index 0000000000..0b62df6a40 --- /dev/null +++ b/workflow-recipes/src/reconciler/oauthBrokerDeleteLedger.ts @@ -0,0 +1,176 @@ +/** + * In-process B3(a)(b) ledger for oauth-broker Secret / NetworkPolicy deletes. + * Transition is (recipe, metadata.generation). The NetworkPolicy side adds a + * 1h TTL so a later pass can reap a leftover without a watch. Indexing only + * by recipe name is the E.6 vacuity mutation: a generation bump must miss. + * + * One entry per recipe, holding the highest generation (the watermark) seen + * for the recipe's current uid. A pass whose generation is below the + * watermark is always skipped: a queued pass still carrying an older object + * must not delete what a newer generation provisioned. A pass at the + * watermark is skipped unless a token ADDED re-armed it. A different uid is a + * recipe recreated under the same name, whose generations restart at 1, so it + * always deletes and replaces the entry even when the recipe DELETE event was + * missed. + * + * The Secret side deletes only after the Secret watch observed the recipe's + * token (a non-zero epoch): a recipe that never had one gets no DELETE. The + * epoch survives recipe deletion and returns to 0 only when the finalizer's + * own token DELETE is observed 2xx/404 with no ADDED in between. + * + * The Secret watermark rises on a recorded delete and on + * `noteSecretProvisioned`, which the backgroundAccess branch calls for the + * generation that wants the token. + * + * `invalidateSecret` (a token ADDED) keeps the uid and the watermark and + * re-arms the delete only for passes at or above the watermark. It also bumps + * a per-recipe epoch: a Secret delete is recorded only when the epoch is + * unchanged since before the DELETE was sent, so an ADDED handled while the + * DELETE is in flight is not overwritten by it. + */ + +export const OAUTH_BROKER_NP_TTL_MS = 60 * 60 * 1000 + +/** The recipe identity the ledger keys on; a recipe's `metadata` satisfies it. */ +export interface OAuthBrokerLedgerRecipe { + name: string + uid?: string + generation?: number +} + +interface WatermarkEntry { + uid: string | undefined + generation: number +} + +interface SecretEntry extends WatermarkEntry { + /** A token ADDED landed after the watermark was set. */ + rearmed: boolean +} + +interface PolicyEntry extends WatermarkEntry { + expiresAt: number +} + +function normalizeGeneration(generation: number | undefined): number { + return generation ?? 0 +} + +function coversPass(entry: WatermarkEntry | undefined, recipe: OAuthBrokerLedgerRecipe): boolean { + return ( + entry !== undefined && + entry.uid === recipe.uid && + normalizeGeneration(recipe.generation) <= entry.generation + ) +} + +function nextEntry( + entry: WatermarkEntry | undefined, + recipe: OAuthBrokerLedgerRecipe +): WatermarkEntry { + const generation = normalizeGeneration(recipe.generation) + if (entry !== undefined && entry.uid === recipe.uid) { + return { uid: recipe.uid, generation: Math.max(entry.generation, generation) } + } + return { uid: recipe.uid, generation } +} + +export class OAuthBrokerDeleteLedger { + private readonly secrets = new Map() + private readonly policies = new Map() + private readonly secretEpochs = new Map() + private epochClock = 0 + + shouldDeleteSecret(recipe: OAuthBrokerLedgerRecipe): boolean { + // Epoch 0: no token ADDED since this process started, or the finalizer + // observed the token gone (2xx/404) with no ADDED since. The Secret + // watch's initial list replays ADDED for every token that exists, so + // there is no token to reap and a DELETE would only 404. + if (this.secretEpoch(recipe.name) === 0) return false + const entry = this.secrets.get(recipe.name) + if (entry === undefined || entry.uid !== recipe.uid) return true + const generation = normalizeGeneration(recipe.generation) + if (generation !== entry.generation) return generation > entry.generation + return entry.rearmed + } + + /** Read before sending the DELETE and hand back to `recordSecretDelete`. */ + secretEpoch(recipeName: string): number { + return this.secretEpochs.get(recipeName) ?? 0 + } + + /** + * Returns false, recording nothing, when an invalidation landed after + * `epochBeforeDelete` was read. + */ + recordSecretDelete(recipe: OAuthBrokerLedgerRecipe, epochBeforeDelete: number): boolean { + if (this.secretEpoch(recipe.name) !== epochBeforeDelete) return false + this.raiseSecretWatermark(recipe) + return true + } + + /** + * The recipe's generation wants the token (backgroundAccess), so no pass at + * or below it may delete the Secret, even after a later token ADDED. + */ + noteSecretProvisioned(recipe: OAuthBrokerLedgerRecipe): void { + this.raiseSecretWatermark(recipe) + } + + private raiseSecretWatermark(recipe: OAuthBrokerLedgerRecipe): void { + const entry = this.secrets.get(recipe.name) + const generation = normalizeGeneration(recipe.generation) + if (entry !== undefined && entry.uid === recipe.uid && generation < entry.generation) return + this.secrets.set(recipe.name, { uid: recipe.uid, generation, rearmed: false }) + } + + shouldDeletePolicy(recipe: OAuthBrokerLedgerRecipe, nowMs = Date.now()): boolean { + const entry = this.policies.get(recipe.name) + if (entry === undefined || !coversPass(entry, recipe)) return true + return nowMs >= entry.expiresAt + } + + recordPolicyDelete(recipe: OAuthBrokerLedgerRecipe, nowMs = Date.now()): void { + this.policies.set(recipe.name, { + ...nextEntry(this.policies.get(recipe.name), recipe), + expiresAt: nowMs + OAUTH_BROKER_NP_TTL_MS, + }) + } + + /** + * A recreated token Secret re-arms only the Secret side, and only for + * passes at or above the watermark; the uid and watermark are kept. The + * NetworkPolicy TTL is not tied to that Secret, and a watch reconnect + * replays ADDED for every existing token, so clearing the policy side here + * would cost one extra DELETE per recipe after each reconnect. + */ + invalidateSecret(recipeName: string): void { + const entry = this.secrets.get(recipeName) + if (entry !== undefined) entry.rearmed = true + this.epochClock += 1 + this.secretEpochs.set(recipeName, this.epochClock) + } + + /** + * Finalizer start: drops the Secret and NetworkPolicy entries and keeps the + * epoch. Deleting a recipe does not delete its token; only the finalizer's + * own DELETE, observed through `noteSecretGone`, may clear the epoch. A + * DELETE that fails, is never sent, or races an ADDED leaves the token + * seen, so a recipe recreated under the same name still reaps it. + */ + forgetRecipe(recipeName: string): void { + this.secrets.delete(recipeName) + this.policies.delete(recipeName) + } + + /** + * The finalizer's token DELETE returned 2xx or 404. Clears the epoch only + * when it is unchanged since `epochBeforeDelete` was read; returns false, + * clearing nothing, when an ADDED landed while the DELETE was in flight. + */ + noteSecretGone(recipeName: string, epochBeforeDelete: number): boolean { + if (this.secretEpoch(recipeName) !== epochBeforeDelete) return false + this.secretEpochs.delete(recipeName) + return true + } +} diff --git a/workflow-recipes/src/reconciler/reconciler.test.ts b/workflow-recipes/src/reconciler/reconciler.test.ts index f5853340b6..d6f00f38e7 100644 --- a/workflow-recipes/src/reconciler/reconciler.test.ts +++ b/workflow-recipes/src/reconciler/reconciler.test.ts @@ -15,8 +15,17 @@ import { WorkloadDef, } from '../types' import { INHERITED_PARENT_RESOURCES_ANNOTATION } from '../workflow/childRecipeFactory' +import type { CodexExecutionProjection } from '../workflow/codexExecutionProjection' import { buildCoordinatorGfsNetworkPolicy } from '../workflow/networkPolicyFactory' -import { captureLogger } from './__tests__/captureLogger' +import type { NetworkPolicyPassSummary } from '../workflow/pluginWorkloadSdkProvisioner' +import { deriveWorkflowRuntimePlan } from '../workflow/runtimePlan' +import { + LegacyNetworkPolicyDeletePendingError, + WorkflowReconciler, + buildNetworkPolicyConvergedCondition, + networkPolicyMarkerConditions, +} from '../workflow/workflowReconciler' +import { captureLogger, captureLoggerLevels } from './__tests__/captureLogger' import { defaultFqdnLookup } from './fqdnResolver' import { isRetryableInfraError } from './k8sErrors' import type { NetworkPolicyFamily } from './networkPolicyConvergence' @@ -25,6 +34,7 @@ import { PLUGIN_WORKLOAD_SDK_POLICY_PENDING_CONDITION_TYPE, PLUGIN_WORKLOAD_SDK_PROVIDER_UNAVAILABLE_CONDITION_TYPE, } from './pluginWorkloadSdkValidator' +import { RecipeEventQueue } from './recipeEventQueue' import { buildUiIngressNetworkPolicy, buildWorkloadIngressNetworkPolicy, @@ -80,6 +90,7 @@ const mockCoreApi = { readNamespacedPersistentVolumeClaim: vi.fn().mockRejectedValue({ code: 404 }), deleteNamespacedPersistentVolumeClaim: vi.fn().mockResolvedValue({}), deleteCollectionNamespacedPod: vi.fn().mockResolvedValue({}), + deleteNamespacedPod: vi.fn().mockResolvedValue({}), createNamespacedSecret: vi.fn().mockResolvedValue({}), readNamespacedSecret: vi.fn().mockResolvedValue({ metadata: { resourceVersion: '1' } }), replaceNamespacedSecret: vi.fn().mockResolvedValue({}), @@ -1318,7 +1329,12 @@ describe('WorkflowRecipeReconciler', () => { policyReady: true, verifiedAt: '2026-08-04T00:00:00.000Z', }, - networkPolicies: { conflicts: [], retryPending: false }, + networkPolicies: { + conflicts: [], + retryPending: false, + prune: 'converged', + legacy: 'removed', + }, }) const workflowReconcile = vi.fn() const setCodexReconcileContext = vi.fn() @@ -1377,7 +1393,12 @@ describe('WorkflowRecipeReconciler', () => { const reconcilePluginWorkloadSdkOnly = vi.fn().mockResolvedValue({ phase: 'active', message: 'Plugin Workload SDK mcp-host registered', - networkPolicies: { conflicts: [], retryPending: false }, + networkPolicies: { + conflicts: [], + retryPending: false, + prune: 'converged', + legacy: 'removed', + }, }) ;( reconciler as unknown as { @@ -1417,7 +1438,12 @@ describe('WorkflowRecipeReconciler', () => { const reconcilePluginWorkloadSdkOnly = vi.fn().mockResolvedValue({ phase: 'deploying', message: 'Plugin Workload SDK mcp-host starting', - networkPolicies: { conflicts: [], retryPending: false }, + networkPolicies: { + conflicts: [], + retryPending: false, + prune: 'converged', + legacy: 'removed', + }, }) ;( reconciler as unknown as { @@ -1508,6 +1534,8 @@ describe('WorkflowRecipeReconciler', () => { }, ], retryPending: false, + prune: 'converged', + legacy: 'removed', }, }) @@ -1532,7 +1560,12 @@ describe('WorkflowRecipeReconciler', () => { phase: 'active', message: 'Plugin Workload SDK mcp-host registered', pluginWorkloadSdkBootstrapProof: bootstrapProof, - networkPolicies: { conflicts: [], retryPending: false }, + networkPolicies: { + conflicts: [], + retryPending: false, + prune: 'converged', + legacy: 'removed', + }, }) const result = await reconciler.reconcile(sdkOnlyRecipe([ownershipCondition])) @@ -1573,6 +1606,8 @@ describe('WorkflowRecipeReconciler', () => { }, ], retryPending: false, + prune: 'converged', + legacy: 'removed', }, }) @@ -1594,7 +1629,12 @@ describe('WorkflowRecipeReconciler', () => { const converged = stubSdkOnly({ phase: 'failed', message: 'Plugin Workload SDK mcp-host could not start', - networkPolicies: { conflicts: [], retryPending: false }, + networkPolicies: { + conflicts: [], + retryPending: false, + prune: 'converged', + legacy: 'removed', + }, }) const failedConverged = await reconciler.reconcile(sdkOnlyRecipe([ownershipCondition])) @@ -1612,7 +1652,12 @@ describe('WorkflowRecipeReconciler', () => { phase: 'active', message: 'Plugin Workload SDK mcp-host registered', pluginWorkloadSdkBootstrapProof: bootstrapProof, - networkPolicies: { conflicts: [], retryPending: true }, + networkPolicies: { + conflicts: [], + retryPending: true, + prune: 'converged', + legacy: 'removed', + }, }) const retrying = await reconciler.reconcile(sdkOnlyRecipe()) @@ -1635,7 +1680,12 @@ describe('WorkflowRecipeReconciler', () => { phase: 'active', message: 'Plugin Workload SDK mcp-host registered', pluginWorkloadSdkBootstrapProof: bootstrapProof, - networkPolicies: { conflicts: [], retryPending: false }, + networkPolicies: { + conflicts: [], + retryPending: false, + prune: 'converged', + legacy: 'removed', + }, }) const steady = await reconciler.reconcile(sdkOnlyRecipe()) @@ -1655,7 +1705,12 @@ describe('WorkflowRecipeReconciler', () => { const pending = stubSdkOnly({ phase: 'failed', message: 'Plugin Workload SDK mcp-host could not start', - networkPolicies: { conflicts: [], retryPending: true }, + networkPolicies: { + conflicts: [], + retryPending: true, + prune: 'converged', + legacy: 'removed', + }, }) const retrying = await reconciler.reconcile(sdkOnlyRecipe()) @@ -1676,7 +1731,12 @@ describe('WorkflowRecipeReconciler', () => { const settled = stubSdkOnly({ phase: 'failed', message: 'Plugin Workload SDK mcp-host could not start', - networkPolicies: { conflicts: [], retryPending: false }, + networkPolicies: { + conflicts: [], + retryPending: false, + prune: 'converged', + legacy: 'removed', + }, }) const terminal = await reconciler.reconcile(sdkOnlyRecipe()) @@ -1717,6 +1777,7 @@ describe('WorkflowRecipeReconciler', () => { expect(cleanupPluginWorkloadSdk).toHaveBeenCalledTimes(1) expect(cleanupPluginWorkloadSdk).toHaveBeenCalledWith('test-recipe', { preserveWorkflowRuntime: false, + recipeUid: 'uid-123', }) expect(sdkOnly).not.toHaveBeenCalled() expect(result.phase).not.toBe('failed') @@ -1738,6 +1799,121 @@ describe('WorkflowRecipeReconciler', () => { 'WorkflowNetworkPolicyOwnership' ) }) + + // R4-L3: the teardown removed every SDK-owned policy, including the + // legacy one by name, so the lane without an SDK runtime is `unmanaged`: + // a prune it can no longer finish is not carried as PrunePending. + const prunePendingMarker = () => + buildNetworkPolicyConvergedCondition( + { apply: 'converged', prune: 'pending' }, + '2026-09-23T10:00:00.000Z', + undefined + )! + const capabilityRemovedRecipe = (phase: string, conditions: unknown[]) => + makeRecipe({ + spec: { + workloads: [{ id: 'app', type: 'deployment', image: 'nginx:1.30.1-alpine', port: 8080 }], + }, + status: { + phase, + pluginWorkloadSdk: { state: 'validated', promptBridge: true, clientNotifications: false }, + conditions, + }, + } as Partial) + const stubCapabilityRemovalTeardown = () => { + stubSdkOnly({}) + const cleanupPluginWorkloadSdk = vi.fn().mockResolvedValue(undefined) + ;( + reconciler as unknown as { + workflowReconciler: { cleanupPluginWorkloadSdk: typeof cleanupPluginWorkloadSdk } + } + ).workflowReconciler.cleanupPluginWorkloadSdk = cleanupPluginWorkloadSdk + return cleanupPluginWorkloadSdk + } + + it.each(['SDK-never-published', 'steps-removed'] as const)( + 'R5-L1: %s without an SDK runtime or teardown keeps its published apply, prune and legacy facts', + async history => { + const cleanup = stubCapabilityRemovalTeardown() + const inner = (reconciler as unknown as { workflowReconciler: WorkflowReconciler }) + .workflowReconciler + const retryLegacy = vi.fn().mockResolvedValue('pending') + inner.retryLegacyMcpServersInternetEgressDelete = retryLegacy + inner.legacyMcpServersInternetEgressRetryDelayMs = vi.fn().mockReturnValue(60_000) + const facts = networkPolicyMarkerConditions( + { + kind: 'reconcile', + summary: { conflicts: [], retryPending: true, prune: 'pending', legacy: 'pending' }, + }, + [], + '2026-09-23T10:00:00.000Z' + ) + const recipe = makeRecipe({ + spec: { + workloads: [ + { id: 'app', type: 'deployment', image: 'nginx:1.30.1-alpine', port: 8080 }, + ], + ...(history === 'steps-removed' ? { steps: [] } : {}), + }, + status: { + phase: 'active', + conditions: [ownershipCondition, ...facts], + ...(history === 'steps-removed' ? { workflowExecution: { phase: 'completed' } } : {}), + }, + }) + expect(recipe.spec.pluginWorkloadSdk).toBeUndefined() + expect(recipe.status?.pluginWorkloadSdk).toBeUndefined() + + const result = await reconciler.reconcile(recipe) + + expect(retryLegacy).toHaveBeenCalledWith('test-recipe', 'uid-123') + expect(cleanup).not.toHaveBeenCalled() + expect(result.networkPolicyOwnershipConditions).toBeUndefined() + expect(result.requeueAfterMs).toBe(60_000) + expect(result.requeueOperation).toBe('legacy-network-policy-delete') + await reconciler.patchStatus(recipe, result) + const published = mockCustomApi.patchNamespacedCustomObjectStatus.mock.calls.at(-1)![0].body + .status.conditions as StatusCondition[] + for (const fact of [ownershipCondition, ...facts]) { + expect(published).toContainEqual(fact) + } + } + ) + + it('R4-L3: clears a published PrunePending marker once the SDK capability is removed from the spec', async () => { + const cleanupPluginWorkloadSdk = stubCapabilityRemovalTeardown() + const recipe = capabilityRemovedRecipe('active', [ownershipCondition, prunePendingMarker()]) + + const result = await reconciler.reconcile(recipe) + + // Liveness witness: the capability-removal teardown ran. + expect(cleanupPluginWorkloadSdk).toHaveBeenCalledWith('test-recipe', { + preserveWorkflowRuntime: false, + recipeUid: 'uid-123', + }) + expect(result.phase).not.toBe('failed') + expect(result.networkPolicyOwnershipConditions).toEqual([]) + }) + + it('R4-L3: clears a published PrunePending marker when the SDK capability is removed from a failed recipe', async () => { + const cleanupPluginWorkloadSdk = stubCapabilityRemovalTeardown() + const recipe = capabilityRemovedRecipe('failed', [ownershipCondition, prunePendingMarker()]) + + await reconciler.reconcile(recipe) + + // Liveness witness: the capability-removal teardown ran. + expect(cleanupPluginWorkloadSdk).toHaveBeenCalledWith('test-recipe', { + preserveWorkflowRuntime: false, + recipeUid: 'uid-123', + }) + const conditionPatches = mockCustomApi.patchNamespacedCustomObjectStatus.mock.calls + .map(call => call[0].body.status) + .filter(status => Object.keys(status).length === 1 && 'conditions' in status) + expect(conditionPatches).toHaveLength(1) + const types = (conditionPatches[0].conditions as Array<{ type: string }>).map(c => c.type) + expect(types).not.toContain('WorkflowNetworkPoliciesConverged') + expect(types).not.toContain('WorkflowNetworkPolicyOwnership') + }) }) // awaiting_policy waits for an operator grant. The grant arrives by event @@ -1749,7 +1925,12 @@ describe('WorkflowRecipeReconciler', () => { const reconcilePluginWorkloadSdkOnly = vi.fn().mockResolvedValue({ phase: 'awaiting_policy', message: 'operator policy pending (policy_not_ready)', - networkPolicies: { conflicts: [], retryPending: false }, + networkPolicies: { + conflicts: [], + retryPending: false, + prune: 'converged', + legacy: 'removed', + }, }) ;( reconciler as unknown as { @@ -1827,6 +2008,7 @@ describe('WorkflowRecipeReconciler', () => { expect(result.phase).toBe('active') expect(cleanupPluginWorkloadSdk).toHaveBeenCalledWith('test-recipe', { preserveWorkflowRuntime: false, + recipeUid: 'uid-123', }) expect(workflowReconcile).not.toHaveBeenCalled() }) @@ -1857,6 +2039,7 @@ describe('WorkflowRecipeReconciler', () => { expect(result.phase).toBe(phase) expect(cleanupPluginWorkloadSdk).toHaveBeenCalledWith('test-recipe', { preserveWorkflowRuntime: false, + recipeUid: 'uid-123', }) } ) @@ -1902,10 +2085,69 @@ describe('WorkflowRecipeReconciler', () => { }) expect(cleanupPluginWorkloadSdk).toHaveBeenCalledWith('test-recipe', { preserveWorkflowRuntime: false, + recipeUid: 'uid-123', }) expect(workflowReconcile).not.toHaveBeenCalled() }) + it.each(['kill-switch', 'capability-removal'] as const)( + 'R5-L2: %s cleanup passes the live UID and preserves the exact legacy backoff', + async branch => { + const cleanup = vi.fn() + const retry = vi.fn() + const delay = vi.fn() + const internal = reconciler as unknown as { + config: { pluginWorkloadSdkEnabled: boolean } + workflowReconciler: unknown + } + internal.config.pluginWorkloadSdkEnabled = branch !== 'kill-switch' + internal.workflowReconciler = { + cleanupPluginWorkloadSdk: cleanup, + retryLegacyMcpServersInternetEgressDelete: retry, + legacyMcpServersInternetEgressRetryDelayMs: delay, + } + const conditions = networkPolicyMarkerConditions( + { + kind: 'reconcile', + summary: { conflicts: [], retryPending: true, prune: 'pending', legacy: 'pending' }, + }, + [], + '2026-09-23T10:00:00.000Z' + ) + const recipe = makeRecipe({ + spec: { + workloads: [], + ...(branch === 'kill-switch' ? { pluginWorkloadSdk: { allowedCallers: [] } } : {}), + }, + status: { + phase: 'active', + conditions, + pluginWorkloadSdk: { state: 'validated', promptBridge: true, clientNotifications: false }, + }, + }) + for (const remaining of [60_000, 40_123, 999]) { + cleanup.mockRejectedValue( + new LegacyNetworkPolicyDeletePendingError('test-recipe', remaining) + ) + const result = await reconciler.reconcile(recipe) + expect(cleanup).toHaveBeenLastCalledWith('test-recipe', { + preserveWorkflowRuntime: false, + recipeUid: 'uid-123', + }) + expect(result.requeueAfterMs).toBe(remaining) + expect(result.requeueFixedInterval).toBe(true) + expect(result.requeueOperation).toBeUndefined() + expect(result.skipStatusPatch).toBe(true) + expect(result.networkPolicyOwnershipConditions).toBeUndefined() + expect(result.pluginWorkloadSdkTeardownConfirmed).toBeUndefined() + expect(recipe.status?.conditions).toEqual(conditions) + } + expect(retry).not.toHaveBeenCalled() + expect(delay).not.toHaveBeenCalled() + expect(mockCustomApi.patchNamespacedCustomObjectStatus).not.toHaveBeenCalled() + } + ) + // The kill switch returns before the SDK-only lane, which is the only path // that clears the ownership condition. With the runtime torn down no policy // is managed, so a conflict published earlier is stale. @@ -3266,9 +3508,13 @@ describe('WorkflowRecipeReconciler', () => { const deployWorkflowWorkloads = () => ( reconciler as unknown as { - deployWorkflowWorkloads: (r: WorkflowRecipeCRD, n: string) => Promise + deployWorkflowWorkloads: ( + r: WorkflowRecipeCRD, + n: string, + oauthBrokerTokenReapedEarly: boolean + ) => Promise } - ).deployWorkflowWorkloads(recipe, recipe.metadata.name) + ).deployWorkflowWorkloads(recipe, recipe.metadata.name, false) await deployWorkflowWorkloads() const created = onlyPost(mockCoreApi.createNamespacedService) const name = created.body.metadata.name @@ -7470,6 +7716,41 @@ describe('WorkflowRecipeReconciler', () => { ) }) + // UID identifies the shared ledger. Only a real finalizer may forget it after + // the last-chance cleanup succeeds; a live teardown must retain that history. + it.each([false, true])( + 'R5-L2: reconcileDelete identifies finalizer SDK cleanup for workflow=%s', + async workflow => { + const cleanupPluginWorkloadSdk = vi.fn().mockResolvedValue(undefined) + const deleteWorkflow = vi.fn().mockResolvedValue(undefined) + ;( + reconciler as unknown as { + workflowReconciler: { cleanupPluginWorkloadSdk: typeof cleanupPluginWorkloadSdk } + } + ).workflowReconciler = { cleanupPluginWorkloadSdk, reconcileDelete: deleteWorkflow } + const recipe = makeRecipe({ + metadata: { name: 'test-recipe', namespace: 'sandbox-recipes', uid: 'uid-sdk-delete' }, + spec: { + workloads: [{ id: 'app', type: 'deployment', image: 'nginx:1.30.1-alpine', port: 8080 }], + ...(workflow ? { steps: [{ id: 'task', instruction: 'run' }] } : {}), + }, + status: { + phase: 'active', + pluginWorkloadSdk: { state: 'validated', promptBridge: true, clientNotifications: false }, + }, + } as Partial) + + await reconciler.reconcileDelete(recipe) + + expect(cleanupPluginWorkloadSdk).toHaveBeenCalledTimes(1) + expect(cleanupPluginWorkloadSdk).toHaveBeenCalledWith('test-recipe', { + recipeUid: 'uid-sdk-delete', + recipeDeleted: true, + ...(workflow ? { preserveWorkflowRuntime: true } : {}), + }) + } + ) + // ─── Phase 8: Namespace Splitting ───────────────────────────────── it('R.8.1 — non-MCP workload deploys to sandbox-recipes namespace', async () => { @@ -9153,7 +9434,12 @@ describe('WorkflowRecipeReconciler', () => { await reconciler.reconcileDelete(recipe) - expect(workflowInfraCleanup).toHaveBeenCalledWith('test-recipe', 'sandbox-recipes', recipe.spec) + expect(workflowInfraCleanup).toHaveBeenCalledWith( + 'test-recipe', + 'sandbox-recipes', + recipe.spec, + recipe.metadata.uid + ) expect(mockAppsApi.deleteNamespacedDeployment).toHaveBeenCalledWith({ name: 'test-recipe-web-search-12345678', namespace: 'mcp-server', @@ -9195,7 +9481,12 @@ describe('WorkflowRecipeReconciler', () => { await reconciler.reconcileDelete(recipe) expect(order).toEqual(['sdk-revoke-and-cleanup', 'workflow-cleanup']) - expect(workflowInfraCleanup).toHaveBeenCalledWith('test-recipe', 'sandbox-recipes', recipe.spec) + expect(workflowInfraCleanup).toHaveBeenCalledWith( + 'test-recipe', + 'sandbox-recipes', + recipe.spec, + recipe.metadata.uid + ) }) it('forwards custom coordinator workflow fields to the workflow reconciler', async () => { @@ -9931,6 +10222,67 @@ describe('WorkflowRecipeReconciler', () => { expect(result.requeueAfterMs).toBeUndefined() }) + // A marker naming both facts: the retry still owes an apply, and a + // reconcile() prune still owes a DELETE. Built by the production builder, + // so the fixture cannot drift from what a pass publishes. + const retryAndPruneMarker = buildNetworkPolicyConvergedCondition( + { apply: 'pending', prune: 'pending' }, + retryMarker.lastTransitionTime + )! + + it('R4-L5: the marker naming both facts carries its own reason', () => { + expect(retryAndPruneMarker.reason).toBe('RetryAndPrunePending') + }) + + it('reapplies from the active short-circuit for a marker that names both facts, and keeps the prune fact', async () => { + const retry = vi.fn().mockResolvedValue({ conflicts: [], retryPending: false }) + installRunLane(retry) + const recipe = runningRecipe({ conditions: [unrelatedCondition, retryAndPruneMarker] }) + + const result = await reconciler.reconcile(recipe) + + expect(retry).toHaveBeenCalledTimes(1) + const patches = conditionPatches() + expect(patches).toHaveLength(1) + expect(patches[0].body.status.conditions).toEqual([ + unrelatedCondition, + expect.objectContaining({ + type: 'WorkflowNetworkPoliciesConverged', + status: 'False', + reason: 'PrunePending', + lastTransitionTime: retryMarker.lastTransitionTime, + }), + ]) + expect(result.requeueAfterMs).toBeUndefined() + }) + + it('rewrites a marker that names both facts to PrunePending from the terminal branch', async () => { + const retry = vi.fn() + const stub = installRunLane(retry) + const recipe = runningRecipe({ + execPhase: 'completed', + conditions: [unrelatedCondition, retryAndPruneMarker], + }) + + await reconciler.reconcile(recipe) + + // Witness: the terminal branch ran. + expect(stub.ensureMcpHostRuntimeCredentials).toHaveBeenCalledTimes(1) + expect(retry).not.toHaveBeenCalled() + const patches = conditionPatches() + expect(patches).toHaveLength(1) + expect(Object.keys(patches[0].body.status)).toEqual(['conditions']) + expect(patches[0].body.status.conditions).toEqual([ + unrelatedCondition, + expect.objectContaining({ + type: 'WorkflowNetworkPoliciesConverged', + status: 'False', + reason: 'PrunePending', + lastTransitionTime: retryMarker.lastTransitionTime, + }), + ]) + }) + it('opens no patch from the terminal branch without the marker', async () => { const retry = vi.fn() const stub = installRunLane(retry) @@ -15368,86 +15720,1867 @@ describe('WorkflowRecipeReconciler', () => { }) }) - // ─── Broker-token issuance failure is non-fatal ───────────────────────── - // - // A transient control-api blip during reconcile (e.g. it is mid-restart) - // used to permanently brick the recipe — phase=failed is a terminal, - // non-deployable state and the rotation loop alone cannot transition out - // of it. Reconcile now logs and continues so dependent pods sit in - // ContainerCreating until the next rotation tick succeeds, rather than - // the whole recipe getting stuck. - describe('broker-token issuance failure during reconcile', () => { - beforeEach(() => { - vi.spyOn(brokerIssuer, 'issueOAuthBrokerToken').mockReset() - mockCoreApi.readNamespacedSecret.mockReset() - }) + describe('B3(a)(b) oauth-broker delete-on-transition ledger', () => { + const SECRET_NAME = 'wf-test-recipe-oauth-broker-token' + const POLICY_NAME = 'wf-test-recipe-oauth-broker-egress' - function recipeWithBackgroundAccess(): WorkflowRecipeCRD { + function reapRecipe(generation: number): WorkflowRecipeCRD { return makeRecipe({ - spec: { - workloads: [{ id: 'app', type: 'deployment', image: 'nginx:1.30.1-alpine', port: 8080 }], - oauthClients: [ - { - id: 'gmail', - provider: 'google', - clientIdRef: { name: 'creds', key: 'client-id' }, - clientSecretRef: { name: 'creds', key: 'client-secret' }, - scopes: ['https://www.googleapis.com/auth/gmail.readonly'], - backgroundAccess: true, - }, - ], + metadata: { + name: 'test-recipe', + namespace: 'sandbox-recipes', + uid: 'uid-123', + generation, }, }) } - it('does not transition the recipe to failed when issueOAuthBrokerToken throws', async () => { - // Secret missing → reconcile takes the "create" branch, which calls - // issueOAuthBrokerToken. The mock makes that call time out. - mockCoreApi.readNamespacedSecret.mockRejectedValue( - Object.assign(new Error('not found'), { code: 404 }) - ) - vi.spyOn(brokerIssuer, 'issueOAuthBrokerToken').mockRejectedValue( - new Error('OAuth broker token issuance timed out after 10000ms for recipe "test-recipe"') - ) - const warnSpy = captureLogger('warn') + function secretDeletes(): number { + return mockCoreApi.deleteNamespacedSecret.mock.calls.filter( + ([arg]) => arg.name === SECRET_NAME + ).length + } - const result = await reconciler.reconcile(recipeWithBackgroundAccess()) + function policyDeletes(): number { + return mockNetworkingApi.deleteNamespacedNetworkPolicy.mock.calls.filter( + ([arg]) => arg.name === POLICY_NAME + ).length + } - expect(result.phase).not.toBe('failed') - expect(result.phase).toBe('active') - // Warning was emitted so the operator can see what happened. - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('Broker-token issuance failed during reconcile'), - expect.objectContaining({ err: expect.any(Error) }) + async function reapPolicy(recipe: WorkflowRecipeCRD): Promise { + await ( + reconciler as unknown as { + reconcileOAuthBrokerEgressPolicy: (next: WorkflowRecipeCRD) => Promise + } + ).reconcileOAuthBrokerEgressPolicy(recipe) + } + + /** + * The Secret watch observed the recipe's token (an ADDED, or the relist + * that replays one). The Secret-side cases start here: without it the + * ledger reaps nothing, so a skipped DELETE would prove nothing. + */ + function tokenSeen(recipeName = 'test-recipe'): void { + reconciler.invalidateOAuthBrokerSecretLedger(recipeName) + } + + it('deletes the Secret once for the same generation and logs the skip', async () => { + const recipe = reapRecipe(4) + tokenSeen() + const debugSpy = captureLogger('debug') + + await reconciler.ensureOAuthBrokerTokenSecret(recipe) + await reconciler.ensureOAuthBrokerTokenSecret(recipe) + + expect(secretDeletes()).toBe(1) + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining('Skipping oauth-broker-token delete'), + expect.objectContaining({ recipe: 'test-recipe', generation: 4 }) ) - // Workloads still deployed. - expect(mockAppsApi.createNamespacedDeployment).toHaveBeenCalledTimes(1) - warnSpy.mockRestore() + debugSpy.mockRestore() }) - }) - // ─── Transient API-server blip must not latch a healthy recipe ────────── - // - // A momentary controller↔API partition (connect ETIMEDOUT) used to throw - // mid-reconcile and flip an otherwise-healthy recipe to the terminal - // `failed` phase, which the skip guard then refused to re-process — a - // false-positive failure with no self-recovery. Reconcile now classifies - // retryable infra errors and leaves the recipe alone so the next resync - // retries, and re-reconciles a transiently-latched recipe back to active. - describe('transient infra error handling', () => { - function etimedout(): Error { - return Object.assign( - new Error( - 'request to https://203.0.113.10/api/v1/namespaces/sandbox-recipes/services/app failed, reason: connect ETIMEDOUT 203.0.113.10:443' - ), - { code: 'ETIMEDOUT', name: 'FetchError' } - ) - } + it('deletes the Secret again when metadata.generation changes', async () => { + tokenSeen() + await reconciler.ensureOAuthBrokerTokenSecret(reapRecipe(4)) + await reconciler.ensureOAuthBrokerTokenSecret(reapRecipe(4)) + expect(secretDeletes()).toBe(1) - it('does not latch an active recipe to failed when an API call times out', async () => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) - // A K8s call deep in the pipeline (Service create) times out at connect. - mockCoreApi.createNamespacedService.mockRejectedValueOnce(etimedout()) + await reconciler.ensureOAuthBrokerTokenSecret(reapRecipe(5)) + expect(secretDeletes()).toBe(2) + }) + + it('a new process sends no token DELETE until the watch replays the token ADDED, then one', async () => { + tokenSeen() + await reconciler.ensureOAuthBrokerTokenSecret(reapRecipe(4)) + expect(secretDeletes()).toBe(1) + + const next = new WorkflowRecipeReconciler(new k8s.KubeConfig(), undefined, { + verifyWorkflowRunProvenance: mockVerifyWorkflowRunProvenance, + }) + await next.ensureOAuthBrokerTokenSecret(reapRecipe(4)) + expect(secretDeletes()).toBe(1) + + // The Secret watch's initial list replays ADDED for a token that exists. + next.invalidateOAuthBrokerSecretLedger('test-recipe') + await next.ensureOAuthBrokerTokenSecret(reapRecipe(4)) + expect(secretDeletes()).toBe(2) + }) + + it('deletes the Secret again after ADDED invalidation', async () => { + const recipe = reapRecipe(4) + tokenSeen() + await reconciler.ensureOAuthBrokerTokenSecret(recipe) + await reconciler.ensureOAuthBrokerTokenSecret(recipe) + expect(secretDeletes()).toBe(1) + + reconciler.invalidateOAuthBrokerSecretLedger('test-recipe') + await reconciler.ensureOAuthBrokerTokenSecret(recipe) + expect(secretDeletes()).toBe(2) + }) + + it('ADDED invalidation re-arms the Secret delete but keeps the NetworkPolicy TTL', async () => { + const recipe = reapRecipe(4) + tokenSeen() + await reconciler.ensureOAuthBrokerTokenSecret(recipe) + await reapPolicy(recipe) + expect(secretDeletes()).toBe(1) + expect(policyDeletes()).toBe(1) + + reconciler.invalidateOAuthBrokerSecretLedger('test-recipe') + await reconciler.ensureOAuthBrokerTokenSecret(recipe) + await reapPolicy(recipe) + expect(secretDeletes()).toBe(2) + expect(policyDeletes()).toBe(1) + }) + + function recreatedRecipe(): WorkflowRecipeCRD { + return makeRecipe({ + metadata: { + name: 'test-recipe', + namespace: 'sandbox-recipes', + uid: 'uid-recreated', + generation: 1, + }, + }) + } + + /** Fails only the token Secret DELETE; every other Secret DELETE succeeds. */ + function failTokenDelete(code: number): void { + mockCoreApi.deleteNamespacedSecret.mockImplementation(async ({ name }: { name: string }) => { + if (name === SECRET_NAME) throw { code } + return {} + }) + } + + it.each(['2xx', '404'] as const)( + 'R5-L6: after the finalizer observed token %s, a recreated recipe sends no DELETE until a new token ADDED, then even below the old watermark', + async outcome => { + const recipe = reapRecipe(4) + tokenSeen() + await reconciler.ensureOAuthBrokerTokenSecret(recipe) + await reconciler.ensureOAuthBrokerTokenSecret(recipe) + expect(secretDeletes()).toBe(1) + + if (outcome === '404') failTokenDelete(404) + try { + await reconciler.reconcileDelete(recipe) + } finally { + mockCoreApi.deleteNamespacedSecret.mockResolvedValue({}) + } + // The real finalizer sent the token DELETE and mapped its observed result. + expect(secretDeletes()).toBe(2) + mockCoreApi.deleteNamespacedSecret.mockClear() + + const debugSpy = captureLogger('debug') + try { + await reconciler.ensureOAuthBrokerTokenSecret(recreatedRecipe()) + await reconciler.ensureOAuthBrokerTokenSecret(recreatedRecipe()) + expect(secretDeletes()).toBe(0) + // Liveness witness: both passes reached the ledger and it declined. + expect( + debugSpy.mock.calls.filter( + ([message, fields]) => + typeof message === 'string' && + message.startsWith('Skipping oauth-broker-token delete') && + (fields as { generation?: number }).generation === 1 + ) + ).toHaveLength(2) + } finally { + debugSpy.mockRestore() + } + + // A token ADDED for a recipe under the same name: the old watermark is + // gone, so a lower generation deletes. + tokenSeen() + await reconciler.ensureOAuthBrokerTokenSecret(reapRecipe(3)) + expect(secretDeletes()).toBe(1) + } + ) + + it('R4-L1/R5-L7: a failed finalizer token DELETE preserves its error object and keeps the token seen', async () => { + tokenSeen() + const error = { code: 500, message: 'delete failed' } + const errorLog = captureLogger('error') + mockCoreApi.deleteNamespacedSecret.mockImplementation(async ({ name }: { name: string }) => { + if (name === SECRET_NAME) throw error + return {} + }) + try { + await reconciler.reconcileDelete(reapRecipe(4)) + // Witness: the finalizer sent the token DELETE and it failed. + expect(secretDeletes()).toBe(1) + expect(errorLog).toHaveBeenCalledWith('Failed to delete resource', { + label: `Secret "${SECRET_NAME}" in sandbox-recipes`, + err: error, + }) + const failure = errorLog.mock.calls.find( + ([message]) => message === 'Failed to delete resource' + ) + expect((failure?.[1] as { err?: unknown }).err).toBe(error) + } finally { + mockCoreApi.deleteNamespacedSecret.mockResolvedValue({}) + errorLog.mockRestore() + } + mockCoreApi.deleteNamespacedSecret.mockClear() + + // The recipe is recreated under the same name without backgroundAccess. + await reconciler.ensureOAuthBrokerTokenSecret(recreatedRecipe()) + await reconciler.ensureOAuthBrokerTokenSecret(recreatedRecipe()) + expect(secretDeletes()).toBe(1) + }) + + it('R4-L1: a token ADDED while the finalizer DELETE is in flight keeps the token seen', async () => { + tokenSeen() + mockCoreApi.deleteNamespacedSecret.mockImplementation(async ({ name }: { name: string }) => { + // A late ADDED delivery (or a restart's relist) was handled before the + // finalizer's DELETE returned; it does not imply a concurrent CR recreation. + if (name === SECRET_NAME) reconciler.invalidateOAuthBrokerSecretLedger('test-recipe') + return {} + }) + try { + await reconciler.reconcileDelete(reapRecipe(4)) + // Witness: the finalizer sent the token DELETE. + expect(secretDeletes()).toBe(1) + } finally { + mockCoreApi.deleteNamespacedSecret.mockResolvedValue({}) + } + mockCoreApi.deleteNamespacedSecret.mockClear() + + await reconciler.ensureOAuthBrokerTokenSecret(recreatedRecipe()) + await reconciler.ensureOAuthBrokerTokenSecret(recreatedRecipe()) + expect(secretDeletes()).toBe(1) + }) + + it('G3: does not record a Secret delete after DELETE 403 or 500', async () => { + const recipe = reapRecipe(4) + tokenSeen() + mockCoreApi.deleteNamespacedSecret.mockRejectedValue({ code: 403 }) + await reconciler.ensureOAuthBrokerTokenSecret(recipe) + await reconciler.ensureOAuthBrokerTokenSecret(recipe) + expect(secretDeletes()).toBe(2) + + mockCoreApi.deleteNamespacedSecret.mockRejectedValue({ code: 500 }) + mockCoreApi.deleteNamespacedSecret.mockClear() + await reconciler.ensureOAuthBrokerTokenSecret(recipe) + await reconciler.ensureOAuthBrokerTokenSecret(recipe) + expect(secretDeletes()).toBe(2) + mockCoreApi.deleteNamespacedSecret.mockResolvedValue({}) + }) + + it('G3: records a Secret delete after DELETE 404 and skips the next same-generation call', async () => { + mockCoreApi.deleteNamespacedSecret.mockRejectedValue({ code: 404 }) + const recipe = reapRecipe(7) + tokenSeen() + await reconciler.ensureOAuthBrokerTokenSecret(recipe) + await reconciler.ensureOAuthBrokerTokenSecret(recipe) + expect(secretDeletes()).toBe(1) + mockCoreApi.deleteNamespacedSecret.mockResolvedValue({}) + }) + + it('G3: does not start the NP TTL after DELETE 403', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-09-25T00:00:00.000Z')) + mockNetworkingApi.deleteNamespacedNetworkPolicy.mockRejectedValue({ code: 403 }) + const recipe = reapRecipe(4) + await reapPolicy(recipe) + await reapPolicy(recipe) + expect(policyDeletes()).toBe(2) + vi.setSystemTime(new Date('2026-09-25T01:00:00.000Z')) + await reapPolicy(recipe) + expect(policyDeletes()).toBe(3) + mockNetworkingApi.deleteNamespacedNetworkPolicy.mockResolvedValue({}) + vi.useRealTimers() + }) + + it('c3: deletes the NetworkPolicy again when metadata.generation changes', async () => { + await reapPolicy(reapRecipe(4)) + await reapPolicy(reapRecipe(4)) + expect(policyDeletes()).toBe(1) + await reapPolicy(reapRecipe(5)) + expect(policyDeletes()).toBe(2) + }) + + it('c3: a recorded delete for one recipe does not skip another recipe', async () => { + tokenSeen() + tokenSeen('other-recipe') + await reconciler.ensureOAuthBrokerTokenSecret(reapRecipe(0)) + // Liveness witness: the first recipe's delete really ran. + expect(secretDeletes()).toBe(1) + const other = makeRecipe({ + metadata: { + name: 'other-recipe', + namespace: 'sandbox-recipes', + uid: 'uid-other', + }, + }) + await reconciler.ensureOAuthBrokerTokenSecret(other) + expect( + mockCoreApi.deleteNamespacedSecret.mock.calls.filter( + ([arg]) => arg.name === 'wf-other-recipe-oauth-broker-token' + ) + ).toHaveLength(1) + }) + + it('R4-L2: deletes the NetworkPolicy once for the same generation, then again after the 1h TTL, logging the skip at debug', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-09-25T00:00:00.000Z')) + const recipe = reapRecipe(4) + const logs = captureLoggerLevels(['debug', 'info'] as const) + + await reapPolicy(recipe) + await reapPolicy(recipe) + expect(policyDeletes()).toBe(1) + // Witness: the skip is logged, at debug. + expect(logs.calls.debug).toHaveBeenCalledWith( + expect.stringContaining('Skipping oauth-broker-egress delete'), + expect.objectContaining({ recipe: 'test-recipe', generation: 4 }) + ) + expect(logs.calls.info).not.toHaveBeenCalledWith( + expect.stringContaining('Skipping oauth-broker-egress delete'), + expect.anything() + ) + + vi.setSystemTime(new Date('2026-09-25T01:00:00.000Z')) + await reapPolicy(recipe) + expect(policyDeletes()).toBe(2) + logs.restore() + vi.useRealTimers() + }) + + it('R1-L2: a late pass carrying an older generation does not re-delete the Secret', async () => { + tokenSeen() + const debugSpy = captureLogger('debug') + await reconciler.ensureOAuthBrokerTokenSecret(reapRecipe(4)) + await reconciler.ensureOAuthBrokerTokenSecret(reapRecipe(5)) + // Liveness witness: both newer-generation deletes really ran. + expect(secretDeletes()).toBe(2) + + await reconciler.ensureOAuthBrokerTokenSecret(reapRecipe(4)) + expect(secretDeletes()).toBe(2) + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining('Skipping oauth-broker-token delete'), + expect.objectContaining({ recipe: 'test-recipe', generation: 4 }) + ) + debugSpy.mockRestore() + }) + + it('R1-L2: a late pass carrying an older generation does not re-delete the NetworkPolicy, and R4-L2 logs the skip at debug', async () => { + const logs = captureLoggerLevels(['debug', 'info'] as const) + await reapPolicy(reapRecipe(4)) + await reapPolicy(reapRecipe(5)) + // Liveness witness: both newer-generation deletes really ran. + expect(policyDeletes()).toBe(2) + + await reapPolicy(reapRecipe(4)) + expect(policyDeletes()).toBe(2) + // Witness: the skip is logged, at debug. + expect(logs.calls.debug).toHaveBeenCalledWith( + expect.stringContaining('Skipping oauth-broker-egress delete'), + expect.objectContaining({ recipe: 'test-recipe', generation: 4 }) + ) + expect(logs.calls.info).not.toHaveBeenCalledWith( + expect.stringContaining('Skipping oauth-broker-egress delete'), + expect.anything() + ) + logs.restore() + }) + + it('R1-L2: a recipe recreated under the same name is reaped even at a lower generation', async () => { + tokenSeen() + await reconciler.ensureOAuthBrokerTokenSecret(reapRecipe(5)) + await reapPolicy(reapRecipe(5)) + expect(secretDeletes()).toBe(1) + expect(policyDeletes()).toBe(1) + + // The recipe DELETE event was missed, so nothing invalidated the ledger. + const recreated = makeRecipe({ + metadata: { + name: 'test-recipe', + namespace: 'sandbox-recipes', + uid: 'uid-recreated', + generation: 1, + }, + }) + await reconciler.ensureOAuthBrokerTokenSecret(recreated) + await reapPolicy(recreated) + expect(secretDeletes()).toBe(2) + expect(policyDeletes()).toBe(2) + }) + + it('R1-L1: an ADDED invalidation that lands while the DELETE is in flight is not overwritten', async () => { + const recipe = reapRecipe(4) + tokenSeen() + mockCoreApi.deleteNamespacedSecret.mockImplementationOnce(async () => { + // Another writer recreated the Secret and its ADDED was handled before + // this DELETE returned. + reconciler.invalidateOAuthBrokerSecretLedger('test-recipe') + throw { code: 404 } + }) + await reconciler.ensureOAuthBrokerTokenSecret(recipe) + expect(secretDeletes()).toBe(1) + + await reconciler.ensureOAuthBrokerTokenSecret(recipe) + expect(secretDeletes()).toBe(2) + + // Liveness witness for the ledger itself: with no racing ADDED the + // second delete is recorded and the next same-generation pass skips. + await reconciler.ensureOAuthBrokerTokenSecret(recipe) + expect(secretDeletes()).toBe(2) + }) + + it('R2-L1: a stale pass of an older generation does not delete the token a newer one provisioned', async () => { + const issue = vi + .spyOn(brokerIssuer, 'issueOAuthBrokerToken') + .mockResolvedValue({ brokerToken: 'issued-token' } as Awaited< + ReturnType + >) + const debugSpy = captureLogger('debug') + try { + tokenSeen() + // gen4 has no backgroundAccess: the Secret is reaped and recorded. + await reconciler.ensureOAuthBrokerTokenSecret(reapRecipe(4)) + expect(secretDeletes()).toBe(1) + + // gen5 turns backgroundAccess on and the token is issued. + const withBackgroundAccess = makeRecipe({ + metadata: { + name: 'test-recipe', + namespace: 'sandbox-recipes', + uid: 'uid-123', + generation: 5, + }, + spec: { + workloads: [ + { id: 'app', type: 'deployment', image: 'nginx:1.30.1-alpine', port: 8080 }, + ], + oauthClients: [ + { + id: 'gmail', + provider: 'google', + clientIdRef: { name: 'creds', key: 'client-id' }, + clientSecretRef: { name: 'creds', key: 'client-secret' }, + scopes: ['https://www.googleapis.com/auth/gmail.readonly'], + backgroundAccess: true, + }, + ], + }, + }) + mockCoreApi.readNamespacedSecret.mockRejectedValueOnce({ code: 404 }) + await reconciler.ensureOAuthBrokerTokenSecret(withBackgroundAccess) + // Witness that the issuance branch ran for gen5. + expect(issue).toHaveBeenCalledTimes(1) + expect( + mockCoreApi.createNamespacedSecret.mock.calls.filter( + ([arg]) => arg.body?.metadata?.name === SECRET_NAME + ) + ).toHaveLength(1) + + // The token's watch ADDED, then a queued pass still carrying gen4. + reconciler.invalidateOAuthBrokerSecretLedger('test-recipe') + await reconciler.ensureOAuthBrokerTokenSecret(reapRecipe(4)) + expect(secretDeletes()).toBe(1) + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining('Skipping oauth-broker-token delete'), + expect.objectContaining({ recipe: 'test-recipe', generation: 4 }) + ) + + // Liveness witness: a newer generation that drops backgroundAccess reaps it. + await reconciler.ensureOAuthBrokerTokenSecret(reapRecipe(6)) + expect(secretDeletes()).toBe(2) + } finally { + debugSpy.mockRestore() + issue.mockRestore() + } + }) + + function shortCircuitWorkflowRecipe( + spec: Partial, + status: WorkflowRecipeCRD['status'], + labels: Record + ): WorkflowRecipeCRD { + return makeRecipe({ + spec: { + agent: { provider: 'zai', model: 'glm-4.7' }, + workloads: [], + steps: [{ id: 'run-qa', instruction: 'Validate the QA API workload.' }], + ...spec, + }, + metadata: { + name: 'test-recipe', + namespace: 'sandbox-recipes', + uid: 'uid-123', + generation: 4, + labels, + }, + status, + }) + } + + const RUN_LABEL = { 'clerum.io/workflow-run-id': 'run-123' } + + // Every workflow pass that returns before the first-deploy path. A triggered + // run (RUN_LABEL) has awaitsTriggeredRun=false; the onDemand recipe without + // one idles awaiting its trigger. + const WORKFLOW_SHORT_CIRCUITS = [ + { + label: 'awaiting-trigger', + recipe: () => + shortCircuitWorkflowRecipe( + { triggers: { onDemand: { allowedActors: ['user'] } } }, + { phase: 'active' }, + {} + ), + expected: { phase: 'active', message: 'Workflow trigger infrastructure registered' }, + }, + { + label: 'active', + recipe: () => + shortCircuitWorkflowRecipe( + {}, + { phase: 'active', workflowExecution: { phase: 'running' } }, + RUN_LABEL + ), + expected: { phase: 'active', message: 'Workflow running' }, + }, + { + label: 'in-progress', + recipe: () => + shortCircuitWorkflowRecipe( + {}, + { phase: 'deploying', workflowExecution: { phase: 'running' } }, + RUN_LABEL + ), + expected: { phase: 'active', message: 'Workflow running' }, + }, + { + label: 'terminal', + recipe: () => + shortCircuitWorkflowRecipe( + {}, + { phase: 'active', workflowExecution: { phase: 'completed' } }, + RUN_LABEL + ), + expected: { phase: 'active', message: 'Workflow completed' }, + }, + { + label: 'dryRun', + recipe: () => shortCircuitWorkflowRecipe({ dryRun: true }, { phase: 'approved' }, {}), + expected: { + phase: 'candidate', + message: 'Dry-run: preview generated, no resources created', + }, + }, + ] + + function installShortCircuitWorkflowReconciler(): ReturnType { + const workflowReconcile = vi.fn() + ;( + reconciler as unknown as { + workflowReconciler: { + reconcile: typeof workflowReconcile + validateWorkflowSpec: () => undefined + ensureMcpHostRuntimeCredentials?: () => Promise + } + } + ).workflowReconciler = { + reconcile: workflowReconcile, + validateWorkflowSpec: () => undefined, + ensureMcpHostRuntimeCredentials: vi.fn().mockResolvedValue(undefined), + } + return workflowReconcile + } + + // The four short-circuits the early reap in reconcileWorkflowRecipe names. + describe.each(WORKFLOW_SHORT_CIRCUITS.filter(({ label }) => label !== 'dryRun'))( + 'R1-L13: the $label workflow short-circuit', + ({ recipe, expected }) => { + it('reaps a token Secret the recipe must not have, without the inner reconcile', async () => { + const workflowReconcile = installShortCircuitWorkflowReconciler() + const current = recipe() + + // The watch ADDED re-arms the ledger and enqueues this reconcile. + reconciler.invalidateOAuthBrokerSecretLedger('test-recipe') + const result = await reconciler.reconcile(current) + + // Witness that the short-circuit was taken, not the first-deploy path. + expect(workflowReconcile).not.toHaveBeenCalled() + expect(result).toMatchObject(expected) + expect(secretDeletes()).toBe(1) + + // The ledger still bounds it: a second pass of the same generation skips. + await reconciler.reconcile(current) + expect(secretDeletes()).toBe(1) + }) + } + ) + + describe.each(WORKFLOW_SHORT_CIRCUITS)( + 'R2-L2: the $label workflow short-circuit on a fresh process', + ({ recipe, expected }) => { + it('sends no token DELETE until a token ADDED arrives, then exactly one', async () => { + const workflowReconcile = installShortCircuitWorkflowReconciler() + const current = recipe() + + // A restarted process: no token ADDED has been observed for the recipe. + for (let pass = 0; pass < 3; pass++) { + const result = await reconciler.reconcile(current) + // Witness that each pass took the short-circuit under test. + expect(result).toMatchObject(expected) + } + expect(workflowReconcile).not.toHaveBeenCalled() + expect(secretDeletes()).toBe(0) + + // The watch relist replays ADDED for a token that really exists. + reconciler.invalidateOAuthBrokerSecretLedger('test-recipe') + await reconciler.reconcile(current) + expect(secretDeletes()).toBe(1) + + // The ledger bounds it: the next pass of the same generation skips. + await reconciler.reconcile(current) + expect(secretDeletes()).toBe(1) + }) + } + ) + + function installFirstDeployWorkflow(): { + recipe: WorkflowRecipeCRD + workflowReconcile: ReturnType + } { + const workflowReconcile = vi.fn().mockResolvedValue({ + phase: 'deploying', + message: 'Workflow infrastructure created', + workflowPhase: 'initializing', + }) + ;( + reconciler as unknown as { + workflowReconciler: { + reconcile: typeof workflowReconcile + validateWorkflowSpec: () => undefined + } + } + ).workflowReconciler = { reconcile: workflowReconcile, validateWorkflowSpec: () => undefined } + const recipe = makeRecipe({ + metadata: { + name: 'test-recipe', + namespace: 'sandbox-recipes', + uid: 'uid-123', + generation: 4, + }, + spec: { + workloads: [{ id: 'app', type: 'deployment', image: 'nginx:1.30.1-alpine', port: 8080 }], + steps: [{ id: 'run', run: snippetRun() }], + }, + }) + return { recipe, workflowReconcile } + } + + it('R3-L11: without a token ADDED the first-deploy pass sends no DELETE and logs the skip once, at debug', async () => { + const { recipe, workflowReconcile } = installFirstDeployWorkflow() + const skips = (spy: ReturnType) => + spy.mock.calls.filter( + ([message]) => + typeof message === 'string' && message.startsWith('Skipping oauth-broker-token delete') + ) + const logs = captureLoggerLevels(['debug', 'info'] as const) + try { + await reconciler.reconcile(recipe) + + // Witness that the pass went through deployWorkflowWorkloads. + expect(workflowReconcile).toHaveBeenCalledTimes(1) + // Witness that the reap ran and the ledger declined it, once per pass. + expect(skips(logs.calls.debug)).toHaveLength(1) + expect(skips(logs.calls.info)).toHaveLength(0) + expect(secretDeletes()).toBe(0) + } finally { + logs.restore() + } + + // Liveness witness: once the watch replays the token ADDED, the next + // pass sends its one DELETE. + tokenSeen() + await reconciler.reconcile(recipe) + expect(workflowReconcile).toHaveBeenCalledTimes(2) + expect(secretDeletes()).toBe(1) + }) + + it('R2-L3: a first-deploy pass sends one token DELETE when it fails, and the next pass retries', async () => { + const { recipe, workflowReconcile } = installFirstDeployWorkflow() + let tokenDeletes = 0 + mockCoreApi.deleteNamespacedSecret.mockImplementation(async ({ name }: { name: string }) => { + if (name !== SECRET_NAME) return {} + tokenDeletes += 1 + if (tokenDeletes === 1) throw { code: 503 } + return {} + }) + try { + // A token ADDED was observed, so the early reap runs in this pass. + reconciler.invalidateOAuthBrokerSecretLedger('test-recipe') + await reconciler.reconcile(recipe) + + // Witness that the pass went through deployWorkflowWorkloads. + expect(workflowReconcile).toHaveBeenCalledTimes(1) + expect(secretDeletes()).toBe(1) + + // The failed DELETE was not recorded, so the next pass retries it. + await reconciler.reconcile(recipe) + expect(workflowReconcile).toHaveBeenCalledTimes(2) + expect(secretDeletes()).toBe(2) + } finally { + mockCoreApi.deleteNamespacedSecret.mockResolvedValue({}) + } + }) + }) + + function ineligibleProjection(): CodexExecutionProjection { + return { + targets: [], + eligibleTargets: [], + derivedScopes: [], + requiresCodexProxyEgress: false, + driftHashInput: '', + catalogContentHash: null, + catalogRevision: null, + connectionRevision: null, + eligibility: 'ineligible', + reason: 'static_only', + } + } + + // Replaces the WRC's inner reconciler with a real WorkflowReconciler wired + // to the shared API mocks. + function installRealInner(): WorkflowReconciler { + const inner = new WorkflowReconciler({ + coreApi: mockCoreApi, + customApi: mockCustomApi, + networkingApi: mockNetworkingApi, + config: { + coordinatorImage: 'coordinator:test', + mcpHostImage: 'mcp-host:test', + wrcEndpoint: 'http://wrc.example/api', + sandboxNamespace: 'sandbox-recipes', + mcpServerNamespace: 'mcp-server', + imagePullPolicy: 'IfNotPresent', + maxWorkflowSteps: 100, + runtimeTokenTtlSeconds: 3600, + runtimeTokenRefreshBeforeSeconds: 300, + }, + tokenFactory: { + signWrcArtifactDeleteToken: vi.fn().mockResolvedValue('t'), + signCoordinatorToMcpHostToken: vi.fn().mockResolvedValue('t'), + signCustomCoordinatorToWrcToken: vi.fn().mockResolvedValue('t'), + signCoordinatorToWrcToken: vi.fn().mockResolvedValue('t'), + }, + pluginWorkloadSdkRevocationClient: { + revoke: vi.fn().mockResolvedValue({ state: 'missing', revoked: 0, fencedInvocations: 0 }), + finalize: vi.fn(), + }, + } as never) + ;(reconciler as unknown as { workflowReconciler: WorkflowReconciler }).workflowReconciler = + inner + return inner + } + + describe('G2 skipStatusPatch must not leave GFS deleted', () => { + const RECIPE = 'g2-recipe' + const GFS = `${RECIPE}-coordinator-to-gfs` + + it('inner apply does not delete reserved GFS and outer skips ensure on skipStatusPatch', async () => { + mockNetworkingApi.listNamespacedNetworkPolicy.mockResolvedValue({ + items: [ + { + metadata: { + name: GFS, + namespace: 'sandbox-recipes', + labels: { 'clerum.io/recipe': RECIPE, 'clerum.io/managed-by': 'wrc' }, + }, + }, + { + // Liveness witness: an ordinary catalog member that is not desired sits in + // the same LIST and must be deleted, so "GFS was not deleted" cannot hold + // just because the prune never iterated. + metadata: { + name: `${RECIPE}-mcp-host-to-grok-proxy`, + namespace: 'sandbox-recipes', + labels: { 'clerum.io/recipe': RECIPE, 'clerum.io/managed-by': 'wrc' }, + }, + }, + ], + }) + const inner = installRealInner() + const apply = ( + inner as unknown as { + applyWorkflowNetworkPolicies: (...args: unknown[]) => Promise + } + ).applyWorkflowNetworkPolicies.bind(inner) + vi.spyOn(inner, 'reconcile').mockImplementation(async (recipeName, recipeUid, _ns, spec) => { + const runtime = deriveWorkflowRuntimePlan(spec as never, { + recipeName: String(recipeName), + runtimeScopeRecipeName: String(recipeName), + workflowRunId: 'run-g2', + }) + const projection = ineligibleProjection() + await apply(recipeName, recipeUid, spec, runtime, false, projection, false, { + ...projection, + requiresGrokProxyEgress: false, + }) + return { + phase: 'active', + message: 'transient', + workflowPhase: 'running', + skipStatusPatch: true, + } + }) + + const result = await reconciler.reconcile( + makeRecipe({ + metadata: { + name: RECIPE, + namespace: 'sandbox-recipes', + uid: 'uid-g2', + labels: { 'clerum.io/workflow-run-id': 'run-g2' }, + }, + spec: { + agent: { provider: 'openai', model: 'gpt-4o' }, + steps: [{ id: 'publish', instruction: 'publish' }], + gfs: { publishTargets: [{ drive: 'main', target: 'out' }] }, + }, + status: { phase: 'pending' }, + }) + ) + + expect(result, result.message).toMatchObject({ skipStatusPatch: true }) + expect(mockNetworkingApi.deleteNamespacedNetworkPolicy).toHaveBeenCalledWith( + expect.objectContaining({ name: `${RECIPE}-mcp-host-to-grok-proxy` }) + ) + expect(mockNetworkingApi.deleteNamespacedNetworkPolicy).not.toHaveBeenCalledWith( + expect.objectContaining({ name: GFS }) + ) + expect( + mockNetworkingApi.createNamespacedNetworkPolicy.mock.calls.some( + ([arg]) => arg.body?.metadata?.name === GFS + ) + ).toBe(false) + }) + + it('retryRunLaneNetworkPolicies prunes nothing: no LIST and no DELETE, reserved GFS included', async () => { + mockNetworkingApi.listNamespacedNetworkPolicy.mockResolvedValue({ + items: [ + { + metadata: { + name: GFS, + namespace: 'sandbox-recipes', + labels: { 'clerum.io/recipe': RECIPE, 'clerum.io/managed-by': 'wrc' }, + }, + }, + { + metadata: { + name: `${RECIPE}-snippet-runner-egress`, + namespace: 'sandbox-recipes', + labels: { 'clerum.io/recipe': RECIPE, 'clerum.io/managed-by': 'wrc' }, + }, + }, + { + metadata: { + name: `${RECIPE}-mcp-host-to-grok-proxy`, + namespace: 'sandbox-recipes', + labels: { 'clerum.io/recipe': RECIPE, 'clerum.io/managed-by': 'wrc' }, + }, + }, + ], + }) + const inner = installRealInner() + + await inner.retryRunLaneNetworkPolicies( + RECIPE, + 'uid-g2', + { steps: [{ id: 'publish', instruction: 'publish' }] }, + RECIPE, + 'run-g2' + ) + + // Liveness witness: the retry built this agent spec and applied a lane + // it wants, so the absent DELETEs below are the retry declining to + // prune mid-run, not a retry that never ran. + expect( + mockNetworkingApi.createNamespacedNetworkPolicy.mock.calls.some( + ([arg]) => arg.body?.metadata?.name === `${RECIPE}-coord-to-mcp-host` + ) + ).toBe(true) + // The run's pods are live, so the retry revokes no lane: not the + // reserved GFS policy, not a sibling the spec no longer wants, not a + // Codex/Grok proxy, and not the legacy mcp-servers internet policy. It + // does not even LIST what a prune would find. + expect(mockNetworkingApi.listNamespacedNetworkPolicy).not.toHaveBeenCalled() + expect(mockNetworkingApi.deleteNamespacedNetworkPolicy).not.toHaveBeenCalled() + }) + }) + + // R4-L4: a legacy mcp-servers internet policy DELETE that did not land is + // published as WorkflowLegacyNetworkPolicyRemoved=False/DeletePending. The + // in-progress, active and terminal short-circuits never reach reconcile(), + // so the legacy-retry writer runs before them: one DELETE per backoff window + // (60 s, doubling up to 1 h), a fixed requeue for the rest of the window, + // and the marker cleared once the policy is gone. + describe('R4-L4: legacy internet policy DELETE retried before the short-circuits', () => { + const RECIPE = 'lg-recipe' + const RUN_ID = 'run-lg' + const LEGACY = `${RECIPE}-mcp-servers-egress-internet` + const LEGACY_TYPE = 'WorkflowLegacyNetworkPolicyRemoved' + const MARKER_TYPE = 'WorkflowNetworkPoliciesConverged' + const T0 = new Date('2026-09-23T10:00:00.000Z').getTime() + + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(T0) + }) + + afterEach(() => { + vi.useRealTimers() + mockCustomApi.patchNamespacedCustomObjectStatus.mockReset().mockResolvedValue({}) + }) + + function runScopedRecipe(status: Record): WorkflowRecipeCRD { + return makeRecipe({ + metadata: { + name: RECIPE, + namespace: 'sandbox-recipes', + uid: 'uid-lg', + labels: { 'clerum.io/workflow-run-id': RUN_ID }, + }, + spec: { + agent: { provider: 'openai', model: 'gpt-4o' }, + steps: [{ id: 'research', instruction: 'run' }], + }, + status: status as WorkflowRecipeCRD['status'], + }) + } + + function publishedFacts(prune: 'converged' | 'pending'): StatusCondition[] { + return networkPolicyMarkerConditions( + { + kind: 'reconcile', + summary: { conflicts: [], retryPending: false, prune, legacy: 'pending' }, + }, + [], + '2026-09-23T09:00:00.000Z' + ) + } + + function ofType(conditions: StatusCondition[] | undefined, type: string): StatusCondition[] { + return (conditions ?? []).filter(c => c.type === type) + } + + function legacyDeletes(): number { + return mockNetworkingApi.deleteNamespacedNetworkPolicy.mock.calls.filter( + ([arg]) => (arg as { name: string }).name === LEGACY + ).length + } + + function legacyDeleteAnswers(error: { code: number; message: string }) { + mockNetworkingApi.deleteNamespacedNetworkPolicy.mockImplementation( + async ({ name }: { name: string }) => { + if (name === LEGACY) throw error + return {} + } + ) + } + + function stubShortCircuits(inner: WorkflowReconciler) { + const ensureCredentials = vi + .spyOn(inner, 'ensureMcpHostRuntimeCredentials') + .mockResolvedValue(undefined) + vi.spyOn(inner, 'refreshRuntimeHttpEgressNetworkPolicies').mockResolvedValue({ + conflicts: [], + retryPending: false, + }) + const teardown = vi.spyOn(inner, 'teardownComputePodsForTerminalRun').mockResolvedValue() + const innerReconcile = vi.spyOn(inner, 'reconcile') + return { ensureCredentials, teardown, innerReconcile } + } + + it('R4-L4: sends one legacy DELETE per backoff window from the in-progress, active and terminal short-circuits', async () => { + const inner = installRealInner() + const { ensureCredentials, teardown, innerReconcile } = stubShortCircuits(inner) + legacyDeleteAnswers({ code: 403, message: 'forbidden' }) + const conditions = publishedFacts('converged') + // Liveness witness: the fixture publishes the legacy fact alone. + expect(conditions).toMatchObject([ + { type: LEGACY_TYPE, status: 'False', reason: 'DeletePending' }, + ]) + + const inProgress = runScopedRecipe({ + phase: 'deploying', + message: 'Workflow running', + workflowExecution: { phase: 'running' }, + conditions, + }) + const first = await reconciler.reconcile(inProgress) + + // Witness: the in-progress short-circuit ran. + expect(ensureCredentials).toHaveBeenCalled() + expect(legacyDeletes()).toBe(1) + expect(first.requeueAfterMs).toBe(60_000) + expect(first.requeueFixedInterval).toBe(true) + expect(ofType(inProgress.status?.conditions, LEGACY_TYPE)).toMatchObject([ + { status: 'False', reason: 'DeletePending' }, + ]) + + vi.setSystemTime(T0 + 20_000) + const active = runScopedRecipe({ + phase: 'active', + message: 'Workflow running', + workflowExecution: { phase: 'running' }, + conditions, + }) + const second = await reconciler.reconcile(active) + + // Inside the window: no DELETE, and a requeue for the 40 s left. + expect(legacyDeletes()).toBe(1) + expect(second.requeueAfterMs).toBe(40_000) + expect(second.requeueFixedInterval).toBe(true) + + vi.setSystemTime(T0 + 61_000) + const terminal = runScopedRecipe({ + phase: 'active', + message: 'Workflow completed', + workflowExecution: { phase: 'completed' }, + conditions, + }) + const third = await reconciler.reconcile(terminal) + + // Witness: the terminal branch tore the run's compute down. + expect(teardown).toHaveBeenCalledWith(RECIPE) + // Past the window: one more DELETE, and the window doubles. + expect(legacyDeletes()).toBe(2) + expect(third.requeueAfterMs).toBe(120_000) + expect(third.requeueFixedInterval).toBe(true) + expect(ofType(terminal.status?.conditions, LEGACY_TYPE)).toMatchObject([ + { status: 'False', reason: 'DeletePending' }, + ]) + expect(innerReconcile).not.toHaveBeenCalled() + }) + + it('R4-L4: clears DeletePending from the active short-circuit once the legacy DELETE answers 404, and carries the prune fact', async () => { + const inner = installRealInner() + const { ensureCredentials } = stubShortCircuits(inner) + legacyDeleteAnswers({ code: 404, message: 'not found' }) + const conditions = publishedFacts('pending') + expect(ofType(conditions, MARKER_TYPE)).toMatchObject([{ reason: 'PrunePending' }]) + mockCustomApi.patchNamespacedCustomObjectStatus.mockClear() + const active = runScopedRecipe({ + phase: 'active', + message: 'Workflow running', + workflowExecution: { phase: 'running' }, + conditions, + }) + + const result = await reconciler.reconcile(active) + + // Witness: the active short-circuit ran and sent the DELETE. + expect(ensureCredentials).toHaveBeenCalled() + expect(legacyDeletes()).toBe(1) + expect(ofType(active.status?.conditions, LEGACY_TYPE)).toEqual([]) + expect(ofType(active.status?.conditions, MARKER_TYPE)).toEqual( + ofType(conditions, MARKER_TYPE) + ) + expect(mockCustomApi.patchNamespacedCustomObjectStatus).toHaveBeenCalledTimes(1) + expect(result.requeueAfterMs).toBeUndefined() + }) + + it('R4-L4: sends no legacy DELETE from a short-circuit when no DeletePending marker is published', async () => { + const inner = installRealInner() + const { ensureCredentials } = stubShortCircuits(inner) + legacyDeleteAnswers({ code: 403, message: 'forbidden' }) + const active = runScopedRecipe({ + phase: 'active', + message: 'Workflow running', + workflowExecution: { phase: 'running' }, + conditions: [], + }) + + const result = await reconciler.reconcile(active) + + // Witness: the active short-circuit ran. + expect(ensureCredentials).toHaveBeenCalled() + expect(result.message).toBe('Workflow running') + expect(legacyDeletes()).toBe(0) + expect(result.requeueAfterMs).toBeUndefined() + }) + + it('R4-L4: keeps the requeue a short-circuit already asked for while DeletePending stays published', async () => { + const inner = installRealInner() + stubShortCircuits(inner) + const retry = vi + .spyOn(inner, 'retryRunLaneNetworkPolicies') + .mockResolvedValue({ conflicts: [], retryPending: true }) + legacyDeleteAnswers({ code: 403, message: 'forbidden' }) + const conditions = networkPolicyMarkerConditions( + { + kind: 'reconcile', + summary: { conflicts: [], retryPending: true, prune: 'converged', legacy: 'pending' }, + }, + [], + '2026-09-23T09:00:00.000Z' + ) + expect(ofType(conditions, MARKER_TYPE)).toMatchObject([{ reason: 'RetryPending' }]) + expect(ofType(conditions, LEGACY_TYPE)).toMatchObject([{ reason: 'DeletePending' }]) + const active = runScopedRecipe({ + phase: 'active', + message: 'Workflow running', + workflowExecution: { phase: 'running' }, + conditions, + }) + + const result = await reconciler.reconcile(active) + + // Witnesses: the apply retry ran and the legacy DELETE was sent. + expect(retry).toHaveBeenCalledTimes(1) + expect(legacyDeletes()).toBe(1) + expect(ofType(active.status?.conditions, LEGACY_TYPE)).toMatchObject([ + { reason: 'DeletePending' }, + ]) + // The apply retry's progress requeue wins over the 60 s legacy window. + expect(result.requeueAfterMs).toBe(WORKFLOW_PROGRESS_REQUEUE_BASE_MS) + expect(result.requeueFixedInterval).toBe(false) + }) + + it('R5-J1: retries the legacy DELETE before a validation return, so DeletePending never requeues at 1 s', async () => { + const inner = installRealInner() + const { innerReconcile } = stubShortCircuits(inner) + legacyDeleteAnswers({ code: 403, message: 'forbidden' }) + const conditions = publishedFacts('converged') + const overLimit = makeRecipe({ + metadata: { + name: RECIPE, + namespace: 'sandbox-recipes', + uid: 'uid-lg', + labels: { 'clerum.io/workflow-run-id': RUN_ID }, + }, + spec: { + agent: { provider: 'openai', model: 'gpt-4o' }, + steps: Array.from({ length: 101 }, (_, i) => ({ id: `s${i}`, instruction: 'run' })), + }, + status: { + phase: 'active', + message: 'Workflow running', + workflowExecution: { phase: 'running' }, + conditions, + } as WorkflowRecipeCRD['status'], + }) + + const result = await reconciler.reconcile(overLimit) + + // Witness: the pass took the spec-limit return, before reconcile(). + expect(result.phase).toBe('failed') + expect(result.message).toBe('spec.steps must contain at most 100 items') + expect(innerReconcile).not.toHaveBeenCalled() + // The DELETE was sent, so the requeue is the 60 s backoff, not the 1 s floor. + expect(legacyDeletes()).toBe(1) + expect(result.requeueAfterMs).toBe(60_000) + expect(result.requeueFixedInterval).toBe(true) + }) + + function realWatcherBridge(recipe: WorkflowRecipeCRD) { + // The surrounding ledger-only cases fake Date; this bridge drives real timers too. + vi.useRealTimers() + vi.useFakeTimers() + vi.setSystemTime(T0) + let live: WorkflowRecipeCRD | undefined = structuredClone(recipe) + const previousGet = mockCustomApi.getNamespacedCustomObject.getMockImplementation()! + mockCustomApi.getNamespacedCustomObject.mockImplementation(async args => { + if (args.plural !== 'workflowrecipes' || args.name !== RECIPE) return previousGet(args) + if (!live) throw { code: 404 } + return structuredClone(live) + }) + mockCustomApi.patchNamespacedCustomObjectStatus.mockImplementation(async ({ body }) => { + if (!live) throw { code: 404 } + if ( + body.metadata && + (body.metadata.uid !== live.metadata.uid || + body.metadata.resourceVersion !== live.metadata.resourceVersion) + ) { + throw { code: 409 } + } + live.status = { ...live.status, ...body.status } + live.metadata.resourceVersion = String(Number(live.metadata.resourceVersion) + 1) + return structuredClone(live) + }) + const queueFailures = vi.fn() + const queue = new RecipeEventQueue(queueFailures) + const watcher = Object.create(WorkflowRecipeWatcher.prototype) as { + recipes: Map + eventQueue: RecipeEventQueue + transientRetries: Map; attempts: number }> + handleRecipeEvent: (type: string, recipe: WorkflowRecipeCRD) => Promise + clearTransientRetry: (name: string) => void + } + Object.assign(watcher, { + recipes: new Map(), + eventQueue: queue, + transientRetries: new Map(), + stopped: false, + traceReporter: null, + dbRunProcessor: null, + reconciler, + customApi: mockCustomApi, + }) + return { + watcher, + queue, + queueFailures, + current: () => live, + replace: (next: WorkflowRecipeCRD | undefined) => { + live = next + }, + drain: () => queue.enqueue(RECIPE, async () => {}), + } + } + + function terminalRetryRecipe(): WorkflowRecipeCRD { + const recipe = runScopedRecipe({ + phase: 'active', + message: 'Workflow completed', + workflowExecution: { phase: 'completed' }, + conditions: publishedFacts('converged'), + workloadInstances: { 'web-search': `${RECIPE}-web-search-run` }, + }) + recipe.metadata.resourceVersion = '1' + recipe.metadata.generation = 1 + recipe.spec.workloads = [ + { + id: 'web-search', + type: 'deployment', + image: 'web-search:test', + transport: { type: 'streamableHttp', path: '/mcp' }, + }, + ] + return recipe + } + + it('R5-L3: the dedicated retry retains the pipeline namespace allowlist', async () => { + const inner = installRealInner() + const retry = vi.spyOn(inner, 'retryLegacyMcpServersInternetEgressDelete') + const recipe = terminalRetryRecipe() + recipe.metadata.namespace = 'other-namespace' + expect(ofType(recipe.status?.conditions, LEGACY_TYPE)).toHaveLength(1) + mockCustomApi.getNamespacedCustomObject.mockClear() + const result = await reconciler.retryPendingLegacyNetworkPolicyDeleteOnly(recipe) + expect(result).toEqual({}) + expect(retry).not.toHaveBeenCalled() + expect(mockCustomApi.getNamespacedCustomObject).not.toHaveBeenCalled() + expect(legacyDeletes()).toBe(0) + }) + + it('R5-L3: the real watcher, WRC and event queue retry only legacy DELETE across 60/120/240 s windows', async () => { + vi.useFakeTimers() + const inner = installRealInner() + vi.spyOn(inner, 'ensureMcpHostRuntimeCredentials').mockResolvedValue() + const teardown = vi.spyOn(inner, 'teardownComputePodsForTerminalRun') + const fullReconcile = vi.spyOn(reconciler, 'reconcile') + legacyDeleteAnswers({ code: 403, message: 'forbidden' }) + const bridge = realWatcherBridge(terminalRetryRecipe()) + + await bridge.queue.enqueue(RECIPE, () => + bridge.watcher.handleRecipeEvent('ADDED', bridge.current()!) + ) + + expect(teardown).toHaveBeenCalledTimes(1) + expect(legacyDeletes()).toBe(1) + const initialEffects = { + pods: mockCoreApi.deleteNamespacedPod.mock.calls.length, + transport: mockCustomApi.deleteNamespacedCustomObject.mock.calls.filter( + ([arg]) => arg.plural === 'mcpservers' + ).length, + deployments: mockAppsApi.deleteNamespacedDeployment.mock.calls.length, + gfs: mockNetworkingApi.deleteNamespacedNetworkPolicy.mock.calls.filter(([arg]) => + arg.name.endsWith('-coordinator-to-gfs') + ).length, + } + expect(initialEffects.pods).toBe(3) + expect(initialEffects.transport).toBeGreaterThan(0) + expect(initialEffects.deployments).toBeGreaterThan(0) + expect(initialEffects.gfs).toBeGreaterThan(0) + + for (const [index, delay] of [60_000, 120_000, 240_000].entries()) { + await vi.advanceTimersByTimeAsync(delay) + await bridge.drain() + expect(legacyDeletes()).toBe(index + 2) + expect(fullReconcile).toHaveBeenCalledTimes(1) + expect(teardown).toHaveBeenCalledTimes(1) + expect(mockCoreApi.deleteNamespacedPod).toHaveBeenCalledTimes(initialEffects.pods) + expect(mockAppsApi.deleteNamespacedDeployment).toHaveBeenCalledTimes( + initialEffects.deployments + ) + expect( + mockCustomApi.deleteNamespacedCustomObject.mock.calls.filter( + ([arg]) => arg.plural === 'mcpservers' + ) + ).toHaveLength(initialEffects.transport) + expect( + mockNetworkingApi.deleteNamespacedNetworkPolicy.mock.calls.filter(([arg]) => + arg.name.endsWith('-coordinator-to-gfs') + ) + ).toHaveLength(initialEffects.gfs) + } + + // Recovery performs a conditions-only patch, preserving the workflow/SDK state. + const beforeRecovery = structuredClone(bridge.current()!.status) + legacyDeleteAnswers({ code: 404, message: 'not found' }) + mockCustomApi.patchNamespacedCustomObjectStatus.mockClear() + await vi.advanceTimersByTimeAsync(480_000) + await bridge.drain() + expect(legacyDeletes()).toBe(5) + expect(fullReconcile).toHaveBeenCalledTimes(1) + expect(bridge.current()!.status?.workflowExecution).toEqual(beforeRecovery?.workflowExecution) + expect(bridge.current()!.status?.pluginWorkloadSdk).toEqual(beforeRecovery?.pluginWorkloadSdk) + expect(ofType(bridge.current()!.status?.conditions, LEGACY_TYPE)).toEqual([]) + expect(mockCustomApi.patchNamespacedCustomObjectStatus).toHaveBeenCalledWith( + expect.objectContaining({ + body: { + metadata: { uid: 'uid-lg', resourceVersion: expect.any(String) }, + status: { conditions: expect.any(Array) }, + }, + }), + expect.anything() + ) + expect(bridge.queueFailures).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + + // Deliver the actual accepted PATCH response through the ordinary watch + // path. Its unchanged generation makes this a status-only MODIFIED, whose + // terminal cleanup remains required by the existing recovery contract. + expect(mockCustomApi.patchNamespacedCustomObjectStatus).toHaveBeenCalledTimes(1) + const recoveryWatchObject = (await mockCustomApi.patchNamespacedCustomObjectStatus.mock + .results[0].value) as WorkflowRecipeCRD + expect(recoveryWatchObject).toEqual(bridge.current()) + expect(recoveryWatchObject.metadata.resourceVersion).toBe('2') + expect(recoveryWatchObject.metadata.generation).toBe(1) + await bridge.queue.enqueue(RECIPE, () => + bridge.watcher.handleRecipeEvent('MODIFIED', recoveryWatchObject) + ) + await bridge.drain() + expect(fullReconcile).toHaveBeenCalledTimes(2) + expect(teardown).toHaveBeenCalledTimes(2) + expect(mockCoreApi.deleteNamespacedPod).toHaveBeenCalledTimes(initialEffects.pods * 2) + expect(mockAppsApi.deleteNamespacedDeployment).toHaveBeenCalledTimes( + initialEffects.deployments * 2 + ) + expect( + mockCustomApi.deleteNamespacedCustomObject.mock.calls.filter( + ([arg]) => arg.plural === 'mcpservers' + ) + ).toHaveLength(initialEffects.transport * 2) + expect( + mockNetworkingApi.deleteNamespacedNetworkPolicy.mock.calls.filter(([arg]) => + arg.name.endsWith('-coordinator-to-gfs') + ) + ).toHaveLength(initialEffects.gfs * 2) + expect(legacyDeletes()).toBe(5) + // That ordinary pass publishes nothing, so it cannot drive another watch + // event. No legacy timer or continuing status-write loop remains either. + expect(mockCustomApi.patchNamespacedCustomObjectStatus).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + await vi.advanceTimersByTimeAsync(3_600_000) + await bridge.drain() + expect(fullReconcile).toHaveBeenCalledTimes(2) + expect(teardown).toHaveBeenCalledTimes(2) + expect(legacyDeletes()).toBe(5) + expect(mockCustomApi.patchNamespacedCustomObjectStatus).toHaveBeenCalledTimes(1) + expect(bridge.queueFailures).not.toHaveBeenCalled() + bridge.watcher.clearTransientRetry(RECIPE) + }) + + it.each([ + 'recreated', + 'deleted', + 'deleting', + 'marker-cleared', + 'namespace-changed', + 'name-changed', + ] as const)( + 'R5-L3: a queued legacy retry drops a %s recipe after its fresh API read', + async change => { + vi.useFakeTimers() + const inner = installRealInner() + vi.spyOn(inner, 'ensureMcpHostRuntimeCredentials').mockResolvedValue() + const teardown = vi.spyOn(inner, 'teardownComputePodsForTerminalRun') + const fullReconcile = vi.spyOn(reconciler, 'reconcile') + legacyDeleteAnswers({ code: 403, message: 'forbidden' }) + const bridge = realWatcherBridge(terminalRetryRecipe()) + await bridge.queue.enqueue(RECIPE, () => + bridge.watcher.handleRecipeEvent('ADDED', bridge.current()!) + ) + expect(legacyDeletes()).toBe(1) + expect(vi.getTimerCount()).toBe(1) + let release!: () => void + const blocker = bridge.queue.enqueue( + RECIPE, + () => + new Promise(resolve => { + release = resolve + }) + ) + await Promise.resolve() + await vi.advanceTimersByTimeAsync(60_000) + const next = structuredClone(bridge.current()!) + if (change === 'deleted') bridge.replace(undefined) + else { + if (change === 'recreated') next.metadata.uid = 'uid-new' + if (change === 'deleting') next.metadata.deletionTimestamp = new Date().toISOString() + if (change === 'marker-cleared') next.status!.conditions = [] + if (change === 'namespace-changed') next.metadata.namespace = 'other-namespace' + if (change === 'name-changed') next.metadata.name = 'other-recipe' + bridge.replace(next) + } + // Leave the watcher's cache stale: the API read after the queue wait is authoritative. + release() + await blocker + await bridge.drain() + expect(legacyDeletes()).toBe(1) + expect(fullReconcile).toHaveBeenCalledTimes(1) + expect(teardown).toHaveBeenCalledTimes(1) + expect(bridge.queueFailures).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + bridge.watcher.clearTransientRetry(RECIPE) + } + ) + + it.each(['recreated', 'deleting', 'marker-cleared'] as const)( + 'R5-L3: the post-DELETE read refuses to publish on a %s recipe', + async change => { + const inner = installRealInner() + vi.spyOn(inner, 'ensureMcpHostRuntimeCredentials').mockResolvedValue() + const fullReconcile = vi.spyOn(reconciler, 'reconcile') + legacyDeleteAnswers({ code: 403, message: 'forbidden' }) + const bridge = realWatcherBridge(terminalRetryRecipe()) + await bridge.queue.enqueue(RECIPE, () => + bridge.watcher.handleRecipeEvent('ADDED', bridge.current()!) + ) + expect(legacyDeletes()).toBe(1) + mockCustomApi.patchNamespacedCustomObjectStatus.mockClear() + mockNetworkingApi.deleteNamespacedNetworkPolicy.mockImplementation(async ({ name }) => { + if (name === LEGACY) { + const next = structuredClone(bridge.current()!) + if (change === 'recreated') next.metadata.uid = 'uid-new' + if (change === 'deleting') next.metadata.deletionTimestamp = new Date().toISOString() + if (change === 'marker-cleared') next.status!.conditions = [] + bridge.replace(next) + } + return {} + }) + await vi.advanceTimersByTimeAsync(60_000) + await bridge.drain() + expect(legacyDeletes()).toBe(2) + expect(fullReconcile).toHaveBeenCalledTimes(1) + expect(mockCustomApi.patchNamespacedCustomObjectStatus).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + expect(bridge.queueFailures).not.toHaveBeenCalled() + } + ) + + it.each(['recreated', 'newer-policy-facts'] as const)( + 'R5-L3: a status conflict caused by %s retries only the fenced conditions patch', + async change => { + const inner = installRealInner() + vi.spyOn(inner, 'ensureMcpHostRuntimeCredentials').mockResolvedValue() + const fullReconcile = vi.spyOn(reconciler, 'reconcile') + legacyDeleteAnswers({ code: 403, message: 'forbidden' }) + const bridge = realWatcherBridge(terminalRetryRecipe()) + await bridge.queue.enqueue(RECIPE, () => + bridge.watcher.handleRecipeEvent('ADDED', bridge.current()!) + ) + mockCustomApi.patchNamespacedCustomObjectStatus.mockClear() + const newerFacts = networkPolicyMarkerConditions( + { + kind: 'reconcile', + summary: { conflicts: [], retryPending: true, prune: 'pending', legacy: 'pending' }, + }, + [], + new Date().toISOString() + ) + mockCustomApi.patchNamespacedCustomObjectStatus.mockImplementationOnce(async ({ body }) => { + expect(body.metadata).toEqual({ uid: 'uid-lg', resourceVersion: '1' }) + const next = structuredClone(bridge.current()!) + next.metadata.resourceVersion = '2' + if (change === 'recreated') next.metadata.uid = 'uid-new' + else next.status!.conditions = newerFacts + bridge.replace(next) + throw { code: 409 } + }) + legacyDeleteAnswers({ code: 404, message: 'not found' }) + await vi.advanceTimersByTimeAsync(60_000) + await bridge.drain() + expect(legacyDeletes()).toBe(2) + expect(mockCustomApi.patchNamespacedCustomObjectStatus).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(4_999) + await bridge.drain() + expect(mockCustomApi.patchNamespacedCustomObjectStatus).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + await bridge.drain() + expect(legacyDeletes()).toBe(2) + expect(fullReconcile).toHaveBeenCalledTimes(1) + if (change === 'recreated') { + expect(mockCustomApi.patchNamespacedCustomObjectStatus).toHaveBeenCalledTimes(1) + expect(bridge.current()!.metadata.uid).toBe('uid-new') + } else { + expect(mockCustomApi.patchNamespacedCustomObjectStatus).toHaveBeenCalledTimes(2) + expect( + mockCustomApi.patchNamespacedCustomObjectStatus.mock.calls[1][0].body.metadata + ).toEqual({ uid: 'uid-lg', resourceVersion: '2' }) + expect(ofType(bridge.current()!.status?.conditions, LEGACY_TYPE)).toEqual([]) + expect(ofType(bridge.current()!.status?.conditions, MARKER_TYPE)).toEqual( + ofType(newerFacts, MARKER_TYPE) + ) + expect(bridge.current()!.status?.workflowExecution).toEqual({ phase: 'completed' }) + } + expect(vi.getTimerCount()).toBe(0) + expect(bridge.queueFailures).not.toHaveBeenCalled() + } + ) + + it('R5-L3: clearing the retry while its callback waits in the real queue cancels its API work', async () => { + const inner = installRealInner() + vi.spyOn(inner, 'ensureMcpHostRuntimeCredentials').mockResolvedValue() + legacyDeleteAnswers({ code: 403, message: 'forbidden' }) + const bridge = realWatcherBridge(terminalRetryRecipe()) + await bridge.queue.enqueue(RECIPE, () => + bridge.watcher.handleRecipeEvent('ADDED', bridge.current()!) + ) + let release!: () => void + const blocker = bridge.queue.enqueue( + RECIPE, + () => + new Promise(resolve => { + release = resolve + }) + ) + await Promise.resolve() + await vi.advanceTimersByTimeAsync(60_000) + bridge.watcher.clearTransientRetry(RECIPE) + mockCustomApi.getNamespacedCustomObject.mockClear() + release() + await blocker + await bridge.drain() + expect(mockCustomApi.getNamespacedCustomObject).not.toHaveBeenCalled() + expect(legacyDeletes()).toBe(1) + expect(vi.getTimerCount()).toBe(0) + expect(bridge.queueFailures).not.toHaveBeenCalled() + }) + }) + + // A reconcile() prune whose DELETE did not land publishes + // WorkflowNetworkPoliciesConverged=False/PrunePending. That fact is not a + // pending retry: the mid-run retry prunes nothing, so the short-circuits + // must not reapply for it, and the terminal branch must keep it, because + // the terminal teardown removes pods, not run-lane policies. The finalizer + // sweep by recipe label is what removes the leftover. + describe('run-lane NetworkPolicy prune-pending across a run-scoped run', () => { + const RECIPE = 'pp-recipe' + const RUN_ID = 'run-pp' + const GFS = `${RECIPE}-coordinator-to-gfs` + const LEFTOVER = `${RECIPE}-mcp-host-to-grok-proxy` + const WRC_LABELS = { 'clerum.io/recipe': RECIPE, 'clerum.io/managed-by': 'wrc' } + const MARKER_TYPE = 'WorkflowNetworkPoliciesConverged' + + function runScopedRecipe(status: Record): WorkflowRecipeCRD { + return makeRecipe({ + metadata: { + name: RECIPE, + namespace: 'sandbox-recipes', + uid: 'uid-pp', + labels: { 'clerum.io/workflow-run-id': RUN_ID }, + }, + spec: { + agent: { provider: 'openai', model: 'gpt-4o' }, + steps: [{ id: 'research', instruction: 'run' }], + }, + status: status as WorkflowRecipeCRD['status'], + }) + } + + function markers(conditions: StatusCondition[] | undefined): StatusCondition[] { + return (conditions ?? []).filter(c => c.type === MARKER_TYPE) + } + + function deletedPolicyNames(): string[] { + return mockNetworkingApi.deleteNamespacedNetworkPolicy.mock.calls.map( + ([arg]) => (arg as { name: string }).name + ) + } + + // The WRC first-deploy pass with the real inner apply and prune. The + // leftover is live under the recipe's labels and its DELETE answers 403. + // The inner reconcile() is reduced to the NetworkPolicy step: the real + // apply-and-prune, published through the production `reconcile` marker + // writer. The inner suite pins that reconcile() publishes exactly that + // writer's conditions for its own pass summary (nit-3), so this stub + // cannot drift from the translation it stands in for. + async function failedPrunePass(inner: WorkflowReconciler) { + mockNetworkingApi.listNamespacedNetworkPolicy.mockResolvedValue({ + items: [{ metadata: { name: LEFTOVER, namespace: 'sandbox-recipes', labels: WRC_LABELS } }], + }) + mockNetworkingApi.deleteNamespacedNetworkPolicy.mockImplementation( + async ({ name }: { name: string }) => { + if (name === LEFTOVER) throw { code: 403, message: 'forbidden' } + return {} + } + ) + const apply = ( + inner as unknown as { + applyWorkflowNetworkPolicies: (...args: unknown[]) => Promise + } + ).applyWorkflowNetworkPolicies.bind(inner) + vi.spyOn(inner, 'reconcile').mockImplementation( + async (recipeName, recipeUid, _ns, spec, currentStatus) => { + const runtime = deriveWorkflowRuntimePlan(spec as never, { + recipeName: String(recipeName), + runtimeScopeRecipeName: String(recipeName), + workflowRunId: RUN_ID, + }) + const projection = ineligibleProjection() + const summary = await apply( + recipeName, + recipeUid, + spec, + runtime, + false, + projection, + false, + { + ...projection, + requiresGrokProxyEgress: false, + } + ) + // No retry flag is owed: the pass has nothing pending but the prune. + expect(summary.retryPending).toBe(false) + return { + phase: 'active', + message: 'Workflow running', + workflowPhase: 'running', + networkPolicyOwnershipConditions: networkPolicyMarkerConditions( + { kind: 'reconcile', summary }, + currentStatus?.conditions, + new Date().toISOString() + ), + } + } + ) + + const result = await reconciler.reconcile(runScopedRecipe({ phase: 'pending' })) + // Liveness witness: the prune reached the leftover's DELETE. + expect(deletedPolicyNames()).toContain(LEFTOVER) + mockNetworkingApi.deleteNamespacedNetworkPolicy.mockClear() + mockNetworkingApi.deleteNamespacedNetworkPolicy.mockImplementation(async () => ({})) + return result + } + + it('publishes PrunePending from a reconcile() prune that could not delete, with no requeue', async () => { + const inner = installRealInner() + + const result = await failedPrunePass(inner) + + expect(markers(result.networkPolicyOwnershipConditions)).toMatchObject([ + { status: 'False', reason: 'PrunePending' }, + ]) + expect(result.requeueAfterMs).toBeUndefined() + }) + + it('does not reapply from the in-progress short-circuit for a pending prune', async () => { + const inner = installRealInner() + const published = (await failedPrunePass(inner)).networkPolicyOwnershipConditions + const ensureCredentials = vi + .spyOn(inner, 'ensureMcpHostRuntimeCredentials') + .mockResolvedValue(undefined) + vi.spyOn(inner, 'refreshRuntimeHttpEgressNetworkPolicies').mockResolvedValue({ + conflicts: [], + retryPending: false, + }) + const retry = vi.spyOn(inner, 'retryRunLaneNetworkPolicies') + mockCustomApi.patchNamespacedCustomObjectStatus.mockClear() + const recipe = runScopedRecipe({ + phase: 'deploying', + message: 'Workflow running', + workflowExecution: { phase: 'running' }, + conditions: published, + }) + + const result = await reconciler.reconcile(recipe) + + // Witness: the in-progress short-circuit ran. + expect(ensureCredentials).toHaveBeenCalled() + expect(result.message).toBe('Workflow running') + expect(retry).not.toHaveBeenCalled() + expect(result.requeueAfterMs).toBeUndefined() + expect(markers(recipe.status?.conditions)).toMatchObject([ + { status: 'False', reason: 'PrunePending' }, + ]) + }) + + it('keeps the PrunePending marker through the terminal teardown, which deletes no run-lane policy', async () => { + const inner = installRealInner() + const published = (await failedPrunePass(inner)).networkPolicyOwnershipConditions + vi.spyOn(inner, 'ensureMcpHostRuntimeCredentials').mockResolvedValue(undefined) + vi.spyOn(inner, 'refreshRuntimeHttpEgressNetworkPolicies').mockResolvedValue({ + conflicts: [], + retryPending: false, + }) + // The real teardown runs: the "no run-lane DELETE" assertion below is + // only meaningful if the code that could send one executes. + const teardown = vi.spyOn(inner, 'teardownComputePodsForTerminalRun') + const retry = vi.spyOn(inner, 'retryRunLaneNetworkPolicies') + mockCustomApi.patchNamespacedCustomObjectStatus.mockClear() + const recipe = runScopedRecipe({ + phase: 'active', + message: 'Workflow completed', + workflowExecution: { phase: 'completed' }, + conditions: published, + }) + + // The shared core mock has no single-Pod DELETE; the teardown deletes + // the run's compute pods one by one. + const coreApi = mockCoreApi as unknown as Record + const deletePod = vi.fn().mockResolvedValue({}) + coreApi.deleteNamespacedPod = deletePod + try { + await reconciler.reconcile(recipe) + } finally { + delete coreApi.deleteNamespacedPod + } + + // Witnesses: the terminal branch ran the real teardown, which deleted + // the run's coordinator pod. + expect(teardown).toHaveBeenCalledWith(RECIPE) + expect(deletePod).toHaveBeenCalledWith({ + name: `${RECIPE}-coordinator`, + namespace: 'sandbox-recipes', + }) + expect(retry).not.toHaveBeenCalled() + expect(deletedPolicyNames().filter(name => name !== GFS)).toEqual([]) + expect(markers(recipe.status?.conditions)).toMatchObject([ + { status: 'False', reason: 'PrunePending' }, + ]) + }) + + it('removes the leftover from the finalizer sweep', async () => { + const inner = installRealInner() + await failedPrunePass(inner) + // Only the finalizer's own calls are asserted below. + mockNetworkingApi.listNamespacedNetworkPolicy.mockClear() + mockNetworkingApi.deleteNamespacedNetworkPolicy.mockClear() + mockNetworkingApi.listNamespacedNetworkPolicy.mockResolvedValue({ + items: [{ metadata: { name: LEFTOVER, namespace: 'sandbox-recipes', labels: WRC_LABELS } }], + }) + + // The shared core mock has no single-Pod DELETE; the finalizer deletes + // the run's pods one by one before it answers. + const coreApi = mockCoreApi as unknown as Record + const deletePod = vi.fn().mockResolvedValue({}) + coreApi.deleteNamespacedPod = deletePod + try { + await reconciler.reconcileDelete( + runScopedRecipe({ phase: 'active', workflowExecution: { phase: 'completed' } }) + ) + } finally { + delete coreApi.deleteNamespacedPod + } + + // Witness: the finalizer ran the workflow cleanup. + expect(deletePod).toHaveBeenCalledWith({ + name: `${RECIPE}-coordinator`, + namespace: 'sandbox-recipes', + }) + expect(mockNetworkingApi.listNamespacedNetworkPolicy).toHaveBeenCalledWith({ + namespace: 'sandbox-recipes', + labelSelector: `clerum.io/recipe=${RECIPE},clerum.io/managed-by=wrc`, + }) + expect(mockNetworkingApi.deleteNamespacedNetworkPolicy).toHaveBeenCalledWith({ + name: LEFTOVER, + namespace: 'sandbox-recipes', + }) + }) + }) + + // ─── Broker-token issuance failure is non-fatal ───────────────────────── + // + // A transient control-api blip during reconcile (e.g. it is mid-restart) + // used to permanently brick the recipe — phase=failed is a terminal, + // non-deployable state and the rotation loop alone cannot transition out + // of it. Reconcile now logs and continues so dependent pods sit in + // ContainerCreating until the next rotation tick succeeds, rather than + // the whole recipe getting stuck. + describe('broker-token issuance failure during reconcile', () => { + beforeEach(() => { + vi.spyOn(brokerIssuer, 'issueOAuthBrokerToken').mockReset() + mockCoreApi.readNamespacedSecret.mockReset() + }) + + function recipeWithBackgroundAccess(): WorkflowRecipeCRD { + return makeRecipe({ + spec: { + workloads: [{ id: 'app', type: 'deployment', image: 'nginx:1.30.1-alpine', port: 8080 }], + oauthClients: [ + { + id: 'gmail', + provider: 'google', + clientIdRef: { name: 'creds', key: 'client-id' }, + clientSecretRef: { name: 'creds', key: 'client-secret' }, + scopes: ['https://www.googleapis.com/auth/gmail.readonly'], + backgroundAccess: true, + }, + ], + }, + }) + } + + it('does not transition the recipe to failed when issueOAuthBrokerToken throws', async () => { + // Secret missing → reconcile takes the "create" branch, which calls + // issueOAuthBrokerToken. The mock makes that call time out. + mockCoreApi.readNamespacedSecret.mockRejectedValue( + Object.assign(new Error('not found'), { code: 404 }) + ) + vi.spyOn(brokerIssuer, 'issueOAuthBrokerToken').mockRejectedValue( + new Error('OAuth broker token issuance timed out after 10000ms for recipe "test-recipe"') + ) + const warnSpy = captureLogger('warn') + + const result = await reconciler.reconcile(recipeWithBackgroundAccess()) + + expect(result.phase).not.toBe('failed') + expect(result.phase).toBe('active') + // Warning was emitted so the operator can see what happened. + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Broker-token issuance failed during reconcile'), + expect.objectContaining({ err: expect.any(Error) }) + ) + // Workloads still deployed. + expect(mockAppsApi.createNamespacedDeployment).toHaveBeenCalledTimes(1) + warnSpy.mockRestore() + }) + }) + + // ─── Transient API-server blip must not latch a healthy recipe ────────── + // + // A momentary controller↔API partition (connect ETIMEDOUT) used to throw + // mid-reconcile and flip an otherwise-healthy recipe to the terminal + // `failed` phase, which the skip guard then refused to re-process — a + // false-positive failure with no self-recovery. Reconcile now classifies + // retryable infra errors and leaves the recipe alone so the next resync + // retries, and re-reconciles a transiently-latched recipe back to active. + describe('transient infra error handling', () => { + function etimedout(): Error { + return Object.assign( + new Error( + 'request to https://203.0.113.10/api/v1/namespaces/sandbox-recipes/services/app failed, reason: connect ETIMEDOUT 203.0.113.10:443' + ), + { code: 'ETIMEDOUT', name: 'FetchError' } + ) + } + + it('does not latch an active recipe to failed when an API call times out', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + // A K8s call deep in the pipeline (Service create) times out at connect. + mockCoreApi.createNamespacedService.mockRejectedValueOnce(etimedout()) const recipe = makeRecipe({ status: { @@ -16182,7 +18315,12 @@ describe('WorkflowRecipeReconciler', () => { policyReady: true, verifiedAt: '2026-08-04T00:00:00.000Z', }, - networkPolicies: { conflicts: [], retryPending: false }, + networkPolicies: { + conflicts: [], + retryPending: false, + prune: 'converged', + legacy: 'removed', + }, }) ;( reconciler as unknown as { config: { pluginWorkloadSdkEnabled: boolean } } @@ -16227,7 +18365,12 @@ describe('WorkflowRecipeReconciler', () => { policyReady: true, verifiedAt: new Date().toISOString(), }, - networkPolicies: { conflicts: [], retryPending: false }, + networkPolicies: { + conflicts: [], + retryPending: false, + prune: 'converged', + legacy: 'removed', + }, }) ;( reconciler as unknown as { config: { pluginWorkloadSdkEnabled: boolean } } diff --git a/workflow-recipes/src/reconciler/resourceBuilder.test.ts b/workflow-recipes/src/reconciler/resourceBuilder.test.ts index 4886756cd2..719a8bfd3e 100644 --- a/workflow-recipes/src/reconciler/resourceBuilder.test.ts +++ b/workflow-recipes/src/reconciler/resourceBuilder.test.ts @@ -25,8 +25,10 @@ import { buildUiEgressNetworkPolicy, oauthBrokerEgressPolicyName, oauthBrokerTokenSecretName, + parseOAuthBrokerTokenSecretRecipe, recipeHasBackgroundAccessClient, resolveCronJobResourceName, + resolveOAuthBrokerTokenWatchRecipe, resolveResourceName, resolveScopedCronJobResourceName, resolveScopedResourceName, @@ -2078,6 +2080,30 @@ describe('recipe OAuth broker token', () => { expect(workloadUsesBackgroundOauth(danglingRef, recipe)).toBe(false) }) + it('resolves watch ADDED only for the canonical oauth-broker-token Secret name', () => { + const recipe = 'test-recipe' + // The Secret WRC itself writes: the resolver must bind it, so a drift in + // the builder's name or labels fails here. + const built = buildOAuthBrokerTokenSecret(recipe, 'jwt', 'sandbox-recipes') + const canonical = built.metadata?.name ?? '' + const labels = built.metadata?.labels ?? {} + expect(canonical).toBe(oauthBrokerTokenSecretName(recipe)) + expect(parseOAuthBrokerTokenSecretRecipe(canonical)).toBe(recipe) + expect(parseOAuthBrokerTokenSecretRecipe('wf--oauth-broker-token')).toBeUndefined() + expect(resolveOAuthBrokerTokenWatchRecipe(canonical, labels)).toBe(recipe) + const withoutRecipeLabel = Object.fromEntries( + Object.entries(labels).filter(([key]) => key !== 'clerum.io/recipe') + ) + expect(resolveOAuthBrokerTokenWatchRecipe(canonical, withoutRecipeLabel)).toBe(recipe) + expect(resolveOAuthBrokerTokenWatchRecipe('forged-name', labels)).toBeUndefined() + expect( + resolveOAuthBrokerTokenWatchRecipe(canonical, { + ...labels, + 'clerum.io/component': 'other', + }) + ).toBeUndefined() + }) + it('buildOAuthBrokerTokenSecret stores the token base64-encoded under broker-token', () => { const secret = buildOAuthBrokerTokenSecret('test-recipe', 'JWT.VALUE', 'sandbox-recipes') expect(secret.metadata?.name).toBe(oauthBrokerTokenSecretName('test-recipe')) diff --git a/workflow-recipes/src/reconciler/resourceBuilder.ts b/workflow-recipes/src/reconciler/resourceBuilder.ts index 7b7dc447b3..caadd50ad4 100644 --- a/workflow-recipes/src/reconciler/resourceBuilder.ts +++ b/workflow-recipes/src/reconciler/resourceBuilder.ts @@ -435,6 +435,41 @@ export function oauthBrokerTokenSecretName(recipeName: string): string { return `wf-${recipeName}-oauth-broker-token` } +const OAUTH_BROKER_TOKEN_SECRET_PREFIX = 'wf-' +const OAUTH_BROKER_TOKEN_SECRET_SUFFIX = '-oauth-broker-token' + +/** Parse `wf--oauth-broker-token`. Rejects an empty recipe segment. */ +export function parseOAuthBrokerTokenSecretRecipe(secretName: string): string | undefined { + if ( + !secretName.startsWith(OAUTH_BROKER_TOKEN_SECRET_PREFIX) || + !secretName.endsWith(OAUTH_BROKER_TOKEN_SECRET_SUFFIX) + ) { + return undefined + } + const recipe = secretName.slice( + OAUTH_BROKER_TOKEN_SECRET_PREFIX.length, + secretName.length - OAUTH_BROKER_TOKEN_SECRET_SUFFIX.length + ) + return recipe.length > 0 ? recipe : undefined +} + +/** + * Watch-bind for B3(a) invalidation. Requires `clerum.io/component=oauth-broker-token` + * and `metadata.name === wf-${recipe}-oauth-broker-token`. A forged recipe label + * on a different Secret name must not invalidate. + */ +export function resolveOAuthBrokerTokenWatchRecipe( + secretName: string, + labels: Record | undefined +): string | undefined { + if (labels?.['clerum.io/component'] !== 'oauth-broker-token') return undefined + const labeled = labels['clerum.io/recipe'] + if (labeled) { + return secretName === oauthBrokerTokenSecretName(labeled) ? labeled : undefined + } + return parseOAuthBrokerTokenSecretRecipe(secretName) +} + export function recipeHasBackgroundAccessClient(recipe: WorkflowRecipeCRD): boolean { return (recipe.spec.oauthClients ?? []).some(c => c.backgroundAccess === true) } diff --git a/workflow-recipes/src/reconciler/secretWatcher.test.ts b/workflow-recipes/src/reconciler/secretWatcher.test.ts index 7f7e07997a..2188b6fa68 100644 --- a/workflow-recipes/src/reconciler/secretWatcher.test.ts +++ b/workflow-recipes/src/reconciler/secretWatcher.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { buildOAuthBrokerTokenSecret } from './resourceBuilder' import { OWNER_RECIPE_LABEL_KEY, SHARED_LABEL_KEY } from './secretOwnership' import { SecretReverseIndex } from './secretReverseIndex' import { SecretWatcher } from './secretWatcher' @@ -215,4 +216,70 @@ describe('SecretWatcher', () => { vi.advanceTimersByTime(DEBOUNCE_MS) expect(enqueued.sort()).toEqual(['recipe-a', 'recipe-b']) }) + + it('invalidates the oauth-broker-token ledger on ADDED before key-set dedup', () => { + const reverseIndex = new SecretReverseIndex() + const enqueued: string[] = [] + const invalidated: string[] = [] + const watcher = new SecretWatcher( + reverseIndex, + name => { + enqueued.push(name) + }, + DEBOUNCE_MS, + recipeName => { + invalidated.push(recipeName) + } + ) + // The Secret WRC itself writes, so a drift in the builder's labels or + // name breaks this test instead of passing against a hand-written copy. + const brokerSecret = buildOAuthBrokerTokenSecret('test-recipe', 'jwt', 'sandbox-recipes') + + watcher.handleEvent('ADDED', brokerSecret) + watcher.handleEvent('ADDED', brokerSecret) + expect(invalidated).toEqual(['test-recipe', 'test-recipe']) + expect(enqueued).toEqual([]) + vi.advanceTimersByTime(DEBOUNCE_MS) + // E.4(a): invalidate only. A watch replay must not enqueue every recipe. + expect(enqueued).toEqual([]) + watcher.stop() + }) + + it('does not invalidate on forged name, wrong component, or MODIFIED', () => { + const reverseIndex = new SecretReverseIndex() + const enqueued: string[] = [] + const invalidated: string[] = [] + const watcher = new SecretWatcher( + reverseIndex, + name => { + enqueued.push(name) + }, + DEBOUNCE_MS, + recipeName => { + invalidated.push(recipeName) + } + ) + const canonical = buildOAuthBrokerTokenSecret('test-recipe', 'jwt', 'sandbox-recipes') + + watcher.handleEvent('ADDED', { + ...canonical, + metadata: { ...canonical.metadata, name: 'forged-oauth-broker-token' }, + }) + watcher.handleEvent('ADDED', { + ...canonical, + metadata: { + ...canonical.metadata, + labels: { ...canonical.metadata?.labels, 'clerum.io/component': 'other' }, + }, + }) + watcher.handleEvent('MODIFIED', canonical) + expect(invalidated).toEqual([]) + // Liveness witness: the same watcher does invalidate for the canonical + // ADDED, so the empty list above is not the callback being unwired. + watcher.handleEvent('ADDED', canonical) + expect(invalidated).toEqual(['test-recipe']) + vi.advanceTimersByTime(DEBOUNCE_MS) + expect(enqueued).toEqual([]) + watcher.stop() + }) }) diff --git a/workflow-recipes/src/reconciler/secretWatcher.ts b/workflow-recipes/src/reconciler/secretWatcher.ts index c1725b6aee..535e22cf75 100644 --- a/workflow-recipes/src/reconciler/secretWatcher.ts +++ b/workflow-recipes/src/reconciler/secretWatcher.ts @@ -1,3 +1,4 @@ +import { resolveOAuthBrokerTokenWatchRecipe } from './resourceBuilder' import { OWNER_RECIPE_LABEL_KEY, SHARED_LABEL_KEY } from './secretOwnership' import { SecretReverseIndex } from './secretReverseIndex' @@ -41,7 +42,8 @@ export class SecretWatcher { constructor( private readonly reverseIndex: SecretReverseIndex, private readonly enqueueReconcile: (recipeName: string) => void, - private readonly debounceMs: number = 10_000 + private readonly debounceMs: number = 10_000, + private readonly onOAuthBrokerTokenAdded?: (recipeName: string) => void ) {} /** @@ -58,6 +60,17 @@ export class SecretWatcher { const name = secret.metadata?.name if (!name) return + // B3(a): ADDED of the canonical broker token is reported even when the + // key-set matches a previous observation (dedup would drop it). This + // watcher never enqueues on it: a watch reconnect replays ADDED for every + // Secret, so the callback owner decides whether a reconcile is needed. + if (type === 'ADDED') { + const recipeName = resolveOAuthBrokerTokenWatchRecipe(name, secret.metadata?.labels) + if (recipeName) { + this.onOAuthBrokerTokenAdded?.(recipeName) + } + } + const newKeys = type === 'DELETED' ? new Set() diff --git a/workflow-recipes/src/reconciler/workflowRecipeReconciler.ts b/workflow-recipes/src/reconciler/workflowRecipeReconciler.ts index c73653edf7..1a716ffabb 100644 --- a/workflow-recipes/src/reconciler/workflowRecipeReconciler.ts +++ b/workflow-recipes/src/reconciler/workflowRecipeReconciler.ts @@ -57,23 +57,28 @@ import { ModelConfigHandler } from '../workflow/modelConfigHandler' import { buildCoordinatorGfsNetworkPolicy } from '../workflow/networkPolicyFactory' import type { EagerSdkBootstrapProof, + NetworkPolicyPassSummary, WorkflowNetworkPolicyApplySummary, } from '../workflow/pluginWorkloadSdkProvisioner' import { HttpPluginWorkloadSdkRevocationClient } from '../workflow/pluginWorkloadSdkRevocationClient' import { deriveWorkflowRuntimePlan } from '../workflow/runtimePlan' import { validateWorkflowRecipeLimits } from '../workflow/workflowLimits' import { - NETWORK_POLICIES_CONVERGED_CONDITION_TYPE, + LegacyNetworkPolicyDeletePendingError, NETWORK_POLICY_OWNERSHIP_CONDITION_TYPES, + type NetworkPolicyMarkerWriter, WORKFLOW_OUTPUT_CONDITION_TYPES, WorkflowReconciler, WorkflowReconcilerDeps, + hasLegacyNetworkPolicyDeletePendingMarker, hasNetworkPolicyRetryPendingMarker, networkPolicyConditionsChanged, - translateNetworkPolicyApplySummary, + networkPolicyMarkerConditions, + translateNetworkPolicyPassSummary, } from '../workflow/workflowReconciler' import { evaluateComputedValues } from './computedValuesEvaluator' import { CRD_GROUP, CRD_VERSION, WORKFLOWRECIPE_PLURAL } from './crdConstants' +import { deleteOutcomeFields, observeNamespacedDelete, shouldRecordDelete } from './deleteOutcome' import { sort as sortDependencies } from './dependencyGraph' import { type AccumulateOutput, @@ -127,6 +132,7 @@ import { decideNetworkPolicyConvergence, networkPolicyMetadataMatchesDesired, } from './networkPolicyConvergence' +import { OAuthBrokerDeleteLedger } from './oauthBrokerDeleteLedger' import { issueOAuthBrokerToken } from './oauthBrokerTokenIssuerClient' import { PLUGIN_WORKLOAD_SDK_CONDITION_TYPE, @@ -684,8 +690,18 @@ export interface ReconcileResult { * false/undefined so genuine flakiness still backs off. */ requeueFixedInterval?: boolean + /** A legacy-only timer must not repeat the recipe's runtime lifecycle. */ + requeueOperation?: 'legacy-network-policy-delete' } +type LegacyNetworkPolicyDeleteRetryResult = Pick< + ReconcileResult, + | 'requeueAfterMs' + | 'requeueFixedInterval' + | 'requeueOperation' + | 'networkPolicyOwnershipConditions' +> + /** * Base requeue delay for a transient (skipStatusPatch) reconcile result. The * watcher backs off exponentially from here up to its own cap. @@ -941,10 +957,20 @@ export class WorkflowRecipeReconciler { // F2 gate) and the safe direction; under-refreshing would reopen #299. private externalEgressMinObservedTtlMs = Infinity private secretReverseIndex: SecretReverseIndex | null + private readonly oauthBrokerDeleteLedger = new OAuthBrokerDeleteLedger() private verifyWorkflowRunProvenance: NonNullable< WorkflowRecipeReconcilerDeps['verifyWorkflowRunProvenance'] > + /** + * Re-arms the Secret delete for a recipe whose token Secret was (re)created. + * The NetworkPolicy TTL is untouched: a watch reconnect replays ADDED for + * every existing token and must not reset it. + */ + invalidateOAuthBrokerSecretLedger(recipeName: string): void { + this.oauthBrokerDeleteLedger.invalidateSecret(recipeName) + } + constructor(kc: k8s.KubeConfig, config?: OperatorConfig, deps?: WorkflowRecipeReconcilerDeps) { this.appsApi = kc.makeApiClient(k8s.AppsV1Api) this.batchApi = kc.makeApiClient(k8s.BatchV1Api) @@ -1082,7 +1108,7 @@ export class WorkflowRecipeReconciler { */ private async cleanupPluginWorkloadSdkOrThrow( recipeName: string, - options: { preserveWorkflowRuntime?: boolean } = {} + options: { preserveWorkflowRuntime?: boolean; recipeUid?: string; recipeDeleted?: boolean } = {} ): Promise { if (!this.workflowReconciler) { throw new Error('workflow subsystem is not initialized; SDK cleanup cannot be confirmed') @@ -1460,11 +1486,16 @@ export class WorkflowRecipeReconciler { /** * Applies the run-lane NetworkPolicies again from a short-circuit when the - * published status carries the retry marker. Those short-circuits never - * reach `WorkflowReconciler.reconcile()`, so without this a policy left - * pending a retry would stay unapplied until the run ends. Nothing is - * pruned: the prune decisions depend on the eligibility verdicts that only - * the full pass computes. + * published marker says an apply is pending a retry. Those short-circuits + * never reach `WorkflowReconciler.reconcile()`, so without this a policy + * left pending a retry would stay unapplied until the run ends. The retry + * only applies the desired policies, with a verdict computed for this pass; + * it prunes no run-lane policy, because the run's pods may still use any + * lane. A marker that carries only a pending prune (`PrunePending`) does not + * enter: the retry could not clear it. A pending prune carried next to the + * apply survives the retry, and the leftover waits for the next reconcile() + * pass or the finalizer sweep (see + * `WorkflowReconciler.retryRunLaneNetworkPolicies`). * * Returns the requeue delay the short-circuit must add: the progress base * while a policy is still pending, the transient base when the apply threw @@ -1502,33 +1533,177 @@ export class WorkflowRecipeReconciler { ) return TRANSIENT_REQUEUE_BASE_MS } - const existing = recipe.status?.conditions - const translated = translateNetworkPolicyApplySummary( - summary, - existing, + await this.publishNetworkPolicyMarkerWriter(recipe, { kind: 'apply-retry', summary }) + return summary.retryPending ? WORKFLOW_PROGRESS_REQUEUE_BASE_MS : undefined + } + + /** + * R4-L4: the `legacy-retry` writer. Enters only while the published group + * says the legacy mcp-servers internet policy is pending a delete. The + * delete shares the reconcile() pass's per-recipe backoff, so inside the + * window it sends no request. `removed` clears the legacy fact with a + * conditions-only patch and carries the apply and prune facts; `pending` + * writes nothing, and the `reconcile()` wrapper requeues for the rest of + * the window. + */ + private async retryPendingLegacyNetworkPolicyDelete(recipe: WorkflowRecipeCRD): Promise { + if ( + !this.workflowReconciler || + !hasLegacyNetworkPolicyDeletePendingMarker(recipe.status?.conditions) + ) { + return + } + const legacy = await this.workflowReconciler.retryLegacyMcpServersInternetEgressDelete( + recipe.metadata.name, + recipe.metadata.uid ?? '' + ) + if (legacy === 'pending') return + await this.publishNetworkPolicyMarkerWriter(recipe, { kind: 'legacy-retry', legacy }) + } + + /** + * The timer's only operation: retry the shared legacy DELETE and publish its + * condition group. Reread after the asynchronous DELETE so a replacement CR, + * deletion or newer policy facts cannot receive this snapshot's status. + * Resource-version conflicts retry through the watcher's bounded timer; the + * shared gone ledger prevents another DELETE while that patch converges. + */ + async retryPendingLegacyNetworkPolicyDeleteOnly( + recipe: WorkflowRecipeCRD + ): Promise { + if ( + !this.workflowReconciler || + !recipe.metadata.uid || + recipe.metadata.namespace !== this.config.sandboxNamespace || + recipe.metadata.deletionTimestamp || + !hasLegacyNetworkPolicyDeletePendingMarker(recipe.status?.conditions) + ) + return {} + + const legacy = await this.workflowReconciler.retryLegacyMcpServersInternetEgressDelete( + recipe.metadata.name, + recipe.metadata.uid + ) + let live: WorkflowRecipeCRD + try { + live = (await this.customApi.getNamespacedCustomObject({ + group: CRD_GROUP, + version: CRD_VERSION, + namespace: recipe.metadata.namespace, + plural: WORKFLOWRECIPE_PLURAL, + name: recipe.metadata.name, + })) as WorkflowRecipeCRD + } catch (error) { + if (getErrorCode(error) === 404) return {} + throw error + } + if ( + live.metadata.uid !== recipe.metadata.uid || + live.metadata.name !== recipe.metadata.name || + live.metadata.namespace !== recipe.metadata.namespace || + live.metadata.deletionTimestamp || + !hasLegacyNetworkPolicyDeletePendingMarker(live.status?.conditions) + ) + return {} + + if (legacy === 'pending') { + const result: LegacyNetworkPolicyDeleteRetryResult = {} + this.requeueForPendingLegacyNetworkPolicyDelete(live, result) + return result + } + const fresh = networkPolicyMarkerConditions( + { kind: 'legacy-retry', legacy }, + live.status?.conditions, new Date().toISOString() ) - const fresh = translated.networkPolicyOwnershipConditions ?? [] - if (networkPolicyConditionsChanged(existing, fresh)) { - await this.publishNetworkPolicyConditions( - recipe, - mergeOwnedConditions(existing, fresh, NETWORK_POLICY_OWNERSHIP_CONDITION_TYPES) ?? [] + const conditions = + mergeOwnedConditions( + live.status?.conditions, + fresh, + NETWORK_POLICY_OWNERSHIP_CONDITION_TYPES + ) ?? [] + if (!live.metadata.resourceVersion) { + throw new RetryableReconcileError( + 'Cannot publish legacy policy retry without recipe resourceVersion' ) } - return translated.networkPolicyRetryPending ? WORKFLOW_PROGRESS_REQUEUE_BASE_MS : undefined + await this.customApi.patchNamespacedCustomObjectStatus( + { + group: CRD_GROUP, + version: CRD_VERSION, + namespace: live.metadata.namespace, + plural: WORKFLOWRECIPE_PLURAL, + name: live.metadata.name, + body: { + metadata: { uid: live.metadata.uid, resourceVersion: live.metadata.resourceVersion }, + status: { conditions }, + }, + }, + { middleware: [k8s.setHeaderMiddleware('Content-Type', 'application/merge-patch+json')] } + ) + if (recipe.status) recipe.status.conditions = conditions + return {} + } + + /** + * R4-L4: a pass that leaves `DeletePending` published and asks for no other + * requeue is requeued at a fixed interval for the rest of the legacy + * delete's backoff window, at least 1 s. The group the pass publishes wins + * over the one it found; a pass that publishes no group keeps the found + * one, including a legacy-retry patch made during the pass. + */ + private requeueForPendingLegacyNetworkPolicyDelete( + recipe: WorkflowRecipeCRD, + result: LegacyNetworkPolicyDeleteRetryResult + ): void { + if ( + !this.workflowReconciler || + recipe.metadata.namespace !== this.config.sandboxNamespace || + recipe.metadata.deletionTimestamp || + result.requeueAfterMs !== undefined + ) + return + const conditions = result.networkPolicyOwnershipConditions ?? recipe.status?.conditions + if (!hasLegacyNetworkPolicyDeletePendingMarker(conditions)) return + result.requeueAfterMs = Math.max( + 1_000, + this.workflowReconciler.legacyMcpServersInternetEgressRetryDelayMs( + recipe.metadata.name, + recipe.metadata.uid ?? '' + ) + ) + result.requeueFixedInterval = true + result.requeueOperation = 'legacy-network-policy-delete' } /** - * Drops the retry marker once the run is terminal: the run no longer needs - * its policies, so there is nothing left to retry. The ownership condition, - * if any, stays published. + * Settles the marker once the run is terminal. The run no longer needs its + * policies, so a pending apply is dropped: there is nothing left to retry. + * A pending or unevaluated prune is kept: the terminal teardown deletes the + * run's pods, not its run-lane policies, so a leftover may still be live and + * only a later reconcile() prune or the finalizer removes it. The ownership + * condition, if any, stays published. Opens a patch only when the marker + * changes. */ - private async clearNetworkPolicyRetryMarker(recipe: WorkflowRecipeCRD): Promise { + private async settleNetworkPolicyMarkerForTerminalRun(recipe: WorkflowRecipeCRD): Promise { + await this.publishNetworkPolicyMarkerWriter(recipe, { kind: 'terminal' }) + } + + /** + * Publishes the NetworkPolicy condition group `writer` produces, outside + * `patchStatus`, for the paths that return before it. Opens a patch only + * when the group changes; every other condition is kept. + */ + private async publishNetworkPolicyMarkerWriter( + recipe: WorkflowRecipeCRD, + writer: NetworkPolicyMarkerWriter + ): Promise { const existing = recipe.status?.conditions - if (!hasNetworkPolicyRetryPendingMarker(existing)) return + const fresh = networkPolicyMarkerConditions(writer, existing, new Date().toISOString()) + if (!networkPolicyConditionsChanged(existing, fresh)) return await this.publishNetworkPolicyConditions( recipe, - (existing ?? []).filter(c => c.type !== NETWORK_POLICIES_CONVERGED_CONDITION_TYPE) + mergeOwnedConditions(existing, fresh, NETWORK_POLICY_OWNERSHIP_CONDITION_TYPES) ?? [] ) } @@ -1731,6 +1906,7 @@ export class WorkflowRecipeReconciler { // (e.g. observeCurrentWorkloadStatus) leave the projection undefined, which // both consumers treat as "no SDK opinion this pass". const result = await this.reconcileInternal(recipe) + this.requeueForPendingLegacyNetworkPolicyDelete(recipe, result) result.pluginWorkloadSdkProjection = this.projectPluginWorkloadSdk(recipe, result) createLogger('wrc', recipe.metadata.name).info('recipe reconciliation completed', { recipe: recipe.metadata.name, @@ -1748,6 +1924,7 @@ export class WorkflowRecipeReconciler { const ns = recipe.metadata.namespace const currentPhase = recipe.status?.phase ?? 'candidate' let sdkOnlyProviderUnavailable = false + let sdkOnlyCapabilityRemovalTeardownConfirmed = false // Defense-in-depth: the VAP + admin-API already enforce this invariant, // but a manually-applied CRD in another namespace must not be reconciled. @@ -1781,6 +1958,7 @@ export class WorkflowRecipeReconciler { try { await this.cleanupPluginWorkloadSdkOrThrow(name, { preserveWorkflowRuntime: isWorkflow, + recipeUid: recipe.metadata.uid, }) } catch (error) { return { @@ -1788,16 +1966,24 @@ export class WorkflowRecipeReconciler { message: `Plugin Workload SDK teardown failed while the feature flag is disabled: ${String(error)}`, workloadStatuses: [], skipStatusPatch: true, - requeueAfterMs: TRANSIENT_REQUEUE_BASE_MS, + requeueAfterMs: + error instanceof LegacyNetworkPolicyDeletePendingError + ? error.retryAfterMs + : TRANSIENT_REQUEUE_BASE_MS, + requeueFixedInterval: error instanceof LegacyNetworkPolicyDeletePendingError, } } return { phase: currentPhase, message: 'Plugin Workload SDK disabled after confirmed teardown', workloadStatuses: [], - // The SDK runtime is gone and this return skips the SDK-only lane, so - // no policy is managed and a published ownership conflict is stale. - networkPolicyOwnershipConditions: [], + // The confirmed teardown deleted every SDK policy (the legacy one by + // name included), so no policy is managed: the unmanaged writer. + networkPolicyOwnershipConditions: networkPolicyMarkerConditions( + { kind: 'unmanaged' }, + recipe.status?.conditions, + new Date().toISOString() + ), pluginWorkloadSdkTeardownConfirmed: true, } } @@ -1816,6 +2002,7 @@ export class WorkflowRecipeReconciler { try { await this.cleanupPluginWorkloadSdkOrThrow(name, { preserveWorkflowRuntime: isWorkflow, + recipeUid: recipe.metadata.uid, }) } catch (error) { return { @@ -1823,11 +2010,31 @@ export class WorkflowRecipeReconciler { message: `Plugin Workload SDK teardown failed after capability removal: ${String(error)}`, workloadStatuses: [], skipStatusPatch: true, - requeueAfterMs: TRANSIENT_REQUEUE_BASE_MS, + requeueAfterMs: + error instanceof LegacyNetworkPolicyDeletePendingError + ? error.retryAfterMs + : TRANSIENT_REQUEUE_BASE_MS, + requeueFixedInterval: error instanceof LegacyNetworkPolicyDeletePendingError, } } + // A stepless recipe managed policies only through the SDK, and the + // teardown above deleted them all, so the group is cleared here: the + // terminal and validation returns below never reach the SDK-only lane. + // A workflow recipe keeps its run lane, which still manages policies. + if (!isWorkflow) { + sdkOnlyCapabilityRemovalTeardownConfirmed = true + await this.publishNetworkPolicyMarkerWriter(recipe, { kind: 'unmanaged' }) + } } + // R4-L4 / R5-J1: the validation, policy and step-limit returns and the + // workflow short-circuits below never reach the inner reconcile(), so a + // legacy mcp-servers internet policy pending a delete is retried here, + // before all of them (the `legacy-retry` writer). Every pass that leaves + // DeletePending published has then either sent the DELETE or is inside + // its backoff window, and the wrapper's requeue is that window. + await this.retryPendingLegacyNetworkPolicyDelete(recipe) + const limitError = validateWorkflowRecipeLimits(recipe.spec, this.config) if (limitError) { if (isWorkflow) await this.revokeCoordinatorGfsNetworkPolicy(recipe) @@ -1952,6 +2159,20 @@ export class WorkflowRecipeReconciler { } } + // B3(a): the awaiting-trigger, terminal, in-progress and active + // short-circuits below return before the first-deploy path that calls + // ensureOAuthBrokerTokenSecret, so the token-ADDED re-reconcile would + // never reach the reap. Only the delete branch runs here (issuance stays + // on the first-deploy path and the rotation loop), and the delete ledger + // decides: no DELETE until a token ADDED was observed for the recipe, + // then one per generation or ADDED. The first-deploy path below skips + // its own call when this one ran, so a failed DELETE is not repeated in + // the same pass. + const oauthBrokerTokenReapedEarly = !rb.recipeHasBackgroundAccessClient(recipe) + if (oauthBrokerTokenReapedEarly) { + await this.ensureOAuthBrokerTokenSecret(recipe) + } + if ( currentPhase === 'active' && awaitsTriggeredRun && @@ -2018,7 +2239,7 @@ export class WorkflowRecipeReconciler { // torn down above; this teardown is idempotent/404-tolerant). const terminalOwnership = await this.revokeOrRequeueSteadyWorkflow(recipe, derivedPhase) if (terminalOwnership) return terminalOwnership - await this.clearNetworkPolicyRetryMarker(recipe) + await this.settleNetworkPolicyMarkerForTerminalRun(recipe) // The recipe phase may remain active across running -> completed. Still // patch once if the top-level message is stale so UIs do not show a // completed execution as still running. @@ -2277,7 +2498,11 @@ export class WorkflowRecipeReconciler { name, }) try { - const workflowDeploy = await this.deployWorkflowWorkloads(recipe, name) + const workflowDeploy = await this.deployWorkflowWorkloads( + recipe, + name, + oauthBrokerTokenReapedEarly + ) workflowInternalDependencyConditions = workflowDeploy.internalDependencyConditions workflowWorkloadConditions = workflowDeploy.workloadConditions workflowTransportNetworkConditions = workflowDeploy.transportNetworkConditions @@ -2424,9 +2649,12 @@ export class WorkflowRecipeReconciler { // WorkflowNetworkPoliciesConverged=False/RetryPending marker. The // requeued pass can stop at a short-circuit above, before // WorkflowReconciler.reconcile(): the in-progress and active - // short-circuits reapply the run-lane policies while the marker is - // present, and the terminal branch removes it. The awaiting-trigger - // short-circuit does not reapply; the triggered run applies them. + // short-circuits reapply the run-lane policies while the marker names + // a pending apply, and the terminal branch drops that fact. A pending + // prune (PrunePending) adds no requeue and survives both; only a + // later reconcile() prune or the finalizer clears it. The + // awaiting-trigger short-circuit does not reapply; the triggered run + // applies them. requeueAfterMs: result.skipStatusPatch ? TRANSIENT_REQUEUE_BASE_MS : result.phase === 'deploying' || @@ -3033,19 +3261,28 @@ export class WorkflowRecipeReconciler { sdkOnlyRuntime?.phase === 'active' && recipe.spec.pluginWorkloadSdk !== undefined && sdkOnlyRuntime.pluginWorkloadSdkBootstrapProof?.ready !== true - // Without an SDK runtime this lane manages no policies: nothing conflicts - // and nothing is pending, so a condition published by an earlier pass is - // stale and `[]` removes it. A runtime without a summary did not evaluate - // the policies (the host returned before the apply), so the ownership - // field stays absent and patchStatus keeps what was published. + // Only a completed capability-removal teardown proves this lane manages + // no policy. A missing runtime alone evaluated nothing and must preserve + // the published apply, prune, ownership and legacy-delete facts. + const now = new Date().toISOString() const { networkPolicyRetryPending: sdkOnlyNetworkPolicyRetryPending, ...sdkOnlyNetworkPolicyOwnership - } = translateNetworkPolicyApplySummary( - sdkOnlyRuntime ? sdkOnlyRuntime.networkPolicies : { conflicts: [], retryPending: false }, - recipe.status?.conditions, - new Date().toISOString() - ) + } = sdkOnlyRuntime + ? translateNetworkPolicyPassSummary( + sdkOnlyRuntime.networkPolicies, + recipe.status?.conditions, + now + ) + : sdkOnlyCapabilityRemovalTeardownConfirmed + ? { + networkPolicyOwnershipConditions: networkPolicyMarkerConditions( + { kind: 'unmanaged' }, + recipe.status?.conditions, + now + ), + } + : {} if (sdkOnlyRuntime?.phase === 'failed') { return { phase: 'failed', @@ -3228,7 +3465,7 @@ export class WorkflowRecipeReconciler { message: string pluginWorkloadSdkBootstrapProof?: EagerSdkBootstrapProof /** Undefined when the pass returned before applying the policies. */ - networkPolicies?: WorkflowNetworkPolicyApplySummary + networkPolicies?: NetworkPolicyPassSummary }> { if (!this.workflowReconciler) { return { @@ -3618,7 +3855,8 @@ export class WorkflowRecipeReconciler { private async deployWorkflowWorkloads( recipe: WorkflowRecipeCRD, - name: string + name: string, + oauthBrokerTokenReapedEarly: boolean ): Promise<{ internalDependencyConditions: StatusCondition[] secretOwnershipConditions: StatusCondition[] @@ -3710,14 +3948,17 @@ export class WorkflowRecipeReconciler { // Provision the OAuth broker token Secret before workloads so the // RECIPE_OAUTH_BROKER_TOKEN secretKeyRef resolves on first pod start. // Non-fatal — same rationale as the non-workflow branch above. The - // periodic rotation loop will retry on its own cadence. - try { - await this.ensureOAuthBrokerTokenSecret(recipe) - } catch (err) { - createLogger('wrc', recipe.metadata.name).warn( - 'Broker-token issuance failed during workflow reconciliation; rotation will retry', - { name, err } - ) + // periodic rotation loop will retry on its own cadence. Skipped when the + // early reap in reconcile() already ran the delete branch for this pass. + if (!oauthBrokerTokenReapedEarly) { + try { + await this.ensureOAuthBrokerTokenSecret(recipe) + } catch (err) { + createLogger('wrc', recipe.metadata.name).warn( + 'Broker-token issuance failed during workflow reconciliation; rotation will retry', + { name, err } + ) + } } // Create/update workload resources in dependency order. Secret ownership was @@ -3922,6 +4163,7 @@ export class WorkflowRecipeReconciler { createLogger('wrc', recipe.metadata.name).info('Deleting recipe resources', { name }) this.secretReverseIndex?.delete(name) + this.oauthBrokerDeleteLedger.forgetRecipe(name) // ─── Workflow Delete (Stage 1) ──────────────────────────────────── const isWorkflow = recipe.spec.steps !== undefined && recipe.spec.steps.length > 0 @@ -3930,9 +4172,18 @@ export class WorkflowRecipeReconciler { // the shared mcp-host/token resources. Hybrid recipes must not bypass // broker revocation merely because they also have coordinator state. if (recipe.spec.pluginWorkloadSdk || recipe.status?.pluginWorkloadSdk) { - await this.cleanupPluginWorkloadSdkOrThrow(name, { preserveWorkflowRuntime: true }) + await this.cleanupPluginWorkloadSdkOrThrow(name, { + preserveWorkflowRuntime: true, + recipeUid: recipe.metadata.uid, + recipeDeleted: true, + }) } - await this.workflowReconciler.reconcileDelete(name, recipe.metadata.namespace, recipe.spec) + await this.workflowReconciler.reconcileDelete( + name, + recipe.metadata.namespace, + recipe.spec, + recipe.metadata.uid + ) await this.cleanupDelegationIfNeeded(recipe) await this.cleanupDeclaredRuntimeResources(recipe) return @@ -3942,7 +4193,10 @@ export class WorkflowRecipeReconciler { // a capability may have been removed in an update before Kubernetes emits // the final delete event, and cleanup must still revoke its host tokens. if (recipe.spec.pluginWorkloadSdk || recipe.status?.pluginWorkloadSdk) { - await this.cleanupPluginWorkloadSdkOrThrow(name) + await this.cleanupPluginWorkloadSdkOrThrow(name, { + recipeUid: recipe.metadata.uid, + recipeDeleted: true, + }) } await this.cleanupDelegationIfNeeded(recipe) @@ -4186,16 +4440,9 @@ export class WorkflowRecipeReconciler { } // The OAuth broker token Secret + its egress NetworkPolicy (Path B) carry - // no ownerReference, so they are reaped explicitly here. safeDelete - // swallows 404 when the recipe never used background OAuth. - await this.safeDelete( - () => - this.coreApi.deleteNamespacedSecret({ - name: rb.oauthBrokerTokenSecretName(recipe.metadata.name), - namespace: this.config.sandboxNamespace, - }), - `Secret "${rb.oauthBrokerTokenSecretName(recipe.metadata.name)}" in ${this.config.sandboxNamespace}` - ) + // no ownerReference, so they are reaped explicitly here. A 404 is logged + // as already gone when the recipe never used background OAuth. + await this.deleteOAuthBrokerTokenOnFinalize(recipe.metadata.name) await this.safeDelete( () => this.networkingApi.deleteNamespacedNetworkPolicy({ @@ -4619,6 +4866,45 @@ export class WorkflowRecipeReconciler { }) } + /** + * Finalizer reap of the oauth-broker token Secret. Logs exactly as + * `safeDelete` does and swallows a failure the same way (#949), but hands + * the observed outcome to the ledger: only a 2xx/404 with no token ADDED + * while the DELETE was in flight clears the token-seen epoch. A failed + * DELETE keeps it, so a recipe recreated under the same name still reaps + * the leftover token. + */ + private async deleteOAuthBrokerTokenOnFinalize(recipeName: string): Promise { + const secretName = rb.oauthBrokerTokenSecretName(recipeName) + const namespace = this.config.sandboxNamespace + const label = `Secret "${secretName}" in ${namespace}` + const epochBeforeDelete = this.oauthBrokerDeleteLedger.secretEpoch(recipeName) + const outcome = await observeNamespacedDelete(() => + this.coreApi.deleteNamespacedSecret({ name: secretName, namespace }) + ) + const log = createLogger('wrc', 'workflow-recipes') + switch (outcome.kind) { + case 'deleted': + log.info('Deleted resource', { label }) + break + case 'gone': + log.info('Resource already gone', { label }) + break + case 'failed': + log.error('Failed to delete resource', { label, err: outcome.error }) + return + default: { + const _exhaustive: never = outcome + return _exhaustive + } + } + if (!this.oauthBrokerDeleteLedger.noteSecretGone(recipeName, epochBeforeDelete)) { + log.info('oauth-broker-token Secret re-added during finalizer delete; keeping it seen', { + recipe: recipeName, + }) + } + } + private async safeDelete(deleteFn: () => Promise, label: string): Promise { try { await deleteFn() @@ -7499,13 +7785,50 @@ export class WorkflowRecipeReconciler { const secretName = rb.oauthBrokerTokenSecretName(recipeName) if (!rb.recipeHasBackgroundAccessClient(recipe)) { - await this.safeDelete( - () => this.coreApi.deleteNamespacedSecret({ name: secretName, namespace: ns }), - `Secret "${secretName}" in ${ns}` + const generation = recipe.metadata.generation + if (!this.oauthBrokerDeleteLedger.shouldDeleteSecret(recipe.metadata)) { + // Every non-backgroundAccess pass reaches this, so it stays at debug. + createLogger('wrc', recipeName).debug( + 'Skipping oauth-broker-token delete; no token seen, or this generation already reaped it', + { recipe: recipeName, generation } + ) + return + } + const epochBeforeDelete = this.oauthBrokerDeleteLedger.secretEpoch(recipeName) + const outcome = await observeNamespacedDelete(() => + this.coreApi.deleteNamespacedSecret({ name: secretName, namespace: ns }) ) + const log = createLogger('wrc', recipeName) + if (outcome.kind === 'failed') { + log.error('Failed to delete oauth-broker-token Secret', { + secretName, + ns, + ...deleteOutcomeFields(outcome), + err: outcome.error, + }) + } else { + log.info('oauth-broker-token Secret delete', { + secretName, + ns, + ...deleteOutcomeFields(outcome), + }) + } + if ( + shouldRecordDelete(outcome) && + !this.oauthBrokerDeleteLedger.recordSecretDelete(recipe.metadata, epochBeforeDelete) + ) { + log.info('oauth-broker-token Secret re-added during delete; not recording', { + recipe: recipeName, + generation, + }) + } return } + // This generation wants the token: a queued pass still carrying an older + // generation without backgroundAccess must not delete it after its ADDED. + this.oauthBrokerDeleteLedger.noteSecretProvisioned(recipe.metadata) + let existing: k8s.V1Secret | null = null try { existing = await this.coreApi.readNamespacedSecret({ name: secretName, namespace: ns }) @@ -7565,10 +7888,36 @@ export class WorkflowRecipeReconciler { this.config.controlPlaneNamespace ) if (!policy) { - await this.safeDelete( - () => this.networkingApi.deleteNamespacedNetworkPolicy({ name: policyName, namespace: ns }), - `NetworkPolicy "${policyName}" in ${ns}` + const recipeName = recipe.metadata.name + const generation = recipe.metadata.generation + if (!this.oauthBrokerDeleteLedger.shouldDeletePolicy(recipe.metadata)) { + createLogger('wrc', recipeName).debug( + 'Skipping oauth-broker-egress delete; generation already seen', + { recipe: recipeName, generation } + ) + return + } + const outcome = await observeNamespacedDelete(() => + this.networkingApi.deleteNamespacedNetworkPolicy({ name: policyName, namespace: ns }) ) + const log = createLogger('wrc', recipeName) + if (outcome.kind === 'failed') { + log.error('Failed to delete oauth-broker-egress NetworkPolicy', { + policyName, + ns, + ...deleteOutcomeFields(outcome), + err: outcome.error, + }) + } else { + log.info('oauth-broker-egress NetworkPolicy delete', { + policyName, + ns, + ...deleteOutcomeFields(outcome), + }) + } + if (shouldRecordDelete(outcome)) { + this.oauthBrokerDeleteLedger.recordPolicyDelete(recipe.metadata) + } return } await this.applyNetworkPolicy(policy, ns, { family: 'oauth-broker-egress' }) diff --git a/workflow-recipes/src/workflow/crashRecovery.test.ts b/workflow-recipes/src/workflow/crashRecovery.test.ts index 7a0816e87b..2ee83cede9 100644 --- a/workflow-recipes/src/workflow/crashRecovery.test.ts +++ b/workflow-recipes/src/workflow/crashRecovery.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { evaluateCompletedRuntimePodRecovery, evaluateCrashRecovery, + getPodPresence, inspectPodReadiness, waitForPodDeletion, } from './crashRecovery' @@ -111,8 +112,43 @@ describe('crash recovery pod readiness classification', () => { }) }) +describe('getPodPresence', () => { + it('returns absent on GET 404 and present on GET 200 with empty status', async () => { + const readNamespacedPod = vi + .fn() + .mockRejectedValueOnce({ code: 404 }) + .mockResolvedValueOnce({ metadata: { name: 'recipe-coordinator' }, status: {} }) + const coreApi = { readNamespacedPod } as unknown as Parameters[0] + + await expect(getPodPresence(coreApi, 'recipe-coordinator', 'sandbox-recipes')).resolves.toEqual( + { + kind: 'absent', + } + ) + await expect(getPodPresence(coreApi, 'recipe-coordinator', 'sandbox-recipes')).resolves.toEqual( + { + kind: 'present', + phase: undefined, + } + ) + }) + + it.each([403, 500])('rethrows a GET %i instead of reporting the pod absent', async code => { + const readNamespacedPod = vi.fn().mockRejectedValueOnce({ code }) + const coreApi = { readNamespacedPod } as unknown as Parameters[0] + + await expect( + getPodPresence(coreApi, 'recipe-coordinator', 'sandbox-recipes') + ).rejects.toMatchObject({ code }) + expect(readNamespacedPod).toHaveBeenCalledWith({ + name: 'recipe-coordinator', + namespace: 'sandbox-recipes', + }) + }) +}) + describe('waitForPodDeletion', () => { - it('returns true once getPodPhase reports the pod is gone', async () => { + it('returns true once getPodPresence reports the pod is gone', async () => { const readNamespacedPod = vi .fn() .mockResolvedValueOnce({ status: { phase: 'Running' } }) @@ -129,4 +165,23 @@ describe('waitForPodDeletion', () => { ).resolves.toBe(true) expect(readNamespacedPod).toHaveBeenCalledTimes(2) }) + + it('does not treat a GET 200 with empty status.phase as deleted', async () => { + const readNamespacedPod = vi + .fn() + .mockResolvedValue({ metadata: { name: 'recipe-mcp-host' }, status: {} }) + const coreApi = { + readNamespacedPod, + } as unknown as Parameters[0] + + await expect( + waitForPodDeletion(coreApi, 'recipe-mcp-host', 'sandbox-recipes', { + timeoutMs: 20, + pollIntervalMs: 5, + }) + ).resolves.toBe(false) + // Liveness witness: `false` also results if the wait never polled. Several GETs + // that each answered 200 show it kept looking until the timeout. + expect(readNamespacedPod.mock.calls.length).toBeGreaterThan(1) + }) }) diff --git a/workflow-recipes/src/workflow/crashRecovery.ts b/workflow-recipes/src/workflow/crashRecovery.ts index 2b5aabce33..10cbdbdacf 100644 --- a/workflow-recipes/src/workflow/crashRecovery.ts +++ b/workflow-recipes/src/workflow/crashRecovery.ts @@ -148,26 +148,56 @@ export async function waitForPodDeletion( const pollIntervalMs = options.pollIntervalMs ?? POD_DELETE_POLL_MS const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { - if ((await getPodPhase(coreApi, name, namespace)) === undefined) return true + if ((await getPodPresence(coreApi, name, namespace)).kind === 'absent') return true await new Promise(resolve => setTimeout(resolve, pollIntervalMs)) } - return (await getPodPhase(coreApi, name, namespace)) === undefined + return (await getPodPresence(coreApi, name, namespace)).kind === 'absent' } -export async function getPodPhase( +/** + * Discriminated GET result. `unread` is only for callers that skipped the GET; + * `getPodPresence` never returns it. Skip DELETE only on `absent` — a live + * object with an empty `status.phase` is `present`. + */ +export type PodPresence = + | { kind: 'absent' } + | { kind: 'present'; phase?: string } + | { kind: 'unread' } + +export type ObservedPodPresence = Exclude + +export async function getPodPresence( coreApi: k8s.CoreV1Api, name: string, namespace: string -): Promise { +): Promise { try { const pod = await coreApi.readNamespacedPod({ name, namespace }) - return pod.status?.phase + return { kind: 'present', phase: pod.status?.phase } } catch (error: unknown) { - if (getErrorCode(error) === 404) return undefined + if (getErrorCode(error) === 404) return { kind: 'absent' } throw error } } +export async function getPodPhase( + coreApi: k8s.CoreV1Api, + name: string, + namespace: string +): Promise { + const presence = await getPodPresence(coreApi, name, namespace) + switch (presence.kind) { + case 'absent': + return undefined + case 'present': + return presence.phase + default: { + const _exhaustive: never = presence + return _exhaustive + } + } +} + export async function getPodReadiness( coreApi: k8s.CoreV1Api, name: string, diff --git a/workflow-recipes/src/workflow/networkPolicyFactory.gfs.test.ts b/workflow-recipes/src/workflow/networkPolicyFactory.gfs.test.ts index 17d2ed2012..8e846bc1eb 100644 --- a/workflow-recipes/src/workflow/networkPolicyFactory.gfs.test.ts +++ b/workflow-recipes/src/workflow/networkPolicyFactory.gfs.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { type NetworkPolicyConfig, buildCoordinatorGfsNetworkPolicy, + buildRunLaneNetworkPolicyCatalog, buildWorkflowNetworkPolicies, } from './networkPolicyFactory' @@ -154,4 +155,60 @@ describe('workflow GFS NetworkPolicy', () => { }, }) }) + + it('run-lane catalog includes factory leftovers and never coordinator-to-gfs', () => { + const catalog = buildRunLaneNetworkPolicyCatalog({ + ...baseConfig, + includeCoordinatorGfs: true, + }) + expect(catalog.has('daily-report-mcp-host-to-grok-proxy')).toBe(true) + expect(catalog.has('daily-report-mcp-host-to-codex-proxy')).toBe(true) + expect(catalog.has('daily-report-wrc-to-artifact-reader')).toBe(true) + expect(catalog.has('daily-report-coordinator-to-gfs')).toBe(false) + }) + + it('forces unused lanes into the catalog so a pass-config prune would not go empty', () => { + const applyConfig: NetworkPolicyConfig = { + ...baseConfig, + includeCoordinator: true, + includeMcpHost: false, + includeCodexProxyEgress: false, + includeGrokProxyEgress: false, + includeArtifactReader: false, + includeSnippetRunner: false, + } + const desired = new Set( + buildWorkflowNetworkPolicies(applyConfig) + .map(policy => policy.metadata?.name) + .filter((name): name is string => Boolean(name)) + ) + const catalog = buildRunLaneNetworkPolicyCatalog(applyConfig) + expect(desired.has('daily-report-coord-to-wrc')).toBe(true) + expect(desired.has('daily-report-coord-to-mcp-host')).toBe(false) + expect(catalog.has('daily-report-coord-to-mcp-host')).toBe(true) + expect(catalog.has('daily-report-mcp-host-to-grok-proxy')).toBe(true) + expect(catalog.has('daily-report-coordinator-to-gfs')).toBe(false) + }) + + it('an empty server list keeps mcp-host-to-servers prunable and no per-server ingress name', () => { + const serverEgress = 'daily-report-mcp-host-to-servers' + const perServerIngress = 'daily-report-wf-mcp-ingress-web-search' + const desired = new Set( + buildWorkflowNetworkPolicies(baseConfig, []) + .map(policy => policy.metadata?.name) + .filter((name): name is string => Boolean(name)) + ) + const emptyCatalog = buildRunLaneNetworkPolicyCatalog(baseConfig, []) + // The server egress lane is not desired but stays in the prune universe, + // so its leftover is deleted once the recipe lists no MCP server. + expect(desired.has(serverEgress)).toBe(false) + expect(emptyCatalog.has(serverEgress)).toBe(true) + // A server's ingress policy is named after the server, so the catalog + // cannot name one the spec no longer lists: that leftover is not pruned. + expect(emptyCatalog.has(perServerIngress)).toBe(false) + // Liveness witness: the same name is in the catalog while the server is listed. + expect(buildRunLaneNetworkPolicyCatalog(baseConfig, ['web-search']).has(perServerIngress)).toBe( + true + ) + }) }) diff --git a/workflow-recipes/src/workflow/networkPolicyFactory.ts b/workflow-recipes/src/workflow/networkPolicyFactory.ts index 8e3895eda2..2c96b39fd2 100644 --- a/workflow-recipes/src/workflow/networkPolicyFactory.ts +++ b/workflow-recipes/src/workflow/networkPolicyFactory.ts @@ -1068,3 +1068,51 @@ export function buildWorkflowNetworkPolicies( ...pluginWorkloadSdkPolicies, ]) } + +const RUN_LANE_CATALOG_PLACEHOLDER_SERVER = '_catalog' + +/** + * Names the run-lane factory can emit for this recipe. Outer-owned + * `${recipe}-coordinator-to-gfs` is never included (`includeCoordinatorGfs: false`). + * Catalog membership — not a GFS-name keep-list — is the prune universe. + * + * Lane flags are forced on so leftovers of unused lanes stay in the universe + * when this pass's applyConfig turned them off. Building the catalog from the + * pass config alone makes catalog == desired and prune never deletes a leftover. + * Awaiting-trigger already tears down the mcp-host pod unless eager SDK; pruning + * those NPs is the same "mcp-host is not live" decision, not an accidental retire. + * + * An empty server list is replaced by the `_catalog` placeholder so + * `${recipe}-mcp-host-to-servers`, which the factory emits only for a + * non-empty list, stays in the universe and its leftover is pruned. The + * per-server `${recipe}-wf-mcp-ingress-` names come only from the + * servers passed in, so the ingress policy of a server the spec no longer + * lists is never in the catalog; it lives in the mcp-server namespace, which + * the prune does not list either, and stays until the finalizer's label sweep + * there. The mcp-host egress side is exact: `-mcp-host-to-servers` selects + * only the listed servers. + */ +export function buildRunLaneNetworkPolicyCatalog( + applyConfig: NetworkPolicyConfig, + mcpServerNames: string[] = [], + snippetMcpServerNames: string[] = [] +): Set { + const catalogConfig: NetworkPolicyConfig = { + ...applyConfig, + includeCoordinator: true, + includeMcpHost: true, + includeCodexProxyEgress: true, + includeGrokProxyEgress: true, + includeCoordinatorGfs: false, + includeArtifactReader: true, + includeSnippetRunner: true, + pluginWorkloadSdkSandboxAccess: true, + } + const serverNames = + mcpServerNames.length > 0 ? mcpServerNames : [RUN_LANE_CATALOG_PLACEHOLDER_SERVER] + return new Set( + buildWorkflowNetworkPolicies(catalogConfig, serverNames, snippetMcpServerNames) + .map(policy => policy.metadata?.name) + .filter((name): name is string => Boolean(name)) + ) +} diff --git a/workflow-recipes/src/workflow/pluginWorkloadSdkProvisioner.ts b/workflow-recipes/src/workflow/pluginWorkloadSdkProvisioner.ts index e574c969c6..f8e481edac 100644 --- a/workflow-recipes/src/workflow/pluginWorkloadSdkProvisioner.ts +++ b/workflow-recipes/src/workflow/pluginWorkloadSdkProvisioner.ts @@ -50,23 +50,50 @@ export type EagerSdkMcpHostStatus = | 'provider_unavailable' /** - * What one `applyWorkflowNetworkPolicies` pass could not converge. A conflict - * names a live policy another controller owns; WRC leaves it untouched. A - * pending retry means a policy was left unwritten for a later pass: it was - * terminating, or still contended after the bounded apply rounds. + * What one policy apply could not converge. A conflict names a live policy + * another controller owns; WRC leaves it untouched. A pending retry means a + * policy was left unwritten for a later pass: it was terminating, or still + * contended after the bounded apply rounds. An apply prunes nothing, so it + * says nothing about the prune or the legacy policy. */ export type WorkflowNetworkPolicyApplySummary = { conflicts: { policy: string; reason: NetworkPolicyConflictReason }[] retryPending: boolean } +/** + * What a prune pass established about the run-lane policies the spec no + * longer wants: `converged` when every DELETE it owed landed (2xx or 404), + * `pending` when one did not (a non-404 DELETE), and `unevaluated` when its + * LIST failed, so it could not tell which policies were owed a DELETE at all. + */ +export type NetworkPolicyPruneFact = 'converged' | 'pending' | 'unevaluated' + +/** + * What a pass established about the legacy + * `-mcp-servers-egress-internet` policy, deleted by name because it + * may carry no labels: `removed` when its DELETE landed (2xx or 404) or an + * earlier DELETE already did, `pending` when it did not. + */ +export type NetworkPolicyLegacyFact = 'removed' | 'pending' + +/** + * What one full `applyWorkflowNetworkPolicies` pass (apply, prune and legacy + * delete) established. Every field is required: an absent fact is never + * read as "carry the published one". + */ +export type NetworkPolicyPassSummary = WorkflowNetworkPolicyApplySummary & { + prune: NetworkPolicyPruneFact + legacy: NetworkPolicyLegacyFact +} + export type EagerSdkMcpHostResult = { status: EagerSdkMcpHostStatus /** * Undefined when the pass returned before applying the policies. An empty * summary would claim every policy converged and clear a published conflict. */ - networkPolicies?: WorkflowNetworkPolicyApplySummary + networkPolicies?: NetworkPolicyPassSummary } /** Why the mcp-host runtime JWT Secret was reminted. */ @@ -160,7 +187,7 @@ export type PluginWorkloadSdkProvisionerDeps = { codexProjection: CodexRecipeVerdict['projection'], eagerSdkMcpHost: boolean, grokProjection?: CodexRecipeVerdict['grokProjection'] - ) => Promise + ) => Promise ensureMcpHostHeadlessService: (recipeName: string) => Promise createIfNotExists: (createFn: () => Promise, label: string) => Promise safeDelete: (deleteFn: () => Promise) => Promise diff --git a/workflow-recipes/src/workflow/runtimePlan.ts b/workflow-recipes/src/workflow/runtimePlan.ts index 63f2f3d085..8cce99456c 100644 --- a/workflow-recipes/src/workflow/runtimePlan.ts +++ b/workflow-recipes/src/workflow/runtimePlan.ts @@ -370,6 +370,8 @@ export function deriveWorkflowRuntimePlan( : undefined, }, cleanup: { + // Awaiting-trigger tears down only coordinator + mcp-host. Snippet-runner + // is run-scoped and is not in this list (B3(d) does not skip it here). deleteBeforeTriggeredRun: [ 'workflow-coordinator', ...(needsMcpHost ? (['workflow-mcp-host'] as const) : []), diff --git a/workflow-recipes/src/workflow/workflowReconciler.codexScopeProvenance.test.ts b/workflow-recipes/src/workflow/workflowReconciler.codexScopeProvenance.test.ts index 5cf95ac094..7e77b9f527 100644 --- a/workflow-recipes/src/workflow/workflowReconciler.codexScopeProvenance.test.ts +++ b/workflow-recipes/src/workflow/workflowReconciler.codexScopeProvenance.test.ts @@ -24,6 +24,10 @@ const crashRecoveryMocks = vi.hoisted(() => ({ evaluateCrashRecovery: vi.fn().mockReturnValue({ action: 'none', message: 'Pod is healthy' }), getContainerWaitingReason: vi.fn().mockResolvedValue(undefined), getPodPhase: vi.fn().mockResolvedValue(undefined), + getPodPresence: vi.fn(async (api: unknown, name: string, namespace: string) => { + const phase = await crashRecoveryMocks.getPodPhase(api, name, namespace) + return phase === undefined ? { kind: 'absent' as const } : { kind: 'present' as const, phase } + }), getPodReadiness: vi.fn().mockResolvedValue({ ready: true, phase: 'Running', uid: 'pod-uid-1' }), isRecoverableContainerWaitingReason: vi.fn(() => false), })) @@ -381,6 +385,22 @@ describe('WorkflowReconciler Codex scope provenance', () => { }) runtimeTokenIssuerMocks.issueMcpHostRuntimeTokens.mockClear() crashRecoveryMocks.deletePodIfExists.mockClear() + // B2: withdraw is LIST membership of the leftover proxy, not a named DELETE + // of an absent object. Seed the leftover created on the first pass. + networkingApi.listNamespacedNetworkPolicy.mockResolvedValue({ + items: [ + { + metadata: { + name: CODEX_PROXY_POLICY, + namespace: sandboxNamespace, + labels: { + 'clerum.io/recipe': 'codex-recipe', + 'clerum.io/managed-by': 'wrc', + }, + }, + }, + ], + }) await reconcileRecipe( reconciler, @@ -400,6 +420,12 @@ describe('WorkflowReconciler Codex scope provenance', () => { expect( remintPatch.body?.metadata?.annotations?.['clerum.io/mcp-host-runtime-token-generation'] ).toBe('1') + expect(networkingApi.listNamespacedNetworkPolicy).toHaveBeenCalledWith( + expect.objectContaining({ + namespace: sandboxNamespace, + labelSelector: 'clerum.io/recipe=codex-recipe,clerum.io/managed-by=wrc', + }) + ) expect(networkingApi.deleteNamespacedNetworkPolicy).toHaveBeenCalledWith({ name: CODEX_PROXY_POLICY, namespace: sandboxNamespace, @@ -444,6 +470,21 @@ describe('WorkflowReconciler Codex scope provenance', () => { }) runtimeTokenIssuerMocks.issueMcpHostRuntimeTokens.mockClear() crashRecoveryMocks.deletePodIfExists.mockClear() + // B2: leftover proxy stays listed so withdraw prunes by membership, not by name. + networkingApi.listNamespacedNetworkPolicy.mockResolvedValue({ + items: [ + { + metadata: { + name: CODEX_PROXY_POLICY, + namespace: sandboxNamespace, + labels: { + 'clerum.io/recipe': 'codex-recipe', + 'clerum.io/managed-by': 'wrc', + }, + }, + }, + ], + }) await reconcileRecipe(reconciler, makeCodexSpec()) expect(issueMcpHostRuntimeTokens).toHaveBeenCalled() @@ -453,6 +494,12 @@ describe('WorkflowReconciler Codex scope provenance', () => { 'codex-recipe-mcp-host', sandboxNamespace ) + expect(networkingApi.listNamespacedNetworkPolicy).toHaveBeenCalledWith( + expect.objectContaining({ + namespace: sandboxNamespace, + labelSelector: 'clerum.io/recipe=codex-recipe,clerum.io/managed-by=wrc', + }) + ) expect(networkingApi.deleteNamespacedNetworkPolicy).toHaveBeenCalledWith({ name: CODEX_PROXY_POLICY, namespace: sandboxNamespace, diff --git a/workflow-recipes/src/workflow/workflowReconciler.codexUncertainScope.test.ts b/workflow-recipes/src/workflow/workflowReconciler.codexUncertainScope.test.ts index 8949f7c36b..9ea5cacbb2 100644 --- a/workflow-recipes/src/workflow/workflowReconciler.codexUncertainScope.test.ts +++ b/workflow-recipes/src/workflow/workflowReconciler.codexUncertainScope.test.ts @@ -16,6 +16,10 @@ const crashRecoveryMocks = vi.hoisted(() => ({ evaluateCrashRecovery: vi.fn().mockReturnValue({ action: 'none', message: 'Pod is healthy' }), getContainerWaitingReason: vi.fn().mockResolvedValue(undefined), getPodPhase: vi.fn().mockResolvedValue(undefined), + getPodPresence: vi.fn(async (api: unknown, name: string, namespace: string) => { + const phase = await crashRecoveryMocks.getPodPhase(api, name, namespace) + return phase === undefined ? { kind: 'absent' as const } : { kind: 'present' as const, phase } + }), getPodReadiness: vi.fn().mockResolvedValue({ ready: true, phase: 'Running', uid: 'pod-uid-1' }), isRecoverableContainerWaitingReason: vi.fn(() => false), })) diff --git a/workflow-recipes/src/workflow/workflowReconciler.grokWrcFlag.test.ts b/workflow-recipes/src/workflow/workflowReconciler.grokWrcFlag.test.ts index 8e02c55240..740674edfb 100644 --- a/workflow-recipes/src/workflow/workflowReconciler.grokWrcFlag.test.ts +++ b/workflow-recipes/src/workflow/workflowReconciler.grokWrcFlag.test.ts @@ -23,6 +23,10 @@ const crashRecoveryMocks = vi.hoisted(() => ({ evaluateCrashRecovery: vi.fn().mockReturnValue({ action: 'none', message: 'Pod is healthy' }), getContainerWaitingReason: vi.fn().mockResolvedValue(undefined), getPodPhase: vi.fn().mockResolvedValue(undefined), + getPodPresence: vi.fn(async (api: unknown, name: string, namespace: string) => { + const phase = await crashRecoveryMocks.getPodPhase(api, name, namespace) + return phase === undefined ? { kind: 'absent' as const } : { kind: 'present' as const, phase } + }), getPodReadiness: vi.fn().mockResolvedValue({ ready: true, phase: 'Running', uid: 'pod-uid-1' }), isRecoverableContainerWaitingReason: vi.fn(() => false), })) @@ -294,7 +298,15 @@ describe('WorkflowReconciler Grok WRC switch', () => { expect(runtimeTokenIssuerMocks.issueMcpHostRuntimeTokens).toHaveBeenCalled() expect(issuedScopes()).not.toContain('llm:grok:execute') expect(createdPolicyNames(networkingApi)).not.toContain(GROK_PROXY_POLICY) - expect(networkingApi.deleteNamespacedNetworkPolicy).toHaveBeenCalledWith( + // B2: withheld proxy is not in the desired apply set; LIST membership is + // empty, so there is no leftover DELETE of the named Grok policy. + expect(networkingApi.listNamespacedNetworkPolicy).toHaveBeenCalledWith( + expect.objectContaining({ + namespace: sandboxNamespace, + labelSelector: `clerum.io/recipe=${RECIPE},clerum.io/managed-by=wrc`, + }) + ) + expect(networkingApi.deleteNamespacedNetworkPolicy).not.toHaveBeenCalledWith( expect.objectContaining({ name: GROK_PROXY_POLICY }) ) const env = mcpHostEnvNames(coreApi) @@ -302,6 +314,30 @@ describe('WorkflowReconciler Grok WRC switch', () => { expect(env).not.toContain('GROK_LLM_PROXY_RUNTIME_URL') }) + it(`deletes a seeded leftover Grok proxy when the WRC flag is off for a ${shape} recipe`, async () => { + const { reconciler, networkingApi } = createHarness(false) + bindGrokGrant(reconciler) + networkingApi.listNamespacedNetworkPolicy.mockResolvedValue({ + items: [ + { + metadata: { + name: GROK_PROXY_POLICY, + namespace: sandboxNamespace, + labels: { 'clerum.io/recipe': RECIPE, 'clerum.io/managed-by': 'wrc' }, + }, + }, + ], + }) + + await reconcileRecipe(reconciler, spec()) + + expect(createdPolicyNames(networkingApi)).not.toContain(GROK_PROXY_POLICY) + expect(networkingApi.deleteNamespacedNetworkPolicy).toHaveBeenCalledWith({ + name: GROK_PROXY_POLICY, + namespace: sandboxNamespace, + }) + }) + it(`keeps Grok scope, proxy egress and pod env for a ${shape} recipe when the WRC flag is on`, async () => { const { reconciler, coreApi, networkingApi } = createHarness(true) bindGrokGrant(reconciler) diff --git a/workflow-recipes/src/workflow/workflowReconciler.test.ts b/workflow-recipes/src/workflow/workflowReconciler.test.ts index c64c120dd5..09577c19a6 100644 --- a/workflow-recipes/src/workflow/workflowReconciler.test.ts +++ b/workflow-recipes/src/workflow/workflowReconciler.test.ts @@ -1,7 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { buildMcpHostHeadlessService } from './podFactory' import { buildMcpHostServiceName } from './resourceNames' -import { WorkflowReconciler, type WorkflowReconcilerDeps } from './workflowReconciler' +import { + LEGACY_NETWORK_POLICY_DELETE_BACKOFF_BASE_MS, + LegacyNetworkPolicyDeletePendingError, + WorkflowReconciler, + type WorkflowReconcilerDeps, +} from './workflowReconciler' const crashRecoveryMocks = vi.hoisted(() => ({ deletePodIfExists: vi.fn().mockResolvedValue(undefined), @@ -16,6 +21,10 @@ const crashRecoveryMocks = vi.hoisted(() => ({ evaluateCrashRecovery: vi.fn().mockReturnValue({ action: 'none', message: 'Pod is healthy' }), getContainerWaitingReason: vi.fn().mockResolvedValue(undefined), getPodPhase: vi.fn().mockResolvedValue(undefined), + getPodPresence: vi.fn(async (api: unknown, name: string, namespace: string) => { + const phase = await crashRecoveryMocks.getPodPhase(api, name, namespace) + return phase === undefined ? { kind: 'absent' as const } : { kind: 'present' as const, phase } + }), getPodReadiness: vi.fn().mockResolvedValue({ ready: true, phase: 'Running', uid: 'pod-uid-1' }), isRecoverableContainerWaitingReason: vi.fn( (phase: string | undefined, reason: string | undefined) => @@ -1289,15 +1298,30 @@ describe('WorkflowReconciler.reconcileDelete — orphaned Service cleanup', () = for (const call of crashRecoveryMocks.deletePodIfExists.mock.calls) { expect(call[2]).toBe(sandboxNamespace) } + expect(crashRecoveryMocks.getPodPresence).not.toHaveBeenCalled() + }) + + it('is DELETE-first and 404-safe even when pods are already gone', async () => { + crashRecoveryMocks.deletePodIfExists.mockResolvedValue(undefined) + const reconciler = new WorkflowReconciler(deps) + + await reconciler.teardownComputePodsForTerminalRun('wf-run-7') + + // Witness: the teardown ran (three DELETEs) and none of them was + // preceded by a raw pod GET, which getPodPresence alone would not show. + expect(crashRecoveryMocks.deletePodIfExists).toHaveBeenCalledTimes(3) + expect(crashRecoveryMocks.getPodPresence).not.toHaveBeenCalled() + expect(mockCoreApi.readNamespacedPod).not.toHaveBeenCalled() }) - it('is idempotent: re-running after pods are gone is a no-op (404-safe)', async () => { + it('is idempotent: a second pass still DELETE-first (404-safe)', async () => { const reconciler = new WorkflowReconciler(deps) - // deletePodIfExists already swallows 404 internally; a second pass must not throw. await reconciler.teardownComputePodsForTerminalRun('wf-run-7') + crashRecoveryMocks.deletePodIfExists.mockClear() await expect( reconciler.teardownComputePodsForTerminalRun('wf-run-7') ).resolves.toBeUndefined() + expect(crashRecoveryMocks.deletePodIfExists).toHaveBeenCalledTimes(3) }) }) }) @@ -1411,6 +1435,9 @@ describe('WorkflowReconciler — Plugin Workload SDK eager mcp-host', () => { mockCoreApi.readNamespacedEndpoints.mockRejectedValue({ code: 404 }) mockNetworkingApi.readNamespacedNetworkPolicy.mockRejectedValue({ code: 404 }) mockNetworkingApi.listNamespacedNetworkPolicy.mockResolvedValue({ items: [] }) + // clearAllMocks keeps implementations, so a DELETE failure one test + // injects must not leak into the next teardown. + mockNetworkingApi.deleteNamespacedNetworkPolicy.mockResolvedValue({}) mockModelConfigHandler.configurePluginWorkloadSdkBootstrap.mockResolvedValue({ status: 202, body: sdkBootstrapProof(), @@ -1708,9 +1735,12 @@ describe('WorkflowReconciler — Plugin Workload SDK eager mcp-host', () => { // The conflict did not stop the rest of the pass. expect(createdNames()).toContain('sdk-only-workload-to-mcp-host-sdk-egress') expect(result.phase).toBe('active') + // The eager apply prunes too, so its summary carries the prune and legacy facts. expect(result.networkPolicies).toEqual({ conflicts: [{ policy: foreignName, reason: 'owner-reference-mismatch' }], retryPending: false, + prune: 'converged', + legacy: 'removed', }) expect(writesTo(foreignName)).toHaveLength(0) }) @@ -1740,6 +1770,8 @@ describe('WorkflowReconciler — Plugin Workload SDK eager mcp-host', () => { networkPolicies: { conflicts: [{ policy: foreignName, reason: 'owner-reference-mismatch' }], retryPending: true, + prune: 'converged', + legacy: 'removed', }, }) @@ -1756,6 +1788,8 @@ describe('WorkflowReconciler — Plugin Workload SDK eager mcp-host', () => { expect(result.networkPolicies).toEqual({ conflicts: [{ policy: foreignName, reason: 'owner-reference-mismatch' }], retryPending: true, + prune: 'converged', + legacy: 'removed', }) } ) @@ -1869,6 +1903,8 @@ describe('WorkflowReconciler — Plugin Workload SDK eager mcp-host', () => { networkPolicies: { conflicts: [{ policy: foreignName, reason: 'owner-reference-mismatch' }], retryPending: false, + prune: 'converged', + legacy: 'removed', }, }) @@ -1906,7 +1942,12 @@ describe('WorkflowReconciler — Plugin Workload SDK eager mcp-host', () => { ).pluginWorkloadSdkProvisioner const ensure = vi.spyOn(provisioner, 'ensureEagerSdkMcpHost').mockResolvedValue({ status: 'failed', - networkPolicies: { conflicts: [], retryPending: true }, + networkPolicies: { + conflicts: [], + retryPending: true, + prune: 'converged', + legacy: 'removed', + }, }) const result = await reconciler.reconcile( @@ -1947,6 +1988,8 @@ describe('WorkflowReconciler — Plugin Workload SDK eager mcp-host', () => { networkPolicies: { conflicts: [{ policy: foreignName, reason: 'owner-reference-mismatch' }], retryPending: true, + prune: 'converged', + legacy: 'removed', }, }) @@ -2000,6 +2043,8 @@ describe('WorkflowReconciler — Plugin Workload SDK eager mcp-host', () => { networkPolicies: { conflicts: [{ policy: foreignName, reason: 'owner-reference-mismatch' }], retryPending: false, + prune: 'converged', + legacy: 'removed', }, }) @@ -2104,6 +2149,262 @@ describe('WorkflowReconciler — Plugin Workload SDK eager mcp-host', () => { expect(mockCoreApi.createNamespacedPod).not.toHaveBeenCalled() }) + // R4-L3: the SDK-only teardown is what makes the `unmanaged` marker writer + // true. The legacy mcp-servers internet policy may carry no labels, so the + // label sweep cannot be trusted to remove it; the teardown deletes it by name. + it('R4-L3: deletes the legacy internet policy by name in the SDK-only teardown, next to the label sweep', async () => { + const reconciler = new WorkflowReconciler(makeDeps()) + + await expect(reconciler.cleanupPluginWorkloadSdk('sdk-only')).resolves.toBeUndefined() + + // Liveness witness: the label sweep ran in both namespaces. + expect(mockNetworkingApi.listNamespacedNetworkPolicy).toHaveBeenCalledWith({ + namespace: sandboxNamespace, + labelSelector: 'clerum.io/recipe=sdk-only,clerum.io/managed-by=wrc', + }) + expect(mockNetworkingApi.listNamespacedNetworkPolicy).toHaveBeenCalledWith({ + namespace: mcpServerNamespace, + labelSelector: 'clerum.io/recipe=sdk-only,clerum.io/managed-by=wrc', + }) + expect(mockNetworkingApi.deleteNamespacedNetworkPolicy).toHaveBeenCalledWith({ + name: 'sdk-only-mcp-servers-egress-internet', + namespace: mcpServerNamespace, + }) + }) + + it('fails live SDK cleanup with the remaining shared legacy-delete window', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + mockNetworkingApi.deleteNamespacedNetworkPolicy.mockImplementation( + async ({ name }: { name: string }) => { + if (name === 'sdk-only-mcp-servers-egress-internet') { + throw { code: 403, message: 'forbidden' } + } + return {} + } + ) + const reconciler = new WorkflowReconciler(makeDeps()) + + try { + const pending = await reconciler + .cleanupPluginWorkloadSdk('sdk-only', { recipeUid: 'uid-sdk-only' }) + .catch(error => error) + + expect(pending).toBeInstanceOf(LegacyNetworkPolicyDeletePendingError) + expect((pending as LegacyNetworkPolicyDeletePendingError).retryAfterMs).toBe( + LEGACY_NETWORK_POLICY_DELETE_BACKOFF_BASE_MS + ) + // Positive API witness: the shared legacy path attempted the DELETE; + // the typed error describes the live cleanup's remaining backoff. + expect( + mockNetworkingApi.deleteNamespacedNetworkPolicy.mock.calls.filter( + ([arg]) => arg.name === 'sdk-only-mcp-servers-egress-internet' + ) + ).toHaveLength(1) + } finally { + vi.useRealTimers() + } + }) + + // A-1: a pending legacy mcp-servers internet DELETE must not hold the SDK + // host pod and token Secrets in place. The legacy policy is unrelated to the + // SDK credentials, so the pod and Secret deletes run on every pass while the + // cleanup still fails closed with the legacy-pending error. + it('A-1: deletes the SDK host pod and token Secrets while the legacy policy DELETE is pending', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + mockNetworkingApi.deleteNamespacedNetworkPolicy.mockImplementation( + async ({ name }: { name: string }) => { + if (name === 'sdk-only-mcp-servers-egress-internet') { + throw { code: 403, message: 'forbidden' } + } + return {} + } + ) + const reconciler = new WorkflowReconciler(makeDeps()) + const policyDeletes = (name: string) => + mockNetworkingApi.deleteNamespacedNetworkPolicy.mock.calls.filter( + ([arg]) => arg.name === name + ) + + try { + const first = await reconciler + .cleanupPluginWorkloadSdk('sdk-only', { recipeUid: 'uid-sdk-only' }) + .catch(error => error) + const second = await reconciler + .cleanupPluginWorkloadSdk('sdk-only', { recipeUid: 'uid-sdk-only' }) + .catch(error => error) + + expect(first).toBeInstanceOf(LegacyNetworkPolicyDeletePendingError) + expect(second).toBeInstanceOf(LegacyNetworkPolicyDeletePendingError) + // Liveness witness: both passes ran the SDK network-policy teardown. + expect(policyDeletes('sdk-only-workload-to-mcp-host-sdk-ingress')).toHaveLength(2) + // The first pass attempted the legacy DELETE; the second is inside the + // backoff window and sends no request. + expect(policyDeletes('sdk-only-mcp-servers-egress-internet')).toHaveLength(1) + expect(mockCoreApi.deleteNamespacedPod).toHaveBeenCalledTimes(2) + expect(mockCoreApi.deleteNamespacedPod).toHaveBeenCalledWith({ + name: 'sdk-only-mcp-host', + namespace: sandboxNamespace, + propagationPolicy: 'Background', + }) + expect(mockCoreApi.deleteNamespacedSecret).toHaveBeenCalledTimes(4) + expect(mockCoreApi.deleteNamespacedSecret).toHaveBeenCalledWith({ + name: 'wf-sdk-only-plugin-workload-sdk-token', + namespace: sandboxNamespace, + }) + expect(mockCoreApi.deleteNamespacedSecret).toHaveBeenCalledWith({ + name: 'wf-sdk-only-mcp-host-runtime-tokens', + namespace: sandboxNamespace, + }) + } finally { + vi.useRealTimers() + } + }) + + it('R4-L3: keeps the legacy internet policy in a hybrid teardown, which preserves the workflow runtime', async () => { + const reconciler = new WorkflowReconciler(makeDeps()) + + await expect( + reconciler.cleanupPluginWorkloadSdk('hybrid', { preserveWorkflowRuntime: true }) + ).resolves.toBeUndefined() + + // Liveness witness: the SDK-owned policies were deleted. + expect(mockNetworkingApi.deleteNamespacedNetworkPolicy).toHaveBeenCalledWith({ + name: 'hybrid-workload-to-mcp-host-sdk-ingress', + namespace: sandboxNamespace, + }) + expect(mockNetworkingApi.deleteNamespacedNetworkPolicy).not.toHaveBeenCalledWith({ + name: 'hybrid-mcp-servers-egress-internet', + namespace: mcpServerNamespace, + }) + }) + + it('uses recipeUid as live identity and preserves the shared legacy gone-set', async () => { + const reconciler = new WorkflowReconciler(makeDeps()) + const legacyDeletes = () => + mockNetworkingApi.deleteNamespacedNetworkPolicy.mock.calls.filter( + ([arg]) => arg.name === 'sdk-only-mcp-servers-egress-internet' + ) + + await expect( + reconciler.retryLegacyMcpServersInternetEgressDelete('sdk-only', 'uid-sdk-only') + ).resolves.toBe('removed') + expect(legacyDeletes()).toHaveLength(1) + + await reconciler.cleanupPluginWorkloadSdk('sdk-only', { recipeUid: 'uid-sdk-only' }) + expect(legacyDeletes()).toHaveLength(1) + + // This old-UID retry is a direct ledger probe. It does not pretend that + // Kubernetes recreated an object with the old UID. + await expect( + reconciler.retryLegacyMcpServersInternetEgressDelete('sdk-only', 'uid-sdk-only') + ).resolves.toBe('removed') + expect(legacyDeletes()).toHaveLength(1) + }) + + it('gives finalizer cleanup one last chance and forgets only after it completes', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + const reconciler = new WorkflowReconciler(makeDeps()) + const legacyDeletes = () => + mockNetworkingApi.deleteNamespacedNetworkPolicy.mock.calls.filter( + ([arg]) => arg.name === 'sdk-only-mcp-servers-egress-internet' + ) + mockNetworkingApi.deleteNamespacedNetworkPolicy.mockImplementation( + async ({ name }: { name: string }) => { + if (name === 'sdk-only-mcp-servers-egress-internet') { + throw { code: 403, message: 'forbidden' } + } + return {} + } + ) + + try { + await expect( + reconciler.retryLegacyMcpServersInternetEgressDelete('sdk-only', 'uid-sdk-only') + ).resolves.toBe('pending') + expect(legacyDeletes()).toHaveLength(1) + + // Finalizer cleanup must attempt the DELETE even inside the live window. + const pending = await reconciler + .cleanupPluginWorkloadSdk('sdk-only', { + recipeUid: 'uid-sdk-only', + recipeDeleted: true, + }) + .catch(error => error) + expect(pending).toBeInstanceOf(LegacyNetworkPolicyDeletePendingError) + expect(legacyDeletes()).toHaveLength(2) + + // A normal old-UID probe is still inside the shared backoff. This direct + // ledger probe does not model a Kubernetes object recreation. + await expect( + reconciler.retryLegacyMcpServersInternetEgressDelete('sdk-only', 'uid-sdk-only') + ).resolves.toBe('pending') + expect(legacyDeletes()).toHaveLength(2) + + mockNetworkingApi.deleteNamespacedNetworkPolicy.mockImplementation(async () => ({})) + await reconciler.cleanupPluginWorkloadSdk('sdk-only', { + recipeUid: 'uid-sdk-only', + recipeDeleted: true, + }) + expect(legacyDeletes()).toHaveLength(3) + + // The successful, completed finalizer cleanup forgot the old entry. The + // old-UID probe is again a direct process-local ledger witness. + await expect( + reconciler.retryLegacyMcpServersInternetEgressDelete('sdk-only', 'uid-sdk-only') + ).resolves.toBe('removed') + expect(legacyDeletes()).toHaveLength(4) + } finally { + vi.useRealTimers() + } + }) + + it('does not forget the finalizer ledger entry when a later cleanup phase fails', async () => { + const lateError = new Error('late finalize failed') + const revocationId = 'late-finalize-revocation' + const deps = makeDeps() as unknown as { + pluginWorkloadSdkRevocationClient?: unknown + } + const revocationClient = { + revoke: vi.fn().mockResolvedValue({ + state: 'revoking', + revocationId, + revoked: 1, + fencedInvocations: 1, + }), + finalize: vi.fn().mockRejectedValue(lateError), + } + deps.pluginWorkloadSdkRevocationClient = revocationClient + const reconciler = new WorkflowReconciler(deps as WorkflowReconcilerDeps) + const legacyDeletes = () => + mockNetworkingApi.deleteNamespacedNetworkPolicy.mock.calls.filter( + ([arg]) => arg.name === 'sdk-only-mcp-servers-egress-internet' + ) + + await expect( + reconciler.cleanupPluginWorkloadSdk('sdk-only', { + recipeUid: 'uid-sdk-only', + recipeDeleted: true, + }) + ).rejects.toBe(lateError) + + // Positive witnesses: the central legacy DELETE succeeded, and the later + // revocation-finalization phase—not an earlier network failure—rejected. + expect(legacyDeletes()).toHaveLength(1) + expect(revocationClient.finalize).toHaveBeenCalledWith( + sandboxNamespace, + 'sdk-only', + revocationId + ) + + // The old-UID retry is a direct process-local ledger probe, not a + // Kubernetes recreation. It must hit the recorded gone-set, not send a + // second DELETE after only partial cleanup completion. + await expect( + reconciler.retryLegacyMcpServersInternetEgressDelete('sdk-only', 'uid-sdk-only') + ).resolves.toBe('removed') + expect(legacyDeletes()).toHaveLength(1) + }) + it('defers absence reads until pod deletion completes so 404s cannot become unhandled rejections', async () => { const events: string[] = [] crashRecoveryMocks.waitForPodDeletion.mockImplementationOnce(async () => { diff --git a/workflow-recipes/src/workflow/workflowReconciler.ts b/workflow-recipes/src/workflow/workflowReconciler.ts index 0ca2a594cb..bee490e01e 100644 --- a/workflow-recipes/src/workflow/workflowReconciler.ts +++ b/workflow-recipes/src/workflow/workflowReconciler.ts @@ -25,6 +25,11 @@ import { import { mintRecipeHostGfsToken } from '../gfsBinding' import { createLogger } from '../observability/logger' import { CRD_GROUP, CRD_VERSION, WORKFLOWRECIPE_PLURAL } from '../reconciler/crdConstants' +import { + deleteOutcomeFields, + observeNamespacedDelete, + shouldRecordDelete, +} from '../reconciler/deleteOutcome' import { ResourceVanishedAfterConflictError, RetryableReconcileError, @@ -64,12 +69,14 @@ import { projectCodexRecipeVerdict, } from './codexRecipeVerdict' import { + type PodPresence, type PodReadiness, deletePodIfExists, evaluateCompletedRuntimePodRecovery, evaluateCrashRecovery, getContainerWaitingReason, getPodPhase, + getPodPresence, getPodReadiness, isRecoverableContainerWaitingReason, waitForPodDeletion, @@ -89,13 +96,20 @@ import { issueMcpHostWorkflowControlToken, } from './mcpHostRuntimeTokenIssuerClient' import type { ModelConfigHandler } from './modelConfigHandler' -import { NetworkPolicyConfig, buildWorkflowNetworkPolicies } from './networkPolicyFactory' +import { + NetworkPolicyConfig, + buildRunLaneNetworkPolicyCatalog, + buildWorkflowNetworkPolicies, +} from './networkPolicyFactory' import { ObjectStorageClient, StorageCredentials, StorageRef } from './objectStorageClient' import { type EagerSdkBootstrapProof, type McpHostRuntimeTokenRefreshReason, type McpHostRuntimeTokenRefreshResult, NO_MCP_HOST_RUNTIME_TOKEN_REFRESH, + type NetworkPolicyLegacyFact, + type NetworkPolicyPassSummary, + type NetworkPolicyPruneFact, PluginWorkloadSdkProvisioner, type WorkflowNetworkPolicyApplySummary, } from './pluginWorkloadSdkProvisioner' @@ -220,6 +234,14 @@ type RunLaneNetworkPolicyApplyResult = reason: 'terminating' | 'contended' } | { policy: string; action: 'conflict'; reason: NetworkPolicyConflictReason } +/** + * Which Codex/Grok proxy policy survives a prune that does not desire it: a + * proxy whose verdict is uncertain. The skip log names it `reason: 'uncertain'`. + */ +interface RunLaneProxyKeepReasons { + codex: boolean + grok: boolean +} // Read-then-write rounds per NetworkPolicy and pass. Three rounds is at most // three writes, the same as the create plus two replaces it replaced. const NETWORK_POLICY_APPLY_ROUNDS = 3 @@ -243,20 +265,160 @@ export interface PluginWorkloadSdkCleanupOptions { * remains the owner of shared runtime resources. */ preserveWorkflowRuntime?: boolean + /** + * Identity for the live legacy-delete ledger. It shares the gone-set and + * backoff with `reconcile()` and the WRC retry; it never erases either. + */ + recipeUid?: string + /** + * True only while the recipe itself is being deleted. A completed cleanup + * may then drop the process-local legacy ledger entry; a pending cleanup + * must retain it so the finalizer retry resumes from the same window. + */ + recipeDeleted?: boolean +} + +export class LegacyNetworkPolicyDeletePendingError extends Error { + constructor( + recipeName: string, + readonly retryAfterMs: number + ) { + super(`Legacy mcp-servers internet egress NetworkPolicy delete is pending for ${recipeName}`) + this.name = 'LegacyNetworkPolicyDeletePendingError' + } +} + +function buildLegacyMcpServersInternetEgressPolicyName(recipeName: string): string { + return `${recipeName}-mcp-servers-egress-internet` } export const NETWORK_POLICY_OWNERSHIP_CONDITION_TYPE = 'WorkflowNetworkPolicyOwnership' /** - * Published only as `False/RetryPending` while a run-lane NetworkPolicy is - * left pending a retry (terminating or contended), and removed once the - * policies converge. The WRC short-circuits of a running workflow never reach - * `reconcile()`, so this marker is what tells them to apply the policies again. + * Published only as `False` while the run-lane policies have not converged, + * and removed once they have. It carries two independent facts (see + * `networkPolicyMarkerFacts`): + * + * - apply (`converged | pending`): `pending` when a desired policy was left + * unwritten (terminating or contended). The pass requeues, and the WRC + * short-circuits of a running workflow, which never reach `reconcile()`, + * apply the policies again while it is set. + * - prune (`converged | pending | unevaluated`, see `NetworkPolicyPruneFact`): + * `pending` when a DELETE the prune owed did not land, `unevaluated` when + * its LIST failed. No requeue for either, since nothing but `reconcile()` + * prunes. Only a later `reconcile()` prune that lands, the `unmanaged` + * writer after a full SDK teardown, or the finalizer clears it; every other + * path carries it over (see `networkPolicyMarkerConditions`). + * + * Each combination with a fact that is not converged has its own reason + * (`RetryPending`, `PrunePending`, `PruneUnevaluated`, `RetryAndPrunePending`, + * `RetryAndPruneUnevaluated`), and current facts are read back from the reason. + * The frozen historical combined marker is normalized by the decoder below. */ export const NETWORK_POLICIES_CONVERGED_CONDITION_TYPE = 'WorkflowNetworkPoliciesConverged' -const NETWORK_POLICY_RETRY_PENDING_MESSAGE = - 'One or more run-lane NetworkPolicies are pending a retry (terminating or contended)' +/** The two facts the WorkflowNetworkPoliciesConverged marker carries. */ +export interface NetworkPolicyMarkerFacts { + apply: 'converged' | 'pending' + prune: NetworkPolicyPruneFact +} + +const CONVERGED_NETWORK_POLICY_MARKER_FACTS = { + apply: 'converged', + prune: 'converged', +} as const satisfies NetworkPolicyMarkerFacts + +/** + * One current reason per combination of facts. Current messages are prose for + * operators. The decoder separately recognizes the exact historical combined + * marker emitted before those facts had distinct reasons. + */ +const NETWORK_POLICY_MARKER_REASONS = { + RetryPending: { + facts: { apply: 'pending', prune: 'converged' }, + message: 'One or more run-lane NetworkPolicies are pending a retry (terminating or contended)', + }, + PrunePending: { + facts: { apply: 'converged', prune: 'pending' }, + message: + 'One or more run-lane NetworkPolicies the spec no longer wants are pending a delete; the next reconcile() pass or the finalizer removes them', + }, + PruneUnevaluated: { + facts: { apply: 'converged', prune: 'unevaluated' }, + message: + 'The run-lane NetworkPolicies the spec no longer wants could not be listed, so none was pruned; the next reconcile() pass or the finalizer removes them', + }, + RetryAndPrunePending: { + facts: { apply: 'pending', prune: 'pending' }, + message: + 'One or more run-lane NetworkPolicies are pending a retry (terminating or contended), and one or more the spec no longer wants are pending a delete', + }, + RetryAndPruneUnevaluated: { + facts: { apply: 'pending', prune: 'unevaluated' }, + message: + 'One or more run-lane NetworkPolicies are pending a retry (terminating or contended), and the ones the spec no longer wants could not be listed for a prune', + }, +} as const satisfies Record + +/** + * The only historical message this controller interprets. Before every fact + * had its own reason, `af0152924` encoded apply-plus-prune as `RetryPending` + * with exactly this prose. Equality is deliberate: substring or reworded + * messages do not gain a prune fact. + */ +const HISTORICAL_RETRY_AND_PRUNE_PENDING_MESSAGE = + 'One or more run-lane NetworkPolicies are pending a retry (terminating or contended), and one or more the spec no longer wants are pending a delete' + +type NetworkPolicyMarkerReason = keyof typeof NETWORK_POLICY_MARKER_REASONS + +function isNetworkPolicyMarkerReason( + reason: string | undefined +): reason is NetworkPolicyMarkerReason { + return reason !== undefined && Object.hasOwn(NETWORK_POLICY_MARKER_REASONS, reason) +} + +/** The reason for `facts`, or `undefined` when both are converged. */ +function networkPolicyMarkerReason( + facts: NetworkPolicyMarkerFacts +): NetworkPolicyMarkerReason | undefined { + const retry = facts.apply === 'pending' + switch (facts.prune) { + case 'converged': + return retry ? 'RetryPending' : undefined + case 'pending': + return retry ? 'RetryAndPrunePending' : 'PrunePending' + case 'unevaluated': + return retry ? 'RetryAndPruneUnevaluated' : 'PruneUnevaluated' + default: { + const unreachable: never = facts.prune + throw new Error(`Unknown NetworkPolicy prune fact: ${String(unreachable)}`) + } + } +} + +/** + * Published only as `False`/`DeletePending` while the legacy + * `-mcp-servers-egress-internet` policy is pending a delete (see + * `NetworkPolicyLegacyFact`), and removed once it is gone. The fact is read + * back from the reason alone. + */ +export const LEGACY_NETWORK_POLICY_REMOVED_CONDITION_TYPE = 'WorkflowLegacyNetworkPolicyRemoved' + +const LEGACY_NETWORK_POLICY_REASONS = { + DeletePending: { + fact: 'pending', + message: + 'The legacy mcp-servers internet egress NetworkPolicy is pending a delete; a later pass or the finalizer removes it', + }, +} as const satisfies Record + +type LegacyNetworkPolicyReason = keyof typeof LEGACY_NETWORK_POLICY_REASONS + +function isLegacyNetworkPolicyReason( + reason: string | undefined +): reason is LegacyNetworkPolicyReason { + return reason !== undefined && Object.hasOwn(LEGACY_NETWORK_POLICY_REASONS, reason) +} /** * Merged by its own group in `patchStatus`, apart from the workflow-output @@ -266,6 +428,7 @@ const NETWORK_POLICY_RETRY_PENDING_MESSAGE = export const NETWORK_POLICY_OWNERSHIP_CONDITION_TYPES = new Set([ NETWORK_POLICY_OWNERSHIP_CONDITION_TYPE, NETWORK_POLICIES_CONVERGED_CONDITION_TYPE, + LEGACY_NETWORK_POLICY_REMOVED_CONDITION_TYPE, ]) /** @@ -287,27 +450,91 @@ export function networkPolicyConditionsChanged( return signal(existing ?? []) !== signal(fresh) } -/** True when the published status still carries the retry marker. */ +/** + * True when the published marker says an apply is pending a retry. A marker + * that carries only a pending prune does not count: the retry prunes nothing, + * so reapplying for it would do no work. + */ export function hasNetworkPolicyRetryPendingMarker( conditions: StatusCondition[] | undefined ): boolean { - return (conditions ?? []).some( + return networkPolicyMarkerFacts(conditions).apply === 'pending' +} + +/** + * True while the published group says the legacy mcp-servers internet policy + * is pending a delete (`WorkflowLegacyNetworkPolicyRemoved=False/DeletePending`). + * Only this reason enters the `legacy-retry` writer; a reason this controller + * did not write is never interpreted. + */ +export function hasLegacyNetworkPolicyDeletePendingMarker( + conditions: StatusCondition[] | undefined +): boolean { + const published = publishedMarker( + conditions, + LEGACY_NETWORK_POLICY_REMOVED_CONDITION_TYPE, + isLegacyNetworkPolicyReason + ) + return ( + published.kind === 'known' && + LEGACY_NETWORK_POLICY_REASONS[published.condition.reason as LegacyNetworkPolicyReason].fact === + 'pending' + ) +} + +/** + * Backoff between two DELETEs of the legacy mcp-servers internet policy for + * one recipe after a DELETE that did not land: 60 s, doubling up to 1 h. A + * transient 403 does not leave a false DeletePending for hours, a sustained + * one costs one DELETE per hour per recipe, and a converged recipe costs none. + */ +export const LEGACY_NETWORK_POLICY_DELETE_BACKOFF_BASE_MS = 60_000 +export const LEGACY_NETWORK_POLICY_DELETE_BACKOFF_MAX_MS = 3_600_000 + +/** + * Reads the facts a published marker carries. This and + * `buildNetworkPolicyConvergedCondition` are the only places that encode what + * the marker means. A `False` marker with any other reason was not written by + * this controller and reads as converged facts. + */ +export function networkPolicyMarkerFacts( + conditions: StatusCondition[] | undefined +): NetworkPolicyMarkerFacts { + const marker = (conditions ?? []).find( c => c.type === NETWORK_POLICIES_CONVERGED_CONDITION_TYPE && c.status === 'False' ) + if (!isNetworkPolicyMarkerReason(marker?.reason)) { + return { ...CONVERGED_NETWORK_POLICY_MARKER_FACTS } + } + if ( + marker.reason === 'RetryPending' && + marker.message === HISTORICAL_RETRY_AND_PRUNE_PENDING_MESSAGE + ) { + return { apply: 'pending', prune: 'pending' } + } + return { ...NETWORK_POLICY_MARKER_REASONS[marker.reason].facts } } -function buildNetworkPolicyRetryPendingCondition( +/** + * The marker for `facts`, or `undefined` when both are converged (the marker + * is then removed). `lastTransitionTime` follows `status`: a marker that stays + * `False` keeps the time it was first published, whichever fact it now names. + */ +export function buildNetworkPolicyConvergedCondition( + facts: NetworkPolicyMarkerFacts, now: string, existingConditions?: StatusCondition[] -): StatusCondition { +): StatusCondition | undefined { + const reason = networkPolicyMarkerReason(facts) + if (reason === undefined) return undefined const existing = existingConditions?.find( c => c.type === NETWORK_POLICIES_CONVERGED_CONDITION_TYPE && c.status === 'False' ) return { type: NETWORK_POLICIES_CONVERGED_CONDITION_TYPE, status: 'False', - reason: 'RetryPending', - message: NETWORK_POLICY_RETRY_PENDING_MESSAGE, + reason, + message: NETWORK_POLICY_MARKER_REASONS[reason].message, lastTransitionTime: existing?.lastTransitionTime ?? now, } } @@ -354,26 +581,229 @@ export function buildNetworkPolicyOwnershipConditions( } /** - * The status fields one policy apply pass contributes, shared by the run lane - * and the SDK-only lane so both translate a summary the same way. No summary - * (the pass returned before the apply) yields neither field, which keeps the - * published conditions. A summary always yields the group's conditions, `[]` - * included: the ownership condition when a policy conflicts, and the retry - * marker plus the retry flag when a policy was left pending a retry. + * The legacy marker for `fact`, or `undefined` once the policy is removed. + * `lastTransitionTime` is kept while the marker stays `False`. + */ +function buildLegacyNetworkPolicyCondition( + fact: NetworkPolicyLegacyFact, + now: string, + existingConditions?: StatusCondition[] +): StatusCondition | undefined { + switch (fact) { + case 'removed': + return undefined + case 'pending': { + const existing = existingConditions?.find( + c => c.type === LEGACY_NETWORK_POLICY_REMOVED_CONDITION_TYPE && c.status === 'False' + ) + return { + type: LEGACY_NETWORK_POLICY_REMOVED_CONDITION_TYPE, + status: 'False', + reason: 'DeletePending', + message: LEGACY_NETWORK_POLICY_REASONS.DeletePending.message, + lastTransitionTime: existing?.lastTransitionTime ?? now, + } + } + default: { + const unreachable: never = fact + throw new Error(`Unknown legacy NetworkPolicy fact: ${String(unreachable)}`) + } + } +} + +/** + * Who writes the NetworkPolicy condition group, and so which facts it + * observed (Table A on `retryRunLaneNetworkPolicies`): + * + * - `reconcile`: a full pass (run lane, eager lane, SDK-only lane with a + * summary). Observes the apply, the prune and the legacy delete. + * - `apply-retry`: the mid-run retry. Observes the apply; carries the prune + * and legacy facts. + * - `legacy-retry`: the legacy delete the WRC retries before its + * short-circuits while `DeletePending` is published. Observes the legacy + * fact; carries the apply and prune facts and the ownership condition. + * - `terminal`: the run ended, so a pending apply is dropped; carries the + * prune and legacy facts and the ownership condition. + * - `unmanaged`: the kill switch, the SDK-only lane without a runtime and + * the capability removal. Written only after a teardown that deleted every + * SDK policy, the legacy one by name included, so it clears the group. + */ +export type NetworkPolicyMarkerWriter = + | { kind: 'reconcile'; summary: NetworkPolicyPassSummary } + | { kind: 'apply-retry'; summary: WorkflowNetworkPolicyApplySummary } + | { kind: 'legacy-retry'; legacy: NetworkPolicyLegacyFact } + | { kind: 'terminal' } + | { kind: 'unmanaged' } + +/** + * A marker a carrying writer found published. `unknown` is a condition of a + * marker type whose status or reason this controller did not write: it is + * never interpreted, only kept as it is. */ -export function translateNetworkPolicyApplySummary( - summary: WorkflowNetworkPolicyApplySummary | undefined, +type PublishedMarker = + | { kind: 'absent' } + | { kind: 'known'; condition: StatusCondition } + | { kind: 'unknown'; condition: StatusCondition } + +function publishedMarker( + existingConditions: StatusCondition[] | undefined, + type: string, + isKnownReason: (reason: string | undefined) => boolean +): PublishedMarker { + const condition = (existingConditions ?? []).find(c => c.type === type) + if (condition === undefined) return { kind: 'absent' } + if (condition.status === 'False' && isKnownReason(condition.reason)) { + return { kind: 'known', condition } + } + return { kind: 'unknown', condition } +} + +function warnUnknownNetworkPolicyMarker( + writer: NetworkPolicyMarkerWriter['kind'], + condition: StatusCondition +): void { + createLogger('wrc', 'network-policy-marker').warn( + 'Keeping a NetworkPolicy marker with a reason this controller did not write', + { writer, type: condition.type, status: condition.status, reason: condition.reason } + ) +} + +/** The converged marker a carrying writer publishes for its observed apply. */ +function carriedConvergedMarker( + writer: NetworkPolicyMarkerWriter['kind'], + apply: NetworkPolicyMarkerFacts['apply'], + existingConditions: StatusCondition[] | undefined, + now: string +): StatusCondition | undefined { + const published = publishedMarker( + existingConditions, + NETWORK_POLICIES_CONVERGED_CONDITION_TYPE, + isNetworkPolicyMarkerReason + ) + if (published.kind === 'unknown') { + warnUnknownNetworkPolicyMarker(writer, published.condition) + return published.condition + } + const { prune } = networkPolicyMarkerFacts(existingConditions) + return buildNetworkPolicyConvergedCondition({ apply, prune }, now, existingConditions) +} + +/** The legacy marker a carrying writer publishes: the published one, as is. */ +function carriedLegacyMarker( + writer: NetworkPolicyMarkerWriter['kind'], + existingConditions: StatusCondition[] | undefined +): StatusCondition | undefined { + const published = publishedMarker( + existingConditions, + LEGACY_NETWORK_POLICY_REMOVED_CONDITION_TYPE, + isLegacyNetworkPolicyReason + ) + switch (published.kind) { + case 'absent': + return undefined + case 'known': + return published.condition + case 'unknown': + warnUnknownNetworkPolicyMarker(writer, published.condition) + return published.condition + default: { + const unreachable: never = published + throw new Error(`Unknown published marker: ${String(unreachable)}`) + } + } +} + +function presentConditions(conditions: (StatusCondition | undefined)[]): StatusCondition[] { + return conditions.filter((c): c is StatusCondition => c !== undefined) +} + +/** + * The whole NetworkPolicy condition group `writer` publishes: the ownership + * condition, the converged marker and the legacy marker, each only while it + * is `False`. `[]` removes the group. Every writer goes through here, so each + * fact is observed, carried or cleared exactly as Table A says. + */ +export function networkPolicyMarkerConditions( + writer: NetworkPolicyMarkerWriter, + existingConditions: StatusCondition[] | undefined, + now: string +): StatusCondition[] { + switch (writer.kind) { + case 'reconcile': { + const { summary } = writer + return presentConditions([ + ...buildNetworkPolicyOwnershipConditions(summary, now, existingConditions), + buildNetworkPolicyConvergedCondition( + { apply: summary.retryPending ? 'pending' : 'converged', prune: summary.prune }, + now, + existingConditions + ), + buildLegacyNetworkPolicyCondition(summary.legacy, now, existingConditions), + ]) + } + case 'apply-retry': { + const { summary } = writer + return presentConditions([ + ...buildNetworkPolicyOwnershipConditions(summary, now, existingConditions), + carriedConvergedMarker( + writer.kind, + summary.retryPending ? 'pending' : 'converged', + existingConditions, + now + ), + carriedLegacyMarker(writer.kind, existingConditions), + ]) + } + case 'legacy-retry': + return presentConditions([ + ...(existingConditions ?? []).filter( + c => c.type === NETWORK_POLICY_OWNERSHIP_CONDITION_TYPE + ), + carriedConvergedMarker( + writer.kind, + networkPolicyMarkerFacts(existingConditions).apply, + existingConditions, + now + ), + buildLegacyNetworkPolicyCondition(writer.legacy, now, existingConditions), + ]) + case 'terminal': + return presentConditions([ + ...(existingConditions ?? []).filter( + c => c.type === NETWORK_POLICY_OWNERSHIP_CONDITION_TYPE + ), + carriedConvergedMarker(writer.kind, 'converged', existingConditions, now), + carriedLegacyMarker(writer.kind, existingConditions), + ]) + case 'unmanaged': + return [] + default: { + const unreachable: never = writer + throw new Error(`Unknown NetworkPolicy marker writer: ${String(unreachable)}`) + } + } +} + +/** + * The status fields one full pass contributes, shared by the run lane, the + * eager lane and the SDK-only lane so all three translate a summary the same + * way (the `reconcile` writer). No summary (the pass returned before the + * apply) yields neither field, which keeps the published conditions. A + * summary always yields the whole group, `[]` included. Only a pending retry + * sets the retry flag (and with it a requeue). + */ +export function translateNetworkPolicyPassSummary( + summary: NetworkPolicyPassSummary | undefined, existingConditions: StatusCondition[] | undefined, now: string ): Pick { if (summary === undefined) return {} return { - networkPolicyOwnershipConditions: [ - ...buildNetworkPolicyOwnershipConditions(summary, now, existingConditions), - ...(summary.retryPending - ? [buildNetworkPolicyRetryPendingCondition(now, existingConditions)] - : []), - ], + networkPolicyOwnershipConditions: networkPolicyMarkerConditions( + { kind: 'reconcile', summary }, + existingConditions, + now + ), ...(summary.retryPending ? { networkPolicyRetryPending: true } : {}), } } @@ -896,10 +1326,11 @@ export interface WorkflowReconcileResult { clearWorkflowExecution?: boolean workflowConditions?: StatusCondition[] /** - * The `WorkflowNetworkPolicyOwnership` condition from this pass's policy - * apply. Undefined when the pass returned before the apply, which keeps the - * published condition; `[]` when no policy is owned by another controller (a - * pending retry does not count as a conflict), which removes it. + * The NetworkPolicy condition group this pass's policy apply produced + * (`networkPolicyMarkerConditions`): the ownership condition and the + * converged and legacy markers. Undefined when the pass returned before the + * apply, which keeps the published group; `[]` when every fact converged + * and no policy is owned by another controller, which removes it. */ networkPolicyOwnershipConditions?: StatusCondition[] /** @@ -1120,6 +1551,16 @@ export class WorkflowReconciler { snapshot: CodexCatalogSnapshot } = { snapshot: { flagEnabled: false } } private readonly codexContexts = new Map() + /** B3(c): one process-lifetime DELETE of the leftover mcp-servers internet NP. Keyed by recipe uid. */ + private readonly prunedLegacyMcpServersInternetEgress = new Set() + /** + * R4-L4: the backoff after a legacy DELETE that did not land, keyed like the + * gone set. `notBeforeMs` is the earliest time the next DELETE may be sent. + */ + private readonly legacyMcpServersInternetEgressBackoff = new Map< + string, + { delayMs: number; notBeforeMs: number } + >() constructor(private readonly deps: WorkflowReconcilerDeps) { this.pluginWorkloadSdkProvisioner = new PluginWorkloadSdkProvisioner({ @@ -1357,7 +1798,7 @@ export class WorkflowReconciler { message: string pluginWorkloadSdkBootstrapProof?: EagerSdkBootstrapProof /** Undefined when the eager host returned before applying the policies. */ - networkPolicies?: WorkflowNetworkPolicyApplySummary + networkPolicies?: NetworkPolicyPassSummary }> { if (!this.deps.config.pluginWorkloadSdkEnabled || !spec.pluginWorkloadSdk) { // Nothing is applied here, so no summary: an empty one would read as @@ -1527,13 +1968,15 @@ export class WorkflowReconciler { this.teardownDelete(() => this.deleteNetworkPoliciesByLabelSelector( ns, - `clerum.io/recipe=${recipeName},clerum.io/managed-by=wrc` + `clerum.io/recipe=${recipeName},clerum.io/managed-by=wrc`, + buildLegacyMcpServersInternetEgressPolicyName(recipeName) ) ), this.teardownDelete(() => this.deleteNetworkPoliciesByLabelSelector( this.deps.config.mcpServerNamespace, - `clerum.io/recipe=${recipeName},clerum.io/managed-by=wrc` + `clerum.io/recipe=${recipeName},clerum.io/managed-by=wrc`, + buildLegacyMcpServersInternetEgressPolicyName(recipeName) ) ), ]), @@ -1568,6 +2011,18 @@ export class WorkflowReconciler { ), ]), ]) + if (!preserveWorkflowRuntime) { + // The legacy internet policy may carry no labels, while a labeled copy + // is excluded from the sweeps above. Both forms go through the one live + // ledger so a completed cleanup can prove `unmanaged`. It runs after the + // host pod and token Secrets are gone: a pending legacy DELETE must not + // hold the SDK credentials in place. + await this.cleanupLegacyMcpServersInternetEgress( + recipeName, + options.recipeUid, + options.recipeDeleted === true + ) + } await this.assertPluginWorkloadSdkResourcesAbsent(recipeName, ns, networkPolicyNames, { preserveWorkflowRuntime, }) @@ -1590,6 +2045,9 @@ export class WorkflowReconciler { ) } } + if (options.recipeDeleted === true) { + this.forgetLegacyMcpServersInternetEgress(recipeName, options.recipeUid) + } } private async assertPluginWorkloadSdkResourcesAbsent( @@ -1752,7 +2210,7 @@ export class WorkflowReconciler { // Empty until this pass applies the policies, then what the apply found. // A return before the apply leaves it empty, and patchStatus keeps the // published condition. - let networkPolicyStatus: ReturnType = {} + let networkPolicyStatus: ReturnType = {} const withWorkflowConditions = (result: WorkflowReconcileResult): WorkflowReconcileResult => ({ ...result, workflowConditions, @@ -1776,11 +2234,12 @@ export class WorkflowReconciler { try { // Check crash recovery for existing Pods - let coordPhase = await getPodPhase( + const coordPresence = await getPodPresence( this.deps.coreApi, `${recipeName}-coordinator`, this.deps.config.sandboxNamespace ) + let coordPhase = coordPresence.kind === 'present' ? coordPresence.phase : undefined const coordWaitingReason = await getContainerWaitingReason( this.deps.coreApi, `${recipeName}-coordinator`, @@ -1800,13 +2259,14 @@ export class WorkflowReconciler { this.deps.config.sandboxNamespace ) : undefined - let mcpHostPhase = needsMcpHost - ? await getPodPhase( + const mcpHostPresence: PodPresence = needsMcpHost + ? await getPodPresence( this.deps.coreApi, `${recipeName}-mcp-host`, this.deps.config.sandboxNamespace ) - : undefined + : { kind: 'unread' } + let mcpHostPhase = mcpHostPresence.kind === 'present' ? mcpHostPresence.phase : undefined let artifactReaderPhase = needsArtifactReader ? await getPodPhase( this.deps.coreApi, @@ -1858,11 +2318,26 @@ export class WorkflowReconciler { } if (awaitsTriggeredRun) { + const samePassPresence: Partial> = { + 'workflow-coordinator': coordPresence, + ...(mcpHostPresence.kind !== 'unread' ? { 'workflow-mcp-host': mcpHostPresence } : {}), + } for (const component of runtime.cleanup.deleteBeforeTriggeredRun) { // Plugin Workload SDK: keep the mcp-host alive across the // awaiting-trigger window — it hosts the always-on SDK server. // Only the coordinator (and run-scoped pods) are torn down here. if (eagerMcpHostForSdk && component === 'workflow-mcp-host') continue + // B3(d): skip DELETE only when the same-pass GET was a 404. A live + // pod with an empty status.phase is present and must still be deleted. + const presence = samePassPresence[component] + if (presence?.kind === 'absent') { + log.debug('Skipping runtime component DELETE; same-pass GET was absent', { + recipe: recipeName, + component, + presence: presence.kind, + }) + continue + } await this.deleteRuntimeComponentIfExists(recipeName, component) } @@ -1900,7 +2375,7 @@ export class WorkflowReconciler { // No summary means the host returned before the apply, so the pass // cannot say whether a conflict is gone. A `failed` pod after the // apply still carries a real summary and publishes it. - networkPolicyStatus = translateNetworkPolicyApplySummary( + networkPolicyStatus = translateNetworkPolicyPassSummary( eagerNetworkPolicies, currentStatus?.conditions, new Date().toISOString() @@ -2365,7 +2840,7 @@ export class WorkflowReconciler { false, codexVerdict.grokProjection ) - networkPolicyStatus = translateNetworkPolicyApplySummary( + networkPolicyStatus = translateNetworkPolicyPassSummary( runLaneNetworkPolicies, currentStatus?.conditions, new Date().toISOString() @@ -2899,8 +3374,10 @@ export class WorkflowReconciler { * so dropping mcp-host/coordinator/snippet-runner here frees CPU/RAM without * losing any artifacts. * - * Idempotent: deleting an already-gone pod is a 404 no-op (deletePodIfExists). - * Deliberately EXCLUDES `workflow-artifact-reader`. + * DELETE-first and 404-safe (`deletePodIfExists`). This path is outside B3 + * (E.4(e), §7.16): do not add a GET just to skip the DELETE. The archive + * TTL still owns leftover objects. Deliberately EXCLUDES + * `workflow-artifact-reader`. * * The component list is an explicit literal (not derived from * `runtime.cleanup.*`) on purpose: the "artifact-reader is preserved" @@ -2948,9 +3425,95 @@ export class WorkflowReconciler { * Reapply the run-lane NetworkPolicies outside reconcile(). The running and * active short-circuits return before reconcile(), so a policy a previous * pass left pending (terminating or contended) would otherwise stay as it is - * until the run ends. Builds the same set reconcile() applies and only - * applies it: no codex, grok or legacy prune, because those follow an - * eligibility verdict that can change while a run is in progress. + * until the run ends. Builds the desired set with a verdict computed now and + * applies it, but deletes nothing: the run's pods are live and may still use + * any lane the spec wanted when they were created, so a revoke here cuts + * their traffic. That includes the legacy mcp-servers internet policy, which + * this path keeps. The summary is apply-only, so this path + * is the `apply-retry` writer of Table A below: it observes the apply and + * carries the published prune and legacy facts. + * + * What each path does to a run-lane policy (verdict = Codex/Grok + * eligibility; it governs only the proxy lanes, every other catalog lane + * follows the spec alone; legacy = `-mcp-servers-egress-internet`): + * + * | path | proxy required | proxy uncertain | proxy retired | other lane, not desired | legacy | + * | reconcile() pass | applied | kept if live | pruned | pruned | deleted | + * | mid-run retry (this) | applied | kept if live | kept | kept | kept | + * | legacy retry | untouched | untouched | untouched | untouched | deleted, backoff | + * | terminal teardown | kept | kept | kept | kept | kept | + * | finalizer | deleted | deleted | deleted | deleted | deleted | + * + * Table A, what each writer publishes (`networkPolicyMarkerConditions`). + * The facts are A (apply: `converged | pending`), P (prune: `converged | + * pending | unevaluated`, `unevaluated` being a failed LIST) and L (legacy: + * `removed | pending`), read back from the condition reasons alone: + * + * | A | P | `WorkflowNetworkPoliciesConverged` | requeue | + * | converged | converged | absent | none | + * | pending | converged | False/`RetryPending` | the retry's | + * | converged | pending | False/`PrunePending` | none | + * | converged | unevaluated | False/`PruneUnevaluated` | none | + * | pending | pending | False/`RetryAndPrunePending` | as RetryPending | + * | pending | unevaluated | False/`RetryAndPruneUnevaluated` | as RetryPending | + * + * | L | `WorkflowLegacyNetworkPolicyRemoved` | + * | removed | absent | + * | pending | False/`DeletePending` | + * + * | writer | A | P | L | clears | + * | `reconcile` (run lane, eager, SDK with a summary) | observed | observed | observed | what converged | + * | SDK with a runtime and no summary | not written | not written | not written | nothing | + * | `apply-retry` (this, in-progress and active) | observed | carried | carried | A only | + * | `legacy-retry` (WRC, before the short-circuits) | carried | carried | observed, backoff | L only | + * | `terminal` | forced converged | carried | carried | A | + * | `unmanaged` (kill switch, SDK lane without a runtime, capability removal) | converged | converged | removed | everything | + * | finalizer | the object is deleted | | | | + * + * The legacy retry runs in the WRC before limit validation, every + * short-circuit and the early token reap, only while `DeletePending` is + * published. It, the reconcile() pass and a full SDK cleanup share one + * backoff per recipe: after a DELETE that did + * not land, the next one waits `min(prev * 2 || 60 s, 1 h)`, and inside + * that window the delete answers `pending` without a request. A `removed` + * answer clears L with a conditions-only patch; `pending` writes nothing. + * The watcher's legacy-only callback requeues after the rest of the window; + * it does not force a full reconcile. A cleanup that must remove the legacy + * policy but observes `pending` fails before `unmanaged` and requeues the + * full cleanup at the same window. Finalizer cleanup is the last chance: + * it sends through the same ledger even inside a window, then records and + * forgets state only after the complete cleanup succeeds. + * + * A carrying writer keeps a marker whose reason it did not write as it is + * and warns; `reconcile` overwrites it. `unmanaged` is written only after + * `cleanupPluginWorkloadSdk` without `preserveWorkflowRuntime` succeeded: + * its label sweeps exclude the legacy name, and the shared live ledger + * deletes that name once; a failure or backoff fails the pass before the + * writer runs. A pending or + * unevaluated prune is not retried outside reconcile(): only reconcile() + * can compute the desired set, and the finalizer's label sweep removes + * every run-lane leftover. On the finalizer, a non-404 DELETE is logged by + * `safeDelete` and not rethrown, so the finalizer completes. + * + * Historical `af0152924` markers are read only from their reason, except + * the one exact `RetryPending` combined message defined beside the decoder. + * Its ambiguous `PrunePending` cannot be safely promoted to L: current + * ordinary prune failures use the same reason, so they require a real + * reconcile() observation before the legacy retry can run. + * + * A desired lane is applied on both apply paths; an uncertain verdict never + * creates a proxy. Terminal teardown deletes a run-scoped run's compute pods + * and its cross-namespace transport workloads, and, for every terminal + * recipe, revokes `-coordinator-to-gfs` unless the run completed and + * that policy may open; that policy is outside the run-lane catalog. It + * deletes no run-lane policy. The finalizer deletes a fixed list of run-lane + * names and sweeps by the recipe labels in the sandbox and mcp-server + * namespaces; the legacy policy predates those labels and is deleted by + * name in the mcp-server namespace. + * + * reconcile(), and so its prune, also runs while the run's pods are live: + * the WRC falls through to it for a run in `initializing` or `recovering`, + * and for any in-progress run while transport network readiness is pending. */ async retryRunLaneNetworkPolicies( recipeName: string, @@ -2975,7 +3538,7 @@ export class WorkflowReconciler { runtimeScopeRecipeName, codexView ) - const policies = await this.buildWorkflowNetworkPoliciesForSpec( + const { policies } = await this.buildWorkflowNetworkPoliciesForSpec( recipeName, recipeUid, spec, @@ -3174,13 +3737,115 @@ export class WorkflowReconciler { } } - private async pruneLegacyMcpServersInternetEgressPolicy(recipeName: string): Promise { - await this.safeDelete(() => + private legacyMcpServersInternetEgressKey(recipeName: string, recipeUid?: string): string { + return recipeUid && recipeUid.length > 0 ? recipeUid : recipeName + } + + private forgetLegacyMcpServersInternetEgress(recipeName: string, recipeUid?: string): void { + this.prunedLegacyMcpServersInternetEgress.delete(recipeName) + this.legacyMcpServersInternetEgressBackoff.delete(recipeName) + if (recipeUid) { + this.prunedLegacyMcpServersInternetEgress.delete(recipeUid) + this.legacyMcpServersInternetEgressBackoff.delete(recipeUid) + } + } + + private async cleanupLegacyMcpServersInternetEgress( + recipeName: string, + recipeUid?: string, + lastChance = false + ): Promise { + const fact = await this.pruneLegacyMcpServersInternetEgressPolicy( + recipeName, + recipeUid, + lastChance + ) + if (fact === 'pending') { + throw new LegacyNetworkPolicyDeletePendingError( + recipeName, + Math.max(1, this.legacyMcpServersInternetEgressRetryDelayMs(recipeName, recipeUid ?? '')) + ) + } + } + + /** + * R4-L4: the legacy delete the WRC retries before its short-circuits while + * `DeletePending` is published (the `legacy-retry` writer). The same delete + * and backoff as the reconcile() pass: inside the backoff window it answers + * `pending` without a request. + */ + async retryLegacyMcpServersInternetEgressDelete( + recipeName: string, + recipeUid: string + ): Promise { + return this.pruneLegacyMcpServersInternetEgressPolicy(recipeName, recipeUid) + } + + /** + * R4-L4: how long until the next legacy DELETE may be sent for this recipe; + * 0 when no backoff is running. The WRC requeues a pass that leaves + * `DeletePending` published after this delay. + */ + legacyMcpServersInternetEgressRetryDelayMs(recipeName: string, recipeUid: string): number { + const backoff = this.legacyMcpServersInternetEgressBackoff.get( + this.legacyMcpServersInternetEgressKey(recipeName, recipeUid) + ) + if (backoff === undefined) return 0 + return Math.max(0, backoff.notBeforeMs - Date.now()) + } + + private async pruneLegacyMcpServersInternetEgressPolicy( + recipeName: string, + recipeUid?: string, + lastChance = false + ): Promise { + const key = this.legacyMcpServersInternetEgressKey(recipeName, recipeUid) + if (this.prunedLegacyMcpServersInternetEgress.has(key)) { + this.log.debug('Skipping legacy mcp-servers internet NP delete; already observed gone', { + recipe: recipeName, + }) + return 'removed' + } + const backoff = this.legacyMcpServersInternetEgressBackoff.get(key) + if (backoff !== undefined && !lastChance && Date.now() < backoff.notBeforeMs) { + this.log.debug('Skipping legacy mcp-servers internet NP delete; inside its backoff window', { + recipe: recipeName, + retryInMs: backoff.notBeforeMs - Date.now(), + }) + return 'pending' + } + const outcome = await observeNamespacedDelete(() => this.deps.networkingApi.deleteNamespacedNetworkPolicy({ - name: `${recipeName}-mcp-servers-egress-internet`, + name: buildLegacyMcpServersInternetEgressPolicyName(recipeName), namespace: this.deps.config.mcpServerNamespace, }) ) + if (outcome.kind === 'failed') { + const delayMs = Math.min( + backoff === undefined ? LEGACY_NETWORK_POLICY_DELETE_BACKOFF_BASE_MS : backoff.delayMs * 2, + LEGACY_NETWORK_POLICY_DELETE_BACKOFF_MAX_MS + ) + this.legacyMcpServersInternetEgressBackoff.set(key, { + delayMs, + notBeforeMs: Date.now() + delayMs, + }) + this.log.error('Legacy mcp-servers internet NP delete failed', { + recipe: recipeName, + ...deleteOutcomeFields(outcome), + retryInMs: delayMs, + err: outcome.error, + }) + return 'pending' + } + this.legacyMcpServersInternetEgressBackoff.delete(key) + this.log.info('Legacy mcp-servers internet NP delete', { + recipe: recipeName, + ...deleteOutcomeFields(outcome), + }) + if (shouldRecordDelete(outcome)) { + this.prunedLegacyMcpServersInternetEgress.add(key) + } + return 'removed' } private needsRuntimeHttpEgressRefresh(spec: WorkflowRecipeSpec): boolean { @@ -3215,7 +3880,7 @@ export class WorkflowReconciler { eligibility: 'ineligible', reason: 'static_only', } - ): Promise { + ): Promise<{ policies: k8s.V1NetworkPolicy[]; catalog: Set }> { const runtimeHttpEgressPolicyNames = this.runtimeHttpEgressPolicyNames(recipeName, spec) const runtimeHttpEgressState = runtimeHttpEgressPolicyNames.length > 0 && runtimeHttpEgressClass(spec) === 'exact-host' @@ -3322,7 +3987,12 @@ export class WorkflowReconciler { runtimeHttpEgressState.annotations ) } - return policies + const catalog = buildRunLaneNetworkPolicyCatalog( + npConfig, + mcpServerFullNames, + snippetMcpServerFullNames + ) + return { policies, catalog } } /** @@ -3341,8 +4011,8 @@ export class WorkflowReconciler { codexProjection: CodexExecutionProjection, eagerSdkMcpHost = false, grokProjection?: CodexExecutionProjection & { requiresGrokProxyEgress?: boolean } - ): Promise { - const policies = await this.buildWorkflowNetworkPoliciesForSpec( + ): Promise { + const { policies, catalog } = await this.buildWorkflowNetworkPoliciesForSpec( recipeName, recipeUid, spec, @@ -3352,29 +4022,119 @@ export class WorkflowReconciler { eagerSdkMcpHost, grokProjection ) - const summary = await this.applyNetworkPolicyList(policies) + return this.applyAndPruneRunLaneNetworkPolicies(recipeName, recipeUid, policies, catalog, { + codex: codexProjection.eligibility === 'uncertain', + grok: grokProjection?.eligibility === 'uncertain', + }) + } + + private async applyAndPruneRunLaneNetworkPolicies( + recipeName: string, + recipeUid: string, + policies: k8s.V1NetworkPolicy[], + catalog: Set, + keepProxies: RunLaneProxyKeepReasons + ): Promise { + const applied = await this.applyNetworkPolicyList(policies) const policyNames = new Set(policies.map(policy => policy.metadata?.name)) + const prune = await this.pruneUndesiredRunLaneNetworkPolicies( + recipeName, + policyNames, + catalog, + keepProxies + ) + const legacy = await this.pruneLegacyMcpServersInternetEgressPolicy(recipeName, recipeUid) + return { ...applied, prune, legacy } + } + + /** + * Drop run-lane NetworkPolicies that exist and are no longer desired. + * Two-term selector only (`managed-by=wrc`): a single-term recipe selector + * would also return the recipes-lane policies in the same namespace. + * Universe is the factory catalog (GFS never in it), not LIST \ desired. + * A Codex/Grok proxy whose verdict is uncertain survives even when it is + * absent from this pass's desired set. A listed policy with an + * ownerReference is skipped, matching the apply's ownership veto. Only + * reconcile() prunes; the mid-run retry never calls this (see + * retryRunLaneNetworkPolicies for the per-path table). A failed LIST + * evaluated nothing and answers `unevaluated`; a DELETE that did not land + * answers `pending`. + */ + private async pruneUndesiredRunLaneNetworkPolicies( + recipeName: string, + desiredNames: Set, + catalog: Set, + keepProxies: RunLaneProxyKeepReasons + ): Promise { + const namespace = this.deps.config.sandboxNamespace + const labelSelector = `clerum.io/recipe=${recipeName},clerum.io/managed-by=wrc` + let list: { items?: k8s.V1NetworkPolicy[] } + try { + list = await this.deps.networkingApi.listNamespacedNetworkPolicy({ + namespace, + labelSelector, + }) + } catch (error: unknown) { + this.log.error('Run-lane NP prune LIST failed; apply outcome is kept', { + recipe: recipeName, + err: error, + }) + return 'unevaluated' + } const codexProxyPolicyName = `${recipeName}-mcp-host-to-codex-proxy` const grokProxyPolicyName = `${recipeName}-mcp-host-to-grok-proxy` - - if (!policyNames.has(codexProxyPolicyName) && codexProjection.eligibility !== 'uncertain') { - await this.safeDelete(() => - this.deps.networkingApi.deleteNamespacedNetworkPolicy({ - name: codexProxyPolicyName, - namespace: this.deps.config.sandboxNamespace, + let deletePending = false + for (const policy of list.items ?? []) { + const name = policy.metadata?.name + if (!name || desiredNames.has(name)) continue + const keep = + name === codexProxyPolicyName + ? keepProxies.codex + : name === grokProxyPolicyName + ? keepProxies.grok + : false + if (keep) { + this.log.debug('Skipping run-lane NP prune', { + recipe: recipeName, + policy: name, + reason: 'uncertain', }) - ) - } - if (!policyNames.has(grokProxyPolicyName) && grokProjection?.eligibility !== 'uncertain') { - await this.safeDelete(() => - this.deps.networkingApi.deleteNamespacedNetworkPolicy({ - name: grokProxyPolicyName, - namespace: this.deps.config.sandboxNamespace, + continue + } + if (!catalog.has(name)) { + this.log.debug('Skipping run-lane NP prune', { + recipe: recipeName, + policy: name, + reason: 'not-in-catalog', }) + continue + } + // Same ownership veto the run-lane apply uses: a policy under our name + // that carries an ownerReference belongs to someone else, and the apply + // already refuses to replace it. + const ownership = classifyOwnerlessNetworkPolicyOwnership(policy) + if (ownership.kind !== 'owned') { + this.log.warn('Skipping run-lane NP prune', { + recipe: recipeName, + policy: name, + reason: ownership.kind === 'conflict' ? ownership.reason : ownership.kind, + }) + continue + } + const outcome = await observeNamespacedDelete(() => + this.deps.networkingApi.deleteNamespacedNetworkPolicy({ name, namespace }) ) + if (outcome.kind === 'failed') { + this.log.error('Run-lane NP prune delete failed', { + recipe: recipeName, + policy: name, + ...deleteOutcomeFields(outcome), + err: outcome.error, + }) + deletePending = true + } } - await this.pruneLegacyMcpServersInternetEgressPolicy(recipeName) - return summary + return deletePending ? 'pending' : 'converged' } /** @@ -3638,12 +4398,14 @@ export class WorkflowReconciler { async reconcileDelete( recipeName: string, recipeNamespace?: string, - spec?: WorkflowRecipeSpec + spec?: WorkflowRecipeSpec, + recipeUid?: string ): Promise { const ns = this.deps.config.sandboxNamespace const log = createLogger('wrc', recipeName) log.info(`Deleting workflow resources`) this.pluginWorkloadSdkProvisioner.clearRecipeState(recipeName) + this.forgetLegacyMcpServersInternetEgress(recipeName, recipeUid) // Best-effort cleanup of the recipe's subdirectory on the workflow output PVC. // Must run BEFORE pod deletion while mcp-host is still reachable; failures @@ -3758,8 +4520,9 @@ export class WorkflowReconciler { // NP-02: wf-mcp-host-ingress lives in mcpServerNamespace, not sandboxNamespace. // NP-03: now one NP per mcp-server (named `wf-mcp-ingress-${mcpServerName}`). // List-by-label is the cleanest cleanup — all managed NPs have recipe label. - const mcpServerNpNames: string[] = [] // legacy single-NP name no longer used; - // per-mcp-server NPs are deleted via label selector below. + // Per-mcp-server NPs are deleted via label selector below. The legacy + // internet policy predates those labels, so it is deleted by name (R4-L4). + const mcpServerNpNames: string[] = [buildLegacyMcpServersInternetEgressPolicyName(recipeName)] const mcpNs = this.deps.config.mcpServerNamespace const cleanupTasks: Array<{ label: string; run: () => Promise }> = [ @@ -3978,7 +4741,8 @@ export class WorkflowReconciler { private async deleteNetworkPoliciesByLabelSelector( namespace: string, - labelSelector: string + labelSelector: string, + excludeName?: string ): Promise { const list = await this.deps.networkingApi.listNamespacedNetworkPolicy({ namespace, @@ -3988,6 +4752,7 @@ export class WorkflowReconciler { (list.items ?? []) .map(policy => policy.metadata?.name) .filter((name): name is string => Boolean(name)) + .filter(name => name !== excludeName) .map(async name => { try { await this.deps.networkingApi.deleteNamespacedNetworkPolicy({ name, namespace }) diff --git a/workflow-recipes/tests/unit/workflow/workflowReconciler.test.ts b/workflow-recipes/tests/unit/workflow/workflowReconciler.test.ts index fcc659b32d..bf5228fe1a 100644 --- a/workflow-recipes/tests/unit/workflow/workflowReconciler.test.ts +++ b/workflow-recipes/tests/unit/workflow/workflowReconciler.test.ts @@ -8,7 +8,9 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as k8s from '@kubernetes/client-node' +import type { CodexExecutionProjection } from '@clerum/codex-catalog-projection' import { mintRecipeHostGfsToken } from '../../../src/gfsBinding' +import { captureLogger } from '../../../src/reconciler/__tests__/captureLogger' import { resolveStatefulSetHeadlessServiceName, resolveWorkloadMcpServerLabel, @@ -18,6 +20,8 @@ import { issueMcpHostRuntimeTokens, issueMcpHostWorkflowControlToken, } from '../../../src/workflow/mcpHostRuntimeTokenIssuerClient' +import { buildWorkflowNetworkPolicies } from '../../../src/workflow/networkPolicyFactory' +import type { NetworkPolicyPassSummary } from '../../../src/workflow/pluginWorkloadSdkProvisioner' import { buildArtifactReaderHeadlessService, buildMcpHostHeadlessService, @@ -30,13 +34,20 @@ import { buildMcpHostServiceName, buildSnippetRunnerServiceName, } from '../../../src/workflow/resourceNames' +import { deriveWorkflowRuntimePlan } from '../../../src/workflow/runtimePlan' import { + LEGACY_NETWORK_POLICY_DELETE_BACKOFF_BASE_MS, + LegacyNetworkPolicyDeletePendingError, type WorkflowRecipeSpec, WorkflowReconciler, type WorkflowReconcilerDeps, + buildNetworkPolicyConvergedCondition, buildNetworkPolicyOwnershipConditions, + hasLegacyNetworkPolicyDeletePendingMarker, networkPolicyConditionsChanged, - translateNetworkPolicyApplySummary, + networkPolicyMarkerConditions, + networkPolicyMarkerFacts, + translateNetworkPolicyPassSummary, } from '../../../src/workflow/workflowReconciler' import { asApiserverNetworkPolicy } from './asApiserverNetworkPolicy' @@ -428,11 +439,35 @@ function makeApiserverNetworkingApi() { return {} } ), - listNamespacedNetworkPolicy: vi.fn().mockResolvedValue({ items: [] }), + listNamespacedNetworkPolicy: vi.fn( + async ({ namespace, labelSelector }: { namespace: string; labelSelector?: string }) => ({ + items: [...live.values()] + .filter( + policy => + (policy.metadata?.namespace ?? namespace) === namespace && + policyMatchesLabelSelector(policy.metadata?.labels, labelSelector) + ) + .map(policy => structuredClone(policy)), + }) + ), } return { api, live, key } } +function policyMatchesLabelSelector( + labels: Record | undefined, + labelSelector: string | undefined +): boolean { + if (!labelSelector) return true + const present = labels ?? {} + return labelSelector.split(',').every(term => { + const trimmed = term.trim() + const eq = trimmed.indexOf('=') + if (eq < 0) return false + return present[trimmed.slice(0, eq)] === trimmed.slice(eq + 1) + }) +} + function createdPolicyNames(networkingApi: ReturnType): string[] { return networkingApi.createNamespacedNetworkPolicy.mock.calls.map( ([arg]) => (arg as { body?: k8s.V1NetworkPolicy }).body?.metadata?.name ?? '' @@ -2985,6 +3020,942 @@ describe('WorkflowReconciler — reconcile loop', () => { ([arg]) => arg.body?.metadata?.name === 'test-wf-mcp-servers-egress-internet' ) ).toBe(false) + + networkingApi.deleteNamespacedNetworkPolicy.mockClear() + networkingApi.listNamespacedNetworkPolicy.mockClear() + const second = await reconciler.reconcile( + 'test-wf', + 'uid-123', + 'sandbox-recipes', + makeSpec({ mcpServers: ['redis-mcp'] }) + ) + expect(second.workflowPhase).not.toBe('failed') + expect(networkingApi.listNamespacedNetworkPolicy).toHaveBeenCalled() + expect(networkingApi.deleteNamespacedNetworkPolicy).not.toHaveBeenCalledWith({ + name: 'test-wf-mcp-servers-egress-internet', + namespace: 'mcp-server', + }) + }) + + // R4-L4: a 403 does not mark the policy gone; the next DELETE waits for the + // end of its backoff window (60 s after the first failure). + it('G3: does not skip the legacy internet NP after DELETE 403, and retries it once its backoff window ends', async () => { + const networkingApi = makeNetworkingApi() as ReturnType + networkingApi.deleteNamespacedNetworkPolicy.mockImplementation(async ({ name, namespace }) => { + if (name === 'test-wf-mcp-servers-egress-internet' && namespace === 'mcp-server') { + throw { code: 403 } + } + return {} + }) + const reconciler = new WorkflowReconciler( + makeDeps({ + networkingApi: networkingApi as never, + }) + ) + const legacyDeletes = () => + networkingApi.deleteNamespacedNetworkPolicy.mock.calls.filter( + ([arg]) => + arg.name === 'test-wf-mcp-servers-egress-internet' && arg.namespace === 'mcp-server' + ) + const t0 = new Date('2026-09-23T10:00:00.000Z').getTime() + vi.useFakeTimers({ toFake: ['Date'] }) + try { + vi.setSystemTime(t0) + const first = await reconciler.reconcile( + 'test-wf', + 'uid-123', + 'sandbox-recipes', + makeSpec({ mcpServers: ['redis-mcp'] }) + ) + expect(legacyDeletes()).toHaveLength(1) + expect( + (first.networkPolicyOwnershipConditions ?? []).filter( + c => c.type === 'WorkflowLegacyNetworkPolicyRemoved' + ) + ).toMatchObject([{ status: 'False', reason: 'DeletePending' }]) + + vi.setSystemTime(t0 + 30_000) + const inside = await reconciler.reconcile( + 'test-wf', + 'uid-123', + 'sandbox-recipes', + makeSpec({ mcpServers: ['redis-mcp'] }) + ) + // Liveness witness: the pass reached the prune (the legacy fact is + // published), and inside the window it sent no DELETE. + expect( + (inside.networkPolicyOwnershipConditions ?? []).filter( + c => c.type === 'WorkflowLegacyNetworkPolicyRemoved' + ) + ).toMatchObject([{ status: 'False', reason: 'DeletePending' }]) + expect(legacyDeletes()).toHaveLength(1) + expect(reconciler.legacyMcpServersInternetEgressRetryDelayMs('test-wf', 'uid-123')).toBe( + 30_000 + ) + + vi.setSystemTime(t0 + 60_000) + await reconciler.reconcile( + 'test-wf', + 'uid-123', + 'sandbox-recipes', + makeSpec({ mcpServers: ['redis-mcp'] }) + ) + expect(legacyDeletes()).toHaveLength(2) + // The second failure doubles the window. + expect(reconciler.legacyMcpServersInternetEgressRetryDelayMs('test-wf', 'uid-123')).toBe( + 120_000 + ) + } finally { + vi.useRealTimers() + } + }) + + it('R4-L4: doubles the legacy DELETE backoff up to 1 h, and a 404 ends it', async () => { + const LEGACY = 'test-wf-mcp-servers-egress-internet' + const networkingApi = makeNetworkingApi() as ReturnType + let legacyAnswer = { code: 403 } + networkingApi.deleteNamespacedNetworkPolicy.mockImplementation(async ({ name, namespace }) => { + if (name === LEGACY && namespace === 'mcp-server') throw legacyAnswer + return {} + }) + const reconciler = new WorkflowReconciler( + makeDeps({ + networkingApi: networkingApi as never, + }) + ) + const legacyDeletes = () => + networkingApi.deleteNamespacedNetworkPolicy.mock.calls.filter( + ([arg]) => arg.name === LEGACY && arg.namespace === 'mcp-server' + ) + vi.useFakeTimers({ toFake: ['Date'] }) + try { + let now = new Date('2026-09-23T10:00:00.000Z').getTime() + const windows: number[] = [] + for (let attempt = 0; attempt < 8; attempt++) { + vi.setSystemTime(now) + expect( + await reconciler.retryLegacyMcpServersInternetEgressDelete('test-wf', 'uid-123') + ).toBe('pending') + const delay = reconciler.legacyMcpServersInternetEgressRetryDelayMs('test-wf', 'uid-123') + windows.push(delay) + now += delay + } + + expect(windows).toEqual([ + 60_000, 120_000, 240_000, 480_000, 960_000, 1_920_000, 3_600_000, 3_600_000, + ]) + expect(legacyDeletes()).toHaveLength(8) + + legacyAnswer = { code: 404 } + vi.setSystemTime(now) + expect(await reconciler.retryLegacyMcpServersInternetEgressDelete('test-wf', 'uid-123')).toBe( + 'removed' + ) + expect(reconciler.legacyMcpServersInternetEgressRetryDelayMs('test-wf', 'uid-123')).toBe(0) + expect(legacyDeletes()).toHaveLength(9) + // Observed gone: a later retry answers from the gone set, with no DELETE. + expect(await reconciler.retryLegacyMcpServersInternetEgressDelete('test-wf', 'uid-123')).toBe( + 'removed' + ) + expect(legacyDeletes()).toHaveLength(9) + } finally { + vi.useRealTimers() + } + }) + + it('R4-L4: the finalizer deletes the unlabelled legacy internet policy by name in the mcp-server namespace', async () => { + const networkingApi = makeNetworkingApi() as ReturnType + // The label sweep finds nothing: the legacy policy predates the labels. + networkingApi.listNamespacedNetworkPolicy.mockResolvedValue({ items: [] }) + const reconciler = new WorkflowReconciler( + makeDeps({ + networkingApi: networkingApi as never, + }) + ) + + await reconciler.reconcileDelete('test-wf', 'sandbox-recipes', makeSpec(), 'uid-123') + + // Liveness witness: the fixed run-lane list was deleted by name. + expect(networkingApi.deleteNamespacedNetworkPolicy).toHaveBeenCalledWith({ + name: 'test-wf-mcp-host-to-servers', + namespace: 'sandbox-recipes', + }) + expect(networkingApi.deleteNamespacedNetworkPolicy).toHaveBeenCalledWith({ + name: 'test-wf-mcp-servers-egress-internet', + namespace: 'mcp-server', + }) + }) + + it('clears the legacy internet NP process set on recipe delete so a recreate can delete again', async () => { + const networkingApi = makeNetworkingApi() as ReturnType + const reconciler = new WorkflowReconciler( + makeDeps({ + networkingApi: networkingApi as never, + }) + ) + + await reconciler.reconcile( + 'test-wf', + 'uid-123', + 'sandbox-recipes', + makeSpec({ mcpServers: ['redis-mcp'] }) + ) + networkingApi.deleteNamespacedNetworkPolicy.mockClear() + await reconciler.reconcileDelete('test-wf', 'sandbox-recipes', makeSpec(), 'uid-123') + await reconciler.reconcile( + 'test-wf', + 'uid-123', + 'sandbox-recipes', + makeSpec({ mcpServers: ['redis-mcp'] }) + ) + expect(networkingApi.deleteNamespacedNetworkPolicy).toHaveBeenCalledWith({ + name: 'test-wf-mcp-servers-egress-internet', + namespace: 'mcp-server', + }) + }) + + it('keys the legacy internet NP ledger by recipe uid, so a recreated recipe deletes again', async () => { + const networkingApi = makeNetworkingApi() as ReturnType + const reconciler = new WorkflowReconciler( + makeDeps({ + networkingApi: networkingApi as never, + }) + ) + const legacyDeletes = () => + networkingApi.deleteNamespacedNetworkPolicy.mock.calls.filter( + ([arg]) => + arg.name === 'test-wf-mcp-servers-egress-internet' && arg.namespace === 'mcp-server' + ) + const reconcileWithUid = (uid: string) => + reconciler.reconcile( + 'test-wf', + uid, + 'sandbox-recipes', + makeSpec({ mcpServers: ['redis-mcp'] }) + ) + + await reconcileWithUid('uid-a') + await reconcileWithUid('uid-a') + // Witness: the ledger suppresses the repeat for the same uid. + expect(legacyDeletes()).toHaveLength(1) + + // Same name, new uid, and no reconcileDelete seen by this process (another + // replica handled the delete): a new identity must not be suppressed. + await reconcileWithUid('uid-b') + expect(legacyDeletes()).toHaveLength(2) + }) + + describe('B2 desired-set NetworkPolicy prune', () => { + const RECIPE = 'test-wf' + const SANDBOX = 'sandbox-recipes' + const TWO_TERM_SELECTOR = `clerum.io/recipe=${RECIPE},clerum.io/managed-by=wrc` + const CODEX_PROXY = `${RECIPE}-mcp-host-to-codex-proxy` + const GROK_PROXY = `${RECIPE}-mcp-host-to-grok-proxy` + const COORD_TO_WRC = `${RECIPE}-coord-to-wrc` + const COORD_TO_MCP_HOST = `${RECIPE}-coord-to-mcp-host` + const WRC_LABELS = { + 'clerum.io/recipe': RECIPE, + 'clerum.io/managed-by': 'wrc', + } + + function ineligibleProjection( + overrides: Partial = {} + ): CodexExecutionProjection { + return { + targets: [], + eligibleTargets: [], + derivedScopes: [], + requiresCodexProxyEgress: false, + driftHashInput: '', + catalogContentHash: null, + catalogRevision: null, + connectionRevision: null, + eligibility: 'ineligible', + reason: 'static_only', + ...overrides, + } + } + + function seedLivePolicy( + apiserver: ReturnType, + name: string, + labels: Record, + namespace = SANDBOX + ): void { + apiserver.live.set(apiserver.key(namespace, name), { + apiVersion: 'networking.k8s.io/v1', + kind: 'NetworkPolicy', + metadata: { name, namespace, labels }, + spec: { podSelector: {}, policyTypes: ['Egress'] }, + }) + } + + function listCalls( + api: ReturnType['api'] + ): Array<{ namespace: string; labelSelector?: string }> { + return api.listNamespacedNetworkPolicy.mock.calls.map(([arg]) => arg) + } + + function sandboxDeletes( + api: ReturnType['api'] + ): Array<{ name: string; namespace: string }> { + return api.deleteNamespacedNetworkPolicy.mock.calls + .map(([arg]) => arg) + .filter(arg => arg.namespace === SANDBOX) + } + + async function applyPolicies( + reconciler: WorkflowReconciler, + opts: { + codex?: Partial + grok?: Partial & { requiresGrokProxyEgress?: boolean } + awaitsTriggeredRun?: boolean + eagerSdkMcpHost?: boolean + } = {} + ): Promise<{ retryPending: boolean }> { + const spec = makeSpec() + const runtime = deriveWorkflowRuntimePlan(spec, { + recipeName: RECIPE, + runtimeScopeRecipeName: RECIPE, + workflowRunId: opts.awaitsTriggeredRun ? undefined : 'run-1', + }) + const codex = ineligibleProjection({ + requiresCodexProxyEgress: false, + ...opts.codex, + }) + const grok = { + ...ineligibleProjection(), + requiresCodexProxyEgress: false, + requiresGrokProxyEgress: false, + ...opts.grok, + } + return (await ( + reconciler as unknown as { + applyWorkflowNetworkPolicies: ( + recipeName: string, + recipeUid: string, + spec: WorkflowRecipeSpec, + runtime: ReturnType, + awaitsTriggeredRun: boolean, + codexProjection: CodexExecutionProjection, + eagerSdkMcpHost: boolean, + grokProjection: typeof grok + ) => Promise<{ retryPending: boolean }> + } + ).applyWorkflowNetworkPolicies( + RECIPE, + 'uid-123', + spec, + runtime, + opts.awaitsTriggeredRun === true, + codex, + opts.eagerSdkMcpHost === true, + grok + )) as { retryPending: boolean } + } + + function expectListWitness( + api: ReturnType['api'], + times = 1 + ): void { + expect(listCalls(api)).toEqual( + Array.from({ length: times }, () => ({ + namespace: SANDBOX, + labelSelector: TWO_TERM_SELECTOR, + })) + ) + } + + /** + * Liveness witness for the negative prune tests: a catalog member that is not + * desired sits in the same LIST and must be deleted. If the prune loop never + * ran, "X was not deleted" would hold anyway; the sibling's delete proves the + * loop iterated over the listed items. + */ + function seedPrunableSibling(apiserver: ReturnType): void { + seedLivePolicy(apiserver, GROK_PROXY, WRC_LABELS) + } + + function expectPrunableSiblingDeleted( + apiserver: ReturnType + ): void { + expect(sandboxDeletes(apiserver.api).map(d => d.name)).toContain(GROK_PROXY) + expect(apiserver.live.has(apiserver.key(SANDBOX, GROK_PROXY))).toBe(false) + } + + it('does not delete a listed name that is outside the run-lane catalog', async () => { + const apiserver = makeApiserverNetworkingApi() + seedLivePolicy(apiserver, `${RECIPE}-obsolete-wrc`, WRC_LABELS) + seedPrunableSibling(apiserver) + const reconciler = new WorkflowReconciler(makeDeps({ networkingApi: apiserver.api as never })) + + await applyPolicies(reconciler) + + expectListWitness(apiserver.api) + expectPrunableSiblingDeleted(apiserver) + expect(sandboxDeletes(apiserver.api).map(d => d.name)).not.toContain(`${RECIPE}-obsolete-wrc`) + expect(apiserver.live.has(apiserver.key(SANDBOX, `${RECIPE}-obsolete-wrc`))).toBe(true) + }) + + it('G1: reserved coordinator-to-gfs survives a run-lane apply', async () => { + const apiserver = makeApiserverNetworkingApi() + const gfsName = `${RECIPE}-coordinator-to-gfs` + seedLivePolicy(apiserver, gfsName, WRC_LABELS) + seedLivePolicy(apiserver, `${RECIPE}-obsolete-wrc`, WRC_LABELS) + seedLivePolicy(apiserver, COORD_TO_WRC, WRC_LABELS) + seedLivePolicy(apiserver, `${RECIPE}-oauth-broker-egress`, { + 'clerum.io/recipe': RECIPE, + 'clerum.io/managed-by': 'workflow-recipes', + }) + seedLivePolicy(apiserver, `${RECIPE}-outer-unknown-wrc`, WRC_LABELS) + seedLivePolicy(apiserver, 'other-recipe-coordinator-to-gfs', { + 'clerum.io/recipe': 'other-recipe', + 'clerum.io/managed-by': 'wrc', + }) + seedLivePolicy(apiserver, GROK_PROXY, WRC_LABELS) + const reconciler = new WorkflowReconciler(makeDeps({ networkingApi: apiserver.api as never })) + + await applyPolicies(reconciler) + + expectListWitness(apiserver.api) + expect(sandboxDeletes(apiserver.api).map(d => d.name)).toEqual([GROK_PROXY]) + expect(apiserver.live.has(apiserver.key(SANDBOX, gfsName))).toBe(true) + expect(apiserver.live.has(apiserver.key(SANDBOX, `${RECIPE}-obsolete-wrc`))).toBe(true) + expect(apiserver.live.has(apiserver.key(SANDBOX, COORD_TO_WRC))).toBe(true) + expect(apiserver.live.has(apiserver.key(SANDBOX, `${RECIPE}-oauth-broker-egress`))).toBe(true) + expect(apiserver.live.has(apiserver.key(SANDBOX, `${RECIPE}-outer-unknown-wrc`))).toBe(true) + expect(apiserver.live.has(apiserver.key(SANDBOX, 'other-recipe-coordinator-to-gfs'))).toBe( + true + ) + expect(apiserver.live.has(apiserver.key(SANDBOX, GROK_PROXY))).toBe(false) + }) + + it('deletes a seeded leftover Grok proxy and does not name-delete when unseeded', async () => { + const seeded = makeApiserverNetworkingApi() + seedLivePolicy(seeded, GROK_PROXY, WRC_LABELS) + const seededReconciler = new WorkflowReconciler( + makeDeps({ networkingApi: seeded.api as never }) + ) + await applyPolicies(seededReconciler) + expectListWitness(seeded.api) + expect(sandboxDeletes(seeded.api).map(d => d.name)).toEqual([GROK_PROXY]) + + const unseeded = makeApiserverNetworkingApi() + const unseededReconciler = new WorkflowReconciler( + makeDeps({ networkingApi: unseeded.api as never }) + ) + await applyPolicies(unseededReconciler) + expectListWitness(unseeded.api) + expect(sandboxDeletes(unseeded.api).map(d => d.name)).not.toContain(GROK_PROXY) + }) + + it('deletes a seeded leftover Codex proxy and does not name-delete when unseeded', async () => { + const seeded = makeApiserverNetworkingApi() + seedLivePolicy(seeded, CODEX_PROXY, WRC_LABELS) + const seededReconciler = new WorkflowReconciler( + makeDeps({ networkingApi: seeded.api as never }) + ) + await applyPolicies(seededReconciler) + expect(sandboxDeletes(seeded.api).map(d => d.name)).toEqual([CODEX_PROXY]) + + const unseeded = makeApiserverNetworkingApi() + const unseededReconciler = new WorkflowReconciler( + makeDeps({ networkingApi: unseeded.api as never }) + ) + await applyPolicies(unseededReconciler) + expect(sandboxDeletes(unseeded.api).map(d => d.name)).not.toContain(CODEX_PROXY) + }) + + it('c2: awaiting-trigger prunes a run-only leftover that apply(false) would keep', async () => { + const leftover = `${RECIPE}-wrc-to-artifact-reader` + const awaiting = makeApiserverNetworkingApi() + seedLivePolicy(awaiting, leftover, WRC_LABELS) + const awaitingReconciler = new WorkflowReconciler( + makeDeps({ networkingApi: awaiting.api as never }) + ) + await applyPolicies(awaitingReconciler, { awaitsTriggeredRun: true }) + expect(sandboxDeletes(awaiting.api).map(d => d.name)).toEqual([leftover]) + + const running = makeApiserverNetworkingApi() + seedLivePolicy(running, leftover, WRC_LABELS) + const runningReconciler = new WorkflowReconciler( + makeDeps({ networkingApi: running.api as never }) + ) + await applyPolicies(runningReconciler, { awaitsTriggeredRun: false }) + expect(sandboxDeletes(running.api).map(d => d.name)).not.toContain(leftover) + expect(running.live.has(running.key(SANDBOX, leftover))).toBe(true) + }) + + it('does not delete in the sandbox namespace when the LIST is empty', async () => { + const apiserver = makeApiserverNetworkingApi() + const reconciler = new WorkflowReconciler(makeDeps({ networkingApi: apiserver.api as never })) + + await applyPolicies(reconciler) + + expectListWitness(apiserver.api) + expect(sandboxDeletes(apiserver.api)).toEqual([]) + }) + + it('does not delete a listed policy that is in the desired set', async () => { + const apiserver = makeApiserverNetworkingApi() + seedLivePolicy(apiserver, COORD_TO_WRC, WRC_LABELS) + seedPrunableSibling(apiserver) + const reconciler = new WorkflowReconciler(makeDeps({ networkingApi: apiserver.api as never })) + + await applyPolicies(reconciler) + + expectPrunableSiblingDeleted(apiserver) + expect(sandboxDeletes(apiserver.api).filter(d => d.name === COORD_TO_WRC)).toEqual([]) + expect(apiserver.live.has(apiserver.key(SANDBOX, COORD_TO_WRC))).toBe(true) + }) + + it('does not prune an uncatalogued policy carrying the recipe label pair; LIST is issued once', async () => { + const apiserver = makeApiserverNetworkingApi() + seedLivePolicy(apiserver, `${RECIPE}-oauth-broker-egress`, { + 'clerum.io/recipe': RECIPE, + 'clerum.io/managed-by': 'workflow-recipes', + }) + seedPrunableSibling(apiserver) + const reconciler = new WorkflowReconciler(makeDeps({ networkingApi: apiserver.api as never })) + + await applyPolicies(reconciler) + + expectListWitness(apiserver.api) + expectPrunableSiblingDeleted(apiserver) + expect(sandboxDeletes(apiserver.api).map(d => d.name)).not.toContain( + `${RECIPE}-oauth-broker-egress` + ) + expect(apiserver.live.has(apiserver.key(SANDBOX, `${RECIPE}-oauth-broker-egress`))).toBe(true) + }) + + it('keeps an unlisted-as-desired proxy while eligibility is uncertain, and deletes the twin', async () => { + const uncertain = makeApiserverNetworkingApi() + seedLivePolicy(uncertain, CODEX_PROXY, WRC_LABELS) + const debugLog = captureLogger('debug') + try { + const uncertainReconciler = new WorkflowReconciler( + makeDeps({ networkingApi: uncertain.api as never }) + ) + await applyPolicies(uncertainReconciler, { + codex: { eligibility: 'uncertain', requiresCodexProxyEgress: false, reason: 'forbidden' }, + }) + // The skip names why the proxy survives. + expect(debugLog).toHaveBeenCalledWith( + 'Skipping run-lane NP prune', + expect.objectContaining({ policy: CODEX_PROXY, reason: 'uncertain' }) + ) + } finally { + debugLog.mockRestore() + } + expectListWitness(uncertain.api) + expect(sandboxDeletes(uncertain.api).map(d => d.name)).not.toContain(CODEX_PROXY) + expect(uncertain.live.has(uncertain.key(SANDBOX, CODEX_PROXY))).toBe(true) + + const decided = makeApiserverNetworkingApi() + seedLivePolicy(decided, CODEX_PROXY, WRC_LABELS) + const decidedReconciler = new WorkflowReconciler( + makeDeps({ networkingApi: decided.api as never }) + ) + await applyPolicies(decidedReconciler, { + codex: { eligibility: 'ineligible', requiresCodexProxyEgress: false }, + }) + expectListWitness(decided.api) + expect(sandboxDeletes(decided.api).map(d => d.name)).toContain(CODEX_PROXY) + }) + + it('keeps a desired live Codex proxy and deletes an undesired Grok proxy only on the first pass', async () => { + const apiserver = makeApiserverNetworkingApi() + seedLivePolicy(apiserver, CODEX_PROXY, WRC_LABELS) + seedLivePolicy(apiserver, GROK_PROXY, WRC_LABELS) + const reconciler = new WorkflowReconciler(makeDeps({ networkingApi: apiserver.api as never })) + + await applyPolicies(reconciler, { + codex: { eligibility: 'eligible', requiresCodexProxyEgress: true, reason: 'granted' }, + }) + expectListWitness(apiserver.api) + expect(sandboxDeletes(apiserver.api).map(d => d.name)).toEqual([GROK_PROXY]) + expect(apiserver.live.has(apiserver.key(SANDBOX, CODEX_PROXY))).toBe(true) + expect(apiserver.live.has(apiserver.key(SANDBOX, GROK_PROXY))).toBe(false) + + apiserver.api.listNamespacedNetworkPolicy.mockClear() + apiserver.api.deleteNamespacedNetworkPolicy.mockClear() + await applyPolicies(reconciler, { + codex: { eligibility: 'eligible', requiresCodexProxyEgress: true, reason: 'granted' }, + }) + expectListWitness(apiserver.api) + expect(sandboxDeletes(apiserver.api)).toEqual([]) + }) + + it('keeps an unlisted-as-desired Grok proxy while eligibility is uncertain, and deletes the twin', async () => { + const uncertain = makeApiserverNetworkingApi() + seedLivePolicy(uncertain, GROK_PROXY, WRC_LABELS) + const uncertainReconciler = new WorkflowReconciler( + makeDeps({ networkingApi: uncertain.api as never }) + ) + await applyPolicies(uncertainReconciler, { + grok: { eligibility: 'uncertain', requiresGrokProxyEgress: false, reason: 'forbidden' }, + }) + expectListWitness(uncertain.api) + expect(sandboxDeletes(uncertain.api).map(d => d.name)).not.toContain(GROK_PROXY) + expect(uncertain.live.has(uncertain.key(SANDBOX, GROK_PROXY))).toBe(true) + + const decided = makeApiserverNetworkingApi() + seedLivePolicy(decided, GROK_PROXY, WRC_LABELS) + const decidedReconciler = new WorkflowReconciler( + makeDeps({ networkingApi: decided.api as never }) + ) + await applyPolicies(decidedReconciler, { + grok: { eligibility: 'ineligible', requiresGrokProxyEgress: false }, + }) + expectListWitness(decided.api) + expect(sandboxDeletes(decided.api).map(d => d.name)).toContain(GROK_PROXY) + }) + + it('does not prune a catalog-named recipes-lane policy (two-term selector)', async () => { + const apiserver = makeApiserverNetworkingApi() + seedLivePolicy(apiserver, COORD_TO_MCP_HOST, { + 'clerum.io/recipe': RECIPE, + 'clerum.io/managed-by': 'workflow-recipes', + }) + seedPrunableSibling(apiserver) + const reconciler = new WorkflowReconciler(makeDeps({ networkingApi: apiserver.api as never })) + + await applyPolicies(reconciler, { awaitsTriggeredRun: true }) + + expectListWitness(apiserver.api) + expectPrunableSiblingDeleted(apiserver) + expect(sandboxDeletes(apiserver.api).map(d => d.name)).not.toContain(COORD_TO_MCP_HOST) + expect(apiserver.live.has(apiserver.key(SANDBOX, COORD_TO_MCP_HOST))).toBe(true) + }) + + it('deletes a leftover coord-to-mcp-host when awaiting trigger', async () => { + const apiserver = makeApiserverNetworkingApi() + seedLivePolicy(apiserver, COORD_TO_MCP_HOST, WRC_LABELS) + const reconciler = new WorkflowReconciler(makeDeps({ networkingApi: apiserver.api as never })) + + await applyPolicies(reconciler, { awaitsTriggeredRun: true }) + + expectListWitness(apiserver.api) + expect(sandboxDeletes(apiserver.api).map(d => d.name)).toContain(COORD_TO_MCP_HOST) + expect(apiserver.live.has(apiserver.key(SANDBOX, COORD_TO_MCP_HOST))).toBe(false) + }) + + it('R1-M1: does not prune a catalog policy that carries an ownerReference (apply refuses it too)', async () => { + const apiserver = makeApiserverNetworkingApi() + seedLivePolicy(apiserver, COORD_TO_MCP_HOST, WRC_LABELS) + const seeded = apiserver.live.get(apiserver.key(SANDBOX, COORD_TO_MCP_HOST))! + seeded.metadata = { + ...seeded.metadata, + ownerReferences: [ + { + apiVersion: 'apps/v1', + kind: 'Deployment', + name: 'someone-else', + uid: 'uid-deployment', + controller: true, + }, + ], + } + seedPrunableSibling(apiserver) + const warnLog = captureLogger('warn') + try { + const reconciler = new WorkflowReconciler( + makeDeps({ networkingApi: apiserver.api as never }) + ) + + await applyPolicies(reconciler, { awaitsTriggeredRun: true }) + + expectListWitness(apiserver.api) + // Liveness witness: the WRC-owned sibling in the same LIST was pruned. + expectPrunableSiblingDeleted(apiserver) + expect(sandboxDeletes(apiserver.api).map(d => d.name)).not.toContain(COORD_TO_MCP_HOST) + expect(apiserver.live.has(apiserver.key(SANDBOX, COORD_TO_MCP_HOST))).toBe(true) + expect(warnLog).toHaveBeenCalledWith('Skipping run-lane NP prune', { + recipe: RECIPE, + policy: COORD_TO_MCP_HOST, + reason: 'owner-reference-mismatch', + }) + } finally { + warnLog.mockRestore() + } + }) + + it('eager SDK path still prunes a leftover Grok proxy', async () => { + const apiserver = makeApiserverNetworkingApi() + seedLivePolicy(apiserver, GROK_PROXY, WRC_LABELS) + const reconciler = new WorkflowReconciler(makeDeps({ networkingApi: apiserver.api as never })) + + await applyPolicies(reconciler, { awaitsTriggeredRun: true, eagerSdkMcpHost: true }) + + expectListWitness(apiserver.api) + expect(sandboxDeletes(apiserver.api).map(d => d.name)).toContain(GROK_PROXY) + }) + + // A prune DELETE that did not land is prune-pending, not a pending retry: + // it publishes PrunePending and does not requeue (R3-L3). + it('marks the prune pending, not a retry, when a leftover prune DELETE fails', async () => { + const apiserver = makeApiserverNetworkingApi() + seedLivePolicy(apiserver, GROK_PROXY, WRC_LABELS) + apiserver.api.deleteNamespacedNetworkPolicy.mockImplementation( + async ({ name, namespace }: { name: string; namespace: string }) => { + if (name === GROK_PROXY) throw { code: 403, message: 'forbidden' } + if (!apiserver.live.delete(apiserver.key(namespace, name))) throw { code: 404 } + return {} + } + ) + const reconciler = new WorkflowReconciler(makeDeps({ networkingApi: apiserver.api as never })) + + const summary = await applyPolicies(reconciler) + expect(summary.prune).toBe('pending') + expect(summary.retryPending).toBe(false) + expect(apiserver.live.has(apiserver.key(SANDBOX, GROK_PROXY))).toBe(true) + }) + + it.each([ + { code: 404, prune: 'converged' }, + { code: 409, prune: 'pending' }, + { code: 500, prune: 'pending' }, + ])('prune DELETE answering $code leaves the prune $prune', async ({ code, prune }) => { + const apiserver = makeApiserverNetworkingApi() + seedLivePolicy(apiserver, GROK_PROXY, WRC_LABELS) + apiserver.api.deleteNamespacedNetworkPolicy.mockImplementation( + async ({ name, namespace }: { name: string; namespace: string }) => { + if (name === GROK_PROXY) throw { code } + if (!apiserver.live.delete(apiserver.key(namespace, name))) throw { code: 404 } + return {} + } + ) + const reconciler = new WorkflowReconciler(makeDeps({ networkingApi: apiserver.api as never })) + + const summary = await applyPolicies(reconciler) + + // Liveness witness: the prune reached the DELETE for the leftover. + expect(sandboxDeletes(apiserver.api).map(d => d.name)).toContain(GROK_PROXY) + expect(summary.prune).toBe(prune) + expect(summary.retryPending).toBe(false) + }) + + it('logs prune failures as an error object, never as a pre-flattened message string', async () => { + const errorLog = captureLogger('error') + try { + // A client exception whose message embeds the response body and headers: the + // logger only redacts those when it receives the error itself. + const failure = Object.assign(new Error('HTTP-Code: 403 Body: "{}" Headers: {}'), { + code: 403, + }) + const apiserver = makeApiserverNetworkingApi() + seedLivePolicy(apiserver, GROK_PROXY, WRC_LABELS) + apiserver.api.deleteNamespacedNetworkPolicy.mockImplementation( + async ({ name, namespace }: { name: string; namespace: string }) => { + if (name === GROK_PROXY) throw failure + if (!apiserver.live.delete(apiserver.key(namespace, name))) throw { code: 404 } + return {} + } + ) + apiserver.api.listNamespacedNetworkPolicy.mockRejectedValueOnce(failure) + const reconciler = new WorkflowReconciler( + makeDeps({ networkingApi: apiserver.api as never }) + ) + + await applyPolicies(reconciler) // LIST fails + await applyPolicies(reconciler) // LIST ok, DELETE of the leftover fails + + const fields = (message: string) => + errorLog.mock.calls.find(([msg]) => msg === message)?.[1] as + | Record + | undefined + for (const message of [ + 'Run-lane NP prune LIST failed; apply outcome is kept', + 'Run-lane NP prune delete failed', + ]) { + expect(fields(message), message).toBeDefined() + expect(fields(message)?.err).toBe(failure) + expect(fields(message)).not.toHaveProperty('error') + } + } finally { + errorLog.mockRestore() + } + }) + + it('marks the legacy policy pending, apart from the prune, when the legacy internet NP DELETE fails', async () => { + const apiserver = makeApiserverNetworkingApi() + apiserver.api.deleteNamespacedNetworkPolicy.mockImplementation( + async ({ name, namespace }: { name: string; namespace: string }) => { + if (name === `${RECIPE}-mcp-servers-egress-internet`) { + throw { code: 403, message: 'forbidden' } + } + if (!apiserver.live.delete(apiserver.key(namespace, name))) throw { code: 404 } + return {} + } + ) + const reconciler = new WorkflowReconciler(makeDeps({ networkingApi: apiserver.api as never })) + + const summary = await applyPolicies(reconciler) + // Liveness witness: the legacy delete was attempted. + expect( + apiserver.api.deleteNamespacedNetworkPolicy.mock.calls.filter( + ([arg]: [{ name: string }]) => arg.name === `${RECIPE}-mcp-servers-egress-internet` + ) + ).toHaveLength(1) + // R4-L3: the legacy delete is its own fact; the catalog prune converged. + expect(summary.legacy).toBe('pending') + expect(summary.prune).toBe('converged') + expect(summary.retryPending).toBe(false) + }) + + // R4-L6: a failed LIST evaluated nothing. It is not a DELETE that did + // not land, so it is its own prune fact, `unevaluated`. + it('R4-L6: marks the prune unevaluated, not pending, and keeps the apply outcome when the prune LIST fails', async () => { + const apiserver = makeApiserverNetworkingApi() + seedLivePolicy(apiserver, GROK_PROXY, WRC_LABELS) + apiserver.api.listNamespacedNetworkPolicy.mockRejectedValue({ code: 500, message: 'boom' }) + const reconciler = new WorkflowReconciler(makeDeps({ networkingApi: apiserver.api as never })) + + const summary = await applyPolicies(reconciler) + expect(summary.prune).toBe('unevaluated') + expect(summary.retryPending).toBe(false) + expect(sandboxDeletes(apiserver.api)).toEqual([]) + expect(apiserver.live.has(apiserver.key(SANDBOX, GROK_PROXY))).toBe(true) + // The apply outcome survives: the legacy internet policy prune that runs + // after the catalog prune is still attempted despite the failed LIST. + expect(apiserver.api.listNamespacedNetworkPolicy).toHaveBeenCalled() + expect( + apiserver.api.deleteNamespacedNetworkPolicy.mock.calls.filter( + ([arg]: [{ name: string }]) => arg.name === `${RECIPE}-mcp-servers-egress-internet` + ) + ).toHaveLength(1) + }) + }) + + describe('B3(d) skip coordinator DELETE after same-pass 404', () => { + const COORDINATOR = 'test-wf-coordinator' + const MCP_HOST = 'test-wf-mcp-host' + const SANDBOX = 'sandbox-recipes' + + function podReads(coreApi: ReturnType, name: string): number { + return coreApi.readNamespacedPod.mock.calls.filter(([arg]) => arg.name === name).length + } + + function podDeletes(coreApi: ReturnType, name: string): number { + return coreApi.deleteNamespacedPod.mock.calls.filter(([arg]) => arg.name === name).length + } + + it('does not delete the coordinator when same-pass GET is 404', async () => { + const coreApi = makeCoreApi(false) + const reconciler = new WorkflowReconciler(makeDeps({ coreApi: coreApi as never })) + + const result = await reconciler.reconcile('test-wf', 'uid-123', SANDBOX, makeSpec()) + + expect(result.workflowPhase).not.toBe('failed') + expect(podReads(coreApi, COORDINATOR)).toBe(2) + expect(podDeletes(coreApi, COORDINATOR)).toBe(0) + }) + + it('G4: deletes the coordinator when GET 200 has an empty status.phase', async () => { + const coreApi = makeCoreApi(false) + coreApi.readNamespacedPod.mockImplementation(async ({ name }: { name: string }) => { + if (name === COORDINATOR) return { metadata: { name }, status: {} } + throw { code: 404 } + }) + const reconciler = new WorkflowReconciler(makeDeps({ coreApi: coreApi as never })) + + await reconciler.reconcile('test-wf', 'uid-123', SANDBOX, makeSpec()) + + expect(podReads(coreApi, COORDINATOR)).toBe(2) + expect(podDeletes(coreApi, COORDINATOR)).toBe(1) + }) + + it('G4: a 403 on coordinator GET does not skip as 404', async () => { + const coreApi = makeCoreApi(false) + coreApi.readNamespacedPod.mockImplementation(async ({ name }: { name: string }) => { + if (name === COORDINATOR) throw { code: 403, message: 'forbidden' } + throw { code: 404 } + }) + const reconciler = new WorkflowReconciler(makeDeps({ coreApi: coreApi as never })) + + const result = await reconciler.reconcile('test-wf', 'uid-123', SANDBOX, makeSpec()) + + expect(result.workflowPhase).toBe('failed') + // The 403 stops the reconcile at the first coordinator read; a 403 + // mapped to absent would continue to a second read. + expect(podReads(coreApi, COORDINATOR)).toBe(1) + expect(podDeletes(coreApi, COORDINATOR)).toBe(0) + }) + + it('c2: does not delete a Running coordinator when workflowRunId is set', async () => { + const coreApi = makeCoreApi(true) + const reconciler = new WorkflowReconciler(makeDeps({ coreApi: coreApi as never })) + + await reconciler.reconcile( + 'test-wf', + 'uid-123', + SANDBOX, + makeSpec(), + undefined, + undefined, + undefined, + 'run-1' + ) + + expect(podReads(coreApi, COORDINATOR)).toBe(3) + expect(podDeletes(coreApi, COORDINATOR)).toBe(0) + }) + + it('deletes the coordinator when same-pass getPodPhase is Running', async () => { + const coreApi = makeCoreApi(true) + const reconciler = new WorkflowReconciler(makeDeps({ coreApi: coreApi as never })) + + await reconciler.reconcile('test-wf', 'uid-123', SANDBOX, makeSpec()) + + expect(podReads(coreApi, COORDINATOR)).toBe(3) + expect(podDeletes(coreApi, COORDINATOR)).toBe(1) + expect(coreApi.deleteNamespacedPod).toHaveBeenCalledWith({ + name: COORDINATOR, + namespace: SANDBOX, + }) + }) + + it('neither reads nor deletes mcp-host when needsMcpHost is false', async () => { + const coreApi = makeCoreApi(false) + const reconciler = new WorkflowReconciler( + makeDeps({ + coreApi: coreApi as never, + config: { + ...makeConfig(), + enableSnippetRuntime: true, + snippetRunnerImage: 'clerum/workflow-snippet-runner:test', + } as never, + }) + ) + + await reconciler.reconcile( + 'test-wf', + 'uid-123', + SANDBOX, + makeSpec({ + agent: undefined, + steps: [{ id: 'snippet', run: snippetRun() }], + }) + ) + + expect(podReads(coreApi, MCP_HOST)).toBe(0) + expect(podDeletes(coreApi, MCP_HOST)).toBe(0) + // Snippet-only has needsMcpHost=false, so awaitsTriggeredRun is false and + // B3(d) does not run. The coordinator is still read for crash recovery. + expect(podReads(coreApi, COORDINATOR)).toBe(2) + expect(podDeletes(coreApi, COORDINATOR)).toBe(0) + }) + + it('does not delete mcp-host when same-pass GET is 404', async () => { + const coreApi = makeCoreApi(false) + const reconciler = new WorkflowReconciler(makeDeps({ coreApi: coreApi as never })) + + const result = await reconciler.reconcile('test-wf', 'uid-123', SANDBOX, makeSpec()) + + expect(result.workflowPhase).not.toBe('failed') + expect(podReads(coreApi, MCP_HOST)).toBe(2) + expect(podDeletes(coreApi, MCP_HOST)).toBe(0) + }) }) it('retries NetworkPolicy replace once when resourceVersion is stale', async () => { @@ -4403,9 +5374,9 @@ describe('WorkflowReconciler — reconcile loop', () => { const first = await reconciler.reconcile('test-wf', 'uid-123', 'sandbox-recipes', spec()) expect(first.workflowPhase).not.toBe('failed') expect([...apiserver.live.keys()].sort()).toEqual(RUN_LANE_POLICY_NAMES) - // reconcile() also prunes the codex, grok and legacy policies, so a - // zero delete count below means the retry skipped them, not that no - // prune exists for this spec. + // reconcile() LISTs leftover run-lane policies and still DELETEs the + // legacy mcp-servers-egress-internet once (B3(c)). A zero delete count + // below means the retry skipped those paths, not that no prune exists. expect(apiserver.api.deleteNamespacedNetworkPolicy).toHaveBeenCalled() apiserver.api.createNamespacedNetworkPolicy.mockClear() apiserver.api.readNamespacedNetworkPolicy.mockClear() @@ -4464,13 +5435,482 @@ describe('WorkflowReconciler — reconcile loop', () => { deletionTimestamp: new Date('2026-09-24T10:00:00Z'), } - const summary = await reconciler.retryRunLaneNetworkPolicies('test-wf', 'uid-123', spec()) + const summary = await reconciler.retryRunLaneNetworkPolicies('test-wf', 'uid-123', spec()) + + expect(summary).toEqual({ conflicts: [], retryPending: true }) + expect(readPolicyNames(apiserver.api)).toContain('test-wf-coord-to-wrc') + expect(apiserver.api.createNamespacedNetworkPolicy).toHaveBeenCalledTimes(0) + expect(apiserver.api.replaceNamespacedNetworkPolicy).toHaveBeenCalledTimes(0) + expect(apiserver.api.deleteNamespacedNetworkPolicy).toHaveBeenCalledTimes(0) + }) + + // The retry runs only from the in-progress and active short-circuits, + // where the run's pods are live and may still use any lane the spec + // wanted when they were created. It applies what the current spec wants + // and prunes nothing; the next reconcile() pass prunes the rest. + describe('when the spec no longer wants a live lane', () => { + const SNIPPET_LANES = [ + 'test-wf-coord-to-snippet-runner', + 'test-wf-coord-to-snippet-runner-ingress', + 'test-wf-snippet-runner-egress', + ] + const AGENT_LANE = 'test-wf-coord-to-mcp-host' + + it('keeps it on a mid-run retry and still applies the lanes the spec wants', async () => { + const { apiserver, reconciler } = await convergedRunLane() + apiserver.api.listNamespacedNetworkPolicy.mockClear() + + // An agent recipe with a run id wants the mcp-host lanes and none of + // the snippet lanes the converged snippet recipe created. + const summary = await reconciler.retryRunLaneNetworkPolicies( + 'test-wf', + 'uid-123', + makeSpec(), + 'test-wf', + 'run-1' + ) + + // Liveness witness: the retry built the agent lane and created it. + expect( + apiserver.api.createNamespacedNetworkPolicy.mock.calls.map( + ([arg]) => arg.body.metadata?.name + ) + ).toContain(AGENT_LANE) + expect(apiserver.live.has(apiserver.key('sandbox-recipes', AGENT_LANE))).toBe(true) + for (const name of SNIPPET_LANES) { + expect(apiserver.live.has(apiserver.key('sandbox-recipes', name)), name).toBe(true) + } + expect(apiserver.api.listNamespacedNetworkPolicy).toHaveBeenCalledTimes(0) + expect(apiserver.api.deleteNamespacedNetworkPolicy).toHaveBeenCalledTimes(0) + expect(summary).toEqual({ conflicts: [], retryPending: false }) + }) + + it('prunes it on the next reconcile() pass', async () => { + const { apiserver, reconciler } = await convergedRunLane() + await reconciler.retryRunLaneNetworkPolicies( + 'test-wf', + 'uid-123', + makeSpec(), + 'test-wf', + 'run-1' + ) + expect( + apiserver.live.has(apiserver.key('sandbox-recipes', 'test-wf-snippet-runner-egress')) + ).toBe(true) + + const result = await reconciler.reconcile( + 'test-wf', + 'uid-123', + 'sandbox-recipes', + makeSpec(), + undefined, + undefined, + 'test-wf', + 'run-1' + ) + + expect(result.workflowPhase).not.toBe('failed') + const deleted = apiserver.api.deleteNamespacedNetworkPolicy.mock.calls.map( + ([arg]) => arg.name + ) + for (const name of SNIPPET_LANES) { + expect(deleted, name).toContain(name) + expect(apiserver.live.has(apiserver.key('sandbox-recipes', name)), name).toBe(false) + } + expect(apiserver.live.has(apiserver.key('sandbox-recipes', AGENT_LANE))).toBe(true) + }) + }) + + // A reconcile() prune that failed publishes PrunePending with no retry + // flag. The retry does not prune and carries no prune fact, so the + // marker stays until the next reconcile() pass prunes the leftover. + describe('when a prune left a leftover behind', () => { + // A catalog member the snippet lane does not want and that no + // eligibility verdict governs. + const LEFTOVER = 'test-wf-coord-to-mcp-host' + const GROK_PROXY = 'test-wf-mcp-host-to-grok-proxy' + const TWO_TERM_SELECTOR = 'clerum.io/recipe=test-wf,clerum.io/managed-by=wrc' + type Converged = Awaited>['apiserver'] + + function seedLeftover(apiserver: Converged) { + apiserver.live.set(apiserver.key('sandbox-recipes', LEFTOVER), { + apiVersion: 'networking.k8s.io/v1', + kind: 'NetworkPolicy', + metadata: { + name: LEFTOVER, + namespace: 'sandbox-recipes', + labels: { 'clerum.io/recipe': 'test-wf', 'clerum.io/managed-by': 'wrc' }, + }, + spec: { podSelector: {}, policyTypes: ['Egress'] }, + }) + } + + // nit-3: the WRC suite stubs reconcile() with the real apply and the + // `reconcile` marker writer; this pins that the real reconcile() + // publishes exactly that writer's conditions for its own summary. + it('nit-3: publishes exactly the reconcile marker writer conditions for its own pass summary', async () => { + const { apiserver, reconciler } = await convergedRunLane() + seedLeftover(apiserver) + apiserver.api.deleteNamespacedNetworkPolicy.mockRejectedValueOnce({ + code: 403, + message: 'forbidden', + }) + const apply = vi.spyOn( + reconciler as unknown as { + applyWorkflowNetworkPolicies: ( + ...args: unknown[] + ) => Promise + }, + 'applyWorkflowNetworkPolicies' + ) + + const failed = await reconciler.reconcile('test-wf', 'uid-123', 'sandbox-recipes', spec()) + + // Liveness witness: the pass ran the run-lane apply once, and its + // prune could not delete the leftover. + expect(apply).toHaveBeenCalledTimes(1) + const summary = await apply.mock.results[0]!.value + expect(summary).toEqual({ + conflicts: [], + retryPending: false, + prune: 'pending', + legacy: 'removed', + }) + const published = failed.networkPolicyOwnershipConditions ?? [] + expect(published).toHaveLength(1) + expect(published).toEqual( + networkPolicyMarkerConditions( + { kind: 'reconcile', summary }, + undefined, + published[0]!.lastTransitionTime! + ) + ) + expect(failed.networkPolicyRetryPending).toBeUndefined() + }) + + it('keeps it and its PrunePending marker through the retry, and prunes it on the next reconcile()', async () => { + const { apiserver, reconciler } = await convergedRunLane() + seedLeftover(apiserver) + apiserver.api.deleteNamespacedNetworkPolicy.mockRejectedValueOnce({ + code: 403, + message: 'forbidden', + }) + + const failed = await reconciler.reconcile('test-wf', 'uid-123', 'sandbox-recipes', spec()) + expect(failed.networkPolicyRetryPending).toBeFalsy() + const published = failed.networkPolicyOwnershipConditions ?? [] + expect( + published.filter(c => c.type === 'WorkflowNetworkPoliciesConverged') + ).toMatchObject([{ status: 'False', reason: 'PrunePending' }]) + expect(apiserver.live.has(apiserver.key('sandbox-recipes', LEFTOVER))).toBe(true) + + apiserver.api.listNamespacedNetworkPolicy.mockClear() + apiserver.api.createNamespacedNetworkPolicy.mockClear() + apiserver.api.deleteNamespacedNetworkPolicy.mockClear() + expect( + apiserver.live.delete(apiserver.key('sandbox-recipes', 'test-wf-coord-to-wrc')) + ).toBe(true) + const retried = await reconciler.retryRunLaneNetworkPolicies('test-wf', 'uid-123', spec()) + // Liveness witness: the retry applied the lane the spec wants. + expect( + apiserver.api.createNamespacedNetworkPolicy.mock.calls.map( + ([arg]) => arg.body.metadata?.name + ) + ).toEqual(['test-wf-coord-to-wrc']) + expect(retried).toEqual({ conflicts: [], retryPending: false }) + expect(apiserver.api.listNamespacedNetworkPolicy).toHaveBeenCalledTimes(0) + expect(apiserver.api.deleteNamespacedNetworkPolicy).toHaveBeenCalledTimes(0) + expect(apiserver.live.has(apiserver.key('sandbox-recipes', LEFTOVER))).toBe(true) + + const pruned = await reconciler.reconcile( + 'test-wf', + 'uid-123', + 'sandbox-recipes', + spec(), + { + conditions: published, + } + ) + expect(pruned.networkPolicyRetryPending).toBeFalsy() + // The prune that landed clears the marker: the pass publishes its + // conditions and none of them is of that type any more. + expect(pruned.networkPolicyOwnershipConditions).toBeDefined() + expect( + (pruned.networkPolicyOwnershipConditions ?? []).filter( + c => c.type === 'WorkflowNetworkPoliciesConverged' + ) + ).toEqual([]) + expect(apiserver.api.listNamespacedNetworkPolicy).toHaveBeenCalledWith({ + namespace: 'sandbox-recipes', + labelSelector: TWO_TERM_SELECTOR, + }) + expect( + apiserver.api.deleteNamespacedNetworkPolicy.mock.calls.map(([arg]) => arg.name) + ).toEqual([LEFTOVER]) + expect(apiserver.live.has(apiserver.key('sandbox-recipes', LEFTOVER))).toBe(false) + }) + + it('R4-L6: publishes PruneUnevaluated, with no retry flag, from a reconcile() whose prune LIST failed', async () => { + const { apiserver, reconciler } = await convergedRunLane() + seedLeftover(apiserver) + const liveList = apiserver.api.listNamespacedNetworkPolicy.getMockImplementation()! + apiserver.api.listNamespacedNetworkPolicy.mockImplementation( + async (arg: { namespace: string; labelSelector?: string }) => { + if (arg.labelSelector === TWO_TERM_SELECTOR) throw { code: 500, message: 'boom' } + return liveList(arg) + } + ) + apiserver.api.listNamespacedNetworkPolicy.mockClear() + + const failed = await reconciler.reconcile('test-wf', 'uid-123', 'sandbox-recipes', spec()) + + // Liveness witness: the prune issued its LIST, and it failed. + expect(apiserver.api.listNamespacedNetworkPolicy).toHaveBeenCalledWith({ + namespace: 'sandbox-recipes', + labelSelector: TWO_TERM_SELECTOR, + }) + expect(apiserver.live.has(apiserver.key('sandbox-recipes', LEFTOVER))).toBe(true) + expect(failed.networkPolicyRetryPending).toBeFalsy() + expect( + (failed.networkPolicyOwnershipConditions ?? []).filter( + c => c.type === 'WorkflowNetworkPoliciesConverged' + ) + ).toMatchObject([{ status: 'False', reason: 'PruneUnevaluated' }]) + + // Witness: once the LIST answers, a DELETE that does not land is + // still PrunePending, and it replaces the unevaluated marker. + apiserver.api.listNamespacedNetworkPolicy.mockImplementation(liveList) + apiserver.api.deleteNamespacedNetworkPolicy.mockClear() + apiserver.api.deleteNamespacedNetworkPolicy.mockRejectedValueOnce({ + code: 403, + message: 'forbidden', + }) + const pending = await reconciler.reconcile( + 'test-wf', + 'uid-123', + 'sandbox-recipes', + spec(), + { conditions: failed.networkPolicyOwnershipConditions ?? [] } + ) + expect(apiserver.api.deleteNamespacedNetworkPolicy.mock.calls[0]?.[0].name).toBe(LEFTOVER) + expect(apiserver.live.has(apiserver.key('sandbox-recipes', LEFTOVER))).toBe(true) + expect(pending.networkPolicyRetryPending).toBeFalsy() + expect( + (pending.networkPolicyOwnershipConditions ?? []).filter( + c => c.type === 'WorkflowNetworkPoliciesConverged' + ) + ).toMatchObject([{ status: 'False', reason: 'PrunePending' }]) + }) + + // The legacy mcp-servers internet policy is deleted by name, apart + // from the catalog prune: reconcile() deletes it, and the WRC's + // legacy-retry writer retries it (R4-L4). A failed delete is its own + // fact (R4-L3), DeletePending on its own condition, with no retry flag + // and the catalog prune converged, and the next reconcile() pass after + // the backoff window deletes it again. + const LEGACY = 'test-wf-mcp-servers-egress-internet' + const legacyDeletes = (apiserver: Converged) => + apiserver.api.deleteNamespacedNetworkPolicy.mock.calls.filter( + ([arg]) => arg.name === LEGACY + ) + + it('reports a failed legacy internet policy delete from reconcile() as DeletePending, and deletes it on the next reconcile() after its backoff window', async () => { + const t0 = new Date('2026-09-23T10:00:00.000Z').getTime() + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(t0) + try { + const apiserver = makeApiserverNetworkingApi() + const reconciler = new WorkflowReconciler( + makeDeps({ networkingApi: apiserver.api as never }) + ) + const liveDelete = apiserver.api.deleteNamespacedNetworkPolicy.getMockImplementation()! + apiserver.api.deleteNamespacedNetworkPolicy.mockImplementation( + async (arg: { name: string; namespace: string }) => { + if (arg.name === LEGACY && legacyDeletes(apiserver).length === 1) { + throw { code: 403, message: 'forbidden' } + } + return liveDelete(arg) + } + ) + + const failed = await reconciler.reconcile( + 'test-wf', + 'uid-123', + 'sandbox-recipes', + spec() + ) + expect(legacyDeletes(apiserver)).toHaveLength(1) + expect(failed.networkPolicyRetryPending).toBeFalsy() + const published = failed.networkPolicyOwnershipConditions ?? [] + expect(published.filter(c => c.type === 'WorkflowNetworkPoliciesConverged')).toEqual([]) + expect( + published.filter(c => c.type === 'WorkflowLegacyNetworkPolicyRemoved') + ).toMatchObject([{ status: 'False', reason: 'DeletePending' }]) + + vi.setSystemTime(t0 + 60_000) + const pruned = await reconciler.reconcile( + 'test-wf', + 'uid-123', + 'sandbox-recipes', + spec(), + { + conditions: published, + } + ) + expect(legacyDeletes(apiserver)).toHaveLength(2) + expect(pruned.networkPolicyRetryPending).toBeFalsy() + expect(pruned.networkPolicyOwnershipConditions).toEqual([]) + } finally { + vi.useRealTimers() + } + }) + + it('sends no legacy internet policy DELETE from the retry, even one no pass has seen gone', async () => { + const apiserver = makeApiserverNetworkingApi() + const reconciler = new WorkflowReconciler( + makeDeps({ networkingApi: apiserver.api as never }) + ) + + const retried = await reconciler.retryRunLaneNetworkPolicies('test-wf', 'uid-123', spec()) + + // Liveness witness: the retry applied the lanes the spec wants. + expect( + apiserver.api.createNamespacedNetworkPolicy.mock.calls + .map(([arg]) => arg.body.metadata?.name) + .sort() + ).toEqual(RUN_LANE_POLICY_NAMES.map(key => key.split('/')[1]).sort()) + expect(retried).toEqual({ conflicts: [], retryPending: false }) + expect(legacyDeletes(apiserver)).toEqual([]) + expect(apiserver.api.deleteNamespacedNetworkPolicy).toHaveBeenCalledTimes(0) + expect(apiserver.api.listNamespacedNetworkPolicy).toHaveBeenCalledTimes(0) + }) + + // The retry computes its own verdict, and the verdict decides only + // which proxy it creates: the retry prunes nothing (see the per-path + // table on retryRunLaneNetworkPolicies), so a live proxy survives + // every verdict and one case covers them all. Both cases assert the + // create of a policy the spec wants as the liveness witness, so a + // negative assertion cannot pass because the retry never applied. + describe('with a fresh Codex/Grok verdict', () => { + const SNIPPET_SIBLING = 'test-wf-snippet-runner-egress' + const AGENT_LANE = 'test-wf-coord-to-mcp-host' + const CODEX_PROXY = 'test-wf-mcp-host-to-codex-proxy' + const projection = (overrides: Record) => ({ + targets: [], + eligibleTargets: [], + derivedScopes: [], + requiresCodexProxyEgress: false, + requiresGrokProxyEgress: false, + driftHashInput: '', + catalogContentHash: null, + catalogRevision: null, + connectionRevision: null, + eligibility: 'ineligible', + reason: 'static_only', + ...overrides, + }) + const REQUIRED = { eligibility: 'eligible', reason: 'granted' } + + // The proxy policies as the factory emits them for this recipe, stored + // the way the apiserver returns them. + function seedProxies(apiserver: Converged) { + const config = makeConfig() + const proxies = buildWorkflowNetworkPolicies( + { + recipeName: 'test-wf', + sandboxNamespace: config.sandboxNamespace, + controlPlaneNamespace: config.controlPlaneNamespace, + mcpServerNamespace: config.mcpServerNamespace, + wrcPort: config.wrcPort, + mcpHostPort: config.mcpHostPort, + includeMcpHost: true, + includeCodexProxyEgress: true, + includeGrokProxyEgress: true, + }, + [] + ).filter(policy => [CODEX_PROXY, GROK_PROXY].includes(policy.metadata?.name ?? '')) + expect(proxies.map(policy => policy.metadata?.name).sort()).toEqual([ + CODEX_PROXY, + GROK_PROXY, + ]) + for (const policy of proxies) { + apiserver.live.set( + apiserver.key('sandbox-recipes', policy.metadata!.name!), + asApiserverNetworkPolicy({ + ...policy, + metadata: { ...policy.metadata, namespace: 'sandbox-recipes' }, + }) + ) + } + } + + async function retryWith( + verdict: { codex: Record; grok: Record }, + seed: boolean + ) { + const { apiserver, reconciler } = await convergedRunLane() + apiserver.api.listNamespacedNetworkPolicy.mockClear() + if (seed) seedProxies(apiserver) + const verdictSpy = vi.spyOn(reconciler as never, 'codexVerdictFor').mockReturnValue({ + projection: projection(verdict.codex), + grokProjection: projection(verdict.grok), + } as never) + try { + // An agent recipe with a run id needs mcp-host, the only consumer of + // the Codex/Grok proxy policies; the snippet spec above never wants them. + const summary = await reconciler.retryRunLaneNetworkPolicies( + 'test-wf', + 'uid-123', + makeSpec(), + 'test-wf', + 'run-1' + ) + expect(verdictSpy).toHaveBeenCalledTimes(1) + return { apiserver, summary } + } finally { + verdictSpy.mockRestore() + } + } + + const createdNames = (apiserver: Converged) => + apiserver.api.createNamespacedNetworkPolicy.mock.calls.map( + ([arg]) => arg.body.metadata?.name + ) + + // Decision: the retry never revokes a Codex/Grok proxy. It runs only + // while a run is in progress or active, and a step of the running run + // may still be calling the proxy. The retired verdict is the one under + // which reconcile() deletes the proxy, so it is the case pinned here. + it('never revokes a live proxy mid-run, whatever the verdict', async () => { + const { apiserver, summary } = await retryWith({ codex: {}, grok: {} }, true) + + expect(createdNames(apiserver)).toContain(AGENT_LANE) + expect(apiserver.live.has(apiserver.key('sandbox-recipes', SNIPPET_SIBLING))).toBe(true) + expect(apiserver.api.listNamespacedNetworkPolicy).toHaveBeenCalledTimes(0) + expect(apiserver.api.deleteNamespacedNetworkPolicy).toHaveBeenCalledTimes(0) + expect(apiserver.live.has(apiserver.key('sandbox-recipes', CODEX_PROXY))).toBe(true) + expect(apiserver.live.has(apiserver.key('sandbox-recipes', GROK_PROXY))).toBe(true) + // A kept proxy is not a pending retry, so the marker can clear. + expect(summary.retryPending).toBe(false) + }) - expect(summary).toEqual({ conflicts: [], retryPending: true }) - expect(readPolicyNames(apiserver.api)).toContain('test-wf-coord-to-wrc') - expect(apiserver.api.createNamespacedNetworkPolicy).toHaveBeenCalledTimes(0) - expect(apiserver.api.replaceNamespacedNetworkPolicy).toHaveBeenCalledTimes(0) - expect(apiserver.api.deleteNamespacedNetworkPolicy).toHaveBeenCalledTimes(0) + // The verdict-dependent half: a required proxy is created (an allow) + // and a retired one is not, so a retry that ignores the verdict, + // creating both or neither, fails here. + it('creates only the proxy the fresh verdict requires', async () => { + const { apiserver, summary } = await retryWith( + { codex: { ...REQUIRED, requiresCodexProxyEgress: true }, grok: {} }, + false + ) + + expect(createdNames(apiserver)).toContain(AGENT_LANE) + expect(createdNames(apiserver)).toContain(CODEX_PROXY) + expect(apiserver.live.has(apiserver.key('sandbox-recipes', CODEX_PROXY))).toBe(true) + expect(createdNames(apiserver)).not.toContain(GROK_PROXY) + expect(apiserver.live.has(apiserver.key('sandbox-recipes', GROK_PROXY))).toBe(false) + expect(summary.retryPending).toBe(false) + }) + }) }) }) }) @@ -7788,6 +9228,151 @@ describe('WorkflowReconciler — reconcile loop', () => { }) }) +describe('cleanupPluginWorkloadSdk legacy internet policy ownership', () => { + const RECIPE = 'sdk-only' + const RECIPE_UID = 'uid-sdk-only' + const LEGACY = `${RECIPE}-mcp-servers-egress-internet` + + function legacyDeletes(networkingApi: ReturnType): number { + return networkingApi.deleteNamespacedNetworkPolicy.mock.calls.filter( + ([arg]) => arg.name === LEGACY + ).length + } + + function makeCleanupCoreApi(): ReturnType { + const coreApi = makeCoreApi() as ReturnType + coreApi.readNamespacedEndpoints = vi.fn().mockRejectedValue({ code: 404 }) + return coreApi + } + + it('routes a labeled legacy policy through the shared live ledger instead of the label sweep', async () => { + const networkingApi = makeNetworkingApi() as ReturnType + let legacyPresent = true + networkingApi.listNamespacedNetworkPolicy.mockImplementation( + async ({ namespace }: { namespace: string }) => ({ + items: + namespace === 'mcp-server' + ? legacyPresent + ? [ + { + metadata: { + name: LEGACY, + namespace, + labels: { 'clerum.io/recipe': RECIPE, 'clerum.io/managed-by': 'wrc' }, + }, + }, + ] + : [] + : [], + }) + ) + networkingApi.deleteNamespacedNetworkPolicy.mockImplementation(async ({ name }) => { + if (name === LEGACY) legacyPresent = false + return {} + }) + const coreApi = makeCoreApi() as ReturnType + coreApi.readNamespacedEndpoints = vi.fn().mockRejectedValue({ code: 404 }) + const reconciler = new WorkflowReconciler( + makeDeps({ + coreApi: coreApi as never, + networkingApi: networkingApi as never, + pluginWorkloadSdkRevocationClient: { + revoke: vi.fn().mockResolvedValue({ state: 'disabled' }), + } as never, + }) + ) + + await reconciler.cleanupPluginWorkloadSdk(RECIPE, { recipeUid: RECIPE_UID }) + + // Liveness witness: both label-sweep namespaces were evaluated, and the + // shared by-name path sent the sole legacy DELETE. + expect(networkingApi.listNamespacedNetworkPolicy).toHaveBeenCalledWith( + expect.objectContaining({ + namespace: 'mcp-server', + labelSelector: `clerum.io/recipe=${RECIPE},clerum.io/managed-by=wrc`, + }) + ) + expect(legacyDeletes(networkingApi)).toBe(1) + + await reconciler.retryLegacyMcpServersInternetEgressDelete(RECIPE, RECIPE_UID) + expect(legacyDeletes(networkingApi)).toBe(1) + }) + + it('keeps live cleanup on the shared backoff while finalizer cleanup gets one last chance', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + try { + const networkingApi = makeNetworkingApi() as ReturnType + let legacyDeleteFails = true + networkingApi.deleteNamespacedNetworkPolicy.mockImplementation(async ({ name }) => { + if (name === LEGACY && legacyDeleteFails) throw { code: 403, message: 'forbidden' } + return {} + }) + const reconciler = new WorkflowReconciler( + makeDeps({ + coreApi: makeCleanupCoreApi() as never, + networkingApi: networkingApi as never, + pluginWorkloadSdkRevocationClient: { + revoke: vi.fn().mockResolvedValue({ state: 'disabled' }), + } as never, + }) + ) + + await expect( + reconciler.retryLegacyMcpServersInternetEgressDelete(RECIPE, RECIPE_UID) + ).resolves.toBe('pending') + expect(legacyDeletes(networkingApi)).toBe(1) + + const pending = await reconciler + .cleanupPluginWorkloadSdk(RECIPE, { + recipeUid: RECIPE_UID, + }) + .catch(error => error) + expect(pending).toBeInstanceOf(LegacyNetworkPolicyDeletePendingError) + expect((pending as LegacyNetworkPolicyDeletePendingError).retryAfterMs).toBe( + LEGACY_NETWORK_POLICY_DELETE_BACKOFF_BASE_MS + ) + expect(legacyDeletes(networkingApi)).toBe(1) + + legacyDeleteFails = false + await reconciler.cleanupPluginWorkloadSdk(RECIPE, { + recipeUid: RECIPE_UID, + recipeDeleted: true, + }) + expect(legacyDeletes(networkingApi)).toBe(2) + + await reconciler.retryLegacyMcpServersInternetEgressDelete(RECIPE, RECIPE_UID) + expect(legacyDeletes(networkingApi)).toBe(3) + } finally { + vi.useRealTimers() + } + }) + + it('keeps the legacy policy live when cleanup preserves the workflow runtime', async () => { + const networkingApi = makeNetworkingApi() as ReturnType + const reconciler = new WorkflowReconciler( + makeDeps({ + coreApi: makeCleanupCoreApi() as never, + networkingApi: networkingApi as never, + pluginWorkloadSdkRevocationClient: { + revoke: vi.fn().mockResolvedValue({ state: 'disabled' }), + } as never, + }) + ) + + await reconciler.cleanupPluginWorkloadSdk('hybrid', { + recipeUid: 'uid-hybrid', + preserveWorkflowRuntime: true, + }) + + expect( + networkingApi.deleteNamespacedNetworkPolicy.mock.calls.filter( + ([arg]) => arg.name === 'hybrid-mcp-servers-egress-internet' + ) + ).toHaveLength(0) + expect(networkingApi.listNamespacedNetworkPolicy).not.toHaveBeenCalled() + }) +}) + describe('buildNetworkPolicyOwnershipConditions', () => { it('names the conflicting policies in sorted order, whatever order the apply produced them in', () => { const conditions = buildNetworkPolicyOwnershipConditions( @@ -7814,17 +9399,24 @@ describe('buildNetworkPolicyOwnershipConditions', () => { }) }) -// The one translation of an apply summary into status fields, shared by the -// run lane and the SDK-only lane. `toStrictEqual` pins which keys are present: -// an absent ownership key keeps the published condition, so it must not be -// emitted as an explicit `undefined`. -describe('translateNetworkPolicyApplySummary', () => { +// The one translation of a full pass summary into status fields, shared by +// the run lane, the eager lane and the SDK-only lane (the `reconcile` writer +// of Table A). `toStrictEqual` pins which keys are present: an absent +// ownership key keeps the published condition, so it must not be emitted as +// an explicit `undefined`. +describe('translateNetworkPolicyPassSummary', () => { const now = '2026-09-23T12:00:00.000Z' const earlier = '2026-09-20T08:00:00.000Z' - const conflictSummary = { - conflicts: [{ policy: 'test-wf-coord-to-wrc', reason: 'owner-reference-mismatch' as const }], + const pass = (overrides: Partial = {}): NetworkPolicyPassSummary => ({ + conflicts: [], retryPending: false, - } + prune: 'converged', + legacy: 'removed', + ...overrides, + }) + const conflictSummary = pass({ + conflicts: [{ policy: 'test-wf-coord-to-wrc', reason: 'owner-reference-mismatch' as const }], + }) const publishedConflict = { type: 'WorkflowNetworkPolicyOwnership', status: 'False' as const, @@ -7841,23 +9433,19 @@ describe('translateNetworkPolicyApplySummary', () => { } it('emits neither field when the pass produced no summary, so the published condition is kept', () => { - const result = translateNetworkPolicyApplySummary(undefined, [publishedConflict], now) + const result = translateNetworkPolicyPassSummary(undefined, [publishedConflict], now) expect(result).toStrictEqual({}) // Witness: the same existing conditions with a summary do produce the // field, so the empty result above is caused by the missing summary. expect( - translateNetworkPolicyApplySummary(conflictSummary, [publishedConflict], now) + translateNetworkPolicyPassSummary(conflictSummary, [publishedConflict], now) .networkPolicyOwnershipConditions ).toEqual([publishedConflict]) }) it('clears the condition with [] and sets no retry flag when nothing conflicts or is pending', () => { - const result = translateNetworkPolicyApplySummary( - { conflicts: [], retryPending: false }, - [publishedConflict], - now - ) + const result = translateNetworkPolicyPassSummary(pass(), [publishedConflict], now) expect(result.networkPolicyOwnershipConditions).toEqual([]) expect(result).not.toHaveProperty('networkPolicyRetryPending') @@ -7865,8 +9453,8 @@ describe('translateNetworkPolicyApplySummary', () => { }) it('replaces the conflict with the retry marker and sets the retry flag when a retry is pending without a conflict', () => { - const result = translateNetworkPolicyApplySummary( - { conflicts: [], retryPending: true }, + const result = translateNetworkPolicyPassSummary( + pass({ retryPending: true }), [publishedConflict], now ) @@ -7878,8 +9466,8 @@ describe('translateNetworkPolicyApplySummary', () => { }) it('keeps the lastTransitionTime of a published retry marker while the retry stays pending', () => { - const result = translateNetworkPolicyApplySummary( - { conflicts: [], retryPending: true }, + const result = translateNetworkPolicyPassSummary( + pass({ retryPending: true }), [publishedRetryMarker], now ) @@ -7891,24 +9479,19 @@ describe('translateNetworkPolicyApplySummary', () => { }) it('clears a published retry marker with [] once no retry is pending', () => { - const result = translateNetworkPolicyApplySummary( - { conflicts: [], retryPending: false }, - [publishedRetryMarker], - now - ) + const result = translateNetworkPolicyPassSummary(pass(), [publishedRetryMarker], now) expect(result).toStrictEqual({ networkPolicyOwnershipConditions: [] }) }) it('replaces the condition with one False condition naming every conflicting policy', () => { - const result = translateNetworkPolicyApplySummary( - { + const result = translateNetworkPolicyPassSummary( + pass({ conflicts: [ { policy: 'test-wf-wrc-to-artifact-reader', reason: 'identity-label-mismatch' }, { policy: 'test-wf-coord-to-wrc', reason: 'owner-reference-mismatch' }, ], - retryPending: false, - }, + }), undefined, now ) @@ -7927,14 +9510,14 @@ describe('translateNetworkPolicyApplySummary', () => { }) it('keeps the lastTransitionTime of an identical published condition', () => { - const result = translateNetworkPolicyApplySummary(conflictSummary, [publishedConflict], now) + const result = translateNetworkPolicyPassSummary(conflictSummary, [publishedConflict], now) expect(result).toStrictEqual({ networkPolicyOwnershipConditions: [publishedConflict] }) expect(result.networkPolicyOwnershipConditions?.[0]?.lastTransitionTime).toBe(earlier) }) it('reports a conflict and a pending retry from the same pass together', () => { - const result = translateNetworkPolicyApplySummary( + const result = translateNetworkPolicyPassSummary( { ...conflictSummary, retryPending: true }, undefined, now @@ -7948,6 +9531,451 @@ describe('translateNetworkPolicyApplySummary', () => { networkPolicyRetryPending: true, }) }) + + // R3-L3: a prune that could not delete (a non-404 DELETE) is its own fact; + // a failed LIST is `unevaluated` (R4-L6). It is published as PrunePending + // and never sets the retry flag: nothing in the run's short-circuits can + // prune, so a requeue would only repeat the failed DELETE. + describe('prune-pending', () => { + const publishedPruneMarker = { + type: 'WorkflowNetworkPoliciesConverged', + status: 'False' as const, + reason: 'PrunePending', + message: + 'One or more run-lane NetworkPolicies the spec no longer wants are pending a delete; the next reconcile() pass or the finalizer removes them', + lastTransitionTime: earlier, + } + + it('publishes PrunePending without the retry flag when only the prune is pending', () => { + const result = translateNetworkPolicyPassSummary( + pass({ prune: 'pending' }), + [publishedConflict], + now + ) + + expect(result).toStrictEqual({ + networkPolicyOwnershipConditions: [{ ...publishedPruneMarker, lastTransitionTime: now }], + }) + }) + + it('clears a published PrunePending marker once a pass pruned', () => { + const result = translateNetworkPolicyPassSummary(pass(), [publishedPruneMarker], now) + + expect(result).toStrictEqual({ networkPolicyOwnershipConditions: [] }) + }) + + it('reports a pending retry and a pending prune in one RetryAndPrunePending marker', () => { + const result = translateNetworkPolicyPassSummary( + pass({ retryPending: true, prune: 'pending' }), + [publishedPruneMarker], + now + ) + + expect(result).toStrictEqual({ + networkPolicyOwnershipConditions: [ + { + type: 'WorkflowNetworkPoliciesConverged', + status: 'False', + reason: 'RetryAndPrunePending', + message: + 'One or more run-lane NetworkPolicies are pending a retry (terminating or contended), and one or more the spec no longer wants are pending a delete', + lastTransitionTime: earlier, + }, + ], + networkPolicyRetryPending: true, + }) + }) + }) + + // R4-L6: a failed prune LIST evaluated nothing, so the prune fact is + // `unevaluated`, never `pending`. It has its own reasons and sets no retry + // flag of its own. + describe('prune-unevaluated', () => { + const convergedMarkers = (result: ReturnType) => + (result.networkPolicyOwnershipConditions ?? []).filter( + c => c.type === 'WorkflowNetworkPoliciesConverged' + ) + + it('R4-L6: publishes PruneUnevaluated without the retry flag when the prune LIST failed', () => { + const result = translateNetworkPolicyPassSummary( + pass({ prune: 'unevaluated' }), + [publishedConflict], + now + ) + + expect(convergedMarkers(result)).toMatchObject([ + { status: 'False', reason: 'PruneUnevaluated', lastTransitionTime: now }, + ]) + expect(result.networkPolicyRetryPending).toBeUndefined() + }) + + it('R4-L6: publishes RetryAndPruneUnevaluated for a pending retry and an unevaluated prune', () => { + const result = translateNetworkPolicyPassSummary( + pass({ retryPending: true, prune: 'unevaluated' }), + [], + now + ) + + expect(convergedMarkers(result)).toMatchObject([ + { status: 'False', reason: 'RetryAndPruneUnevaluated' }, + ]) + expect(result.networkPolicyRetryPending).toBe(true) + }) + }) + + // R4-L3: the legacy policy is its own fact, on its own condition. + describe('legacy', () => { + const legacyMarkers = (result: ReturnType) => + (result.networkPolicyOwnershipConditions ?? []).filter( + c => c.type === 'WorkflowLegacyNetworkPolicyRemoved' + ) + + it('R4-L3: publishes DeletePending on its own condition, apart from the prune marker', () => { + const result = translateNetworkPolicyPassSummary(pass({ legacy: 'pending' }), [], now) + + expect(result).toStrictEqual({ + networkPolicyOwnershipConditions: [ + { + type: 'WorkflowLegacyNetworkPolicyRemoved', + status: 'False', + reason: 'DeletePending', + message: + 'The legacy mcp-servers internet egress NetworkPolicy is pending a delete; a later pass or the finalizer removes it', + lastTransitionTime: now, + }, + ], + }) + }) + + it('R4-L3: keeps the first transition time while the delete stays pending, and clears it once removed', () => { + const published = translateNetworkPolicyPassSummary( + pass({ legacy: 'pending' }), + [], + earlier + ).networkPolicyOwnershipConditions! + // Liveness witness: the failing pass published the marker. + expect(legacyMarkers({ networkPolicyOwnershipConditions: published })).toHaveLength(1) + + const still = translateNetworkPolicyPassSummary(pass({ legacy: 'pending' }), published, now) + expect(legacyMarkers(still)).toMatchObject([{ lastTransitionTime: earlier }]) + + expect(translateNetworkPolicyPassSummary(pass(), published, now)).toStrictEqual({ + networkPolicyOwnershipConditions: [], + }) + }) + }) +}) + +// R4-L3: every writer goes through `networkPolicyMarkerConditions`, and each +// fact is observed, carried or cleared as Table A says. +describe('networkPolicyMarkerConditions', () => { + const now = '2026-09-23T12:00:00.000Z' + const earlier = '2026-09-20T08:00:00.000Z' + const pass = (overrides: Partial = {}): NetworkPolicyPassSummary => ({ + conflicts: [], + retryPending: false, + prune: 'converged', + legacy: 'removed', + ...overrides, + }) + const conflict = { policy: 'test-wf-coord-to-wrc', reason: 'owner-reference-mismatch' as const } + const published = (overrides: Partial) => + networkPolicyMarkerConditions({ kind: 'reconcile', summary: pass(overrides) }, [], earlier) + const reasons = (conditions: { type: string; reason?: string }[]) => + conditions.map(c => `${c.type}/${c.reason}`) + + describe('apply-retry', () => { + it('R4-L3: observes the apply and carries the prune and legacy facts', () => { + const existing = published({ retryPending: true, prune: 'pending', legacy: 'pending' }) + // Liveness witness: all three facts were published. + expect(reasons(existing)).toEqual([ + 'WorkflowNetworkPoliciesConverged/RetryAndPrunePending', + 'WorkflowLegacyNetworkPolicyRemoved/DeletePending', + ]) + + const settled = networkPolicyMarkerConditions( + { kind: 'apply-retry', summary: { conflicts: [], retryPending: false } }, + existing, + now + ) + + expect(reasons(settled)).toEqual([ + 'WorkflowNetworkPoliciesConverged/PrunePending', + 'WorkflowLegacyNetworkPolicyRemoved/DeletePending', + ]) + expect(settled.every(c => c.lastTransitionTime === earlier)).toBe(true) + }) + + it('R4-L3: carries an unevaluated prune through a pending retry', () => { + const existing = published({ prune: 'unevaluated' }) + + const retried = networkPolicyMarkerConditions( + { kind: 'apply-retry', summary: { conflicts: [], retryPending: true } }, + existing, + now + ) + + expect(reasons(retried)).toEqual([ + 'WorkflowNetworkPoliciesConverged/RetryAndPruneUnevaluated', + ]) + }) + + it('R4-L3: publishes the conflicts the retry observed', () => { + const retried = networkPolicyMarkerConditions( + { kind: 'apply-retry', summary: { conflicts: [conflict], retryPending: false } }, + [], + now + ) + + expect(reasons(retried)).toEqual(['WorkflowNetworkPolicyOwnership/OwnershipConflict']) + }) + }) + + describe('legacy-retry', () => { + it('R4-L4: clears the legacy fact once removed and carries the apply, the prune and the ownership condition', () => { + const existing = published({ + conflicts: [conflict], + retryPending: true, + prune: 'unevaluated', + legacy: 'pending', + }) + // Liveness witness: all three conditions were published. + expect(existing).toHaveLength(3) + + const settled = networkPolicyMarkerConditions( + { kind: 'legacy-retry', legacy: 'removed' }, + existing, + now + ) + + expect(reasons(settled)).toEqual([ + 'WorkflowNetworkPolicyOwnership/OwnershipConflict', + 'WorkflowNetworkPoliciesConverged/RetryAndPruneUnevaluated', + ]) + expect(settled.every(c => c.lastTransitionTime === earlier)).toBe(true) + }) + + it('R4-L4: keeps DeletePending and its transition time while the delete stays pending', () => { + const existing = published({ prune: 'pending', legacy: 'pending' }) + expect(existing).toHaveLength(2) + + expect( + networkPolicyMarkerConditions({ kind: 'legacy-retry', legacy: 'pending' }, existing, now) + ).toEqual(existing) + }) + + it('R4-L4: enters only for a published DeletePending', () => { + expect(hasLegacyNetworkPolicyDeletePendingMarker(published({ legacy: 'pending' }))).toBe(true) + expect(hasLegacyNetworkPolicyDeletePendingMarker(published({ prune: 'pending' }))).toBe(false) + expect(hasLegacyNetworkPolicyDeletePendingMarker(undefined)).toBe(false) + expect( + hasLegacyNetworkPolicyDeletePendingMarker([ + { + type: 'WorkflowLegacyNetworkPolicyRemoved', + status: 'False', + reason: 'SomethingElse', + message: 'written by another head', + lastTransitionTime: earlier, + }, + ]) + ).toBe(false) + }) + }) + + describe('terminal', () => { + it('R4-L3: drops a pending apply and carries the prune, the legacy fact and the ownership condition', () => { + const existing = published({ + conflicts: [conflict], + retryPending: true, + prune: 'pending', + legacy: 'pending', + }) + // Liveness witness: all three conditions were published. + expect(existing).toHaveLength(3) + + const settled = networkPolicyMarkerConditions({ kind: 'terminal' }, existing, now) + + expect(reasons(settled)).toEqual([ + 'WorkflowNetworkPolicyOwnership/OwnershipConflict', + 'WorkflowNetworkPoliciesConverged/PrunePending', + 'WorkflowLegacyNetworkPolicyRemoved/DeletePending', + ]) + }) + + it('R4-L3: removes a marker that only named a pending apply', () => { + const existing = published({ retryPending: true }) + expect(reasons(existing)).toEqual(['WorkflowNetworkPoliciesConverged/RetryPending']) + + expect(networkPolicyMarkerConditions({ kind: 'terminal' }, existing, now)).toEqual([]) + }) + }) + + describe('unmanaged', () => { + it('R4-L3: clears every fact after a full SDK teardown', () => { + const existing = published({ + conflicts: [conflict], + retryPending: true, + prune: 'unevaluated', + legacy: 'pending', + }) + // Liveness witness: the group was published. + expect(existing).toHaveLength(3) + + expect(networkPolicyMarkerConditions({ kind: 'unmanaged' }, existing, now)).toEqual([]) + }) + }) + + describe('a reason this controller did not write', () => { + const foreignMarker = { + type: 'WorkflowNetworkPoliciesConverged', + status: 'False' as const, + reason: 'SomethingElse', + message: 'written by another head', + lastTransitionTime: earlier, + } + const foreignLegacy = { + type: 'WorkflowLegacyNetworkPolicyRemoved', + status: 'False' as const, + reason: 'SomethingElse', + message: 'written by another head', + lastTransitionTime: earlier, + } + + it('R4-L3: is kept as it is by a carrying writer, which warns', () => { + const warnLog = captureLogger('warn') + try { + const carried = networkPolicyMarkerConditions( + { kind: 'terminal' }, + [foreignMarker, foreignLegacy], + now + ) + + expect(carried).toEqual([foreignMarker, foreignLegacy]) + expect(warnLog).toHaveBeenCalledTimes(2) + expect(warnLog).toHaveBeenCalledWith( + 'Keeping a NetworkPolicy marker with a reason this controller did not write', + { + writer: 'terminal', + type: 'WorkflowNetworkPoliciesConverged', + status: 'False', + reason: 'SomethingElse', + } + ) + } finally { + warnLog.mockRestore() + } + }) + + it('R4-L3: is overwritten by reconcile()', () => { + const warnLog = captureLogger('warn') + try { + const overwritten = networkPolicyMarkerConditions( + { kind: 'reconcile', summary: pass({ prune: 'pending' }) }, + [foreignMarker, foreignLegacy], + now + ) + + expect(reasons(overwritten)).toEqual(['WorkflowNetworkPoliciesConverged/PrunePending']) + expect(warnLog).not.toHaveBeenCalled() + // Liveness witness: the same input through a carrying writer warns. + networkPolicyMarkerConditions({ kind: 'terminal' }, [foreignMarker], now) + expect(warnLog).toHaveBeenCalledTimes(1) + } finally { + warnLog.mockRestore() + } + }) + }) +}) + +// R4-L5: each combination of facts has its own reason, and the facts are read +// back from the reason alone. A reworded message must not change what a +// published marker means. R4-L6: the prune fact has three values (Table A), +// so there are five combinations with a marker. +describe('networkPolicyMarkerFacts', () => { + const now = '2026-09-23T12:00:00.000Z' + const combinations = [ + { apply: 'pending', prune: 'converged' }, + { apply: 'converged', prune: 'pending' }, + { apply: 'converged', prune: 'unevaluated' }, + { apply: 'pending', prune: 'pending' }, + { apply: 'pending', prune: 'unevaluated' }, + ] as const + + it('R4-L5: a pending apply and a pending prune publish their own reason', () => { + const marker = buildNetworkPolicyConvergedCondition({ apply: 'pending', prune: 'pending' }, now) + expect(marker?.reason).toBe('RetryAndPrunePending') + }) + + it('R4-L6: an unevaluated prune publishes its own reason, alone or with a pending apply', () => { + expect( + buildNetworkPolicyConvergedCondition({ apply: 'converged', prune: 'unevaluated' }, now) + ?.reason + ).toBe('PruneUnevaluated') + expect( + buildNetworkPolicyConvergedCondition({ apply: 'pending', prune: 'unevaluated' }, now)?.reason + ).toBe('RetryAndPruneUnevaluated') + }) + + it('R4-L6: converged facts publish no marker and read back from no marker', () => { + expect( + buildNetworkPolicyConvergedCondition({ apply: 'converged', prune: 'converged' }, now) + ).toBeUndefined() + expect(networkPolicyMarkerFacts([])).toStrictEqual({ apply: 'converged', prune: 'converged' }) + }) + + it.each(combinations)( + 'R4-L5: reads %o back from the reason alone, whatever the message says', + facts => { + const marker = buildNetworkPolicyConvergedCondition(facts, now) + // Liveness witness: the builder published a marker for these facts. + expect(marker?.type).toBe('WorkflowNetworkPoliciesConverged') + expect(marker?.status).toBe('False') + expect(networkPolicyMarkerFacts([marker!])).toStrictEqual(facts) + expect(networkPolicyMarkerFacts([{ ...marker!, message: 'reworded' }])).toStrictEqual(facts) + } + ) + + it('R4-L5: every combination has a distinct reason', () => { + const reasons = combinations.map( + facts => buildNetworkPolicyConvergedCondition(facts, now)?.reason + ) + expect(new Set(reasons).size).toBe(combinations.length) + }) + + it('reads the one historical RetryPending message that encoded a pending prune', () => { + const historical = { + type: 'WorkflowNetworkPoliciesConverged', + status: 'False' as const, + reason: 'RetryPending', + message: + 'One or more run-lane NetworkPolicies are pending a retry (terminating or contended), and one or more the spec no longer wants are pending a delete', + lastTransitionTime: now, + } + + expect(networkPolicyMarkerFacts([historical])).toStrictEqual({ + apply: 'pending', + prune: 'pending', + }) + expect( + networkPolicyMarkerFacts([{ ...historical, message: `${historical.message} ` }]) + ).toStrictEqual({ + apply: 'pending', + prune: 'converged', + }) + + const settled = networkPolicyMarkerConditions( + { kind: 'apply-retry', summary: { conflicts: [], retryPending: false } }, + [historical], + now + ) + expect(settled).toMatchObject([ + { + type: 'WorkflowNetworkPoliciesConverged', + status: 'False', + reason: 'PrunePending', + }, + ]) + }) }) describe('networkPolicyConditionsChanged', () => { From daf484ce3ebcf3a0748c96aed8bbc31cd51b748d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:33:13 -0600 Subject: [PATCH 279/528] test(control-api): align Task 106 PostgreSQL cleanup with dev Use the incoming teardown helper and isolated database cleanup contract in Task 106 Real PostgreSQL fixtures without changing production behavior. --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- ...ordinator.realPostgres.integration.test.ts | 22 ++++++---- ...gFamilies.realPostgres.integration.test.ts | 24 ++++++----- ...rformance.realPostgres.integration.test.ts | 22 ++++++---- ...logShadow.realPostgres.integration.test.ts | 20 +++++---- ...cellation.realPostgres.integration.test.ts | 41 ++++++++++++++++--- ...agination.realPostgres.integration.test.ts | 22 ++++++---- ...Producers.realPostgres.integration.test.ts | 22 ++++++---- ...omponents.realPostgres.integration.test.ts | 22 ++++++---- ...ngleTouch.realPostgres.integration.test.ts | 24 ++++++----- ...Lifecycle.realPostgres.integration.test.ts | 24 ++++++----- ...LockOrder.realPostgres.integration.test.ts | 22 ++++++---- ...nboarding.realPostgres.integration.test.ts | 22 ++++++---- ...hBackfill.realPostgres.integration.test.ts | 20 +++++---- ...hangeRace.realPostgres.integration.test.ts | 22 ++++++---- ...ionPolicy.realPostgres.integration.test.ts | 20 +++++---- ...atibility.realPostgres.integration.test.ts | 24 ++++++----- ...Authority.realPostgres.integration.test.ts | 20 +++++---- ...evocation.realPostgres.integration.test.ts | 20 +++++---- ...eContract.realPostgres.integration.test.ts | 22 ++++++---- ...baseClock.realPostgres.integration.test.ts | 22 ++++++---- 22 files changed, 284 insertions(+), 179 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index f91636e539..def1061e8c 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.724", + "version": "0.1.725", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.724", + "version": "0.1.725", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 50219ce5d1..80a38aff52 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.724", + "version": "0.1.725", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/accessCatalogCoordinator.realPostgres.integration.test.ts b/control-api/test/accessCatalogCoordinator.realPostgres.integration.test.ts index ca63992f65..ecaf3e9a90 100644 --- a/control-api/test/accessCatalogCoordinator.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogCoordinator.realPostgres.integration.test.ts @@ -12,6 +12,7 @@ import { resolveLiveAuthorization } from '../src/services/access/liveAuthorizati import { canonicalEnvironmentId } from '../src/services/access/operationalAccessProjection.js' import { canonicalResourceIdentity } from '../src/services/access/resourceIdentity.js' import type { ExternalSessionAuthorityContext } from '../src/services/auth/externalSessionAuthentication.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -91,17 +92,20 @@ describeRealPostgres('aggregate catalog coordinator on real PostgreSQL', () => { }) afterAll(async () => { - await databasePool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) + try { + await endPoolAndWaitForClients(databasePool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) - await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts index 2c38f4164d..97d9f02399 100644 --- a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts @@ -24,6 +24,7 @@ import { loadConfiguredUserAccessIntent, } from '../src/services/access/userAccessPolicy.js' import type { ExternalSessionAuthorityContext } from '../src/services/auth/externalSessionAuthentication.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' import { TemporaryKubernetesApi } from './helpers/temporaryKubernetesApi.js' vi.mock('../src/services/access/operationTarget.js', async importOriginal => { @@ -329,18 +330,21 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { }) afterAll(async () => { - await kubernetesApi.close() - await databasePool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) + try { + await kubernetesApi.close() + await endPoolAndWaitForClients(databasePool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) - await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts b/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts index 0c6d78c412..81aeabfde8 100644 --- a/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts @@ -18,6 +18,7 @@ import { } from '../src/services/access/teamGfsTopK.js' import type { ExternalSessionAuthorityContext } from '../src/services/auth/externalSessionAuthentication.js' import { CatalogQueryObservation } from './accessCatalogQueryObservation.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -124,17 +125,20 @@ describeRealPostgres('aggregate catalog plans on real PostgreSQL', () => { }) afterAll(async () => { - await databasePool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) + try { + await endPoolAndWaitForClients(databasePool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) - await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/accessCatalogShadow.realPostgres.integration.test.ts b/control-api/test/accessCatalogShadow.realPostgres.integration.test.ts index 80e6231953..bd9d781480 100644 --- a/control-api/test/accessCatalogShadow.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogShadow.realPostgres.integration.test.ts @@ -10,6 +10,7 @@ import { } from '../src/services/access/accessDatabaseQuery.js' import { AccessExecutionBudget } from '../src/services/access/accessExecutionBudget.js' import type { ExternalSessionAuthorityContext } from '../src/services/auth/externalSessionAuthentication.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -100,16 +101,19 @@ describeRealPostgres('aggregate shadow physical statement budget on real Postgre }) afterAll(async () => { - await databasePool?.end() - if (!adminPool) return - await adminPool.query( - `SELECT pg_terminate_backend(pid) + try { + await endPoolAndWaitForClients(databasePool) + if (!adminPool) return + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + } finally { + await adminPool?.end() + } }) it('executes and charges a real shadow SQL statement only when capacity is reserved', async () => { diff --git a/control-api/test/accessDatabaseCancellation.realPostgres.integration.test.ts b/control-api/test/accessDatabaseCancellation.realPostgres.integration.test.ts index e58a139267..f7b195e95c 100644 --- a/control-api/test/accessDatabaseCancellation.realPostgres.integration.test.ts +++ b/control-api/test/accessDatabaseCancellation.realPostgres.integration.test.ts @@ -1,4 +1,5 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { randomBytes } from 'node:crypto' import { Pool, type PoolClient } from 'pg' import { runAccessDatabaseQuery, @@ -11,6 +12,8 @@ import { } from '../src/services/access/accessExecutionBudget.js' import { catalogQuery } from '../src/services/access/catalogProducerSupport.js' import { OperationalAccessIndex } from '../src/services/access/operationalAccessIndex.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' +import { waitForDatabaseConnectionsToClose } from './realPostgresCleanup.ts' describe('physical access database statement accounting', () => { it('charges before execution and rejects the statement after the configured limit', async () => { @@ -95,18 +98,44 @@ const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip describeRealPostgres('access database cancellation on real PostgreSQL', () => { - let pool: Pool + const database = `access_cancel_${randomBytes(6).toString('hex')}` + let adminPool: Pool + let testPool: Pool - beforeAll(() => { - pool = new Pool({ connectionString: adminUrl }) + function databaseUrl(baseUrl: string, databaseName: string): string { + const url = new URL(baseUrl) + url.pathname = `/${databaseName}` + return url.toString() + } + + function quoteIdentifier(value: string): string { + return `"${value.replace(/"/g, '""')}"` + } + + beforeAll(async () => { + adminPool = new Pool({ connectionString: adminUrl }) + await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) + testPool = new Pool({ + connectionString: databaseUrl( + adminUrl ?? 'postgresql://postgres@db.example.com/postgres', + database + ), + }) }) afterAll(async () => { - await pool?.end() + try { + await endPoolAndWaitForClients(testPool) + if (!adminPool) return + await waitForDatabaseConnectionsToClose(adminPool, database) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + } finally { + await adminPool?.end() + } }) it('cancels an active statement when its request budget is cancelled', async () => { - const client = (await pool.connect()) as PoolClient + const client = (await testPool.connect()) as PoolClient const budget = AccessExecutionBudget.create('catalog') const startedAt = performance.now() try { @@ -121,7 +150,7 @@ describeRealPostgres('access database cancellation on real PostgreSQL', () => { }) it('maps a real statement timeout to authoritative budget exhaustion', async () => { - const client = (await pool.connect()) as PoolClient + const client = (await testPool.connect()) as PoolClient const budget = AccessExecutionBudget.create('catalog') const startedAt = performance.now() try { diff --git a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts index a694295fdc..dc30c4de4a 100644 --- a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts +++ b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts @@ -20,6 +20,7 @@ import { } from '../src/services/access/operationalAccessProjection.js' import { teamGfsTopKSql } from '../src/services/access/teamGfsTopK.js' import type { ExternalSessionAuthorityContext } from '../src/services/auth/externalSessionAuthentication.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -295,17 +296,20 @@ describeRealPostgres('distinct catalog key pagination on real PostgreSQL', () => }) afterAll(async () => { - await databasePool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) + try { + await endPoolAndWaitForClients(databasePool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) - await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/catalogProducers.realPostgres.integration.test.ts b/control-api/test/catalogProducers.realPostgres.integration.test.ts index 051f254a54..5ca19d66ec 100644 --- a/control-api/test/catalogProducers.realPostgres.integration.test.ts +++ b/control-api/test/catalogProducers.realPostgres.integration.test.ts @@ -19,6 +19,7 @@ import { canonicalEnvironmentId, projectOperationalObject, } from '../src/services/access/operationalAccessProjection.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -58,17 +59,20 @@ describeRealPostgres('catalog producer SQL on real PostgreSQL', () => { }) afterAll(async () => { - await databasePool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) + try { + await endPoolAndWaitForClients(databasePool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) - await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts b/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts index a331692c07..e71d66b9de 100644 --- a/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts +++ b/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts @@ -8,6 +8,7 @@ import { GFS_HYDRATION_SQL } from '../src/services/access/catalogHydrationSql.js import { canonicalEnvironmentId } from '../src/services/access/operationalAccessProjection.js' import { applyComposableCatalogRevisionSchema } from '../src/services/access/userAccessFoundationSchema.js' import type { ExternalSessionAuthorityContext } from '../src/services/auth/externalSessionAuthentication.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -119,17 +120,20 @@ describeRealPostgres('composable catalog revisions on real PostgreSQL', () => { }) afterAll(async () => { - await databasePool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) + try { + await endPoolAndWaitForClients(databasePool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) - await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/externalSessionSingleTouch.realPostgres.integration.test.ts b/control-api/test/externalSessionSingleTouch.realPostgres.integration.test.ts index b096aafc6f..172a096b5c 100644 --- a/control-api/test/externalSessionSingleTouch.realPostgres.integration.test.ts +++ b/control-api/test/externalSessionSingleTouch.realPostgres.integration.test.ts @@ -8,6 +8,7 @@ import { authenticateExternalUserSessionIdentity, } from '../src/services/auth/externalSessionAuthentication.js' import { createUserSession } from '../src/services/auth/userSessionService.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -71,18 +72,21 @@ describeRealPostgres('external-session staged touch ownership on real PostgreSQL }) afterAll(async () => { - corePoolConnectSpy?.mockRestore() - await databasePool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) + try { + corePoolConnectSpy?.mockRestore() + await endPoolAndWaitForClients(databasePool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) - await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/finalAuthorityLifecycle.realPostgres.integration.test.ts b/control-api/test/finalAuthorityLifecycle.realPostgres.integration.test.ts index 78bb657bb7..2988e1cd92 100644 --- a/control-api/test/finalAuthorityLifecycle.realPostgres.integration.test.ts +++ b/control-api/test/finalAuthorityLifecycle.realPostgres.integration.test.ts @@ -11,6 +11,7 @@ import { canonicalResourceIdentity } from '../src/services/access/resourceIdenti import type { ExternalSessionAuthorityContext } from '../src/services/auth/externalSessionAuthentication.js' import { createUserSession } from '../src/services/auth/userSessionService.js' import { retireDesktopUser } from '../src/services/directory/users.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -154,18 +155,21 @@ describeRealPostgres('final catalog and resolve lifecycle authority on real Post }) afterAll(async () => { - corePoolConnectSpy?.mockRestore() - await databasePool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) + try { + corePoolConnectSpy?.mockRestore() + await endPoolAndWaitForClients(databasePool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) - await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/gfsRevisionLockOrder.realPostgres.integration.test.ts b/control-api/test/gfsRevisionLockOrder.realPostgres.integration.test.ts index 6319ba9f30..8a54c9c9d0 100644 --- a/control-api/test/gfsRevisionLockOrder.realPostgres.integration.test.ts +++ b/control-api/test/gfsRevisionLockOrder.realPostgres.integration.test.ts @@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { randomBytes, randomUUID } from 'node:crypto' import { Pool, type PoolClient } from 'pg' import { initDb } from '../src/db.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -114,17 +115,20 @@ describeRealPostgres('GFS subject revision lock order on real PostgreSQL', () => }) afterAll(async () => { - await databasePool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) + try { + await endPoolAndWaitForClients(databasePool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) - await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/invitationAcceptedOnboarding.realPostgres.integration.test.ts b/control-api/test/invitationAcceptedOnboarding.realPostgres.integration.test.ts index 459c2fa1e5..6b655d264b 100644 --- a/control-api/test/invitationAcceptedOnboarding.realPostgres.integration.test.ts +++ b/control-api/test/invitationAcceptedOnboarding.realPostgres.integration.test.ts @@ -10,6 +10,7 @@ import { setInvitationPasswordForEmail, setInvitationPasswordForUser, } from '../src/services/directory/membership.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -100,17 +101,20 @@ describeRealPostgres('accepted onboarding expiry on real PostgreSQL', () => { }) afterAll(async () => { - await databasePool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) + try { + await endPoolAndWaitForClients(databasePool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) - await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts b/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts index 6cfd0fa78b..572c21b798 100644 --- a/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts +++ b/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts @@ -7,6 +7,7 @@ import { signExternalSessionToken, verifyExternalSessionToken, } from '../src/utils/auth/externalSessionAuthToken.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -57,16 +58,19 @@ describeRealPostgres('legacy password epoch backfill on real PostgreSQL', () => }) afterAll(async () => { - await databasePool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) + try { + await endPoolAndWaitForClients(databasePool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts b/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts index 607ea06317..c045f55e42 100644 --- a/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts +++ b/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts @@ -24,6 +24,7 @@ import { verifyExternalSessionToken, } from '../src/utils/auth/externalSessionAuthToken.js' import { verifyUserSessionV2Token } from '../src/utils/auth/userSessionV2Token.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -228,17 +229,20 @@ describeRealPostgres('legacy replacement exchange serialization on real PostgreS }) afterAll(async () => { - await databasePool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) + try { + await endPoolAndWaitForClients(databasePool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) - await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts index 9156249c45..988b43132f 100644 --- a/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts +++ b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts @@ -14,6 +14,7 @@ import { PR1_ONLINE_INDEX_PLAN, ensureOnlineIndex, } from '../src/migrations/pr1OnlineIndexPlan.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -79,17 +80,20 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { }) afterAll(async () => { - await databasePool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) + try { + await endPoolAndWaitForClients(databasePool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts b/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts index ae668f1806..2a8ad6e87e 100644 --- a/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts +++ b/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts @@ -3,6 +3,7 @@ import { createHash, randomBytes, randomUUID } from 'node:crypto' import { readFileSync } from 'node:fs' import { Pool, type PoolClient } from 'pg' import { initDb } from '../src/db.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -54,19 +55,22 @@ describeRealPostgres('D34 previous-image writer compatibility on real PostgreSQL }) afterAll(async () => { - vi.doUnmock('../src/db.js') - vi.resetModules() - await databasePool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) + try { + vi.doUnmock('../src/db.js') + vi.resetModules() + await endPoolAndWaitForClients(databasePool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts b/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts index cf07fc03ec..da4b5ddfe1 100644 --- a/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts +++ b/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts @@ -12,6 +12,7 @@ import { acceptInvitationForEmail, createManagedInvitationForUser, } from '../src/services/directory/index.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -429,16 +430,19 @@ describeRealPostgres('external team mutations final source authority on real Pos }) afterAll(async () => { - await databasePool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) + try { + await endPoolAndWaitForClients(databasePool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts b/control-api/test/routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts index 5805e114e1..d1942a2f4e 100644 --- a/control-api/test/routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts +++ b/control-api/test/routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts @@ -7,6 +7,7 @@ import { initDb } from '../src/db.js' import { createExternalTeamsRouter } from '../src/routes/external/teams.js' import { issueExternalUserSession } from '../src/services/auth/externalSessionIssuance.js' import { verifyExternalSessionToken } from '../src/utils/auth/externalSessionAuthToken.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const userId = '11111111-1111-4111-8111-111111111111' const teamId = '22222222-2222-4222-8222-222222222222' @@ -165,16 +166,19 @@ describeRealPostgres('legacy V1 live-admin revocation on real external team rout }) afterAll(async () => { - await databasePool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) + try { + await endPoolAndWaitForClients(databasePool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/userAccessRuntimeContract.realPostgres.integration.test.ts b/control-api/test/userAccessRuntimeContract.realPostgres.integration.test.ts index fae67778fb..e59c4aca10 100644 --- a/control-api/test/userAccessRuntimeContract.realPostgres.integration.test.ts +++ b/control-api/test/userAccessRuntimeContract.realPostgres.integration.test.ts @@ -19,6 +19,7 @@ import { revokeUserSession, } from '../src/services/auth/userSessionService.js' import { verifyUserSessionV2Token } from '../src/utils/auth/userSessionV2Token.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -85,17 +86,20 @@ describeRealPostgres('user-access runtime role contract on real PostgreSQL', () }) afterAll(async () => { - await databasePool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) + try { + await endPoolAndWaitForClients(databasePool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) - await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + } + } finally { + await adminPool?.end() } }) diff --git a/control-api/test/userSessionDatabaseClock.realPostgres.integration.test.ts b/control-api/test/userSessionDatabaseClock.realPostgres.integration.test.ts index 48dab0af5d..f0b3267783 100644 --- a/control-api/test/userSessionDatabaseClock.realPostgres.integration.test.ts +++ b/control-api/test/userSessionDatabaseClock.realPostgres.integration.test.ts @@ -8,6 +8,7 @@ import { validateUserSessionClaims, } from '../src/services/auth/userSessionService.js' import { verifyUserSessionV2Token } from '../src/utils/auth/userSessionV2Token.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -66,17 +67,20 @@ describeRealPostgres('user-session database clock authority on real PostgreSQL', }) afterAll(async () => { - await databasePool?.end() - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) + try { + await endPoolAndWaitForClients(databasePool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) - await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) - await adminPool.end() + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + } + } finally { + await adminPool?.end() } }) From 818d43d32a9f5f67a6cc9b59345b8d17bee9e9b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:27:50 -0600 Subject: [PATCH 280/528] fix(control-api): r57-b1 unify cancellation test pool fixture Class-check: pool/testPool references -> 8 stale pool sites fixed to canonical testPool; retain adminPool/constrainedPool ownership Repro-test: control-api/test/accessDatabaseCancellation.realPostgres.integration.test.ts fails at 18ee8367ff19cec71e895b6a8e711ae9d35b81cd; job 111897236822 --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- ...Cancellation.realPostgres.integration.test.ts | 16 ++++++++-------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index def1061e8c..e29bc25e58 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.725", + "version": "0.1.726", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.725", + "version": "0.1.726", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 80a38aff52..0f608d265a 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.725", + "version": "0.1.726", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/accessDatabaseCancellation.realPostgres.integration.test.ts b/control-api/test/accessDatabaseCancellation.realPostgres.integration.test.ts index f7b195e95c..adad69c91b 100644 --- a/control-api/test/accessDatabaseCancellation.realPostgres.integration.test.ts +++ b/control-api/test/accessDatabaseCancellation.realPostgres.integration.test.ts @@ -173,7 +173,7 @@ describeRealPostgres('access database cancellation on real PostgreSQL', () => { }) it('does not classify an operator cancellation with the same SQLSTATE as a timeout', async () => { - const client = (await pool.connect()) as PoolClient + const client = (await testPool.connect()) as PoolClient const budget = AccessExecutionBudget.create('catalog') try { await runAccessDatabaseQuery(client, budget, `SET statement_timeout = '500ms'`, [], { @@ -183,7 +183,7 @@ describeRealPostgres('access database cancellation on real PostgreSQL', () => { }) const query = catalogQuery(client, budget, 'SELECT pg_sleep(2)', []) setTimeout(() => { - void pool.query('SELECT pg_cancel_backend($1)', [client.processID]) + void testPool.query('SELECT pg_cancel_backend($1)', [client.processID]) }, 10) const error = await query.catch(value => value) expect(error).toMatchObject({ code: '57014' }) @@ -195,7 +195,7 @@ describeRealPostgres('access database cancellation on real PostgreSQL', () => { }) it('requires rollback and preserves no transactional writes after timeout', async () => { - const client = (await pool.connect()) as PoolClient + const client = (await testPool.connect()) as PoolClient const budget = AccessExecutionBudget.create('catalog') try { await client.query(`CREATE TEMP TABLE access_timeout_rollback(value integer)`) @@ -223,7 +223,7 @@ describeRealPostgres('access database cancellation on real PostgreSQL', () => { it('owns commit and cancellation rollback under one request budget', async () => { const table = `access_budget_transaction_${Date.now()}` - await pool.query(`CREATE TABLE ${table}(value integer NOT NULL)`) + await testPool.query(`CREATE TABLE ${table}(value integer NOT NULL)`) const committed = AccessExecutionBudget.create('catalog', { limits: { producerCalls: 1 } }) try { await withAccessDatabaseTransaction( @@ -231,7 +231,7 @@ describeRealPostgres('access database cancellation on real PostgreSQL', () => { async db => { await db.query(`INSERT INTO ${table}(value) VALUES(1)`) }, - { mode: 'read_write', connectionPool: pool } + { mode: 'read_write', connectionPool: testPool } ) } finally { committed.close() @@ -245,17 +245,17 @@ describeRealPostgres('access database cancellation on real PostgreSQL', () => { await db.query(`INSERT INTO ${table}(value) VALUES(2)`) await db.query('SELECT pg_sleep(2)') }, - { mode: 'read_write', connectionPool: pool } + { mode: 'read_write', connectionPool: testPool } ) setTimeout(() => cancelled.cancel(), 50) await expect(work).rejects.toBeInstanceOf(AccessExecutionCancelledError) - const values = await pool.query<{ value: number }>( + const values = await testPool.query<{ value: number }>( `SELECT value FROM ${table} ORDER BY value` ) expect(values.rows).toEqual([{ value: 1 }]) } finally { cancelled.close() - await pool.query(`DROP TABLE IF EXISTS ${table}`) + await testPool.query(`DROP TABLE IF EXISTS ${table}`) } }) From f77120267b13fc855242d259a67fe8b266e7b13d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:59:17 -0600 Subject: [PATCH 281/528] fix(control-api): r58-m1 distinguish post-event V1 issuance Finding-ID: R58-M1 Class-check: V1 generation/issuance/revocation contract -> 11 sites -> four issuers lock and reread lifecycle generation; six all-session writers advance it; one direct signer adds jti. Repro-test: control-api/test/legacyV1SessionGeneration.test.ts (fails at 818d43d32a9f5f67a6cc9b59345b8d17bee9e9b9) --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../services/auth/externalSessionIssuance.ts | 44 +++- .../src/services/auth/userSessionService.ts | 38 ++- .../utils/auth/externalSessionAuthToken.ts | 4 +- ...eneration.realPostgres.integration.test.ts | 219 ++++++++++++++++++ .../test/legacyV1SessionGeneration.test.ts | 150 ++++++++++++ ...evocation.realPostgres.integration.test.ts | 2 +- .../test/userSessionRevokeAllAtomic.test.ts | 9 +- control-api/test/userSessionService.test.ts | 8 +- 10 files changed, 452 insertions(+), 28 deletions(-) create mode 100644 control-api/test/legacyV1Generation.realPostgres.integration.test.ts create mode 100644 control-api/test/legacyV1SessionGeneration.test.ts diff --git a/control-api/package-lock.json b/control-api/package-lock.json index e29bc25e58..f7f8d9ea82 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.726", + "version": "0.1.727", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.726", + "version": "0.1.727", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 0f608d265a..9eb9cb4747 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.726", + "version": "0.1.727", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/auth/externalSessionIssuance.ts b/control-api/src/services/auth/externalSessionIssuance.ts index e00d918c5f..5d79a29fe6 100644 --- a/control-api/src/services/auth/externalSessionIssuance.ts +++ b/control-api/src/services/auth/externalSessionIssuance.ts @@ -6,7 +6,11 @@ import { compareSemanticVersions, } from '../access/userAccessPolicy.js' import type { ExternalSessionClient } from './externalSessionAuthentication.js' -import { createUserSession, validateLegacyUserSession } from './userSessionService.js' +import { + createUserSession, + loadSessionDatabaseNow, + validateLegacyUserSession, +} from './userSessionService.js' export type ExternalSessionContract = 'v1' | 'v2' @@ -54,6 +58,8 @@ export async function issueExternalUserSession( email: string teamId: string | null role: TeamRole + // Legacy callers may still supply the generation they observed. V1 issuance + // deliberately re-reads it under the canonical user lock below. authGeneration?: number authenticationMethods: string[] }, @@ -81,17 +87,39 @@ export async function issueExternalUserSession( if (!policy.issueV1 || !policy.acceptV1) { throw new Error('legacy user-session issuance is not effective') } - if (!Number.isSafeInteger(input.authGeneration) || Number(input.authGeneration) < 1) { - throw new Error('legacy user-session issuance requires an active lifecycle generation') - } - return { - token: signExternalSessionToken({ + + const issueLegacy = async (db: Pick) => { + const user = await db.query( + `SELECT lifecycle_state, lifecycle_version + FROM users + WHERE id = $1 + FOR UPDATE`, + [input.userId] + ) + const row = user.rows[0] as + | { lifecycle_state?: unknown; lifecycle_version?: unknown } + | undefined + const authGeneration = Number(row?.lifecycle_version) + if ( + row?.lifecycle_state !== 'active' || + !Number.isSafeInteger(authGeneration) || + authGeneration < 1 + ) { + throw new Error('legacy user-session issuance requires an active lifecycle generation') + } + const issuedAt = Math.floor((await loadSessionDatabaseNow(db)).getTime() / 1000) + return signExternalSessionToken({ userId: input.userId, email: input.email, teamId: input.teamId, role: input.role, - authGeneration: Number(input.authGeneration), - }), + authGeneration, + iat: issuedAt, + }) + } + + return { + token: options.db ? await issueLegacy(options.db) : await withTransaction(issueLegacy), contract: 'v1', } } diff --git a/control-api/src/services/auth/userSessionService.ts b/control-api/src/services/auth/userSessionService.ts index 54902af750..cb6fa743ee 100644 --- a/control-api/src/services/auth/userSessionService.ts +++ b/control-api/src/services/auth/userSessionService.ts @@ -104,7 +104,7 @@ function nullableDateOf(value: Date | string | null): Date | null { return value === null ? null : dateOf(value) } -async function loadDatabaseNow(db: SessionDatabase): Promise { +export async function loadSessionDatabaseNow(db: SessionDatabase): Promise { const result = await db.query(`SELECT date_trunc('second', clock_timestamp()) AS db_now`) const value = (result.rows[0] as { db_now?: Date | string } | undefined)?.db_now const now = value ? dateOf(value) : null @@ -234,7 +234,7 @@ export async function createUserSession( const work = async (db: SessionDatabase) => { const user = await db.query(`SELECT id FROM users WHERE id = $1 FOR UPDATE`, [input.userId]) if ((user.rowCount ?? 0) === 0) throw new Error('user session principal does not exist') - const now = await loadDatabaseNow(db) + const now = await loadSessionDatabaseNow(db) const authenticatedAt = atSecond(input.authenticatedAt ?? now) const idleExpiresAt = plusSeconds(now, USER_SESSION_IDLE_LIFETIME_SECONDS) const absoluteExpiresAt = plusSeconds(now, USER_SESSION_ABSOLUTE_LIFETIME_SECONDS) @@ -310,7 +310,7 @@ export async function validateUserSessionClaims( ): Promise { const work = async (db: SessionDatabase) => { const row = await loadSessionForUpdate(db, claims.sid) - const now = await loadDatabaseNow(db) + const now = await loadSessionDatabaseNow(db) return validateLoadedSession(db, row, claims, now, options.touch !== false) } if (options.db) { @@ -334,7 +334,7 @@ export async function renewUserSession( ): Promise { const work = async (db: SessionDatabase) => { const row = await loadSessionForUpdate(db, claims.sid) - const now = await loadDatabaseNow(db) + const now = await loadSessionDatabaseNow(db) const validation = await validateLoadedSession(db, row, claims, now, false) if (validation.status !== 'valid' || !row) return validation @@ -433,7 +433,7 @@ export async function revokeUserSession( [sid, userId] ) if ((locked.rowCount ?? 0) === 0) return false - const now = await loadDatabaseNow(transaction) + const now = await loadSessionDatabaseNow(transaction) const result = await transaction.query( `UPDATE external_user_sessions SET revoked_at = COALESCE(revoked_at, $3), @@ -462,7 +462,23 @@ export async function revokeAllUserSessions( if (actorContext && !(await isCurrentRevocationActor(transaction, userId, actorContext))) { return 0 } - const revokedAt = await loadDatabaseNow(transaction) + const revokedAt = await loadSessionDatabaseNow(transaction) + const compatibilityValidAfter = new Date(revokedAt.getTime() - 1000) + + const advanced = await transaction.query( + `UPDATE users + SET lifecycle_version = lifecycle_version + 1, + updated_at = NOW() + WHERE id = $1 + RETURNING lifecycle_version`, + [userId] + ) + const lifecycleVersion = Number( + (advanced.rows[0] as { lifecycle_version?: unknown } | undefined)?.lifecycle_version + ) + if (!Number.isSafeInteger(lifecycleVersion) || lifecycleVersion < 1) { + throw new Error('security event did not advance lifecycle generation') + } await transaction.query( `INSERT INTO external_user_session_security_epochs(user_id, valid_after, reason, updated_at) @@ -474,7 +490,7 @@ export async function revokeAllUserSessions( ), reason = EXCLUDED.reason, updated_at = EXCLUDED.updated_at`, - [userId, revokedAt, reason] + [userId, compatibilityValidAfter, reason] ) const result = await transaction.query( `UPDATE external_user_sessions @@ -521,7 +537,7 @@ export async function validateExternalSessionAuthorityContext( return { status: 'revoked', reason: 'user_unavailable' } } const row = await loadSessionForUpdate(db, context.sid) - const now = await loadDatabaseNow(db) + const now = await loadSessionDatabaseNow(db) const claims: UserSessionV2Claims = { sub: context.userId, sid: context.sid, @@ -560,7 +576,7 @@ export async function validateExternalSessionAuthorityContext( return { status: 'revoked', reason: 'security_event' } } - const now = await loadDatabaseNow(db) + const now = await loadSessionDatabaseNow(db) const authority = await db.query( `SELECT epoch.valid_after, EXISTS ( @@ -631,7 +647,7 @@ export async function validateLegacyUserSession( ]) if ((locked.rowCount ?? 0) === 0) return { status: 'revoked', reason: 'user_unavailable' } } - const now = await loadDatabaseNow(db) + const now = await loadSessionDatabaseNow(db) const result = await db.query( `SELECT u.id, u.lifecycle_state, u.lifecycle_version, epoch.valid_after, @@ -702,7 +718,7 @@ export async function revokeLegacyUserSession( claims.userId, ]) if ((user.rowCount ?? 0) === 0) return false - const now = await loadDatabaseNow(transaction) + const now = await loadSessionDatabaseNow(transaction) const result = await transaction.query( `INSERT INTO external_v1_session_revocations( token_hash, user_id, expires_at, revoked_at, reason diff --git a/control-api/src/utils/auth/externalSessionAuthToken.ts b/control-api/src/utils/auth/externalSessionAuthToken.ts index 8a6a835139..0d24565599 100644 --- a/control-api/src/utils/auth/externalSessionAuthToken.ts +++ b/control-api/src/utils/auth/externalSessionAuthToken.ts @@ -1,5 +1,5 @@ import jwt from 'jsonwebtoken' -import { createPublicKey } from 'node:crypto' +import { createPublicKey, randomUUID } from 'node:crypto' import { config } from '../../config.js' import { AuthClaims, TEAM_ROLES } from '../../profileTypes.js' import { legacyExternalSessionAuthGeneration } from '../../services/auth/legacyV1Generation.js' @@ -33,6 +33,7 @@ export function signExternalSessionToken( expiresIn: ttlSeconds, issuer: config.jwtIssuer, audience: config.jwtAudience, + jwtid: randomUUID(), } ) } @@ -71,6 +72,7 @@ export function verifyExternalSessionToken(token: string): AuthClaims | null { ...(authGeneration === undefined ? {} : { authGeneration: Number(authGeneration) }), exp: payload.exp, ...(typeof payload.iat === 'number' ? { iat: payload.iat } : {}), + ...(typeof payload.jti === 'string' ? { jti: payload.jti } : {}), } } catch { return null diff --git a/control-api/test/legacyV1Generation.realPostgres.integration.test.ts b/control-api/test/legacyV1Generation.realPostgres.integration.test.ts new file mode 100644 index 0000000000..5ac904f19b --- /dev/null +++ b/control-api/test/legacyV1Generation.realPostgres.integration.test.ts @@ -0,0 +1,219 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { randomBytes, randomUUID } from 'node:crypto' +import { Pool } from 'pg' +import { isCurrentExternalSession as isCurrentBaseExternalSession } from '../../../base/control-api/src/middleware/externalSessionAuth.js' +import { + signExternalSessionToken as signBaseExternalSessionToken, + verifyExternalSessionToken as verifyBaseExternalSessionToken, +} from '../../../base/control-api/src/utils/auth/externalSessionAuthToken.js' +import { initDb } from '../src/db.js' +import type { EffectiveUserAccessPolicy } from '../src/services/access/userAccessPolicy.js' +import { issueExternalUserSession } from '../src/services/auth/externalSessionIssuance.js' +import { + revokeAllUserSessions, + revokeLegacyUserSession, + validateLegacyUserSession, +} from '../src/services/auth/userSessionService.js' +import { verifyExternalSessionToken } from '../src/utils/auth/externalSessionAuthToken.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' + +const baseDatabase = vi.hoisted(() => ({ query: vi.fn() })) + +vi.mock('../../../base/control-api/src/db.js', () => ({ + pool: { query: baseDatabase.query }, +})) + +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const describeRealPostgres = adminUrl ? describe : describe.skip +const runtimeRoles = [ + 'control_api_runtime', + 'trace_maintenance_runtime', + 'workflow_recipes_runtime', +] as const +const legacyPolicy = { issueV1: true, acceptV1: true } as EffectiveUserAccessPolicy + +function databaseUrl(baseUrl: string, database: string): string { + const value = new URL(baseUrl) + value.pathname = `/${database}` + return value.toString() +} + +function quoteIdentifier(value: string): string { + return `"${value.replace(/"/g, '""')}"` +} + +async function alignToFreshDatabaseSecond(pool: Pool): Promise { + await pool.query(`SELECT pg_sleep( + EXTRACT(EPOCH FROM date_trunc('second', clock_timestamp()) + interval '1 second' - clock_timestamp()) + + 0.05 + )`) +} + +describeRealPostgres('legacy V1 generation ordering on real PostgreSQL', () => { + const database = `control_api_legacy_v1_generation_${randomBytes(6).toString('hex')}` + const connectionString = databaseUrl( + adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', + database + ) + let adminPool: Pool + let databasePool: Pool + + async function principal(label: string): Promise<{ + userId: string + email: string + teamId: string + }> { + const userId = randomUUID() + const teamId = randomUUID() + const email = `${label}-${userId}@example.test` + await databasePool.query(`INSERT INTO users(id, email, name) VALUES ($1, $2, $3)`, [ + userId, + email, + label, + ]) + await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, $2)`, [teamId, label]) + await databasePool.query( + `INSERT INTO team_members(team_id, user_id, role, status) + VALUES ($1, $2, 'member', 'active')`, + [teamId, userId] + ) + return { userId, email, teamId } + } + + async function issueLegacy(source: Awaited>): Promise { + return ( + await issueExternalUserSession( + { + contract: 'v1', + userId: source.userId, + email: source.email, + teamId: source.teamId, + role: 'member', + authenticationMethods: ['password'], + }, + { db: databasePool, policy: legacyPolicy } + ) + ).token + } + + function verified(token: string) { + const claims = verifyExternalSessionToken(token) + if (!claims) throw new Error('real V1 issuer did not create a verifiable representation') + return claims + } + + beforeAll(async () => { + adminPool = new Pool({ connectionString: adminUrl }) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) + databasePool = new Pool({ connectionString }) + baseDatabase.query.mockImplementation((text: string, values?: unknown[]) => + databasePool.query(text, values) + ) + await initDb({ connect: () => databasePool.connect() }) + }) + + afterAll(async () => { + try { + await endPoolAndWaitForClients(databasePool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + } + } finally { + await adminPool?.end() + } + }) + + it('orders same-second revoke-all and replacement issuance by lifecycle generation', async () => { + const source = await principal('same-second-security-event') + await alignToFreshDatabaseSecond(databasePool) + const predecessor = await issueLegacy(source) + const predecessorClaims = verified(predecessor) + + await revokeAllUserSessions(source.userId, 'password_changed', databasePool) + const successor = await issueLegacy(source) + const successorClaims = verified(successor) + const authority = await databasePool.query<{ + lifecycle_version: number + valid_after: Date + }>( + `SELECT u.lifecycle_version, epoch.valid_after + FROM users u + JOIN external_user_session_security_epochs epoch ON epoch.user_id = u.id + WHERE u.id = $1`, + [source.userId] + ) + + expect(predecessorClaims.iat).toBe(successorClaims.iat) + expect(successorClaims.authGeneration).toBe(Number(authority.rows[0]?.lifecycle_version)) + expect(successorClaims.authGeneration).toBe(predecessorClaims.authGeneration! + 1) + expect(successorClaims.iat).toBeGreaterThan( + Math.floor(authority.rows[0]!.valid_after.getTime() / 1000) + ) + expect(successorClaims.iat).toBeLessThanOrEqual(Math.floor(Date.now() / 1000)) + await expect( + validateLegacyUserSession(predecessor, predecessorClaims, { db: databasePool }) + ).resolves.toEqual({ status: 'revoked', reason: 'security_event' }) + await expect( + validateLegacyUserSession(successor, successorClaims, { db: databasePool }) + ).resolves.toMatchObject({ status: 'valid' }) + const baseClaims = verifyBaseExternalSessionToken(successor) + expect(baseClaims).toMatchObject({ + userId: source.userId, + authGeneration: successorClaims.authGeneration, + }) + await expect(isCurrentBaseExternalSession(baseClaims!)).resolves.toBe(true) + }) + + it('keeps fingerprint logout scoped to one same-second V1 representation', async () => { + const source = await principal('same-second-fingerprint') + await alignToFreshDatabaseSecond(databasePool) + const first = await issueLegacy(source) + const firstClaims = verified(first) + await expect(revokeLegacyUserSession(first, firstClaims, 'logout', databasePool)).resolves.toBe( + true + ) + const successor = await issueLegacy(source) + const successorClaims = verified(successor) + const lifecycle = await databasePool.query<{ lifecycle_version: number }>( + `SELECT lifecycle_version FROM users WHERE id = $1`, + [source.userId] + ) + + expect(successor).not.toBe(first) + expect(successorClaims.jti).toEqual(expect.any(String)) + expect(Number(lifecycle.rows[0]?.lifecycle_version)).toBe(1) + await expect( + validateLegacyUserSession(first, firstClaims, { db: databasePool }) + ).resolves.toEqual({ status: 'revoked', reason: 'logout' }) + await expect( + validateLegacyUserSession(successor, successorClaims, { db: databasePool }) + ).resolves.toMatchObject({ status: 'valid' }) + }) + + it('keeps the deployed base writer compatible with the candidate reader', async () => { + const source = await principal('base-writer-compatibility') + const baseToken = signBaseExternalSessionToken({ + userId: source.userId, + email: source.email, + teamId: source.teamId, + role: 'member', + authGeneration: 1, + }) + const baseClaims = verifyBaseExternalSessionToken(baseToken) + const candidateClaims = verifyExternalSessionToken(baseToken) + expect(baseClaims).not.toBeNull() + expect(candidateClaims).not.toBeNull() + await expect(isCurrentBaseExternalSession(baseClaims!)).resolves.toBe(true) + await expect( + validateLegacyUserSession(baseToken, candidateClaims!, { db: databasePool }) + ).resolves.toMatchObject({ status: 'valid' }) + }) +}) diff --git a/control-api/test/legacyV1SessionGeneration.test.ts b/control-api/test/legacyV1SessionGeneration.test.ts new file mode 100644 index 0000000000..9796546440 --- /dev/null +++ b/control-api/test/legacyV1SessionGeneration.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest' +import { createHash, randomUUID } from 'node:crypto' +import type { EffectiveUserAccessPolicy } from '../src/services/access/userAccessPolicy.js' +import { issueExternalUserSession } from '../src/services/auth/externalSessionIssuance.js' +import { + revokeAllUserSessions, + revokeLegacyUserSession, + validateLegacyUserSession, +} from '../src/services/auth/userSessionService.js' +import { verifyExternalSessionToken } from '../src/utils/auth/externalSessionAuthToken.js' + +const legacyPolicy = { issueV1: true, acceptV1: true } as EffectiveUserAccessPolicy + +function fingerprint(token: string): string { + return createHash('sha256').update(token, 'utf8').digest('hex') +} + +function legacyAuthorityDatabase() { + const state = { + userId: randomUUID(), + lifecycleVersion: 1, + validAfter: null as Date | null, + revokedFingerprints: new Set(), + now: new Date(Math.floor(Date.now() / 1000) * 1000), + } + + const db = { + query: async (sql: string, values: unknown[] = []) => { + if (sql.includes('clock_timestamp()')) return { rows: [{ db_now: state.now }], rowCount: 1 } + if (sql.includes('SELECT lifecycle_state, lifecycle_version')) { + return { + rows: [{ lifecycle_state: 'active', lifecycle_version: state.lifecycleVersion }], + rowCount: 1, + } + } + if (sql.includes('SET lifecycle_version = lifecycle_version + 1')) { + state.lifecycleVersion += 1 + return { rows: [{ lifecycle_version: state.lifecycleVersion }], rowCount: 1 } + } + if (sql.includes('INSERT INTO external_user_session_security_epochs')) { + state.validAfter = values[1] as Date + return { rows: [], rowCount: 1 } + } + if (sql.includes('UPDATE external_user_sessions')) return { rows: [], rowCount: 0 } + if (sql.includes('INSERT INTO external_v1_session_revocations')) { + state.revokedFingerprints.add(values[0] as string) + return { rows: [{ token_hash: values[0] }], rowCount: 1 } + } + if (sql.includes('SELECT id FROM users WHERE id = $1 FOR UPDATE')) { + return { rows: [{ id: state.userId }], rowCount: 1 } + } + if (sql.includes('SELECT u.id, u.lifecycle_state, u.lifecycle_version')) { + return { + rows: [ + { + id: state.userId, + lifecycle_state: 'active', + lifecycle_version: state.lifecycleVersion, + valid_after: state.validAfter, + token_revoked: state.revokedFingerprints.has(values[1] as string), + }, + ], + rowCount: 1, + } + } + throw new Error(`unexpected legacy authority query: ${sql}`) + }, + } + return { db, state } +} + +async function issueLegacy( + db: ReturnType['db'], + userId: string, + authGeneration: number +): Promise { + return ( + await issueExternalUserSession( + { + contract: 'v1', + userId, + email: 'legacy@example.test', + teamId: 'team-1', + role: 'member', + // This observed value models existing callers. The canonical issuer must + // re-read lifecycle_version while it holds the user lock. + authGeneration, + authenticationMethods: ['password'], + }, + { db, policy: legacyPolicy } + ) + ).token +} + +function verified(token: string) { + const claims = verifyExternalSessionToken(token) + if (!claims) throw new Error('expected real V1 signer to produce a verifiable token') + return claims +} + +describe('legacy V1 security-event generation and representation identity', () => { + it('keeps a same-second post-event V1 issuance usable while revoking the predecessor', async () => { + const { db, state } = legacyAuthorityDatabase() + const predecessor = await issueLegacy(db, state.userId, state.lifecycleVersion) + const predecessorClaims = verified(predecessor) + + await revokeAllUserSessions(state.userId, 'password_changed', db) + const successor = await issueLegacy(db, state.userId, state.lifecycleVersion) + const successorClaims = verified(successor) + + expect(successorClaims.authGeneration).toBe(2) + expect(successorClaims.iat).toBe(Math.floor(state.now.getTime() / 1000)) + expect(successorClaims.iat).toBeLessThanOrEqual(Math.floor(Date.now() / 1000)) + await expect( + validateLegacyUserSession(predecessor, predecessorClaims, { db }) + ).resolves.toEqual({ + status: 'revoked', + reason: 'security_event', + }) + await expect( + validateLegacyUserSession(successor, successorClaims, { db }) + ).resolves.toMatchObject({ + status: 'valid', + }) + }) + + it('keeps an individually revoked V1 representation revoked without revoking a same-second successor', async () => { + const { db, state } = legacyAuthorityDatabase() + const first = await issueLegacy(db, state.userId, state.lifecycleVersion) + const firstClaims = verified(first) + await expect(revokeLegacyUserSession(first, firstClaims, 'logout', db)).resolves.toBe(true) + + const successor = await issueLegacy(db, state.userId, state.lifecycleVersion) + const successorClaims = verified(successor) + + expect(successor).not.toBe(first) + expect(fingerprint(successor)).not.toBe(fingerprint(first)) + expect(successorClaims.jti).toEqual(expect.any(String)) + expect(state.lifecycleVersion).toBe(1) + await expect(validateLegacyUserSession(first, firstClaims, { db })).resolves.toEqual({ + status: 'revoked', + reason: 'logout', + }) + await expect( + validateLegacyUserSession(successor, successorClaims, { db }) + ).resolves.toMatchObject({ + status: 'valid', + }) + }) +}) diff --git a/control-api/test/routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts b/control-api/test/routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts index d1942a2f4e..3c07156acc 100644 --- a/control-api/test/routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts +++ b/control-api/test/routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts @@ -153,7 +153,7 @@ describeRealPostgres('legacy V1 live-admin revocation on real external team rout authGeneration: 1, authenticationMethods: ['pwd'], }, - { policy: runtimePolicy } + { db: databasePool, policy: runtimePolicy } ) staleAdminToken = issued.token expect(staleAdminToken).not.toBe('same-valid-v1-token-minted-while-admin') diff --git a/control-api/test/userSessionRevokeAllAtomic.test.ts b/control-api/test/userSessionRevokeAllAtomic.test.ts index 467443bf10..52dfa540b2 100644 --- a/control-api/test/userSessionRevokeAllAtomic.test.ts +++ b/control-api/test/userSessionRevokeAllAtomic.test.ts @@ -21,6 +21,7 @@ describe('revoke-all transaction boundary', () => { rows: [{ db_now: new Date('2026-08-27T00:00:01.000Z') }], rowCount: 1, }) + .mockResolvedValueOnce({ rows: [{ lifecycle_version: 2 }], rowCount: 1 }) .mockImplementationOnce(async () => { mocks.committedEpoch = true return { rows: [], rowCount: 1 } @@ -42,7 +43,7 @@ describe('revoke-all transaction boundary', () => { ) expect(mocks.withTransaction).toHaveBeenCalledTimes(1) - expect(mocks.query).toHaveBeenCalledTimes(4) + expect(mocks.query).toHaveBeenCalledTimes(5) expect(String(mocks.query.mock.calls[0]?.[0])).toContain('FOR UPDATE') expect(mocks.committedEpoch).toBe(false) }) @@ -54,6 +55,7 @@ describe('revoke-all transaction boundary', () => { mocks.query .mockResolvedValueOnce({ rows: [{ id: 'user-1' }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [{ db_now: afterLock }], rowCount: 1 }) + .mockResolvedValueOnce({ rows: [{ lifecycle_version: 2 }], rowCount: 1 }) .mockImplementationOnce(async (_sql, values) => { cutoffs.push(values[1] as Date) return { rows: [], rowCount: 1 } @@ -66,6 +68,9 @@ describe('revoke-all transaction boundary', () => { expect(String(mocks.query.mock.calls[0]?.[0])).toContain('FOR UPDATE') expect(String(mocks.query.mock.calls[1]?.[0])).toContain('clock_timestamp()') - expect(cutoffs).toEqual([afterLock]) + expect(String(mocks.query.mock.calls[2]?.[0])).toContain( + 'lifecycle_version = lifecycle_version + 1' + ) + expect(cutoffs).toEqual([new Date(afterLock.getTime() - 1000)]) }) }) diff --git a/control-api/test/userSessionService.test.ts b/control-api/test/userSessionService.test.ts index 335bd6f656..fbab3a3436 100644 --- a/control-api/test/userSessionService.test.ts +++ b/control-api/test/userSessionService.test.ts @@ -224,14 +224,18 @@ describe('user session state machine', () => { .fn() .mockResolvedValueOnce({ rows: [{ id: userId }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [{ db_now: now }], rowCount: 1 }) + .mockResolvedValueOnce({ rows: [{ lifecycle_version: 2 }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 2 }), } await expect(revokeAllUserSessions(userId, 'password_changed', db)).resolves.toBe(2) expect(String(db.query.mock.calls[0]?.[0])).toContain('FOR UPDATE') expect(String(db.query.mock.calls[1]?.[0])).toContain('clock_timestamp()') - expect(String(db.query.mock.calls[2]?.[0])).toContain('security_epochs') - expect(String(db.query.mock.calls[3]?.[0])).toContain('external_user_sessions') + expect(String(db.query.mock.calls[2]?.[0])).toContain( + 'lifecycle_version = lifecycle_version + 1' + ) + expect(String(db.query.mock.calls[3]?.[0])).toContain('security_epochs') + expect(String(db.query.mock.calls[4]?.[0])).toContain('external_user_sessions') const claims = { userId, From fb480cb0dbe3f6605a381ba0780eccdd00c33534 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:16:38 -0600 Subject: [PATCH 282/528] chore(sync): adapt Control API to current dev contracts Re-slot Task 106 migration identities after dev added its 0125 migration, preserving prior receipts as aliases. Adapt the PR1 transaction wrapper to the cancellation-aware call signature while retaining the existing failure mapper. Keep signed external-session rate-limit bucketing inside the canonical auth boundary without making it an authorization decision. --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- control-api/src/db.ts | 187 +++++++++++++++--- control-api/src/migrations/migrationRunner.ts | 17 +- .../src/migrations/pr1OnlineIndexPlan.ts | 54 ++--- ...essFoundationDefinerTempShadowMigration.ts | 2 +- .../src/routes/workflows/shared/rateLimit.ts | 4 +- .../auth/externalSessionAuthentication.ts | 9 + .../db.catalogUtf8OrderingMigration.test.ts | 2 +- ...composableCatalogRevisionMigration.test.ts | 2 +- ...CatalogRevisionComponentsMigration.test.ts | 8 +- control-api/test/db.initDb.test.ts | 4 +- control-api/test/db.migrationsOrder.test.ts | 14 +- .../db.oauthGrantsOwnerGeneralization.test.ts | 2 +- ....realPostgresMigration.integration.test.ts | 2 +- .../db.userAccessFoundationMigration.test.ts | 47 ++++- ...ionPolicy.realPostgres.integration.test.ts | 20 +- .../test/migrationExecutionPolicy.test.ts | 39 ++-- .../test/routes.externalWorkflows.test.ts | 8 + 19 files changed, 302 insertions(+), 125 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 418e0868b7..b3f1d2c975 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.728", + "version": "0.1.729", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.728", + "version": "0.1.729", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index cc96d88dc5..f14702c939 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.728", + "version": "0.1.729", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/db.ts b/control-api/src/db.ts index 85d56b5d69..91494c596b 100644 --- a/control-api/src/db.ts +++ b/control-api/src/db.ts @@ -1,4 +1,4 @@ -import { Pool, type PoolClient } from 'pg' +import { type Client, Pool, type PoolClient } from 'pg' import { type BoundedPoolBudget, type PoolConstructor, @@ -16,6 +16,7 @@ import { applyUserAccessFoundationSchema, backfillLegacyPasswordSecurityEpochs, } from './services/access/userAccessFoundationSchema.js' +import { applyAdminSubscriptionRateLimitNamespace } from './services/adminSubscriptionRateLimitMigration.js' import { applyCodexCatalogModelsSchema } from './services/codexSubscriptionCatalog.js' import { applyCodexChatgptAccountIdSchema, @@ -91,6 +92,11 @@ export type DbTransactionClient = DbClient & { readonly [dbTransactionClientBrand]: 'transaction' } +export type DbTransactionOptions = { + signal?: AbortSignal + onDatabaseFailure?: (error: unknown) => unknown +} + type DbSessionClient = DbClient & { release: (destroy?: boolean | Error) => void } @@ -6338,11 +6344,16 @@ export const CONTROL_API_MIGRATIONS: DbMigration[] = [ apply: applyEntityChangeDefinerSearchPathSchema, }, { - // The frozen dev parent now owns every slot through 0124. Preserve each + version: '0125_admin_subscription_rate_limit_namespace', + apply: applyAdminSubscriptionRateLimitNamespace, + }, + { + // The frozen dev parent now owns every slot through 0125. Preserve each // previously deployed identity as an alias so re-slotting only records a // canonical version and never replays an already-applied migration body. - version: '0125_user_access_foundation', + version: '0126_user_access_foundation', legacyVersions: [ + '0125_user_access_foundation', '0109_user_access_foundation', '0107_user_access_foundation', '0101_user_access_foundation', @@ -6350,8 +6361,9 @@ export const CONTROL_API_MIGRATIONS: DbMigration[] = [ apply: applyUserAccessFoundationSchema, }, { - version: '0126_invitation_delivery_commands', + version: '0127_invitation_delivery_commands', legacyVersions: [ + '0126_invitation_delivery_commands', '010a_invitation_delivery_commands', '0108_invitation_delivery_commands', '0102_invitation_delivery_commands', @@ -6359,8 +6371,9 @@ export const CONTROL_API_MIGRATIONS: DbMigration[] = [ apply: applyInvitationDeliveryCommandFoundation, }, { - version: '0127_catalog_utf8_ordering', + version: '0128_catalog_utf8_ordering', legacyVersions: [ + '0127_catalog_utf8_ordering', '010b_catalog_utf8_ordering', '0109_catalog_utf8_ordering', '0103_catalog_utf8_ordering', @@ -6368,8 +6381,9 @@ export const CONTROL_API_MIGRATIONS: DbMigration[] = [ apply: applyCatalogUtf8OrderingSchema, }, { - version: '0128_composable_catalog_revisions', + version: '0129_composable_catalog_revisions', legacyVersions: [ + '0128_composable_catalog_revisions', '010c_composable_catalog_revisions', '010a_composable_catalog_revisions', '0104_composable_catalog_revisions', @@ -6379,8 +6393,9 @@ export const CONTROL_API_MIGRATIONS: DbMigration[] = [ { // Fix-forward for databases that recorded the first composable-catalog // body before the GFS resource-component mapping was completed. - version: '0129_gfs_catalog_revision_components', + version: '012a_gfs_catalog_revision_components', legacyVersions: [ + '0129_gfs_catalog_revision_components', '010d_gfs_catalog_revision_components', '010b_gfs_catalog_revision_components', '0105_gfs_catalog_revision_components', @@ -6390,7 +6405,8 @@ export const CONTROL_API_MIGRATIONS: DbMigration[] = [ { // The access-foundation bodies are immutable; harden their installed // SECURITY DEFINER search paths without replaying those historical bodies. - version: '012a_user_access_foundation_definer_temp_shadow_hardening', + version: '012b_user_access_foundation_definer_temp_shadow_hardening', + legacyVersions: ['012a_user_access_foundation_definer_temp_shadow_hardening'], apply: applyUserAccessFoundationDefinerTempShadowHardening, }, { @@ -6802,24 +6818,32 @@ export async function assertDbReady(db: DbClient = pool): Promise { export async function withTransaction( work: (db: DbTransactionClient) => Promise, - txPoolOrOptions: Pool | { onDatabaseFailure?: (error: unknown) => unknown } = pool + txPoolOrOptions: Pool | DbTransactionOptions = pool, + explicitOptions: DbTransactionOptions = {} ): Promise { // Tests may inject a Pool to exercise client lifecycle behavior. Session - // persistence callers may instead provide a failure mapper so database - // errors retain their typed provenance. Production defaults to the core pool. + // persistence callers may provide a failure mapper so database errors retain + // typed provenance; cancellable callers use the same bounded pool with signal. const isPool = typeof (txPoolOrOptions as Pool).connect === 'function' const txPool = isPool ? (txPoolOrOptions as Pool) : pool - const options: { onDatabaseFailure?: (error: unknown) => unknown } = isPool - ? {} - : (txPoolOrOptions as { onDatabaseFailure?: (error: unknown) => unknown }) - let client: PoolClient + const options: DbTransactionOptions = isPool + ? explicitOptions + : (txPoolOrOptions as DbTransactionOptions) + const signal = options.signal + const mapDatabaseFailure = (error: unknown): unknown => + options.onDatabaseFailure ? options.onDatabaseFailure(error) : error + signal?.throwIfAborted() + let client: PoolClient & Pick try { - client = (await txPool.connect()) as PoolClient + client = (await txPool.connect()) as typeof client } catch (error) { - throw options.onDatabaseFailure ? options.onDatabaseFailure(error) : error + signal?.throwIfAborted() + throw mapDatabaseFailure(error) } let transactionStarted = false let commitSent = false + let transactionFinished = false + let originalIdleTimeout: string | undefined let releaseError: Error | boolean | undefined // pg-pool removes its idle 'error' listener while a client is checked out. @@ -6832,39 +6856,138 @@ export async function withTransaction( } client.on('error', onClientError) - const transactionClient: DbClient = options.onDatabaseFailure - ? Object.assign(Object.create(client) as DbClient, { - query: async (text: string, values?: unknown[]) => { - try { - return await client.query(text, values) - } catch (error) { - throw options.onDatabaseFailure!(error) - } - }, - }) - : (client as DbClient) + let termination: Promise | undefined + const terminateClient = (reason: Error | boolean): void => { + releaseError ??= reason + if (termination) return + termination = client.end().catch(error => { + releaseError ??= error instanceof Error ? error : true + }) + client.connection.stream.destroy() + } + const onAbort = (): void => { + terminateClient(signal?.reason instanceof Error ? signal.reason : true) + } + signal?.addEventListener('abort', onAbort, { once: true }) + if (signal?.aborted) onAbort() + + const executeQuery = async ( + text: string, + values?: unknown[], + checkSignalAfter = true + ): Promise<{ rows: unknown[]; rowCount: number | null }> => { + signal?.throwIfAborted() + try { + const result = await client.query(text, values) + if (checkSignalAfter) signal?.throwIfAborted() + return result + } catch (error) { + signal?.throwIfAborted() + throw mapDatabaseFailure(error) + } + } + + const transactionClient: DbClient = + signal || options.onDatabaseFailure + ? Object.assign(Object.create(client) as DbClient, { + query: (text: string, values?: unknown[]) => executeQuery(text, values), + }) + : (client as DbClient) try { - await transactionClient.query('BEGIN') + signal?.throwIfAborted() + if (signal) { + const settings = await client.query<{ + statement_timeout_ms: string | null + idle_timeout_ms: string | null + }>(`SELECT + (SELECT setting FROM pg_settings WHERE name = 'statement_timeout') AS statement_timeout_ms, + (SELECT setting FROM pg_settings WHERE name = 'idle_in_transaction_session_timeout') AS idle_timeout_ms`) + signal.throwIfAborted() + const statementTimeout = settings.rows[0]?.statement_timeout_ms + const idleTimeout = settings.rows[0]?.idle_timeout_ms + const statementMs = Number(statementTimeout) + const idleMs = Number(idleTimeout) + if ( + typeof statementTimeout !== 'string' || + !/^\d+$/.test(statementTimeout) || + !Number.isInteger(statementMs) || + statementMs < 100 || + statementMs > 30_000 || + typeof idleTimeout !== 'string' || + !/^\d+$/.test(idleTimeout) || + !Number.isSafeInteger(idleMs) + ) { + throw new Error('Invalid effective PostgreSQL cancellation timeout bounds') + } + if (idleMs === 0 || idleMs > statementMs) { + originalIdleTimeout = idleTimeout + await client.query('SELECT set_config($1, $2, false)', [ + 'idle_in_transaction_session_timeout', + statementTimeout, + ]) + signal.throwIfAborted() + } + } + await executeQuery('BEGIN', undefined, false) transactionStarted = true + signal?.throwIfAborted() + if (signal) { + signal.throwIfAborted() + await client.query("SET LOCAL client_connection_check_interval = '100ms'") + } // The brand is nominal-only (a declared unique symbol); the checked-out // client IS the transaction session, so this cast is the single blessed // point where the brand is minted (issue #375 M3). + signal?.throwIfAborted() const result = await work(transactionClient as unknown as DbTransactionClient) + signal?.throwIfAborted() commitSent = true - await transactionClient.query('COMMIT') + await executeQuery('COMMIT', undefined, false) + transactionFinished = true return result } catch (error) { if (commitSent || !transactionStarted) { releaseError = error instanceof Error ? error : true } else { try { - await transactionClient.query('ROLLBACK') + await client.query('ROLLBACK') + transactionFinished = true } catch (rollbackError) { - releaseError = rollbackError instanceof Error ? rollbackError : true + if (!signal?.aborted) { + releaseError = rollbackError instanceof Error ? rollbackError : true + } } } + signal?.throwIfAborted() throw error } finally { + if ( + originalIdleTimeout !== undefined && + transactionFinished && + !releaseError && + !asyncClientError && + !signal?.aborted + ) { + try { + await client.query('SELECT set_config($1, $2, false)', [ + 'idle_in_transaction_session_timeout', + originalIdleTimeout, + ]) + } catch (restoreError) { + releaseError = restoreError instanceof Error ? restoreError : true + } + } + if (signal && (releaseError || asyncClientError || signal.aborted)) { + terminateClient( + signal.aborted + ? signal.reason instanceof Error + ? signal.reason + : true + : (releaseError ?? asyncClientError ?? true) + ) + } + if (termination) await termination + signal?.removeEventListener('abort', onAbort) client.removeListener('error', onClientError) // A client that emitted 'error' mid-checkout is poisoned; destroy it. Keep an // existing releaseError (the primary failure from work/commit/rollback) and diff --git a/control-api/src/migrations/migrationRunner.ts b/control-api/src/migrations/migrationRunner.ts index 24c9569f05..c1e426b8c8 100644 --- a/control-api/src/migrations/migrationRunner.ts +++ b/control-api/src/migrations/migrationRunner.ts @@ -3,14 +3,14 @@ import { migrationSessionBoundsSql } from './migrationExecutionPolicy.js' import { preparePr1Migration } from './pr1OnlineIndexPlan.js' export const PR1_MIGRATION_VERSIONS = Object.freeze([ - '0125_user_access_foundation', - '0126_invitation_delivery_commands', - '0127_catalog_utf8_ordering', - '0128_composable_catalog_revisions', - '0129_gfs_catalog_revision_components', - '012a_user_access_foundation_definer_temp_shadow_hardening', + '0126_user_access_foundation', + '0127_invitation_delivery_commands', + '0128_catalog_utf8_ordering', + '0129_composable_catalog_revisions', + '012a_gfs_catalog_revision_components', + '012b_user_access_foundation_definer_temp_shadow_hardening', '0130_legacy_password_security_epoch_backfill', - // Executed immediately after 0125 by the runner, before the remaining PR1 migrations. + // Executed immediately after 0126 by the runner, before the remaining PR1 migrations. '0138_authorization_revision_delete_compatibility', ] as const) @@ -33,6 +33,7 @@ export const DEV_POST_0106_MIGRATION_VERSIONS = Object.freeze([ '0122_durable_entity_change_feed', '0123_entity_change_checkpoint_cursor_convergence', '0124_entity_change_definer_search_path', + '0125_admin_subscription_rate_limit_namespace', ] as const) const NON_PR1_POST_0106_MIGRATION_VERSIONS = new Set(DEV_POST_0106_MIGRATION_VERSIONS) @@ -48,7 +49,7 @@ export type MigrationDescriptor = { apply: (db: DbClient) => Promise } -const AUTHORIZATION_REVISION_COMPATIBILITY_PREREQUISITE = '0125_user_access_foundation' +const AUTHORIZATION_REVISION_COMPATIBILITY_PREREQUISITE = '0126_user_access_foundation' const AUTHORIZATION_REVISION_COMPATIBILITY_VERSION = '0138_authorization_revision_delete_compatibility' diff --git a/control-api/src/migrations/pr1OnlineIndexPlan.ts b/control-api/src/migrations/pr1OnlineIndexPlan.ts index a8e764c8bf..543ed1d4a0 100644 --- a/control-api/src/migrations/pr1OnlineIndexPlan.ts +++ b/control-api/src/migrations/pr1OnlineIndexPlan.ts @@ -5,7 +5,7 @@ import { } from './migrationExecutionPolicy.js' export type OnlineIndexDefinition = Readonly<{ - migrationVersion: '0125_user_access_foundation' | '0127_catalog_utf8_ordering' + migrationVersion: '0126_user_access_foundation' | '0128_catalog_utf8_ordering' name: string table: string unique?: boolean @@ -14,70 +14,70 @@ export type OnlineIndexDefinition = Readonly<{ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.freeze([ { - migrationVersion: '0125_user_access_foundation', + migrationVersion: '0126_user_access_foundation', name: 'team_members_user_active_idx', table: 'team_members', createSql: `CREATE INDEX CONCURRENTLY team_members_user_active_idx ON team_members (user_id, status, team_id) INCLUDE (role, updated_at)`, }, { - migrationVersion: '0125_user_access_foundation', + migrationVersion: '0126_user_access_foundation', name: 'user_contexts_context_user_idx', table: 'user_contexts', createSql: `CREATE INDEX CONCURRENTLY user_contexts_context_user_idx ON user_contexts (context_id, user_id)`, }, { - migrationVersion: '0125_user_access_foundation', + migrationVersion: '0126_user_access_foundation', name: 'team_contexts_context_team_idx', table: 'team_contexts', createSql: `CREATE INDEX CONCURRENTLY team_contexts_context_team_idx ON team_contexts (context_id, team_id)`, }, { - migrationVersion: '0125_user_access_foundation', + migrationVersion: '0126_user_access_foundation', name: 'user_agents_agent_user_idx', table: 'user_agents', createSql: `CREATE INDEX CONCURRENTLY user_agents_agent_user_idx ON user_agents (agent_name, user_id)`, }, { - migrationVersion: '0125_user_access_foundation', + migrationVersion: '0126_user_access_foundation', name: 'team_agents_agent_team_idx', table: 'team_agents', createSql: `CREATE INDEX CONCURRENTLY team_agents_agent_team_idx ON team_agents (agent_name, team_id)`, }, { - migrationVersion: '0125_user_access_foundation', + migrationVersion: '0126_user_access_foundation', name: 'user_workflow_triggers_recipe_user_idx', table: 'user_workflow_triggers', createSql: `CREATE INDEX CONCURRENTLY user_workflow_triggers_recipe_user_idx ON user_workflow_triggers (recipe_namespace, recipe_name, user_id)`, }, { - migrationVersion: '0125_user_access_foundation', + migrationVersion: '0126_user_access_foundation', name: 'team_workflow_triggers_recipe_team_idx', table: 'team_workflow_triggers', createSql: `CREATE INDEX CONCURRENTLY team_workflow_triggers_recipe_team_idx ON team_workflow_triggers (recipe_namespace, recipe_name, team_id)`, }, { - migrationVersion: '0125_user_access_foundation', + migrationVersion: '0126_user_access_foundation', name: 'user_workflow_triggers_catalog_key_idx', table: 'user_workflow_triggers', createSql: `CREATE INDEX CONCURRENTLY user_workflow_triggers_catalog_key_idx ON user_workflow_triggers (user_id, ((recipe_namespace || '/'::text) || recipe_name))`, }, { - migrationVersion: '0125_user_access_foundation', + migrationVersion: '0126_user_access_foundation', name: 'team_workflow_triggers_catalog_key_idx', table: 'team_workflow_triggers', createSql: `CREATE INDEX CONCURRENTLY team_workflow_triggers_catalog_key_idx ON team_workflow_triggers (((recipe_namespace || '/'::text) || recipe_name), team_id)`, }, { - migrationVersion: '0125_user_access_foundation', + migrationVersion: '0126_user_access_foundation', name: 'workflow_runs_actor_catalog_idx', table: 'workflow_runs', createSql: `CREATE INDEX CONCURRENTLY workflow_runs_actor_catalog_idx @@ -86,7 +86,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr WHERE actor_type = 'user' AND actor_id IS NOT NULL`, }, { - migrationVersion: '0125_user_access_foundation', + migrationVersion: '0126_user_access_foundation', name: 'workflow_runs_team_catalog_idx', table: 'workflow_runs', createSql: `CREATE INDEX CONCURRENTLY workflow_runs_team_catalog_idx @@ -95,7 +95,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr WHERE team_id IS NOT NULL`, }, { - migrationVersion: '0125_user_access_foundation', + migrationVersion: '0126_user_access_foundation', name: 'workflow_runs_usage_team_catalog_idx', table: 'workflow_runs', createSql: `CREATE INDEX CONCURRENTLY workflow_runs_usage_team_catalog_idx @@ -104,7 +104,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr WHERE usage_team_id IS NOT NULL`, }, { - migrationVersion: '0125_user_access_foundation', + migrationVersion: '0126_user_access_foundation', name: 'workflow_approval_user_catalog_idx', table: 'workflow_approval_requests', createSql: `CREATE INDEX CONCURRENTLY workflow_approval_user_catalog_idx @@ -113,7 +113,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr WHERE target_user_id IS NOT NULL`, }, { - migrationVersion: '0125_user_access_foundation', + migrationVersion: '0126_user_access_foundation', name: 'workflow_approval_team_catalog_idx', table: 'workflow_approval_requests', createSql: `CREATE INDEX CONCURRENTLY workflow_approval_team_catalog_idx @@ -122,7 +122,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr WHERE target_team_id IS NOT NULL`, }, { - migrationVersion: '0125_user_access_foundation', + migrationVersion: '0126_user_access_foundation', name: 'notification_user_catalog_idx', table: 'notification_deliveries', createSql: `CREATE INDEX CONCURRENTLY notification_user_catalog_idx @@ -130,7 +130,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr INCLUDE (expires_at, status, event_type) WHERE audience ? 'userId'`, }, { - migrationVersion: '0125_user_access_foundation', + migrationVersion: '0126_user_access_foundation', name: 'notification_team_catalog_idx', table: 'notification_deliveries', createSql: `CREATE INDEX CONCURRENTLY notification_team_catalog_idx @@ -138,7 +138,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr INCLUDE (expires_at, status, event_type) WHERE audience ? 'teamId'`, }, { - migrationVersion: '0125_user_access_foundation', + migrationVersion: '0126_user_access_foundation', name: 'gfs_grants_subject_resource_catalog_idx', table: 'gfs_grants', createSql: `CREATE INDEX CONCURRENTLY gfs_grants_subject_resource_catalog_idx @@ -146,7 +146,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr INCLUDE (id, drive, permissions, inherit)`, }, { - migrationVersion: '0125_user_access_foundation', + migrationVersion: '0126_user_access_foundation', name: 'gfs_shares_subject_resource_catalog_idx', table: 'gfs_shares', createSql: `CREATE INDEX CONCURRENTLY gfs_shares_subject_resource_catalog_idx @@ -154,35 +154,35 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr INCLUDE (id, drive, permissions, include_descendants)`, }, { - migrationVersion: '0127_catalog_utf8_ordering', + migrationVersion: '0128_catalog_utf8_ordering', name: 'user_agents_catalog_utf8_idx', table: 'user_agents', createSql: `CREATE INDEX CONCURRENTLY user_agents_catalog_utf8_idx ON user_agents (user_id, catalog_utf8_bytes(agent_name))`, }, { - migrationVersion: '0127_catalog_utf8_ordering', + migrationVersion: '0128_catalog_utf8_ordering', name: 'team_agents_catalog_utf8_idx', table: 'team_agents', createSql: `CREATE INDEX CONCURRENTLY team_agents_catalog_utf8_idx ON team_agents (catalog_utf8_bytes(agent_name), team_id)`, }, { - migrationVersion: '0127_catalog_utf8_ordering', + migrationVersion: '0128_catalog_utf8_ordering', name: 'user_contexts_catalog_utf8_idx', table: 'user_contexts', createSql: `CREATE INDEX CONCURRENTLY user_contexts_catalog_utf8_idx ON user_contexts (user_id, catalog_utf8_bytes(context_id))`, }, { - migrationVersion: '0127_catalog_utf8_ordering', + migrationVersion: '0128_catalog_utf8_ordering', name: 'team_contexts_catalog_utf8_idx', table: 'team_contexts', createSql: `CREATE INDEX CONCURRENTLY team_contexts_catalog_utf8_idx ON team_contexts (catalog_utf8_bytes(context_id), team_id)`, }, { - migrationVersion: '0127_catalog_utf8_ordering', + migrationVersion: '0128_catalog_utf8_ordering', name: 'user_workflow_triggers_catalog_utf8_idx', table: 'user_workflow_triggers', createSql: `CREATE INDEX CONCURRENTLY user_workflow_triggers_catalog_utf8_idx @@ -190,7 +190,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr (user_id, catalog_utf8_bytes(recipe_namespace || '/' || recipe_name))`, }, { - migrationVersion: '0127_catalog_utf8_ordering', + migrationVersion: '0128_catalog_utf8_ordering', name: 'team_workflow_triggers_catalog_utf8_idx', table: 'team_workflow_triggers', createSql: `CREATE INDEX CONCURRENTLY team_workflow_triggers_catalog_utf8_idx @@ -198,7 +198,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr (catalog_utf8_bytes(recipe_namespace || '/' || recipe_name), team_id)`, }, { - migrationVersion: '0127_catalog_utf8_ordering', + migrationVersion: '0128_catalog_utf8_ordering', name: 'operational_relationship_catalog_utf8_target_idx', table: 'operational_resource_relationships', createSql: `CREATE INDEX CONCURRENTLY operational_relationship_catalog_utf8_target_idx @@ -654,6 +654,6 @@ async function prepareCatalogUtf8Function(db: DbClient): Promise { export async function preparePr1Migration(db: DbClient, version: string): Promise { const indexes = PR1_ONLINE_INDEX_PLAN.filter(entry => entry.migrationVersion === version) if (indexes.length === 0) return - if (version === '0127_catalog_utf8_ordering') await prepareCatalogUtf8Function(db) + if (version === '0128_catalog_utf8_ordering') await prepareCatalogUtf8Function(db) for (const index of indexes) await ensureOnlineIndex(db, index) } diff --git a/control-api/src/migrations/userAccessFoundationDefinerTempShadowMigration.ts b/control-api/src/migrations/userAccessFoundationDefinerTempShadowMigration.ts index 897b09cc91..33324029d4 100644 --- a/control-api/src/migrations/userAccessFoundationDefinerTempShadowMigration.ts +++ b/control-api/src/migrations/userAccessFoundationDefinerTempShadowMigration.ts @@ -4,7 +4,7 @@ import type { DbClient } from '../db.js' * Harden the installed access-foundation SECURITY DEFINER functions without * changing their bodies, ownership, grants, or behavior. * The obsolete authorization_bump_catalog_revision() routine is intentionally - * excluded because 0128_composable_catalog_revisions drops it before this runs. + * excluded because 0129_composable_catalog_revisions drops it before this runs. */ export async function applyUserAccessFoundationDefinerTempShadowHardening( db: DbClient diff --git a/control-api/src/routes/workflows/shared/rateLimit.ts b/control-api/src/routes/workflows/shared/rateLimit.ts index e53ccc20e7..4cf06c1d2e 100644 --- a/control-api/src/routes/workflows/shared/rateLimit.ts +++ b/control-api/src/routes/workflows/shared/rateLimit.ts @@ -8,8 +8,8 @@ import { rateLimitMiddleware, } from '../../../middleware/rateLimitMiddleware.js' import { rateLimitHitsTotal } from '../../../observability/metrics.js' +import { externalSessionUserIdForRateLimit } from '../../../services/auth/externalSessionAuthentication.js' import { verifyAdminToken } from '../../../utils/auth/adminAuthToken.js' -import { verifyExternalSessionToken } from '../../../utils/auth/externalSessionAuthToken.js' import { mcpHostVerifiedRateLimitPrincipal, verifyMcpHostAccessJwt, @@ -442,7 +442,7 @@ function unverifiedTriggerIpCredential(req: Request): string { /** Stable per-account key. Raw tokens rotate and must not mint new buckets. */ function verifiedUserSessionRateLimitSubject(token: string): string | null { - const userId = verifyExternalSessionToken(token)?.userId + const userId = externalSessionUserIdForRateLimit(token) return userId ? `user:${userId}` : null } diff --git a/control-api/src/services/auth/externalSessionAuthentication.ts b/control-api/src/services/auth/externalSessionAuthentication.ts index 9d17935c67..c069e739ac 100644 --- a/control-api/src/services/auth/externalSessionAuthentication.ts +++ b/control-api/src/services/auth/externalSessionAuthentication.ts @@ -80,6 +80,15 @@ export type ExternalSessionAuthorityContext = Readonly< } > +/** + * Return a signed V1 subject solely for pre-authentication rate-limit bucketing. + * This does not establish current session authority; protected work must still + * pass through authenticateExternalUserSession and its live lifecycle checks. + */ +export function externalSessionUserIdForRateLimit(token: string): string | null { + return verifyExternalSessionToken(token)?.userId ?? null +} + function clientMeetsMinimum( client: ExternalSessionClient | undefined, policy: EffectiveUserAccessPolicy diff --git a/control-api/test/db.catalogUtf8OrderingMigration.test.ts b/control-api/test/db.catalogUtf8OrderingMigration.test.ts index adbc10fd58..39cac72ea2 100644 --- a/control-api/test/db.catalogUtf8OrderingMigration.test.ts +++ b/control-api/test/db.catalogUtf8OrderingMigration.test.ts @@ -8,7 +8,7 @@ describe('0127 catalog UTF-8 ordering migration', () => { it('installs the immutable byte function and complete supporting index class', async () => { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0127_catalog_utf8_ordering' + candidate => candidate.version === '0128_catalog_utf8_ordering' ) expect(migration).toBeDefined() diff --git a/control-api/test/db.composableCatalogRevisionMigration.test.ts b/control-api/test/db.composableCatalogRevisionMigration.test.ts index 6a40176509..868ab96f6f 100644 --- a/control-api/test/db.composableCatalogRevisionMigration.test.ts +++ b/control-api/test/db.composableCatalogRevisionMigration.test.ts @@ -8,7 +8,7 @@ describe('010c composable catalog revisions migration', () => { it('replaces the singleton trigger class with scoped transactional writers', async () => { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0128_composable_catalog_revisions' + candidate => candidate.version === '0129_composable_catalog_revisions' ) expect(migration).toBeDefined() diff --git a/control-api/test/db.gfsCatalogRevisionComponentsMigration.test.ts b/control-api/test/db.gfsCatalogRevisionComponentsMigration.test.ts index 712f92bbc7..c05c5f7173 100644 --- a/control-api/test/db.gfsCatalogRevisionComponentsMigration.test.ts +++ b/control-api/test/db.gfsCatalogRevisionComponentsMigration.test.ts @@ -8,13 +8,13 @@ describe('0129 GFS catalog revision components migration', () => { it('fixes forward after a database has already recorded the prior composable revision', async () => { const { CONTROL_API_MIGRATIONS, initDb } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0129_gfs_catalog_revision_components' + candidate => candidate.version === '012a_gfs_catalog_revision_components' ) const composableIndex = CONTROL_API_MIGRATIONS.findIndex( - candidate => candidate.version === '0128_composable_catalog_revisions' + candidate => candidate.version === '0129_composable_catalog_revisions' ) const gfsIndex = CONTROL_API_MIGRATIONS.findIndex( - candidate => candidate.version === '0129_gfs_catalog_revision_components' + candidate => candidate.version === '012a_gfs_catalog_revision_components' ) expect(migration).toBeDefined() expect(gfsIndex).toBe(composableIndex + 1) @@ -39,7 +39,7 @@ describe('0129 GFS catalog revision components migration', () => { expect(appliedSql).toContain('INSERT INTO authorization_resource_revisions') expect(clientQuery).toHaveBeenCalledWith( expect.stringContaining('INSERT INTO schema_migrations'), - ['0129_gfs_catalog_revision_components'] + ['012a_gfs_catalog_revision_components'] ) }) }) diff --git a/control-api/test/db.initDb.test.ts b/control-api/test/db.initDb.test.ts index 5267a58186..c70f3b34a3 100644 --- a/control-api/test/db.initDb.test.ts +++ b/control-api/test/db.initDb.test.ts @@ -65,8 +65,8 @@ describe('db.initDb', () => { ).toBe(false) expect(sqls.some(sql => sql.includes('DROP TABLE IF EXISTS workflow_run_outputs'))).toBe(false) expect(sqls).not.toContain('ROLLBACK') - expect(sqls.filter(sql => sql === 'BEGIN')).toHaveLength(27) - expect(sqls.filter(sql => sql === 'COMMIT')).toHaveLength(27) + expect(sqls.filter(sql => sql === 'BEGIN')).toHaveLength(28) + expect(sqls.filter(sql => sql === 'COMMIT')).toHaveLength(28) expect(sqls[sqls.length - 2]).toBe('COMMIT') expect(sqls[sqls.length - 1]).toContain('SELECT pg_advisory_unlock') expect(clientRelease).toHaveBeenCalledTimes(1) diff --git a/control-api/test/db.migrationsOrder.test.ts b/control-api/test/db.migrationsOrder.test.ts index 5e23a53d10..da15ce6de7 100644 --- a/control-api/test/db.migrationsOrder.test.ts +++ b/control-api/test/db.migrationsOrder.test.ts @@ -69,13 +69,15 @@ describe('CONTROL_API_MIGRATIONS ordering invariant', () => { it('registers the narrow R56-B1 access-foundation definer hardening migration', async () => { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') - const version = '012a_user_access_foundation_definer_temp_shadow_hardening' + const version = '012b_user_access_foundation_definer_temp_shadow_hardening' const migration = CONTROL_API_MIGRATIONS.find(candidate => candidate.version === version) const versions = CONTROL_API_MIGRATIONS.map(candidate => candidate.version) expect(migration).toBeDefined() - expect(migration?.legacyVersions).toBeUndefined() - expect(versions.indexOf('0129_gfs_catalog_revision_components')).toBeLessThan( + expect(migration?.legacyVersions).toEqual([ + '012a_user_access_foundation_definer_temp_shadow_hardening', + ]) + expect(versions.indexOf('012a_gfs_catalog_revision_components')).toBeLessThan( versions.indexOf(version) ) expect(versions.indexOf(version)).toBeLessThan( @@ -111,7 +113,7 @@ describe('CONTROL_API_MIGRATIONS ordering invariant', () => { 'authorization_bump_operational_relationship_revision()', ] - // 0128_composable_catalog_revisions removes this trigger before 012a runs. + // 0129_composable_catalog_revisions removes this trigger before 012b runs. expect(signatures).not.toContain('authorization_bump_catalog_revision()') expect(sql.match(/ALTER FUNCTION public\./g)).toHaveLength(signatures.length) for (const signature of signatures) { @@ -278,10 +280,10 @@ describe('CONTROL_API_MIGRATIONS ordering invariant', () => { ) }) - it('registers the narrow 0125 team-delete compatibility successor', async () => { + it('registers the narrow 0126 team-delete compatibility successor', async () => { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const predecessorIndex = CONTROL_API_MIGRATIONS.findIndex( - candidate => candidate.version === '0125_user_access_foundation' + candidate => candidate.version === '0126_user_access_foundation' ) const migration = CONTROL_API_MIGRATIONS.find( candidate => candidate.version === '0138_authorization_revision_delete_compatibility' diff --git a/control-api/test/db.oauthGrantsOwnerGeneralization.test.ts b/control-api/test/db.oauthGrantsOwnerGeneralization.test.ts index c9e8ef8b8c..8a4aeccf1a 100644 --- a/control-api/test/db.oauthGrantsOwnerGeneralization.test.ts +++ b/control-api/test/db.oauthGrantsOwnerGeneralization.test.ts @@ -64,7 +64,7 @@ describe('0101 oauth_grants owner generalization migration', () => { versions.indexOf('0108_llm_provider_attempts_sdk_link_on_delete_set_null') ) expect(versions.indexOf('0124_entity_change_definer_search_path')).toBeLessThan( - versions.indexOf('0125_user_access_foundation') + versions.indexOf('0126_user_access_foundation') ) expect(versions).toContain('0099_gfs_upload_finalizing_recovery') }) diff --git a/control-api/test/db.realPostgresMigration.integration.test.ts b/control-api/test/db.realPostgresMigration.integration.test.ts index 8f8b06fcc2..54e11f27d9 100644 --- a/control-api/test/db.realPostgresMigration.integration.test.ts +++ b/control-api/test/db.realPostgresMigration.integration.test.ts @@ -2004,7 +2004,7 @@ describeRealPostgres('control-api real Postgres migrations', () => { it('prevents temp-type shadowing in access-foundation SECURITY DEFINER triggers', async () => { const { initDb } = await import('../src/db.js') const connector = { connect: () => dbPool.connect() } - const hardeningVersion = '012a_user_access_foundation_definer_temp_shadow_hardening' + const hardeningVersion = '012b_user_access_foundation_definer_temp_shadow_hardening' const preUpgradeFunctions = await readAccessFoundationDefinerState(dbPool) expect(preUpgradeFunctions.rows.map(row => row.function_name)).toEqual( diff --git a/control-api/test/db.userAccessFoundationMigration.test.ts b/control-api/test/db.userAccessFoundationMigration.test.ts index e75da71aa9..502dee418f 100644 --- a/control-api/test/db.userAccessFoundationMigration.test.ts +++ b/control-api/test/db.userAccessFoundationMigration.test.ts @@ -10,7 +10,7 @@ vi.mock('../src/config.js', () => ({ async function migrationSql(): Promise { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0125_user_access_foundation' + candidate => candidate.version === '0126_user_access_foundation' ) expect(migration).toBeDefined() @@ -32,45 +32,74 @@ async function legacyEpochBackfillSql(): Promise { } describe('user-access foundation migration', () => { + it('keeps the parent migration in 0125 and aliases the previously published PR1 slot', async () => { + const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') + const parent = CONTROL_API_MIGRATIONS.find( + candidate => candidate.version === '0125_admin_subscription_rate_limit_namespace' + ) + const foundation = CONTROL_API_MIGRATIONS.find( + candidate => candidate.version === '0126_user_access_foundation' + ) + + expect(parent).toBeDefined() + expect(foundation?.legacyVersions).toContain('0125_user_access_foundation') + expect(CONTROL_API_MIGRATIONS.indexOf(parent!)).toBeLessThan( + CONTROL_API_MIGRATIONS.indexOf(foundation!) + ) + }) + it('carries all prior PR1 migration identities as legacy aliases', async () => { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const expectedAliases = new Map([ [ - '0125_user_access_foundation', + '0126_user_access_foundation', [ + '0125_user_access_foundation', '0109_user_access_foundation', '0107_user_access_foundation', '0101_user_access_foundation', ], ], [ - '0126_invitation_delivery_commands', + '0127_invitation_delivery_commands', [ + '0126_invitation_delivery_commands', '010a_invitation_delivery_commands', '0108_invitation_delivery_commands', '0102_invitation_delivery_commands', ], ], [ - '0127_catalog_utf8_ordering', - ['010b_catalog_utf8_ordering', '0109_catalog_utf8_ordering', '0103_catalog_utf8_ordering'], + '0128_catalog_utf8_ordering', + [ + '0127_catalog_utf8_ordering', + '010b_catalog_utf8_ordering', + '0109_catalog_utf8_ordering', + '0103_catalog_utf8_ordering', + ], ], [ - '0128_composable_catalog_revisions', + '0129_composable_catalog_revisions', [ + '0128_composable_catalog_revisions', '010c_composable_catalog_revisions', '010a_composable_catalog_revisions', '0104_composable_catalog_revisions', ], ], [ - '0129_gfs_catalog_revision_components', + '012a_gfs_catalog_revision_components', [ + '0129_gfs_catalog_revision_components', '010d_gfs_catalog_revision_components', '010b_gfs_catalog_revision_components', '0105_gfs_catalog_revision_components', ], ], + [ + '012b_user_access_foundation_definer_temp_shadow_hardening', + ['012a_user_access_foundation_definer_temp_shadow_hardening'], + ], [ '0130_legacy_password_security_epoch_backfill', [ @@ -210,10 +239,10 @@ describe('user-access foundation migration', () => { ) expect(migration).toBeDefined() const gfsIndex = CONTROL_API_MIGRATIONS.findIndex( - candidate => candidate.version === '0129_gfs_catalog_revision_components' + candidate => candidate.version === '012a_gfs_catalog_revision_components' ) const hardeningIndex = CONTROL_API_MIGRATIONS.findIndex( - candidate => candidate.version === '012a_user_access_foundation_definer_temp_shadow_hardening' + candidate => candidate.version === '012b_user_access_foundation_definer_temp_shadow_hardening' ) const epochIndex = CONTROL_API_MIGRATIONS.findIndex( candidate => candidate.version === '0130_legacy_password_security_epoch_backfill' diff --git a/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts index 988b43132f..c8c50a3477 100644 --- a/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts +++ b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts @@ -55,7 +55,7 @@ async function versions(pool: Pool): Promise { async function removeMigrationReceiptForReplay(pool: Pool, version: string): Promise { const versionsToRemove = - version === '0125_user_access_foundation' + version === '0126_user_access_foundation' ? [version, '0138_authorization_revision_delete_compatibility'] : [version] await pool.query('DELETE FROM schema_migrations WHERE version = ANY($1::text[])', [ @@ -202,7 +202,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { for (const candidate of cases) { await databasePool.query(`CREATE INDEX ${candidate.name} ON ${table} ${candidate.actual}`) const entry: OnlineIndexDefinition = { - migrationVersion: '0125_user_access_foundation', + migrationVersion: '0126_user_access_foundation', name: candidate.name, table, createSql: `CREATE INDEX CONCURRENTLY ${candidate.name} ON ${table} ${candidate.expected}`, @@ -216,7 +216,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { await databasePool.query(`CREATE INDEX ${uniqueName} ON ${table} (userid)`) await expect( ensureOnlineIndex(databasePool, { - migrationVersion: '0125_user_access_foundation', + migrationVersion: '0126_user_access_foundation', name: uniqueName, table, unique: true, @@ -228,7 +228,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { it('repairs an equivalent interrupted index and enforces the online bound', async () => { const name = `d34_interrupted_${randomBytes(4).toString('hex')}` const entry: OnlineIndexDefinition = { - migrationVersion: '0125_user_access_foundation', + migrationVersion: '0126_user_access_foundation', name, table: 'd34_interrupted_index', unique: true, @@ -277,7 +277,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { const functionName = `d34_slow_index_value_${suffix}` const name = `d34_slow_index_${suffix}_idx` const entry: OnlineIndexDefinition = { - migrationVersion: '0125_user_access_foundation', + migrationVersion: '0126_user_access_foundation', name, table, createSql: `CREATE INDEX CONCURRENTLY ${name} ON ${table} (${functionName}(value))`, @@ -391,7 +391,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { const locker = await databasePool.connect() await databasePool.query( `DELETE FROM schema_migrations - WHERE version IN ('0128_composable_catalog_revisions', '0129_gfs_catalog_revision_components')` + WHERE version IN ('0129_composable_catalog_revisions', '012a_gfs_catalog_revision_components')` ) await locker.query('BEGIN') await locker.query('LOCK TABLE team_members IN ACCESS EXCLUSIVE MODE') @@ -407,8 +407,8 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { expect(Date.now() - started).toBeGreaterThanOrEqual(9_000) expect(Date.now() - started).toBeLessThan(15_000) const failedVersions = await versions(databasePool) - expect(failedVersions).not.toContain('0128_composable_catalog_revisions') - expect(failedVersions).not.toContain('0129_gfs_catalog_revision_components') + expect(failedVersions).not.toContain('0129_composable_catalog_revisions') + expect(failedVersions).not.toContain('012a_gfs_catalog_revision_components') await initDb({ connect: () => databasePool.connect() }) }, 20_000) @@ -429,7 +429,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { ...PR1_MIGRATION_VERSIONS.map(version => ({ version, apply: async (db: DbClient) => { - if (version === '0128_composable_catalog_revisions') { + if (version === '0129_composable_catalog_revisions') { await db.query('SELECT pg_sleep(20)') } }, @@ -453,7 +453,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { `SELECT version FROM ${recordTable} ORDER BY version` ) expect(recorded.rows).toEqual([]) - expect(appliedVersions).not.toContain('0129_gfs_catalog_revision_components') + expect(appliedVersions).not.toContain('012a_gfs_catalog_revision_components') } finally { client.release(true) } diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index 4716568fe8..40918ead06 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -30,7 +30,7 @@ const FRESH_TABLE_INDEXES = Object.freeze([ 'invitation_delivery_commands_invitation_idx', ]) -const USER_ACCESS_FOUNDATION_VERSION = '0125_user_access_foundation' +const USER_ACCESS_FOUNDATION_VERSION = '0126_user_access_foundation' const AUTHORIZATION_REVISION_COMPATIBILITY_VERSION = '0138_authorization_revision_delete_compatibility' @@ -46,13 +46,14 @@ function expectedMigrationExecutionOrder(versions: readonly string[]): string[] describe('D34 migration execution policy', () => { it('classifies inherited parent migrations before the re-slotted PR1 migrations', () => { - expect(DEV_POST_0106_MIGRATION_VERSIONS.slice(-6)).toEqual([ + expect(DEV_POST_0106_MIGRATION_VERSIONS.slice(-7)).toEqual([ '0119_dynamic_clients_table', '0120_dynamic_clients_runtime_access', '0121_oauth_install_identity', '0122_durable_entity_change_feed', '0123_entity_change_checkpoint_cursor_convergence', '0124_entity_change_definer_search_path', + '0125_admin_subscription_rate_limit_namespace', ]) expect(PR1_MIGRATION_VERSIONS).not.toContain('0117_control_admin_invitation_replace_inviter') expect(PR1_MIGRATION_VERSIONS).not.toContain('0118_control_admin_replace_inviter_accept_guard') @@ -76,10 +77,10 @@ describe('D34 migration execution policy', () => { expect(PR1_ONLINE_INDEX_PLAN).toHaveLength(25) expect(new Set(PR1_ONLINE_INDEX_PLAN.map(index => index.name))).toHaveLength(25) expect( - PR1_ONLINE_INDEX_PLAN.filter(index => index.migrationVersion.startsWith('0125')) + PR1_ONLINE_INDEX_PLAN.filter(index => index.migrationVersion.startsWith('0126')) ).toHaveLength(18) expect( - PR1_ONLINE_INDEX_PLAN.filter(index => index.migrationVersion.startsWith('0127')) + PR1_ONLINE_INDEX_PLAN.filter(index => index.migrationVersion.startsWith('0128')) ).toHaveLength(7) expect( PR1_ONLINE_INDEX_PLAN.some(index => index.name.startsWith('external_user_sessions_')) @@ -279,8 +280,12 @@ describe('D34 PR1 migration runner', () => { ...PR1_MIGRATION_VERSIONS, ]) ) - expect(queries.filter(({ sql }) => sql === 'BEGIN')).toHaveLength(26) - expect(queries.filter(({ sql }) => sql === 'COMMIT')).toHaveLength(26) + expect(queries.filter(({ sql }) => sql === 'BEGIN')).toHaveLength( + DEV_POST_0106_MIGRATION_VERSIONS.length + PR1_MIGRATION_VERSIONS.length + ) + expect(queries.filter(({ sql }) => sql === 'COMMIT')).toHaveLength( + DEV_POST_0106_MIGRATION_VERSIONS.length + PR1_MIGRATION_VERSIONS.length + ) expect(queries.filter(({ sql }) => sql === 'ROLLBACK')).toHaveLength(0) }) @@ -385,7 +390,7 @@ describe('D34 PR1 migration runner', () => { expect(recorded).toEqual(expectedOrder) }) - it('commits 0125 and its deletion-compatible successor as one migration phase', async () => { + it('commits 0126 and its deletion-compatible successor as one migration phase', async () => { const events: string[] = [] const transactionEvents: string[] = [] let activeTransaction = 0 @@ -475,7 +480,7 @@ describe('D34 PR1 migration runner', () => { ) }) - it('repairs an applied 0125 prefix before preparing 0127 indexes', async () => { + it('repairs an applied 0126 prefix before preparing 0128 indexes', async () => { const events: string[] = [] const migrations = [ ...DEV_POST_0106_MIGRATION_VERSIONS.map(version => ({ @@ -497,7 +502,7 @@ describe('D34 PR1 migration runner', () => { events.push(sql) } if (sql.includes('FROM pg_class index_rel')) { - events.push('prepare:0127') + events.push('prepare:0128') const entry = PR1_ONLINE_INDEX_PLAN.find(index => index.name === values?.[0]) return { rows: entry @@ -519,12 +524,12 @@ describe('D34 PR1 migration runner', () => { const appliedVersions = new Set([ ...DEV_POST_0106_MIGRATION_VERSIONS, USER_ACCESS_FOUNDATION_VERSION, - '0126_invitation_delivery_commands', + '0127_invitation_delivery_commands', ...PR1_MIGRATION_VERSIONS.filter( version => version !== USER_ACCESS_FOUNDATION_VERSION && - version !== '0126_invitation_delivery_commands' && - version !== '0127_catalog_utf8_ordering' && + version !== '0127_invitation_delivery_commands' && + version !== '0128_catalog_utf8_ordering' && version !== AUTHORIZATION_REVISION_COMPATIBILITY_VERSION ), ]) @@ -539,18 +544,18 @@ describe('D34 PR1 migration runner', () => { }) expect(events.indexOf(AUTHORIZATION_REVISION_COMPATIBILITY_VERSION)).toBeLessThan( - events.indexOf('prepare:0127') + events.indexOf('prepare:0128') ) expect(events.indexOf(`receipt:${AUTHORIZATION_REVISION_COMPATIBILITY_VERSION}`)).toBeLessThan( - events.indexOf('prepare:0127') + events.indexOf('prepare:0128') ) expect(events.filter(event => event === 'BEGIN')).toHaveLength(3) expect(events.filter(event => event === 'COMMIT')).toHaveLength(3) const repairCommit = events.indexOf('COMMIT') - expect(repairCommit).toBeLessThan(events.indexOf('prepare:0127')) + expect(repairCommit).toBeLessThan(events.indexOf('prepare:0128')) }) - it('preserves the 0125 alias when its 0138 successor is already recorded', async () => { + it('preserves the prior foundation alias when its 0138 successor is already recorded', async () => { const foundation = { version: USER_ACCESS_FOUNDATION_VERSION, legacyVersions: ['0109_user_access_foundation'], @@ -595,7 +600,7 @@ describe('D34 PR1 migration runner', () => { expect(appliedVersions).toContain(USER_ACCESS_FOUNDATION_VERSION) }) - it('fails closed when 0138 is recorded without the 0125 prerequisite', async () => { + it('fails closed when 0138 is recorded without the 0126 prerequisite', async () => { await expect( applyPendingPr1Migrations({ db: { query: vi.fn(async () => ({ rows: [], rowCount: 0 })) }, diff --git a/control-api/test/routes.externalWorkflows.test.ts b/control-api/test/routes.externalWorkflows.test.ts index eb41b14025..212b006c1b 100644 --- a/control-api/test/routes.externalWorkflows.test.ts +++ b/control-api/test/routes.externalWorkflows.test.ts @@ -45,6 +45,14 @@ vi.mock('../src/services/auth/externalSessionAuthentication.js', () => ({ mockAuthenticateExternalUserSession(...args), authenticateExternalUserSessionIdentity: (...args: unknown[]) => mockAuthenticateExternalUserSession(...args), + externalSessionUserIdForRateLimit: (token: string) => + token === 'user-session-token' + ? 'user-123' + : token === 'user-session-token-b' + ? 'user-456' + : token === 'user-session-token-rotated' + ? 'user-123' + : null, })) vi.mock('../src/services/rateLimiterService.js', () => ({ From 13ad712bbd3aa9cc04d632a280a5fabb99863922 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:23:27 -0600 Subject: [PATCH 283/528] test(rpc-proxy): use real admission error class in route mock The synchronized route now checks the Control API message-admission error with instanceof. Reuse the production class in the route-test module mock so the added outer-catch cases exercise the intended logging path instead of failing on an absent mock export. --- external-rest-api/src/releaseManifest.ts | 2 +- rpc-proxy/package-lock.json | 4 ++-- rpc-proxy/package.json | 2 +- rpc-proxy/src/__tests__/wake-and-hold-route.test.ts | 8 +++++++- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index eff448f41b..68e0b1eb0b 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -9,7 +9,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.10.0', externalRestApiVersion: '0.1.113', - rpcProxyVersion: '0.1.126', + rpcProxyVersion: '0.1.127', desktopVersion: '0.10.0', minimumDesktopVersion: '0.1.252', } diff --git a/rpc-proxy/package-lock.json b/rpc-proxy/package-lock.json index 9dbe73d72d..a6161efd1a 100644 --- a/rpc-proxy/package-lock.json +++ b/rpc-proxy/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-rpc-proxy", - "version": "0.1.126", + "version": "0.1.127", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-rpc-proxy", - "version": "0.1.126", + "version": "0.1.127", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/rpc-proxy/package.json b/rpc-proxy/package.json index 23152ba52e..a78fe097ed 100644 --- a/rpc-proxy/package.json +++ b/rpc-proxy/package.json @@ -1,6 +1,6 @@ { "name": "clerum-rpc-proxy", - "version": "0.1.126", + "version": "0.1.127", "description": "External-facing RPC proxy for tenant-scoped MCP access", "main": "dist/main.js", "engines": { diff --git a/rpc-proxy/src/__tests__/wake-and-hold-route.test.ts b/rpc-proxy/src/__tests__/wake-and-hold-route.test.ts index 9bb66c4fbd..373189d5a8 100644 --- a/rpc-proxy/src/__tests__/wake-and-hold-route.test.ts +++ b/rpc-proxy/src/__tests__/wake-and-hold-route.test.ts @@ -53,7 +53,13 @@ const controlApiMock = vi.hoisted(() => ({ vi.mock('../authToken.js', () => authTokenMock) vi.mock('../services/mcpProxyService.js', () => serviceMock) -vi.mock('../services/controlApiRestService.js', () => controlApiMock) +vi.mock('../services/controlApiRestService.js', async importOriginal => { + const actual = await importOriginal() + return { + ...controlApiMock, + ControlApiHostMessageAdmissionError: actual.ControlApiHostMessageAdmissionError, + } +}) // Issue #791 §11.4: a wake-eligible finite operation carries host:wake:write in // addition to its operation scope (Desktop adds it; the route scope guard is From 67692928e762122dc40360f8160a0ad29c5c1267 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:35:13 -0600 Subject: [PATCH 284/528] fix(control-api): r58-m2 key workflow triggers by authenticated user Class-check: workflowTriggerRateLimit -> 1 external admission route; staged V1/V2 identity and the separate MCP Host bearer-only limiter are preserved. Repro-test: workflows.shared.rateLimit.test.ts staged-V2 case (fails at 13ad712bbd3aa9cc04d632a280a5fabb99863922); passes at f77120267b13fc855242d259a67fe8b266e7b13d. --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- .../src/routes/workflows/shared/rateLimit.ts | 10 +++++++--- ...ionSingleTouch.realPostgres.integration.test.ts | 13 +++++++++++++ .../test/workflows.shared.rateLimit.test.ts | 14 ++++++++++++++ 5 files changed, 37 insertions(+), 6 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index b3f1d2c975..758392154a 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.729", + "version": "0.1.730", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.729", + "version": "0.1.730", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index f14702c939..36fddb2cd2 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.729", + "version": "0.1.730", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/routes/workflows/shared/rateLimit.ts b/control-api/src/routes/workflows/shared/rateLimit.ts index 4cf06c1d2e..789e797506 100644 --- a/control-api/src/routes/workflows/shared/rateLimit.ts +++ b/control-api/src/routes/workflows/shared/rateLimit.ts @@ -447,11 +447,15 @@ function verifiedUserSessionRateLimitSubject(token: string): string | null { } /** - * External trigger lane: prefer the verified Profile userId when - * external-rest-api forwards both a service bearer and x-user-session-token. - * An unverified session header falls back to IP so rotation cannot evade the cap. + * External trigger lane: prefer the canonical authenticated session identity + * staged before this limiter for either V1 or V2. A raw unverified session + * header falls back to IP so rotation cannot evade the cap. */ export function workflowTriggerRateLimitCredential(req: Request): string | null { + const stagedUserId = (req as Request & { externalAuth?: { userId?: string } }).externalAuth + ?.userId + if (stagedUserId) return `user:${stagedUserId}` + const userSessionToken = String(req.header('x-user-session-token') || '').trim() if (userSessionToken) { return ( diff --git a/control-api/test/externalSessionSingleTouch.realPostgres.integration.test.ts b/control-api/test/externalSessionSingleTouch.realPostgres.integration.test.ts index 172a096b5c..f06643abcd 100644 --- a/control-api/test/externalSessionSingleTouch.realPostgres.integration.test.ts +++ b/control-api/test/externalSessionSingleTouch.realPostgres.integration.test.ts @@ -1,7 +1,9 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import type { Request } from 'express' import { randomBytes, randomUUID } from 'node:crypto' import { Pool } from 'pg' import { initDb, pool } from '../src/db.js' +import { workflowTriggerRateLimitCredential } from '../src/routes/workflows/shared/rateLimit.js' import type { EffectiveUserAccessPolicy } from '../src/services/access/userAccessPolicy.js' import { authenticateExternalUserSession, @@ -100,6 +102,17 @@ describeRealPostgres('external-session staged touch ownership on real PostgreSQL const identity = await authenticateExternalUserSessionIdentity(issued.token) expect(identity.status).toBe('authenticated') if (identity.status !== 'authenticated') throw new Error('Stage A rejected the issued session') + expect( + workflowTriggerRateLimitCredential({ + externalAuth: identity.claims, + ip: '203.0.113.10', + header(name: string) { + if (name.toLowerCase() === 'authorization') return 'Bearer workflow-service-token' + if (name.toLowerCase() === 'x-user-session-token') return issued.token + return undefined + }, + } as Request) + ).toBe(`user:${userId}`) const policy: EffectiveUserAccessPolicy = { policyVersion: '1', diff --git a/control-api/test/workflows.shared.rateLimit.test.ts b/control-api/test/workflows.shared.rateLimit.test.ts index 6b7aed01aa..0c622061f0 100644 --- a/control-api/test/workflows.shared.rateLimit.test.ts +++ b/control-api/test/workflows.shared.rateLimit.test.ts @@ -1067,6 +1067,20 @@ describe('routes/workflows/shared/rateLimit', () => { expect(workflowTriggerRateLimitCredential(reqFor('forged-not-a-jwt'))).not.toBe('service-token') }) + it('workflowTriggerRateLimitCredential uses the authenticated staged V2 identity', () => { + const req = { + externalAuth: { userId: 'user-v2-staged', sessionContract: 'v2' }, + ip: '203.0.113.10', + header(name: string) { + if (name.toLowerCase() === 'authorization') return 'Bearer service-token' + if (name.toLowerCase() === 'x-user-session-token') return 'signed-v2-session' + return undefined + }, + } as express.Request + + expect(workflowTriggerRateLimitCredential(req)).toBe('user:user-v2-staged') + }) + it('workflowTriggerRateLimitCredential ignores whitespace user tokens and falls back to bearer', () => { const req = { header(name: string) { From 2370a399d4221350946462d41427fad8aee0087f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:39:58 -0600 Subject: [PATCH 285/528] fix(control-api): r58-m1 repair v1 acceptance fixtures Class-check: V1 issuance/revocation fixture pattern -> four stale test sites plus one pinned historical source set -> model canonical SQL and pin old reader/signer source; production unchanged. Repro-test: focused auth/invitation tests fail at 67692928e762122dc40360f8160a0ad29c5c1267 for stale SQL fixtures; the RealPG test fails collection there on external base/ imports. The corrected suite passes with the pinned source fixture. Original R58 behavioral RED: legacyV1SessionGeneration.test.ts fails at 818d43d32a9f5f67a6cc9b59345b8d17bee9e9b9 for same-second generation/fingerprint. Finding-ID: R58-M1 --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- control-api/test/app.test.ts | 8 + .../directoryAuthorizationPrivacy.test.ts | 21 +- .../test/fixtures/legacyV1Base/src/config.ts | 1 + .../test/fixtures/legacyV1Base/src/db.ts | 1 + .../src/middleware/externalSessionAuth.ts | 198 ++++++++++++++++++ .../legacyV1Base/src/observability/logger.ts | 1 + .../fixtures/legacyV1Base/src/profileTypes.ts | 66 ++++++ .../utils/auth/externalSessionAuthToken.ts | 76 +++++++ ...eneration.realPostgres.integration.test.ts | 49 ++++- ...es.externalInvitations.unavailable.test.ts | 20 +- 12 files changed, 433 insertions(+), 14 deletions(-) create mode 100644 control-api/test/fixtures/legacyV1Base/src/config.ts create mode 100644 control-api/test/fixtures/legacyV1Base/src/db.ts create mode 100644 control-api/test/fixtures/legacyV1Base/src/middleware/externalSessionAuth.ts create mode 100644 control-api/test/fixtures/legacyV1Base/src/observability/logger.ts create mode 100644 control-api/test/fixtures/legacyV1Base/src/profileTypes.ts create mode 100644 control-api/test/fixtures/legacyV1Base/src/utils/auth/externalSessionAuthToken.ts diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 758392154a..40685ff8fd 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.730", + "version": "0.1.732", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.730", + "version": "0.1.732", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 36fddb2cd2..edf241fcb3 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.730", + "version": "0.1.732", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/app.test.ts b/control-api/test/app.test.ts index 667190e701..a9ce215c80 100644 --- a/control-api/test/app.test.ts +++ b/control-api/test/app.test.ts @@ -284,6 +284,12 @@ describe('app router wiring', () => { rowCount: 1, } } + if (sql.includes('SELECT lifecycle_state, lifecycle_version')) { + return { + rows: [{ lifecycle_state: 'active', lifecycle_version: 1 }], + rowCount: 1, + } + } if (sql.includes('JOIN team_members')) { return { rows: [{ role: payload.role, lifecycle_version: 1 }], rowCount: 1 } } @@ -312,6 +318,8 @@ describe('app router wiring', () => { expect.stringContaining('clock_timestamp()'), expect.stringContaining('external_user_session_security_epochs'), expect.stringContaining('JOIN team_members'), + expect.stringContaining('SELECT lifecycle_state, lifecycle_version'), + expect.stringContaining('clock_timestamp()'), 'COMMIT', ]) }) diff --git a/control-api/test/directoryAuthorizationPrivacy.test.ts b/control-api/test/directoryAuthorizationPrivacy.test.ts index 00ff4dfeff..32ca32c982 100644 --- a/control-api/test/directoryAuthorizationPrivacy.test.ts +++ b/control-api/test/directoryAuthorizationPrivacy.test.ts @@ -705,6 +705,8 @@ describe('directory privacy and atomic authorization', () => { let status = invitation.status let passwordHash: string | null = null let membershipWrites = 0 + let lifecycleVersion = 1 + let lifecycleAdvances = 0 mocks.query.mockImplementation(async (sql: string) => { if ( sql.includes('FROM invitations i') && @@ -751,6 +753,11 @@ describe('directory privacy and atomic authorization', () => { passwordHash = 'stored-hash' return { rows: [], rowCount: 1 } } + if (sql.includes('SET lifecycle_version = lifecycle_version + 1')) { + lifecycleVersion += 1 + lifecycleAdvances += 1 + return { rows: [{ lifecycle_version: lifecycleVersion }], rowCount: 1 } + } if (sql.includes('SELECT id') && sql.includes('FROM users') && sql.includes('FOR UPDATE')) { return { rows: [{ id: TARGET }], rowCount: 1 } } @@ -770,6 +777,8 @@ describe('directory privacy and atomic authorization', () => { expect(status).toBe('accepted') expect(passwordHash).not.toBeNull() expect(membershipWrites).toBe(1) + expect(lifecycleVersion).toBe(2) + expect(lifecycleAdvances).toBe(1) await expect( setInvitationPasswordForEmail( @@ -969,6 +978,7 @@ describe('directory privacy and atomic authorization', () => { .mockResolvedValueOnce({ rows: [{ password_hash: currentHash }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [{ id: MANAGER }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [{ id: MANAGER }], rowCount: 1 }) + .mockResolvedValueOnce({ rows: [{ lifecycle_version: 2 }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 2 }) @@ -981,9 +991,18 @@ describe('directory privacy and atomic authorization', () => { expect(passwordUpdate).toContain('AND password_hash = $4') expect(String(mocks.query.mock.calls[2]?.[0])).toContain('FOR UPDATE') expect(String(mocks.query.mock.calls[3]?.[0])).toContain( + 'SET lifecycle_version = lifecycle_version + 1' + ) + expect(mocks.query.mock.calls[3]?.[1]).toEqual([MANAGER]) + expect(String(mocks.query.mock.calls[4]?.[0])).toContain( 'INSERT INTO external_user_session_security_epochs' ) - expect(String(mocks.query.mock.calls[4]?.[0])).toContain('UPDATE external_user_sessions') + expect(String(mocks.query.mock.calls[5]?.[0])).toContain('UPDATE external_user_sessions') + expect( + mocks.query.mock.calls.filter(([sql]) => + String(sql).includes('SET lifecycle_version = lifecycle_version + 1') + ) + ).toHaveLength(1) }) it('treats wildcard characters literally, excludes channels, and publishes a stable cursor', async () => { diff --git a/control-api/test/fixtures/legacyV1Base/src/config.ts b/control-api/test/fixtures/legacyV1Base/src/config.ts new file mode 100644 index 0000000000..e27913b8ea --- /dev/null +++ b/control-api/test/fixtures/legacyV1Base/src/config.ts @@ -0,0 +1 @@ +export { config } from '../../../../src/config.js' diff --git a/control-api/test/fixtures/legacyV1Base/src/db.ts b/control-api/test/fixtures/legacyV1Base/src/db.ts new file mode 100644 index 0000000000..747c66ad3f --- /dev/null +++ b/control-api/test/fixtures/legacyV1Base/src/db.ts @@ -0,0 +1 @@ +export { pool } from '../../../../src/db.js' diff --git a/control-api/test/fixtures/legacyV1Base/src/middleware/externalSessionAuth.ts b/control-api/test/fixtures/legacyV1Base/src/middleware/externalSessionAuth.ts new file mode 100644 index 0000000000..abf10ae32f --- /dev/null +++ b/control-api/test/fixtures/legacyV1Base/src/middleware/externalSessionAuth.ts @@ -0,0 +1,198 @@ +import { NextFunction, Request, Response } from 'express' +import { DatabaseError } from 'pg' +import { pool } from '../db.js' +import { rootLogger } from '../observability/logger.js' +import { AuthClaims, TeamRole } from '../profileTypes.js' +import { verifyExternalSessionToken } from '../utils/auth/externalSessionAuthToken.js' + +/** Retry-After sent when the user row cannot be read to validate a session. */ +export const SESSION_BACKEND_RETRY_AFTER_SECONDS = 2 + +// SQLSTATE classes that mean the server could not run the query: 08 connection +// exception, 53 insufficient resources, 57 operator intervention (includes +// 57014 statement timeout), 58 system error. +const BACKEND_UNAVAILABLE_SQLSTATE_CLASSES = new Set(['08', '53', '57', '58']) + +// A Node system error code (ECONNREFUSED, ETIMEDOUT, EPIPE, ...). Node's own +// argument and state errors use ERR_* codes, which this does not match. +const SYSTEM_ERROR_CODE = /^E[A-Z]+$/ + +/** + * True when the lookup failed because PostgreSQL could not be reached or could + * not run it: + * - a DatabaseError whose SQLSTATE class is 08, 53, 57 or 58; + * - a plain Error, which is how pg and pg-pool report an acquire timeout or a + * dropped connection; + * - an error carrying a Node system code, as socket failures do (including the + * AggregateError Node raises when every address of a host refuses). + * Anything else is a defect: a DatabaseError in another class (22P02, 42P01, + * ...) is in the query or the schema, and a TypeError or RangeError is in this + * process's code. A 503 would hide either one. + */ +function isBackendUnavailableError(error: unknown): boolean { + if (error instanceof DatabaseError) { + return BACKEND_UNAVAILABLE_SQLSTATE_CLASSES.has(String(error.code).slice(0, 2)) + } + if (!(error instanceof Error)) return false + const code = (error as { code?: unknown }).code + if (typeof code === 'string' && SYSTEM_ERROR_CODE.test(code)) return true + return error.constructor === Error +} + +export type ExternalAuthedRequest = Request & { + externalAuth?: AuthClaims +} + +/** + * The session JWT is only a signed locator. The user row remains authoritative + * for lifecycle and generation, so retirement takes effect before the token's + * nominal expiry. A missing row and an inactive row intentionally share the + * same denial to avoid user enumeration. + */ +export async function isCurrentExternalSession(claims: AuthClaims): Promise { + const authGeneration = claims.authGeneration + if ( + typeof authGeneration !== 'number' || + !Number.isSafeInteger(authGeneration) || + authGeneration < 1 + ) + return false + const result = await pool.query( + `SELECT lifecycle_state, lifecycle_version + FROM users + WHERE id = $1 + LIMIT 1`, + [claims.userId] + ) + const row = result.rows[0] as + | { lifecycle_state?: unknown; lifecycle_version?: unknown } + | undefined + if (row?.lifecycle_state !== 'active') return false + return Number(row.lifecycle_version) === authGeneration +} + +export async function assertCurrentExternalSession(claims: AuthClaims): Promise { + if (!(await isCurrentExternalSession(claims))) { + throw new Error('external session is inactive or stale') + } +} + +function extractUserSessionToken(req: Request): string { + return String(req.header('x-user-session-token') || '').trim() +} + +export function requireValidExternalSessionToken( + req: ExternalAuthedRequest, + res: Response, + next: NextFunction +): Promise { + return requireValidExternalSessionTokenAsync(req, res, next) +} + +async function requireValidExternalSessionTokenAsync( + req: ExternalAuthedRequest, + res: Response, + next: NextFunction +): Promise { + try { + const token = extractUserSessionToken(req) + const claims = verifyExternalSessionToken(token) + if (!claims) { + res.status(401).json({ error: 'Unauthorized' }) + return + } + + let current: boolean + try { + current = await isCurrentExternalSession(claims) + } catch (error) { + // A query or schema defect goes to the error handler (500). + if (!isBackendUnavailableError(error)) throw error + // The users-table lookup could not run (pool acquire timeout, connection + // or statement timeout): the session could not be judged, which is + // neither a denial (401) nor a defect in this request (500). + rootLogger.warn( + { + event: 'external_session_backend_unavailable', + err: error instanceof Error ? error.message : String(error), + }, + 'external session validation could not reach PostgreSQL' + ) + res.setHeader('Retry-After', String(SESSION_BACKEND_RETRY_AFTER_SECONDS)) + res.setHeader('Cache-Control', 'no-store') + res.status(503).json({ + error: 'session_backend_unavailable', + retryAfterSeconds: SESSION_BACKEND_RETRY_AFTER_SECONDS, + }) + return + } + if (!current) { + res.status(401).json({ error: 'Unauthorized' }) + return + } + + req.externalAuth = claims + next() + } catch (error) { + next(error) + } +} + +export function requireExternalUserParamMatch(paramName = 'userId') { + return (req: ExternalAuthedRequest, res: Response, next: NextFunction): void => { + const claims = req.externalAuth + const requestedUserId = String(req.params?.[paramName] || '').trim() + if (!claims || !requestedUserId || claims.userId !== requestedUserId) { + res.status(403).json({ error: 'Forbidden' }) + return + } + next() + } +} + +export function requireExternalTeamParamMatch(paramName = 'teamId') { + return (req: ExternalAuthedRequest, res: Response, next: NextFunction): void => { + const claims = req.externalAuth + const requestedTeamId = String(req.params?.[paramName] || '').trim() + if (!claims || !requestedTeamId || claims.teamId !== requestedTeamId) { + res.status(403).json({ error: 'Forbidden' }) + return + } + next() + } +} + +export function rejectBodyUserTeamMismatch( + req: ExternalAuthedRequest, + res: Response, + next: NextFunction +): void { + const claims = req.externalAuth + if (!claims) { + res.status(403).json({ error: 'Forbidden' }) + return + } + + const body = req.body ?? {} + const bodyUserId = String((body as { userId?: unknown }).userId || '').trim() + const bodyTeamId = String((body as { teamId?: unknown }).teamId || '').trim() + if ( + (bodyUserId && bodyUserId !== claims.userId) || + (bodyTeamId && bodyTeamId !== claims.teamId) + ) { + res.status(403).json({ error: 'Forbidden' }) + return + } + next() +} + +export function requireExternalRole(allowedRoles: TeamRole[]) { + return (req: ExternalAuthedRequest, res: Response, next: NextFunction): void => { + const role = req.externalAuth?.role + if (!role || !allowedRoles.includes(role)) { + res.status(403).json({ error: 'Forbidden' }) + return + } + next() + } +} diff --git a/control-api/test/fixtures/legacyV1Base/src/observability/logger.ts b/control-api/test/fixtures/legacyV1Base/src/observability/logger.ts new file mode 100644 index 0000000000..0469119286 --- /dev/null +++ b/control-api/test/fixtures/legacyV1Base/src/observability/logger.ts @@ -0,0 +1 @@ +export { rootLogger } from '../../../../../src/observability/logger.js' diff --git a/control-api/test/fixtures/legacyV1Base/src/profileTypes.ts b/control-api/test/fixtures/legacyV1Base/src/profileTypes.ts new file mode 100644 index 0000000000..ee30f86f8b --- /dev/null +++ b/control-api/test/fixtures/legacyV1Base/src/profileTypes.ts @@ -0,0 +1,66 @@ +export type TeamRole = 'admin' | 'inviter' | 'member' +export const TEAM_ROLES: TeamRole[] = ['admin', 'inviter', 'member'] + +export type RpcScope = + | 'mcp:servers:list' + | 'mcp:server:invoke' + | 'host:health:read' + | 'host:status:read' + | 'host:activity:read' + | 'host:message:invoke' + | 'host:wake:write' + | 'host:workflow-approval:decide' + | 'host:task:read' + | 'host:approval:write' + | 'host:session:read' + | 'host:session:write' + | 'host:model:write' + | 'host:cron:read' + | 'host:cron:ack' + | 'desktop:view' + | 'sandbox:ui:view' +export const RPC_SCOPES: RpcScope[] = [ + 'mcp:servers:list', + 'mcp:server:invoke', + 'host:health:read', + 'host:status:read', + 'host:activity:read', + 'host:message:invoke', + 'host:wake:write', + 'host:workflow-approval:decide', + 'host:task:read', + 'host:approval:write', + 'host:session:read', + 'host:session:write', + 'host:model:write', + 'host:cron:read', + 'host:cron:ack', + 'desktop:view', + 'sandbox:ui:view', +] +export type RpcTokenType = 'service' | 'user' +export type RpcAccessScope = 'service' | 'team' | 'user' + +export type AuthClaims = { + userId: string + email: string + teamId: string | null + role: TeamRole + /** Authoritative users.lifecycle_version captured when the session was issued. */ + authGeneration: number + exp: number +} + +export type RpcAccessClaims = { + sub: string + typ: RpcTokenType + accessScope: RpcAccessScope + teamId: string | null + scopes: RpcScope[] + hostRefs: string[] + jti: string + iat: number + exp: number + service?: string + role?: TeamRole +} diff --git a/control-api/test/fixtures/legacyV1Base/src/utils/auth/externalSessionAuthToken.ts b/control-api/test/fixtures/legacyV1Base/src/utils/auth/externalSessionAuthToken.ts new file mode 100644 index 0000000000..d6e2c56889 --- /dev/null +++ b/control-api/test/fixtures/legacyV1Base/src/utils/auth/externalSessionAuthToken.ts @@ -0,0 +1,76 @@ +import jwt from 'jsonwebtoken' +import { createPublicKey } from 'node:crypto' +import { config } from '../../config.js' +import { AuthClaims, TEAM_ROLES } from '../../profileTypes.js' + +const ALLOWED_ROLES = new Set(TEAM_ROLES) +const sessionJwtPublicKey = createPublicKey(config.sessionJwtPrivateKey).export({ + type: 'spki', + format: 'pem', +}) + +export function signExternalSessionToken( + claims: Omit, + ttlSeconds = 60 * 60 * 12 +): string { + const authGeneration = claims.authGeneration + if ( + typeof authGeneration !== 'number' || + !Number.isSafeInteger(authGeneration) || + authGeneration < 1 + ) { + throw new Error('auth_generation_required') + } + return jwt.sign( + { + ...claims, + authGeneration, + }, + config.sessionJwtPrivateKey, + { + algorithm: 'RS256', + expiresIn: ttlSeconds, + issuer: config.jwtIssuer, + audience: config.jwtAudience, + } + ) +} + +export function verifyExternalSessionToken(token: string): AuthClaims | null { + try { + if (token.length > 4096) return null + const payload = jwt.verify(token, sessionJwtPublicKey, { + algorithms: ['RS256'], + issuer: config.jwtIssuer, + audience: config.jwtAudience, + }) as jwt.JwtPayload & Omit + + const teamId = payload?.teamId + const authGeneration = payload?.authGeneration + if ( + typeof payload?.userId !== 'string' || + typeof payload?.email !== 'string' || + (teamId !== null && typeof teamId !== 'string') || + typeof payload?.role !== 'string' || + typeof payload?.exp !== 'number' || + !Number.isSafeInteger(authGeneration) || + Number(authGeneration) < 1 + ) { + return null + } + if (!ALLOWED_ROLES.has(payload.role as AuthClaims['role'])) { + return null + } + + return { + userId: payload.userId, + email: payload.email, + teamId, + role: payload.role as AuthClaims['role'], + authGeneration: Number(authGeneration), + exp: payload.exp, + } + } catch { + return null + } +} diff --git a/control-api/test/legacyV1Generation.realPostgres.integration.test.ts b/control-api/test/legacyV1Generation.realPostgres.integration.test.ts index 5ac904f19b..9580cbc30e 100644 --- a/control-api/test/legacyV1Generation.realPostgres.integration.test.ts +++ b/control-api/test/legacyV1Generation.realPostgres.integration.test.ts @@ -1,11 +1,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' -import { randomBytes, randomUUID } from 'node:crypto' +import { createHash, randomBytes, randomUUID } from 'node:crypto' +import { readFileSync } from 'node:fs' import { Pool } from 'pg' -import { isCurrentExternalSession as isCurrentBaseExternalSession } from '../../../base/control-api/src/middleware/externalSessionAuth.js' -import { - signExternalSessionToken as signBaseExternalSessionToken, - verifyExternalSessionToken as verifyBaseExternalSessionToken, -} from '../../../base/control-api/src/utils/auth/externalSessionAuthToken.js' import { initDb } from '../src/db.js' import type { EffectiveUserAccessPolicy } from '../src/services/access/userAccessPolicy.js' import { issueExternalUserSession } from '../src/services/auth/externalSessionIssuance.js' @@ -15,11 +11,16 @@ import { validateLegacyUserSession, } from '../src/services/auth/userSessionService.js' import { verifyExternalSessionToken } from '../src/utils/auth/externalSessionAuthToken.js' +import { isCurrentExternalSession as isCurrentBaseExternalSession } from './fixtures/legacyV1Base/src/middleware/externalSessionAuth.js' +import { + signExternalSessionToken as signBaseExternalSessionToken, + verifyExternalSessionToken as verifyBaseExternalSessionToken, +} from './fixtures/legacyV1Base/src/utils/auth/externalSessionAuthToken.js' import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const baseDatabase = vi.hoisted(() => ({ query: vi.fn() })) -vi.mock('../../../base/control-api/src/db.js', () => ({ +vi.mock('./fixtures/legacyV1Base/src/db.js', () => ({ pool: { query: baseDatabase.query }, })) @@ -32,6 +33,32 @@ const runtimeRoles = [ ] as const const legacyPolicy = { issueV1: true, acceptV1: true } as EffectiveUserAccessPolicy +// These full source snapshots are verbatim from the deployed reader/writer base +// at 74e0d81d9b70bbc0e123ed2bad89f08d3e13e99e; the hashes prevent drift. +describe('pinned deployed-base V1 source snapshot at 74e0d81d9b70bbc0e123ed2bad89f08d3e13e99e', () => { + it('matches the exact synchronized-dev reader and signer sources', () => { + const snapshots = [ + [ + './fixtures/legacyV1Base/src/middleware/externalSessionAuth.ts', + '4b7e1368f6a9cf3c71d3c1c0bca409a8c458e51f8ccd3f6a07e01173873d87ab', + ], + [ + './fixtures/legacyV1Base/src/utils/auth/externalSessionAuthToken.ts', + '57663b8d7538a30c2dbf3235bc3faf45faeefcdfd73f2328ae8ff1ec8e2b9494', + ], + [ + './fixtures/legacyV1Base/src/profileTypes.ts', + '85703bd09e4c584a55c4a0bd8c797ba7f62653f49df197f40068a0706a8bcd2e', + ], + ] as const + + for (const [path, expectedSha256] of snapshots) { + const source = readFileSync(new URL(path, import.meta.url)) + expect(createHash('sha256').update(source).digest('hex')).toBe(expectedSha256) + } + }) +}) + function databaseUrl(baseUrl: string, database: string): string { const value = new URL(baseUrl) value.pathname = `/${database}` @@ -158,9 +185,14 @@ describeRealPostgres('legacy V1 generation ordering on real PostgreSQL', () => { Math.floor(authority.rows[0]!.valid_after.getTime() / 1000) ) expect(successorClaims.iat).toBeLessThanOrEqual(Math.floor(Date.now() / 1000)) + expect(successorClaims.exp - successorClaims.iat).toBeGreaterThan(0) + expect(successorClaims.exp - successorClaims.iat).toBeLessThanOrEqual(60 * 60 * 12) + const basePredecessorClaims = verifyBaseExternalSessionToken(predecessor) + expect(basePredecessorClaims).not.toBeNull() await expect( validateLegacyUserSession(predecessor, predecessorClaims, { db: databasePool }) ).resolves.toEqual({ status: 'revoked', reason: 'security_event' }) + await expect(isCurrentBaseExternalSession(basePredecessorClaims!)).resolves.toBe(false) await expect( validateLegacyUserSession(successor, successorClaims, { db: databasePool }) ).resolves.toMatchObject({ status: 'valid' }) @@ -188,6 +220,9 @@ describeRealPostgres('legacy V1 generation ordering on real PostgreSQL', () => { ) expect(successor).not.toBe(first) + expect(createHash('sha256').update(successor).digest('hex')).not.toBe( + createHash('sha256').update(first).digest('hex') + ) expect(successorClaims.jti).toEqual(expect.any(String)) expect(Number(lifecycle.rows[0]?.lifecycle_version)).toBe(1) await expect( diff --git a/control-api/test/routes.externalInvitations.unavailable.test.ts b/control-api/test/routes.externalInvitations.unavailable.test.ts index 46f56ddd3c..622dad5c76 100644 --- a/control-api/test/routes.externalInvitations.unavailable.test.ts +++ b/control-api/test/routes.externalInvitations.unavailable.test.ts @@ -79,6 +79,7 @@ function app(): express.Express { describe('external invitation routes when the hub is unavailable', () => { beforeEach(() => { vi.clearAllMocks() + database.query.mockReset() database.withTransaction.mockImplementation(async work => work({ query: database.query })) rateLimiter.checkAndIncrement.mockResolvedValue({ allowed: true, @@ -123,9 +124,18 @@ describe('external invitation routes when the hub is unavailable', () => { }) .mockResolvedValueOnce({ error: 'not_pending' }) .mockResolvedValueOnce({ error: 'not_pending' }) - database.query.mockResolvedValue({ - rows: [{ id: '00000000-0000-4000-8000-000000000001' }], - rowCount: 1, + database.query.mockImplementation(async sql => { + const text = String(sql) + if (text.includes('SELECT id FROM users') && text.includes('FOR UPDATE')) { + return { rows: [{ id: '00000000-0000-4000-8000-000000000001' }], rowCount: 1 } + } + if (text.includes('SELECT lifecycle_state, lifecycle_version')) { + return { rows: [{ lifecycle_state: 'active', lifecycle_version: 1 }], rowCount: 1 } + } + if (text.includes('clock_timestamp()')) { + return { rows: [{ db_now: new Date('2026-10-07T12:00:00.000Z') }], rowCount: 1 } + } + throw new Error(`unexpected invitation session issuance query: ${text}`) }) const first = await request(app()) @@ -145,6 +155,10 @@ describe('external invitation routes when the hub is unavailable', () => { expect(passwordReset.status).toBe(400) expect(passwordReset.body).toEqual({ error: 'not_pending' }) expect(tokenSigner.sign).toHaveBeenCalledTimes(1) + expect(database.query).toHaveBeenCalledTimes(3) + expect(String(database.query.mock.calls[0]?.[0])).toContain('FOR UPDATE') + expect(String(database.query.mock.calls[1]?.[0])).toContain('lifecycle_version') + expect(String(database.query.mock.calls[2]?.[0])).toContain('clock_timestamp()') }) it('binds the secret capability to the public id and never issues on password setup', async () => { From 75edb63225ab12c9cb43bd9448a7a61dc573e396 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:17:45 -0600 Subject: [PATCH 286/528] fix(control-api): reject stale legacy session issuance (R59-M1) Finding-ID: R59-M1 Class-check: canonical V1 signer -> four callers -> cutoff check before signing. Repro-test: control-api/test/legacyV1Generation.realPostgres.integration.test.ts (fails at 2370a399d4221350946462d41427fad8aee0087f: token signed; canonical validator rejected it as revoked). --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- control-api/src/http/errorHandler.ts | 19 +++ control-api/src/http/publicApiError.ts | 1 + .../services/auth/externalSessionIssuance.ts | 14 ++ .../auth/externalSessionIssuanceError.ts | 9 ++ control-api/test/app.test.ts | 1 + .../http.errorHandler.clientErrorCode.test.ts | 17 +++ ...eneration.realPostgres.integration.test.ts | 144 +++++++++++++++++- .../test/legacyV1SessionGeneration.test.ts | 8 + ...es.externalInvitations.unavailable.test.ts | 4 +- external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- .../src/__tests__/publicErrorContract.test.ts | 46 ++++++ external-rest-api/src/controlApiClient.ts | 3 + external-rest-api/src/http/publicApiError.ts | 22 ++- external-rest-api/src/releaseManifest.ts | 2 +- 17 files changed, 285 insertions(+), 17 deletions(-) create mode 100644 control-api/src/services/auth/externalSessionIssuanceError.ts diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 40685ff8fd..e82848a8ba 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.732", + "version": "0.1.734", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.732", + "version": "0.1.734", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index edf241fcb3..f674910c05 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.732", + "version": "0.1.734", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/http/errorHandler.ts b/control-api/src/http/errorHandler.ts index 08efa15ea8..8ebf49841e 100644 --- a/control-api/src/http/errorHandler.ts +++ b/control-api/src/http/errorHandler.ts @@ -2,6 +2,7 @@ import type { NextFunction, Request, Response } from 'express' import { ApiException } from '@kubernetes/client-node' import { STATUS_CODES } from 'node:http' import { rootLogger } from '../observability/logger.js' +import { ExternalSessionIssuanceUnavailableError } from '../services/auth/externalSessionIssuanceError.js' import { memberRegistrationErrorResponse } from '../services/memberRegistrationErrors.js' import { sendPublicApiError } from './publicApiError.js' @@ -159,6 +160,24 @@ export function clerumErrorHandler( const correlationId = req.correlationId ?? Math.random().toString(36).slice(2, 10) const log = req.log ?? rootLogger + if (err instanceof ExternalSessionIssuanceUnavailableError) { + log.warn( + { event: err.code, correlationId }, + 'external session issuance deferred by current security cutoff' + ) + res.setHeader('Retry-After', '2') + res.setHeader('Cache-Control', 'no-store') + sendPublicApiError( + req, + res, + 503, + err.code, + 'A session could not be issued right now. Try again in two seconds.', + true + ) + return + } + // Typed member-registration failures map to 503 (spec §8.6) — scoped // instanceof check; the generic 5xx-collapse below stays intact. const memberRegistration = memberRegistrationErrorResponse(err) diff --git a/control-api/src/http/publicApiError.ts b/control-api/src/http/publicApiError.ts index 8b477464ea..4e3f98ac48 100644 --- a/control-api/src/http/publicApiError.ts +++ b/control-api/src/http/publicApiError.ts @@ -10,6 +10,7 @@ export type PublicApiErrorCode = | 'access_path_required' | 'access_path_stale' | 'authority_unavailable' + | 'session_issuance_temporarily_unavailable' | 'invalid_request' | 'rate_limited' | 'upgrade_required' diff --git a/control-api/src/services/auth/externalSessionIssuance.ts b/control-api/src/services/auth/externalSessionIssuance.ts index 5d79a29fe6..34a0a771f4 100644 --- a/control-api/src/services/auth/externalSessionIssuance.ts +++ b/control-api/src/services/auth/externalSessionIssuance.ts @@ -6,6 +6,7 @@ import { compareSemanticVersions, } from '../access/userAccessPolicy.js' import type { ExternalSessionClient } from './externalSessionAuthentication.js' +import { ExternalSessionIssuanceUnavailableError } from './externalSessionIssuanceError.js' import { createUserSession, loadSessionDatabaseNow, @@ -13,6 +14,7 @@ import { } from './userSessionService.js' export type ExternalSessionContract = 'v1' | 'v2' +export { ExternalSessionIssuanceUnavailableError } from './externalSessionIssuanceError.js' export type ExternalSessionSelection = | { status: 'selected'; contract: ExternalSessionContract } @@ -108,6 +110,18 @@ export async function issueExternalUserSession( throw new Error('legacy user-session issuance requires an active lifecycle generation') } const issuedAt = Math.floor((await loadSessionDatabaseNow(db)).getTime() / 1000) + const epoch = await db.query( + `SELECT valid_after + FROM external_user_session_security_epochs + WHERE user_id = $1 + LIMIT 1`, + [input.userId] + ) + const validAfter = (epoch.rows[0] as { valid_after?: Date | string | null } | undefined) + ?.valid_after + if (validAfter && issuedAt * 1000 <= new Date(validAfter).getTime()) { + throw new ExternalSessionIssuanceUnavailableError() + } return signExternalSessionToken({ userId: input.userId, email: input.email, diff --git a/control-api/src/services/auth/externalSessionIssuanceError.ts b/control-api/src/services/auth/externalSessionIssuanceError.ts new file mode 100644 index 0000000000..76a82edd94 --- /dev/null +++ b/control-api/src/services/auth/externalSessionIssuanceError.ts @@ -0,0 +1,9 @@ +export class ExternalSessionIssuanceUnavailableError extends Error { + readonly status = 503 + readonly code = 'session_issuance_temporarily_unavailable' + + constructor() { + super('A session could not be issued right now. Try again in two seconds.') + this.name = 'ExternalSessionIssuanceUnavailableError' + } +} diff --git a/control-api/test/app.test.ts b/control-api/test/app.test.ts index a9ce215c80..da8370be8b 100644 --- a/control-api/test/app.test.ts +++ b/control-api/test/app.test.ts @@ -320,6 +320,7 @@ describe('app router wiring', () => { expect.stringContaining('JOIN team_members'), expect.stringContaining('SELECT lifecycle_state, lifecycle_version'), expect.stringContaining('clock_timestamp()'), + expect.stringContaining('SELECT valid_after'), 'COMMIT', ]) }) diff --git a/control-api/test/http.errorHandler.clientErrorCode.test.ts b/control-api/test/http.errorHandler.clientErrorCode.test.ts index 2db8129b3b..ec69763f18 100644 --- a/control-api/test/http.errorHandler.clientErrorCode.test.ts +++ b/control-api/test/http.errorHandler.clientErrorCode.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import express from 'express' import request from 'supertest' import { clerumErrorHandler } from '../src/http/errorHandler.js' +import { ExternalSessionIssuanceUnavailableError } from '../src/services/auth/externalSessionIssuance.js' import { TracingIdempotencyConflictError, UnsafeTracingInputError, @@ -26,6 +27,22 @@ function appThrowing(err: unknown) { } describe('clerumErrorHandler — machine-readable code on allowlisted 4xx', () => { + it('returns bounded no-store retry guidance for inadmissible V1 issuance', async () => { + const res = await request(appThrowing(new ExternalSessionIssuanceUnavailableError())).post( + '/boom' + ) + + expect(res.status).toBe(503) + expect(res.headers['cache-control']).toBe('no-store') + expect(res.headers['retry-after']).toBe('2') + expect(res.body.error).toMatchObject({ + code: 'session_issuance_temporarily_unavailable', + retryable: true, + message: 'A session could not be issued right now. Try again in two seconds.', + }) + expect(res.body.error).not.toHaveProperty('details') + }) + it.each([ [ new TracingIdempotencyConflictError('administrative', 'hcc_internal_control', 'e-1'), diff --git a/control-api/test/legacyV1Generation.realPostgres.integration.test.ts b/control-api/test/legacyV1Generation.realPostgres.integration.test.ts index 9580cbc30e..6da3d24907 100644 --- a/control-api/test/legacyV1Generation.realPostgres.integration.test.ts +++ b/control-api/test/legacyV1Generation.realPostgres.integration.test.ts @@ -2,7 +2,9 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { createHash, randomBytes, randomUUID } from 'node:crypto' import { readFileSync } from 'node:fs' import { Pool } from 'pg' -import { initDb } from '../src/db.js' +import { initDb, withTransaction } from '../src/db.js' +import type { DbClient } from '../src/db.js' +import { backfillLegacyPasswordSecurityEpochs } from '../src/services/access/userAccessFoundationSchema.js' import type { EffectiveUserAccessPolicy } from '../src/services/access/userAccessPolicy.js' import { issueExternalUserSession } from '../src/services/auth/externalSessionIssuance.js' import { @@ -76,6 +78,19 @@ async function alignToFreshDatabaseSecond(pool: Pool): Promise { )`) } +async function alignToDatabaseSecondFraction(pool: Pool, fractionSeconds: number): Promise { + await pool.query( + `SELECT pg_sleep( + GREATEST( + 0, + EXTRACT(EPOCH FROM date_trunc('second', clock_timestamp()) + + interval '1 second' - clock_timestamp()) + $1::double precision + ) + )`, + [fractionSeconds] + ) +} + describeRealPostgres('legacy V1 generation ordering on real PostgreSQL', () => { const database = `control_api_legacy_v1_generation_${randomBytes(6).toString('hex')}` const connectionString = databaseUrl( @@ -168,7 +183,7 @@ describeRealPostgres('legacy V1 generation ordering on real PostgreSQL', () => { const successor = await issueLegacy(source) const successorClaims = verified(successor) const authority = await databasePool.query<{ - lifecycle_version: number + lifecycle_version: number | string valid_after: Date }>( `SELECT u.lifecycle_version, epoch.valid_after @@ -233,6 +248,131 @@ describeRealPostgres('legacy V1 generation ordering on real PostgreSQL', () => { ).resolves.toMatchObject({ status: 'valid' }) }) + it('does not publish V1 issuance behind a retained historical password cutoff', async () => { + const source = await principal('historical-cutoff-issuance') + await alignToDatabaseSecondFraction(databasePool, 0.65) + + const predecessor = await withTransaction(async db => { + const issued = await issueExternalUserSession( + { + contract: 'v1', + userId: source.userId, + email: source.email, + teamId: source.teamId, + role: 'member', + authenticationMethods: ['password'], + }, + { db, policy: legacyPolicy } + ) + return issued.token + }, databasePool) + const predecessorClaims = verified(predecessor) + const historical = await databasePool.query<{ password_set_at: Date }>( + `UPDATE users + SET password_set_at = date_trunc('second', clock_timestamp()) + interval '200 milliseconds' + WHERE id = $1 + RETURNING password_set_at`, + [source.userId] + ) + const cutoff = historical.rows[0]?.password_set_at + expect(cutoff).toBeInstanceOf(Date) + expect(predecessorClaims.iat! * 1000).toBeLessThan(cutoff!.getTime()) + + // Use the production historical backfill writer, then the canonical + // security-event writer and the same transactional V1 issuer. + await backfillLegacyPasswordSecurityEpochs(databasePool as unknown as DbClient) + await withTransaction( + db => revokeAllUserSessions(source.userId, 'password_changed', db), + databasePool + ) + const authority = await databasePool.query<{ + lifecycle_version: number + valid_after: Date + }>( + `SELECT u.lifecycle_version, epoch.valid_after + FROM users u + JOIN external_user_session_security_epochs epoch ON epoch.user_id = u.id + WHERE u.id = $1`, + [source.userId] + ) + expect(authority.rows[0]?.valid_after).toEqual(cutoff) + expect(Number(authority.rows[0]?.lifecycle_version)).toBe(predecessorClaims.authGeneration! + 1) + expect(Math.floor(authority.rows[0]!.valid_after.getTime() / 1000)).toBe(predecessorClaims.iat) + await expect( + validateLegacyUserSession(predecessor, predecessorClaims, { db: databasePool }) + ).resolves.toMatchObject({ status: 'revoked' }) + await expect( + isCurrentBaseExternalSession(verifyBaseExternalSessionToken(predecessor)!) + ).resolves.toBe(false) + + let issuedToken: string | null = null + let issuanceError: unknown + try { + issuedToken = await withTransaction(async db => { + const issued = await issueExternalUserSession( + { + contract: 'v1', + userId: source.userId, + email: source.email, + teamId: source.teamId, + role: 'member', + authenticationMethods: ['password'], + }, + { db, policy: legacyPolicy } + ) + return issued.token + }, databasePool) + } catch (error) { + issuanceError = error + } + + if (issuedToken) { + const claims = verified(issuedToken) + const validation = await validateLegacyUserSession(issuedToken, claims, { + db: databasePool, + }) + expect( + validation, + 'successful issuance must be accepted by the canonical validator' + ).toMatchObject({ status: 'valid' }) + } else { + expect(issuanceError).toMatchObject({ + name: 'ExternalSessionIssuanceUnavailableError', + code: 'session_issuance_temporarily_unavailable', + status: 503, + }) + } + + await databasePool.query(`SELECT pg_sleep( + GREATEST( + 0, + EXTRACT(EPOCH FROM date_trunc('second', clock_timestamp()) + + interval '1 second' - clock_timestamp()) + 0.2 + ) + )`) + const nextSecond = await withTransaction(async db => { + const issued = await issueExternalUserSession( + { + contract: 'v1', + userId: source.userId, + email: source.email, + teamId: source.teamId, + role: 'member', + authenticationMethods: ['password'], + }, + { db, policy: legacyPolicy } + ) + return issued.token + }, databasePool) + const nextClaims = verified(nextSecond) + expect(nextClaims.iat! * 1000).toBeGreaterThan(cutoff!.getTime()) + expect(nextClaims.iat! * 1000).toBeLessThanOrEqual(Date.now()) + expect(Number(nextClaims.authGeneration)).toBe(Number(authority.rows[0]?.lifecycle_version)) + await expect( + validateLegacyUserSession(nextSecond, nextClaims, { db: databasePool }) + ).resolves.toMatchObject({ status: 'valid' }) + }) + it('keeps the deployed base writer compatible with the candidate reader', async () => { const source = await principal('base-writer-compatibility') const baseToken = signBaseExternalSessionToken({ diff --git a/control-api/test/legacyV1SessionGeneration.test.ts b/control-api/test/legacyV1SessionGeneration.test.ts index 9796546440..652e200392 100644 --- a/control-api/test/legacyV1SessionGeneration.test.ts +++ b/control-api/test/legacyV1SessionGeneration.test.ts @@ -41,6 +41,14 @@ function legacyAuthorityDatabase() { state.validAfter = values[1] as Date return { rows: [], rowCount: 1 } } + if ( + sql.includes('SELECT valid_after') && + sql.includes('external_user_session_security_epochs') + ) { + return state.validAfter + ? { rows: [{ valid_after: state.validAfter }], rowCount: 1 } + : { rows: [], rowCount: 0 } + } if (sql.includes('UPDATE external_user_sessions')) return { rows: [], rowCount: 0 } if (sql.includes('INSERT INTO external_v1_session_revocations')) { state.revokedFingerprints.add(values[0] as string) diff --git a/control-api/test/routes.externalInvitations.unavailable.test.ts b/control-api/test/routes.externalInvitations.unavailable.test.ts index 622dad5c76..b1f8ea547a 100644 --- a/control-api/test/routes.externalInvitations.unavailable.test.ts +++ b/control-api/test/routes.externalInvitations.unavailable.test.ts @@ -135,6 +135,7 @@ describe('external invitation routes when the hub is unavailable', () => { if (text.includes('clock_timestamp()')) { return { rows: [{ db_now: new Date('2026-10-07T12:00:00.000Z') }], rowCount: 1 } } + if (text.includes('SELECT valid_after')) return { rows: [], rowCount: 0 } throw new Error(`unexpected invitation session issuance query: ${text}`) }) @@ -155,10 +156,11 @@ describe('external invitation routes when the hub is unavailable', () => { expect(passwordReset.status).toBe(400) expect(passwordReset.body).toEqual({ error: 'not_pending' }) expect(tokenSigner.sign).toHaveBeenCalledTimes(1) - expect(database.query).toHaveBeenCalledTimes(3) + expect(database.query).toHaveBeenCalledTimes(4) expect(String(database.query.mock.calls[0]?.[0])).toContain('FOR UPDATE') expect(String(database.query.mock.calls[1]?.[0])).toContain('lifecycle_version') expect(String(database.query.mock.calls[2]?.[0])).toContain('clock_timestamp()') + expect(String(database.query.mock.calls[3]?.[0])).toContain('SELECT valid_after') }) it('binds the secret capability to the public id and never issues on password setup', async () => { diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index fdb163a400..0208396c1b 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.113", + "version": "0.1.114", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.113", + "version": "0.1.114", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index b3aef729b4..a81c39f9b7 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.113", + "version": "0.1.114", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/__tests__/publicErrorContract.test.ts b/external-rest-api/src/__tests__/publicErrorContract.test.ts index a85bec792f..038f24e0e8 100644 --- a/external-rest-api/src/__tests__/publicErrorContract.test.ts +++ b/external-rest-api/src/__tests__/publicErrorContract.test.ts @@ -78,6 +78,52 @@ describe('External REST public error contract', () => { expect(JSON.stringify(response.body)).not.toContain('secret') }) + it('preserves bounded retry guidance for inadmissible V1 session issuance', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + error: { + code: 'session_issuance_temporarily_unavailable', + message: 'private upstream message', + correlationId: 'correlation_123', + retryable: true, + }, + }), + { + status: 503, + headers: { + 'content-type': 'application/json', + 'retry-after': '2', + 'cache-control': 'no-store', + 'x-internal-secret': 'must-not-cross', + }, + } + ) + ) + ) + + const upstream = await controlApiRequest('POST', '/external/auth/password-login').catch( + (error: unknown) => error + ) + expect(upstream).toBeInstanceOf(ControlApiError) + if (!(upstream instanceof ControlApiError)) throw new Error('expected ControlApiError') + + const response = await request(appThrowing(upstream)).get('/failure') + expect(response.status).toBe(503) + expect(response.headers['retry-after']).toBe('2') + expect(response.headers['cache-control']).toBe('no-store') + expect(response.body.error).toEqual({ + code: 'session_issuance_temporarily_unavailable', + message: 'A session could not be issued right now. Try again in two seconds.', + correlationId: 'correlation_123', + retryable: true, + }) + expect(response.headers['x-internal-secret']).toBeUndefined() + expect(JSON.stringify(response.body)).not.toContain('private upstream') + }) + it('maps upstream throttling to the stable retryable rate-limit error', async () => { const upstream = new ControlApiError( 'raw rate limiter state', diff --git a/external-rest-api/src/controlApiClient.ts b/external-rest-api/src/controlApiClient.ts index 57a0d0c70a..1ceb6a2fa1 100644 --- a/external-rest-api/src/controlApiClient.ts +++ b/external-rest-api/src/controlApiClient.ts @@ -61,6 +61,9 @@ function safeForwardableResponseHeaders(headers: Headers, status: number): Recor } } } + if (status === 503 && headers.get('cache-control')?.trim().toLowerCase() === 'no-store') { + result['cache-control'] = 'no-store' + } return result } diff --git a/external-rest-api/src/http/publicApiError.ts b/external-rest-api/src/http/publicApiError.ts index 40978239be..617f9ca876 100644 --- a/external-rest-api/src/http/publicApiError.ts +++ b/external-rest-api/src/http/publicApiError.ts @@ -56,6 +56,8 @@ const PUBLIC_MESSAGE_BY_CODE: Readonly> = { internal_error: 'The request could not be completed.', upstream_unavailable: 'The upstream service is temporarily unavailable.', authority_unavailable: 'Authorization is temporarily unavailable.', + session_issuance_temporarily_unavailable: + 'A session could not be issued right now. Try again in two seconds.', member_registration_unavailable: 'Member registration is temporarily unavailable.', member_registration_misconfigured: 'Member registration is not configured correctly.', upstream_timeout: 'The upstream service timed out.', @@ -80,6 +82,7 @@ const ALLOWED_CODES_BY_STATUS: Readonly>> = { 502: new Set(['upstream_unavailable']), 503: new Set([ 'authority_unavailable', + 'session_issuance_temporarily_unavailable', 'member_registration_unavailable', 'member_registration_misconfigured', ]), @@ -229,13 +232,18 @@ export function sanitizeControlApiPublicError( : undefined const safeHeaders = safePublicRateLimitHeaders(responseHeaders) const publicHeaders = - code === 'rate_limited' - ? safeHeaders - : error.status === 413 - ? safeUnsignedIntegerHeader(responseHeaders, 'upload-length') - : retryable && safeHeaders['retry-after'] - ? { 'retry-after': safeHeaders['retry-after'] } - : {} + code === 'session_issuance_temporarily_unavailable' + ? { + 'cache-control': 'no-store', + ...(safeHeaders['retry-after'] ? { 'retry-after': safeHeaders['retry-after'] } : {}), + } + : code === 'rate_limited' + ? safeHeaders + : error.status === 413 + ? safeUnsignedIntegerHeader(responseHeaders, 'upload-length') + : retryable && safeHeaders['retry-after'] + ? { 'retry-after': safeHeaders['retry-after'] } + : {} return { status: error.status, diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index 68e0b1eb0b..c1aa291fc9 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.10.0', - externalRestApiVersion: '0.1.113', + externalRestApiVersion: '0.1.114', rpcProxyVersion: '0.1.127', desktopVersion: '0.10.0', minimumDesktopVersion: '0.1.252', From fb4e56c8f2634f5d894dd820dbdaa2dfad66e56a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:21:53 -0600 Subject: [PATCH 287/528] fix(control-api): preserve original session currentness in streams (R59-M2) Finding-ID: R59-M2 Class-check: entity-change and notification user streams -> 2 carriers; admin workflow SSE and one-shot GFS/shared-filesystem transfers retain separate validators. Repro-test: control-api/test/r59M2.previousHead.realPostgres.integration.test.ts (fails at 2370a399d4221350946462d41427fad8aee0087f: valid V2 at generation >1 is denied by the legacy-only currentness helper). --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../src/middleware/externalSessionAuth.ts | 1 + control-api/src/routes/entityChangeStream.ts | 52 ++- .../routes/external/entityChanges.routes.ts | 22 +- .../routes/external/notifications.routes.ts | 79 ++++- .../externalSessionCurrentnessObserver.ts | 189 +++++++++++ ...rrentness.realPostgres.integration.test.ts | 302 ++++++++++++++++++ .../test/routes.entityChangeStream.test.ts | 137 +++++++- .../test/routes.externalEntityChanges.test.ts | 118 ++++++- ...rrentness.realPostgres.integration.test.ts | 198 ++++++++++++ ....externalNotifications.currentness.test.ts | 236 ++++++++++++++ 12 files changed, 1290 insertions(+), 50 deletions(-) create mode 100644 control-api/src/services/auth/externalSessionCurrentnessObserver.ts create mode 100644 control-api/test/externalSessionCurrentness.realPostgres.integration.test.ts create mode 100644 control-api/test/routes.externalNotifications.currentness.realPostgres.integration.test.ts create mode 100644 control-api/test/routes.externalNotifications.currentness.test.ts diff --git a/control-api/package-lock.json b/control-api/package-lock.json index e82848a8ba..d83e454627 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.734", + "version": "0.1.735", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.734", + "version": "0.1.735", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index f674910c05..ea2474f281 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.734", + "version": "0.1.735", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/middleware/externalSessionAuth.ts b/control-api/src/middleware/externalSessionAuth.ts index 92c91134f3..56cc460822 100644 --- a/control-api/src/middleware/externalSessionAuth.ts +++ b/control-api/src/middleware/externalSessionAuth.ts @@ -205,6 +205,7 @@ async function validateExternalSessionToken( } req.externalAuth = authentication.claims req.externalSessionAuthority = authentication.authorityContext + req.externalSessionAuthentication = authentication next() } catch (error) { handleExternalSessionBackendFailure(error, req, res, next) diff --git a/control-api/src/routes/entityChangeStream.ts b/control-api/src/routes/entityChangeStream.ts index 67bae1b0be..abca26d953 100644 --- a/control-api/src/routes/entityChangeStream.ts +++ b/control-api/src/routes/entityChangeStream.ts @@ -20,6 +20,26 @@ const BACKPRESSURE_TIMEOUT_MS = 5000 const AUTHORIZATION_RECHECK_MS = 15_000 const ZERO_CURSOR = '00000000-0000-0000-0000-000000000000' +type AuthorizationObservation = + | boolean + | { status: 'current' | 'denied' | 'unavailable'; error?: unknown } + +function authorizationStatus( + observation: AuthorizationObservation +): 'current' | 'denied' | 'unavailable' { + return typeof observation === 'boolean' + ? observation + ? 'current' + : 'denied' + : observation.status +} + +function sendSessionBackendUnavailable(res: Response): void { + res.setHeader('retry-after', '2') + res.setHeader('cache-control', 'no-store') + res.status(503).json({ error: 'session_backend_unavailable', retryAfterSeconds: 2 }) +} + type EntityChangeStreamMessage = | { schemaVersion: 1 @@ -67,7 +87,7 @@ export function streamEntityChanges( req: Request, res: Response, initialCursor: string | null, - isAuthorized: () => Promise, + isAuthorized: () => Promise, principalKind: 'user' | 'operator', principalId: string = principalKind ): void { @@ -178,7 +198,10 @@ export function streamEntityChanges( // therefore use a fixed-cadence, generic invalidation and always // refetch current authorized state; their frames never depend on // global feed contents, retention, or hidden mutations. - if (!(await isAuthorized())) { + const authorization = await isAuthorized() + const authorizationState = authorizationStatus(authorization) + if (authorizationState === 'unavailable') return + if (authorizationState === 'denied') { await closeForFailure('session_expired', ZERO_CURSOR) return } @@ -193,6 +216,15 @@ export function streamEntityChanges( await closeForFailure('slow_consumer', ZERO_CURSOR) return } + // A generic invalidation is not protected payload, but verify again + // after any drain wait before letting this poll complete. + const afterObservation = await isAuthorized() + const afterWrite = authorizationStatus(afterObservation) + if (afterWrite === 'unavailable') return + if (afterWrite === 'denied') { + await closeForFailure('session_expired', ZERO_CURSOR) + return + } userInitialResyncPending = false if (type === 'resync_required') { entityChangeStreamResyncRequiredTotal.inc({ principal_kind: principalKind }) @@ -204,7 +236,13 @@ export function streamEntityChanges( if (hasChange || Date.now() - lastAuthorizationCheck >= AUTHORIZATION_RECHECK_MS) { if (Date.now() < authorizationRetryAfter) return try { - if (!(await isAuthorized())) { + const authorization = await isAuthorized() + const authorizationState = authorizationStatus(authorization) + if (authorizationState === 'unavailable') { + const error = typeof authorization === 'boolean' ? undefined : authorization.error + throw error ?? new Error('session authority unavailable') + } + if (authorizationState === 'denied') { await closeForFailure('session_expired', checkpoint.cursor) return } @@ -275,7 +313,13 @@ export function streamEntityChanges( } try { - if (!(await isAuthorized())) { + const initialAuthorization = authorizationStatus(await isAuthorized()) + if (initialAuthorization === 'unavailable') { + sendSessionBackendUnavailable(res) + cleanup() + return + } + if (initialAuthorization === 'denied') { res.status(401).end() cleanup() return diff --git a/control-api/src/routes/external/entityChanges.routes.ts b/control-api/src/routes/external/entityChanges.routes.ts index 9e3b2b8a80..677524a14a 100644 --- a/control-api/src/routes/external/entityChanges.routes.ts +++ b/control-api/src/routes/external/entityChanges.routes.ts @@ -1,22 +1,25 @@ import { Request, Response, Router } from 'express' import { config } from '../../config.js' +import type { DbClient } from '../../db.js' import { asyncHandler } from '../../http/asyncHandler.js' import { createExternalClientRateLimiters } from '../../middleware/externalClientIdentity.js' import { type ExternalAuthedRequest, - isCurrentExternalSession, requireValidExternalSessionToken, } from '../../middleware/externalSessionAuth.js' import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' -import type { AuthClaims } from '../../profileTypes.js' +import type { ExternalSessionAuthentication } from '../../services/auth/externalSessionAuthentication.js' +import { + type ExternalSessionCurrentness, + observeExternalSessionCurrentness, +} from '../../services/auth/externalSessionCurrentnessObserver.js' import { parseRequestedEntityChangeCursor, streamEntityChanges } from '../entityChangeStream.js' export function isEntityChangeExternalSessionCurrent( - claims: AuthClaims, - nowMs = Date.now() -): Promise { - if (claims.exp * 1000 <= nowMs) return Promise.resolve(false) - return isCurrentExternalSession(claims) + authentication: Extract, + options: { db?: Pick } = {} +): Promise { + return observeExternalSessionCurrentness(authentication, options) } export function createExternalEntityChangesRouter(): Router { @@ -42,7 +45,8 @@ export function createExternalEntityChangesRouter(): Router { asyncHandler(async (req: Request, res: Response) => { const externalReq = req as ExternalAuthedRequest const claims = externalReq.externalAuth - if (!claims) { + const authentication = externalReq.externalSessionAuthentication + if (!claims || !authentication) { res.status(401).json({ error: 'Unauthorized' }) return } @@ -55,7 +59,7 @@ export function createExternalEntityChangesRouter(): Router { req, res, cursor, - () => isEntityChangeExternalSessionCurrent(claims), + () => isEntityChangeExternalSessionCurrent(authentication), 'user', `user:${claims.userId}` ) diff --git a/control-api/src/routes/external/notifications.routes.ts b/control-api/src/routes/external/notifications.routes.ts index 9f58a7a26e..1698285d29 100644 --- a/control-api/src/routes/external/notifications.routes.ts +++ b/control-api/src/routes/external/notifications.routes.ts @@ -1,9 +1,11 @@ import { Request, Response, Router } from 'express' import { config } from '../../config.js' import { asyncHandler } from '../../http/asyncHandler.js' +import { sendPublicApiError } from '../../http/publicApiError.js' import { createExternalClientRateLimiters } from '../../middleware/externalClientIdentity.js' import { type ExternalAuthedRequest, + handleExternalSessionBackendFailure, requireValidExternalSessionToken, } from '../../middleware/externalSessionAuth.js' import { externalUserRateLimitOptions } from '../../middleware/externalUserRateLimitPolicy.js' @@ -18,6 +20,7 @@ import { notificationStreamSnapshotSize, } from '../../observability/metrics.js' import { scheduleAccessCatalogShadow } from '../../services/access/accessCatalogShadow.js' +import { observeExternalSessionCurrentness } from '../../services/auth/externalSessionCurrentnessObserver.js' import { acknowledgeDesktopNotificationDelivery } from '../../services/notificationAckService.js' import { listActiveApprovalNotificationsForUser, @@ -115,15 +118,26 @@ export function createExternalNotificationsRouter(): Router { }, onBackendUnavailable: 'process-memory', }), - (req: Request, res: Response) => { + (req: Request, res: Response, next) => { void (async () => { const extReq = req as ExternalAuthedRequest const claims = extReq.externalAuth - if (!claims) { + const authentication = extReq.externalSessionAuthentication + if (!claims || !authentication) { res.status(401).json({ error: 'Unauthorized' }) return } + const initialAuthority = await observeExternalSessionCurrentness(authentication) + if (initialAuthority.status === 'denied') { + sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') + return + } + if (initialAuthority.status === 'unavailable') { + handleExternalSessionBackendFailure(initialAuthority.error, req, res, next) + return + } + const rawCursor = String(req.query?.cursor || '').trim() const parsedCursor = rawCursor ? parseNotificationCursor(rawCursor) : null if (rawCursor && !parsedCursor) { @@ -161,6 +175,46 @@ export function createExternalNotificationsRouter(): Router { ) } + const closeForAuthority = async (): Promise => { + if (closed) return false + let authority + try { + authority = await observeExternalSessionCurrentness(authentication) + } catch { + // Unexpected observer failures are fail-closed after the stream + // starts, and must release its listener and connection accounting. + disconnectReason = 'stream_error' + logger.error( + { + event: 'notification_stream_currentness_failed', + userId: claims.userId, + correlationId: req.correlationId ?? null, + }, + 'notification stream currentness check failed' + ) + res.end() + cleanup() + return false + } + if (closed) return false + if (authority.status === 'current') return true + if (authority.status === 'denied') { + disconnectReason = 'session_expired' + writeStreamMessage(res, { + type: 'stream.closing', + reason: 'session_expired', + observedAt: new Date().toISOString(), + }) + } else { + // The client already has bounded reconnect/backoff behavior for an + // ended notification transport. Emit no data or cursor on outage. + disconnectReason = 'stream_error' + } + res.end() + cleanup() + return false + } + req.on('close', cleanup) req.on('aborted', () => { disconnectReason = 'client_aborted' @@ -185,11 +239,13 @@ export function createExternalNotificationsRouter(): Router { if (closed || flushInFlight) return flushInFlight = true try { + if (!(await closeForAuthority())) return const initialSnapshot = lastCursor === null const events = await listNotificationStreamEventsForUser(claims.userId, { limit: config.notificationStreamSnapshotLimit, after: lastCursor ? parseNotificationCursor(lastCursor) : null, }) + if (!(await closeForAuthority())) return const legacyComplete = initialSnapshot && events.length < config.notificationStreamSnapshotLimit scheduleAccessCatalogShadow({ @@ -212,6 +268,7 @@ export function createExternalNotificationsRouter(): Router { } for (const event of events) { if (closed) break + if (!(await closeForAuthority())) break if (event.eventType === 'approval.requested') { writeStreamMessage(res, { type: 'approval.requested', @@ -297,10 +354,12 @@ export function createExternalNotificationsRouter(): Router { ) } + if (!(await closeForAuthority())) return const snapshot = await listActiveApprovalNotificationsForUser(claims.userId, { limit: config.notificationStreamSnapshotLimit, after: parsedCursor, }) + if (!(await closeForAuthority())) return const snapshotCursor = newestNotificationCursor(snapshot, lastCursor) notificationStreamSnapshotSize.observe(snapshot.length) writeStreamMessage(res, { @@ -313,13 +372,15 @@ export function createExternalNotificationsRouter(): Router { void flushNewEvents() heartbeatTimer = setInterval(() => { - void flushNewEvents() - if (closed) return - writeStreamMessage(res, { - type: 'heartbeat', - observedAt: new Date().toISOString(), - }) - notificationStreamEventsSentTotal.inc({ event_type: 'heartbeat' }, 1) + void (async () => { + void flushNewEvents() + if (closed || !(await closeForAuthority())) return + writeStreamMessage(res, { + type: 'heartbeat', + observedAt: new Date().toISOString(), + }) + notificationStreamEventsSentTotal.inc({ event_type: 'heartbeat' }, 1) + })() }, config.notificationStreamHeartbeatMs) lifetimeTimer = setTimeout(() => { diff --git a/control-api/src/services/auth/externalSessionCurrentnessObserver.ts b/control-api/src/services/auth/externalSessionCurrentnessObserver.ts new file mode 100644 index 0000000000..72b0c639a5 --- /dev/null +++ b/control-api/src/services/auth/externalSessionCurrentnessObserver.ts @@ -0,0 +1,189 @@ +import { type DbClient, pool } from '../../db.js' +import type { AuthClaims } from '../../profileTypes.js' +import type { UserSessionV2Claims } from '../../utils/auth/userSessionV2Token.js' +import type { ExternalSessionAuthentication } from './externalSessionAuthentication.js' +import { legacyExternalSessionAuthGeneration } from './legacyV1Generation.js' +import { + ExternalSessionBackendUnavailableError, + externalSessionDatabaseFailure, + isExternalSessionBackendUnavailableError, +} from './sessionDatabaseFailure.js' + +type AuthenticatedSession = Extract +type CurrentnessDatabase = Pick + +export type ExternalSessionCurrentness = + | { status: 'current' } + | { status: 'denied'; reason: string } + | { status: 'unavailable'; error: ExternalSessionBackendUnavailableError } + +function dateOf(value: Date | string | null | undefined): Date | null { + if (value === null || value === undefined) return null + const date = value instanceof Date ? value : new Date(value) + return Number.isFinite(date.getTime()) ? date : null +} + +function sessionDatabase(options: { db?: CurrentnessDatabase }): CurrentnessDatabase { + return options.db ?? pool +} + +/** + * Read-only stream currentness observation over the authority accepted by the + * initial session verifier. This deliberately does not call the request-time + * validator: V2 validation may touch or revoke a session as part of normal + * authentication, while an observer must never mutate session state. + */ +export async function observeExternalSessionCurrentness( + authentication: AuthenticatedSession, + options: { db?: CurrentnessDatabase } = {} +): Promise { + const context = authentication.authorityContext + const db = sessionDatabase(options) + + try { + if (authentication.contract === 'v1' && context.contract === 'v1') { + const claims = authentication.tokenClaims as AuthClaims + const generation = legacyExternalSessionAuthGeneration(claims) + if ( + claims.userId !== context.userId || + claims.iat !== context.issuedAt || + generation === null || + generation !== context.authGeneration + ) { + return { status: 'denied', reason: 'legacy_authority_mismatch' } + } + + const result = await db.query( + `WITH observed_clock AS (SELECT clock_timestamp() AS db_now) + SELECT u.lifecycle_state, + u.lifecycle_version, + epoch.valid_after, + observed_clock.db_now, + EXISTS ( + SELECT 1 + FROM external_v1_session_revocations revoked + WHERE revoked.token_hash = $2 + AND revoked.user_id = u.id + AND revoked.expires_at > observed_clock.db_now + ) AS token_revoked + FROM users u + CROSS JOIN observed_clock + LEFT JOIN external_user_session_security_epochs epoch ON epoch.user_id = u.id + WHERE u.id = $1 + LIMIT 1`, + [context.userId, context.tokenHash] + ) + const row = result.rows[0] as + | { + lifecycle_state?: unknown + lifecycle_version?: unknown + valid_after?: Date | string | null + db_now?: Date | string + token_revoked?: boolean + } + | undefined + const now = dateOf(row?.db_now) + if (!row || !now || typeof claims.exp !== 'number' || claims.exp * 1000 <= now.getTime()) { + return { status: 'denied', reason: 'legacy_authority_expired_or_missing' } + } + if ( + row.lifecycle_state !== 'active' || + Number(row.lifecycle_version) !== context.authGeneration + ) { + return { status: 'denied', reason: 'legacy_generation_changed' } + } + if (row.token_revoked) return { status: 'denied', reason: 'legacy_representation_revoked' } + const validAfter = dateOf(row.valid_after) + if (validAfter && context.issuedAt * 1000 <= validAfter.getTime()) { + return { status: 'denied', reason: 'legacy_security_cutoff' } + } + return { status: 'current' } + } + + if (authentication.contract === 'v2' && context.contract === 'v2') { + const claims = authentication.tokenClaims as UserSessionV2Claims + if ( + claims.sub !== context.userId || + claims.sid !== context.sid || + claims.jti !== context.jti || + claims.sv !== context.sessionVersion + ) { + return { status: 'denied', reason: 'v2_authority_mismatch' } + } + + const result = await db.query( + `WITH observed_clock AS (SELECT clock_timestamp() AS db_now) + SELECT s.user_id, + s.session_version, + s.current_jti, + s.prior_jti, + s.prior_jti_expires_at, + s.idle_expires_at, + s.absolute_expires_at, + s.revoked_at, + u.lifecycle_state, + observed_clock.db_now + FROM external_user_sessions s + JOIN users u ON u.id = s.user_id + CROSS JOIN observed_clock + WHERE s.sid = $1 + LIMIT 1`, + [context.sid] + ) + const row = result.rows[0] as + | { + user_id?: string + session_version?: number | string + current_jti?: string + prior_jti?: string | null + prior_jti_expires_at?: Date | string | null + idle_expires_at?: Date | string + absolute_expires_at?: Date | string + revoked_at?: Date | string | null + lifecycle_state?: unknown + db_now?: Date | string + } + | undefined + const now = dateOf(row?.db_now) + if (!row || !now || typeof claims.exp !== 'number' || claims.exp * 1000 <= now.getTime()) { + return { status: 'denied', reason: 'v2_authority_expired_or_missing' } + } + if ( + row.user_id !== context.userId || + row.lifecycle_state !== 'active' || + row.revoked_at !== null || + Number(row.session_version) !== context.sessionVersion + ) { + return { status: 'denied', reason: 'v2_session_revoked_or_changed' } + } + const idleExpiresAt = dateOf(row.idle_expires_at) + const absoluteExpiresAt = dateOf(row.absolute_expires_at) + if ( + !idleExpiresAt || + !absoluteExpiresAt || + now.getTime() >= idleExpiresAt.getTime() || + now.getTime() >= absoluteExpiresAt.getTime() + ) { + return { status: 'denied', reason: 'v2_session_expired' } + } + const currentRepresentation = claims.jti === row.current_jti + const priorExpiry = dateOf(row.prior_jti_expires_at) + const priorRepresentation = + claims.jti === row.prior_jti && + priorExpiry !== null && + now.getTime() <= priorExpiry.getTime() + if (!currentRepresentation && !priorRepresentation) { + return { status: 'denied', reason: 'v2_representation_superseded' } + } + return { status: 'current' } + } + + return { status: 'denied', reason: 'session_contract_mismatch' } + } catch (error) { + const classified = externalSessionDatabaseFailure(error) + if (isExternalSessionBackendUnavailableError(classified)) { + return { status: 'unavailable', error: classified } + } + throw classified + } +} diff --git a/control-api/test/externalSessionCurrentness.realPostgres.integration.test.ts b/control-api/test/externalSessionCurrentness.realPostgres.integration.test.ts new file mode 100644 index 0000000000..2f809144ff --- /dev/null +++ b/control-api/test/externalSessionCurrentness.realPostgres.integration.test.ts @@ -0,0 +1,302 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { randomBytes, randomUUID } from 'node:crypto' +import { Pool } from 'pg' +import { type DbClient, initDb } from '../src/db.js' +import { isEntityChangeExternalSessionCurrent } from '../src/routes/external/entityChanges.routes.js' +import type { EffectiveUserAccessPolicy } from '../src/services/access/userAccessPolicy.js' +import { authenticateExternalUserSession } from '../src/services/auth/externalSessionAuthentication.js' +import { issueExternalUserSession } from '../src/services/auth/externalSessionIssuance.js' +import { + createUserSession, + renewUserSession, + revokeAllUserSessions, + revokeLegacyUserSession, + revokeUserSession, +} from '../src/services/auth/userSessionService.js' +import type { UserSessionV2Claims } from '../src/utils/auth/userSessionV2Token.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' + +const producerDatabase = vi.hoisted(() => ({ + pool: undefined as Pool | undefined, + transaction: undefined as ((work: (db: DbClient) => Promise) => Promise) | undefined, +})) + +vi.mock('../src/db.js', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + pool: { + query: (text: string, values?: unknown[]) => producerDatabase.pool!.query(text, values), + }, + withTransaction: (work: (db: DbClient) => Promise) => producerDatabase.transaction!(work), + } +}) + +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const describeRealPostgres = adminUrl ? describe : describe.skip +const runtimeRoles = [ + 'control_api_runtime', + 'trace_maintenance_runtime', + 'workflow_recipes_runtime', +] as const +const policy = { + policyVersion: '1', + policyRevision: 'currentness-test', + acceptV1: true, + issueV1: true, + acceptV2: true, + issueV2: true, + renewV2: true, + switchCompatibility: true, + computeCatalogShadow: false, + serveCatalog: false, + actionContextV2: false, + rpcDelegationV2: false, + desktopAllTeamMode: false, + profileV2Mode: false, + minimumClientVersion: null, + enforceMinimumClient: false, + advertisedCatalogFamilies: [], +} as EffectiveUserAccessPolicy + +function databaseUrl(baseUrl: string, database: string): string { + const value = new URL(baseUrl) + value.pathname = `/${database}` + return value.toString() +} + +function quoteIdentifier(value: string): string { + return `"${value.replace(/"/g, '""')}"` +} + +describeRealPostgres('external stream session currentness on real PostgreSQL', () => { + const database = `control_api_stream_currentness_${randomBytes(6).toString('hex')}` + const connectionString = databaseUrl( + adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', + database + ) + let adminPool: Pool + let databasePool: Pool + + async function createPrincipal(label: string) { + const userId = randomUUID() + const teamId = randomUUID() + const email = `${label}-${userId}@example.test` + await databasePool.query(`INSERT INTO users(id, email, name) VALUES ($1, $2, $3)`, [ + userId, + email, + label, + ]) + await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, $2)`, [teamId, label]) + await databasePool.query( + `INSERT INTO team_members(team_id, user_id, role, status) + VALUES ($1, $2, 'member', 'active')`, + [teamId, userId] + ) + return { userId, teamId, email } + } + + async function authenticate(token: string) { + const result = await authenticateExternalUserSession(token, { + purpose: 'protected', + policy, + }) + if (result.status !== 'authenticated') { + throw new Error(`test producer failed to authenticate: ${result.status}`) + } + return result + } + + async function transaction(work: (db: DbClient) => Promise): Promise { + const client = await databasePool.connect() + try { + await client.query('BEGIN') + const result = await work(client as unknown as DbClient) + await client.query('COMMIT') + return result + } catch (error) { + await client.query('ROLLBACK') + throw error + } finally { + client.release() + } + } + + beforeAll(async () => { + adminPool = new Pool({ connectionString: adminUrl }) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) + databasePool = new Pool({ connectionString }) + producerDatabase.pool = databasePool + producerDatabase.transaction = transaction + await initDb({ connect: () => databasePool.connect() }) + }) + + afterAll(async () => { + try { + await endPoolAndWaitForClients(databasePool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + } + } finally { + await adminPool?.end() + } + }) + + it('keeps an authenticated V2 stream current above lifecycle generation one', async () => { + const principal = await createPrincipal('v2-stream-generation') + await transaction(db => revokeAllUserSessions(principal.userId, 'generation-advance', db)) + const sessionA = await transaction(db => + createUserSession( + { + userId: principal.userId, + email: principal.email, + authenticationMethods: ['pwd'], + }, + { db } + ) + ) + const sessionB = await transaction(db => + createUserSession( + { + userId: principal.userId, + email: principal.email, + authenticationMethods: ['pwd'], + }, + { db } + ) + ) + const authenticationA = await authenticate(sessionA.token) + const authenticationB = await authenticate(sessionB.token) + const generation = await databasePool.query<{ lifecycle_version: number | string }>( + `SELECT lifecycle_version FROM users WHERE id = $1`, + [principal.userId] + ) + expect(Number(generation.rows[0]?.lifecycle_version)).toBeGreaterThan(1) + expect(authenticationB.contract).toBe('v2') + + const beforeObserve = await databasePool.query<{ + last_used_at: Date + idle_expires_at: Date + current_jti: string + session_version: number + revoked_at: Date | null + }>( + `SELECT last_used_at, idle_expires_at, current_jti, session_version, revoked_at + FROM external_user_sessions WHERE sid = $1`, + [sessionB.identity.sid] + ) + await expect( + isEntityChangeExternalSessionCurrent(authenticationB as never, { db: databasePool } as never) + ).resolves.toMatchObject({ status: 'current' }) + const afterObserve = await databasePool.query( + `SELECT last_used_at, idle_expires_at, current_jti, session_version, revoked_at + FROM external_user_sessions WHERE sid = $1`, + [sessionB.identity.sid] + ) + expect(afterObserve.rows[0]).toEqual(beforeObserve.rows[0]) + + await transaction(db => + revokeUserSession(principal.userId, sessionA.identity.sid, 'individual_logout', db) + ) + await expect( + isEntityChangeExternalSessionCurrent(authenticationA as never, { db: databasePool } as never) + ).resolves.toMatchObject({ status: 'denied' }) + await expect( + isEntityChangeExternalSessionCurrent(authenticationB as never, { db: databasePool } as never) + ).resolves.toMatchObject({ status: 'current' }) + + const renewed = await transaction(db => + renewUserSession(authenticationB.tokenClaims as UserSessionV2Claims, { db }) + ) + if (!('token' in renewed)) throw new Error('real producer did not renew the V2 session') + const successor = await authenticate(renewed.token) + await expect( + isEntityChangeExternalSessionCurrent(authenticationB as never, { db: databasePool } as never) + ).resolves.toMatchObject({ status: 'current' }) + + await databasePool.query( + `UPDATE external_user_sessions + SET prior_jti_expires_at = clock_timestamp() - interval '1 second' + WHERE sid = $1`, + [sessionB.identity.sid] + ) + await expect( + isEntityChangeExternalSessionCurrent(authenticationB as never, { db: databasePool } as never) + ).resolves.toMatchObject({ status: 'denied', reason: 'v2_representation_superseded' }) + await expect( + isEntityChangeExternalSessionCurrent(successor as never, { db: databasePool } as never) + ).resolves.toMatchObject({ status: 'current' }) + + await transaction(db => revokeAllUserSessions(principal.userId, 'revoke_all', db)) + await expect( + isEntityChangeExternalSessionCurrent(successor as never, { db: databasePool } as never) + ).resolves.toMatchObject({ status: 'denied' }) + }) + + it('keeps a current V1 stream at the new generation and observes fingerprint logout', async () => { + const principal = await createPrincipal('v1-stream-generation') + await transaction(db => revokeAllUserSessions(principal.userId, 'generation-advance', db)) + const representation = await transaction(db => + issueExternalUserSession( + { + contract: 'v1', + userId: principal.userId, + email: principal.email, + teamId: principal.teamId, + role: 'member', + authenticationMethods: ['pwd'], + }, + { db, policy } + ) + ) + const authentication = await authenticate(representation.token) + expect(authentication.contract).toBe('v1') + await expect( + isEntityChangeExternalSessionCurrent(authentication as never, { db: databasePool } as never) + ).resolves.toMatchObject({ status: 'current' }) + await transaction(db => + revokeLegacyUserSession( + representation.token, + authentication.tokenClaims as never, + 'individual_logout', + db + ) + ) + await expect( + isEntityChangeExternalSessionCurrent(authentication as never, { db: databasePool } as never) + ).resolves.toMatchObject({ status: 'denied', reason: 'legacy_representation_revoked' }) + }) + + it('classifies only a database transport failure as unavailable', async () => { + const principal = await createPrincipal('v2-stream-outage') + const session = await transaction(db => + createUserSession( + { + userId: principal.userId, + email: principal.email, + authenticationMethods: ['pwd'], + }, + { db } + ) + ) + const authentication = await authenticate(session.token) + const unavailableDb = { + query: async () => { + const error = new Error('connection refused') as Error & { code: string } + error.code = 'ECONNREFUSED' + throw error + }, + } + await expect( + isEntityChangeExternalSessionCurrent(authentication as never, { db: unavailableDb } as never) + ).resolves.toMatchObject({ status: 'unavailable' }) + }) +}) diff --git a/control-api/test/routes.entityChangeStream.test.ts b/control-api/test/routes.entityChangeStream.test.ts index 53b5a6adc9..6b775cb738 100644 --- a/control-api/test/routes.entityChangeStream.test.ts +++ b/control-api/test/routes.entityChangeStream.test.ts @@ -271,7 +271,7 @@ describe('routes/entityChangeStream', () => { vi.useRealTimers() }) - it('closes an established user stream when visibility authorization is revoked', async () => { + it('closes an established user stream when post-frame currentness is revoked', async () => { vi.useFakeTimers() const req = new FakeRequest() const res = new FakeResponse() @@ -290,17 +290,6 @@ describe('routes/entityChangeStream', () => { 'user-1' ) await vi.advanceTimersByTimeAsync(0) - expect(res.frames.map(frame => JSON.parse(frame))).toEqual([ - { - schemaVersion: 1, - type: 'resync_required', - cursor: '00000000-0000-0000-0000-000000000000', - scopes: ['gfs', 'authorization'], - }, - ]) - - await vi.advanceTimersByTimeAsync(configMock.entityChangeUserVisibilityRefreshMs) - expect(res.frames.map(frame => JSON.parse(frame))).toEqual([ { schemaVersion: 1, @@ -321,6 +310,130 @@ describe('routes/entityChangeStream', () => { vi.useRealTimers() }) + it('rechecks user currentness after a backpressure wait before allowing the poll to continue', async () => { + vi.useFakeTimers() + serviceMock.readEntityChangeCheckpoint.mockResolvedValue({ + resyncRequired: false, + cursor: CURSOR, + scopes: ['gfs'], + }) + const req = new FakeRequest() + const res = new FakeResponse() + res.writeReturns = false + const isAuthorized = vi + .fn() + .mockResolvedValueOnce({ status: 'current' }) + .mockResolvedValueOnce({ status: 'current' }) + .mockResolvedValueOnce({ status: 'denied', reason: 'revoked' }) + + streamEntityChanges( + req as unknown as Request, + res as unknown as Response, + CURSOR, + isAuthorized, + 'user', + 'user-backpressure' + ) + try { + await vi.advanceTimersByTimeAsync(0) + expect(res.frames.map(frame => JSON.parse(frame))).toEqual([ + { + schemaVersion: 1, + type: 'resync_required', + cursor: '00000000-0000-0000-0000-000000000000', + scopes: ['gfs', 'authorization'], + }, + ]) + res.once('drain', () => { + res.writeReturns = true + }) + res.emit('drain') + await vi.advanceTimersByTimeAsync(0) + + expect(res.frames.map(frame => JSON.parse(frame))).toEqual([ + { + schemaVersion: 1, + type: 'resync_required', + cursor: '00000000-0000-0000-0000-000000000000', + scopes: ['gfs', 'authorization'], + }, + { + schemaVersion: 1, + type: 'stream.closing', + cursor: '00000000-0000-0000-0000-000000000000', + reason: 'session_expired', + }, + ]) + expect(res.writableEnded).toBe(true) + expect(isAuthorized).toHaveBeenCalledTimes(3) + } finally { + closeActiveEntityChangeStreams() + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + } + }) + + it('holds the user cursor during authority outage after backpressure and resumes after recovery', async () => { + vi.useFakeTimers() + const req = new FakeRequest() + const res = new FakeResponse() + res.writeReturns = false + const isAuthorized = vi + .fn() + .mockResolvedValueOnce({ status: 'current' }) + .mockResolvedValueOnce({ status: 'current' }) + .mockResolvedValueOnce({ status: 'unavailable', error: new Error('database unavailable') }) + .mockResolvedValue({ status: 'current' }) + + streamEntityChanges( + req as unknown as Request, + res as unknown as Response, + CURSOR, + isAuthorized, + 'user', + 'user-outage' + ) + try { + await vi.advanceTimersByTimeAsync(0) + expect(res.frames).toHaveLength(1) + res.once('drain', () => { + res.writeReturns = true + }) + res.emit('drain') + await vi.advanceTimersByTimeAsync(0) + + expect(res.frames.map(frame => JSON.parse(frame))).toEqual([ + { + schemaVersion: 1, + type: 'resync_required', + cursor: '00000000-0000-0000-0000-000000000000', + scopes: ['gfs', 'authorization'], + }, + ]) + await vi.advanceTimersByTimeAsync(configMock.entityChangeUserVisibilityRefreshMs) + expect(res.frames.map(frame => JSON.parse(frame))).toEqual([ + { + schemaVersion: 1, + type: 'resync_required', + cursor: '00000000-0000-0000-0000-000000000000', + scopes: ['gfs', 'authorization'], + }, + { + schemaVersion: 1, + type: 'resync_required', + cursor: '00000000-0000-0000-0000-000000000000', + scopes: ['gfs', 'authorization'], + }, + ]) + expect(res.frames.join('')).not.toContain(CURSOR) + expect(res.writableEnded).toBe(false) + } finally { + closeActiveEntityChangeStreams() + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + } + }) + it('rechecks authorization before delivering an established operator invalidation', async () => { vi.useFakeTimers() serviceMock.readEntityChangeCheckpoint.mockResolvedValue({ diff --git a/control-api/test/routes.externalEntityChanges.test.ts b/control-api/test/routes.externalEntityChanges.test.ts index def2154ba4..b886c4c732 100644 --- a/control-api/test/routes.externalEntityChanges.test.ts +++ b/control-api/test/routes.externalEntityChanges.test.ts @@ -2,26 +2,118 @@ import { describe, expect, it, vi } from 'vitest' import { isEntityChangeExternalSessionCurrent } from '../src/routes/external/entityChanges.routes.js' const sessionAuth = vi.hoisted(() => ({ - isCurrentExternalSession: vi.fn(), requireValidExternalSessionToken: vi.fn(), })) +const currentness = vi.hoisted(() => ({ observe: vi.fn() })) vi.mock('../src/middleware/externalSessionAuth.js', () => sessionAuth) +vi.mock('../src/services/auth/externalSessionCurrentnessObserver.js', () => ({ + observeExternalSessionCurrentness: currentness.observe, +})) describe('external entity-change stream authorization', () => { - it('stops delivery after the authenticated session token expires', async () => { - const claims = { - userId: 'user-1', - email: 'user@example.test', - teamId: 'team-1', - role: 'member' as const, - authGeneration: 1, - exp: 1_000, + it('observes original authenticated V2 authority instead of its V1 projection', async () => { + const authentication = { + status: 'authenticated' as const, + contract: 'v2' as const, + tokenClaims: { + sub: 'user-1', + sid: 'sid-1', + jti: 'jti-1', + sv: 1, + exp: 10_000, + }, + authorityContext: { + contract: 'v2' as const, + userId: 'user-1', + sid: 'sid-1', + jti: 'jti-1', + sessionVersion: 1, + }, + claims: { + userId: 'user-1', + email: 'user@example.test', + teamId: null, + role: 'member' as const, + exp: 10_000, + iat: 1, + sessionContract: 'v2', + sid: 'sid-1', + jti: 'jti-1', + sv: 1, + ver: 2, + }, + policy: {}, + } + currentness.observe.mockReset().mockResolvedValue({ status: 'current' }) + + await expect(isEntityChangeExternalSessionCurrent(authentication as never)).resolves.toEqual({ + status: 'current', + }) + expect(currentness.observe).toHaveBeenCalledWith(authentication, {}) + }) + + it('preserves unavailable as a retryable authority state', async () => { + const authentication = { + status: 'authenticated' as const, + contract: 'v1' as const, + tokenClaims: { userId: 'user-1', iat: 999, exp: 1_000, authGeneration: 1 }, + authorityContext: { + contract: 'v1' as const, + userId: 'user-1', + tokenHash: 'fingerprint-1', + issuedAt: 999, + authGeneration: 1, + }, + claims: { + userId: 'user-1', + email: 'user@example.test', + teamId: 'team-1', + role: 'member' as const, + authGeneration: 1, + exp: 1_000, + iat: 999, + }, + policy: {}, + } + const error = new Error('database unavailable') + currentness.observe.mockResolvedValue({ status: 'unavailable', error }) + + await expect(isEntityChangeExternalSessionCurrent(authentication as never)).resolves.toEqual({ + status: 'unavailable', + error, + }) + }) + + it('uses the original authority context for observer rechecks', async () => { + const authentication = { + status: 'authenticated' as const, + contract: 'v1' as const, + tokenClaims: { userId: 'user-1', iat: 999, exp: 1_000, authGeneration: 1 }, + authorityContext: { + contract: 'v1' as const, + userId: 'user-1', + tokenHash: 'fingerprint-1', + issuedAt: 999, + authGeneration: 1, + }, + claims: { + userId: 'user-1', + email: 'user@example.test', + teamId: 'team-1', + role: 'member' as const, + authGeneration: 1, + exp: 1_000, + iat: 999, + }, + policy: {}, } - sessionAuth.isCurrentExternalSession.mockReset().mockResolvedValue(true) + currentness.observe.mockResolvedValue({ status: 'denied', reason: 'revoked' }) - await expect(isEntityChangeExternalSessionCurrent(claims, 999_000)).resolves.toBe(true) - await expect(isEntityChangeExternalSessionCurrent(claims, 1_000_000)).resolves.toBe(false) - expect(sessionAuth.isCurrentExternalSession).toHaveBeenCalledOnce() + await expect(isEntityChangeExternalSessionCurrent(authentication as never)).resolves.toEqual({ + status: 'denied', + reason: 'revoked', + }) + expect(currentness.observe).toHaveBeenCalledWith(authentication, {}) }) }) diff --git a/control-api/test/routes.externalNotifications.currentness.realPostgres.integration.test.ts b/control-api/test/routes.externalNotifications.currentness.realPostgres.integration.test.ts new file mode 100644 index 0000000000..8d3f8fcde2 --- /dev/null +++ b/control-api/test/routes.externalNotifications.currentness.realPostgres.integration.test.ts @@ -0,0 +1,198 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import express from 'express' +import { randomBytes, randomUUID } from 'node:crypto' +import { EventEmitter } from 'node:events' +import { Pool } from 'pg' +import request from 'supertest' +import { type DbClient, initDb } from '../src/db.js' +import { createExternalNotificationsRouter } from '../src/routes/external/notifications.routes.js' +import { + createUserSession, + revokeAllUserSessions, +} from '../src/services/auth/userSessionService.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' + +const producerDatabase = vi.hoisted(() => ({ + pool: undefined as Pool | undefined, + transaction: undefined as ((work: (db: DbClient) => Promise) => Promise) | undefined, + listEvents: vi.fn(), + listActive: vi.fn(), + openListener: vi.fn(), +})) + +vi.mock('../src/db.js', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + pool: { + query: (text: string, values?: unknown[]) => producerDatabase.pool!.query(text, values), + }, + withTransaction: (work: (db: DbClient) => Promise) => producerDatabase.transaction!(work), + } +}) +vi.mock('../src/middleware/externalClientIdentity.js', () => ({ + createExternalClientRateLimiters: () => [ + (_req: express.Request, _res: express.Response, next: express.NextFunction) => next(), + ], +})) +vi.mock('../src/middleware/rateLimitMiddleware.js', () => ({ + rateLimitMiddleware: + () => (_req: express.Request, _res: express.Response, next: express.NextFunction) => + next(), +})) +vi.mock('../src/middleware/mcpHostHttpMetrics.js', () => ({ + mcpHostHttpMetrics: + () => (_req: express.Request, _res: express.Response, next: express.NextFunction) => + next(), +})) +vi.mock('../src/observability/logger.js', () => ({ + rootLogger: { child: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }) }, +})) +vi.mock('../src/observability/metrics.js', () => { + const metric = { inc: vi.fn(), dec: vi.fn(), observe: vi.fn() } + return { + notificationStreamConnectionsActive: metric, + notificationStreamDisconnectsTotal: metric, + notificationStreamEventsFilteredTotal: metric, + notificationStreamEventsSentTotal: metric, + notificationStreamSnapshotSize: metric, + } +}) +vi.mock('../src/services/access/accessCatalogShadow.js', () => ({ + scheduleAccessCatalogShadow: vi.fn(), +})) +vi.mock('../src/services/notificationStreamService.js', () => ({ + listActiveApprovalNotificationsForUser: producerDatabase.listActive, + listNotificationStreamEventsForUser: producerDatabase.listEvents, + newestNotificationCursor: () => null, + openNotificationQueueListener: producerDatabase.openListener, + parseNotificationCursor: (value: string) => ({ createdAt: value, id: 'cursor-id' }), +})) + +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const describeRealPostgres = adminUrl ? describe : describe.skip +const runtimeRoles = [ + 'control_api_runtime', + 'trace_maintenance_runtime', + 'workflow_recipes_runtime', +] as const + +function quoteIdentifier(value: string): string { + return `"${value.replace(/"/g, '""')}"` +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise(done => { + resolve = done + }) + return { promise, resolve } +} + +describeRealPostgres('mounted notification stream currentness on real PostgreSQL', () => { + const database = `control_api_notification_currentness_${randomBytes(6).toString('hex')}` + const databaseUrl = new URL(adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres') + databaseUrl.pathname = `/${database}` + let adminPool: Pool + let databasePool: Pool + + async function transaction(work: (db: DbClient) => Promise): Promise { + const client = await databasePool.connect() + try { + await client.query('BEGIN') + const result = await work(client as unknown as DbClient) + await client.query('COMMIT') + return result + } catch (error) { + await client.query('ROLLBACK') + throw error + } finally { + client.release() + } + } + + beforeAll(async () => { + adminPool = new Pool({ connectionString: adminUrl }) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) + databasePool = new Pool({ connectionString: databaseUrl.toString() }) + producerDatabase.pool = databasePool + producerDatabase.transaction = transaction + await initDb({ connect: () => databasePool.connect() }) + }) + + afterAll(async () => { + try { + await endPoolAndWaitForClients(databasePool) + await adminPool.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + } finally { + await adminPool?.end() + } + }) + + it('drops a real V2 notification batch after revoke-all during the awaited read', async () => { + const userId = randomUUID() + const email = `notification-currentness-${userId}@example.test` + await databasePool.query(`INSERT INTO users(id, email, name) VALUES ($1, $2, $3)`, [ + userId, + email, + 'Notification currentness', + ]) + await transaction(db => revokeAllUserSessions(userId, 'generation-advance', db)) + const session = await transaction(db => + createUserSession({ userId, email, authenticationMethods: ['pwd'] }, { db }) + ) + const generation = await databasePool.query<{ lifecycle_version: number | string }>( + `SELECT lifecycle_version FROM users WHERE id = $1`, + [userId] + ) + expect(Number(generation.rows[0]?.lifecycle_version)).toBeGreaterThan(1) + + const events = deferred() + producerDatabase.listActive.mockResolvedValue([]) + producerDatabase.listEvents.mockReset().mockReturnValue(events.promise) + producerDatabase.openListener.mockResolvedValue( + Object.assign(new EventEmitter(), { release: vi.fn() }) + ) + + const app = express() + app.use(createExternalNotificationsRouter()) + const responsePromise = request(app) + .get('/external/notifications/stream') + .set('x-user-session-token', session.token) + let responseResult: { status: number; text: string } | undefined + void responsePromise.then(response => { + responseResult = { status: response.status, text: response.text } + }) + await vi.waitFor(() => + expect(producerDatabase.listEvents, JSON.stringify(responseResult)).toHaveBeenCalledOnce() + ) + + await transaction(db => revokeAllUserSessions(userId, 'revoke-all-during-notification', db)) + events.resolve([ + { + eventType: 'approval.updated', + id: 'private-event-id', + cursor: 'private-cursor', + approvalRequestId: 'private-approval-id', + status: 'approved', + }, + ]) + const response = await responsePromise + + expect(response.status).toBe(200) + expect(response.text).toContain('notification.snapshot') + expect(response.text).toContain('stream.closing') + expect(response.text).toContain('session_expired') + expect(response.text).not.toContain('private-event-id') + expect(response.text).not.toContain('private-cursor') + expect(response.text).not.toContain('private-approval-id') + }) +}) diff --git a/control-api/test/routes.externalNotifications.currentness.test.ts b/control-api/test/routes.externalNotifications.currentness.test.ts new file mode 100644 index 0000000000..86303494a3 --- /dev/null +++ b/control-api/test/routes.externalNotifications.currentness.test.ts @@ -0,0 +1,236 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import { EventEmitter } from 'node:events' +import request from 'supertest' +import { createExternalNotificationsRouter } from '../src/routes/external/notifications.routes.js' + +const streamState = vi.hoisted(() => ({ + observer: vi.fn(), + listActive: vi.fn(), + listEvents: vi.fn(), + newestCursor: vi.fn(), + openListener: vi.fn(), + scheduleShadow: vi.fn(), + backendFailure: vi.fn(), +})) + +vi.mock('../src/middleware/externalClientIdentity.js', () => ({ + createExternalClientRateLimiters: () => [ + (_req: express.Request, _res: express.Response, next: express.NextFunction) => next(), + ], +})) +vi.mock('../src/middleware/externalSessionAuth.js', () => ({ + requireValidExternalSessionToken: ( + req: express.Request, + _res: express.Response, + next: express.NextFunction + ) => { + const externalReq = req as express.Request & Record + externalReq.externalAuth = { userId: 'user-1' } + externalReq.externalSessionAuthority = { contract: 'v2', userId: 'user-1' } + externalReq.externalSessionAuthentication = { + status: 'authenticated', + contract: 'v2', + claims: { userId: 'user-1' }, + tokenClaims: { sub: 'user-1', sid: 'sid-1', jti: 'jti-1', sv: 1, exp: 4_000_000_000 }, + authorityContext: { + contract: 'v2', + userId: 'user-1', + sid: 'sid-1', + jti: 'jti-1', + sessionVersion: 1, + }, + policy: {}, + } + next() + }, + handleExternalSessionBackendFailure: ( + _error: unknown, + _req: express.Request, + res: express.Response, + _next: express.NextFunction + ) => { + streamState.backendFailure() + res.setHeader('Retry-After', '2') + res.setHeader('Cache-Control', 'no-store') + res.status(503).json({ error: 'session_backend_unavailable', retryAfterSeconds: 2 }) + }, +})) +vi.mock('../src/middleware/rateLimitMiddleware.js', () => ({ + rateLimitMiddleware: + () => (_req: express.Request, _res: express.Response, next: express.NextFunction) => + next(), +})) +vi.mock('../src/middleware/mcpHostHttpMetrics.js', () => ({ + mcpHostHttpMetrics: + () => (_req: express.Request, _res: express.Response, next: express.NextFunction) => + next(), +})) +vi.mock('../src/observability/logger.js', () => ({ + rootLogger: { + child: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), + }, +})) +vi.mock('../src/observability/metrics.js', () => { + const metric = { inc: vi.fn(), dec: vi.fn(), observe: vi.fn() } + return { + notificationStreamConnectionsActive: metric, + notificationStreamDisconnectsTotal: metric, + notificationStreamEventsFilteredTotal: metric, + notificationStreamEventsSentTotal: metric, + notificationStreamSnapshotSize: metric, + } +}) +vi.mock('../src/services/access/accessCatalogShadow.js', () => ({ + scheduleAccessCatalogShadow: streamState.scheduleShadow, +})) +vi.mock('../src/services/auth/externalSessionCurrentnessObserver.js', () => ({ + observeExternalSessionCurrentness: streamState.observer, +})) +vi.mock('../src/services/notificationAckService.js', () => ({ + acknowledgeDesktopNotificationDelivery: vi.fn(), +})) +vi.mock('../src/services/notificationStreamService.js', () => ({ + listActiveApprovalNotificationsForUser: streamState.listActive, + listNotificationStreamEventsForUser: streamState.listEvents, + newestNotificationCursor: streamState.newestCursor, + openNotificationQueueListener: streamState.openListener, + parseNotificationCursor: (value: string) => ({ createdAt: value, id: 'event-1' }), +})) +vi.mock('../src/services/userNotificationPreferencesService.js', () => ({ + getUserNotificationPreferences: vi.fn(), + upsertUserNotificationPreferences: vi.fn(), +})) + +function makeApp() { + const app = express() + app.use(createExternalNotificationsRouter()) + return app +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise(done => { + resolve = done + }) + return { promise, resolve } +} + +describe('external notification stream currentness', () => { + beforeEach(() => { + vi.resetAllMocks() + streamState.observer.mockResolvedValue({ status: 'current' }) + streamState.listActive.mockResolvedValue([]) + streamState.listEvents.mockResolvedValue([]) + streamState.newestCursor.mockReturnValue(null) + streamState.openListener.mockImplementation(async () => { + const listener = new EventEmitter() as EventEmitter & { release: () => void } + listener.release = vi.fn() + return listener + }) + streamState.backendFailure.mockReset() + }) + + it('returns the established bounded backend-unavailable response before headers', async () => { + streamState.observer.mockResolvedValueOnce({ + status: 'unavailable', + error: new Error('database unavailable'), + }) + + const response = await request(makeApp()).get('/external/notifications/stream') + + expect(response.status, response.text).toBe(503) + expect(response.headers['retry-after']).toBe('2') + expect(response.headers['cache-control']).toBe('no-store') + expect(response.body).toEqual({ error: 'session_backend_unavailable', retryAfterSeconds: 2 }) + expect(streamState.listActive).not.toHaveBeenCalled() + expect(streamState.backendFailure).toHaveBeenCalledOnce() + }) + + it('drops an awaited notification batch when original authority is revoked', async () => { + const batch = deferred() + streamState.observer + .mockResolvedValueOnce({ status: 'current' }) + .mockResolvedValueOnce({ status: 'current' }) + .mockResolvedValueOnce({ status: 'current' }) + .mockResolvedValueOnce({ status: 'current' }) + .mockResolvedValueOnce({ status: 'denied', reason: 'v2_session_revoked_or_changed' }) + streamState.listEvents.mockReturnValueOnce(batch.promise) + + const responsePromise = request(makeApp()).get('/external/notifications/stream') + let responseResult: { status: number; text: string } | undefined + void responsePromise.then(response => { + responseResult = { status: response.status, text: response.text } + }) + await vi.waitFor(() => + expect(streamState.listActive, JSON.stringify(responseResult)).toHaveBeenCalledOnce() + ) + await vi.waitFor(() => expect(streamState.listEvents).toHaveBeenCalledOnce()) + batch.resolve([ + { + eventType: 'approval.updated', + id: 'private-event-id', + cursor: 'private-cursor', + approvalRequestId: 'private-approval-id', + status: 'approved', + }, + ]) + const response = await responsePromise + + expect(response.status).toBe(200) + expect(response.text).toContain('stream.closing') + expect(response.text).toContain('session_expired') + expect(response.text).not.toContain('private-event-id') + expect(response.text).not.toContain('private-cursor') + expect(response.text).not.toContain('private-approval-id') + }) + + it('ends without protected data or cursor when authority becomes unavailable after headers', async () => { + const batch = deferred() + streamState.observer + .mockResolvedValueOnce({ status: 'current' }) + .mockResolvedValueOnce({ status: 'current' }) + .mockResolvedValueOnce({ status: 'current' }) + .mockResolvedValueOnce({ status: 'current' }) + .mockResolvedValueOnce({ status: 'unavailable', error: new Error('database unavailable') }) + streamState.listEvents.mockReturnValueOnce(batch.promise) + + const responsePromise = request(makeApp()).get('/external/notifications/stream') + let responseResult: { status: number; text: string } | undefined + void responsePromise.then(response => { + responseResult = { status: response.status, text: response.text } + }) + await vi.waitFor(() => + expect(streamState.listActive, JSON.stringify(responseResult)).toHaveBeenCalledOnce() + ) + await vi.waitFor(() => expect(streamState.listEvents).toHaveBeenCalledOnce()) + batch.resolve([ + { + eventType: 'approval.updated', + id: 'private-event-id', + cursor: 'private-cursor', + approvalRequestId: 'private-approval-id', + status: 'approved', + }, + ]) + const response = await responsePromise + + expect(response.status).toBe(200) + expect(response.text).toContain('notification.snapshot') + expect(response.text).not.toContain('private-event-id') + expect(response.text).not.toContain('private-cursor') + expect(response.text).not.toContain('session_backend_unavailable') + }) + + it('closes and cleans up when a post-header currentness read fails unexpectedly', async () => { + streamState.observer + .mockResolvedValueOnce({ status: 'current' }) + .mockRejectedValueOnce(new Error('unexpected observer failure')) + + const response = await request(makeApp()).get('/external/notifications/stream') + + expect(response.status).toBe(200) + expect(response.text).toBe('') + expect(streamState.listActive).not.toHaveBeenCalled() + }) +}) From 362e1ac144e7d97bd8964839bb6bfb167611a397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:11:52 -0600 Subject: [PATCH 288/528] fix(external-rest-api): r61-m1 preserve typed issuance errors Class-check: sendPasswordAuthorityError has 2 callers: password-login and invitation desktop-authorization. Only the typed R59 code defers to the canonical sanitizer; generic 429/503 mapping is unchanged. Repro-test: external-rest-api/test/passwordAdmissionError.test.ts (fails at 36facedde124ce2916256484c83db90158b1cb12 with generic authority_unavailable instead of the typed issuance code). Finding-ID: R61-M1 --- external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- .../src/http/passwordAdmissionError.ts | 11 +++ external-rest-api/src/releaseManifest.ts | 2 +- .../test/passwordAdmissionError.test.ts | 92 +++++++++++++++++++ 5 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 external-rest-api/test/passwordAdmissionError.test.ts diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 9df13ec7f3..47d8299629 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.115", + "version": "0.1.116", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.115", + "version": "0.1.116", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index d29b621ad3..9942d9963d 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.115", + "version": "0.1.116", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/http/passwordAdmissionError.ts b/external-rest-api/src/http/passwordAdmissionError.ts index ee04946e33..14da3657c3 100644 --- a/external-rest-api/src/http/passwordAdmissionError.ts +++ b/external-rest-api/src/http/passwordAdmissionError.ts @@ -1,9 +1,20 @@ import type { Response } from 'express' import { ControlApiError } from '../controlApiClient.js' +const SESSION_ISSUANCE_UNAVAILABLE_CODE = 'session_issuance_temporarily_unavailable' + +function isSessionIssuanceUnavailable(error: ControlApiError): boolean { + if (!error.body || typeof error.body !== 'object') return false + const rawError = (error.body as Record).error + if (rawError === SESSION_ISSUANCE_UNAVAILABLE_CODE) return true + if (!rawError || typeof rawError !== 'object') return false + return (rawError as Record).code === SESSION_ISSUANCE_UNAVAILABLE_CODE +} + /** Only the bounded public authority contract crosses this credential seam. */ export function sendPasswordAuthorityError(error: unknown, res: Response): boolean { if (!(error instanceof ControlApiError) || ![429, 503].includes(error.status)) return false + if (error.status === 503 && isSessionIssuanceUnavailable(error)) return false const retry = Number(error.headers['retry-after']) const retryAfterSeconds = Number.isFinite(retry) && retry > 0 ? Math.min(900, Math.max(1, Math.ceil(retry))) : 2 diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index 8f6f3acccd..ea90ef7783 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.10.0', - externalRestApiVersion: '0.1.115', + externalRestApiVersion: '0.1.116', rpcProxyVersion: '0.1.127', desktopVersion: '0.10.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/test/passwordAdmissionError.test.ts b/external-rest-api/test/passwordAdmissionError.test.ts new file mode 100644 index 0000000000..2f6fa4d92c --- /dev/null +++ b/external-rest-api/test/passwordAdmissionError.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import request from 'supertest' +import { + type PublicApiErrorCode, + publicApiErrorBody, +} from '../../control-api/src/http/publicApiError.js' +import { externalRestPublicErrorHandler } from '../src/app.js' +import { createAuthRouter } from '../src/routes/auth.js' + +function buildApp() { + const app = express() + app.use(express.json()) + app.use('/api/v1', createAuthRouter()) + app.use(externalRestPublicErrorHandler) + return app +} + +function stubControlApiError( + status: number, + code: PublicApiErrorCode, + headers: Record +) { + const body = publicApiErrorBody('issuance_ID-42', code, 'private upstream message', true) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json', ...headers }, + }) + ) + ) +} + +describe('password admission error forwarding', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('preserves the typed temporary V1 session issuance contract', async () => { + stubControlApiError(503, 'session_issuance_temporarily_unavailable', { + 'retry-after': '2', + 'cache-control': 'no-store', + }) + + const response = await request(buildApp()) + .post('/api/v1/auth/password-login') + .send({ email: 'user@example.invalid', password: 'valid-password' }) + + expect(response.status).toBe(503) + expect(response.headers['cache-control']).toBe('no-store') + expect(response.headers['retry-after']).toBe('2') + expect(response.headers['set-cookie']).toBeUndefined() + expect(response.body).toEqual({ + error: { + code: 'session_issuance_temporarily_unavailable', + message: 'A session could not be issued right now. Try again in two seconds.', + correlationId: 'issuance_ID-42', + retryable: true, + }, + }) + expect(JSON.stringify(response.body)).not.toContain('private upstream message') + }) + + it('keeps generic authority failures on the bounded admission response', async () => { + stubControlApiError(503, 'authority_unavailable', { + 'retry-after': '5', + 'cache-control': 'no-store', + }) + + const response = await request(buildApp()) + .post('/api/v1/auth/password-login') + .send({ email: 'user@example.invalid', password: 'valid-password' }) + + expect(response.status).toBe(503) + expect(response.headers['retry-after']).toBe('5') + expect(response.headers['cache-control']).toBe('no-store') + expect(response.body).toEqual({ error: 'authority_unavailable', retryAfterSeconds: 5 }) + }) + + it('keeps password admission throttling on the bounded rate-limit response', async () => { + stubControlApiError(429, 'rate_limited', { 'retry-after': '7', 'x-ratelimit-limit': '5' }) + + const response = await request(buildApp()) + .post('/api/v1/auth/password-login') + .send({ email: 'user@example.invalid', password: 'valid-password' }) + + expect(response.status).toBe(429) + expect(response.headers['retry-after']).toBe('7') + expect(response.headers['x-ratelimit-limit']).toBeUndefined() + expect(response.body).toEqual({ error: 'rate_limited', retryAfterSeconds: 7 }) + }) +}) From 82cf1e9087f1035496117c4da4065c6079248159 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:52:31 -0600 Subject: [PATCH 289/528] fix(external-rest-api): r61-m2 preserve opaque password-login denials Class-check: password-login upstream status mapping in external-rest-api/src/routes/auth.ts -> 1 route; normalize only 401/403/409. Google, invitations, and password reset remain separate contracts. Repro-test: external-rest-api/test/routes.auth.test.ts (fails at 362e1ac144e7d97bd8964839bb6bfb167611a397) --- external-rest-api/package-lock.json | 4 ++-- external-rest-api/package.json | 2 +- external-rest-api/src/releaseManifest.ts | 2 +- external-rest-api/src/routes/auth.ts | 17 ++-------------- external-rest-api/test/routes.auth.test.ts | 23 +++++++++++++++------- 5 files changed, 22 insertions(+), 26 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 47d8299629..02cea13ea0 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.116", + "version": "0.1.117", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.116", + "version": "0.1.117", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index 9942d9963d..9bdd8f0dfc 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.116", + "version": "0.1.117", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index ea90ef7783..6124ad8820 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.10.0', - externalRestApiVersion: '0.1.116', + externalRestApiVersion: '0.1.117', rpcProxyVersion: '0.1.127', desktopVersion: '0.10.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/src/routes/auth.ts b/external-rest-api/src/routes/auth.ts index 68f9a641de..4aada60ad1 100644 --- a/external-rest-api/src/routes/auth.ts +++ b/external-rest-api/src/routes/auth.ts @@ -113,21 +113,8 @@ export function createAuthRouter(): Router { const result = await loginWithPassword(email, password, requestedSessionContract(req), req.ip) sendLoginResponse(req, res, result) } catch (error) { - const message = error instanceof Error ? error.message : '' - if (message.includes('(409)')) { - res.status(409).json({ error: 'password_not_set' }) - return - } - if (message.includes('(401)')) { - res.status(401).json({ error: 'Unauthorized' }) - return - } - if (isRetiredAccountLoginDenial(error)) { - res.status(401).json({ error: 'Unauthorized' }) - return - } - if (message.includes('(403)')) { - res.status(403).json({ error: 'Membership not found' }) + if (error instanceof ControlApiError && [401, 403, 409].includes(error.status)) { + res.status(401).json({ error: 'invalid_credentials' }) return } if (sendPasswordAuthorityError(error, res)) return diff --git a/external-rest-api/test/routes.auth.test.ts b/external-rest-api/test/routes.auth.test.ts index 305c9e946a..8fb1868a80 100644 --- a/external-rest-api/test/routes.auth.test.ts +++ b/external-rest-api/test/routes.auth.test.ts @@ -30,9 +30,13 @@ describe('routes/auth password-login', () => { authServiceMock.logoutUserSession.mockReset() }) - it('propagates invalid credentials as a 401 instead of a 500', async () => { + it.each([ + [401, { error: 'invalid_credentials' }], + [403, { error: 'membership_not_found' }], + [409, { error: 'password_not_set' }], + ] as const)('keeps upstream %s password-login outcomes opaque', async (status, body) => { authServiceMock.loginWithPassword.mockRejectedValueOnce( - new ControlApiError('control-api error (401)', 401, { error: 'Unauthorized' }) + new ControlApiError(`control-api error (${status})`, status, body) ) const res = await request(buildApp()) @@ -41,9 +45,14 @@ describe('routes/auth password-login', () => { expect(res.status).toBe(401) expect(res.body).toEqual({ error: 'invalid_credentials' }) + expect(res.headers['cache-control']).toBeUndefined() + expect(res.headers['retry-after']).toBeUndefined() + expect(res.headers['x-ratelimit-limit']).toBeUndefined() + expect(res.headers['set-cookie']).toBeUndefined() + expect(JSON.stringify(res.body)).not.toMatch(/membership_not_found|password_not_set/) }) - it('keeps retired accounts indistinguishable from invalid credentials', async () => { + it('keeps retired password accounts indistinguishable from invalid credentials', async () => { authServiceMock.loginWithPassword.mockRejectedValueOnce( new ControlApiError('private upstream detail', 403, { error: 'membership_not_found' }) ) @@ -53,7 +62,7 @@ describe('routes/auth password-login', () => { .send({ email: 'retired@example.invalid', password: 'wrong-password' }) expect(res.status).toBe(401) - expect(res.body).toEqual({ error: 'Unauthorized' }) + expect(res.body).toEqual({ error: 'invalid_credentials' }) }) it('keeps retired Google accounts indistinguishable across credential providers', async () => { @@ -69,7 +78,7 @@ describe('routes/auth password-login', () => { expect(res.body).toEqual({ error: 'Unauthorized' }) }) - it('preserves the password-not-set response', async () => { + it('keeps password setup state opaque on password-login', async () => { authServiceMock.loginWithPassword.mockRejectedValueOnce( new ControlApiError('control-api error (409)', 409, { error: 'password_not_set' }) ) @@ -78,8 +87,8 @@ describe('routes/auth password-login', () => { .post('/api/v1/auth/password-login') .send({ email: 'active@example.invalid', password: 'valid-password' }) - expect(res.status).toBe(409) - expect(res.body).toEqual({ error: 'password_not_set' }) + expect(res.status).toBe(401) + expect(res.body).toEqual({ error: 'invalid_credentials' }) }) it('sets an HttpOnly profile session cookie and omits bearer token body for browser login', async () => { From 0d081e7a3b48da402b22a831cf1c23580045cf06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:15:09 -0600 Subject: [PATCH 290/528] fix(control-api): r61-b1 prove migration identity compatibility Class-check: 15 approved migration descriptors; 8 PR1 mapped, 7 PR2 deferred. Historical receipt class: 8 displaced PR1 identities map only to their same-body descriptors. Repro-test: producer-backed migrationExecutionPolicy RealPG fails at 82cf1e9087f1035496117c4da4065c6079248159 with unclassified DEV 0126/0127/0128, after the pinned historical runner generated real receipts. --- .github/workflows/ci-public.yml | 49 ++ control-api/package-lock.json | 4 +- control-api/package.json | 2 +- control-api/src/db.ts | 62 ++- control-api/src/migrations/migrationRunner.ts | 42 +- .../src/migrations/pr1OnlineIndexPlan.ts | 54 +- ...essFoundationDefinerTempShadowMigration.ts | 2 +- .../db.catalogUtf8OrderingMigration.test.ts | 4 +- ...composableCatalogRevisionMigration.test.ts | 4 +- ...CatalogRevisionComponentsMigration.test.ts | 10 +- control-api/test/db.initDb.test.ts | 5 +- control-api/test/db.migrationsOrder.test.ts | 47 +- .../db.oauthGrantsOwnerGeneralization.test.ts | 2 +- ....realPostgresMigration.integration.test.ts | 2 +- .../db.userAccessFoundationMigration.test.ts | 69 ++- ...hBackfill.realPostgres.integration.test.ts | 8 +- ...ionPolicy.realPostgres.integration.test.ts | 470 +++++++++++++++++- .../test/migrationExecutionPolicy.test.ts | 116 ++++- ...atibility.realPostgres.integration.test.ts | 6 +- 19 files changed, 804 insertions(+), 154 deletions(-) diff --git a/.github/workflows/ci-public.yml b/.github/workflows/ci-public.yml index c068072d6d..912e081e02 100644 --- a/.github/workflows/ci-public.yml +++ b/.github/workflows/ci-public.yml @@ -338,6 +338,21 @@ jobs: steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + # R61-B1 acceptance runs the exact migration producers that created the + # historical Task 106 receipts and the observed DEV receipt set. These + # immutable source pins make the RealPG test self-contained on clean CI. + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: 2370a399d4221350946462d41427fad8aee0087f + path: r61-b1-producers/historical-task106 + persist-credentials: false + + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: 0b26101eb247adcc44f70d39451f3e82c3225d47 + path: r61-b1-producers/observed-dev + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: '24' @@ -356,6 +371,38 @@ jobs: working-directory: packages/workflow-runtime-core run: npm run build + - name: Install historical Task 106 producer dependencies + working-directory: r61-b1-producers/historical-task106/packages/workflow-runtime-core + run: npm ci + + - name: Build historical Task 106 producer dependency + working-directory: r61-b1-producers/historical-task106/packages/workflow-runtime-core + run: npm run build + + - name: Install historical Task 106 Control API dependencies + working-directory: r61-b1-producers/historical-task106/control-api + run: npm ci + + - name: Build historical Task 106 migration runner + working-directory: r61-b1-producers/historical-task106/control-api + run: npm run build + + - name: Install observed DEV producer dependencies + working-directory: r61-b1-producers/observed-dev/packages/workflow-runtime-core + run: npm ci + + - name: Build observed DEV producer dependency + working-directory: r61-b1-producers/observed-dev/packages/workflow-runtime-core + run: npm run build + + - name: Install observed DEV Control API dependencies + working-directory: r61-b1-producers/observed-dev/control-api + run: npm ci + + - name: Build observed DEV migration runner + working-directory: r61-b1-producers/observed-dev/control-api + run: npm run build + - name: Install External REST contract-test dependencies working-directory: external-rest-api run: npm ci @@ -365,6 +412,8 @@ jobs: env: CONTROL_API_REAL_PG_ADMIN_URL: postgresql://postgres@127.0.0.1:15433/postgres CONTROL_API_REAL_PG_REQUIRED: '1' + TASK106_R61_B1_HISTORICAL_SOURCE_DIR: ${{ github.workspace }}/r61-b1-producers/historical-task106 + TASK106_R61_B1_DEV_SOURCE_DIR: ${{ github.workspace }}/r61-b1-producers/observed-dev FORCE_COLOR: '0' NO_COLOR: '1' # The `realPostgres` filter matches every control-api diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 7b29f65cb2..092d6afeed 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.736", + "version": "0.1.738", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.736", + "version": "0.1.738", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index e793ae989d..2b01bf3823 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.736", + "version": "0.1.738", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/db.ts b/control-api/src/db.ts index 808e85a50b..90a336a34f 100644 --- a/control-api/src/db.ts +++ b/control-api/src/db.ts @@ -6353,11 +6353,24 @@ export const CONTROL_API_MIGRATIONS: DbMigration[] = [ apply: applyAdminSubscriptionRateLimitNamespace, }, { - // The frozen dev parent now owns every slot through 0125. Preserve each - // previously deployed identity as an alias so re-slotting only records a - // canonical version and never replays an already-applied migration body. - version: '0126_user_access_foundation', + version: '0126_bug192_password_admission', + // Preserve the deployed identity after dev assigned slot 0125 to admin admission. + legacyVersions: ['0125_bug192_password_admission'], + apply: applyPasswordAdmissionSchema, + }, + { + version: '0127_password_evaluation_retention', + apply: applyPasswordEvaluationRetentionSchema, + }, + { + version: '0128_password_work_ownership', + apply: applyPasswordWorkOwnershipSchema, + }, + { + // Preserve the displaced Task 106 identity as a same-body receipt alias. + version: '0129_user_access_foundation', legacyVersions: [ + '0126_user_access_foundation', '0125_user_access_foundation', '0109_user_access_foundation', '0107_user_access_foundation', @@ -6366,8 +6379,9 @@ export const CONTROL_API_MIGRATIONS: DbMigration[] = [ apply: applyUserAccessFoundationSchema, }, { - version: '0127_invitation_delivery_commands', + version: '0130_invitation_delivery_commands', legacyVersions: [ + '0127_invitation_delivery_commands', '0126_invitation_delivery_commands', '010a_invitation_delivery_commands', '0108_invitation_delivery_commands', @@ -6376,8 +6390,9 @@ export const CONTROL_API_MIGRATIONS: DbMigration[] = [ apply: applyInvitationDeliveryCommandFoundation, }, { - version: '0128_catalog_utf8_ordering', + version: '0131_catalog_utf8_ordering', legacyVersions: [ + '0128_catalog_utf8_ordering', '0127_catalog_utf8_ordering', '010b_catalog_utf8_ordering', '0109_catalog_utf8_ordering', @@ -6386,8 +6401,9 @@ export const CONTROL_API_MIGRATIONS: DbMigration[] = [ apply: applyCatalogUtf8OrderingSchema, }, { - version: '0129_composable_catalog_revisions', + version: '0132_composable_catalog_revisions', legacyVersions: [ + '0129_composable_catalog_revisions', '0128_composable_catalog_revisions', '010c_composable_catalog_revisions', '010a_composable_catalog_revisions', @@ -6398,8 +6414,9 @@ export const CONTROL_API_MIGRATIONS: DbMigration[] = [ { // Fix-forward for databases that recorded the first composable-catalog // body before the GFS resource-component mapping was completed. - version: '012a_gfs_catalog_revision_components', + version: '0133_gfs_catalog_revision_components', legacyVersions: [ + '012a_gfs_catalog_revision_components', '0129_gfs_catalog_revision_components', '010d_gfs_catalog_revision_components', '010b_gfs_catalog_revision_components', @@ -6410,13 +6427,17 @@ export const CONTROL_API_MIGRATIONS: DbMigration[] = [ { // The access-foundation bodies are immutable; harden their installed // SECURITY DEFINER search paths without replaying those historical bodies. - version: '012b_user_access_foundation_definer_temp_shadow_hardening', - legacyVersions: ['012a_user_access_foundation_definer_temp_shadow_hardening'], + version: '0134_user_access_foundation_definer_temp_shadow_hardening', + legacyVersions: [ + '012b_user_access_foundation_definer_temp_shadow_hardening', + '012a_user_access_foundation_definer_temp_shadow_hardening', + ], apply: applyUserAccessFoundationDefinerTempShadowHardening, }, { - version: '0130_legacy_password_security_epoch_backfill', + version: '0135_legacy_password_security_epoch_backfill', legacyVersions: [ + '0130_legacy_password_security_epoch_backfill', '010e_legacy_password_security_epoch_backfill', '010c_legacy_password_security_epoch_backfill', '0106_legacy_password_security_epoch_backfill', @@ -6424,23 +6445,12 @@ export const CONTROL_API_MIGRATIONS: DbMigration[] = [ apply: backfillLegacyPasswordSecurityEpochs, }, { - version: '0138_authorization_revision_delete_compatibility', + // This narrow forward fix retains its late canonical receipt while the + // runner executes it atomically immediately after the access foundation. + version: '0143_authorization_revision_delete_compatibility', + legacyVersions: ['0138_authorization_revision_delete_compatibility'], apply: applyAuthorizationRevisionDeleteCompatibility, }, - { - version: '0126_bug192_password_admission', - // Preserve the deployed identity after dev assigned slot 0125 to admin admission. - legacyVersions: ['0125_bug192_password_admission'], - apply: applyPasswordAdmissionSchema, - }, - { - version: '0127_password_evaluation_retention', - apply: applyPasswordEvaluationRetentionSchema, - }, - { - version: '0128_password_work_ownership', - apply: applyPasswordWorkOwnershipSchema, - }, ] async function consolidateWorkflowAllowedUsersToTriggers(db: DbClient): Promise { diff --git a/control-api/src/migrations/migrationRunner.ts b/control-api/src/migrations/migrationRunner.ts index c1e426b8c8..972a48acd8 100644 --- a/control-api/src/migrations/migrationRunner.ts +++ b/control-api/src/migrations/migrationRunner.ts @@ -3,15 +3,15 @@ import { migrationSessionBoundsSql } from './migrationExecutionPolicy.js' import { preparePr1Migration } from './pr1OnlineIndexPlan.js' export const PR1_MIGRATION_VERSIONS = Object.freeze([ - '0126_user_access_foundation', - '0127_invitation_delivery_commands', - '0128_catalog_utf8_ordering', - '0129_composable_catalog_revisions', - '012a_gfs_catalog_revision_components', - '012b_user_access_foundation_definer_temp_shadow_hardening', - '0130_legacy_password_security_epoch_backfill', - // Executed immediately after 0126 by the runner, before the remaining PR1 migrations. - '0138_authorization_revision_delete_compatibility', + '0129_user_access_foundation', + '0130_invitation_delivery_commands', + '0131_catalog_utf8_ordering', + '0132_composable_catalog_revisions', + '0133_gfs_catalog_revision_components', + '0134_user_access_foundation_definer_temp_shadow_hardening', + '0135_legacy_password_security_epoch_backfill', + // Executed immediately after 0129 by the runner, before the remaining PR1 migrations. + '0143_authorization_revision_delete_compatibility', ] as const) export const DEV_POST_0106_MIGRATION_VERSIONS = Object.freeze([ @@ -34,6 +34,9 @@ export const DEV_POST_0106_MIGRATION_VERSIONS = Object.freeze([ '0123_entity_change_checkpoint_cursor_convergence', '0124_entity_change_definer_search_path', '0125_admin_subscription_rate_limit_namespace', + '0126_bug192_password_admission', + '0127_password_evaluation_retention', + '0128_password_work_ownership', ] as const) const NON_PR1_POST_0106_MIGRATION_VERSIONS = new Set(DEV_POST_0106_MIGRATION_VERSIONS) @@ -49,9 +52,9 @@ export type MigrationDescriptor = { apply: (db: DbClient) => Promise } -const AUTHORIZATION_REVISION_COMPATIBILITY_PREREQUISITE = '0126_user_access_foundation' +const AUTHORIZATION_REVISION_COMPATIBILITY_PREREQUISITE = '0129_user_access_foundation' const AUTHORIZATION_REVISION_COMPATIBILITY_VERSION = - '0138_authorization_revision_delete_compatibility' + '0143_authorization_revision_delete_compatibility' type ApplyPendingPr1MigrationsInput = { db: DbClient @@ -120,24 +123,35 @@ export async function applyPendingPr1Migrations({ const currentMigrationApplied = appliedVersions.has(version) || Boolean(acceptedLegacyVersion) if (currentMigrationApplied) { const currentReceiptPending = !appliedVersions.has(version) + const acceptedCompatibilityLegacyVersion = compatibilityMigration?.legacyVersions?.find( + alias => appliedVersions.has(alias) + ) const compatibilityReceiptPending = compatibilityMigration && !appliedVersions.has(compatibilityMigration.version) if (currentReceiptPending || compatibilityReceiptPending) { await runBoundedTransaction(db, async () => { if (currentReceiptPending) await recordMigration(db, version) if (compatibilityReceiptPending) { - await compatibilityMigration.apply(db) + if (!acceptedCompatibilityLegacyVersion) { + await compatibilityMigration.apply(db) + } await recordMigration(db, compatibilityMigration.version) } }) if (currentReceiptPending) appliedVersions.add(version) - if (compatibilityReceiptPending) appliedVersions.add(compatibilityMigration.version) + if (compatibilityReceiptPending) { + appliedVersions.add(compatibilityMigration.version) + } } continue } const isPr1Migration = expected.has(version) - if (compatibilityMigration && appliedVersions.has(compatibilityMigration.version)) { + const compatibilityReceiptAlreadyApplied = compatibilityMigration + ? appliedVersions.has(compatibilityMigration.version) || + compatibilityMigration.legacyVersions?.some(alias => appliedVersions.has(alias)) + : false + if (compatibilityMigration && compatibilityReceiptAlreadyApplied) { throw new Error( `Team revision compatibility is recorded before its prerequisite: ${compatibilityMigration.version}` ) diff --git a/control-api/src/migrations/pr1OnlineIndexPlan.ts b/control-api/src/migrations/pr1OnlineIndexPlan.ts index 543ed1d4a0..1fa40f18b9 100644 --- a/control-api/src/migrations/pr1OnlineIndexPlan.ts +++ b/control-api/src/migrations/pr1OnlineIndexPlan.ts @@ -5,7 +5,7 @@ import { } from './migrationExecutionPolicy.js' export type OnlineIndexDefinition = Readonly<{ - migrationVersion: '0126_user_access_foundation' | '0128_catalog_utf8_ordering' + migrationVersion: '0129_user_access_foundation' | '0131_catalog_utf8_ordering' name: string table: string unique?: boolean @@ -14,70 +14,70 @@ export type OnlineIndexDefinition = Readonly<{ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.freeze([ { - migrationVersion: '0126_user_access_foundation', + migrationVersion: '0129_user_access_foundation', name: 'team_members_user_active_idx', table: 'team_members', createSql: `CREATE INDEX CONCURRENTLY team_members_user_active_idx ON team_members (user_id, status, team_id) INCLUDE (role, updated_at)`, }, { - migrationVersion: '0126_user_access_foundation', + migrationVersion: '0129_user_access_foundation', name: 'user_contexts_context_user_idx', table: 'user_contexts', createSql: `CREATE INDEX CONCURRENTLY user_contexts_context_user_idx ON user_contexts (context_id, user_id)`, }, { - migrationVersion: '0126_user_access_foundation', + migrationVersion: '0129_user_access_foundation', name: 'team_contexts_context_team_idx', table: 'team_contexts', createSql: `CREATE INDEX CONCURRENTLY team_contexts_context_team_idx ON team_contexts (context_id, team_id)`, }, { - migrationVersion: '0126_user_access_foundation', + migrationVersion: '0129_user_access_foundation', name: 'user_agents_agent_user_idx', table: 'user_agents', createSql: `CREATE INDEX CONCURRENTLY user_agents_agent_user_idx ON user_agents (agent_name, user_id)`, }, { - migrationVersion: '0126_user_access_foundation', + migrationVersion: '0129_user_access_foundation', name: 'team_agents_agent_team_idx', table: 'team_agents', createSql: `CREATE INDEX CONCURRENTLY team_agents_agent_team_idx ON team_agents (agent_name, team_id)`, }, { - migrationVersion: '0126_user_access_foundation', + migrationVersion: '0129_user_access_foundation', name: 'user_workflow_triggers_recipe_user_idx', table: 'user_workflow_triggers', createSql: `CREATE INDEX CONCURRENTLY user_workflow_triggers_recipe_user_idx ON user_workflow_triggers (recipe_namespace, recipe_name, user_id)`, }, { - migrationVersion: '0126_user_access_foundation', + migrationVersion: '0129_user_access_foundation', name: 'team_workflow_triggers_recipe_team_idx', table: 'team_workflow_triggers', createSql: `CREATE INDEX CONCURRENTLY team_workflow_triggers_recipe_team_idx ON team_workflow_triggers (recipe_namespace, recipe_name, team_id)`, }, { - migrationVersion: '0126_user_access_foundation', + migrationVersion: '0129_user_access_foundation', name: 'user_workflow_triggers_catalog_key_idx', table: 'user_workflow_triggers', createSql: `CREATE INDEX CONCURRENTLY user_workflow_triggers_catalog_key_idx ON user_workflow_triggers (user_id, ((recipe_namespace || '/'::text) || recipe_name))`, }, { - migrationVersion: '0126_user_access_foundation', + migrationVersion: '0129_user_access_foundation', name: 'team_workflow_triggers_catalog_key_idx', table: 'team_workflow_triggers', createSql: `CREATE INDEX CONCURRENTLY team_workflow_triggers_catalog_key_idx ON team_workflow_triggers (((recipe_namespace || '/'::text) || recipe_name), team_id)`, }, { - migrationVersion: '0126_user_access_foundation', + migrationVersion: '0129_user_access_foundation', name: 'workflow_runs_actor_catalog_idx', table: 'workflow_runs', createSql: `CREATE INDEX CONCURRENTLY workflow_runs_actor_catalog_idx @@ -86,7 +86,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr WHERE actor_type = 'user' AND actor_id IS NOT NULL`, }, { - migrationVersion: '0126_user_access_foundation', + migrationVersion: '0129_user_access_foundation', name: 'workflow_runs_team_catalog_idx', table: 'workflow_runs', createSql: `CREATE INDEX CONCURRENTLY workflow_runs_team_catalog_idx @@ -95,7 +95,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr WHERE team_id IS NOT NULL`, }, { - migrationVersion: '0126_user_access_foundation', + migrationVersion: '0129_user_access_foundation', name: 'workflow_runs_usage_team_catalog_idx', table: 'workflow_runs', createSql: `CREATE INDEX CONCURRENTLY workflow_runs_usage_team_catalog_idx @@ -104,7 +104,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr WHERE usage_team_id IS NOT NULL`, }, { - migrationVersion: '0126_user_access_foundation', + migrationVersion: '0129_user_access_foundation', name: 'workflow_approval_user_catalog_idx', table: 'workflow_approval_requests', createSql: `CREATE INDEX CONCURRENTLY workflow_approval_user_catalog_idx @@ -113,7 +113,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr WHERE target_user_id IS NOT NULL`, }, { - migrationVersion: '0126_user_access_foundation', + migrationVersion: '0129_user_access_foundation', name: 'workflow_approval_team_catalog_idx', table: 'workflow_approval_requests', createSql: `CREATE INDEX CONCURRENTLY workflow_approval_team_catalog_idx @@ -122,7 +122,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr WHERE target_team_id IS NOT NULL`, }, { - migrationVersion: '0126_user_access_foundation', + migrationVersion: '0129_user_access_foundation', name: 'notification_user_catalog_idx', table: 'notification_deliveries', createSql: `CREATE INDEX CONCURRENTLY notification_user_catalog_idx @@ -130,7 +130,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr INCLUDE (expires_at, status, event_type) WHERE audience ? 'userId'`, }, { - migrationVersion: '0126_user_access_foundation', + migrationVersion: '0129_user_access_foundation', name: 'notification_team_catalog_idx', table: 'notification_deliveries', createSql: `CREATE INDEX CONCURRENTLY notification_team_catalog_idx @@ -138,7 +138,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr INCLUDE (expires_at, status, event_type) WHERE audience ? 'teamId'`, }, { - migrationVersion: '0126_user_access_foundation', + migrationVersion: '0129_user_access_foundation', name: 'gfs_grants_subject_resource_catalog_idx', table: 'gfs_grants', createSql: `CREATE INDEX CONCURRENTLY gfs_grants_subject_resource_catalog_idx @@ -146,7 +146,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr INCLUDE (id, drive, permissions, inherit)`, }, { - migrationVersion: '0126_user_access_foundation', + migrationVersion: '0129_user_access_foundation', name: 'gfs_shares_subject_resource_catalog_idx', table: 'gfs_shares', createSql: `CREATE INDEX CONCURRENTLY gfs_shares_subject_resource_catalog_idx @@ -154,35 +154,35 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr INCLUDE (id, drive, permissions, include_descendants)`, }, { - migrationVersion: '0128_catalog_utf8_ordering', + migrationVersion: '0131_catalog_utf8_ordering', name: 'user_agents_catalog_utf8_idx', table: 'user_agents', createSql: `CREATE INDEX CONCURRENTLY user_agents_catalog_utf8_idx ON user_agents (user_id, catalog_utf8_bytes(agent_name))`, }, { - migrationVersion: '0128_catalog_utf8_ordering', + migrationVersion: '0131_catalog_utf8_ordering', name: 'team_agents_catalog_utf8_idx', table: 'team_agents', createSql: `CREATE INDEX CONCURRENTLY team_agents_catalog_utf8_idx ON team_agents (catalog_utf8_bytes(agent_name), team_id)`, }, { - migrationVersion: '0128_catalog_utf8_ordering', + migrationVersion: '0131_catalog_utf8_ordering', name: 'user_contexts_catalog_utf8_idx', table: 'user_contexts', createSql: `CREATE INDEX CONCURRENTLY user_contexts_catalog_utf8_idx ON user_contexts (user_id, catalog_utf8_bytes(context_id))`, }, { - migrationVersion: '0128_catalog_utf8_ordering', + migrationVersion: '0131_catalog_utf8_ordering', name: 'team_contexts_catalog_utf8_idx', table: 'team_contexts', createSql: `CREATE INDEX CONCURRENTLY team_contexts_catalog_utf8_idx ON team_contexts (catalog_utf8_bytes(context_id), team_id)`, }, { - migrationVersion: '0128_catalog_utf8_ordering', + migrationVersion: '0131_catalog_utf8_ordering', name: 'user_workflow_triggers_catalog_utf8_idx', table: 'user_workflow_triggers', createSql: `CREATE INDEX CONCURRENTLY user_workflow_triggers_catalog_utf8_idx @@ -190,7 +190,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr (user_id, catalog_utf8_bytes(recipe_namespace || '/' || recipe_name))`, }, { - migrationVersion: '0128_catalog_utf8_ordering', + migrationVersion: '0131_catalog_utf8_ordering', name: 'team_workflow_triggers_catalog_utf8_idx', table: 'team_workflow_triggers', createSql: `CREATE INDEX CONCURRENTLY team_workflow_triggers_catalog_utf8_idx @@ -198,7 +198,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr (catalog_utf8_bytes(recipe_namespace || '/' || recipe_name), team_id)`, }, { - migrationVersion: '0128_catalog_utf8_ordering', + migrationVersion: '0131_catalog_utf8_ordering', name: 'operational_relationship_catalog_utf8_target_idx', table: 'operational_resource_relationships', createSql: `CREATE INDEX CONCURRENTLY operational_relationship_catalog_utf8_target_idx @@ -654,6 +654,6 @@ async function prepareCatalogUtf8Function(db: DbClient): Promise { export async function preparePr1Migration(db: DbClient, version: string): Promise { const indexes = PR1_ONLINE_INDEX_PLAN.filter(entry => entry.migrationVersion === version) if (indexes.length === 0) return - if (version === '0128_catalog_utf8_ordering') await prepareCatalogUtf8Function(db) + if (version === '0131_catalog_utf8_ordering') await prepareCatalogUtf8Function(db) for (const index of indexes) await ensureOnlineIndex(db, index) } diff --git a/control-api/src/migrations/userAccessFoundationDefinerTempShadowMigration.ts b/control-api/src/migrations/userAccessFoundationDefinerTempShadowMigration.ts index 33324029d4..3c0afdc2cb 100644 --- a/control-api/src/migrations/userAccessFoundationDefinerTempShadowMigration.ts +++ b/control-api/src/migrations/userAccessFoundationDefinerTempShadowMigration.ts @@ -4,7 +4,7 @@ import type { DbClient } from '../db.js' * Harden the installed access-foundation SECURITY DEFINER functions without * changing their bodies, ownership, grants, or behavior. * The obsolete authorization_bump_catalog_revision() routine is intentionally - * excluded because 0129_composable_catalog_revisions drops it before this runs. + * excluded because 0132_composable_catalog_revisions drops it before this runs. */ export async function applyUserAccessFoundationDefinerTempShadowHardening( db: DbClient diff --git a/control-api/test/db.catalogUtf8OrderingMigration.test.ts b/control-api/test/db.catalogUtf8OrderingMigration.test.ts index 39cac72ea2..2b5449a042 100644 --- a/control-api/test/db.catalogUtf8OrderingMigration.test.ts +++ b/control-api/test/db.catalogUtf8OrderingMigration.test.ts @@ -4,11 +4,11 @@ vi.mock('../src/config.js', () => ({ config: { databaseUrl: 'postgresql://test' }, })) -describe('0127 catalog UTF-8 ordering migration', () => { +describe('0131 catalog UTF-8 ordering migration', () => { it('installs the immutable byte function and complete supporting index class', async () => { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0128_catalog_utf8_ordering' + candidate => candidate.version === '0131_catalog_utf8_ordering' ) expect(migration).toBeDefined() diff --git a/control-api/test/db.composableCatalogRevisionMigration.test.ts b/control-api/test/db.composableCatalogRevisionMigration.test.ts index 868ab96f6f..2d143eea2e 100644 --- a/control-api/test/db.composableCatalogRevisionMigration.test.ts +++ b/control-api/test/db.composableCatalogRevisionMigration.test.ts @@ -4,11 +4,11 @@ vi.mock('../src/config.js', () => ({ config: { databaseUrl: 'postgresql://test' }, })) -describe('010c composable catalog revisions migration', () => { +describe('0132 composable catalog revisions migration', () => { it('replaces the singleton trigger class with scoped transactional writers', async () => { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0129_composable_catalog_revisions' + candidate => candidate.version === '0132_composable_catalog_revisions' ) expect(migration).toBeDefined() diff --git a/control-api/test/db.gfsCatalogRevisionComponentsMigration.test.ts b/control-api/test/db.gfsCatalogRevisionComponentsMigration.test.ts index c05c5f7173..6496e30c05 100644 --- a/control-api/test/db.gfsCatalogRevisionComponentsMigration.test.ts +++ b/control-api/test/db.gfsCatalogRevisionComponentsMigration.test.ts @@ -4,17 +4,17 @@ vi.mock('../src/config.js', () => ({ config: { databaseUrl: 'postgresql://test' }, })) -describe('0129 GFS catalog revision components migration', () => { +describe('0133 GFS catalog revision components migration', () => { it('fixes forward after a database has already recorded the prior composable revision', async () => { const { CONTROL_API_MIGRATIONS, initDb } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '012a_gfs_catalog_revision_components' + candidate => candidate.version === '0133_gfs_catalog_revision_components' ) const composableIndex = CONTROL_API_MIGRATIONS.findIndex( - candidate => candidate.version === '0129_composable_catalog_revisions' + candidate => candidate.version === '0132_composable_catalog_revisions' ) const gfsIndex = CONTROL_API_MIGRATIONS.findIndex( - candidate => candidate.version === '012a_gfs_catalog_revision_components' + candidate => candidate.version === '0133_gfs_catalog_revision_components' ) expect(migration).toBeDefined() expect(gfsIndex).toBe(composableIndex + 1) @@ -39,7 +39,7 @@ describe('0129 GFS catalog revision components migration', () => { expect(appliedSql).toContain('INSERT INTO authorization_resource_revisions') expect(clientQuery).toHaveBeenCalledWith( expect.stringContaining('INSERT INTO schema_migrations'), - ['012a_gfs_catalog_revision_components'] + ['0133_gfs_catalog_revision_components'] ) }) }) diff --git a/control-api/test/db.initDb.test.ts b/control-api/test/db.initDb.test.ts index c70f3b34a3..40f4f21001 100644 --- a/control-api/test/db.initDb.test.ts +++ b/control-api/test/db.initDb.test.ts @@ -65,8 +65,9 @@ describe('db.initDb', () => { ).toBe(false) expect(sqls.some(sql => sql.includes('DROP TABLE IF EXISTS workflow_run_outputs'))).toBe(false) expect(sqls).not.toContain('ROLLBACK') - expect(sqls.filter(sql => sql === 'BEGIN')).toHaveLength(28) - expect(sqls.filter(sql => sql === 'COMMIT')).toHaveLength(28) + // The migration plan now includes three separately committed parent-dev receipts at 0126-0128. + expect(sqls.filter(sql => sql === 'BEGIN')).toHaveLength(31) + expect(sqls.filter(sql => sql === 'COMMIT')).toHaveLength(31) expect(sqls[sqls.length - 2]).toBe('COMMIT') expect(sqls[sqls.length - 1]).toContain('SELECT pg_advisory_unlock') expect(clientRelease).toHaveBeenCalledTimes(1) diff --git a/control-api/test/db.migrationsOrder.test.ts b/control-api/test/db.migrationsOrder.test.ts index 875fee2998..f35507ab38 100644 --- a/control-api/test/db.migrationsOrder.test.ts +++ b/control-api/test/db.migrationsOrder.test.ts @@ -56,6 +56,34 @@ describe('CONTROL_API_MIGRATIONS ordering invariant', () => { expect(duplicates, `duplicate migration slot(s):\n${duplicates.join('\n')}`).toEqual([]) }) + it('keeps current-dev password migrations before the relocated Task 106 sequence', async () => { + const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') + const versions = CONTROL_API_MIGRATIONS.map(migration => migration.version) + const parentDev = [ + '0126_bug192_password_admission', + '0127_password_evaluation_retention', + '0128_password_work_ownership', + ] + const task106 = [ + '0129_user_access_foundation', + '0130_invitation_delivery_commands', + '0131_catalog_utf8_ordering', + '0132_composable_catalog_revisions', + '0133_gfs_catalog_revision_components', + '0134_user_access_foundation_definer_temp_shadow_hardening', + '0135_legacy_password_security_epoch_backfill', + '0143_authorization_revision_delete_compatibility', + ] + + expect( + versions.slice(versions.indexOf(parentDev[0]!), versions.indexOf(parentDev[0]!) + 3) + ).toEqual(parentDev) + expect( + versions.slice(versions.indexOf(task106[0]!), versions.indexOf(task106[0]!) + 8) + ).toEqual(task106) + expect(versions.indexOf(parentDev.at(-1)!)).toBeLessThan(versions.indexOf(task106[0]!)) + }) + it('keeps legacy aliases unique and distinct from current versions', async () => { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const current = new Set(CONTROL_API_MIGRATIONS.map(migration => migration.version)) @@ -69,19 +97,20 @@ describe('CONTROL_API_MIGRATIONS ordering invariant', () => { it('registers the narrow R56-B1 access-foundation definer hardening migration', async () => { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') - const version = '012b_user_access_foundation_definer_temp_shadow_hardening' + const version = '0134_user_access_foundation_definer_temp_shadow_hardening' const migration = CONTROL_API_MIGRATIONS.find(candidate => candidate.version === version) const versions = CONTROL_API_MIGRATIONS.map(candidate => candidate.version) expect(migration).toBeDefined() expect(migration?.legacyVersions).toEqual([ + '012b_user_access_foundation_definer_temp_shadow_hardening', '012a_user_access_foundation_definer_temp_shadow_hardening', ]) - expect(versions.indexOf('012a_gfs_catalog_revision_components')).toBeLessThan( + expect(versions.indexOf('0133_gfs_catalog_revision_components')).toBeLessThan( versions.indexOf(version) ) expect(versions.indexOf(version)).toBeLessThan( - versions.indexOf('0130_legacy_password_security_epoch_backfill') + versions.indexOf('0135_legacy_password_security_epoch_backfill') ) if (!migration) return @@ -113,7 +142,7 @@ describe('CONTROL_API_MIGRATIONS ordering invariant', () => { 'authorization_bump_operational_relationship_revision()', ] - // 0129_composable_catalog_revisions removes this trigger before 012b runs. + // 0132_composable_catalog_revisions removes this trigger before 0134 runs. expect(signatures).not.toContain('authorization_bump_catalog_revision()') expect(sql.match(/ALTER FUNCTION public\./g)).toHaveLength(signatures.length) for (const signature of signatures) { @@ -308,21 +337,21 @@ describe('CONTROL_API_MIGRATIONS ordering invariant', () => { ) }) - it('registers the narrow 0126 team-delete compatibility successor', async () => { + it('registers the narrow 0129 team-delete compatibility successor', async () => { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const predecessorIndex = CONTROL_API_MIGRATIONS.findIndex( - candidate => candidate.version === '0126_user_access_foundation' + candidate => candidate.version === '0129_user_access_foundation' ) const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0138_authorization_revision_delete_compatibility' + candidate => candidate.version === '0143_authorization_revision_delete_compatibility' ) expect(predecessorIndex).toBeGreaterThanOrEqual(0) expect(migration).toBeDefined() expect(CONTROL_API_MIGRATIONS.at(-1)?.version).toBe( - '0138_authorization_revision_delete_compatibility' + '0143_authorization_revision_delete_compatibility' ) - expect(migration?.legacyVersions).toBeUndefined() + expect(migration?.legacyVersions).toEqual(['0138_authorization_revision_delete_compatibility']) if (!migration) return const query = vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }) diff --git a/control-api/test/db.oauthGrantsOwnerGeneralization.test.ts b/control-api/test/db.oauthGrantsOwnerGeneralization.test.ts index 8a4aeccf1a..a73f80cdf4 100644 --- a/control-api/test/db.oauthGrantsOwnerGeneralization.test.ts +++ b/control-api/test/db.oauthGrantsOwnerGeneralization.test.ts @@ -64,7 +64,7 @@ describe('0101 oauth_grants owner generalization migration', () => { versions.indexOf('0108_llm_provider_attempts_sdk_link_on_delete_set_null') ) expect(versions.indexOf('0124_entity_change_definer_search_path')).toBeLessThan( - versions.indexOf('0126_user_access_foundation') + versions.indexOf('0129_user_access_foundation') ) expect(versions).toContain('0099_gfs_upload_finalizing_recovery') }) diff --git a/control-api/test/db.realPostgresMigration.integration.test.ts b/control-api/test/db.realPostgresMigration.integration.test.ts index 8d96efc4c4..bd26eee504 100644 --- a/control-api/test/db.realPostgresMigration.integration.test.ts +++ b/control-api/test/db.realPostgresMigration.integration.test.ts @@ -2083,7 +2083,7 @@ describeRealPostgres('control-api real Postgres migrations', () => { it('prevents temp-type shadowing in access-foundation SECURITY DEFINER triggers', async () => { const { initDb } = await import('../src/db.js') const connector = { connect: () => dbPool.connect() } - const hardeningVersion = '012b_user_access_foundation_definer_temp_shadow_hardening' + const hardeningVersion = '0134_user_access_foundation_definer_temp_shadow_hardening' const preUpgradeFunctions = await readAccessFoundationDefinerState(dbPool) expect(preUpgradeFunctions.rows.map(row => row.function_name)).toEqual( diff --git a/control-api/test/db.userAccessFoundationMigration.test.ts b/control-api/test/db.userAccessFoundationMigration.test.ts index 502dee418f..c53f8a6b75 100644 --- a/control-api/test/db.userAccessFoundationMigration.test.ts +++ b/control-api/test/db.userAccessFoundationMigration.test.ts @@ -10,7 +10,7 @@ vi.mock('../src/config.js', () => ({ async function migrationSql(): Promise { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0126_user_access_foundation' + candidate => candidate.version === '0129_user_access_foundation' ) expect(migration).toBeDefined() @@ -22,7 +22,7 @@ async function migrationSql(): Promise { async function legacyEpochBackfillSql(): Promise { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0130_legacy_password_security_epoch_backfill' + candidate => candidate.version === '0135_legacy_password_security_epoch_backfill' ) expect(migration).toBeDefined() @@ -32,18 +32,40 @@ async function legacyEpochBackfillSql(): Promise { } describe('user-access foundation migration', () => { - it('keeps the parent migration in 0125 and aliases the previously published PR1 slot', async () => { + it('keeps current-dev identities and aliases the displaced Task 106 identity', async () => { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const parent = CONTROL_API_MIGRATIONS.find( candidate => candidate.version === '0125_admin_subscription_rate_limit_namespace' ) + const passwordAdmission = CONTROL_API_MIGRATIONS.find( + candidate => candidate.version === '0126_bug192_password_admission' + ) + const retention = CONTROL_API_MIGRATIONS.find( + candidate => candidate.version === '0127_password_evaluation_retention' + ) + const ownership = CONTROL_API_MIGRATIONS.find( + candidate => candidate.version === '0128_password_work_ownership' + ) const foundation = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0126_user_access_foundation' + candidate => candidate.version === '0129_user_access_foundation' ) expect(parent).toBeDefined() + expect(passwordAdmission?.legacyVersions).toEqual(['0125_bug192_password_admission']) + expect(retention).toBeDefined() + expect(ownership).toBeDefined() + expect(foundation?.legacyVersions).toContain('0126_user_access_foundation') expect(foundation?.legacyVersions).toContain('0125_user_access_foundation') expect(CONTROL_API_MIGRATIONS.indexOf(parent!)).toBeLessThan( + CONTROL_API_MIGRATIONS.indexOf(passwordAdmission!) + ) + expect(CONTROL_API_MIGRATIONS.indexOf(passwordAdmission!)).toBeLessThan( + CONTROL_API_MIGRATIONS.indexOf(retention!) + ) + expect(CONTROL_API_MIGRATIONS.indexOf(retention!)).toBeLessThan( + CONTROL_API_MIGRATIONS.indexOf(ownership!) + ) + expect(CONTROL_API_MIGRATIONS.indexOf(ownership!)).toBeLessThan( CONTROL_API_MIGRATIONS.indexOf(foundation!) ) }) @@ -52,8 +74,9 @@ describe('user-access foundation migration', () => { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const expectedAliases = new Map([ [ - '0126_user_access_foundation', + '0129_user_access_foundation', [ + '0126_user_access_foundation', '0125_user_access_foundation', '0109_user_access_foundation', '0107_user_access_foundation', @@ -61,8 +84,9 @@ describe('user-access foundation migration', () => { ], ], [ - '0127_invitation_delivery_commands', + '0130_invitation_delivery_commands', [ + '0127_invitation_delivery_commands', '0126_invitation_delivery_commands', '010a_invitation_delivery_commands', '0108_invitation_delivery_commands', @@ -70,8 +94,9 @@ describe('user-access foundation migration', () => { ], ], [ - '0128_catalog_utf8_ordering', + '0131_catalog_utf8_ordering', [ + '0128_catalog_utf8_ordering', '0127_catalog_utf8_ordering', '010b_catalog_utf8_ordering', '0109_catalog_utf8_ordering', @@ -79,8 +104,9 @@ describe('user-access foundation migration', () => { ], ], [ - '0129_composable_catalog_revisions', + '0132_composable_catalog_revisions', [ + '0129_composable_catalog_revisions', '0128_composable_catalog_revisions', '010c_composable_catalog_revisions', '010a_composable_catalog_revisions', @@ -88,8 +114,9 @@ describe('user-access foundation migration', () => { ], ], [ - '012a_gfs_catalog_revision_components', + '0133_gfs_catalog_revision_components', [ + '012a_gfs_catalog_revision_components', '0129_gfs_catalog_revision_components', '010d_gfs_catalog_revision_components', '010b_gfs_catalog_revision_components', @@ -97,17 +124,25 @@ describe('user-access foundation migration', () => { ], ], [ - '012b_user_access_foundation_definer_temp_shadow_hardening', - ['012a_user_access_foundation_definer_temp_shadow_hardening'], + '0134_user_access_foundation_definer_temp_shadow_hardening', + [ + '012b_user_access_foundation_definer_temp_shadow_hardening', + '012a_user_access_foundation_definer_temp_shadow_hardening', + ], ], [ - '0130_legacy_password_security_epoch_backfill', + '0135_legacy_password_security_epoch_backfill', [ + '0130_legacy_password_security_epoch_backfill', '010e_legacy_password_security_epoch_backfill', '010c_legacy_password_security_epoch_backfill', '0106_legacy_password_security_epoch_backfill', ], ], + [ + '0143_authorization_revision_delete_compatibility', + ['0138_authorization_revision_delete_compatibility'], + ], ]) for (const [version, legacyVersions] of expectedAliases) { @@ -235,17 +270,17 @@ describe('user-access foundation migration', () => { it('dispatches the historical epoch fix-forward after already-recorded access migrations', async () => { const { CONTROL_API_MIGRATIONS, initDb } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0130_legacy_password_security_epoch_backfill' + candidate => candidate.version === '0135_legacy_password_security_epoch_backfill' ) expect(migration).toBeDefined() const gfsIndex = CONTROL_API_MIGRATIONS.findIndex( - candidate => candidate.version === '012a_gfs_catalog_revision_components' + candidate => candidate.version === '0133_gfs_catalog_revision_components' ) const hardeningIndex = CONTROL_API_MIGRATIONS.findIndex( - candidate => candidate.version === '012b_user_access_foundation_definer_temp_shadow_hardening' + candidate => candidate.version === '0134_user_access_foundation_definer_temp_shadow_hardening' ) const epochIndex = CONTROL_API_MIGRATIONS.findIndex( - candidate => candidate.version === '0130_legacy_password_security_epoch_backfill' + candidate => candidate.version === '0135_legacy_password_security_epoch_backfill' ) expect(hardeningIndex).toBe(gfsIndex + 1) expect(epochIndex).toBe(hardeningIndex + 1) @@ -267,7 +302,7 @@ describe('user-access foundation migration', () => { expect(appliedSql).toContain('historical_password_event') expect(clientQuery).toHaveBeenCalledWith( expect.stringContaining('INSERT INTO schema_migrations'), - ['0130_legacy_password_security_epoch_backfill'] + ['0135_legacy_password_security_epoch_backfill'] ) }) }) diff --git a/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts b/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts index 572c21b798..6de2836023 100644 --- a/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts +++ b/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts @@ -108,7 +108,7 @@ describeRealPostgres('legacy password epoch backfill on real PostgreSQL', () => await databasePool.query( `DELETE FROM schema_migrations - WHERE version = '0130_legacy_password_security_epoch_backfill'` + WHERE version = '0135_legacy_password_security_epoch_backfill'` ) await initDb({ connect: () => databasePool.connect() }) @@ -128,7 +128,7 @@ describeRealPostgres('legacy password epoch backfill on real PostgreSQL', () => const recorded = await databasePool.query<{ count: string }>( `SELECT COUNT(*)::text AS count FROM schema_migrations - WHERE version = '0130_legacy_password_security_epoch_backfill'` + WHERE version = '0135_legacy_password_security_epoch_backfill'` ) expect(recorded.rows[0]?.count).toBe('1') }) @@ -161,12 +161,12 @@ describeRealPostgres('legacy password epoch backfill on real PostgreSQL', () => ) await databasePool.query( `DELETE FROM schema_migrations - WHERE version = '0130_legacy_password_security_epoch_backfill'` + WHERE version = '0135_legacy_password_security_epoch_backfill'` ) await initDb({ connect: () => databasePool.connect() }) await databasePool.query( `DELETE FROM schema_migrations - WHERE version = '0130_legacy_password_security_epoch_backfill'` + WHERE version = '0135_legacy_password_security_epoch_backfill'` ) await initDb({ connect: () => databasePool.connect() }) diff --git a/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts index c8c50a3477..9abd8e21e8 100644 --- a/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts +++ b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts @@ -1,9 +1,11 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { spawn } from 'node:child_process' -import { randomBytes, randomUUID } from 'node:crypto' +import { execFileSync, spawn } from 'node:child_process' +import { createHash, generateKeyPairSync, randomBytes, randomUUID } from 'node:crypto' import { once } from 'node:events' +import { existsSync, readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' import { Pool, type PoolClient } from 'pg' -import { type DbClient, initDb } from '../src/db.js' +import { CONTROL_API_MIGRATIONS, type DbClient, assertDbReady, initDb } from '../src/db.js' import { DEV_POST_0106_MIGRATION_VERSIONS, PR1_MIGRATION_VERSIONS, @@ -18,6 +20,96 @@ import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip +const HISTORICAL_TASK106_SOURCE_COMMIT = '2370a399d4221350946462d41427fad8aee0087f' +const HISTORICAL_TASK106_DB_SOURCE_SHA256 = + 'f2e06aba1cfa84a920eedefdb1ec56748efccbacf5baecad7caede4938364379' +const HISTORICAL_TASK106_MIGRATION_RUNNER_SOURCE_SHA256 = + '65023efa1630261ce8d180fc2fa8789e517eb6a13ace2a6458ba95236046748f' +const OBSERVED_DEV_SOURCE_COMMIT = '0b26101eb247adcc44f70d39451f3e82c3225d47' +const OBSERVED_DEV_DB_SOURCE_SHA256 = + '1948ad306fe56b368f120edbc70ddbac6cb9b981d9fd218e7ded2575ed3b2beb' +const OBSERVED_DEV_RECEIPT_COUNT = 133 +const OBSERVED_DEV_RECEIPT_SET_SHA256 = + 'acb72ad12e1d342310832cf7628ab8351a80cee7311111039b4c2b9720077307' + +const DISPLACED_TASK106_RECEIPTS = Object.freeze([ + ['0126_user_access_foundation', '0129_user_access_foundation'], + ['0127_invitation_delivery_commands', '0130_invitation_delivery_commands'], + ['0128_catalog_utf8_ordering', '0131_catalog_utf8_ordering'], + ['0129_composable_catalog_revisions', '0132_composable_catalog_revisions'], + ['012a_gfs_catalog_revision_components', '0133_gfs_catalog_revision_components'], + [ + '012b_user_access_foundation_definer_temp_shadow_hardening', + '0134_user_access_foundation_definer_temp_shadow_hardening', + ], + ['0130_legacy_password_security_epoch_backfill', '0135_legacy_password_security_epoch_backfill'], + [ + '0138_authorization_revision_delete_compatibility', + '0143_authorization_revision_delete_compatibility', + ], +] as const) + +const TASK106_HISTORICAL_SOURCE_DIR = process.env.TASK106_R61_B1_HISTORICAL_SOURCE_DIR +const OBSERVED_DEV_SOURCE_DIR = process.env.TASK106_R61_B1_DEV_SOURCE_DIR +if ( + process.env.CONTROL_API_REAL_PG_REQUIRED === '1' && + (!TASK106_HISTORICAL_SOURCE_DIR || !OBSERVED_DEV_SOURCE_DIR) +) { + throw new Error( + 'R61-B1 real PostgreSQL lane requires both pinned historical producer source directories' + ) +} + +type HistoricalRunnerPin = { + label: string + sourceDir: string | undefined + commit: string + sourceFiles: ReadonlyArray +} + +const HISTORICAL_TASK106_RUNNER: HistoricalRunnerPin = { + label: 'historical Task 106', + sourceDir: TASK106_HISTORICAL_SOURCE_DIR, + commit: HISTORICAL_TASK106_SOURCE_COMMIT, + sourceFiles: [ + ['control-api/src/db.ts', HISTORICAL_TASK106_DB_SOURCE_SHA256], + [ + 'control-api/src/migrations/migrationRunner.ts', + HISTORICAL_TASK106_MIGRATION_RUNNER_SOURCE_SHA256, + ], + ], +} + +const OBSERVED_DEV_RUNNER: HistoricalRunnerPin = { + label: 'observed DEV', + sourceDir: OBSERVED_DEV_SOURCE_DIR, + commit: OBSERVED_DEV_SOURCE_COMMIT, + sourceFiles: [['control-api/src/db.ts', OBSERVED_DEV_DB_SOURCE_SHA256]], +} + +let ephemeralMigrationSigningKeys: + | { + rpc: string + session: string + admin: string + } + | undefined + +function migrationRunnerSigningKeys(): NonNullable { + if (!ephemeralMigrationSigningKeys) { + const privateKey = () => + generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey.export({ + type: 'pkcs8', + format: 'pem', + }) as string + ephemeralMigrationSigningKeys = { + rpc: privateKey(), + session: privateKey(), + admin: privateKey(), + } + } + return ephemeralMigrationSigningKeys +} const FRESH_TABLE_INDEXES = Object.freeze([ 'external_user_sessions_user_live_idx', @@ -46,6 +138,179 @@ function quoteIdentifier(value: string): string { return `"${value.replace(/"/g, '""')}"` } +function sha256File(path: string): string { + return createHash('sha256').update(readFileSync(path)).digest('hex') +} + +function verifyPinnedRunnerSource(pin: HistoricalRunnerPin): string { + if (!pin.sourceDir) { + throw new Error(`R61-B1 test did not receive ${pin.label} source directory`) + } + const sourceDir = resolve(pin.sourceDir) + const revision = execFileSync('git', ['-C', sourceDir, 'rev-parse', 'HEAD'], { + encoding: 'utf8', + }).trim() + expect(revision, `${pin.label} source revision`).toBe(pin.commit) + const status = execFileSync('git', ['-C', sourceDir, 'status', '--porcelain'], { + encoding: 'utf8', + }).trim() + expect(status, `${pin.label} source checkout is clean`).toBe('') + + for (const [relativePath, expectedHash] of pin.sourceFiles) { + expect(sha256File(join(sourceDir, relativePath)), `${pin.label} ${relativePath} SHA-256`).toBe( + expectedHash + ) + } + + const runnerPath = join(sourceDir, 'control-api/dist/migrate.js') + expect(existsSync(runnerPath), `${pin.label} migration runner build`).toBe(true) + return runnerPath +} + +async function runPinnedMigrationProducer( + pin: HistoricalRunnerPin, + targetDatabaseUrl: string +): Promise { + const runnerPath = verifyPinnedRunnerSource(pin) + const keys = migrationRunnerSigningKeys() + const child = spawn(process.execPath, [runnerPath], { + cwd: join(resolve(pin.sourceDir!), 'control-api'), + env: { + NODE_ENV: 'test', + CONTROL_API_PG_CONNECTION_STRING: targetDatabaseUrl, + CONTROL_API_RPC_JWT_PRIVATE_KEY: keys.rpc, + CONTROL_API_SESSION_JWT_PRIVATE_KEY: keys.session, + CONTROL_API_ADMIN_JWT_PRIVATE_KEY: keys.admin, + TZ: 'UTC', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + + const output: string[] = [] + const appendOutput = (chunk: Buffer) => { + output.push(chunk.toString('utf8')) + while (output.join('').length > 32_000) output.shift() + } + child.stdout?.on('data', appendOutput) + child.stderr?.on('data', appendOutput) + + await new Promise((resolvePromise, rejectPromise) => { + let timedOut = false + const timeout = setTimeout(() => { + timedOut = true + child.kill('SIGTERM') + setTimeout(() => child.kill('SIGKILL'), 5_000).unref() + }, 240_000) + timeout.unref() + + child.once('error', error => { + clearTimeout(timeout) + rejectPromise(error) + }) + child.once('close', (code, signal) => { + clearTimeout(timeout) + if (code === 0 && !timedOut) { + resolvePromise() + return + } + const detail = output + .join('') + .replaceAll(targetDatabaseUrl, '') + .slice(-8_000) + rejectPromise( + new Error( + `${pin.label} migration runner ${timedOut ? 'timed out' : `exited ${code ?? signal}`}\n${detail}` + ) + ) + }) + }) +} + +function receiptSetSha256(receipts: readonly string[]): string { + return createHash('sha256') + .update(`${[...receipts].sort().join('\n')}\n`) + .digest('hex') +} + +async function withMigrationApplyObserver( + observe: ReadonlySet, + work: () => Promise +): Promise<{ result: T; applyCounts: Map; applyOrder: string[] }> { + const originals = new Map Promise>() + const applyCounts = new Map() + const applyOrder: string[] = [] + for (const migration of CONTROL_API_MIGRATIONS) { + if (!observe.has(migration.version)) continue + const original = migration.apply + originals.set(migration.version, original) + migration.apply = async db => { + applyCounts.set(migration.version, (applyCounts.get(migration.version) ?? 0) + 1) + applyOrder.push(migration.version) + await original(db) + } + } + + try { + return { result: await work(), applyCounts, applyOrder } + } finally { + for (const migration of CONTROL_API_MIGRATIONS) { + const original = originals.get(migration.version) + if (original) migration.apply = original + } + } +} + +async function currentMigrationSchemaAndPrivileges(pool: Pool): Promise<{ + objects: Record + privileges: string[] +}> { + const objectNames = [ + 'external_user_sessions', + 'external_v1_session_revocations', + 'authorization_resource_revisions', + 'invitation_delivery_commands', + ] + const objectRows = await pool.query<{ name: string; relation: string | null }>( + `SELECT name, to_regclass(format('public.%I', name))::text AS relation + FROM unnest($1::text[]) AS names(name)`, + [objectNames] + ) + const privilegeRows = await pool.query<{ privilege: string; allowed: boolean }>( + `SELECT relation || ':' || privilege AS privilege, + has_table_privilege('control_api_runtime', relation, privilege) AS allowed + FROM (VALUES + ('schema_migrations'::text, 'SELECT'::text), + ('schema_migrations', 'INSERT'), + ('schema_migrations', 'UPDATE'), + ('schema_migrations', 'DELETE') + ) AS required(relation, privilege)`, + [] + ) + return { + objects: Object.fromEntries(objectRows.rows.map(row => [row.name, row.relation])), + privileges: privilegeRows.rows.map(row => `${row.privilege}:${row.allowed}`).sort(), + } +} + +async function passwordMigrationPrivileges(pool: Pool): Promise { + const result = await pool.query<{ privilege: string; allowed: boolean }>( + `SELECT relation || ':' || privilege AS privilege, + has_table_privilege('control_api_runtime', relation, privilege) AS allowed + FROM (VALUES + ('password_identifier_state'::text, 'SELECT'::text), + ('password_identifier_state', 'INSERT'), + ('password_identifier_state', 'UPDATE'), + ('password_identifier_state', 'DELETE'), + ('password_verification_work', 'SELECT'), + ('password_verification_work', 'INSERT'), + ('password_verification_work', 'UPDATE'), + ('password_verification_work', 'DELETE') + ) AS required(relation, privilege)`, + [] + ) + return result.rows.map(row => `${row.privilege}:${row.allowed}`).sort() +} + async function versions(pool: Pool): Promise { const result = await pool.query<{ version: string }>( 'SELECT version FROM schema_migrations ORDER BY version' @@ -55,8 +320,8 @@ async function versions(pool: Pool): Promise { async function removeMigrationReceiptForReplay(pool: Pool, version: string): Promise { const versionsToRemove = - version === '0126_user_access_foundation' - ? [version, '0138_authorization_revision_delete_compatibility'] + version === '0129_user_access_foundation' + ? [version, '0143_authorization_revision_delete_compatibility'] : [version] await pool.query('DELETE FROM schema_migrations WHERE version = ANY($1::text[])', [ versionsToRemove, @@ -71,6 +336,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { ) let adminPool: Pool let databasePool: Pool + const isolatedDatabases: string[] = [] beforeAll(async () => { adminPool = new Pool({ connectionString: adminUrl }) @@ -83,6 +349,16 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { try { await endPoolAndWaitForClients(databasePool) if (adminPool) { + for (const isolatedDatabase of isolatedDatabases) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 + AND pid <> pg_backend_pid()`, + [isolatedDatabase] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(isolatedDatabase)}`) + } await adminPool.query( `SELECT pg_terminate_backend(pid) FROM pg_stat_activity @@ -202,7 +478,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { for (const candidate of cases) { await databasePool.query(`CREATE INDEX ${candidate.name} ON ${table} ${candidate.actual}`) const entry: OnlineIndexDefinition = { - migrationVersion: '0126_user_access_foundation', + migrationVersion: '0129_user_access_foundation', name: candidate.name, table, createSql: `CREATE INDEX CONCURRENTLY ${candidate.name} ON ${table} ${candidate.expected}`, @@ -216,7 +492,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { await databasePool.query(`CREATE INDEX ${uniqueName} ON ${table} (userid)`) await expect( ensureOnlineIndex(databasePool, { - migrationVersion: '0126_user_access_foundation', + migrationVersion: '0129_user_access_foundation', name: uniqueName, table, unique: true, @@ -228,7 +504,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { it('repairs an equivalent interrupted index and enforces the online bound', async () => { const name = `d34_interrupted_${randomBytes(4).toString('hex')}` const entry: OnlineIndexDefinition = { - migrationVersion: '0126_user_access_foundation', + migrationVersion: '0129_user_access_foundation', name, table: 'd34_interrupted_index', unique: true, @@ -277,7 +553,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { const functionName = `d34_slow_index_value_${suffix}` const name = `d34_slow_index_${suffix}_idx` const entry: OnlineIndexDefinition = { - migrationVersion: '0126_user_access_foundation', + migrationVersion: '0129_user_access_foundation', name, table, createSql: `CREATE INDEX CONCURRENTLY ${name} ON ${table} (${functionName}(value))`, @@ -391,7 +667,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { const locker = await databasePool.connect() await databasePool.query( `DELETE FROM schema_migrations - WHERE version IN ('0129_composable_catalog_revisions', '012a_gfs_catalog_revision_components')` + WHERE version IN ('0132_composable_catalog_revisions', '0133_gfs_catalog_revision_components')` ) await locker.query('BEGIN') await locker.query('LOCK TABLE team_members IN ACCESS EXCLUSIVE MODE') @@ -407,8 +683,8 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { expect(Date.now() - started).toBeGreaterThanOrEqual(9_000) expect(Date.now() - started).toBeLessThan(15_000) const failedVersions = await versions(databasePool) - expect(failedVersions).not.toContain('0129_composable_catalog_revisions') - expect(failedVersions).not.toContain('012a_gfs_catalog_revision_components') + expect(failedVersions).not.toContain('0132_composable_catalog_revisions') + expect(failedVersions).not.toContain('0133_gfs_catalog_revision_components') await initDb({ connect: () => databasePool.connect() }) }, 20_000) @@ -417,7 +693,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { const appliedVersions = new Set([ ...DEV_POST_0106_MIGRATION_VERSIONS, ...PR1_MIGRATION_VERSIONS.slice(0, 3), - '0138_authorization_revision_delete_compatibility', + '0143_authorization_revision_delete_compatibility', ]) const recordTable = `d34_record_${randomBytes(4).toString('hex')}` await client.query(`CREATE TEMP TABLE ${recordTable}(version text PRIMARY KEY)`) @@ -429,7 +705,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { ...PR1_MIGRATION_VERSIONS.map(version => ({ version, apply: async (db: DbClient) => { - if (version === '0129_composable_catalog_revisions') { + if (version === '0132_composable_catalog_revisions') { await db.query('SELECT pg_sleep(20)') } }, @@ -453,7 +729,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { `SELECT version FROM ${recordTable} ORDER BY version` ) expect(recorded.rows).toEqual([]) - expect(appliedVersions).not.toContain('012a_gfs_catalog_revision_components') + expect(appliedVersions).not.toContain('0133_gfs_catalog_revision_components') } finally { client.release(true) } @@ -482,6 +758,170 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { expect(await versions(databasePool)).toContain(entry.migrationVersion) }) + it('reconciles real historical Task 106 receipts without replaying migration bodies', async () => { + const databaseName = `r61_historical_task106_${randomBytes(6).toString('hex')}` + const isolatedUrl = databaseUrl(adminUrl!, databaseName) + await adminPool.query(`CREATE DATABASE ${quoteIdentifier(databaseName)}`) + isolatedDatabases.push(databaseName) + const isolatedPool = new Pool({ connectionString: isolatedUrl }) + try { + await runPinnedMigrationProducer(HISTORICAL_TASK106_RUNNER, isolatedUrl) + + const historicalReceipts = await versions(isolatedPool) + const historicalReceiptSet = new Set(historicalReceipts) + for (const [historicalReceipt] of DISPLACED_TASK106_RECEIPTS) { + expect( + historicalReceiptSet.has(historicalReceipt), + `${historicalReceipt} producer receipt` + ).toBe(true) + } + for (const [, currentVersion] of DISPLACED_TASK106_RECEIPTS) { + expect( + historicalReceiptSet.has(currentVersion), + `${currentVersion} not in old producer` + ).toBe(false) + } + + const schemaAndPrivilegesBefore = await currentMigrationSchemaAndPrivileges(isolatedPool) + const observedVersions = new Set([ + ...PR1_MIGRATION_VERSIONS, + '0126_bug192_password_admission', + '0127_password_evaluation_retention', + '0128_password_work_ownership', + ]) + const currentRun = await withMigrationApplyObserver(observedVersions, async () => + initDb({ connect: () => isolatedPool.connect() }) + ) + + const receiptsAfterReconciliation = await versions(isolatedPool) + const reconciledSet = new Set(receiptsAfterReconciliation) + for (const [historicalReceipt, currentVersion] of DISPLACED_TASK106_RECEIPTS) { + const migration = CONTROL_API_MIGRATIONS.find( + candidate => candidate.version === currentVersion + ) + expect(migration?.legacyVersions, `${historicalReceipt} alias owner`).toContain( + historicalReceipt + ) + expect(reconciledSet.has(historicalReceipt), `${historicalReceipt} preserved`).toBe(true) + expect(reconciledSet.has(currentVersion), `${currentVersion} canonical receipt`).toBe(true) + expect( + currentRun.applyCounts.get(currentVersion) ?? 0, + `${currentVersion} body replay` + ).toBe(0) + } + for (const version of [ + '0126_bug192_password_admission', + '0127_password_evaluation_retention', + '0128_password_work_ownership', + ]) { + expect(currentRun.applyCounts.get(version), `${version} applies to old Task 106 DB`).toBe(1) + expect(reconciledSet.has(version), `${version} receipt`).toBe(true) + } + + await assertDbReady(isolatedPool) + const schemaAndPrivilegesAfter = await currentMigrationSchemaAndPrivileges(isolatedPool) + expect(schemaAndPrivilegesAfter.objects).toEqual(schemaAndPrivilegesBefore.objects) + expect(schemaAndPrivilegesAfter.privileges).toEqual(schemaAndPrivilegesBefore.privileges) + expect(Object.values(schemaAndPrivilegesAfter.objects).every(Boolean)).toBe(true) + expect(await passwordMigrationPrivileges(isolatedPool)).toEqual([ + 'password_identifier_state:DELETE:true', + 'password_identifier_state:INSERT:true', + 'password_identifier_state:SELECT:true', + 'password_identifier_state:UPDATE:true', + 'password_verification_work:DELETE:true', + 'password_verification_work:INSERT:true', + 'password_verification_work:SELECT:true', + 'password_verification_work:UPDATE:false', + ]) + + const afterFirstRerun = await versions(isolatedPool) + const secondRun = await withMigrationApplyObserver(observedVersions, async () => + initDb({ connect: () => isolatedPool.connect() }) + ) + expect(await versions(isolatedPool)).toEqual(afterFirstRerun) + expect([...secondRun.applyCounts.values()]).toEqual([]) + await assertDbReady(isolatedPool) + } finally { + await endPoolAndWaitForClients(isolatedPool) + } + }) + + it('upgrades the exact observed DEV migration identity set with real migration producers', async () => { + const databaseName = `r61_observed_dev_${randomBytes(6).toString('hex')}` + const isolatedUrl = databaseUrl(adminUrl!, databaseName) + await adminPool.query(`CREATE DATABASE ${quoteIdentifier(databaseName)}`) + isolatedDatabases.push(databaseName) + const isolatedPool = new Pool({ connectionString: isolatedUrl }) + try { + await runPinnedMigrationProducer(OBSERVED_DEV_RUNNER, isolatedUrl) + + const observedDevReceipts = await versions(isolatedPool) + expect(observedDevReceipts).toHaveLength(OBSERVED_DEV_RECEIPT_COUNT) + expect(receiptSetSha256(observedDevReceipts)).toBe(OBSERVED_DEV_RECEIPT_SET_SHA256) + for (const version of [ + '0126_bug192_password_admission', + '0127_password_evaluation_retention', + '0128_password_work_ownership', + ]) { + expect(observedDevReceipts).toContain(version) + } + expect( + DISPLACED_TASK106_RECEIPTS.some(([historicalReceipt]) => + observedDevReceipts.includes(historicalReceipt) + ) + ).toBe(false) + + const observedDevSet = new Set(observedDevReceipts) + const firstRun = await withMigrationApplyObserver( + new Set([ + ...PR1_MIGRATION_VERSIONS, + '0126_bug192_password_admission', + '0127_password_evaluation_retention', + '0128_password_work_ownership', + ]), + async () => initDb({ connect: () => isolatedPool.connect() }) + ) + const upgradedReceipts = await versions(isolatedPool) + const upgradedSet = new Set(upgradedReceipts) + expect(upgradedReceipts).toHaveLength( + OBSERVED_DEV_RECEIPT_COUNT + PR1_MIGRATION_VERSIONS.length + ) + expect(upgradedReceipts.filter(version => !observedDevSet.has(version))).toEqual( + [...PR1_MIGRATION_VERSIONS].sort() + ) + expect(upgradedReceipts.filter(version => observedDevSet.has(version))).toEqual( + observedDevReceipts + ) + for (const version of PR1_MIGRATION_VERSIONS) { + expect(firstRun.applyCounts.get(version), `${version} applies from DEV`).toBe(1) + expect(upgradedSet.has(version), `${version} current receipt`).toBe(true) + } + for (const version of [ + '0126_bug192_password_admission', + '0127_password_evaluation_retention', + '0128_password_work_ownership', + ]) { + expect(firstRun.applyCounts.get(version) ?? 0, `${version} DEV body replay`).toBe(0) + } + const foundationIndex = firstRun.applyOrder.indexOf('0129_user_access_foundation') + expect(firstRun.applyOrder[foundationIndex + 1]).toBe( + '0143_authorization_revision_delete_compatibility' + ) + await assertDbReady(isolatedPool) + + const afterUpgrade = await versions(isolatedPool) + const secondRun = await withMigrationApplyObserver( + new Set(PR1_MIGRATION_VERSIONS), + async () => initDb({ connect: () => isolatedPool.connect() }) + ) + expect(await versions(isolatedPool)).toEqual(afterUpgrade) + expect([...secondRun.applyCounts.values()]).toEqual([]) + await assertDbReady(isolatedPool) + } finally { + await endPoolAndWaitForClients(isolatedPool) + } + }) + it('keeps legacy team-member payloads compatible with revision triggers', async () => { const userId = randomUUID() const teamId = randomUUID() diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index 40918ead06..a2133e16a1 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -30,9 +30,9 @@ const FRESH_TABLE_INDEXES = Object.freeze([ 'invitation_delivery_commands_invitation_idx', ]) -const USER_ACCESS_FOUNDATION_VERSION = '0126_user_access_foundation' +const USER_ACCESS_FOUNDATION_VERSION = '0129_user_access_foundation' const AUTHORIZATION_REVISION_COMPATIBILITY_VERSION = - '0138_authorization_revision_delete_compatibility' + '0143_authorization_revision_delete_compatibility' function expectedMigrationExecutionOrder(versions: readonly string[]): string[] { const withoutCompatibility = versions.filter( @@ -45,8 +45,8 @@ function expectedMigrationExecutionOrder(versions: readonly string[]): string[] } describe('D34 migration execution policy', () => { - it('classifies inherited parent migrations before the re-slotted PR1 migrations', () => { - expect(DEV_POST_0106_MIGRATION_VERSIONS.slice(-7)).toEqual([ + it('classifies current-dev migrations before the re-slotted PR1 migrations', () => { + expect(DEV_POST_0106_MIGRATION_VERSIONS.slice(-10)).toEqual([ '0119_dynamic_clients_table', '0120_dynamic_clients_runtime_access', '0121_oauth_install_identity', @@ -54,6 +54,19 @@ describe('D34 migration execution policy', () => { '0123_entity_change_checkpoint_cursor_convergence', '0124_entity_change_definer_search_path', '0125_admin_subscription_rate_limit_namespace', + '0126_bug192_password_admission', + '0127_password_evaluation_retention', + '0128_password_work_ownership', + ]) + expect(PR1_MIGRATION_VERSIONS).toEqual([ + '0129_user_access_foundation', + '0130_invitation_delivery_commands', + '0131_catalog_utf8_ordering', + '0132_composable_catalog_revisions', + '0133_gfs_catalog_revision_components', + '0134_user_access_foundation_definer_temp_shadow_hardening', + '0135_legacy_password_security_epoch_backfill', + '0143_authorization_revision_delete_compatibility', ]) expect(PR1_MIGRATION_VERSIONS).not.toContain('0117_control_admin_invitation_replace_inviter') expect(PR1_MIGRATION_VERSIONS).not.toContain('0118_control_admin_replace_inviter_accept_guard') @@ -77,10 +90,10 @@ describe('D34 migration execution policy', () => { expect(PR1_ONLINE_INDEX_PLAN).toHaveLength(25) expect(new Set(PR1_ONLINE_INDEX_PLAN.map(index => index.name))).toHaveLength(25) expect( - PR1_ONLINE_INDEX_PLAN.filter(index => index.migrationVersion.startsWith('0126')) + PR1_ONLINE_INDEX_PLAN.filter(index => index.migrationVersion.startsWith('0129')) ).toHaveLength(18) expect( - PR1_ONLINE_INDEX_PLAN.filter(index => index.migrationVersion.startsWith('0128')) + PR1_ONLINE_INDEX_PLAN.filter(index => index.migrationVersion.startsWith('0131')) ).toHaveLength(7) expect( PR1_ONLINE_INDEX_PLAN.some(index => index.name.startsWith('external_user_sessions_')) @@ -390,7 +403,7 @@ describe('D34 PR1 migration runner', () => { expect(recorded).toEqual(expectedOrder) }) - it('commits 0126 and its deletion-compatible successor as one migration phase', async () => { + it('commits 0129 and its deletion-compatible successor as one migration phase', async () => { const events: string[] = [] const transactionEvents: string[] = [] let activeTransaction = 0 @@ -480,7 +493,7 @@ describe('D34 PR1 migration runner', () => { ) }) - it('repairs an applied 0126 prefix before preparing 0128 indexes', async () => { + it('repairs an applied 0129 prefix before preparing 0131 indexes', async () => { const events: string[] = [] const migrations = [ ...DEV_POST_0106_MIGRATION_VERSIONS.map(version => ({ @@ -502,7 +515,7 @@ describe('D34 PR1 migration runner', () => { events.push(sql) } if (sql.includes('FROM pg_class index_rel')) { - events.push('prepare:0128') + events.push('prepare:0131') const entry = PR1_ONLINE_INDEX_PLAN.find(index => index.name === values?.[0]) return { rows: entry @@ -528,8 +541,8 @@ describe('D34 PR1 migration runner', () => { ...PR1_MIGRATION_VERSIONS.filter( version => version !== USER_ACCESS_FOUNDATION_VERSION && - version !== '0127_invitation_delivery_commands' && - version !== '0128_catalog_utf8_ordering' && + version !== '0130_invitation_delivery_commands' && + version !== '0131_catalog_utf8_ordering' && version !== AUTHORIZATION_REVISION_COMPATIBILITY_VERSION ), ]) @@ -544,18 +557,18 @@ describe('D34 PR1 migration runner', () => { }) expect(events.indexOf(AUTHORIZATION_REVISION_COMPATIBILITY_VERSION)).toBeLessThan( - events.indexOf('prepare:0128') + events.indexOf('prepare:0131') ) expect(events.indexOf(`receipt:${AUTHORIZATION_REVISION_COMPATIBILITY_VERSION}`)).toBeLessThan( - events.indexOf('prepare:0128') + events.indexOf('prepare:0131') ) - expect(events.filter(event => event === 'BEGIN')).toHaveLength(3) - expect(events.filter(event => event === 'COMMIT')).toHaveLength(3) + expect(events.filter(event => event === 'BEGIN')).toHaveLength(4) + expect(events.filter(event => event === 'COMMIT')).toHaveLength(4) const repairCommit = events.indexOf('COMMIT') - expect(repairCommit).toBeLessThan(events.indexOf('prepare:0128')) + expect(repairCommit).toBeLessThan(events.indexOf('prepare:0131')) }) - it('preserves the prior foundation alias when its 0138 successor is already recorded', async () => { + it('preserves the prior foundation alias when its 0143 successor is already recorded', async () => { const foundation = { version: USER_ACCESS_FOUNDATION_VERSION, legacyVersions: ['0109_user_access_foundation'], @@ -600,7 +613,65 @@ describe('D34 PR1 migration runner', () => { expect(appliedVersions).toContain(USER_ACCESS_FOUNDATION_VERSION) }) - it('fails closed when 0138 is recorded without the 0126 prerequisite', async () => { + it('reconciles displaced foundation and compatibility receipts without replaying either body', async () => { + const foundation = { + version: USER_ACCESS_FOUNDATION_VERSION, + legacyVersions: ['0126_user_access_foundation'], + apply: vi.fn(async () => undefined), + } + const compatibility = { + version: AUTHORIZATION_REVISION_COMPATIBILITY_VERSION, + legacyVersions: ['0138_authorization_revision_delete_compatibility'], + apply: vi.fn(async () => undefined), + } + const migrations = [ + ...DEV_POST_0106_MIGRATION_VERSIONS.map(version => ({ + version, + apply: vi.fn(async () => undefined), + })), + ...PR1_MIGRATION_VERSIONS.filter( + version => + version !== USER_ACCESS_FOUNDATION_VERSION && + version !== AUTHORIZATION_REVISION_COMPATIBILITY_VERSION + ).map(version => ({ version, apply: vi.fn(async () => undefined) })), + foundation, + compatibility, + ] + const appliedVersions = new Set([ + ...DEV_POST_0106_MIGRATION_VERSIONS, + ...PR1_MIGRATION_VERSIONS.filter( + version => + version !== USER_ACCESS_FOUNDATION_VERSION && + version !== AUTHORIZATION_REVISION_COMPATIBILITY_VERSION + ), + '0126_user_access_foundation', + '0138_authorization_revision_delete_compatibility', + ]) + const recorded: string[] = [] + + await applyPendingPr1Migrations({ + db: { query: vi.fn(async () => ({ rows: [], rowCount: 0 })) }, + migrations, + appliedVersions, + recordMigration: async (_db, version) => { + recorded.push(version) + }, + }) + + expect(recorded).toEqual([ + USER_ACCESS_FOUNDATION_VERSION, + AUTHORIZATION_REVISION_COMPATIBILITY_VERSION, + ]) + expect(foundation.apply).not.toHaveBeenCalled() + expect(compatibility.apply).not.toHaveBeenCalled() + expect(appliedVersions).toContain(USER_ACCESS_FOUNDATION_VERSION) + expect(appliedVersions).toContain(AUTHORIZATION_REVISION_COMPATIBILITY_VERSION) + }) + + it.each([ + AUTHORIZATION_REVISION_COMPATIBILITY_VERSION, + '0138_authorization_revision_delete_compatibility', + ])('fails closed when %s is recorded without its 0129 prerequisite', async receipt => { await expect( applyPendingPr1Migrations({ db: { query: vi.fn(async () => ({ rows: [], rowCount: 0 })) }, @@ -611,13 +682,14 @@ describe('D34 PR1 migration runner', () => { })), ...PR1_MIGRATION_VERSIONS.map(version => ({ version, + legacyVersions: + version === AUTHORIZATION_REVISION_COMPATIBILITY_VERSION + ? ['0138_authorization_revision_delete_compatibility'] + : undefined, apply: vi.fn(async () => undefined), })), ], - appliedVersions: new Set([ - ...DEV_POST_0106_MIGRATION_VERSIONS, - AUTHORIZATION_REVISION_COMPATIBILITY_VERSION, - ]), + appliedVersions: new Set([...DEV_POST_0106_MIGRATION_VERSIONS, receipt]), recordMigration: vi.fn(async () => undefined), }) ).rejects.toThrow('recorded before its prerequisite') diff --git a/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts b/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts index 2a8ad6e87e..3cbf2ec6c3 100644 --- a/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts +++ b/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts @@ -142,7 +142,7 @@ describeRealPostgres('D34 previous-image writer compatibility on real PostgreSQL expect( await databasePool.query( `SELECT 1 FROM schema_migrations - WHERE version = '0138_authorization_revision_delete_compatibility'` + WHERE version = '0143_authorization_revision_delete_compatibility'` ) ).toMatchObject({ rowCount: 1 }) }) @@ -234,7 +234,7 @@ describeRealPostgres('D34 previous-image writer compatibility on real PostgreSQL await databasePool.query( `DELETE FROM schema_migrations - WHERE version = '0138_authorization_revision_delete_compatibility'` + WHERE version = '0143_authorization_revision_delete_compatibility'` ) await initDb({ connect: () => databasePool.connect() }) @@ -283,7 +283,7 @@ describeRealPostgres('D34 previous-image writer compatibility on real PostgreSQL expect( await databasePool.query( `SELECT 1 FROM schema_migrations - WHERE version = '0138_authorization_revision_delete_compatibility'` + WHERE version = '0143_authorization_revision_delete_compatibility'` ) ).toMatchObject({ rowCount: 1 }) }) From 46a14bf9f49c6667575d54e3a581969fc032f438 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:20:58 -0600 Subject: [PATCH 291/528] fix(control-api): r61-b2 accept opaque login error values Class-check: sendExternalLoginError -> 2 callers; exact sentinel and fixed fallback retained. Repro-test: clean Control API build fails at 53bc96f2b57ecb846f3ced29947de5b90568af33 with TS2345 at control-api/src/routes/external/auth.ts:218:46. --- control-api/src/routes/external/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control-api/src/routes/external/auth.ts b/control-api/src/routes/external/auth.ts index 3af3e7c72b..a817eb7bd4 100644 --- a/control-api/src/routes/external/auth.ts +++ b/control-api/src/routes/external/auth.ts @@ -91,7 +91,7 @@ function requireLegacySessionTokenPayload(req: Request, res: Response, next: Nex next() } -function sendExternalLoginError(res: Response, error: string | undefined): Response { +function sendExternalLoginError(res: Response, error: unknown): Response { if (error === 'password_not_set') { return res.status(409).json({ error: 'password_not_set' }) } From 2e80afda1fc083cc6296230d4c800768b5ff64bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:14:49 -0600 Subject: [PATCH 292/528] fix(control-api): address r61-m3 duplicate password admission Finding-ID: R61-M3 Class-check: generic authentication_attempt registration -> 2 routes (Google, password); retain Google generic. Repro-test: password-login RealPG cross-service third attempt fails at 46a14bf with duplicate-charge 429. --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- control-api/src/routes/external/auth.ts | 1 - .../bug192.passwordLogin.realPostgres.integration.test.ts | 7 +++++++ 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 092d6afeed..e202849e83 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.738", + "version": "0.1.739", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.738", + "version": "0.1.739", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 2b01bf3823..38771b118b 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.738", + "version": "0.1.739", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/routes/external/auth.ts b/control-api/src/routes/external/auth.ts index a817eb7bd4..3ac2a7a334 100644 --- a/control-api/src/routes/external/auth.ts +++ b/control-api/src/routes/external/auth.ts @@ -189,7 +189,6 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { '/external/auth/password-login', validatePasswordLogin, passwordLoginSourceAdmission, - rateLimitMiddleware(externalUserRateLimitOptions('authentication_attempt', 'pre_auth')), async (req, res, next) => { try { const email = String(req.body?.email || '') diff --git a/control-api/test/bug192.passwordLogin.realPostgres.integration.test.ts b/control-api/test/bug192.passwordLogin.realPostgres.integration.test.ts index f5243eb3f0..dd1882a6f4 100644 --- a/control-api/test/bug192.passwordLogin.realPostgres.integration.test.ts +++ b/control-api/test/bug192.passwordLogin.realPostgres.integration.test.ts @@ -367,6 +367,13 @@ realPg('BUG-192 observable password-login regressions on real PostgreSQL', () => body: { error: 'invalid_credentials' }, }) } + const sourceCharges = await holder.pool.query( + `SELECT COALESCE(sum(count), 0)::int AS count + FROM rate_limit_buckets + WHERE bucket_key = $1`, + ['external_authentication_attempt:ip:192.0.2.123'] + ) + expect(sourceCharges.rows[0].count).toBe(3) const limited = await request(edge) .post('/api/v1/auth/password-login') .set('X-Forwarded-For', '192.0.2.124') From 2867b5b4711e86dcf51119f27956517b698c05d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:18:27 -0600 Subject: [PATCH 293/528] fix(control-api): align internal password-login assertions Finding-ID: R61-L1 Class-check: direct password-login denial assertions -> 7 cases; keep public REST mapping separate. Repro-test: BUG-192 login RealPG fails at 46a14bf (401 Unauthorized vs invalid_credentials). --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- .../bug192.passwordLogin.realPostgres.integration.test.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index e202849e83..df33914910 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.739", + "version": "0.1.740", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.739", + "version": "0.1.740", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 38771b118b..f03e556c03 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.739", + "version": "0.1.740", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/bug192.passwordLogin.realPostgres.integration.test.ts b/control-api/test/bug192.passwordLogin.realPostgres.integration.test.ts index dd1882a6f4..8c661a69cf 100644 --- a/control-api/test/bug192.passwordLogin.realPostgres.integration.test.ts +++ b/control-api/test/bug192.passwordLogin.realPostgres.integration.test.ts @@ -128,7 +128,7 @@ realPg('BUG-192 observable password-login regressions on real PostgreSQL', () => const response = await login(email) expect({ status: response.status, body: response.body }).toEqual({ status: 401, - body: { error: 'invalid_credentials' }, + body: { error: 'Unauthorized' }, }) } ) @@ -150,7 +150,7 @@ realPg('BUG-192 observable password-login regressions on real PostgreSQL', () => await holder.pool.query('UPDATE users SET password_hash = $1 WHERE id = $2', [hash, userId]) const response = await login('member@example.invalid', password) expect(response.status).toBe(401) - expect(response.body).toEqual({ error: 'invalid_credentials' }) + expect(response.body).toEqual({ error: 'Unauthorized' }) expect(compare).toHaveBeenCalledTimes(1) expect(compare.mock.calls[0][1]).not.toBe(hash) expect(bcrypt.getRounds(compare.mock.calls[0][1] as string)).toBe(12) From aa0e03b602dd2b0349d76efb1d948d60c829b61e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:20:12 -0600 Subject: [PATCH 294/528] fix(control-api): use issued token in password persistence test Finding-ID: R61-L2 Class-check: setInvitationPasswordForEmail -> 7 sites -> repair sole 3-arg fixture via issuer. Repro-test: BUG-192 persistence RealPG failed pre-SQL at 46a14bf; repaired suite passes 16/16. --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- ...rsistence.realPostgres.integration.test.ts | 22 +++++++++++-------- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index df33914910..d8062d02cb 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.740", + "version": "0.1.741", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.740", + "version": "0.1.741", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index f03e556c03..fedcb33201 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.740", + "version": "0.1.741", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/bug192.passwordPersistence.realPostgres.integration.test.ts b/control-api/test/bug192.passwordPersistence.realPostgres.integration.test.ts index 688c22ef07..857019c0ff 100644 --- a/control-api/test/bug192.passwordPersistence.realPostgres.integration.test.ts +++ b/control-api/test/bug192.passwordPersistence.realPostgres.integration.test.ts @@ -17,6 +17,7 @@ import { verifyMemberPassword, } from '../src/services/auth/passwordCredentialVerification.js' import { + createInvitationForTeams, setInvitationPasswordForEmail, updateUserPassword, } from '../src/services/directory/membership.js' @@ -26,6 +27,7 @@ const holder = vi.hoisted(() => ({ pool: null as unknown as Pool, leaseClient: null as unknown as import('pg').PoolClient, })) +const registration = vi.hoisted(() => ({ registerAndSendInvitation: vi.fn() })) vi.mock('../src/db.js', async importOriginal => { const real = await importOriginal() const proxy = { @@ -49,6 +51,7 @@ vi.mock('../src/services/rateLimiterService.js', async importOriginal => { ) => real.acquireRateLimitConcurrencyLease(r, { client: holder.leaseClient }), } }) +vi.mock('../src/services/invitationFlowRegistrationService.js', () => registration) const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const realPg = adminUrl ? describe : describe.skip realPg('Spec 043 persistence, migration, atomicity and fencing design acceptance', () => { @@ -65,6 +68,7 @@ realPg('Spec 043 persistence, migration, atomicity and fencing design acceptance holder.pool = new Pool({ connectionString }) await initDb(holder.pool) holder.leaseClient = await holder.pool.connect() + registration.registerAndSendInvitation.mockResolvedValue(undefined) hash = await bcrypt.hash(password, 12) }, 60_000) beforeEach(async () => { @@ -231,17 +235,17 @@ realPg('Spec 043 persistence, migration, atomicity and fencing design acceptance it('real reset/establishment producer clears cooldown and fences old work', async () => { const stale = await capturePasswordEvaluation(email, false) await completedFailures() - // Database producer for a pending reset; the real reset function owns the - // credential write/acceptance and its generation trigger in one transaction. - const invitation = ( - await holder.pool.query( - "INSERT INTO invitations(email,role,purpose,status) VALUES ($1,'member','password_reset','pending') RETURNING id", - [email] - ) - ).rows[0] + const invitation = await createInvitationForTeams({ + inviteeName: 'Password reset', + email, + purpose: 'password_reset', + teamAssignments: [], + fallbackRole: 'member', + }) const result = await setInvitationPasswordForEmail( email, - invitation.id, + invitation.token, + String(invitation.id), 'Synthetic-BUG192-reset-password' ) expect(result).not.toHaveProperty('error') From f29e1a3adb56c2c935022518110bfdad5e1e0bcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:59:32 -0600 Subject: [PATCH 295/528] fix(control-api): r61-l3 isolate pacing state between race tests Finding-ID: R61-L3 Class-check: password_verification_pace in RealPG suites -> 8 files; reset the shared-DB race suite; other seven reset state or use per-test databases. Repro-test: control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts (fails at aa0e03b602dd2b0349d76efb1d948d60c829b61e: 3 real admission 429s) --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- ...essionExchangeRace.realPostgres.integration.test.ts | 10 ++++++++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index d8062d02cb..440d297032 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.741", + "version": "0.1.742", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.741", + "version": "0.1.742", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index fedcb33201..0036854a43 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.741", + "version": "0.1.742", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts b/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts index c045f55e42..07bb785497 100644 --- a/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts +++ b/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import bcrypt from 'bcryptjs' import { createHash, randomBytes, randomUUID } from 'node:crypto' import { Pool, type PoolClient } from 'pg' @@ -27,7 +27,7 @@ import { verifyUserSessionV2Token } from '../src/utils/auth/userSessionV2Token.j import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL -const describeRealPostgres = adminUrl ? describe : describe.skip +const describeRealPostgres = adminUrl ? describe.sequential : describe.skip const runtimeRoles = [ 'control_api_runtime', 'trace_maintenance_runtime', @@ -246,6 +246,12 @@ describeRealPostgres('legacy replacement exchange serialization on real PostgreS } }) + beforeEach(async () => { + // This suite owns one disposable database and serializes its cases. Reset + // only the shared admission pace after the prior case has fully settled. + await databasePool.query('DELETE FROM password_verification_pace') + }) + it.each(['normal password change', 'password reset'] as const)( 'fails %s exchange when invalidation wins the lock order', async label => { From 8aff21a54414a7b0e7388496020156c225bc6450 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:55:31 -0600 Subject: [PATCH 296/528] fix(deploy): enforce WRC effective column access The production Workflow Recipes verifier now accepts only the owner-approved SELECT column exceptions. Direct, inherited, and PUBLIC column permissions are compared against the exact runtime policy. Class-check: WRC verifier -> 1 production call site; Control API and trace-maintenance verifiers remain separate. Repro-test: control-api/test/db.realPostgresMigration.integration.test.ts fails at 4f5749061dcef5955563c056a386ba4fe5b5da21 because the old parser accepts an unsupported manifest privilege; corrected suite passes 70/70. --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- ....realPostgresMigration.integration.test.ts | 475 +++++++++++++++++- .../scripts/run-control-api-db-migration.sh | 105 +++- ...rkflow-recipes-runtime-access-profiles.tsv | 107 ++++ ...recipes-runtime-column-access-profiles.tsv | 7 + 6 files changed, 689 insertions(+), 11 deletions(-) create mode 100644 deploy/scripts/workflow-recipes-runtime-column-access-profiles.tsv diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 6b4c1ff56c..7598627b7f 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.743", + "version": "0.1.745", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.743", + "version": "0.1.745", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index f6c177d2c1..d7791d50f8 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.743", + "version": "0.1.745", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/db.realPostgresMigration.integration.test.ts b/control-api/test/db.realPostgresMigration.integration.test.ts index 4db3e6f5eb..f21d23d672 100644 --- a/control-api/test/db.realPostgresMigration.integration.test.ts +++ b/control-api/test/db.realPostgresMigration.integration.test.ts @@ -2,7 +2,9 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' import express, { type Request as ExpressRequest } from 'express' import { execFileSync } from 'node:child_process' import { randomBytes, randomUUID } from 'node:crypto' -import { readFileSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { Pool, type PoolClient } from 'pg' import request from 'supertest' @@ -195,6 +197,126 @@ verify_trace_maintenance_access_contract`, return queries } +type WorkflowRecipesVerifierResult = { passed: true } | { passed: false; stderr: string } + +function runWorkflowRecipesRuntimeVerifier( + databaseConnectionString: string, + columnProfilePath?: string, + relationProfilePath?: string +): WorkflowRecipesVerifierResult { + const script = readFileSync( + new URL('../../deploy/scripts/run-control-api-db-migration.sh', import.meta.url), + 'utf8' + ) + const start = script.indexOf('\nworkflow_recipes_access_contract_values() {') + const end = script.indexOf('\nverify_db_migration_state() {', start) + if (start < 0 || end < 0) throw new Error('Workflow Recipes deployment verifier is missing') + + // Execute the exact production parser and verifier function. Only its database + // assertion adapter is replaced so each emitted production query runs against + // this test's real PostgreSQL database without entering Kubernetes operations. + const productionFunctions = script.slice(start, end) + try { + execFileSync( + 'bash', + [ + '-c', + `set -euo pipefail +WORKFLOW_RECIPES_ACCESS_PROFILES_FILE="$1" +WORKFLOW_RECIPES_COLUMN_ACCESS_PROFILES_FILE="$2" +die() { printf '%s\\n' "$*" >&2; exit 1; } +assert_db_query_equals() { + local description="$1" expected="$2" sql="$3" actual + actual="$(psql "$CONTROL_API_REAL_PG_ADMIN_URL" -v ON_ERROR_STOP=1 -At -c "$sql")" \\ + || die "\${description} (production verifier query failed)" + if [[ "$actual" != "$expected" ]]; then + die "\${description} (expected \${expected}, got '\${actual:-}')" + fi +} +${productionFunctions} +verify_workflow_recipes_runtime_boundary`, + 'workflow-recipes-production-verifier', + relationProfilePath ?? + fileURLToPath( + new URL( + '../../deploy/scripts/workflow-recipes-runtime-access-profiles.tsv', + import.meta.url + ) + ), + columnProfilePath ?? + fileURLToPath( + new URL( + '../../deploy/scripts/workflow-recipes-runtime-column-access-profiles.tsv', + import.meta.url + ) + ), + ], + { + encoding: 'utf8', + timeout: 20_000, + env: { ...process.env, CONTROL_API_REAL_PG_ADMIN_URL: databaseConnectionString }, + } + ) + return { passed: true } + } catch (error) { + const failure = error as NodeJS.ErrnoException & { + status?: number | null + stderr?: Buffer | string + } + if (failure.code === 'ETIMEDOUT' || failure.status === null || failure.status === undefined) { + throw new Error('Workflow Recipes production verifier did not complete', { cause: error }) + } + return { + passed: false, + stderr: Buffer.isBuffer(failure.stderr) + ? failure.stderr.toString('utf8') + : String(failure.stderr ?? ''), + } + } +} + +function expectWorkflowRecipesVerifierRejects( + databaseConnectionString: string, + message: string, + columnProfilePath?: string, + relationProfilePath?: string +): void { + const result = runWorkflowRecipesRuntimeVerifier( + databaseConnectionString, + columnProfilePath, + relationProfilePath + ) + expect(result.passed).toBe(false) + if (!result.passed) expect(result.stderr).toContain(message) +} + +const workflowRecipesCleanupRelations = [ + 'team_workflow_triggers', + 'user_workflow_triggers', + 'workflow_recipe_allowed_teams', +] as const +const workflowRecipesCleanupColumns = workflowRecipesCleanupRelations.flatMap(relation => + (['recipe_namespace', 'recipe_name'] as const).map(column => ({ relation, column })) +) +const workflowRecipesColumnPrincipals = ['direct', 'inherited', 'public'] as const +const workflowRecipesUnexpectedSelectFixtures = workflowRecipesCleanupRelations.flatMap(relation => + workflowRecipesColumnPrincipals.map(principal => ({ + relation, + column: relation === 'user_workflow_triggers' ? 'user_id' : 'team_id', + principal, + })) +) +const workflowRecipesUnexpectedPrivilegeFixtures = ( + ['INSERT', 'UPDATE', 'REFERENCES'] as const +).flatMap(privilege => + workflowRecipesColumnPrincipals.map(principal => ({ + relation: 'team_workflow_triggers', + column: 'team_id', + privilege, + principal, + })) +) + const deniedRuntimeColumnPrivileges = [ { privilege: 'SELECT', relation: 'gfs_blob_manifests', column: 'blob_key' }, { privilege: 'INSERT', relation: 'gfs_blob_manifests', column: 'blob_key' }, @@ -308,15 +430,366 @@ describeRealPostgres('control-api real Postgres migrations', () => { } }) + const workflowRecipesColumnVerifierMessage = + 'workflow_recipes_runtime effective column privileges differ from the explicit access contract' + + async function ensureMigratedSchema(): Promise { + const { initDb } = await import('../src/db.js') + await initDb({ connect: () => dbPool.connect() }) + } + + it('executes the production Workflow Recipes verifier on the clean migrated schema', async () => { + await ensureMigratedSchema() + + const tableAccess = await dbPool.query<{ select_allowed: boolean; column_select: boolean }>(` + SELECT has_table_privilege( + 'workflow_recipes_runtime', 'public.workflow_runs', 'SELECT' + ) AS select_allowed, + has_column_privilege( + 'workflow_recipes_runtime', 'public.workflow_runs', 'run_id', 'SELECT' + ) AS column_select + `) + expect(tableAccess.rows).toEqual([{ select_allowed: true, column_select: true }]) + expect(runWorkflowRecipesRuntimeVerifier(connectionString)).toEqual({ passed: true }) + }, 60_000) + + it.each(workflowRecipesCleanupColumns)( + 'rejects missing required SELECT on $relation column $column', + async ({ relation, column }) => { + await ensureMigratedSchema() + const relationName = quoteIdent(relation) + const columnName = quoteIdent(column) + const revoke = `REVOKE SELECT (${columnName}) ON TABLE ${relationName} FROM workflow_recipes_runtime` + const grant = `GRANT SELECT (${columnName}) ON TABLE ${relationName} TO workflow_recipes_runtime` + + await dbPool.query(revoke) + try { + const effective = await dbPool.query<{ allowed: boolean }>( + `SELECT has_column_privilege('workflow_recipes_runtime', $1, $2, 'SELECT') AS allowed`, + [relation, column] + ) + expect(effective.rows).toEqual([{ allowed: false }]) + expectWorkflowRecipesVerifierRejects(connectionString, workflowRecipesColumnVerifierMessage) + } finally { + await dbPool.query(grant) + } + expect(runWorkflowRecipesRuntimeVerifier(connectionString)).toEqual({ passed: true }) + } + ) + + it.each(workflowRecipesUnexpectedSelectFixtures)( + 'rejects effective $principal SELECT($column) on $relation', + async ({ relation, column, principal }) => { + await ensureMigratedSchema() + const relationName = quoteIdent(relation) + const columnName = quoteIdent(column) + const inheritedRole = `wrc_column_acl_probe_${randomBytes(6).toString('hex')}` + const inherited = principal === 'inherited' + const grantee = principal === 'public' ? 'PUBLIC' : 'workflow_recipes_runtime' + let roleCreated = false + let privilegeGranted = false + let membershipGranted = false + + try { + if (inherited) { + await dbPool.query(`CREATE ROLE ${quoteIdent(inheritedRole)} NOLOGIN INHERIT`) + roleCreated = true + } + const grantTarget = inherited ? quoteIdent(inheritedRole) : grantee + await dbPool.query( + `GRANT SELECT (${columnName}) ON TABLE ${relationName} TO ${grantTarget}` + ) + privilegeGranted = true + if (inherited) { + await dbPool.query(`GRANT ${quoteIdent(inheritedRole)} TO workflow_recipes_runtime`) + membershipGranted = true + } + + const effective = await dbPool.query<{ + table_access: boolean + column_access: boolean + }>( + `SELECT has_table_privilege('workflow_recipes_runtime', $1, 'SELECT') AS table_access, + has_column_privilege('workflow_recipes_runtime', $1, $2, 'SELECT') AS column_access`, + [relation, column] + ) + expect(effective.rows).toEqual([{ table_access: false, column_access: true }]) + expectWorkflowRecipesVerifierRejects(connectionString, workflowRecipesColumnVerifierMessage) + } finally { + if (membershipGranted) { + await dbPool.query(`REVOKE ${quoteIdent(inheritedRole)} FROM workflow_recipes_runtime`) + } + if (privilegeGranted) { + const grantTarget = inherited ? quoteIdent(inheritedRole) : grantee + await dbPool.query( + `REVOKE SELECT (${columnName}) ON TABLE ${relationName} FROM ${grantTarget}` + ) + } + if (roleCreated) await dbPool.query(`DROP ROLE ${quoteIdent(inheritedRole)}`) + } + expect(runWorkflowRecipesRuntimeVerifier(connectionString)).toEqual({ passed: true }) + } + ) + + it.each(workflowRecipesUnexpectedPrivilegeFixtures)( + 'rejects effective $principal column-level $privilege on $relation column $column', + async ({ relation, column, privilege, principal }) => { + await ensureMigratedSchema() + const relationName = quoteIdent(relation) + const columnName = quoteIdent(column) + const inheritedRole = `wrc_column_acl_probe_${randomBytes(6).toString('hex')}` + const inherited = principal === 'inherited' + const grantee = principal === 'public' ? 'PUBLIC' : 'workflow_recipes_runtime' + let roleCreated = false + let privilegeGranted = false + let membershipGranted = false + + try { + if (inherited) { + await dbPool.query(`CREATE ROLE ${quoteIdent(inheritedRole)} NOLOGIN INHERIT`) + roleCreated = true + } + const grantTarget = inherited ? quoteIdent(inheritedRole) : grantee + await dbPool.query( + `GRANT ${privilege} (${columnName}) ON TABLE ${relationName} TO ${grantTarget}` + ) + privilegeGranted = true + if (inherited) { + await dbPool.query(`GRANT ${quoteIdent(inheritedRole)} TO workflow_recipes_runtime`) + membershipGranted = true + } + + const effective = await dbPool.query<{ allowed: boolean }>( + `SELECT has_column_privilege('workflow_recipes_runtime', $1, $2, $3) AS allowed`, + [relation, column, privilege] + ) + expect(effective.rows).toEqual([{ allowed: true }]) + expectWorkflowRecipesVerifierRejects(connectionString, workflowRecipesColumnVerifierMessage) + } finally { + if (membershipGranted) { + await dbPool.query(`REVOKE ${quoteIdent(inheritedRole)} FROM workflow_recipes_runtime`) + } + if (privilegeGranted) { + const grantTarget = inherited ? quoteIdent(inheritedRole) : grantee + await dbPool.query( + `REVOKE ${privilege} (${columnName}) ON TABLE ${relationName} FROM ${grantTarget}` + ) + } + if (roleCreated) await dbPool.query(`DROP ROLE ${quoteIdent(inheritedRole)}`) + } + expect(runWorkflowRecipesRuntimeVerifier(connectionString)).toEqual({ passed: true }) + } + ) + + it.each(workflowRecipesColumnPrincipals)( + 'rejects unexpected $principal whole-table access and retains approved table profiles', + async principal => { + await ensureMigratedSchema() + expect(runWorkflowRecipesRuntimeVerifier(connectionString)).toEqual({ passed: true }) + + const inheritedRole = `wrc_table_acl_probe_${randomBytes(6).toString('hex')}` + const inherited = principal === 'inherited' + const grantee = principal === 'public' ? 'PUBLIC' : 'workflow_recipes_runtime' + let roleCreated = false + let privilegeGranted = false + let membershipGranted = false + + try { + if (inherited) { + await dbPool.query(`CREATE ROLE ${quoteIdent(inheritedRole)} NOLOGIN INHERIT`) + roleCreated = true + } + const grantTarget = inherited ? quoteIdent(inheritedRole) : grantee + await dbPool.query(`GRANT SELECT ON team_workflow_triggers TO ${grantTarget}`) + privilegeGranted = true + if (inherited) { + await dbPool.query(`GRANT ${quoteIdent(inheritedRole)} TO workflow_recipes_runtime`) + membershipGranted = true + } + expectWorkflowRecipesVerifierRejects( + connectionString, + 'workflow_recipes_runtime relation privileges differ from the explicit access contract' + ) + } finally { + if (membershipGranted) { + await dbPool.query(`REVOKE ${quoteIdent(inheritedRole)} FROM workflow_recipes_runtime`) + } + if (privilegeGranted) { + const grantTarget = inherited ? quoteIdent(inheritedRole) : grantee + await dbPool.query(`REVOKE SELECT ON team_workflow_triggers FROM ${grantTarget}`) + } + if (roleCreated) await dbPool.query(`DROP ROLE ${quoteIdent(inheritedRole)}`) + await dbPool.query( + 'GRANT SELECT (recipe_namespace, recipe_name) ON team_workflow_triggers TO workflow_recipes_runtime' + ) + } + expect(runWorkflowRecipesRuntimeVerifier(connectionString)).toEqual({ passed: true }) + } + ) + + it('rejects a newly created public relation absent from the explicit access inventory', async () => { + await ensureMigratedSchema() + const relation = `wrc_unclassified_probe_${randomBytes(6).toString('hex')}` + const relationName = quoteIdent(relation) + await dbPool.query(`CREATE TABLE ${relationName} (probe_id integer)`) + try { + expectWorkflowRecipesVerifierRejects( + connectionString, + 'workflow_recipes_runtime relation privileges differ from the explicit access contract' + ) + } finally { + await dbPool.query(`DROP TABLE ${relationName}`) + } + expect(runWorkflowRecipesRuntimeVerifier(connectionString)).toEqual({ passed: true }) + }) + + it('rejects access to a newly added column absent from the exception manifest', async () => { + await ensureMigratedSchema() + const column = `wrc_unclassified_probe_${randomBytes(6).toString('hex')}` + const columnName = quoteIdent(column) + await dbPool.query(`ALTER TABLE team_workflow_triggers ADD COLUMN ${columnName} text`) + try { + await dbPool.query( + `GRANT SELECT (${columnName}) ON team_workflow_triggers TO workflow_recipes_runtime` + ) + try { + expectWorkflowRecipesVerifierRejects(connectionString, workflowRecipesColumnVerifierMessage) + } finally { + await dbPool.query( + `REVOKE SELECT (${columnName}) ON team_workflow_triggers FROM workflow_recipes_runtime` + ) + } + } finally { + await dbPool.query(`ALTER TABLE team_workflow_triggers DROP COLUMN ${columnName}`) + } + expect(runWorkflowRecipesRuntimeVerifier(connectionString)).toEqual({ passed: true }) + }) + + it.each([ + { + label: 'unknown relation', + contents: + '# relationcolumneffective column privilege\nunknown_relation\tteam_id\tSELECT\n', + expected: workflowRecipesColumnVerifierMessage, + }, + { + label: 'unknown column', + contents: + '# relationcolumneffective column privilege\nteam_workflow_triggers\tunknown_column\tSELECT\n', + expected: workflowRecipesColumnVerifierMessage, + }, + { + label: 'table-wide privilege exception', + contents: + '# relationcolumneffective column privilege\nworkflow_runs\trun_id\tSELECT\n', + expected: workflowRecipesColumnVerifierMessage, + }, + { + label: 'malformed privilege', + contents: + '# relationcolumneffective column privilege\nteam_workflow_triggers\tteam_id\tALL\n', + expected: 'workflow-recipes column access profile file is malformed', + }, + { + label: 'duplicate policy row', + contents: + '# relationcolumneffective column privilege\nteam_workflow_triggers\trecipe_name\tSELECT\nteam_workflow_triggers\trecipe_name\tSELECT\n', + expected: 'workflow-recipes column access profile file is malformed', + }, + ])('fails closed for $label in the column policy manifest', async ({ contents, expected }) => { + await ensureMigratedSchema() + const temporaryDirectory = mkdtempSync(join(tmpdir(), 'workflow-recipes-column-policy-')) + const temporaryManifest = join(temporaryDirectory, 'column-access.tsv') + try { + writeFileSync(temporaryManifest, contents, 'utf8') + expectWorkflowRecipesVerifierRejects(connectionString, expected, temporaryManifest) + } finally { + rmSync(temporaryDirectory, { recursive: true, force: true }) + } + expect(runWorkflowRecipesRuntimeVerifier(connectionString)).toEqual({ passed: true }) + }) + + it.each(['INSERT', 'UPDATE', 'REFERENCES'] as const)( + 'rejects an unapproved %s column exception even when PostgreSQL grants it', + async privilege => { + await ensureMigratedSchema() + const temporaryDirectory = mkdtempSync(join(tmpdir(), 'workflow-recipes-column-policy-')) + const temporaryManifest = join(temporaryDirectory, 'column-access.tsv') + const relationName = quoteIdent('team_workflow_triggers') + const columnName = quoteIdent('team_id') + const productionManifest = readFileSync( + new URL( + '../../deploy/scripts/workflow-recipes-runtime-column-access-profiles.tsv', + import.meta.url + ), + 'utf8' + ) + + try { + writeFileSync( + temporaryManifest, + `${productionManifest}team_workflow_triggers\tteam_id\t${privilege}\n`, + 'utf8' + ) + await dbPool.query( + `GRANT ${privilege} (${columnName}) ON TABLE ${relationName} TO workflow_recipes_runtime` + ) + expectWorkflowRecipesVerifierRejects( + connectionString, + 'workflow-recipes column access profile file is malformed', + temporaryManifest + ) + } finally { + await dbPool.query( + `REVOKE ${privilege} (${columnName}) ON TABLE ${relationName} FROM workflow_recipes_runtime` + ) + rmSync(temporaryDirectory, { recursive: true, force: true }) + } + + expect(runWorkflowRecipesRuntimeVerifier(connectionString)).toEqual({ passed: true }) + } + ) + + it.each([ + { + label: 'malformed relation profile', + contents: '# public relationaccess profile\nnot-a-valid-profile\n', + }, + { + label: 'duplicate relation profile', + contents: + '# public relationaccess profile\nteam_workflow_triggers\tdelete\nteam_workflow_triggers\tnone\n', + }, + ])('fails closed for $label declarations', async ({ contents }) => { + await ensureMigratedSchema() + const temporaryDirectory = mkdtempSync(join(tmpdir(), 'workflow-recipes-relation-policy-')) + const temporaryManifest = join(temporaryDirectory, 'relation-access.tsv') + try { + writeFileSync(temporaryManifest, contents, 'utf8') + expectWorkflowRecipesVerifierRejects( + connectionString, + 'workflow-recipes access profile file is malformed', + undefined, + temporaryManifest + ) + } finally { + rmSync(temporaryDirectory, { recursive: true, force: true }) + } + expect(runWorkflowRecipesRuntimeVerifier(connectionString)).toEqual({ passed: true }) + }) + it('verifies the deployment access contract against migrated schema and rejects drift', async () => { const { initDb } = await import('../src/db.js') await initDb({ connect: () => dbPool.connect() }) const [relationQuery, sequenceQuery] = deploymentRuntimeAccessQueries() + const traceMaintenanceQueries = traceMaintenanceRuntimeAccessQueries() const violations = async (sql: string) => Number((await dbPool.query({ text: sql, rowMode: 'array' })).rows[0][0]) expect(await violations(relationQuery)).toBe(0) expect(await violations(sequenceQuery)).toBe(0) + for (const query of traceMaintenanceQueries) expect(await violations(query)).toBe(0) + expect(runWorkflowRecipesRuntimeVerifier(connectionString)).toEqual({ passed: true }) const privileges = await relationPrivileges(dbPool, 'control_api_runtime') for (const relation of ['password_identifier_state', 'password_verification_pace']) { expectPrivileges([...privileges[relation]], ['SELECT', 'INSERT', 'UPDATE', 'DELETE']) diff --git a/deploy/scripts/run-control-api-db-migration.sh b/deploy/scripts/run-control-api-db-migration.sh index ca95d3aa0c..9f0e988a65 100755 --- a/deploy/scripts/run-control-api-db-migration.sh +++ b/deploy/scripts/run-control-api-db-migration.sh @@ -10,6 +10,7 @@ MAINTENANCE_ACCESS_PROFILES_FILE="${SCRIPT_DIR}/trace-maintenance-runtime-access MAINTENANCE_SEQUENCE_ACCESS_PROFILES_FILE="${SCRIPT_DIR}/trace-maintenance-runtime-sequence-access-profiles.tsv" MAINTENANCE_FUNCTION_ACCESS_PROFILES_FILE="${SCRIPT_DIR}/trace-maintenance-runtime-function-access-profiles.tsv" WORKFLOW_RECIPES_ACCESS_PROFILES_FILE="${SCRIPT_DIR}/workflow-recipes-runtime-access-profiles.tsv" +WORKFLOW_RECIPES_COLUMN_ACCESS_PROFILES_FILE="${SCRIPT_DIR}/workflow-recipes-runtime-column-access-profiles.tsv" CONTEXT="${CONTEXT:?set CONTEXT to the target kube-context}" ALLOWED_CONTEXTS="${ALLOWED_CONTEXTS:?set ALLOWED_CONTEXTS to an exact comma-separated context allowlist}" @@ -796,7 +797,7 @@ workflow_recipes_access_contract_values() { /^[[:space:]]*#/ || /^[[:space:]]*$/ { next } NF != 2 { exit 2 } $1 !~ /^[a-z][a-z0-9_]*$/ { exit 3 } - $2 !~ /^(legacy_dml|read_update|upsert|delete)$/ { exit 4 } + $2 !~ /^(legacy_dml|read|read_update|upsert|delete|none)$/ { exit 4 } seen[$1]++ { exit 5 } { count++ @@ -807,6 +808,28 @@ workflow_recipes_access_contract_values() { die "workflow-recipes access profile file is malformed or contains duplicate relations: $WORKFLOW_RECIPES_ACCESS_PROFILES_FILE" } +workflow_recipes_column_access_contract_values() { + [ -f "$WORKFLOW_RECIPES_COLUMN_ACCESS_PROFILES_FILE" ] || \ + die "workflow-recipes column access profile file not found: $WORKFLOW_RECIPES_COLUMN_ACCESS_PROFILES_FILE" + + awk -F '\t' ' + BEGIN { count = 0 } + /^[[:space:]]*#/ || /^[[:space:]]*$/ { next } + NF != 3 { exit 2 } + $1 !~ /^[a-z][a-z0-9_]*$/ { exit 3 } + $2 !~ /^[a-z][a-z0-9_]*$/ { exit 4 } + $3 !~ /^SELECT$/ { exit 5 } + { + key = $1 SUBSEP $2 SUBSEP $3 + if (seen[key]++) exit 6 + count++ + printf "%s(\047%s\047, \047%s\047, \047%s\047)", (count == 1 ? "" : ",\n"), $1, $2, $3 + } + END { if (count == 0) exit 7 } + ' "$WORKFLOW_RECIPES_COLUMN_ACCESS_PROFILES_FILE" || \ + die "workflow-recipes column access profile file is malformed or contains duplicate privileges: $WORKFLOW_RECIPES_COLUMN_ACCESS_PROFILES_FILE" +} + verify_runtime_access_contract() { local expected_values expected_sequence_values expected_values="$(runtime_access_contract_values)" @@ -1041,8 +1064,9 @@ verify_trace_maintenance_access_contract() { } verify_workflow_recipes_runtime_boundary() { - local expected_values + local expected_values expected_column_values expected_values="$(workflow_recipes_access_contract_values)" + expected_column_values="$(workflow_recipes_column_access_contract_values)" assert_db_query_equals \ "workflow_recipes_runtime relation privileges differ from the explicit access contract" \ @@ -1055,13 +1079,13 @@ verify_workflow_recipes_runtime_boundary() { FROM pg_class relation JOIN pg_namespace namespace ON namespace.oid = relation.relnamespace WHERE namespace.nspname = 'public' - AND relation.relkind IN ('r', 'p', 'v', 'm') + AND relation.relkind IN ('r', 'p', 'v', 'm', 'f') ), relation_coverage_violations AS ( - SELECT expected.relation_name + SELECT COALESCE(expected.relation_name, actual.relation_name) AS relation_name FROM expected_access expected - LEFT JOIN actual_relations actual USING (relation_name) - WHERE actual.relation_name IS NULL + FULL OUTER JOIN actual_relations actual USING (relation_name) + WHERE expected.relation_name IS NULL OR actual.relation_name IS NULL ), privilege_violations AS ( SELECT actual.relation_name @@ -1069,7 +1093,7 @@ verify_workflow_recipes_runtime_boundary() { LEFT JOIN expected_access expected USING (relation_name) CROSS JOIN LATERAL ( VALUES - ('SELECT', COALESCE(expected.access_profile IN ('legacy_dml', 'read_update', 'upsert'), false)), + ('SELECT', COALESCE(expected.access_profile IN ('legacy_dml', 'read', 'read_update', 'upsert'), false)), ('INSERT', COALESCE(expected.access_profile IN ('legacy_dml', 'upsert'), false)), ('UPDATE', COALESCE(expected.access_profile IN ('legacy_dml', 'read_update', 'upsert'), false)), ('DELETE', COALESCE(expected.access_profile IN ('legacy_dml', 'delete'), false)), @@ -1087,6 +1111,73 @@ verify_workflow_recipes_runtime_boundary() { (SELECT COUNT(*) FROM relation_coverage_violations) + (SELECT COUNT(*) FROM privilege_violations);" + assert_db_query_equals \ + "workflow_recipes_runtime effective column privileges differ from the explicit access contract" \ + "0" \ + "WITH expected_access(relation_name, access_profile) AS ( + VALUES ${expected_values} + ), + expected_column_access(relation_name, column_name, privilege_name) AS ( + VALUES ${expected_column_values} + ), + actual_relations AS ( + SELECT relation.oid, relation.relname AS relation_name + FROM pg_class relation + JOIN pg_namespace namespace ON namespace.oid = relation.relnamespace + WHERE namespace.nspname = 'public' + AND relation.relkind IN ('r', 'p', 'v', 'm', 'f') + ), + actual_columns AS ( + SELECT relation.oid, + relation.relation_name, + attribute.attname AS column_name + FROM actual_relations relation + JOIN pg_attribute attribute ON attribute.attrelid = relation.oid + WHERE attribute.attnum > 0 + AND NOT attribute.attisdropped + ), + column_policy_coverage_violations AS ( + SELECT exception.relation_name, exception.column_name, exception.privilege_name + FROM expected_column_access exception + LEFT JOIN expected_access profile USING (relation_name) + LEFT JOIN actual_columns actual + ON actual.relation_name = exception.relation_name + AND actual.column_name = exception.column_name + WHERE profile.relation_name IS NULL + OR actual.column_name IS NULL + OR CASE exception.privilege_name + WHEN 'SELECT' THEN profile.access_profile IN ('legacy_dml', 'read', 'read_update', 'upsert') + WHEN 'INSERT' THEN profile.access_profile IN ('legacy_dml', 'upsert') + WHEN 'UPDATE' THEN profile.access_profile IN ('legacy_dml', 'read_update', 'upsert') + WHEN 'REFERENCES' THEN false + END + ), + column_privilege_violations AS ( + SELECT actual.relation_name, actual.column_name, required.privilege_name + FROM actual_columns actual + JOIN expected_access profile USING (relation_name) + CROSS JOIN LATERAL ( + VALUES + ('SELECT', profile.access_profile IN ('legacy_dml', 'read', 'read_update', 'upsert')), + ('INSERT', profile.access_profile IN ('legacy_dml', 'upsert')), + ('UPDATE', profile.access_profile IN ('legacy_dml', 'read_update', 'upsert')), + ('REFERENCES', false) + ) required(privilege_name, table_allowed) + LEFT JOIN expected_column_access exception + ON exception.relation_name = actual.relation_name + AND exception.column_name = actual.column_name + AND exception.privilege_name = required.privilege_name + WHERE has_column_privilege( + 'workflow_recipes_runtime', + actual.oid, + actual.column_name, + required.privilege_name + ) IS DISTINCT FROM (required.table_allowed OR exception.relation_name IS NOT NULL) + ) + SELECT + (SELECT COUNT(*) FROM column_policy_coverage_violations) + + (SELECT COUNT(*) FROM column_privilege_violations);" + assert_db_query_equals \ "workflow_recipes_runtime sequence privileges differ from the explicit access contract" \ "0" \ diff --git a/deploy/scripts/workflow-recipes-runtime-access-profiles.tsv b/deploy/scripts/workflow-recipes-runtime-access-profiles.tsv index 4b553d831b..c1e494578e 100644 --- a/deploy/scripts/workflow-recipes-runtime-access-profiles.tsv +++ b/deploy/scripts/workflow-recipes-runtime-access-profiles.tsv @@ -1,8 +1,115 @@ # public relationworkflow_recipes_runtime access profile +administrative_events none +agent_run_events none +authorization_catalog_environment none +authorization_catalog_writer_components none +authorization_resource_revisions none +authorization_team_revisions none +authorization_user_revisions none +entity_change_feed none +entity_change_outbox none +entity_change_watermark none +budget_pending_reservations none +codex_subscription_connections none +codex_subscription_oauth_states none +codex_catalog_models none +grok_subscription_connections none +grok_subscription_oauth_states none +grok_catalog_models none +control_admin_deletion_audit none +control_admin_email_change_requests none +control_admin_invitations none +control_admin_password_reset_requests none +control_admin_revoked_tokens none +control_admin_users none +external_user_session_security_epochs none +external_user_sessions none +external_v1_session_revocations none +gfs_audit none +gfs_blob_manifests none +gfs_desktop_operator_links none +desktop_user_retirement_operations none +gfs_grants none +gfs_resources none +gfs_shares none +gfs_upload_parts none +gfs_upload_sessions none +governed_event_read_v1 none +governed_event_stream none +governed_run_attribution_bindings none +governed_approval_prompt_history none +host_heartbeats none +host_wake_generations none +infrastructure_cost_daily none +infrastructure_cost_daily_components none +infrastructure_price_snapshots none +infrastructure_telemetry_events none +invitation_delivery_commands none +invitation_teams none +invitations none +llm_allowed_models none +llm_allowed_models_audit none +llm_catalog_sync_runs none +llm_model_prices none +llm_provider_attempt_tickets none +llm_provider_attempts none +member_registration_credentials none +mcp_secret_rollback_permits none +notification_deliveries none +dynamic_clients none +oauth_grants none +operational_catalog_source_state none +operational_relationships_staging none +operational_resource_index none +operational_resource_index_staging none +operational_resource_relationships none +password_identifier_state none +password_verification_pace none +password_verification_work none +plugin_workload_sdk_credential_ticket_jtis none +plugin_workload_sdk_invocation_attempts none +plugin_workload_sdk_grants none +plugin_workload_sdk_invocations none +plugin_workload_sdk_provider_attempts none +plugin_workload_sdk_quota_counters none +plugin_workload_sdk_spend_outcomes none +profiles none +rate_limit_buckets none +registry_connection none +role_changes_audit none +schema_migrations none +team_agents none +team_contexts none +team_members none +team_workflow_grants_audit none team_workflow_triggers delete +teams none +token_budgets none +trigger_grants_audit none +usage_5min none +usage_daily none +usage_events none +usage_hourly none +user_agents none +user_contexts none +user_notification_preferences none user_workflow_triggers delete +users none +workflow_approval_medium_accounts none +workflow_approval_medium_challenges none +workflow_approval_provider_events none +workflow_approval_reader_events none workflow_approval_requests read_update +workflow_approval_requests_archive none +workflow_approval_trigger_intents none +workflow_approval_trigger_intents_archive none +workflow_approval_trigger_run_intents none +workflow_approval_trigger_run_intents_archive none workflow_recipe_allowed_teams delete +workflow_recipe_allowed_teams_audit none +workflow_revoked_refresh_jtis none +workflow_run_step_audit none workflow_run_steps upsert workflow_runs read_update +workflow_runs_audit none workflow_schedules legacy_dml diff --git a/deploy/scripts/workflow-recipes-runtime-column-access-profiles.tsv b/deploy/scripts/workflow-recipes-runtime-column-access-profiles.tsv new file mode 100644 index 0000000000..f1a4b77599 --- /dev/null +++ b/deploy/scripts/workflow-recipes-runtime-column-access-profiles.tsv @@ -0,0 +1,7 @@ +# relationcolumneffective column privilege +team_workflow_triggers recipe_name SELECT +team_workflow_triggers recipe_namespace SELECT +user_workflow_triggers recipe_name SELECT +user_workflow_triggers recipe_namespace SELECT +workflow_recipe_allowed_teams recipe_name SELECT +workflow_recipe_allowed_teams recipe_namespace SELECT From fbf1dff5b8c20dc40ca1fd235d7f43acbd7a0ef2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:00:05 -0600 Subject: [PATCH 297/528] fix(gfs): r64-m1 bind external tokens to lifecycle generation Finding: R64-M1 Class-check: external user-session GFS token paths -> 4 sites -> all use locked currentness; Control UI admin and provisioner token issuers remain separate and unchanged. Repro-test: control-api/test/routes.externalGfsV2Lifecycle.realPostgres.integration.test.ts (fails at 113479887ce4fdfd787e9cb1100d74bae596640f: V2 GFSC HTTP 403; passes after correction). --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- control-api/src/gfs/externalAuthority.ts | 103 ++++++ control-api/src/routes/external/gfs.ts | 132 ++++--- control-api/test/routes.externalGfs.test.ts | 23 ++ ...Lifecycle.realPostgres.integration.test.ts | 348 ++++++++++++++++++ 6 files changed, 561 insertions(+), 51 deletions(-) create mode 100644 control-api/test/routes.externalGfsV2Lifecycle.realPostgres.integration.test.ts diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 5a1d149f90..39ae925e30 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.746", + "version": "0.1.747", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.746", + "version": "0.1.747", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 00003d11b5..a945ea8abc 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.746", + "version": "0.1.747", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/gfs/externalAuthority.ts b/control-api/src/gfs/externalAuthority.ts index ac6563e22a..7a714a5b75 100644 --- a/control-api/src/gfs/externalAuthority.ts +++ b/control-api/src/gfs/externalAuthority.ts @@ -1,5 +1,13 @@ import type { NextFunction, Request, Response } from 'express' +import { type GfsPathBinding, type GfsScope, signGfsToken } from '../auth/gfsToken.js' import { config } from '../config.js' +import { withTransaction } from '../db.js' +import type { ExternalSessionAuthentication } from '../services/auth/externalSessionAuthentication.js' +import { + externalSessionDatabaseFailure, + isExternalSessionBackendUnavailableError, +} from '../services/auth/sessionDatabaseFailure.js' +import { validateExternalSessionAuthorityContext } from '../services/auth/userSessionService.js' import { type GfsDesktopOperatorLink, GfsDesktopOperatorLinkError, @@ -28,6 +36,101 @@ export type ExternalGfsLinkedAdminAuthority = { export type ExternalGfsAuthority = ExternalGfsUserAuthority | ExternalGfsLinkedAdminAuthority +type AuthenticatedExternalSession = Extract< + ExternalSessionAuthentication, + { status: 'authenticated' } +> + +export type ExternalGfsUserTokenInput = Readonly<{ + subject: string + drive: string + scopes: readonly GfsScope[] + pathBindings?: readonly GfsPathBinding[] +}> + +/** + * Sign a user-plane GFS token while the authenticated session and canonical + * user generation are current under the same principal lock. GFSC consumes + * users.lifecycle_version as authGeneration; V2's sessionVersion is a + * different fence and must never be projected into that claim. + * + * The signed bytes remain private to this function until COMMIT succeeds, so a + * lost/uncertain commit cannot be reported as a usable GFS credential. + */ +export async function signCurrentExternalGfsUserToken( + authentication: AuthenticatedExternalSession | undefined, + input: ExternalGfsUserTokenInput +): Promise<{ token: string; expiresInSeconds: number } | null> { + if (!authentication) return null + const context = authentication.authorityContext + const tokenClaims = authentication.tokenClaims + const hasLegacySubject = 'userId' in tokenClaims + const tokenSubject = hasLegacySubject ? tokenClaims.userId : tokenClaims.sub + const expiresAt = authentication.tokenClaims.exp + if ( + context.contract !== authentication.contract || + (authentication.contract === 'v1') !== hasLegacySubject || + context.userId !== input.subject || + tokenSubject !== context.userId || + typeof expiresAt !== 'number' || + !Number.isSafeInteger(expiresAt) || + expiresAt < 1 + ) { + return null + } + + try { + return await withTransaction( + async transaction => { + const current = await validateExternalSessionAuthorityContext(context, { + db: transaction, + }) + if (current.status !== 'valid' || current.identity.userId !== context.userId) return null + + const result = await transaction.query( + `SELECT lifecycle_state, lifecycle_version, clock_timestamp() AS db_now + FROM users + WHERE id = $1`, + [context.userId] + ) + const row = result.rows[0] as + | { + lifecycle_state?: unknown + lifecycle_version?: unknown + db_now?: Date | string + } + | undefined + const generation = Number(row?.lifecycle_version) + const dbNow = row?.db_now instanceof Date ? row.db_now : new Date(String(row?.db_now ?? '')) + if ( + row?.lifecycle_state !== 'active' || + !Number.isSafeInteger(generation) || + generation < 1 || + !Number.isFinite(dbNow.getTime()) || + expiresAt * 1000 <= dbNow.getTime() + ) { + return null + } + + return signGfsToken({ + subject: input.subject, + drive: input.drive, + scopes: input.scopes, + ...(input.pathBindings === undefined ? {} : { pathBindings: input.pathBindings }), + authGeneration: generation, + principalType: 'user', + }) + }, + { onDatabaseFailure: externalSessionDatabaseFailure } + ) + } catch (error) { + if (isExternalSessionBackendUnavailableError(error)) { + throw new ExternalGfsAuthorityError(503, 'gfs_authority_unavailable', { cause: error }) + } + throw error + } +} + export class ExternalGfsAuthorityError extends Error { constructor( readonly status: 403 | 503, diff --git a/control-api/src/routes/external/gfs.ts b/control-api/src/routes/external/gfs.ts index 8792674101..7e9218c417 100644 --- a/control-api/src/routes/external/gfs.ts +++ b/control-api/src/routes/external/gfs.ts @@ -11,9 +11,12 @@ import { config } from '../../config.js' import { pool } from '../../db.js' import { type ExternalGfsAuthority, + ExternalGfsAuthorityError, + type ExternalGfsUserTokenInput, type RequestWithExternalGfsAuthority, attachExternalGfsAuthority, attachExternalGfsUserLifecycle, + signCurrentExternalGfsUserToken, } from '../../gfs/externalAuthority.js' import { isValidHostSubjectId, makeHostSubjectId } from '../../gfs/hostSubject.js' import { DbResolveStore } from '../../gfs/resolve.js' @@ -170,6 +173,25 @@ function authorityOf(req: ExternalGfsRequest): ExternalGfsAuthority { return authority } +async function signCurrentUserToken( + req: ExternalAuthedRequest, + res: import('express').Response, + input: ExternalGfsUserTokenInput +): Promise<{ token: string; expiresInSeconds: number } | null> { + try { + const issued = await signCurrentExternalGfsUserToken(req.externalSessionAuthentication, input) + if (issued) return issued + res.status(401).json({ error: 'Unauthorized' }) + return null + } catch (error) { + if (error instanceof ExternalGfsAuthorityError) { + res.status(error.status).json({ error: error.code }) + return null + } + throw error + } +} + type ExternalGfsUploadRequest = GfsUploadAdmissionRequest & { gfsUploadDrive?: string } @@ -545,15 +567,14 @@ export function createExternalGfsRouter(): Router { } // pathBindings: [] — the permission store is the source of truth; gfsc // re-checks it on every op (same as the operator/human mint). - const { token, expiresInSeconds } = signGfsToken({ + const issued = await signCurrentUserToken(req, res, { subject: claims.userId, drive, scopes, pathBindings: [], - ...(claims.authGeneration === undefined ? {} : { authGeneration: claims.authGeneration }), - principalType: 'user', }) - res.status(200).json({ token, expiresInSeconds }) + if (!issued) return + res.status(200).json(issued) }) ) @@ -1062,25 +1083,34 @@ async function proxyReadToGfsc( gfscPath: string ): Promise { const authority = authorityOf(req as ExternalGfsRequest) - const { token } = signGfsToken({ - subject: authority.tokenSubject, - drive, - scopes: [GFS_READ_SCOPE], - ...(authority.authGeneration === undefined ? {} : { authGeneration: authority.authGeneration }), - principalType: authority.kind === 'linked-admin' ? 'control-admin' : 'user', - ...(authority.kind === 'linked-admin' - ? { - brokeredAuthority: { - desktopUserId: authority.desktopUserId, - controlAdminId: authority.controlAdminId, - authoritySource: authority.authoritySource, - linkLineageId: authority.linkLineageId, - linkGeneration: authority.linkGeneration, - desktopUserGeneration: authority.desktopUserGeneration, - }, - } - : {}), - }) + let token: string + if (authority.kind === 'linked-admin') { + token = signGfsToken({ + subject: authority.tokenSubject, + drive, + scopes: [GFS_READ_SCOPE], + ...(authority.authGeneration === undefined + ? {} + : { authGeneration: authority.authGeneration }), + principalType: 'control-admin', + brokeredAuthority: { + desktopUserId: authority.desktopUserId, + controlAdminId: authority.controlAdminId, + authoritySource: authority.authoritySource, + linkLineageId: authority.linkLineageId, + linkGeneration: authority.linkGeneration, + desktopUserGeneration: authority.desktopUserGeneration, + }, + }).token + } else { + const issued = await signCurrentUserToken(req, res, { + subject: authority.tokenSubject, + drive, + scopes: [GFS_READ_SCOPE], + }) + if (!issued) return + token = issued.token + } const target = `${config.gfscBaseUrl.replace(/\/+$/, '')}${gfscPath}` let upstream: Response // Header-only deadline: bound the wait for gfsc to START responding, but never @@ -1163,25 +1193,34 @@ async function proxyMutationToGfsc( scope: typeof GFS_WRITE_SCOPE | typeof GFS_DELETE_SCOPE ): Promise { const authority = authorityOf(req as ExternalGfsRequest) - const { token } = signGfsToken({ - subject: authority.tokenSubject, - drive, - scopes: [scope], - ...(authority.authGeneration === undefined ? {} : { authGeneration: authority.authGeneration }), - principalType: authority.kind === 'linked-admin' ? 'control-admin' : 'user', - ...(authority.kind === 'linked-admin' - ? { - brokeredAuthority: { - desktopUserId: authority.desktopUserId, - controlAdminId: authority.controlAdminId, - authoritySource: authority.authoritySource, - linkLineageId: authority.linkLineageId, - linkGeneration: authority.linkGeneration, - desktopUserGeneration: authority.desktopUserGeneration, - }, - } - : {}), - }) + let token: string + if (authority.kind === 'linked-admin') { + token = signGfsToken({ + subject: authority.tokenSubject, + drive, + scopes: [scope], + ...(authority.authGeneration === undefined + ? {} + : { authGeneration: authority.authGeneration }), + principalType: 'control-admin', + brokeredAuthority: { + desktopUserId: authority.desktopUserId, + controlAdminId: authority.controlAdminId, + authoritySource: authority.authoritySource, + linkLineageId: authority.linkLineageId, + linkGeneration: authority.linkGeneration, + desktopUserGeneration: authority.desktopUserGeneration, + }, + }).token + } else { + const issued = await signCurrentUserToken(req, res, { + subject: authority.tokenSubject, + drive, + scopes: [scope], + }) + if (!issued) return + token = issued.token + } const target = `${config.gfscWriteBaseUrl.replace(/\/+$/, '')}${gfscPath}` const headers: Record = { 'content-type': 'application/json' } headers['author' + 'ization'] = ['Bearer', token].join(' ') @@ -1257,16 +1296,13 @@ async function proxyUploadToGfsc( query?: Record ): Promise { const claims = req.externalAuth! - const { token } = signGfsToken({ + const issued = await signCurrentUserToken(req, res, { subject: claims.userId, drive, scopes: [GFS_WRITE_SCOPE], - // GFSC resolves user authority against users.lifecycle_version on every - // request. The session generation must cross this relay boundary or a - // valid external session is rejected as an un-fenced user token. - authGeneration: claims.authGeneration, - principalType: 'user', }) + if (!issued) return + const { token } = issued const targetUrl = new URL(`${config.gfscWriteBaseUrl.replace(/\/+$/, '')}${gfscPath}`) for (const [key, value] of Object.entries(query ?? {})) { if (value !== undefined && value !== '') targetUrl.searchParams.set(key, value) diff --git a/control-api/test/routes.externalGfs.test.ts b/control-api/test/routes.externalGfs.test.ts index 6d2fa8b67b..1f26b7f728 100644 --- a/control-api/test/routes.externalGfs.test.ts +++ b/control-api/test/routes.externalGfs.test.ts @@ -23,6 +23,7 @@ import { externalGfsRateLimitRequestsTotal } from '../src/observability/metrics. const mockVerifyExternalSessionToken = vi.fn() const mockSignGfsToken = vi.hoisted(() => vi.fn()) +const mockSignCurrentExternalGfsUserToken = vi.hoisted(() => vi.fn()) const mockQuery = vi.hoisted(() => vi.fn()) const mockAppendPermissionEvents = vi.hoisted(() => vi.fn()) const mockWithTransaction = vi.hoisted(() => vi.fn()) @@ -97,6 +98,14 @@ vi.mock('../src/db.js', () => ({ rateLimitPool: { query: (...a: unknown[]) => mockQuery(...a) }, withTransaction: (...a: unknown[]) => mockWithTransaction(...a), })) +vi.mock('../src/gfs/externalAuthority.js', async importOriginal => { + const original = await importOriginal() + return { + ...original, + signCurrentExternalGfsUserToken: (...args: unknown[]) => + mockSignCurrentExternalGfsUserToken(...args), + } +}) vi.mock('../src/services/tracing/controlApiPermissionEvents.js', () => ({ appendControlApiPermissionEventsInTransaction: (...a: unknown[]) => mockAppendPermissionEvents(...a), @@ -210,6 +219,7 @@ async function buildAdminGrantsApp(sub: string) { beforeEach(() => { mockVerifyExternalSessionToken.mockReset() mockSignGfsToken.mockReset() + mockSignCurrentExternalGfsUserToken.mockReset() mockQuery.mockReset() mockAppendPermissionEvents.mockReset() mockWithTransaction.mockReset() @@ -229,6 +239,19 @@ beforeEach(() => { ) mockAppendPermissionEvents.mockResolvedValue(null) mockSignGfsToken.mockReturnValue({ token: 'gfs-user-token', expiresInSeconds: 300 }) + mockSignCurrentExternalGfsUserToken.mockImplementation( + async ( + authentication: { claims?: typeof SESSION } | undefined, + input: Record + ) => + mockSignGfsToken({ + ...input, + ...(authentication?.claims?.authGeneration === undefined + ? {} + : { authGeneration: authentication.claims.authGeneration }), + principalType: 'user', + }) + ) ;(config as { gfscProxyTimeoutMs: number }).gfscProxyTimeoutMs = 300_000 ;(config as { desktopGfsOperatorLinkingEnabled: boolean }).desktopGfsOperatorLinkingEnabled = false diff --git a/control-api/test/routes.externalGfsV2Lifecycle.realPostgres.integration.test.ts b/control-api/test/routes.externalGfsV2Lifecycle.realPostgres.integration.test.ts new file mode 100644 index 0000000000..551fccb8af --- /dev/null +++ b/control-api/test/routes.externalGfsV2Lifecycle.realPostgres.integration.test.ts @@ -0,0 +1,348 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import express from 'express' +import { createPublicKey, randomBytes, randomUUID } from 'node:crypto' +import { once } from 'node:events' +import { Readable } from 'node:stream' +import { Pool } from 'pg' +import request from 'supertest' +import { GfsServingHandler } from '../../gfs-controller/src/api/serve.js' +import { loadVerificationKey } from '../../gfs-controller/src/auth/keys.js' +import { verifyGfsToken } from '../../gfs-controller/src/auth/verify.js' +import { DbAuditSink, PermissionClient } from '../../gfs-controller/src/authz/permissionClient.js' +import { resolveAuthzContext } from '../../gfs-controller/src/authz/subjectResolver.js' +import type { GfsConfig } from '../../gfs-controller/src/config.js' +import { PgResourceStore } from '../../gfs-controller/src/db/resourceStore.js' +import { GfsServer } from '../../gfs-controller/src/server.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' + +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const describeRealPostgres = adminUrl ? describe : describe.skip + +function databaseUrl(baseUrl: string, database: string): string { + const url = new URL(baseUrl) + url.pathname = `/${database}` + return url.toString() +} + +function quoteIdentifier(value: string): string { + return `"${value.replaceAll('"', '""')}"` +} + +describeRealPostgres('external GFS session generation on real PostgreSQL', () => { + const database = `control_api_gfs_v2_generation_${randomBytes(6).toString('hex')}` + const connectionString = databaseUrl( + adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', + database + ) + const userId = randomUUID() + const teamId = randomUUID() + const directResourceId = randomUUID() + const teamResourceId = randomUUID() + const envKeys = [ + 'CONTROL_API_PG_CONNECTION_STRING', + 'CONTROL_API_USER_ACCESS_SESSION_V2_ACCEPTANCE', + ] as const + const previousEnv = new Map() + let adminPool: Pool | undefined + let controlPool: Pool | undefined + let limiterPool: Pool | undefined + let appServer: ReturnType | undefined + let gfscServer: GfsServer | undefined + let gfscPort = 0 + let createUserSession: typeof import('../src/services/auth/userSessionService.js').createUserSession + let issueExternalUserSession: typeof import('../src/services/auth/externalSessionIssuance.js').issueExternalUserSession + let revokeAllUserSessions: typeof import('../src/services/auth/userSessionService.js').revokeAllUserSessions + let revokeUserSession: typeof import('../src/services/auth/userSessionService.js').revokeUserSession + let transaction: typeof import('../src/db.js').withTransaction + let authenticateExternalUserSession: typeof import('../src/services/auth/externalSessionAuthentication.js').authenticateExternalUserSession + let gfsToken: typeof import('../src/auth/gfsToken.js') + let v1Token = '' + let v2Token = '' + + beforeAll(async () => { + if (!adminUrl) throw new Error('CONTROL_API_REAL_PG_ADMIN_URL is required by this suite') + adminPool = new Pool({ connectionString: adminUrl }) + await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) + + for (const key of envKeys) previousEnv.set(key, process.env[key]) + process.env.CONTROL_API_PG_CONNECTION_STRING = connectionString + process.env.CONTROL_API_USER_ACCESS_SESSION_V2_ACCEPTANCE = 'true' + + const dbModule = await import('../src/db.js') + controlPool = dbModule.pool as unknown as Pool + limiterPool = dbModule.rateLimitPool as unknown as Pool + controlPool.on('error', () => {}) + limiterPool.on('error', () => {}) + const migrationPool = new Pool({ connectionString }) + await dbModule.initDb({ connect: () => migrationPool.connect() }) + await endPoolAndWaitForClients(migrationPool) + + const configModule = await import('../src/config.js') + const authModule = await import('../src/services/auth/userSessionService.js') + const issuanceModule = await import('../src/services/auth/externalSessionIssuance.js') + const authnModule = await import('../src/services/auth/externalSessionAuthentication.js') + const tokenModule = await import('../src/auth/gfsToken.js') + const routesModule = await import('../src/routes/external/gfs.js') + createUserSession = authModule.createUserSession + revokeAllUserSessions = authModule.revokeAllUserSessions + revokeUserSession = authModule.revokeUserSession + issueExternalUserSession = issuanceModule.issueExternalUserSession + authenticateExternalUserSession = authnModule.authenticateExternalUserSession + transaction = dbModule.withTransaction + gfsToken = tokenModule + + await controlPool.query( + `INSERT INTO users (id, email, name) VALUES ($1, $2, 'gfs-generation-test')`, + [userId, `${userId}@example.test`] + ) + await controlPool.query(`INSERT INTO teams (id, name) VALUES ($1, 'gfs-generation-team')`, [ + teamId, + ]) + await controlPool.query( + `INSERT INTO team_members (team_id, user_id, role, status) + VALUES ($1, $2, 'member', 'active')`, + [teamId, userId] + ) + await transaction(db => revokeAllUserSessions(userId, 'gfs-generation-fixture', db)) + + const rootResourceId = randomUUID() + await controlPool.query( + `INSERT INTO gfs_resources (resource_id, drive, name, kind, path_cache) + VALUES ($1, 'main', 'root', 'directory', '/')`, + [rootResourceId] + ) + await controlPool.query( + `INSERT INTO gfs_resources + (resource_id, drive, parent_resource_id, name, kind, path_cache) + VALUES ($1, 'main', $3, 'direct.txt', 'file', '/direct.txt'), + ($2, 'main', $3, 'team.txt', 'file', '/team.txt')`, + [directResourceId, teamResourceId, rootResourceId] + ) + await controlPool.query( + `INSERT INTO gfs_grants (drive, resource_id, subject_type, subject_id, permissions) + VALUES ('main', $1, 'user', $2, ARRAY['read']::text[]), + ('main', $3, 'team', $4, ARRAY['read']::text[])`, + [directResourceId, userId, teamResourceId, teamId] + ) + + const testPolicy = { + issueV1: true, + acceptV1: true, + issueV2: false, + acceptV2: true, + } as import('../src/services/access/userAccessPolicy.js').EffectiveUserAccessPolicy + const issuedV1 = await issueExternalUserSession( + { + contract: 'v1', + userId, + email: `${userId}@example.test`, + teamId, + role: 'member', + authenticationMethods: ['pwd'], + }, + { policy: testPolicy } + ) + v1Token = issuedV1.token + const issuedV2 = await transaction(db => + createUserSession( + { userId, email: `${userId}@example.test`, authenticationMethods: ['pwd'] }, + { db } + ) + ) + v2Token = issuedV2.token + + const publicKey = createPublicKey(configModule.config.rpcJwtPrivateKey).export({ + type: 'spki', + format: 'pem', + }) + const verificationKey = loadVerificationKey(String(publicKey)) + const permissions = new PermissionClient( + controlPool as never, + new DbAuditSink(controlPool as never) + ) + const serving = new GfsServingHandler({ + verifyToken: token => + verifyGfsToken(token, { + key: verificationKey, + audience: configModule.config.gfsTokenAudience, + issuer: configModule.config.rpcJwtIssuer, + }), + resolveContext: (claims, requestId) => + resolveAuthzContext(controlPool as never, claims, requestId), + authorize: (context, resourceId, operation) => + permissions.authorize(context, resourceId, operation), + store: new PgResourceStore(controlPool as never), + blobs: { read: async () => Readable.from([]) }, + rateLimit: { + reads: { check: () => undefined, limitPerWindow: 1 }, + writes: { check: () => undefined, limitPerWindow: 1 }, + }, + }) + gfscServer = new GfsServer( + { + port: 0, + storageMountPath: '/unused-in-read-test', + storageRole: 'reader', + pgConnectionString: connectionString, + tokenAudience: configModule.config.gfsTokenAudience, + publicKey: String(publicKey), + driveName: 'main', + decisionCacheTtlMs: 0, + credentialProbeIntervalMs: 60_000, + blobCleanupSafetyWindowMs: 60_000, + blobCleanupIntervalMs: 60_000, + blobCleanupBatchSize: 1, + agentReadRlPerMinPerReplica: 1, + agentWriteRlPerMinPerReplica: 1, + syncCopyMaxObjects: 1, + syncCopyMaxBytes: 1, + syncCopyTimeoutMs: 1_000, + syncRenameMaxObjects: 1, + syncRenameTimeoutMs: 1_000, + uploadV2: {} as GfsConfig['uploadV2'], + devMode: true, + } as GfsConfig, + { isStorageMounted: async () => true, pingPermissionStore: async () => undefined }, + serving + ) + gfscPort = await gfscServer.start() + configModule.config.gfscBaseUrl = `http://127.0.0.1:${gfscPort}` + + const app = express() + app.use(express.json()) + app.use(routesModule.createExternalGfsRouter()) + appServer = app.listen(0, '127.0.0.1') + await once(appServer, 'listening') + }, 60_000) + + afterAll(async () => { + try { + appServer?.closeAllConnections?.() + if (appServer) await new Promise(resolve => appServer!.close(() => resolve())) + await gfscServer?.stop() + for (const key of envKeys) { + const value = previousEnv.get(key) + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + await endPoolAndWaitForClients(controlPool) + await endPoolAndWaitForClients(limiterPool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + } + } finally { + await adminPool?.end() + } + }) + + async function resolve(token: string, resourceId: string) { + if (!appServer) throw new Error('GFS test app did not start') + const client = request(appServer) + return client + .get(`/external/gfs/resolve?uri=${encodeURIComponent(`gfs://main/${resourceId}`)}`) + .set('x-user-session-token', token) + .set('x-forwarded-for', '127.0.0.1') + } + + async function mintGfsToken(token: string) { + if (!appServer) throw new Error('GFS test app did not start') + return request(appServer) + .post('/external/gfs/token') + .set('x-user-session-token', token) + .set('x-forwarded-for', '127.0.0.1') + .send({ drive: 'main', scopes: ['gfs.read'] }) + } + + it('keeps legacy V1 reads valid through the real GFSC HTTP and PostgreSQL permission path', async () => { + const result = await resolve(v1Token, directResourceId) + expect(result.status).toBe(200) + expect(result.body.data?.resourceId).toBe(directResourceId) + }) + + it('mints a current V2 generation for GFSC and preserves direct/team access checks', async () => { + const authentication = await authenticateExternalUserSession(v2Token, { purpose: 'protected' }) + expect(authentication.status).toBe('authenticated') + if (authentication.status !== 'authenticated') return + expect(authentication.contract).toBe('v2') + expect(authentication.claims.authGeneration).toBeUndefined() + const lifecycle = await controlPool!.query<{ lifecycle_version: number | string }>( + `SELECT lifecycle_version FROM users WHERE id = $1`, + [userId] + ) + expect(Number(lifecycle.rows[0]?.lifecycle_version)).toBeGreaterThan(1) + + const staleGeneration = gfsToken.signGfsToken({ + subject: userId, + drive: 'main', + scopes: ['gfs.read'], + authGeneration: Number(lifecycle.rows[0]?.lifecycle_version) - 1, + principalType: 'user', + }) + const staleGenerationResult = await fetch( + `http://127.0.0.1:${gfscPort}/v1/resolve?uri=${encodeURIComponent(`gfs://main/${directResourceId}`)}`, + { headers: { authorization: `Bearer ${staleGeneration.token}` } } + ) + expect([401, 403]).toContain(staleGenerationResult.status) + + const direct = await resolve(v2Token, directResourceId) + if (direct.status === 403) { + expect(direct.body).toMatchObject({ error: { code: 'forbidden' } }) + } + expect(direct.status).toBe(200) + expect(direct.body.data?.resourceId).toBe(directResourceId) + + const team = await resolve(v2Token, teamResourceId) + expect(team.status).toBe(200) + expect(team.body.data?.resourceId).toBe(teamResourceId) + + await controlPool!.query( + `UPDATE team_members SET status = 'deleted' WHERE team_id = $1 AND user_id = $2`, + [teamId, userId] + ) + const revokedTeam = await resolve(v2Token, teamResourceId) + expect(revokedTeam.status).toBe(403) + const survivingDirect = await resolve(v2Token, directResourceId) + expect(survivingDirect.status).toBe(200) + }) + + it('returns a V2 GFS token that the real GFSC HTTP consumer accepts', async () => { + const minted = await mintGfsToken(v2Token) + expect(minted.status).toBe(200) + expect(typeof minted.body.token).toBe('string') + const configModule = await import('../src/config.js') + expect(minted.body.expiresInSeconds).toBe(configModule.config.gfsTokenTtlSeconds) + + const consumed = await fetch( + `http://127.0.0.1:${gfscPort}/v1/resolve?uri=${encodeURIComponent(`gfs://main/${directResourceId}`)}`, + { headers: { authorization: `Bearer ${minted.body.token}` } } + ) + expect(consumed.status).toBe(200) + const body = (await consumed.json()) as { data?: { resourceId?: string } } + expect(body.data?.resourceId).toBe(directResourceId) + }) + + it('does not use a revoked V2 session as a legacy fallback', async () => { + const issued = await transaction(db => + createUserSession( + { userId, email: `${userId}@example.test`, authenticationMethods: ['pwd'] }, + { db } + ) + ) + const authentication = await authenticateExternalUserSession(issued.token, { + purpose: 'protected', + }) + if (authentication.status !== 'authenticated' || authentication.contract !== 'v2') { + throw new Error('current V2 producer did not create an accepted V2 session') + } + await transaction(db => revokeUserSession(userId, issued.identity.sid, 'logout', db)) + const result = await resolve(issued.token, directResourceId) + expect(result.status).toBe(401) + const mintAfterRevocation = await mintGfsToken(issued.token) + expect(mintAfterRevocation.status).toBe(401) + }) +}) From 20be2fbe46977950972b90eb898035bfdc197bdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:14:24 -0600 Subject: [PATCH 298/528] fix(control-api): r61-b1 relocate pr1 migration identities Class-check: PR1/DEV version and alias declarations -> 15 descriptors -> preserve same-body owners. Decision: classify DEV 0129 first; reject canonical/alias collisions and unknown migrations. Repro-test: migration-order and RealPG producer-policy tests fail at 611bd5ec80a58f0bdcd0cb110094135b7378f703. --- .github/workflows/ci-public.yml | 52 ++- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- control-api/src/db.ts | 38 +- control-api/src/migrations/migrationRunner.ts | 107 +++++- .../src/migrations/pr1OnlineIndexPlan.ts | 54 +-- ...essFoundationDefinerTempShadowMigration.ts | 2 +- .../db.catalogUtf8OrderingMigration.test.ts | 2 +- ...composableCatalogRevisionMigration.test.ts | 2 +- ...CatalogRevisionComponentsMigration.test.ts | 8 +- control-api/test/db.migrationsOrder.test.ts | 40 ++- .../db.oauthGrantsOwnerGeneralization.test.ts | 2 +- ....realPostgresMigration.integration.test.ts | 16 +- .../db.userAccessFoundationMigration.test.ts | 56 ++- ...hBackfill.realPostgres.integration.test.ts | 34 +- ...ionPolicy.realPostgres.integration.test.ts | 329 ++++++++++++++++-- .../test/migrationExecutionPolicy.test.ts | 158 +++++++-- ...atibility.realPostgres.integration.test.ts | 13 +- .../test-control-api-migration-bounds.sh | 2 +- 19 files changed, 746 insertions(+), 175 deletions(-) diff --git a/.github/workflows/ci-public.yml b/.github/workflows/ci-public.yml index 912e081e02..c3cefca98c 100644 --- a/.github/workflows/ci-public.yml +++ b/.github/workflows/ci-public.yml @@ -338,9 +338,9 @@ jobs: steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - # R61-B1 acceptance runs the exact migration producers that created the - # historical Task 106 receipts and the observed DEV receipt set. These - # immutable source pins make the RealPG test self-contained on clean CI. + # R61-B1 acceptance runs exact migration producers for the historical + # Task 106 receipts, the published rollback runner, observed DEV history, + # and the latest fetched DEV graph. Immutable pins keep clean CI portable. - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: ref: 2370a399d4221350946462d41427fad8aee0087f @@ -353,6 +353,18 @@ jobs: path: r61-b1-producers/observed-dev persist-credentials: false + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: 113479887ce4fdfd787e9cb1100d74bae596640f + path: r61-b1-producers/published-pr1 + persist-credentials: false + + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: 87f962fb999ed5dbcab90bd52562de0eba4aa564 + path: r61-b1-producers/latest-dev + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: '24' @@ -403,6 +415,38 @@ jobs: working-directory: r61-b1-producers/observed-dev/control-api run: npm run build + - name: Install published PR1 rollback producer dependencies + working-directory: r61-b1-producers/published-pr1/packages/workflow-runtime-core + run: npm ci + + - name: Build published PR1 rollback producer dependency + working-directory: r61-b1-producers/published-pr1/packages/workflow-runtime-core + run: npm run build + + - name: Install published PR1 Control API dependencies + working-directory: r61-b1-producers/published-pr1/control-api + run: npm ci + + - name: Build published PR1 rollback migration runner + working-directory: r61-b1-producers/published-pr1/control-api + run: npm run build + + - name: Install latest DEV producer dependencies + working-directory: r61-b1-producers/latest-dev/packages/workflow-runtime-core + run: npm ci + + - name: Build latest DEV producer dependency + working-directory: r61-b1-producers/latest-dev/packages/workflow-runtime-core + run: npm run build + + - name: Install latest DEV Control API dependencies + working-directory: r61-b1-producers/latest-dev/control-api + run: npm ci + + - name: Build latest DEV migration runner + working-directory: r61-b1-producers/latest-dev/control-api + run: npm run build + - name: Install External REST contract-test dependencies working-directory: external-rest-api run: npm ci @@ -413,7 +457,9 @@ jobs: CONTROL_API_REAL_PG_ADMIN_URL: postgresql://postgres@127.0.0.1:15433/postgres CONTROL_API_REAL_PG_REQUIRED: '1' TASK106_R61_B1_HISTORICAL_SOURCE_DIR: ${{ github.workspace }}/r61-b1-producers/historical-task106 + TASK106_R61_B1_PUBLISHED_PR1_SOURCE_DIR: ${{ github.workspace }}/r61-b1-producers/published-pr1 TASK106_R61_B1_DEV_SOURCE_DIR: ${{ github.workspace }}/r61-b1-producers/observed-dev + TASK106_R61_B1_LATEST_DEV_SOURCE_DIR: ${{ github.workspace }}/r61-b1-producers/latest-dev FORCE_COLOR: '0' NO_COLOR: '1' # The `realPostgres` filter matches every control-api diff --git a/control-api/package-lock.json b/control-api/package-lock.json index da78ca66e3..1b6d7666c0 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.751", + "version": "0.1.752", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.751", + "version": "0.1.752", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 62fe86662e..f24fe4a2fd 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.751", + "version": "0.1.752", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/db.ts b/control-api/src/db.ts index d2b39474be..8dcddb182e 100644 --- a/control-api/src/db.ts +++ b/control-api/src/db.ts @@ -117,6 +117,8 @@ type DbMigration = { version: string // Equivalent names used before a branch merge renumbered the same migration body. legacyVersions?: readonly string[] + // Retain same-body alias receipts so supported older runners do not replay this body. + preserveLegacyReceipts?: boolean apply: (db: DbClient) => Promise } @@ -6396,87 +6398,105 @@ export const CONTROL_API_MIGRATIONS: DbMigration[] = [ }, { // Preserve the displaced Task 106 identity as a same-body receipt alias. - version: '0129_user_access_foundation', + version: '0130_user_access_foundation', legacyVersions: [ + '0129_user_access_foundation', '0126_user_access_foundation', '0125_user_access_foundation', '0109_user_access_foundation', '0107_user_access_foundation', '0101_user_access_foundation', ], + preserveLegacyReceipts: true, apply: applyUserAccessFoundationSchema, }, { - version: '0130_invitation_delivery_commands', + version: '0131_invitation_delivery_commands', legacyVersions: [ + '0130_invitation_delivery_commands', '0127_invitation_delivery_commands', '0126_invitation_delivery_commands', '010a_invitation_delivery_commands', '0108_invitation_delivery_commands', '0102_invitation_delivery_commands', ], + preserveLegacyReceipts: true, apply: applyInvitationDeliveryCommandFoundation, }, { - version: '0131_catalog_utf8_ordering', + version: '0132_catalog_utf8_ordering', legacyVersions: [ + '0131_catalog_utf8_ordering', '0128_catalog_utf8_ordering', '0127_catalog_utf8_ordering', '010b_catalog_utf8_ordering', '0109_catalog_utf8_ordering', '0103_catalog_utf8_ordering', ], + preserveLegacyReceipts: true, apply: applyCatalogUtf8OrderingSchema, }, { - version: '0132_composable_catalog_revisions', + version: '0133_composable_catalog_revisions', legacyVersions: [ + '0132_composable_catalog_revisions', '0129_composable_catalog_revisions', '0128_composable_catalog_revisions', '010c_composable_catalog_revisions', '010a_composable_catalog_revisions', '0104_composable_catalog_revisions', ], + preserveLegacyReceipts: true, apply: applyComposableCatalogRevisionSchema, }, { // Fix-forward for databases that recorded the first composable-catalog // body before the GFS resource-component mapping was completed. - version: '0133_gfs_catalog_revision_components', + version: '0134_gfs_catalog_revision_components', legacyVersions: [ + '0133_gfs_catalog_revision_components', '012a_gfs_catalog_revision_components', '0129_gfs_catalog_revision_components', '010d_gfs_catalog_revision_components', '010b_gfs_catalog_revision_components', '0105_gfs_catalog_revision_components', ], + preserveLegacyReceipts: true, apply: applyComposableCatalogRevisionSchema, }, { // The access-foundation bodies are immutable; harden their installed // SECURITY DEFINER search paths without replaying those historical bodies. - version: '0134_user_access_foundation_definer_temp_shadow_hardening', + version: '0135_user_access_foundation_definer_temp_shadow_hardening', legacyVersions: [ + '0134_user_access_foundation_definer_temp_shadow_hardening', '012b_user_access_foundation_definer_temp_shadow_hardening', '012a_user_access_foundation_definer_temp_shadow_hardening', ], + preserveLegacyReceipts: true, apply: applyUserAccessFoundationDefinerTempShadowHardening, }, { - version: '0135_legacy_password_security_epoch_backfill', + version: '0136_legacy_password_security_epoch_backfill', legacyVersions: [ + '0135_legacy_password_security_epoch_backfill', '0130_legacy_password_security_epoch_backfill', '010e_legacy_password_security_epoch_backfill', '010c_legacy_password_security_epoch_backfill', '0106_legacy_password_security_epoch_backfill', ], + preserveLegacyReceipts: true, apply: backfillLegacyPasswordSecurityEpochs, }, { // This narrow forward fix retains its late canonical receipt while the // runner executes it atomically immediately after the access foundation. - version: '0143_authorization_revision_delete_compatibility', - legacyVersions: ['0138_authorization_revision_delete_compatibility'], + version: '0144_authorization_revision_delete_compatibility', + legacyVersions: [ + '0143_authorization_revision_delete_compatibility', + '0138_authorization_revision_delete_compatibility', + ], + preserveLegacyReceipts: true, apply: applyAuthorizationRevisionDeleteCompatibility, }, ] diff --git a/control-api/src/migrations/migrationRunner.ts b/control-api/src/migrations/migrationRunner.ts index 972a48acd8..f6725bcf4c 100644 --- a/control-api/src/migrations/migrationRunner.ts +++ b/control-api/src/migrations/migrationRunner.ts @@ -3,15 +3,15 @@ import { migrationSessionBoundsSql } from './migrationExecutionPolicy.js' import { preparePr1Migration } from './pr1OnlineIndexPlan.js' export const PR1_MIGRATION_VERSIONS = Object.freeze([ - '0129_user_access_foundation', - '0130_invitation_delivery_commands', - '0131_catalog_utf8_ordering', - '0132_composable_catalog_revisions', - '0133_gfs_catalog_revision_components', - '0134_user_access_foundation_definer_temp_shadow_hardening', - '0135_legacy_password_security_epoch_backfill', - // Executed immediately after 0129 by the runner, before the remaining PR1 migrations. - '0143_authorization_revision_delete_compatibility', + '0130_user_access_foundation', + '0131_invitation_delivery_commands', + '0132_catalog_utf8_ordering', + '0133_composable_catalog_revisions', + '0134_gfs_catalog_revision_components', + '0135_user_access_foundation_definer_temp_shadow_hardening', + '0136_legacy_password_security_epoch_backfill', + // Executed immediately after 0130 by the runner, before remaining PR1 migrations. + '0144_authorization_revision_delete_compatibility', ] as const) export const DEV_POST_0106_MIGRATION_VERSIONS = Object.freeze([ @@ -37,6 +37,7 @@ export const DEV_POST_0106_MIGRATION_VERSIONS = Object.freeze([ '0126_bug192_password_admission', '0127_password_evaluation_retention', '0128_password_work_ownership', + '0129_workflow_schedules_concurrency_policy', ] as const) const NON_PR1_POST_0106_MIGRATION_VERSIONS = new Set(DEV_POST_0106_MIGRATION_VERSIONS) @@ -49,12 +50,14 @@ const CLASSIFIED_POST_0106_MIGRATION_VERSIONS = Object.freeze([ export type MigrationDescriptor = { version: string legacyVersions?: readonly string[] + // Keep supported older-reader identities alongside the current receipt. + preserveLegacyReceipts?: boolean apply: (db: DbClient) => Promise } -const AUTHORIZATION_REVISION_COMPATIBILITY_PREREQUISITE = '0129_user_access_foundation' +const AUTHORIZATION_REVISION_COMPATIBILITY_PREREQUISITE = '0130_user_access_foundation' const AUTHORIZATION_REVISION_COMPATIBILITY_VERSION = - '0143_authorization_revision_delete_compatibility' + '0144_authorization_revision_delete_compatibility' type ApplyPendingPr1MigrationsInput = { db: DbClient @@ -92,6 +95,23 @@ export async function applyPendingPr1Migrations({ recordMigration, }: ApplyPendingPr1MigrationsInput): Promise { const byVersion = new Map(migrations.map(migration => [migration.version, migration])) + const canonicalVersions = new Set(byVersion.keys()) + const aliasOwners = new Map() + for (const migration of migrations) { + if (migration.preserveLegacyReceipts && !migration.legacyVersions?.length) { + throw new Error(`Migration ${migration.version} preserves no legacy receipts`) + } + for (const alias of migration.legacyVersions ?? []) { + if (canonicalVersions.has(alias)) { + throw new Error(`Migration alias collides with canonical version: ${alias}`) + } + const priorOwner = aliasOwners.get(alias) + if (priorOwner && priorOwner !== migration.version) { + throw new Error(`Migration alias has multiple owners: ${alias}`) + } + aliasOwners.set(alias, migration.version) + } + } const expected = new Set(PR1_MIGRATION_VERSIONS) const unclassified = migrations.filter( migration => @@ -120,28 +140,59 @@ export async function applyPendingPr1Migrations({ const acceptedLegacyVersion = migration.legacyVersions?.find(alias => appliedVersions.has(alias) ) + const preservedMigrationReceipts = migration.preserveLegacyReceipts + ? (migration.legacyVersions ?? []) + : [] const currentMigrationApplied = appliedVersions.has(version) || Boolean(acceptedLegacyVersion) if (currentMigrationApplied) { const currentReceiptPending = !appliedVersions.has(version) + const preservedMigrationReceiptsPending = preservedMigrationReceipts.filter( + alias => !appliedVersions.has(alias) + ) const acceptedCompatibilityLegacyVersion = compatibilityMigration?.legacyVersions?.find( alias => appliedVersions.has(alias) ) const compatibilityReceiptPending = compatibilityMigration && !appliedVersions.has(compatibilityMigration.version) - if (currentReceiptPending || compatibilityReceiptPending) { + const preservedCompatibilityReceipts = compatibilityMigration?.preserveLegacyReceipts + ? (compatibilityMigration.legacyVersions ?? []) + : [] + const preservedCompatibilityReceiptsPending = preservedCompatibilityReceipts.filter( + alias => !appliedVersions.has(alias) + ) + const compatibilityBodyPending = + compatibilityMigration && + !appliedVersions.has(compatibilityMigration.version) && + !acceptedCompatibilityLegacyVersion + if ( + currentReceiptPending || + preservedMigrationReceiptsPending.length > 0 || + compatibilityReceiptPending || + preservedCompatibilityReceiptsPending.length > 0 + ) { await runBoundedTransaction(db, async () => { if (currentReceiptPending) await recordMigration(db, version) - if (compatibilityReceiptPending) { - if (!acceptedCompatibilityLegacyVersion) { - await compatibilityMigration.apply(db) - } + for (const alias of preservedMigrationReceiptsPending) { + await recordMigration(db, alias) + } + if (compatibilityBodyPending) { + await compatibilityMigration!.apply(db) + } + if (compatibilityMigration && compatibilityReceiptPending) { await recordMigration(db, compatibilityMigration.version) } + if (compatibilityMigration) { + for (const alias of preservedCompatibilityReceiptsPending) { + await recordMigration(db, alias) + } + } }) if (currentReceiptPending) appliedVersions.add(version) + for (const alias of preservedMigrationReceiptsPending) appliedVersions.add(alias) if (compatibilityReceiptPending) { appliedVersions.add(compatibilityMigration.version) } + for (const alias of preservedCompatibilityReceiptsPending) appliedVersions.add(alias) } continue } @@ -163,12 +214,32 @@ export async function applyPendingPr1Migrations({ await runBoundedTransaction(db, async () => { if (!acceptedLegacyVersion) await migration.apply(db) await recordMigration(db, version) + for (const alias of preservedMigrationReceipts) { + if (!appliedVersions.has(alias)) await recordMigration(db, alias) + } if (compatibilityMigration) { - await compatibilityMigration.apply(db) - await recordMigration(db, compatibilityMigration.version) + const acceptedAlias = compatibilityMigration.legacyVersions?.find(alias => + appliedVersions.has(alias) + ) + if (!appliedVersions.has(compatibilityMigration.version) && !acceptedAlias) { + await compatibilityMigration.apply(db) + } + if (!appliedVersions.has(compatibilityMigration.version)) { + await recordMigration(db, compatibilityMigration.version) + } + const compatibilityAliases = compatibilityMigration.preserveLegacyReceipts + ? (compatibilityMigration.legacyVersions ?? []) + : [] + for (const alias of compatibilityAliases) { + if (!appliedVersions.has(alias)) await recordMigration(db, alias) + } } }) appliedVersions.add(version) + for (const alias of preservedMigrationReceipts) appliedVersions.add(alias) if (compatibilityMigration) appliedVersions.add(compatibilityMigration.version) + if (compatibilityMigration?.preserveLegacyReceipts) { + for (const alias of compatibilityMigration.legacyVersions ?? []) appliedVersions.add(alias) + } } } diff --git a/control-api/src/migrations/pr1OnlineIndexPlan.ts b/control-api/src/migrations/pr1OnlineIndexPlan.ts index 1fa40f18b9..584929df03 100644 --- a/control-api/src/migrations/pr1OnlineIndexPlan.ts +++ b/control-api/src/migrations/pr1OnlineIndexPlan.ts @@ -5,7 +5,7 @@ import { } from './migrationExecutionPolicy.js' export type OnlineIndexDefinition = Readonly<{ - migrationVersion: '0129_user_access_foundation' | '0131_catalog_utf8_ordering' + migrationVersion: '0130_user_access_foundation' | '0132_catalog_utf8_ordering' name: string table: string unique?: boolean @@ -14,70 +14,70 @@ export type OnlineIndexDefinition = Readonly<{ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.freeze([ { - migrationVersion: '0129_user_access_foundation', + migrationVersion: '0130_user_access_foundation', name: 'team_members_user_active_idx', table: 'team_members', createSql: `CREATE INDEX CONCURRENTLY team_members_user_active_idx ON team_members (user_id, status, team_id) INCLUDE (role, updated_at)`, }, { - migrationVersion: '0129_user_access_foundation', + migrationVersion: '0130_user_access_foundation', name: 'user_contexts_context_user_idx', table: 'user_contexts', createSql: `CREATE INDEX CONCURRENTLY user_contexts_context_user_idx ON user_contexts (context_id, user_id)`, }, { - migrationVersion: '0129_user_access_foundation', + migrationVersion: '0130_user_access_foundation', name: 'team_contexts_context_team_idx', table: 'team_contexts', createSql: `CREATE INDEX CONCURRENTLY team_contexts_context_team_idx ON team_contexts (context_id, team_id)`, }, { - migrationVersion: '0129_user_access_foundation', + migrationVersion: '0130_user_access_foundation', name: 'user_agents_agent_user_idx', table: 'user_agents', createSql: `CREATE INDEX CONCURRENTLY user_agents_agent_user_idx ON user_agents (agent_name, user_id)`, }, { - migrationVersion: '0129_user_access_foundation', + migrationVersion: '0130_user_access_foundation', name: 'team_agents_agent_team_idx', table: 'team_agents', createSql: `CREATE INDEX CONCURRENTLY team_agents_agent_team_idx ON team_agents (agent_name, team_id)`, }, { - migrationVersion: '0129_user_access_foundation', + migrationVersion: '0130_user_access_foundation', name: 'user_workflow_triggers_recipe_user_idx', table: 'user_workflow_triggers', createSql: `CREATE INDEX CONCURRENTLY user_workflow_triggers_recipe_user_idx ON user_workflow_triggers (recipe_namespace, recipe_name, user_id)`, }, { - migrationVersion: '0129_user_access_foundation', + migrationVersion: '0130_user_access_foundation', name: 'team_workflow_triggers_recipe_team_idx', table: 'team_workflow_triggers', createSql: `CREATE INDEX CONCURRENTLY team_workflow_triggers_recipe_team_idx ON team_workflow_triggers (recipe_namespace, recipe_name, team_id)`, }, { - migrationVersion: '0129_user_access_foundation', + migrationVersion: '0130_user_access_foundation', name: 'user_workflow_triggers_catalog_key_idx', table: 'user_workflow_triggers', createSql: `CREATE INDEX CONCURRENTLY user_workflow_triggers_catalog_key_idx ON user_workflow_triggers (user_id, ((recipe_namespace || '/'::text) || recipe_name))`, }, { - migrationVersion: '0129_user_access_foundation', + migrationVersion: '0130_user_access_foundation', name: 'team_workflow_triggers_catalog_key_idx', table: 'team_workflow_triggers', createSql: `CREATE INDEX CONCURRENTLY team_workflow_triggers_catalog_key_idx ON team_workflow_triggers (((recipe_namespace || '/'::text) || recipe_name), team_id)`, }, { - migrationVersion: '0129_user_access_foundation', + migrationVersion: '0130_user_access_foundation', name: 'workflow_runs_actor_catalog_idx', table: 'workflow_runs', createSql: `CREATE INDEX CONCURRENTLY workflow_runs_actor_catalog_idx @@ -86,7 +86,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr WHERE actor_type = 'user' AND actor_id IS NOT NULL`, }, { - migrationVersion: '0129_user_access_foundation', + migrationVersion: '0130_user_access_foundation', name: 'workflow_runs_team_catalog_idx', table: 'workflow_runs', createSql: `CREATE INDEX CONCURRENTLY workflow_runs_team_catalog_idx @@ -95,7 +95,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr WHERE team_id IS NOT NULL`, }, { - migrationVersion: '0129_user_access_foundation', + migrationVersion: '0130_user_access_foundation', name: 'workflow_runs_usage_team_catalog_idx', table: 'workflow_runs', createSql: `CREATE INDEX CONCURRENTLY workflow_runs_usage_team_catalog_idx @@ -104,7 +104,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr WHERE usage_team_id IS NOT NULL`, }, { - migrationVersion: '0129_user_access_foundation', + migrationVersion: '0130_user_access_foundation', name: 'workflow_approval_user_catalog_idx', table: 'workflow_approval_requests', createSql: `CREATE INDEX CONCURRENTLY workflow_approval_user_catalog_idx @@ -113,7 +113,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr WHERE target_user_id IS NOT NULL`, }, { - migrationVersion: '0129_user_access_foundation', + migrationVersion: '0130_user_access_foundation', name: 'workflow_approval_team_catalog_idx', table: 'workflow_approval_requests', createSql: `CREATE INDEX CONCURRENTLY workflow_approval_team_catalog_idx @@ -122,7 +122,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr WHERE target_team_id IS NOT NULL`, }, { - migrationVersion: '0129_user_access_foundation', + migrationVersion: '0130_user_access_foundation', name: 'notification_user_catalog_idx', table: 'notification_deliveries', createSql: `CREATE INDEX CONCURRENTLY notification_user_catalog_idx @@ -130,7 +130,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr INCLUDE (expires_at, status, event_type) WHERE audience ? 'userId'`, }, { - migrationVersion: '0129_user_access_foundation', + migrationVersion: '0130_user_access_foundation', name: 'notification_team_catalog_idx', table: 'notification_deliveries', createSql: `CREATE INDEX CONCURRENTLY notification_team_catalog_idx @@ -138,7 +138,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr INCLUDE (expires_at, status, event_type) WHERE audience ? 'teamId'`, }, { - migrationVersion: '0129_user_access_foundation', + migrationVersion: '0130_user_access_foundation', name: 'gfs_grants_subject_resource_catalog_idx', table: 'gfs_grants', createSql: `CREATE INDEX CONCURRENTLY gfs_grants_subject_resource_catalog_idx @@ -146,7 +146,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr INCLUDE (id, drive, permissions, inherit)`, }, { - migrationVersion: '0129_user_access_foundation', + migrationVersion: '0130_user_access_foundation', name: 'gfs_shares_subject_resource_catalog_idx', table: 'gfs_shares', createSql: `CREATE INDEX CONCURRENTLY gfs_shares_subject_resource_catalog_idx @@ -154,35 +154,35 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr INCLUDE (id, drive, permissions, include_descendants)`, }, { - migrationVersion: '0131_catalog_utf8_ordering', + migrationVersion: '0132_catalog_utf8_ordering', name: 'user_agents_catalog_utf8_idx', table: 'user_agents', createSql: `CREATE INDEX CONCURRENTLY user_agents_catalog_utf8_idx ON user_agents (user_id, catalog_utf8_bytes(agent_name))`, }, { - migrationVersion: '0131_catalog_utf8_ordering', + migrationVersion: '0132_catalog_utf8_ordering', name: 'team_agents_catalog_utf8_idx', table: 'team_agents', createSql: `CREATE INDEX CONCURRENTLY team_agents_catalog_utf8_idx ON team_agents (catalog_utf8_bytes(agent_name), team_id)`, }, { - migrationVersion: '0131_catalog_utf8_ordering', + migrationVersion: '0132_catalog_utf8_ordering', name: 'user_contexts_catalog_utf8_idx', table: 'user_contexts', createSql: `CREATE INDEX CONCURRENTLY user_contexts_catalog_utf8_idx ON user_contexts (user_id, catalog_utf8_bytes(context_id))`, }, { - migrationVersion: '0131_catalog_utf8_ordering', + migrationVersion: '0132_catalog_utf8_ordering', name: 'team_contexts_catalog_utf8_idx', table: 'team_contexts', createSql: `CREATE INDEX CONCURRENTLY team_contexts_catalog_utf8_idx ON team_contexts (catalog_utf8_bytes(context_id), team_id)`, }, { - migrationVersion: '0131_catalog_utf8_ordering', + migrationVersion: '0132_catalog_utf8_ordering', name: 'user_workflow_triggers_catalog_utf8_idx', table: 'user_workflow_triggers', createSql: `CREATE INDEX CONCURRENTLY user_workflow_triggers_catalog_utf8_idx @@ -190,7 +190,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr (user_id, catalog_utf8_bytes(recipe_namespace || '/' || recipe_name))`, }, { - migrationVersion: '0131_catalog_utf8_ordering', + migrationVersion: '0132_catalog_utf8_ordering', name: 'team_workflow_triggers_catalog_utf8_idx', table: 'team_workflow_triggers', createSql: `CREATE INDEX CONCURRENTLY team_workflow_triggers_catalog_utf8_idx @@ -198,7 +198,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr (catalog_utf8_bytes(recipe_namespace || '/' || recipe_name), team_id)`, }, { - migrationVersion: '0131_catalog_utf8_ordering', + migrationVersion: '0132_catalog_utf8_ordering', name: 'operational_relationship_catalog_utf8_target_idx', table: 'operational_resource_relationships', createSql: `CREATE INDEX CONCURRENTLY operational_relationship_catalog_utf8_target_idx @@ -654,6 +654,6 @@ async function prepareCatalogUtf8Function(db: DbClient): Promise { export async function preparePr1Migration(db: DbClient, version: string): Promise { const indexes = PR1_ONLINE_INDEX_PLAN.filter(entry => entry.migrationVersion === version) if (indexes.length === 0) return - if (version === '0131_catalog_utf8_ordering') await prepareCatalogUtf8Function(db) + if (version === '0132_catalog_utf8_ordering') await prepareCatalogUtf8Function(db) for (const index of indexes) await ensureOnlineIndex(db, index) } diff --git a/control-api/src/migrations/userAccessFoundationDefinerTempShadowMigration.ts b/control-api/src/migrations/userAccessFoundationDefinerTempShadowMigration.ts index 3c0afdc2cb..221a7b94d0 100644 --- a/control-api/src/migrations/userAccessFoundationDefinerTempShadowMigration.ts +++ b/control-api/src/migrations/userAccessFoundationDefinerTempShadowMigration.ts @@ -4,7 +4,7 @@ import type { DbClient } from '../db.js' * Harden the installed access-foundation SECURITY DEFINER functions without * changing their bodies, ownership, grants, or behavior. * The obsolete authorization_bump_catalog_revision() routine is intentionally - * excluded because 0132_composable_catalog_revisions drops it before this runs. + * excluded because 0133_composable_catalog_revisions drops it before this runs. */ export async function applyUserAccessFoundationDefinerTempShadowHardening( db: DbClient diff --git a/control-api/test/db.catalogUtf8OrderingMigration.test.ts b/control-api/test/db.catalogUtf8OrderingMigration.test.ts index 2b5449a042..cf09f99989 100644 --- a/control-api/test/db.catalogUtf8OrderingMigration.test.ts +++ b/control-api/test/db.catalogUtf8OrderingMigration.test.ts @@ -8,7 +8,7 @@ describe('0131 catalog UTF-8 ordering migration', () => { it('installs the immutable byte function and complete supporting index class', async () => { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0131_catalog_utf8_ordering' + candidate => candidate.version === '0132_catalog_utf8_ordering' ) expect(migration).toBeDefined() diff --git a/control-api/test/db.composableCatalogRevisionMigration.test.ts b/control-api/test/db.composableCatalogRevisionMigration.test.ts index 2d143eea2e..2eb4c2a9b0 100644 --- a/control-api/test/db.composableCatalogRevisionMigration.test.ts +++ b/control-api/test/db.composableCatalogRevisionMigration.test.ts @@ -8,7 +8,7 @@ describe('0132 composable catalog revisions migration', () => { it('replaces the singleton trigger class with scoped transactional writers', async () => { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0132_composable_catalog_revisions' + candidate => candidate.version === '0133_composable_catalog_revisions' ) expect(migration).toBeDefined() diff --git a/control-api/test/db.gfsCatalogRevisionComponentsMigration.test.ts b/control-api/test/db.gfsCatalogRevisionComponentsMigration.test.ts index 6496e30c05..5d56a5724a 100644 --- a/control-api/test/db.gfsCatalogRevisionComponentsMigration.test.ts +++ b/control-api/test/db.gfsCatalogRevisionComponentsMigration.test.ts @@ -8,13 +8,13 @@ describe('0133 GFS catalog revision components migration', () => { it('fixes forward after a database has already recorded the prior composable revision', async () => { const { CONTROL_API_MIGRATIONS, initDb } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0133_gfs_catalog_revision_components' + candidate => candidate.version === '0134_gfs_catalog_revision_components' ) const composableIndex = CONTROL_API_MIGRATIONS.findIndex( - candidate => candidate.version === '0132_composable_catalog_revisions' + candidate => candidate.version === '0133_composable_catalog_revisions' ) const gfsIndex = CONTROL_API_MIGRATIONS.findIndex( - candidate => candidate.version === '0133_gfs_catalog_revision_components' + candidate => candidate.version === '0134_gfs_catalog_revision_components' ) expect(migration).toBeDefined() expect(gfsIndex).toBe(composableIndex + 1) @@ -39,7 +39,7 @@ describe('0133 GFS catalog revision components migration', () => { expect(appliedSql).toContain('INSERT INTO authorization_resource_revisions') expect(clientQuery).toHaveBeenCalledWith( expect.stringContaining('INSERT INTO schema_migrations'), - ['0133_gfs_catalog_revision_components'] + ['0134_gfs_catalog_revision_components'] ) }) }) diff --git a/control-api/test/db.migrationsOrder.test.ts b/control-api/test/db.migrationsOrder.test.ts index f35507ab38..fc0c9fd210 100644 --- a/control-api/test/db.migrationsOrder.test.ts +++ b/control-api/test/db.migrationsOrder.test.ts @@ -64,20 +64,23 @@ describe('CONTROL_API_MIGRATIONS ordering invariant', () => { '0127_password_evaluation_retention', '0128_password_work_ownership', ] + const incomingDev = '0129_workflow_schedules_concurrency_policy' const task106 = [ - '0129_user_access_foundation', - '0130_invitation_delivery_commands', - '0131_catalog_utf8_ordering', - '0132_composable_catalog_revisions', - '0133_gfs_catalog_revision_components', - '0134_user_access_foundation_definer_temp_shadow_hardening', - '0135_legacy_password_security_epoch_backfill', - '0143_authorization_revision_delete_compatibility', + '0130_user_access_foundation', + '0131_invitation_delivery_commands', + '0132_catalog_utf8_ordering', + '0133_composable_catalog_revisions', + '0134_gfs_catalog_revision_components', + '0135_user_access_foundation_definer_temp_shadow_hardening', + '0136_legacy_password_security_epoch_backfill', + '0144_authorization_revision_delete_compatibility', ] expect( versions.slice(versions.indexOf(parentDev[0]!), versions.indexOf(parentDev[0]!) + 3) ).toEqual(parentDev) + expect(versions[versions.indexOf(parentDev.at(-1)!) + 1]).toBe(incomingDev) + expect(versions.indexOf(incomingDev)).toBeLessThan(versions.indexOf(task106[0]!)) expect( versions.slice(versions.indexOf(task106[0]!), versions.indexOf(task106[0]!) + 8) ).toEqual(task106) @@ -97,20 +100,22 @@ describe('CONTROL_API_MIGRATIONS ordering invariant', () => { it('registers the narrow R56-B1 access-foundation definer hardening migration', async () => { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') - const version = '0134_user_access_foundation_definer_temp_shadow_hardening' + const version = '0135_user_access_foundation_definer_temp_shadow_hardening' const migration = CONTROL_API_MIGRATIONS.find(candidate => candidate.version === version) const versions = CONTROL_API_MIGRATIONS.map(candidate => candidate.version) expect(migration).toBeDefined() expect(migration?.legacyVersions).toEqual([ + '0134_user_access_foundation_definer_temp_shadow_hardening', '012b_user_access_foundation_definer_temp_shadow_hardening', '012a_user_access_foundation_definer_temp_shadow_hardening', ]) - expect(versions.indexOf('0133_gfs_catalog_revision_components')).toBeLessThan( + expect(migration?.preserveLegacyReceipts).toBe(true) + expect(versions.indexOf('0134_gfs_catalog_revision_components')).toBeLessThan( versions.indexOf(version) ) expect(versions.indexOf(version)).toBeLessThan( - versions.indexOf('0135_legacy_password_security_epoch_backfill') + versions.indexOf('0136_legacy_password_security_epoch_backfill') ) if (!migration) return @@ -142,7 +147,7 @@ describe('CONTROL_API_MIGRATIONS ordering invariant', () => { 'authorization_bump_operational_relationship_revision()', ] - // 0132_composable_catalog_revisions removes this trigger before 0134 runs. + // 0133_composable_catalog_revisions removes this trigger before 0135 runs. expect(signatures).not.toContain('authorization_bump_catalog_revision()') expect(sql.match(/ALTER FUNCTION public\./g)).toHaveLength(signatures.length) for (const signature of signatures) { @@ -340,18 +345,21 @@ describe('CONTROL_API_MIGRATIONS ordering invariant', () => { it('registers the narrow 0129 team-delete compatibility successor', async () => { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const predecessorIndex = CONTROL_API_MIGRATIONS.findIndex( - candidate => candidate.version === '0129_user_access_foundation' + candidate => candidate.version === '0130_user_access_foundation' ) const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0143_authorization_revision_delete_compatibility' + candidate => candidate.version === '0144_authorization_revision_delete_compatibility' ) expect(predecessorIndex).toBeGreaterThanOrEqual(0) expect(migration).toBeDefined() expect(CONTROL_API_MIGRATIONS.at(-1)?.version).toBe( - '0143_authorization_revision_delete_compatibility' + '0144_authorization_revision_delete_compatibility' ) - expect(migration?.legacyVersions).toEqual(['0138_authorization_revision_delete_compatibility']) + expect(migration?.legacyVersions).toEqual([ + '0143_authorization_revision_delete_compatibility', + '0138_authorization_revision_delete_compatibility', + ]) if (!migration) return const query = vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }) diff --git a/control-api/test/db.oauthGrantsOwnerGeneralization.test.ts b/control-api/test/db.oauthGrantsOwnerGeneralization.test.ts index a73f80cdf4..648d32ac22 100644 --- a/control-api/test/db.oauthGrantsOwnerGeneralization.test.ts +++ b/control-api/test/db.oauthGrantsOwnerGeneralization.test.ts @@ -64,7 +64,7 @@ describe('0101 oauth_grants owner generalization migration', () => { versions.indexOf('0108_llm_provider_attempts_sdk_link_on_delete_set_null') ) expect(versions.indexOf('0124_entity_change_definer_search_path')).toBeLessThan( - versions.indexOf('0129_user_access_foundation') + versions.indexOf('0130_user_access_foundation') ) expect(versions).toContain('0099_gfs_upload_finalizing_recovery') }) diff --git a/control-api/test/db.realPostgresMigration.integration.test.ts b/control-api/test/db.realPostgresMigration.integration.test.ts index 189f498980..5c92593312 100644 --- a/control-api/test/db.realPostgresMigration.integration.test.ts +++ b/control-api/test/db.realPostgresMigration.integration.test.ts @@ -2815,7 +2815,7 @@ describeRealPostgres('control-api real Postgres migrations', () => { it('prevents temp-type shadowing in access-foundation SECURITY DEFINER triggers', async () => { const { initDb } = await import('../src/db.js') const connector = { connect: () => dbPool.connect() } - const hardeningVersion = '0134_user_access_foundation_definer_temp_shadow_hardening' + const hardeningVersion = '0135_user_access_foundation_definer_temp_shadow_hardening' const preUpgradeFunctions = await readAccessFoundationDefinerState(dbPool) expect(preUpgradeFunctions.rows.map(row => row.function_name)).toEqual( @@ -2834,11 +2834,19 @@ describeRealPostgres('control-api real Postgres migrations', () => { for (const signature of accessFoundationDefinerSignatures) { await dbPool.query(`ALTER FUNCTION public.${signature} SET search_path = pg_catalog, public`) } + const hardeningReceipts = [ + hardeningVersion, + '0134_user_access_foundation_definer_temp_shadow_hardening', + '012b_user_access_foundation_definer_temp_shadow_hardening', + '012a_user_access_foundation_definer_temp_shadow_hardening', + ] const removedMarker = await dbPool.query( - `DELETE FROM schema_migrations WHERE version = $1 RETURNING version`, - [hardeningVersion] + `DELETE FROM schema_migrations + WHERE version = ANY($1::text[]) + RETURNING version`, + [hardeningReceipts] ) - expect(removedMarker.rows).toEqual([{ version: hardeningVersion }]) + expect(removedMarker.rows.map(row => row.version).sort()).toEqual([...hardeningReceipts].sort()) await initDb(connector) const migratedMarker = await dbPool.query<{ version: string }>( diff --git a/control-api/test/db.userAccessFoundationMigration.test.ts b/control-api/test/db.userAccessFoundationMigration.test.ts index c53f8a6b75..0bba89214a 100644 --- a/control-api/test/db.userAccessFoundationMigration.test.ts +++ b/control-api/test/db.userAccessFoundationMigration.test.ts @@ -10,7 +10,7 @@ vi.mock('../src/config.js', () => ({ async function migrationSql(): Promise { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0129_user_access_foundation' + candidate => candidate.version === '0130_user_access_foundation' ) expect(migration).toBeDefined() @@ -22,7 +22,7 @@ async function migrationSql(): Promise { async function legacyEpochBackfillSql(): Promise { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0135_legacy_password_security_epoch_backfill' + candidate => candidate.version === '0136_legacy_password_security_epoch_backfill' ) expect(migration).toBeDefined() @@ -32,7 +32,7 @@ async function legacyEpochBackfillSql(): Promise { } describe('user-access foundation migration', () => { - it('keeps current-dev identities and aliases the displaced Task 106 identity', async () => { + it('keeps current-dev identities and aliases displaced Task 106 identities', async () => { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const parent = CONTROL_API_MIGRATIONS.find( candidate => candidate.version === '0125_admin_subscription_rate_limit_namespace' @@ -46,16 +46,22 @@ describe('user-access foundation migration', () => { const ownership = CONTROL_API_MIGRATIONS.find( candidate => candidate.version === '0128_password_work_ownership' ) + const workflowSchedule = CONTROL_API_MIGRATIONS.find( + candidate => candidate.version === '0129_workflow_schedules_concurrency_policy' + ) const foundation = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0129_user_access_foundation' + candidate => candidate.version === '0130_user_access_foundation' ) expect(parent).toBeDefined() expect(passwordAdmission?.legacyVersions).toEqual(['0125_bug192_password_admission']) expect(retention).toBeDefined() expect(ownership).toBeDefined() + expect(workflowSchedule).toBeDefined() + expect(workflowSchedule?.legacyVersions ?? []).not.toContain('0129_user_access_foundation') expect(foundation?.legacyVersions).toContain('0126_user_access_foundation') expect(foundation?.legacyVersions).toContain('0125_user_access_foundation') + expect(foundation?.legacyVersions).toContain('0129_user_access_foundation') expect(CONTROL_API_MIGRATIONS.indexOf(parent!)).toBeLessThan( CONTROL_API_MIGRATIONS.indexOf(passwordAdmission!) ) @@ -66,6 +72,9 @@ describe('user-access foundation migration', () => { CONTROL_API_MIGRATIONS.indexOf(ownership!) ) expect(CONTROL_API_MIGRATIONS.indexOf(ownership!)).toBeLessThan( + CONTROL_API_MIGRATIONS.indexOf(workflowSchedule!) + ) + expect(CONTROL_API_MIGRATIONS.indexOf(workflowSchedule!)).toBeLessThan( CONTROL_API_MIGRATIONS.indexOf(foundation!) ) }) @@ -74,8 +83,9 @@ describe('user-access foundation migration', () => { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const expectedAliases = new Map([ [ - '0129_user_access_foundation', + '0130_user_access_foundation', [ + '0129_user_access_foundation', '0126_user_access_foundation', '0125_user_access_foundation', '0109_user_access_foundation', @@ -84,8 +94,9 @@ describe('user-access foundation migration', () => { ], ], [ - '0130_invitation_delivery_commands', + '0131_invitation_delivery_commands', [ + '0130_invitation_delivery_commands', '0127_invitation_delivery_commands', '0126_invitation_delivery_commands', '010a_invitation_delivery_commands', @@ -94,8 +105,9 @@ describe('user-access foundation migration', () => { ], ], [ - '0131_catalog_utf8_ordering', + '0132_catalog_utf8_ordering', [ + '0131_catalog_utf8_ordering', '0128_catalog_utf8_ordering', '0127_catalog_utf8_ordering', '010b_catalog_utf8_ordering', @@ -104,8 +116,9 @@ describe('user-access foundation migration', () => { ], ], [ - '0132_composable_catalog_revisions', + '0133_composable_catalog_revisions', [ + '0132_composable_catalog_revisions', '0129_composable_catalog_revisions', '0128_composable_catalog_revisions', '010c_composable_catalog_revisions', @@ -114,8 +127,9 @@ describe('user-access foundation migration', () => { ], ], [ - '0133_gfs_catalog_revision_components', + '0134_gfs_catalog_revision_components', [ + '0133_gfs_catalog_revision_components', '012a_gfs_catalog_revision_components', '0129_gfs_catalog_revision_components', '010d_gfs_catalog_revision_components', @@ -124,15 +138,17 @@ describe('user-access foundation migration', () => { ], ], [ - '0134_user_access_foundation_definer_temp_shadow_hardening', + '0135_user_access_foundation_definer_temp_shadow_hardening', [ + '0134_user_access_foundation_definer_temp_shadow_hardening', '012b_user_access_foundation_definer_temp_shadow_hardening', '012a_user_access_foundation_definer_temp_shadow_hardening', ], ], [ - '0135_legacy_password_security_epoch_backfill', + '0136_legacy_password_security_epoch_backfill', [ + '0135_legacy_password_security_epoch_backfill', '0130_legacy_password_security_epoch_backfill', '010e_legacy_password_security_epoch_backfill', '010c_legacy_password_security_epoch_backfill', @@ -140,14 +156,18 @@ describe('user-access foundation migration', () => { ], ], [ - '0143_authorization_revision_delete_compatibility', - ['0138_authorization_revision_delete_compatibility'], + '0144_authorization_revision_delete_compatibility', + [ + '0143_authorization_revision_delete_compatibility', + '0138_authorization_revision_delete_compatibility', + ], ], ]) for (const [version, legacyVersions] of expectedAliases) { const migration = CONTROL_API_MIGRATIONS.find(candidate => candidate.version === version) expect(migration?.legacyVersions).toEqual(legacyVersions) + expect(migration?.preserveLegacyReceipts).toBe(true) } }) @@ -270,17 +290,17 @@ describe('user-access foundation migration', () => { it('dispatches the historical epoch fix-forward after already-recorded access migrations', async () => { const { CONTROL_API_MIGRATIONS, initDb } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0135_legacy_password_security_epoch_backfill' + candidate => candidate.version === '0136_legacy_password_security_epoch_backfill' ) expect(migration).toBeDefined() const gfsIndex = CONTROL_API_MIGRATIONS.findIndex( - candidate => candidate.version === '0133_gfs_catalog_revision_components' + candidate => candidate.version === '0134_gfs_catalog_revision_components' ) const hardeningIndex = CONTROL_API_MIGRATIONS.findIndex( - candidate => candidate.version === '0134_user_access_foundation_definer_temp_shadow_hardening' + candidate => candidate.version === '0135_user_access_foundation_definer_temp_shadow_hardening' ) const epochIndex = CONTROL_API_MIGRATIONS.findIndex( - candidate => candidate.version === '0135_legacy_password_security_epoch_backfill' + candidate => candidate.version === '0136_legacy_password_security_epoch_backfill' ) expect(hardeningIndex).toBe(gfsIndex + 1) expect(epochIndex).toBe(hardeningIndex + 1) @@ -302,7 +322,7 @@ describe('user-access foundation migration', () => { expect(appliedSql).toContain('historical_password_event') expect(clientQuery).toHaveBeenCalledWith( expect.stringContaining('INSERT INTO schema_migrations'), - ['0135_legacy_password_security_epoch_backfill'] + ['0136_legacy_password_security_epoch_backfill'] ) }) }) diff --git a/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts b/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts index 6de2836023..deba20255d 100644 --- a/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts +++ b/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts @@ -22,6 +22,23 @@ function quoteIdentifier(value: string): string { return `"${value.replace(/"/g, '""')}"` } +async function removeLegacyEpochMigrationReceipts(databasePool: Pool): Promise { + await databasePool.query( + `DELETE FROM schema_migrations + WHERE version = ANY($1::text[])`, + [ + [ + '0136_legacy_password_security_epoch_backfill', + '0135_legacy_password_security_epoch_backfill', + '0130_legacy_password_security_epoch_backfill', + '010e_legacy_password_security_epoch_backfill', + '010c_legacy_password_security_epoch_backfill', + '0106_legacy_password_security_epoch_backfill', + ], + ] + ) +} + function legacyToken(input: { userId: string; email: string; teamId: string; issuedAt: Date }) { vi.useFakeTimers() vi.setSystemTime(input.issuedAt) @@ -106,10 +123,7 @@ describeRealPostgres('legacy password epoch backfill on real PostgreSQL', () => validateLegacyUserSession(old.token, old.claims, { db: databasePool }) ).resolves.toMatchObject({ status: 'valid' }) - await databasePool.query( - `DELETE FROM schema_migrations - WHERE version = '0135_legacy_password_security_epoch_backfill'` - ) + await removeLegacyEpochMigrationReceipts(databasePool) await initDb({ connect: () => databasePool.connect() }) await expect( @@ -128,7 +142,7 @@ describeRealPostgres('legacy password epoch backfill on real PostgreSQL', () => const recorded = await databasePool.query<{ count: string }>( `SELECT COUNT(*)::text AS count FROM schema_migrations - WHERE version = '0135_legacy_password_security_epoch_backfill'` + WHERE version = '0136_legacy_password_security_epoch_backfill'` ) expect(recorded.rows[0]?.count).toBe('1') }) @@ -159,15 +173,9 @@ describeRealPostgres('legacy password epoch backfill on real PostgreSQL', () => ($3, $4, 'newer')`, [olderUser, new Date('2026-08-27T09:00:00Z'), newerUser, new Date('2026-08-27T11:00:00Z')] ) - await databasePool.query( - `DELETE FROM schema_migrations - WHERE version = '0135_legacy_password_security_epoch_backfill'` - ) + await removeLegacyEpochMigrationReceipts(databasePool) await initDb({ connect: () => databasePool.connect() }) - await databasePool.query( - `DELETE FROM schema_migrations - WHERE version = '0135_legacy_password_security_epoch_backfill'` - ) + await removeLegacyEpochMigrationReceipts(databasePool) await initDb({ connect: () => databasePool.connect() }) const rows = await databasePool.query<{ diff --git a/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts index 9abd8e21e8..9bb6193726 100644 --- a/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts +++ b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts @@ -25,38 +25,71 @@ const HISTORICAL_TASK106_DB_SOURCE_SHA256 = 'f2e06aba1cfa84a920eedefdb1ec56748efccbacf5baecad7caede4938364379' const HISTORICAL_TASK106_MIGRATION_RUNNER_SOURCE_SHA256 = '65023efa1630261ce8d180fc2fa8789e517eb6a13ace2a6458ba95236046748f' +const PUBLISHED_PR1_SOURCE_COMMIT = '113479887ce4fdfd787e9cb1100d74bae596640f' +const PUBLISHED_PR1_DB_SOURCE_SHA256 = + '81a94222ded611d22e03e674ace2aaa07cbcca4989fecb7008507e67e56d203f' +const PUBLISHED_PR1_MIGRATION_RUNNER_SOURCE_SHA256 = + '5effefa5cfc88d9a1edcae73edfe8df749279a7987b03962263bd432bece92e5' const OBSERVED_DEV_SOURCE_COMMIT = '0b26101eb247adcc44f70d39451f3e82c3225d47' const OBSERVED_DEV_DB_SOURCE_SHA256 = '1948ad306fe56b368f120edbc70ddbac6cb9b981d9fd218e7ded2575ed3b2beb' const OBSERVED_DEV_RECEIPT_COUNT = 133 const OBSERVED_DEV_RECEIPT_SET_SHA256 = 'acb72ad12e1d342310832cf7628ab8351a80cee7311111039b4c2b9720077307' +const LATEST_DEV_SOURCE_COMMIT = '87f962fb999ed5dbcab90bd52562de0eba4aa564' +const LATEST_DEV_DB_SOURCE_SHA256 = + '616678c106f762a8b82ea45f42e01ddc42fb90dda935d62fd0f4b72c2a2eeebc' +const LATEST_DEV_MIGRATION_ENTRY_SOURCE_SHA256 = + '2b582252753c6455fc85d7cb4563313e3b63ecf7c7630aa6252f70f0bb286a81' +const LATEST_DEV_RECEIPT_COUNT = 134 const DISPLACED_TASK106_RECEIPTS = Object.freeze([ - ['0126_user_access_foundation', '0129_user_access_foundation'], - ['0127_invitation_delivery_commands', '0130_invitation_delivery_commands'], - ['0128_catalog_utf8_ordering', '0131_catalog_utf8_ordering'], - ['0129_composable_catalog_revisions', '0132_composable_catalog_revisions'], - ['012a_gfs_catalog_revision_components', '0133_gfs_catalog_revision_components'], + ['0126_user_access_foundation', '0130_user_access_foundation'], + ['0127_invitation_delivery_commands', '0131_invitation_delivery_commands'], + ['0128_catalog_utf8_ordering', '0132_catalog_utf8_ordering'], + ['0129_composable_catalog_revisions', '0133_composable_catalog_revisions'], + ['012a_gfs_catalog_revision_components', '0134_gfs_catalog_revision_components'], [ '012b_user_access_foundation_definer_temp_shadow_hardening', - '0134_user_access_foundation_definer_temp_shadow_hardening', + '0135_user_access_foundation_definer_temp_shadow_hardening', ], - ['0130_legacy_password_security_epoch_backfill', '0135_legacy_password_security_epoch_backfill'], + ['0130_legacy_password_security_epoch_backfill', '0136_legacy_password_security_epoch_backfill'], [ '0138_authorization_revision_delete_compatibility', + '0144_authorization_revision_delete_compatibility', + ], +] as const) + +const DISPLACED_PUBLISHED_PR1_RECEIPTS = Object.freeze([ + ['0129_user_access_foundation', '0130_user_access_foundation'], + ['0130_invitation_delivery_commands', '0131_invitation_delivery_commands'], + ['0131_catalog_utf8_ordering', '0132_catalog_utf8_ordering'], + ['0132_composable_catalog_revisions', '0133_composable_catalog_revisions'], + ['0133_gfs_catalog_revision_components', '0134_gfs_catalog_revision_components'], + [ + '0134_user_access_foundation_definer_temp_shadow_hardening', + '0135_user_access_foundation_definer_temp_shadow_hardening', + ], + ['0135_legacy_password_security_epoch_backfill', '0136_legacy_password_security_epoch_backfill'], + [ '0143_authorization_revision_delete_compatibility', + '0144_authorization_revision_delete_compatibility', ], ] as const) const TASK106_HISTORICAL_SOURCE_DIR = process.env.TASK106_R61_B1_HISTORICAL_SOURCE_DIR +const PUBLISHED_PR1_SOURCE_DIR = process.env.TASK106_R61_B1_PUBLISHED_PR1_SOURCE_DIR const OBSERVED_DEV_SOURCE_DIR = process.env.TASK106_R61_B1_DEV_SOURCE_DIR +const LATEST_DEV_SOURCE_DIR = process.env.TASK106_R61_B1_LATEST_DEV_SOURCE_DIR if ( process.env.CONTROL_API_REAL_PG_REQUIRED === '1' && - (!TASK106_HISTORICAL_SOURCE_DIR || !OBSERVED_DEV_SOURCE_DIR) + (!TASK106_HISTORICAL_SOURCE_DIR || + !PUBLISHED_PR1_SOURCE_DIR || + !OBSERVED_DEV_SOURCE_DIR || + !LATEST_DEV_SOURCE_DIR) ) { throw new Error( - 'R61-B1 real PostgreSQL lane requires both pinned historical producer source directories' + 'R61-B1 real PostgreSQL lane requires all pinned historical/current producer source directories' ) } @@ -80,6 +113,16 @@ const HISTORICAL_TASK106_RUNNER: HistoricalRunnerPin = { ], } +const PUBLISHED_PR1_RUNNER: HistoricalRunnerPin = { + label: 'published PR1 rollback', + sourceDir: PUBLISHED_PR1_SOURCE_DIR, + commit: PUBLISHED_PR1_SOURCE_COMMIT, + sourceFiles: [ + ['control-api/src/db.ts', PUBLISHED_PR1_DB_SOURCE_SHA256], + ['control-api/src/migrations/migrationRunner.ts', PUBLISHED_PR1_MIGRATION_RUNNER_SOURCE_SHA256], + ], +} + const OBSERVED_DEV_RUNNER: HistoricalRunnerPin = { label: 'observed DEV', sourceDir: OBSERVED_DEV_SOURCE_DIR, @@ -87,6 +130,16 @@ const OBSERVED_DEV_RUNNER: HistoricalRunnerPin = { sourceFiles: [['control-api/src/db.ts', OBSERVED_DEV_DB_SOURCE_SHA256]], } +const LATEST_DEV_RUNNER: HistoricalRunnerPin = { + label: 'latest fetched DEV', + sourceDir: LATEST_DEV_SOURCE_DIR, + commit: LATEST_DEV_SOURCE_COMMIT, + sourceFiles: [ + ['control-api/src/db.ts', LATEST_DEV_DB_SOURCE_SHA256], + ['control-api/src/migrate.ts', LATEST_DEV_MIGRATION_ENTRY_SOURCE_SHA256], + ], +} + let ephemeralMigrationSigningKeys: | { rpc: string @@ -319,12 +372,20 @@ async function versions(pool: Pool): Promise { } async function removeMigrationReceiptForReplay(pool: Pool, version: string): Promise { - const versionsToRemove = - version === '0129_user_access_foundation' - ? [version, '0143_authorization_revision_delete_compatibility'] - : [version] + const migration = CONTROL_API_MIGRATIONS.find(candidate => candidate.version === version) + const versionsToRemove = new Set([ + version, + ...(migration?.preserveLegacyReceipts ? (migration.legacyVersions ?? []) : []), + ]) + if (version === '0130_user_access_foundation') { + const compatibilityMigration = CONTROL_API_MIGRATIONS.find( + candidate => candidate.version === '0144_authorization_revision_delete_compatibility' + ) + versionsToRemove.add('0144_authorization_revision_delete_compatibility') + for (const alias of compatibilityMigration?.legacyVersions ?? []) versionsToRemove.add(alias) + } await pool.query('DELETE FROM schema_migrations WHERE version = ANY($1::text[])', [ - versionsToRemove, + [...versionsToRemove], ]) } @@ -478,7 +539,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { for (const candidate of cases) { await databasePool.query(`CREATE INDEX ${candidate.name} ON ${table} ${candidate.actual}`) const entry: OnlineIndexDefinition = { - migrationVersion: '0129_user_access_foundation', + migrationVersion: '0130_user_access_foundation', name: candidate.name, table, createSql: `CREATE INDEX CONCURRENTLY ${candidate.name} ON ${table} ${candidate.expected}`, @@ -492,7 +553,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { await databasePool.query(`CREATE INDEX ${uniqueName} ON ${table} (userid)`) await expect( ensureOnlineIndex(databasePool, { - migrationVersion: '0129_user_access_foundation', + migrationVersion: '0130_user_access_foundation', name: uniqueName, table, unique: true, @@ -504,7 +565,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { it('repairs an equivalent interrupted index and enforces the online bound', async () => { const name = `d34_interrupted_${randomBytes(4).toString('hex')}` const entry: OnlineIndexDefinition = { - migrationVersion: '0129_user_access_foundation', + migrationVersion: '0130_user_access_foundation', name, table: 'd34_interrupted_index', unique: true, @@ -553,7 +614,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { const functionName = `d34_slow_index_value_${suffix}` const name = `d34_slow_index_${suffix}_idx` const entry: OnlineIndexDefinition = { - migrationVersion: '0129_user_access_foundation', + migrationVersion: '0130_user_access_foundation', name, table, createSql: `CREATE INDEX CONCURRENTLY ${name} ON ${table} (${functionName}(value))`, @@ -665,10 +726,8 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { it('bounds ordinary DDL lock acquisition and never runs the later version', async () => { const locker = await databasePool.connect() - await databasePool.query( - `DELETE FROM schema_migrations - WHERE version IN ('0132_composable_catalog_revisions', '0133_gfs_catalog_revision_components')` - ) + await removeMigrationReceiptForReplay(databasePool, '0133_composable_catalog_revisions') + await removeMigrationReceiptForReplay(databasePool, '0134_gfs_catalog_revision_components') await locker.query('BEGIN') await locker.query('LOCK TABLE team_members IN ACCESS EXCLUSIVE MODE') const started = Date.now() @@ -683,8 +742,8 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { expect(Date.now() - started).toBeGreaterThanOrEqual(9_000) expect(Date.now() - started).toBeLessThan(15_000) const failedVersions = await versions(databasePool) - expect(failedVersions).not.toContain('0132_composable_catalog_revisions') - expect(failedVersions).not.toContain('0133_gfs_catalog_revision_components') + expect(failedVersions).not.toContain('0133_composable_catalog_revisions') + expect(failedVersions).not.toContain('0134_gfs_catalog_revision_components') await initDb({ connect: () => databasePool.connect() }) }, 20_000) @@ -693,7 +752,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { const appliedVersions = new Set([ ...DEV_POST_0106_MIGRATION_VERSIONS, ...PR1_MIGRATION_VERSIONS.slice(0, 3), - '0143_authorization_revision_delete_compatibility', + '0144_authorization_revision_delete_compatibility', ]) const recordTable = `d34_record_${randomBytes(4).toString('hex')}` await client.query(`CREATE TEMP TABLE ${recordTable}(version text PRIMARY KEY)`) @@ -705,7 +764,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { ...PR1_MIGRATION_VERSIONS.map(version => ({ version, apply: async (db: DbClient) => { - if (version === '0132_composable_catalog_revisions') { + if (version === '0133_composable_catalog_revisions') { await db.query('SELECT pg_sleep(20)') } }, @@ -729,7 +788,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { `SELECT version FROM ${recordTable} ORDER BY version` ) expect(recorded.rows).toEqual([]) - expect(appliedVersions).not.toContain('0133_gfs_catalog_revision_components') + expect(appliedVersions).not.toContain('0134_gfs_catalog_revision_components') } finally { client.release(true) } @@ -835,6 +894,22 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { ]) const afterFirstRerun = await versions(isolatedPool) + const beforeHistoricalRollback = await versions(isolatedPool) + await runPinnedMigrationProducer(HISTORICAL_TASK106_RUNNER, isolatedUrl) + expect(await versions(isolatedPool)).toEqual(beforeHistoricalRollback) + expect(await currentMigrationSchemaAndPrivileges(isolatedPool)).toEqual( + schemaAndPrivilegesAfter + ) + expect(await passwordMigrationPrivileges(isolatedPool)).toEqual([ + 'password_identifier_state:DELETE:true', + 'password_identifier_state:INSERT:true', + 'password_identifier_state:SELECT:true', + 'password_identifier_state:UPDATE:true', + 'password_verification_work:DELETE:true', + 'password_verification_work:INSERT:true', + 'password_verification_work:SELECT:true', + 'password_verification_work:UPDATE:false', + ]) const secondRun = await withMigrationApplyObserver(observedVersions, async () => initDb({ connect: () => isolatedPool.connect() }) ) @@ -846,6 +921,180 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { } }) + it('reconciles the published PR1 migration identities as same-body aliases', async () => { + const databaseName = `r61_published_pr1_${randomBytes(6).toString('hex')}` + const isolatedUrl = databaseUrl(adminUrl!, databaseName) + await adminPool.query(`CREATE DATABASE ${quoteIdentifier(databaseName)}`) + isolatedDatabases.push(databaseName) + const isolatedPool = new Pool({ connectionString: isolatedUrl }) + try { + await runPinnedMigrationProducer(PUBLISHED_PR1_RUNNER, isolatedUrl) + + const publishedReceipts = await versions(isolatedPool) + for (const [publishedReceipt, currentVersion] of DISPLACED_PUBLISHED_PR1_RECEIPTS) { + expect(publishedReceipts, `${publishedReceipt} published producer receipt`).toContain( + publishedReceipt + ) + expect(publishedReceipts, `${currentVersion} not in published producer`).not.toContain( + currentVersion + ) + } + const beforeSchemaAndPrivileges = await currentMigrationSchemaAndPrivileges(isolatedPool) + const observedVersions = new Set([ + ...PR1_MIGRATION_VERSIONS, + ...DEV_POST_0106_MIGRATION_VERSIONS, + ]) + const currentRun = await withMigrationApplyObserver(observedVersions, async () => + initDb({ connect: () => isolatedPool.connect() }) + ) + const reconciledReceipts = await versions(isolatedPool) + for (const [publishedReceipt, currentVersion] of DISPLACED_PUBLISHED_PR1_RECEIPTS) { + const migration = CONTROL_API_MIGRATIONS.find( + candidate => candidate.version === currentVersion + ) + expect(migration?.legacyVersions, `${publishedReceipt} alias owner`).toContain( + publishedReceipt + ) + expect(reconciledReceipts, `${publishedReceipt} preserved`).toContain(publishedReceipt) + expect(reconciledReceipts, `${currentVersion} canonical receipt`).toContain(currentVersion) + expect( + currentRun.applyCounts.get(currentVersion) ?? 0, + `${currentVersion} body replay` + ).toBe(0) + } + for (const version of PR1_MIGRATION_VERSIONS) { + const migration = CONTROL_API_MIGRATIONS.find(candidate => candidate.version === version) + expect(migration?.preserveLegacyReceipts, `${version} preserves rollback aliases`).toBe( + true + ) + for (const alias of migration?.legacyVersions ?? []) { + expect(reconciledReceipts, `${version} rollback alias ${alias}`).toContain(alias) + } + } + for (const version of [ + '0126_bug192_password_admission', + '0127_password_evaluation_retention', + '0128_password_work_ownership', + ]) { + expect(currentRun.applyCounts.get(version) ?? 0, `${version} body replay`).toBe(0) + } + expect( + currentRun.applyCounts.get('0129_workflow_schedules_concurrency_policy'), + 'latest DEV migration applies exactly once to the published PR1 state' + ).toBe(1) + await assertDbReady(isolatedPool) + const afterSchemaAndPrivileges = await currentMigrationSchemaAndPrivileges(isolatedPool) + expect(afterSchemaAndPrivileges).toEqual(beforeSchemaAndPrivileges) + expect( + await isolatedPool.query(`SELECT to_regclass('public.workflow_schedules') AS relation`) + ).toMatchObject({ rows: [{ relation: 'workflow_schedules' }] }) + + const beforeRollbackRunner = await versions(isolatedPool) + await runPinnedMigrationProducer(PUBLISHED_PR1_RUNNER, isolatedUrl) + expect(await versions(isolatedPool)).toEqual(beforeRollbackRunner) + expect(await currentMigrationSchemaAndPrivileges(isolatedPool)).toEqual( + afterSchemaAndPrivileges + ) + await assertDbReady(isolatedPool) + + const afterUpgrade = await versions(isolatedPool) + const replay = await withMigrationApplyObserver(observedVersions, async () => + initDb({ connect: () => isolatedPool.connect() }) + ) + expect(await versions(isolatedPool)).toEqual(afterUpgrade) + expect([...replay.applyCounts.values()]).toEqual([]) + await assertDbReady(isolatedPool) + } finally { + await endPoolAndWaitForClients(isolatedPool) + } + }) + + it('installs the exact fetched DEV graph and upgrades it without replay', async () => { + const databaseName = `r61_latest_dev_${randomBytes(6).toString('hex')}` + const isolatedUrl = databaseUrl(adminUrl!, databaseName) + await adminPool.query(`CREATE DATABASE ${quoteIdentifier(databaseName)}`) + isolatedDatabases.push(databaseName) + const isolatedPool = new Pool({ connectionString: isolatedUrl }) + try { + await runPinnedMigrationProducer(LATEST_DEV_RUNNER, isolatedUrl) + + const latestDevReceipts = await versions(isolatedPool) + expect(latestDevReceipts).toHaveLength(LATEST_DEV_RECEIPT_COUNT) + for (const version of DEV_POST_0106_MIGRATION_VERSIONS) { + expect(latestDevReceipts, `${version} latest DEV producer receipt`).toContain(version) + } + expect(latestDevReceipts).toContain('0129_workflow_schedules_concurrency_policy') + expect( + DISPLACED_TASK106_RECEIPTS.some(([historicalReceipt]) => + latestDevReceipts.includes(historicalReceipt) + ) + ).toBe(false) + expect( + DISPLACED_PUBLISHED_PR1_RECEIPTS.some(([publishedReceipt]) => + latestDevReceipts.includes(publishedReceipt) + ) + ).toBe(false) + + const beforeUpgrade = new Set(latestDevReceipts) + const task106Aliases = PR1_MIGRATION_VERSIONS.flatMap( + version => + CONTROL_API_MIGRATIONS.find(candidate => candidate.version === version)?.legacyVersions ?? + [] + ) + const observedVersions = new Set([ + ...PR1_MIGRATION_VERSIONS, + ...DEV_POST_0106_MIGRATION_VERSIONS, + ]) + const upgrade = await withMigrationApplyObserver(observedVersions, async () => + initDb({ connect: () => isolatedPool.connect() }) + ) + const upgradedReceipts = await versions(isolatedPool) + expect(upgradedReceipts).toHaveLength( + LATEST_DEV_RECEIPT_COUNT + PR1_MIGRATION_VERSIONS.length + task106Aliases.length + ) + expect(upgradedReceipts.filter(version => !beforeUpgrade.has(version))).toEqual( + [...PR1_MIGRATION_VERSIONS, ...task106Aliases].sort() + ) + expect(upgradedReceipts.filter(version => beforeUpgrade.has(version))).toEqual( + latestDevReceipts + ) + for (const version of PR1_MIGRATION_VERSIONS) { + expect(upgrade.applyCounts.get(version), `${version} Task 106 execution`).toBe(1) + const migration = CONTROL_API_MIGRATIONS.find(candidate => candidate.version === version) + for (const alias of migration?.legacyVersions ?? []) { + expect(upgradedReceipts, `${version} rollback alias ${alias}`).toContain(alias) + } + } + expect( + upgrade.applyCounts.get('0129_workflow_schedules_concurrency_policy') ?? 0, + 'latest DEV migration body replay' + ).toBe(0) + const foundationIndex = upgrade.applyOrder.indexOf('0130_user_access_foundation') + expect(upgrade.applyOrder.indexOf('0129_workflow_schedules_concurrency_policy')).toBeLessThan( + foundationIndex + ) + expect(upgrade.applyOrder[foundationIndex + 1]).toBe( + '0144_authorization_revision_delete_compatibility' + ) + await assertDbReady(isolatedPool) + + const beforeRollbackRunner = await versions(isolatedPool) + await runPinnedMigrationProducer(PUBLISHED_PR1_RUNNER, isolatedUrl) + expect(await versions(isolatedPool)).toEqual(beforeRollbackRunner) + await assertDbReady(isolatedPool) + + const firstReceipts = await versions(isolatedPool) + const replay = await withMigrationApplyObserver(observedVersions, async () => + initDb({ connect: () => isolatedPool.connect() }) + ) + expect(await versions(isolatedPool)).toEqual(firstReceipts) + expect([...replay.applyCounts.values()]).toEqual([]) + await assertDbReady(isolatedPool) + } finally { + await endPoolAndWaitForClients(isolatedPool) + } + }) + it('upgrades the exact observed DEV migration identity set with real migration producers', async () => { const databaseName = `r61_observed_dev_${randomBytes(6).toString('hex')}` const isolatedUrl = databaseUrl(adminUrl!, databaseName) @@ -872,22 +1121,33 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { ).toBe(false) const observedDevSet = new Set(observedDevReceipts) + const task106Aliases = PR1_MIGRATION_VERSIONS.flatMap( + version => + CONTROL_API_MIGRATIONS.find(candidate => candidate.version === version)?.legacyVersions ?? + [] + ) const firstRun = await withMigrationApplyObserver( new Set([ ...PR1_MIGRATION_VERSIONS, '0126_bug192_password_admission', '0127_password_evaluation_retention', '0128_password_work_ownership', + '0129_workflow_schedules_concurrency_policy', ]), async () => initDb({ connect: () => isolatedPool.connect() }) ) const upgradedReceipts = await versions(isolatedPool) const upgradedSet = new Set(upgradedReceipts) expect(upgradedReceipts).toHaveLength( - OBSERVED_DEV_RECEIPT_COUNT + PR1_MIGRATION_VERSIONS.length + OBSERVED_DEV_RECEIPT_COUNT + + PR1_MIGRATION_VERSIONS.length + + 1 + + task106Aliases.filter(version => !observedDevSet.has(version)).length ) expect(upgradedReceipts.filter(version => !observedDevSet.has(version))).toEqual( - [...PR1_MIGRATION_VERSIONS].sort() + [...PR1_MIGRATION_VERSIONS, '0129_workflow_schedules_concurrency_policy', ...task106Aliases] + .filter(version => !observedDevSet.has(version)) + .sort() ) expect(upgradedReceipts.filter(version => observedDevSet.has(version))).toEqual( observedDevReceipts @@ -903,9 +1163,16 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { ]) { expect(firstRun.applyCounts.get(version) ?? 0, `${version} DEV body replay`).toBe(0) } - const foundationIndex = firstRun.applyOrder.indexOf('0129_user_access_foundation') + expect( + firstRun.applyCounts.get('0129_workflow_schedules_concurrency_policy'), + 'new latest DEV migration applies exactly once from the observed DEV state' + ).toBe(1) + const foundationIndex = firstRun.applyOrder.indexOf('0130_user_access_foundation') + expect( + firstRun.applyOrder.indexOf('0129_workflow_schedules_concurrency_policy') + ).toBeLessThan(foundationIndex) expect(firstRun.applyOrder[foundationIndex + 1]).toBe( - '0143_authorization_revision_delete_compatibility' + '0144_authorization_revision_delete_compatibility' ) await assertDbReady(isolatedPool) diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index a2133e16a1..c0327f8133 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -30,9 +30,9 @@ const FRESH_TABLE_INDEXES = Object.freeze([ 'invitation_delivery_commands_invitation_idx', ]) -const USER_ACCESS_FOUNDATION_VERSION = '0129_user_access_foundation' +const USER_ACCESS_FOUNDATION_VERSION = '0130_user_access_foundation' const AUTHORIZATION_REVISION_COMPATIBILITY_VERSION = - '0143_authorization_revision_delete_compatibility' + '0144_authorization_revision_delete_compatibility' function expectedMigrationExecutionOrder(versions: readonly string[]): string[] { const withoutCompatibility = versions.filter( @@ -46,7 +46,7 @@ function expectedMigrationExecutionOrder(versions: readonly string[]): string[] describe('D34 migration execution policy', () => { it('classifies current-dev migrations before the re-slotted PR1 migrations', () => { - expect(DEV_POST_0106_MIGRATION_VERSIONS.slice(-10)).toEqual([ + expect(DEV_POST_0106_MIGRATION_VERSIONS.slice(-11)).toEqual([ '0119_dynamic_clients_table', '0120_dynamic_clients_runtime_access', '0121_oauth_install_identity', @@ -57,16 +57,17 @@ describe('D34 migration execution policy', () => { '0126_bug192_password_admission', '0127_password_evaluation_retention', '0128_password_work_ownership', + '0129_workflow_schedules_concurrency_policy', ]) expect(PR1_MIGRATION_VERSIONS).toEqual([ - '0129_user_access_foundation', - '0130_invitation_delivery_commands', - '0131_catalog_utf8_ordering', - '0132_composable_catalog_revisions', - '0133_gfs_catalog_revision_components', - '0134_user_access_foundation_definer_temp_shadow_hardening', - '0135_legacy_password_security_epoch_backfill', - '0143_authorization_revision_delete_compatibility', + '0130_user_access_foundation', + '0131_invitation_delivery_commands', + '0132_catalog_utf8_ordering', + '0133_composable_catalog_revisions', + '0134_gfs_catalog_revision_components', + '0135_user_access_foundation_definer_temp_shadow_hardening', + '0136_legacy_password_security_epoch_backfill', + '0144_authorization_revision_delete_compatibility', ]) expect(PR1_MIGRATION_VERSIONS).not.toContain('0117_control_admin_invitation_replace_inviter') expect(PR1_MIGRATION_VERSIONS).not.toContain('0118_control_admin_replace_inviter_accept_guard') @@ -90,10 +91,10 @@ describe('D34 migration execution policy', () => { expect(PR1_ONLINE_INDEX_PLAN).toHaveLength(25) expect(new Set(PR1_ONLINE_INDEX_PLAN.map(index => index.name))).toHaveLength(25) expect( - PR1_ONLINE_INDEX_PLAN.filter(index => index.migrationVersion.startsWith('0129')) + PR1_ONLINE_INDEX_PLAN.filter(index => index.migrationVersion.startsWith('0130')) ).toHaveLength(18) expect( - PR1_ONLINE_INDEX_PLAN.filter(index => index.migrationVersion.startsWith('0131')) + PR1_ONLINE_INDEX_PLAN.filter(index => index.migrationVersion.startsWith('0132')) ).toHaveLength(7) expect( PR1_ONLINE_INDEX_PLAN.some(index => index.name.startsWith('external_user_sessions_')) @@ -233,6 +234,95 @@ describe('D34 migration execution policy', () => { }) describe('D34 PR1 migration runner', () => { + it('records approved relocated aliases in the same transaction as each canonical receipt', async () => { + const transactions: string[] = [] + const applied: string[] = [] + const indexStates = new Map>() + let transaction = 0 + const migrations = [ + ...DEV_POST_0106_MIGRATION_VERSIONS.map(version => ({ + version, + apply: vi.fn(async () => undefined), + })), + ...PR1_MIGRATION_VERSIONS.map(version => ({ + version, + legacyVersions: [`historical_${version}`], + preserveLegacyReceipts: true, + apply: vi.fn(async () => undefined), + })), + ] + await applyPendingPr1Migrations({ + db: { + query: vi.fn(async (sql: string, values?: unknown[]) => { + if (sql === 'BEGIN') { + transaction += 1 + transactions.push(`begin:${transaction}`) + } + if (sql === 'COMMIT') transactions.push(`commit:${transaction}`) + if (sql === 'ROLLBACK') transactions.push(`rollback:${transaction}`) + if (sql.includes('FROM pg_class index_rel')) { + const state = indexStates.get(String(values?.[0])) + return { rows: state ? [state] : [], rowCount: state ? 1 : 0 } + } + if (sql.startsWith('CREATE INDEX CONCURRENTLY')) { + const entry = PR1_ONLINE_INDEX_PLAN.find(index => sql === index.createSql) + if (entry) { + indexStates.set(entry.name, { + table_name: entry.table, + indisunique: Boolean(entry.unique), + indisvalid: true, + definition: sql, + }) + } + } + return { rows: [], rowCount: 0 } + }), + }, + migrations, + appliedVersions: new Set(), + recordMigration: async (_db, version) => { + applied.push(version) + transactions.push(`receipt:${transaction}:${version}`) + }, + }) + + const taskVersions = [...PR1_MIGRATION_VERSIONS] + const compatibilityIndex = taskVersions.indexOf(AUTHORIZATION_REVISION_COMPATIBILITY_VERSION) + const compatibilityVersion = taskVersions.splice(compatibilityIndex, 1)[0]! + const foundationIndex = taskVersions.indexOf(USER_ACCESS_FOUNDATION_VERSION) + const afterFoundation = taskVersions.splice(foundationIndex + 1) + const expected = [ + ...DEV_POST_0106_MIGRATION_VERSIONS, + USER_ACCESS_FOUNDATION_VERSION, + `historical_${USER_ACCESS_FOUNDATION_VERSION}`, + compatibilityVersion, + `historical_${compatibilityVersion}`, + ...taskVersions + .slice(foundationIndex + 1) + .flatMap(version => [version, `historical_${version}`]), + ...afterFoundation.flatMap(version => [version, `historical_${version}`]), + ] + expect(applied).toEqual(expected) + expect(transactions.filter(event => event.startsWith('rollback:'))).toEqual([]) + const transactionForReceipt = (version: string) => + transactions.find(event => event.endsWith(`:${version}`))?.split(':')[1] + for (const version of PR1_MIGRATION_VERSIONS) { + expect(transactionForReceipt(`historical_${version}`), `${version} alias transaction`).toBe( + transactionForReceipt(version) + ) + } + expect( + transactionForReceipt(`historical_${AUTHORIZATION_REVISION_COMPATIBILITY_VERSION}`), + 'compatibility alias is recorded with the foundation transaction' + ).toBe(transactionForReceipt(USER_ACCESS_FOUNDATION_VERSION)) + expect(transactions.filter(event => event.startsWith('begin:'))).toHaveLength( + DEV_POST_0106_MIGRATION_VERSIONS.length + PR1_MIGRATION_VERSIONS.length + ) + expect(transactions.filter(event => event.startsWith('commit:'))).toHaveLength( + DEV_POST_0106_MIGRATION_VERSIONS.length + PR1_MIGRATION_VERSIONS.length + ) + }) + it('commits and records each PR1 version independently in order', async () => { const queries: Array<{ sql: string; values?: unknown[] }> = [] const db = { @@ -349,6 +439,32 @@ describe('D34 PR1 migration runner', () => { ).rejects.toThrow('Unclassified post-0106 migrations') }) + it('rejects a historical alias that collides with a canonical DEV identity', async () => { + const migrations = [ + ...DEV_POST_0106_MIGRATION_VERSIONS.map(version => ({ version, apply: vi.fn() })), + ...PR1_MIGRATION_VERSIONS.map(version => ({ + version, + ...(version === USER_ACCESS_FOUNDATION_VERSION + ? { + legacyVersions: ['0129_workflow_schedules_concurrency_policy'], + preserveLegacyReceipts: true, + } + : {}), + apply: vi.fn(), + })), + ] + await expect( + applyPendingPr1Migrations({ + db: { query: vi.fn() }, + migrations, + appliedVersions: new Set(), + recordMigration: vi.fn(), + }) + ).rejects.toThrow( + 'Migration alias collides with canonical version: 0129_workflow_schedules_concurrency_policy' + ) + }) + it('applies classified dev-owned post-0106 migrations before PR1 migrations', async () => { const indexStates = new Map>() const db = { @@ -515,7 +631,7 @@ describe('D34 PR1 migration runner', () => { events.push(sql) } if (sql.includes('FROM pg_class index_rel')) { - events.push('prepare:0131') + events.push('prepare:0132') const entry = PR1_ONLINE_INDEX_PLAN.find(index => index.name === values?.[0]) return { rows: entry @@ -541,8 +657,8 @@ describe('D34 PR1 migration runner', () => { ...PR1_MIGRATION_VERSIONS.filter( version => version !== USER_ACCESS_FOUNDATION_VERSION && - version !== '0130_invitation_delivery_commands' && - version !== '0131_catalog_utf8_ordering' && + version !== '0131_invitation_delivery_commands' && + version !== '0132_catalog_utf8_ordering' && version !== AUTHORIZATION_REVISION_COMPATIBILITY_VERSION ), ]) @@ -557,18 +673,18 @@ describe('D34 PR1 migration runner', () => { }) expect(events.indexOf(AUTHORIZATION_REVISION_COMPATIBILITY_VERSION)).toBeLessThan( - events.indexOf('prepare:0131') + events.indexOf('prepare:0132') ) expect(events.indexOf(`receipt:${AUTHORIZATION_REVISION_COMPATIBILITY_VERSION}`)).toBeLessThan( - events.indexOf('prepare:0131') + events.indexOf('prepare:0132') ) expect(events.filter(event => event === 'BEGIN')).toHaveLength(4) expect(events.filter(event => event === 'COMMIT')).toHaveLength(4) const repairCommit = events.indexOf('COMMIT') - expect(repairCommit).toBeLessThan(events.indexOf('prepare:0131')) + expect(repairCommit).toBeLessThan(events.indexOf('prepare:0132')) }) - it('preserves the prior foundation alias when its 0143 successor is already recorded', async () => { + it('preserves the prior foundation alias when its 0144 successor is already recorded', async () => { const foundation = { version: USER_ACCESS_FOUNDATION_VERSION, legacyVersions: ['0109_user_access_foundation'], @@ -671,7 +787,7 @@ describe('D34 PR1 migration runner', () => { it.each([ AUTHORIZATION_REVISION_COMPATIBILITY_VERSION, '0138_authorization_revision_delete_compatibility', - ])('fails closed when %s is recorded without its 0129 prerequisite', async receipt => { + ])('fails closed when %s is recorded without its 0130 prerequisite', async receipt => { await expect( applyPendingPr1Migrations({ db: { query: vi.fn(async () => ({ rows: [], rowCount: 0 })) }, diff --git a/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts b/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts index 3cbf2ec6c3..04148d0433 100644 --- a/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts +++ b/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts @@ -142,7 +142,7 @@ describeRealPostgres('D34 previous-image writer compatibility on real PostgreSQL expect( await databasePool.query( `SELECT 1 FROM schema_migrations - WHERE version = '0143_authorization_revision_delete_compatibility'` + WHERE version = '0144_authorization_revision_delete_compatibility'` ) ).toMatchObject({ rowCount: 1 }) }) @@ -234,7 +234,14 @@ describeRealPostgres('D34 previous-image writer compatibility on real PostgreSQL await databasePool.query( `DELETE FROM schema_migrations - WHERE version = '0143_authorization_revision_delete_compatibility'` + WHERE version = ANY($1::text[])`, + [ + [ + '0144_authorization_revision_delete_compatibility', + '0143_authorization_revision_delete_compatibility', + '0138_authorization_revision_delete_compatibility', + ], + ] ) await initDb({ connect: () => databasePool.connect() }) @@ -283,7 +290,7 @@ describeRealPostgres('D34 previous-image writer compatibility on real PostgreSQL expect( await databasePool.query( `SELECT 1 FROM schema_migrations - WHERE version = '0143_authorization_revision_delete_compatibility'` + WHERE version = '0144_authorization_revision_delete_compatibility'` ) ).toMatchObject({ rowCount: 1 }) }) diff --git a/scripts/tests/test-control-api-migration-bounds.sh b/scripts/tests/test-control-api-migration-bounds.sh index d6bcb9c0f7..d2da16d549 100755 --- a/scripts/tests/test-control-api-migration-bounds.sh +++ b/scripts/tests/test-control-api-migration-bounds.sh @@ -41,7 +41,7 @@ grep -q 'applyPendingPr1Migrations' "$DB_FILE" || \ grep -q 'CREATE INDEX CONCURRENTLY' "$INDEX_PLAN_FILE" || \ fail "existing-table indexes are not prepared concurrently" -index_count="$(grep -c "^ migrationVersion: '012[57]_" "$INDEX_PLAN_FILE")" +index_count="$(grep -c "^ migrationVersion: '013[02]_" "$INDEX_PLAN_FILE")" [ "$index_count" = "25" ] || fail "expected 25 online indexes, found $index_count" grep -q 'activeDeadlineSeconds' "$DEPLOY_FILE" || \ From b2dade697ec532de42fbf3e5b71332c56dc25289 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:09:06 -0600 Subject: [PATCH 299/528] fix(control-api): preserve configured context integrity for r64-m2 Finding: R64-M2 Class-check: hydrateCanonicalKeys -> 1 SQL producer/1 coordinator consumer; other families unchanged. Repro-test: coordinator RealPG complete=true at 20be2fbe46977950972b90eb898035bfdc197bdd --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../access/accessCatalogCoordinator.ts | 74 ++++- .../src/services/access/catalogContracts.ts | 16 +- .../services/access/catalogHydrationSql.ts | 21 ++ .../src/services/access/catalogProducers.ts | 50 +++- ...ordinator.realPostgres.integration.test.ts | 265 +++++++++++++++++- 7 files changed, 398 insertions(+), 34 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 1b6d7666c0..3556918636 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.752", + "version": "0.1.753", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.752", + "version": "0.1.753", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index f24fe4a2fd..510f8b82be 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.752", + "version": "0.1.753", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/access/accessCatalogCoordinator.ts b/control-api/src/services/access/accessCatalogCoordinator.ts index 20df8bc0d6..19a5df5bb2 100644 --- a/control-api/src/services/access/accessCatalogCoordinator.ts +++ b/control-api/src/services/access/accessCatalogCoordinator.ts @@ -32,6 +32,8 @@ import { import { CATALOG_FAMILIES, type CatalogFamily, + type CatalogHydrationIncompleteRoot, + type CatalogHydrationResult, type CatalogIdentityCandidate, type CatalogKey, type CatalogProducerPage, @@ -113,13 +115,24 @@ function emptyContinuations(): Record { ) as Record } -function safePartialErrors(runtimes: readonly ProducerRuntime[]): SafeCatalogPartialError[] { +function safePartialErrors( + runtimes: readonly ProducerRuntime[], + hasIncompleteHosts: boolean +): SafeCatalogPartialError[] { const values = new Map() for (const runtime of runtimes) { for (const error of runtime.page.partialErrors) { values.set(JSON.stringify([error.producer, error.code]), error) } } + if (hasIncompleteHosts) { + const error: SafeCatalogPartialError = Object.freeze({ + producer: 'host', + code: 'operational_source_unavailable', + retryable: true, + }) + values.set(JSON.stringify([error.producer, error.code]), error) + } return [...values.entries()] .sort(([left], [right]) => left.localeCompare(right)) .map(([, value]) => value) @@ -128,7 +141,10 @@ function safePartialErrors(runtimes: readonly ProducerRuntime[]): SafeCatalogPar async function hydrateSelected( context: CatalogRequestContext, selected: readonly CatalogIdentityCandidate[] -): Promise> { +): Promise<{ + resources: Map + incompleteRoots: ReadonlyMap +}> { const byFamily = new Map() for (const candidate of selected) { const values = byFamily.get(candidate.key[1]) ?? [] @@ -136,19 +152,49 @@ async function hydrateSelected( byFamily.set(candidate.key[1], values) } const hydratedValues: HydratedCatalogResource[] = [] + const incompleteRoots: CatalogHydrationIncompleteRoot[] = [] for (const family of CATALOG_FAMILIES) { const keys = byFamily.get(family) if (!keys?.length) continue const values = await requireCatalogProducer(family).hydrateCanonicalKeys(context, keys) - hydratedValues.push(...values) + if (Array.isArray(values)) { + hydratedValues.push(...values) + } else { + const result = values as CatalogHydrationResult + if (!Array.isArray(result.resources) || !Array.isArray(result.incompleteRoots)) { + throw new AccessCatalogRequestError('authority_unavailable') + } + hydratedValues.push(...result.resources) + incompleteRoots.push(...result.incompleteRoots) + } + } + const resources = mergeHydratedCatalogResources(hydratedValues) + const selectedByKey = new Map( + selected.map(candidate => [JSON.stringify(candidate.key), candidate]) + ) + const incompleteByKey = new Map() + for (const incomplete of incompleteRoots) { + const key = JSON.stringify(incomplete.key) + const candidate = selectedByKey.get(key) + if ( + !candidate || + candidate.key[1] !== 'host' || + (incomplete.reason !== 'configured_context_missing' && + incomplete.reason !== 'configured_context_ambiguous') || + incompleteByKey.has(key) || + resources.has(key) + ) { + throw new AccessCatalogRequestError('authority_unavailable') + } + incompleteByKey.set(key, incomplete.reason) } - const hydrated = mergeHydratedCatalogResources(hydratedValues) for (const candidate of selected) { - if (!hydrated.has(JSON.stringify(candidate.key))) { + const key = JSON.stringify(candidate.key) + if (!resources.has(key) && !incompleteByKey.has(key)) { throw new AccessCatalogRequestError('authority_unavailable') } } - return hydrated + return { resources, incompleteRoots: incompleteByKey } } function catalogItem( @@ -297,11 +343,15 @@ async function buildInTransaction(input: { input.limit + 1 ) const selected = merged.slice(0, input.limit) - const hydrated = await hydrateSelected(context, selected) - const items = selected.map(candidate => - catalogItem(context, hydrated.get(JSON.stringify(candidate.key))!) - ) - const partialErrors = safePartialErrors(runtimes) + const hydration = await hydrateSelected(context, selected) + const items = selected.flatMap(candidate => { + const key = JSON.stringify(candidate.key) + if (hydration.incompleteRoots.has(key)) return [] + const resource = hydration.resources.get(key) + if (!resource) throw new AccessCatalogRequestError('authority_unavailable') + return [catalogItem(context, resource)] + }) + const partialErrors = safePartialErrors(runtimes, hydration.incompleteRoots.size > 0) const continuations = finalContinuations({ requestedFamilies: input.families, runtimes, @@ -314,7 +364,7 @@ async function buildInTransaction(input: { const continuation = continuations[runtime.family] return runtime.page.sourceCompleteness === 'complete' && !continuation.exhausted }) - const validUntil = earliestValidUntil(merged, hydrated) + const validUntil = earliestValidUntil(merged, hydration.resources) const lastCanonicalKey = selected.at(-1)?.key const nextCursor = hasNext && lastCanonicalKey diff --git a/control-api/src/services/access/catalogContracts.ts b/control-api/src/services/access/catalogContracts.ts index d6a21692bd..049a3e97e9 100644 --- a/control-api/src/services/access/catalogContracts.ts +++ b/control-api/src/services/access/catalogContracts.ts @@ -72,6 +72,20 @@ export type HydratedCatalogResource = Readonly<{ validUntil: string | null }> +export type CatalogHydrationIncompleteRoot = Readonly<{ + key: CatalogKey + reason: 'configured_context_missing' | 'configured_context_ambiguous' +}> + +/** Internal producer signal for selected roots omitted due to configured-reference integrity. */ +export type CatalogHydrationResult = Readonly<{ + resources: readonly HydratedCatalogResource[] + incompleteRoots: readonly CatalogHydrationIncompleteRoot[] +}> + +/** Keep the unchanged fast path for complete pages; incomplete roots use the typed result. */ +export type CatalogHydrationOutcome = readonly HydratedCatalogResource[] | CatalogHydrationResult + export type CatalogPrincipalSnapshot = Readonly<{ userId: string sessionContract: 'v1' | 'v2' @@ -112,7 +126,7 @@ export interface CatalogProducer { hydrateCanonicalKeys( context: CatalogRequestContext, keys: readonly CatalogKey[] - ): Promise + ): Promise } export function catalogKey( diff --git a/control-api/src/services/access/catalogHydrationSql.ts b/control-api/src/services/access/catalogHydrationSql.ts index b520bfd9c6..9593fd184c 100644 --- a/control-api/src/services/access/catalogHydrationSql.ts +++ b/control-api/src/services/access/catalogHydrationSql.ts @@ -221,6 +221,24 @@ export const SIMPLE_OPERATIONAL_HYDRATION_SQL = ` AND alias_source.resource_type = 'context' AND alias_source.logical_id = alias_relationship.source_id AND alias_source.provider_uid = alias_relationship.source_provider_uid + ), configured_context_candidate_counts AS MATERIALIZED ( + SELECT candidate.reference_id, COUNT(DISTINCT candidate.canonical_id)::int AS candidate_count + FROM context_identity_candidates candidate + GROUP BY candidate.reference_id + ), incomplete_configured_contexts AS MATERIALIZED ( + SELECT relationship.source_id AS logical_id, + CASE WHEN BOOL_OR(COALESCE(candidate_count.candidate_count, 0) > 1) + THEN 'configured_context_ambiguous' + ELSE 'configured_context_missing' + END AS incomplete_reason + FROM outgoing_relationships relationship + LEFT JOIN configured_context_candidate_counts candidate_count + ON candidate_count.reference_id = relationship.target_id + WHERE relationship.source_type = 'host' + AND relationship.relationship_type = 'uses_context' + AND relationship.target_type = 'context' + AND COALESCE(candidate_count.candidate_count, 0) <> 1 + GROUP BY relationship.source_id ), context_identities AS MATERIALIZED ( SELECT candidate.reference_id, MIN(candidate.canonical_id) AS canonical_id FROM context_identity_candidates candidate @@ -298,8 +316,11 @@ export const SIMPLE_OPERATIONAL_HYDRATION_SQL = ` ), '[]'::jsonb) AS relationships, (SELECT COUNT(*) FROM paths) AS total_path_rows, (SELECT COUNT(*) FROM relationships) AS total_relationship_rows, + incomplete.incomplete_reason, NULL::timestamptz AS valid_until FROM resources resource + LEFT JOIN incomplete_configured_contexts incomplete + ON $4::text = 'host' AND incomplete.logical_id = resource.logical_id ORDER BY resource.logical_id` export const DERIVED_OPERATIONAL_HYDRATION_SQL = ` diff --git a/control-api/src/services/access/catalogProducers.ts b/control-api/src/services/access/catalogProducers.ts index 3bf81d82de..e2e87b560c 100644 --- a/control-api/src/services/access/catalogProducers.ts +++ b/control-api/src/services/access/catalogProducers.ts @@ -12,6 +12,9 @@ import { gfsPermissionsToCapabilities } from './capabilityRegistry.js' import { CATALOG_FAMILIES, type CatalogFamily, + type CatalogHydrationIncompleteRoot, + type CatalogHydrationOutcome, + type CatalogHydrationResult, type CatalogKey, type CatalogProducer, type CatalogProducerPage, @@ -401,13 +404,14 @@ function hydrateRows(input: { family: CatalogFamily sourceRevision: string rows: readonly JsonRecord[] -}): HydratedCatalogResource[] { +}): CatalogHydrationResult { input.context.budget.charge({ kind: 'decodedBytes', amount: decodedHydrationBytes(input.rows), authorityRequired: true, }) const hydrated: HydratedCatalogResource[] = [] + const incompleteRoots: CatalogHydrationIncompleteRoot[] = [] let representedPaths = 0 let representedRelationships = 0 let rawPaths = 0 @@ -417,7 +421,6 @@ function hydrateRows(input: { for (const row of input.rows) { const logicalId = boundedString(row.logical_id, 'resource_logical_id') const pathRows = records(row.paths) - if (pathRows.length === 0) continue const relationshipRows = records(row.relationships) rawPaths += pathRows.length rawRelationships += relationshipRows.length @@ -429,6 +432,21 @@ function hydrateRows(input: { reportedRelationshipRows, nonNegativeInteger(row.total_relationship_rows ?? 0, 'total_relationship_rows') ) + if (row.incomplete_reason !== null && row.incomplete_reason !== undefined) { + if ( + input.family !== 'host' || + (row.incomplete_reason !== 'configured_context_missing' && + row.incomplete_reason !== 'configured_context_ambiguous') + ) { + throw new CatalogProducerContractError('incomplete_root_reason_invalid') + } + incompleteRoots.push({ + key: catalogKey(input.context.environmentId, input.family, logicalId), + reason: row.incomplete_reason, + }) + continue + } + if (pathRows.length === 0) continue const accessPaths = input.family === 'host' || input.family === 'context' || @@ -517,7 +535,10 @@ function hydrateRows(input: { authorityRequired: true, }) } - return hydrated + return Object.freeze({ + resources: Object.freeze(hydrated), + incompleteRoots: Object.freeze(incompleteRoots), + }) } class SqlCatalogProducer implements CatalogProducer { @@ -554,7 +575,7 @@ class SqlCatalogProducer implements CatalogProducer { async hydrateCanonicalKeys( context: CatalogRequestContext, keys: readonly CatalogKey[] - ): Promise { + ): Promise { const logicalIds = validateHydrationKeys({ context, family: this.family, keys }) if (logicalIds.length === 0) return Object.freeze([]) const readiness = operationalReadiness(context, this.family, this.requiredOperationalSources) @@ -567,17 +588,16 @@ class SqlCatalogProducer implements CatalogProducer { hydrationSql(this.family), hydrationValues({ context, family: this.family, logicalIds }) ) - return Object.freeze( - hydrateRows({ - context, - family: this.family, - sourceRevision: - this.requiredOperationalSources.length === 0 - ? 'database-resource' - : readiness.sourceRevision, - rows: result.rows as JsonRecord[], - }) - ) + const hydrated = hydrateRows({ + context, + family: this.family, + sourceRevision: + this.requiredOperationalSources.length === 0 + ? 'database-resource' + : readiness.sourceRevision, + rows: result.rows as JsonRecord[], + }) + return hydrated.incompleteRoots.length > 0 ? hydrated : hydrated.resources } } diff --git a/control-api/test/accessCatalogCoordinator.realPostgres.integration.test.ts b/control-api/test/accessCatalogCoordinator.realPostgres.integration.test.ts index ecaf3e9a90..2d12ff2b42 100644 --- a/control-api/test/accessCatalogCoordinator.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogCoordinator.realPostgres.integration.test.ts @@ -1,15 +1,28 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { randomBytes, randomUUID } from 'node:crypto' import { Pool, type PoolClient } from 'pg' +import { config } from '../src/config.js' import { type DbClient, initDb } from '../src/db.js' -import { buildAccessCatalog } from '../src/services/access/accessCatalogCoordinator.js' +import { + AccessCatalogRequestError, + buildAccessCatalog, +} from '../src/services/access/accessCatalogCoordinator.js' import { AccessCatalogCursorError, decodeAccessCatalogCursor, } from '../src/services/access/accessCatalogCursor.js' -import { AccessExecutionBudget } from '../src/services/access/accessExecutionBudget.js' +import { + AccessBudgetExceededError, + AccessExecutionBudget, +} from '../src/services/access/accessExecutionBudget.js' +import { type CatalogRequestContext, catalogKey } from '../src/services/access/catalogContracts.js' +import { requireCatalogProducer } from '../src/services/access/catalogProducers.js' import { resolveLiveAuthorization } from '../src/services/access/liveAuthorizationResolver.js' -import { canonicalEnvironmentId } from '../src/services/access/operationalAccessProjection.js' +import { OperationalAccessIndex } from '../src/services/access/operationalAccessIndex.js' +import { + canonicalEnvironmentId, + projectOperationalObject, +} from '../src/services/access/operationalAccessProjection.js' import { canonicalResourceIdentity } from '../src/services/access/resourceIdentity.js' import type { ExternalSessionAuthorityContext } from '../src/services/auth/externalSessionAuthentication.js' import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' @@ -182,4 +195,250 @@ describeRealPostgres('aggregate catalog coordinator on real PostgreSQL', () => { budget.close() } }) + + it('keeps missing and ambiguous configured Context references root-local and advances cursors', async () => { + const suffix = randomBytes(5).toString('hex') + const names = { + missing: `a-missing-${suffix}`, + ambiguous: `b-ambiguous-${suffix}`, + canonical: `c-canonical-${suffix}`, + alias: `d-alias-${suffix}`, + contextFree: `e-context-free-${suffix}`, + } + const hostNamespace = config.hostsNamespace + const contextNamespace = config.contextsNamespace + const aliasName = `wire-${suffix}` + const ambiguousAlias = `collision-${suffix}` + const projection = ( + kind: 'hosts' | 'contexts', + name: string, + spec: Record + ) => { + const namespace = kind === 'hosts' ? hostNamespace : contextNamespace + return projectOperationalObject({ + environmentId, + plural: kind, + namespace, + behaviorFingerprintKey: 'catalog-integrity-test-key', + relationshipNamespaces: { + context: contextNamespace, + mcpServer: config.mcpServersNamespace, + sharedFilesystem: config.sharedFilesystemsNamespace, + }, + object: { + apiVersion: 'clerum.io/v1alpha1', + kind: kind === 'hosts' ? 'Host' : 'Context', + metadata: { + name, + namespace, + uid: randomUUID(), + resourceVersion: '1', + generation: 1, + }, + spec: { enabled: true, ...spec }, + }, + }) + } + const contextProjections = [ + projection('contexts', `canonical-${suffix}`, { mcpServers: [], sharedFileSystems: [] }), + projection('contexts', `alias-source-${suffix}`, { + contextId: aliasName, + mcpServers: [], + sharedFileSystems: [], + }), + projection('contexts', `ambiguous-one-${suffix}`, { + contextId: ambiguousAlias, + mcpServers: [], + sharedFileSystems: [], + }), + projection('contexts', `ambiguous-two-${suffix}`, { + contextId: ambiguousAlias, + mcpServers: [], + sharedFileSystems: [], + }), + ] + const hostProjections = [ + projection('hosts', names.missing, { contextRef: `missing-${suffix}` }), + projection('hosts', names.ambiguous, { contextRef: ambiguousAlias }), + projection('hosts', names.canonical, { contextRef: `canonical-${suffix}` }), + projection('hosts', names.alias, { contextRef: aliasName }), + projection('hosts', names.contextFree, {}), + ] + await databasePool.query( + `INSERT INTO user_agents(user_id, agent_name) + SELECT $1, agent_name FROM UNNEST($2::text[]) AS requested(agent_name)`, + [userId, Object.values(names)] + ) + + const index = new OperationalAccessIndex(databasePool, transaction(databasePool)) + const indexBudget = AccessExecutionBudget.create('indexer') + try { + for (const [sourceFamily, projections] of [ + ['context', contextProjections], + ['host', hostProjections], + ] as const) { + const stagingGeneration = await index.beginRelist({ + environmentId, + sourceFamily, + budget: indexBudget, + }) + await index.stageRelistPage({ + environmentId, + sourceFamily, + stagingGeneration, + projections, + budget: indexBudget, + }) + await index.promoteRelist({ + environmentId, + sourceFamily, + stagingGeneration, + resourceVersion: '1', + budget: indexBudget, + }) + } + } finally { + indexBudget.close() + } + + let queryCount = 0 + const measuredTransaction = (work: (db: DbClient) => Promise) => + transaction(databasePool)(db => + work({ + query: (text, values) => { + queryCount += 1 + return db.query(text, values) + }, + }) + ) + const completePage = await buildAccessCatalog( + { session, families: ['user', 'host'], limit: 100 }, + { transaction: measuredTransaction } + ) + + expect(completePage.complete).toBe(false) + expect(completePage.partialErrors).toEqual([ + { producer: 'host', code: 'operational_source_unavailable', retryable: true }, + ]) + const itemIds = completePage.items.map(item => item.resource.logicalId) + expect(itemIds).toContain(userId) + expect(itemIds).toContain(`${hostNamespace}/${names.canonical}`) + expect(itemIds).toContain(`${hostNamespace}/${names.alias}`) + expect(itemIds).toContain(`${hostNamespace}/${names.contextFree}`) + expect(itemIds).not.toContain(`${hostNamespace}/${names.missing}`) + expect(itemIds).not.toContain(`${hostNamespace}/${names.ambiguous}`) + expect(JSON.stringify(completePage)).not.toMatch(/configured_context_(missing|ambiguous)/u) + + const producerBudget = AccessExecutionBudget.create('catalog') + try { + const producerContext: CatalogRequestContext = { + db: databasePool, + budget: producerBudget, + principal: { + userId, + sessionContract: 'v1', + sessionRevision: '1', + userRevision: '1', + authorizationRevision: 'producer-context-integrity', + memberships: [], + }, + environmentId, + sourceStates: new Map([ + [ + 'host', + { + family: 'host', + generation: '1', + resourceVersion: '1', + status: 'current', + }, + ], + ]), + } + const producerResult = await requireCatalogProducer('host').hydrateCanonicalKeys( + producerContext, + [ + catalogKey(environmentId, 'host', `${hostNamespace}/${names.missing}`), + catalogKey(environmentId, 'host', `${hostNamespace}/${names.ambiguous}`), + ] + ) + expect(Array.isArray(producerResult)).toBe(false) + const integrity = producerResult as unknown as { + resources: readonly unknown[] + incompleteRoots: readonly Readonly<{ key: readonly string[]; reason: string }>[] + } + expect(integrity.resources).toEqual([]) + expect(integrity.incompleteRoots).toEqual([ + { + key: catalogKey(environmentId, 'host', `${hostNamespace}/${names.missing}`), + reason: 'configured_context_missing', + }, + { + key: catalogKey(environmentId, 'host', `${hostNamespace}/${names.ambiguous}`), + reason: 'configured_context_ambiguous', + }, + ]) + } finally { + producerBudget.close() + } + + queryCount = 0 + await buildAccessCatalog( + { session, families: ['host'], limit: 100 }, + { transaction: measuredTransaction } + ) + const multiRootQueryCount = queryCount + + queryCount = 0 + const firstHostPage = await buildAccessCatalog( + { session, families: ['host'], limit: 1 }, + { transaction: measuredTransaction } + ) + expect(firstHostPage.items).toEqual([]) + expect(firstHostPage.complete).toBe(false) + expect(firstHostPage.nextCursor).not.toBeNull() + const cursorBudget = AccessExecutionBudget.create('catalog') + try { + const cursor = decodeAccessCatalogCursor(firstHostPage.nextCursor!, cursorBudget) + expect(cursor.lastCanonicalKey).toEqual([ + environmentId, + 'host', + `${hostNamespace}/${names.missing}`, + ]) + } finally { + cursorBudget.close() + } + const firstPageQueryCount = queryCount + + queryCount = 0 + const oneHostPage = await buildAccessCatalog( + { session, families: ['host'], limit: 1, cursor: firstHostPage.nextCursor }, + { transaction: measuredTransaction } + ) + expect(oneHostPage.items).toEqual([]) + expect(oneHostPage.nextCursor).not.toBe(firstHostPage.nextCursor) + expect(queryCount).toBe(firstPageQueryCount) + expect(multiRootQueryCount).toBe(firstPageQueryCount) + + await expect( + buildAccessCatalog( + { session, families: ['host'], limit: 100 }, + { + transaction: transaction(databasePool), + limits: { decodedBytes: 1 }, + } + ) + ).rejects.toBeInstanceOf(AccessBudgetExceededError) + + await expect( + buildAccessCatalog( + { session: { ...session, userId: randomUUID() }, families: ['user', 'host'], limit: 100 }, + { transaction: transaction(databasePool) } + ) + ).rejects.toEqual( + expect.objectContaining>({ + code: 'authority_unavailable', + }) + ) + }) }) From f0ceba68e21f760f92df12ac9b775d29656a6aa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:42:42 -0600 Subject: [PATCH 300/528] test(control-api): align migration count with latest dev Finding: R61-B1 Class-check: BEGIN/COMMIT counts -> 1 test/2 sites; no sibling count sites. Repro-test: test/db.initDb.test.ts fails at b2dade697ec532de42fbf3e5b71332c56dc25289 (31 expected, 32 observed). --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- control-api/test/db.initDb.test.ts | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 3556918636..62861e1d19 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.753", + "version": "0.1.754", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.753", + "version": "0.1.754", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 510f8b82be..7c81030c18 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.753", + "version": "0.1.754", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/db.initDb.test.ts b/control-api/test/db.initDb.test.ts index 40f4f21001..e997577a15 100644 --- a/control-api/test/db.initDb.test.ts +++ b/control-api/test/db.initDb.test.ts @@ -65,9 +65,9 @@ describe('db.initDb', () => { ).toBe(false) expect(sqls.some(sql => sql.includes('DROP TABLE IF EXISTS workflow_run_outputs'))).toBe(false) expect(sqls).not.toContain('ROLLBACK') - // The migration plan now includes three separately committed parent-dev receipts at 0126-0128. - expect(sqls.filter(sql => sql === 'BEGIN')).toHaveLength(31) - expect(sqls.filter(sql => sql === 'COMMIT')).toHaveLength(31) + // The migration plan now includes four separately committed parent-dev receipts at 0126-0129. + expect(sqls.filter(sql => sql === 'BEGIN')).toHaveLength(32) + expect(sqls.filter(sql => sql === 'COMMIT')).toHaveLength(32) expect(sqls[sqls.length - 2]).toBe('COMMIT') expect(sqls[sqls.length - 1]).toContain('SELECT pg_advisory_unlock') expect(clientRelease).toHaveBeenCalledTimes(1) From 802da76e7b31b609c0166a9a6b1fd8f90aeeb912 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:04:25 -0600 Subject: [PATCH 301/528] fix(control-api): r65-b1 honor migration receipt aliases in readiness test Finding: R65-B1 Class-check: `DELETE FROM schema_migrations` in control-api/test -> 24 sites; one stale GFS readiness test retained accepted aliases; replay and full-equivalent-removal cases stay intact. Repro-test: gfsReadiness.realPostgres.integration.test.ts; prior head f0ceba68e21f760f92df12ac9b775d29656a6aa8: 14/15 RED; corrected PostgreSQL 16 run: 16/16. --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- ...Readiness.realPostgres.integration.test.ts | 43 +++++++++++++++++-- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 62861e1d19..1c36f4baef 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.754", + "version": "0.1.755", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.754", + "version": "0.1.755", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 7c81030c18..b8e8235096 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.754", + "version": "0.1.755", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/gfsReadiness.realPostgres.integration.test.ts b/control-api/test/gfsReadiness.realPostgres.integration.test.ts index a230e7648d..1e3bae83f8 100644 --- a/control-api/test/gfsReadiness.realPostgres.integration.test.ts +++ b/control-api/test/gfsReadiness.realPostgres.integration.test.ts @@ -20,7 +20,7 @@ function quoteIdent(value: string): string { } describeRealPostgres('GFS Phase 0 real PostgreSQL readiness', () => { - const latestMigration = CONTROL_API_MIGRATIONS.at(-1)!.version + const latestMigration = CONTROL_API_MIGRATIONS.at(-1)! const database = `gfs_readiness_${randomBytes(6).toString('hex')}` const connectionString = databaseUrl( adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', @@ -74,12 +74,47 @@ describeRealPostgres('GFS Phase 0 real PostgreSQL readiness', () => { } }) - it('fails Control API readiness while the latest migration is absent', async () => { + it('keeps Control API readiness when the latest migration has an accepted historical receipt', async () => { + const acceptedAliases = latestMigration.legacyVersions ?? [] + expect(acceptedAliases.length).toBeGreaterThan(0) + + const client = await pool.connect() + try { + await client.query('BEGIN') + await client.query('DELETE FROM schema_migrations WHERE version = $1', [ + latestMigration.version, + ]) + const aliasReceipts = await client.query<{ version: string }>( + `SELECT version FROM schema_migrations WHERE version = ANY($1::text[])`, + [acceptedAliases] + ) + expect(aliasReceipts.rows.map(row => row.version)).not.toHaveLength(0) + await expect(assertDbReady(client)).resolves.toBeUndefined() + } finally { + await client.query('ROLLBACK').catch(() => undefined) + client.release() + } + }) + + it('fails Control API readiness only when the latest migration and every accepted alias are absent', async () => { + const acceptedLatestReceipts = [ + latestMigration.version, + ...(latestMigration.legacyVersions ?? []), + ] const client = await pool.connect() try { await client.query('BEGIN') - await client.query(`DELETE FROM schema_migrations WHERE version=$1`, [latestMigration]) - await expect(assertDbReady(client)).rejects.toThrow(new RegExp(latestMigration)) + await client.query(`DELETE FROM schema_migrations WHERE version = ANY($1::text[])`, [ + acceptedLatestReceipts, + ]) + const remainingReceipts = await client.query<{ version: string }>( + `SELECT version FROM schema_migrations WHERE version = ANY($1::text[])`, + [acceptedLatestReceipts] + ) + expect(remainingReceipts.rows).toHaveLength(0) + await expect(assertDbReady(client)).rejects.toMatchObject({ + message: `Control API database is not ready: missing migrations ${latestMigration.version}`, + }) } finally { await client.query('ROLLBACK').catch(() => undefined) client.release() From 213c3555829b2d5983b62dc770751237d1309563 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:20:47 -0600 Subject: [PATCH 302/528] fix(control-api): remove unused migration fixture local Finding: R69-N1. Remove the unused role-list fixture without changing test behavior. Class-check: runtimeRoles -> 1 unused local -> remove declaration only Repro-test: CodeQL check 117310895715 flags runtimeRoles at 2d9b7f5 (note) --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- .../test/db.realPostgresMigration.integration.test.ts | 5 ----- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index f29f433c0e..34cb48ffdb 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.757", + "version": "0.1.758", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.757", + "version": "0.1.758", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index ad760aae20..d81b1024bd 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.757", + "version": "0.1.758", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/db.realPostgresMigration.integration.test.ts b/control-api/test/db.realPostgresMigration.integration.test.ts index 5c92593312..87e1d89866 100644 --- a/control-api/test/db.realPostgresMigration.integration.test.ts +++ b/control-api/test/db.realPostgresMigration.integration.test.ts @@ -42,11 +42,6 @@ type PrivilegeExpectation = Record> const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip -const runtimeRoles = [ - 'control_api_runtime', - 'trace_maintenance_runtime', - 'workflow_recipes_runtime', -] as const const accessFoundationDefinerSignatures = [ 'authorization_bump_user_revision(pg_catalog.uuid)', 'authorization_bump_team_revision(pg_catalog.uuid)', From 118b134e3be11feaf96084377d8c4fc900186f04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:42:03 -0600 Subject: [PATCH 303/528] test(hcc): unlink dangling symlink fixture cleanup Finding: R69-INT-1 Class-check: dangling-root-symlink fixture cleanup -> statelessDeployment.workspaceLayoutMerge.test.ts:935 -> use unlinkSync Repro-test: npm test -- test/statelessDeployment.workspaceLayoutMerge.test.ts (fails at 840dfb2; passes at this commit) --- host-context-controller/package-lock.json | 4 ++-- host-context-controller/package.json | 2 +- .../test/statelessDeployment.workspaceLayoutMerge.test.ts | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/host-context-controller/package-lock.json b/host-context-controller/package-lock.json index c49f0f750c..9d294f4d20 100644 --- a/host-context-controller/package-lock.json +++ b/host-context-controller/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-host-context-controller", - "version": "0.1.403", + "version": "0.1.404", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-host-context-controller", - "version": "0.1.403", + "version": "0.1.404", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@clerum/codex-catalog-projection": "file:../packages/codex-catalog-projection", diff --git a/host-context-controller/package.json b/host-context-controller/package.json index 4bcd62ee5d..0a38696b08 100644 --- a/host-context-controller/package.json +++ b/host-context-controller/package.json @@ -1,6 +1,6 @@ { "name": "clerum-host-context-controller", - "version": "0.1.403", + "version": "0.1.404", "description": "Host Context Controller - provides McpServer CRDs to mcp-host via REST API", "main": "dist/main.js", "engines": { diff --git a/host-context-controller/test/statelessDeployment.workspaceLayoutMerge.test.ts b/host-context-controller/test/statelessDeployment.workspaceLayoutMerge.test.ts index 6e07cf6cb3..9e821ca8e1 100644 --- a/host-context-controller/test/statelessDeployment.workspaceLayoutMerge.test.ts +++ b/host-context-controller/test/statelessDeployment.workspaceLayoutMerge.test.ts @@ -21,6 +21,7 @@ import { rmSync, statSync, symlinkSync, + unlinkSync, writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' @@ -932,7 +933,7 @@ describe('workspace layout merge', () => { expect(snapshot(root)).toEqual(refusedTree) expect(messageCount(join(root, 'state.db'))).toEqual({ rows: ROWS, check: 'ok' }) // Witness: without the root symlink the same rollback init splits the tree. - rmSync(join(root, 'evenfire')) + unlinkSync(join(root, 'evenfire')) baseInit(root) expect(readdirSync(join(root, 'state'))).toContain('state.db') }, 15_000) From f8ff1db77b390f7c4c16e9634ed7f252e3bb528f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:39:42 -0600 Subject: [PATCH 304/528] fix(mcp-host): r69-m1 support node 24.22 fixture copy Class-check: cpSync with errorOnExist and pre-created root -> 1 fixture site -> remove flag; other cpSync sites do not combine this flag with a pre-created root. Repro-test: Node 24.22 MCP Host tests failed at 118b134e3be11feaf96084377d8c4fc900186f04 and passed after this correction. --- mcp-host/package-lock.json | 4 ++-- mcp-host/package.json | 2 +- mcp-host/src/__tests__/fixtures/devGfsStoreFixture.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mcp-host/package-lock.json b/mcp-host/package-lock.json index 5f37585e16..fdd0c8f298 100644 --- a/mcp-host/package-lock.json +++ b/mcp-host/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-mcp-host", - "version": "0.1.593", + "version": "0.1.594", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-mcp-host", - "version": "0.1.593", + "version": "0.1.594", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@anthropic-ai/sdk": "^0.32.0", diff --git a/mcp-host/package.json b/mcp-host/package.json index 0884cccb33..7a4f1ff0cd 100644 --- a/mcp-host/package.json +++ b/mcp-host/package.json @@ -1,6 +1,6 @@ { "name": "clerum-mcp-host", - "version": "0.1.593", + "version": "0.1.594", "description": "MCP Host - reads Host CRD and provides LLM access via OpenAI/Claude", "main": "dist/main.js", "engines": { diff --git a/mcp-host/src/__tests__/fixtures/devGfsStoreFixture.ts b/mcp-host/src/__tests__/fixtures/devGfsStoreFixture.ts index 29e115c2e4..b6fe720baf 100644 --- a/mcp-host/src/__tests__/fixtures/devGfsStoreFixture.ts +++ b/mcp-host/src/__tests__/fixtures/devGfsStoreFixture.ts @@ -102,7 +102,7 @@ export function loadDevStore(root: string, snapshot: DevSnapshot): DevStore { const generation = readGeneration() const source = path.join(FIXTURE, snapshot) fs.mkdirSync(root, { recursive: true, mode: 0o700 }) - fs.cpSync(source, root, { recursive: true, errorOnExist: true, force: false }) + fs.cpSync(source, root, { recursive: true, force: false }) // Git keeps no modes: restore the ones the dev store wrote, deepest first so // a 0700 directory never blocks its children. From 5907db76956f1d1723321c1ae1c758b9f823cf71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:29:07 -0600 Subject: [PATCH 305/528] fix(test): reconcile R70-M1 PR1 RealPG census The PR1 tree now accounts for every physical public RealPG suite while retaining both private producer exclusions.\n\nClass-check: control-api/test/**/*realPostgres*.test.ts -> 115 suites -> 113 public,\n2 existing private producer exclusions; no other exclusions.\nRepro-test: 871ef9b fails verify (24 unclassified); corrected verify and contract test\npass 27/27, including unclassified-file control. --- .../password-recovery-suite-contract.test.mjs | 38 +++++++++++++++++++ tests/contracts/password-recovery-suites.json | 26 ++++++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/scripts/tests/password-recovery-suite-contract.test.mjs b/scripts/tests/password-recovery-suite-contract.test.mjs index 1cc4f40941..59c3062f81 100644 --- a/scripts/tests/password-recovery-suite-contract.test.mjs +++ b/scripts/tests/password-recovery-suite-contract.test.mjs @@ -30,6 +30,44 @@ test('exact checked-in census and full reporter pass', () => { assert.equal(verifySuiteManifest(root, manifest), manifest) assert.deepEqual(reportViolations(root, manifest, 'publicPostgres', green()), []) }) +test('new unclassified physical PostgreSQL suite fails closed', () => { + const scratchRoot = process.env.CODEX_SCRATCH_ROOT + assert.ok(scratchRoot, 'CODEX_SCRATCH_ROOT is required for census fixtures') + const repositoryScratch = join(scratchRoot, 'evenfire') + mkdirSync(repositoryScratch, { recursive: true }) + const fixture = mkdtempSync(join(repositoryScratch, 'recovery-census-')) + const selection = { + version: 1, + publicPostgres: ['control-api/test/public.realPostgres.test.ts'], + privatePostgres: { + local: 'control-api/test/local.realPostgres.test.ts', + configured: 'control-api/test/configured.realPostgres.test.ts', + }, + composedContract: ['control-api/test/contract.test.ts'], + consumer: ['external-rest-api/test/consumer.test.ts'], + } + try { + for (const directory of ['control-api/test', 'external-rest-api/test']) + mkdirSync(join(fixture, directory), { recursive: true }) + for (const entry of [ + ...selection.publicPostgres, + ...Object.values(selection.privatePostgres), + ...selection.composedContract, + ...selection.consumer, + ]) + writeFileSync(join(fixture, entry), '// fixture\n') + writeFileSync( + join(fixture, 'control-api/test/unclassified.realPostgres.test.ts'), + '// newly discovered physical suite\n' + ) + assert.throws( + () => verifySuiteManifest(fixture, selection), + /PostgreSQL suite census differs from exact manifest/ + ) + } finally { + rmSync(fixture, { recursive: true, force: true }) + } +}) test('pathname replacement cannot substitute nonempty contents after a suite is opened', () => { const scratchRoot = process.env.CODEX_SCRATCH_ROOT assert.ok(scratchRoot, 'CODEX_SCRATCH_ROOT is required for manifest fixtures') diff --git a/tests/contracts/password-recovery-suites.json b/tests/contracts/password-recovery-suites.json index f23c871da8..95c8da0ad5 100644 --- a/tests/contracts/password-recovery-suites.json +++ b/tests/contracts/password-recovery-suites.json @@ -89,7 +89,31 @@ "control-api/test/services.rateLimiter.realPostgres.integration.test.ts", "control-api/test/services.rateLimiterPoolSaturation.realPostgres.integration.test.ts", "control-api/test/services.subscriptionCatalogSyncCron.realPostgres.integration.test.ts", - "control-api/test/services.workflowScheduleForbid.realPostgres.integration.test.ts" + "control-api/test/services.workflowScheduleForbid.realPostgres.integration.test.ts", + "control-api/test/accessCatalogCoordinator.realPostgres.integration.test.ts", + "control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts", + "control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts", + "control-api/test/accessCatalogShadow.realPostgres.integration.test.ts", + "control-api/test/accessDatabaseCancellation.realPostgres.integration.test.ts", + "control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts", + "control-api/test/catalogProducers.realPostgres.integration.test.ts", + "control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts", + "control-api/test/externalSessionCurrentness.realPostgres.integration.test.ts", + "control-api/test/externalSessionSingleTouch.realPostgres.integration.test.ts", + "control-api/test/finalAuthorityLifecycle.realPostgres.integration.test.ts", + "control-api/test/gfsRevisionLockOrder.realPostgres.integration.test.ts", + "control-api/test/invitationAcceptedOnboarding.realPostgres.integration.test.ts", + "control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts", + "control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts", + "control-api/test/legacyV1Generation.realPostgres.integration.test.ts", + "control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts", + "control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts", + "control-api/test/routes.externalGfsV2Lifecycle.realPostgres.integration.test.ts", + "control-api/test/routes.externalNotifications.currentness.realPostgres.integration.test.ts", + "control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts", + "control-api/test/routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts", + "control-api/test/userAccessRuntimeContract.realPostgres.integration.test.ts", + "control-api/test/userSessionDatabaseClock.realPostgres.integration.test.ts" ], "privatePostgres": { "local": "control-api/test/passwordResetPayload.externalRest.realPostgres.integration.test.ts", From f73389f3137923a6a3930eb85e6ab190599a61e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:02:05 -0600 Subject: [PATCH 306/528] fix(control-api): r70-m2 preserve admission retry metadata Class-check: trace password-admission producers and all external route consumers; reuse the existing typed mapper only in the authenticated invitation endpoint. Repro-test: previous composed route lost Retry-After; focused disposable-Postgres password-recovery RealPG suite passed after the route-scoped mapper. --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- control-api/src/routes/external/invitations.ts | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index a454916363..a9d59615e0 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.786", + "version": "0.1.787", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.786", + "version": "0.1.787", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 8b27b361e6..1c8f9ca9da 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.786", + "version": "0.1.787", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/routes/external/invitations.ts b/control-api/src/routes/external/invitations.ts index ae01f8642f..8701f35692 100644 --- a/control-api/src/routes/external/invitations.ts +++ b/control-api/src/routes/external/invitations.ts @@ -214,6 +214,7 @@ export function createExternalInvitationsRouter(): Router { } return res.status(200).json(result.data) } catch (error) { + if (sendPasswordAdmissionError(error, res)) return return next(error) } } From 2a15cec4528cac0c810d332090f9aeb8d2f3d536 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:02:22 -0600 Subject: [PATCH 307/528] fix(control-api): r70-m3 restore invitation bucket namespaces Class-check: trace both persisted invitation buckets and verify current-dev key derivation; keep preview and password-token limits, auth, and ordering unchanged. Repro-test: uncorrected candidate returned 429 after ten previews; corrected invitationAdmission RealPG suite passed 3/3 on disposable PostgreSQL. --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- control-api/src/routes/external/invitations.ts | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index a9d59615e0..bbd5e34b6a 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.787", + "version": "0.1.788", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.787", + "version": "0.1.788", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 1c8f9ca9da..e6a3f89d38 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.787", + "version": "0.1.788", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/routes/external/invitations.ts b/control-api/src/routes/external/invitations.ts index 8701f35692..7c3ee6ec83 100644 --- a/control-api/src/routes/external/invitations.ts +++ b/control-api/src/routes/external/invitations.ts @@ -36,8 +36,8 @@ import { } from '../../services/invitationFlowRegistrationService.js' import { memberRegistrationErrorResponse } from '../../services/memberRegistrationErrors.js' -function invitationLookupIpKey(req: Request): string { - return `invite-token-ip:${externalClientIpRateLimitKey(req)}` +function invitationAdmissionKey(req: Request, operation: 'preview' | 'password-token'): string { + return `invite-token:${operation}:${externalClientIpRateLimitKey(req)}` } export function createExternalInvitationsRouter(): Router { @@ -54,7 +54,7 @@ export function createExternalInvitationsRouter(): Router { rateLimitMiddleware({ bucketType: 'external_invitation_lookup', maxPerMinute: 30, - getBucketKey: req => invitationLookupIpKey(req), + getBucketKey: req => invitationAdmissionKey(req, 'preview'), onBackendUnavailable: 'process-memory', }), async (req, res, next) => { @@ -88,7 +88,7 @@ export function createExternalInvitationsRouter(): Router { rateLimitMiddleware({ bucketType: 'external_invitation_password_token', maxPerMinute: 10, - getBucketKey: req => invitationLookupIpKey(req), + getBucketKey: req => invitationAdmissionKey(req, 'password-token'), onBackendUnavailable: 'process-memory', }), async (req, res, next) => { From d824fe3b171ab62c399d27dffce6b99fcc084ff4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:02:55 -0600 Subject: [PATCH 308/528] fix(control-api): r70-h2 revoke sessions after password updates Class-check: trace every successful memberPassword hash write, generation CAS, and session-revocation consumer; restore revocation inside the existing mutation transaction while preserving the #1056 lease and commit callbacks. Repro-test: previous composed race suite failed four session/replacement assertions; corrected legacySessionExchangeRace RealPG assertions pass with the actual suite-pool transaction runner and callbacks. --- control-api/package-lock.json | 4 +-- control-api/package.json | 2 +- .../src/services/directory/memberPassword.ts | 4 +++ ...hangeRace.realPostgres.integration.test.ts | 25 ++++++++----------- 4 files changed, 18 insertions(+), 17 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index bbd5e34b6a..85dad5b608 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.788", + "version": "0.1.789", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.788", + "version": "0.1.789", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index e6a3f89d38..09d2672988 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.788", + "version": "0.1.789", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/directory/memberPassword.ts b/control-api/src/services/directory/memberPassword.ts index 9e88b6f000..5f92c0230c 100644 --- a/control-api/src/services/directory/memberPassword.ts +++ b/control-api/src/services/directory/memberPassword.ts @@ -1,6 +1,7 @@ import bcrypt from 'bcryptjs' import { type DbClient, pool } from '../../db.js' import { type MemberPasswordWorkLease, runPasswordMutation } from '../auth/passwordWorkOwnership.js' +import { revokeAllUserSessions } from '../auth/userSessionService.js' import { type InvitationUserRow, ensureInvitationUser, @@ -121,6 +122,7 @@ export async function setInvitationPasswordForUser( if (!(await updateActivePasswordHash(db, user.id, passwordHash))) { return { error: 'user_retired' as const } } + await revokeAllUserSessions(user.id, 'password_changed', db) if (!invitation.accepted_user_id) { await db.query(`UPDATE invitations SET accepted_user_id = $2 WHERE id = $1`, [ @@ -192,6 +194,7 @@ export async function setInvitationPasswordForEmail( if (!(await updateActivePasswordHash(db, user.id, passwordHash))) { return { error: 'user_retired' as const } } + await revokeAllUserSessions(user.id, 'password_changed', db) const redeemed = await db.query( `UPDATE invitations @@ -278,6 +281,7 @@ export async function updateUserPassword( ] ) if (update.rows.length !== 1) return { error: 'credential_changed' as const } + await revokeAllUserSessions(normalizedUserId, 'password_changed', db) return { updated: true as const } }, { mode: 'standard' } diff --git a/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts b/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts index 07bb785497..81935cace7 100644 --- a/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts +++ b/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts @@ -2,7 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites import bcrypt from 'bcryptjs' import { createHash, randomBytes, randomUUID } from 'node:crypto' import { Pool, type PoolClient } from 'pg' -import { type DbClient, initDb } from '../src/db.js' +import { type DbTransactionClient, type DbTransactionOptions, initDb } from '../src/db.js' import type { AuthClaims } from '../src/profileTypes.js' import type { EffectiveUserAccessPolicy } from '../src/services/access/userAccessPolicy.js' import { exchangeLegacyExternalUserSession } from '../src/services/auth/externalSessionIssuance.js' @@ -41,19 +41,16 @@ vi.mock('../src/db.js', async importOriginal => { const pool = { query: (text: string, values?: unknown[]) => producerDatabase.pool!.query(text, values), } - const withTransaction = async (work: (db: DbClient) => Promise): Promise => { - const client = await producerDatabase.pool!.connect() - try { - await client.query('BEGIN') - const result = await work(client as unknown as DbClient) - await client.query('COMMIT') - return result - } catch (error) { - await client.query('ROLLBACK') - throw error - } finally { - client.release() - } + const withTransaction = ( + work: (db: DbTransactionClient) => Promise, + txPoolOrOptions?: Pool | DbTransactionOptions, + explicitOptions: DbTransactionOptions = {} + ): Promise => { + const options: DbTransactionOptions = + txPoolOrOptions && typeof (txPoolOrOptions as Pool).connect !== 'function' + ? (txPoolOrOptions as DbTransactionOptions) + : explicitOptions + return actual.withTransaction(work, producerDatabase.pool!, options) } return { ...actual, pool, withTransaction } }) From 8abec595ee468d496cdff63ab9bc77fcfcd235b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:03:44 -0600 Subject: [PATCH 309/528] test(control-api): align invitation onboarding realpg contract Class-check: trace invitation-email replay and both transaction-runner overloads; make the suite use the actual pool/options/callback contract. Repro-test: composed invitation onboarding RealPG now passes with the current one-use email-token response and real transaction callbacks. --- control-api/package-lock.json | 4 +-- control-api/package.json | 2 +- ...nboarding.realPostgres.integration.test.ts | 27 +++++++++---------- 3 files changed, 15 insertions(+), 18 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 85dad5b608..7b5b587115 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.789", + "version": "0.1.790", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.789", + "version": "0.1.790", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 09d2672988..1adfb726e8 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.789", + "version": "0.1.790", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/invitationAcceptedOnboarding.realPostgres.integration.test.ts b/control-api/test/invitationAcceptedOnboarding.realPostgres.integration.test.ts index 6b655d264b..f71efe6523 100644 --- a/control-api/test/invitationAcceptedOnboarding.realPostgres.integration.test.ts +++ b/control-api/test/invitationAcceptedOnboarding.realPostgres.integration.test.ts @@ -2,7 +2,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import bcrypt from 'bcryptjs' import { randomBytes, randomUUID } from 'node:crypto' import { Pool } from 'pg' -import { type DbClient, initDb } from '../src/db.js' +import { type DbTransactionClient, type DbTransactionOptions, initDb } from '../src/db.js' import { acceptInvitationForEmail, createInvitationForTeams, @@ -27,19 +27,16 @@ vi.mock('../src/db.js', async importOriginal => { const pool = { query: (text: string, values?: unknown[]) => producerDatabase.pool!.query(text, values), } - const withTransaction = async (work: (db: DbClient) => Promise): Promise => { - const client = await producerDatabase.pool!.connect() - try { - await client.query('BEGIN') - const result = await work(client as unknown as DbClient) - await client.query('COMMIT') - return result - } catch (error) { - await client.query('ROLLBACK') - throw error - } finally { - client.release() - } + const withTransaction = ( + work: (db: DbTransactionClient) => Promise, + txPoolOrOptions?: Pool | DbTransactionOptions, + explicitOptions: DbTransactionOptions = {} + ): Promise => { + const options: DbTransactionOptions = + txPoolOrOptions && typeof (txPoolOrOptions as Pool).connect !== 'function' + ? (txPoolOrOptions as DbTransactionOptions) + : explicitOptions + return actual.withTransaction(work, producerDatabase.pool!, options) } return { ...actual, pool, withTransaction } }) @@ -170,7 +167,7 @@ describeRealPostgres('accepted onboarding expiry on real PostgreSQL', () => { invitation.id, 'Replay-Password-1!' ) - ).resolves.toEqual({ error: 'expired' }) + ).resolves.toEqual({ error: 'not_found' }) const stored = await databasePool.query<{ password_hash: string | null }>( `SELECT password_hash FROM users WHERE id = $1`, From 89c12a6affb6bb3ca46dc3b0ff179e7554d7655a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:03:47 -0600 Subject: [PATCH 310/528] test(control-api): assert sanitized direct login errors Class-check: verify the direct Control API boundary and retain External REST's separate invalid_credentials mapping contract. Repro-test: composed password-login RealPG asserts 401 Unauthorized and preserves dummy-hash comparison coverage. --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- .../test/passwordLogin.realPostgres.integration.test.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 7b5b587115..f80598026a 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.790", + "version": "0.1.791", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.790", + "version": "0.1.791", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 1adfb726e8..c42d871fd3 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.790", + "version": "0.1.791", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/passwordLogin.realPostgres.integration.test.ts b/control-api/test/passwordLogin.realPostgres.integration.test.ts index 6575f33f14..33876fc842 100644 --- a/control-api/test/passwordLogin.realPostgres.integration.test.ts +++ b/control-api/test/passwordLogin.realPostgres.integration.test.ts @@ -224,7 +224,7 @@ realPg('BUG-192 observable password-login regressions on real PostgreSQL', () => const response = await login(email) expect({ status: response.status, body: response.body }).toEqual({ status: 401, - body: { error: 'invalid_credentials' }, + body: { error: 'Unauthorized' }, }) } ) @@ -246,7 +246,7 @@ realPg('BUG-192 observable password-login regressions on real PostgreSQL', () => await holder.pool.query('UPDATE users SET password_hash = $1 WHERE id = $2', [hash, userId]) const response = await login('member@example.invalid', password) expect(response.status).toBe(401) - expect(response.body).toEqual({ error: 'invalid_credentials' }) + expect(response.body).toEqual({ error: 'Unauthorized' }) expect(compare).toHaveBeenCalledTimes(1) expect(compare.mock.calls[0][1]).not.toBe(hash) expect(bcrypt.getRounds(compare.mock.calls[0][1] as string)).toBe(12) From d070785c53f580cedd41a570ffdf59c65f31433e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:03:51 -0600 Subject: [PATCH 311/528] test(control-api): mount composed recovery auth route Class-check: trace the real Control API recovery endpoint graph after the auth-router split; mount the producer exercised by the public-flow suite. Repro-test: password-recovery HTTP RealPG passes against the composed route and retains sanitized 429/8 and 503/2 assertions. --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- .../passwordRecovery.http.realPostgres.integration.test.ts | 2 ++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index f80598026a..bca53fc350 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.791", + "version": "0.1.792", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.791", + "version": "0.1.792", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index c42d871fd3..2a702e5bcc 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.791", + "version": "0.1.792", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/passwordRecovery.http.realPostgres.integration.test.ts b/control-api/test/passwordRecovery.http.realPostgres.integration.test.ts index f7f230e738..30b200dd08 100644 --- a/control-api/test/passwordRecovery.http.realPostgres.integration.test.ts +++ b/control-api/test/passwordRecovery.http.realPostgres.integration.test.ts @@ -68,6 +68,7 @@ realPg('R2-M1/R2-M2 public password authority contract on PostgreSQL 16', () => const { clerumErrorHandler } = await import('../src/http/errorHandler.js') const { createExternalInvitationsRouter } = await import('../src/routes/external/invitations.js') + const { createExternalAuthRouter } = await import('../src/routes/external/auth.js') const { createExternalUsersRouter } = await import('../src/routes/external/users.js') controlApp = express() @@ -82,6 +83,7 @@ realPg('R2-M1/R2-M2 public password authority contract on PostgreSQL 16', () => const controlApi = express.Router() controlApi.use('/external', requireInternalService('external-rest-api')) controlApi.use(createExternalInvitationsRouter()) + controlApi.use(createExternalAuthRouter({} as Parameters[0])) controlApi.use(createExternalUsersRouter({} as Parameters[0])) controlApp.use('/api/v1', controlApi) controlApp.use(clerumErrorHandler) From 2ca0f5371cb277a63ab88841ad0120e2390c85e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:22:20 -0600 Subject: [PATCH 312/528] fix: exclude pending invites from Google login authority Class-check: trace Google membership producers and consumers; keep accepted-membership healing. Repro-test: composed login regression confirms pending invitations grant no membership authority. --- control-api/package-lock.json | 4 +-- control-api/package.json | 2 +- control-api/src/services/directory/login.ts | 32 +-------------------- 3 files changed, 4 insertions(+), 34 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index bca53fc350..9a218b1c4b 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.792", + "version": "0.1.793", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.792", + "version": "0.1.793", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 2a702e5bcc..130c9f9db3 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.792", + "version": "0.1.793", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/directory/login.ts b/control-api/src/services/directory/login.ts index 61cc103183..98b8849283 100644 --- a/control-api/src/services/directory/login.ts +++ b/control-api/src/services/directory/login.ts @@ -43,32 +43,6 @@ async function findFirstActiveMembership( ) } -async function findPendingInvitationMembership( - db: { - query: ( - text: string, - values?: unknown[] - ) => Promise<{ rows: unknown[]; rowCount: number | null }> - }, - email: string -) { - return db.query( - `SELECT COALESCE(it.team_id, i.team_id) AS team_id, - COALESCE(it.role, i.role) AS role, - t.name AS team_name - FROM invitations i - LEFT JOIN invitation_teams it ON it.invitation_id = i.id - JOIN teams t ON t.id = COALESCE(it.team_id, i.team_id) - WHERE LOWER(i.email) = LOWER($1) - AND COALESCE(it.team_id, i.team_id) IS NOT NULL - AND i.status = 'pending' - AND i.expires_at > NOW() - ORDER BY i.created_at DESC - LIMIT 1`, - [email] - ) -} - async function healAcceptedInvitationMemberships( db: { query: ( @@ -195,11 +169,7 @@ export async function googleLoginData(input: { email: string; name?: string; pic await healAcceptedInvitationMemberships(db, user.id, input.email) await linkAcceptedInvitationsToUser(db, user.id, input.email) - let membership = await findFirstActiveMembership(db, user.id) - - if ((membership.rowCount ?? 0) === 0) { - membership = await findPendingInvitationMembership(db, input.email) - } + const membership = await findFirstActiveMembership(db, user.id) return { isNewUser, From 03ff0a1c8b03f3c4323a3cb1ab062020ede94b35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:22:32 -0600 Subject: [PATCH 313/528] fix: preserve empty-password invitation error contract Class-check: separate required invitation identity fields from password validation; retain sanitized codes. Repro-test: composed producer returns invalid_password for empty passwords and invalid payload for missing identity. --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- control-api/src/routes/external/invitations.ts | 5 ++++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 9a218b1c4b..1a579479ed 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.793", + "version": "0.1.794", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.793", + "version": "0.1.794", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 130c9f9db3..d81d73740f 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.793", + "version": "0.1.794", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/routes/external/invitations.ts b/control-api/src/routes/external/invitations.ts index 7c3ee6ec83..0838b958a2 100644 --- a/control-api/src/routes/external/invitations.ts +++ b/control-api/src/routes/external/invitations.ts @@ -99,9 +99,12 @@ export function createExternalInvitationsRouter(): Router { const token = String(req.body?.token || '').trim() const invitationId = String(req.body?.invitationId || '').trim() const password = String(req.body?.password || '') - if (!email || !token || !invitationId || !password) { + if (!email || !token || !invitationId) { return res.status(400).json({ error: 'invalid payload' }) } + if (!password) { + return res.status(400).json({ error: 'invalid_password' }) + } let validation: { email: string; invitationUuid: string } try { From c529f62c65efeed7512e18302306ff9e4095284c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:22:35 -0600 Subject: [PATCH 314/528] fix: preserve typed RPC authorization denials Class-check: trace Control API denial through RPC service and route sanitizer; keep public allowlist unchanged. Repro-test: composed request propagates malformed 403 responses to the existing safe sanitizer. --- external-rest-api/package-lock.json | 4 +-- external-rest-api/package.json | 2 +- external-rest-api/src/releaseManifest.ts | 2 +- external-rest-api/src/services/rpcService.ts | 28 +++++++------------- external-rest-api/test/services.rpc.test.ts | 4 +-- 5 files changed, 16 insertions(+), 24 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 8646f53c4e..6d12d748cd 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.135", + "version": "0.1.136", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.135", + "version": "0.1.136", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index fd6ea41a95..c5fbc1bd94 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.135", + "version": "0.1.136", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index b397ee7576..4b6d9a4012 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.11.0', - externalRestApiVersion: '0.1.135', + externalRestApiVersion: '0.1.136', rpcProxyVersion: '0.1.127', desktopVersion: '0.11.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/src/services/rpcService.ts b/external-rest-api/src/services/rpcService.ts index 127411d3a9..f27bb19b84 100644 --- a/external-rest-api/src/services/rpcService.ts +++ b/external-rest-api/src/services/rpcService.ts @@ -1,4 +1,4 @@ -import { ControlApiError, controlApiRequest } from '../controlApiClient.js' +import { controlApiRequest } from '../controlApiClient.js' import { RpcAccessScope, RpcScope } from '../types.js' type IssuedRpcToken = { @@ -11,7 +11,7 @@ type IssuedRpcToken = { droppedScopes?: RpcScope[] } -export type RpcAccessTokenResult = IssuedRpcToken | { error: string } +export type RpcAccessTokenResult = IssuedRpcToken export async function issueRpcAccessToken( sessionToken: string, @@ -19,20 +19,12 @@ export async function issueRpcAccessToken( requestedHostRefsInput: unknown, clientIp?: string ): Promise { - try { - return await controlApiRequest('POST', '/external/rpc/token', { - body: { - sessionToken, - scopes: requestedScopesInput, - hostRefs: requestedHostRefsInput, - }, - ...(clientIp ? { extraHeaders: { 'x-evenfire-client-ip': clientIp } } : {}), - }) - } catch (error) { - // Preserve the target service's public denial reason while keeping its HTTP contract. - if (error instanceof ControlApiError && error.status === 403) { - return { error: error.code ?? 'forbidden' } - } - throw error - } + return controlApiRequest('POST', '/external/rpc/token', { + body: { + sessionToken, + scopes: requestedScopesInput, + hostRefs: requestedHostRefsInput, + }, + ...(clientIp ? { extraHeaders: { 'x-evenfire-client-ip': clientIp } } : {}), + }) } diff --git a/external-rest-api/test/services.rpc.test.ts b/external-rest-api/test/services.rpc.test.ts index 2f7bb90350..376ecddb2b 100644 --- a/external-rest-api/test/services.rpc.test.ts +++ b/external-rest-api/test/services.rpc.test.ts @@ -27,7 +27,7 @@ describe('rpcService.issueRpcAccessToken', () => { ).rejects.toBeInstanceOf(ControlApiError) }) - it('uses the generic forbidden reason when a 403 error code is not a string', async () => { + it('propagates malformed 403 bodies for the route sanitizer to reject safely', async () => { for (const body of [{ error: ['desktop_requires_team'] }, { error: 42 }]) { clientMock.controlApiRequest.mockRejectedValueOnce( new ControlApiError( @@ -39,7 +39,7 @@ describe('rpcService.issueRpcAccessToken', () => { await expect( issueRpcAccessToken('session', ['desktop:view'], ['pro-agent']) - ).resolves.toEqual({ error: 'forbidden' }) + ).rejects.toBeInstanceOf(ControlApiError) } }) From 1dd0fb967ec25e02b1837c906313bc3ef98951e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:22:41 -0600 Subject: [PATCH 315/528] test: align invitation fixtures with current password flow Class-check: inspect invitation producer contracts and route expectations; retain reset and cookie checks. Repro-test: preview, accept, then password setup fixtures pass with reauthentication and cookie assertions. --- external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- external-rest-api/src/releaseManifest.ts | 2 +- .../test/routes.invitations.test.ts | 75 ++++++++++++++----- 4 files changed, 60 insertions(+), 23 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 6d12d748cd..5321a7348f 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.136", + "version": "0.1.137", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.136", + "version": "0.1.137", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index c5fbc1bd94..d5c4ed85e3 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.136", + "version": "0.1.137", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index 4b6d9a4012..2bb5b64968 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.11.0', - externalRestApiVersion: '0.1.136', + externalRestApiVersion: '0.1.137', rpcProxyVersion: '0.1.127', desktopVersion: '0.11.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/test/routes.invitations.test.ts b/external-rest-api/test/routes.invitations.test.ts index 776d1681e7..810ad3b482 100644 --- a/external-rest-api/test/routes.invitations.test.ts +++ b/external-rest-api/test/routes.invitations.test.ts @@ -39,7 +39,20 @@ describe('routes/invitations', () => { } it('clears the revoked invitation session after setting the password', async () => { - invitationsServiceMock.setupInvitationPasswordWithToken.mockResolvedValue({ + invitationsServiceMock.getInvitationByToken.mockResolvedValue({ + id: 'inv-1', + email: 'invitee@example.com', + purpose: 'member_invitation', + status: 'pending', + }) + invitationsServiceMock.acceptInvitation.mockResolvedValue({ + data: { + userId: 'user-1', + email: 'invitee@example.com', + token: 'invited-session-token', + }, + }) + invitationsServiceMock.setupInvitationPassword.mockResolvedValue({ data: { id: 'inv-1', passwordUpdated: true }, }) @@ -63,15 +76,20 @@ describe('routes/invitations', () => { expect(String(res.headers['set-cookie'])).not.toContain('invited-session-token') expect(String(res.headers['set-cookie'])).toContain('HttpOnly') expect(authTokenMock.verifyToken).not.toHaveBeenCalled() - expect(invitationsServiceMock.acceptInvitation).not.toHaveBeenCalled() - expect(invitationsServiceMock.setupInvitationPassword).not.toHaveBeenCalled() - expect(invitationsServiceMock.setupInvitationPasswordWithToken).toHaveBeenCalledWith({ - token: 'invitation-link-token', - email: 'invitee@example.com', - invitationId: 'inv-1', - password: 'user123!', - sessionContract: 'v2', - }) + expect(invitationsServiceMock.getInvitationByToken).toHaveBeenCalledWith( + 'invitation-link-token' + ) + expect(invitationsServiceMock.acceptInvitation).toHaveBeenCalledWith( + 'invitation-link-token', + 'invitee@example.com', + 'v2' + ) + expect(invitationsServiceMock.setupInvitationPassword).toHaveBeenCalledWith( + { userId: 'user-1', email: 'invitee@example.com', sessionToken: 'invited-session-token' }, + 'inv-1', + 'user123!' + ) + expect(invitationsServiceMock.setupInvitationPasswordWithToken).not.toHaveBeenCalled() }) it('requires reauthentication after password reset and never restores a session', async () => { @@ -401,7 +419,20 @@ describe('routes/invitations', () => { it('falls back to invitation token flow when an old bearer token is invalid', async () => { authTokenMock.verifyToken.mockReturnValue(null) - invitationsServiceMock.setupInvitationPasswordWithToken.mockResolvedValue({ + invitationsServiceMock.getInvitationByToken.mockResolvedValue({ + id: 'inv-1', + email: 'invitee@example.com', + purpose: 'member_invitation', + status: 'pending', + }) + invitationsServiceMock.acceptInvitation.mockResolvedValue({ + data: { + userId: 'user-1', + email: 'invitee@example.com', + token: 'invited-session-token', + }, + }) + invitationsServiceMock.setupInvitationPassword.mockResolvedValue({ data: { id: 'inv-1', passwordUpdated: true }, }) @@ -417,14 +448,20 @@ describe('routes/invitations', () => { .expect(200) expect(authTokenMock.verifyToken).toHaveBeenCalledWith('stale-token') - expect(invitationsServiceMock.acceptInvitation).not.toHaveBeenCalled() - expect(invitationsServiceMock.setupInvitationPasswordWithToken).toHaveBeenCalledWith({ - token: 'invitation-link-token', - email: 'invitee@example.com', - invitationId: 'inv-1', - password: 'user123!', - sessionContract: undefined, - }) + expect(invitationsServiceMock.getInvitationByToken).toHaveBeenCalledWith( + 'invitation-link-token' + ) + expect(invitationsServiceMock.acceptInvitation).toHaveBeenCalledWith( + 'invitation-link-token', + 'invitee@example.com', + undefined + ) + expect(invitationsServiceMock.setupInvitationPassword).toHaveBeenCalledWith( + { userId: 'user-1', email: 'invitee@example.com', sessionToken: 'invited-session-token' }, + 'inv-1', + 'user123!' + ) + expect(invitationsServiceMock.setupInvitationPasswordWithToken).not.toHaveBeenCalled() }) it('sets invitation password with a valid bearer token without accepting again', async () => { From c107806b296e4f3a6bd4f452722c29eee154ee67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:22:44 -0600 Subject: [PATCH 316/528] test: assert sanitized retired-member failures Class-check: inspect member retirement error handling and public response sanitizer. Repro-test: malformed upstream retirement response yields a sanitized generic 500 envelope. --- external-rest-api/package-lock.json | 4 ++-- external-rest-api/package.json | 2 +- external-rest-api/src/releaseManifest.ts | 2 +- external-rest-api/test/routes.members.test.ts | 17 ++++++++++++----- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 5321a7348f..0c2e108af6 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.137", + "version": "0.1.138", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.137", + "version": "0.1.138", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index d5c4ed85e3..b46e7544a5 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.137", + "version": "0.1.138", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index 2bb5b64968..17e30ea9e6 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.11.0', - externalRestApiVersion: '0.1.137', + externalRestApiVersion: '0.1.138', rpcProxyVersion: '0.1.127', desktopVersion: '0.11.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/test/routes.members.test.ts b/external-rest-api/test/routes.members.test.ts index 94bd384536..67549581fa 100644 --- a/external-rest-api/test/routes.members.test.ts +++ b/external-rest-api/test/routes.members.test.ts @@ -386,15 +386,22 @@ describe('routes/members', () => { { error: ['reason_required'] } ) ) - await request(createApp()) + const response = await request(createApp()) .delete(`/api/v1/members/${TARGET_USER_ID}`) .set('authorization', 'Bearer good-token') .set('Idempotency-Key', 'retire-user-2-v1') .send({ reason: 'team access no longer required' }) - .expect(500, { - error: 'Internal Server Error', - message: 'Control API returned an unsupported response', - }) + + expect(response.status).toBe(500) + expect(response.body).toEqual({ + error: { + code: 'internal_error', + message: 'The request could not be completed.', + correlationId: expect.any(String), + retryable: false, + }, + }) + expect(JSON.stringify(response.body)).not.toMatch(/reason_required|Control API|unsupported/) }) it.each([ From 44173e2d4b254e3651c27271edabbd9c9d38e299 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:22:48 -0600 Subject: [PATCH 317/528] test: align password lease and invitation fixtures Class-check: trace password-work lease callbacks and email-only member-invitation behavior. Repro-test: lease mocks assert single-generation revocation; preview and acceptance precede password setup. --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../directoryAuthorizationPrivacy.test.ts | 110 +++++++++--------- 3 files changed, 61 insertions(+), 55 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 1a579479ed..a0b5749632 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.794", + "version": "0.1.795", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.794", + "version": "0.1.795", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index d81d73740f..e66834fe51 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.794", + "version": "0.1.795", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/directoryAuthorizationPrivacy.test.ts b/control-api/test/directoryAuthorizationPrivacy.test.ts index 32ca32c982..5e81a797f0 100644 --- a/control-api/test/directoryAuthorizationPrivacy.test.ts +++ b/control-api/test/directoryAuthorizationPrivacy.test.ts @@ -46,6 +46,19 @@ async function queryWithDatabaseClock(sql: string, values?: unknown[]) { return mocks.query(sql, values) } +async function queryWithPasswordWorkLease(sql: string, values?: unknown[]) { + if (sql.includes('SET LOCAL synchronous_commit = on')) { + return { rows: [], rowCount: 0 } + } + if (sql.includes('INSERT INTO password_verification_work')) { + return { rows: [{ singleton: true }], rowCount: 1 } + } + if (sql.includes('DELETE FROM password_verification_work')) { + return { rows: [{ singleton: true }], rowCount: 1 } + } + return queryWithDatabaseClock(sql, values) +} + function activeInvitationWindow() { return { created_at: new Date(Date.now() - 60_000), @@ -60,7 +73,12 @@ describe('directory privacy and atomic authorization', () => { mocks.appendEvents.mockClear() mocks.registerInvitation.mockReset() mocks.registerInvitation.mockResolvedValue(undefined) - mocks.withTransaction.mockImplementation(async work => work({ query: queryWithDatabaseClock })) + mocks.withTransaction.mockImplementation(async (work, _client, hooks) => { + const result = await work({ query: queryWithPasswordWorkLease }) + const callbacks = hooks as { onCommitAcknowledged?: () => void } | undefined + callbacks?.onCommitAcknowledged?.() + return result + }) }) it('projects managed members only through teams the caller currently manages', async () => { @@ -491,6 +509,7 @@ describe('directory privacy and atomic authorization', () => { } } if (sql.includes('INSERT INTO profiles')) return { rows: [], rowCount: 0 } + if (sql.includes('UPDATE profiles')) return { rows: [], rowCount: 1 } if (sql.includes("SET status = 'accepted'")) { if (status !== 'pending') return { rows: [], rowCount: 0 } status = 'accepted' @@ -643,21 +662,20 @@ describe('directory privacy and atomic authorization', () => { for (const operation of [ () => setInvitationPasswordForUser(TARGET, invitation.email, invitation.id, 'valid-password'), - () => - setInvitationPasswordForEmail( - invitation.email, - invitation.token, - invitation.id, - 'valid-password' - ), + () => setInvitationPasswordForEmail(invitation.email, invitation.id, 'valid-password'), ]) { mocks.query.mockReset() mocks.query.mockImplementation(async (sql: string) => { if ( sql.includes('FROM invitations i') && - (sql.includes('WHERE i.id::text = $1') || sql.includes('WHERE i.token = $1')) + (sql.includes('i.id::text = $1') || sql.includes('i.token = $1')) ) { - return { rows: [invitation], rowCount: 1 } + return { + rows: [ + sql.includes('FOR UPDATE OF i') ? { ...invitation, status: 'accepted' } : invitation, + ], + rowCount: 1, + } } if (sql.includes('FROM users') && sql.includes('WHERE email = $1')) { return { @@ -681,12 +699,11 @@ describe('directory privacy and atomic authorization', () => { await expect(operation()).resolves.toEqual({ error: 'not_pending' }) const sql = mocks.query.mock.calls.map(call => String(call[0])).join('\n') expect(sql).toContain('FOR UPDATE OF i') - expect(sql).toContain("AND status = 'pending'") expect(sql).not.toContain('SET password_hash') } }) - it('atomically accepts a pending member invitation with first password setup', async () => { + it('accepts a member invitation before first password setup', async () => { const invitation = { id: '00000000-0000-4000-8000-000000000200', team_id: TEAM_A, @@ -710,7 +727,7 @@ describe('directory privacy and atomic authorization', () => { mocks.query.mockImplementation(async (sql: string) => { if ( sql.includes('FROM invitations i') && - (sql.includes('WHERE i.token = $1') || sql.includes('WHERE i.id::text = $1')) + (sql.includes('i.token = $1') || sql.includes('i.id::text = $1')) ) { return { rows: [ @@ -728,12 +745,15 @@ describe('directory privacy and atomic authorization', () => { name: 'New user', picture: null, password_hash: passwordHash, + lifecycle_state: 'active', + lifecycle_version: lifecycleVersion, }, ], rowCount: 1, } } if (sql.includes('INSERT INTO profiles')) return { rows: [], rowCount: 0 } + if (sql.includes('UPDATE profiles')) return { rows: [], rowCount: 1 } if (sql.includes("SET status = 'accepted'")) { if (status !== 'pending') return { rows: [], rowCount: 0 } status = 'accepted' @@ -751,15 +771,16 @@ describe('directory privacy and atomic authorization', () => { } if (sql.includes('SET password_hash = $2')) { passwordHash = 'stored-hash' - return { rows: [], rowCount: 1 } - } - if (sql.includes('SET lifecycle_version = lifecycle_version + 1')) { lifecycleVersion += 1 lifecycleAdvances += 1 return { rows: [{ lifecycle_version: lifecycleVersion }], rowCount: 1 } } - if (sql.includes('SELECT id') && sql.includes('FROM users') && sql.includes('FOR UPDATE')) { - return { rows: [{ id: TARGET }], rowCount: 1 } + if ( + sql.includes('SELECT id') && + sql.includes('FROM users') && + sql.includes('FOR NO KEY UPDATE') + ) { + return { rows: [{ id: TARGET, lifecycle_version: lifecycleVersion }], rowCount: 1 } } if (sql.includes('external_user_session_security_epochs')) return { rows: [], rowCount: 1 } if (sql.includes('UPDATE external_user_sessions')) return { rows: [], rowCount: 0 } @@ -767,12 +788,10 @@ describe('directory privacy and atomic authorization', () => { }) await expect( - setInvitationPasswordForEmail( - invitation.email, - invitation.token, - invitation.id, - 'valid-password' - ) + acceptInvitationForEmail(invitation.email, invitation.token, invitation.id) + ).resolves.toMatchObject({ data: { accepted: true, userId: TARGET, status: 'accepted' } }) + await expect( + setInvitationPasswordForUser(TARGET, invitation.email, invitation.id, 'valid-password') ).resolves.toMatchObject({ data: { passwordUpdated: true, status: 'accepted' } }) expect(status).toBe('accepted') expect(passwordHash).not.toBeNull() @@ -780,18 +799,10 @@ describe('directory privacy and atomic authorization', () => { expect(lifecycleVersion).toBe(2) expect(lifecycleAdvances).toBe(1) - await expect( - setInvitationPasswordForEmail( - invitation.email, - invitation.token, - invitation.id, - 'another-password' - ) - ).resolves.toEqual({ error: 'not_pending' }) expect(membershipWrites).toBe(1) }) - it('rejects token-authenticated password setup after invitation acceptance', async () => { + it('rejects email-only password setup for a member invitation', async () => { const invitation = { id: '00000000-0000-4000-8000-000000000200', team_id: TEAM_A, @@ -808,22 +819,19 @@ describe('directory privacy and atomic authorization', () => { team_name: 'Team A', } mocks.query.mockImplementation(async (sql: string) => { - if (sql.includes('FROM invitations i') && sql.includes('WHERE i.token = $1')) { + if ( + sql.includes('FROM invitations i') && + (sql.includes('i.token = $1') || sql.includes('i.id::text = $1')) + ) { return { rows: [invitation], rowCount: 1 } } throw new Error(`unexpected query: ${sql.slice(0, 40)}`) }) await expect( - setInvitationPasswordForEmail( - invitation.email, - invitation.token, - invitation.id, - 'attacker-password' - ) - ).resolves.toEqual({ error: 'not_pending' }) + setInvitationPasswordForEmail(invitation.email, invitation.id, 'attacker-password') + ).resolves.toEqual({ error: 'not_found' }) const sql = mocks.query.mock.calls.map(call => String(call[0])).join('\n') - expect(sql).toContain('FOR UPDATE OF i') expect(sql).not.toContain('SET password_hash') expect(sql).not.toContain('external_user_session_security_epochs') }) @@ -976,9 +984,8 @@ describe('directory privacy and atomic authorization', () => { const currentHash = await bcrypt.hash('current-password', 4) mocks.query .mockResolvedValueOnce({ rows: [{ password_hash: currentHash }], rowCount: 1 }) - .mockResolvedValueOnce({ rows: [{ id: MANAGER }], rowCount: 1 }) - .mockResolvedValueOnce({ rows: [{ id: MANAGER }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [{ lifecycle_version: 2 }], rowCount: 1 }) + .mockResolvedValueOnce({ rows: [{ id: MANAGER, lifecycle_version: 2 }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 2 }) @@ -986,21 +993,20 @@ describe('directory privacy and atomic authorization', () => { updateUserPassword(MANAGER, 'manager@example.com', 'current-password', 'next-password') ).resolves.toEqual({ updated: true }) - expect(mocks.withTransaction).toHaveBeenCalledTimes(1) + expect(mocks.withTransaction).toHaveBeenCalledTimes(2) const passwordUpdate = String(mocks.query.mock.calls[1]?.[0]) + expect(passwordUpdate).toContain('SET password_hash = $3') + expect(passwordUpdate).toContain('lifecycle_version = lifecycle_version + 1') expect(passwordUpdate).toContain('AND password_hash = $4') - expect(String(mocks.query.mock.calls[2]?.[0])).toContain('FOR UPDATE') + expect(passwordUpdate).toContain('RETURNING lifecycle_version') + expect(String(mocks.query.mock.calls[2]?.[0])).toContain('FOR NO KEY UPDATE') expect(String(mocks.query.mock.calls[3]?.[0])).toContain( - 'SET lifecycle_version = lifecycle_version + 1' - ) - expect(mocks.query.mock.calls[3]?.[1]).toEqual([MANAGER]) - expect(String(mocks.query.mock.calls[4]?.[0])).toContain( 'INSERT INTO external_user_session_security_epochs' ) - expect(String(mocks.query.mock.calls[5]?.[0])).toContain('UPDATE external_user_sessions') + expect(String(mocks.query.mock.calls[4]?.[0])).toContain('UPDATE external_user_sessions') expect( mocks.query.mock.calls.filter(([sql]) => - String(sql).includes('SET lifecycle_version = lifecycle_version + 1') + String(sql).includes('lifecycle_version = lifecycle_version + 1') ) ).toHaveLength(1) }) From f1b8c5caae0c8931fd79d3a88393ae1f9d16dba4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:22:52 -0600 Subject: [PATCH 318/528] test: align composed authentication contracts with dev Class-check: compare recovery, invitation, and legacy error producer contracts across both services. Repro-test: composed service suites retain reauthentication and cookie clearing, and assert sanitized current errors. --- control-api/package-lock.json | 4 +-- control-api/package.json | 2 +- .../helpers/passwordRecoveryResponseProxy.ts | 13 +------- .../passwordRecovery.http.contract.test.ts | 29 +++-------------- external-rest-api/package-lock.json | 4 +-- external-rest-api/package.json | 2 +- external-rest-api/src/releaseManifest.ts | 2 +- .../test/invitationAdmission.public.test.ts | 4 ++- .../test/meUnavailable.contract.test.ts | 14 +++++--- .../test/services.invitationsService.test.ts | 32 +++++++++---------- .../services.legacyAuthorityErrors.test.ts | 4 ++- 11 files changed, 43 insertions(+), 67 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index a0b5749632..99e2da674f 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.795", + "version": "0.1.796", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.795", + "version": "0.1.796", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index e66834fe51..5be313c800 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.795", + "version": "0.1.796", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/helpers/passwordRecoveryResponseProxy.ts b/control-api/test/helpers/passwordRecoveryResponseProxy.ts index 8b1115370e..e571721c8f 100644 --- a/control-api/test/helpers/passwordRecoveryResponseProxy.ts +++ b/control-api/test/helpers/passwordRecoveryResponseProxy.ts @@ -7,13 +7,7 @@ export function responseDropProxyApp( onDroppedReset: () => void, options: { dropResetResponse?: boolean - resetFault?: - | 'html' - | 'missing_session' - | 'empty_session' - | 'invalid_payload' - | 'legacy_busy' - | 'legacy_unavailable' + resetFault?: 'html' | 'invalid_payload' | 'legacy_busy' | 'legacy_unavailable' } = {} ) { const producerBase = new URL(producerBaseUrl) @@ -91,11 +85,6 @@ export function responseDropProxyApp( }) } else if (options.resetFault === 'invalid_payload') { res.status(400).json({ error: 'invalid payload' }) - } else { - const payload = JSON.parse(body) - if (options.resetFault === 'missing_session') delete payload.token - else payload.token = '' - res.status(upstream.status).json(payload) } return } diff --git a/control-api/test/passwordRecovery.http.contract.test.ts b/control-api/test/passwordRecovery.http.contract.test.ts index c376a01193..5911b58f7b 100644 --- a/control-api/test/passwordRecovery.http.contract.test.ts +++ b/control-api/test/passwordRecovery.http.contract.test.ts @@ -6,10 +6,7 @@ import { requireInternalToken } from '../src/middleware/internalServiceAuth.js' import { createExternalInvitationsRouter } from '../src/routes/external/invitations.js' import { createExternalUsersRouter } from '../src/routes/external/users.js' import { PasswordAdmissionError } from '../src/services/auth/passwordCredentialVerification.js' -import { - signExternalSessionToken, - verifyExternalSessionToken, -} from '../src/utils/auth/externalSessionAuthToken.js' +import { signExternalSessionToken } from '../src/utils/auth/externalSessionAuthToken.js' import { type ExternalRestHttpProcess, boundedRequest as request, @@ -810,21 +807,12 @@ describe('invitation/password producer response mappings', () => { purpose: 'password_reset', status: 'accepted', passwordUpdated: true, + reauthenticationRequired: true, }) expect(response.body).not.toHaveProperty('token') - expect(String(response.headers['set-cookie'])).toContain('profile_session=') + expect(response.body).not.toHaveProperty('sessionContext') + expect(String(response.headers['set-cookie'])).toContain('profile_session=;') expect(String(response.headers['set-cookie'])).toContain('HttpOnly') - const cookie = String(response.headers['set-cookie']).match(/profile_session=([^;]+)/)?.[1] - expect(cookie).toBeTruthy() - const claims = verifyExternalSessionToken(decodeURIComponent(cookie!)) - expect(claims).toMatchObject({ - userId: preview.userId, - email: preview.email, - teamId: null, - role: 'member', - authGeneration: 7, - }) - expect(claims!.exp).toBeGreaterThan(Math.floor(Date.now() / 1000)) expect(directory.setInvitationPasswordForEmail).toHaveBeenCalledWith( preview.email, @@ -1045,14 +1033,7 @@ describe('invitation/password producer response mappings', () => { } ) - it.each([ - 'html', - 'missing_session', - 'empty_session', - 'invalid_payload', - 'legacy_busy', - 'legacy_unavailable', - ] as const)( + it.each(['html', 'invalid_payload', 'legacy_busy', 'legacy_unavailable'] as const)( 'alters only %s after real preview and serialized successful producer completion', async resetFault => { directory.setInvitationPasswordForEmail.mockResolvedValue({ diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 0c2e108af6..2157fa9752 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.138", + "version": "0.1.139", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.138", + "version": "0.1.139", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index b46e7544a5..d39470947b 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.138", + "version": "0.1.139", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index 17e30ea9e6..7a328a7b93 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.11.0', - externalRestApiVersion: '0.1.138', + externalRestApiVersion: '0.1.139', rpcProxyVersion: '0.1.127', desktopVersion: '0.11.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/test/invitationAdmission.public.test.ts b/external-rest-api/test/invitationAdmission.public.test.ts index bac6eef4fa..a2531f03bc 100644 --- a/external-rest-api/test/invitationAdmission.public.test.ts +++ b/external-rest-api/test/invitationAdmission.public.test.ts @@ -92,8 +92,10 @@ describe('accepted invitation password authority outcomes', () => { id: 'synthetic-invitation-row', passwordUpdated: true, status: 'accepted', + reauthenticationRequired: true, }) - expect(String(retried.headers['set-cookie'])).toContain('profile_session=test-session') + expect(String(retried.headers['set-cookie'])).toContain('profile_session=;') + expect(String(retried.headers['set-cookie'])).not.toContain('test-session') expect(fetchMock).toHaveBeenCalledTimes(2) }) diff --git a/external-rest-api/test/meUnavailable.contract.test.ts b/external-rest-api/test/meUnavailable.contract.test.ts index 68469c86de..8c40046d97 100644 --- a/external-rest-api/test/meUnavailable.contract.test.ts +++ b/external-rest-api/test/meUnavailable.contract.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import express from 'express' import request from 'supertest' +import { externalRestPublicErrorHandler } from '../src/app.js' import { ControlApiError } from '../src/controlApiClient.js' import { createMeRouter } from '../src/routes/me.js' @@ -29,7 +30,7 @@ describe('External REST /me unavailable producer contract', () => { afterEach(() => vi.restoreAllMocks()) - it('maps a Control API outage to the route 404 consumed by the recovery UI', async () => { + it('preserves a Control API outage through the sanitized public error handler', async () => { mocks.controlApiRequest.mockRejectedValue( new ControlApiError( 'private Control API failure', @@ -42,15 +43,18 @@ describe('External REST /me unavailable producer contract', () => { ) const app = express() app.use(createMeRouter()) + app.use(externalRestPublicErrorHandler) const response = await request(app) .get('/me') .set('authorization', 'Bearer synthetic-session-token') - expect(response.status).toBe(404) - expect(response.body).toEqual({ error: 'User not found' }) - expect(response.headers['retry-after']).toBeUndefined() - expect(JSON.stringify(response.body)).not.toMatch(/private|authority|synthetic/) + expect(response.status).toBe(503) + expect(response.body.error).toMatchObject({ code: 'authority_unavailable', retryable: true }) + expect(response.headers['retry-after']).toBe('60') + expect(JSON.stringify(response.body)).not.toMatch( + /private Control API failure|authority_failure|synthetic-member-id|synthetic-session-token/ + ) expect(mocks.controlApiRequest).toHaveBeenCalledWith( 'GET', '/external/users/synthetic-member-id/me', diff --git a/external-rest-api/test/services.invitationsService.test.ts b/external-rest-api/test/services.invitationsService.test.ts index 722137c47a..a6e4382002 100644 --- a/external-rest-api/test/services.invitationsService.test.ts +++ b/external-rest-api/test/services.invitationsService.test.ts @@ -1,10 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { ControlApiError } from '../src/controlApiClient.js' import { - acceptInvitation, setupInvitationPassword, setupInvitationPasswordWithToken, -} from '../src/services/invitationsService.js' +} from '../src/services/invitationPasswordService.js' +import { acceptInvitation } from '../src/services/invitationsService.js' const client = vi.hoisted(() => ({ request: vi.fn() })) vi.mock('../src/controlApiClient.js', async importOriginal => ({ @@ -17,7 +17,7 @@ describe('invitation service error and token boundaries', () => { it('never converts Control API unavailability into not-pending or invalid-password', async () => { const unavailable = new ControlApiError('unavailable', 503, { - error: { code: 'authority_unavailable' }, + error: 'authority_unavailable', }) for (const operation of [ @@ -29,13 +29,12 @@ describe('invitation service error and token boundaries', () => { 'valid-password' ), () => - setupInvitationPasswordWithToken({ - token: 'flow-token', - email: 'user@example.com', - invitationId: 'inv-1', - password: 'valid-password', - sessionContract: 'v2', - }), + setupInvitationPasswordWithToken( + 'flow-token', + 'user@example.com', + 'inv-1', + 'valid-password' + ), ]) { client.request.mockRejectedValueOnce(unavailable) await expect(operation()).rejects.toBe(unavailable) @@ -50,13 +49,12 @@ describe('invitation service error and token boundaries', () => { token: 'must-never-reach-browser-route', }) - const result = await setupInvitationPasswordWithToken({ - token: 'flow-token', - email: 'user@example.com', - invitationId: 'inv-1', - password: 'valid-password', - sessionContract: 'v2', - }) + const result = await setupInvitationPasswordWithToken( + 'flow-token', + 'user@example.com', + 'inv-1', + 'valid-password' + ) expect(result.data).toMatchObject({ id: 'inv-1', passwordUpdated: true }) expect(result.data).not.toHaveProperty('token') diff --git a/external-rest-api/test/services.legacyAuthorityErrors.test.ts b/external-rest-api/test/services.legacyAuthorityErrors.test.ts index 0653375815..8445706692 100644 --- a/external-rest-api/test/services.legacyAuthorityErrors.test.ts +++ b/external-rest-api/test/services.legacyAuthorityErrors.test.ts @@ -102,7 +102,9 @@ describe('legacy authority adapters', () => { }) it('does not convert desktop-authorization authority outages into bad passwords', async () => { - clientMock.controlApiRequest.mockRejectedValueOnce(upstream(403)) + clientMock.controlApiRequest.mockRejectedValueOnce( + new ControlApiError('Control API rejected the password', 401, { error: 'invalid_password' }) + ) await expect(createDesktopAuthorization(auth, 'wrong-password')).resolves.toEqual({ error: 'invalid_password', }) From 64ffa820170e33bcf9a22287e75b5dd80c189db1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:22:59 -0600 Subject: [PATCH 319/528] fix: advance password auth generation once Class-check: trace password producers and revocation consumers; separate admin-control writer is excluded. Repro-test: previous RealPG candidate failed generation and FK key-share assertions; corrected focus passes 31 tests. --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../src/services/auth/userSessionService.ts | 56 +++++++--- .../services/directory/adminProvisioning.ts | 49 ++++++--- .../src/services/directory/memberPassword.ts | 39 +++++-- .../test/legacyV1SessionGeneration.test.ts | 7 +- .../test/membershipPasswordSecurity.test.ts | 30 +++++- ...rsistence.realPostgres.integration.test.ts | 101 ++++++++++++++++++ ...rvices.directory.adminProvisioning.test.ts | 23 ++-- 9 files changed, 255 insertions(+), 56 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 99e2da674f..67f816c02a 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.796", + "version": "0.1.797", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.796", + "version": "0.1.797", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 5be313c800..afe0530d11 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.796", + "version": "0.1.797", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/auth/userSessionService.ts b/control-api/src/services/auth/userSessionService.ts index cb6fa743ee..459dde7d16 100644 --- a/control-api/src/services/auth/userSessionService.ts +++ b/control-api/src/services/auth/userSessionService.ts @@ -454,30 +454,54 @@ export async function revokeAllUserSessions( userId: string, reason: string, db?: SessionDatabase, - actorContext?: ExternalSessionAuthorityContext + actorContext?: ExternalSessionAuthorityContext, + expectedLifecycleVersion?: string | number ): Promise { const work = async (transaction: SessionDatabase) => { - const user = await transaction.query(`SELECT id FROM users WHERE id = $1 FOR UPDATE`, [userId]) - if ((user.rowCount ?? 0) === 0) return 0 + const userLock = expectedLifecycleVersion === undefined ? 'FOR UPDATE' : 'FOR NO KEY UPDATE' + const user = await transaction.query( + `SELECT id, lifecycle_version + FROM users + WHERE id = $1 + ${userLock}`, + [userId] + ) + if ((user.rowCount ?? 0) === 0) { + if (expectedLifecycleVersion !== undefined) { + throw new Error('password transition user disappeared before session revocation') + } + return 0 + } if (actorContext && !(await isCurrentRevocationActor(transaction, userId, actorContext))) { return 0 } const revokedAt = await loadSessionDatabaseNow(transaction) const compatibilityValidAfter = new Date(revokedAt.getTime() - 1000) - const advanced = await transaction.query( - `UPDATE users - SET lifecycle_version = lifecycle_version + 1, - updated_at = NOW() - WHERE id = $1 - RETURNING lifecycle_version`, - [userId] - ) - const lifecycleVersion = Number( - (advanced.rows[0] as { lifecycle_version?: unknown } | undefined)?.lifecycle_version - ) - if (!Number.isSafeInteger(lifecycleVersion) || lifecycleVersion < 1) { - throw new Error('security event did not advance lifecycle generation') + if (expectedLifecycleVersion !== undefined) { + const lockedVersion = String( + (user.rows[0] as { lifecycle_version?: unknown } | undefined)?.lifecycle_version ?? '' + ) + if (!/^[1-9]\d*$/.test(lockedVersion) || lockedVersion !== String(expectedLifecycleVersion)) { + throw new Error( + 'password transition lifecycle generation changed before session revocation' + ) + } + } else { + const advanced = await transaction.query( + `UPDATE users + SET lifecycle_version = lifecycle_version + 1, + updated_at = NOW() + WHERE id = $1 + RETURNING lifecycle_version`, + [userId] + ) + const lifecycleVersion = Number( + (advanced.rows[0] as { lifecycle_version?: unknown } | undefined)?.lifecycle_version + ) + if (!Number.isSafeInteger(lifecycleVersion) || lifecycleVersion < 1) { + throw new Error('security event did not advance lifecycle generation') + } } await transaction.query( diff --git a/control-api/src/services/directory/adminProvisioning.ts b/control-api/src/services/directory/adminProvisioning.ts index ced7d6df2f..9d509928f8 100644 --- a/control-api/src/services/directory/adminProvisioning.ts +++ b/control-api/src/services/directory/adminProvisioning.ts @@ -11,6 +11,30 @@ import { type TeamRole, normalizeTeamRoleInput } from './types.js' const logger = rootLogger.child({ module: 'admin-provisioning' }) +async function setProvisionedUserPassword( + db: Pick, + userId: string, + passwordHash: string, + advanceLifecycle: boolean +): Promise { + const update = await db.query( + `UPDATE users + SET password_hash = $2, + lifecycle_version = lifecycle_version + CASE WHEN $3::boolean THEN 1 ELSE 0 END, + password_set_at = NOW(), + updated_at = NOW() + WHERE id = $1 + RETURNING lifecycle_version`, + [userId, passwordHash, advanceLifecycle] + ) + const lifecycleVersion = (update.rows[0] as { lifecycle_version?: string | number } | undefined) + ?.lifecycle_version + if (lifecycleVersion === undefined) { + throw new Error('provisioned password update did not return its lifecycle generation') + } + return lifecycleVersion +} + /** * Ensure the user administers a team and that the default agent/context grants exist at * both team and user level. Idempotent: reuses an existing owned team and uses @@ -132,13 +156,14 @@ export async function provisionAdminDesktopWorkspace( ) if (input.seedPassword !== false) { - await db.query( - `UPDATE users SET password_hash = $2, password_set_at = NOW(), updated_at = NOW() - WHERE id = $1`, - [userId, input.passwordHash] + const lifecycleVersion = await setProvisionedUserPassword( + db, + userId, + input.passwordHash, + existingUser ) if (existingUser) { - await revokeAllUserSessions(userId, 'password_changed', db) + await revokeAllUserSessions(userId, 'password_changed', db, undefined, lifecycleVersion) } } @@ -249,16 +274,14 @@ export async function provisionMemberFromAdmin(input: { ) if (input.seedPassword) { - await db.query( - `UPDATE users - SET password_hash = $2, - password_set_at = NOW(), - updated_at = NOW() - WHERE id = $1`, - [user.id, admin.password_hash] + const lifecycleVersion = await setProvisionedUserPassword( + db, + user.id, + admin.password_hash, + !created ) if (!created) { - await revokeAllUserSessions(user.id, 'password_changed', db) + await revokeAllUserSessions(user.id, 'password_changed', db, undefined, lifecycleVersion) } } diff --git a/control-api/src/services/directory/memberPassword.ts b/control-api/src/services/directory/memberPassword.ts index 5f92c0230c..1b6287ed19 100644 --- a/control-api/src/services/directory/memberPassword.ts +++ b/control-api/src/services/directory/memberPassword.ts @@ -17,18 +17,25 @@ async function updateActivePasswordHash( db: Pick, userId: string, passwordHash: string -): Promise { +): Promise { const update = await db.query( `UPDATE users SET password_hash = $2, + lifecycle_version = lifecycle_version + 1, password_set_at = NOW(), updated_at = NOW() WHERE id = $1 AND lifecycle_state = 'active' - RETURNING id`, + RETURNING lifecycle_version`, [userId, passwordHash] ) - return update.rows.length === 1 + if (update.rows.length !== 1) return null + const lifecycleVersion = (update.rows[0] as { lifecycle_version?: string | number }) + .lifecycle_version + if (lifecycleVersion === undefined) { + throw new Error('password setup did not return its lifecycle generation') + } + return lifecycleVersion } async function loadInvitationPasswordResponse( @@ -119,10 +126,11 @@ export async function setInvitationPasswordForUser( if (invitationUserIsRetired(user)) return { error: 'user_retired' as const } if (user.id !== trimmedUserId) return { error: 'forbidden' as const } const passwordHash = await bcrypt.hash(trimmedPassword, 12) - if (!(await updateActivePasswordHash(db, user.id, passwordHash))) { + const lifecycleVersion = await updateActivePasswordHash(db, user.id, passwordHash) + if (lifecycleVersion === null) { return { error: 'user_retired' as const } } - await revokeAllUserSessions(user.id, 'password_changed', db) + await revokeAllUserSessions(user.id, 'password_changed', db, undefined, lifecycleVersion) if (!invitation.accepted_user_id) { await db.query(`UPDATE invitations SET accepted_user_id = $2 WHERE id = $1`, [ @@ -191,10 +199,11 @@ export async function setInvitationPasswordForEmail( } const passwordHash = await bcrypt.hash(trimmedPassword, 12) - if (!(await updateActivePasswordHash(db, user.id, passwordHash))) { + const lifecycleVersion = await updateActivePasswordHash(db, user.id, passwordHash) + if (lifecycleVersion === null) { return { error: 'user_retired' as const } } - await revokeAllUserSessions(user.id, 'password_changed', db) + await revokeAllUserSessions(user.id, 'password_changed', db, undefined, lifecycleVersion) const redeemed = await db.query( `UPDATE invitations @@ -267,12 +276,13 @@ export async function updateUserPassword( const update = await db.query( `UPDATE users SET password_hash = $3, + lifecycle_version = lifecycle_version + 1, password_set_at = NOW(), updated_at = NOW() WHERE id = $1 AND password_auth_generation = $2 AND password_hash = $4 - RETURNING id`, + RETURNING lifecycle_version`, [ normalizedUserId, user.password_auth_generation, @@ -281,7 +291,18 @@ export async function updateUserPassword( ] ) if (update.rows.length !== 1) return { error: 'credential_changed' as const } - await revokeAllUserSessions(normalizedUserId, 'password_changed', db) + const lifecycleVersion = (update.rows[0] as { lifecycle_version?: string | number }) + .lifecycle_version + if (lifecycleVersion === undefined) { + throw new Error('password update did not return its lifecycle generation') + } + await revokeAllUserSessions( + normalizedUserId, + 'password_changed', + db, + undefined, + lifecycleVersion + ) return { updated: true as const } }, { mode: 'standard' } diff --git a/control-api/test/legacyV1SessionGeneration.test.ts b/control-api/test/legacyV1SessionGeneration.test.ts index 652e200392..a9c23bc604 100644 --- a/control-api/test/legacyV1SessionGeneration.test.ts +++ b/control-api/test/legacyV1SessionGeneration.test.ts @@ -54,8 +54,11 @@ function legacyAuthorityDatabase() { state.revokedFingerprints.add(values[0] as string) return { rows: [{ token_hash: values[0] }], rowCount: 1 } } - if (sql.includes('SELECT id FROM users WHERE id = $1 FOR UPDATE')) { - return { rows: [{ id: state.userId }], rowCount: 1 } + if (sql.includes('FROM users') && sql.includes('FOR UPDATE')) { + return { + rows: [{ id: state.userId, lifecycle_version: state.lifecycleVersion }], + rowCount: 1, + } } if (sql.includes('SELECT u.id, u.lifecycle_state, u.lifecycle_version')) { return { diff --git a/control-api/test/membershipPasswordSecurity.test.ts b/control-api/test/membershipPasswordSecurity.test.ts index b2c39e563a..ca24aa1c23 100644 --- a/control-api/test/membershipPasswordSecurity.test.ts +++ b/control-api/test/membershipPasswordSecurity.test.ts @@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({ compare: vi.fn(), hash: vi.fn(), revokeAll: vi.fn(), + runPasswordMutation: vi.fn(), })) vi.mock('../src/db.js', () => ({ @@ -19,6 +20,9 @@ vi.mock('bcryptjs', () => ({ vi.mock('../src/services/auth/userSessionService.js', () => ({ revokeAllUserSessions: mocks.revokeAll, })) +vi.mock('../src/services/auth/passwordWorkOwnership.js', () => ({ + runPasswordMutation: mocks.runPasswordMutation, +})) describe('member password security events', () => { beforeEach(() => { @@ -27,10 +31,19 @@ describe('member password security events', () => { mocks.compare.mockResolvedValue(true) mocks.hash.mockResolvedValue('next-hash') mocks.revokeAll.mockResolvedValue(2) + mocks.runPasswordMutation.mockImplementation( + async (work: (db: { query: typeof mocks.transactionQuery }) => Promise) => + work({ query: mocks.transactionQuery }) + ) }) it('conditionally changes the credential and revokes sessions in the same transaction', async () => { - mocks.transactionQuery.mockResolvedValueOnce({ rows: [{ id: 'user-1' }], rowCount: 1 }) + mocks.transactionQuery + .mockResolvedValueOnce({ + rows: [{ password_hash: 'old-hash', password_auth_generation: '4' }], + rowCount: 1, + }) + .mockResolvedValueOnce({ rows: [{ lifecycle_version: '7' }], rowCount: 1 }) const { updateUserPassword } = await import('../src/services/directory/membership.js') await expect( @@ -39,22 +52,29 @@ describe('member password security events', () => { expect(mocks.transactionQuery).toHaveBeenCalledWith( expect.stringContaining('AND password_hash = $4'), - ['user-1', 'user@example.com', 'next-hash', 'old-hash'] + ['user-1', '4', 'next-hash', 'old-hash'] ) expect(mocks.revokeAll).toHaveBeenCalledWith( 'user-1', 'password_changed', - expect.objectContaining({ query: mocks.transactionQuery }) + expect.objectContaining({ query: mocks.transactionQuery }), + undefined, + '7' ) }) it('does not revoke when a concurrent credential change wins first', async () => { - mocks.transactionQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }) + mocks.transactionQuery + .mockResolvedValueOnce({ + rows: [{ password_hash: 'old-hash', password_auth_generation: '4' }], + rowCount: 1, + }) + .mockResolvedValueOnce({ rows: [], rowCount: 0 }) const { updateUserPassword } = await import('../src/services/directory/membership.js') await expect( updateUserPassword('user-1', 'user@example.com', 'old-password', 'next-password') - ).resolves.toEqual({ error: 'invalid_current_password' }) + ).resolves.toEqual({ error: 'credential_changed' }) expect(mocks.revokeAll).not.toHaveBeenCalled() }) }) diff --git a/control-api/test/passwordPersistence.realPostgres.integration.test.ts b/control-api/test/passwordPersistence.realPostgres.integration.test.ts index 1f47f921ce..03b7417e49 100644 --- a/control-api/test/passwordPersistence.realPostgres.integration.test.ts +++ b/control-api/test/passwordPersistence.realPostgres.integration.test.ts @@ -239,9 +239,16 @@ realPg('Spec 043 persistence, migration, atomicity and fencing design acceptance it('real password-change producer fences stale failure and success results', async () => { const failure = await capturePasswordEvaluation(email, false), success = await capturePasswordEvaluation(email, false) + const before = ( + await holder.pool.query('SELECT password_auth_generation FROM users WHERE id = $1', [userId]) + ).rows[0].password_auth_generation expect( await updateUserPassword(userId, email, password, 'Synthetic-BUG192-new-password') ).toEqual({ updated: true }) + const after = ( + await holder.pool.query('SELECT password_auth_generation FROM users WHERE id = $1', [userId]) + ).rows[0].password_auth_generation + expect(Number(after)).toBe(Number(before) + 1) expect(await completePasswordEvaluation(failure, false)).toBe(false) expect(await completePasswordEvaluation(success, true)).toBe(false) expect((await row()).failures).toEqual([]) @@ -310,12 +317,19 @@ realPg('Spec 043 persistence, migration, atomicity and fencing design acceptance [email] ) ).rows[0] + const before = ( + await holder.pool.query('SELECT password_auth_generation FROM users WHERE id = $1', [userId]) + ).rows[0].password_auth_generation const result = await setInvitationPasswordForEmail( email, invitation.id, 'Synthetic-BUG192-reset-password' ) expect(result).not.toHaveProperty('error') + const after = ( + await holder.pool.query('SELECT password_auth_generation FROM users WHERE id = $1', [userId]) + ).rows[0].password_auth_generation + expect(Number(after)).toBe(Number(before) + 1) expect(await completePasswordEvaluation(stale, false)).toBe(false) expect(await row()).toMatchObject({ failures: [], locked_until_ms: '0' }) const credential = ( @@ -363,6 +377,86 @@ realPg('Spec 043 persistence, migration, atomicity and fencing design acceptance await endPoolAndWaitForClients(singleConnectionPool) } }) + it('advances password generation once for both existing-user admin seed producers', async () => { + const { provisionAdminDesktopWorkspace, provisionMemberFromAdmin } = + await import('../src/services/directory/adminProvisioning.js') + const firstSeedHash = await bcrypt.hash('Synthetic-BUG192-admin-seed-one', 12) + const secondSeedHash = await bcrypt.hash('Synthetic-BUG192-admin-seed-two', 12) + const before = ( + await holder.pool.query( + 'SELECT password_auth_generation, lifecycle_version FROM users WHERE id = $1', + [userId] + ) + ).rows[0] + const adminId = ( + await holder.pool.query( + `INSERT INTO control_admin_users(username, email, password_hash) + VALUES ($1, $2, $3) + RETURNING id`, + [`seed-admin-${randomBytes(4).toString('hex')}`, email, secondSeedHash] + ) + ).rows[0].id as string + + await provisionAdminDesktopWorkspace({ + controlAdminId: adminId, + email, + displayName: 'Synthetic member', + passwordHash: firstSeedHash, + agentNames: [], + contextIds: [], + linkDesktopOperator: false, + }) + const afterWorkspaceSeed = ( + await holder.pool.query( + 'SELECT password_auth_generation, lifecycle_version FROM users WHERE id = $1', + [userId] + ) + ).rows[0] + expect(Number(afterWorkspaceSeed.password_auth_generation)).toBe( + Number(before.password_auth_generation) + 1 + ) + expect(Number(afterWorkspaceSeed.lifecycle_version)).toBe(Number(before.lifecycle_version) + 1) + + await provisionMemberFromAdmin({ + adminId, + operatorSub: adminId, + teamAssignments: [], + seedPassword: true, + }) + const afterMemberSeed = ( + await holder.pool.query( + 'SELECT password_auth_generation, lifecycle_version FROM users WHERE id = $1', + [userId] + ) + ).rows[0] + expect(Number(afterMemberSeed.password_auth_generation)).toBe( + Number(afterWorkspaceSeed.password_auth_generation) + 1 + ) + expect(Number(afterMemberSeed.lifecycle_version)).toBe( + Number(afterWorkspaceSeed.lifecycle_version) + 1 + ) + }) + it('fails closed when session revocation sees a different password lifecycle generation', async () => { + const { revokeAllUserSessions } = await import('../src/services/auth/userSessionService.js') + const before = ( + await holder.pool.query('SELECT lifecycle_version FROM users WHERE id = $1', [userId]) + ).rows[0].lifecycle_version + const staleVersion = (BigInt(String(before)) - 1n).toString() + + await expect( + revokeAllUserSessions(userId, 'password_changed', undefined, undefined, staleVersion) + ).rejects.toThrow('password transition lifecycle generation changed before session revocation') + + const after = ( + await holder.pool.query('SELECT lifecycle_version FROM users WHERE id = $1', [userId]) + ).rows[0].lifecycle_version + const epochs = await holder.pool.query( + 'SELECT count(*)::int AS count FROM external_user_session_security_epochs WHERE user_id = $1', + [userId] + ) + expect(String(after)).toBe(String(before)) + expect(epochs.rows[0].count).toBe(0) + }) it('does not hash accepted invitation passwords outside the durable owner', async () => { const invitation = await createSilentInvitationForTeams({ inviteeName: 'Synthetic member', @@ -406,6 +500,9 @@ realPg('Spec 043 persistence, migration, atomicity and fencing design acceptance }) const accepted = await acceptInvitationForEmail(email, invitation.token, invitation.id) expect(accepted).toMatchObject({ data: { accepted: true, userId } }) + const before = ( + await holder.pool.query('SELECT password_auth_generation FROM users WHERE id = $1', [userId]) + ).rows[0].password_auth_generation const result = await withMemberPasswordWorkLease(lease => setInvitationPasswordForUser( @@ -418,6 +515,10 @@ realPg('Spec 043 persistence, migration, atomicity and fencing design acceptance ) expect(result).not.toHaveProperty('error') + const after = ( + await holder.pool.query('SELECT password_auth_generation FROM users WHERE id = $1', [userId]) + ).rows[0].password_auth_generation + expect(Number(after)).toBe(Number(before) + 1) expect( (await holder.pool.query('SELECT password_hash FROM users WHERE id = $1', [userId])).rows[0] .password_hash diff --git a/control-api/test/services.directory.adminProvisioning.test.ts b/control-api/test/services.directory.adminProvisioning.test.ts index a8155603bb..d023b608f8 100644 --- a/control-api/test/services.directory.adminProvisioning.test.ts +++ b/control-api/test/services.directory.adminProvisioning.test.ts @@ -99,7 +99,7 @@ describe('provisionAdminDesktopWorkspace', () => { .mockResolvedValueOnce({ rows: [], rowCount: 0 }) // SELECT users by email → none .mockResolvedValueOnce({ rows: [{ id: 'user-1' }], rowCount: 1 }) // INSERT users .mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT profiles - .mockResolvedValueOnce({ rows: [], rowCount: 1 }) // UPDATE users password + .mockResolvedValueOnce({ rows: [{ lifecycle_version: '1' }], rowCount: 1 }) // UPDATE users password .mockResolvedValueOnce({ rows: [], rowCount: 0 }) // SELECT admin team → none .mockResolvedValueOnce({ rows: [{ id: 'team-1' }], rowCount: 1 }) // INSERT teams .mockResolvedValue({ rows: [], rowCount: 1 }) // membership + grants @@ -122,6 +122,7 @@ describe('provisionAdminDesktopWorkspace', () => { expect(dbMocks.txQuery).toHaveBeenCalledWith(expect.stringContaining('UPDATE users'), [ 'user-1', 'HASH', + false, ]) expect(dbMocks.txQuery).toHaveBeenCalledWith( expect.stringContaining('INSERT INTO team_agents'), @@ -134,7 +135,7 @@ describe('provisionAdminDesktopWorkspace', () => { dbMocks.txQuery .mockResolvedValueOnce({ rows: [{ id: 'user-9' }], rowCount: 1 }) // SELECT users by email → found .mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT profiles - .mockResolvedValueOnce({ rows: [], rowCount: 1 }) // UPDATE password + .mockResolvedValueOnce({ rows: [{ lifecycle_version: '8' }], rowCount: 1 }) // UPDATE password .mockResolvedValueOnce({ rows: [{ id: 'team-x' }], rowCount: 1 }) // SELECT admin team → found .mockResolvedValue({ rows: [], rowCount: 1 }) @@ -156,11 +157,14 @@ describe('provisionAdminDesktopWorkspace', () => { expect(dbMocks.txQuery).toHaveBeenCalledWith(expect.stringContaining('UPDATE users'), [ 'user-9', 'H', + true, ]) expect(userSessionMocks.revokeAll).toHaveBeenCalledWith( 'user-9', 'password_changed', - expect.objectContaining({ query: dbMocks.txQuery }) + expect.objectContaining({ query: dbMocks.txQuery }), + undefined, + '8' ) }) @@ -224,7 +228,7 @@ describe('provisionAdminDesktopWorkspace', () => { dbMocks.txQuery .mockResolvedValueOnce({ rows: [{ id: 'user-1' }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }) - .mockResolvedValueOnce({ rows: [], rowCount: 1 }) + .mockResolvedValueOnce({ rows: [{ lifecycle_version: '8' }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [{ id: 'team-1' }], rowCount: 1 }) const mod = await import('../src/services/directory/adminProvisioning.js') @@ -254,7 +258,7 @@ describe('provisionAdminDesktopWorkspace', () => { dbMocks.txQuery .mockResolvedValueOnce({ rows: [{ id: 'user-1' }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }) - .mockResolvedValueOnce({ rows: [], rowCount: 1 }) + .mockResolvedValueOnce({ rows: [{ lifecycle_version: '8' }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [{ id: 'team-1' }], rowCount: 1 }) operatorLinkMocks.linkInTransaction.mockRejectedValueOnce(new Error('link conflict')) @@ -301,7 +305,7 @@ describe('provisionMemberFromAdmin', () => { rowCount: 1, }) // INSERT users .mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT profiles - .mockResolvedValueOnce({ rows: [], rowCount: 1 }) // UPDATE users password + .mockResolvedValueOnce({ rows: [{ lifecycle_version: '1' }], rowCount: 1 }) // UPDATE users password const mod = await import('../src/services/directory/adminProvisioning.js') const result = await mod.provisionMemberFromAdmin({ @@ -357,7 +361,8 @@ describe('provisionMemberFromAdmin', () => { rows: [{ id: 'user-1', email: 'ada@example.com', name: 'ada' }], rowCount: 1, }) - .mockResolvedValueOnce({ rows: [], rowCount: 1 }) + .mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT profiles + .mockResolvedValueOnce({ rows: [{ lifecycle_version: '8' }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }) const mod = await import('../src/services/directory/adminProvisioning.js') @@ -371,7 +376,9 @@ describe('provisionMemberFromAdmin', () => { expect(userSessionMocks.revokeAll).toHaveBeenCalledWith( 'user-1', 'password_changed', - expect.objectContaining({ query: dbMocks.txQuery }) + expect.objectContaining({ query: dbMocks.txQuery }), + undefined, + '8' ) }) From a07e95c6d143bf8c0a0413ac6843e76268b959d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:31:16 -0600 Subject: [PATCH 320/528] test: provide toast context in auth recovery fixtures Class-check: current-dev AuthProvider requires ToastProvider; no authentication behavior changes. Repro-test: old fixtures failed outside ToastProvider; Profile UI passed 84 Node and 71 Vitest tests. --- .../__tests__/AuthContext.recovery.test.tsx | 17 +++++++++++------ profile-ui/package-lock.json | 4 ++-- profile-ui/package.json | 2 +- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/profile-ui/app/components/__tests__/AuthContext.recovery.test.tsx b/profile-ui/app/components/__tests__/AuthContext.recovery.test.tsx index 30d59d9d87..55467692b2 100644 --- a/profile-ui/app/components/__tests__/AuthContext.recovery.test.tsx +++ b/profile-ui/app/components/__tests__/AuthContext.recovery.test.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { AuthProvider, useAuth } from '../AuthContext' +import { ToastProvider } from '../Toast' const apiMocks = vi.hoisted(() => ({ clearToken: vi.fn(), @@ -70,9 +71,11 @@ describe('AuthProvider verified recovery session', () => { .mockResolvedValueOnce(member) render( - - - + + + + + ) await waitFor(() => expect(screen.getByTestId('auth-state')).toHaveTextContent('signed-in:')) @@ -93,9 +96,11 @@ describe('AuthProvider verified recovery session', () => { apiMocks.getMe.mockReturnValueOnce(initialAnonymousCheck.promise).mockResolvedValueOnce(member) render( - - - + + + + + ) fireEvent.click(screen.getByRole('button', { name: 'Check recovered session' })) diff --git a/profile-ui/package-lock.json b/profile-ui/package-lock.json index b78143e7d3..d57b859d34 100644 --- a/profile-ui/package-lock.json +++ b/profile-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-profile-ui", - "version": "0.1.181", + "version": "0.1.182", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-profile-ui", - "version": "0.1.181", + "version": "0.1.182", "dependencies": { "@clerum/desktop-app-links": "file:../packages/desktop-app-links", "@clerum/frontend-components": "file:../packages/frontend-components", diff --git a/profile-ui/package.json b/profile-ui/package.json index 6ae0a2d4d7..cd2c535b53 100644 --- a/profile-ui/package.json +++ b/profile-ui/package.json @@ -1,6 +1,6 @@ { "name": "clerum-profile-ui", - "version": "0.1.181", + "version": "0.1.182", "private": true, "scripts": { "dev": "next dev", From 068a2bcfec48d725f80b58c5f087cbe43c2da01f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9?= <124098826+josue-tech-94@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:46:04 -0600 Subject: [PATCH 321/528] docs: require explicit context for R70-H1 setup Class-check: Makefile and full-setup callers pass CONTEXT; production documentation was the only direct operator call without it. Repro-test: scripts/tests/test-r70-h1-credential-preflight.sh verifies the helper with CONTEXT=fixture-context; the documented command now passes KCTX explicitly. --- docs/deploy/production.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/deploy/production.md b/docs/deploy/production.md index 3d14eed131..14096790f2 100644 --- a/docs/deploy/production.md +++ b/docs/deploy/production.md @@ -254,7 +254,11 @@ kubectl --context "$KCTX" cluster-info # confirm this is the cluster you mean Control UI CSRF secret, the prompt-history encryption key, and the Secrets and public-key ConfigMaps that carry them. Then run [`deploy/scripts/apply-inter-service-tokens.sh`](../../deploy/scripts/apply-inter-service-tokens.sh) - for the service-to-service tokens. Know these before you run them: + for the service-to-service tokens with the same explicit context: + ```bash + CONTEXT="$KCTX" bash deploy/scripts/apply-inter-service-tokens.sh + ``` + Know these before you run them: - `gen-jwt-keys.sh` generates **new** keys on every run. Running it again rotates every key, including the OAuth encryption key. Run it once and keep the output. From 8469b0352f78a1c52e7a5f51aec3d8cb7b1ca73c Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 21 Oct 2026 17:06:38 +0200 Subject: [PATCH 322/528] fix(external-rest-api): fail closed on an invalid invitation session A session credential presented to POST /invitations/password (bearer header or profile_session cookie) is now the only proof considered. When verifyToken rejects it the route answers 401 instead of falling through to the invitation-link proof (CodeQL alert #462). The invitation fixture session token now uses a synthetic marker that the public-boundary scanner exempts by shape, so the Shell gate no longer rejects the test diff. Co-Authored-By: Claude Opus 5.5 --- external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- external-rest-api/src/releaseManifest.ts | 2 +- external-rest-api/src/routes/invitations.ts | 7 +- .../test/routes.invitations.test.ts | 99 ++++++++++--------- 5 files changed, 62 insertions(+), 52 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 2157fa9752..995c564245 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.139", + "version": "0.1.140", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.139", + "version": "0.1.140", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index d39470947b..2d7e77e50b 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.139", + "version": "0.1.140", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index 7a328a7b93..cb9b383cb5 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.11.0', - externalRestApiVersion: '0.1.139', + externalRestApiVersion: '0.1.140', rpcProxyVersion: '0.1.127', desktopVersion: '0.11.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/src/routes/invitations.ts b/external-rest-api/src/routes/invitations.ts index 2bb071d9db..f8ab70d52c 100644 --- a/external-rest-api/src/routes/invitations.ts +++ b/external-rest-api/src/routes/invitations.ts @@ -142,10 +142,13 @@ async function invitationPasswordAuthFromRequest(req: Request): Promise< > { const sessionToken = extractAuthToken(req) if (sessionToken) { + // A presented session credential is the only proof considered: an invalid + // one fails closed instead of falling through to the invitation link. const claims = verifyToken(sessionToken) - if (claims) { - return { auth: { userId: claims.userId, email: claims.email, sessionToken } } + if (!claims) { + return { status: 401, body: { error: 'Unauthorized' } } } + return { auth: { userId: claims.userId, email: claims.email, sessionToken } } } const token = String(req.body?.token || '').trim() diff --git a/external-rest-api/test/routes.invitations.test.ts b/external-rest-api/test/routes.invitations.test.ts index 810ad3b482..daa1002de9 100644 --- a/external-rest-api/test/routes.invitations.test.ts +++ b/external-rest-api/test/routes.invitations.test.ts @@ -49,7 +49,7 @@ describe('routes/invitations', () => { data: { userId: 'user-1', email: 'invitee@example.com', - token: 'invited-session-token', + token: 'synthetic-invited-session-token', }, }) invitationsServiceMock.setupInvitationPassword.mockResolvedValue({ @@ -73,7 +73,7 @@ describe('routes/invitations', () => { reauthenticationRequired: true, }) expect(String(res.headers['set-cookie'])).toContain('profile_session=;') - expect(String(res.headers['set-cookie'])).not.toContain('invited-session-token') + expect(String(res.headers['set-cookie'])).not.toContain('synthetic-invited-session-token') expect(String(res.headers['set-cookie'])).toContain('HttpOnly') expect(authTokenMock.verifyToken).not.toHaveBeenCalled() expect(invitationsServiceMock.getInvitationByToken).toHaveBeenCalledWith( @@ -85,7 +85,11 @@ describe('routes/invitations', () => { 'v2' ) expect(invitationsServiceMock.setupInvitationPassword).toHaveBeenCalledWith( - { userId: 'user-1', email: 'invitee@example.com', sessionToken: 'invited-session-token' }, + { + userId: 'user-1', + email: 'invitee@example.com', + sessionToken: 'synthetic-invited-session-token', + }, 'inv-1', 'user123!' ) @@ -417,52 +421,55 @@ describe('routes/invitations', () => { expect(JSON.stringify(res.body)).not.toContain('/external/invitations/token') }) - it('falls back to invitation token flow when an old bearer token is invalid', async () => { - authTokenMock.verifyToken.mockReturnValue(null) - invitationsServiceMock.getInvitationByToken.mockResolvedValue({ - id: 'inv-1', - email: 'invitee@example.com', - purpose: 'member_invitation', - status: 'pending', - }) - invitationsServiceMock.acceptInvitation.mockResolvedValue({ - data: { - userId: 'user-1', - email: 'invitee@example.com', - token: 'invited-session-token', - }, - }) - invitationsServiceMock.setupInvitationPassword.mockResolvedValue({ - data: { id: 'inv-1', passwordUpdated: true }, - }) - - await request(makeApp()) - .post('/invitations/password') - .set('authorization', 'Bearer stale-token') - .send({ - token: 'invitation-link-token', + it.each([ + ['bearer header', 'authorization', 'Bearer stale-token'], + ['profile session cookie', 'cookie', 'profile_session=stale-token'], + ])( + 'fails closed with 401 when an invalid %s is presented alongside an invitation link', + async (_source, header, value) => { + authTokenMock.verifyToken.mockReturnValue(null) + invitationsServiceMock.getInvitationByToken.mockResolvedValue({ + id: 'inv-1', email: 'invitee@example.com', - invitationId: 'inv-1', - password: 'user123!', + purpose: 'member_invitation', + status: 'pending', + }) + // The invitation-link path would succeed if it were reached. + invitationsServiceMock.acceptInvitation.mockResolvedValue({ + data: { + userId: 'user-1', + email: 'invitee@example.com', + token: 'synthetic-invited-session-token', + }, + }) + invitationsServiceMock.setupInvitationPassword.mockResolvedValue({ + data: { id: 'inv-1', passwordUpdated: true }, }) - .expect(200) - expect(authTokenMock.verifyToken).toHaveBeenCalledWith('stale-token') - expect(invitationsServiceMock.getInvitationByToken).toHaveBeenCalledWith( - 'invitation-link-token' - ) - expect(invitationsServiceMock.acceptInvitation).toHaveBeenCalledWith( - 'invitation-link-token', - 'invitee@example.com', - undefined - ) - expect(invitationsServiceMock.setupInvitationPassword).toHaveBeenCalledWith( - { userId: 'user-1', email: 'invitee@example.com', sessionToken: 'invited-session-token' }, - 'inv-1', - 'user123!' - ) - expect(invitationsServiceMock.setupInvitationPasswordWithToken).not.toHaveBeenCalled() - }) + const res = await request(makeApp()) + .post('/invitations/password') + // Own rate-limit bucket: the module-level limiter is shared across this file. + .set('x-forwarded-for', '203.0.113.20') + .set(header, value) + .send({ + token: 'invitation-link-token', + email: 'invitee@example.com', + invitationId: 'inv-1', + password: 'user123!', + }) + .expect(401) + + expect(res.body).toEqual({ error: 'Unauthorized' }) + // Liveness: the presented credential was verified and rejected. + expect(authTokenMock.verifyToken).toHaveBeenCalledWith('stale-token') + expect(invitationsServiceMock.getInvitationByToken).toHaveBeenCalledWith( + 'invitation-link-token' + ) + expect(invitationsServiceMock.acceptInvitation).not.toHaveBeenCalled() + expect(invitationsServiceMock.setupInvitationPassword).not.toHaveBeenCalled() + expect(invitationsServiceMock.setupInvitationPasswordWithToken).not.toHaveBeenCalled() + } + ) it('sets invitation password with a valid bearer token without accepting again', async () => { authTokenMock.verifyToken.mockReturnValue({ From 4c1ff4087fada85cbbf7f228b50cd46cd36e8187 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 21 Oct 2026 17:25:49 +0200 Subject: [PATCH 323/528] fix(deploy): allowlist Host message and artifact sub-paths on the rpc gateway rpc-proxy resolves the Host through two sub-paths that control-api-rpc-gateway did not allowlist: POST /api/v1/rpc/access/users/:userId/mcp-hosts/:hostRef/message-resolution GET /api/v1/rpc/access/users/:userId/mcp-hosts/:hostRef/artifact-read The parent location is anchored at the Host ref, so both fell through to the default `return 403` and every message send and artifact read was denied at the edge. Add one exact-method location per sub-path, without widening the default deny. Add a parity test in rpc-proxy that records the requests the real emitter functions send to control-api and checks each one against the gateway's nginx locations. Without this change the test reports both sub-paths as denied. Add scripts/e2e/e2e-rpc-gateway-host-paths.sh, a bounded T2 healthcheck. It sends one message and one artifact list through rpc-proxy and checks three things: HTTP 200, a gateway access-log line with 200 and no 403 for each sub-path, and the user turn persisted once in the Host's state.db. Rollout order (master spec H.4): 1. Record coordinates and rollback images. 2. Apply additive migrations with a bounded Job. 3. Roll out control-api with the new routes, external-rest-api, and these exact gateway locations. The default deny stays as it is. 4. Roll out rpc-proxy and dependent binaries, with legacy paths active and V2 gates off. rpc-proxy must not emit message-resolution or artifact-read before step 3 completes. Co-Authored-By: Claude Opus 5.5 --- deploy/base/control-plane/configmaps.yaml | 27 +++ external-rest-api/src/releaseManifest.ts | 2 +- rpc-proxy/package-lock.json | 4 +- rpc-proxy/package.json | 2 +- .../control-api-gateway-parity.test.ts | 183 +++++++++++++++ scripts/e2e/e2e-rpc-gateway-host-paths.sh | 217 ++++++++++++++++++ 6 files changed, 431 insertions(+), 4 deletions(-) create mode 100644 rpc-proxy/src/__tests__/control-api-gateway-parity.test.ts create mode 100755 scripts/e2e/e2e-rpc-gateway-host-paths.sh diff --git a/deploy/base/control-plane/configmaps.yaml b/deploy/base/control-plane/configmaps.yaml index 6f80b3653b..933e015897 100644 --- a/deploy/base/control-plane/configmaps.yaml +++ b/deploy/base/control-plane/configmaps.yaml @@ -250,6 +250,33 @@ data: proxy_set_header X-Forwarded-Proto $scheme; } + # Host artifact reads and message sends resolve the Host through these + # sub-paths so control-api charges the matching admission bucket. The + # parent location above is anchored at the Host ref, so without these + # blocks both fall through to the default deny. rpc-proxy's + # gateway parity test fails when an emitted path has no location here. + location ~ ^/api/v1/rpc/access/users/[^/]+/mcp-hosts/[^/]+/artifact-read$ { + limit_except GET { + deny all; + } + proxy_pass http://control_api_upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location ~ ^/api/v1/rpc/access/users/[^/]+/mcp-hosts/[^/]+/message-resolution$ { + limit_except POST { + deny all; + } + proxy_pass http://control_api_upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + # Stateless Agents — wake trigger for a suspended/draining host. # rpc-proxy forwards the caller's rpc access token; control-api # authorizes via requireRpcTokenHostMatch before any mutation. diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index cb9b383cb5..9dfa8e64eb 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -9,7 +9,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.11.0', externalRestApiVersion: '0.1.140', - rpcProxyVersion: '0.1.127', + rpcProxyVersion: '0.1.128', desktopVersion: '0.11.0', minimumDesktopVersion: '0.1.252', } diff --git a/rpc-proxy/package-lock.json b/rpc-proxy/package-lock.json index a6161efd1a..8cfa3736c6 100644 --- a/rpc-proxy/package-lock.json +++ b/rpc-proxy/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-rpc-proxy", - "version": "0.1.127", + "version": "0.1.128", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-rpc-proxy", - "version": "0.1.127", + "version": "0.1.128", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/rpc-proxy/package.json b/rpc-proxy/package.json index a78fe097ed..54a548d8d3 100644 --- a/rpc-proxy/package.json +++ b/rpc-proxy/package.json @@ -1,6 +1,6 @@ { "name": "clerum-rpc-proxy", - "version": "0.1.127", + "version": "0.1.128", "description": "External-facing RPC proxy for tenant-scoped MCP access", "main": "dist/main.js", "engines": { diff --git a/rpc-proxy/src/__tests__/control-api-gateway-parity.test.ts b/rpc-proxy/src/__tests__/control-api-gateway-parity.test.ts new file mode 100644 index 0000000000..9a5fcfde61 --- /dev/null +++ b/rpc-proxy/src/__tests__/control-api-gateway-parity.test.ts @@ -0,0 +1,183 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { readFileSync } from 'node:fs' +import { + fetchArtifactReadHostConnectionFromControlApi, + fetchHostConnectionFromControlApi, + fetchUserAllowedServersFromControlApi, + fetchUserConnectorsFromControlApi, + requestHostWakeFromControlApi, +} from '../services/controlApiRestService.js' + +// rpc-proxy reaches control-api only through control-api-rpc-gateway, whose +// nginx config ends in a default `return 403`. Every request this service +// emits must therefore select a proxying location that allows its method. +// The requests come from the real emitter functions, not from a hand-written +// list, so a new control-api call without a gateway location fails here. + +const USER = '00000000-0000-4000-8000-000000000001' +const HOST = 'host-a' + +interface GatewayLocation { + modifier: '=' | '~' | 'prefix' + path: string + methods: string[] | null + proxies: boolean +} + +function gatewayNginxConf(): string { + const configmaps = readFileSync( + new URL('../../../deploy/base/control-plane/configmaps.yaml', import.meta.url), + 'utf-8' + ) + const docs = configmaps + .split(/\n---\n/) + .filter(doc => /^kind: ConfigMap$/m.test(doc) && /^ name: control-api-rpc-gateway$/m.test(doc)) + if (docs.length !== 1) { + throw new Error(`expected one control-api-rpc-gateway ConfigMap, found ${docs.length}`) + } + return docs[0] +} + +function blockBody(conf: string, openBrace: number): string { + let depth = 0 + for (let i = openBrace; i < conf.length; i += 1) { + if (conf[i] === '{') depth += 1 + if (conf[i] === '}') { + depth -= 1 + if (depth === 0) return conf.slice(openBrace + 1, i) + } + } + throw new Error('unterminated nginx location block') +} + +function gatewayLocations(conf: string): GatewayLocation[] { + const locations: GatewayLocation[] = [] + const re = /location\s+(?:(=|~)\s+)?([^\s{@][^\s{]*)\s*\{/g + let m: RegExpExecArray | null + while ((m = re.exec(conf)) !== null) { + const body = blockBody(conf, m.index + m[0].length - 1) + const limit = body.match(/limit_except\s+([A-Z ]+?)\s*\{/) + locations.push({ + modifier: (m[1] as '=' | '~' | undefined) ?? 'prefix', + path: m[2], + methods: limit ? limit[1].trim().split(/\s+/) : null, + proxies: /proxy_pass\s+http:\/\/control_api_upstream;/.test(body), + }) + } + return locations +} + +// nginx selection for the shapes this config uses: an exact match wins, then +// the first matching regex in file order, then the longest prefix. +function selectLocation(locations: GatewayLocation[], path: string): GatewayLocation { + const exact = locations.find(l => l.modifier === '=' && l.path === path) + if (exact) return exact + const regex = locations.find(l => l.modifier === '~' && new RegExp(l.path).test(path)) + if (regex) return regex + const prefixes = locations + .filter(l => l.modifier === 'prefix' && path.startsWith(l.path)) + .sort((a, b) => b.path.length - a.path.length) + if (prefixes.length === 0) throw new Error(`no gateway location selects ${path}`) + return prefixes[0] +} + +function gatewayAdmits(locations: GatewayLocation[], method: string, path: string): boolean { + const location = selectLocation(locations, path) + if (!location.proxies) return false + if (!location.methods) return true + // limit_except GET also admits HEAD. + return ( + location.methods.includes(method) || (method === 'HEAD' && location.methods.includes('GET')) + ) +} + +function hostConnectionBody(): Response { + return Response.json({ + userId: USER, + hostRef: HOST, + url: 'http://host-a.mcp-host.svc.cluster.local:8080', + bindingStatus: 'recorded', + }) +} + +async function emittedRequests(): Promise> { + const fetchMock = vi.fn(async (url: string | URL) => { + const { pathname } = new URL(String(url)) + if (pathname.includes('/mcp-hosts/')) return hostConnectionBody() + if (pathname.endsWith('/wake')) return Response.json({ status: 'active', wakeGeneration: 1 }) + return Response.json({ userId: USER, contextIds: [], servers: [], agents: [] }) + }) + vi.stubGlobal('fetch', fetchMock) + const fetchImpl = fetchMock as unknown as typeof fetch + + await fetchUserAllowedServersFromControlApi(USER, 'jwt') + await fetchUserConnectorsFromControlApi(USER, 'jwt') + await fetchHostConnectionFromControlApi(USER, HOST, 'jwt', { fetchImpl }) + await fetchHostConnectionFromControlApi(USER, HOST, 'jwt', { + directRunBinding: { + runId: '00000000-0000-4000-8000-000000000123', + sessionId: 'session-a', + origin: 'direct_chat', + }, + fetchImpl, + }) + await fetchHostConnectionFromControlApi(USER, HOST, 'jwt', { messageResolution: true, fetchImpl }) + await fetchArtifactReadHostConnectionFromControlApi(USER, HOST, 'jwt', { fetchImpl }) + await requestHostWakeFromControlApi(HOST, 'jwt') + + return fetchMock.mock.calls.map(([url, init]) => ({ + method: String((init as RequestInit | undefined)?.method ?? 'GET'), + path: new URL(String(url)).pathname, + })) +} + +describe('control-api-rpc-gateway parity with the rpc-proxy emitter', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('admits every request rpc-proxy sends to control-api', async () => { + const requests = await emittedRequests() + // Liveness witness: every emitter ran and produced its request. + expect(requests).toEqual([ + { method: 'GET', path: `/api/v1/rpc/access/users/${USER}/mcp-servers` }, + { method: 'GET', path: `/api/v1/rpc/access/users/${USER}/mcp-connectors` }, + { method: 'GET', path: `/api/v1/rpc/access/users/${USER}/mcp-hosts/${HOST}` }, + { method: 'POST', path: `/api/v1/rpc/access/users/${USER}/mcp-hosts/${HOST}` }, + { + method: 'POST', + path: `/api/v1/rpc/access/users/${USER}/mcp-hosts/${HOST}/message-resolution`, + }, + { method: 'GET', path: `/api/v1/rpc/access/users/${USER}/mcp-hosts/${HOST}/artifact-read` }, + { method: 'POST', path: `/api/v1/rpc/hosts/${HOST}/wake` }, + ]) + + const locations = gatewayLocations(gatewayNginxConf()) + const denied = requests.filter(r => !gatewayAdmits(locations, r.method, r.path)) + expect(denied).toEqual([]) + }) + + it('keeps the default deny for paths and methods rpc-proxy does not emit', () => { + const locations = gatewayLocations(gatewayNginxConf()) + // Witness that the matcher can deny: these would pass a matcher that + // admits everything. + expect(gatewayAdmits(locations, 'GET', `/api/v1/rpc/access/users/${USER}/contexts`)).toBe(false) + expect( + gatewayAdmits( + locations, + 'POST', + `/api/v1/rpc/access/users/${USER}/mcp-hosts/${HOST}/artifact-read` + ) + ).toBe(false) + expect( + gatewayAdmits( + locations, + 'GET', + `/api/v1/rpc/access/users/${USER}/mcp-hosts/${HOST}/message-resolution` + ) + ).toBe(false) + expect( + gatewayAdmits(locations, 'GET', `/api/v1/rpc/access/users/${USER}/mcp-hosts/${HOST}/other`) + ).toBe(false) + }) +}) diff --git a/scripts/e2e/e2e-rpc-gateway-host-paths.sh b/scripts/e2e/e2e-rpc-gateway-host-paths.sh new file mode 100755 index 0000000000..6c6a1541a2 --- /dev/null +++ b/scripts/e2e/e2e-rpc-gateway-host-paths.sh @@ -0,0 +1,217 @@ +#!/usr/bin/env bash +# ====================================================================== +# J4 — rpc-proxy Host sub-paths through control-api-rpc-gateway +# ====================================================================== +# A user message and an artifact list go through rpc-proxy, which resolves +# the Host through control-api-rpc-gateway: +# POST /api/v1/rpc/access/users/:userId/mcp-hosts/:hostRef/message-resolution +# GET /api/v1/rpc/access/users/:userId/mcp-hosts/:hostRef/artifact-read +# The gateway ends in a default `return 403`, so a missing location turns +# both journeys into an opaque denial. Signals per journey: +# 1. rpc-proxy answers HTTP 200; +# 2. the gateway access log has the sub-path with status 200 and no 403; +# 3. for the message, the user turn is persisted in the Host's state.db. +# +# Meant as a T2 healthcheck: +# T2_HEALTHCHECK_COMMAND="KUBECONTEXT= bash scripts/e2e/e2e-rpc-gateway-host-paths.sh" +# +# Required environment: +# KUBECONTEXT (or E2E_K8S_CONTEXT) explicit branch-profile context +# E2E_DEV_LOGIN_EMAIL seeded Desktop login for the Host owner +# E2E_USER_PASSWORD or ADMIN_PASSWORD +# Optional: +# E2E_GATEWAY_HOST_REF (default chatllm) +# EXTERNAL_REST_API_BASE_URL (default http://127.0.0.1:8091) +# RPC_PROXY_BASE_URL (default http://127.0.0.1:8094) +# E2E_TURN_TIMEOUT (default 120), J4_DEADLINE_SECONDS (default 300) +# ====================================================================== +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/e2e-lib.sh" + +if [ -z "${E2E_KUBECONTEXT:-}" ]; then + echo "J4 requires an explicit KUBECONTEXT (or E2E_K8S_CONTEXT); the global current-context is never used" >&2 + exit 2 +fi +require_safe_kube_context +: "${E2E_DEV_LOGIN_EMAIL:?J4 requires E2E_DEV_LOGIN_EMAIL (the seeded Desktop login)}" +DEV_PASSWORD="${E2E_USER_PASSWORD:-${ADMIN_PASSWORD:-}}" +if [ -z "$DEV_PASSWORD" ]; then + echo "J4 requires E2E_USER_PASSWORD or ADMIN_PASSWORD" >&2 + exit 2 +fi + +HOST_REF="${E2E_GATEWAY_HOST_REF:-chatllm}" +EXT_BASE="${EXTERNAL_REST_API_BASE_URL:-http://127.0.0.1:8091}" +RPC_BASE="${RPC_PROXY_BASE_URL:-http://127.0.0.1:8094}" +GATEWAY_NS=control-plane +GATEWAY_SELECTOR=app=control-api-rpc-gateway +E2E_TURN_TIMEOUT="${E2E_TURN_TIMEOUT:-120}" +J4_DEADLINE_SECONDS="${J4_DEADLINE_SECONDS:-300}" +RUN_ID="$(date +%s)-$$-${RANDOM}" +MARKER="j4-gateway-${RUN_ID}" +THREAD_ID="e2e-rpc-gateway-host-paths-${RUN_ID}" +STARTED_SECONDS=$SECONDS + +within_deadline() { + if [ $((SECONDS - STARTED_SECONDS)) -ge "$J4_DEADLINE_SECONDS" ]; then + fail "J4 deadline of ${J4_DEADLINE_SECONDS}s expired before: $1" + print_results || true + exit 1 + fi +} + +http_call() { + # http_call

- - - Sign in instead - -
- ) case 'outcome-unknown': return (
@@ -131,24 +116,50 @@ export function InvitationActionPanel(props: InvitationActionPanelProps) {
) - case 'accepted': + case 'password-updated': return ( + ) + case 'used': + return ( +
+

+ This invitation has already been used. Sign in with your password to continue. +

+ + Sign in + {props.isPasswordReset ? ( + + Request a new recovery link + + ) : null} +
+ ) + case 'link-error': + return ( + ) case 'expired': - return
This invitation has expired.
+ return ( +
+
This invitation has expired.
+ + Sign in + +
+ ) case 'accept': return (
diff --git a/profile-ui/app/invitations/[token]/InvitationClient.tsx b/profile-ui/app/invitations/[token]/InvitationClient.tsx index 93bb7c2cd1..fd5cb9d5b4 100644 --- a/profile-ui/app/invitations/[token]/InvitationClient.tsx +++ b/profile-ui/app/invitations/[token]/InvitationClient.tsx @@ -9,13 +9,11 @@ import { useRef, useState, } from 'react' -import { useRouter } from 'next/navigation' -import { useAuth } from '@components/AuthContext' import { PROFILE_ROUTES } from '@constants/routes' import { ProfileApiError, acceptInvitation, - getEvenfireDownloadUrl, + clearUnconfirmedLogout, setupAcceptedInvitationPasswordWithToken, setupInvitationPasswordWithToken, } from '@lib/api' @@ -29,14 +27,7 @@ import type { InvitationPreview } from '@/app/types/api' import { InvitationActionPanel } from './InvitationActionPanel' import type { InvitationClientProps } from './types' -type RecoveryPhase = - | { state: 'editing' } - | { state: 'submitting' } - | { state: 'checking_session' } - | { state: 'committed' } - | { state: 'session_unverified' } - | { state: 'outcome_unknown' } - | { state: 'authenticated' } +type PasswordPhase = 'editing' | 'submitting' | 'updated' | 'outcome_unknown' function statusForInvitation(invitation: InvitationPreview): string { if (invitation.purpose === 'password_reset') { @@ -95,8 +86,6 @@ export function InvitationClient({ initialInvitation, initialError, }: InvitationClientProps) { - const router = useRouter() - const { checkAuth } = useAuth() const [invitation, setInvitation] = useState(initialInvitation) const [error, setError] = useState(initialError) const [status, setStatus] = useState(() => @@ -106,7 +95,7 @@ export function InvitationClient({ const [password, setPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [submitting, setSubmitting] = useState<'accept' | 'password' | null>(null) - const [recoveryPhase, setRecoveryPhase] = useState({ state: 'editing' }) + const [passwordPhase, setPasswordPhase] = useState('editing') const actionInFlightRef = useRef(false) useEffect(() => { @@ -121,9 +110,15 @@ export function InvitationClient({ }, [initialInvitation, initialError]) useEffect(() => { - setRecoveryPhase({ state: 'editing' }) + setPasswordPhase('editing') }, [invitationToken]) + useEffect(() => { + // A recovery link is a fresh sign-in path. A previous logout that could not + // confirm revocation leaves a fence that would otherwise block this form. + if (initialInvitation?.purpose === 'password_reset') clearUnconfirmedLogout() + }, [initialInvitation]) + const invitationExpired = useMemo(() => { if (!invitation || invitation.status !== 'pending') return false return formatRemaining(invitation.expiresAt, now) === 'expired' @@ -137,7 +132,6 @@ export function InvitationClient({ : `This invitation will expire in ${remaining}.` }, [invitation, now]) - const downloadUrl = getEvenfireDownloadUrl() const isPasswordReset = invitation?.purpose === 'password_reset' const teamLabel = invitation ? buildInvitationTeamsLabel(invitation.teams, invitation.teamName) @@ -156,12 +150,30 @@ export function InvitationClient({ email: invitation?.email.trim().toLowerCase(), }) const busy = submitting !== null - const recoveryNeedsSessionCheck = - isPasswordReset && - ['committed', 'checking_session', 'session_unverified', 'authenticated'].includes( - recoveryPhase.state - ) - const recoveryOutcomeUnknown = isPasswordReset && recoveryPhase.state === 'outcome_unknown' + const passwordUpdated = passwordPhase === 'updated' + const recoveryOutcomeUnknown = isPasswordReset && passwordPhase === 'outcome_unknown' + const invitationUsed = Boolean( + invitation && invitation.status === 'accepted' && !invitation.passwordPending + ) + const showPasswordSetup = Boolean( + invitation && + !passwordUpdated && + !recoveryOutcomeUnknown && + !invitationUsed && + !invitationExpired && + ((isPasswordReset && invitation.status === 'pending') || + (invitation.status === 'accepted' && invitation.passwordPending)) + ) + const pageTitle = passwordUpdated + ? 'Password updated' + : isPasswordReset + ? 'Reset password' + : buildInvitationHeading(invitation?.teamName) + const pageStatus = passwordUpdated + ? 'Your password has been updated.' + : invitationUsed + ? 'This invitation has already been used.' + : status || (error ? 'We could not open this invitation.' : 'Invitation loaded.') function friendlyInvitationError(value: unknown, isRecovery = false): string { if (!(value instanceof Error)) return 'Failed to update invitation.' @@ -258,54 +270,13 @@ export function InvitationClient({ void handleAccept() } - async function verifyRecoveredSession(expectedUserId: string) { - setRecoveryPhase({ state: 'checking_session' }) - setError('') - try { - const result = await checkAuth() - if (result.status === 'authenticated') { - if (result.me.id === expectedUserId) { - setRecoveryPhase({ state: 'authenticated' }) - router.replace(PROFILE_ROUTES.home) - return - } - setRecoveryPhase({ state: 'session_unverified' }) - setError( - 'Your password was updated, but the signed-in account does not match this recovery link.' - ) - return - } - setRecoveryPhase({ state: 'session_unverified' }) - setError( - 'Your password was updated, but we could not verify your account session. Try again.' - ) - } catch { - setRecoveryPhase({ state: 'session_unverified' }) - setError( - 'Your password was updated, but we could not verify your account session. Try again.' - ) - } - } - - async function retryRecoveredSession() { - if ( - (recoveryPhase.state !== 'committed' && - recoveryPhase.state !== 'session_unverified' && - recoveryPhase.state !== 'authenticated') || - !invitation?.userId || - busy - ) { - return - } - await verifyRecoveredSession(invitation.userId) - } - async function handlePasswordSubmit() { if ( !invitation || busy || actionInFlightRef.current || - (isPasswordReset && recoveryPhase.state !== 'editing') + passwordPhase === 'updated' || + passwordPhase === 'outcome_unknown' ) { return } @@ -321,48 +292,28 @@ export function InvitationClient({ } actionInFlightRef.current = true setSubmitting('password') - if (isPasswordReset) setRecoveryPhase({ state: 'submitting' }) + setPasswordPhase('submitting') try { const setupPassword = isPasswordReset ? setupInvitationPasswordWithToken : setupAcceptedInvitationPasswordWithToken - const response = await setupPassword( - invitationToken, - invitation.email, - invitation.id, - password - ) - if (isPasswordReset) { - setRecoveryPhase({ state: 'committed' }) - setStatus('Your password has been updated.') - setPassword('') - setConfirmPassword('') - if (!invitation.userId || response.userId !== invitation.userId) { - setRecoveryPhase({ state: 'session_unverified' }) - setError( - 'Your password was updated, but the signed-in account does not match this recovery link.' - ) - return - } - await verifyRecoveredSession(invitation.userId) - return - } - applyInvitationUpdate({ - ...invitation, - ...response, - status: response.status || 'accepted', - passwordPending: false, - }) + await setupPassword(invitationToken, invitation.email, invitation.id, password) + // The backend issues no session from password setup. The screen must send + // the person to the ordinary login instead of hydrating a recovery session. + setPasswordPhase('updated') + setStatus('Your password has been updated.') + setPassword('') + setConfirmPassword('') } catch (nextError) { if (isPasswordReset && isAmbiguousRecoveryOutcome(nextError)) { - setRecoveryPhase({ state: 'outcome_unknown' }) + setPasswordPhase('outcome_unknown') setPassword('') setConfirmPassword('') setError( 'We could not confirm whether your password change completed. Try signing in with the new password. If that does not work, request a new reset link.' ) } else { - if (isPasswordReset) setRecoveryPhase({ state: 'editing' }) + setPasswordPhase('editing') setError(friendlyInvitationError(nextError, isPasswordReset)) } } finally { @@ -390,12 +341,8 @@ export function InvitationClient({

Evenfire Invitation

-

- {isPasswordReset ? 'Reset password' : buildInvitationHeading(invitation?.teamName)} -

-

- {status || (error ? 'We could not open this invitation.' : 'Invitation loaded.')} -

+

{pageTitle}

+

{pageStatus}

{error ? ( @@ -439,22 +386,26 @@ export function InvitationClient({
) : null} - {!invitation ? null : recoveryNeedsSessionCheck ? ( + {!invitation ? ( + + ) : passwordUpdated ? ( + + ) : recoveryOutcomeUnknown ? ( void retryRecoveredSession()} + mode="outcome-unknown" loginHref={profileLoginHref} + forgotPasswordHref={forgotPasswordHref} /> - ) : isPasswordReset && recoveryOutcomeUnknown ? ( + ) : invitationUsed ? ( - ) : (isPasswordReset && invitation.status === 'pending') || - (invitation.status === 'accepted' && invitation.passwordPending) ? ( + ) : invitationExpired ? ( + + ) : showPasswordSetup ? ( - ) : invitation.status === 'accepted' ? ( - - ) : invitationExpired ? ( - ) : ( Date: Wed, 21 Oct 2026 20:26:18 +0200 Subject: [PATCH 348/528] test(e2e): rewrite the profile-ui recovery lane as journeys J1-J3 Replace the fixture-owned session and canned /me with a parity-tested control-api simulator: invitation previews, acceptance, password setup, login, identity and logout go through the real external-rest-api, and the fixture no longer sets cookies or fakes /me. Rewrite the lane as J1 invitation onboarding, J2 password recovery with the logout fence and prior-session revocation, J3 logout, and negative entry routes. Move the reset header check to an external-rest-api contract test and add a real-PostgreSQL parity test for every control-api handler the fixture keeps. --- Makefile | 5 +- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- ...re.parity.realPostgres.integration.test.ts | 494 +++++++++++++++ tests/e2e/playwright/package-lock.json | 4 +- tests/e2e/playwright/package.json | 2 +- .../profile-ui-recovery-control-api.mjs | 507 +++++++++++++++ .../profile-ui-recovery-fixture.mjs | 585 ++---------------- .../playwright/profile-ui-recovery.config.ts | 6 +- .../j1-invitation-onboarding.spec.ts | 91 +++ .../j2-password-recovery.spec.ts | 82 +++ .../profile-ui-recovery/j3-logout.spec.ts | 48 ++ .../profile-ui-recovery/journeys.ts | 79 +++ .../negative-routes.spec.ts | 68 ++ .../ordinary-invitation-onboarding.spec.ts | 189 ------ .../reset-session-hydration.spec.ts | 202 ------ .../playwright/run-profile-ui-recovery.mjs | 45 +- 17 files changed, 1448 insertions(+), 965 deletions(-) create mode 100644 control-api/test/profileUiRecoveryFixture.parity.realPostgres.integration.test.ts create mode 100644 tests/e2e/playwright/profile-ui-recovery-control-api.mjs create mode 100644 tests/e2e/playwright/profile-ui-recovery/j1-invitation-onboarding.spec.ts create mode 100644 tests/e2e/playwright/profile-ui-recovery/j2-password-recovery.spec.ts create mode 100644 tests/e2e/playwright/profile-ui-recovery/j3-logout.spec.ts create mode 100644 tests/e2e/playwright/profile-ui-recovery/journeys.ts create mode 100644 tests/e2e/playwright/profile-ui-recovery/negative-routes.spec.ts delete mode 100644 tests/e2e/playwright/profile-ui-recovery/ordinary-invitation-onboarding.spec.ts delete mode 100644 tests/e2e/playwright/profile-ui-recovery/reset-session-hydration.spec.ts diff --git a/Makefile b/Makefile index 779b676f78..d562aad7f2 100644 --- a/Makefile +++ b/Makefile @@ -1543,8 +1543,9 @@ test-playwright-desktop: ## Run Playwright tests for Desktop App only (requires: .PHONY: test-playwright-profile-ui-recovery test-playwright-profile-ui-recovery: ## Audit and run the Profile UI password-recovery browser journey - @python3 tools/e2e_static_audit.py tests/e2e/playwright/profile-ui-recovery/reset-session-hydration.spec.ts - @python3 tools/e2e_static_audit.py tests/e2e/playwright/profile-ui-recovery/ordinary-invitation-onboarding.spec.ts + @for spec in tests/e2e/playwright/profile-ui-recovery/*.spec.ts; do \ + python3 tools/e2e_static_audit.py "$$spec" || exit $$?; \ + done @cd tests/e2e/playwright && node run-profile-ui-recovery.mjs .PHONY: test-playwright-headed diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 036617853f..6b5e83041d 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.810", + "version": "0.1.811", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.810", + "version": "0.1.811", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 63b5beb3b2..6f4277d556 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.810", + "version": "0.1.811", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/profileUiRecoveryFixture.parity.realPostgres.integration.test.ts b/control-api/test/profileUiRecoveryFixture.parity.realPostgres.integration.test.ts new file mode 100644 index 0000000000..c66c2945c1 --- /dev/null +++ b/control-api/test/profileUiRecoveryFixture.parity.realPostgres.integration.test.ts @@ -0,0 +1,494 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import express from 'express' +import bcrypt from 'bcryptjs' +import { generateKeyPairSync, randomBytes } from 'node:crypto' +import type { Server } from 'node:http' +import { Pool } from 'pg' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' + +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const realPg = adminUrl ? describe : describe.skip + +type FixtureModule = { + RECOVERY_CONTROL_API_SERVICE_TOKEN: string + RECOVERY_INVITATIONS: Array<{ + token: string + id: string + email: string + purpose: string + status: string + role: string + teamId: string | null + teamName: string | null + userId: string | null + expiresAt: string + acceptedAt: string | null + }> + RECOVERY_PASSWORDS: Record<'recovery' | 'logout' | 'replacement' | 'onboarding', string> + RECOVERY_USERS: Record< + 'onboarding' | 'recovery' | 'logout' | 'usedInvitee', + { + id: string + email: string + name: string + teamId: string | null + teamName: string | null + role: string + password: string | null + } + > + createRecoveryControlApi: (options: { privateKey: unknown }) => Promise<{ + handle: (input: { + method: string + path: string + headers: Record + body?: unknown + }) => Promise<{ status: number; body: unknown }> + }> +} + +function bodyShape(value: unknown): unknown { + if (Array.isArray(value)) return value.map(bodyShape) + if (!value || typeof value !== 'object') return typeof value + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, bodyShape(entry)]) + ) +} + +realPg('Profile UI recovery lane fixture parity on PostgreSQL 16', () => { + const database = `profile_ui_fixture_parity_${randomBytes(6).toString('hex')}` + const internalServiceToken = `synthetic-fixture-parity-${randomBytes(20).toString('hex')}` + let admin: Pool + let db: typeof import('../src/db.js') + let controlServer: Server + let fixture: Awaited> + let fixtureModule: FixtureModule + let privateKeyPem: string + let sourceCounter = 1 + + function nextSourceIp() { + sourceCounter += 1 + return `198.51.100.${sourceCounter % 250}` + } + + async function requestReal( + method: 'GET' | 'POST', + path: string, + input: { body?: unknown; sessionToken?: string } = {} + ) { + const target = controlServer.address() + if (!target || typeof target === 'string') throw new Error('control server did not bind TCP') + const url = `http://127.0.0.1:${target.port}/api/v1${path}` + const headers: Record = { + authorization: `Bearer ${internalServiceToken}`, + 'x-service-token': 'external-rest-api', + 'x-external-client-ip': nextSourceIp(), + } + if (input.sessionToken) headers['x-user-session-token'] = input.sessionToken + const response = await fetch(url, { + method, + headers: { + ...headers, + ...(input.body === undefined ? {} : { 'content-type': 'application/json' }), + }, + body: input.body === undefined ? undefined : JSON.stringify(input.body), + }) + const text = await response.text() + return { status: response.status, body: text ? JSON.parse(text) : null } + } + + async function requestFixture( + method: 'GET' | 'POST', + path: string, + input: { body?: unknown; sessionToken?: string } = {} + ) { + const headers: Record = { + authorization: `Bearer ${fixtureModule.RECOVERY_CONTROL_API_SERVICE_TOKEN}`, + 'x-service-token': 'external-rest-api', + } + if (input.sessionToken) headers['x-user-session-token'] = input.sessionToken + return fixture.handle({ method, path, headers, body: input.body }) + } + + async function expectParity( + method: 'GET' | 'POST', + path: string, + input: { body?: unknown; sessionToken?: string } = {} + ) { + const real = await requestReal(method, path, input) + const synthetic = await requestFixture(method, path, input) + expect(synthetic.status, `${method} ${path}`).toBe(real.status) + expect(bodyShape(synthetic.body), `${method} ${path}`).toEqual(bodyShape(real.body)) + return { real, synthetic } + } + + async function openPasswordPace() { + await db.pool.query( + "UPDATE password_verification_pace SET next_permit = clock_timestamp() - interval '1 second'" + ) + } + + async function loginReal(email: string, password: string) { + return requestReal('POST', '/external/auth/password-login', { body: { email, password } }) + } + + async function loginFixture(email: string, password: string) { + return requestFixture('POST', '/external/auth/password-login', { body: { email, password } }) + } + + beforeAll(async () => { + admin = new Pool({ connectionString: adminUrl }) + await admin.query(`CREATE DATABASE "${database}"`) + const databaseUrl = new URL(adminUrl!) + databaseUrl.pathname = `/${database}` + vi.stubEnv('CONTROL_API_PG_CONNECTION_STRING', databaseUrl.toString()) + vi.stubEnv('CONTROL_API_INTERNAL_SERVICE_TOKENS', `external-rest-api=${internalServiceToken}`) + const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }) + privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString() + vi.stubEnv('CONTROL_API_SESSION_JWT_PRIVATE_KEY', privateKeyPem) + vi.stubEnv( + 'EXTERNAL_REST_API_JWT_PUBLIC_KEY', + publicKey.export({ type: 'spki', format: 'pem' }).toString() + ) + vi.resetModules() + fixtureModule = + (await import('../../tests/e2e/playwright/profile-ui-recovery-control-api.mjs')) as FixtureModule + vi.doMock('../src/services/invitationFlowRegistrationService.js', () => ({ + validateInvitationFlowToken: vi.fn(async (token: string, email?: string) => { + const invitation = fixtureModule.RECOVERY_INVITATIONS.find(row => row.token === token) + if (!invitation) throw new Error('invalid synthetic invitation flow') + if (email && invitation.email.toLowerCase() !== email.toLowerCase()) { + throw new Error('invalid synthetic invitation flow') + } + return { email: invitation.email, invitationUuid: invitation.token } + }), + storeDesktopAuthorizationToken: vi.fn(), + })) + + db = await import('../src/db.js') + await db.initDb() + fixture = await fixtureModule.createRecoveryControlApi({ privateKey }) + + for (const user of Object.values(fixtureModule.RECOVERY_USERS)) { + const passwordHash = user.password ? await bcrypt.hash(user.password, 12) : null + await db.pool.query( + `INSERT INTO users(id, email, name, password_hash, lifecycle_version) + VALUES ($1, $2, $3, $4, 1)`, + [user.id, user.email, user.name, passwordHash] + ) + await db.pool.query( + `INSERT INTO profiles(user_id, display_name, channels) VALUES ($1, $2, $3::jsonb)`, + [user.id, user.name, JSON.stringify({})] + ) + if (user.teamId) { + await db.pool.query(`INSERT INTO teams(id, name) VALUES ($1, $2)`, [ + user.teamId, + user.teamName, + ]) + await db.pool.query( + `INSERT INTO team_members(team_id, user_id, role, status) VALUES ($1, $2, $3, 'active')`, + [user.teamId, user.id, user.role] + ) + } + } + for (const invitation of fixtureModule.RECOVERY_INVITATIONS) { + await db.pool.query( + `INSERT INTO invitations( + id, team_id, email, role, token, status, purpose, expires_at, accepted_at, accepted_user_id, invitee_name + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + [ + invitation.id, + invitation.teamId, + invitation.email, + invitation.role, + invitation.token, + invitation.status, + invitation.purpose, + invitation.expiresAt, + invitation.acceptedAt, + invitation.status === 'accepted' ? invitation.userId : null, + 'Synthetic recovery fixture invitee', + ] + ) + } + + const { requireInternalService, requireInternalToken } = + await import('../src/middleware/internalServiceAuth.js') + const { clerumErrorHandler } = await import('../src/http/errorHandler.js') + const { createExternalInvitationsRouter } = + await import('../src/routes/external/invitations.js') + const { createExternalAuthRouter } = await import('../src/routes/external/auth.js') + const { createExternalUsersRouter } = await import('../src/routes/external/users.js') + + const app = express() + app.use(express.json()) + app.use(requireInternalToken) + const controlApi = express.Router() + controlApi.use('/external', requireInternalService('external-rest-api')) + controlApi.use(createExternalInvitationsRouter()) + controlApi.use(createExternalAuthRouter({} as Parameters[0])) + controlApi.use(createExternalUsersRouter({} as Parameters[0])) + app.use('/api/v1', controlApi) + app.use(clerumErrorHandler) + controlServer = app.listen(0, '127.0.0.1') + await new Promise(resolve => controlServer.once('listening', resolve)) + }, 120_000) + + afterAll(async () => { + try { + if (controlServer) { + await new Promise((resolve, reject) => + controlServer.close(error => (error ? reject(error) : resolve())) + ) + } + if (db) { + await endPoolAndWaitForClients(db.pool) + await endPoolAndWaitForClients(db.rateLimitPool) + } + if (admin) await admin.query(`DROP DATABASE IF EXISTS "${database}"`) + } finally { + await admin?.end() + vi.unstubAllEnvs() + vi.doUnmock('../src/services/invitationFlowRegistrationService.js') + } + }) + + it('returns the same invitation preview for pending, used, expired and invented tokens', async () => { + for (const token of [ + 'synthetic-member-invitation', + 'synthetic-reset-proof', + 'synthetic-used-invitation', + 'synthetic-expired-invitation', + 'synthetic-invented-token', + ]) { + await expectParity('GET', `/external/invitations/token/${token}`) + } + }) + + it('parities accept, authenticated password setup and the resulting session identity', async () => { + const accepted = await expectParity('POST', '/external/invitations/accept', { + body: { + email: fixtureModule.RECOVERY_USERS.onboarding.email, + token: 'synthetic-member-invitation', + }, + }) + expect(accepted.real.status).toBe(200) + const realToken = (accepted.real.body as { token: string }).token + const fixtureToken = (accepted.synthetic.body as { token: string }).token + expect(realToken).toEqual(expect.any(String)) + expect(fixtureToken).toEqual(expect.any(String)) + + const passwordPayload = { + userId: fixtureModule.RECOVERY_USERS.onboarding.id, + email: fixtureModule.RECOVERY_USERS.onboarding.email, + invitationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1', + password: fixtureModule.RECOVERY_PASSWORDS.onboarding, + } + const realPassword = await requestReal('POST', '/external/invitations/password', { + body: passwordPayload, + sessionToken: realToken, + }) + const fixturePassword = await requestFixture('POST', '/external/invitations/password', { + body: passwordPayload, + sessionToken: fixtureToken, + }) + expect(fixturePassword.status, JSON.stringify(fixturePassword.body)).toBe(realPassword.status) + expect(bodyShape(fixturePassword.body)).toEqual(bodyShape(realPassword.body)) + expect(realPassword.status).toBe(200) + + await openPasswordPace() + const realLogin = await loginReal( + fixtureModule.RECOVERY_USERS.onboarding.email, + fixtureModule.RECOVERY_PASSWORDS.onboarding + ) + const fixtureLogin = await loginFixture( + fixtureModule.RECOVERY_USERS.onboarding.email, + fixtureModule.RECOVERY_PASSWORDS.onboarding + ) + expect(fixtureLogin.status).toBe(realLogin.status) + expect(bodyShape(fixtureLogin.body)).toEqual(bodyShape(realLogin.body)) + expect(realLogin.status).toBe(200) + + const realMe = await requestReal( + 'GET', + `/external/users/${fixtureModule.RECOVERY_USERS.onboarding.id}/me?teamId=${fixtureModule.RECOVERY_USERS.onboarding.teamId}`, + { sessionToken: (realLogin.body as { token: string }).token } + ) + const fixtureMe = await requestFixture( + 'GET', + `/external/users/${fixtureModule.RECOVERY_USERS.onboarding.id}/me?teamId=${fixtureModule.RECOVERY_USERS.onboarding.teamId}`, + { sessionToken: (fixtureLogin.body as { token: string }).token } + ) + expect(fixtureMe.status).toBe(realMe.status) + expect(bodyShape(fixtureMe.body)).toEqual(bodyShape(realMe.body)) + expect(realMe.status).toBe(200) + + const realRevoked = await requestReal( + 'GET', + `/external/users/${fixtureModule.RECOVERY_USERS.onboarding.id}/me?teamId=${fixtureModule.RECOVERY_USERS.onboarding.teamId}`, + { sessionToken: realToken } + ) + const fixtureRevoked = await requestFixture( + 'GET', + `/external/users/${fixtureModule.RECOVERY_USERS.onboarding.id}/me?teamId=${fixtureModule.RECOVERY_USERS.onboarding.teamId}`, + { sessionToken: fixtureToken } + ) + expect(fixtureRevoked.status).toBe(realRevoked.status) + expect(bodyShape(fixtureRevoked.body)).toEqual(bodyShape(realRevoked.body)) + expect(realRevoked.status).toBe(401) + }) + + it('parities reset completion, the revoked prior session and the fresh login session', async () => { + await openPasswordPace() + const priorLogin = await loginReal( + fixtureModule.RECOVERY_USERS.recovery.email, + fixtureModule.RECOVERY_PASSWORDS.recovery + ) + const priorFixtureLogin = await loginFixture( + fixtureModule.RECOVERY_USERS.recovery.email, + fixtureModule.RECOVERY_PASSWORDS.recovery + ) + expect(priorLogin.status).toBe(200) + expect(priorFixtureLogin.status).toBe(200) + const priorRealToken = (priorLogin.body as { token: string }).token + const priorFixtureToken = (priorFixtureLogin.body as { token: string }).token + const validBefore = await requestReal( + 'GET', + `/external/users/${fixtureModule.RECOVERY_USERS.recovery.id}/me?teamId=`, + { sessionToken: priorRealToken } + ) + expect(validBefore.status).toBe(200) + const fixtureValidBefore = await requestFixture( + 'GET', + `/external/users/${fixtureModule.RECOVERY_USERS.recovery.id}/me?teamId=`, + { sessionToken: priorFixtureToken } + ) + expect(fixtureValidBefore.status).toBe(200) + + const resetPayload = { + email: fixtureModule.RECOVERY_USERS.recovery.email, + token: 'synthetic-reset-proof', + invitationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2', + password: fixtureModule.RECOVERY_PASSWORDS.replacement, + } + await expectParity('POST', '/external/invitations/password-token', { body: resetPayload }) + + const realRevoked = await requestReal( + 'GET', + `/external/users/${fixtureModule.RECOVERY_USERS.recovery.id}/me?teamId=`, + { sessionToken: priorRealToken } + ) + const fixtureRevoked = await requestFixture( + 'GET', + `/external/users/${fixtureModule.RECOVERY_USERS.recovery.id}/me?teamId=`, + { sessionToken: priorFixtureToken } + ) + expect(fixtureRevoked.status).toBe(realRevoked.status) + expect(bodyShape(fixtureRevoked.body)).toEqual(bodyShape(realRevoked.body)) + expect(realRevoked.status).toBe(401) + + await openPasswordPace() + const realLogin = await loginReal( + fixtureModule.RECOVERY_USERS.recovery.email, + fixtureModule.RECOVERY_PASSWORDS.replacement + ) + const fixtureLogin = await loginFixture( + fixtureModule.RECOVERY_USERS.recovery.email, + fixtureModule.RECOVERY_PASSWORDS.replacement + ) + expect(fixtureLogin.status).toBe(realLogin.status) + expect(bodyShape(fixtureLogin.body)).toEqual(bodyShape(realLogin.body)) + expect(realLogin.status).toBe(200) + const realFreshMe = await requestReal( + 'GET', + `/external/users/${fixtureModule.RECOVERY_USERS.recovery.id}/me?teamId=`, + { sessionToken: (realLogin.body as { token: string }).token } + ) + const fixtureFreshMe = await requestFixture( + 'GET', + `/external/users/${fixtureModule.RECOVERY_USERS.recovery.id}/me?teamId=`, + { sessionToken: (fixtureLogin.body as { token: string }).token } + ) + expect(fixtureFreshMe.status).toBe(realFreshMe.status) + expect(bodyShape(fixtureFreshMe.body)).toEqual(bodyShape(realFreshMe.body)) + expect(realFreshMe.status).toBe(200) + }) + + it('parities login, wrong-password and logout handlers', async () => { + await openPasswordPace() + const loggedIn = await expectParity('POST', '/external/auth/password-login', { + body: { + email: fixtureModule.RECOVERY_USERS.logout.email, + password: fixtureModule.RECOVERY_PASSWORDS.logout, + }, + }) + expect(loggedIn.real.status).toBe(200) + const realToken = (loggedIn.real.body as { token: string }).token + const fixtureToken = (loggedIn.synthetic.body as { token: string }).token + + await openPasswordPace() + await expectParity('POST', '/external/auth/password-login', { + body: { + email: fixtureModule.RECOVERY_USERS.logout.email, + password: 'definitely-not-the-password', + }, + }) + + const realLogout = await requestReal('POST', '/external/auth/session/logout', { + sessionToken: realToken, + }) + const fixtureLogout = await requestFixture('POST', '/external/auth/session/logout', { + sessionToken: fixtureToken, + }) + expect(fixtureLogout.status).toBe(realLogout.status) + expect(bodyShape(fixtureLogout.body)).toEqual(bodyShape(realLogout.body)) + expect(realLogout.status).toBe(200) + + const realRevoked = await requestReal( + 'GET', + `/external/users/${fixtureModule.RECOVERY_USERS.logout.id}/me?teamId=`, + { sessionToken: realToken } + ) + const fixtureRevoked = await requestFixture( + 'GET', + `/external/users/${fixtureModule.RECOVERY_USERS.logout.id}/me?teamId=`, + { sessionToken: fixtureToken } + ) + expect(fixtureRevoked.status).toBe(realRevoked.status) + expect(bodyShape(fixtureRevoked.body)).toEqual(bodyShape(realRevoked.body)) + expect(realRevoked.status).toBe(401) + + await expectParity( + 'GET', + `/external/users/${fixtureModule.RECOVERY_USERS.logout.id}/me?teamId=` + ) + }) + + it('rejects a member invitation on the reset producer in both implementations', async () => { + const payload = { + email: fixtureModule.RECOVERY_USERS.usedInvitee.email, + token: 'synthetic-used-invitation', + invitationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3', + password: 'Synthetic-Fixture-Parity-123', + } + const parity = await expectParity('POST', '/external/invitations/password-token', { + body: payload, + }) + expect(parity.real.status).toBe(409) + const realRow = await db.pool.query('SELECT status FROM invitations WHERE id = $1', [ + 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3', + ]) + const fixtureRow = (await fixture.handle({ + method: 'GET', + path: '/external/invitations/token/synthetic-used-invitation', + headers: { + authorization: `Bearer ${fixtureModule.RECOVERY_CONTROL_API_SERVICE_TOKEN}`, + 'x-service-token': 'external-rest-api', + }, + })) as { body: { status: string } } + expect(realRow.rows[0].status).toBe('accepted') + expect(fixtureRow.body.status).toBe('accepted') + }) +}) diff --git a/tests/e2e/playwright/package-lock.json b/tests/e2e/playwright/package-lock.json index 3e4214b498..e93c98a603 100644 --- a/tests/e2e/playwright/package-lock.json +++ b/tests/e2e/playwright/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-playwright-tests", - "version": "0.1.83", + "version": "0.1.84", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-playwright-tests", - "version": "0.1.83", + "version": "0.1.84", "devDependencies": { "@playwright/test": "^1.50.0", "@types/node": "^22.0.0", diff --git a/tests/e2e/playwright/package.json b/tests/e2e/playwright/package.json index d3295d64a8..5117d976a2 100644 --- a/tests/e2e/playwright/package.json +++ b/tests/e2e/playwright/package.json @@ -1,6 +1,6 @@ { "name": "clerum-playwright-tests", - "version": "0.1.83", + "version": "0.1.84", "private": true, "description": "Playwright E2E tests for Control UI and Desktop App", "scripts": { diff --git a/tests/e2e/playwright/profile-ui-recovery-control-api.mjs b/tests/e2e/playwright/profile-ui-recovery-control-api.mjs new file mode 100644 index 0000000000..c2bd9d0900 --- /dev/null +++ b/tests/e2e/playwright/profile-ui-recovery-control-api.mjs @@ -0,0 +1,507 @@ +import { sign } from 'node:crypto' +import { createRequire } from 'node:module' + +const requireFromControlApi = createRequire( + new URL('../../../control-api/package.json', import.meta.url) +) +const bcrypt = requireFromControlApi('bcryptjs') + +export const RECOVERY_CONTROL_API_SERVICE_TOKEN = 'synthetic-profile-recovery-token' + +export const RECOVERY_PASSWORDS = { + recovery: 'Synthetic-Recovery-Password-123', + logout: 'Synthetic-Logout-Password-123', + replacement: 'Synthetic-Replacement-Password-123', + onboarding: 'Synthetic-Onboarding-Password-123', +} + +export const RECOVERY_USERS = { + onboarding: { + id: '11111111-1111-4111-8111-111111111111', + email: 'onboarding-member@example.invalid', + name: 'Synthetic Member', + teamId: '22222222-2222-4222-8222-222222222222', + teamName: 'Synthetic Member Team', + role: 'member', + password: null, + }, + recovery: { + id: '33333333-3333-4333-8333-333333333333', + email: 'recovery-member@example.invalid', + name: 'Synthetic Recovery Member', + teamId: null, + teamName: null, + role: 'member', + password: RECOVERY_PASSWORDS.recovery, + }, + logout: { + id: '44444444-4444-4444-8444-444444444444', + email: 'logout-member@example.invalid', + name: 'Synthetic Logout Member', + teamId: null, + teamName: null, + role: 'member', + password: RECOVERY_PASSWORDS.logout, + }, + usedInvitee: { + id: '55555555-5555-4555-8555-555555555555', + email: 'used-invitee@example.invalid', + name: 'Synthetic Used Invitee', + teamId: null, + teamName: null, + role: 'member', + password: RECOVERY_PASSWORDS.replacement, + }, +} + +export const RECOVERY_INVITATIONS = [ + { + token: 'synthetic-member-invitation', + id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1', + email: RECOVERY_USERS.onboarding.email, + purpose: 'member_invitation', + status: 'pending', + role: 'member', + teamId: RECOVERY_USERS.onboarding.teamId, + teamName: RECOVERY_USERS.onboarding.teamName, + userId: RECOVERY_USERS.onboarding.id, + expiresAt: '2030-01-01T00:00:00.000Z', + acceptedAt: null, + }, + { + token: 'synthetic-reset-proof', + id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2', + email: RECOVERY_USERS.recovery.email, + purpose: 'password_reset', + status: 'pending', + role: 'member', + teamId: null, + teamName: null, + userId: RECOVERY_USERS.recovery.id, + expiresAt: '2030-01-01T00:00:00.000Z', + acceptedAt: null, + }, + { + token: 'synthetic-used-invitation', + id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3', + email: RECOVERY_USERS.usedInvitee.email, + purpose: 'member_invitation', + status: 'accepted', + role: 'member', + teamId: null, + teamName: null, + userId: RECOVERY_USERS.usedInvitee.id, + expiresAt: '2030-01-01T00:00:00.000Z', + acceptedAt: '2026-10-01T00:00:00.000Z', + }, + { + token: 'synthetic-expired-invitation', + id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa4', + email: RECOVERY_USERS.usedInvitee.email, + purpose: 'member_invitation', + status: 'pending', + role: 'member', + teamId: null, + teamName: null, + userId: null, + expiresAt: '2020-01-01T00:00:00.000Z', + acceptedAt: null, + }, +] + +function clone(value) { + return JSON.parse(JSON.stringify(value)) +} + +function base64url(value) { + return Buffer.from(value).toString('base64url') +} + +/** + * Synthetic control-api authority for the Profile UI recovery browser lane. + * It mirrors the production routes the lane depends on and is parity-tested + * against the real Control API with real PostgreSQL. + */ +export async function createRecoveryControlApi({ + privateKey, + issuer = 'control-api', + audience = 'profile-ui', +}) { + if (!privateKey) throw new Error('Recovery control-api fixture requires a session signer') + + const users = new Map() + for (const [key, seed] of Object.entries(RECOVERY_USERS)) { + users.set(seed.id, { + key, + id: seed.id, + email: seed.email, + name: seed.name, + teamId: seed.teamId, + teamName: seed.teamName, + role: seed.role, + lifecycleVersion: 1, + passwordHash: seed.password ? await bcrypt.hash(seed.password, 12) : null, + lifecycleState: 'active', + }) + } + + const invitations = new Map( + RECOVERY_INVITATIONS.map(seed => [ + seed.token, + { + ...clone(seed), + acceptedUserId: seed.status === 'accepted' ? seed.userId : null, + }, + ]) + ) + const sessions = new Map() + const metrics = { + previewRequests: [], + acceptRequests: [], + passwordTokenRequests: [], + passwordRequests: [], + loginRequests: [], + loginSuccesses: [], + meRequests: [], + logoutRequests: [], + passwordUpdates: [], + unauthorizedMeRequests: [], + } + + function invitationPreview(invitation) { + const user = users.get(invitation.userId) || null + return { + id: invitation.id, + teamId: invitation.teamId, + teamName: invitation.teamName, + teams: + invitation.teamId && invitation.teamName + ? [{ id: invitation.teamId, name: invitation.teamName, role: invitation.role }] + : [], + email: invitation.email, + role: invitation.role, + purpose: invitation.purpose, + status: invitation.status, + expiresAt: invitation.expiresAt, + acceptedAt: invitation.acceptedAt, + userId: invitation.acceptedUserId || invitation.userId, + passwordPending: !user || !user.passwordHash, + authGeneration: user ? user.lifecycleVersion : null, + lifecycleState: user ? user.lifecycleState : null, + } + } + + function activeSession(token) { + const session = sessions.get(token) + if (!session || session.revoked) return null + const user = users.get(session.userId) + if (!user) return null + if (user.lifecycleState !== 'active' || session.authGeneration !== user.lifecycleVersion) { + return null + } + return { session, user } + } + + function issueSession(user) { + const now = Math.floor(Date.now() / 1000) + const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' })) + const payload = base64url( + JSON.stringify({ + userId: user.id, + email: user.email, + teamId: user.teamId, + role: user.role, + authGeneration: user.lifecycleVersion, + iat: now, + exp: now + 3600, + iss: issuer, + aud: audience, + }) + ) + const signed = `${header}.${payload}` + const token = `${signed}.${sign('RSA-SHA256', Buffer.from(signed), privateKey).toString('base64url')}` + sessions.set(token, { + userId: user.id, + authGeneration: user.lifecycleVersion, + revoked: false, + }) + return token + } + + function revokeUserSessions(userId) { + for (const session of sessions.values()) { + if (session.userId === userId) session.revoked = true + } + } + + function passwordHash(password) { + if (password.trim().length < 8 || password.length > 256) return null + return bcrypt.hash(password, 12) + } + + function setPassword(user, passwordHashValue, invitation) { + user.passwordHash = passwordHashValue + user.lifecycleVersion += 1 + revokeUserSessions(user.id) + if (invitation) { + invitation.status = 'accepted' + invitation.acceptedAt = new Date().toISOString() + invitation.acceptedUserId = user.id + invitation.userId = user.id + } + metrics.passwordUpdates.push({ userId: user.id, invitationId: invitation?.id || null }) + } + + function serviceAuthorized(headers) { + return String(headers.authorization || '') === `Bearer ${RECOVERY_CONTROL_API_SERVICE_TOKEN}` + } + + function sessionToken(headers) { + return String(headers['x-user-session-token'] || '').trim() + } + + async function handle({ method, path, headers = {}, body }) { + if (!serviceAuthorized(headers)) { + return { status: 401, body: { error: 'Unauthorized' } } + } + + const previewMatch = path.match(/^\/external\/invitations\/token\/([^/]+)$/) + if (method === 'GET' && previewMatch) { + const token = decodeURIComponent(previewMatch[1]) + metrics.previewRequests.push({ token }) + const invitation = invitations.get(token) + if (!invitation) { + return { status: 400, body: { error: 'invalid_invitation' } } + } + if ( + invitation.status === 'draft' || + invitation.status === 'revoked' || + (invitation.status === 'pending' && Date.parse(invitation.expiresAt) <= Date.now()) + ) { + return { status: 404, body: { error: 'not_found' } } + } + return { status: 200, body: invitationPreview(invitation) } + } + + if (method === 'POST' && path === '/external/invitations/accept') { + const token = String(body?.token || '').trim() + const email = String(body?.email || '') + .trim() + .toLowerCase() + metrics.acceptRequests.push({ token }) + const invitation = invitations.get(token) + if (!invitation) return { status: 404, body: { error: 'not_found' } } + if (invitation.email.toLowerCase() !== email) { + return { status: 403, body: { error: 'forbidden' } } + } + if (invitation.purpose === 'password_reset') { + return { status: 409, body: { error: 'not_pending' } } + } + if (invitation.status !== 'pending') { + return { status: 409, body: { error: 'not_pending' } } + } + if (Date.parse(invitation.expiresAt) <= Date.now()) { + return { status: 410, body: { error: 'expired' } } + } + const user = users.get(invitation.userId) || null + if (!user) return { status: 404, body: { error: 'not_found' } } + invitation.status = 'accepted' + invitation.acceptedAt = new Date().toISOString() + invitation.acceptedUserId = user.id + return { + status: 200, + body: { + accepted: true, + ...invitationPreview(invitation), + token: issueSession(user), + sessionContract: 'v1', + }, + } + } + + if (method === 'POST' && path === '/external/invitations/password-token') { + const token = String(body?.token || '').trim() + const email = String(body?.email || '') + .trim() + .toLowerCase() + const invitationId = String(body?.invitationId || '').trim() + const password = String(body?.password || '') + metrics.passwordTokenRequests.push({ token, invitationId }) + const invitation = invitations.get(token) + if (!invitation || invitation.id !== invitationId) { + return { status: 403, body: { error: 'forbidden' } } + } + if (invitation.email.toLowerCase() !== email || invitation.purpose !== 'password_reset') { + return { status: 409, body: { error: 'invitation_not_pending' } } + } + if (invitation.status !== 'pending') { + return { status: 409, body: { error: 'invitation_not_pending' } } + } + if (Date.parse(invitation.expiresAt) <= Date.now()) { + return { status: 410, body: { error: 'expired' } } + } + const user = users.get(invitation.userId) || null + if (!user) return { status: 404, body: { error: 'not_found' } } + const nextHash = await passwordHash(password) + if (!nextHash) return { status: 400, body: { error: 'invalid_password' } } + setPassword(user, nextHash, invitation) + return { + status: 200, + body: { + ...invitationPreview(invitation), + passwordUpdated: true, + reauthenticationRequired: true, + }, + } + } + + if (method === 'POST' && path === '/external/invitations/password') { + const userId = String(body?.userId || '').trim() + const email = String(body?.email || '') + .trim() + .toLowerCase() + const invitationId = String(body?.invitationId || '').trim() + const password = String(body?.password || '') + metrics.passwordRequests.push({ userId, invitationId }) + const session = activeSession(sessionToken(headers)) + if (!session || session.user.id !== userId || session.user.email.toLowerCase() !== email) { + return { status: 403, body: { error: 'forbidden' } } + } + const invitation = [...invitations.values()].find(row => row.id === invitationId) + if (!invitation) return { status: 404, body: { error: 'not_found' } } + if (invitation.userId !== userId || invitation.email.toLowerCase() !== email) { + return { status: 403, body: { error: 'forbidden' } } + } + if (!['member_invitation', 'admin_desktop_access'].includes(invitation.purpose)) { + return { status: 404, body: { error: 'not_found' } } + } + if (invitation.status !== 'accepted' || session.user.passwordHash) { + return { status: 409, body: { error: 'invitation_not_pending' } } + } + const nextHash = await passwordHash(password) + if (!nextHash) return { status: 400, body: { error: 'invalid_password' } } + setPassword(session.user, nextHash, invitation) + return { + status: 200, + body: { + ...invitationPreview(invitation), + passwordUpdated: true, + }, + } + } + + if (method === 'POST' && path === '/external/auth/password-login') { + const email = String(body?.email || '') + .trim() + .toLowerCase() + const password = String(body?.password || '') + metrics.loginRequests.push({ email }) + const user = [...users.values()].find(candidate => candidate.email.toLowerCase() === email) + if (!user) return { status: 401, body: { error: 'Unauthorized' } } + if (!user.passwordHash) return { status: 409, body: { error: 'password_not_set' } } + if (!(await bcrypt.compare(password, user.passwordHash))) { + return { status: 401, body: { error: 'Unauthorized' } } + } + const token = issueSession(user) + metrics.loginSuccesses.push({ userId: user.id }) + return { + status: 200, + body: { + token, + sessionContract: 'v1', + me: { + id: user.id, + email: user.email, + name: user.name, + picture: null, + teamId: user.teamId, + teamName: user.teamName, + role: user.role, + }, + }, + } + } + + const [pathname, search = ''] = path.split('?') + const meMatch = pathname.match(/^\/external\/users\/([^/]+)\/me$/) + if (method === 'GET' && meMatch) { + const session = activeSession(sessionToken(headers)) + if (!session || session.user.id !== meMatch[1]) { + metrics.unauthorizedMeRequests.push({ userId: meMatch[1] }) + return { status: 401, body: { error: 'Unauthorized' } } + } + const user = session.user + const requestedTeamId = new URLSearchParams(search).get('teamId') || '' + const memberTeam = Boolean(user.teamId) && requestedTeamId === user.teamId + metrics.meRequests.push({ userId: user.id }) + return { + status: 200, + body: { + id: user.id, + email: user.email, + name: user.name, + picture: null, + teamId: memberTeam ? user.teamId : null, + teamName: memberTeam ? user.teamName : null, + role: memberTeam ? user.role : null, + profile: { + displayName: user.name || user.email, + channels: { + emails: [], + telegramHandles: [], + slackUserNames: [], + telegramIds: [], + discordUserNames: [], + whatsappNumbers: [], + }, + }, + }, + } + } + + if (method === 'POST' && path === '/external/auth/session/logout') { + const token = sessionToken(headers) + metrics.logoutRequests.push({ hasToken: Boolean(token) }) + const session = sessions.get(token) + if (!session || session.revoked) { + return { status: 401, body: { error: 'Unauthorized' } } + } + session.revoked = true + return { status: 200, body: { revoked: true } } + } + + return { status: 404, body: { error: 'not_found' } } + } + + function snapshot() { + return clone({ + ...metrics, + users: [...users.values()].map(user => ({ + id: user.id, + email: user.email, + lifecycleVersion: user.lifecycleVersion, + passwordPending: !user.passwordHash, + })), + invitations: [...invitations.values()].map(invitation => ({ + id: invitation.id, + token: invitation.token, + purpose: invitation.purpose, + status: invitation.status, + acceptedUserId: invitation.acceptedUserId, + passwordPending: invitationPreview(invitation).passwordPending, + })), + }) + } + + return { + handle, + snapshot, + users, + invitations, + metrics, + issueSession, + sessionValid(token) { + return Boolean(activeSession(token)) + }, + } +} diff --git a/tests/e2e/playwright/profile-ui-recovery-fixture.mjs b/tests/e2e/playwright/profile-ui-recovery-fixture.mjs index c082057d4c..95d1775a83 100644 --- a/tests/e2e/playwright/profile-ui-recovery-fixture.mjs +++ b/tests/e2e/playwright/profile-ui-recovery-fixture.mjs @@ -1,8 +1,9 @@ import { spawn, spawnSync } from 'node:child_process' -import { generateKeyPairSync, sign, verify } from 'node:crypto' +import { generateKeyPairSync } from 'node:crypto' import { createServer } from 'node:http' import path from 'node:path' import { fileURLToPath } from 'node:url' +import { createRecoveryControlApi } from './profile-ui-recovery-control-api.mjs' const fixtureDirectory = path.dirname(fileURLToPath(import.meta.url)) const repositoryRoot = path.resolve(fixtureDirectory, '../../..') @@ -10,108 +11,14 @@ const profileUiRoot = path.join(repositoryRoot, 'profile-ui') const externalRestRoot = path.join(repositoryRoot, 'external-rest-api') const port = Number(process.env.PROFILE_UI_RECOVERY_PORT || 31873) const profileOrigin = `http://127.0.0.1:${port}` -const invitationToken = 'synthetic-reset-proof' -const syntheticBearer = 'Bearer synthetic-profile-recovery-token' // PUBLIC_SYNTHETIC_BEARER_FIXTURE -const contractPasswordPrefix = 'Synthetic-Recovery-Contract-' -const invitation = { - id: 'synthetic-reset-row', - teamId: null, - teamName: null, - email: 'member@example.invalid', - role: 'member', - purpose: 'password_reset', - status: 'pending', - expiresAt: '2030-01-01T00:00:00.000Z', - acceptedAt: null, - userId: 'synthetic-member-id', - passwordPending: true, -} -const member = { - id: 'synthetic-member-id', - email: invitation.email, - name: 'Synthetic Member', - picture: null, - teamId: null, - teamName: null, - role: 'member', - profile: { displayName: 'Synthetic Member', channels: {} }, -} -const ordinaryInvitations = new Map([ - [ - 'synthetic-member-invitation', - { - id: 'synthetic-member-invitation-row', - teamId: 'synthetic-member-team', - teamName: 'Synthetic Member Team', - email: 'member-onboarding@example.invalid', - role: 'member', - purpose: 'member_invitation', - status: 'pending', - expiresAt: '2030-01-01T00:00:00.000Z', - acceptedAt: null, - acceptedUserId: null, - userId: 'synthetic-member-onboarding-user', - passwordPending: true, - passwordMutationCount: 0, - }, - ], - [ - 'synthetic-admin-invitation', - { - id: 'synthetic-admin-invitation-row', - teamId: null, - teamName: null, - email: 'admin-onboarding@example.invalid', - role: 'admin', - purpose: 'admin_desktop_access', - status: 'accepted', - expiresAt: '2030-01-01T00:00:00.000Z', - acceptedAt: '2026-10-16T00:00:00.000Z', - acceptedUserId: 'synthetic-admin-onboarding-user', - userId: 'synthetic-admin-onboarding-user', - passwordPending: true, - passwordMutationCount: 0, - }, - ], - [ - 'synthetic-wrong-purpose-invitation', - { - id: 'synthetic-wrong-purpose-invitation-row', - teamId: null, - teamName: null, - email: 'wrong-purpose@example.invalid', - role: 'member', - purpose: 'member_invitation', - status: 'pending', - expiresAt: '2030-01-01T00:00:00.000Z', - acceptedAt: null, - acceptedUserId: null, - userId: 'synthetic-wrong-purpose-user', - passwordPending: true, - passwordMutationCount: 0, - }, - ], -]) -const ordinaryMetrics = { - acceptanceRequests: [], - passwordRequests: [], - resetRequests: [], - meRequests: [], -} - -let resetPosts = 0 -let authenticatedMeReads = 0 -let passwordChanged = false -let contractResetPosts = 0 -let externalRestBaseUrl -let sessionPrivateKey -let sessionPublicKey +const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }) +const controlApi = await createRecoveryControlApi({ privateKey }) +const metricsPort = Number(process.env.PROFILE_UI_RECOVERY_METRICS_PORT || 31874) -function sendJson(response, status, body, headers = {}) { +function sendJson(response, status, body) { response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', - ...headers, }) response.end(JSON.stringify(body)) } @@ -119,462 +26,65 @@ function sendJson(response, status, body, headers = {}) { async function readJson(request) { const chunks = [] for await (const chunk of request) chunks.push(chunk) - return JSON.parse(Buffer.concat(chunks).toString('utf8')) -} - -async function readBody(request) { - const chunks = [] - for await (const chunk of request) chunks.push(chunk) - return Buffer.concat(chunks) -} - -function sessionTokenFor(invitation) { - if (!sessionPrivateKey) throw new Error('Synthetic session signer is unavailable') - const now = Math.floor(Date.now() / 1000) - const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url') - const payload = Buffer.from( - JSON.stringify({ - userId: invitation.userId, - email: invitation.email, - teamId: invitation.teamId, - role: invitation.role, - authGeneration: 1, - iat: now, - exp: now + 3600, - iss: 'control-api', - aud: 'profile-ui', - }) - ).toString('base64url') - const signed = `${header}.${payload}` - return `${signed}.${sign('RSA-SHA256', Buffer.from(signed), sessionPrivateKey).toString('base64url')}` -} - -function sessionClaims(token) { - if (!sessionPublicKey || typeof token !== 'string') return null - const [header, payload, signature] = token.split('.') - if (!header || !payload || !signature) return null - const signed = `${header}.${payload}` - if ( - !verify( - 'RSA-SHA256', - Buffer.from(signed), - sessionPublicKey, - Buffer.from(signature, 'base64url') - ) - ) { - return null - } - try { - const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) - if ( - claims.iss !== 'control-api' || - claims.aud !== 'profile-ui' || - typeof claims.exp !== 'number' || - claims.exp <= Math.floor(Date.now() / 1000) - ) { - return null - } - return claims - } catch { - return null - } -} - -function invitationForToken(token) { - return ordinaryInvitations.get(token) || null -} - -function ordinarySnapshot() { - return { - operations: ordinaryMetrics, - invitations: [...ordinaryInvitations.values()].map( - ({ - id, - purpose, - status, - userId, - acceptedUserId, - passwordPending, - passwordMutationCount, - }) => ({ - id, - purpose, - status, - userId, - acceptedUserId, - passwordPending, - passwordMutationCount, - }) - ), - } -} - -async function proxyExternalRest(request, response, path, bodyOverride) { - try { - const headers = {} - for (const name of ['content-type', 'cookie', 'authorization', 'origin', 'sec-fetch-site']) { - if (request.headers[name]) headers[name] = request.headers[name] - } - headers['x-forwarded-for'] = request.headers['x-forwarded-for'] || '192.0.2.48' - const body = - bodyOverride !== undefined - ? Buffer.from(JSON.stringify(bodyOverride)) - : ['GET', 'HEAD'].includes(request.method || 'GET') - ? undefined - : await readBody(request) - const upstream = await fetch(`${externalRestBaseUrl}${path}`, { - method: request.method, - headers, - body, - redirect: 'manual', - }) - const responseHeaders = { - 'content-type': upstream.headers.get('content-type') || 'application/json', - } - const cookies = upstream.headers.getSetCookie?.() || [] - if (cookies.length > 0) responseHeaders['set-cookie'] = cookies - else if (upstream.headers.get('set-cookie')) { - responseHeaders['set-cookie'] = upstream.headers.get('set-cookie') - } - response.writeHead(upstream.status, responseHeaders) - response.end(await upstream.text()) - } catch { - sendJson(response, 502, { error: 'synthetic_external_rest_unavailable' }) - } + const text = Buffer.concat(chunks).toString('utf8') + return text.trim() ? JSON.parse(text) : {} } -const apiServer = createServer(async (request, response) => { +const controlApiServer = createServer(async (request, response) => { const url = new URL(request.url || '/', 'http://127.0.0.1') - - const publicInvitationMatch = url.pathname.match(/^\/api\/v1\/invitations\/token\/([^/]+)$/) - if (request.method === 'GET' && publicInvitationMatch) { - const token = decodeURIComponent(publicInvitationMatch[1]) - if (invitationForToken(token)) { - await proxyExternalRest(request, response, `${url.pathname}${url.search}`) - return - } - } - - if ( - request.method === 'POST' && - ['/api/v1/invitations/accept', '/api/v1/invitations/password'].includes(url.pathname) - ) { - let body - try { - body = await readJson(request) - } catch { - sendJson(response, 400, { error: 'invalid_payload' }) - return - } - if (invitationForToken(String(body.token || ''))) { - await proxyExternalRest(request, response, `${url.pathname}${url.search}`, body) - return - } - } - - if (request.method === 'GET' && url.pathname === `/api/v1/invitations/token/${invitationToken}`) { - sendJson(response, 200, invitation) - return - } - - if (request.method === 'POST' && url.pathname === '/api/v1/invitations/password-reset') { - let body - try { - body = await readJson(request) - } catch { - sendJson(response, 400, { error: 'invalid_payload' }) - return - } - if (typeof body.password === 'string' && body.password.startsWith(contractPasswordPrefix)) { - contractResetPosts += 1 - const ipSuffix = String((contractResetPosts % 200) + 20) - try { - const upstream = await fetch(`${externalRestBaseUrl}/api/v1/invitations/password-reset`, { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'x-forwarded-for': `198.51.100.${ipSuffix}`, - }, - body: JSON.stringify(body), - }) - const text = await upstream.text() - response.writeHead(upstream.status, { - 'content-type': upstream.headers.get('content-type') || 'application/json', - ...(upstream.headers.get('set-cookie') - ? { 'set-cookie': upstream.headers.get('set-cookie') } - : {}), - }) - response.end(text) - } catch { - sendJson(response, 502, { error: 'synthetic_external_rest_unavailable' }) - } - return - } - if (invitationForToken(String(body.token || ''))) { - ordinaryMetrics.resetRequests.push({ - id: invitationForToken(String(body.token)).id, - }) - await proxyExternalRest(request, response, `${url.pathname}${url.search}`, body) - return - } - resetPosts += 1 - if ( - body.token !== invitationToken || - body.email !== invitation.email || - body.invitationId !== invitation.id || - typeof body.password !== 'string' || - body.password.length < 8 - ) { - sendJson(response, 400, { error: 'invalid_invitation' }) - return - } - if (passwordChanged) { - sendJson(response, 409, { error: 'invitation_not_pending' }) - return - } - passwordChanged = true - sendJson( - response, - 200, - { ...invitation, status: 'accepted', passwordPending: false, passwordUpdated: true }, - { - 'set-cookie': 'profile_session=synthetic-recovered-session; HttpOnly; Path=/; SameSite=Lax', - } - ) - return - } - - if (request.method === 'GET' && url.pathname === '/api/v1/me') { - const cookie = String(request.headers.cookie || '') - if (cookie.includes('profile_session=synthetic-recovered-session')) { - authenticatedMeReads += 1 - if (authenticatedMeReads === 1) { - sendJson(response, 404, { error: 'User not found' }) - return - } - sendJson(response, 200, member) - return - } - if (!cookie.includes('profile_session=')) { - sendJson(response, 401, { error: 'Unauthorized' }) - return - } - await proxyExternalRest(request, response, `${url.pathname}${url.search}`) + if (url.pathname === '/__test/metrics' && request.method === 'GET') { + sendJson(response, 200, controlApi.snapshot()) return } - - if (request.method === 'GET' && url.pathname === '/__test/metrics') { - sendJson(response, 200, { - resetPosts, - authenticatedMeReads, - passwordChanged, - contractResetPosts, - ordinary: ordinarySnapshot(), - }) - return - } - - const invitationTokenMatch = url.pathname.match( - /^\/api\/v1\/external\/invitations\/token\/([^/]+)$/ - ) - if (request.method === 'GET' && invitationTokenMatch) { - if (request.headers.authorization !== syntheticBearer) { - sendJson(response, 401, { error: 'Unauthorized' }) - return - } - const token = decodeURIComponent(invitationTokenMatch[1]) - const invited = invitationForToken(token) - if (!invited) { - sendJson(response, 404, { error: 'not_found' }) - return - } - sendJson(response, 200, invited) - return - } - - if (request.method === 'POST' && url.pathname === '/api/v1/external/invitations/accept') { - let body - try { - body = await readJson(request) - } catch { - sendJson(response, 400, { error: 'invalid_payload' }) - return - } - const invited = invitationForToken(String(body.token || '')) - if (request.headers.authorization !== syntheticBearer) { - sendJson(response, 401, { error: 'Unauthorized' }) - return - } - if (!invited || String(body.email || '').toLowerCase() !== invited.email.toLowerCase()) { - sendJson(response, 403, { error: 'forbidden' }) - return - } - if (invited.purpose === 'password_reset' || invited.status === 'revoked') { - sendJson(response, 400, { error: 'not_pending' }) - return - } - if (invited.acceptedUserId && invited.acceptedUserId !== invited.userId) { - sendJson(response, 403, { error: 'forbidden' }) - return - } - invited.status = 'accepted' - invited.acceptedAt ||= new Date().toISOString() - invited.acceptedUserId = invited.userId - ordinaryMetrics.acceptanceRequests.push({ id: invited.id, purpose: invited.purpose }) - sendJson(response, 200, { - accepted: true, - ...invited, - token: sessionTokenFor(invited), - }) + if (!url.pathname.startsWith('/api/v1/')) { + sendJson(response, 404, { error: 'not_found' }) return } - - if (request.method === 'POST' && url.pathname === '/api/v1/external/invitations/password') { - let body + let body + if (request.method === 'POST') { try { body = await readJson(request) } catch { sendJson(response, 400, { error: 'invalid_payload' }) return } - if (request.headers.authorization !== syntheticBearer) { - sendJson(response, 401, { error: 'Unauthorized' }) - return - } - const claims = sessionClaims(String(request.headers['x-user-session-token'] || '')) - const selected = [...ordinaryInvitations.values()].find(item => item.id === body.invitationId) - if ( - !claims || - !selected || - claims.userId !== selected.userId || - claims.email !== selected.email || - body.userId !== selected.userId || - String(body.email || '').toLowerCase() !== selected.email.toLowerCase() || - selected.acceptedUserId !== selected.userId - ) { - sendJson(response, 403, { error: 'forbidden' }) - return - } - if ( - selected.purpose === 'password_reset' || - !['member_invitation', 'admin_desktop_access'].includes(selected.purpose) - ) { - sendJson(response, 404, { error: 'not_found' }) - return - } - if ( - selected.status !== 'accepted' || - !selected.passwordPending || - selected.passwordMutationCount - ) { - sendJson(response, 409, { error: 'invitation_not_pending' }) - return - } - if (typeof body.password !== 'string' || body.password.trim().length < 8) { - sendJson(response, 400, { error: 'invalid_password' }) - return - } - selected.passwordMutationCount += 1 - selected.passwordPending = false - ordinaryMetrics.passwordRequests.push({ id: selected.id, purpose: selected.purpose }) - sendJson(response, 200, { ...selected, passwordUpdated: true }) - return } - - const meMatch = url.pathname.match(/^\/api\/v1\/external\/users\/([^/]+)\/me$/) - if (request.method === 'GET' && meMatch) { - if (request.headers.authorization !== syntheticBearer) { - sendJson(response, 401, { error: 'Unauthorized' }) - return - } - const claims = sessionClaims(String(request.headers['x-user-session-token'] || '')) - const invited = [...ordinaryInvitations.values()].find(item => item.userId === meMatch[1]) - if (!claims || !invited || claims.userId !== invited.userId || claims.email !== invited.email) { - sendJson(response, 403, { error: 'forbidden' }) - return - } - ordinaryMetrics.meRequests.push({ id: invited.id, userId: claims.userId }) - sendJson(response, 200, { - id: invited.userId, - email: invited.email, - name: invited.role === 'admin' ? 'Synthetic Admin' : 'Synthetic Member', - picture: null, - teamId: invited.teamId, - teamName: invited.teamName, - role: invited.role, - profile: { displayName: invited.role === 'admin' ? 'Synthetic Admin' : 'Synthetic Member' }, + try { + const result = await controlApi.handle({ + method: request.method, + path: url.pathname.slice('/api/v1'.length), + headers: request.headers, + body, }) - return + sendJson(response, result.status, result.body) + } catch { + sendJson(response, 500, { error: 'internal_error' }) } +}) - if (request.method === 'POST' && url.pathname === '/api/v1/external/invitations/password-token') { - let body - try { - body = await readJson(request) - } catch { - sendJson(response, 400, { error: 'invalid_payload' }) - return - } - const ordinaryInvitation = invitationForToken(String(body.token || '')) - if (ordinaryInvitation) { - ordinaryMetrics.resetRequests.push({ id: ordinaryInvitation.id }) - if (body.email !== ordinaryInvitation.email || body.invitationId !== ordinaryInvitation.id) { - sendJson(response, 403, { error: 'forbidden' }) - } else { - sendJson(response, 409, { error: 'invitation_not_pending' }) - } - return - } - if ( - request.headers.authorization !== syntheticBearer || - body.token !== invitationToken || - body.email !== invitation.email || - body.invitationId !== invitation.id || - typeof body.password !== 'string' || - !body.password.startsWith(contractPasswordPrefix) || - body.password.trim().length < 8 - ) { - sendJson(response, 400, { error: 'invalid_payload' }) - return - } - const outcomes = { - invalid_password: [400, 'invalid_password'], - invalid_invitation: [400, 'invalid_invitation'], - forbidden: [403, 'forbidden'], - expired: [410, 'expired'], - not_pending: [409, 'invitation_not_pending'], - rate_limited: [429, 'rate_limited'], - authority_unavailable: [503, 'authority_unavailable'], - recovery_outcome_unknown: [503, 'recovery_outcome_unknown'], - internal_error: [500, 'internal_error'], - unknown_500: [500, 'unclassified_failure'], - } - const scenario = body.password - .slice(contractPasswordPrefix.length) - .match(/^(.+)-Password-123$/)?.[1] - const outcome = outcomes[String(scenario || '')] - if (outcome) { - sendJson(response, outcome[0], { error: outcome[1] }) - return - } - sendJson(response, 404, { error: 'not_found' }) +await new Promise((resolve, reject) => { + controlApiServer.once('error', reject) + controlApiServer.listen(0, '127.0.0.1', resolve) +}) + +const metricsServer = createServer((request, response) => { + const url = new URL(request.url || '/', 'http://127.0.0.1') + if (request.method === 'GET' && url.pathname === '/__test/metrics') { + sendJson(response, 200, controlApi.snapshot()) return } - sendJson(response, 404, { error: 'not_found' }) }) - await new Promise((resolve, reject) => { - apiServer.once('error', reject) - apiServer.listen(0, '127.0.0.1', resolve) + metricsServer.once('error', reject) + metricsServer.listen(metricsPort, '127.0.0.1', resolve) }) +const controlApiAddress = controlApiServer.address() +if (!controlApiAddress || typeof controlApiAddress === 'string') { + throw new Error('Fixture Control API did not bind TCP') +} +const controlApiBaseUrl = `http://127.0.0.1:${controlApiAddress.port}/api/v1` -const apiAddress = apiServer.address() -if (!apiAddress || typeof apiAddress === 'string') throw new Error('Fixture API did not bind TCP') - -const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }) -sessionPrivateKey = privateKey -sessionPublicKey = publicKey const externalRestProcess = spawn( process.execPath, [path.join(externalRestRoot, 'test/helpers/passwordRecoveryHttpServer.mjs')], @@ -582,8 +92,9 @@ const externalRestProcess = spawn( cwd: externalRestRoot, env: { NODE_ENV: 'development', - EXTERNAL_REST_API_CONTROL_API_BASE_URL: `http://127.0.0.1:${apiAddress.port}/api/v1`, + EXTERNAL_REST_API_CONTROL_API_BASE_URL: controlApiBaseUrl, EXTERNAL_REST_API_CONTROL_API_SERVICE_TOKEN: 'synthetic-profile-recovery-token', + EXTERNAL_REST_API_CONTROL_API_SERVICE_NAME: 'external-rest-api', EXTERNAL_REST_API_JWT_PUBLIC_KEY: publicKey .export({ type: 'spki', format: 'pem' }) .toString(), @@ -593,7 +104,7 @@ const externalRestProcess = spawn( } ) -externalRestBaseUrl = await new Promise((resolve, reject) => { +const externalRestBaseUrl = await new Promise((resolve, reject) => { let output = '' const timeout = setTimeout(() => reject(new Error('External REST fixture did not start')), 15_000) externalRestProcess.stdout.setEncoding('utf8') @@ -637,7 +148,7 @@ const nextProcess = spawn( cwd: profileUiRoot, env: { ...nextEnvironment, - EXTERNAL_REST_API_INTERNAL_URL: `http://127.0.0.1:${apiAddress.port}`, + EXTERNAL_REST_API_INTERNAL_URL: externalRestBaseUrl, NEXT_TELEMETRY_DISABLED: '1', NODE_ENV: 'development', }, @@ -708,9 +219,11 @@ async function stopChild(child) { async function shutdown(signal) { if (shuttingDown) return shuttingDown = true - apiServer.closeAllConnections() + controlApiServer.closeAllConnections() + metricsServer.closeAllConnections() await Promise.all([stopChild(nextProcess), stopChild(externalRestProcess)]) - await new Promise(resolve => apiServer.close(resolve)) + await new Promise(resolve => controlApiServer.close(resolve)) + await new Promise(resolve => metricsServer.close(resolve)) } process.on('SIGINT', () => void shutdown('SIGINT')) diff --git a/tests/e2e/playwright/profile-ui-recovery.config.ts b/tests/e2e/playwright/profile-ui-recovery.config.ts index fbcb004947..f116824497 100644 --- a/tests/e2e/playwright/profile-ui-recovery.config.ts +++ b/tests/e2e/playwright/profile-ui-recovery.config.ts @@ -3,6 +3,7 @@ import path from 'node:path' const port = Number(process.env.PROFILE_UI_RECOVERY_PORT || 31873) const baseURL = `http://127.0.0.1:${port}` +const metricsPort = Number(process.env.PROFILE_UI_RECOVERY_METRICS_PORT || 31874) export default defineConfig({ testDir: path.join(__dirname, 'profile-ui-recovery'), @@ -25,6 +26,9 @@ export default defineConfig({ url: `${baseURL}/invitations/synthetic-reset-proof`, reuseExistingServer: false, timeout: 120_000, - env: { PROFILE_UI_RECOVERY_PORT: String(port) }, + env: { + PROFILE_UI_RECOVERY_PORT: String(port), + PROFILE_UI_RECOVERY_METRICS_PORT: String(metricsPort), + }, }, }) diff --git a/tests/e2e/playwright/profile-ui-recovery/j1-invitation-onboarding.spec.ts b/tests/e2e/playwright/profile-ui-recovery/j1-invitation-onboarding.spec.ts new file mode 100644 index 0000000000..3fe931b419 --- /dev/null +++ b/tests/e2e/playwright/profile-ui-recovery/j1-invitation-onboarding.spec.ts @@ -0,0 +1,91 @@ +import { expect, test } from '@playwright/test' +import { + ONBOARDING_MEMBER, + readControlApiMetrics, + sessionCookieValue, + signInThroughUi, +} from './journeys' + +test('J1 invitation onboarding ends in guided sign-in and a real login', async ({ page }) => { + const before = await readControlApiMetrics(page) + expect(before.meRequests.filter(entry => entry.userId === ONBOARDING_MEMBER.id)).toEqual([]) + + // E2E_GUARDIAN_NAVIGATION: email-reset-entry — the synthetic emailed invitation link + await page.goto('/invitations/synthetic-member-invitation') + + await expect(page.getByRole('heading', { name: 'Join Synthetic Member Team team' })).toBeVisible() + await expect(page.getByText('onboarding-member@example.invalid', { exact: true })).toBeVisible() + await expect(page.getByText('Synthetic Member Team', { exact: true })).toBeVisible() + + const acceptResponse = page.waitForResponse( + response => + response.url().endsWith('/api/v1/invitations/accept') && + response.request().method() === 'POST' + ) + await page.getByRole('button', { name: 'Accept invitation' }).click() + expect((await acceptResponse).status()).toBe(200) + await expect( + page.getByText('Set your password to finish joining Synthetic Member Team.') + ).toBeVisible() + + // Acceptance still issues the invitation session. Setting the password must + // end it, so the witness is a real cookie before this transition. + expect(await sessionCookieValue(page)).toBeTruthy() + + await page.getByLabel('Password', { exact: true }).fill(ONBOARDING_MEMBER.password) + await page.getByLabel('Confirm password').fill(ONBOARDING_MEMBER.password) + const passwordResponse = page.waitForResponse( + response => + response.url().endsWith('/api/v1/invitations/password') && + response.request().method() === 'POST' + ) + await page.getByRole('button', { name: 'Set password and continue' }).click() + const passwordResult = await passwordResponse + expect(passwordResult.status()).toBe(200) + + await expect(page.getByRole('heading', { name: 'Password updated' })).toBeVisible() + await expect( + page.getByText('Your password has been updated. Sign in with your new password to continue.') + ).toBeVisible() + expect(await sessionCookieValue(page)).toBeNull() + + const afterPassword = await readControlApiMetrics(page) + expect(afterPassword.passwordUpdates).toContainEqual({ + userId: ONBOARDING_MEMBER.id, + invitationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1', + }) + expect(afterPassword.meRequests.filter(entry => entry.userId === ONBOARDING_MEMBER.id)).toEqual( + [] + ) + + const loginLink = page.getByRole('link', { name: 'Sign in' }) + await expect(loginLink).toHaveAttribute( + 'href', + `/?email=${encodeURIComponent(ONBOARDING_MEMBER.email)}` + ) + await loginLink.click() + + await expect(page.getByLabel('Email', { exact: true })).toHaveValue(ONBOARDING_MEMBER.email) + await signInThroughUi(page, ONBOARDING_MEMBER.email, ONBOARDING_MEMBER.password) + + await expect(page.getByRole('heading', { name: 'Welcome, Synthetic Member' })).toBeVisible() + await expect(page.getByText('onboarding-member@example.invalid')).toBeVisible() + + const afterLogin = await readControlApiMetrics(page) + expect(afterLogin.loginSuccesses).toContainEqual({ userId: ONBOARDING_MEMBER.id }) + expect(afterLogin.meRequests).toContainEqual({ userId: ONBOARDING_MEMBER.id }) + + const identity = await page.evaluate(async () => { + const response = await fetch('/external-rest-api/api/v1/me') + return { status: response.status, body: await response.json() } + }) + expect(identity).toMatchObject({ + status: 200, + body: { + id: ONBOARDING_MEMBER.id, + email: ONBOARDING_MEMBER.email, + teamId: ONBOARDING_MEMBER.teamId, + role: 'member', + }, + }) +}) diff --git a/tests/e2e/playwright/profile-ui-recovery/j2-password-recovery.spec.ts b/tests/e2e/playwright/profile-ui-recovery/j2-password-recovery.spec.ts new file mode 100644 index 0000000000..8bd12fead9 --- /dev/null +++ b/tests/e2e/playwright/profile-ui-recovery/j2-password-recovery.spec.ts @@ -0,0 +1,82 @@ +import { expect, test } from '@playwright/test' +import { + RECOVERY_MEMBER, + fetchMeWithCookie, + readControlApiMetrics, + readLogoutFence, + seedLogoutFence, + sessionCookieValue, + signInThroughUi, +} from './journeys' + +test('J2 recovery link clears the logout fence, revokes the prior session, and leads to login', async ({ + page, +}) => { + // E2E_GUARDIAN_NAVIGATION: root-entry — the ordinary login that owns the prior session + await page.goto('/') + await signInThroughUi(page, RECOVERY_MEMBER.email, RECOVERY_MEMBER.password) + await expect( + page.getByRole('heading', { name: 'Welcome, Synthetic Recovery Member' }) + ).toBeVisible() + + const priorSession = await sessionCookieValue(page) + expect(priorSession).toBeTruthy() + if (!priorSession) throw new Error('login did not set a profile session') + const validBefore = await fetchMeWithCookie(page, priorSession) + expect(validBefore.status).toBe(200) + expect(validBefore.body).toMatchObject({ id: RECOVERY_MEMBER.id }) + + await test.step('logout fence precondition: PROFILE_LOGOUT_UNCONFIRMED_KEY from a previous logout', async () => { + await seedLogoutFence(page) + expect(await readLogoutFence(page)).toBe('true') + }) + + // E2E_GUARDIAN_NAVIGATION: email-reset-entry — the synthetic emailed recovery link + await page.goto('/invitations/synthetic-reset-proof') + await expect(page.getByRole('heading', { name: 'Reset password' })).toBeVisible() + + // The recovery link must clear the fence before the form can reach the backend. + await expect.poll(() => readLogoutFence(page)).toBeNull() + + await page.getByLabel('Password', { exact: true }).fill(RECOVERY_MEMBER.replacementPassword) + await page.getByLabel('Confirm password').fill(RECOVERY_MEMBER.replacementPassword) + const resetResponse = page.waitForResponse( + response => + response.url().endsWith('/api/v1/invitations/password-reset') && + response.request().method() === 'POST' + ) + await page.getByRole('button', { name: 'Reset password' }).click() + const resetResult = await resetResponse + expect(resetResult.status()).toBe(200) + + await expect(page.getByRole('heading', { name: 'Password updated' })).toBeVisible() + expect(await sessionCookieValue(page)).toBeNull() + + const revoked = await fetchMeWithCookie(page, priorSession) + expect(revoked.status).toBe(401) + + await page.getByRole('link', { name: 'Sign in' }).click() + await expect(page.getByLabel('Email', { exact: true })).toHaveValue(RECOVERY_MEMBER.email) + await signInThroughUi(page, RECOVERY_MEMBER.email, RECOVERY_MEMBER.replacementPassword) + + await expect( + page.getByRole('heading', { name: 'Welcome, Synthetic Recovery Member' }) + ).toBeVisible() + const freshSession = await sessionCookieValue(page) + expect(freshSession).toBeTruthy() + if (!freshSession) throw new Error('fresh login did not set a profile session') + expect(freshSession).not.toBe(priorSession) + const validAfter = await fetchMeWithCookie(page, freshSession) + expect(validAfter.status).toBe(200) + expect(validAfter.body).toMatchObject({ id: RECOVERY_MEMBER.id }) + + const metrics = await readControlApiMetrics(page) + expect(metrics.passwordUpdates).toContainEqual({ + userId: RECOVERY_MEMBER.id, + invitationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2', + }) + expect(metrics.meRequests).toContainEqual({ userId: RECOVERY_MEMBER.id }) + expect(metrics.loginSuccesses.filter(entry => entry.userId === RECOVERY_MEMBER.id)).toHaveLength( + 2 + ) +}) diff --git a/tests/e2e/playwright/profile-ui-recovery/j3-logout.spec.ts b/tests/e2e/playwright/profile-ui-recovery/j3-logout.spec.ts new file mode 100644 index 0000000000..ef2413f782 --- /dev/null +++ b/tests/e2e/playwright/profile-ui-recovery/j3-logout.spec.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test' +import { + LOGOUT_MEMBER, + fetchMeWithCookie, + readControlApiMetrics, + sessionCookieValue, + signInThroughUi, +} from './journeys' + +test('J3 logout returns to login and the previous session is rejected', async ({ page }) => { + // E2E_GUARDIAN_NAVIGATION: root-entry — the ordinary login that owns the session + await page.goto('/') + await signInThroughUi(page, LOGOUT_MEMBER.email, LOGOUT_MEMBER.password) + await expect( + page.getByRole('heading', { name: 'Welcome, Synthetic Logout Member' }) + ).toBeVisible() + + const priorSession = await sessionCookieValue(page) + expect(priorSession).toBeTruthy() + if (!priorSession) throw new Error('login did not set a profile session') + + const logoutResponse = page.waitForResponse( + response => + response.url().endsWith('/api/v1/auth/logout') && response.request().method() === 'POST' + ) + await page.locator('aside').getByRole('button', { name: 'Log out' }).click() + const logoutResult = await logoutResponse + expect(logoutResult.status()).toBe(200) + + await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Welcome, Synthetic Logout Member' })).toHaveCount( + 0 + ) + expect(await sessionCookieValue(page)).toBeNull() + + const oldSession = await fetchMeWithCookie(page, priorSession) + expect(oldSession.status).toBe(401) + + await page.goto('/') + await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Welcome, Synthetic Logout Member' })).toHaveCount( + 0 + ) + + const metrics = await readControlApiMetrics(page) + expect(metrics.logoutRequests).toContainEqual({ hasToken: true }) + expect(metrics.loginSuccesses).toContainEqual({ userId: LOGOUT_MEMBER.id }) +}) diff --git a/tests/e2e/playwright/profile-ui-recovery/journeys.ts b/tests/e2e/playwright/profile-ui-recovery/journeys.ts new file mode 100644 index 0000000000..bc838e9585 --- /dev/null +++ b/tests/e2e/playwright/profile-ui-recovery/journeys.ts @@ -0,0 +1,79 @@ +import { type Page, expect } from '@playwright/test' + +export const LOGOUT_FENCE_KEY = 'profile_logout_unconfirmed' +export const RECOVERY_MEMBER = { + id: '33333333-3333-4333-8333-333333333333', + email: 'recovery-member@example.invalid', + password: 'Synthetic-Recovery-Password-123', + replacementPassword: 'Synthetic-Replacement-Password-123', +} +export const LOGOUT_MEMBER = { + id: '44444444-4444-4444-8444-444444444444', + email: 'logout-member@example.invalid', + password: 'Synthetic-Logout-Password-123', +} +export const ONBOARDING_MEMBER = { + id: '11111111-1111-4111-8111-111111111111', + email: 'onboarding-member@example.invalid', + password: 'Synthetic-Onboarding-Password-123', + teamId: '22222222-2222-4222-8222-222222222222', + teamName: 'Synthetic Member Team', +} + +type ControlApiMetrics = { + meRequests: Array<{ userId: string }> + loginSuccesses: Array<{ userId: string }> + passwordUpdates: Array<{ userId: string; invitationId: string | null }> + logoutRequests: Array<{ hasToken: boolean }> + unauthorizedMeRequests: Array<{ userId: string }> + invitations: Array<{ + id: string + token: string + purpose: string + status: string + acceptedUserId: string | null + passwordPending: boolean + }> +} + +export async function readControlApiMetrics(page: Page): Promise { + const port = process.env.PROFILE_UI_RECOVERY_METRICS_PORT || '31874' + const response = await page.request.get(`http://127.0.0.1:${port}/__test/metrics`) + expect(response.status()).toBe(200) + return (await response.json()) as ControlApiMetrics +} + +export async function sessionCookieValue(page: Page): Promise { + const cookies = await page.context().cookies() + return cookies.find(cookie => cookie.name === 'profile_session')?.value || null +} + +export async function signInThroughUi(page: Page, email: string, password: string) { + await page.getByLabel('Email', { exact: true }).fill(email) + await page.getByLabel('Password', { exact: true }).fill(password) + const loginResponse = page.waitForResponse( + response => + response.url().endsWith('/api/v1/auth/password-login') && + response.request().method() === 'POST' + ) + await page.getByRole('button', { name: 'Sign in', exact: true }).click() + expect((await loginResponse).status()).toBe(200) +} + +export async function seedLogoutFence(page: Page): Promise { + await page.evaluate(key => window.localStorage.setItem(key, 'true'), LOGOUT_FENCE_KEY) +} + +export async function readLogoutFence(page: Page): Promise { + return page.evaluate(key => window.localStorage.getItem(key), LOGOUT_FENCE_KEY) +} + +export async function fetchMeWithCookie(page: Page, cookieValue: string) { + const response = await page.request.get('/external-rest-api/api/v1/me', { + headers: { cookie: `profile_session=${cookieValue}` }, + }) + return { + status: response.status(), + body: response.ok() ? await response.json() : await response.json().catch(() => null), + } +} diff --git a/tests/e2e/playwright/profile-ui-recovery/negative-routes.spec.ts b/tests/e2e/playwright/profile-ui-recovery/negative-routes.spec.ts new file mode 100644 index 0000000000..f13a7a1358 --- /dev/null +++ b/tests/e2e/playwright/profile-ui-recovery/negative-routes.spec.ts @@ -0,0 +1,68 @@ +import { expect, test } from '@playwright/test' +import { readControlApiMetrics, sessionCookieValue } from './journeys' + +test('negative routes show an explicit error and never issue a session', async ({ page }) => { + const before = await readControlApiMetrics(page) + + const expiredDocument = page.waitForResponse(response => + response.url().endsWith('/invitations/synthetic-expired-invitation') + ) + // E2E_GUARDIAN_NAVIGATION: negative-route-guard — expired token is hidden by the authority + await page.goto('/invitations/synthetic-expired-invitation') + expect((await expiredDocument).status()).toBe(200) + await expect(page.getByText('Invitation not found.', { exact: true })).toBeVisible() + await expect(page.getByLabel('Password', { exact: true })).toHaveCount(0) + expect(await sessionCookieValue(page)).toBeNull() + + // E2E_GUARDIAN_NAVIGATION: negative-route-guard — a redeemed invitation stays visible as used + await page.goto('/invitations/synthetic-used-invitation') + await expect( + page.getByText('This invitation has already been used.', { exact: true }) + ).toBeVisible() + await expect(page.getByLabel('Password', { exact: true })).toHaveCount(0) + await expect(page.getByRole('link', { name: 'Sign in' })).toBeVisible() + expect(await sessionCookieValue(page)).toBeNull() + + // E2E_GUARDIAN_NAVIGATION: negative-route-guard — an invented token does not resolve + await page.goto('/invitations/synthetic-invented-token') + await expect(page.getByText('Invalid invitation.', { exact: true })).toBeVisible() + await expect(page.getByLabel('Password', { exact: true })).toHaveCount(0) + expect(await sessionCookieValue(page)).toBeNull() + + const after = await readControlApiMetrics(page) + expect(after.passwordTokenRequests).toEqual(before.passwordTokenRequests) + expect(after.loginSuccesses).toEqual(before.loginSuccesses) + expect(after.invitations).toContainEqual( + expect.objectContaining({ + id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3', + purpose: 'member_invitation', + status: 'accepted', + passwordPending: false, + }) + ) + expect(after.invitations).toContainEqual( + expect.objectContaining({ + id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa4', + purpose: 'member_invitation', + status: 'pending', + passwordPending: true, + }) + ) +}) + +test('direct access to the authenticated home without login shows the login form', async ({ + page, +}) => { + const homeSession = page.waitForResponse( + response => response.url().endsWith('/api/v1/me') && response.request().method() === 'GET' + ) + await page.goto('/') + expect((await homeSession).status()).toBe(401) + await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeVisible() + await expect(page.getByRole('heading', { name: /Welcome,/ })).toHaveCount(0) + await expect(page.getByRole('heading', { name: 'Password updated' })).toHaveCount(0) + expect(await sessionCookieValue(page)).toBeNull() + + const me = await page.request.get('/external-rest-api/api/v1/me') + expect(me.status()).toBe(401) +}) diff --git a/tests/e2e/playwright/profile-ui-recovery/ordinary-invitation-onboarding.spec.ts b/tests/e2e/playwright/profile-ui-recovery/ordinary-invitation-onboarding.spec.ts deleted file mode 100644 index f7f1aa5fb1..0000000000 --- a/tests/e2e/playwright/profile-ui-recovery/ordinary-invitation-onboarding.spec.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { expect, test } from '@playwright/test' - -async function readMetrics(page: import('@playwright/test').Page) { - const response = await page.request.get('/external-rest-api/__test/metrics') - expect(response.status()).toBe(200) - return response.json() -} - -test('member accepts the prepared invitation and establishes its own password', async ({ - page, -}) => { - // E2E_GUARDIAN_NAVIGATION: email-reset-entry — synthetic emailed invitation entry - await page.goto('/invitations/synthetic-member-invitation') - - await expect(page.getByRole('heading', { name: 'Join Synthetic Member Team team' })).toBeVisible() - await expect(page.getByText('member-onboarding@example.invalid', { exact: true })).toBeVisible() - await expect(page.getByText('Synthetic Member Team', { exact: true })).toBeVisible() - await expect(page.getByText('Participant', { exact: true })).toBeVisible() - - const acceptPromise = page.waitForResponse( - response => - response.url().endsWith('/api/v1/invitations/accept') && - response.request().method() === 'POST' - ) - await page.getByRole('button', { name: 'Accept invitation' }).click() - const accepted = await acceptPromise - expect(accepted.status()).toBe(200) - await expect( - page.getByText('Set your password to finish joining Synthetic Member Team.') - ).toBeVisible() - - await page.getByLabel('Password', { exact: true }).fill('Synthetic-Member-Onboarding-123') - await page.getByLabel('Confirm password').fill('Synthetic-Member-Onboarding-123') - const passwordResponsePromise = page.waitForResponse( - response => - /\/api\/v1\/invitations\/password(?:-reset)?$/.test(response.url()) && - response.request().method() === 'POST' - ) - await page.getByRole('button', { name: 'Set password and continue' }).click() - const passwordResponse = await passwordResponsePromise - expect(new URL(passwordResponse.url()).pathname).toBe( - '/external-rest-api/api/v1/invitations/password' - ) - expect(passwordResponse.status()).toBe(200) - await expect( - page.getByText('You are now registered to Synthetic Member Team.', { exact: true }) - ).toBeVisible() - - const cookies = await page.context().cookies() - expect(cookies).toContainEqual( - expect.objectContaining({ name: 'profile_session', httpOnly: true, sameSite: 'Lax' }) - ) - const identity = await page.evaluate(async () => { - const response = await fetch('/external-rest-api/api/v1/me') - return { status: response.status, body: await response.json() } - }) - expect(identity).toMatchObject({ - status: 200, - body: { - id: 'synthetic-member-onboarding-user', - email: 'member-onboarding@example.invalid', - teamId: 'synthetic-member-team', - role: 'member', - }, - }) - - const metrics = await readMetrics(page) - expect(metrics.ordinary.operations.acceptanceRequests).toEqual([ - { id: 'synthetic-member-invitation-row', purpose: 'member_invitation' }, - ]) - expect(metrics.ordinary.operations.passwordRequests).toEqual([ - { id: 'synthetic-member-invitation-row', purpose: 'member_invitation' }, - ]) - expect(metrics.ordinary.operations.resetRequests).toEqual([]) - expect(metrics.ordinary.invitations).toContainEqual( - expect.objectContaining({ - id: 'synthetic-member-invitation-row', - purpose: 'member_invitation', - status: 'accepted', - userId: 'synthetic-member-onboarding-user', - acceptedUserId: 'synthetic-member-onboarding-user', - passwordPending: false, - passwordMutationCount: 1, - }) - ) -}) - -test('accepted admin desktop invitation uses the token fallback without a prior session', async ({ - page, -}) => { - // E2E_GUARDIAN_NAVIGATION: email-reset-entry — synthetic emailed invitation entry - await page.goto('/invitations/synthetic-admin-invitation') - - await expect(page.getByText('admin-onboarding@example.invalid', { exact: true })).toBeVisible() - await expect(page.getByText('Leader', { exact: true })).toBeVisible() - await expect( - page.getByText('Set your password to finish joining Evenfire.', { exact: true }) - ).toBeVisible() - expect(await page.context().cookies()).toEqual([]) - - await page.getByLabel('Password', { exact: true }).fill('Synthetic-Admin-Onboarding-123') - await page.getByLabel('Confirm password').fill('Synthetic-Admin-Onboarding-123') - const passwordResponsePromise = page.waitForResponse( - response => - /\/api\/v1\/invitations\/password(?:-reset)?$/.test(response.url()) && - response.request().method() === 'POST' - ) - await page.getByRole('button', { name: 'Set password and continue' }).click() - const passwordResponse = await passwordResponsePromise - expect(new URL(passwordResponse.url()).pathname).toBe( - '/external-rest-api/api/v1/invitations/password' - ) - expect(passwordResponse.status()).toBe(200) - await expect( - page.getByText('You are now registered with Evenfire.', { exact: true }) - ).toBeVisible() - - const identity = await page.evaluate(async () => { - const response = await fetch('/external-rest-api/api/v1/me') - return { status: response.status, body: await response.json() } - }) - expect(identity).toMatchObject({ - status: 200, - body: { - id: 'synthetic-admin-onboarding-user', - email: 'admin-onboarding@example.invalid', - role: 'admin', - }, - }) - - const metrics = await readMetrics(page) - expect(metrics.ordinary.operations.acceptanceRequests).toContainEqual({ - id: 'synthetic-admin-invitation-row', - purpose: 'admin_desktop_access', - }) - expect(metrics.ordinary.operations.passwordRequests).toContainEqual({ - id: 'synthetic-admin-invitation-row', - purpose: 'admin_desktop_access', - }) - expect(metrics.ordinary.operations.resetRequests).toEqual([]) - expect(metrics.ordinary.invitations).toContainEqual( - expect.objectContaining({ - id: 'synthetic-admin-invitation-row', - purpose: 'admin_desktop_access', - status: 'accepted', - userId: 'synthetic-admin-onboarding-user', - acceptedUserId: 'synthetic-admin-onboarding-user', - passwordPending: false, - passwordMutationCount: 1, - }) - ) -}) - -test('reset-only producer rejects an ordinary invitation without creating a session', async ({ - page, -}) => { - const before = await readMetrics(page) - const response = await page.request.post('/external-rest-api/api/v1/invitations/password-reset', { - data: { - token: 'synthetic-wrong-purpose-invitation', - email: 'wrong-purpose@example.invalid', - invitationId: 'synthetic-wrong-purpose-invitation-row', - password: 'Synthetic-Wrong-Purpose-123', - }, - }) - - expect(response.status()).toBe(409) - expect(await response.json()).toMatchObject({ error: 'invitation_not_pending' }) - expect(response.headers()['set-cookie']).toBeUndefined() - expect(await page.context().cookies()).toEqual([]) - - const after = await readMetrics(page) - expect(after.ordinary.operations.passwordRequests).toEqual( - before.ordinary.operations.passwordRequests - ) - expect(after.ordinary.invitations).toContainEqual( - expect.objectContaining({ - id: 'synthetic-wrong-purpose-invitation-row', - purpose: 'member_invitation', - status: 'pending', - acceptedUserId: null, - passwordPending: true, - passwordMutationCount: 0, - }) - ) - expect(after.ordinary.operations.resetRequests).toContainEqual({ - id: 'synthetic-wrong-purpose-invitation-row', - }) -}) diff --git a/tests/e2e/playwright/profile-ui-recovery/reset-session-hydration.spec.ts b/tests/e2e/playwright/profile-ui-recovery/reset-session-hydration.spec.ts deleted file mode 100644 index 657b6a7590..0000000000 --- a/tests/e2e/playwright/profile-ui-recovery/reset-session-hydration.spec.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { expect, test } from '@playwright/test' - -const resetErrorCases = [ - { - errorCode: 'invalid_password', - status: 400, - message: 'Password must be between 8 and 256 characters.', - }, - { - errorCode: 'invalid_invitation', - status: 400, - message: 'This recovery link is invalid or expired.', - }, - { errorCode: 'forbidden', status: 403, message: 'Invitation email does not match.' }, - { errorCode: 'expired', status: 410, message: 'This invitation has expired.' }, - { errorCode: 'not_pending', status: 409, message: 'This invitation has already been used.' }, - { - errorCode: 'rate_limited', - status: 429, - message: 'Account recovery is temporarily busy. Please wait a moment and try again.', - }, - { - errorCode: 'authority_unavailable', - status: 503, - message: 'Account recovery is temporarily unavailable. Please try again shortly.', - }, - { - errorCode: 'recovery_outcome_unknown', - status: 503, - message: - 'We could not confirm whether your password change completed. Try signing in with the new password. If that does not work, request a new reset link.', - }, - { - errorCode: 'internal_error', - status: 500, - publicError: 'internal_error', - state: 'editable', - message: 'We could not update your password. Please try again.', - }, - { - errorCode: 'unknown_500', - status: 503, - publicError: 'recovery_outcome_unknown', - state: 'ambiguous', - message: - 'We could not confirm whether your password change completed. Try signing in with the new password. If that does not work, request a new reset link.', - }, -] as const - -async function openRecovery(page: import('@playwright/test').Page) { - // E2E_GUARDIAN_NAVIGATION: email-reset-entry — the synthetic emailed reset URL - await page.goto('/invitations/synthetic-reset-proof') - await expect(page.getByRole('heading', { name: 'Reset password' })).toBeVisible() -} - -test('retries session hydration after committed reset without resubmitting the reset proof', async ({ - page, -}) => { - await openRecovery(page) - - await page.getByLabel('Password', { exact: true }).fill('Synthetic-Recovery-Password-123') - await page.getByLabel('Confirm password').fill('Synthetic-Recovery-Password-123') - const resetResponsePromise = page.waitForResponse( - response => - response.url().endsWith('/api/v1/invitations/password-reset') && - response.request().method() === 'POST' - ) - const firstSessionResponsePromise = page.waitForResponse( - response => response.url().endsWith('/api/v1/me') && response.request().method() === 'GET' - ) - await page.getByRole('button', { name: 'Reset password' }).click() - - const [resetResponse, firstSessionResponse] = await Promise.all([ - resetResponsePromise, - firstSessionResponsePromise, - ]) - expect(resetResponse.status()).toBe(200) - expect(firstSessionResponse.status()).toBe(404) - expect(await firstSessionResponse.json()).toEqual({ error: 'User not found' }) - - await expect( - page.getByText( - 'Your password was updated, but we could not verify your account session. Try again.', - { exact: true } - ) - ).toBeVisible() - await expect(page.getByRole('button', { name: 'Retry account session check' })).toBeEnabled() - await expect(page).not.toHaveURL(/\/$/) - - const beforeRetry = await page.request - .get('/external-rest-api/__test/metrics') - .then(response => response.json()) - expect(beforeRetry).toMatchObject({ - resetPosts: 1, - authenticatedMeReads: 1, - passwordChanged: true, - }) - - const retrySessionResponsePromise = page.waitForResponse( - response => - response.url().endsWith('/api/v1/me') && - response.request().method() === 'GET' && - response.status() === 200 - ) - await page.getByRole('button', { name: 'Retry account session check' }).click() - const retrySessionResponse = await retrySessionResponsePromise - expect(retrySessionResponse.status()).toBe(200) - expect(await retrySessionResponse.json()).toMatchObject({ - id: 'synthetic-member-id', - email: 'member@example.invalid', - }) - await expect(page).toHaveURL(/\/$/) - expect(new URL(page.url()).pathname).toBe('/') - await expect(page.getByRole('heading', { name: 'Welcome, Synthetic Member' })).toBeVisible() - await expect(page.getByText('member@example.invalid')).toBeVisible() - - const afterRetry = await page.request - .get('/external-rest-api/__test/metrics') - .then(response => response.json()) - expect(afterRetry).toMatchObject({ - resetPosts: 1, - authenticatedMeReads: 2, - passwordChanged: true, - }) -}) - -test('trimmed-too-short password gets password guidance without dispatching a reset', async ({ - page, -}) => { - await openRecovery(page) - const before = await page.request - .get('/external-rest-api/__test/metrics') - .then(response => response.json()) - - await page.getByLabel('Password', { exact: true }).fill('abcdefg ') - await page.getByLabel('Confirm password').fill('abcdefg ') - await page.getByRole('button', { name: 'Reset password' }).click() - - await expect( - page.getByText('Password must be between 8 and 256 characters.', { exact: true }) - ).toBeVisible() - await expect( - page.getByText('This recovery link is invalid or expired.', { exact: true }) - ).toHaveCount(0) - const after = await page.request - .get('/external-rest-api/__test/metrics') - .then(response => response.json()) - expect(after.contractResetPosts).toBe(before.contractResetPosts) - expect(after.resetPosts).toBe(before.resetPosts) -}) - -for (const failure of resetErrorCases) { - test(`real reset route response ${failure.errorCode} reaches Profile UI apiSend`, async ({ - page, - }) => { - await openRecovery(page) - const before = await page.request - .get('/external-rest-api/__test/metrics') - .then(response => response.json()) - - const password = `Synthetic-Recovery-Contract-${failure.errorCode}-Password-123` - await page.getByLabel('Password', { exact: true }).fill(password) - await page.getByLabel('Confirm password').fill(password) - const resetResponsePromise = page.waitForResponse( - response => - response.url().endsWith('/api/v1/invitations/password-reset') && - response.request().method() === 'POST' - ) - await page.getByRole('button', { name: 'Reset password' }).click() - const resetResponse = await resetResponsePromise - - expect(resetResponse.status()).toBe(failure.status) - expect(resetResponse.headers()['set-cookie']).toBeUndefined() - if ('publicError' in failure) { - expect(await resetResponse.json()).toMatchObject({ error: failure.publicError }) - } - await expect(page.getByText(failure.message, { exact: true })).toBeVisible() - await expect( - page.getByText('This recovery link is invalid or expired.', { exact: true }) - ).toHaveCount(failure.errorCode === 'invalid_invitation' ? 1 : 0) - - const after = await page.request - .get('/external-rest-api/__test/metrics') - .then(response => response.json()) - expect(after.contractResetPosts).toBe(before.contractResetPosts + 1) - expect(after.authenticatedMeReads).toBe(before.authenticatedMeReads) - expect((await page.context().cookies()).some(cookie => cookie.name === 'profile_session')).toBe( - false - ) - if ('state' in failure && failure.state === 'editable') { - await expect(page.getByLabel('Password', { exact: true })).toBeVisible() - await expect(page.getByLabel('Confirm password')).toBeVisible() - await expect(page.getByRole('button', { name: 'Reset password' })).toBeEnabled() - } - if ('state' in failure && failure.state === 'ambiguous') { - await expect(page.getByLabel('Password', { exact: true })).toHaveCount(0) - await expect(page.getByRole('link', { name: 'Try signing in' })).toBeVisible() - await expect(page.getByRole('link', { name: 'Request a new recovery link' })).toBeVisible() - } - await expect(page.getByText(/unclassified_failure|private detail/)).toHaveCount(0) - }) -} diff --git a/tests/e2e/playwright/run-profile-ui-recovery.mjs b/tests/e2e/playwright/run-profile-ui-recovery.mjs index 2885cfb784..884286d915 100644 --- a/tests/e2e/playwright/run-profile-ui-recovery.mjs +++ b/tests/e2e/playwright/run-profile-ui-recovery.mjs @@ -5,27 +5,18 @@ import { fileURLToPath } from 'node:url' const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)) const config = path.join(scriptDirectory, 'profile-ui-recovery.config.ts') const playwrightCli = path.join(scriptDirectory, 'node_modules/@playwright/test/cli.js') -const requiredResetErrorCodes = [ - 'invalid_password', - 'invalid_invitation', - 'forbidden', - 'expired', - 'not_pending', - 'rate_limited', - 'authority_unavailable', - 'recovery_outcome_unknown', - 'internal_error', - 'unknown_500', -] const requiredTestTitles = [ - 'member accepts the prepared invitation and establishes its own password', - 'accepted admin desktop invitation uses the token fallback without a prior session', - 'reset-only producer rejects an ordinary invitation without creating a session', - 'retries session hydration after committed reset without resubmitting the reset proof', - 'trimmed-too-short password gets password guidance without dispatching a reset', - ...requiredResetErrorCodes.map( - errorCode => `real reset route response ${errorCode} reaches Profile UI apiSend` - ), + 'J1 invitation onboarding ends in guided sign-in and a real login', + 'J2 recovery link clears the logout fence, revokes the prior session, and leads to login', + 'J3 logout returns to login and the previous session is rejected', + 'negative routes show an explicit error and never issue a session', + 'direct access to the authenticated home without login shows the login form', +] +const requiredSpecFiles = [ + 'j1-invitation-onboarding.spec.ts', + 'j2-password-recovery.spec.ts', + 'j3-logout.spec.ts', + 'negative-routes.spec.ts', ] function assertRequiredCases(output, phase) { @@ -48,11 +39,10 @@ const listingOutput = `${listing.stdout || ''}${listing.stderr || ''}` process.stdout.write(listingOutput) if (listing.error) throw listing.error if (listing.status !== 0) process.exit(listing.status || 1) -if (!listingOutput.includes('reset-session-hydration.spec.ts')) { - throw new Error('Profile UI recovery runner did not collect the recovery spec') -} -if (!listingOutput.includes('ordinary-invitation-onboarding.spec.ts')) { - throw new Error('Profile UI recovery runner did not collect ordinary invitation onboarding') +for (const spec of requiredSpecFiles) { + if (!listingOutput.includes(spec)) { + throw new Error(`Profile UI recovery runner did not collect ${spec}`) + } } assertRequiredCases(listingOutput, 'did not collect') @@ -64,10 +54,7 @@ if (run.status !== 0) process.exit(run.status || 1) if (!/[1-9]\d* passed/.test(runOutput)) { throw new Error('Profile UI recovery runner did not report a passing test') } -for (const requiredSuite of [ - 'reset-session-hydration.spec.ts', - 'ordinary-invitation-onboarding.spec.ts', -]) { +for (const requiredSuite of requiredSpecFiles) { if (!runOutput.includes(requiredSuite)) { throw new Error(`Profile UI recovery runner did not execute ${requiredSuite}`) } From 9ac5995854c5c4ce724a27583d3fff4f1d39f0bd Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 21 Oct 2026 20:27:22 +0200 Subject: [PATCH 349/528] fix(e2e): forward the profile session team query to the recovery fixture --- tests/e2e/playwright/package-lock.json | 4 ++-- tests/e2e/playwright/package.json | 2 +- tests/e2e/playwright/profile-ui-recovery-fixture.mjs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/e2e/playwright/package-lock.json b/tests/e2e/playwright/package-lock.json index e93c98a603..3366e02b83 100644 --- a/tests/e2e/playwright/package-lock.json +++ b/tests/e2e/playwright/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-playwright-tests", - "version": "0.1.84", + "version": "0.1.85", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-playwright-tests", - "version": "0.1.84", + "version": "0.1.85", "devDependencies": { "@playwright/test": "^1.50.0", "@types/node": "^22.0.0", diff --git a/tests/e2e/playwright/package.json b/tests/e2e/playwright/package.json index 5117d976a2..ed4e1dabf2 100644 --- a/tests/e2e/playwright/package.json +++ b/tests/e2e/playwright/package.json @@ -1,6 +1,6 @@ { "name": "clerum-playwright-tests", - "version": "0.1.84", + "version": "0.1.85", "private": true, "description": "Playwright E2E tests for Control UI and Desktop App", "scripts": { diff --git a/tests/e2e/playwright/profile-ui-recovery-fixture.mjs b/tests/e2e/playwright/profile-ui-recovery-fixture.mjs index 95d1775a83..8e961aa1ce 100644 --- a/tests/e2e/playwright/profile-ui-recovery-fixture.mjs +++ b/tests/e2e/playwright/profile-ui-recovery-fixture.mjs @@ -52,7 +52,7 @@ const controlApiServer = createServer(async (request, response) => { try { const result = await controlApi.handle({ method: request.method, - path: url.pathname.slice('/api/v1'.length), + path: `${url.pathname.slice('/api/v1'.length)}${url.search}`, headers: request.headers, body, }) From a85d7285a7275ed386f81943e322b4dcd743b922 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 21 Oct 2026 20:50:40 +0200 Subject: [PATCH 350/528] fix(control-api): keep session fields out of password responses Strip token and sessionContext at the public producer and consumer boundaries instead of trusting a directory result to omit them. The tests now supply hostile producer fields, so removing the boundary strip fails, and a real-PostgreSQL service assertion covers the directory result itself. Extend the revocation test to prove the old password is rejected before the fresh login succeeds. --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../src/routes/external/invitations.ts | 20 +++++- ...wordLogin.realPostgres.integration.test.ts | 3 + .../passwordRecovery.http.contract.test.ts | 8 +++ ...very.http.realPostgres.integration.test.ts | 10 ++- ...es.externalInvitations.unavailable.test.ts | 8 +++ external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- external-rest-api/src/releaseManifest.ts | 2 +- .../src/services/invitationPasswordService.ts | 24 ++++++- ...nvitations.passwordRecovery.public.test.ts | 62 +++++++++++++++++++ 12 files changed, 136 insertions(+), 13 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 6b5e83041d..ba7fd77acb 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.811", + "version": "0.1.812", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.811", + "version": "0.1.812", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 6f4277d556..45819e1dc5 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.811", + "version": "0.1.812", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/routes/external/invitations.ts b/control-api/src/routes/external/invitations.ts index c0f498a547..4991e34c9e 100644 --- a/control-api/src/routes/external/invitations.ts +++ b/control-api/src/routes/external/invitations.ts @@ -40,6 +40,22 @@ function invitationAdmissionKey(req: Request, operation: 'preview' | 'password-t return `invite-token:${operation}:${externalClientIpRateLimitKey(req)}` } +// Password setup never returns a session representation. Strip the fields at +// the public boundary even if a directory implementation supplies them. +function withoutSessionResponseFields( + data: T +): Omit { + const { + sessionContext: _sessionContext, + token: _token, + ...response + } = data as T & { + sessionContext?: unknown + token?: unknown + } + return response +} + export function createExternalInvitationsRouter(): Router { const router = Router() const externalInvitationsEdgeRateLimits = createExternalClientRateLimiters( @@ -156,7 +172,7 @@ export function createExternalInvitationsRouter(): Router { return res.status(409).json({ error: 'invitation_not_ready' }) } return res.status(200).json({ - ...result.data, + ...withoutSessionResponseFields(result.data), reauthenticationRequired: true, }) } catch (error) { @@ -217,7 +233,7 @@ export function createExternalInvitationsRouter(): Router { } return res.status(400).json({ error: 'invalid_password' }) } - return res.status(200).json(result.data) + return res.status(200).json(withoutSessionResponseFields(result.data)) } catch (error) { if (sendPasswordAdmissionError(error, res)) return return next(error) diff --git a/control-api/test/passwordLogin.realPostgres.integration.test.ts b/control-api/test/passwordLogin.realPostgres.integration.test.ts index bf5fa655da..c01964ddde 100644 --- a/control-api/test/passwordLogin.realPostgres.integration.test.ts +++ b/control-api/test/passwordLogin.realPostgres.integration.test.ts @@ -472,6 +472,9 @@ realPg('BUG-192 observable password-login regressions on real PostgreSQL', () => expect(result).toMatchObject({ data: { passwordUpdated: true }, }) + const resultData = (result as { data: Record }).data + expect(resultData).not.toHaveProperty('sessionContext') + expect(resultData).not.toHaveProperty('token') expect( ( await holder.pool.query('SELECT id, status FROM invitations WHERE id = ANY($1::uuid[])', [ diff --git a/control-api/test/passwordRecovery.http.contract.test.ts b/control-api/test/passwordRecovery.http.contract.test.ts index 255965a515..84a3d2ef29 100644 --- a/control-api/test/passwordRecovery.http.contract.test.ts +++ b/control-api/test/passwordRecovery.http.contract.test.ts @@ -833,6 +833,14 @@ describe('invitation/password producer response mappings', () => { lifecycleState: 'active', authGeneration: 7, passwordUpdated: true, + token: 'synthetic-producer-session-token', + sessionContext: { + userId: preview.userId, + email: preview.email, + teamId: null, + role: 'member', + authGeneration: 7, + }, }, }) diff --git a/control-api/test/passwordRecovery.http.realPostgres.integration.test.ts b/control-api/test/passwordRecovery.http.realPostgres.integration.test.ts index 65f01cbd1e..5e88b58d84 100644 --- a/control-api/test/passwordRecovery.http.realPostgres.integration.test.ts +++ b/control-api/test/passwordRecovery.http.realPostgres.integration.test.ts @@ -523,7 +523,7 @@ realPg('R2-M1/R2-M2 public password authority contract on PostgreSQL 16', () => ).toBe(unavailableMember.passwordHash) }) - it('revokes the pre-reset session, sets no session, and accepts only a fresh login', async () => { + it('revokes the pre-reset session, sets no session, rejects the old password, and accepts a fresh login', async () => { const member = await memberWithPassword() const sessionCookie = `profile_session=${member.token}` const beforeReset = await request(publicApp).get('/api/v1/me').set('cookie', sessionCookie) @@ -569,6 +569,14 @@ realPg('R2-M1/R2-M2 public password authority contract on PostgreSQL 16', () => const revoked = await request(publicApp).get('/api/v1/me').set('cookie', sessionCookie) expect(revoked.status).toBe(401) + await db.pool.query( + "UPDATE password_verification_pace SET next_permit = clock_timestamp() - interval '1 second'" + ) + const stalePasswordLogin = await request(publicApp) + .post('/api/v1/auth/password-login') + .set('x-forwarded-for', nextSourceIp()) + .send({ email: member.email, password: currentPassword }) + expect(stalePasswordLogin.status).toBe(401) await db.pool.query( "UPDATE password_verification_pace SET next_permit = clock_timestamp() - interval '1 second'" ) diff --git a/control-api/test/routes.externalInvitations.unavailable.test.ts b/control-api/test/routes.externalInvitations.unavailable.test.ts index 4cb80f092b..0525f837af 100644 --- a/control-api/test/routes.externalInvitations.unavailable.test.ts +++ b/control-api/test/routes.externalInvitations.unavailable.test.ts @@ -212,6 +212,14 @@ describe('external invitation routes when the hub is unavailable', () => { passwordUpdated: true, authGeneration: 1, lifecycleState: 'active', + token: 'synthetic-producer-session-token', + sessionContext: { + userId: '00000000-0000-4000-8000-000000000001', + email: 'a@b.c', + teamId: null, + role: 'member', + authGeneration: 1, + }, }, }) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index e7940719a8..ca9e143050 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.148", + "version": "0.1.149", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.148", + "version": "0.1.149", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index 68bcc4abd4..b8b38d16b5 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.148", + "version": "0.1.149", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index 65a44f4316..d1b696425a 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.11.0', - externalRestApiVersion: '0.1.148', + externalRestApiVersion: '0.1.149', rpcProxyVersion: '0.1.132', desktopVersion: '0.11.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/src/services/invitationPasswordService.ts b/external-rest-api/src/services/invitationPasswordService.ts index 5da08440ba..117ebdffe7 100644 --- a/external-rest-api/src/services/invitationPasswordService.ts +++ b/external-rest-api/src/services/invitationPasswordService.ts @@ -67,6 +67,22 @@ function unknownRecoveryOutcome(): ControlApiError { ) } +// Password setup never publishes a session representation. Keep the public +// consumer contract free of these fields even against a mixed-version producer. +function withoutSessionResponseFields( + data: T +): Omit { + const { + sessionContext: _sessionContext, + token: _token, + ...response + } = data as T & { + sessionContext?: unknown + token?: unknown + } + return response +} + function passwordAuthorityUnavailable(): ControlApiError { return new ControlApiError( 'Password recovery authority unavailable', @@ -104,7 +120,7 @@ export async function setupInvitationPassword( userSessionToken: auth.sessionToken, } ) - return { data } + return { data: withoutSessionResponseFields(data) } } catch (error) { if (error instanceof ControlApiTransportError) throw passwordAuthorityUnavailable() if ( @@ -148,12 +164,14 @@ export async function setupInvitationPasswordWithToken( passwordUpdated: boolean reauthenticationRequired?: boolean token?: string + sessionContext?: unknown } >('POST', '/external/invitations/password-token', { body: { email, token, invitationId, password }, }) - const { token: _discardedSessionRepresentation, ...safe } = data - return { data: { ...safe, reauthenticationRequired: true } } + return { + data: { ...withoutSessionResponseFields(data), reauthenticationRequired: true }, + } } catch (error) { if (error instanceof ControlApiTransportError) throw unknownRecoveryOutcome() if (error instanceof ControlApiError && error.status === 429) throw error diff --git a/external-rest-api/test/invitations.passwordRecovery.public.test.ts b/external-rest-api/test/invitations.passwordRecovery.public.test.ts index e7247e0888..40651a0312 100644 --- a/external-rest-api/test/invitations.passwordRecovery.public.test.ts +++ b/external-rest-api/test/invitations.passwordRecovery.public.test.ts @@ -224,6 +224,40 @@ describe('password reset preview authority failures', () => { expect(fetchMock).not.toHaveBeenCalled() }) + it('never forwards producer session fields from a successful reset', async () => { + await startUpstream((_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end( + JSON.stringify({ + id: '00000000-0000-4000-8000-000000000001', + email: 'member@example.invalid', + purpose: 'password_reset', + status: 'accepted', + passwordUpdated: true, + reauthenticationRequired: true, + token: 'synthetic-producer-session-token', + sessionContext: { + userId: 'synthetic-user', + email: 'member@example.invalid', + authGeneration: 2, + }, + }) + ) + }) + + const response = await submitDedicatedReset() + + expect(response.status).toBe(200) + expect(response.body).toMatchObject({ + passwordUpdated: true, + reauthenticationRequired: true, + }) + expect(response.body).not.toHaveProperty('token') + expect(response.body).not.toHaveProperty('sessionContext') + expect(String(response.headers['set-cookie'])).toContain('profile_session=;') + expect(String(response.headers['set-cookie'])).not.toMatch(/profile_session=[^;]+/) + }) + it('maps an ordinary invitation rejection on the dedicated reset route without a session cookie', async () => { const requests: Array<{ method?: string; url?: string; body?: Record }> = [] await startUpstream(async (req, res) => { @@ -299,6 +333,34 @@ describe('setupInvitationPasswordWithToken', () => { ).resolves.toEqual({ error: 'invalid_password' }) }) + it('strips producer session fields from the reset response', async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + id: 'synthetic-reset-row', + email: 'member@example.invalid', + status: 'accepted', + passwordUpdated: true, + reauthenticationRequired: true, + token: 'synthetic-producer-session-token', + sessionContext: { userId: 'synthetic-user', authGeneration: 2 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + ) + + const result = await setupInvitationPasswordWithToken( + 'synthetic-proof', + 'member@example.invalid', + 'synthetic-row-id', + 'Synthetic-Valid-Password-123' + ) + + expect(result.data).toMatchObject({ passwordUpdated: true, reauthenticationRequired: true }) + expect(result.data).not.toHaveProperty('token') + expect(result.data).not.toHaveProperty('sessionContext') + }) + it('rejects an unrecognized producer error instead of treating it as a password error', async () => { fetchMock.mockResolvedValue( new Response(JSON.stringify({ error: 'invalid payload' }), { status: 400 }) From a0f2c1dc8524d0c7c25129772eeae0f48cb0e207 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 21 Oct 2026 20:50:45 +0200 Subject: [PATCH 351/528] docs(profile-ui): drop the removed download shortcut The password-updated screen replaces the accepted-invitation download panel, so remove the now-unused release-link helper and stop claiming that the profile home offers a Download Evenfire link. Align the affected test titles with what they actually prove. --- docs/surfaces/profile-ui.md | 6 +++--- .../__tests__/InvitationClient.identityFence.test.tsx | 2 +- .../__tests__/InvitationClient.recoveryContract.test.tsx | 2 +- profile-ui/app/components/__tests__/api.logout.test.ts | 2 +- profile-ui/app/constants/api.ts | 2 -- profile-ui/lib/api.ts | 5 ----- profile-ui/package-lock.json | 4 ++-- profile-ui/package.json | 2 +- 8 files changed, 9 insertions(+), 16 deletions(-) diff --git a/docs/surfaces/profile-ui.md b/docs/surfaces/profile-ui.md index 673a734ba0..1aa15e3df2 100644 --- a/docs/surfaces/profile-ui.md +++ b/docs/surfaces/profile-ui.md @@ -37,9 +37,9 @@ Surfaces](README.md) for how the three consoles divide the platform. the ordinary login. Password reset (`POST /api/v1/invitations/password-reset`) follows the same post-password contract. -5. **The person signs in with the new password** and lands on the profile home, - which offers the Download Evenfire link — not an automatic handoff. The - member installs the [Desktop App](desktop-app.md) themselves. +5. **The person signs in with the new password** and lands on the profile home. + Profile UI does not hand off automatically; the member starts the + [Desktop App](desktop-app.md) and completes step 6 from there. 6. **The Desktop App completes the handoff later**, from its own sign-in screen: entering the invited email there makes the Desktop App look up the invitation profile and open Profile UI's `/desktop-setup?email=...` diff --git a/profile-ui/app/components/__tests__/InvitationClient.identityFence.test.tsx b/profile-ui/app/components/__tests__/InvitationClient.identityFence.test.tsx index d41ec95d23..aadbe8842e 100644 --- a/profile-ui/app/components/__tests__/InvitationClient.identityFence.test.tsx +++ b/profile-ui/app/components/__tests__/InvitationClient.identityFence.test.tsx @@ -43,7 +43,7 @@ beforeEach(() => { afterEach(cleanup) describe('R6 reset response session boundary', () => { - it('does not hydrate a session from the reset response', async () => { + it('shows the password-updated screen from the reset response without navigating', async () => { render( { afterEach(cleanup) describe('R6 recovery outcome and guided reauthentication', () => { - it('moves a committed reset to the password-updated screen without a session', async () => { + it('moves a committed reset to the password-updated screen', async () => { renderReset() fillPassword() submitReset() diff --git a/profile-ui/app/components/__tests__/api.logout.test.ts b/profile-ui/app/components/__tests__/api.logout.test.ts index 938b01b752..715bed4636 100644 --- a/profile-ui/app/components/__tests__/api.logout.test.ts +++ b/profile-ui/app/components/__tests__/api.logout.test.ts @@ -54,7 +54,7 @@ describe('Profile logout client boundary', () => { expect(vi.mocked(fetch).mock.calls[2]?.[1]?.credentials).toBe('include') }) - it('clears the in-memory and stored fence on the recovery path', async () => { + it('clears the in-memory and stored logout fence', async () => { vi.mocked(fetch) .mockResolvedValueOnce(new Response('', { status: 503 })) .mockResolvedValueOnce( diff --git a/profile-ui/app/constants/api.ts b/profile-ui/app/constants/api.ts index 7e3157e0d3..e8d3854323 100644 --- a/profile-ui/app/constants/api.ts +++ b/profile-ui/app/constants/api.ts @@ -4,5 +4,3 @@ export const EXTERNAL_REST_API_BASE_URL = export const EXTERNAL_SESSION_TOKEN_KEY = 'external_session_token' export const EXTERNAL_SESSION_TOKEN_EXPIRES_AT_KEY = 'external_session_token_expires_at' export const EXTERNAL_SESSION_TOKEN_TTL_SECONDS = 60 * 60 * 24 - -export const EVENFIRE_RELEASES_URL = 'https://github.com/evenfire-ai/evenfire/releases' diff --git a/profile-ui/lib/api.ts b/profile-ui/lib/api.ts index de9473d612..61f7610189 100644 --- a/profile-ui/lib/api.ts +++ b/profile-ui/lib/api.ts @@ -1,7 +1,6 @@ 'use client' import { - EVENFIRE_RELEASES_URL, EXTERNAL_REST_API_BASE_URL, EXTERNAL_SESSION_TOKEN_EXPIRES_AT_KEY, EXTERNAL_SESSION_TOKEN_KEY, @@ -515,7 +514,3 @@ export function getConfiguredExternalRestApiBaseUrl(): string { return EXTERNAL_REST_API_BASE_URL.replace(/\/+$/, '') } } - -export function getEvenfireDownloadUrl(): string { - return EVENFIRE_RELEASES_URL -} diff --git a/profile-ui/package-lock.json b/profile-ui/package-lock.json index 822fe65c85..8202f7ad26 100644 --- a/profile-ui/package-lock.json +++ b/profile-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-profile-ui", - "version": "0.1.183", + "version": "0.1.184", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-profile-ui", - "version": "0.1.183", + "version": "0.1.184", "dependencies": { "@clerum/desktop-app-links": "file:../packages/desktop-app-links", "@clerum/frontend-components": "file:../packages/frontend-components", diff --git a/profile-ui/package.json b/profile-ui/package.json index 9b650279e8..8100f57aca 100644 --- a/profile-ui/package.json +++ b/profile-ui/package.json @@ -1,6 +1,6 @@ { "name": "clerum-profile-ui", - "version": "0.1.183", + "version": "0.1.184", "private": true, "scripts": { "dev": "next dev", From df65572fdb519696f2a1082c6d8a292f9bc6d7aa Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 21 Oct 2026 20:50:50 +0200 Subject: [PATCH 352/528] test(e2e): harden the recovery lane fixture and CI audit Retarget the CI static-audit job and its scope regression at the new J1-J3 specs, and make the lane build external-rest-api so it cannot pass against a stale dist. J2 now produces the logout fence through a real logout whose revocation request is aborted, instead of seeding browser storage. Align fixture error branches with the producer, add parity cases for repeat and rejected mutations, and keep the parity body comparison free of session fields. --- .github/workflows/ci-public.yml | 6 +- Makefile | 1 + control-api/package-lock.json | 4 +- control-api/package.json | 2 +- ...re.parity.realPostgres.integration.test.ts | 83 +++++++++++++++++++ tests/e2e/playwright/package-lock.json | 4 +- tests/e2e/playwright/package.json | 2 +- .../profile-ui-recovery-control-api.mjs | 25 +++--- .../j2-password-recovery.spec.ts | 15 +++- .../profile-ui-recovery/journeys.ts | 13 +-- tools/test_e2e_static_audit_scope.py | 5 +- 11 files changed, 129 insertions(+), 31 deletions(-) diff --git a/.github/workflows/ci-public.yml b/.github/workflows/ci-public.yml index 7f77db4d56..aeef35646c 100644 --- a/.github/workflows/ci-public.yml +++ b/.github/workflows/ci-public.yml @@ -729,8 +729,10 @@ jobs: - name: Audit live Profile UI recovery Playwright spec run: >- python3 tools/e2e_static_audit.py - tests/e2e/playwright/profile-ui-recovery/reset-session-hydration.spec.ts - tests/e2e/playwright/profile-ui-recovery/ordinary-invitation-onboarding.spec.ts + tests/e2e/playwright/profile-ui-recovery/j1-invitation-onboarding.spec.ts + tests/e2e/playwright/profile-ui-recovery/j2-password-recovery.spec.ts + tests/e2e/playwright/profile-ui-recovery/j3-logout.spec.ts + tests/e2e/playwright/profile-ui-recovery/negative-routes.spec.ts # ─── Registry contract — boot evenfire-registry and drive the real client ── # Catches wire-format drift in clerumRegistryClient.ts before merge. Requires diff --git a/Makefile b/Makefile index d562aad7f2..ab73001c85 100644 --- a/Makefile +++ b/Makefile @@ -1546,6 +1546,7 @@ test-playwright-profile-ui-recovery: ## Audit and run the Profile UI password-re @for spec in tests/e2e/playwright/profile-ui-recovery/*.spec.ts; do \ python3 tools/e2e_static_audit.py "$$spec" || exit $$?; \ done + @npm --prefix external-rest-api run build @cd tests/e2e/playwright && node run-profile-ui-recovery.mjs .PHONY: test-playwright-headed diff --git a/control-api/package-lock.json b/control-api/package-lock.json index ba7fd77acb..be08f01e37 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.812", + "version": "0.1.813", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.812", + "version": "0.1.813", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 45819e1dc5..95c3799010 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.812", + "version": "0.1.813", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/profileUiRecoveryFixture.parity.realPostgres.integration.test.ts b/control-api/test/profileUiRecoveryFixture.parity.realPostgres.integration.test.ts index c66c2945c1..86a7cd09a6 100644 --- a/control-api/test/profileUiRecoveryFixture.parity.realPostgres.integration.test.ts +++ b/control-api/test/profileUiRecoveryFixture.parity.realPostgres.integration.test.ts @@ -267,6 +267,71 @@ realPg('Profile UI recovery lane fixture parity on PostgreSQL 16', () => { } }) + it('parities rejected invitation mutations without creating a session', async () => { + const rejected = [ + { + path: '/external/invitations/accept', + body: { + email: fixtureModule.RECOVERY_USERS.usedInvitee.email, + token: 'synthetic-used-invitation', + }, + error: 'not_pending', + }, + { + path: '/external/invitations/accept', + body: { + email: fixtureModule.RECOVERY_USERS.recovery.email, + token: 'synthetic-reset-proof', + }, + error: 'not_pending', + }, + { + path: '/external/invitations/accept', + body: { + email: fixtureModule.RECOVERY_USERS.onboarding.email, + token: 'synthetic-invented-token', + }, + error: 'invalid_invitation', + }, + { + path: '/external/invitations/password-token', + body: { + email: fixtureModule.RECOVERY_USERS.recovery.email, + token: 'synthetic-invented-token', + invitationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2', + password: 'Synthetic-Fixture-Parity-123', + }, + error: 'invalid_invitation', + }, + { + path: '/external/invitations/password-token', + body: { + email: 'wrong-recovery@example.invalid', + token: 'synthetic-reset-proof', + invitationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2', + password: 'Synthetic-Fixture-Parity-123', + }, + error: 'invalid_invitation', + }, + { + path: '/external/invitations/password-token', + body: { + email: fixtureModule.RECOVERY_USERS.recovery.email, + token: 'synthetic-reset-proof', + invitationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3', + password: 'Synthetic-Fixture-Parity-123', + }, + error: 'forbidden', + }, + ] + for (const scenario of rejected) { + const parity = await expectParity('POST', scenario.path, { body: scenario.body }) + expect(parity.real.status).toBeGreaterThanOrEqual(400) + expect(parity.real.body).toMatchObject({ error: scenario.error }) + expect(parity.synthetic.body).not.toHaveProperty('token') + } + }) + it('parities accept, authenticated password setup and the resulting session identity', async () => { const accepted = await expectParity('POST', '/external/invitations/accept', { body: { @@ -325,6 +390,24 @@ realPg('Profile UI recovery lane fixture parity on PostgreSQL 16', () => { expect(bodyShape(fixtureMe.body)).toEqual(bodyShape(realMe.body)) expect(realMe.status).toBe(200) + const repeatPayload = { + userId: fixtureModule.RECOVERY_USERS.onboarding.id, + email: fixtureModule.RECOVERY_USERS.onboarding.email, + invitationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1', + password: fixtureModule.RECOVERY_PASSWORDS.replacement, + } + const realRepeat = await requestReal('POST', '/external/invitations/password', { + body: repeatPayload, + sessionToken: (realLogin.body as { token: string }).token, + }) + const fixtureRepeat = await requestFixture('POST', '/external/invitations/password', { + body: repeatPayload, + sessionToken: (fixtureLogin.body as { token: string }).token, + }) + expect(fixtureRepeat.status).toBe(realRepeat.status) + expect(bodyShape(fixtureRepeat.body)).toEqual(bodyShape(realRepeat.body)) + expect(realRepeat.status).toBe(200) + const realRevoked = await requestReal( 'GET', `/external/users/${fixtureModule.RECOVERY_USERS.onboarding.id}/me?teamId=${fixtureModule.RECOVERY_USERS.onboarding.teamId}`, diff --git a/tests/e2e/playwright/package-lock.json b/tests/e2e/playwright/package-lock.json index 3366e02b83..4594cccfc7 100644 --- a/tests/e2e/playwright/package-lock.json +++ b/tests/e2e/playwright/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-playwright-tests", - "version": "0.1.85", + "version": "0.1.86", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-playwright-tests", - "version": "0.1.85", + "version": "0.1.86", "devDependencies": { "@playwright/test": "^1.50.0", "@types/node": "^22.0.0", diff --git a/tests/e2e/playwright/package.json b/tests/e2e/playwright/package.json index ed4e1dabf2..165d865d24 100644 --- a/tests/e2e/playwright/package.json +++ b/tests/e2e/playwright/package.json @@ -1,6 +1,6 @@ { "name": "clerum-playwright-tests", - "version": "0.1.85", + "version": "0.1.86", "private": true, "description": "Playwright E2E tests for Control UI and Desktop App", "scripts": { diff --git a/tests/e2e/playwright/profile-ui-recovery-control-api.mjs b/tests/e2e/playwright/profile-ui-recovery-control-api.mjs index c2bd9d0900..d29a6b10ac 100644 --- a/tests/e2e/playwright/profile-ui-recovery-control-api.mjs +++ b/tests/e2e/playwright/profile-ui-recovery-control-api.mjs @@ -290,15 +290,12 @@ export async function createRecoveryControlApi({ .toLowerCase() metrics.acceptRequests.push({ token }) const invitation = invitations.get(token) - if (!invitation) return { status: 404, body: { error: 'not_found' } } + if (!invitation) return { status: 400, body: { error: 'invalid_invitation' } } if (invitation.email.toLowerCase() !== email) { - return { status: 403, body: { error: 'forbidden' } } - } - if (invitation.purpose === 'password_reset') { - return { status: 409, body: { error: 'not_pending' } } + return { status: 400, body: { error: 'invalid_invitation' } } } - if (invitation.status !== 'pending') { - return { status: 409, body: { error: 'not_pending' } } + if (invitation.purpose === 'password_reset' || invitation.status !== 'pending') { + return { status: 400, body: { error: 'not_pending' } } } if (Date.parse(invitation.expiresAt) <= Date.now()) { return { status: 410, body: { error: 'expired' } } @@ -328,10 +325,16 @@ export async function createRecoveryControlApi({ const password = String(body?.password || '') metrics.passwordTokenRequests.push({ token, invitationId }) const invitation = invitations.get(token) - if (!invitation || invitation.id !== invitationId) { + if (!invitation) { + return { status: 400, body: { error: 'invalid_invitation' } } + } + if (invitation.email.toLowerCase() !== email) { + return { status: 400, body: { error: 'invalid_invitation' } } + } + if (invitation.id !== invitationId) { return { status: 403, body: { error: 'forbidden' } } } - if (invitation.email.toLowerCase() !== email || invitation.purpose !== 'password_reset') { + if (invitation.purpose !== 'password_reset') { return { status: 409, body: { error: 'invitation_not_pending' } } } if (invitation.status !== 'pending') { @@ -375,8 +378,8 @@ export async function createRecoveryControlApi({ if (!['member_invitation', 'admin_desktop_access'].includes(invitation.purpose)) { return { status: 404, body: { error: 'not_found' } } } - if (invitation.status !== 'accepted' || session.user.passwordHash) { - return { status: 409, body: { error: 'invitation_not_pending' } } + if (invitation.status !== 'accepted') { + return { status: 409, body: { error: 'invitation_not_accepted' } } } const nextHash = await passwordHash(password) if (!nextHash) return { status: 400, body: { error: 'invalid_password' } } diff --git a/tests/e2e/playwright/profile-ui-recovery/j2-password-recovery.spec.ts b/tests/e2e/playwright/profile-ui-recovery/j2-password-recovery.spec.ts index 8bd12fead9..eaf377728b 100644 --- a/tests/e2e/playwright/profile-ui-recovery/j2-password-recovery.spec.ts +++ b/tests/e2e/playwright/profile-ui-recovery/j2-password-recovery.spec.ts @@ -4,7 +4,6 @@ import { fetchMeWithCookie, readControlApiMetrics, readLogoutFence, - seedLogoutFence, sessionCookieValue, signInThroughUi, } from './journeys' @@ -26,10 +25,18 @@ test('J2 recovery link clears the logout fence, revokes the prior session, and l expect(validBefore.status).toBe(200) expect(validBefore.body).toMatchObject({ id: RECOVERY_MEMBER.id }) - await test.step('logout fence precondition: PROFILE_LOGOUT_UNCONFIRMED_KEY from a previous logout', async () => { - await seedLogoutFence(page) - expect(await readLogoutFence(page)).toBe('true') + await page.route('**/api/v1/auth/logout', route => route.abort('failed')) + await test.step('logout fence precondition: unconfirmed logout leaves the recovery fence', async () => { + await page.locator('aside').getByRole('button', { name: 'Log out' }).click() + await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeVisible() + await expect.poll(() => readLogoutFence(page)).toBe('true') + // The aborted logout never revoked the server-side session or cleared the + // cookie, which is exactly the state the recovery link must recover from. + expect(await sessionCookieValue(page)).toBe(priorSession) }) + await page.unroute('**/api/v1/auth/logout') + const stillValidAfterLogout = await fetchMeWithCookie(page, priorSession) + expect(stillValidAfterLogout.status).toBe(200) // E2E_GUARDIAN_NAVIGATION: email-reset-entry — the synthetic emailed recovery link await page.goto('/invitations/synthetic-reset-proof') diff --git a/tests/e2e/playwright/profile-ui-recovery/journeys.ts b/tests/e2e/playwright/profile-ui-recovery/journeys.ts index bc838e9585..789c3033db 100644 --- a/tests/e2e/playwright/profile-ui-recovery/journeys.ts +++ b/tests/e2e/playwright/profile-ui-recovery/journeys.ts @@ -22,6 +22,7 @@ export const ONBOARDING_MEMBER = { type ControlApiMetrics = { meRequests: Array<{ userId: string }> + passwordTokenRequests: Array<{ token: string; invitationId: string }> loginSuccesses: Array<{ userId: string }> passwordUpdates: Array<{ userId: string; invitationId: string | null }> logoutRequests: Array<{ hasToken: boolean }> @@ -45,7 +46,8 @@ export async function readControlApiMetrics(page: Page): Promise { const cookies = await page.context().cookies() - return cookies.find(cookie => cookie.name === 'profile_session')?.value || null + const cookie = cookies.find(candidate => candidate.name === 'profile_session') + return cookie ? cookie.value : null } export async function signInThroughUi(page: Page, email: string, password: string) { @@ -60,10 +62,8 @@ export async function signInThroughUi(page: Page, email: string, password: strin expect((await loginResponse).status()).toBe(200) } -export async function seedLogoutFence(page: Page): Promise { - await page.evaluate(key => window.localStorage.setItem(key, 'true'), LOGOUT_FENCE_KEY) -} - +// Read-only view of the logout fence. The lane never seeds it: J2 produces it +// through a real logout whose revocation request is aborted. export async function readLogoutFence(page: Page): Promise { return page.evaluate(key => window.localStorage.getItem(key), LOGOUT_FENCE_KEY) } @@ -72,8 +72,9 @@ export async function fetchMeWithCookie(page: Page, cookieValue: string) { const response = await page.request.get('/external-rest-api/api/v1/me', { headers: { cookie: `profile_session=${cookieValue}` }, }) + const text = await response.text() return { status: response.status(), - body: response.ok() ? await response.json() : await response.json().catch(() => null), + body: text ? (JSON.parse(text) as unknown) : null, } } diff --git a/tools/test_e2e_static_audit_scope.py b/tools/test_e2e_static_audit_scope.py index ebd7871436..86de59969c 100644 --- a/tools/test_e2e_static_audit_scope.py +++ b/tools/test_e2e_static_audit_scope.py @@ -195,10 +195,11 @@ def test_one_marker_does_not_allow_two_calls_on_the_same_line(self) -> None: def test_removing_the_real_spec_marker_is_detected(self) -> None: spec = Path( - "tests/e2e/playwright/profile-ui-recovery/reset-session-hydration.spec.ts" + "tests/e2e/playwright/profile-ui-recovery/j2-password-recovery.spec.ts" ).read_text(encoding="utf-8") without_marker = spec.replace( - "// E2E_GUARDIAN_NAVIGATION: email-reset-entry — the synthetic emailed reset URL\n", "" + "// E2E_GUARDIAN_NAVIGATION: email-reset-entry — the synthetic emailed recovery link\n", + "", ) self.assertTrue( any( From 26572202dedffee7a50d7436db041f8d79b0644a Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 21 Oct 2026 21:12:29 +0200 Subject: [PATCH 353/528] fix(profile-ui): close recovery fence and terminal-state gaps Release the logout fence only for a current pending unexpired password-reset invitation, keep it for used, expired and invalid links, and turn definitive submit errors into a terminal panel with sign-in and a fresh recovery link. Assert the browser route after recovery sign-in, login and logout, cover the password-updated screen through a direct invitation entry, and add a real product cookie witness to the negative routes. Extend the recovery fixture with used and pending reset invitations for the fence journey. --- .../__tests__/InvitationClient.reset.test.tsx | 127 ++++++++++++++++-- .../[token]/InvitationActionPanel.tsx | 14 ++ .../invitations/[token]/InvitationClient.tsx | 59 +++++++- profile-ui/package-lock.json | 4 +- profile-ui/package.json | 2 +- tests/e2e/playwright/package-lock.json | 4 +- tests/e2e/playwright/package.json | 2 +- .../profile-ui-recovery-control-api.mjs | 26 ++++ .../j1-invitation-onboarding.spec.ts | 23 +++- .../j2-password-recovery.spec.ts | 73 ++++++++++ .../profile-ui-recovery/j3-logout.spec.ts | 11 ++ .../profile-ui-recovery/journeys.ts | 9 ++ .../negative-routes.spec.ts | 28 +++- 13 files changed, 358 insertions(+), 24 deletions(-) diff --git a/profile-ui/app/components/__tests__/InvitationClient.reset.test.tsx b/profile-ui/app/components/__tests__/InvitationClient.reset.test.tsx index ef449523e9..3318fedffd 100644 --- a/profile-ui/app/components/__tests__/InvitationClient.reset.test.tsx +++ b/profile-ui/app/components/__tests__/InvitationClient.reset.test.tsx @@ -188,23 +188,134 @@ describe('InvitationClient guided password reauthentication', () => { expect(screen.queryByRole('heading', { name: 'Password updated' })).toBeNull() }) - it('shows a recovery-link error instead of blaming the new password', async () => { - mocks.setupInvitationPasswordWithToken.mockRejectedValue( - new ProfileApiError(400, 'invalid_invitation', '400 Bad Request - recovery link rejected') + it.each([ + [ + 'invitation_not_pending', + 409, + '409 Conflict - invitation already used', + 'This invitation has already been used.', + ], + [ + 'invalid_invitation', + 400, + '400 Bad Request - recovery link rejected', + 'This recovery link is invalid or expired.', + ], + ['not_found', 404, '404 Not Found - recovery link unknown', 'Invitation not found.'], + ] as const)( + 'turns a %s submit failure into usable terminal actions instead of blaming the new password', + async (code, status, detail, message) => { + mocks.setupInvitationPasswordWithToken.mockRejectedValue( + new ProfileApiError(status, code, detail) + ) + render( + + ) + + // The initial preview rendered the live form; the proof dies afterwards. + expect(screen.getByRole('button', { name: 'Reset password' })).toBeInTheDocument() + fillPassword() + fireEvent.click(screen.getByRole('button', { name: 'Reset password' })) + + expect(await screen.findByText(message)).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Password updated' })).toBeNull() + expect(screen.queryByLabelText('Password')).toBeNull() + expect(screen.queryByRole('button', { name: 'Reset password' })).toBeNull() + expect(screen.getByRole('link', { name: 'Sign in' }).getAttribute('href')).toBe( + PROFILE_ROUTES.login({ email: passwordReset.email }) + ) + expect( + screen.getByRole('link', { name: 'Request a new recovery link' }).getAttribute('href') + ).toBe(PROFILE_ROUTES.forgotPassword({ email: passwordReset.email })) + expect(mocks.setupInvitationPasswordWithToken).toHaveBeenCalledOnce() + } + ) + + it('offers only sign in when a member invitation proof is invalidated after preview', async () => { + mocks.setupAcceptedInvitationPasswordWithToken.mockRejectedValue( + new ProfileApiError(409, 'invitation_not_pending', '409 Conflict - invitation_not_pending') ) render( ) fillPassword() - fireEvent.click(screen.getByRole('button', { name: 'Reset password' })) + fireEvent.click(screen.getByRole('button', { name: 'Set password and continue' })) - expect(await screen.findByText('This recovery link is invalid or expired.')).toBeInTheDocument() - expect(screen.queryByRole('heading', { name: 'Password updated' })).toBeNull() + expect(await screen.findByText('This invitation has already been used.')).toBeInTheDocument() + expect(screen.queryByLabelText('Password')).toBeNull() + expect(screen.getByRole('link', { name: 'Sign in' }).getAttribute('href')).toBe( + PROFILE_ROUTES.login({ email: acceptedMemberInvitation.email }) + ) + expect(screen.queryByRole('link', { name: 'Request a new recovery link' })).toBeNull() + expect(mocks.setupAcceptedInvitationPasswordWithToken).toHaveBeenCalledOnce() + }) + + it('releases the logout fence only for a current pending recovery capability', () => { + window.localStorage.setItem('profile_logout_unconfirmed', 'true') + + const { rerender } = render( + + ) + // A redeemed link is visible but cannot recover: the fence must survive. + expect(screen.getByText('This invitation has already been used.')).toBeInTheDocument() + expect(window.localStorage.getItem('profile_logout_unconfirmed')).toBe('true') + + rerender( + + ) + expect( + screen.getByText('This invitation has expired.', { selector: '.message--error-plain' }) + ).toBeInTheDocument() + expect(window.localStorage.getItem('profile_logout_unconfirmed')).toBe('true') + + rerender( + + ) + expect(screen.getByRole('alert')).toHaveTextContent('Invitation not found.') + expect(window.localStorage.getItem('profile_logout_unconfirmed')).toBe('true') + + rerender( + + ) + // A current pending capability is the only case that releases the fence. + expect(screen.getByRole('button', { name: 'Reset password' })).toBeInTheDocument() + expect(window.localStorage.getItem('profile_logout_unconfirmed')).toBeNull() }) it('does not resubmit a reset proof after an ambiguous recovery outcome', async () => { diff --git a/profile-ui/app/invitations/[token]/InvitationActionPanel.tsx b/profile-ui/app/invitations/[token]/InvitationActionPanel.tsx index 977d6e5e9d..b4874310d4 100644 --- a/profile-ui/app/invitations/[token]/InvitationActionPanel.tsx +++ b/profile-ui/app/invitations/[token]/InvitationActionPanel.tsx @@ -34,6 +34,7 @@ type InvitationActionPanelProps = forgotPasswordHref: string } | { mode: 'link-error'; loginHref: string } + | { mode: 'terminal'; loginHref: string; forgotPasswordHref?: string } | { mode: 'expired'; loginHref: string } | { mode: 'accept' @@ -151,6 +152,19 @@ export function InvitationActionPanel(props: InvitationActionPanelProps) {
) + case 'terminal': + return ( +
+ + Sign in + + {props.forgotPasswordHref ? ( + + Request a new recovery link + + ) : null} +
+ ) case 'expired': return (
diff --git a/profile-ui/app/invitations/[token]/InvitationClient.tsx b/profile-ui/app/invitations/[token]/InvitationClient.tsx index fd5cb9d5b4..7718ae301d 100644 --- a/profile-ui/app/invitations/[token]/InvitationClient.tsx +++ b/profile-ui/app/invitations/[token]/InvitationClient.tsx @@ -27,7 +27,32 @@ import type { InvitationPreview } from '@/app/types/api' import { InvitationActionPanel } from './InvitationActionPanel' import type { InvitationClientProps } from './types' -type PasswordPhase = 'editing' | 'submitting' | 'updated' | 'outcome_unknown' +type PasswordPhase = 'editing' | 'submitting' | 'updated' | 'outcome_unknown' | 'terminal' + +// A submit error that means the proof can never be used again. Retrying the +// same form only repeats the failure, so the screen must offer a way forward. +const TERMINAL_PASSWORD_ERROR_CODES = new Set([ + 'invitation_not_pending', + 'invalid_invitation', + 'not_found', +]) + +function isTerminalPasswordError(value: unknown): boolean { + return ( + value instanceof ProfileApiError && + value.code !== null && + TERMINAL_PASSWORD_ERROR_CODES.has(value.code) + ) +} + +// The logout fence may only be released while the recovery capability can +// still set a password: a pending, unexpired password-reset invitation. +function isCurrentRecoveryCapability(invitation: InvitationPreview | null, now: number): boolean { + if (!invitation || invitation.purpose !== 'password_reset') return false + if (invitation.status !== 'pending') return false + const expiresAt = Date.parse(invitation.expiresAt) + return Number.isFinite(expiresAt) && expiresAt > now +} function statusForInvitation(invitation: InvitationPreview): string { if (invitation.purpose === 'password_reset') { @@ -114,9 +139,12 @@ export function InvitationClient({ }, [invitationToken]) useEffect(() => { - // A recovery link is a fresh sign-in path. A previous logout that could not - // confirm revocation leaves a fence that would otherwise block this form. - if (initialInvitation?.purpose === 'password_reset') clearUnconfirmedLogout() + // A recovery link is a fresh sign-in path only while it can still set the + // password. A redeemed, expired or invalid link must keep the fence so a + // surviving session cannot resume without a login. + if (isCurrentRecoveryCapability(initialInvitation, Date.now())) { + clearUnconfirmedLogout() + } }, [initialInvitation]) const invitationExpired = useMemo(() => { @@ -152,6 +180,7 @@ export function InvitationClient({ const busy = submitting !== null const passwordUpdated = passwordPhase === 'updated' const recoveryOutcomeUnknown = isPasswordReset && passwordPhase === 'outcome_unknown' + const passwordTerminal = passwordPhase === 'terminal' const invitationUsed = Boolean( invitation && invitation.status === 'accepted' && !invitation.passwordPending ) @@ -159,6 +188,7 @@ export function InvitationClient({ invitation && !passwordUpdated && !recoveryOutcomeUnknown && + !passwordTerminal && !invitationUsed && !invitationExpired && ((isPasswordReset && invitation.status === 'pending') || @@ -171,9 +201,11 @@ export function InvitationClient({ : buildInvitationHeading(invitation?.teamName) const pageStatus = passwordUpdated ? 'Your password has been updated.' - : invitationUsed - ? 'This invitation has already been used.' - : status || (error ? 'We could not open this invitation.' : 'Invitation loaded.') + : passwordTerminal + ? 'Sign in to continue with your account.' + : invitationUsed + ? 'This invitation has already been used.' + : status || (error ? 'We could not open this invitation.' : 'Invitation loaded.') function friendlyInvitationError(value: unknown, isRecovery = false): string { if (!(value instanceof Error)) return 'Failed to update invitation.' @@ -312,6 +344,13 @@ export function InvitationClient({ setError( 'We could not confirm whether your password change completed. Try signing in with the new password. If that does not work, request a new reset link.' ) + } else if (isTerminalPasswordError(nextError)) { + // The proof was invalidated after this screen loaded. Offering the same + // dead form again would trap the person, so end at a usable action. + setPasswordPhase('terminal') + setPassword('') + setConfirmPassword('') + setError(friendlyInvitationError(nextError, isPasswordReset)) } else { setPasswordPhase('editing') setError(friendlyInvitationError(nextError, isPasswordReset)) @@ -396,6 +435,12 @@ export function InvitationClient({ loginHref={profileLoginHref} forgotPasswordHref={forgotPasswordHref} /> + ) : passwordTerminal ? ( + ) : invitationUsed ? ( { + // E2E_GUARDIAN_NAVIGATION: root-entry — the ordinary login that owns the live session + await page.goto('/') + await signInThroughUi(page, LOGOUT_MEMBER.email, LOGOUT_MEMBER.password) + await expectBrowserRoute(page, { pathname: '/' }) + await expect( + page.getByRole('heading', { name: 'Welcome, Synthetic Logout Member' }) + ).toBeVisible() + + const liveSession = await sessionCookieValue(page) + expect(liveSession).toBeTruthy() + if (!liveSession) throw new Error('login did not set a profile session') + const validBefore = await fetchMeWithCookie(page, liveSession) + expect(validBefore.status).toBe(200) + expect(validBefore.body).toMatchObject({ id: LOGOUT_MEMBER.id }) + + await page.route('**/api/v1/auth/logout', route => route.abort('failed')) + await test.step('logout fence precondition: unconfirmed logout leaves the recovery fence', async () => { + await page.locator('aside').getByRole('button', { name: 'Log out' }).click() + await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeVisible() + await expect.poll(() => readLogoutFence(page)).toBe('true') + expect(await sessionCookieValue(page)).toBe(liveSession) + }) + await page.unroute('**/api/v1/auth/logout') + + // E2E_GUARDIAN_NAVIGATION: email-reset-entry — a redeemed recovery link cannot release the fence + await page.goto('/invitations/synthetic-used-reset-proof') + await expect( + page.getByText('This invitation has already been used.', { exact: true }) + ).toBeVisible() + expect(await readLogoutFence(page)).toBe('true') + expect(await sessionCookieValue(page)).toBe(liveSession) + const stillValid = await fetchMeWithCookie(page, liveSession) + expect(stillValid.status).toBe(200) + + // The fence still blocks the surviving cookie from resuming the session. + // E2E_GUARDIAN_NAVIGATION: root-entry — the fenced live session stays at login + await page.goto('/') + await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeVisible() + await expectBrowserRoute(page, { pathname: '/' }) + await expect(page.getByRole('heading', { name: 'Welcome, Synthetic Logout Member' })).toHaveCount( + 0 + ) + expect(await readLogoutFence(page)).toBe('true') + + // A current pending recovery capability is the only release witness. + // E2E_GUARDIAN_NAVIGATION: email-reset-entry — a current recovery link releases the fence + await page.goto('/invitations/synthetic-pending-reset-proof') + await expect(page.getByRole('heading', { name: 'Reset password' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Reset password' })).toBeVisible() + await expect.poll(() => readLogoutFence(page)).toBeNull() +}) diff --git a/tests/e2e/playwright/profile-ui-recovery/j3-logout.spec.ts b/tests/e2e/playwright/profile-ui-recovery/j3-logout.spec.ts index ef2413f782..1f267c8f0d 100644 --- a/tests/e2e/playwright/profile-ui-recovery/j3-logout.spec.ts +++ b/tests/e2e/playwright/profile-ui-recovery/j3-logout.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from '@playwright/test' import { LOGOUT_MEMBER, + expectBrowserRoute, fetchMeWithCookie, readControlApiMetrics, sessionCookieValue, @@ -11,6 +12,7 @@ test('J3 logout returns to login and the previous session is rejected', async ({ // E2E_GUARDIAN_NAVIGATION: root-entry — the ordinary login that owns the session await page.goto('/') await signInThroughUi(page, LOGOUT_MEMBER.email, LOGOUT_MEMBER.password) + await expectBrowserRoute(page, { pathname: '/' }) await expect( page.getByRole('heading', { name: 'Welcome, Synthetic Logout Member' }) ).toBeVisible() @@ -19,6 +21,12 @@ test('J3 logout returns to login and the previous session is rejected', async ({ expect(priorSession).toBeTruthy() if (!priorSession) throw new Error('login did not set a profile session') + // Logout from an authenticated sub-route so the resulting home URL is a real + // navigation signal instead of the page's starting URL. + await page.getByRole('link', { name: 'Settings' }).click() + await expect(page).toHaveURL(/\/settings\/profile$/) + await expectBrowserRoute(page, { pathname: '/settings/profile' }) + const logoutResponse = page.waitForResponse( response => response.url().endsWith('/api/v1/auth/logout') && response.request().method() === 'POST' @@ -27,7 +35,9 @@ test('J3 logout returns to login and the previous session is rejected', async ({ const logoutResult = await logoutResponse expect(logoutResult.status()).toBe(200) + await expect(page).toHaveURL(/\/$/) await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeVisible() + await expectBrowserRoute(page, { pathname: '/' }) await expect(page.getByRole('heading', { name: 'Welcome, Synthetic Logout Member' })).toHaveCount( 0 ) @@ -37,6 +47,7 @@ test('J3 logout returns to login and the previous session is rejected', async ({ expect(oldSession.status).toBe(401) await page.goto('/') + await expectBrowserRoute(page, { pathname: '/' }) await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeVisible() await expect(page.getByRole('heading', { name: 'Welcome, Synthetic Logout Member' })).toHaveCount( 0 diff --git a/tests/e2e/playwright/profile-ui-recovery/journeys.ts b/tests/e2e/playwright/profile-ui-recovery/journeys.ts index 789c3033db..7aa8d69d56 100644 --- a/tests/e2e/playwright/profile-ui-recovery/journeys.ts +++ b/tests/e2e/playwright/profile-ui-recovery/journeys.ts @@ -44,6 +44,15 @@ export async function readControlApiMetrics(page: Page): Promise } +) { + const current = new URL(page.url()) + expect(current.pathname).toBe(expected.pathname) + expect(Object.fromEntries(current.searchParams.entries())).toEqual(expected.query ?? {}) +} + export async function sessionCookieValue(page: Page): Promise { const cookies = await page.context().cookies() const cookie = cookies.find(candidate => candidate.name === 'profile_session') diff --git a/tests/e2e/playwright/profile-ui-recovery/negative-routes.spec.ts b/tests/e2e/playwright/profile-ui-recovery/negative-routes.spec.ts index f13a7a1358..9d6b84feef 100644 --- a/tests/e2e/playwright/profile-ui-recovery/negative-routes.spec.ts +++ b/tests/e2e/playwright/profile-ui-recovery/negative-routes.spec.ts @@ -1,7 +1,31 @@ import { expect, test } from '@playwright/test' -import { readControlApiMetrics, sessionCookieValue } from './journeys' +import { + LOGOUT_MEMBER, + readControlApiMetrics, + sessionCookieValue, + signInThroughUi, +} from './journeys' + +test('negative routes show an explicit error and never issue a session', async ({ + page, + browser, +}) => { + // Same-observer witness: a real product login in an isolated context proves + // this cookie observer sees product-issued sessions. It never seeds the + // credentials or cookies used by the negative navigation below. + const witnessContext = await browser.newContext({ baseURL: test.info().project.use.baseURL }) + const witnessPage = await witnessContext.newPage() + try { + await witnessPage.goto('/') + await signInThroughUi(witnessPage, LOGOUT_MEMBER.email, LOGOUT_MEMBER.password) + await expect( + witnessPage.getByRole('heading', { name: 'Welcome, Synthetic Logout Member' }) + ).toBeVisible() + expect(await sessionCookieValue(witnessPage)).toBeTruthy() + } finally { + await witnessContext.close() + } -test('negative routes show an explicit error and never issue a session', async ({ page }) => { const before = await readControlApiMetrics(page) const expiredDocument = page.waitForResponse(response => From f09e3dd7a07a2bf9cdc96a7c7c7ad3176c7530dc Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 21 Oct 2026 22:15:05 +0200 Subject: [PATCH 354/528] fix(invitations): classify terminal password failures by edge codes Publish stable machine codes for the invitation password failures that previously fell back to English sentences, keep the legacy error field, and classify terminal states in profile-ui from the code. Release the logout fence only after the server accepts a reset submission. A recovery page view no longer depends on the browser clock or on a serialized preview; the reset request itself gets one scoped fence bypass. Used, expired and invalid links keep the fence. Test the real edge bodies through the profile-ui parser and component, add the second negative-route cookie witness, and record the per-test mutation map for the parity, entry-state, fence and remaining changed tests. --- external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- external-rest-api/src/releaseManifest.ts | 2 +- external-rest-api/src/routes/invitations.ts | 39 ++++-- ...wordPublicErrors.profileUiContract.test.ts | 115 +++++++++++++++ ...InvitationClient.recoveryContract.test.tsx | 8 +- .../__tests__/InvitationClient.reset.test.tsx | 132 ++++++++++-------- .../components/__tests__/api.logout.test.ts | 67 +++++++++ .../invitations/[token]/InvitationClient.tsx | 52 +++---- profile-ui/lib/api.ts | 54 ++++--- profile-ui/lib/invitationErrors.ts | 21 +++ profile-ui/package-lock.json | 4 +- profile-ui/package.json | 2 +- .../invitation-password-public-errors.json | 51 +++++++ tests/e2e/playwright/package-lock.json | 4 +- tests/e2e/playwright/package.json | 2 +- .../j2-password-recovery.spec.ts | 18 ++- .../negative-routes.spec.ts | 17 +++ 18 files changed, 462 insertions(+), 132 deletions(-) create mode 100644 external-rest-api/test/invitationPasswordPublicErrors.profileUiContract.test.ts create mode 100644 profile-ui/lib/invitationErrors.ts create mode 100644 tests/contracts/invitation-password-public-errors.json diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index ca9e143050..5ae3af5342 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.149", + "version": "0.1.150", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.149", + "version": "0.1.150", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index b8b38d16b5..9e3fee067d 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.149", + "version": "0.1.150", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index d1b696425a..6938b58bcc 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.11.0', - externalRestApiVersion: '0.1.149', + externalRestApiVersion: '0.1.150', rpcProxyVersion: '0.1.132', desktopVersion: '0.11.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/src/routes/invitations.ts b/external-rest-api/src/routes/invitations.ts index a63d09289a..b1045fafaa 100644 --- a/external-rest-api/src/routes/invitations.ts +++ b/external-rest-api/src/routes/invitations.ts @@ -64,6 +64,29 @@ function acceptInvitationErrorResponse(error: AcceptInvitationError): { return { status: 400, body: { error: 'Invitation is not pending' } } } +// The password fallback publishes stable machine codes: profile-ui classifies +// terminal states from `code` (profile-ui/lib/invitationErrors.ts). +const FALLBACK_ACCEPT_ERROR_RESPONSES: Record< + AcceptInvitationError, + { status: number; body: Record } +> = { + invalid: { status: 400, body: { error: 'Invalid invitation' } }, + not_found: { status: 404, body: { error: 'Invitation not found', code: 'not_found' } }, + forbidden: { status: 403, body: { error: 'Invitation email does not match authenticated user' } }, + expired: { status: 410, body: { error: 'Invitation has expired', code: 'expired' } }, + not_pending: { + status: 400, + body: { error: 'Invitation is not pending', code: 'invitation_not_pending' }, + }, +} + +function fallbackAcceptErrorResponse(error: AcceptInvitationError): { + status: number + body: Record +} { + return FALLBACK_ACCEPT_ERROR_RESPONSES[error] +} + function sendAcceptInvitationError(res: Response, error: AcceptInvitationError): void { const response = acceptInvitationErrorResponse(error) res.status(response.status).json(response.body) @@ -85,7 +108,7 @@ function sendInvitationPasswordError( return } if (error === 'not_found') { - res.status(404).json({ error: 'Invitation not found' }) + res.status(404).json({ error: 'Invitation not found', code: 'not_found' }) return } if (error === 'forbidden') { @@ -97,11 +120,13 @@ function sendInvitationPasswordError( return } if (error === 'not_pending') { - res.status(409).json({ error: 'Invitation has already been used' }) + res + .status(409) + .json({ error: 'Invitation has already been used', code: 'invitation_not_pending' }) return } if (error === 'expired') { - res.status(410).json({ error: 'Invitation has expired' }) + res.status(410).json({ error: 'Invitation has expired', code: 'expired' }) return } res.status(400).json({ error: 'Password must be between 8 and 256 characters' }) @@ -199,16 +224,14 @@ async function invitationPasswordAuthFromRequest(req: Request): Promise< const accepted = await acceptInvitation(token, email, requestedSessionContract(req)) if (accepted.error) { - return acceptInvitationErrorResponse(accepted.error) + return fallbackAcceptErrorResponse(accepted.error) } // A replay is an explicit success without a session. It must not continue // into the password change: the token no longer proves a pending invitation, // so it maps exactly like a not-pending acceptance instead of the 502 that - // marks a broken first-acceptance response. `error` stays byte-identical to - // that mapping; `code` is the stable machine signal for the fallback replay. + // marks a broken first-acceptance response. if (accepted.data?.replay) { - const replay = acceptInvitationErrorResponse('not_pending') - return { status: replay.status, body: { ...replay.body, code: 'invitation_not_pending' } } + return fallbackAcceptErrorResponse('not_pending') } if (!accepted.data?.token || !accepted.data.userId) { return { status: 502, body: { error: 'Failed to start invited session' } } diff --git a/external-rest-api/test/invitationPasswordPublicErrors.profileUiContract.test.ts b/external-rest-api/test/invitationPasswordPublicErrors.profileUiContract.test.ts new file mode 100644 index 0000000000..8d2342f0d1 --- /dev/null +++ b/external-rest-api/test/invitationPasswordPublicErrors.profileUiContract.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import request from 'supertest' +import { ProfileApiError, apiSend } from '../../profile-ui/lib/api.js' +import { isTerminalPasswordError } from '../../profile-ui/lib/invitationErrors.js' +import contractDocument from '../../tests/contracts/invitation-password-public-errors.json' +import { createInvitationsRouter } from '../src/routes/invitations.js' + +type ContractEntry = { + route: 'password' | 'password-reset' + previewPurpose?: 'password_reset' | 'member_invitation' + serviceError?: string + acceptError?: string + status: number + body: Record + componentPreview: 'reset' | 'accepted_member' + terminal: boolean + uiMessage: string +} + +const contract = contractDocument as Record + +const authTokenMock = vi.hoisted(() => ({ + verifyToken: vi.fn(), +})) + +const serviceMock = vi.hoisted(() => ({ + acceptInvitation: vi.fn(), + createDesktopAuthorization: vi.fn(), + getInvitationByToken: vi.fn(), + listPendingInvitations: vi.fn(), + setupInvitationPassword: vi.fn(), + setupInvitationPasswordWithToken: vi.fn(), +})) + +vi.mock('../src/authToken.js', () => authTokenMock) +vi.mock('../src/services/invitationsService.js', () => serviceMock) +vi.mock('../src/services/invitationPasswordService.js', () => ({ + setupInvitationPassword: serviceMock.setupInvitationPassword, + setupInvitationPasswordWithToken: serviceMock.setupInvitationPasswordWithToken, +})) + +function makeApp() { + const app = express() + app.set('trust proxy', 1) + app.use(express.json()) + app.use(createInvitationsRouter()) + return app +} + +async function edgeResponse(name: string, entry: ContractEntry, sourceIp: string) { + serviceMock.getInvitationByToken.mockResolvedValue({ + id: 'invitation-row-1', + email: 'invitee@example.com', + purpose: entry.previewPurpose, + status: 'pending', + }) + if (entry.acceptError) { + serviceMock.acceptInvitation.mockResolvedValue({ error: entry.acceptError }) + } + if (entry.serviceError) { + serviceMock.setupInvitationPasswordWithToken.mockResolvedValue({ error: entry.serviceError }) + } + + const path = entry.route === 'password' ? '/invitations/password' : '/invitations/password-reset' + return request(makeApp()).post(path).set('x-forwarded-for', sourceIp).send({ + token: 'synthetic-contract-proof', + email: 'invitee@example.com', + invitationId: 'invitation-row-1', + password: 'Synthetic-Contract-Password-123', + }) +} + +describe('invitation password public errors reach Profile UI classification', () => { + beforeEach(() => { + Object.values(serviceMock).forEach(mock => mock.mockReset()) + authTokenMock.verifyToken.mockReset() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it.each(Object.entries(contract).map(([name, entry], index) => [name, entry, index] as const))( + 'classifies the real edge %s body through the Profile UI parser', + async (name, entry, index) => { + const response = await edgeResponse(name, entry, `198.51.100.${20 + index}`) + + expect(response.status).toBe(entry.status) + expect(response.body).toEqual(entry.body) + expect(String(response.headers['set-cookie'] ?? '')).not.toMatch(/profile_session=[^;]+/) + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify(response.body), { + status: response.status, + statusText: 'Synthetic Edge Status', + headers: { 'content-type': 'application/json' }, + }) + ) + ) + + const parsed = await apiSend('POST', '/api/v1/invitations/password-reset', {}).catch( + error => error + ) + + expect(parsed).toBeInstanceOf(ProfileApiError) + expect((parsed as ProfileApiError).status).toBe(entry.status) + expect((parsed as ProfileApiError).code).toBe(entry.body.code ?? entry.body.error) + expect(isTerminalPasswordError(parsed)).toBe(entry.terminal) + expect(fetch).toHaveBeenCalledOnce() + } + ) +}) diff --git a/profile-ui/app/components/__tests__/InvitationClient.recoveryContract.test.tsx b/profile-ui/app/components/__tests__/InvitationClient.recoveryContract.test.tsx index bce1423751..f962fc110c 100644 --- a/profile-ui/app/components/__tests__/InvitationClient.recoveryContract.test.tsx +++ b/profile-ui/app/components/__tests__/InvitationClient.recoveryContract.test.tsx @@ -152,16 +152,20 @@ describe('R6 recovery outcome and guided reauthentication', () => { expect(mocks.setupInvitationPasswordWithToken).toHaveBeenCalledTimes(2) }) - it('clears the logout fence when the recovery link opens and still completes the reset', async () => { + it('keeps the logout fence on the recovery preview and clears it after the reset is accepted', async () => { window.localStorage.setItem(LOGOUT_FENCE_KEY, 'true') expect(window.localStorage.getItem(LOGOUT_FENCE_KEY)).toBe('true') renderReset() - await waitFor(() => expect(window.localStorage.getItem(LOGOUT_FENCE_KEY)).toBeNull()) + // Opening the link is not an authoritative recovery result. + expect(screen.getByRole('button', { name: 'Reset password' })).toBeInTheDocument() + expect(window.localStorage.getItem(LOGOUT_FENCE_KEY)).toBe('true') + fillPassword() submitReset() await waitFor(expectPasswordUpdatedScreen) + expect(window.localStorage.getItem(LOGOUT_FENCE_KEY)).toBeNull() expect(mocks.setupInvitationPasswordWithToken).toHaveBeenCalledOnce() }) }) diff --git a/profile-ui/app/components/__tests__/InvitationClient.reset.test.tsx b/profile-ui/app/components/__tests__/InvitationClient.reset.test.tsx index 3318fedffd..72929bfb25 100644 --- a/profile-ui/app/components/__tests__/InvitationClient.reset.test.tsx +++ b/profile-ui/app/components/__tests__/InvitationClient.reset.test.tsx @@ -3,8 +3,23 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re import { PROFILE_ROUTES } from '@constants/routes' import { ProfileApiError } from '@lib/api' import type { InvitationPreview } from '@/app/types/api' +import contractDocument from '../../../../tests/contracts/invitation-password-public-errors.json' import { InvitationClient } from '../../invitations/[token]/InvitationClient' +type ContractEntry = { + route: 'password' | 'password-reset' + previewPurpose?: string + serviceError?: string + acceptError?: string + status: number + body: Record + componentPreview: 'reset' | 'accepted_member' + terminal: boolean + uiMessage: string +} + +const contract = contractDocument as Record + const mocks = vi.hoisted(() => ({ acceptInvitation: vi.fn(), setupAcceptedInvitationPasswordWithToken: vi.fn(), @@ -73,6 +88,19 @@ function expectPasswordUpdatedScreen(email: string) { expect(screen.queryByLabelText('Password')).toBeNull() } +function stubEdgeResponse(status: number, body: Record) { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify(body), { + status, + statusText: 'Synthetic Edge Status', + headers: { 'content-type': 'application/json' }, + }) + ) + ) +} + beforeEach(() => { window.localStorage.clear() mocks.acceptInvitation.mockReset() @@ -89,7 +117,10 @@ beforeEach(() => { }) }) -afterEach(cleanup) +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) describe('InvitationClient guided password reauthentication', () => { it('moves a completed reset to the password-updated screen with a login link', async () => { @@ -188,78 +219,59 @@ describe('InvitationClient guided password reauthentication', () => { expect(screen.queryByRole('heading', { name: 'Password updated' })).toBeNull() }) - it.each([ - [ - 'invitation_not_pending', - 409, - '409 Conflict - invitation already used', - 'This invitation has already been used.', - ], - [ - 'invalid_invitation', - 400, - '400 Bad Request - recovery link rejected', - 'This recovery link is invalid or expired.', - ], - ['not_found', 404, '404 Not Found - recovery link unknown', 'Invitation not found.'], - ] as const)( - 'turns a %s submit failure into usable terminal actions instead of blaming the new password', - async (code, status, detail, message) => { - mocks.setupInvitationPasswordWithToken.mockRejectedValue( - new ProfileApiError(status, code, detail) - ) + it.each(Object.entries(contract))( + 'turns the real %s edge response into terminal actions from the parsed body', + async (_name, entry) => { + const actual = await vi.importActual('@lib/api') + const preview = entry.componentPreview === 'reset' ? passwordReset : acceptedMemberInvitation + if (entry.componentPreview === 'reset') { + mocks.setupInvitationPasswordWithToken.mockImplementation( + async (invitationUrlId: string, email: string, invitationId: string, password: string) => + actual.setupInvitationPasswordWithToken(invitationUrlId, email, invitationId, password) + ) + } else { + mocks.setupAcceptedInvitationPasswordWithToken.mockImplementation( + async (invitationUrlId: string, email: string, invitationId: string, password: string) => + actual.setupAcceptedInvitationPasswordWithToken( + invitationUrlId, + email, + invitationId, + password + ) + ) + } + stubEdgeResponse(entry.status, entry.body) + render( ) + const submitLabel = + entry.componentPreview === 'reset' ? 'Reset password' : 'Set password and continue' // The initial preview rendered the live form; the proof dies afterwards. - expect(screen.getByRole('button', { name: 'Reset password' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: submitLabel })).toBeInTheDocument() fillPassword() - fireEvent.click(screen.getByRole('button', { name: 'Reset password' })) + fireEvent.click(screen.getByRole('button', { name: submitLabel })) - expect(await screen.findByText(message)).toBeInTheDocument() + expect(await screen.findByText(entry.uiMessage)).toBeInTheDocument() expect(screen.queryByRole('heading', { name: 'Password updated' })).toBeNull() expect(screen.queryByLabelText('Password')).toBeNull() - expect(screen.queryByRole('button', { name: 'Reset password' })).toBeNull() + expect(screen.queryByRole('button', { name: submitLabel })).toBeNull() expect(screen.getByRole('link', { name: 'Sign in' }).getAttribute('href')).toBe( - PROFILE_ROUTES.login({ email: passwordReset.email }) + PROFILE_ROUTES.login({ email: preview.email }) ) expect( screen.getByRole('link', { name: 'Request a new recovery link' }).getAttribute('href') - ).toBe(PROFILE_ROUTES.forgotPassword({ email: passwordReset.email })) - expect(mocks.setupInvitationPasswordWithToken).toHaveBeenCalledOnce() + ).toBe(PROFILE_ROUTES.forgotPassword({ email: preview.email })) + expect(fetch).toHaveBeenCalledOnce() } ) - it('offers only sign in when a member invitation proof is invalidated after preview', async () => { - mocks.setupAcceptedInvitationPasswordWithToken.mockRejectedValue( - new ProfileApiError(409, 'invitation_not_pending', '409 Conflict - invitation_not_pending') - ) - render( - - ) - - fillPassword() - fireEvent.click(screen.getByRole('button', { name: 'Set password and continue' })) - - expect(await screen.findByText('This invitation has already been used.')).toBeInTheDocument() - expect(screen.queryByLabelText('Password')).toBeNull() - expect(screen.getByRole('link', { name: 'Sign in' }).getAttribute('href')).toBe( - PROFILE_ROUTES.login({ email: acceptedMemberInvitation.email }) - ) - expect(screen.queryByRole('link', { name: 'Request a new recovery link' })).toBeNull() - expect(mocks.setupAcceptedInvitationPasswordWithToken).toHaveBeenCalledOnce() - }) - - it('releases the logout fence only for a current pending recovery capability', () => { + it('keeps the logout fence on every preview and clears it only after the reset is accepted', async () => { window.localStorage.setItem('profile_logout_unconfirmed', 'true') const { rerender } = render( @@ -313,8 +325,16 @@ describe('InvitationClient guided password reauthentication', () => { initialError="" /> ) - // A current pending capability is the only case that releases the fence. + // Even a current pending preview is a page view, not an authoritative + // server result: a stale snapshot and a slow browser clock cannot release + // the fence here. expect(screen.getByRole('button', { name: 'Reset password' })).toBeInTheDocument() + expect(window.localStorage.getItem('profile_logout_unconfirmed')).toBe('true') + + fillPassword() + fireEvent.click(screen.getByRole('button', { name: 'Reset password' })) + await waitFor(() => expectPasswordUpdatedScreen(passwordReset.email)) + // Only the accepted reset releases the surviving session. expect(window.localStorage.getItem('profile_logout_unconfirmed')).toBeNull() }) diff --git a/profile-ui/app/components/__tests__/api.logout.test.ts b/profile-ui/app/components/__tests__/api.logout.test.ts index 715bed4636..893f2408f2 100644 --- a/profile-ui/app/components/__tests__/api.logout.test.ts +++ b/profile-ui/app/components/__tests__/api.logout.test.ts @@ -1,10 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AuthExpiredError, + ProfileApiError, apiGet, clearUnconfirmedLogout, loginWithPassword, logoutProfileUI, + setupInvitationPasswordWithToken, } from '@lib/api' describe('Profile logout client boundary', () => { @@ -75,4 +77,69 @@ describe('Profile logout client boundary', () => { await expect(apiGet('/api/v1/me')).resolves.toEqual({ id: 'user-1' }) expect(fetch).toHaveBeenCalledTimes(2) }) + + it('sends a recovery submission through the fence without releasing it locally', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce(new Response('', { status: 503 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ passwordUpdated: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) + + await logoutProfileUI() + expect(window.localStorage.getItem('profile_logout_unconfirmed')).toBe('true') + // Liveness: the fence blocks an ordinary session request without a fetch. + await expect(apiGet('/api/v1/me')).rejects.toBeInstanceOf(AuthExpiredError) + expect(fetch).toHaveBeenCalledOnce() + + await expect( + setupInvitationPasswordWithToken( + 'synthetic-reset-proof', + 'member@example.invalid', + 'reset-row-1', + 'Synthetic-New-Password-123' + ) + ).resolves.toEqual({ passwordUpdated: true }) + + expect(fetch).toHaveBeenCalledTimes(2) + expect(String(vi.mocked(fetch).mock.calls[1]?.[0])).toContain( + '/api/v1/invitations/password-reset' + ) + expect(vi.mocked(fetch).mock.calls[1]?.[1]?.method).toBe('POST') + expect(vi.mocked(fetch).mock.calls[1]?.[1]?.credentials).toBe('include') + // The API boundary proves the request reached the server. Releasing the + // fence is the screen's decision after this authoritative response. + expect(window.localStorage.getItem('profile_logout_unconfirmed')).toBe('true') + }) + + it('keeps the fence when the server rejects the recovery submission', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce(new Response('', { status: 503 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'expired', message: 'Invitation has expired' }), { + status: 410, + headers: { 'content-type': 'application/json' }, + }) + ) + + await logoutProfileUI() + expect(window.localStorage.getItem('profile_logout_unconfirmed')).toBe('true') + + const rejected = setupInvitationPasswordWithToken( + 'synthetic-expired-reset-proof', + 'member@example.invalid', + 'reset-row-2', + 'Synthetic-New-Password-123' + ) + await expect(rejected).rejects.toBeInstanceOf(ProfileApiError) + await expect(rejected).rejects.toMatchObject({ status: 410, code: 'expired' }) + expect(fetch).toHaveBeenCalledTimes(2) + expect(window.localStorage.getItem('profile_logout_unconfirmed')).toBe('true') + + // Liveness: the surviving session is still fenced after the rejection. + await expect(apiGet('/api/v1/me')).rejects.toBeInstanceOf(AuthExpiredError) + expect(fetch).toHaveBeenCalledTimes(2) + }) }) diff --git a/profile-ui/app/invitations/[token]/InvitationClient.tsx b/profile-ui/app/invitations/[token]/InvitationClient.tsx index 7718ae301d..fed01cc49d 100644 --- a/profile-ui/app/invitations/[token]/InvitationClient.tsx +++ b/profile-ui/app/invitations/[token]/InvitationClient.tsx @@ -17,6 +17,7 @@ import { setupAcceptedInvitationPasswordWithToken, setupInvitationPasswordWithToken, } from '@lib/api' +import { isTerminalPasswordError } from '@lib/invitationErrors' import { buildInvitationHeading, buildInvitationTeamsLabel, @@ -29,31 +30,6 @@ import type { InvitationClientProps } from './types' type PasswordPhase = 'editing' | 'submitting' | 'updated' | 'outcome_unknown' | 'terminal' -// A submit error that means the proof can never be used again. Retrying the -// same form only repeats the failure, so the screen must offer a way forward. -const TERMINAL_PASSWORD_ERROR_CODES = new Set([ - 'invitation_not_pending', - 'invalid_invitation', - 'not_found', -]) - -function isTerminalPasswordError(value: unknown): boolean { - return ( - value instanceof ProfileApiError && - value.code !== null && - TERMINAL_PASSWORD_ERROR_CODES.has(value.code) - ) -} - -// The logout fence may only be released while the recovery capability can -// still set a password: a pending, unexpired password-reset invitation. -function isCurrentRecoveryCapability(invitation: InvitationPreview | null, now: number): boolean { - if (!invitation || invitation.purpose !== 'password_reset') return false - if (invitation.status !== 'pending') return false - const expiresAt = Date.parse(invitation.expiresAt) - return Number.isFinite(expiresAt) && expiresAt > now -} - function statusForInvitation(invitation: InvitationPreview): string { if (invitation.purpose === 'password_reset') { return invitation.status === 'accepted' @@ -138,15 +114,6 @@ export function InvitationClient({ setPasswordPhase('editing') }, [invitationToken]) - useEffect(() => { - // A recovery link is a fresh sign-in path only while it can still set the - // password. A redeemed, expired or invalid link must keep the fence so a - // surviving session cannot resume without a login. - if (isCurrentRecoveryCapability(initialInvitation, Date.now())) { - clearUnconfirmedLogout() - } - }, [initialInvitation]) - const invitationExpired = useMemo(() => { if (!invitation || invitation.status !== 'pending') return false return formatRemaining(invitation.expiresAt, now) === 'expired' @@ -237,6 +204,17 @@ export function InvitationClient({ } return 'We could not update your password. Please try again.' } + if (value instanceof ProfileApiError && value.code) { + if (value.code === 'invalid_password') return 'Password must be between 8 and 256 characters.' + if (value.code === 'invalid_invitation') return 'This recovery link is invalid or expired.' + if (value.code === 'invitation_not_accepted') + return 'Accept the invitation before setting a password.' + if (value.code === 'invitation_not_pending' || value.code === 'invitation_not_ready') + return 'This invitation has already been used.' + if (value.code === 'expired') return 'This invitation has expired.' + if (value.code === 'forbidden') return 'Invitation email does not match.' + if (value.code === 'not_found') return 'Invitation not found.' + } const message = value.message.replace(/^\d{3}\s+[A-Za-z ]+\s+-\s+/, '') if (message === 'invalid_password') return 'Password must be between 8 and 256 characters.' if (message === 'invalid_invitation') return 'This recovery link is invalid or expired.' @@ -330,6 +308,10 @@ export function InvitationClient({ ? setupInvitationPasswordWithToken : setupAcceptedInvitationPasswordWithToken await setupPassword(invitationToken, invitation.email, invitation.id, password) + // The server accepted the recovery proof. Only that authoritative result + // releases the logout fence: a page view or a client-clock expiry is not + // proof that the surviving session can be discarded safely. + if (isPasswordReset) clearUnconfirmedLogout() // The backend issues no session from password setup. The screen must send // the person to the ordinary login instead of hydrating a recovery session. setPasswordPhase('updated') @@ -439,7 +421,7 @@ export function InvitationClient({ ) : invitationUsed ? ( void) | null = null type ApiRequestOptions = { silentUnauthorized?: boolean + /** + * Allows one invitation-proof request through the unconfirmed-logout fence. + * Only a password-reset submission uses it: that route ignores the surviving + * cookie and clears it server-side, so the fence can be released after the + * server accepts the recovery action. Ordinary session requests must not + * bypass the fence. + */ + allowUnconfirmedLogout?: boolean } const PROFILE_LOGOUT_UNCONFIRMED_KEY = 'profile_logout_unconfirmed' @@ -57,9 +65,9 @@ function markLogoutUnconfirmed(): void { } /** - * Clears the logout fence. A recovery link is a fresh sign-in path: when a - * previous logout could not confirm revocation, the fence would otherwise - * block the recovery form before it reaches the backend. + * Clears the logout fence. The fence is released only after a successful + * password login, a confirmed logout, or an accepted recovery submission; a + * recovery page view never releases it. */ export function clearUnconfirmedLogout(): void { if (typeof window === 'undefined') return @@ -115,6 +123,10 @@ function handleUnauthorized(): never { throw new AuthExpiredError() } +// The public envelope may carry an object `error` with a code, or the legacy +// `error` field. A top-level `code` is the stable machine code invitation +// routes publish for responses whose `error` is an English sentence; it must +// win over that sentence so classification never depends on display copy. function publicErrorFields(text: string): { code?: string; detail?: string } { try { const parsed = JSON.parse(text) as unknown @@ -125,11 +137,13 @@ function publicErrorFields(text: string): { code?: string; detail?: string } { ? (body.error as Record) : undefined const code = - typeof envelope?.code === 'string' - ? envelope.code - : typeof body.error === 'string' - ? body.error - : undefined + typeof body.code === 'string' + ? body.code + : typeof envelope?.code === 'string' + ? envelope.code + : typeof body.error === 'string' + ? body.error + : undefined const message = typeof envelope?.message === 'string' ? envelope.message @@ -199,9 +213,10 @@ export async function apiSend( path: string, body?: unknown, q: Record = {}, - extraHeaders: Record = {} + extraHeaders: Record = {}, + options: ApiRequestOptions = {} ) { - if (hasUnconfirmedLogout()) handleUnauthorized() + if (hasUnconfirmedLogout() && !options.allowUnconfirmedLogout) handleUnauthorized() const headers: Record = { 'Content-Type': 'application/json', ...extraHeaders, @@ -334,12 +349,19 @@ export async function setupInvitationPasswordWithToken( invitationId: string, password: string ): Promise { - const response = (await apiSend('POST', '/api/v1/invitations/password-reset', { - token: invitationUrlId, - email, - invitationId, - password, - })) as InvitationPreview & { passwordUpdated: boolean } + const response = (await apiSend( + 'POST', + '/api/v1/invitations/password-reset', + { + token: invitationUrlId, + email, + invitationId, + password, + }, + {}, + {}, + { allowUnconfirmedLogout: true } + )) as InvitationPreview & { passwordUpdated: boolean } clearToken() return response } diff --git a/profile-ui/lib/invitationErrors.ts b/profile-ui/lib/invitationErrors.ts new file mode 100644 index 0000000000..96f12a82e5 --- /dev/null +++ b/profile-ui/lib/invitationErrors.ts @@ -0,0 +1,21 @@ +import { ProfileApiError } from './api' + +// A submit error that means the proof can never be used again. Retrying the +// same form only repeats the failure, so the screen must offer a way forward. +// The edge publishes these as stable machine codes; the human `error` sentence +// is display-only. `expired` is included because the dedicated reset route +// reports it as a code, not as the `/password` sentence. +export const TERMINAL_PASSWORD_ERROR_CODES: ReadonlySet = new Set([ + 'invitation_not_pending', + 'invalid_invitation', + 'not_found', + 'expired', +]) + +export function isTerminalPasswordError(value: unknown): value is ProfileApiError { + return ( + value instanceof ProfileApiError && + value.code !== null && + TERMINAL_PASSWORD_ERROR_CODES.has(value.code) + ) +} diff --git a/profile-ui/package-lock.json b/profile-ui/package-lock.json index 36f601c336..228f839b4f 100644 --- a/profile-ui/package-lock.json +++ b/profile-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-profile-ui", - "version": "0.1.185", + "version": "0.1.186", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-profile-ui", - "version": "0.1.185", + "version": "0.1.186", "dependencies": { "@clerum/desktop-app-links": "file:../packages/desktop-app-links", "@clerum/frontend-components": "file:../packages/frontend-components", diff --git a/profile-ui/package.json b/profile-ui/package.json index 7337d308fb..3ba40c79a7 100644 --- a/profile-ui/package.json +++ b/profile-ui/package.json @@ -1,6 +1,6 @@ { "name": "clerum-profile-ui", - "version": "0.1.185", + "version": "0.1.186", "private": true, "scripts": { "dev": "next dev", diff --git a/tests/contracts/invitation-password-public-errors.json b/tests/contracts/invitation-password-public-errors.json new file mode 100644 index 0000000000..ccd18629e7 --- /dev/null +++ b/tests/contracts/invitation-password-public-errors.json @@ -0,0 +1,51 @@ +{ + "password_not_found": { + "route": "password", + "previewPurpose": "password_reset", + "serviceError": "not_found", + "status": 404, + "body": { "error": "Invitation not found", "code": "not_found" }, + "componentPreview": "reset", + "terminal": true, + "uiMessage": "Invitation not found." + }, + "password_not_pending": { + "route": "password", + "previewPurpose": "password_reset", + "serviceError": "not_pending", + "status": 409, + "body": { "error": "Invitation has already been used", "code": "invitation_not_pending" }, + "componentPreview": "reset", + "terminal": true, + "uiMessage": "This invitation has already been used." + }, + "password_expired": { + "route": "password", + "previewPurpose": "password_reset", + "serviceError": "expired", + "status": 410, + "body": { "error": "Invitation has expired", "code": "expired" }, + "componentPreview": "reset", + "terminal": true, + "uiMessage": "This invitation has expired." + }, + "reset_expired": { + "route": "password-reset", + "serviceError": "expired", + "status": 410, + "body": { "error": "expired", "message": "Invitation has expired" }, + "componentPreview": "reset", + "terminal": true, + "uiMessage": "This invitation has expired." + }, + "accept_fallback_not_pending": { + "route": "password", + "previewPurpose": "member_invitation", + "acceptError": "not_pending", + "status": 400, + "body": { "error": "Invitation is not pending", "code": "invitation_not_pending" }, + "componentPreview": "accepted_member", + "terminal": true, + "uiMessage": "This invitation has already been used." + } +} diff --git a/tests/e2e/playwright/package-lock.json b/tests/e2e/playwright/package-lock.json index ffb5c096f4..e14fc2b26f 100644 --- a/tests/e2e/playwright/package-lock.json +++ b/tests/e2e/playwright/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-playwright-tests", - "version": "0.1.87", + "version": "0.1.88", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-playwright-tests", - "version": "0.1.87", + "version": "0.1.88", "devDependencies": { "@playwright/test": "^1.50.0", "@types/node": "^22.0.0", diff --git a/tests/e2e/playwright/package.json b/tests/e2e/playwright/package.json index 959a29f211..8cb7abee28 100644 --- a/tests/e2e/playwright/package.json +++ b/tests/e2e/playwright/package.json @@ -1,6 +1,6 @@ { "name": "clerum-playwright-tests", - "version": "0.1.87", + "version": "0.1.88", "private": true, "description": "Playwright E2E tests for Control UI and Desktop App", "scripts": { diff --git a/tests/e2e/playwright/profile-ui-recovery/j2-password-recovery.spec.ts b/tests/e2e/playwright/profile-ui-recovery/j2-password-recovery.spec.ts index 53359a13ac..2ea64de331 100644 --- a/tests/e2e/playwright/profile-ui-recovery/j2-password-recovery.spec.ts +++ b/tests/e2e/playwright/profile-ui-recovery/j2-password-recovery.spec.ts @@ -45,8 +45,11 @@ test('J2 recovery link clears the logout fence, revokes the prior session, and l await page.goto('/invitations/synthetic-reset-proof') await expect(page.getByRole('heading', { name: 'Reset password' })).toBeVisible() - // The recovery link must clear the fence before the form can reach the backend. - await expect.poll(() => readLogoutFence(page)).toBeNull() + // Opening the link is only a stale preview, not an authoritative recovery + // result. The fence must survive even when the browser clock says the link + // is current; the reset submission itself is allowed through the fence. + await expect(page.getByRole('button', { name: 'Reset password' })).toBeVisible() + expect(await readLogoutFence(page)).toBe('true') await page.getByLabel('Password', { exact: true }).fill(RECOVERY_MEMBER.replacementPassword) await page.getByLabel('Confirm password').fill(RECOVERY_MEMBER.replacementPassword) @@ -60,6 +63,8 @@ test('J2 recovery link clears the logout fence, revokes the prior session, and l expect(resetResult.status()).toBe(200) await expect(page.getByRole('heading', { name: 'Password updated' })).toBeVisible() + // The server accepted the recovery action: only now may the fence go. + await expect.poll(() => readLogoutFence(page)).toBeNull() expect(await sessionCookieValue(page)).toBeNull() const revoked = await fetchMeWithCookie(page, priorSession) @@ -153,10 +158,13 @@ test('J2 a redeemed recovery link keeps the logout fence over a live session', a ) expect(await readLogoutFence(page)).toBe('true') - // A current pending recovery capability is the only release witness. - // E2E_GUARDIAN_NAVIGATION: email-reset-entry — a current recovery link releases the fence + // A current pending preview is still only a page view. It must not release + // the fence: a stale snapshot plus a slow browser clock would otherwise let + // the surviving session resume without a server decision. The other J2 test + // proves release after the server accepts the reset. + // E2E_GUARDIAN_NAVIGATION: email-reset-entry — a pending preview does not release the fence await page.goto('/invitations/synthetic-pending-reset-proof') await expect(page.getByRole('heading', { name: 'Reset password' })).toBeVisible() await expect(page.getByRole('button', { name: 'Reset password' })).toBeVisible() - await expect.poll(() => readLogoutFence(page)).toBeNull() + expect(await readLogoutFence(page)).toBe('true') }) diff --git a/tests/e2e/playwright/profile-ui-recovery/negative-routes.spec.ts b/tests/e2e/playwright/profile-ui-recovery/negative-routes.spec.ts index 9d6b84feef..788fe77281 100644 --- a/tests/e2e/playwright/profile-ui-recovery/negative-routes.spec.ts +++ b/tests/e2e/playwright/profile-ui-recovery/negative-routes.spec.ts @@ -76,7 +76,24 @@ test('negative routes show an explicit error and never issue a session', async ( test('direct access to the authenticated home without login shows the login form', async ({ page, + browser, }) => { + // Same-observer witness: a real product login in an isolated context proves + // this test's cookie observer sees product-issued sessions. It never seeds + // the credentials or cookies used by the unauthenticated navigation below. + const witnessContext = await browser.newContext({ baseURL: test.info().project.use.baseURL }) + const witnessPage = await witnessContext.newPage() + try { + await witnessPage.goto('/') + await signInThroughUi(witnessPage, LOGOUT_MEMBER.email, LOGOUT_MEMBER.password) + await expect( + witnessPage.getByRole('heading', { name: 'Welcome, Synthetic Logout Member' }) + ).toBeVisible() + expect(await sessionCookieValue(witnessPage)).toBeTruthy() + } finally { + await witnessContext.close() + } + const homeSession = page.waitForResponse( response => response.url().endsWith('/api/v1/me') && response.request().method() === 'GET' ) From d9d4fae2c8fc66f1f8c0a026400e6bf9c60951e3 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 21 Oct 2026 22:44:39 +0200 Subject: [PATCH 355/528] fix(invitations): publish password_already_set and witness contract cookies Map the Control API 409 password_already_set rejection to a terminal edge domain error, publish it with a stable code and English sentence, and classify it as terminal in Profile UI with the sign-in message. Every edge contract case now observes a real product session cookie from an independent acceptance before asserting cookie absence on the rejection. Register the Profile UI recovery fixture parity suite in the RealPG manifest and classify the pre-existing V1 cutoff-sites suite required by the census. --- external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- external-rest-api/src/releaseManifest.ts | 2 +- external-rest-api/src/routes/invitations.ts | 11 ++- .../src/services/invitationPasswordService.ts | 3 + ...wordPublicErrors.profileUiContract.test.ts | 80 ++++++++++++++++--- .../test/services.invitationsService.test.ts | 18 +++++ .../invitations/[token]/InvitationClient.tsx | 3 + profile-ui/lib/invitationErrors.ts | 1 + profile-ui/package-lock.json | 4 +- profile-ui/package.json | 2 +- .../invitation-password-public-errors.json | 11 +++ tests/contracts/password-recovery-suites.json | 1 + 13 files changed, 125 insertions(+), 17 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 5ae3af5342..23f176d09c 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.150", + "version": "0.1.151", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.150", + "version": "0.1.151", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index 9e3fee067d..f98e750dc6 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.150", + "version": "0.1.151", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index 6938b58bcc..27eb22388c 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.11.0', - externalRestApiVersion: '0.1.150', + externalRestApiVersion: '0.1.151', rpcProxyVersion: '0.1.132', desktopVersion: '0.11.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/src/routes/invitations.ts b/external-rest-api/src/routes/invitations.ts index b1045fafaa..ba71f7a2ae 100644 --- a/external-rest-api/src/routes/invitations.ts +++ b/external-rest-api/src/routes/invitations.ts @@ -102,6 +102,7 @@ function sendInvitationPasswordError( | 'expired' | 'invalid_invitation' | 'invalid_password' + | 'password_already_set' ): void { if (error === 'invalid_invitation') { res.status(400).json({ error: 'invalid_invitation' }) @@ -119,6 +120,12 @@ function sendInvitationPasswordError( res.status(409).json({ error: 'Invitation must be accepted before setting a password' }) return } + if (error === 'password_already_set') { + res + .status(409) + .json({ error: 'This account already has a password', code: 'password_already_set' }) + return + } if (error === 'not_pending') { res .status(409) @@ -259,7 +266,9 @@ async function completePasswordReset( email: string, invitationId: string, password: string, - sendError: typeof sendInvitationPasswordError = sendInvitationPasswordError + // The reset producer cannot report password_already_set; type this hook to + // the reset errors it can publish. + sendError: typeof sendPasswordResetError = sendInvitationPasswordError ): Promise { const result = await setupInvitationPasswordWithToken(token, email, invitationId, password) if (result.error) { diff --git a/external-rest-api/src/services/invitationPasswordService.ts b/external-rest-api/src/services/invitationPasswordService.ts index 117ebdffe7..3d894adf4e 100644 --- a/external-rest-api/src/services/invitationPasswordService.ts +++ b/external-rest-api/src/services/invitationPasswordService.ts @@ -29,6 +29,7 @@ function invitationPasswordDomainError( | 'not_pending' | 'expired' | 'invalid_password' + | 'password_already_set' | null { const code = error.code if (error.status === 400 && code === 'invalid_password') return 'invalid_password' @@ -37,6 +38,7 @@ function invitationPasswordDomainError( if (error.status === 404 && code === 'not_found') return 'not_found' if (error.status === 409 && code === 'invitation_not_accepted') return 'not_accepted' if (error.status === 409 && code === 'invitation_not_pending') return 'not_pending' + if (error.status === 409 && code === 'password_already_set') return 'password_already_set' if (error.status === 410 && code === 'expired') return 'expired' return null } @@ -104,6 +106,7 @@ export async function setupInvitationPassword( | 'not_pending' | 'expired' | 'invalid_password' + | 'password_already_set' data?: InvitationPreview & { passwordUpdated: boolean } }> { try { diff --git a/external-rest-api/test/invitationPasswordPublicErrors.profileUiContract.test.ts b/external-rest-api/test/invitationPasswordPublicErrors.profileUiContract.test.ts index 8d2342f0d1..c7a6eea768 100644 --- a/external-rest-api/test/invitationPasswordPublicErrors.profileUiContract.test.ts +++ b/external-rest-api/test/invitationPasswordPublicErrors.profileUiContract.test.ts @@ -9,7 +9,9 @@ import { createInvitationsRouter } from '../src/routes/invitations.js' type ContractEntry = { route: 'password' | 'password-reset' previewPurpose?: 'password_reset' | 'member_invitation' + auth?: 'session' serviceError?: string + memberServiceError?: string acceptError?: string status: number body: Record @@ -20,6 +22,16 @@ type ContractEntry = { const contract = contractDocument as Record +// Witness credentials and the rejection credentials are deliberately +// different: the rejection request must not prove the witness by reusing its +// proof or its issued session. +const WITNESS_PROOF = 'synthetic-cookie-witness-proof' +const WITNESS_EMAIL = 'cookie-witness@example.invalid' +const WITNESS_SESSION = 'synthetic-witness-profile-session' +const CONTRACT_PROOF = 'synthetic-contract-proof' +const CONTRACT_EMAIL = 'invitee@example.com' +const CONTRACT_SESSION = 'synthetic-contract-session' + const authTokenMock = vi.hoisted(() => ({ verifyToken: vi.fn(), })) @@ -48,24 +60,66 @@ function makeApp() { return app } -async function edgeResponse(name: string, entry: ContractEntry, sourceIp: string) { +// One observation helper for the positive witness and for every rejection: +// disabling the cookie writer makes every case fail at its witness first. +function observedSetCookie(response: request.Response): string { + return String(response.headers['set-cookie'] ?? '') +} + +function configureContractMocks(entry: ContractEntry) { serviceMock.getInvitationByToken.mockResolvedValue({ id: 'invitation-row-1', - email: 'invitee@example.com', + email: CONTRACT_EMAIL, purpose: entry.previewPurpose, status: 'pending', }) - if (entry.acceptError) { - serviceMock.acceptInvitation.mockResolvedValue({ error: entry.acceptError }) + serviceMock.acceptInvitation.mockImplementation(async (token: string) => { + if (token === WITNESS_PROOF) { + return { + data: { + token: WITNESS_SESSION, + userId: 'cookie-witness-user', + email: WITNESS_EMAIL, + status: 'accepted', + passwordPending: true, + }, + } + } + if (entry.acceptError) return { error: entry.acceptError } + throw new Error(`unexpected accept lookup for ${token}`) + }) + if (entry.auth === 'session') { + authTokenMock.verifyToken.mockReturnValue({ + userId: 'contract-session-user', + email: CONTRACT_EMAIL, + }) } if (entry.serviceError) { serviceMock.setupInvitationPasswordWithToken.mockResolvedValue({ error: entry.serviceError }) } + if (entry.memberServiceError) { + serviceMock.setupInvitationPassword.mockResolvedValue({ error: entry.memberServiceError }) + } +} + +// A real router acceptance with the witness proof, on its own rate-limit +// identity, issues the product session cookie the rejection case must lack. +async function issueWitnessSessionCookie(sourceIp: string) { + return request(makeApp()) + .post('/invitations/accept') + .set('x-forwarded-for', sourceIp) + .send({ token: WITNESS_PROOF, email: WITNESS_EMAIL }) +} +async function edgeResponse(entry: ContractEntry, sourceIp: string) { const path = entry.route === 'password' ? '/invitations/password' : '/invitations/password-reset' - return request(makeApp()).post(path).set('x-forwarded-for', sourceIp).send({ - token: 'synthetic-contract-proof', - email: 'invitee@example.com', + const edge = request(makeApp()).post(path).set('x-forwarded-for', sourceIp) + if (entry.auth === 'session') { + edge.set('authorization', `Bearer ${CONTRACT_SESSION}`) + } + return edge.send({ + token: CONTRACT_PROOF, + email: CONTRACT_EMAIL, invitationId: 'invitation-row-1', password: 'Synthetic-Contract-Password-123', }) @@ -84,11 +138,19 @@ describe('invitation password public errors reach Profile UI classification', () it.each(Object.entries(contract).map(([name, entry], index) => [name, entry, index] as const))( 'classifies the real edge %s body through the Profile UI parser', async (name, entry, index) => { - const response = await edgeResponse(name, entry, `198.51.100.${20 + index}`) + configureContractMocks(entry) + + // Positive witness in this same test, before the rejection: the real + // router issues a product session cookie for a successful acceptance. + const witness = await issueWitnessSessionCookie(`198.51.100.${120 + index}`) + expect(witness.status, name).toBe(200) + expect(observedSetCookie(witness)).toContain(`profile_session=${WITNESS_SESSION}`) + + const response = await edgeResponse(entry, `198.51.100.${20 + index}`) expect(response.status).toBe(entry.status) expect(response.body).toEqual(entry.body) - expect(String(response.headers['set-cookie'] ?? '')).not.toMatch(/profile_session=[^;]+/) + expect(observedSetCookie(response)).not.toMatch(/profile_session=[^;]+/) vi.stubGlobal( 'fetch', diff --git a/external-rest-api/test/services.invitationsService.test.ts b/external-rest-api/test/services.invitationsService.test.ts index a6e4382002..f607db5c87 100644 --- a/external-rest-api/test/services.invitationsService.test.ts +++ b/external-rest-api/test/services.invitationsService.test.ts @@ -41,6 +41,24 @@ describe('invitation service error and token boundaries', () => { } }) + it('classifies a producer password_already_set rejection as a terminal domain error', async () => { + client.request.mockRejectedValueOnce( + new ControlApiError( + 'Control API POST /external/invitations/password failed (409): password_already_set', + 409, + { error: 'password_already_set' } + ) + ) + + await expect( + setupInvitationPassword( + { userId: 'user-1', email: 'user@example.com', sessionToken: 'session-token' }, + 'inv-1', + 'valid-password' + ) + ).resolves.toEqual({ error: 'password_already_set' }) + }) + it('discards any transitional session representation from atomic password setup', async () => { client.request.mockResolvedValueOnce({ id: 'inv-1', diff --git a/profile-ui/app/invitations/[token]/InvitationClient.tsx b/profile-ui/app/invitations/[token]/InvitationClient.tsx index fed01cc49d..0b1ef9b8b6 100644 --- a/profile-ui/app/invitations/[token]/InvitationClient.tsx +++ b/profile-ui/app/invitations/[token]/InvitationClient.tsx @@ -214,6 +214,9 @@ export function InvitationClient({ if (value.code === 'expired') return 'This invitation has expired.' if (value.code === 'forbidden') return 'Invitation email does not match.' if (value.code === 'not_found') return 'Invitation not found.' + if (value.code === 'password_already_set') { + return 'This account already has a password. Sign in instead.' + } } const message = value.message.replace(/^\d{3}\s+[A-Za-z ]+\s+-\s+/, '') if (message === 'invalid_password') return 'Password must be between 8 and 256 characters.' diff --git a/profile-ui/lib/invitationErrors.ts b/profile-ui/lib/invitationErrors.ts index 96f12a82e5..54daba633b 100644 --- a/profile-ui/lib/invitationErrors.ts +++ b/profile-ui/lib/invitationErrors.ts @@ -10,6 +10,7 @@ export const TERMINAL_PASSWORD_ERROR_CODES: ReadonlySet = new Set([ 'invalid_invitation', 'not_found', 'expired', + 'password_already_set', ]) export function isTerminalPasswordError(value: unknown): value is ProfileApiError { diff --git a/profile-ui/package-lock.json b/profile-ui/package-lock.json index 228f839b4f..e85bf1bb0f 100644 --- a/profile-ui/package-lock.json +++ b/profile-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-profile-ui", - "version": "0.1.186", + "version": "0.1.187", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-profile-ui", - "version": "0.1.186", + "version": "0.1.187", "dependencies": { "@clerum/desktop-app-links": "file:../packages/desktop-app-links", "@clerum/frontend-components": "file:../packages/frontend-components", diff --git a/profile-ui/package.json b/profile-ui/package.json index 3ba40c79a7..d6a11cc88c 100644 --- a/profile-ui/package.json +++ b/profile-ui/package.json @@ -1,6 +1,6 @@ { "name": "clerum-profile-ui", - "version": "0.1.186", + "version": "0.1.187", "private": true, "scripts": { "dev": "next dev", diff --git a/tests/contracts/invitation-password-public-errors.json b/tests/contracts/invitation-password-public-errors.json index ccd18629e7..ecfad67515 100644 --- a/tests/contracts/invitation-password-public-errors.json +++ b/tests/contracts/invitation-password-public-errors.json @@ -47,5 +47,16 @@ "componentPreview": "accepted_member", "terminal": true, "uiMessage": "This invitation has already been used." + }, + "password_already_set": { + "route": "password", + "previewPurpose": "member_invitation", + "auth": "session", + "memberServiceError": "password_already_set", + "status": 409, + "body": { "error": "This account already has a password", "code": "password_already_set" }, + "componentPreview": "accepted_member", + "terminal": true, + "uiMessage": "This account already has a password. Sign in instead." } } diff --git a/tests/contracts/password-recovery-suites.json b/tests/contracts/password-recovery-suites.json index 3e09589bce..aa2d4581c1 100644 --- a/tests/contracts/password-recovery-suites.json +++ b/tests/contracts/password-recovery-suites.json @@ -35,6 +35,7 @@ "control-api/test/oauth.store.sharedGrants.realPostgres.integration.test.ts", "control-api/test/oauth.store.userGrantsListing.realPostgres.integration.test.ts", "control-api/test/passwordRecovery.http.realPostgres.integration.test.ts", + "control-api/test/profileUiRecoveryFixture.parity.realPostgres.integration.test.ts", "control-api/test/passwordResetInvitations.realPostgres.integration.test.ts", "control-api/test/passwordWorkOwnership.operation.realPostgres.integration.test.ts", "control-api/test/pluginWorkloadSdkCodexDualLedger.realPostgres.integration.test.ts", From 870b0b4bbad568e5459fb4966ad21970b963e058 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 22 Oct 2026 12:45:09 +0200 Subject: [PATCH 356/528] test(invitations): align recovery fixture and edge fallback with replay and A9 The recovery lane fixture now follows the merged producer: an owner replay of an accepted invitation is a 200 success without a session, and the session password route refuses a second password with 409 password_already_set. The parity suite asserts both against real PostgreSQL, including that the replay writes nothing and that the first password still signs in after the refusal. A new edge chain test runs the real invitations service and router against an HTTP producer: a replay reaches the terminal invitation_not_pending body that profile-ui renders from the shared contract, and a producer 400 not_pending stays the non-terminal invalid invitation body. Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- ...re.parity.realPostgres.integration.test.ts | 48 +++++-- external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- external-rest-api/src/releaseManifest.ts | 2 +- .../invitationPasswordFallback.chain.test.ts | 133 ++++++++++++++++++ tests/e2e/playwright/package-lock.json | 4 +- tests/e2e/playwright/package.json | 2 +- .../profile-ui-recovery-control-api.mjs | 18 ++- 10 files changed, 199 insertions(+), 20 deletions(-) create mode 100644 external-rest-api/test/invitationPasswordFallback.chain.test.ts diff --git a/control-api/package-lock.json b/control-api/package-lock.json index be08f01e37..081c3bc260 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.813", + "version": "0.1.814", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.813", + "version": "0.1.814", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 95c3799010..58addd2546 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.813", + "version": "0.1.814", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/profileUiRecoveryFixture.parity.realPostgres.integration.test.ts b/control-api/test/profileUiRecoveryFixture.parity.realPostgres.integration.test.ts index 86a7cd09a6..5d022038a0 100644 --- a/control-api/test/profileUiRecoveryFixture.parity.realPostgres.integration.test.ts +++ b/control-api/test/profileUiRecoveryFixture.parity.realPostgres.integration.test.ts @@ -269,14 +269,6 @@ realPg('Profile UI recovery lane fixture parity on PostgreSQL 16', () => { it('parities rejected invitation mutations without creating a session', async () => { const rejected = [ - { - path: '/external/invitations/accept', - body: { - email: fixtureModule.RECOVERY_USERS.usedInvitee.email, - token: 'synthetic-used-invitation', - }, - error: 'not_pending', - }, { path: '/external/invitations/accept', body: { @@ -332,6 +324,29 @@ realPg('Profile UI recovery lane fixture parity on PostgreSQL 16', () => { } }) + it('parities an owner replay of an accepted invitation as a success without a session', async () => { + const before = await db.pool.query( + 'SELECT status, accepted_at FROM invitations WHERE id = $1', + ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3'] + ) + const parity = await expectParity('POST', '/external/invitations/accept', { + body: { + email: fixtureModule.RECOVERY_USERS.usedInvitee.email, + token: 'synthetic-used-invitation', + }, + }) + expect(parity.real.status).toBe(200) + expect(parity.real.body).toMatchObject({ accepted: true, replay: true }) + expect(parity.synthetic.body).toMatchObject({ accepted: true, replay: true }) + expect(parity.real.body).not.toHaveProperty('token') + expect(parity.synthetic.body).not.toHaveProperty('token') + const after = await db.pool.query('SELECT status, accepted_at FROM invitations WHERE id = $1', [ + 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3', + ]) + expect(before.rows[0].status).toBe('accepted') + expect(after.rows).toEqual(before.rows) + }) + it('parities accept, authenticated password setup and the resulting session identity', async () => { const accepted = await expectParity('POST', '/external/invitations/accept', { body: { @@ -406,7 +421,22 @@ realPg('Profile UI recovery lane fixture parity on PostgreSQL 16', () => { }) expect(fixtureRepeat.status).toBe(realRepeat.status) expect(bodyShape(fixtureRepeat.body)).toEqual(bodyShape(realRepeat.body)) - expect(realRepeat.status).toBe(200) + expect(realRepeat.status).toBe(409) + expect(realRepeat.body).toEqual({ error: 'password_already_set' }) + expect(fixtureRepeat.body).toEqual({ error: 'password_already_set' }) + + // The refused repeat leaves the first password in place on both sides. + await openPasswordPace() + const realKeptLogin = await loginReal( + fixtureModule.RECOVERY_USERS.onboarding.email, + fixtureModule.RECOVERY_PASSWORDS.onboarding + ) + const fixtureKeptLogin = await loginFixture( + fixtureModule.RECOVERY_USERS.onboarding.email, + fixtureModule.RECOVERY_PASSWORDS.onboarding + ) + expect(realKeptLogin.status).toBe(200) + expect(fixtureKeptLogin.status).toBe(200) const realRevoked = await requestReal( 'GET', diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 23f176d09c..d01d6fc55b 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.151", + "version": "0.1.152", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.151", + "version": "0.1.152", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index f98e750dc6..c7e39d9d52 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.151", + "version": "0.1.152", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index 27eb22388c..44544b5aac 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.11.0', - externalRestApiVersion: '0.1.151', + externalRestApiVersion: '0.1.152', rpcProxyVersion: '0.1.132', desktopVersion: '0.11.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/test/invitationPasswordFallback.chain.test.ts b/external-rest-api/test/invitationPasswordFallback.chain.test.ts new file mode 100644 index 0000000000..142e7a353f --- /dev/null +++ b/external-rest-api/test/invitationPasswordFallback.chain.test.ts @@ -0,0 +1,133 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { type Server, createServer } from 'node:http' +import { type AddressInfo } from 'node:net' +import request from 'supertest' +import contractDocument from '../../tests/contracts/invitation-password-public-errors.json' +import { createApp } from '../src/app.js' +import { config } from '../src/config.js' + +// The sessionless password fallback runs the real invitations service and +// router against an HTTP Control API stand-in, so the producer status reaches +// the edge body through every mapping layer. +const invitationToken = 'synthetic-member-invitation-proof' +const invitationId = '00000000-0000-4000-8000-000000000001' +const inviteeEmail = 'member@example.invalid' + +type UpstreamRequest = { method?: string; url?: string; body?: Record } + +describe('sessionless invitation password fallback through the producer', () => { + let previousControlApiBaseUrl: string + let previousControlApiServiceToken: string + let upstream: Server | undefined + + beforeEach(() => { + previousControlApiBaseUrl = config.controlApiBaseUrl + previousControlApiServiceToken = config.controlApiServiceToken + config.controlApiServiceToken = 'synthetic-internal-service-token' + }) + + afterEach(async () => { + config.controlApiBaseUrl = previousControlApiBaseUrl + config.controlApiServiceToken = previousControlApiServiceToken + if (upstream?.listening) { + await new Promise((resolve, reject) => + upstream!.close(error => (error ? reject(error) : resolve())) + ) + } + upstream = undefined + }) + + async function startProducer(accept: { status: number; body: unknown }) { + const requests: UpstreamRequest[] = [] + upstream = createServer(async (req, res) => { + const chunks: Buffer[] = [] + for await (const chunk of req) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + } + const raw = Buffer.concat(chunks).toString('utf8') + requests.push({ method: req.method, url: req.url, body: raw ? JSON.parse(raw) : undefined }) + res.setHeader('content-type', 'application/json') + if ( + req.method === 'GET' && + req.url === `/api/v1/external/invitations/token/${invitationToken}` + ) { + res.writeHead(200) + res.end( + JSON.stringify({ + id: invitationId, + email: inviteeEmail, + purpose: 'member_invitation', + status: 'accepted', + }) + ) + return + } + if (req.method === 'POST' && req.url === '/api/v1/external/invitations/accept') { + res.writeHead(accept.status) + res.end(JSON.stringify(accept.body)) + return + } + res.writeHead(500) + res.end(JSON.stringify({ error: `unexpected ${req.method} ${req.url}` })) + }) + await new Promise((resolve, reject) => { + upstream!.once('error', reject) + upstream!.listen(0, '127.0.0.1', resolve) + }) + const address = upstream.address() as AddressInfo + config.controlApiBaseUrl = `http://127.0.0.1:${address.port}/api/v1` + return requests + } + + async function submitFallbackPassword() { + return request(createApp()).post('/api/v1/invitations/password').send({ + token: invitationToken, + email: inviteeEmail, + invitationId, + password: 'Synthetic-First-Password-123', + }) + } + + function expectStoppedAtAccept(requests: UpstreamRequest[]) { + expect(requests.map(entry => `${entry.method} ${entry.url}`)).toEqual([ + `GET /api/v1/external/invitations/token/${invitationToken}`, + 'POST /api/v1/external/invitations/accept', + ]) + expect(requests[1]?.body).toMatchObject({ email: inviteeEmail, token: invitationToken }) + } + + it('maps an owner replay to the terminal invitation_not_pending code without a session', async () => { + const requests = await startProducer({ + status: 200, + body: { + accepted: true, + teamId: null, + teamName: null, + role: 'member', + email: inviteeEmail, + userId: '00000000-0000-4000-8000-000000000002', + replay: true, + }, + }) + + const response = await submitFallbackPassword() + + // The same body profile-ui renders as terminal in its component contract test. + const terminal = contractDocument.accept_fallback_not_pending + expect(response.status).toBe(terminal.status) + expect(response.body).toEqual(terminal.body) + expect(String(response.headers['set-cookie'] ?? '')).not.toMatch(/profile_session=[^;]+/) + expectStoppedAtAccept(requests) + }) + + it('maps a producer 400 not_pending to the non-terminal invalid invitation body', async () => { + const requests = await startProducer({ status: 400, body: { error: 'not_pending' } }) + + const response = await submitFallbackPassword() + + expect(response.status).toBe(400) + expect(response.body).toEqual({ error: 'Invalid invitation' }) + expect(String(response.headers['set-cookie'] ?? '')).not.toMatch(/profile_session=[^;]+/) + expectStoppedAtAccept(requests) + }) +}) diff --git a/tests/e2e/playwright/package-lock.json b/tests/e2e/playwright/package-lock.json index e14fc2b26f..e65085a1fa 100644 --- a/tests/e2e/playwright/package-lock.json +++ b/tests/e2e/playwright/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-playwright-tests", - "version": "0.1.88", + "version": "0.1.89", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-playwright-tests", - "version": "0.1.88", + "version": "0.1.89", "devDependencies": { "@playwright/test": "^1.50.0", "@types/node": "^22.0.0", diff --git a/tests/e2e/playwright/package.json b/tests/e2e/playwright/package.json index 8cb7abee28..466887f121 100644 --- a/tests/e2e/playwright/package.json +++ b/tests/e2e/playwright/package.json @@ -1,6 +1,6 @@ { "name": "clerum-playwright-tests", - "version": "0.1.88", + "version": "0.1.89", "private": true, "description": "Playwright E2E tests for Control UI and Desktop App", "scripts": { diff --git a/tests/e2e/playwright/profile-ui-recovery-control-api.mjs b/tests/e2e/playwright/profile-ui-recovery-control-api.mjs index 732c10357a..e12b3d4e4a 100644 --- a/tests/e2e/playwright/profile-ui-recovery-control-api.mjs +++ b/tests/e2e/playwright/profile-ui-recovery-control-api.mjs @@ -320,7 +320,18 @@ export async function createRecoveryControlApi({ if (invitation.email.toLowerCase() !== email) { return { status: 400, body: { error: 'invalid_invitation' } } } - if (invitation.purpose === 'password_reset' || invitation.status !== 'pending') { + if (invitation.purpose === 'password_reset') { + return { status: 400, body: { error: 'not_pending' } } + } + // An accepted invitation replayed by its owner is a success without a + // session: the token no longer proves a pending capability. + if (invitation.status === 'accepted') { + return { + status: 200, + body: { accepted: true, ...invitationPreview(invitation), replay: true }, + } + } + if (invitation.status !== 'pending') { return { status: 400, body: { error: 'not_pending' } } } if (Date.parse(invitation.expiresAt) <= Date.now()) { @@ -409,6 +420,11 @@ export async function createRecoveryControlApi({ } const nextHash = await passwordHash(password) if (!nextHash) return { status: 400, body: { error: 'invalid_password' } } + // The session route sets only the first password; a later change goes + // through the reset link or Settings. + if (session.user.passwordHash) { + return { status: 409, body: { error: 'password_already_set' } } + } setPassword(session.user, nextHash, invitation) return { status: 200, From e1746484502051ac2d8f56be601fd880b5d9af59 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 22 Oct 2026 16:24:15 +0200 Subject: [PATCH 357/528] test(control-api): pin password producer mappings to the shared contract The edge publishes stable machine codes for not-found, already-used and expired invitation bodies (`9f94f30a2`), and profile-ui classifies terminal states from them. The composed producer contract still expected the uncoded bodies; its last green run predated the edge change. The expected bodies and statuses now come from tests/contracts/invitation-password-public-errors.json, the document the edge and component tests already read. Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../passwordRecovery.http.contract.test.ts | 45 ++++++++++++------- 3 files changed, 32 insertions(+), 19 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 081c3bc260..4d473b0dad 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.814", + "version": "0.1.815", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.814", + "version": "0.1.815", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 58addd2546..02894656ac 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.814", + "version": "0.1.815", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/passwordRecovery.http.contract.test.ts b/control-api/test/passwordRecovery.http.contract.test.ts index 84a3d2ef29..4898d59f0b 100644 --- a/control-api/test/passwordRecovery.http.contract.test.ts +++ b/control-api/test/passwordRecovery.http.contract.test.ts @@ -1,5 +1,6 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import express from 'express' +import { readFileSync } from 'node:fs' import { request as httpRequest } from 'node:http' import type { IncomingHttpHeaders, Server } from 'node:http' import { requireInternalToken } from '../src/middleware/internalServiceAuth.js' @@ -14,6 +15,23 @@ import { } from './helpers/externalRestHttpProcess.js' import { responseDropProxyApp } from './helpers/passwordRecoveryResponseProxy.js' +// The edge publishes these bodies and profile-ui classifies them by `code` +// from the same document, so the producer mappings are pinned to it. +const invitationPasswordContract = JSON.parse( + readFileSync( + new URL('../../tests/contracts/invitation-password-public-errors.json', import.meta.url), + 'utf8' + ) +) as Record< + 'password_not_found' | 'password_not_pending' | 'password_expired', + { status: number; body: Record } +> +const { + password_not_found: notFound, + password_not_pending: notPending, + password_expired: expired, +} = invitationPasswordContract + const externalConfig = { controlApiBaseUrl: '', controlApiServiceToken: '', @@ -481,14 +499,9 @@ describe('invitation/password producer response mappings', () => { 403, { error: 'Invitation is invalid or unavailable' }, ], - ['missing invitation', { error: 'not_found' }, 404, { error: 'Invitation not found' }], - [ - 'redeemed invitation', - { error: 'not_pending' }, - 409, - { error: 'Invitation has already been used' }, - ], - ['expired invitation', { error: 'expired' }, 410, { error: 'Invitation has expired' }], + ['missing invitation', { error: 'not_found' }, notFound.status, notFound.body], + ['redeemed invitation', { error: 'not_pending' }, notPending.status, notPending.body], + ['expired invitation', { error: 'expired' }, expired.status, expired.body], ])( 'maps the producer %s domain response to the public contract', async (label, result, status, body) => { @@ -610,8 +623,8 @@ describe('invitation/password producer response mappings', () => { serviceResult: { error: 'user_retired' }, producerStatus: 401, producerBody: { error: 'user_retired' }, - publicStatus: 404, - publicBody: { error: 'Invitation not found' }, + publicStatus: notFound.status, + publicBody: notFound.body, }, { label: 'wrong member binding', @@ -630,8 +643,8 @@ describe('invitation/password producer response mappings', () => { serviceResult: { error: 'not_found' }, producerStatus: 404, producerBody: { error: 'not_found' }, - publicStatus: 404, - publicBody: { error: 'Invitation not found' }, + publicStatus: notFound.status, + publicBody: notFound.body, }, { label: 'invitation no longer accepted', @@ -650,8 +663,8 @@ describe('invitation/password producer response mappings', () => { serviceResult: { error: 'not_pending' }, producerStatus: 409, producerBody: { error: 'invitation_not_pending' }, - publicStatus: 409, - publicBody: { error: 'Invitation has already been used' }, + publicStatus: notPending.status, + publicBody: notPending.body, }, { label: 'reset invitation expired through authenticated setup', @@ -660,8 +673,8 @@ describe('invitation/password producer response mappings', () => { serviceResult: { error: 'expired' }, producerStatus: 410, producerBody: { error: 'expired' }, - publicStatus: 410, - publicBody: { error: 'Invitation has expired' }, + publicStatus: expired.status, + publicBody: expired.body, }, ])('maps the authenticated password-setup producer outcome: $label', async scenario => { directory.setInvitationPasswordForUser.mockResolvedValue(scenario.serviceResult) From 912052be8d76e2c1d50c88359e4182fe56dd13c1 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 22 Oct 2026 16:40:51 +0200 Subject: [PATCH 358/528] test(external-rest-api): witness session issuance in the fallback chain Both fallback chain cases asserted that no profile_session cookie is returned, which also holds when the cookie writer does nothing. Each case now first accepts a pending invitation through the edge against the same producer stand-in and observes the product-written cookie with the same header read, then checks that the replay and the producer 400 do not set one. Disabling setProfileSessionCookie turns both cases red at the witness. Co-Authored-By: Claude Opus 5.5 --- external-rest-api/package-lock.json | 4 +-- external-rest-api/package.json | 2 +- external-rest-api/src/releaseManifest.ts | 2 +- .../invitationPasswordFallback.chain.test.ts | 35 +++++++++++++++++++ 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index d01d6fc55b..2ba09d3958 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.152", + "version": "0.1.153", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.152", + "version": "0.1.153", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index c7e39d9d52..0b3532c33c 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.152", + "version": "0.1.153", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index 44544b5aac..7022a61615 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.11.0', - externalRestApiVersion: '0.1.152', + externalRestApiVersion: '0.1.153', rpcProxyVersion: '0.1.132', desktopVersion: '0.11.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/test/invitationPasswordFallback.chain.test.ts b/external-rest-api/test/invitationPasswordFallback.chain.test.ts index 142e7a353f..a68054e61a 100644 --- a/external-rest-api/test/invitationPasswordFallback.chain.test.ts +++ b/external-rest-api/test/invitationPasswordFallback.chain.test.ts @@ -12,6 +12,8 @@ import { config } from '../src/config.js' const invitationToken = 'synthetic-member-invitation-proof' const invitationId = '00000000-0000-4000-8000-000000000001' const inviteeEmail = 'member@example.invalid' +const witnessToken = 'synthetic-witness-invitation-proof' +const witnessSession = 'synthetic-witness-session-token' type UpstreamRequest = { method?: string; url?: string; body?: Record } @@ -63,6 +65,21 @@ describe('sessionless invitation password fallback through the producer', () => return } if (req.method === 'POST' && req.url === '/api/v1/external/invitations/accept') { + if (requests.at(-1)?.body?.token === witnessToken) { + res.writeHead(200) + res.end( + JSON.stringify({ + accepted: true, + teamId: null, + teamName: null, + role: 'member', + email: inviteeEmail, + userId: '00000000-0000-4000-8000-000000000003', + token: witnessSession, + }) + ) + return + } res.writeHead(accept.status) res.end(JSON.stringify(accept.body)) return @@ -79,6 +96,22 @@ describe('sessionless invitation password fallback through the producer', () => return requests } + // Positive witness in the same app and producer: a first acceptance through + // the edge issues the product session cookie the fallback response must lack. + async function expectWitnessSessionCookie(requests: UpstreamRequest[]) { + const witness = await request(createApp()) + .post('/api/v1/invitations/accept') + .send({ token: witnessToken, email: inviteeEmail }) + expect(witness.status).toBe(200) + expect(String(witness.headers['set-cookie'] ?? '')).toContain( + `profile_session=${witnessSession}` + ) + expect(requests.map(entry => `${entry.method} ${entry.url}`)).toEqual([ + 'POST /api/v1/external/invitations/accept', + ]) + requests.splice(0) + } + async function submitFallbackPassword() { return request(createApp()).post('/api/v1/invitations/password').send({ token: invitationToken, @@ -109,6 +142,7 @@ describe('sessionless invitation password fallback through the producer', () => replay: true, }, }) + await expectWitnessSessionCookie(requests) const response = await submitFallbackPassword() @@ -122,6 +156,7 @@ describe('sessionless invitation password fallback through the producer', () => it('maps a producer 400 not_pending to the non-terminal invalid invitation body', async () => { const requests = await startProducer({ status: 400, body: { error: 'not_pending' } }) + await expectWitnessSessionCookie(requests) const response = await submitFallbackPassword() From ff63612cbef3d9bc65da809a2e49624346056188 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 22 Oct 2026 17:09:30 +0200 Subject: [PATCH 359/528] refactor(control-api): restore the single invitation store membership.ts had copied 13 invitation helpers out of invitationStore.ts; 9 were byte-identical. Make the store the only definition and import the helpers from it. The four divergences are reconciled on one rule each: - getInvitationRecordByToken and getInvitationRecordById take the { lock } options argument; the password-reset caller passes { lock: true }. - getInvitationRecordByTokenOrId keeps membership's strict rule: token and id must both match when both are supplied, plus the { lock } option. - ensureInvitationUser keeps membership's no-overwrite version: insert ON CONFLICT (email) DO NOTHING, re-select on a race, and never rename an existing users.name or profiles.display_name. This removes the rename on the first-password path. Remove the duplicated row/input types, INVITATION_TTL_HOURS and DRAFT_INVITATION_CLEANUP_HOURS from membership.ts. The exported surface stays unchanged by re-exporting the constant and the two public types from the store, as dev did. Add a real-PostgreSQL regression test for the no-rename rule, with a liveness witness that a brand-new invitee still receives invitee_name. Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../src/services/directory/invitationStore.ts | 65 ++- .../src/services/directory/memberPassword.ts | 2 +- .../src/services/directory/membership.ts | 381 ++---------------- ...dFirstSet.realPostgres.integration.test.ts | 7 +- ...dIdentity.realPostgres.integration.test.ts | 248 ++++++++++++ tests/contracts/password-recovery-suites.json | 1 + 8 files changed, 311 insertions(+), 399 deletions(-) create mode 100644 control-api/test/invitationFirstPasswordIdentity.realPostgres.integration.test.ts diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 4d473b0dad..9174a0ab35 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.815", + "version": "0.1.816", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.815", + "version": "0.1.816", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 02894656ac..64dd753acd 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.815", + "version": "0.1.816", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/directory/invitationStore.ts b/control-api/src/services/directory/invitationStore.ts index 928c075c20..08fb4cba49 100644 --- a/control-api/src/services/directory/invitationStore.ts +++ b/control-api/src/services/directory/invitationStore.ts @@ -63,7 +63,8 @@ export function normalizeInvitationPurpose(purpose?: InvitationPurpose): Invitat export async function getInvitationRecordByToken( db: Pick, - token: string + token: string, + options: { lock?: boolean } = {} ): Promise { const result = await db.query( `SELECT i.id, i.team_id, i.invitee_name, i.email, i.role, i.token, i.status, i.purpose, i.created_at, i.expires_at, i.accepted_at, @@ -72,7 +73,8 @@ export async function getInvitationRecordByToken( FROM invitations i LEFT JOIN teams t ON t.id = i.team_id WHERE i.token = $1 - LIMIT 1`, + LIMIT 1 + ${options.lock ? 'FOR UPDATE OF i' : ''}`, [token] ) return (result.rows[0] as InvitationRow | undefined) || null @@ -81,7 +83,8 @@ export async function getInvitationRecordByToken( export async function getInvitationRecordByTokenOrId( db: Pick, token: string, - invitationId: string + invitationId: string, + options: { lock?: boolean } = {} ): Promise { const normalizedToken = token.trim() const normalizedInvitationId = invitationId.trim() @@ -95,10 +98,12 @@ export async function getInvitationRecordByTokenOrId( t.name AS team_name FROM invitations i LEFT JOIN teams t ON t.id = i.team_id - WHERE ($1 <> '' AND i.token = $1) - OR ($2 <> '' AND i.id::text = $2) + WHERE ($1 <> '' AND $2 = '' AND i.token = $1) + OR ($1 = '' AND $2 <> '' AND i.id::text = $2) + OR ($1 <> '' AND $2 <> '' AND i.token = $1 AND i.id::text = $2) ORDER BY i.created_at DESC - LIMIT 1`, + LIMIT 1 + ${options.lock ? 'FOR UPDATE OF i' : ''}`, [normalizedToken, normalizedInvitationId] ) @@ -108,7 +113,7 @@ export async function getInvitationRecordByTokenOrId( export async function getInvitationRecordById( db: Pick, invitationId: string, - lockForUpdate = false + options: { lock?: boolean } = {} ): Promise { const normalizedInvitationId = invitationId.trim() if (!normalizedInvitationId) return null @@ -121,7 +126,7 @@ export async function getInvitationRecordById( LEFT JOIN teams t ON t.id = i.team_id WHERE i.id::text = $1 LIMIT 1 - ${lockForUpdate ? 'FOR UPDATE OF i' : ''}`, + ${options.lock ? 'FOR UPDATE OF i' : ''}`, [normalizedInvitationId] ) return (result.rows[0] as InvitationRow | undefined) || null @@ -180,29 +185,26 @@ export async function ensureInvitationUser( let user = (existing.rows[0] as InvitationUserRow | undefined) || null if (!user) { - user = ( - await db.query( - `INSERT INTO users(email, name) + const inserted = await db.query( + `INSERT INTO users(email, name) VALUES($1, $2) - ON CONFLICT (email) DO UPDATE - SET name = COALESCE(users.name, EXCLUDED.name), - updated_at = NOW() - RETURNING id, email, name, picture, password_hash, lifecycle_state, lifecycle_version`, - [normalizedEmail, normalizedInviteeName] - ) - ).rows[0] as InvitationUserRow - } else if (normalizedInviteeName && user.name !== normalizedInviteeName) { - user = ( - await db.query( - `UPDATE users - SET name = $2, - updated_at = NOW() - WHERE id = $1 + ON CONFLICT (email) DO NOTHING RETURNING id, email, name, picture, password_hash, lifecycle_state, lifecycle_version`, - [user.id, normalizedInviteeName] + [normalizedEmail, normalizedInviteeName] + ) + user = (inserted.rows[0] as InvitationUserRow | undefined) || null + if (!user) { + const racedExisting = await db.query( + `SELECT id, email, name, picture, password_hash, lifecycle_state, lifecycle_version + FROM users + WHERE email = $1 + LIMIT 1`, + [normalizedEmail] ) - ).rows[0] as InvitationUserRow + user = (racedExisting.rows[0] as InvitationUserRow | undefined) || null + } } + if (!user) throw new Error('Failed to resolve invitation user') await db.query( `INSERT INTO profiles(user_id, display_name) @@ -210,15 +212,6 @@ export async function ensureInvitationUser( ON CONFLICT (user_id) DO NOTHING`, [user.id, user.name || null] ) - if (normalizedInviteeName) { - await db.query( - `UPDATE profiles - SET display_name = $2, - updated_at = NOW() - WHERE user_id = $1`, - [user.id, normalizedInviteeName] - ) - } return user } diff --git a/control-api/src/services/directory/memberPassword.ts b/control-api/src/services/directory/memberPassword.ts index 3f624f86b5..183bab2f19 100644 --- a/control-api/src/services/directory/memberPassword.ts +++ b/control-api/src/services/directory/memberPassword.ts @@ -209,7 +209,7 @@ export async function setInvitationPasswordForEmail( if (expectedUserId && user.id !== expectedUserId.trim()) { return { error: 'forbidden' as const } } - const invitation = await getInvitationRecordById(db, trimmedInvitationId, true) + const invitation = await getInvitationRecordById(db, trimmedInvitationId, { lock: true }) if (!invitation || invitation.purpose !== 'password_reset') { return { error: 'not_found' as const } } diff --git a/control-api/src/services/directory/membership.ts b/control-api/src/services/directory/membership.ts index 293d05fc35..7725d8b0d0 100644 --- a/control-api/src/services/directory/membership.ts +++ b/control-api/src/services/directory/membership.ts @@ -5,6 +5,28 @@ import type { ExternalSessionAuthorityContext } from '../auth/externalSessionAut import { validateExternalSessionAuthorityContext } from '../auth/userSessionService.js' import { registerAndSendInvitation } from '../invitationFlowRegistrationService.js' import { appendControlApiPermissionEventsInTransaction } from '../tracing/controlApiPermissionEvents.js' +import { + type InvitationForTeamsInput, + type InvitationPurpose, + type InvitationRow, + type InvitationTeamAssignment, + type InvitationTeamRow, + type InvitationUserRow, + type InvitationWithTeams, + cleanupStaleDraftInvitations, + ensureInvitationUser, + getInvitationRecordById, + getInvitationRecordByToken, + getInvitationRecordByTokenOrId, + insertInvitationForTeams, + insertInvitationTeams, + invitationForTeamsResponse, + invitationResponse, + invitationUserIsRetired, + loadInvitationTeams, + normalizeInvitationPurpose, + normalizeInvitationTeamAssignments, +} from './invitationStore.js' import type { InviteRole, TeamRole } from './types.js' import { normalizeChannels, @@ -23,98 +45,13 @@ export { withPasswordWorkLease as withMemberPasswordWorkLease } from '../auth/pa export type { MemberPasswordWorkLease } from '../auth/passwordWorkOwnership.js' export { requestProfilePasswordReset } from './passwordResetInvitations.js' -export const INVITATION_TTL_HOURS = 48 -const DRAFT_INVITATION_CLEANUP_HOURS = 24 +export { INVITATION_TTL_HOURS } from './invitationStore.js' +export type { InvitationPurpose, InvitationTeamAssignment } from './invitationStore.js' + const DRAFT_INVITATION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000 const USER_LOCK_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i let draftInvitationCleanupTimer: ReturnType | null = null -type InvitationRow = { - id: string - team_id: string | null - invitee_name: string | null - email: string - role: InviteRole - token: string - status: string - purpose: InvitationPurpose - created_at: Date - expires_at: Date - accepted_at: Date | null - accepted_user_id: string | null - team_name: string | null -} - -export type InvitationPurpose = 'member_invitation' | 'password_reset' | 'admin_desktop_access' - -export type InvitationTeamAssignment = { - teamId: string - role: InviteRole -} - -type InvitationTeamRow = { - team_id: string - team_name: string - role: InviteRole -} - -type InvitationUserRow = { - id: string - email: string - name: string | null - picture: string | null - password_hash: string | null - lifecycle_state?: 'active' | 'retired' | string | null - lifecycle_version?: number | string | null -} - -type InvitationWithTeams = { - invitation: InvitationRow - teams: InvitationTeamRow[] -} - -type InvitationForTeamsInput = { - inviteeName: string - email: string - teamAssignments?: readonly InvitationTeamAssignment[] - purpose?: InvitationPurpose - fallbackRole?: InviteRole -} - -function normalizeInvitationPurpose(purpose?: InvitationPurpose): InvitationPurpose { - if (purpose === 'password_reset') return 'password_reset' - if (purpose === 'admin_desktop_access') return 'admin_desktop_access' - return 'member_invitation' -} - -async function getInvitationRecordByToken( - db: Pick, - token: string, - options: { lock?: boolean } = {} -): Promise { - const result = await db.query( - `SELECT i.id, i.team_id, i.invitee_name, i.email, i.role, i.token, i.status, i.purpose, i.created_at, i.expires_at, i.accepted_at, - i.accepted_user_id, - t.name AS team_name - FROM invitations i - LEFT JOIN teams t ON t.id = i.team_id - WHERE i.token = $1 - LIMIT 1 - ${options.lock ? 'FOR UPDATE OF i' : ''}`, - [token] - ) - return (result.rows[0] as InvitationRow | undefined) || null -} - -async function cleanupStaleDraftInvitations(): Promise { - await pool.query( - `DELETE FROM invitations - WHERE status = 'draft' - AND created_at < NOW() - ($1::text || ' hours')::interval`, - [String(DRAFT_INVITATION_CLEANUP_HOURS)] - ) -} - export function startDraftInvitationCleanup(intervalMs = DRAFT_INVITATION_CLEANUP_INTERVAL_MS) { if (draftInvitationCleanupTimer) return cleanupStaleDraftInvitations().catch(() => undefined) @@ -130,150 +67,6 @@ export function stopDraftInvitationCleanup() { draftInvitationCleanupTimer = null } -async function getInvitationRecordByTokenOrId( - db: Pick, - token: string, - invitationId: string, - options: { lock?: boolean } = {} -): Promise { - const normalizedToken = token.trim() - const normalizedInvitationId = invitationId.trim() - if (!normalizedToken && !normalizedInvitationId) { - return null - } - - const result = await db.query( - `SELECT i.id, i.team_id, i.invitee_name, i.email, i.role, i.token, i.status, i.purpose, i.created_at, i.expires_at, i.accepted_at, - i.accepted_user_id, - t.name AS team_name - FROM invitations i - LEFT JOIN teams t ON t.id = i.team_id - WHERE ($1 <> '' AND $2 = '' AND i.token = $1) - OR ($1 = '' AND $2 <> '' AND i.id::text = $2) - OR ($1 <> '' AND $2 <> '' AND i.token = $1 AND i.id::text = $2) - ORDER BY i.created_at DESC - LIMIT 1 - ${options.lock ? 'FOR UPDATE OF i' : ''}`, - [normalizedToken, normalizedInvitationId] - ) - - return (result.rows[0] as InvitationRow | undefined) || null -} - -async function getInvitationRecordById( - db: Pick, - invitationId: string, - options: { lock?: boolean } = {} -): Promise { - const normalizedInvitationId = invitationId.trim() - if (!normalizedInvitationId) return null - - const result = await db.query( - `SELECT i.id, i.team_id, i.invitee_name, i.email, i.role, i.token, i.status, i.purpose, i.created_at, i.expires_at, i.accepted_at, - i.accepted_user_id, - t.name AS team_name - FROM invitations i - LEFT JOIN teams t ON t.id = i.team_id - WHERE i.id::text = $1 - LIMIT 1 - ${options.lock ? 'FOR UPDATE OF i' : ''}`, - [normalizedInvitationId] - ) - return (result.rows[0] as InvitationRow | undefined) || null -} - -async function ensureInvitationUser( - db: Pick, - email: string, - inviteeName: string | null -): Promise { - const normalizedEmail = email.trim().toLowerCase() - const normalizedInviteeName = String(inviteeName || '').trim() || null - const existing = await db.query( - `SELECT id, email, name, picture, password_hash, lifecycle_state, lifecycle_version - FROM users - WHERE email = $1 - LIMIT 1`, - [normalizedEmail] - ) - - let user = (existing.rows[0] as InvitationUserRow | undefined) || null - if (!user) { - const inserted = await db.query( - `INSERT INTO users(email, name) - VALUES($1, $2) - ON CONFLICT (email) DO NOTHING - RETURNING id, email, name, picture, password_hash, lifecycle_state, lifecycle_version`, - [normalizedEmail, normalizedInviteeName] - ) - user = (inserted.rows[0] as InvitationUserRow | undefined) || null - if (!user) { - const racedExisting = await db.query( - `SELECT id, email, name, picture, password_hash, lifecycle_state, lifecycle_version - FROM users - WHERE email = $1 - LIMIT 1`, - [normalizedEmail] - ) - user = (racedExisting.rows[0] as InvitationUserRow | undefined) || null - } - } - if (!user) throw new Error('Failed to resolve invitation user') - - await db.query( - `INSERT INTO profiles(user_id, display_name) - VALUES($1, $2) - ON CONFLICT (user_id) DO NOTHING`, - [user.id, user.name || null] - ) - - return user -} - -function invitationUserIsRetired(user: InvitationUserRow): boolean { - return user.lifecycle_state === 'retired' -} - -function normalizeInvitationTeamAssignments( - assignments: readonly InvitationTeamAssignment[] | null | undefined -): InvitationTeamAssignment[] { - const byTeamId = new Map() - for (const assignment of assignments || []) { - const teamId = String(assignment?.teamId || '').trim() - const role = normalizeTeamRoleInput(assignment?.role) - if (!teamId || !role) continue - byTeamId.set(teamId, role) - } - return Array.from(byTeamId.entries()).map(([teamId, role]) => ({ teamId, role })) -} - -async function loadInvitationTeams( - db: Pick, - invitationId: string, - fallback?: Pick -): Promise { - const result = await db.query( - `SELECT it.team_id, t.name AS team_name, it.role - FROM invitation_teams it - JOIN teams t ON t.id = it.team_id - WHERE it.invitation_id = $1 - ORDER BY CASE WHEN it.team_id = $2::uuid THEN 0 ELSE 1 END, t.name ASC`, - [invitationId, fallback?.team_id || null] - ) - const rows = result.rows as InvitationTeamRow[] - if (rows.length > 0) return rows - if (fallback?.team_id) { - return [ - { - team_id: fallback.team_id, - team_name: fallback.team_name || 'Team', - role: fallback.role, - }, - ] - } - return [] -} - async function loadInvitationTeamsByInvitationIds( db: Pick, invitationIds: readonly string[], @@ -417,71 +210,6 @@ async function applyInvitationAcceptance( return null } -async function insertInvitationTeams( - db: Pick, - invitationId: string, - assignments: readonly InvitationTeamAssignment[] -): Promise { - if (assignments.length === 0) return - await db.query( - `INSERT INTO invitation_teams(invitation_id, team_id, role) - SELECT $1::uuid, item.team_id::uuid, item.role - FROM jsonb_to_recordset($2::jsonb) AS item(team_id text, role text) - ON CONFLICT (invitation_id, team_id) - DO UPDATE SET role = EXCLUDED.role`, - [ - invitationId, - JSON.stringify(assignments.map(item => ({ team_id: item.teamId, role: item.role }))), - ] - ) -} - -function invitationResponse( - invitation: InvitationRow, - user: InvitationUserRow | null, - teams: InvitationTeamRow[] = [] -): { - id: string - teamId: string | null - teamName: string | null - teams: Array<{ id: string; name: string; role: InviteRole }> - email: string - role: InviteRole - purpose: InvitationPurpose - status: string - expiresAt: string - acceptedAt: string | null - userId: string | null - passwordPending: boolean - authGeneration: number | null - lifecycleState: string | null -} { - const primaryTeam = teams[0] || null - return { - id: invitation.id, - teamId: primaryTeam?.team_id || invitation.team_id, - teamName: primaryTeam?.team_name || invitation.team_name, - teams: teams.map(team => ({ - id: team.team_id, - name: team.team_name, - role: team.role, - })), - email: invitation.email, - role: primaryTeam?.role || invitation.role, - purpose: invitation.purpose, - status: invitation.status, - expiresAt: invitation.expires_at.toISOString(), - acceptedAt: invitation.accepted_at ? invitation.accepted_at.toISOString() : null, - userId: invitation.accepted_user_id || user?.id || null, - passwordPending: !user?.password_hash, - authGeneration: - user?.lifecycle_version === undefined || user.lifecycle_version === null - ? null - : Number(user.lifecycle_version), - lifecycleState: user?.lifecycle_state ?? null, - } -} - export async function findMembership(userId: string, teamId: string) { const result = await pool.query( `SELECT tm.team_id, tm.role, t.name AS team_name @@ -698,65 +426,6 @@ export async function addMemberToTeam( }) } -async function insertInvitationForTeams( - db: Pick, - input: InvitationForTeamsInput & { status: 'draft' | 'pending' } -): Promise { - const normalizedAssignments = normalizeInvitationTeamAssignments(input.teamAssignments) - const primaryAssignment = normalizedAssignments[0] || null - const fallbackRole = - normalizeTeamRoleInput(input.fallbackRole) || primaryAssignment?.role || 'member' - const purpose = normalizeInvitationPurpose(input.purpose) - const invited = await db.query( - `WITH inserted AS ( - INSERT INTO invitations(team_id, invitee_name, email, role, token, status, purpose, expires_at) - VALUES($1, $2, $3, $4, gen_random_uuid()::text, $5, $6, NOW() + ($7::text || ' hours')::interval) - RETURNING id, team_id, invitee_name, email, role, token, status, purpose, created_at, expires_at, accepted_at, accepted_user_id - ) - SELECT inserted.id, - inserted.team_id, - inserted.invitee_name, - inserted.email, - inserted.role, - inserted.token, - inserted.status, - inserted.purpose, - inserted.created_at, - inserted.expires_at, - inserted.accepted_at, - inserted.accepted_user_id, - t.name AS team_name - FROM inserted - LEFT JOIN teams t ON t.id = inserted.team_id`, - [ - primaryAssignment?.teamId || null, - input.inviteeName.trim(), - input.email.trim().toLowerCase(), - primaryAssignment?.role || fallbackRole, - input.status, - purpose, - String(INVITATION_TTL_HOURS), - ] - ) - const invitation = invited.rows[0] as InvitationRow - await insertInvitationTeams(db, invitation.id, normalizedAssignments) - return { - invitation, - teams: await loadInvitationTeams(db, invitation.id, invitation), - } -} - -function invitationForTeamsResponse(invitation: InvitationRow, teams: InvitationTeamRow[]) { - return { - ...invitation, - teams: teams.map(team => ({ - id: team.team_id, - name: team.team_name, - role: team.role, - })), - } -} - async function recordInvitationDeliveryCommand( db: Pick, invitationId: string, diff --git a/control-api/test/externalInvitationPasswordFirstSet.realPostgres.integration.test.ts b/control-api/test/externalInvitationPasswordFirstSet.realPostgres.integration.test.ts index b3969a85cd..ea6f581417 100644 --- a/control-api/test/externalInvitationPasswordFirstSet.realPostgres.integration.test.ts +++ b/control-api/test/externalInvitationPasswordFirstSet.realPostgres.integration.test.ts @@ -298,9 +298,10 @@ describeRealPostgres('external invitation password setup on real PostgreSQL', () authenticationMethods: ['password'], }) // A refused call must not overwrite user-chosen identity fields either. - // ensureInvitationUser writes users.name and profiles.display_name from the - // invitation's invitee_name, so pin both rows to recognizable values before - // the refusal and require them to survive it byte-identically. + // The first-password path resolves the invitee through the invitation user + // helper, which must leave an existing users.name and profiles.display_name + // untouched; pin both rows to recognizable values before the refusal and + // require them to survive it byte-identically. await holder.pool.query( `UPDATE users SET name = 'User selected name', diff --git a/control-api/test/invitationFirstPasswordIdentity.realPostgres.integration.test.ts b/control-api/test/invitationFirstPasswordIdentity.realPostgres.integration.test.ts new file mode 100644 index 0000000000..7b9cda352b --- /dev/null +++ b/control-api/test/invitationFirstPasswordIdentity.realPostgres.integration.test.ts @@ -0,0 +1,248 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import express from 'express' +import bcrypt from 'bcryptjs' +import { randomBytes, randomUUID } from 'node:crypto' +import { Pool } from 'pg' +import request from 'supertest' +import { initDb } from '../src/db.js' +import { createExternalInvitationsRouter } from '../src/routes/external/invitations.js' +import type { EffectiveUserAccessPolicy } from '../src/services/access/userAccessPolicy.js' +import { createUserSession } from '../src/services/auth/userSessionService.js' +import { + acceptInvitationById, + createSilentInvitationForTeams, +} from '../src/services/directory/index.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' +import './realPostgres.requirement.ts' + +const holder = vi.hoisted(() => ({ pool: null as unknown as Pool })) +const accessPolicy = vi.hoisted(() => ({ resolveEffectiveUserAccessPolicy: vi.fn() })) + +vi.mock('../src/services/access/userAccessRuntimePolicy.js', () => accessPolicy) + +vi.mock('../src/db.js', async importOriginal => { + const real = await importOriginal() + const proxy = { + query: (...args: unknown[]) => (holder.pool.query as Function)(...args), + connect: () => holder.pool.connect(), + } + return { + ...real, + pool: proxy, + rateLimitPool: proxy, + withTransaction: ( + work: Parameters[0], + _txPool?: Parameters[1], + options?: Parameters[2] + ) => real.withTransaction(work, holder.pool, options), + } +}) + +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const describeRealPostgres = adminUrl ? describe : describe.skip + +const FIRST_PASSWORD = 'First-Password-Identity-1!' + +const policy = { + policyVersion: '1', + policyRevision: 'invitation-password-identity-test', + acceptV1: false, + issueV1: false, + acceptV2: true, + issueV2: true, + renewV2: true, + switchCompatibility: false, + computeCatalogShadow: false, + serveCatalog: false, + actionContextV2: false, + rpcDelegationV2: false, + desktopAllTeamMode: false, + profileV2Mode: false, + minimumClientVersion: null, + enforceMinimumClient: false, + advertisedCatalogFamilies: [], +} as EffectiveUserAccessPolicy + +type IdentityState = { + name: string | null + user_updated_at: Date + display_name: string | null + profile_updated_at: Date +} + +type AcceptedInvitation = { + invitationId: string + userId: string +} + +function app(): express.Express { + const a = express() + a.set('trust proxy', 1) + a.use(express.json()) + a.use(createExternalInvitationsRouter()) + a.use( + (err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + const message = err instanceof Error ? err.message : 'unknown route failure' + res.status(500).json({ error: 'Internal Server Error', message }) + } + ) + return a +} + +describeRealPostgres('invitation first-password identity on real PostgreSQL', () => { + const database = `control_api_invitation_first_password_identity_${randomBytes(6).toString('hex')}` + let adminPool: Pool + let teamId: string + + async function identityState(userId: string): Promise { + const result = await holder.pool.query( + `SELECT u.name, + u.updated_at AS user_updated_at, + p.display_name, + p.updated_at AS profile_updated_at + FROM users u + JOIN profiles p ON p.user_id = u.id + WHERE u.id = $1`, + [userId] + ) + const state = result.rows[0] + if (!state) throw new Error(`identity for user ${userId} was not found`) + return state + } + + async function inviteAndAccept(email: string, inviteeName: string): Promise { + const invitation = await createSilentInvitationForTeams({ + inviteeName, + email, + teamAssignments: [{ teamId, role: 'member' }], + fallbackRole: 'member', + }) + const accepted = await acceptInvitationById(email, invitation.id) + if ('error' in accepted) throw new Error(accepted.error) + return { invitationId: invitation.id, userId: accepted.data.userId } + } + + async function setFirstPassword(userId: string, email: string, invitationId: string) { + const session = await createUserSession({ + userId, + email, + authenticationMethods: ['password'], + }) + return request(app()) + .post('/external/invitations/password') + .set('x-user-session-token', session.token) + .send({ userId, email, invitationId, password: FIRST_PASSWORD }) + } + + beforeAll(async () => { + adminPool = new Pool({ connectionString: adminUrl }) + await adminPool.query(`CREATE DATABASE "${database}"`) + const connection = new URL(adminUrl!) + connection.pathname = `/${database}` + holder.pool = new Pool({ connectionString: connection.toString() }) + await initDb(holder.pool) + teamId = randomUUID() + await holder.pool.query(`INSERT INTO teams(id, name) VALUES ($1, 'Identity Team')`, [teamId]) + accessPolicy.resolveEffectiveUserAccessPolicy.mockResolvedValue(policy) + }, 60_000) + + afterAll(async () => { + try { + await endPoolAndWaitForClients(holder.pool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS "${database}"`) + } + } finally { + await adminPool?.end() + } + }) + + it('keeps an existing users.name and profiles.display_name when setting the first password', async () => { + const email = `existing-${randomUUID()}@example.test` + const existingName = 'Existing User Name' + const existingDisplayName = 'Existing Profile Name' + const inviteeName = 'Different Invitee Name' + + const inserted = await holder.pool.query<{ id: string }>( + `INSERT INTO users(email, name) VALUES ($1, $2) RETURNING id`, + [email, existingName] + ) + const userId = inserted.rows[0]!.id + await holder.pool.query(`INSERT INTO profiles(user_id, display_name) VALUES ($1, $2)`, [ + userId, + existingDisplayName, + ]) + + const { invitationId } = await inviteAndAccept(email, inviteeName) + + // Invitation acceptance resolves the same user through the invitation user + // helper. Re-pin both identity rows after that setup so the assertion below + // measures exactly what the first-password call writes (or leaves alone). + await holder.pool.query( + `UPDATE users + SET name = $2, + updated_at = '2001-01-01T00:00:00Z' + WHERE id = $1`, + [userId, existingName] + ) + await holder.pool.query( + `UPDATE profiles + SET display_name = $2, + updated_at = '2001-01-01T00:00:00Z' + WHERE user_id = $1`, + [userId, existingDisplayName] + ) + const before = await identityState(userId) + expect(before).toMatchObject({ + name: existingName, + display_name: existingDisplayName, + }) + + const response = await setFirstPassword(userId, email, invitationId) + + expect(response.status).toBe(200) + expect(response.body).toMatchObject({ passwordUpdated: true }) + // The owner-decided rule: resolving an existing invitee never renames the + // user or the profile. The credential write updates users.updated_at by + // design, so the identity columns and the whole profile row are what must + // survive unchanged. + const after = await identityState(userId) + expect(after.name).toBe(existingName) + expect(after.display_name).toBe(existingDisplayName) + expect(after.profile_updated_at).toEqual(before.profile_updated_at) + + const credential = await holder.pool.query<{ password_hash: string | null }>( + `SELECT password_hash FROM users WHERE id = $1`, + [userId] + ) + const passwordHash = credential.rows[0]?.password_hash + if (!passwordHash) throw new Error('first password setup did not store a hash') + await expect(bcrypt.compare(FIRST_PASSWORD, passwordHash)).resolves.toBe(true) + }) + + it('names a brand-new invitee from invitee_name through the same first-password flow', async () => { + const email = `new-${randomUUID()}@example.test` + const inviteeName = 'Brand New Invitee' + const { invitationId, userId } = await inviteAndAccept(email, inviteeName) + + expect(await identityState(userId)).toMatchObject({ + name: inviteeName, + display_name: inviteeName, + }) + + const response = await setFirstPassword(userId, email, invitationId) + + expect(response.status).toBe(200) + expect(response.body).toMatchObject({ passwordUpdated: true }) + expect(await identityState(userId)).toMatchObject({ + name: inviteeName, + display_name: inviteeName, + }) + }) +}) diff --git a/tests/contracts/password-recovery-suites.json b/tests/contracts/password-recovery-suites.json index aa2d4581c1..ee0960911d 100644 --- a/tests/contracts/password-recovery-suites.json +++ b/tests/contracts/password-recovery-suites.json @@ -102,6 +102,7 @@ "control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts", "control-api/test/externalInvitationAcceptSession.realPostgres.integration.test.ts", "control-api/test/externalInvitationPasswordFirstSet.realPostgres.integration.test.ts", + "control-api/test/invitationFirstPasswordIdentity.realPostgres.integration.test.ts", "control-api/test/externalSessionCurrentness.realPostgres.integration.test.ts", "control-api/test/externalSessionSingleTouch.realPostgres.integration.test.ts", "control-api/test/externalSessionV1CanonicalRevocation.realPostgres.integration.test.ts", From 2842074bf972b0c81ae57a8df79c1ae9ae527bf8 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 22 Oct 2026 17:12:32 +0200 Subject: [PATCH 360/528] refactor(control-api): split the access authority store by subsystem Move the 1016-line store into subsystem modules without changing behavior: - principalAuthoritySnapshot.ts: principal snapshot loading and the authorization membership parser. - authorityCandidates.ts: candidate types and constructors. - operationalAuthorityCandidates.ts: simple and derived operational grant candidates plus their scoped-name helper. - databaseAuthorityCandidates.ts: database-backed candidates and the resource authority result type. - accessAuthorityQuery.ts: the shared budgeted query wrapper. - accessAuthorityStore.ts keeps resource authority and operation target validity and filtering. No barrel re-exports: every importer now points at the owning module. The three private helpers and the shared query wrapper gained an export prefix because they cross the new module boundary; their bodies are unchanged (verified by S5/verify-split.py, 18/18 declarations textually identical). Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../services/access/accessAuthorityQuery.ts | 12 + .../services/access/accessAuthorityStore.ts | 917 +----------------- .../services/access/authorityCandidates.ts | 124 +++ .../access/catalogAuthorityContext.ts | 2 +- .../src/services/access/catalogProducers.ts | 2 +- .../access/databaseAuthorityCandidates.ts | 257 +++++ .../access/exactOperationalAuthorization.ts | 2 +- .../access/liveAuthorizationResolver.ts | 4 +- .../access/operationalAuthorityCandidates.ts | 387 ++++++++ .../access/principalAuthoritySnapshot.ts | 155 +++ 12 files changed, 954 insertions(+), 914 deletions(-) create mode 100644 control-api/src/services/access/accessAuthorityQuery.ts create mode 100644 control-api/src/services/access/authorityCandidates.ts create mode 100644 control-api/src/services/access/databaseAuthorityCandidates.ts create mode 100644 control-api/src/services/access/operationalAuthorityCandidates.ts create mode 100644 control-api/src/services/access/principalAuthoritySnapshot.ts diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 9174a0ab35..cedfda4de9 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.816", + "version": "0.1.817", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.816", + "version": "0.1.817", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 64dd753acd..0d703a3efd 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.816", + "version": "0.1.817", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/access/accessAuthorityQuery.ts b/control-api/src/services/access/accessAuthorityQuery.ts new file mode 100644 index 0000000000..c562271f29 --- /dev/null +++ b/control-api/src/services/access/accessAuthorityQuery.ts @@ -0,0 +1,12 @@ +import type { DbClient } from '../../db.js' +import { runAccessDatabaseQuery } from './accessDatabaseQuery.js' +import type { AccessExecutionBudget } from './accessExecutionBudget.js' + +export async function query( + db: Pick, + budget: AccessExecutionBudget, + text: string, + values: unknown[] +) { + return runAccessDatabaseQuery(db, budget, text, values) +} diff --git a/control-api/src/services/access/accessAuthorityStore.ts b/control-api/src/services/access/accessAuthorityStore.ts index d33ee8aa61..8d7a5c20e1 100644 --- a/control-api/src/services/access/accessAuthorityStore.ts +++ b/control-api/src/services/access/accessAuthorityStore.ts @@ -1,317 +1,20 @@ import type { DbClient } from '../../db.js' -import type { TeamRole } from '../../profileTypes.js' -import type { ExternalSessionAuthorityContext } from '../auth/externalSessionAuthentication.js' -import { legacySecurityCutoffRejectsSql } from '../auth/legacySecurityCutoff.js' -import { runAccessDatabaseQuery } from './accessDatabaseQuery.js' +import { query } from './accessAuthorityQuery.js' import type { AccessExecutionBudget } from './accessExecutionBudget.js' +import type { AuthorityCandidate } from './authorityCandidates.js' +import type { AccessCapability } from './capabilityRegistry.js' import { - type AccessPathBehavior, - type AccessPathSeed, - knownBehavior, - unknownBehavior, -} from './accessPath.js' -import type { AuthorizationMembershipRevision } from './authorizationRevision.js' -import { compareCanonicalUtf8Text } from './canonicalText.js' -import { - type AccessCapability, - capabilitiesForTeamRole, - gfsPermissionsToCapabilities, - normalizeAccessCapabilities, -} from './capabilityRegistry.js' -import { canonicalContextLogicalIdSql } from './contextIdentitySql.js' + type ResourceAuthorityResult, + loadDatabaseCandidates, +} from './databaseAuthorityCandidates.js' import type { ValidatedOperationTarget } from './operationTarget.js' import type { OperationalResourceGraphResult } from './operationalAccessReader.js' +import { + loadDerivedOperationalCandidates, + loadSimpleOperationalGrantCandidates, +} from './operationalAuthorityCandidates.js' +import type { PrincipalAuthoritySnapshot } from './principalAuthoritySnapshot.js' import type { CanonicalResourceIdentity } from './resourceIdentity.js' -import { workflowTriggerGrantIdentitySql } from './workflowTriggerGrantIdentity.js' - -export type OperationalPathBinding = Readonly<{ - resourceType: 'host' | 'context' | 'mcp_server' | 'workflow_recipe' | 'shared_filesystem' - logicalId: string - providerUid: string - relationships: readonly Readonly<{ - instanceId: string - behaviorAttributes: Readonly> - }>[] -}> - -export type AuthorityCandidate = AccessPathSeed & - Readonly<{ operationalBindings: readonly OperationalPathBinding[] }> - -export type PrincipalAuthoritySnapshot = Readonly<{ - userId: string - sessionContract: 'v1' | 'v2' - sessionLive: boolean - sessionRevision: string - userRevision: string - resourceRevision: string - memberships: readonly AuthorizationMembershipRevision[] -}> - -export type ResourceAuthorityResult = Readonly<{ - exists: boolean - candidates: readonly AuthorityCandidate[] - relationships: readonly Readonly<{ - type: string - targetResourceId: string - instanceId?: string - }>[] - validUntil: Date | null -}> - -async function query( - db: Pick, - budget: AccessExecutionBudget, - text: string, - values: unknown[] -) { - return runAccessDatabaseQuery(db, budget, text, values) -} - -export function parseAuthorizationMemberships(value: unknown): AuthorizationMembershipRevision[] { - if (!Array.isArray(value)) return [] - return value.flatMap(item => { - if (!item || typeof item !== 'object' || Array.isArray(item)) return [] - const row = item as Record - const teamId = String(row.teamId ?? '') - const role = String(row.role ?? '') as TeamRole - if (!teamId || !['admin', 'inviter', 'member'].includes(role)) return [] - return [ - Object.freeze({ - teamId, - role, - membershipUpdatedAt: new Date(String(row.membershipUpdatedAt)).toISOString(), - teamRevision: String(row.teamRevision ?? '1'), - }), - ] - }) -} - -export async function loadPrincipalAuthoritySnapshot(input: { - db: Pick - budget: AccessExecutionBudget - session: ExternalSessionAuthorityContext - resource: CanonicalResourceIdentity -}): Promise { - const result = await query( - input.db, - input.budget, - `WITH active_memberships AS ( - SELECT tm.team_id, tm.role, tm.updated_at, - COALESCE(atr.revision, 1) AS team_revision - FROM team_members tm - LEFT JOIN authorization_team_revisions atr ON atr.team_id = tm.team_id - WHERE tm.user_id = $1 - AND tm.status = 'active' - ) - SELECT u.id AS user_id, - COALESCE(aur.revision, 1) AS user_revision, - COALESCE(arr.revision, 1) AS resource_revision, - CASE - WHEN u.lifecycle_state <> 'active' THEN FALSE - WHEN $2::text = 'v2' THEN EXISTS ( - SELECT 1 - FROM external_user_sessions s - WHERE s.sid::text = $3 - AND s.user_id = u.id - AND s.session_version = $5 - AND s.revoked_at IS NULL - AND s.idle_expires_at > NOW() - AND s.absolute_expires_at > NOW() - AND ( - s.current_jti::text = $4 - OR ( - s.prior_jti::text = $4 - AND s.prior_jti_expires_at >= NOW() - ) - ) - ) - ELSE ( - $7::bigint IS NOT NULL - AND $8::bigint IS NOT NULL - AND u.lifecycle_version = $8::bigint - AND NOT EXISTS ( - SELECT 1 - FROM external_v1_session_revocations r - WHERE r.token_hash = $6 - AND r.user_id = u.id - AND r.expires_at > NOW() - ) - AND NOT EXISTS ( - SELECT 1 - FROM external_user_session_security_epochs e - WHERE e.user_id = u.id - AND ${legacySecurityCutoffRejectsSql('$7::bigint', 'e.valid_after')} - ) - ) - END AS session_live, - CASE - WHEN $2::text = 'v2' THEN COALESCE(( - SELECT s.session_version::text || ':' || s.current_jti::text || ':' || - COALESCE(s.revoked_at::text, '') || ':' || s.absolute_expires_at::text - FROM external_user_sessions s - WHERE s.sid::text = $3 AND s.user_id = u.id - ), 'missing') - ELSE COALESCE(( - SELECT EXTRACT(EPOCH FROM e.valid_after)::text - FROM external_user_session_security_epochs e - WHERE e.user_id = u.id - ), '0') || ':' || $6 - END AS session_revision, - COALESCE( - jsonb_agg( - jsonb_build_object( - 'teamId', am.team_id, - 'role', am.role, - 'membershipUpdatedAt', am.updated_at, - 'teamRevision', am.team_revision - ) ORDER BY am.team_id - ) FILTER (WHERE am.team_id IS NOT NULL), - '[]'::jsonb - ) AS memberships - FROM users u - LEFT JOIN authorization_user_revisions aur ON aur.user_id = u.id - LEFT JOIN authorization_resource_revisions arr - ON arr.environment_id = $9 - AND arr.resource_type = $10 - AND arr.resource_id = $11 - LEFT JOIN active_memberships am ON TRUE - WHERE u.id = $1 - GROUP BY u.id, aur.revision, arr.revision`, - [ - input.session.userId, - input.session.contract, - input.session.contract === 'v2' ? input.session.sid : null, - input.session.contract === 'v2' ? input.session.jti : null, - input.session.contract === 'v2' ? input.session.sessionVersion : null, - input.session.contract === 'v1' ? input.session.tokenHash : null, - input.session.contract === 'v1' ? input.session.issuedAt : null, - input.session.contract === 'v1' ? input.session.authGeneration : null, - input.resource.environmentId, - input.resource.type, - input.resource.logicalId, - ] - ) - const row = result.rows[0] as Record | undefined - if (!row) return null - return Object.freeze({ - userId: String(row.user_id), - sessionContract: input.session.contract, - sessionLive: row.session_live === true, - sessionRevision: String(row.session_revision), - userRevision: String(row.user_revision ?? '1'), - resourceRevision: String(row.resource_revision ?? '1'), - memberships: Object.freeze(parseAuthorizationMemberships(row.memberships)), - }) -} - -function behavior(input: { - userId: string - kind: 'direct' | 'team' - teamId?: string - runtimeSensitive?: boolean - filesystemScope?: string | null - runtimeRef?: string | null - providerModelRef?: string | null - approvalRef?: string | null -}): AccessPathBehavior { - const runtimeSensitive = input.runtimeSensitive === true - return Object.freeze({ - capabilities: Object.freeze([]), - budget: runtimeSensitive ? unknownBehavior() : knownBehavior(null), - credentialPolicy: runtimeSensitive ? unknownBehavior() : knownBehavior(null), - approvalPolicy: - input.approvalRef !== undefined - ? knownBehavior(input.approvalRef) - : runtimeSensitive - ? unknownBehavior() - : knownBehavior(null), - filesystemScope: - input.filesystemScope !== undefined - ? knownBehavior(input.filesystemScope) - : knownBehavior(null), - runtime: - input.runtimeRef !== undefined - ? knownBehavior(input.runtimeRef) - : runtimeSensitive - ? unknownBehavior() - : knownBehavior(null), - providerModelPolicy: - input.providerModelRef !== undefined - ? knownBehavior(input.providerModelRef) - : runtimeSensitive - ? unknownBehavior() - : knownBehavior(null), - audit: knownBehavior(input.kind === 'team' ? `team:${input.teamId}` : `user:${input.userId}`), - }) -} - -export function authorityCandidate(input: { - userId: string - kind: 'direct' | 'team' - grantId: string - teamId?: string - currentRole?: TeamRole - capabilities: readonly AccessCapability[] - runtimeSensitive?: boolean - filesystemScope?: string | null - runtimeRef?: string | null - providerModelRef?: string | null - approvalRef?: string | null - operationalBindings?: readonly OperationalPathBinding[] -}): AuthorityCandidate { - return Object.freeze({ - kind: input.kind, - grantId: input.grantId, - ...(input.teamId ? { teamId: input.teamId } : {}), - ...(input.currentRole ? { currentRole: input.currentRole } : {}), - behavior: Object.freeze({ - ...behavior(input), - capabilities: Object.freeze(normalizeAccessCapabilities(input.capabilities)), - }), - operationalBindings: Object.freeze([...(input.operationalBindings ?? [])]), - }) -} - -export function authorityCandidateFromGrantRow(input: { - row: Record - userId: string - capabilities: readonly AccessCapability[] - runtimeSensitive?: boolean - operationalBindings?: readonly OperationalPathBinding[] - filesystemScope?: string | null - runtimeRef?: string | null - providerModelRef?: string | null - approvalRef?: string | null -}): AuthorityCandidate | null { - const kind = input.row.kind === 'direct' ? 'direct' : input.row.kind === 'team' ? 'team' : null - const grantId = String(input.row.grant_id ?? '') - if (!kind || !grantId) return null - const teamId = kind === 'team' ? String(input.row.team_id ?? '') : undefined - const currentRole = kind === 'team' ? (String(input.row.current_role) as TeamRole) : undefined - if (kind === 'team' && (!teamId || !['admin', 'inviter', 'member'].includes(currentRole!))) { - return null - } - return authorityCandidate({ - userId: input.userId, - kind, - grantId, - ...(teamId ? { teamId } : {}), - ...(currentRole ? { currentRole } : {}), - capabilities: input.capabilities, - runtimeSensitive: input.runtimeSensitive, - operationalBindings: input.operationalBindings, - filesystemScope: input.filesystemScope, - runtimeRef: input.runtimeRef, - providerModelRef: input.providerModelRef, - approvalRef: input.approvalRef, - }) -} - -function scopedName(logicalId: string): { namespace: string; name: string } | null { - const separator = logicalId.indexOf('/') - if (separator < 1 || separator === logicalId.length - 1) return null - return { namespace: logicalId.slice(0, separator), name: logicalId.slice(separator + 1) } -} function publicRelationships(graph: OperationalResourceGraphResult | null) { if (!graph || graph.status !== 'current') return [] @@ -324,604 +27,6 @@ function publicRelationships(graph: OperationalResourceGraphResult | null) { ) } -async function loadSimpleOperationalGrantCandidates(input: { - db: Pick - budget: AccessExecutionBudget - userId: string - resource: CanonicalResourceIdentity - graph: Extract -}): Promise { - const identity = scopedName(input.resource.logicalId) - if (!identity) return [] - const isHost = input.resource.type === 'host' - const isContext = input.resource.type === 'context' - const isRecipe = ['workflow_recipe', 'sandbox_app'].includes(input.resource.type) - if (!isHost && !isContext && !isRecipe) return [] - const directWorkflowTriggerGrantId = workflowTriggerGrantIdentitySql('direct', 'uwt') - const teamWorkflowTriggerGrantId = workflowTriggerGrantIdentitySql('team', 'twt') - const result = await query( - input.db, - input.budget, - `WITH requested_context_references AS ( - SELECT $5::text AS logical_id, split_part($5::text, '/', 2) AS reference_name - UNION - SELECT alias_relationship.source_id, - split_part(alias_relationship.target_id, '/', 2) - FROM operational_resource_relationships alias_relationship - JOIN operational_resource_index alias_source - ON alias_source.environment_id = alias_relationship.environment_id - AND alias_source.resource_type = 'context' - AND alias_source.logical_id = alias_relationship.source_id - AND alias_source.provider_uid = alias_relationship.source_provider_uid - WHERE alias_relationship.environment_id = $6 - AND alias_relationship.source_type = 'context' - AND alias_relationship.source_id = $5 - AND alias_relationship.relationship_type = 'context_identity_alias' - AND alias_relationship.target_type = 'context' - ), candidates AS ( - SELECT 'direct'::text AS kind, - 'user_agents:' || ua.user_id || ':' || ua.agent_name AS grant_id, - NULL::uuid AS team_id, NULL::text AS current_role - FROM user_agents ua - WHERE $2::text = 'host' AND ua.user_id = $1 AND ua.agent_name = $4 - UNION ALL - SELECT 'team', 'team_agents:' || ta.team_id || ':' || ta.agent_name, - ta.team_id, tm.role - FROM team_agents ta - JOIN team_members tm ON tm.team_id = ta.team_id - WHERE $2::text = 'host' AND tm.user_id = $1 AND tm.status = 'active' - AND ta.agent_name = $4 - UNION ALL - SELECT 'direct', 'user_contexts:' || uc.user_id || ':' || uc.context_id, - NULL, NULL - FROM user_contexts uc - JOIN requested_context_references requested - ON requested.reference_name = uc.context_id - WHERE $2::text = 'context' AND uc.user_id = $1 - AND ${canonicalContextLogicalIdSql('$6', "split_part(requested.logical_id, '/', 1) || '/' || uc.context_id")} = requested.logical_id - UNION ALL - SELECT 'team', 'team_contexts:' || tc.team_id || ':' || tc.context_id, - tc.team_id, tm.role - FROM team_contexts tc - JOIN requested_context_references requested - ON requested.reference_name = tc.context_id - JOIN team_members tm ON tm.team_id = tc.team_id - WHERE $2::text = 'context' AND tm.user_id = $1 AND tm.status = 'active' - AND ${canonicalContextLogicalIdSql('$6', "split_part(requested.logical_id, '/', 1) || '/' || tc.context_id")} = requested.logical_id - UNION ALL - SELECT 'direct', ${directWorkflowTriggerGrantId}, - NULL, NULL - FROM user_workflow_triggers uwt - WHERE $2::text IN ('workflow_recipe','sandbox_app') AND uwt.user_id = $1 - AND uwt.recipe_namespace = $3 AND uwt.recipe_name = $4 - UNION ALL - SELECT 'team', ${teamWorkflowTriggerGrantId}, - twt.team_id, tm.role - FROM team_workflow_triggers twt - JOIN team_members tm ON tm.team_id = twt.team_id - WHERE $2::text IN ('workflow_recipe','sandbox_app') - AND tm.user_id = $1 AND tm.status = 'active' - AND twt.recipe_namespace = $3 AND twt.recipe_name = $4 - ) - SELECT * FROM candidates - ORDER BY kind, team_id NULLS FIRST, grant_id - LIMIT $7`, - [ - input.userId, - input.resource.type, - identity.namespace, - identity.name, - input.resource.logicalId, - input.graph.resource.environmentId, - input.budget.limits.accessPaths + 1, - ] - ) - const capabilities: AccessCapability[] = isHost - ? [ - 'host.read', - 'host.use', - 'host.activity.read', - 'remote_desktop.use', - 'chat.read', - 'chat.message.invoke', - 'task.read', - 'task.manage', - 'model.read', - 'model.select', - 'session.read', - 'session.manage', - ] - : isContext - ? ['context.read', 'context.use'] - : input.resource.type === 'sandbox_app' - ? ['sandbox_app.read', 'sandbox_app.use', 'sandbox_oauth.vend'] - : ['workflow.read', 'workflow.trigger'] - const relevantRelationships = input.graph.relationships.filter( - relationship => relationship.sourceType === input.resource.type || isRecipe - ) - const runtimeRef = JSON.stringify( - relevantRelationships - .map(relationship => relationship.relationshipInstanceId) - .sort(compareCanonicalUtf8Text) - ) - const bindingType = - input.resource.type === 'sandbox_app' ? 'workflow_recipe' : input.resource.type - const bindingProviderUid = - input.resource.type === 'sandbox_app' - ? relevantRelationships.find(relationship => relationship.sourceType === 'workflow_recipe') - ?.sourceProviderUid - : input.graph.resource.providerUid - if (!bindingProviderUid) return [] - return (result.rows as Record[]).flatMap(row => { - const value = authorityCandidateFromGrantRow({ - row, - userId: input.userId, - capabilities, - runtimeSensitive: true, - runtimeRef, - operationalBindings: [ - { - resourceType: bindingType as OperationalPathBinding['resourceType'], - logicalId: input.resource.logicalId, - providerUid: bindingProviderUid, - relationships: relevantRelationships.map(relationship => ({ - instanceId: relationship.relationshipInstanceId, - behaviorAttributes: relationship.behaviorAttributes, - })), - }, - ], - }) - return value ? [value] : [] - }) -} - -async function loadDerivedOperationalCandidates(input: { - db: Pick - budget: AccessExecutionBudget - userId: string - resource: CanonicalResourceIdentity - graph: Extract -}): Promise { - if (input.resource.type !== 'mcp_server' && input.resource.type !== 'shared_filesystem') { - return [] - } - const derivedResourceType = input.resource.type - const directEdges = input.graph.relationships.filter(relationship => { - if (input.resource.type === 'mcp_server') { - return ( - relationship.relationshipType === 'includes_mcp_server' && - relationship.targetId === input.resource.logicalId - ) - } - return ( - relationship.relationshipType === 'mounts_shared_filesystem' && - relationship.targetId === input.resource.logicalId - ) - }) - const contextIds = [...new Set(directEdges.map(edge => edge.sourceId))] - const hostEdges = - input.resource.type === 'mcp_server' - ? input.graph.relationships.filter( - relationship => - relationship.relationshipType === 'uses_context' && - relationship.targetType === 'context' && - contextIds.includes(relationship.targetId) - ) - : [] - const hostIds = [...new Set(hostEdges.map(edge => edge.sourceId))] - const hostNames = hostIds.map(value => scopedName(value)?.name ?? '').filter(Boolean) - if (contextIds.length === 0 && hostNames.length === 0) return [] - const result = await query( - input.db, - input.budget, - `WITH context_names AS (SELECT UNNEST($2::text[]) AS logical_id), - context_references AS MATERIALIZED ( - SELECT requested.logical_id, - split_part(requested.logical_id, '/', 2) AS reference_name - FROM context_names requested - UNION - SELECT requested.logical_id, - split_part(alias_relationship.target_id, '/', 2) - FROM context_names requested - JOIN operational_resource_relationships alias_relationship - ON alias_relationship.environment_id = $4 - AND alias_relationship.source_type = 'context' - AND alias_relationship.source_id = requested.logical_id - AND alias_relationship.relationship_type = 'context_identity_alias' - AND alias_relationship.target_type = 'context' - JOIN operational_resource_index alias_source - ON alias_source.environment_id = alias_relationship.environment_id - AND alias_source.resource_type = 'context' - AND alias_source.logical_id = alias_relationship.source_id - AND alias_source.provider_uid = alias_relationship.source_provider_uid - ), - host_names AS (SELECT UNNEST($3::text[]) AS name), - candidates AS ( - SELECT 'direct'::text AS kind, - 'user_contexts:' || uc.user_id || ':' || uc.context_id AS grant_id, - NULL::uuid AS team_id, NULL::text AS current_role, - 'context'::text AS source_type, context_identity.canonical_id AS source_name - FROM user_contexts uc - JOIN context_references requested ON requested.reference_name = uc.context_id - CROSS JOIN LATERAL ( - SELECT ${canonicalContextLogicalIdSql('$4', "split_part(requested.logical_id, '/', 1) || '/' || uc.context_id")} - AS canonical_id - ) context_identity - WHERE uc.user_id = $1 AND context_identity.canonical_id = requested.logical_id - UNION ALL - SELECT 'team', 'team_contexts:' || tc.team_id || ':' || tc.context_id, - tc.team_id, tm.role, 'context', context_identity.canonical_id - FROM team_contexts tc - JOIN context_references requested ON requested.reference_name = tc.context_id - CROSS JOIN LATERAL ( - SELECT ${canonicalContextLogicalIdSql('$4', "split_part(requested.logical_id, '/', 1) || '/' || tc.context_id")} - AS canonical_id - ) context_identity - JOIN team_members tm ON tm.team_id = tc.team_id - WHERE tm.user_id = $1 AND tm.status = 'active' - AND context_identity.canonical_id = requested.logical_id - UNION ALL - SELECT 'direct', 'user_agents:' || ua.user_id || ':' || ua.agent_name, - NULL, NULL, 'host', ua.agent_name - FROM user_agents ua JOIN host_names requested ON requested.name = ua.agent_name - WHERE ua.user_id = $1 - UNION ALL - SELECT 'team', 'team_agents:' || ta.team_id || ':' || ta.agent_name, - ta.team_id, tm.role, 'host', ta.agent_name - FROM team_agents ta JOIN host_names requested ON requested.name = ta.agent_name - JOIN team_members tm ON tm.team_id = ta.team_id - WHERE tm.user_id = $1 AND tm.status = 'active' - ) - SELECT * FROM candidates - ORDER BY kind, team_id NULLS FIRST, grant_id, source_type, source_name - LIMIT $5`, - [ - input.userId, - contextIds, - hostNames, - input.graph.resource.environmentId, - input.budget.limits.accessPaths + 1, - ] - ) - const output: AuthorityCandidate[] = [] - for (const row of result.rows as Record[]) { - const sourceType = String(row.source_type) - const sourceName = String(row.source_name) - if (sourceType === 'context') { - const contextId = contextIds.find(value => value === sourceName) - if (!contextId) continue - for (const edge of directEdges.filter(value => value.sourceId === contextId)) { - const filesystemScope = - input.resource.type === 'shared_filesystem' - ? JSON.stringify({ - contextId, - relationshipInstanceId: edge.relationshipInstanceId, - mountPath: edge.behaviorAttributes.mountPath, - readOnly: true, - }) - : undefined - const value = authorityCandidateFromGrantRow({ - row: { - ...row, - grant_id: `${String(row.grant_id)}:${edge.relationshipInstanceId}`, - }, - userId: input.userId, - capabilities: - input.resource.type === 'mcp_server' - ? ['mcp_server.read', 'mcp_server.use'] - : ['shared_filesystem.read'], - runtimeSensitive: input.resource.type === 'mcp_server', - runtimeRef: edge.relationshipInstanceId, - filesystemScope, - operationalBindings: [ - { - resourceType: 'context', - logicalId: contextId, - providerUid: edge.sourceProviderUid, - relationships: [ - { - instanceId: edge.relationshipInstanceId, - behaviorAttributes: edge.behaviorAttributes, - }, - ], - }, - { - resourceType: derivedResourceType, - logicalId: input.resource.logicalId, - providerUid: input.graph.resource.providerUid, - relationships: [], - }, - ], - }) - if (value) output.push(value) - } - continue - } - const hostId = hostIds.find(value => scopedName(value)?.name === sourceName) - if (!hostId) continue - for (const hostEdge of hostEdges.filter(value => value.sourceId === hostId)) { - const contextEdge = directEdges.find(value => value.sourceId === hostEdge.targetId) - if (!contextEdge) continue - const value = authorityCandidateFromGrantRow({ - row: { - ...row, - grant_id: `${String(row.grant_id)}:${hostEdge.relationshipInstanceId}:${contextEdge.relationshipInstanceId}`, - }, - userId: input.userId, - capabilities: ['mcp_server.read', 'mcp_server.use'], - runtimeSensitive: true, - runtimeRef: JSON.stringify([ - hostEdge.relationshipInstanceId, - contextEdge.relationshipInstanceId, - ]), - operationalBindings: [ - { - resourceType: 'host', - logicalId: hostId, - providerUid: hostEdge.sourceProviderUid, - relationships: [ - { - instanceId: hostEdge.relationshipInstanceId, - behaviorAttributes: hostEdge.behaviorAttributes, - }, - ], - }, - { - resourceType: 'context', - logicalId: hostEdge.targetId, - providerUid: contextEdge.sourceProviderUid, - relationships: [ - { - instanceId: contextEdge.relationshipInstanceId, - behaviorAttributes: contextEdge.behaviorAttributes, - }, - ], - }, - { - resourceType: 'mcp_server', - logicalId: input.resource.logicalId, - providerUid: input.graph.resource.providerUid, - relationships: [], - }, - ], - }) - if (value) output.push(value) - } - } - return output -} - -async function loadDatabaseCandidates(input: { - db: Pick - budget: AccessExecutionBudget - snapshot: PrincipalAuthoritySnapshot - resource: CanonicalResourceIdentity -}): Promise { - const { resource, snapshot } = input - if (resource.type === 'user') { - const owns = resource.logicalId === snapshot.userId - return { - exists: owns, - candidates: owns - ? [ - authorityCandidate({ - userId: snapshot.userId, - kind: 'direct', - grantId: `users:${snapshot.userId}`, - capabilities: ['user.profile.read'], - }), - ] - : [], - relationships: [], - validUntil: null, - } - } - if (resource.type === 'team') { - const membership = snapshot.memberships.find(value => value.teamId === resource.logicalId) - return { - exists: Boolean(membership), - candidates: membership - ? [ - authorityCandidate({ - userId: snapshot.userId, - kind: 'team', - grantId: `team_members:${membership.teamId}:${snapshot.userId}`, - teamId: membership.teamId, - currentRole: membership.role, - capabilities: capabilitiesForTeamRole(membership.role), - }), - ] - : [], - relationships: membership - ? [ - Object.freeze({ - type: 'membership', - targetResourceId: `user:${snapshot.userId}`, - }), - ] - : [], - validUntil: null, - } - } - - const result = await query( - input.db, - input.budget, - `WITH candidates AS ( - SELECT 'direct'::text AS kind, 'workflow_runs:user:' || wr.run_id AS grant_id, - NULL::uuid AS team_id, NULL::text AS current_role, - NULL::text[] AS permissions, wr.recipe_namespace, wr.recipe_name, - NULL::timestamptz AS valid_until, wr.team_id::text AS related_team, - wr.usage_team_id AS usage_team, NULL::text AS drive, - NULL::text AS parent_resource - FROM workflow_runs wr - JOIN user_workflow_triggers uwt - ON uwt.user_id = $1 - AND uwt.recipe_namespace = wr.recipe_namespace - AND uwt.recipe_name = wr.recipe_name - WHERE $2::text = 'workflow_run' AND wr.run_id::text = $3 - AND wr.actor_type = 'user' AND wr.actor_id = $1 - AND wr.team_id IS NULL AND wr.usage_team_id IS NULL - UNION ALL - SELECT 'team', 'workflow_runs:team:' || tm.team_id || ':' || wr.run_id, - tm.team_id, tm.role, NULL, wr.recipe_namespace, wr.recipe_name, - NULL, wr.team_id::text, wr.usage_team_id, NULL, NULL - FROM workflow_runs wr - JOIN team_members tm - ON (tm.team_id = wr.team_id OR tm.team_id::text = wr.usage_team_id) - AND tm.user_id = $1 AND tm.status = 'active' - JOIN team_workflow_triggers twt - ON twt.team_id = tm.team_id - AND twt.recipe_namespace = wr.recipe_namespace - AND twt.recipe_name = wr.recipe_name - WHERE $2::text = 'workflow_run' AND wr.run_id::text = $3 - UNION ALL - SELECT 'direct', 'workflow_approvals:user:' || war.id, NULL, NULL, NULL, - war.recipe_namespace, war.recipe_name, war.expires_at, NULL, NULL, NULL, NULL - FROM workflow_approval_requests war - JOIN user_workflow_triggers uwt - ON uwt.user_id = $1 - AND uwt.recipe_namespace = war.recipe_namespace - AND uwt.recipe_name = war.recipe_name - WHERE $2::text = 'workflow_approval' AND war.id::text = $3 - AND war.target_user_id = $1 AND war.status = 'pending' AND war.expires_at > NOW() - UNION ALL - SELECT 'team', 'workflow_approvals:team:' || tm.team_id || ':' || war.id, - tm.team_id, tm.role, NULL, war.recipe_namespace, war.recipe_name, - war.expires_at, war.target_team_id::text, NULL, NULL, NULL - FROM workflow_approval_requests war - JOIN team_members tm - ON tm.team_id = war.target_team_id AND tm.user_id = $1 AND tm.status = 'active' - JOIN team_workflow_triggers twt - ON twt.team_id = tm.team_id - AND twt.recipe_namespace = war.recipe_namespace - AND twt.recipe_name = war.recipe_name - WHERE $2::text = 'workflow_approval' AND war.id::text = $3 - AND war.status = 'pending' AND war.expires_at > NOW() - UNION ALL - SELECT CASE WHEN g.subject_type = 'user' THEN 'direct' ELSE 'team' END, - 'gfs_grants:' || g.id, - CASE WHEN g.subject_type = 'team' THEN tm.team_id ELSE NULL END, - tm.role, g.permissions, NULL, NULL, NULL, NULL, NULL, g.drive, - gr.parent_resource_id::text - FROM gfs_grants g - JOIN gfs_resources gr ON gr.resource_id = g.resource_id AND gr.deleted_at IS NULL - LEFT JOIN team_members tm - ON g.subject_type = 'team' AND tm.team_id::text = g.subject_id - AND tm.user_id = $1 AND tm.status = 'active' - WHERE $2::text = 'gfs_resource' AND g.resource_id::text = $3 - AND ((g.subject_type = 'user' AND g.subject_id = $1::text) - OR (g.subject_type = 'team' AND tm.team_id IS NOT NULL)) - UNION ALL - SELECT CASE WHEN s.subject_type = 'user' THEN 'direct' ELSE 'team' END, - 'gfs_shares:' || s.id, - CASE WHEN s.subject_type = 'team' THEN tm.team_id ELSE NULL END, - tm.role, s.permissions, NULL, NULL, NULL, NULL, NULL, s.drive, - gr.parent_resource_id::text - FROM gfs_shares s - JOIN gfs_resources gr ON gr.resource_id = s.resource_id AND gr.deleted_at IS NULL - LEFT JOIN team_members tm - ON s.subject_type = 'team' AND tm.team_id::text = s.subject_id - AND tm.user_id = $1 AND tm.status = 'active' - WHERE $2::text = 'gfs_resource' AND s.resource_id::text = $3 - AND ((s.subject_type = 'user' AND s.subject_id = $1::text) - OR (s.subject_type = 'team' AND tm.team_id IS NOT NULL)) - UNION ALL - SELECT 'direct', 'notifications:user:' || nd.id, NULL, NULL, NULL, - NULL, NULL, nd.expires_at, NULL, NULL, NULL, NULL - FROM notification_deliveries nd - WHERE $2::text = 'notification' AND nd.id::text = $3 - AND nd.audience->>'userId' = $1::text - AND (nd.expires_at IS NULL OR nd.expires_at > NOW()) - UNION ALL - SELECT 'team', 'notifications:team:' || tm.team_id || ':' || nd.id, - tm.team_id, tm.role, NULL, NULL, NULL, nd.expires_at, - tm.team_id::text, NULL, NULL, NULL - FROM notification_deliveries nd - JOIN team_members tm - ON tm.team_id::text = nd.audience->>'teamId' - AND tm.user_id = $1 AND tm.status = 'active' - WHERE $2::text = 'notification' AND nd.id::text = $3 - AND (nd.expires_at IS NULL OR nd.expires_at > NOW()) - ) - SELECT * FROM candidates - ORDER BY kind, team_id NULLS FIRST, grant_id - LIMIT $4`, - [snapshot.userId, resource.type, resource.logicalId, input.budget.limits.accessPaths + 1] - ) - const rows = result.rows as Record[] - const candidates = rows.flatMap(row => { - const type = resource.type - const capabilities: AccessCapability[] = - type === 'workflow_run' - ? [ - 'workflow.read', - 'workflow.run.manage', - 'workflow.artifact.read', - 'workflow.artifact.delete', - ] - : type === 'workflow_approval' - ? ['workflow.approval.decide'] - : type === 'gfs_resource' - ? gfsPermissionsToCapabilities(row.permissions) - : ['notification.read'] - const value = authorityCandidateFromGrantRow({ - row, - userId: snapshot.userId, - capabilities, - runtimeSensitive: type === 'workflow_run', - approvalRef: type === 'workflow_approval' ? `approval:${resource.logicalId}` : undefined, - filesystemScope: - type === 'gfs_resource' - ? JSON.stringify({ drive: row.drive, resourceId: resource.logicalId }) - : undefined, - }) - return value ? [value] : [] - }) - const relationships: Array<{ - type: string - targetResourceId: string - instanceId?: string - }> = [] - for (const row of rows) { - if (resource.type === 'workflow_run' || resource.type === 'workflow_approval') { - relationships.push( - Object.freeze({ - type: 'recipe', - targetResourceId: `workflow_recipe:${String(row.recipe_namespace)}/${String(row.recipe_name)}`, - }) - ) - continue - } - if (resource.type === 'gfs_resource' && row.parent_resource) { - relationships.push( - Object.freeze({ - type: 'parent', - targetResourceId: `gfs_resource:${String(row.parent_resource)}`, - }) - ) - continue - } - if (resource.type === 'notification' && row.related_team) { - relationships.push( - Object.freeze({ type: 'team', targetResourceId: `team:${String(row.related_team)}` }) - ) - } - } - const validTimes = rows - .map(row => (row.valid_until ? new Date(String(row.valid_until)) : null)) - .filter((value): value is Date => value !== null) - return Object.freeze({ - exists: candidates.length > 0, - candidates: Object.freeze(candidates), - relationships: Object.freeze(relationships), - validUntil: - validTimes.length > 0 - ? new Date(Math.min(...validTimes.map(value => value.getTime()))) - : null, - }) -} - export async function loadResourceAuthority(input: { db: Pick budget: AccessExecutionBudget diff --git a/control-api/src/services/access/authorityCandidates.ts b/control-api/src/services/access/authorityCandidates.ts new file mode 100644 index 0000000000..820f366d77 --- /dev/null +++ b/control-api/src/services/access/authorityCandidates.ts @@ -0,0 +1,124 @@ +import type { TeamRole } from '../../profileTypes.js' +import { + type AccessPathBehavior, + type AccessPathSeed, + knownBehavior, + unknownBehavior, +} from './accessPath.js' +import { type AccessCapability, normalizeAccessCapabilities } from './capabilityRegistry.js' + +export type OperationalPathBinding = Readonly<{ + resourceType: 'host' | 'context' | 'mcp_server' | 'workflow_recipe' | 'shared_filesystem' + logicalId: string + providerUid: string + relationships: readonly Readonly<{ + instanceId: string + behaviorAttributes: Readonly> + }>[] +}> + +export type AuthorityCandidate = AccessPathSeed & + Readonly<{ operationalBindings: readonly OperationalPathBinding[] }> + +function behavior(input: { + userId: string + kind: 'direct' | 'team' + teamId?: string + runtimeSensitive?: boolean + filesystemScope?: string | null + runtimeRef?: string | null + providerModelRef?: string | null + approvalRef?: string | null +}): AccessPathBehavior { + const runtimeSensitive = input.runtimeSensitive === true + return Object.freeze({ + capabilities: Object.freeze([]), + budget: runtimeSensitive ? unknownBehavior() : knownBehavior(null), + credentialPolicy: runtimeSensitive ? unknownBehavior() : knownBehavior(null), + approvalPolicy: + input.approvalRef !== undefined + ? knownBehavior(input.approvalRef) + : runtimeSensitive + ? unknownBehavior() + : knownBehavior(null), + filesystemScope: + input.filesystemScope !== undefined + ? knownBehavior(input.filesystemScope) + : knownBehavior(null), + runtime: + input.runtimeRef !== undefined + ? knownBehavior(input.runtimeRef) + : runtimeSensitive + ? unknownBehavior() + : knownBehavior(null), + providerModelPolicy: + input.providerModelRef !== undefined + ? knownBehavior(input.providerModelRef) + : runtimeSensitive + ? unknownBehavior() + : knownBehavior(null), + audit: knownBehavior(input.kind === 'team' ? `team:${input.teamId}` : `user:${input.userId}`), + }) +} + +export function authorityCandidate(input: { + userId: string + kind: 'direct' | 'team' + grantId: string + teamId?: string + currentRole?: TeamRole + capabilities: readonly AccessCapability[] + runtimeSensitive?: boolean + filesystemScope?: string | null + runtimeRef?: string | null + providerModelRef?: string | null + approvalRef?: string | null + operationalBindings?: readonly OperationalPathBinding[] +}): AuthorityCandidate { + return Object.freeze({ + kind: input.kind, + grantId: input.grantId, + ...(input.teamId ? { teamId: input.teamId } : {}), + ...(input.currentRole ? { currentRole: input.currentRole } : {}), + behavior: Object.freeze({ + ...behavior(input), + capabilities: Object.freeze(normalizeAccessCapabilities(input.capabilities)), + }), + operationalBindings: Object.freeze([...(input.operationalBindings ?? [])]), + }) +} + +export function authorityCandidateFromGrantRow(input: { + row: Record + userId: string + capabilities: readonly AccessCapability[] + runtimeSensitive?: boolean + operationalBindings?: readonly OperationalPathBinding[] + filesystemScope?: string | null + runtimeRef?: string | null + providerModelRef?: string | null + approvalRef?: string | null +}): AuthorityCandidate | null { + const kind = input.row.kind === 'direct' ? 'direct' : input.row.kind === 'team' ? 'team' : null + const grantId = String(input.row.grant_id ?? '') + if (!kind || !grantId) return null + const teamId = kind === 'team' ? String(input.row.team_id ?? '') : undefined + const currentRole = kind === 'team' ? (String(input.row.current_role) as TeamRole) : undefined + if (kind === 'team' && (!teamId || !['admin', 'inviter', 'member'].includes(currentRole!))) { + return null + } + return authorityCandidate({ + userId: input.userId, + kind, + grantId, + ...(teamId ? { teamId } : {}), + ...(currentRole ? { currentRole } : {}), + capabilities: input.capabilities, + runtimeSensitive: input.runtimeSensitive, + operationalBindings: input.operationalBindings, + filesystemScope: input.filesystemScope, + runtimeRef: input.runtimeRef, + providerModelRef: input.providerModelRef, + approvalRef: input.approvalRef, + }) +} diff --git a/control-api/src/services/access/catalogAuthorityContext.ts b/control-api/src/services/access/catalogAuthorityContext.ts index 5b2b5a349f..9453f79dc2 100644 --- a/control-api/src/services/access/catalogAuthorityContext.ts +++ b/control-api/src/services/access/catalogAuthorityContext.ts @@ -1,7 +1,6 @@ import type { DbClient } from '../../db.js' import type { ExternalSessionAuthorityContext } from '../auth/externalSessionAuthentication.js' import { legacySecurityCutoffRejectsSql } from '../auth/legacySecurityCutoff.js' -import { parseAuthorizationMemberships } from './accessAuthorityStore.js' import type { AccessExecutionBudget } from './accessExecutionBudget.js' import { revisionOfValues } from './authorizationRevision.js' import type { @@ -14,6 +13,7 @@ import { OPERATIONAL_SOURCE_FAMILIES, type OperationalSourceFamily, } from './operationalAccessProjection.js' +import { parseAuthorizationMemberships } from './principalAuthoritySnapshot.js' export class CatalogAuthorityError extends Error { constructor(readonly code: 'principal_not_found' | 'session_not_live' | 'source_state_invalid') { diff --git a/control-api/src/services/access/catalogProducers.ts b/control-api/src/services/access/catalogProducers.ts index e2e87b560c..e05038ade8 100644 --- a/control-api/src/services/access/catalogProducers.ts +++ b/control-api/src/services/access/catalogProducers.ts @@ -1,6 +1,6 @@ import { config } from '../../config.js' import type { TeamRole } from '../../profileTypes.js' -import { type AuthorityCandidate, authorityCandidate } from './accessAuthorityStore.js' +import { type AuthorityCandidate, authorityCandidate } from './authorityCandidates.js' import { canonicalAccessPathSeeds, databaseRelationshipsRevision, diff --git a/control-api/src/services/access/databaseAuthorityCandidates.ts b/control-api/src/services/access/databaseAuthorityCandidates.ts new file mode 100644 index 0000000000..20d498a7d1 --- /dev/null +++ b/control-api/src/services/access/databaseAuthorityCandidates.ts @@ -0,0 +1,257 @@ +import type { DbClient } from '../../db.js' +import { query } from './accessAuthorityQuery.js' +import type { AccessExecutionBudget } from './accessExecutionBudget.js' +import { + type AuthorityCandidate, + authorityCandidate, + authorityCandidateFromGrantRow, +} from './authorityCandidates.js' +import { + type AccessCapability, + capabilitiesForTeamRole, + gfsPermissionsToCapabilities, +} from './capabilityRegistry.js' +import type { PrincipalAuthoritySnapshot } from './principalAuthoritySnapshot.js' +import type { CanonicalResourceIdentity } from './resourceIdentity.js' + +export type ResourceAuthorityResult = Readonly<{ + exists: boolean + candidates: readonly AuthorityCandidate[] + relationships: readonly Readonly<{ + type: string + targetResourceId: string + instanceId?: string + }>[] + validUntil: Date | null +}> + +export async function loadDatabaseCandidates(input: { + db: Pick + budget: AccessExecutionBudget + snapshot: PrincipalAuthoritySnapshot + resource: CanonicalResourceIdentity +}): Promise { + const { resource, snapshot } = input + if (resource.type === 'user') { + const owns = resource.logicalId === snapshot.userId + return { + exists: owns, + candidates: owns + ? [ + authorityCandidate({ + userId: snapshot.userId, + kind: 'direct', + grantId: `users:${snapshot.userId}`, + capabilities: ['user.profile.read'], + }), + ] + : [], + relationships: [], + validUntil: null, + } + } + if (resource.type === 'team') { + const membership = snapshot.memberships.find(value => value.teamId === resource.logicalId) + return { + exists: Boolean(membership), + candidates: membership + ? [ + authorityCandidate({ + userId: snapshot.userId, + kind: 'team', + grantId: `team_members:${membership.teamId}:${snapshot.userId}`, + teamId: membership.teamId, + currentRole: membership.role, + capabilities: capabilitiesForTeamRole(membership.role), + }), + ] + : [], + relationships: membership + ? [ + Object.freeze({ + type: 'membership', + targetResourceId: `user:${snapshot.userId}`, + }), + ] + : [], + validUntil: null, + } + } + + const result = await query( + input.db, + input.budget, + `WITH candidates AS ( + SELECT 'direct'::text AS kind, 'workflow_runs:user:' || wr.run_id AS grant_id, + NULL::uuid AS team_id, NULL::text AS current_role, + NULL::text[] AS permissions, wr.recipe_namespace, wr.recipe_name, + NULL::timestamptz AS valid_until, wr.team_id::text AS related_team, + wr.usage_team_id AS usage_team, NULL::text AS drive, + NULL::text AS parent_resource + FROM workflow_runs wr + JOIN user_workflow_triggers uwt + ON uwt.user_id = $1 + AND uwt.recipe_namespace = wr.recipe_namespace + AND uwt.recipe_name = wr.recipe_name + WHERE $2::text = 'workflow_run' AND wr.run_id::text = $3 + AND wr.actor_type = 'user' AND wr.actor_id = $1 + AND wr.team_id IS NULL AND wr.usage_team_id IS NULL + UNION ALL + SELECT 'team', 'workflow_runs:team:' || tm.team_id || ':' || wr.run_id, + tm.team_id, tm.role, NULL, wr.recipe_namespace, wr.recipe_name, + NULL, wr.team_id::text, wr.usage_team_id, NULL, NULL + FROM workflow_runs wr + JOIN team_members tm + ON (tm.team_id = wr.team_id OR tm.team_id::text = wr.usage_team_id) + AND tm.user_id = $1 AND tm.status = 'active' + JOIN team_workflow_triggers twt + ON twt.team_id = tm.team_id + AND twt.recipe_namespace = wr.recipe_namespace + AND twt.recipe_name = wr.recipe_name + WHERE $2::text = 'workflow_run' AND wr.run_id::text = $3 + UNION ALL + SELECT 'direct', 'workflow_approvals:user:' || war.id, NULL, NULL, NULL, + war.recipe_namespace, war.recipe_name, war.expires_at, NULL, NULL, NULL, NULL + FROM workflow_approval_requests war + JOIN user_workflow_triggers uwt + ON uwt.user_id = $1 + AND uwt.recipe_namespace = war.recipe_namespace + AND uwt.recipe_name = war.recipe_name + WHERE $2::text = 'workflow_approval' AND war.id::text = $3 + AND war.target_user_id = $1 AND war.status = 'pending' AND war.expires_at > NOW() + UNION ALL + SELECT 'team', 'workflow_approvals:team:' || tm.team_id || ':' || war.id, + tm.team_id, tm.role, NULL, war.recipe_namespace, war.recipe_name, + war.expires_at, war.target_team_id::text, NULL, NULL, NULL + FROM workflow_approval_requests war + JOIN team_members tm + ON tm.team_id = war.target_team_id AND tm.user_id = $1 AND tm.status = 'active' + JOIN team_workflow_triggers twt + ON twt.team_id = tm.team_id + AND twt.recipe_namespace = war.recipe_namespace + AND twt.recipe_name = war.recipe_name + WHERE $2::text = 'workflow_approval' AND war.id::text = $3 + AND war.status = 'pending' AND war.expires_at > NOW() + UNION ALL + SELECT CASE WHEN g.subject_type = 'user' THEN 'direct' ELSE 'team' END, + 'gfs_grants:' || g.id, + CASE WHEN g.subject_type = 'team' THEN tm.team_id ELSE NULL END, + tm.role, g.permissions, NULL, NULL, NULL, NULL, NULL, g.drive, + gr.parent_resource_id::text + FROM gfs_grants g + JOIN gfs_resources gr ON gr.resource_id = g.resource_id AND gr.deleted_at IS NULL + LEFT JOIN team_members tm + ON g.subject_type = 'team' AND tm.team_id::text = g.subject_id + AND tm.user_id = $1 AND tm.status = 'active' + WHERE $2::text = 'gfs_resource' AND g.resource_id::text = $3 + AND ((g.subject_type = 'user' AND g.subject_id = $1::text) + OR (g.subject_type = 'team' AND tm.team_id IS NOT NULL)) + UNION ALL + SELECT CASE WHEN s.subject_type = 'user' THEN 'direct' ELSE 'team' END, + 'gfs_shares:' || s.id, + CASE WHEN s.subject_type = 'team' THEN tm.team_id ELSE NULL END, + tm.role, s.permissions, NULL, NULL, NULL, NULL, NULL, s.drive, + gr.parent_resource_id::text + FROM gfs_shares s + JOIN gfs_resources gr ON gr.resource_id = s.resource_id AND gr.deleted_at IS NULL + LEFT JOIN team_members tm + ON s.subject_type = 'team' AND tm.team_id::text = s.subject_id + AND tm.user_id = $1 AND tm.status = 'active' + WHERE $2::text = 'gfs_resource' AND s.resource_id::text = $3 + AND ((s.subject_type = 'user' AND s.subject_id = $1::text) + OR (s.subject_type = 'team' AND tm.team_id IS NOT NULL)) + UNION ALL + SELECT 'direct', 'notifications:user:' || nd.id, NULL, NULL, NULL, + NULL, NULL, nd.expires_at, NULL, NULL, NULL, NULL + FROM notification_deliveries nd + WHERE $2::text = 'notification' AND nd.id::text = $3 + AND nd.audience->>'userId' = $1::text + AND (nd.expires_at IS NULL OR nd.expires_at > NOW()) + UNION ALL + SELECT 'team', 'notifications:team:' || tm.team_id || ':' || nd.id, + tm.team_id, tm.role, NULL, NULL, NULL, nd.expires_at, + tm.team_id::text, NULL, NULL, NULL + FROM notification_deliveries nd + JOIN team_members tm + ON tm.team_id::text = nd.audience->>'teamId' + AND tm.user_id = $1 AND tm.status = 'active' + WHERE $2::text = 'notification' AND nd.id::text = $3 + AND (nd.expires_at IS NULL OR nd.expires_at > NOW()) + ) + SELECT * FROM candidates + ORDER BY kind, team_id NULLS FIRST, grant_id + LIMIT $4`, + [snapshot.userId, resource.type, resource.logicalId, input.budget.limits.accessPaths + 1] + ) + const rows = result.rows as Record[] + const candidates = rows.flatMap(row => { + const type = resource.type + const capabilities: AccessCapability[] = + type === 'workflow_run' + ? [ + 'workflow.read', + 'workflow.run.manage', + 'workflow.artifact.read', + 'workflow.artifact.delete', + ] + : type === 'workflow_approval' + ? ['workflow.approval.decide'] + : type === 'gfs_resource' + ? gfsPermissionsToCapabilities(row.permissions) + : ['notification.read'] + const value = authorityCandidateFromGrantRow({ + row, + userId: snapshot.userId, + capabilities, + runtimeSensitive: type === 'workflow_run', + approvalRef: type === 'workflow_approval' ? `approval:${resource.logicalId}` : undefined, + filesystemScope: + type === 'gfs_resource' + ? JSON.stringify({ drive: row.drive, resourceId: resource.logicalId }) + : undefined, + }) + return value ? [value] : [] + }) + const relationships: Array<{ + type: string + targetResourceId: string + instanceId?: string + }> = [] + for (const row of rows) { + if (resource.type === 'workflow_run' || resource.type === 'workflow_approval') { + relationships.push( + Object.freeze({ + type: 'recipe', + targetResourceId: `workflow_recipe:${String(row.recipe_namespace)}/${String(row.recipe_name)}`, + }) + ) + continue + } + if (resource.type === 'gfs_resource' && row.parent_resource) { + relationships.push( + Object.freeze({ + type: 'parent', + targetResourceId: `gfs_resource:${String(row.parent_resource)}`, + }) + ) + continue + } + if (resource.type === 'notification' && row.related_team) { + relationships.push( + Object.freeze({ type: 'team', targetResourceId: `team:${String(row.related_team)}` }) + ) + } + } + const validTimes = rows + .map(row => (row.valid_until ? new Date(String(row.valid_until)) : null)) + .filter((value): value is Date => value !== null) + return Object.freeze({ + exists: candidates.length > 0, + candidates: Object.freeze(candidates), + relationships: Object.freeze(relationships), + validUntil: + validTimes.length > 0 + ? new Date(Math.min(...validTimes.map(value => value.getTime()))) + : null, + }) +} diff --git a/control-api/src/services/access/exactOperationalAuthorization.ts b/control-api/src/services/access/exactOperationalAuthorization.ts index 685b9f9d0c..122b70ea78 100644 --- a/control-api/src/services/access/exactOperationalAuthorization.ts +++ b/control-api/src/services/access/exactOperationalAuthorization.ts @@ -1,8 +1,8 @@ import { config } from '../../config.js' import type { K8sGateway } from '../../k8s.js' import type { ClerumResourceType } from '../../types.js' -import type { OperationalPathBinding } from './accessAuthorityStore.js' import type { AccessExecutionBudget } from './accessExecutionBudget.js' +import type { OperationalPathBinding } from './authorityCandidates.js' import { projectOperationalObject } from './operationalAccessProjection.js' type ExactGateway = Pick diff --git a/control-api/src/services/access/liveAuthorizationResolver.ts b/control-api/src/services/access/liveAuthorizationResolver.ts index 22edab4aa7..9c329fa6c5 100644 --- a/control-api/src/services/access/liveAuthorizationResolver.ts +++ b/control-api/src/services/access/liveAuthorizationResolver.ts @@ -2,9 +2,7 @@ import type { DbClient } from '../../db.js' import type { K8sGateway } from '../../k8s.js' import type { ExternalSessionAuthorityContext } from '../auth/externalSessionAuthentication.js' import { - type AuthorityCandidate, filterCandidatesForOperationTarget, - loadPrincipalAuthoritySnapshot, loadResourceAuthority, operationTargetIsCurrentlyValid, } from './accessAuthorityStore.js' @@ -21,6 +19,7 @@ import { buildAccessPath, selectEquivalentAccessPath, } from './accessPath.js' +import type { AuthorityCandidate } from './authorityCandidates.js' import { authorizationRevision, canonicalAccessPathSeeds, @@ -39,6 +38,7 @@ import { isOperationalAccessResourceType, loadOperationalResourceGraph, } from './operationalAccessReader.js' +import { loadPrincipalAuthoritySnapshot } from './principalAuthoritySnapshot.js' import { type CanonicalResourceIdentity, canonicalResourceIdentity, diff --git a/control-api/src/services/access/operationalAuthorityCandidates.ts b/control-api/src/services/access/operationalAuthorityCandidates.ts new file mode 100644 index 0000000000..bff996fc70 --- /dev/null +++ b/control-api/src/services/access/operationalAuthorityCandidates.ts @@ -0,0 +1,387 @@ +import type { DbClient } from '../../db.js' +import { query } from './accessAuthorityQuery.js' +import type { AccessExecutionBudget } from './accessExecutionBudget.js' +import { + type AuthorityCandidate, + type OperationalPathBinding, + authorityCandidateFromGrantRow, +} from './authorityCandidates.js' +import { compareCanonicalUtf8Text } from './canonicalText.js' +import type { AccessCapability } from './capabilityRegistry.js' +import { canonicalContextLogicalIdSql } from './contextIdentitySql.js' +import type { OperationalResourceGraphResult } from './operationalAccessReader.js' +import type { CanonicalResourceIdentity } from './resourceIdentity.js' +import { workflowTriggerGrantIdentitySql } from './workflowTriggerGrantIdentity.js' + +function scopedName(logicalId: string): { namespace: string; name: string } | null { + const separator = logicalId.indexOf('/') + if (separator < 1 || separator === logicalId.length - 1) return null + return { namespace: logicalId.slice(0, separator), name: logicalId.slice(separator + 1) } +} + +export async function loadSimpleOperationalGrantCandidates(input: { + db: Pick + budget: AccessExecutionBudget + userId: string + resource: CanonicalResourceIdentity + graph: Extract +}): Promise { + const identity = scopedName(input.resource.logicalId) + if (!identity) return [] + const isHost = input.resource.type === 'host' + const isContext = input.resource.type === 'context' + const isRecipe = ['workflow_recipe', 'sandbox_app'].includes(input.resource.type) + if (!isHost && !isContext && !isRecipe) return [] + const directWorkflowTriggerGrantId = workflowTriggerGrantIdentitySql('direct', 'uwt') + const teamWorkflowTriggerGrantId = workflowTriggerGrantIdentitySql('team', 'twt') + const result = await query( + input.db, + input.budget, + `WITH requested_context_references AS ( + SELECT $5::text AS logical_id, split_part($5::text, '/', 2) AS reference_name + UNION + SELECT alias_relationship.source_id, + split_part(alias_relationship.target_id, '/', 2) + FROM operational_resource_relationships alias_relationship + JOIN operational_resource_index alias_source + ON alias_source.environment_id = alias_relationship.environment_id + AND alias_source.resource_type = 'context' + AND alias_source.logical_id = alias_relationship.source_id + AND alias_source.provider_uid = alias_relationship.source_provider_uid + WHERE alias_relationship.environment_id = $6 + AND alias_relationship.source_type = 'context' + AND alias_relationship.source_id = $5 + AND alias_relationship.relationship_type = 'context_identity_alias' + AND alias_relationship.target_type = 'context' + ), candidates AS ( + SELECT 'direct'::text AS kind, + 'user_agents:' || ua.user_id || ':' || ua.agent_name AS grant_id, + NULL::uuid AS team_id, NULL::text AS current_role + FROM user_agents ua + WHERE $2::text = 'host' AND ua.user_id = $1 AND ua.agent_name = $4 + UNION ALL + SELECT 'team', 'team_agents:' || ta.team_id || ':' || ta.agent_name, + ta.team_id, tm.role + FROM team_agents ta + JOIN team_members tm ON tm.team_id = ta.team_id + WHERE $2::text = 'host' AND tm.user_id = $1 AND tm.status = 'active' + AND ta.agent_name = $4 + UNION ALL + SELECT 'direct', 'user_contexts:' || uc.user_id || ':' || uc.context_id, + NULL, NULL + FROM user_contexts uc + JOIN requested_context_references requested + ON requested.reference_name = uc.context_id + WHERE $2::text = 'context' AND uc.user_id = $1 + AND ${canonicalContextLogicalIdSql('$6', "split_part(requested.logical_id, '/', 1) || '/' || uc.context_id")} = requested.logical_id + UNION ALL + SELECT 'team', 'team_contexts:' || tc.team_id || ':' || tc.context_id, + tc.team_id, tm.role + FROM team_contexts tc + JOIN requested_context_references requested + ON requested.reference_name = tc.context_id + JOIN team_members tm ON tm.team_id = tc.team_id + WHERE $2::text = 'context' AND tm.user_id = $1 AND tm.status = 'active' + AND ${canonicalContextLogicalIdSql('$6', "split_part(requested.logical_id, '/', 1) || '/' || tc.context_id")} = requested.logical_id + UNION ALL + SELECT 'direct', ${directWorkflowTriggerGrantId}, + NULL, NULL + FROM user_workflow_triggers uwt + WHERE $2::text IN ('workflow_recipe','sandbox_app') AND uwt.user_id = $1 + AND uwt.recipe_namespace = $3 AND uwt.recipe_name = $4 + UNION ALL + SELECT 'team', ${teamWorkflowTriggerGrantId}, + twt.team_id, tm.role + FROM team_workflow_triggers twt + JOIN team_members tm ON tm.team_id = twt.team_id + WHERE $2::text IN ('workflow_recipe','sandbox_app') + AND tm.user_id = $1 AND tm.status = 'active' + AND twt.recipe_namespace = $3 AND twt.recipe_name = $4 + ) + SELECT * FROM candidates + ORDER BY kind, team_id NULLS FIRST, grant_id + LIMIT $7`, + [ + input.userId, + input.resource.type, + identity.namespace, + identity.name, + input.resource.logicalId, + input.graph.resource.environmentId, + input.budget.limits.accessPaths + 1, + ] + ) + const capabilities: AccessCapability[] = isHost + ? [ + 'host.read', + 'host.use', + 'host.activity.read', + 'remote_desktop.use', + 'chat.read', + 'chat.message.invoke', + 'task.read', + 'task.manage', + 'model.read', + 'model.select', + 'session.read', + 'session.manage', + ] + : isContext + ? ['context.read', 'context.use'] + : input.resource.type === 'sandbox_app' + ? ['sandbox_app.read', 'sandbox_app.use', 'sandbox_oauth.vend'] + : ['workflow.read', 'workflow.trigger'] + const relevantRelationships = input.graph.relationships.filter( + relationship => relationship.sourceType === input.resource.type || isRecipe + ) + const runtimeRef = JSON.stringify( + relevantRelationships + .map(relationship => relationship.relationshipInstanceId) + .sort(compareCanonicalUtf8Text) + ) + const bindingType = + input.resource.type === 'sandbox_app' ? 'workflow_recipe' : input.resource.type + const bindingProviderUid = + input.resource.type === 'sandbox_app' + ? relevantRelationships.find(relationship => relationship.sourceType === 'workflow_recipe') + ?.sourceProviderUid + : input.graph.resource.providerUid + if (!bindingProviderUid) return [] + return (result.rows as Record[]).flatMap(row => { + const value = authorityCandidateFromGrantRow({ + row, + userId: input.userId, + capabilities, + runtimeSensitive: true, + runtimeRef, + operationalBindings: [ + { + resourceType: bindingType as OperationalPathBinding['resourceType'], + logicalId: input.resource.logicalId, + providerUid: bindingProviderUid, + relationships: relevantRelationships.map(relationship => ({ + instanceId: relationship.relationshipInstanceId, + behaviorAttributes: relationship.behaviorAttributes, + })), + }, + ], + }) + return value ? [value] : [] + }) +} + +export async function loadDerivedOperationalCandidates(input: { + db: Pick + budget: AccessExecutionBudget + userId: string + resource: CanonicalResourceIdentity + graph: Extract +}): Promise { + if (input.resource.type !== 'mcp_server' && input.resource.type !== 'shared_filesystem') { + return [] + } + const derivedResourceType = input.resource.type + const directEdges = input.graph.relationships.filter(relationship => { + if (input.resource.type === 'mcp_server') { + return ( + relationship.relationshipType === 'includes_mcp_server' && + relationship.targetId === input.resource.logicalId + ) + } + return ( + relationship.relationshipType === 'mounts_shared_filesystem' && + relationship.targetId === input.resource.logicalId + ) + }) + const contextIds = [...new Set(directEdges.map(edge => edge.sourceId))] + const hostEdges = + input.resource.type === 'mcp_server' + ? input.graph.relationships.filter( + relationship => + relationship.relationshipType === 'uses_context' && + relationship.targetType === 'context' && + contextIds.includes(relationship.targetId) + ) + : [] + const hostIds = [...new Set(hostEdges.map(edge => edge.sourceId))] + const hostNames = hostIds.map(value => scopedName(value)?.name ?? '').filter(Boolean) + if (contextIds.length === 0 && hostNames.length === 0) return [] + const result = await query( + input.db, + input.budget, + `WITH context_names AS (SELECT UNNEST($2::text[]) AS logical_id), + context_references AS MATERIALIZED ( + SELECT requested.logical_id, + split_part(requested.logical_id, '/', 2) AS reference_name + FROM context_names requested + UNION + SELECT requested.logical_id, + split_part(alias_relationship.target_id, '/', 2) + FROM context_names requested + JOIN operational_resource_relationships alias_relationship + ON alias_relationship.environment_id = $4 + AND alias_relationship.source_type = 'context' + AND alias_relationship.source_id = requested.logical_id + AND alias_relationship.relationship_type = 'context_identity_alias' + AND alias_relationship.target_type = 'context' + JOIN operational_resource_index alias_source + ON alias_source.environment_id = alias_relationship.environment_id + AND alias_source.resource_type = 'context' + AND alias_source.logical_id = alias_relationship.source_id + AND alias_source.provider_uid = alias_relationship.source_provider_uid + ), + host_names AS (SELECT UNNEST($3::text[]) AS name), + candidates AS ( + SELECT 'direct'::text AS kind, + 'user_contexts:' || uc.user_id || ':' || uc.context_id AS grant_id, + NULL::uuid AS team_id, NULL::text AS current_role, + 'context'::text AS source_type, context_identity.canonical_id AS source_name + FROM user_contexts uc + JOIN context_references requested ON requested.reference_name = uc.context_id + CROSS JOIN LATERAL ( + SELECT ${canonicalContextLogicalIdSql('$4', "split_part(requested.logical_id, '/', 1) || '/' || uc.context_id")} + AS canonical_id + ) context_identity + WHERE uc.user_id = $1 AND context_identity.canonical_id = requested.logical_id + UNION ALL + SELECT 'team', 'team_contexts:' || tc.team_id || ':' || tc.context_id, + tc.team_id, tm.role, 'context', context_identity.canonical_id + FROM team_contexts tc + JOIN context_references requested ON requested.reference_name = tc.context_id + CROSS JOIN LATERAL ( + SELECT ${canonicalContextLogicalIdSql('$4', "split_part(requested.logical_id, '/', 1) || '/' || tc.context_id")} + AS canonical_id + ) context_identity + JOIN team_members tm ON tm.team_id = tc.team_id + WHERE tm.user_id = $1 AND tm.status = 'active' + AND context_identity.canonical_id = requested.logical_id + UNION ALL + SELECT 'direct', 'user_agents:' || ua.user_id || ':' || ua.agent_name, + NULL, NULL, 'host', ua.agent_name + FROM user_agents ua JOIN host_names requested ON requested.name = ua.agent_name + WHERE ua.user_id = $1 + UNION ALL + SELECT 'team', 'team_agents:' || ta.team_id || ':' || ta.agent_name, + ta.team_id, tm.role, 'host', ta.agent_name + FROM team_agents ta JOIN host_names requested ON requested.name = ta.agent_name + JOIN team_members tm ON tm.team_id = ta.team_id + WHERE tm.user_id = $1 AND tm.status = 'active' + ) + SELECT * FROM candidates + ORDER BY kind, team_id NULLS FIRST, grant_id, source_type, source_name + LIMIT $5`, + [ + input.userId, + contextIds, + hostNames, + input.graph.resource.environmentId, + input.budget.limits.accessPaths + 1, + ] + ) + const output: AuthorityCandidate[] = [] + for (const row of result.rows as Record[]) { + const sourceType = String(row.source_type) + const sourceName = String(row.source_name) + if (sourceType === 'context') { + const contextId = contextIds.find(value => value === sourceName) + if (!contextId) continue + for (const edge of directEdges.filter(value => value.sourceId === contextId)) { + const filesystemScope = + input.resource.type === 'shared_filesystem' + ? JSON.stringify({ + contextId, + relationshipInstanceId: edge.relationshipInstanceId, + mountPath: edge.behaviorAttributes.mountPath, + readOnly: true, + }) + : undefined + const value = authorityCandidateFromGrantRow({ + row: { + ...row, + grant_id: `${String(row.grant_id)}:${edge.relationshipInstanceId}`, + }, + userId: input.userId, + capabilities: + input.resource.type === 'mcp_server' + ? ['mcp_server.read', 'mcp_server.use'] + : ['shared_filesystem.read'], + runtimeSensitive: input.resource.type === 'mcp_server', + runtimeRef: edge.relationshipInstanceId, + filesystemScope, + operationalBindings: [ + { + resourceType: 'context', + logicalId: contextId, + providerUid: edge.sourceProviderUid, + relationships: [ + { + instanceId: edge.relationshipInstanceId, + behaviorAttributes: edge.behaviorAttributes, + }, + ], + }, + { + resourceType: derivedResourceType, + logicalId: input.resource.logicalId, + providerUid: input.graph.resource.providerUid, + relationships: [], + }, + ], + }) + if (value) output.push(value) + } + continue + } + const hostId = hostIds.find(value => scopedName(value)?.name === sourceName) + if (!hostId) continue + for (const hostEdge of hostEdges.filter(value => value.sourceId === hostId)) { + const contextEdge = directEdges.find(value => value.sourceId === hostEdge.targetId) + if (!contextEdge) continue + const value = authorityCandidateFromGrantRow({ + row: { + ...row, + grant_id: `${String(row.grant_id)}:${hostEdge.relationshipInstanceId}:${contextEdge.relationshipInstanceId}`, + }, + userId: input.userId, + capabilities: ['mcp_server.read', 'mcp_server.use'], + runtimeSensitive: true, + runtimeRef: JSON.stringify([ + hostEdge.relationshipInstanceId, + contextEdge.relationshipInstanceId, + ]), + operationalBindings: [ + { + resourceType: 'host', + logicalId: hostId, + providerUid: hostEdge.sourceProviderUid, + relationships: [ + { + instanceId: hostEdge.relationshipInstanceId, + behaviorAttributes: hostEdge.behaviorAttributes, + }, + ], + }, + { + resourceType: 'context', + logicalId: hostEdge.targetId, + providerUid: contextEdge.sourceProviderUid, + relationships: [ + { + instanceId: contextEdge.relationshipInstanceId, + behaviorAttributes: contextEdge.behaviorAttributes, + }, + ], + }, + { + resourceType: 'mcp_server', + logicalId: input.resource.logicalId, + providerUid: input.graph.resource.providerUid, + relationships: [], + }, + ], + }) + if (value) output.push(value) + } + } + return output +} diff --git a/control-api/src/services/access/principalAuthoritySnapshot.ts b/control-api/src/services/access/principalAuthoritySnapshot.ts new file mode 100644 index 0000000000..9acc7c6098 --- /dev/null +++ b/control-api/src/services/access/principalAuthoritySnapshot.ts @@ -0,0 +1,155 @@ +import type { DbClient } from '../../db.js' +import type { TeamRole } from '../../profileTypes.js' +import type { ExternalSessionAuthorityContext } from '../auth/externalSessionAuthentication.js' +import { legacySecurityCutoffRejectsSql } from '../auth/legacySecurityCutoff.js' +import { query } from './accessAuthorityQuery.js' +import type { AccessExecutionBudget } from './accessExecutionBudget.js' +import type { AuthorizationMembershipRevision } from './authorizationRevision.js' +import type { CanonicalResourceIdentity } from './resourceIdentity.js' + +export type PrincipalAuthoritySnapshot = Readonly<{ + userId: string + sessionContract: 'v1' | 'v2' + sessionLive: boolean + sessionRevision: string + userRevision: string + resourceRevision: string + memberships: readonly AuthorizationMembershipRevision[] +}> + +export function parseAuthorizationMemberships(value: unknown): AuthorizationMembershipRevision[] { + if (!Array.isArray(value)) return [] + return value.flatMap(item => { + if (!item || typeof item !== 'object' || Array.isArray(item)) return [] + const row = item as Record + const teamId = String(row.teamId ?? '') + const role = String(row.role ?? '') as TeamRole + if (!teamId || !['admin', 'inviter', 'member'].includes(role)) return [] + return [ + Object.freeze({ + teamId, + role, + membershipUpdatedAt: new Date(String(row.membershipUpdatedAt)).toISOString(), + teamRevision: String(row.teamRevision ?? '1'), + }), + ] + }) +} + +export async function loadPrincipalAuthoritySnapshot(input: { + db: Pick + budget: AccessExecutionBudget + session: ExternalSessionAuthorityContext + resource: CanonicalResourceIdentity +}): Promise { + const result = await query( + input.db, + input.budget, + `WITH active_memberships AS ( + SELECT tm.team_id, tm.role, tm.updated_at, + COALESCE(atr.revision, 1) AS team_revision + FROM team_members tm + LEFT JOIN authorization_team_revisions atr ON atr.team_id = tm.team_id + WHERE tm.user_id = $1 + AND tm.status = 'active' + ) + SELECT u.id AS user_id, + COALESCE(aur.revision, 1) AS user_revision, + COALESCE(arr.revision, 1) AS resource_revision, + CASE + WHEN u.lifecycle_state <> 'active' THEN FALSE + WHEN $2::text = 'v2' THEN EXISTS ( + SELECT 1 + FROM external_user_sessions s + WHERE s.sid::text = $3 + AND s.user_id = u.id + AND s.session_version = $5 + AND s.revoked_at IS NULL + AND s.idle_expires_at > NOW() + AND s.absolute_expires_at > NOW() + AND ( + s.current_jti::text = $4 + OR ( + s.prior_jti::text = $4 + AND s.prior_jti_expires_at >= NOW() + ) + ) + ) + ELSE ( + $7::bigint IS NOT NULL + AND $8::bigint IS NOT NULL + AND u.lifecycle_version = $8::bigint + AND NOT EXISTS ( + SELECT 1 + FROM external_v1_session_revocations r + WHERE r.token_hash = $6 + AND r.user_id = u.id + AND r.expires_at > NOW() + ) + AND NOT EXISTS ( + SELECT 1 + FROM external_user_session_security_epochs e + WHERE e.user_id = u.id + AND ${legacySecurityCutoffRejectsSql('$7::bigint', 'e.valid_after')} + ) + ) + END AS session_live, + CASE + WHEN $2::text = 'v2' THEN COALESCE(( + SELECT s.session_version::text || ':' || s.current_jti::text || ':' || + COALESCE(s.revoked_at::text, '') || ':' || s.absolute_expires_at::text + FROM external_user_sessions s + WHERE s.sid::text = $3 AND s.user_id = u.id + ), 'missing') + ELSE COALESCE(( + SELECT EXTRACT(EPOCH FROM e.valid_after)::text + FROM external_user_session_security_epochs e + WHERE e.user_id = u.id + ), '0') || ':' || $6 + END AS session_revision, + COALESCE( + jsonb_agg( + jsonb_build_object( + 'teamId', am.team_id, + 'role', am.role, + 'membershipUpdatedAt', am.updated_at, + 'teamRevision', am.team_revision + ) ORDER BY am.team_id + ) FILTER (WHERE am.team_id IS NOT NULL), + '[]'::jsonb + ) AS memberships + FROM users u + LEFT JOIN authorization_user_revisions aur ON aur.user_id = u.id + LEFT JOIN authorization_resource_revisions arr + ON arr.environment_id = $9 + AND arr.resource_type = $10 + AND arr.resource_id = $11 + LEFT JOIN active_memberships am ON TRUE + WHERE u.id = $1 + GROUP BY u.id, aur.revision, arr.revision`, + [ + input.session.userId, + input.session.contract, + input.session.contract === 'v2' ? input.session.sid : null, + input.session.contract === 'v2' ? input.session.jti : null, + input.session.contract === 'v2' ? input.session.sessionVersion : null, + input.session.contract === 'v1' ? input.session.tokenHash : null, + input.session.contract === 'v1' ? input.session.issuedAt : null, + input.session.contract === 'v1' ? input.session.authGeneration : null, + input.resource.environmentId, + input.resource.type, + input.resource.logicalId, + ] + ) + const row = result.rows[0] as Record | undefined + if (!row) return null + return Object.freeze({ + userId: String(row.user_id), + sessionContract: input.session.contract, + sessionLive: row.session_live === true, + sessionRevision: String(row.session_revision), + userRevision: String(row.user_revision ?? '1'), + resourceRevision: String(row.resource_revision ?? '1'), + memberships: Object.freeze(parseAuthorizationMemberships(row.memberships)), + }) +} From 67388bf514fa759cbe9f77c234d0b8d185fc9878 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 22 Oct 2026 17:46:48 +0200 Subject: [PATCH 361/528] docs(control-api): describe the first-password ordering after the store merge The comment still said ensureInvitationUser renames the user and profile from the invitation. Since the single store keeps existing identity, the remaining reason to decide first is that the helper can insert the users and profiles rows on a call that is then refused. Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- control-api/src/services/directory/memberPassword.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index cedfda4de9..534ecaf807 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.817", + "version": "0.1.818", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.817", + "version": "0.1.818", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 0d703a3efd..f73c0f6dea 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.817", + "version": "0.1.818", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/directory/memberPassword.ts b/control-api/src/services/directory/memberPassword.ts index 183bab2f19..f6a820fd3c 100644 --- a/control-api/src/services/directory/memberPassword.ts +++ b/control-api/src/services/directory/memberPassword.ts @@ -133,8 +133,8 @@ export async function setInvitationPasswordForUser( if (invitation.status !== 'accepted') return { error: 'not_accepted' as const } if (options?.rejectIfPasswordAlreadySet) { // The first-password decision must precede ensureInvitationUser, which - // rewrites users.name and profiles.display_name from the invitation: - // deciding after it would mutate a refused call. Lock the session user + // can insert the invitee's users and profiles rows: deciding after it + // would write on a refused call. Lock the session user // row inside the writing transaction and decide from the authoritative // hash, so a concurrent first set either commits before this lock (and // this request sees its hash) or waits behind it. From 314693e2e6c68d5cbca013262fdec925e03020d0 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 22 Oct 2026 20:14:11 +0200 Subject: [PATCH 362/528] fix(control-api): keep PR1 migrations off reader-blocking locks The PR1 migration Job runs while the previous control-api, external-rest-api and workflow-recipes images keep serving the same tables. A real-PostgreSQL probe showed the 0130 and 0132 transactions holding ShareLock on 19 pre-existing tables (in-transaction CREATE INDEX IF NOT EXISTS on indexes PR1_ONLINE_INDEX_PLAN had already built concurrently), and 0133/0134 holding AccessExclusiveLock on users, teams and 17 more tables, which blocks readers. None of these migrations is applied in a shared environment, so their bodies change: - 0130 no longer installs the 18 statement-level *_catalog_revision triggers that 0133 removes. The catalog revision table and function stay until 0133 drops them. - 0130 and 0132 drop the 25 in-transaction index statements that the online plan builds before the transaction. The 14 indexes on tables PR1 creates stay in the transaction. - 0133/0134 replace the 8 row revision triggers with CREATE OR REPLACE TRIGGER (ShareRowExclusiveLock) instead of DROP + CREATE (AccessExclusiveLock). The *_catalog_revision drops stay; they lock nothing when the trigger is absent and clean databases that applied the earlier 0130 body. Writers on the 8 trigger tables still wait for the 0133 and 0134 transactions. The D34 pin for userAccessFoundationSchema.ts moves to the new body. The classification test now requires the 14 fresh-table indexes inside the transaction, the 25 planned indexes outside it, and the planned definitions to hash-match the historical in-transaction ones. A new real-PostgreSQL suite reads pg_locks before each PR1 COMMIT: no AccessExclusiveLock or ShareLock on a pre-PR1 table, readers pass and a writer waits while 0133 is open, the 25 planned indexes are valid, and a database carrying the earlier 0130 triggers converges to the same trigger definitions. It is registered in the CI critical suite list and the recovery suite contract. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci-public.yml | 1 + control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../access/userAccessFoundationSchema.ts | 153 ++------ .../db.catalogUtf8OrderingMigration.test.ts | 28 +- ...composableCatalogRevisionMigration.test.ts | 31 ++ .../db.userAccessFoundationMigration.test.ts | 34 +- .../test/migrationExecutionPolicy.test.ts | 44 +-- ...tionLocks.realPostgres.integration.test.ts | 348 ++++++++++++++++++ tests/contracts/password-recovery-suites.json | 1 + 10 files changed, 472 insertions(+), 174 deletions(-) create mode 100644 control-api/test/pr1MigrationLocks.realPostgres.integration.test.ts diff --git a/.github/workflows/ci-public.yml b/.github/workflows/ci-public.yml index 9ba28f5f1d..f485cab4f8 100644 --- a/.github/workflows/ci-public.yml +++ b/.github/workflows/ci-public.yml @@ -649,6 +649,7 @@ jobs: legacySessionExchangeRace.realPostgres.integration \ migrationExecutionPolicy.realPostgres.integration \ migrationPreviousImageCompatibility.realPostgres.integration \ + pr1MigrationLocks.realPostgres.integration \ routes.externalTeamsFinalAuthority.realPostgres.integration \ routes.externalTeamsLegacyAdminRevocation.realPostgres.integration \ services.codexSubscriptionOAuth.realPostgres.integration \ diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 4eaebe0dc1..626cecc588 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.819", + "version": "0.1.820", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.819", + "version": "0.1.820", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 10853ccc2f..3e2fe36e45 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.819", + "version": "0.1.820", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/access/userAccessFoundationSchema.ts b/control-api/src/services/access/userAccessFoundationSchema.ts index b76a447ce8..455129ef0b 100644 --- a/control-api/src/services/access/userAccessFoundationSchema.ts +++ b/control-api/src/services/access/userAccessFoundationSchema.ts @@ -197,75 +197,20 @@ export async function applyUserAccessFoundationSchema(db: DbClient): Promise>'userId'), id) - INCLUDE (expires_at, status, event_type) - WHERE audience ? 'userId'; - CREATE INDEX IF NOT EXISTS notification_team_catalog_idx - ON notification_deliveries ((audience->>'teamId'), id) - INCLUDE (expires_at, status, event_type) - WHERE audience ? 'teamId'; - - CREATE INDEX IF NOT EXISTS gfs_grants_subject_resource_catalog_idx - ON gfs_grants (subject_type, subject_id, resource_id) - INCLUDE (id, drive, permissions, inherit); - CREATE INDEX IF NOT EXISTS gfs_shares_subject_resource_catalog_idx - ON gfs_shares (subject_type, subject_id, resource_id) - INCLUDE (id, drive, permissions, include_descendants); `) + // Indexes on tables that exist before PR1 are built online by + // PR1_ONLINE_INDEX_PLAN before this transaction opens, so this transaction + // takes no ShareLock on those tables. await applyAuthorizationRevisionFunctions(db) await applyAuthorizationRevisionTriggers(db) await applyUserAccessRuntimePrivileges(db) } -/** Forward migration for D26A's exact UTF-8 byte ordering and supporting indexes. */ +/** + * Forward migration for D26A's exact UTF-8 byte ordering. Its supporting + * indexes are built online by PR1_ONLINE_INDEX_PLAN before this transaction. + */ export async function applyCatalogUtf8OrderingSchema(db: DbClient): Promise { await db.query(` CREATE OR REPLACE FUNCTION catalog_utf8_bytes(value TEXT) @@ -277,32 +222,6 @@ export async function applyCatalogUtf8OrderingSchema(db: DbClient): Promise { END; $$; - DO $$ - DECLARE table_name TEXT; - DECLARE trigger_name TEXT; - BEGIN - FOREACH table_name IN ARRAY ARRAY[ - 'users', 'teams', 'team_members', - 'user_contexts', 'team_contexts', 'user_agents', 'team_agents', - 'user_workflow_triggers', 'team_workflow_triggers', - 'workflow_runs', 'workflow_approval_requests', 'notification_deliveries', - 'gfs_resources', 'gfs_grants', 'gfs_shares', - 'operational_resource_index', 'operational_resource_relationships', - 'operational_catalog_source_state' - ] - LOOP - trigger_name := table_name || '_catalog_revision'; - IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = trigger_name) THEN - EXECUTE format( - 'CREATE TRIGGER %I AFTER INSERT OR UPDATE OR DELETE ON %I ' - || 'FOR EACH STATEMENT EXECUTE FUNCTION authorization_bump_catalog_revision()', - trigger_name, - table_name - ); - END IF; - END LOOP; - END; - $$; + -- No *_catalog_revision statement triggers are installed here: + -- 0133_composable_catalog_revisions replaces the global catalog revision, + -- and dropping an installed trigger there would take AccessExclusiveLock. REVOKE ALL ON FUNCTION authorization_bump_catalog_revision() FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_team_membership_revision() FROM PUBLIC; diff --git a/control-api/test/db.catalogUtf8OrderingMigration.test.ts b/control-api/test/db.catalogUtf8OrderingMigration.test.ts index cf09f99989..b3a9dd1e0a 100644 --- a/control-api/test/db.catalogUtf8OrderingMigration.test.ts +++ b/control-api/test/db.catalogUtf8OrderingMigration.test.ts @@ -5,8 +5,9 @@ vi.mock('../src/config.js', () => ({ })) describe('0131 catalog UTF-8 ordering migration', () => { - it('installs the immutable byte function and complete supporting index class', async () => { + it('installs the immutable byte function and plans its index class online', async () => { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') + const { PR1_ONLINE_INDEX_PLAN } = await import('../src/migrations/pr1OnlineIndexPlan.js') const migration = CONTROL_API_MIGRATIONS.find( candidate => candidate.version === '0132_catalog_utf8_ordering' ) @@ -18,13 +19,22 @@ describe('0131 catalog UTF-8 ordering migration', () => { expect(sql).toContain('CREATE OR REPLACE FUNCTION catalog_utf8_bytes(value TEXT)') expect(sql).toContain("SELECT convert_to(value, 'UTF8')") - expect(sql.match(/catalog_utf8_bytes\(/g)).toHaveLength(8) - expect(sql).toContain('user_agents_catalog_utf8_idx') - expect(sql).toContain('team_agents_catalog_utf8_idx') - expect(sql).toContain('user_contexts_catalog_utf8_idx') - expect(sql).toContain('team_contexts_catalog_utf8_idx') - expect(sql).toContain('user_workflow_triggers_catalog_utf8_idx') - expect(sql).toContain('team_workflow_triggers_catalog_utf8_idx') - expect(sql).toContain('operational_relationship_catalog_utf8_target_idx') + expect(sql.match(/catalog_utf8_bytes\(/g)).toHaveLength(1) + expect(sql).not.toMatch(/CREATE\s+(UNIQUE\s+)?INDEX/i) + + const expectedIndexes = [ + 'user_agents_catalog_utf8_idx', + 'team_agents_catalog_utf8_idx', + 'user_contexts_catalog_utf8_idx', + 'team_contexts_catalog_utf8_idx', + 'user_workflow_triggers_catalog_utf8_idx', + 'team_workflow_triggers_catalog_utf8_idx', + 'operational_relationship_catalog_utf8_target_idx', + ] + const planned = PR1_ONLINE_INDEX_PLAN.filter( + entry => entry.migrationVersion === '0132_catalog_utf8_ordering' + ) + expect(planned.map(entry => entry.name).sort()).toEqual([...expectedIndexes].sort()) + for (const entry of planned) expect(entry.createSql).toContain('catalog_utf8_bytes(') }) }) diff --git a/control-api/test/db.composableCatalogRevisionMigration.test.ts b/control-api/test/db.composableCatalogRevisionMigration.test.ts index 2eb4c2a9b0..5338ef169e 100644 --- a/control-api/test/db.composableCatalogRevisionMigration.test.ts +++ b/control-api/test/db.composableCatalogRevisionMigration.test.ts @@ -50,4 +50,35 @@ describe('0132 composable catalog revisions migration', () => { expect(sql).toContain('DROP TABLE IF EXISTS authorization_catalog_revision') expect(sql).not.toContain('UPDATE authorization_catalog_revision') }) + + it('replaces row-level revision triggers without dropping them', async () => { + const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') + const migration = CONTROL_API_MIGRATIONS.find( + candidate => candidate.version === '0133_composable_catalog_revisions' + ) + expect(migration).toBeDefined() + + const query = vi.fn(async () => ({ rows: [], rowCount: 0 })) + await migration!.apply({ query }) + const sql = query.mock.calls + .map(call => String(call[0])) + .join('\n') + .replace(/\s+/g, ' ') + + const replaced = [...sql.matchAll(/CREATE OR REPLACE TRIGGER (\w+) /g)].map(match => match[1]) + expect(replaced.sort()).toEqual( + [ + 'users_authorization_revision', + 'teams_authorization_revision', + 'workflow_runs_authorization_revision', + 'workflow_approval_requests_authorization_revision', + 'notification_deliveries_authorization_revision', + 'gfs_grants_authorization_revision', + 'gfs_shares_authorization_revision', + 'gfs_resources_authorization_revision', + ].sort() + ) + expect(sql).not.toMatch(/DROP TRIGGER IF EXISTS \w+_authorization_revision/) + expect(sql).not.toMatch(/CREATE TRIGGER /) + }) }) diff --git a/control-api/test/db.userAccessFoundationMigration.test.ts b/control-api/test/db.userAccessFoundationMigration.test.ts index 0bba89214a..f8b86e9255 100644 --- a/control-api/test/db.userAccessFoundationMigration.test.ts +++ b/control-api/test/db.userAccessFoundationMigration.test.ts @@ -216,10 +216,13 @@ describe('user-access foundation migration', () => { expect(sql).not.toMatch(/CREATE\s+MATERIALIZED\s+VIEW/i) }) - it('adds keyset and reverse indexes before catalog producers are introduced', async () => { + it('builds keyset and reverse indexes online, outside the 0130 transaction', async () => { const sql = await migrationSql() - - for (const expectedIndex of [ + const { PR1_ONLINE_INDEX_PLAN } = await import('../src/migrations/pr1OnlineIndexPlan.js') + const planned = PR1_ONLINE_INDEX_PLAN.filter( + entry => entry.migrationVersion === '0130_user_access_foundation' + ) + const expectedIndexes = [ 'team_members_user_active_idx', 'user_contexts_context_user_idx', 'team_contexts_context_team_idx', @@ -238,11 +241,26 @@ describe('user-access foundation migration', () => { 'notification_team_catalog_idx', 'gfs_grants_subject_resource_catalog_idx', 'gfs_shares_subject_resource_catalog_idx', - ]) { - expect(sql).toContain(expectedIndex) - } - expect(sql).toContain('ON workflow_runs (actor_id, run_id)') - expect(sql).toContain("ON notification_deliveries ((audience->>'userId'), id)") + ] + expect(planned.map(entry => entry.name).sort()).toEqual([...expectedIndexes].sort()) + // Witness: the transaction still creates the PR1-owned tables and their indexes. + expect(sql).toContain('CREATE TABLE IF NOT EXISTS external_user_sessions') + expect(sql).toContain('CREATE INDEX IF NOT EXISTS operational_relationship_target_idx') + for (const expectedIndex of expectedIndexes) expect(sql).not.toContain(expectedIndex) + + const plannedSql = planned.map(entry => entry.createSql).join('\n') + expect(plannedSql).toContain('ON workflow_runs (actor_id, run_id)') + expect(plannedSql).toContain("ON notification_deliveries ((audience->>'userId'), id)") + }) + + it('installs no statement-level catalog revision trigger', async () => { + const sql = await migrationSql() + + // Witness: the trigger block ran and still installs the row-level triggers. + expect(sql).toContain("('team_members', 'team_members_authorization_revision',") + expect(sql).toContain('CREATE OR REPLACE FUNCTION authorization_bump_catalog_revision()') + expect(sql).not.toMatch(/FOR EACH STATEMENT/i) + expect(sql).not.toMatch(/\|\|\s*'_catalog_revision'/) }) it('declares exact least-privilege profiles for every new relation', () => { diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index c0327f8133..bfa349a628 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -105,10 +105,13 @@ describe('D34 migration execution policy', () => { }) it('preserves the approved historical migration bodies byte-for-byte', async () => { + // S1d changed the PR1 bodies before any shared environment applied them: + // statement-level catalog triggers are no longer installed by 0130, 0133/0134 + // replace row triggers in place, and planned indexes left the transactions. const files = [ [ 'src/services/access/userAccessFoundationSchema.ts', - 'ed9cb12a3141871ddb4da12a19a865dcf11797436e830357193a4c3f3afc8ab6', + 'bd1c6945cdc02fab9e4555e3c2c2714632bf6ed77a006fd1f321e8df35a59131', ], [ 'src/services/directory/invitationDeliverySchema.ts', @@ -132,25 +135,20 @@ describe('D34 migration execution policy', () => { 'utf8' ), ]) - const historicalNames = historicalSql + const inTransactionNames = historicalSql .flatMap(sql => [...sql.matchAll(/CREATE (?:UNIQUE )?INDEX IF NOT EXISTS (\w+)/g)]) .map(match => match[1]!) .sort() - const historicalDefinitions = new Map( - historicalSql.flatMap(sql => - [...sql.matchAll(/CREATE (?:UNIQUE )?INDEX IF NOT EXISTS (\w+)[\s\S]*?;/g)].map( - match => [match[1]!, match[0]] as const - ) - ) - ) - const classified = [ - ...PR1_ONLINE_INDEX_PLAN.map(index => index.name), - ...FRESH_TABLE_INDEXES, - ].sort() - - expect(historicalNames).toHaveLength(39) - expect(classified).toEqual(historicalNames) - expect(new Set(classified)).toHaveLength(classified.length) + const plannedNames = PR1_ONLINE_INDEX_PLAN.map(index => index.name).sort() + + // Only indexes on tables PR1 itself creates are built inside a transaction; + // every index on a pre-existing table is built online, and only online. + expect(inTransactionNames).toEqual([...FRESH_TABLE_INDEXES].sort()) + expect(plannedNames).toHaveLength(25) + expect(new Set([...plannedNames, ...inTransactionNames])).toHaveLength(39) + + // The planned definitions equal the in-transaction definitions S1d removed + // (canonical form at 579ed2986), so an index built by either path is equivalent. const canonical = (value: string) => value .replace(/\bCONCURRENTLY\b|\bIF NOT EXISTS\b/g, '') @@ -158,11 +156,13 @@ describe('D34 migration execution policy', () => { .replace(/\s+/g, ' ') .replace(/\s*([(),])\s*/g, '$1') .trim() - for (const index of PR1_ONLINE_INDEX_PLAN) { - expect(canonical(index.createSql), index.name).toBe( - canonical(historicalDefinitions.get(index.name) ?? '') - ) - } + const plannedDefinitions = [...PR1_ONLINE_INDEX_PLAN] + .sort((left, right) => (left.name < right.name ? -1 : 1)) + .map(index => `${index.name}\t${canonical(index.createSql)}`) + .join('\n') + expect(createHash('sha256').update(plannedDefinitions).digest('hex')).toBe( + 'bc94e081bba8daa3708eb783a2c9abaf64e72fdfdf11d1b74ac6ba1926e834d2' + ) }) it('normalizes PostgreSQL deparser syntax without accepting changed index tokens', () => { diff --git a/control-api/test/pr1MigrationLocks.realPostgres.integration.test.ts b/control-api/test/pr1MigrationLocks.realPostgres.integration.test.ts new file mode 100644 index 0000000000..98263ef18e --- /dev/null +++ b/control-api/test/pr1MigrationLocks.realPostgres.integration.test.ts @@ -0,0 +1,348 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { randomBytes } from 'node:crypto' +import { Pool, type PoolClient } from 'pg' +import { initDb } from '../src/db.js' +import { PR1_MIGRATION_VERSIONS } from '../src/migrations/migrationRunner.js' +import { PR1_ONLINE_INDEX_PLAN } from '../src/migrations/pr1OnlineIndexPlan.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' + +/* + * The PR1 migration Job runs while the previous control-api, external-rest-api + * and workflow-recipes images keep serving the same tables. A PR1 transaction + * must therefore never hold AccessExclusiveLock (blocks readers) or ShareLock + * (in-transaction CREATE INDEX) on a table that existed before PR1. The locks + * are read from pg_locks just before each PR1 transaction commits, when every + * lock it took is still held. + */ + +type DbConnector = NonNullable[0]> +type RelationLock = { relname: string; mode: string } +type CommitSnapshot = { versions: string[]; locks: RelationLock[] } +type Probe = { ok: boolean; sqlstate: string | null } + +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const describeRealPostgres = adminUrl ? describe : describe.skip +const runtimeRoles = [ + 'control_api_runtime', + 'trace_maintenance_runtime', + 'workflow_recipes_runtime', +] as const + +const PR1_VERSIONS = new Set(PR1_MIGRATION_VERSIONS) +const ACCESS_FOUNDATION_MARKER = 'CREATE TABLE IF NOT EXISTS external_user_sessions' +const COMPOSABLE_CATALOG_VERSION = '0133_composable_catalog_revisions' +const ROW_REVISION_TRIGGER_TABLES = [ + 'users', + 'teams', + 'workflow_runs', + 'workflow_approval_requests', + 'notification_deliveries', + 'gfs_grants', + 'gfs_shares', + 'gfs_resources', +] as const +// The statement-level triggers the 0130 body installed before S1d. A database +// that applied that body still carries them until 0133 drops them. +const LEGACY_CATALOG_TRIGGER_TABLES = [ + 'users', + 'teams', + 'team_members', + 'user_contexts', + 'team_contexts', + 'user_agents', + 'team_agents', + 'user_workflow_triggers', + 'team_workflow_triggers', + 'workflow_runs', + 'workflow_approval_requests', + 'notification_deliveries', + 'gfs_resources', + 'gfs_grants', + 'gfs_shares', + 'operational_resource_index', + 'operational_resource_relationships', + 'operational_catalog_source_state', +] as const +const LEGACY_CATALOG_TRIGGERS_SQL = LEGACY_CATALOG_TRIGGER_TABLES.map( + table => `CREATE OR REPLACE TRIGGER ${table}_catalog_revision AFTER INSERT OR UPDATE OR DELETE ON ${table} + FOR EACH STATEMENT EXECUTE FUNCTION authorization_bump_catalog_revision();` +).join('\n') + +function databaseUrl(baseUrl: string, database: string): string { + const value = new URL(baseUrl) + value.pathname = `/${database}` + return value.toString() +} + +function quoteIdentifier(value: string): string { + return `"${value.replace(/"/g, '""')}"` +} + +async function relationLocks(observer: Pool, pid: number): Promise { + const result = await observer.query( + `SELECT c.relname, l.mode + FROM pg_locks l + JOIN pg_class c ON c.oid = l.relation + WHERE l.pid = $1 + AND l.locktype = 'relation' + AND l.granted + AND c.relkind IN ('r', 'p') + AND l.mode <> 'AccessShareLock' + ORDER BY c.relname, l.mode`, + [pid] + ) + return result.rows +} + +async function committedTables(observer: Pool): Promise> { + const result = await observer.query<{ relname: string }>( + `SELECT c.relname + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = current_schema() AND c.relkind IN ('r', 'p')` + ) + return new Set(result.rows.map(row => row.relname)) +} + +// Runs one statement in its own transaction with a short lock_timeout and +// reports whether it got through or waited for a lock. +async function probe(observer: Pool, sql: string): Promise { + const client = await observer.connect() + try { + await client.query('BEGIN') + await client.query(`SET LOCAL lock_timeout = '1000ms'`) + try { + await client.query(sql) + return { ok: true, sqlstate: null } + } catch (error) { + const sqlstate = (error as { code?: string }).code ?? null + if (sqlstate !== '55P03') throw error + return { ok: false, sqlstate } + } + } finally { + await client.query('ROLLBACK') + client.release() + } +} + +async function triggerDefinitions(pool: Pool): Promise { + const result = await pool.query<{ definition: string }>( + `SELECT pg_get_triggerdef(t.oid) AS definition + FROM pg_trigger t + JOIN pg_class c ON c.oid = t.tgrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE NOT t.tgisinternal AND n.nspname = current_schema() + ORDER BY 1` + ) + return result.rows.map(row => row.definition) +} + +type MigrationRun = { + preExistingTables: Set + commits: CommitSnapshot[] + composableCatalogProbes: Record | null + legacyTriggersBeforeComposableCatalog: number | null +} + +async function migrate( + pool: Pool, + observer: Pool, + options: { installLegacyCatalogTriggers: boolean } +): Promise { + const run: MigrationRun = { + preExistingTables: new Set(), + commits: [], + composableCatalogProbes: null, + legacyTriggersBeforeComposableCatalog: null, + } + const pendingVersions: string[] = [] + let accessFoundationSeen = false + + const connector = { + connect: async () => { + const client: PoolClient = await pool.connect() + const pid = Number((await client.query('SELECT pg_backend_pid() AS pid')).rows[0].pid) + return { + release: (destroy?: boolean) => client.release(destroy), + query: async (text: string, values?: unknown[]) => { + if (!accessFoundationSeen && text.includes(ACCESS_FOUNDATION_MARKER)) { + accessFoundationSeen = true + // The 0130 transaction has not created anything yet, so the + // committed catalog holds exactly the tables that predate PR1. + run.preExistingTables = await committedTables(observer) + } + if (text.includes('INSERT INTO schema_migrations')) { + pendingVersions.push(String(values?.[0])) + } + if (text.trim() === 'COMMIT') { + const versions = pendingVersions.splice(0) + if (versions.some(version => PR1_VERSIONS.has(version))) { + if ( + options.installLegacyCatalogTriggers && + versions.includes('0130_user_access_foundation') + ) { + await client.query(LEGACY_CATALOG_TRIGGERS_SQL) + } + if (versions.includes(COMPOSABLE_CATALOG_VERSION)) { + run.composableCatalogProbes = { + readUsers: await probe(observer, 'SELECT count(*) FROM users'), + readWorkflowRuns: await probe(observer, 'SELECT count(*) FROM workflow_runs'), + writeUsers: await probe(observer, 'LOCK TABLE users IN ROW EXCLUSIVE MODE'), + } + } + run.commits.push({ versions, locks: await relationLocks(observer, pid) }) + } + } + if ( + run.legacyTriggersBeforeComposableCatalog === null && + text.includes('DROP TRIGGER IF EXISTS users_catalog_revision ON users') + ) { + const installed = await observer.query<{ count: number }>( + `SELECT count(*)::int AS count + FROM pg_trigger + WHERE NOT tgisinternal AND tgname LIKE '%\\_catalog\\_revision'` + ) + run.legacyTriggersBeforeComposableCatalog = installed.rows[0]!.count + } + return client.query(text, values) + }, + } + }, + } as unknown as DbConnector + + await initDb(connector) + return run +} + +function blockingLocks(run: MigrationRun): (RelationLock & { versions: string })[] { + return run.commits.flatMap(commit => + commit.locks + .filter( + lock => + run.preExistingTables.has(lock.relname) && + (lock.mode === 'AccessExclusiveLock' || lock.mode === 'ShareLock') + ) + .map(lock => ({ ...lock, versions: commit.versions.join(',') })) + ) +} + +describeRealPostgres('PR1 migration locks on real PostgreSQL', () => { + const suffix = randomBytes(6).toString('hex') + const freshDatabase = `control_api_pr1_locks_fresh_${suffix}` + const legacyDatabase = `control_api_pr1_locks_legacy_${suffix}` + const baseUrl = adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres' + let adminPool: Pool + let freshPool: Pool + let freshObserver: Pool + let legacyPool: Pool + let legacyObserver: Pool + let fresh: MigrationRun + let legacy: MigrationRun + + beforeAll(async () => { + adminPool = new Pool({ connectionString: adminUrl }) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + await adminPool.query(`CREATE DATABASE ${quoteIdentifier(freshDatabase)}`) + await adminPool.query(`CREATE DATABASE ${quoteIdentifier(legacyDatabase)}`) + freshPool = new Pool({ connectionString: databaseUrl(baseUrl, freshDatabase), max: 2 }) + freshObserver = new Pool({ connectionString: databaseUrl(baseUrl, freshDatabase), max: 3 }) + legacyPool = new Pool({ connectionString: databaseUrl(baseUrl, legacyDatabase), max: 2 }) + legacyObserver = new Pool({ connectionString: databaseUrl(baseUrl, legacyDatabase), max: 3 }) + fresh = await migrate(freshPool, freshObserver, { installLegacyCatalogTriggers: false }) + legacy = await migrate(legacyPool, legacyObserver, { installLegacyCatalogTriggers: true }) + }, 120_000) + + afterAll(async () => { + try { + for (const pool of [freshPool, freshObserver, legacyPool, legacyObserver]) { + if (pool) await endPoolAndWaitForClients(pool) + } + if (adminPool) { + for (const database of [freshDatabase, legacyDatabase]) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + } + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + } + } finally { + await adminPool?.end() + } + }) + + it('holds no AccessExclusive or Share lock on a pre-existing table in any PR1 transaction', () => { + // Liveness: every PR1 version committed under observation, the pre-PR1 + // table set was captured, and the snapshots see the trigger locks 0133 takes. + for (const version of PR1_MIGRATION_VERSIONS) { + expect( + fresh.commits.some(commit => commit.versions.includes(version)), + version + ).toBe(true) + } + expect(fresh.preExistingTables.has('users')).toBe(true) + expect(fresh.preExistingTables.has('external_user_sessions')).toBe(false) + const composable = fresh.commits.find(commit => + commit.versions.includes(COMPOSABLE_CATALOG_VERSION) + ) + expect(composable?.locks).toContainEqual({ relname: 'users', mode: 'ShareRowExclusiveLock' }) + + expect(blockingLocks(fresh)).toEqual([]) + }) + + it('lets readers through and holds writers while 0133 is open', () => { + expect(fresh.composableCatalogProbes).toEqual({ + readUsers: { ok: true, sqlstate: null }, + readWorkflowRuns: { ok: true, sqlstate: null }, + // Witness that the probe ran inside the open 0133 transaction. + writeUsers: { ok: false, sqlstate: '55P03' }, + }) + }) + + it('builds every planned index online and leaves it valid', async () => { + const result = await freshPool.query<{ name: string; valid: boolean }>( + `SELECT c.relname AS name, i.indisvalid AS valid + FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + WHERE c.relname = ANY($1::text[]) + ORDER BY 1`, + [PR1_ONLINE_INDEX_PLAN.map(entry => entry.name)] + ) + expect(result.rows).toEqual( + PR1_ONLINE_INDEX_PLAN.map(entry => ({ name: entry.name, valid: true })).sort((left, right) => + left.name < right.name ? -1 : 1 + ) + ) + }) + + it('installs the row revision triggers and no statement-level catalog trigger', async () => { + const definitions = await triggerDefinitions(freshPool) + expect(definitions.filter(definition => definition.includes('_catalog_revision '))).toEqual([]) + for (const table of ROW_REVISION_TRIGGER_TABLES) { + expect( + definitions.some(definition => + definition.startsWith( + `CREATE TRIGGER ${table}_authorization_revision AFTER INSERT OR DELETE OR UPDATE ON public.${table} FOR EACH ROW` + ) + ), + table + ).toBe(true) + } + }) + + it('converges a database that applied the earlier 0130 body to the fresh end state', async () => { + // Liveness: the legacy database really carried the 18 statement triggers + // into 0133, and dropping them there is visible as AccessExclusive locks. + expect(legacy.legacyTriggersBeforeComposableCatalog).toBe(LEGACY_CATALOG_TRIGGER_TABLES.length) + expect(blockingLocks(legacy)).toContainEqual({ + relname: 'users', + mode: 'AccessExclusiveLock', + versions: expect.stringContaining(COMPOSABLE_CATALOG_VERSION), + }) + + expect(await triggerDefinitions(legacyPool)).toEqual(await triggerDefinitions(freshPool)) + }) +}) diff --git a/tests/contracts/password-recovery-suites.json b/tests/contracts/password-recovery-suites.json index ee0960911d..dfe05a38ab 100644 --- a/tests/contracts/password-recovery-suites.json +++ b/tests/contracts/password-recovery-suites.json @@ -116,6 +116,7 @@ "control-api/test/membershipInvitationIdempotency.realPostgres.integration.test.ts", "control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts", "control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts", + "control-api/test/pr1MigrationLocks.realPostgres.integration.test.ts", "control-api/test/routes.externalGfsV2Lifecycle.realPostgres.integration.test.ts", "control-api/test/routes.externalNotifications.currentness.realPostgres.integration.test.ts", "control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts", From abd41e4c42823965f7bf068f690a5aaca0907915 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 22 Oct 2026 20:45:56 +0200 Subject: [PATCH 363/528] test(control-api): name the PR1 body pin after what it checks The D34 test pins the bytes of the PR1 migration bodies reviewed in S1d, not historical bodies that a shared environment applied. Rename it so the title matches the comment above the hashes. Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- control-api/test/migrationExecutionPolicy.test.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 626cecc588..23e5fb7dd1 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.820", + "version": "0.1.821", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.820", + "version": "0.1.821", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 3e2fe36e45..b94de8ff33 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.820", + "version": "0.1.821", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index bfa349a628..d358710113 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -104,7 +104,7 @@ describe('D34 migration execution policy', () => { ).toBe(false) }) - it('preserves the approved historical migration bodies byte-for-byte', async () => { + it('pins the reviewed PR1 migration bodies byte-for-byte', async () => { // S1d changed the PR1 bodies before any shared environment applied them: // statement-level catalog triggers are no longer installed by 0130, 0133/0134 // replace row triggers in place, and planned indexes left the transactions. From 029a3b8e7c3dc5d9807b36452e3ae9bf6ee72714 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 22 Oct 2026 22:10:56 +0200 Subject: [PATCH 364/528] fix(control-api): fold the PR1 migration chain before first release No shared environment has applied a PR1 migration: clerum-dev and production both stop at 0129 with no PR1 footprint. The fix-forward migrations, historical receipt aliases and rollback producers therefore protect states that do not exist, so the chain is folded into its original versions. - Drop 0134, fold 0144 into 0130 and 0135 into the CREATE statements, and remove the duplicate bump functions from 0133. - Stop creating authorization_catalog_revision and its function, and remove the trigger and object drops that cleaned them up. - Remove the 40 legacy receipt aliases and preserveLegacyReceipts; the runner keeps the dev alias path unchanged. - Renumber the legacy password epoch backfill from 0136 to 0134. - Remove the historical Task 106 and published PR1 producers from CI and their tests, re-pin the D34 bodies, and point the GFS readiness alias test at a dev alias. - Cover runtime-role deletion of a user and a team with a deleted membership on a fresh schema. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci-public.yml | 52 +-- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- control-api/src/db.ts | 89 +--- ...ionRevisionDeleteCompatibilityMigration.ts | 43 -- control-api/src/migrations/migrationRunner.ts | 124 +---- ...essFoundationDefinerTempShadowMigration.ts | 53 --- .../access/userAccessFoundationSchema.ts | 139 ++---- ...composableCatalogRevisionMigration.test.ts | 11 +- ...CatalogRevisionComponentsMigration.test.ts | 45 -- control-api/test/db.initDb.test.ts | 5 +- control-api/test/db.migrationsOrder.test.ts | 117 +---- ....realPostgresMigration.integration.test.ts | 55 +-- .../db.userAccessFoundationMigration.test.ts | 189 +------- ...Readiness.realPostgres.integration.test.ts | 41 +- ...hBackfill.realPostgres.integration.test.ts | 17 +- ...ionPolicy.realPostgres.integration.test.ts | 405 +---------------- .../test/migrationExecutionPolicy.test.ts | 424 ++---------------- ...atibility.realPostgres.integration.test.ts | 122 +---- ...tionLocks.realPostgres.integration.test.ts | 93 +--- 20 files changed, 149 insertions(+), 1881 deletions(-) delete mode 100644 control-api/src/migrations/authorizationRevisionDeleteCompatibilityMigration.ts delete mode 100644 control-api/src/migrations/userAccessFoundationDefinerTempShadowMigration.ts delete mode 100644 control-api/test/db.gfsCatalogRevisionComponentsMigration.test.ts diff --git a/.github/workflows/ci-public.yml b/.github/workflows/ci-public.yml index f485cab4f8..25a4075874 100644 --- a/.github/workflows/ci-public.yml +++ b/.github/workflows/ci-public.yml @@ -454,27 +454,15 @@ jobs: steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - # R61-B1 acceptance runs exact migration producers for the historical - # Task 106 receipts, the published rollback runner, observed DEV history, - # and the latest fetched DEV graph. Immutable pins keep clean CI portable. - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - ref: 2370a399d4221350946462d41427fad8aee0087f - path: r61-b1-producers/historical-task106 - persist-credentials: false - + # R61-B1 acceptance runs exact migration producers for observed DEV + # history and the latest fetched DEV graph. Immutable pins keep clean CI + # portable. - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: ref: 0b26101eb247adcc44f70d39451f3e82c3225d47 path: r61-b1-producers/observed-dev persist-credentials: false - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - ref: 113479887ce4fdfd787e9cb1100d74bae596640f - path: r61-b1-producers/published-pr1 - persist-credentials: false - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: ref: 87f962fb999ed5dbcab90bd52562de0eba4aa564 @@ -499,22 +487,6 @@ jobs: working-directory: packages/workflow-runtime-core run: npm run build - - name: Install historical Task 106 producer dependencies - working-directory: r61-b1-producers/historical-task106/packages/workflow-runtime-core - run: npm ci - - - name: Build historical Task 106 producer dependency - working-directory: r61-b1-producers/historical-task106/packages/workflow-runtime-core - run: npm run build - - - name: Install historical Task 106 Control API dependencies - working-directory: r61-b1-producers/historical-task106/control-api - run: npm ci - - - name: Build historical Task 106 migration runner - working-directory: r61-b1-producers/historical-task106/control-api - run: npm run build - - name: Install observed DEV producer dependencies working-directory: r61-b1-producers/observed-dev/packages/workflow-runtime-core run: npm ci @@ -531,22 +503,6 @@ jobs: working-directory: r61-b1-producers/observed-dev/control-api run: npm run build - - name: Install published PR1 rollback producer dependencies - working-directory: r61-b1-producers/published-pr1/packages/workflow-runtime-core - run: npm ci - - - name: Build published PR1 rollback producer dependency - working-directory: r61-b1-producers/published-pr1/packages/workflow-runtime-core - run: npm run build - - - name: Install published PR1 Control API dependencies - working-directory: r61-b1-producers/published-pr1/control-api - run: npm ci - - - name: Build published PR1 rollback migration runner - working-directory: r61-b1-producers/published-pr1/control-api - run: npm run build - - name: Install latest DEV producer dependencies working-directory: r61-b1-producers/latest-dev/packages/workflow-runtime-core run: npm ci @@ -581,8 +537,6 @@ jobs: env: CONTROL_API_REAL_PG_ADMIN_URL: postgresql://postgres@127.0.0.1:15433/postgres CONTROL_API_REAL_PG_REQUIRED: '1' - TASK106_R61_B1_HISTORICAL_SOURCE_DIR: ${{ github.workspace }}/r61-b1-producers/historical-task106 - TASK106_R61_B1_PUBLISHED_PR1_SOURCE_DIR: ${{ github.workspace }}/r61-b1-producers/published-pr1 TASK106_R61_B1_DEV_SOURCE_DIR: ${{ github.workspace }}/r61-b1-producers/observed-dev TASK106_R61_B1_LATEST_DEV_SOURCE_DIR: ${{ github.workspace }}/r61-b1-producers/latest-dev FORCE_COLOR: '0' diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 23e5fb7dd1..53a7da07ab 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.821", + "version": "0.1.822", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.821", + "version": "0.1.822", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index b94de8ff33..f51260d327 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.821", + "version": "0.1.822", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/db.ts b/control-api/src/db.ts index 95298eb34f..4eec820ad4 100644 --- a/control-api/src/db.ts +++ b/control-api/src/db.ts @@ -5,10 +5,8 @@ import { createBoundedPgPoolForConnection, } from './boundedPgPool.js' import { config } from './config.js' -import { applyAuthorizationRevisionDeleteCompatibility } from './migrations/authorizationRevisionDeleteCompatibilityMigration.js' import { migrationSessionBoundsSql } from './migrations/migrationExecutionPolicy.js' import { applyPendingPr1Migrations } from './migrations/migrationRunner.js' -import { applyUserAccessFoundationDefinerTempShadowHardening } from './migrations/userAccessFoundationDefinerTempShadowMigration.js' import { rootLogger } from './observability/logger.js' import { applyCatalogUtf8OrderingSchema, @@ -135,8 +133,6 @@ type DbMigration = { version: string // Equivalent names used before a branch merge renumbered the same migration body. legacyVersions?: readonly string[] - // Retain same-body alias receipts so supported older runners do not replay this body. - preserveLegacyReceipts?: boolean apply: (db: DbClient) => Promise } @@ -6415,108 +6411,25 @@ export const CONTROL_API_MIGRATIONS: DbMigration[] = [ apply: applyWorkflowScheduleConcurrencyPolicyMigration, }, { - // Preserve the displaced Task 106 identity as a same-body receipt alias. version: '0130_user_access_foundation', - legacyVersions: [ - '0129_user_access_foundation', - '0126_user_access_foundation', - '0125_user_access_foundation', - '0109_user_access_foundation', - '0107_user_access_foundation', - '0101_user_access_foundation', - ], - preserveLegacyReceipts: true, apply: applyUserAccessFoundationSchema, }, { version: '0131_invitation_delivery_commands', - legacyVersions: [ - '0130_invitation_delivery_commands', - '0127_invitation_delivery_commands', - '0126_invitation_delivery_commands', - '010a_invitation_delivery_commands', - '0108_invitation_delivery_commands', - '0102_invitation_delivery_commands', - ], - preserveLegacyReceipts: true, apply: applyInvitationDeliveryCommandFoundation, }, { version: '0132_catalog_utf8_ordering', - legacyVersions: [ - '0131_catalog_utf8_ordering', - '0128_catalog_utf8_ordering', - '0127_catalog_utf8_ordering', - '010b_catalog_utf8_ordering', - '0109_catalog_utf8_ordering', - '0103_catalog_utf8_ordering', - ], - preserveLegacyReceipts: true, apply: applyCatalogUtf8OrderingSchema, }, { version: '0133_composable_catalog_revisions', - legacyVersions: [ - '0132_composable_catalog_revisions', - '0129_composable_catalog_revisions', - '0128_composable_catalog_revisions', - '010c_composable_catalog_revisions', - '010a_composable_catalog_revisions', - '0104_composable_catalog_revisions', - ], - preserveLegacyReceipts: true, apply: applyComposableCatalogRevisionSchema, }, { - // Fix-forward for databases that recorded the first composable-catalog - // body before the GFS resource-component mapping was completed. - version: '0134_gfs_catalog_revision_components', - legacyVersions: [ - '0133_gfs_catalog_revision_components', - '012a_gfs_catalog_revision_components', - '0129_gfs_catalog_revision_components', - '010d_gfs_catalog_revision_components', - '010b_gfs_catalog_revision_components', - '0105_gfs_catalog_revision_components', - ], - preserveLegacyReceipts: true, - apply: applyComposableCatalogRevisionSchema, - }, - { - // The access-foundation bodies are immutable; harden their installed - // SECURITY DEFINER search paths without replaying those historical bodies. - version: '0135_user_access_foundation_definer_temp_shadow_hardening', - legacyVersions: [ - '0134_user_access_foundation_definer_temp_shadow_hardening', - '012b_user_access_foundation_definer_temp_shadow_hardening', - '012a_user_access_foundation_definer_temp_shadow_hardening', - ], - preserveLegacyReceipts: true, - apply: applyUserAccessFoundationDefinerTempShadowHardening, - }, - { - version: '0136_legacy_password_security_epoch_backfill', - legacyVersions: [ - '0135_legacy_password_security_epoch_backfill', - '0130_legacy_password_security_epoch_backfill', - '010e_legacy_password_security_epoch_backfill', - '010c_legacy_password_security_epoch_backfill', - '0106_legacy_password_security_epoch_backfill', - ], - preserveLegacyReceipts: true, + version: '0134_legacy_password_security_epoch_backfill', apply: backfillLegacyPasswordSecurityEpochs, }, - { - // This narrow forward fix retains its late canonical receipt while the - // runner executes it atomically immediately after the access foundation. - version: '0144_authorization_revision_delete_compatibility', - legacyVersions: [ - '0143_authorization_revision_delete_compatibility', - '0138_authorization_revision_delete_compatibility', - ], - preserveLegacyReceipts: true, - apply: applyAuthorizationRevisionDeleteCompatibility, - }, ] async function consolidateWorkflowAllowedUsersToTriggers(db: DbClient): Promise { diff --git a/control-api/src/migrations/authorizationRevisionDeleteCompatibilityMigration.ts b/control-api/src/migrations/authorizationRevisionDeleteCompatibilityMigration.ts deleted file mode 100644 index d5f8d684c6..0000000000 --- a/control-api/src/migrations/authorizationRevisionDeleteCompatibilityMigration.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { DbClient } from '../db.js' - -/** - * Forward repair for the 0125 identity-revision trigger behavior. - * - * The immutable 0125 body unconditionally upserts a revision. During a user or - * team delete cascade that can reference an identity already removed by the - * parent statement. Select only still-existing parents, matching the later - * composable-revision behavior without changing either function's ACL. - */ -export async function applyAuthorizationRevisionDeleteCompatibility(db: DbClient): Promise { - await db.query(` - CREATE OR REPLACE FUNCTION public.authorization_bump_user_revision(target_user_id UUID) - RETURNS VOID - LANGUAGE SQL - SECURITY DEFINER - SET search_path = pg_catalog, public, pg_temp - AS $$ - INSERT INTO authorization_user_revisions(user_id, revision, updated_at) - SELECT users.id, 1, clock_timestamp() - FROM users - WHERE users.id = target_user_id - ON CONFLICT (user_id) DO UPDATE - SET revision = authorization_user_revisions.revision + 1, - updated_at = clock_timestamp(); - $$; - - CREATE OR REPLACE FUNCTION public.authorization_bump_team_revision(target_team_id UUID) - RETURNS VOID - LANGUAGE SQL - SECURITY DEFINER - SET search_path = pg_catalog, public, pg_temp - AS $$ - INSERT INTO authorization_team_revisions(team_id, revision, updated_at) - SELECT teams.id, 1, clock_timestamp() - FROM teams - WHERE teams.id = target_team_id - ON CONFLICT (team_id) DO UPDATE - SET revision = authorization_team_revisions.revision + 1, - updated_at = clock_timestamp(); - $$; - `) -} diff --git a/control-api/src/migrations/migrationRunner.ts b/control-api/src/migrations/migrationRunner.ts index f6725bcf4c..e9af5abf34 100644 --- a/control-api/src/migrations/migrationRunner.ts +++ b/control-api/src/migrations/migrationRunner.ts @@ -7,11 +7,7 @@ export const PR1_MIGRATION_VERSIONS = Object.freeze([ '0131_invitation_delivery_commands', '0132_catalog_utf8_ordering', '0133_composable_catalog_revisions', - '0134_gfs_catalog_revision_components', - '0135_user_access_foundation_definer_temp_shadow_hardening', - '0136_legacy_password_security_epoch_backfill', - // Executed immediately after 0130 by the runner, before remaining PR1 migrations. - '0144_authorization_revision_delete_compatibility', + '0134_legacy_password_security_epoch_backfill', ] as const) export const DEV_POST_0106_MIGRATION_VERSIONS = Object.freeze([ @@ -50,15 +46,9 @@ const CLASSIFIED_POST_0106_MIGRATION_VERSIONS = Object.freeze([ export type MigrationDescriptor = { version: string legacyVersions?: readonly string[] - // Keep supported older-reader identities alongside the current receipt. - preserveLegacyReceipts?: boolean apply: (db: DbClient) => Promise } -const AUTHORIZATION_REVISION_COMPATIBILITY_PREREQUISITE = '0130_user_access_foundation' -const AUTHORIZATION_REVISION_COMPATIBILITY_VERSION = - '0144_authorization_revision_delete_compatibility' - type ApplyPendingPr1MigrationsInput = { db: DbClient migrations: readonly MigrationDescriptor[] @@ -98,9 +88,6 @@ export async function applyPendingPr1Migrations({ const canonicalVersions = new Set(byVersion.keys()) const aliasOwners = new Map() for (const migration of migrations) { - if (migration.preserveLegacyReceipts && !migration.legacyVersions?.length) { - throw new Error(`Migration ${migration.version} preserves no legacy receipts`) - } for (const alias of migration.legacyVersions ?? []) { if (canonicalVersions.has(alias)) { throw new Error(`Migration alias collides with canonical version: ${alias}`) @@ -128,118 +115,21 @@ export async function applyPendingPr1Migrations({ for (const version of CLASSIFIED_POST_0106_MIGRATION_VERSIONS) { const migration = byVersion.get(version) if (!migration) throw new Error(`Missing registered post-0106 migration: ${version}`) - const compatibilityMigration = - version === AUTHORIZATION_REVISION_COMPATIBILITY_PREREQUISITE - ? byVersion.get(AUTHORIZATION_REVISION_COMPATIBILITY_VERSION) - : undefined - if (version === AUTHORIZATION_REVISION_COMPATIBILITY_PREREQUISITE && !compatibilityMigration) { - throw new Error( - `Missing registered authorization revision compatibility migration: ${AUTHORIZATION_REVISION_COMPATIBILITY_VERSION}` - ) - } + if (appliedVersions.has(version)) continue const acceptedLegacyVersion = migration.legacyVersions?.find(alias => appliedVersions.has(alias) ) - const preservedMigrationReceipts = migration.preserveLegacyReceipts - ? (migration.legacyVersions ?? []) - : [] - const currentMigrationApplied = appliedVersions.has(version) || Boolean(acceptedLegacyVersion) - if (currentMigrationApplied) { - const currentReceiptPending = !appliedVersions.has(version) - const preservedMigrationReceiptsPending = preservedMigrationReceipts.filter( - alias => !appliedVersions.has(alias) - ) - const acceptedCompatibilityLegacyVersion = compatibilityMigration?.legacyVersions?.find( - alias => appliedVersions.has(alias) - ) - const compatibilityReceiptPending = - compatibilityMigration && !appliedVersions.has(compatibilityMigration.version) - const preservedCompatibilityReceipts = compatibilityMigration?.preserveLegacyReceipts - ? (compatibilityMigration.legacyVersions ?? []) - : [] - const preservedCompatibilityReceiptsPending = preservedCompatibilityReceipts.filter( - alias => !appliedVersions.has(alias) - ) - const compatibilityBodyPending = - compatibilityMigration && - !appliedVersions.has(compatibilityMigration.version) && - !acceptedCompatibilityLegacyVersion - if ( - currentReceiptPending || - preservedMigrationReceiptsPending.length > 0 || - compatibilityReceiptPending || - preservedCompatibilityReceiptsPending.length > 0 - ) { - await runBoundedTransaction(db, async () => { - if (currentReceiptPending) await recordMigration(db, version) - for (const alias of preservedMigrationReceiptsPending) { - await recordMigration(db, alias) - } - if (compatibilityBodyPending) { - await compatibilityMigration!.apply(db) - } - if (compatibilityMigration && compatibilityReceiptPending) { - await recordMigration(db, compatibilityMigration.version) - } - if (compatibilityMigration) { - for (const alias of preservedCompatibilityReceiptsPending) { - await recordMigration(db, alias) - } - } - }) - if (currentReceiptPending) appliedVersions.add(version) - for (const alias of preservedMigrationReceiptsPending) appliedVersions.add(alias) - if (compatibilityReceiptPending) { - appliedVersions.add(compatibilityMigration.version) - } - for (const alias of preservedCompatibilityReceiptsPending) appliedVersions.add(alias) - } + if (acceptedLegacyVersion) { + await runBoundedTransaction(db, () => recordMigration(db, version)) + appliedVersions.add(version) continue } - const isPr1Migration = expected.has(version) - const compatibilityReceiptAlreadyApplied = compatibilityMigration - ? appliedVersions.has(compatibilityMigration.version) || - compatibilityMigration.legacyVersions?.some(alias => appliedVersions.has(alias)) - : false - if (compatibilityMigration && compatibilityReceiptAlreadyApplied) { - throw new Error( - `Team revision compatibility is recorded before its prerequisite: ${compatibilityMigration.version}` - ) - } - if (isPr1Migration && !acceptedLegacyVersion) { - await preparePr1Migration(db, version) - } - + if (expected.has(version)) await preparePr1Migration(db, version) await runBoundedTransaction(db, async () => { - if (!acceptedLegacyVersion) await migration.apply(db) + await migration.apply(db) await recordMigration(db, version) - for (const alias of preservedMigrationReceipts) { - if (!appliedVersions.has(alias)) await recordMigration(db, alias) - } - if (compatibilityMigration) { - const acceptedAlias = compatibilityMigration.legacyVersions?.find(alias => - appliedVersions.has(alias) - ) - if (!appliedVersions.has(compatibilityMigration.version) && !acceptedAlias) { - await compatibilityMigration.apply(db) - } - if (!appliedVersions.has(compatibilityMigration.version)) { - await recordMigration(db, compatibilityMigration.version) - } - const compatibilityAliases = compatibilityMigration.preserveLegacyReceipts - ? (compatibilityMigration.legacyVersions ?? []) - : [] - for (const alias of compatibilityAliases) { - if (!appliedVersions.has(alias)) await recordMigration(db, alias) - } - } }) appliedVersions.add(version) - for (const alias of preservedMigrationReceipts) appliedVersions.add(alias) - if (compatibilityMigration) appliedVersions.add(compatibilityMigration.version) - if (compatibilityMigration?.preserveLegacyReceipts) { - for (const alias of compatibilityMigration.legacyVersions ?? []) appliedVersions.add(alias) - } } } diff --git a/control-api/src/migrations/userAccessFoundationDefinerTempShadowMigration.ts b/control-api/src/migrations/userAccessFoundationDefinerTempShadowMigration.ts deleted file mode 100644 index 221a7b94d0..0000000000 --- a/control-api/src/migrations/userAccessFoundationDefinerTempShadowMigration.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { DbClient } from '../db.js' - -/** - * Harden the installed access-foundation SECURITY DEFINER functions without - * changing their bodies, ownership, grants, or behavior. - * The obsolete authorization_bump_catalog_revision() routine is intentionally - * excluded because 0133_composable_catalog_revisions drops it before this runs. - */ -export async function applyUserAccessFoundationDefinerTempShadowHardening( - db: DbClient -): Promise { - await db.query(` - ALTER FUNCTION public.authorization_bump_user_revision(pg_catalog.uuid) - SET search_path = pg_catalog, public, pg_temp; - ALTER FUNCTION public.authorization_bump_team_revision(pg_catalog.uuid) - SET search_path = pg_catalog, public, pg_temp; - ALTER FUNCTION public.authorization_bump_subject_revision(pg_catalog.text, pg_catalog.text) - SET search_path = pg_catalog, public, pg_temp; - ALTER FUNCTION public.authorization_bump_user_row_revision() - SET search_path = pg_catalog, public, pg_temp; - ALTER FUNCTION public.authorization_bump_team_row_revision() - SET search_path = pg_catalog, public, pg_temp; - ALTER FUNCTION public.authorization_bump_workflow_run_revision() - SET search_path = pg_catalog, public, pg_temp; - ALTER FUNCTION public.authorization_bump_workflow_approval_revision() - SET search_path = pg_catalog, public, pg_temp; - ALTER FUNCTION public.authorization_bump_notification_revision() - SET search_path = pg_catalog, public, pg_temp; - ALTER FUNCTION public.authorization_bump_gfs_subject_revision() - SET search_path = pg_catalog, public, pg_temp; - ALTER FUNCTION public.authorization_bump_gfs_resource_component(pg_catalog.uuid) - SET search_path = pg_catalog, public, pg_temp; - ALTER FUNCTION public.authorization_bump_gfs_authority_revision() - SET search_path = pg_catalog, public, pg_temp; - ALTER FUNCTION public.authorization_bump_gfs_resource_subjects(pg_catalog.uuid) - SET search_path = pg_catalog, public, pg_temp; - ALTER FUNCTION public.authorization_bump_gfs_resource_revision() - SET search_path = pg_catalog, public, pg_temp; - ALTER FUNCTION public.authorization_bump_resource_revision( - pg_catalog.text, pg_catalog.text, pg_catalog.text - ) SET search_path = pg_catalog, public, pg_temp; - ALTER FUNCTION public.authorization_bump_team_membership_revision() - SET search_path = pg_catalog, public, pg_temp; - ALTER FUNCTION public.authorization_bump_user_grant_revision() - SET search_path = pg_catalog, public, pg_temp; - ALTER FUNCTION public.authorization_bump_team_grant_revision() - SET search_path = pg_catalog, public, pg_temp; - ALTER FUNCTION public.authorization_bump_operational_resource_revision() - SET search_path = pg_catalog, public, pg_temp; - ALTER FUNCTION public.authorization_bump_operational_relationship_revision() - SET search_path = pg_catalog, public, pg_temp; - `) -} diff --git a/control-api/src/services/access/userAccessFoundationSchema.ts b/control-api/src/services/access/userAccessFoundationSchema.ts index 455129ef0b..b8b863ca5e 100644 --- a/control-api/src/services/access/userAccessFoundationSchema.ts +++ b/control-api/src/services/access/userAccessFoundationSchema.ts @@ -84,15 +84,6 @@ export async function applyUserAccessFoundationSchema(db: DbClient): Promise 0), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - INSERT INTO authorization_catalog_revision(singleton) - VALUES(TRUE) - ON CONFLICT (singleton) DO NOTHING; - CREATE TABLE IF NOT EXISTS operational_catalog_source_state ( environment_id TEXT NOT NULL, source_family TEXT NOT NULL, @@ -226,8 +217,8 @@ export async function applyCatalogUtf8OrderingSchema(db: DbClient): Promise { await db.query(` @@ -267,36 +258,6 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis SET component_class = EXCLUDED.component_class, rationale = EXCLUDED.rationale; - CREATE OR REPLACE FUNCTION authorization_bump_user_revision(target_user_id UUID) - RETURNS VOID - LANGUAGE SQL - SECURITY DEFINER - SET search_path = pg_catalog, public - AS $$ - INSERT INTO authorization_user_revisions(user_id, revision, updated_at) - SELECT users.id, 1, clock_timestamp() - FROM users - WHERE users.id = target_user_id - ON CONFLICT (user_id) DO UPDATE - SET revision = authorization_user_revisions.revision + 1, - updated_at = clock_timestamp(); - $$; - - CREATE OR REPLACE FUNCTION authorization_bump_team_revision(target_team_id UUID) - RETURNS VOID - LANGUAGE SQL - SECURITY DEFINER - SET search_path = pg_catalog, public - AS $$ - INSERT INTO authorization_team_revisions(team_id, revision, updated_at) - SELECT teams.id, 1, clock_timestamp() - FROM teams - WHERE teams.id = target_team_id - ON CONFLICT (team_id) DO UPDATE - SET revision = authorization_team_revisions.revision + 1, - updated_at = clock_timestamp(); - $$; - CREATE OR REPLACE FUNCTION authorization_bump_subject_revision( target_subject_type TEXT, target_subject_id TEXT @@ -304,7 +265,7 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER - SET search_path = pg_catalog, public + SET search_path = pg_catalog, public, pg_temp AS $$ BEGIN IF target_subject_type NOT IN ('user', 'team', 'operator', 'host', 'context') THEN @@ -333,7 +294,7 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER - SET search_path = pg_catalog, public + SET search_path = pg_catalog, public, pg_temp AS $$ BEGIN IF TG_OP <> 'DELETE' THEN @@ -350,7 +311,7 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER - SET search_path = pg_catalog, public + SET search_path = pg_catalog, public, pg_temp AS $$ BEGIN IF TG_OP <> 'DELETE' THEN @@ -367,7 +328,7 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER - SET search_path = pg_catalog, public + SET search_path = pg_catalog, public, pg_temp AS $$ DECLARE row_value RECORD; BEGIN @@ -396,7 +357,7 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER - SET search_path = pg_catalog, public + SET search_path = pg_catalog, public, pg_temp AS $$ BEGIN IF TG_OP <> 'DELETE' THEN @@ -415,7 +376,7 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER - SET search_path = pg_catalog, public + SET search_path = pg_catalog, public, pg_temp AS $$ BEGIN IF TG_OP <> 'DELETE' THEN @@ -434,7 +395,7 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER - SET search_path = pg_catalog, public + SET search_path = pg_catalog, public, pg_temp AS $$ BEGIN IF TG_OP <> 'DELETE' THEN @@ -453,7 +414,7 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER - SET search_path = pg_catalog, public + SET search_path = pg_catalog, public, pg_temp AS $$ DECLARE target_environment_id TEXT; BEGIN @@ -482,7 +443,7 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER - SET search_path = pg_catalog, public + SET search_path = pg_catalog, public, pg_temp AS $$ BEGIN IF TG_OP <> 'DELETE' THEN @@ -507,7 +468,7 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER - SET search_path = pg_catalog, public + SET search_path = pg_catalog, public, pg_temp AS $$ DECLARE subject RECORD; BEGIN @@ -530,7 +491,7 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER - SET search_path = pg_catalog, public + SET search_path = pg_catalog, public, pg_temp AS $$ BEGIN IF TG_OP <> 'DELETE' THEN @@ -546,30 +507,8 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis END; $$; - DROP TRIGGER IF EXISTS users_catalog_revision ON users; - DROP TRIGGER IF EXISTS teams_catalog_revision ON teams; - DROP TRIGGER IF EXISTS team_members_catalog_revision ON team_members; - DROP TRIGGER IF EXISTS user_contexts_catalog_revision ON user_contexts; - DROP TRIGGER IF EXISTS team_contexts_catalog_revision ON team_contexts; - DROP TRIGGER IF EXISTS user_agents_catalog_revision ON user_agents; - DROP TRIGGER IF EXISTS team_agents_catalog_revision ON team_agents; - DROP TRIGGER IF EXISTS user_workflow_triggers_catalog_revision ON user_workflow_triggers; - DROP TRIGGER IF EXISTS team_workflow_triggers_catalog_revision ON team_workflow_triggers; - DROP TRIGGER IF EXISTS workflow_runs_catalog_revision ON workflow_runs; - DROP TRIGGER IF EXISTS workflow_approval_requests_catalog_revision ON workflow_approval_requests; - DROP TRIGGER IF EXISTS notification_deliveries_catalog_revision ON notification_deliveries; - DROP TRIGGER IF EXISTS gfs_resources_catalog_revision ON gfs_resources; - DROP TRIGGER IF EXISTS gfs_grants_catalog_revision ON gfs_grants; - DROP TRIGGER IF EXISTS gfs_shares_catalog_revision ON gfs_shares; - DROP TRIGGER IF EXISTS operational_resource_index_catalog_revision ON operational_resource_index; - DROP TRIGGER IF EXISTS operational_resource_relationships_catalog_revision - ON operational_resource_relationships; - DROP TRIGGER IF EXISTS operational_catalog_source_state_catalog_revision - ON operational_catalog_source_state; - -- CREATE OR REPLACE takes ShareRowExclusiveLock (readers continue); a - -- DROP of an existing trigger would take AccessExclusiveLock. The - -- *_catalog_revision drops above lock nothing when the trigger is absent. + -- DROP of an existing trigger would take AccessExclusiveLock. CREATE OR REPLACE TRIGGER users_authorization_revision AFTER INSERT OR UPDATE OR DELETE ON users FOR EACH ROW EXECUTE FUNCTION authorization_bump_user_row_revision(); @@ -595,9 +534,6 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis AFTER INSERT OR UPDATE OR DELETE ON gfs_resources FOR EACH ROW EXECUTE FUNCTION authorization_bump_gfs_resource_revision(); - DROP FUNCTION IF EXISTS authorization_bump_catalog_revision(); - DROP TABLE IF EXISTS authorization_catalog_revision; - REVOKE ALL ON TABLE authorization_catalog_writer_components FROM PUBLIC; REVOKE ALL ON TABLE authorization_catalog_environment FROM PUBLIC; GRANT SELECT ON TABLE authorization_catalog_writer_components TO control_api_runtime; @@ -637,10 +573,12 @@ async function applyAuthorizationRevisionFunctions(db: DbClient): Promise RETURNS VOID LANGUAGE SQL SECURITY DEFINER - SET search_path = pg_catalog, public + SET search_path = pg_catalog, public, pg_temp AS $$ INSERT INTO authorization_user_revisions(user_id, revision, updated_at) - VALUES(target_user_id, 1, clock_timestamp()) + SELECT users.id, 1, clock_timestamp() + FROM users + WHERE users.id = target_user_id ON CONFLICT (user_id) DO UPDATE SET revision = authorization_user_revisions.revision + 1, updated_at = clock_timestamp(); @@ -650,10 +588,12 @@ async function applyAuthorizationRevisionFunctions(db: DbClient): Promise RETURNS VOID LANGUAGE SQL SECURITY DEFINER - SET search_path = pg_catalog, public + SET search_path = pg_catalog, public, pg_temp AS $$ INSERT INTO authorization_team_revisions(team_id, revision, updated_at) - VALUES(target_team_id, 1, clock_timestamp()) + SELECT teams.id, 1, clock_timestamp() + FROM teams + WHERE teams.id = target_team_id ON CONFLICT (team_id) DO UPDATE SET revision = authorization_team_revisions.revision + 1, updated_at = clock_timestamp(); @@ -667,7 +607,7 @@ async function applyAuthorizationRevisionFunctions(db: DbClient): Promise RETURNS VOID LANGUAGE SQL SECURITY DEFINER - SET search_path = pg_catalog, public + SET search_path = pg_catalog, public, pg_temp AS $$ INSERT INTO authorization_resource_revisions( environment_id, resource_type, resource_id, revision, updated_at @@ -690,26 +630,11 @@ async function applyAuthorizationRevisionFunctions(db: DbClient): Promise async function applyAuthorizationRevisionTriggers(db: DbClient): Promise { await db.query(` - CREATE OR REPLACE FUNCTION authorization_bump_catalog_revision() - RETURNS TRIGGER - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = pg_catalog, public - AS $$ - BEGIN - UPDATE authorization_catalog_revision - SET revision = revision + 1, - updated_at = clock_timestamp() - WHERE singleton = TRUE; - RETURN NULL; - END; - $$; - CREATE OR REPLACE FUNCTION authorization_bump_team_membership_revision() RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER - SET search_path = pg_catalog, public + SET search_path = pg_catalog, public, pg_temp AS $$ BEGIN IF TG_OP <> 'DELETE' THEN @@ -731,7 +656,7 @@ async function applyAuthorizationRevisionTriggers(db: DbClient): Promise { RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER - SET search_path = pg_catalog, public + SET search_path = pg_catalog, public, pg_temp AS $$ BEGIN IF TG_OP <> 'DELETE' THEN @@ -750,7 +675,7 @@ async function applyAuthorizationRevisionTriggers(db: DbClient): Promise { RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER - SET search_path = pg_catalog, public + SET search_path = pg_catalog, public, pg_temp AS $$ BEGIN IF TG_OP <> 'DELETE' THEN @@ -769,7 +694,7 @@ async function applyAuthorizationRevisionTriggers(db: DbClient): Promise { RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER - SET search_path = pg_catalog, public + SET search_path = pg_catalog, public, pg_temp AS $$ BEGIN IF TG_OP <> 'DELETE' THEN @@ -795,7 +720,7 @@ async function applyAuthorizationRevisionTriggers(db: DbClient): Promise { RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER - SET search_path = pg_catalog, public + SET search_path = pg_catalog, public, pg_temp AS $$ BEGIN IF TG_OP <> 'DELETE' THEN @@ -859,11 +784,6 @@ async function applyAuthorizationRevisionTriggers(db: DbClient): Promise { END; $$; - -- No *_catalog_revision statement triggers are installed here: - -- 0133_composable_catalog_revisions replaces the global catalog revision, - -- and dropping an installed trigger there would take AccessExclusiveLock. - - REVOKE ALL ON FUNCTION authorization_bump_catalog_revision() FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_team_membership_revision() FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_user_grant_revision() FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_team_grant_revision() FROM PUBLIC; @@ -905,7 +825,6 @@ async function applyUserAccessRuntimePrivileges(db: DbClient): Promise { authorization_user_revisions, authorization_team_revisions, authorization_resource_revisions, - authorization_catalog_revision, operational_catalog_source_state, operational_resource_index, operational_resource_relationships, @@ -929,7 +848,5 @@ async function applyUserAccessRuntimePrivileges(db: DbClient): Promise { authorization_team_revisions, authorization_resource_revisions TO control_api_runtime; - - GRANT SELECT ON TABLE authorization_catalog_revision TO control_api_runtime; `) } diff --git a/control-api/test/db.composableCatalogRevisionMigration.test.ts b/control-api/test/db.composableCatalogRevisionMigration.test.ts index 5338ef169e..f9190d3b7a 100644 --- a/control-api/test/db.composableCatalogRevisionMigration.test.ts +++ b/control-api/test/db.composableCatalogRevisionMigration.test.ts @@ -4,8 +4,8 @@ vi.mock('../src/config.js', () => ({ config: { databaseUrl: 'postgresql://test' }, })) -describe('0132 composable catalog revisions migration', () => { - it('replaces the singleton trigger class with scoped transactional writers', async () => { +describe('0133 composable catalog revisions migration', () => { + it('installs scoped transactional writers for every catalog writer table', async () => { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( candidate => candidate.version === '0133_composable_catalog_revisions' @@ -38,17 +38,14 @@ describe('0132 composable catalog revisions migration', () => { 'operational_catalog_source_state', ]) { expect(normalizedSql).toContain(`('${table}',`) - expect(normalizedSql).toContain( - `DROP TRIGGER IF EXISTS ${table}_catalog_revision ON ${table}` - ) } expect(sql).toContain('authorization_bump_workflow_run_revision') expect(sql).toContain('authorization_bump_workflow_approval_revision') expect(sql).toContain('authorization_bump_notification_revision') expect(sql).toContain('authorization_bump_gfs_subject_revision') expect(sql).toContain('authorization_bump_gfs_resource_revision') - expect(sql).toContain('DROP TABLE IF EXISTS authorization_catalog_revision') - expect(sql).not.toContain('UPDATE authorization_catalog_revision') + expect(sql).not.toContain('authorization_catalog_revision') + expect(sql).not.toMatch(/\bDROP\s+(?:TRIGGER|TABLE|FUNCTION)\b/i) }) it('replaces row-level revision triggers without dropping them', async () => { diff --git a/control-api/test/db.gfsCatalogRevisionComponentsMigration.test.ts b/control-api/test/db.gfsCatalogRevisionComponentsMigration.test.ts deleted file mode 100644 index 5d56a5724a..0000000000 --- a/control-api/test/db.gfsCatalogRevisionComponentsMigration.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -vi.mock('../src/config.js', () => ({ - config: { databaseUrl: 'postgresql://test' }, -})) - -describe('0133 GFS catalog revision components migration', () => { - it('fixes forward after a database has already recorded the prior composable revision', async () => { - const { CONTROL_API_MIGRATIONS, initDb } = await import('../src/db.js') - const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0134_gfs_catalog_revision_components' - ) - const composableIndex = CONTROL_API_MIGRATIONS.findIndex( - candidate => candidate.version === '0133_composable_catalog_revisions' - ) - const gfsIndex = CONTROL_API_MIGRATIONS.findIndex( - candidate => candidate.version === '0134_gfs_catalog_revision_components' - ) - expect(migration).toBeDefined() - expect(gfsIndex).toBe(composableIndex + 1) - - const recordedVersions = CONTROL_API_MIGRATIONS.filter( - candidate => candidate.version !== migration?.version - ).map(candidate => ({ version: candidate.version })) - const clientQuery = vi.fn(async (sql: string, params?: unknown[]) => { - if (sql.includes('SELECT version FROM schema_migrations')) { - return { rows: recordedVersions, rowCount: recordedVersions.length } - } - return { rows: [], rowCount: params?.length ?? 0 } - }) - const connect = vi.fn(async () => ({ query: clientQuery, release: vi.fn() })) - - await initDb({ connect }) - - const appliedSql = clientQuery.mock.calls.map(([sql]) => String(sql)).join('\n') - expect(appliedSql).toContain('authorization_catalog_environment') - expect(appliedSql).toContain('authorization_bump_gfs_resource_component') - expect(appliedSql).toContain('authorization_bump_gfs_resource_revision') - expect(appliedSql).toContain('INSERT INTO authorization_resource_revisions') - expect(clientQuery).toHaveBeenCalledWith( - expect.stringContaining('INSERT INTO schema_migrations'), - ['0134_gfs_catalog_revision_components'] - ) - }) -}) diff --git a/control-api/test/db.initDb.test.ts b/control-api/test/db.initDb.test.ts index e997577a15..109ffbb650 100644 --- a/control-api/test/db.initDb.test.ts +++ b/control-api/test/db.initDb.test.ts @@ -66,8 +66,9 @@ describe('db.initDb', () => { expect(sqls.some(sql => sql.includes('DROP TABLE IF EXISTS workflow_run_outputs'))).toBe(false) expect(sqls).not.toContain('ROLLBACK') // The migration plan now includes four separately committed parent-dev receipts at 0126-0129. - expect(sqls.filter(sql => sql === 'BEGIN')).toHaveLength(32) - expect(sqls.filter(sql => sql === 'COMMIT')).toHaveLength(32) + // S1e folded three PR1 migrations; PR1 is five versions plus the 0132 function transaction. + expect(sqls.filter(sql => sql === 'BEGIN')).toHaveLength(30) + expect(sqls.filter(sql => sql === 'COMMIT')).toHaveLength(30) expect(sqls[sqls.length - 2]).toBe('COMMIT') expect(sqls[sqls.length - 1]).toContain('SELECT pg_advisory_unlock') expect(clientRelease).toHaveBeenCalledTimes(1) diff --git a/control-api/test/db.migrationsOrder.test.ts b/control-api/test/db.migrationsOrder.test.ts index fc0c9fd210..dbc5e7ae90 100644 --- a/control-api/test/db.migrationsOrder.test.ts +++ b/control-api/test/db.migrationsOrder.test.ts @@ -70,10 +70,7 @@ describe('CONTROL_API_MIGRATIONS ordering invariant', () => { '0131_invitation_delivery_commands', '0132_catalog_utf8_ordering', '0133_composable_catalog_revisions', - '0134_gfs_catalog_revision_components', - '0135_user_access_foundation_definer_temp_shadow_hardening', - '0136_legacy_password_security_epoch_backfill', - '0144_authorization_revision_delete_compatibility', + '0134_legacy_password_security_epoch_backfill', ] expect( @@ -82,9 +79,10 @@ describe('CONTROL_API_MIGRATIONS ordering invariant', () => { expect(versions[versions.indexOf(parentDev.at(-1)!) + 1]).toBe(incomingDev) expect(versions.indexOf(incomingDev)).toBeLessThan(versions.indexOf(task106[0]!)) expect( - versions.slice(versions.indexOf(task106[0]!), versions.indexOf(task106[0]!) + 8) + versions.slice(versions.indexOf(task106[0]!), versions.indexOf(task106[0]!) + 5) ).toEqual(task106) expect(versions.indexOf(parentDev.at(-1)!)).toBeLessThan(versions.indexOf(task106[0]!)) + expect(versions.at(-1)).toBe(task106.at(-1)) }) it('keeps legacy aliases unique and distinct from current versions', async () => { @@ -98,66 +96,6 @@ describe('CONTROL_API_MIGRATIONS ordering invariant', () => { expect(currentCollisions).toEqual([]) }) - it('registers the narrow R56-B1 access-foundation definer hardening migration', async () => { - const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') - const version = '0135_user_access_foundation_definer_temp_shadow_hardening' - const migration = CONTROL_API_MIGRATIONS.find(candidate => candidate.version === version) - const versions = CONTROL_API_MIGRATIONS.map(candidate => candidate.version) - - expect(migration).toBeDefined() - expect(migration?.legacyVersions).toEqual([ - '0134_user_access_foundation_definer_temp_shadow_hardening', - '012b_user_access_foundation_definer_temp_shadow_hardening', - '012a_user_access_foundation_definer_temp_shadow_hardening', - ]) - expect(migration?.preserveLegacyReceipts).toBe(true) - expect(versions.indexOf('0134_gfs_catalog_revision_components')).toBeLessThan( - versions.indexOf(version) - ) - expect(versions.indexOf(version)).toBeLessThan( - versions.indexOf('0136_legacy_password_security_epoch_backfill') - ) - if (!migration) return - - const query = vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }) - await migration.apply({ query } as never) - expect(query).toHaveBeenCalledOnce() - - const sql = query.mock.calls.map(([statement]) => String(statement)).join('\n') - const compactSql = sql.replace(/\s+/g, '') - const signatures = [ - 'authorization_bump_user_revision(pg_catalog.uuid)', - 'authorization_bump_team_revision(pg_catalog.uuid)', - 'authorization_bump_subject_revision(pg_catalog.text,pg_catalog.text)', - 'authorization_bump_user_row_revision()', - 'authorization_bump_team_row_revision()', - 'authorization_bump_workflow_run_revision()', - 'authorization_bump_workflow_approval_revision()', - 'authorization_bump_notification_revision()', - 'authorization_bump_gfs_subject_revision()', - 'authorization_bump_gfs_resource_component(pg_catalog.uuid)', - 'authorization_bump_gfs_authority_revision()', - 'authorization_bump_gfs_resource_subjects(pg_catalog.uuid)', - 'authorization_bump_gfs_resource_revision()', - 'authorization_bump_resource_revision(pg_catalog.text,pg_catalog.text,pg_catalog.text)', - 'authorization_bump_team_membership_revision()', - 'authorization_bump_user_grant_revision()', - 'authorization_bump_team_grant_revision()', - 'authorization_bump_operational_resource_revision()', - 'authorization_bump_operational_relationship_revision()', - ] - - // 0133_composable_catalog_revisions removes this trigger before 0135 runs. - expect(signatures).not.toContain('authorization_bump_catalog_revision()') - expect(sql.match(/ALTER FUNCTION public\./g)).toHaveLength(signatures.length) - for (const signature of signatures) { - expect(compactSql).toContain( - `ALTERFUNCTIONpublic.${signature}SETsearch_path=pg_catalog,public,pg_temp;` - ) - } - expect(sql).not.toMatch(/\b(?:CREATE OR REPLACE|DROP|GRANT|REVOKE|OWNER TO)\b/i) - }) - it('recognizes deployed BUG-192 admission without reapplying its schema', async () => { const { CONTROL_API_MIGRATIONS, initDb, assertDbReady } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( @@ -341,53 +279,4 @@ describe('CONTROL_API_MIGRATIONS ordering invariant', () => { /CREATE INDEX IF NOT EXISTS[\s\S]+?ON mcp_secret_rollback_permits\s*\(\s*expires_at\s*\)/i ) }) - - it('registers the narrow 0129 team-delete compatibility successor', async () => { - const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') - const predecessorIndex = CONTROL_API_MIGRATIONS.findIndex( - candidate => candidate.version === '0130_user_access_foundation' - ) - const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0144_authorization_revision_delete_compatibility' - ) - - expect(predecessorIndex).toBeGreaterThanOrEqual(0) - expect(migration).toBeDefined() - expect(CONTROL_API_MIGRATIONS.at(-1)?.version).toBe( - '0144_authorization_revision_delete_compatibility' - ) - expect(migration?.legacyVersions).toEqual([ - '0143_authorization_revision_delete_compatibility', - '0138_authorization_revision_delete_compatibility', - ]) - if (!migration) return - - const query = vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }) - await migration.apply({ query } as never) - - expect(query).toHaveBeenCalledOnce() - const sql = String(query.mock.calls[0]?.[0]) - for (const functionName of [ - 'authorization_bump_user_revision', - 'authorization_bump_team_revision', - ]) { - expect(sql).toMatch( - new RegExp(`CREATE OR REPLACE FUNCTION public\\.${functionName}\\(target_.* UUID\\)`, 'i') - ) - expect(sql).toMatch( - new RegExp( - `${functionName}[\\s\\S]+?SELECT ${functionName.includes('user') ? 'users' : 'teams'}\\.id[\\s\\S]+FROM ${functionName.includes('user') ? 'users' : 'teams'}`, - 'i' - ) - ) - } - expect(sql).toMatch( - /CREATE OR REPLACE FUNCTION public\.authorization_bump_team_revision\(target_team_id UUID\)/i - ) - expect(sql).toMatch(/SECURITY DEFINER[\s\S]+SET search_path = pg_catalog, public, pg_temp/i) - expect(sql).toMatch(/SELECT teams\.id[\s\S]+FROM teams[\s\S]+WHERE teams\.id = target_team_id/i) - expect(sql).toMatch(/SELECT users\.id[\s\S]+FROM users[\s\S]+WHERE users\.id = target_user_id/i) - expect(sql).not.toMatch(/VALUES\s*\(target_(?:team|user)_id/i) - expect(sql).not.toMatch(/\b(?:GRANT|REVOKE|OWNER TO)\b/i) - }) }) diff --git a/control-api/test/db.realPostgresMigration.integration.test.ts b/control-api/test/db.realPostgresMigration.integration.test.ts index be7aed559c..0db344edb8 100644 --- a/control-api/test/db.realPostgresMigration.integration.test.ts +++ b/control-api/test/db.realPostgresMigration.integration.test.ts @@ -2887,63 +2887,14 @@ describeRealPostgres('control-api real Postgres migrations', () => { }) it('prevents temp-type shadowing in access-foundation SECURITY DEFINER triggers', async () => { - const { initDb } = await import('../src/db.js') - const connector = { connect: () => dbPool.connect() } - const hardeningVersion = '0135_user_access_foundation_definer_temp_shadow_hardening' - - const preUpgradeFunctions = await readAccessFoundationDefinerState(dbPool) - expect(preUpgradeFunctions.rows.map(row => row.function_name)).toEqual( + const definerFunctions = await readAccessFoundationDefinerState(dbPool) + expect(definerFunctions.rows.map(row => row.function_name)).toEqual( [...accessFoundationDefinerNames].sort() ) - for (const { config } of preUpgradeFunctions.rows) { + for (const { config } of definerFunctions.rows) { expect(config).toEqual(['search_path=pg_catalog, public, pg_temp']) } - const originalOwnersAndGrants = preUpgradeFunctions.rows.map( - ({ function_name, owner, acl }) => ({ function_name, owner, acl }) - ) - - // Model an already-migrated pre-fix database: restore the old function - // settings and remove only the new forward-migration marker. - for (const signature of accessFoundationDefinerSignatures) { - await dbPool.query(`ALTER FUNCTION public.${signature} SET search_path = pg_catalog, public`) - } - const hardeningReceipts = [ - hardeningVersion, - '0134_user_access_foundation_definer_temp_shadow_hardening', - '012b_user_access_foundation_definer_temp_shadow_hardening', - '012a_user_access_foundation_definer_temp_shadow_hardening', - ] - const removedMarker = await dbPool.query( - `DELETE FROM schema_migrations - WHERE version = ANY($1::text[]) - RETURNING version`, - [hardeningReceipts] - ) - expect(removedMarker.rows.map(row => row.version).sort()).toEqual([...hardeningReceipts].sort()) - - await initDb(connector) - const migratedMarker = await dbPool.query<{ version: string }>( - `SELECT version FROM schema_migrations WHERE version = $1`, - [hardeningVersion] - ) - expect(migratedMarker.rows).toEqual([{ version: hardeningVersion }]) - - const upgradedFunctions = await readAccessFoundationDefinerState(dbPool) - expect(upgradedFunctions.rows.map(row => row.function_name)).toEqual( - [...accessFoundationDefinerNames].sort() - ) - for (const { config } of upgradedFunctions.rows) { - expect(config).toEqual(['search_path=pg_catalog, public, pg_temp']) - } - expect( - upgradedFunctions.rows.map(({ function_name, owner, acl }) => ({ - function_name, - owner, - acl, - })) - ).toEqual(originalOwnersAndGrants) - const attackerRole = `entity_change_shadow_${randomBytes(5).toString('hex')}` const attackerPassword = randomBytes(24).toString('hex') let attackerPool: Pool | undefined diff --git a/control-api/test/db.userAccessFoundationMigration.test.ts b/control-api/test/db.userAccessFoundationMigration.test.ts index f8b86e9255..737fd038bc 100644 --- a/control-api/test/db.userAccessFoundationMigration.test.ts +++ b/control-api/test/db.userAccessFoundationMigration.test.ts @@ -22,7 +22,7 @@ async function migrationSql(): Promise { async function legacyEpochBackfillSql(): Promise { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0136_legacy_password_security_epoch_backfill' + candidate => candidate.version === '0134_legacy_password_security_epoch_backfill' ) expect(migration).toBeDefined() @@ -32,145 +32,6 @@ async function legacyEpochBackfillSql(): Promise { } describe('user-access foundation migration', () => { - it('keeps current-dev identities and aliases displaced Task 106 identities', async () => { - const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') - const parent = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0125_admin_subscription_rate_limit_namespace' - ) - const passwordAdmission = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0126_bug192_password_admission' - ) - const retention = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0127_password_evaluation_retention' - ) - const ownership = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0128_password_work_ownership' - ) - const workflowSchedule = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0129_workflow_schedules_concurrency_policy' - ) - const foundation = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0130_user_access_foundation' - ) - - expect(parent).toBeDefined() - expect(passwordAdmission?.legacyVersions).toEqual(['0125_bug192_password_admission']) - expect(retention).toBeDefined() - expect(ownership).toBeDefined() - expect(workflowSchedule).toBeDefined() - expect(workflowSchedule?.legacyVersions ?? []).not.toContain('0129_user_access_foundation') - expect(foundation?.legacyVersions).toContain('0126_user_access_foundation') - expect(foundation?.legacyVersions).toContain('0125_user_access_foundation') - expect(foundation?.legacyVersions).toContain('0129_user_access_foundation') - expect(CONTROL_API_MIGRATIONS.indexOf(parent!)).toBeLessThan( - CONTROL_API_MIGRATIONS.indexOf(passwordAdmission!) - ) - expect(CONTROL_API_MIGRATIONS.indexOf(passwordAdmission!)).toBeLessThan( - CONTROL_API_MIGRATIONS.indexOf(retention!) - ) - expect(CONTROL_API_MIGRATIONS.indexOf(retention!)).toBeLessThan( - CONTROL_API_MIGRATIONS.indexOf(ownership!) - ) - expect(CONTROL_API_MIGRATIONS.indexOf(ownership!)).toBeLessThan( - CONTROL_API_MIGRATIONS.indexOf(workflowSchedule!) - ) - expect(CONTROL_API_MIGRATIONS.indexOf(workflowSchedule!)).toBeLessThan( - CONTROL_API_MIGRATIONS.indexOf(foundation!) - ) - }) - - it('carries all prior PR1 migration identities as legacy aliases', async () => { - const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') - const expectedAliases = new Map([ - [ - '0130_user_access_foundation', - [ - '0129_user_access_foundation', - '0126_user_access_foundation', - '0125_user_access_foundation', - '0109_user_access_foundation', - '0107_user_access_foundation', - '0101_user_access_foundation', - ], - ], - [ - '0131_invitation_delivery_commands', - [ - '0130_invitation_delivery_commands', - '0127_invitation_delivery_commands', - '0126_invitation_delivery_commands', - '010a_invitation_delivery_commands', - '0108_invitation_delivery_commands', - '0102_invitation_delivery_commands', - ], - ], - [ - '0132_catalog_utf8_ordering', - [ - '0131_catalog_utf8_ordering', - '0128_catalog_utf8_ordering', - '0127_catalog_utf8_ordering', - '010b_catalog_utf8_ordering', - '0109_catalog_utf8_ordering', - '0103_catalog_utf8_ordering', - ], - ], - [ - '0133_composable_catalog_revisions', - [ - '0132_composable_catalog_revisions', - '0129_composable_catalog_revisions', - '0128_composable_catalog_revisions', - '010c_composable_catalog_revisions', - '010a_composable_catalog_revisions', - '0104_composable_catalog_revisions', - ], - ], - [ - '0134_gfs_catalog_revision_components', - [ - '0133_gfs_catalog_revision_components', - '012a_gfs_catalog_revision_components', - '0129_gfs_catalog_revision_components', - '010d_gfs_catalog_revision_components', - '010b_gfs_catalog_revision_components', - '0105_gfs_catalog_revision_components', - ], - ], - [ - '0135_user_access_foundation_definer_temp_shadow_hardening', - [ - '0134_user_access_foundation_definer_temp_shadow_hardening', - '012b_user_access_foundation_definer_temp_shadow_hardening', - '012a_user_access_foundation_definer_temp_shadow_hardening', - ], - ], - [ - '0136_legacy_password_security_epoch_backfill', - [ - '0135_legacy_password_security_epoch_backfill', - '0130_legacy_password_security_epoch_backfill', - '010e_legacy_password_security_epoch_backfill', - '010c_legacy_password_security_epoch_backfill', - '0106_legacy_password_security_epoch_backfill', - ], - ], - [ - '0144_authorization_revision_delete_compatibility', - [ - '0143_authorization_revision_delete_compatibility', - '0138_authorization_revision_delete_compatibility', - ], - ], - ]) - - for (const [version, legacyVersions] of expectedAliases) { - const migration = CONTROL_API_MIGRATIONS.find(candidate => candidate.version === version) - expect(migration?.legacyVersions).toEqual(legacyVersions) - expect(migration?.preserveLegacyReceipts).toBe(true) - } - }) - it('adds the stateful user session and revision foundations additively', async () => { const sql = await migrationSql() @@ -184,7 +45,7 @@ describe('user-access foundation migration', () => { expect(sql).toContain('CREATE TABLE IF NOT EXISTS authorization_user_revisions') expect(sql).toContain('CREATE TABLE IF NOT EXISTS authorization_team_revisions') expect(sql).toContain('CREATE TABLE IF NOT EXISTS authorization_resource_revisions') - expect(sql).toContain('CREATE TABLE IF NOT EXISTS authorization_catalog_revision') + expect(sql).not.toContain('authorization_catalog_revision') expect(sql).toContain('SECURITY DEFINER') expect(sql).toContain('authorization_bump_team_membership_revision') expect(sql).not.toMatch(/DROP\s+(?:TABLE|COLUMN)/i) @@ -258,7 +119,10 @@ describe('user-access foundation migration', () => { // Witness: the trigger block ran and still installs the row-level triggers. expect(sql).toContain("('team_members', 'team_members_authorization_revision',") - expect(sql).toContain('CREATE OR REPLACE FUNCTION authorization_bump_catalog_revision()') + expect(sql).toContain( + 'CREATE OR REPLACE FUNCTION authorization_bump_team_membership_revision()' + ) + expect(sql).not.toContain('authorization_bump_catalog_revision') expect(sql).not.toMatch(/FOR EACH STATEMENT/i) expect(sql).not.toMatch(/\|\|\s*'_catalog_revision'/) }) @@ -295,7 +159,7 @@ describe('user-access foundation migration', () => { } }) - it('fixes forward historical password security epochs monotonically', async () => { + it('backfills historical password security epochs monotonically', async () => { const sql = await legacyEpochBackfillSql() expect(sql).toContain('external_user_session_security_epochs') @@ -304,43 +168,4 @@ describe('user-access foundation migration', () => { expect(sql).toContain('historical_password_event') expect(sql).not.toMatch(/DELETE\s+FROM\s+external_user_session_security_epochs/i) }) - - it('dispatches the historical epoch fix-forward after already-recorded access migrations', async () => { - const { CONTROL_API_MIGRATIONS, initDb } = await import('../src/db.js') - const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0136_legacy_password_security_epoch_backfill' - ) - expect(migration).toBeDefined() - const gfsIndex = CONTROL_API_MIGRATIONS.findIndex( - candidate => candidate.version === '0134_gfs_catalog_revision_components' - ) - const hardeningIndex = CONTROL_API_MIGRATIONS.findIndex( - candidate => candidate.version === '0135_user_access_foundation_definer_temp_shadow_hardening' - ) - const epochIndex = CONTROL_API_MIGRATIONS.findIndex( - candidate => candidate.version === '0136_legacy_password_security_epoch_backfill' - ) - expect(hardeningIndex).toBe(gfsIndex + 1) - expect(epochIndex).toBe(hardeningIndex + 1) - - const recordedVersions = CONTROL_API_MIGRATIONS.filter( - candidate => candidate.version !== migration?.version - ).map(candidate => ({ version: candidate.version })) - const clientQuery = vi.fn(async (sql: string, params?: unknown[]) => { - if (sql.includes('SELECT version FROM schema_migrations')) { - return { rows: recordedVersions, rowCount: recordedVersions.length } - } - return { rows: [], rowCount: params?.length ?? 0 } - }) - const connect = vi.fn(async () => ({ query: clientQuery, release: vi.fn() })) - - await initDb({ connect }) - - const appliedSql = clientQuery.mock.calls.map(([sql]) => String(sql)).join('\n') - expect(appliedSql).toContain('historical_password_event') - expect(clientQuery).toHaveBeenCalledWith( - expect.stringContaining('INSERT INTO schema_migrations'), - ['0136_legacy_password_security_epoch_backfill'] - ) - }) }) diff --git a/control-api/test/gfsReadiness.realPostgres.integration.test.ts b/control-api/test/gfsReadiness.realPostgres.integration.test.ts index 1e3bae83f8..c1e1f60241 100644 --- a/control-api/test/gfsReadiness.realPostgres.integration.test.ts +++ b/control-api/test/gfsReadiness.realPostgres.integration.test.ts @@ -20,7 +20,10 @@ function quoteIdent(value: string): string { } describeRealPostgres('GFS Phase 0 real PostgreSQL readiness', () => { - const latestMigration = CONTROL_API_MIGRATIONS.at(-1)! + // A fresh initDb records canonical receipts only; aliases come from DEV history. + const aliasedMigration = CONTROL_API_MIGRATIONS.filter( + migration => (migration.legacyVersions?.length ?? 0) > 0 + ).at(-1)! const database = `gfs_readiness_${randomBytes(6).toString('hex')}` const connectionString = databaseUrl( adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', @@ -74,21 +77,17 @@ describeRealPostgres('GFS Phase 0 real PostgreSQL readiness', () => { } }) - it('keeps Control API readiness when the latest migration has an accepted historical receipt', async () => { - const acceptedAliases = latestMigration.legacyVersions ?? [] - expect(acceptedAliases.length).toBeGreaterThan(0) + it('keeps Control API readiness when a migration has only an accepted historical receipt', async () => { + const acceptedAlias = aliasedMigration.legacyVersions![0] const client = await pool.connect() try { await client.query('BEGIN') - await client.query('DELETE FROM schema_migrations WHERE version = $1', [ - latestMigration.version, + const removed = await client.query('DELETE FROM schema_migrations WHERE version = $1', [ + aliasedMigration.version, ]) - const aliasReceipts = await client.query<{ version: string }>( - `SELECT version FROM schema_migrations WHERE version = ANY($1::text[])`, - [acceptedAliases] - ) - expect(aliasReceipts.rows.map(row => row.version)).not.toHaveLength(0) + expect(removed.rowCount).toBe(1) + await client.query('INSERT INTO schema_migrations (version) VALUES ($1)', [acceptedAlias]) await expect(assertDbReady(client)).resolves.toBeUndefined() } finally { await client.query('ROLLBACK').catch(() => undefined) @@ -96,24 +95,18 @@ describeRealPostgres('GFS Phase 0 real PostgreSQL readiness', () => { } }) - it('fails Control API readiness only when the latest migration and every accepted alias are absent', async () => { - const acceptedLatestReceipts = [ - latestMigration.version, - ...(latestMigration.legacyVersions ?? []), - ] + it('fails Control API readiness only when a migration and every accepted alias are absent', async () => { + const acceptedReceipts = [aliasedMigration.version, ...aliasedMigration.legacyVersions!] const client = await pool.connect() try { await client.query('BEGIN') - await client.query(`DELETE FROM schema_migrations WHERE version = ANY($1::text[])`, [ - acceptedLatestReceipts, - ]) - const remainingReceipts = await client.query<{ version: string }>( - `SELECT version FROM schema_migrations WHERE version = ANY($1::text[])`, - [acceptedLatestReceipts] + const removed = await client.query( + `DELETE FROM schema_migrations WHERE version = ANY($1::text[])`, + [acceptedReceipts] ) - expect(remainingReceipts.rows).toHaveLength(0) + expect(removed.rowCount).toBe(1) await expect(assertDbReady(client)).rejects.toMatchObject({ - message: `Control API database is not ready: missing migrations ${latestMigration.version}`, + message: `Control API database is not ready: missing migrations ${aliasedMigration.version}`, }) } finally { await client.query('ROLLBACK').catch(() => undefined) diff --git a/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts b/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts index deba20255d..56ca8e1437 100644 --- a/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts +++ b/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts @@ -23,20 +23,11 @@ function quoteIdentifier(value: string): string { } async function removeLegacyEpochMigrationReceipts(databasePool: Pool): Promise { - await databasePool.query( + const removed = await databasePool.query( `DELETE FROM schema_migrations - WHERE version = ANY($1::text[])`, - [ - [ - '0136_legacy_password_security_epoch_backfill', - '0135_legacy_password_security_epoch_backfill', - '0130_legacy_password_security_epoch_backfill', - '010e_legacy_password_security_epoch_backfill', - '010c_legacy_password_security_epoch_backfill', - '0106_legacy_password_security_epoch_backfill', - ], - ] + WHERE version = '0134_legacy_password_security_epoch_backfill'` ) + if (removed.rowCount !== 1) throw new Error('legacy epoch backfill receipt was not recorded') } function legacyToken(input: { userId: string; email: string; teamId: string; issuedAt: Date }) { @@ -142,7 +133,7 @@ describeRealPostgres('legacy password epoch backfill on real PostgreSQL', () => const recorded = await databasePool.query<{ count: string }>( `SELECT COUNT(*)::text AS count FROM schema_migrations - WHERE version = '0136_legacy_password_security_epoch_backfill'` + WHERE version = '0134_legacy_password_security_epoch_backfill'` ) expect(recorded.rows[0]?.count).toBe('1') }) diff --git a/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts index 9bb6193726..b56ed5fe71 100644 --- a/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts +++ b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts @@ -20,16 +20,6 @@ import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip -const HISTORICAL_TASK106_SOURCE_COMMIT = '2370a399d4221350946462d41427fad8aee0087f' -const HISTORICAL_TASK106_DB_SOURCE_SHA256 = - 'f2e06aba1cfa84a920eedefdb1ec56748efccbacf5baecad7caede4938364379' -const HISTORICAL_TASK106_MIGRATION_RUNNER_SOURCE_SHA256 = - '65023efa1630261ce8d180fc2fa8789e517eb6a13ace2a6458ba95236046748f' -const PUBLISHED_PR1_SOURCE_COMMIT = '113479887ce4fdfd787e9cb1100d74bae596640f' -const PUBLISHED_PR1_DB_SOURCE_SHA256 = - '81a94222ded611d22e03e674ace2aaa07cbcca4989fecb7008507e67e56d203f' -const PUBLISHED_PR1_MIGRATION_RUNNER_SOURCE_SHA256 = - '5effefa5cfc88d9a1edcae73edfe8df749279a7987b03962263bd432bece92e5' const OBSERVED_DEV_SOURCE_COMMIT = '0b26101eb247adcc44f70d39451f3e82c3225d47' const OBSERVED_DEV_DB_SOURCE_SHA256 = '1948ad306fe56b368f120edbc70ddbac6cb9b981d9fd218e7ded2575ed3b2beb' @@ -43,53 +33,14 @@ const LATEST_DEV_MIGRATION_ENTRY_SOURCE_SHA256 = '2b582252753c6455fc85d7cb4563313e3b63ecf7c7630aa6252f70f0bb286a81' const LATEST_DEV_RECEIPT_COUNT = 134 -const DISPLACED_TASK106_RECEIPTS = Object.freeze([ - ['0126_user_access_foundation', '0130_user_access_foundation'], - ['0127_invitation_delivery_commands', '0131_invitation_delivery_commands'], - ['0128_catalog_utf8_ordering', '0132_catalog_utf8_ordering'], - ['0129_composable_catalog_revisions', '0133_composable_catalog_revisions'], - ['012a_gfs_catalog_revision_components', '0134_gfs_catalog_revision_components'], - [ - '012b_user_access_foundation_definer_temp_shadow_hardening', - '0135_user_access_foundation_definer_temp_shadow_hardening', - ], - ['0130_legacy_password_security_epoch_backfill', '0136_legacy_password_security_epoch_backfill'], - [ - '0138_authorization_revision_delete_compatibility', - '0144_authorization_revision_delete_compatibility', - ], -] as const) - -const DISPLACED_PUBLISHED_PR1_RECEIPTS = Object.freeze([ - ['0129_user_access_foundation', '0130_user_access_foundation'], - ['0130_invitation_delivery_commands', '0131_invitation_delivery_commands'], - ['0131_catalog_utf8_ordering', '0132_catalog_utf8_ordering'], - ['0132_composable_catalog_revisions', '0133_composable_catalog_revisions'], - ['0133_gfs_catalog_revision_components', '0134_gfs_catalog_revision_components'], - [ - '0134_user_access_foundation_definer_temp_shadow_hardening', - '0135_user_access_foundation_definer_temp_shadow_hardening', - ], - ['0135_legacy_password_security_epoch_backfill', '0136_legacy_password_security_epoch_backfill'], - [ - '0143_authorization_revision_delete_compatibility', - '0144_authorization_revision_delete_compatibility', - ], -] as const) - -const TASK106_HISTORICAL_SOURCE_DIR = process.env.TASK106_R61_B1_HISTORICAL_SOURCE_DIR -const PUBLISHED_PR1_SOURCE_DIR = process.env.TASK106_R61_B1_PUBLISHED_PR1_SOURCE_DIR const OBSERVED_DEV_SOURCE_DIR = process.env.TASK106_R61_B1_DEV_SOURCE_DIR const LATEST_DEV_SOURCE_DIR = process.env.TASK106_R61_B1_LATEST_DEV_SOURCE_DIR if ( process.env.CONTROL_API_REAL_PG_REQUIRED === '1' && - (!TASK106_HISTORICAL_SOURCE_DIR || - !PUBLISHED_PR1_SOURCE_DIR || - !OBSERVED_DEV_SOURCE_DIR || - !LATEST_DEV_SOURCE_DIR) + (!OBSERVED_DEV_SOURCE_DIR || !LATEST_DEV_SOURCE_DIR) ) { throw new Error( - 'R61-B1 real PostgreSQL lane requires all pinned historical/current producer source directories' + 'R61-B1 real PostgreSQL lane requires both pinned DEV producer source directories' ) } @@ -100,29 +51,6 @@ type HistoricalRunnerPin = { sourceFiles: ReadonlyArray } -const HISTORICAL_TASK106_RUNNER: HistoricalRunnerPin = { - label: 'historical Task 106', - sourceDir: TASK106_HISTORICAL_SOURCE_DIR, - commit: HISTORICAL_TASK106_SOURCE_COMMIT, - sourceFiles: [ - ['control-api/src/db.ts', HISTORICAL_TASK106_DB_SOURCE_SHA256], - [ - 'control-api/src/migrations/migrationRunner.ts', - HISTORICAL_TASK106_MIGRATION_RUNNER_SOURCE_SHA256, - ], - ], -} - -const PUBLISHED_PR1_RUNNER: HistoricalRunnerPin = { - label: 'published PR1 rollback', - sourceDir: PUBLISHED_PR1_SOURCE_DIR, - commit: PUBLISHED_PR1_SOURCE_COMMIT, - sourceFiles: [ - ['control-api/src/db.ts', PUBLISHED_PR1_DB_SOURCE_SHA256], - ['control-api/src/migrations/migrationRunner.ts', PUBLISHED_PR1_MIGRATION_RUNNER_SOURCE_SHA256], - ], -} - const OBSERVED_DEV_RUNNER: HistoricalRunnerPin = { label: 'observed DEV', sourceDir: OBSERVED_DEV_SOURCE_DIR, @@ -313,38 +241,6 @@ async function withMigrationApplyObserver( } } -async function currentMigrationSchemaAndPrivileges(pool: Pool): Promise<{ - objects: Record - privileges: string[] -}> { - const objectNames = [ - 'external_user_sessions', - 'external_v1_session_revocations', - 'authorization_resource_revisions', - 'invitation_delivery_commands', - ] - const objectRows = await pool.query<{ name: string; relation: string | null }>( - `SELECT name, to_regclass(format('public.%I', name))::text AS relation - FROM unnest($1::text[]) AS names(name)`, - [objectNames] - ) - const privilegeRows = await pool.query<{ privilege: string; allowed: boolean }>( - `SELECT relation || ':' || privilege AS privilege, - has_table_privilege('control_api_runtime', relation, privilege) AS allowed - FROM (VALUES - ('schema_migrations'::text, 'SELECT'::text), - ('schema_migrations', 'INSERT'), - ('schema_migrations', 'UPDATE'), - ('schema_migrations', 'DELETE') - ) AS required(relation, privilege)`, - [] - ) - return { - objects: Object.fromEntries(objectRows.rows.map(row => [row.name, row.relation])), - privileges: privilegeRows.rows.map(row => `${row.privilege}:${row.allowed}`).sort(), - } -} - async function passwordMigrationPrivileges(pool: Pool): Promise { const result = await pool.query<{ privilege: string; allowed: boolean }>( `SELECT relation || ':' || privilege AS privilege, @@ -372,21 +268,8 @@ async function versions(pool: Pool): Promise { } async function removeMigrationReceiptForReplay(pool: Pool, version: string): Promise { - const migration = CONTROL_API_MIGRATIONS.find(candidate => candidate.version === version) - const versionsToRemove = new Set([ - version, - ...(migration?.preserveLegacyReceipts ? (migration.legacyVersions ?? []) : []), - ]) - if (version === '0130_user_access_foundation') { - const compatibilityMigration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0144_authorization_revision_delete_compatibility' - ) - versionsToRemove.add('0144_authorization_revision_delete_compatibility') - for (const alias of compatibilityMigration?.legacyVersions ?? []) versionsToRemove.add(alias) - } - await pool.query('DELETE FROM schema_migrations WHERE version = ANY($1::text[])', [ - [...versionsToRemove], - ]) + const removed = await pool.query('DELETE FROM schema_migrations WHERE version = $1', [version]) + if (removed.rowCount !== 1) throw new Error(`${version} receipt was not recorded`) } describeRealPostgres('D34 migration execution on real PostgreSQL', () => { @@ -727,9 +610,12 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { it('bounds ordinary DDL lock acquisition and never runs the later version', async () => { const locker = await databasePool.connect() await removeMigrationReceiptForReplay(databasePool, '0133_composable_catalog_revisions') - await removeMigrationReceiptForReplay(databasePool, '0134_gfs_catalog_revision_components') + await removeMigrationReceiptForReplay( + databasePool, + '0134_legacy_password_security_epoch_backfill' + ) await locker.query('BEGIN') - await locker.query('LOCK TABLE team_members IN ACCESS EXCLUSIVE MODE') + await locker.query('LOCK TABLE users IN ACCESS EXCLUSIVE MODE') const started = Date.now() try { await expect(initDb({ connect: () => databasePool.connect() })).rejects.toMatchObject({ @@ -743,7 +629,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { expect(Date.now() - started).toBeLessThan(15_000) const failedVersions = await versions(databasePool) expect(failedVersions).not.toContain('0133_composable_catalog_revisions') - expect(failedVersions).not.toContain('0134_gfs_catalog_revision_components') + expect(failedVersions).not.toContain('0134_legacy_password_security_epoch_backfill') await initDb({ connect: () => databasePool.connect() }) }, 20_000) @@ -752,7 +638,6 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { const appliedVersions = new Set([ ...DEV_POST_0106_MIGRATION_VERSIONS, ...PR1_MIGRATION_VERSIONS.slice(0, 3), - '0144_authorization_revision_delete_compatibility', ]) const recordTable = `d34_record_${randomBytes(4).toString('hex')}` await client.query(`CREATE TEMP TABLE ${recordTable}(version text PRIMARY KEY)`) @@ -788,227 +673,12 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { `SELECT version FROM ${recordTable} ORDER BY version` ) expect(recorded.rows).toEqual([]) - expect(appliedVersions).not.toContain('0134_gfs_catalog_revision_components') + expect(appliedVersions).not.toContain('0134_legacy_password_security_epoch_backfill') } finally { client.release(true) } }, 25_000) - it('converges accepted legacy aliases without replaying historical bodies', async () => { - const entry = PR1_ONLINE_INDEX_PLAN[0]! - const before = await databasePool.query<{ oid: string }>( - 'SELECT $1::regclass::oid::text AS oid', - [entry.name] - ) - await removeMigrationReceiptForReplay(databasePool, entry.migrationVersion) - await databasePool.query( - `INSERT INTO schema_migrations(version) - VALUES ('0101_user_access_foundation') - ON CONFLICT DO NOTHING` - ) - - await initDb({ connect: () => databasePool.connect() }) - - const after = await databasePool.query<{ oid: string }>( - 'SELECT $1::regclass::oid::text AS oid', - [entry.name] - ) - expect(after.rows[0]?.oid).toBe(before.rows[0]?.oid) - expect(await versions(databasePool)).toContain(entry.migrationVersion) - }) - - it('reconciles real historical Task 106 receipts without replaying migration bodies', async () => { - const databaseName = `r61_historical_task106_${randomBytes(6).toString('hex')}` - const isolatedUrl = databaseUrl(adminUrl!, databaseName) - await adminPool.query(`CREATE DATABASE ${quoteIdentifier(databaseName)}`) - isolatedDatabases.push(databaseName) - const isolatedPool = new Pool({ connectionString: isolatedUrl }) - try { - await runPinnedMigrationProducer(HISTORICAL_TASK106_RUNNER, isolatedUrl) - - const historicalReceipts = await versions(isolatedPool) - const historicalReceiptSet = new Set(historicalReceipts) - for (const [historicalReceipt] of DISPLACED_TASK106_RECEIPTS) { - expect( - historicalReceiptSet.has(historicalReceipt), - `${historicalReceipt} producer receipt` - ).toBe(true) - } - for (const [, currentVersion] of DISPLACED_TASK106_RECEIPTS) { - expect( - historicalReceiptSet.has(currentVersion), - `${currentVersion} not in old producer` - ).toBe(false) - } - - const schemaAndPrivilegesBefore = await currentMigrationSchemaAndPrivileges(isolatedPool) - const observedVersions = new Set([ - ...PR1_MIGRATION_VERSIONS, - '0126_bug192_password_admission', - '0127_password_evaluation_retention', - '0128_password_work_ownership', - ]) - const currentRun = await withMigrationApplyObserver(observedVersions, async () => - initDb({ connect: () => isolatedPool.connect() }) - ) - - const receiptsAfterReconciliation = await versions(isolatedPool) - const reconciledSet = new Set(receiptsAfterReconciliation) - for (const [historicalReceipt, currentVersion] of DISPLACED_TASK106_RECEIPTS) { - const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === currentVersion - ) - expect(migration?.legacyVersions, `${historicalReceipt} alias owner`).toContain( - historicalReceipt - ) - expect(reconciledSet.has(historicalReceipt), `${historicalReceipt} preserved`).toBe(true) - expect(reconciledSet.has(currentVersion), `${currentVersion} canonical receipt`).toBe(true) - expect( - currentRun.applyCounts.get(currentVersion) ?? 0, - `${currentVersion} body replay` - ).toBe(0) - } - for (const version of [ - '0126_bug192_password_admission', - '0127_password_evaluation_retention', - '0128_password_work_ownership', - ]) { - expect(currentRun.applyCounts.get(version), `${version} applies to old Task 106 DB`).toBe(1) - expect(reconciledSet.has(version), `${version} receipt`).toBe(true) - } - - await assertDbReady(isolatedPool) - const schemaAndPrivilegesAfter = await currentMigrationSchemaAndPrivileges(isolatedPool) - expect(schemaAndPrivilegesAfter.objects).toEqual(schemaAndPrivilegesBefore.objects) - expect(schemaAndPrivilegesAfter.privileges).toEqual(schemaAndPrivilegesBefore.privileges) - expect(Object.values(schemaAndPrivilegesAfter.objects).every(Boolean)).toBe(true) - expect(await passwordMigrationPrivileges(isolatedPool)).toEqual([ - 'password_identifier_state:DELETE:true', - 'password_identifier_state:INSERT:true', - 'password_identifier_state:SELECT:true', - 'password_identifier_state:UPDATE:true', - 'password_verification_work:DELETE:true', - 'password_verification_work:INSERT:true', - 'password_verification_work:SELECT:true', - 'password_verification_work:UPDATE:false', - ]) - - const afterFirstRerun = await versions(isolatedPool) - const beforeHistoricalRollback = await versions(isolatedPool) - await runPinnedMigrationProducer(HISTORICAL_TASK106_RUNNER, isolatedUrl) - expect(await versions(isolatedPool)).toEqual(beforeHistoricalRollback) - expect(await currentMigrationSchemaAndPrivileges(isolatedPool)).toEqual( - schemaAndPrivilegesAfter - ) - expect(await passwordMigrationPrivileges(isolatedPool)).toEqual([ - 'password_identifier_state:DELETE:true', - 'password_identifier_state:INSERT:true', - 'password_identifier_state:SELECT:true', - 'password_identifier_state:UPDATE:true', - 'password_verification_work:DELETE:true', - 'password_verification_work:INSERT:true', - 'password_verification_work:SELECT:true', - 'password_verification_work:UPDATE:false', - ]) - const secondRun = await withMigrationApplyObserver(observedVersions, async () => - initDb({ connect: () => isolatedPool.connect() }) - ) - expect(await versions(isolatedPool)).toEqual(afterFirstRerun) - expect([...secondRun.applyCounts.values()]).toEqual([]) - await assertDbReady(isolatedPool) - } finally { - await endPoolAndWaitForClients(isolatedPool) - } - }) - - it('reconciles the published PR1 migration identities as same-body aliases', async () => { - const databaseName = `r61_published_pr1_${randomBytes(6).toString('hex')}` - const isolatedUrl = databaseUrl(adminUrl!, databaseName) - await adminPool.query(`CREATE DATABASE ${quoteIdentifier(databaseName)}`) - isolatedDatabases.push(databaseName) - const isolatedPool = new Pool({ connectionString: isolatedUrl }) - try { - await runPinnedMigrationProducer(PUBLISHED_PR1_RUNNER, isolatedUrl) - - const publishedReceipts = await versions(isolatedPool) - for (const [publishedReceipt, currentVersion] of DISPLACED_PUBLISHED_PR1_RECEIPTS) { - expect(publishedReceipts, `${publishedReceipt} published producer receipt`).toContain( - publishedReceipt - ) - expect(publishedReceipts, `${currentVersion} not in published producer`).not.toContain( - currentVersion - ) - } - const beforeSchemaAndPrivileges = await currentMigrationSchemaAndPrivileges(isolatedPool) - const observedVersions = new Set([ - ...PR1_MIGRATION_VERSIONS, - ...DEV_POST_0106_MIGRATION_VERSIONS, - ]) - const currentRun = await withMigrationApplyObserver(observedVersions, async () => - initDb({ connect: () => isolatedPool.connect() }) - ) - const reconciledReceipts = await versions(isolatedPool) - for (const [publishedReceipt, currentVersion] of DISPLACED_PUBLISHED_PR1_RECEIPTS) { - const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === currentVersion - ) - expect(migration?.legacyVersions, `${publishedReceipt} alias owner`).toContain( - publishedReceipt - ) - expect(reconciledReceipts, `${publishedReceipt} preserved`).toContain(publishedReceipt) - expect(reconciledReceipts, `${currentVersion} canonical receipt`).toContain(currentVersion) - expect( - currentRun.applyCounts.get(currentVersion) ?? 0, - `${currentVersion} body replay` - ).toBe(0) - } - for (const version of PR1_MIGRATION_VERSIONS) { - const migration = CONTROL_API_MIGRATIONS.find(candidate => candidate.version === version) - expect(migration?.preserveLegacyReceipts, `${version} preserves rollback aliases`).toBe( - true - ) - for (const alias of migration?.legacyVersions ?? []) { - expect(reconciledReceipts, `${version} rollback alias ${alias}`).toContain(alias) - } - } - for (const version of [ - '0126_bug192_password_admission', - '0127_password_evaluation_retention', - '0128_password_work_ownership', - ]) { - expect(currentRun.applyCounts.get(version) ?? 0, `${version} body replay`).toBe(0) - } - expect( - currentRun.applyCounts.get('0129_workflow_schedules_concurrency_policy'), - 'latest DEV migration applies exactly once to the published PR1 state' - ).toBe(1) - await assertDbReady(isolatedPool) - const afterSchemaAndPrivileges = await currentMigrationSchemaAndPrivileges(isolatedPool) - expect(afterSchemaAndPrivileges).toEqual(beforeSchemaAndPrivileges) - expect( - await isolatedPool.query(`SELECT to_regclass('public.workflow_schedules') AS relation`) - ).toMatchObject({ rows: [{ relation: 'workflow_schedules' }] }) - - const beforeRollbackRunner = await versions(isolatedPool) - await runPinnedMigrationProducer(PUBLISHED_PR1_RUNNER, isolatedUrl) - expect(await versions(isolatedPool)).toEqual(beforeRollbackRunner) - expect(await currentMigrationSchemaAndPrivileges(isolatedPool)).toEqual( - afterSchemaAndPrivileges - ) - await assertDbReady(isolatedPool) - - const afterUpgrade = await versions(isolatedPool) - const replay = await withMigrationApplyObserver(observedVersions, async () => - initDb({ connect: () => isolatedPool.connect() }) - ) - expect(await versions(isolatedPool)).toEqual(afterUpgrade) - expect([...replay.applyCounts.values()]).toEqual([]) - await assertDbReady(isolatedPool) - } finally { - await endPoolAndWaitForClients(isolatedPool) - } - }) - it('installs the exact fetched DEV graph and upgrades it without replay', async () => { const databaseName = `r61_latest_dev_${randomBytes(6).toString('hex')}` const isolatedUrl = databaseUrl(adminUrl!, databaseName) @@ -1024,23 +694,8 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { expect(latestDevReceipts, `${version} latest DEV producer receipt`).toContain(version) } expect(latestDevReceipts).toContain('0129_workflow_schedules_concurrency_policy') - expect( - DISPLACED_TASK106_RECEIPTS.some(([historicalReceipt]) => - latestDevReceipts.includes(historicalReceipt) - ) - ).toBe(false) - expect( - DISPLACED_PUBLISHED_PR1_RECEIPTS.some(([publishedReceipt]) => - latestDevReceipts.includes(publishedReceipt) - ) - ).toBe(false) const beforeUpgrade = new Set(latestDevReceipts) - const task106Aliases = PR1_MIGRATION_VERSIONS.flatMap( - version => - CONTROL_API_MIGRATIONS.find(candidate => candidate.version === version)?.legacyVersions ?? - [] - ) const observedVersions = new Set([ ...PR1_MIGRATION_VERSIONS, ...DEV_POST_0106_MIGRATION_VERSIONS, @@ -1050,20 +705,16 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { ) const upgradedReceipts = await versions(isolatedPool) expect(upgradedReceipts).toHaveLength( - LATEST_DEV_RECEIPT_COUNT + PR1_MIGRATION_VERSIONS.length + task106Aliases.length + LATEST_DEV_RECEIPT_COUNT + PR1_MIGRATION_VERSIONS.length ) expect(upgradedReceipts.filter(version => !beforeUpgrade.has(version))).toEqual( - [...PR1_MIGRATION_VERSIONS, ...task106Aliases].sort() + [...PR1_MIGRATION_VERSIONS].sort() ) expect(upgradedReceipts.filter(version => beforeUpgrade.has(version))).toEqual( latestDevReceipts ) for (const version of PR1_MIGRATION_VERSIONS) { expect(upgrade.applyCounts.get(version), `${version} Task 106 execution`).toBe(1) - const migration = CONTROL_API_MIGRATIONS.find(candidate => candidate.version === version) - for (const alias of migration?.legacyVersions ?? []) { - expect(upgradedReceipts, `${version} rollback alias ${alias}`).toContain(alias) - } } expect( upgrade.applyCounts.get('0129_workflow_schedules_concurrency_policy') ?? 0, @@ -1073,13 +724,12 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { expect(upgrade.applyOrder.indexOf('0129_workflow_schedules_concurrency_policy')).toBeLessThan( foundationIndex ) - expect(upgrade.applyOrder[foundationIndex + 1]).toBe( - '0144_authorization_revision_delete_compatibility' - ) + expect(upgrade.applyOrder[foundationIndex + 1]).toBe('0131_invitation_delivery_commands') await assertDbReady(isolatedPool) + // Rollback: the DEV image's migrator runs against the upgraded database. const beforeRollbackRunner = await versions(isolatedPool) - await runPinnedMigrationProducer(PUBLISHED_PR1_RUNNER, isolatedUrl) + await runPinnedMigrationProducer(LATEST_DEV_RUNNER, isolatedUrl) expect(await versions(isolatedPool)).toEqual(beforeRollbackRunner) await assertDbReady(isolatedPool) @@ -1114,18 +764,8 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { ]) { expect(observedDevReceipts).toContain(version) } - expect( - DISPLACED_TASK106_RECEIPTS.some(([historicalReceipt]) => - observedDevReceipts.includes(historicalReceipt) - ) - ).toBe(false) const observedDevSet = new Set(observedDevReceipts) - const task106Aliases = PR1_MIGRATION_VERSIONS.flatMap( - version => - CONTROL_API_MIGRATIONS.find(candidate => candidate.version === version)?.legacyVersions ?? - [] - ) const firstRun = await withMigrationApplyObserver( new Set([ ...PR1_MIGRATION_VERSIONS, @@ -1139,15 +779,10 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { const upgradedReceipts = await versions(isolatedPool) const upgradedSet = new Set(upgradedReceipts) expect(upgradedReceipts).toHaveLength( - OBSERVED_DEV_RECEIPT_COUNT + - PR1_MIGRATION_VERSIONS.length + - 1 + - task106Aliases.filter(version => !observedDevSet.has(version)).length + OBSERVED_DEV_RECEIPT_COUNT + PR1_MIGRATION_VERSIONS.length + 1 ) expect(upgradedReceipts.filter(version => !observedDevSet.has(version))).toEqual( - [...PR1_MIGRATION_VERSIONS, '0129_workflow_schedules_concurrency_policy', ...task106Aliases] - .filter(version => !observedDevSet.has(version)) - .sort() + [...PR1_MIGRATION_VERSIONS, '0129_workflow_schedules_concurrency_policy'].sort() ) expect(upgradedReceipts.filter(version => observedDevSet.has(version))).toEqual( observedDevReceipts @@ -1171,9 +806,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { expect( firstRun.applyOrder.indexOf('0129_workflow_schedules_concurrency_policy') ).toBeLessThan(foundationIndex) - expect(firstRun.applyOrder[foundationIndex + 1]).toBe( - '0144_authorization_revision_delete_compatibility' - ) + expect(firstRun.applyOrder[foundationIndex + 1]).toBe('0131_invitation_delivery_commands') await assertDbReady(isolatedPool) const afterUpgrade = await versions(isolatedPool) diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index d358710113..849a0e8ae7 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -31,18 +31,6 @@ const FRESH_TABLE_INDEXES = Object.freeze([ ]) const USER_ACCESS_FOUNDATION_VERSION = '0130_user_access_foundation' -const AUTHORIZATION_REVISION_COMPATIBILITY_VERSION = - '0144_authorization_revision_delete_compatibility' - -function expectedMigrationExecutionOrder(versions: readonly string[]): string[] { - const withoutCompatibility = versions.filter( - version => version !== AUTHORIZATION_REVISION_COMPATIBILITY_VERSION - ) - const foundationIndex = withoutCompatibility.indexOf(USER_ACCESS_FOUNDATION_VERSION) - expect(foundationIndex).toBeGreaterThanOrEqual(0) - withoutCompatibility.splice(foundationIndex + 1, 0, AUTHORIZATION_REVISION_COMPATIBILITY_VERSION) - return withoutCompatibility -} describe('D34 migration execution policy', () => { it('classifies current-dev migrations before the re-slotted PR1 migrations', () => { @@ -64,10 +52,7 @@ describe('D34 migration execution policy', () => { '0131_invitation_delivery_commands', '0132_catalog_utf8_ordering', '0133_composable_catalog_revisions', - '0134_gfs_catalog_revision_components', - '0135_user_access_foundation_definer_temp_shadow_hardening', - '0136_legacy_password_security_epoch_backfill', - '0144_authorization_revision_delete_compatibility', + '0134_legacy_password_security_epoch_backfill', ]) expect(PR1_MIGRATION_VERSIONS).not.toContain('0117_control_admin_invitation_replace_inviter') expect(PR1_MIGRATION_VERSIONS).not.toContain('0118_control_admin_replace_inviter_accept_guard') @@ -108,10 +93,12 @@ describe('D34 migration execution policy', () => { // S1d changed the PR1 bodies before any shared environment applied them: // statement-level catalog triggers are no longer installed by 0130, 0133/0134 // replace row triggers in place, and planned indexes left the transactions. + // S1e folded 0134, 0135 and 0144 into 0130/0133 and dropped the + // authorization_catalog_revision table and function. const files = [ [ 'src/services/access/userAccessFoundationSchema.ts', - 'bd1c6945cdc02fab9e4555e3c2c2714632bf6ed77a006fd1f321e8df35a59131', + 'eeb09ac027c7a893ea99a53141f097bba1d76fd4694342f56c20515a3a8b2020', ], [ 'src/services/directory/invitationDeliverySchema.ts', @@ -124,7 +111,7 @@ describe('D34 migration execution policy', () => { } }) - it('classifies every immutable PR1 index exactly once', async () => { + it('classifies every PR1 index exactly once', async () => { const historicalSql = await Promise.all([ readFile( new URL('../src/services/access/userAccessFoundationSchema.ts', import.meta.url), @@ -234,93 +221,50 @@ describe('D34 migration execution policy', () => { }) describe('D34 PR1 migration runner', () => { - it('records approved relocated aliases in the same transaction as each canonical receipt', async () => { + it('records an accepted alias receipt in its own transaction without replaying the body', async () => { const transactions: string[] = [] - const applied: string[] = [] - const indexStates = new Map>() let transaction = 0 + const aliasedVersion = '0116_mcp_secret_rollback_permits' + const alias = '0109_mcp_secret_rollback_permits' const migrations = [ ...DEV_POST_0106_MIGRATION_VERSIONS.map(version => ({ version, + ...(version === aliasedVersion ? { legacyVersions: [alias] } : {}), apply: vi.fn(async () => undefined), })), - ...PR1_MIGRATION_VERSIONS.map(version => ({ - version, - legacyVersions: [`historical_${version}`], - preserveLegacyReceipts: true, - apply: vi.fn(async () => undefined), - })), + ...PR1_MIGRATION_VERSIONS.map(version => ({ version, apply: vi.fn(async () => undefined) })), ] + const appliedVersions = new Set([ + ...DEV_POST_0106_MIGRATION_VERSIONS.filter(version => version !== aliasedVersion), + ...PR1_MIGRATION_VERSIONS, + alias, + ]) + const recorded: string[] = [] await applyPendingPr1Migrations({ db: { - query: vi.fn(async (sql: string, values?: unknown[]) => { + query: vi.fn(async (sql: string) => { if (sql === 'BEGIN') { transaction += 1 transactions.push(`begin:${transaction}`) } if (sql === 'COMMIT') transactions.push(`commit:${transaction}`) if (sql === 'ROLLBACK') transactions.push(`rollback:${transaction}`) - if (sql.includes('FROM pg_class index_rel')) { - const state = indexStates.get(String(values?.[0])) - return { rows: state ? [state] : [], rowCount: state ? 1 : 0 } - } - if (sql.startsWith('CREATE INDEX CONCURRENTLY')) { - const entry = PR1_ONLINE_INDEX_PLAN.find(index => sql === index.createSql) - if (entry) { - indexStates.set(entry.name, { - table_name: entry.table, - indisunique: Boolean(entry.unique), - indisvalid: true, - definition: sql, - }) - } - } return { rows: [], rowCount: 0 } }), }, migrations, - appliedVersions: new Set(), + appliedVersions, recordMigration: async (_db, version) => { - applied.push(version) + recorded.push(version) transactions.push(`receipt:${transaction}:${version}`) }, }) - const taskVersions = [...PR1_MIGRATION_VERSIONS] - const compatibilityIndex = taskVersions.indexOf(AUTHORIZATION_REVISION_COMPATIBILITY_VERSION) - const compatibilityVersion = taskVersions.splice(compatibilityIndex, 1)[0]! - const foundationIndex = taskVersions.indexOf(USER_ACCESS_FOUNDATION_VERSION) - const afterFoundation = taskVersions.splice(foundationIndex + 1) - const expected = [ - ...DEV_POST_0106_MIGRATION_VERSIONS, - USER_ACCESS_FOUNDATION_VERSION, - `historical_${USER_ACCESS_FOUNDATION_VERSION}`, - compatibilityVersion, - `historical_${compatibilityVersion}`, - ...taskVersions - .slice(foundationIndex + 1) - .flatMap(version => [version, `historical_${version}`]), - ...afterFoundation.flatMap(version => [version, `historical_${version}`]), - ] - expect(applied).toEqual(expected) - expect(transactions.filter(event => event.startsWith('rollback:'))).toEqual([]) - const transactionForReceipt = (version: string) => - transactions.find(event => event.endsWith(`:${version}`))?.split(':')[1] - for (const version of PR1_MIGRATION_VERSIONS) { - expect(transactionForReceipt(`historical_${version}`), `${version} alias transaction`).toBe( - transactionForReceipt(version) - ) - } - expect( - transactionForReceipt(`historical_${AUTHORIZATION_REVISION_COMPATIBILITY_VERSION}`), - 'compatibility alias is recorded with the foundation transaction' - ).toBe(transactionForReceipt(USER_ACCESS_FOUNDATION_VERSION)) - expect(transactions.filter(event => event.startsWith('begin:'))).toHaveLength( - DEV_POST_0106_MIGRATION_VERSIONS.length + PR1_MIGRATION_VERSIONS.length - ) - expect(transactions.filter(event => event.startsWith('commit:'))).toHaveLength( - DEV_POST_0106_MIGRATION_VERSIONS.length + PR1_MIGRATION_VERSIONS.length - ) + expect(recorded).toEqual([aliasedVersion]) + expect(transactions).toEqual(['begin:1', `receipt:1:${aliasedVersion}`, 'commit:1']) + for (const migration of migrations) + expect(migration.apply, migration.version).not.toHaveBeenCalled() + expect(appliedVersions).toContain(aliasedVersion) }) it('commits and records each PR1 version independently in order', async () => { @@ -377,18 +321,12 @@ describe('D34 PR1 migration runner', () => { }, }) - expect(applied).toEqual( - expectedMigrationExecutionOrder([ - ...DEV_POST_0106_MIGRATION_VERSIONS, - ...PR1_MIGRATION_VERSIONS, - ]) - ) - expect(queries.filter(({ sql }) => sql === 'BEGIN')).toHaveLength( - DEV_POST_0106_MIGRATION_VERSIONS.length + PR1_MIGRATION_VERSIONS.length - ) - expect(queries.filter(({ sql }) => sql === 'COMMIT')).toHaveLength( - DEV_POST_0106_MIGRATION_VERSIONS.length + PR1_MIGRATION_VERSIONS.length - ) + expect(applied).toEqual([...DEV_POST_0106_MIGRATION_VERSIONS, ...PR1_MIGRATION_VERSIONS]) + // One transaction per version, plus the catalog_utf8_bytes function that + // 0132 creates and commits before building its online indexes. + const transactions = DEV_POST_0106_MIGRATION_VERSIONS.length + PR1_MIGRATION_VERSIONS.length + 1 + expect(queries.filter(({ sql }) => sql === 'BEGIN')).toHaveLength(transactions) + expect(queries.filter(({ sql }) => sql === 'COMMIT')).toHaveLength(transactions) expect(queries.filter(({ sql }) => sql === 'ROLLBACK')).toHaveLength(0) }) @@ -445,10 +383,7 @@ describe('D34 PR1 migration runner', () => { ...PR1_MIGRATION_VERSIONS.map(version => ({ version, ...(version === USER_ACCESS_FOUNDATION_VERSION - ? { - legacyVersions: ['0129_workflow_schedules_concurrency_policy'], - preserveLegacyReceipts: true, - } + ? { legacyVersions: ['0129_workflow_schedules_concurrency_policy'] } : {}), apply: vi.fn(), })), @@ -511,305 +446,10 @@ describe('D34 PR1 migration runner', () => { }, }) - const expectedOrder = expectedMigrationExecutionOrder([ - ...DEV_POST_0106_MIGRATION_VERSIONS, - ...PR1_MIGRATION_VERSIONS, - ]) + const expectedOrder = [...DEV_POST_0106_MIGRATION_VERSIONS, ...PR1_MIGRATION_VERSIONS] expect(applyOrder).toEqual(expectedOrder) expect(recorded).toEqual(expectedOrder) }) - - it('commits 0129 and its deletion-compatible successor as one migration phase', async () => { - const events: string[] = [] - const transactionEvents: string[] = [] - let activeTransaction = 0 - let nextTransaction = 0 - const migrations = [ - ...DEV_POST_0106_MIGRATION_VERSIONS.map(version => ({ - version, - apply: vi.fn(async () => { - events.push(version) - transactionEvents.push(`${version}:apply:${activeTransaction}`) - }), - })), - ...PR1_MIGRATION_VERSIONS.map(version => ({ - version, - apply: vi.fn(async () => { - events.push(version) - transactionEvents.push(`${version}:apply:${activeTransaction}`) - }), - })), - ] - const db = { - query: vi.fn(async (sql: string, values?: unknown[]) => { - if (sql === 'BEGIN') { - activeTransaction = ++nextTransaction - } else if (sql === 'COMMIT' || sql === 'ROLLBACK') { - activeTransaction = 0 - } - if (sql.includes('FROM pg_class index_rel')) { - const entry = PR1_ONLINE_INDEX_PLAN.find(index => index.name === values?.[0]) - return { - rows: entry - ? [ - { - table_name: entry.table, - indisunique: Boolean(entry.unique), - indisvalid: true, - definition: entry.createSql, - }, - ] - : [], - rowCount: entry ? 1 : 0, - } - } - return { rows: [], rowCount: 0 } - }), - } - const applied = [...DEV_POST_0106_MIGRATION_VERSIONS] - const expectedOrder = expectedMigrationExecutionOrder(PR1_MIGRATION_VERSIONS) - - await applyPendingPr1Migrations({ - db, - migrations, - appliedVersions: new Set(applied), - recordMigration: async (_db, version) => { - events.push(`receipt:${version}`) - transactionEvents.push(`${version}:receipt:${activeTransaction}`) - }, - }) - - expect(events.filter(event => !event.startsWith('receipt:'))).toEqual(expectedOrder) - const parentIndex = events.indexOf(USER_ACCESS_FOUNDATION_VERSION) - const parentReceiptIndex = events.indexOf(`receipt:${USER_ACCESS_FOUNDATION_VERSION}`) - const compatibilityIndex = events.indexOf(AUTHORIZATION_REVISION_COMPATIBILITY_VERSION) - const compatibilityReceiptIndex = events.indexOf( - `receipt:${AUTHORIZATION_REVISION_COMPATIBILITY_VERSION}` - ) - expect(parentIndex).toBeLessThan(parentReceiptIndex) - expect(parentReceiptIndex).toBeLessThan(compatibilityIndex) - expect(compatibilityIndex).toBeLessThan(compatibilityReceiptIndex) - const phaseTransactionIds = [ - `${USER_ACCESS_FOUNDATION_VERSION}:apply`, - `${USER_ACCESS_FOUNDATION_VERSION}:receipt`, - `${AUTHORIZATION_REVISION_COMPATIBILITY_VERSION}:apply`, - `${AUTHORIZATION_REVISION_COMPATIBILITY_VERSION}:receipt`, - ].map(prefix => { - const event = transactionEvents.find(candidate => candidate.startsWith(`${prefix}:`)) - return event?.split(':').at(-1) - }) - expect(phaseTransactionIds).toHaveLength(4) - expect(new Set(phaseTransactionIds).size).toBe(1) - expect(phaseTransactionIds[0]).not.toBe('0') - expect(db.query.mock.calls.filter(([sql]) => sql === 'BEGIN')).toHaveLength( - PR1_MIGRATION_VERSIONS.length - ) - expect(db.query.mock.calls.filter(([sql]) => sql === 'COMMIT')).toHaveLength( - PR1_MIGRATION_VERSIONS.length - ) - }) - - it('repairs an applied 0129 prefix before preparing 0131 indexes', async () => { - const events: string[] = [] - const migrations = [ - ...DEV_POST_0106_MIGRATION_VERSIONS.map(version => ({ - version, - apply: vi.fn(async () => { - events.push(version) - }), - })), - ...PR1_MIGRATION_VERSIONS.map(version => ({ - version, - apply: vi.fn(async () => { - events.push(version) - }), - })), - ] - const db = { - query: vi.fn(async (sql: string, values?: unknown[]) => { - if (sql === 'BEGIN' || sql === 'COMMIT' || sql === 'ROLLBACK') { - events.push(sql) - } - if (sql.includes('FROM pg_class index_rel')) { - events.push('prepare:0132') - const entry = PR1_ONLINE_INDEX_PLAN.find(index => index.name === values?.[0]) - return { - rows: entry - ? [ - { - table_name: entry.table, - indisunique: Boolean(entry.unique), - indisvalid: true, - definition: entry.createSql, - }, - ] - : [], - rowCount: entry ? 1 : 0, - } - } - return { rows: [], rowCount: 0 } - }), - } - const appliedVersions = new Set([ - ...DEV_POST_0106_MIGRATION_VERSIONS, - USER_ACCESS_FOUNDATION_VERSION, - '0127_invitation_delivery_commands', - ...PR1_MIGRATION_VERSIONS.filter( - version => - version !== USER_ACCESS_FOUNDATION_VERSION && - version !== '0131_invitation_delivery_commands' && - version !== '0132_catalog_utf8_ordering' && - version !== AUTHORIZATION_REVISION_COMPATIBILITY_VERSION - ), - ]) - - await applyPendingPr1Migrations({ - db, - migrations, - appliedVersions, - recordMigration: async (_db, version) => { - events.push(`receipt:${version}`) - }, - }) - - expect(events.indexOf(AUTHORIZATION_REVISION_COMPATIBILITY_VERSION)).toBeLessThan( - events.indexOf('prepare:0132') - ) - expect(events.indexOf(`receipt:${AUTHORIZATION_REVISION_COMPATIBILITY_VERSION}`)).toBeLessThan( - events.indexOf('prepare:0132') - ) - expect(events.filter(event => event === 'BEGIN')).toHaveLength(4) - expect(events.filter(event => event === 'COMMIT')).toHaveLength(4) - const repairCommit = events.indexOf('COMMIT') - expect(repairCommit).toBeLessThan(events.indexOf('prepare:0132')) - }) - - it('preserves the prior foundation alias when its 0144 successor is already recorded', async () => { - const foundation = { - version: USER_ACCESS_FOUNDATION_VERSION, - legacyVersions: ['0109_user_access_foundation'], - apply: vi.fn(async () => undefined), - } - const compatibility = { - version: AUTHORIZATION_REVISION_COMPATIBILITY_VERSION, - apply: vi.fn(async () => undefined), - } - const migrations = [ - ...DEV_POST_0106_MIGRATION_VERSIONS.map(version => ({ - version, - apply: vi.fn(async () => undefined), - })), - ...PR1_MIGRATION_VERSIONS.filter( - version => - version !== USER_ACCESS_FOUNDATION_VERSION && - version !== AUTHORIZATION_REVISION_COMPATIBILITY_VERSION - ).map(version => ({ version, apply: vi.fn(async () => undefined) })), - foundation, - compatibility, - ] - const appliedVersions = new Set([ - ...DEV_POST_0106_MIGRATION_VERSIONS, - ...PR1_MIGRATION_VERSIONS.filter(version => version !== USER_ACCESS_FOUNDATION_VERSION), - '0109_user_access_foundation', - ]) - const recorded: string[] = [] - - await applyPendingPr1Migrations({ - db: { query: vi.fn(async () => ({ rows: [], rowCount: 0 })) }, - migrations, - appliedVersions, - recordMigration: async (_db, version) => { - recorded.push(version) - }, - }) - - expect(recorded).toEqual([USER_ACCESS_FOUNDATION_VERSION]) - expect(foundation.apply).not.toHaveBeenCalled() - expect(compatibility.apply).not.toHaveBeenCalled() - expect(appliedVersions).toContain(USER_ACCESS_FOUNDATION_VERSION) - }) - - it('reconciles displaced foundation and compatibility receipts without replaying either body', async () => { - const foundation = { - version: USER_ACCESS_FOUNDATION_VERSION, - legacyVersions: ['0126_user_access_foundation'], - apply: vi.fn(async () => undefined), - } - const compatibility = { - version: AUTHORIZATION_REVISION_COMPATIBILITY_VERSION, - legacyVersions: ['0138_authorization_revision_delete_compatibility'], - apply: vi.fn(async () => undefined), - } - const migrations = [ - ...DEV_POST_0106_MIGRATION_VERSIONS.map(version => ({ - version, - apply: vi.fn(async () => undefined), - })), - ...PR1_MIGRATION_VERSIONS.filter( - version => - version !== USER_ACCESS_FOUNDATION_VERSION && - version !== AUTHORIZATION_REVISION_COMPATIBILITY_VERSION - ).map(version => ({ version, apply: vi.fn(async () => undefined) })), - foundation, - compatibility, - ] - const appliedVersions = new Set([ - ...DEV_POST_0106_MIGRATION_VERSIONS, - ...PR1_MIGRATION_VERSIONS.filter( - version => - version !== USER_ACCESS_FOUNDATION_VERSION && - version !== AUTHORIZATION_REVISION_COMPATIBILITY_VERSION - ), - '0126_user_access_foundation', - '0138_authorization_revision_delete_compatibility', - ]) - const recorded: string[] = [] - - await applyPendingPr1Migrations({ - db: { query: vi.fn(async () => ({ rows: [], rowCount: 0 })) }, - migrations, - appliedVersions, - recordMigration: async (_db, version) => { - recorded.push(version) - }, - }) - - expect(recorded).toEqual([ - USER_ACCESS_FOUNDATION_VERSION, - AUTHORIZATION_REVISION_COMPATIBILITY_VERSION, - ]) - expect(foundation.apply).not.toHaveBeenCalled() - expect(compatibility.apply).not.toHaveBeenCalled() - expect(appliedVersions).toContain(USER_ACCESS_FOUNDATION_VERSION) - expect(appliedVersions).toContain(AUTHORIZATION_REVISION_COMPATIBILITY_VERSION) - }) - - it.each([ - AUTHORIZATION_REVISION_COMPATIBILITY_VERSION, - '0138_authorization_revision_delete_compatibility', - ])('fails closed when %s is recorded without its 0130 prerequisite', async receipt => { - await expect( - applyPendingPr1Migrations({ - db: { query: vi.fn(async () => ({ rows: [], rowCount: 0 })) }, - migrations: [ - ...DEV_POST_0106_MIGRATION_VERSIONS.map(version => ({ - version, - apply: vi.fn(async () => undefined), - })), - ...PR1_MIGRATION_VERSIONS.map(version => ({ - version, - legacyVersions: - version === AUTHORIZATION_REVISION_COMPATIBILITY_VERSION - ? ['0138_authorization_revision_delete_compatibility'] - : undefined, - apply: vi.fn(async () => undefined), - })), - ], - appliedVersions: new Set([...DEV_POST_0106_MIGRATION_VERSIONS, receipt]), - recordMigration: vi.fn(async () => undefined), - }) - ).rejects.toThrow('recorded before its prerequisite') - }) }) describe('D34 online-index recovery', () => { diff --git a/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts b/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts index 04148d0433..ec3d33fa33 100644 --- a/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts +++ b/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts @@ -139,29 +139,16 @@ describeRealPostgres('D34 previous-image writer compatibility on real PostgreSQL ) expect(state?.definition).not.toMatch(new RegExp(`VALUES\\s*\\(${targetId}`, 'i')) } - expect( - await databasePool.query( - `SELECT 1 FROM schema_migrations - WHERE version = '0144_authorization_revision_delete_compatibility'` - ) - ).toMatchObject({ rowCount: 1 }) }) - it('fixes an applied 0125 prefix before previous writers delete users or teams', async () => { - const writerSource = readFileSync( - new URL('../src/services/directory/teams.ts', import.meta.url) - ) - expect(createHash('sha256').update(writerSource).digest('hex')).toBe( - PRE_D34_TEAMS_WRITER_SHA256 - ) - + it('lets the runtime role delete a user and a team with a deleted membership', async () => { const userId = randomUUID() const teamId = randomUUID() await databasePool.query( - `INSERT INTO users(id, email, name) VALUES ($1, $2, 'D34 prefix repair')`, - [userId, `d34-prefix-${userId}@example.test`] + `INSERT INTO users(id, email, name) VALUES ($1, $2, 'D34 runtime delete')`, + [userId, `d34-runtime-delete-${userId}@example.test`] ) - await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, 'D34 prefix repair')`, [ + await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, 'D34 runtime delete')`, [ teamId, ]) await databasePool.query( @@ -170,51 +157,7 @@ describeRealPostgres('D34 previous-image writer compatibility on real PostgreSQL [teamId, userId] ) - const before = await databasePool.query<{ - name: string - owner: string - acl: string[] | null - config: string[] | null - }>( - `SELECT proname AS name, pg_get_userbyid(proowner) AS owner, proacl AS acl, - proconfig AS config - FROM pg_proc - WHERE oid = ANY(ARRAY[ - 'public.authorization_bump_team_revision(uuid)'::regprocedure, - 'public.authorization_bump_user_revision(uuid)'::regprocedure - ]) - ORDER BY proname` - ) - expect(before.rows).toHaveLength(2) - - await databasePool.query(` - CREATE OR REPLACE FUNCTION public.authorization_bump_user_revision(target_user_id UUID) - RETURNS VOID - LANGUAGE SQL - SECURITY DEFINER - SET search_path = pg_catalog, public - AS $$ - INSERT INTO authorization_user_revisions(user_id, revision, updated_at) - VALUES(target_user_id, 1, clock_timestamp()) - ON CONFLICT (user_id) DO UPDATE - SET revision = authorization_user_revisions.revision + 1, - updated_at = clock_timestamp(); - $$; - - CREATE OR REPLACE FUNCTION public.authorization_bump_team_revision(target_team_id UUID) - RETURNS VOID - LANGUAGE SQL - SECURITY DEFINER - SET search_path = pg_catalog, public - AS $$ - INSERT INTO authorization_team_revisions(team_id, revision, updated_at) - VALUES(target_team_id, 1, clock_timestamp()) - ON CONFLICT (team_id) DO UPDATE - SET revision = authorization_team_revisions.revision + 1, - updated_at = clock_timestamp(); - $$; - `) - + vi.resetModules() const actualDb = await vi.importActual('../src/db.js') vi.doMock('../src/db.js', () => ({ ...actualDb, @@ -229,56 +172,7 @@ describeRealPostgres('D34 previous-image writer compatibility on real PostgreSQL import('../src/services/directory/teams.js'), import('../src/services/directory/users.js'), ]) - await expect(adminDeleteTeam(teamId)).rejects.toMatchObject({ code: '23503' }) - await expect(adminDeleteUser(userId)).rejects.toMatchObject({ code: '23503' }) - - await databasePool.query( - `DELETE FROM schema_migrations - WHERE version = ANY($1::text[])`, - [ - [ - '0144_authorization_revision_delete_compatibility', - '0143_authorization_revision_delete_compatibility', - '0138_authorization_revision_delete_compatibility', - ], - ] - ) - await initDb({ connect: () => databasePool.connect() }) - const repaired = await databasePool.query<{ - name: string - owner: string - acl: string[] | null - config: string[] | null - definition: string - }>( - `SELECT proname AS name, - pg_get_userbyid(proowner) AS owner, - proacl AS acl, - proconfig AS config, - pg_get_functiondef(oid) AS definition - FROM pg_proc - WHERE oid = ANY(ARRAY[ - 'public.authorization_bump_team_revision(uuid)'::regprocedure, - 'public.authorization_bump_user_revision(uuid)'::regprocedure - ]) - ORDER BY proname` - ) - expect(repaired.rows).toHaveLength(2) - for (const [name, parentTable, targetId] of [ - ['authorization_bump_team_revision', 'teams', 'target_team_id'], - ['authorization_bump_user_revision', 'users', 'target_user_id'], - ]) { - const current = repaired.rows.find(row => row.name === name) - const original = before.rows.find(row => row.name === name) - expect(current?.config).toContain('search_path=pg_catalog, public, pg_temp') - expect(current?.definition).toMatch( - new RegExp(`SELECT ${parentTable}\\.id[\\s\\S]*FROM ${parentTable}`, 'i') - ) - expect(current?.definition).not.toMatch(new RegExp(`VALUES\\s*\\(${targetId}`, 'i')) - expect(current?.owner).toBe(original?.owner) - expect(current?.acl).toEqual(original?.acl) - } expect(await adminDeleteUser(userId)).toEqual({ ok: true, id: userId }) expect(await adminDeleteTeam(teamId)).toEqual({ ok: true, id: teamId }) expect(await databasePool.query(`SELECT 1 FROM users WHERE id = $1`, [userId])).toMatchObject({ @@ -287,11 +181,5 @@ describeRealPostgres('D34 previous-image writer compatibility on real PostgreSQL expect(await databasePool.query(`SELECT 1 FROM teams WHERE id = $1`, [teamId])).toMatchObject({ rowCount: 0, }) - expect( - await databasePool.query( - `SELECT 1 FROM schema_migrations - WHERE version = '0144_authorization_revision_delete_compatibility'` - ) - ).toMatchObject({ rowCount: 1 }) }) }) diff --git a/control-api/test/pr1MigrationLocks.realPostgres.integration.test.ts b/control-api/test/pr1MigrationLocks.realPostgres.integration.test.ts index 98263ef18e..78b8b78829 100644 --- a/control-api/test/pr1MigrationLocks.realPostgres.integration.test.ts +++ b/control-api/test/pr1MigrationLocks.realPostgres.integration.test.ts @@ -41,33 +41,6 @@ const ROW_REVISION_TRIGGER_TABLES = [ 'gfs_shares', 'gfs_resources', ] as const -// The statement-level triggers the 0130 body installed before S1d. A database -// that applied that body still carries them until 0133 drops them. -const LEGACY_CATALOG_TRIGGER_TABLES = [ - 'users', - 'teams', - 'team_members', - 'user_contexts', - 'team_contexts', - 'user_agents', - 'team_agents', - 'user_workflow_triggers', - 'team_workflow_triggers', - 'workflow_runs', - 'workflow_approval_requests', - 'notification_deliveries', - 'gfs_resources', - 'gfs_grants', - 'gfs_shares', - 'operational_resource_index', - 'operational_resource_relationships', - 'operational_catalog_source_state', -] as const -const LEGACY_CATALOG_TRIGGERS_SQL = LEGACY_CATALOG_TRIGGER_TABLES.map( - table => `CREATE OR REPLACE TRIGGER ${table}_catalog_revision AFTER INSERT OR UPDATE OR DELETE ON ${table} - FOR EACH STATEMENT EXECUTE FUNCTION authorization_bump_catalog_revision();` -).join('\n') - function databaseUrl(baseUrl: string, database: string): string { const value = new URL(baseUrl) value.pathname = `/${database}` @@ -141,19 +114,13 @@ type MigrationRun = { preExistingTables: Set commits: CommitSnapshot[] composableCatalogProbes: Record | null - legacyTriggersBeforeComposableCatalog: number | null } -async function migrate( - pool: Pool, - observer: Pool, - options: { installLegacyCatalogTriggers: boolean } -): Promise { +async function migrate(pool: Pool, observer: Pool): Promise { const run: MigrationRun = { preExistingTables: new Set(), commits: [], composableCatalogProbes: null, - legacyTriggersBeforeComposableCatalog: null, } const pendingVersions: string[] = [] let accessFoundationSeen = false @@ -177,12 +144,6 @@ async function migrate( if (text.trim() === 'COMMIT') { const versions = pendingVersions.splice(0) if (versions.some(version => PR1_VERSIONS.has(version))) { - if ( - options.installLegacyCatalogTriggers && - versions.includes('0130_user_access_foundation') - ) { - await client.query(LEGACY_CATALOG_TRIGGERS_SQL) - } if (versions.includes(COMPOSABLE_CATALOG_VERSION)) { run.composableCatalogProbes = { readUsers: await probe(observer, 'SELECT count(*) FROM users'), @@ -193,17 +154,6 @@ async function migrate( run.commits.push({ versions, locks: await relationLocks(observer, pid) }) } } - if ( - run.legacyTriggersBeforeComposableCatalog === null && - text.includes('DROP TRIGGER IF EXISTS users_catalog_revision ON users') - ) { - const installed = await observer.query<{ count: number }>( - `SELECT count(*)::int AS count - FROM pg_trigger - WHERE NOT tgisinternal AND tgname LIKE '%\\_catalog\\_revision'` - ) - run.legacyTriggersBeforeComposableCatalog = installed.rows[0]!.count - } return client.query(text, values) }, } @@ -229,44 +179,34 @@ function blockingLocks(run: MigrationRun): (RelationLock & { versions: string }) describeRealPostgres('PR1 migration locks on real PostgreSQL', () => { const suffix = randomBytes(6).toString('hex') const freshDatabase = `control_api_pr1_locks_fresh_${suffix}` - const legacyDatabase = `control_api_pr1_locks_legacy_${suffix}` const baseUrl = adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres' let adminPool: Pool let freshPool: Pool let freshObserver: Pool - let legacyPool: Pool - let legacyObserver: Pool let fresh: MigrationRun - let legacy: MigrationRun beforeAll(async () => { adminPool = new Pool({ connectionString: adminUrl }) await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) await adminPool.query(`CREATE DATABASE ${quoteIdentifier(freshDatabase)}`) - await adminPool.query(`CREATE DATABASE ${quoteIdentifier(legacyDatabase)}`) freshPool = new Pool({ connectionString: databaseUrl(baseUrl, freshDatabase), max: 2 }) freshObserver = new Pool({ connectionString: databaseUrl(baseUrl, freshDatabase), max: 3 }) - legacyPool = new Pool({ connectionString: databaseUrl(baseUrl, legacyDatabase), max: 2 }) - legacyObserver = new Pool({ connectionString: databaseUrl(baseUrl, legacyDatabase), max: 3 }) - fresh = await migrate(freshPool, freshObserver, { installLegacyCatalogTriggers: false }) - legacy = await migrate(legacyPool, legacyObserver, { installLegacyCatalogTriggers: true }) + fresh = await migrate(freshPool, freshObserver) }, 120_000) afterAll(async () => { try { - for (const pool of [freshPool, freshObserver, legacyPool, legacyObserver]) { + for (const pool of [freshPool, freshObserver]) { if (pool) await endPoolAndWaitForClients(pool) } if (adminPool) { - for (const database of [freshDatabase, legacyDatabase]) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) - FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) - } + await adminPool.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [freshDatabase] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(freshDatabase)}`) await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) } } finally { @@ -332,17 +272,4 @@ describeRealPostgres('PR1 migration locks on real PostgreSQL', () => { ).toBe(true) } }) - - it('converges a database that applied the earlier 0130 body to the fresh end state', async () => { - // Liveness: the legacy database really carried the 18 statement triggers - // into 0133, and dropping them there is visible as AccessExclusive locks. - expect(legacy.legacyTriggersBeforeComposableCatalog).toBe(LEGACY_CATALOG_TRIGGER_TABLES.length) - expect(blockingLocks(legacy)).toContainEqual({ - relname: 'users', - mode: 'AccessExclusiveLock', - versions: expect.stringContaining(COMPOSABLE_CATALOG_VERSION), - }) - - expect(await triggerDefinitions(legacyPool)).toEqual(await triggerDefinitions(freshPool)) - }) }) From c76f08ac22f1001b37732cbb7ea7bfeb3dd403c1 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Fri, 23 Oct 2026 13:26:47 +0200 Subject: [PATCH 365/528] fix(external-rest-api): reject an empty session credential before verification The invitation password route verified the presented session token only when it was non-empty, which CodeQL reports as js/user-controlled-bypass. Return 401 for an empty or malformed credential before calling the verifier; the verifier and claims check are unchanged. Co-Authored-By: Claude Opus 5.5 --- external-rest-api/package-lock.json | 4 ++-- external-rest-api/package.json | 2 +- external-rest-api/src/releaseManifest.ts | 2 +- external-rest-api/src/routes/invitations.ts | 7 ++++++- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index a76376a38a..ac0ef1eb33 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.154", + "version": "0.1.155", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.154", + "version": "0.1.155", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index a7929fc2a2..6b8a83fade 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.154", + "version": "0.1.155", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index 2cf7593b2c..1d8c589b9d 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.11.0', - externalRestApiVersion: '0.1.154', + externalRestApiVersion: '0.1.155', rpcProxyVersion: '0.1.133', desktopVersion: '0.11.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/src/routes/invitations.ts b/external-rest-api/src/routes/invitations.ts index ba71f7a2ae..4cffc77d1e 100644 --- a/external-rest-api/src/routes/invitations.ts +++ b/external-rest-api/src/routes/invitations.ts @@ -205,7 +205,12 @@ async function invitationPasswordAuthFromRequest(req: Request): Promise< const authorization = req.headers.authorization if (authorization !== undefined || hasCookie(req, PROFILE_SESSION_COOKIE)) { const sessionToken = presentedSessionToken(authorization, req) - const claims = sessionToken ? verifyToken(sessionToken) : null + // An empty or malformed credential is rejected before verification and + // never reaches the verifier. + if (!sessionToken) { + return { status: 401, body: { error: 'Unauthorized' } } + } + const claims = verifyToken(sessionToken) if (!claims) { return { status: 401, body: { error: 'Unauthorized' } } } From 0f0afe97554fa793d0988a41e6fad2b4b2263a3e Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Fri, 23 Oct 2026 13:27:05 +0200 Subject: [PATCH 366/528] test(ci): use one credential exemption rule in the public boundary scanner The scanner exempted credential values through an exact-literal allowlist and a path-specific contract-control list. Both admitted a longer value whose captured prefix was listed (for example a listed literal followed by a concatenated suffix), and dev rejects the same inputs. Remove safe_fixture_literals, the credential branch of safe_source_fixture_value, contract_control_values and safe_contract_control_value. Credential values are exempt again only through the shared fixture marker convention, a `$` value, the redaction marker or the synthetic bearer line, as on dev. The loopback URL and PostgreSQL source fixture branches are kept. Rename the 43 test values that only the allowlist exempted to `synthetic-`, together with their assertions. The contract writes its materialized secret-shaped values from split literals, drops the test of the removed control mechanism, and adds a control that an unmarked credential in a test file is rejected. Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- ...yPassword.realPostgres.integration.test.ts | 2 +- .../directoryAuthorizationPrivacy.test.ts | 49 +++++++---- ...ptSession.realPostgres.integration.test.ts | 2 +- ...dFirstSet.realPostgres.integration.test.ts | 2 +- .../externalSessionAuthentication.test.ts | 4 +- .../passwordSessionAuthentication.test.ts | 11 ++- ...re.parity.realPostgres.integration.test.ts | 2 +- ...es.externalInvitations.unavailable.test.ts | 20 ++--- ...utes.externalSessionAtomicExchange.test.ts | 23 ++--- ...es.externalSessionRenewalRateLimit.test.ts | 14 +-- .../routes.externalTeamsInvitations.test.ts | 4 +- control-api/test/routes.profile.test.ts | 12 +-- .../services.directory.invitationList.test.ts | 2 +- external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- external-rest-api/src/releaseManifest.ts | 2 +- .../test/passwordAdmissionError.test.ts | 6 +- external-rest-api/test/routes.auth.test.ts | 10 +-- .../routes.controlApiParamEncoding.test.ts | 4 +- .../routes.controlApiPathExposure.test.ts | 26 +++--- .../test/routes.invitations.test.ts | 88 ++++++++++--------- .../routes.teamSwitchCompatibility.test.ts | 8 +- .../services.externalUserClientIp.test.ts | 29 +++--- .../test/services.invitationsService.test.ts | 14 +-- scripts/tests/test-minikube-t2-contract.sh | 60 +++++-------- .../tests/test-minikube-t2-public-boundary.sh | 86 ------------------ 28 files changed, 214 insertions(+), 278 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 53a7da07ab..27699aba0c 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.822", + "version": "0.1.823", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.822", + "version": "0.1.823", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index f51260d327..96964cb2f0 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.822", + "version": "0.1.823", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/adminDesktopReplayPassword.realPostgres.integration.test.ts b/control-api/test/adminDesktopReplayPassword.realPostgres.integration.test.ts index 689d57f4f2..b4e04e1d11 100644 --- a/control-api/test/adminDesktopReplayPassword.realPostgres.integration.test.ts +++ b/control-api/test/adminDesktopReplayPassword.realPostgres.integration.test.ts @@ -133,7 +133,7 @@ describeRealPostgres('control admin completion desktop password on real PostgreS return request(app()) .post('/admin/auth/control-admin-invitations/complete') .send({ - token: 'control-admin-flow-token', + token: 'synthetic-control-admin-flow-token', email: pair.email, username: `invitee-${randomUUID().slice(0, 8)}`, ...body, diff --git a/control-api/test/directoryAuthorizationPrivacy.test.ts b/control-api/test/directoryAuthorizationPrivacy.test.ts index 81ec891f9b..a90856136c 100644 --- a/control-api/test/directoryAuthorizationPrivacy.test.ts +++ b/control-api/test/directoryAuthorizationPrivacy.test.ts @@ -118,7 +118,7 @@ describe('directory privacy and atomic authorization', () => { invitee_name: 'Invitee', email: 'invitee@example.com', role: 'admin', - token: 'must-never-serialize', + token: 'synthetic-must-never-serialize', status: 'pending', purpose: 'member_invitation', ...activeInvitationWindow(), @@ -159,7 +159,7 @@ describe('directory privacy and atomic authorization', () => { expect(result[0]).not.toHaveProperty('team_id') expect(result[0]).not.toHaveProperty('team_name') expect(result[0]).not.toHaveProperty('role') - expect(JSON.stringify(result)).not.toContain('must-never-serialize') + expect(JSON.stringify(result)).not.toContain('synthetic-must-never-serialize') expect(result[0]).toMatchObject({ canCancel: false, canResend: false }) }) @@ -271,7 +271,7 @@ describe('directory privacy and atomic authorization', () => { invitee_name: 'Invitee', email: 'invitee@example.com', role: 'member', - token: 'must-never-leave-control-api', + token: 'synthetic-must-never-leave-control-api', status: 'draft', purpose: 'member_invitation', ...activeInvitationWindow(), @@ -333,7 +333,7 @@ describe('directory privacy and atomic authorization', () => { invitee_name: 'Invitee', email: 'invitee@example.com', role: 'member', - token: 'inert-until-activation', + token: 'synthetic-inert-until-activation', status: 'draft', purpose: 'member_invitation', ...activeInvitationWindow(), @@ -403,7 +403,7 @@ describe('directory privacy and atomic authorization', () => { invitee_name: 'Untrusted inviter label', email: 'target@example.com', role: 'member', - token: 'invitation-token', + token: 'synthetic-invitation-token', status: 'pending', purpose: 'member_invitation', ...activeInvitationWindow(), @@ -480,7 +480,7 @@ describe('directory privacy and atomic authorization', () => { invitee_name: 'Target', email: 'target@example.com', role: 'member', - token: 'single-use-token', + token: 'synthetic-single-use-token', status: 'pending', purpose: 'member_invitation', ...activeInvitationWindow(), @@ -574,7 +574,7 @@ describe('directory privacy and atomic authorization', () => { invitee_name: 'Invitee', email: 'invitee@example.com', role: 'member', - token: 'invitation-token', + token: 'synthetic-invitation-token', status: 'pending', purpose: 'member_invitation', ...activeInvitationWindow(), @@ -668,7 +668,7 @@ describe('directory privacy and atomic authorization', () => { invitee_name: 'Target', email: 'target@example.com', role: 'member', - token: 'password-reset-token', + token: 'synthetic-password-reset-token', status: 'pending', purpose: 'password_reset', ...activeInvitationWindow(), @@ -678,8 +678,15 @@ describe('directory privacy and atomic authorization', () => { } for (const operation of [ - () => setInvitationPasswordForUser(TARGET, invitation.email, invitation.id, 'valid-password'), - () => setInvitationPasswordForEmail(invitation.email, invitation.id, 'valid-password'), + () => + setInvitationPasswordForUser( + TARGET, + invitation.email, + invitation.id, + 'synthetic-valid-password' + ), + () => + setInvitationPasswordForEmail(invitation.email, invitation.id, 'synthetic-valid-password'), ]) { mocks.query.mockReset() mocks.query.mockImplementation(async (sql: string) => { @@ -727,7 +734,7 @@ describe('directory privacy and atomic authorization', () => { invitee_name: 'New user', email: 'new-user@example.com', role: 'member', - token: 'member-setup-token', + token: 'synthetic-member-setup-token', status: 'pending', purpose: 'member_invitation', created_at: new Date(), @@ -808,7 +815,12 @@ describe('directory privacy and atomic authorization', () => { acceptInvitationForEmail(invitation.email, invitation.token, invitation.id) ).resolves.toMatchObject({ data: { accepted: true, userId: TARGET, status: 'accepted' } }) await expect( - setInvitationPasswordForUser(TARGET, invitation.email, invitation.id, 'valid-password') + setInvitationPasswordForUser( + TARGET, + invitation.email, + invitation.id, + 'synthetic-valid-password' + ) ).resolves.toMatchObject({ data: { passwordUpdated: true, status: 'accepted' } }) expect(status).toBe('accepted') expect(passwordHash).not.toBeNull() @@ -826,7 +838,7 @@ describe('directory privacy and atomic authorization', () => { invitee_name: 'Existing user', email: 'existing@example.com', role: 'member', - token: 'consumed-member-token', + token: 'synthetic-consumed-member-token', status: 'accepted', purpose: 'member_invitation', created_at: new Date(), @@ -860,7 +872,7 @@ describe('directory privacy and atomic authorization', () => { invitee_name: 'Invitee', email: 'invitee@example.com', role: 'member', - token: 'invitation-token', + token: 'synthetic-invitation-token', status: 'pending', purpose: 'member_invitation', ...activeInvitationWindow(), @@ -998,7 +1010,7 @@ describe('directory privacy and atomic authorization', () => { }) it('changes a password and revokes every session in one conditional transaction', async () => { - const currentHash = await bcrypt.hash('current-password', 4) + const currentHash = await bcrypt.hash('synthetic-current-password', 4) mocks.query .mockResolvedValueOnce({ rows: [{ password_hash: currentHash }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [{ lifecycle_version: 2 }], rowCount: 1 }) @@ -1007,7 +1019,12 @@ describe('directory privacy and atomic authorization', () => { .mockResolvedValueOnce({ rows: [], rowCount: 2 }) await expect( - updateUserPassword(MANAGER, 'manager@example.com', 'current-password', 'next-password') + updateUserPassword( + MANAGER, + 'manager@example.com', + 'synthetic-current-password', + 'next-password' + ) ).resolves.toEqual({ updated: true }) expect(mocks.withTransaction).toHaveBeenCalledTimes(2) diff --git a/control-api/test/externalInvitationAcceptSession.realPostgres.integration.test.ts b/control-api/test/externalInvitationAcceptSession.realPostgres.integration.test.ts index 8da818b8e9..49b5f1e9a4 100644 --- a/control-api/test/externalInvitationAcceptSession.realPostgres.integration.test.ts +++ b/control-api/test/externalInvitationAcceptSession.realPostgres.integration.test.ts @@ -215,7 +215,7 @@ describeRealPostgres('external invitation acceptance session issuance on real Po it('does not issue a session on a replay after the owner set a password', async () => { const invitation = await createInvitation('replay-after-password') - const password = 'Replay-After-Password-1!' + const password = 'synthetic-Replay-After-Password-1!' const first = await accept(invitation.flowToken, invitation.email) expect(first.status).toBe(200) diff --git a/control-api/test/externalInvitationPasswordFirstSet.realPostgres.integration.test.ts b/control-api/test/externalInvitationPasswordFirstSet.realPostgres.integration.test.ts index ea6f581417..c7ab67bf38 100644 --- a/control-api/test/externalInvitationPasswordFirstSet.realPostgres.integration.test.ts +++ b/control-api/test/externalInvitationPasswordFirstSet.realPostgres.integration.test.ts @@ -392,7 +392,7 @@ describeRealPostgres('external invitation password setup on real PostgreSQL', () const response = await request(app()) .post('/admin/auth/control-admin-invitations/complete') .send({ - token: 'control-admin-flow-token', + token: 'synthetic-control-admin-flow-token', email: pair.email, username: `invitee-${randomUUID().slice(0, 8)}`, password: adminPassword, diff --git a/control-api/test/externalSessionAuthentication.test.ts b/control-api/test/externalSessionAuthentication.test.ts index 4f7a0d0df2..41574b55fc 100644 --- a/control-api/test/externalSessionAuthentication.test.ts +++ b/control-api/test/externalSessionAuthentication.test.ts @@ -208,7 +208,7 @@ describe('external user-session authentication boundary', () => { const identity = validIdentity() const token = v2Token(identity) sessionMocks.renewUserSession.mockResolvedValue({ - token: 'successor', + token: 'synthetic-successor', expiresInSeconds: 3600, identity, }) @@ -221,6 +221,6 @@ describe('external user-session authentication boundary', () => { renewExternalUserSession(token, { policy: policy({ issueV2: true, renewV2: true }), }) - ).resolves.toMatchObject({ status: 'renewed', session: { token: 'successor' } }) + ).resolves.toMatchObject({ status: 'renewed', session: { token: 'synthetic-successor' } }) }) }) diff --git a/control-api/test/passwordSessionAuthentication.test.ts b/control-api/test/passwordSessionAuthentication.test.ts index ace23014a0..59cf85929b 100644 --- a/control-api/test/passwordSessionAuthentication.test.ts +++ b/control-api/test/passwordSessionAuthentication.test.ts @@ -32,7 +32,10 @@ describe('password session issuance serialization', () => { Object.values(mocks).forEach(mock => mock.mockReset()) mocks.passwordLoginData.mockResolvedValue(verifiedLogin) mocks.beforeTransaction.mockResolvedValue(undefined) - mocks.issueExternalUserSession.mockResolvedValue({ token: 'v2-token', contract: 'v2' }) + mocks.issueExternalUserSession.mockResolvedValue({ + token: 'synthetic-v2-token', + contract: 'v2', + }) }) it('does not issue after a password change commits between verification and the row lock', async () => { @@ -45,7 +48,7 @@ describe('password session issuance serialization', () => { const login = authenticatePasswordAndIssueSession({ email: 'user@example.com', - password: 'old-password', + password: 'synthetic-old-password', contract: 'v2', }) await vi.waitFor(() => expect(mocks.passwordLoginData).toHaveBeenCalledTimes(1)) @@ -63,10 +66,10 @@ describe('password session issuance serialization', () => { await expect( authenticatePasswordAndIssueSession({ email: 'user@example.com', - password: 'current-password', + password: 'synthetic-current-password', contract: 'v2', }) - ).resolves.toMatchObject({ issued: { token: 'v2-token', contract: 'v2' } }) + ).resolves.toMatchObject({ issued: { token: 'synthetic-v2-token', contract: 'v2' } }) expect(mocks.issueExternalUserSession).toHaveBeenCalledWith( expect.objectContaining({ userId: 'user-1', authenticationMethods: ['pwd'] }), diff --git a/control-api/test/profileUiRecoveryFixture.parity.realPostgres.integration.test.ts b/control-api/test/profileUiRecoveryFixture.parity.realPostgres.integration.test.ts index 5d022038a0..ea99e723c6 100644 --- a/control-api/test/profileUiRecoveryFixture.parity.realPostgres.integration.test.ts +++ b/control-api/test/profileUiRecoveryFixture.parity.realPostgres.integration.test.ts @@ -545,7 +545,7 @@ realPg('Profile UI recovery lane fixture parity on PostgreSQL 16', () => { await expectParity('POST', '/external/auth/password-login', { body: { email: fixtureModule.RECOVERY_USERS.logout.email, - password: 'definitely-not-the-password', + password: 'synthetic-definitely-not-the-password', }, }) diff --git a/control-api/test/routes.externalInvitations.unavailable.test.ts b/control-api/test/routes.externalInvitations.unavailable.test.ts index 0525f837af..5a64b594e4 100644 --- a/control-api/test/routes.externalInvitations.unavailable.test.ts +++ b/control-api/test/routes.externalInvitations.unavailable.test.ts @@ -53,7 +53,7 @@ vi.mock('../src/middleware/externalSessionAuth.js', () => ({ next() }, })) -const tokenSigner = vi.hoisted(() => ({ sign: vi.fn(() => 'session-token') })) +const tokenSigner = vi.hoisted(() => ({ sign: vi.fn(() => 'synthetic-session-token') })) vi.mock('../src/utils/auth/externalSessionAuthToken.js', () => ({ signExternalSessionToken: tokenSigner.sign, })) @@ -149,13 +149,13 @@ describe('external invitation routes when the hub is unavailable', () => { const first = await request(app()) .post('/external/invitations/accept') - .send({ email: 'a@b.c', token: 'one-use-flow' }) + .send({ email: 'a@b.c', token: 'synthetic-one-use-flow' }) const replay = await request(app()) .post('/external/invitations/accept') - .send({ email: 'a@b.c', token: 'one-use-flow' }) + .send({ email: 'a@b.c', token: 'synthetic-one-use-flow' }) expect(first.status).toBe(200) - expect(first.body.token).toBe('session-token') + expect(first.body.token).toBe('synthetic-session-token') expect(first.body).not.toHaveProperty('replay') expect(replay.status).toBe(200) expect(replay.body).toEqual(replayData) @@ -181,7 +181,7 @@ describe('external invitation routes when the hub is unavailable', () => { const res = await request(app()) .post('/external/invitations/accept') - .send({ email: 'a@b.c', token: 'not-pending-flow' }) + .send({ email: 'a@b.c', token: 'synthetic-not-pending-flow' }) expect(res.status).toBe(400) expect(res.body).toEqual({ error: 'not_pending' }) @@ -225,9 +225,9 @@ describe('external invitation routes when the hub is unavailable', () => { const response = await request(app()).post('/external/invitations/password-token').send({ email: 'a@b.c', - token: 'member-registration-flow-token', + token: 'synthetic-member-registration-flow-token', invitationId: '00000000-0000-4000-8000-000000000200', - password: 'valid-password', + password: 'synthetic-valid-password', }) expect(response.status).toBe(200) @@ -239,7 +239,7 @@ describe('external invitation routes when the hub is unavailable', () => { expect(directory.setInvitationPasswordForEmail).toHaveBeenCalledWith( 'a@b.c', '00000000-0000-4000-8000-000000000200', - 'valid-password' + 'synthetic-valid-password' ) expect(tokenSigner.sign).not.toHaveBeenCalled() }) @@ -258,9 +258,9 @@ describe('external invitation routes when the hub is unavailable', () => { const response = await request(app()).post('/external/invitations/password-token').send({ email: 'a@b.c', - token: 'member-registration-flow-token', + token: 'synthetic-member-registration-flow-token', invitationId: '00000000-0000-4000-8000-000000000200', - password: 'valid-password', + password: 'synthetic-valid-password', }) expect(response.status).toBe(403) diff --git a/control-api/test/routes.externalSessionAtomicExchange.test.ts b/control-api/test/routes.externalSessionAtomicExchange.test.ts index 2d688235b8..ecd7149e54 100644 --- a/control-api/test/routes.externalSessionAtomicExchange.test.ts +++ b/control-api/test/routes.externalSessionAtomicExchange.test.ts @@ -68,7 +68,10 @@ describe('legacy external-session replacement exchange', () => { }, policy: { issueV1: true, switchCompatibility: true }, }) - issuance.issueExternalUserSession.mockResolvedValue({ token: 'stale-token', contract: 'v1' }) + issuance.issueExternalUserSession.mockResolvedValue({ + token: 'synthetic-stale-token', + contract: 'v1', + }) revocations.revokeAllUserSessions.mockResolvedValue(1) revocations.revokeUserSession.mockResolvedValue(true) database.query.mockResolvedValue({ @@ -83,13 +86,13 @@ describe('legacy external-session replacement exchange', () => { const response = await request(app()) .post('/external/auth/session-token') .set('x-forwarded-for', '203.0.113.44') - .set('x-user-session-token', 'source-token') + .set('x-user-session-token', 'synthetic-source-token') .send({ userId: claims.userId, email: claims.email, teamId: 'team-new' }) expect(response.status).toBe(401) expect(response.body).toMatchObject({ error: { code: 'invalid_session' } }) expect(issuance.exchangeLegacyExternalUserSession).toHaveBeenCalledWith( - expect.objectContaining({ token: 'source-token', teamId: 'team-new', claims }), + expect.objectContaining({ token: 'synthetic-source-token', teamId: 'team-new', claims }), expect.objectContaining({ policy: expect.any(Object) }) ) expect(issuance.issueExternalUserSession).not.toHaveBeenCalled() @@ -98,19 +101,19 @@ describe('legacy external-session replacement exchange', () => { it('uses the current locked membership role for the replacement representation', async () => { issuance.exchangeLegacyExternalUserSession.mockResolvedValue({ status: 'issued', - token: 'replacement-token', + token: 'synthetic-replacement-token', role: 'member', }) const response = await request(app()) .post('/external/auth/session-token') .set('x-forwarded-for', '203.0.113.44') - .set('x-user-session-token', 'source-token') + .set('x-user-session-token', 'synthetic-source-token') .send({ userId: claims.userId, email: claims.email, teamId: 'team-new' }) .expect(200) expect(response.body).toEqual({ - token: 'replacement-token', + token: 'synthetic-replacement-token', sessionContract: 'v1', deprecated: true, }) @@ -124,7 +127,7 @@ describe('legacy external-session replacement exchange', () => { await request(app()) .post('/external/auth/session-token') .set('x-forwarded-for', '203.0.113.44') - .set('x-user-session-token', 'source-token') + .set('x-user-session-token', 'synthetic-source-token') .send({ userId: claims.userId, email: claims.email, teamId: 'team-new' }) .expect(403, { error: 'membership_not_found' }) @@ -150,7 +153,7 @@ describe('legacy external-session replacement exchange', () => { await request(app()) .post('/external/auth/sessions/revoke-all') .set('x-forwarded-for', '203.0.113.44') - .set('x-user-session-token', 'source-token') + .set('x-user-session-token', 'synthetic-source-token') .expect(200, { revoked: 1 }) expect(revocations.revokeAllUserSessions).toHaveBeenCalledWith( @@ -185,7 +188,7 @@ describe('legacy external-session replacement exchange', () => { await request(app()) .post('/external/auth/sessions/target-session/revoke') .set('x-forwarded-for', '203.0.113.44') - .set('x-user-session-token', 'source-token') + .set('x-user-session-token', 'synthetic-source-token') .expect(200, { revoked: true }) expect(revocations.revokeUserSession).toHaveBeenCalledWith( @@ -221,7 +224,7 @@ describe('legacy external-session replacement exchange', () => { await request(app()) .post('/external/auth/session/logout') .set('x-forwarded-for', '203.0.113.44') - .set('x-user-session-token', 'source-token') + .set('x-user-session-token', 'synthetic-source-token') .expect(200, { revoked: true }) expect(revocations.revokeUserSession).toHaveBeenCalledWith( diff --git a/control-api/test/routes.externalSessionRenewalRateLimit.test.ts b/control-api/test/routes.externalSessionRenewalRateLimit.test.ts index 56ad40cfb8..a621172ae5 100644 --- a/control-api/test/routes.externalSessionRenewalRateLimit.test.ts +++ b/control-api/test/routes.externalSessionRenewalRateLimit.test.ts @@ -44,7 +44,7 @@ describe('external session route error boundaries and renewal rate limiting', () sessions.renewExternalUserSession.mockResolvedValue({ status: 'renewed', session: { - token: 'rotated-session', + token: 'synthetic-rotated-session', expiresInSeconds: 3600, identity: { absoluteExpiresAt: new Date('2030-01-01T00:00:00.000Z') }, }, @@ -80,13 +80,13 @@ describe('external session route error boundaries and renewal rate limiting', () const response = await request(app()) .post('/external/auth/session/renew') .set('x-forwarded-for', '203.0.113.44') - .send({ token: 'session-token' }) + .send({ token: 'synthetic-session-token' }) expect(response.status).toBe(429) expect(response.body).toMatchObject({ error: { code: 'rate_limited' } }) expect(response.headers['retry-after']).toBeDefined() expect(sessions.authenticateExternalUserSession).toHaveBeenCalledWith( - 'session-token', + 'synthetic-session-token', expect.objectContaining({ purpose: 'renew' }) ) expect(limiter.checkAndIncrement).toHaveBeenNthCalledWith( @@ -106,7 +106,7 @@ describe('external session route error boundaries and renewal rate limiting', () try { const response = await request(app()) .post('/external/auth/verify') - .send({ token: 'session-token' }) + .send({ token: 'synthetic-session-token' }) expect(response.status).toBe(500) expect(response.body).toMatchObject({ error: 'Internal Server Error' }) @@ -133,7 +133,7 @@ describe('external session route error boundaries and renewal rate limiting', () const response = await request(app()) .post('/external/auth/verify') - .send({ token: 'session-token' }) + .send({ token: 'synthetic-session-token' }) expect(response.status).toBe(503) expect(response.body).toEqual({ error: 'session_backend_unavailable', retryAfterSeconds: 2 }) @@ -146,7 +146,7 @@ describe('external session route error boundaries and renewal rate limiting', () it('returns the authenticated claims on successful session verification', async () => { const response = await request(app()) .post('/external/auth/verify') - .send({ token: 'session-token' }) + .send({ token: 'synthetic-session-token' }) expect(response.status).toBe(200) expect(response.body).toEqual({ @@ -169,7 +169,7 @@ describe('external session route error boundaries and renewal rate limiting', () const response = await request(app()) .post('/external/auth/verify') - .send({ token: 'session-token' }) + .send({ token: 'synthetic-session-token' }) expect(response.status).toBe(expectedStatus) expect(response.body.error.code).toBe(expectedCode) diff --git a/control-api/test/routes.externalTeamsInvitations.test.ts b/control-api/test/routes.externalTeamsInvitations.test.ts index 74efdd2ca6..7d1508d2f6 100644 --- a/control-api/test/routes.externalTeamsInvitations.test.ts +++ b/control-api/test/routes.externalTeamsInvitations.test.ts @@ -71,7 +71,7 @@ describe('legacy external team invitation responses', () => { it('never serializes the invitation acceptance capability', async () => { directory.createManagedInvitationForUser.mockResolvedValueOnce({ - invitation: { id: 'inv-1', token: 'must-never-leave-control-api' }, + invitation: { id: 'inv-1', token: 'synthetic-must-never-leave-control-api' }, }) const response = await request(app()) @@ -80,7 +80,7 @@ describe('legacy external team invitation responses', () => { expect(response.status).toBe(200) expect(response.body).toEqual({ id: 'inv-1' }) - expect(JSON.stringify(response.body)).not.toContain('must-never-leave-control-api') + expect(JSON.stringify(response.body)).not.toContain('synthetic-must-never-leave-control-api') }) it('rate limits team reads before directory access', async () => { diff --git a/control-api/test/routes.profile.test.ts b/control-api/test/routes.profile.test.ts index 096b0640d3..8d03010352 100644 --- a/control-api/test/routes.profile.test.ts +++ b/control-api/test/routes.profile.test.ts @@ -827,7 +827,7 @@ describe('routes/profile', () => { role: 'member', }) rpcMock.issueRpcAccessToken.mockReturnValue({ - token: 'rpc-token', + token: 'synthetic-rpc-token', accessScope: 'team', teamId: 'team-from-claims', scopes: ['mcp:servers:list'], @@ -1179,7 +1179,7 @@ describe('routes/profile', () => { .expect(403, { error: 'membership_not_found' }) await withInternalServiceAuth(request(app).post('/external/auth/password-login')) - .send({ email: 'retired@example.com', password: 'correct-password' }) + .send({ email: 'retired@example.com', password: 'synthetic-correct-password' }) .expect(403, { error: 'membership_not_found' }) expect(svc.googleLoginData).toHaveBeenCalledWith({ @@ -1190,7 +1190,7 @@ describe('routes/profile', () => { expect(passwordAuthMock.authenticatePasswordAndIssueSession).toHaveBeenCalledWith( expect.objectContaining({ email: 'retired@example.com', - password: 'correct-password', + password: 'synthetic-correct-password', }) ) }) @@ -1203,7 +1203,7 @@ describe('routes/profile', () => { mountInternalRoutes(app, { listResource: vi.fn() }) await withInternalServiceAuth(request(app).post('/external/auth/password-login')) - .send({ email: 'active@example.com', password: 'wrong-password' }) + .send({ email: 'active@example.com', password: 'synthetic-wrong-password' }) .expect(401, { error: 'Unauthorized' }) }) @@ -1295,7 +1295,7 @@ describe('routes/profile', () => { it('keeps legacy RPC issuance bound to the documented body session token', async () => { rpcMock.issueRpcAccessToken.mockReturnValue({ - token: 'unexpected-token', + token: 'synthetic-unexpected-token', accessScope: 'user', teamId: null, scopes: ['host:message:invoke'], @@ -1328,7 +1328,7 @@ describe('routes/profile', () => { svc.getUserAgents.mockResolvedValue({ userId: 'body-user', agentNames: ['agent-a'] }) svc.getTeamAgents.mockResolvedValue({ teamId: 't1', agentNames: ['agent-a'] }) rpcMock.issueRpcAccessToken.mockReturnValue({ - token: 'body-user-token', + token: 'synthetic-body-user-token', accessScope: 'user', teamId: null, scopes: ['host:message:invoke'], diff --git a/control-api/test/services.directory.invitationList.test.ts b/control-api/test/services.directory.invitationList.test.ts index 4eee900919..ae9021b9db 100644 --- a/control-api/test/services.directory.invitationList.test.ts +++ b/control-api/test/services.directory.invitationList.test.ts @@ -31,7 +31,7 @@ describe('pending invitation list projection', () => { invitee_name: 'Invitee', email: 'invitee@example.com', role: 'member', - token: 'live-secret-capability', + token: 'synthetic-live-secret-capability', status: 'pending', purpose: 'member_invitation', created_at: new Date('2026-08-10T00:00:00Z'), diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index ac0ef1eb33..aa22455ec6 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.155", + "version": "0.1.156", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.155", + "version": "0.1.156", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index 6b8a83fade..95b4d67d45 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.155", + "version": "0.1.156", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index 1d8c589b9d..96ddf1cae0 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.11.0', - externalRestApiVersion: '0.1.155', + externalRestApiVersion: '0.1.156', rpcProxyVersion: '0.1.133', desktopVersion: '0.11.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/test/passwordAdmissionError.test.ts b/external-rest-api/test/passwordAdmissionError.test.ts index 2f6fa4d92c..adf3a488e8 100644 --- a/external-rest-api/test/passwordAdmissionError.test.ts +++ b/external-rest-api/test/passwordAdmissionError.test.ts @@ -44,7 +44,7 @@ describe('password admission error forwarding', () => { const response = await request(buildApp()) .post('/api/v1/auth/password-login') - .send({ email: 'user@example.invalid', password: 'valid-password' }) + .send({ email: 'user@example.invalid', password: 'synthetic-valid-password' }) expect(response.status).toBe(503) expect(response.headers['cache-control']).toBe('no-store') @@ -69,7 +69,7 @@ describe('password admission error forwarding', () => { const response = await request(buildApp()) .post('/api/v1/auth/password-login') - .send({ email: 'user@example.invalid', password: 'valid-password' }) + .send({ email: 'user@example.invalid', password: 'synthetic-valid-password' }) expect(response.status).toBe(503) expect(response.headers['retry-after']).toBe('5') @@ -82,7 +82,7 @@ describe('password admission error forwarding', () => { const response = await request(buildApp()) .post('/api/v1/auth/password-login') - .send({ email: 'user@example.invalid', password: 'valid-password' }) + .send({ email: 'user@example.invalid', password: 'synthetic-valid-password' }) expect(response.status).toBe(429) expect(response.headers['retry-after']).toBe('7') diff --git a/external-rest-api/test/routes.auth.test.ts b/external-rest-api/test/routes.auth.test.ts index cb037ae56e..7d187a1855 100644 --- a/external-rest-api/test/routes.auth.test.ts +++ b/external-rest-api/test/routes.auth.test.ts @@ -41,7 +41,7 @@ describe('routes/auth password-login', () => { const res = await request(buildApp()) .post('/api/v1/auth/password-login') - .send({ email: 'user@example.invalid', password: 'wrong-password' }) + .send({ email: 'user@example.invalid', password: 'synthetic-wrong-password' }) expect(res.status).toBe(401) expect(res.body).toEqual({ error: 'invalid_credentials' }) @@ -59,7 +59,7 @@ describe('routes/auth password-login', () => { const res = await request(buildApp()) .post('/api/v1/auth/password-login') - .send({ email: 'retired@example.invalid', password: 'wrong-password' }) + .send({ email: 'retired@example.invalid', password: 'synthetic-wrong-password' }) expect(res.status).toBe(401) expect(res.body).toEqual({ error: 'invalid_credentials' }) @@ -85,7 +85,7 @@ describe('routes/auth password-login', () => { const res = await request(buildApp()) .post('/api/v1/auth/password-login') - .send({ email: 'active@example.invalid', password: 'valid-password' }) + .send({ email: 'active@example.invalid', password: 'synthetic-valid-password' }) expect(res.status).toBe(401) expect(res.body).toEqual({ error: 'invalid_credentials' }) @@ -125,7 +125,7 @@ describe('routes/auth password-login', () => { .post('/api/v1/auth/password-login') .set('origin', 'http://localhost:3001') .set('x-forwarded-proto', 'https') - .send({ email: 'user@example.invalid', password: 'correct-password' }) + .send({ email: 'user@example.invalid', password: 'synthetic-correct-password' }) .expect(200) expect(res.body.token).toBeUndefined() @@ -155,7 +155,7 @@ describe('routes/auth password-login', () => { const res = await request(buildApp()) .post('/api/v1/auth/password-login') - .send({ email: 'user@example.invalid', password: 'correct-password' }) + .send({ email: 'user@example.invalid', password: 'synthetic-correct-password' }) .expect(200) expect(res.body.token).toBe('desktop-session-test-token') diff --git a/external-rest-api/test/routes.controlApiParamEncoding.test.ts b/external-rest-api/test/routes.controlApiParamEncoding.test.ts index 3624c48853..76ad9157cf 100644 --- a/external-rest-api/test/routes.controlApiParamEncoding.test.ts +++ b/external-rest-api/test/routes.controlApiParamEncoding.test.ts @@ -53,7 +53,7 @@ async function rawRequest(options: { path: options.path, method: options.method, headers: { - authorization: `Bearer ${options.token ?? 'session-token'}`, + authorization: `Bearer ${options.token ?? 'synthetic-session-token'}`, ...(payload === undefined ? {} : { @@ -238,7 +238,7 @@ describe('caller-controlled control-api path components stay in one segment', () jsonResponse({ team_id: 'B/C?D', role: 'member', team_name: 'Team B' }) ) } - return Promise.resolve(jsonResponse({ token: 'next-token' })) + return Promise.resolve(jsonResponse({ token: 'synthetic-next-token' })) }) const alias = await rawRequest({ diff --git a/external-rest-api/test/routes.controlApiPathExposure.test.ts b/external-rest-api/test/routes.controlApiPathExposure.test.ts index 468f9bd784..1ae837a7f0 100644 --- a/external-rest-api/test/routes.controlApiPathExposure.test.ts +++ b/external-rest-api/test/routes.controlApiPathExposure.test.ts @@ -112,7 +112,7 @@ async function expectProductShapedDownloadReachesStub(): Promise { const positive = await rawRequest({ method: 'GET', path: SFS_DOWNLOAD_POSITIVE, - token: 'session-token', + token: 'synthetic-session-token', }) expect(positive.status).toBe(200) @@ -125,7 +125,9 @@ async function expectProductShapedDownloadReachesStub(): Promise { ) expect(upstream.search).toBe('?path=notes%2Fplan.md') expect(init.method).toBe('GET') - expect((init.headers as Record)['x-user-session-token']).toBe('session-token') + expect((init.headers as Record)['x-user-session-token']).toBe( + 'synthetic-session-token' + ) } describe('control-api path exposure through public aliases', () => { @@ -145,7 +147,7 @@ describe('control-api path exposure through public aliases', () => { const alias = await rawRequest({ method: 'POST', path: RESEND_ALIAS, - token: 'session-token', + token: 'synthetic-session-token', correlationId: 'resend-alias-correlation', }) @@ -153,7 +155,7 @@ describe('control-api path exposure through public aliases', () => { expect(alias.body).toEqual(invalidRequestEnvelope('resend-alias-correlation')) // The alias carried a valid session: the 400 is the path guard's client // error, not the auth gate answering 401. - expect(mocks.verifyToken).toHaveBeenCalledWith('session-token') + expect(mocks.verifyToken).toHaveBeenCalledWith('synthetic-session-token') expect(mocks.controlApiFetch).not.toHaveBeenCalled() // Positive witness in the same test: the same app, router, auth middleware @@ -161,7 +163,7 @@ describe('control-api path exposure through public aliases', () => { const positive = await rawRequest({ method: 'POST', path: RESEND_POSITIVE, - token: 'session-token', + token: 'synthetic-session-token', }) expect(positive.status).toBe(200) @@ -172,7 +174,9 @@ describe('control-api path exposure through public aliases', () => { `${PUBLIC_API_PREFIX}/external/members/invitations/${INVITATION_ID}/resend` ) expect(init.method).toBe('POST') - expect((init.headers as Record)['x-user-session-token']).toBe('session-token') + expect((init.headers as Record)['x-user-session-token']).toBe( + 'synthetic-session-token' + ) }) it('rejects the encoded SFS resolve alias before fetch while a product-shaped download stays live', async () => { @@ -181,13 +185,13 @@ describe('control-api path exposure through public aliases', () => { const alias = await rawRequest({ method: 'GET', path: SFS_RESOLVE_ALIAS, - token: 'session-token', + token: 'synthetic-session-token', correlationId: 'sfs-resolve-alias-correlation', }) expect(alias.status).toBe(400) expect(alias.body).toEqual(invalidRequestEnvelope('sfs-resolve-alias-correlation')) - expect(mocks.verifyToken).toHaveBeenCalledWith('session-token') + expect(mocks.verifyToken).toHaveBeenCalledWith('synthetic-session-token') expect(mocks.controlApiFetch).not.toHaveBeenCalled() await expectProductShapedDownloadReachesStub() @@ -199,13 +203,13 @@ describe('control-api path exposure through public aliases', () => { const alias = await rawRequest({ method: 'GET', path: SFS_CAPABILITIES_ALIAS, - token: 'session-token', + token: 'synthetic-session-token', correlationId: 'sfs-capabilities-alias-correlation', }) expect(alias.status).toBe(400) expect(alias.body).toEqual(invalidRequestEnvelope('sfs-capabilities-alias-correlation')) - expect(mocks.verifyToken).toHaveBeenCalledWith('session-token') + expect(mocks.verifyToken).toHaveBeenCalledWith('synthetic-session-token') expect(mocks.controlApiFetch).not.toHaveBeenCalled() await expectProductShapedDownloadReachesStub() @@ -217,7 +221,7 @@ describe('control-api path exposure through public aliases', () => { const response = await rawRequest({ method: 'GET', path: SFS_ENCODED_QUESTION_MARK, - token: 'session-token', + token: 'synthetic-session-token', }) expect(response.status).toBe(200) diff --git a/external-rest-api/test/routes.invitations.test.ts b/external-rest-api/test/routes.invitations.test.ts index 8fe1de3345..3ff4416d76 100644 --- a/external-rest-api/test/routes.invitations.test.ts +++ b/external-rest-api/test/routes.invitations.test.ts @@ -60,10 +60,10 @@ describe('routes/invitations', () => { .post('/invitations/password') .set('x-evenfire-session-contract', 'v2') .send({ - token: 'invitation-link-token', + token: 'synthetic-invitation-link-token', email: 'invitee@example.com', invitationId: 'inv-1', - password: 'user123!', + password: 'synthetic-user123!', }) .expect(200) @@ -77,10 +77,10 @@ describe('routes/invitations', () => { expect(String(res.headers['set-cookie'])).toContain('HttpOnly') expect(authTokenMock.verifyToken).not.toHaveBeenCalled() expect(invitationsServiceMock.getInvitationByToken).toHaveBeenCalledWith( - 'invitation-link-token' + 'synthetic-invitation-link-token' ) expect(invitationsServiceMock.acceptInvitation).toHaveBeenCalledWith( - 'invitation-link-token', + 'synthetic-invitation-link-token', 'invitee@example.com', 'v2' ) @@ -91,7 +91,7 @@ describe('routes/invitations', () => { sessionToken: 'synthetic-invited-session-token', }, 'inv-1', - 'user123!' + 'synthetic-user123!' ) expect(invitationsServiceMock.setupInvitationPasswordWithToken).not.toHaveBeenCalled() }) @@ -422,8 +422,8 @@ describe('routes/invitations', () => { }) it.each([ - ['bearer header', 'authorization', 'Bearer stale-token'], - ['profile session cookie', 'cookie', 'profile_session=stale-token'], + ['bearer header', 'authorization', 'Bearer synthetic-stale-token'], + ['profile session cookie', 'cookie', 'profile_session=synthetic-stale-token'], ])( 'fails closed with 401 when an invalid %s is presented alongside an invitation link', async (_source, header, value) => { @@ -452,18 +452,18 @@ describe('routes/invitations', () => { .set('x-forwarded-for', '203.0.113.20') .set(header, value) .send({ - token: 'invitation-link-token', + token: 'synthetic-invitation-link-token', email: 'invitee@example.com', invitationId: 'inv-1', - password: 'user123!', + password: 'synthetic-user123!', }) .expect(401) expect(res.body).toEqual({ error: 'Unauthorized' }) // Liveness: the presented credential was verified and rejected. - expect(authTokenMock.verifyToken).toHaveBeenCalledWith('stale-token') + expect(authTokenMock.verifyToken).toHaveBeenCalledWith('synthetic-stale-token') expect(invitationsServiceMock.getInvitationByToken).toHaveBeenCalledWith( - 'invitation-link-token' + 'synthetic-invitation-link-token' ) expect(invitationsServiceMock.acceptInvitation).not.toHaveBeenCalled() expect(invitationsServiceMock.setupInvitationPassword).not.toHaveBeenCalled() @@ -516,17 +516,17 @@ describe('routes/invitations', () => { for (const [name, value] of Object.entries(headers)) pending = pending.set(name, value) const res = await pending .send({ - token: 'invitation-link-token', + token: 'synthetic-invitation-link-token', email: 'invitee@example.com', invitationId: 'inv-1', - password: 'user123!', + password: 'synthetic-user123!', }) .expect(401) expect(res.body).toEqual({ error: 'Unauthorized' }) // Liveness: the route reached the credential decision. expect(invitationsServiceMock.getInvitationByToken).toHaveBeenCalledWith( - 'invitation-link-token' + 'synthetic-invitation-link-token' ) expect(authTokenMock.verifyToken).not.toHaveBeenCalled() expect(invitationsServiceMock.acceptInvitation).not.toHaveBeenCalled() @@ -552,7 +552,7 @@ describe('routes/invitations', () => { .set('x-forwarded-for', '203.0.113.37') .set('authorization', 'Bearer fresh-session-token') .set('cookie', 'profile_session=synthetic-cookie-session') - .send({ invitationId: 'inv-1', password: 'user123!' }) + .send({ invitationId: 'inv-1', password: 'synthetic-user123!' }) .expect(200) expect(authTokenMock.verifyToken).toHaveBeenCalledTimes(1) @@ -577,7 +577,7 @@ describe('routes/invitations', () => { .set('authorization', 'Bearer fresh-session-token') .send({ invitationId: 'inv-1', - password: 'user123!', + password: 'synthetic-user123!', }) .expect(200) @@ -590,7 +590,7 @@ describe('routes/invitations', () => { sessionToken: 'fresh-session-token', }, 'inv-1', - 'user123!' + 'synthetic-user123!' ) }) @@ -609,7 +609,7 @@ describe('routes/invitations', () => { const res = await request(makeApp()) .post('/invitations/accept') - .send({ token: 'invitation-link-token', email: 'invitee@example.com' }) + .send({ token: 'synthetic-invitation-link-token', email: 'invitee@example.com' }) .expect(200) expect(res.body.token).toBeUndefined() @@ -632,7 +632,7 @@ describe('routes/invitations', () => { teamId: 'team-1', teamName: 'Team 1', role: 'member', - token: 'first-session-token', + token: 'synthetic-first-session-token', }, }) .mockResolvedValueOnce({ @@ -650,16 +650,18 @@ describe('routes/invitations', () => { const first = await request(makeApp()) .post('/invitations/accept') .set('x-forwarded-for', '198.51.100.201') - .send({ token: 'invitation-link-token', email: 'invitee@example.com' }) + .send({ token: 'synthetic-invitation-link-token', email: 'invitee@example.com' }) .expect(200) const replay = await request(makeApp()) .post('/invitations/accept') .set('x-forwarded-for', '198.51.100.202') - .send({ token: 'invitation-link-token', email: 'invitee@example.com' }) + .send({ token: 'synthetic-invitation-link-token', email: 'invitee@example.com' }) .expect(200) // Positive control: the first acceptance still publishes the session. - expect(String(first.headers['set-cookie'])).toContain('profile_session=first-session-token') + expect(String(first.headers['set-cookie'])).toContain( + 'profile_session=synthetic-first-session-token' + ) expect(first.body).not.toHaveProperty('replay') expect(replay.body).toMatchObject({ accepted: true, @@ -699,7 +701,7 @@ describe('routes/invitations', () => { teamId: null, teamName: null, role: 'member', - token: 'invited-session-token', + token: 'synthetic-invited-session-token', }, }) invitationsServiceMock.setupInvitationPassword.mockResolvedValue({ @@ -710,10 +712,10 @@ describe('routes/invitations', () => { .post('/invitations/password') .set('x-forwarded-for', '198.51.100.203') .send({ - token: 'accepted-invitation-link', + token: 'synthetic-accepted-invitation-link', email: 'invitee@example.com', invitationId: 'inv-1', - password: 'user123!', + password: 'synthetic-user123!', }) .expect(400) @@ -733,10 +735,10 @@ describe('routes/invitations', () => { .post('/invitations/password') .set('x-forwarded-for', '198.51.100.204') .send({ - token: 'pending-invitation-link', + token: 'synthetic-pending-invitation-link', email: 'invitee@example.com', invitationId: 'inv-1', - password: 'user123!', + password: 'synthetic-user123!', }) .expect(200) @@ -744,10 +746,10 @@ describe('routes/invitations', () => { { userId: 'user-1', email: 'invitee@example.com', - sessionToken: 'invited-session-token', + sessionToken: 'synthetic-invited-session-token', }, 'inv-1', - 'user123!' + 'synthetic-user123!' ) expect(String(pending.headers['set-cookie'])).toContain('profile_session=;') expect(invitationsServiceMock.acceptInvitation).toHaveBeenCalledTimes(2) @@ -780,7 +782,7 @@ describe('routes/invitations', () => { accepted: true, userId: 'user-1', email: 'invitee@example.com', - token: 'invited-session-token', + token: 'synthetic-invited-session-token', }, } }) @@ -794,7 +796,7 @@ describe('routes/invitations', () => { .post('/invitations/password') .set('x-forwarded-for', `198.51.100.${210 + index}`) .send({ - token: 'pending-invitation-link', + token: 'synthetic-pending-invitation-link', email: 'invitee@example.com', invitationId: 'inv-1', password, @@ -817,10 +819,10 @@ describe('routes/invitations', () => { .post('/invitations/password') .set('x-forwarded-for', '198.51.100.220') .send({ - token: 'pending-invitation-link', + token: 'synthetic-pending-invitation-link', email: 'invitee@example.com', invitationId: 'inv-1', - password: 'user123!', + password: 'synthetic-user123!', }) .expect(200) expect(String(accepted.headers['set-cookie'])).toContain('profile_session=;') @@ -831,10 +833,10 @@ describe('routes/invitations', () => { { userId: 'user-1', email: 'invitee@example.com', - sessionToken: 'invited-session-token', + sessionToken: 'synthetic-invited-session-token', }, 'inv-1', - 'user123!' + 'synthetic-user123!' ) // The consumed state is real: the same fallback now receives the replay @@ -843,10 +845,10 @@ describe('routes/invitations', () => { .post('/invitations/password') .set('x-forwarded-for', '198.51.100.221') .send({ - token: 'pending-invitation-link', + token: 'synthetic-pending-invitation-link', email: 'invitee@example.com', invitationId: 'inv-1', - password: 'user123!', + password: 'synthetic-user123!', }) .expect(400) expect(replayed.body).toEqual({ @@ -877,7 +879,7 @@ describe('routes/invitations', () => { invitationsServiceMock.acceptInvitation .mockResolvedValueOnce(acceptedWithoutSession) .mockResolvedValueOnce({ - data: { ...acceptedWithoutSession.data, token: 'invited-session-token' }, + data: { ...acceptedWithoutSession.data, token: 'synthetic-invited-session-token' }, }) invitationsServiceMock.setupInvitationPassword.mockResolvedValue({ data: { id: 'inv-1', passwordUpdated: true }, @@ -887,10 +889,10 @@ describe('routes/invitations', () => { .post('/invitations/password') .set('x-forwarded-for', '198.51.100.205') .send({ - token: 'pending-invitation-link', + token: 'synthetic-pending-invitation-link', email: 'invitee@example.com', invitationId: 'inv-1', - password: 'user123!', + password: 'synthetic-user123!', }) .expect(502) @@ -904,10 +906,10 @@ describe('routes/invitations', () => { .post('/invitations/password') .set('x-forwarded-for', '198.51.100.206') .send({ - token: 'pending-invitation-link', + token: 'synthetic-pending-invitation-link', email: 'invitee@example.com', invitationId: 'inv-1', - password: 'user123!', + password: 'synthetic-user123!', }) .expect(200) expect(invitationsServiceMock.setupInvitationPassword).toHaveBeenCalledTimes(1) @@ -919,7 +921,7 @@ describe('routes/invitations', () => { .post('/invitations/password') .send({ invitationId: 'inv-1', - password: 'user123!', + password: 'synthetic-user123!', }) .expect(401) diff --git a/external-rest-api/test/routes.teamSwitchCompatibility.test.ts b/external-rest-api/test/routes.teamSwitchCompatibility.test.ts index bd643e0b29..21324181d6 100644 --- a/external-rest-api/test/routes.teamSwitchCompatibility.test.ts +++ b/external-rest-api/test/routes.teamSwitchCompatibility.test.ts @@ -39,7 +39,7 @@ describe('legacy team-switch compatibility', () => { sessionContract: 'v1', } } - if (token === 'team-b-token') { + if (token === 'synthetic-team-b-token') { return { userId: 'user-1', email: 'user@example.com', @@ -64,10 +64,10 @@ describe('legacy team-switch compatibility', () => { userSessionToken: 'team-a-token', body: { userId: 'user-1', teamId: 'team-b', role: 'admin' }, }) - return { token: 'team-b-token', sessionContract: 'v1', deprecated: true } + return { token: 'synthetic-team-b-token', sessionContract: 'v1', deprecated: true } } if (method === 'PUT' && path === '/external/teams/team-b/name') { - expect(options).toMatchObject({ userSessionToken: 'team-b-token' }) + expect(options).toMatchObject({ userSessionToken: 'synthetic-team-b-token' }) return { id: 'team-b', name: 'Renamed Team B' } } throw new Error(`unexpected Control API request: ${method} ${path}`) @@ -80,7 +80,7 @@ describe('legacy team-switch compatibility', () => { .send({ teamId: 'team-b' }) .expect(200) - expect(switchResponse.body.token).toBe('team-b-token') + expect(switchResponse.body.token).toBe('synthetic-team-b-token') await request(app()) .put('/team/name') diff --git a/external-rest-api/test/services.externalUserClientIp.test.ts b/external-rest-api/test/services.externalUserClientIp.test.ts index 391d0dcadf..c873f37b26 100644 --- a/external-rest-api/test/services.externalUserClientIp.test.ts +++ b/external-rest-api/test/services.externalUserClientIp.test.ts @@ -25,15 +25,15 @@ describe('external-user client IP forwarding', () => { .mockResolvedValueOnce({ token: 'renewed', expiresInSeconds: 3600 }) .mockResolvedValueOnce({ revoked: true }) - await renewUserSession('session-token', clientIp) - await logoutUserSession('session-token', clientIp) + await renewUserSession('synthetic-session-token', clientIp) + await logoutUserSession('synthetic-session-token', clientIp) expect(client.controlApiRequest).toHaveBeenNthCalledWith( 1, 'POST', '/external/auth/session/renew', { - userSessionToken: 'session-token', + userSessionToken: 'synthetic-session-token', extraHeaders: headers, } ) @@ -42,7 +42,7 @@ describe('external-user client IP forwarding', () => { 'POST', '/external/auth/session/logout', { - userSessionToken: 'session-token', + userSessionToken: 'synthetic-session-token', extraHeaders: headers, } ) @@ -50,13 +50,18 @@ describe('external-user client IP forwarding', () => { it('forwards the trusted client IP for credential and RPC issuance boundaries', async () => { client.controlApiRequest - .mockResolvedValueOnce({ token: 'google-session' }) - .mockResolvedValueOnce({ token: 'password-session' }) - .mockResolvedValueOnce({ token: 'rpc-token' }) + .mockResolvedValueOnce({ token: 'synthetic-google-session' }) + .mockResolvedValueOnce({ token: 'synthetic-password-session' }) + .mockResolvedValueOnce({ token: 'synthetic-rpc-token' }) await loginWithGoogle({ idToken: 'google-token' }, clientIp) await loginWithPassword('user@example.test', 'password', undefined, clientIp) - await issueRpcAccessToken('session-token', ['host:message:invoke'], ['agent-a'], clientIp) + await issueRpcAccessToken( + 'synthetic-session-token', + ['host:message:invoke'], + ['agent-a'], + clientIp + ) expect(client.controlApiRequest).toHaveBeenNthCalledWith( 1, @@ -81,16 +86,16 @@ describe('external-user client IP forwarding', () => { it('forwards the trusted client IP only on the session-token calls for team changes', async () => { client.controlApiRequest .mockResolvedValueOnce({ id: 'team-1', name: 'Team One' }) - .mockResolvedValueOnce({ token: 'created-token' }) + .mockResolvedValueOnce({ token: 'synthetic-created-token' }) .mockResolvedValueOnce({ team_id: 'team-2', team_name: 'Team Two', role: 'member' }) - .mockResolvedValueOnce({ token: 'switched-token' }) + .mockResolvedValueOnce({ token: 'synthetic-switched-token' }) await createTeamForUser( - { userId: 'user-1', email: 'user@example.test', sessionToken: 'session-token' }, + { userId: 'user-1', email: 'user@example.test', sessionToken: 'synthetic-session-token' }, 'Team One', clientIp ) - await switchTeam('user-1', 'user@example.test', 'team-2', 'session-token', clientIp) + await switchTeam('user-1', 'user@example.test', 'team-2', 'synthetic-session-token', clientIp) expect(client.controlApiRequest).toHaveBeenNthCalledWith( 2, diff --git a/external-rest-api/test/services.invitationsService.test.ts b/external-rest-api/test/services.invitationsService.test.ts index f607db5c87..61a812b7e6 100644 --- a/external-rest-api/test/services.invitationsService.test.ts +++ b/external-rest-api/test/services.invitationsService.test.ts @@ -24,16 +24,16 @@ describe('invitation service error and token boundaries', () => { () => acceptInvitation('flow-token', 'user@example.com', 'v2'), () => setupInvitationPassword( - { userId: 'user-1', email: 'user@example.com', sessionToken: 'session-token' }, + { userId: 'user-1', email: 'user@example.com', sessionToken: 'synthetic-session-token' }, 'inv-1', - 'valid-password' + 'synthetic-valid-password' ), () => setupInvitationPasswordWithToken( 'flow-token', 'user@example.com', 'inv-1', - 'valid-password' + 'synthetic-valid-password' ), ]) { client.request.mockRejectedValueOnce(unavailable) @@ -52,9 +52,9 @@ describe('invitation service error and token boundaries', () => { await expect( setupInvitationPassword( - { userId: 'user-1', email: 'user@example.com', sessionToken: 'session-token' }, + { userId: 'user-1', email: 'user@example.com', sessionToken: 'synthetic-session-token' }, 'inv-1', - 'valid-password' + 'synthetic-valid-password' ) ).resolves.toEqual({ error: 'password_already_set' }) }) @@ -64,14 +64,14 @@ describe('invitation service error and token boundaries', () => { id: 'inv-1', email: 'user@example.com', passwordUpdated: true, - token: 'must-never-reach-browser-route', + token: 'synthetic-must-never-reach-browser-route', }) const result = await setupInvitationPasswordWithToken( 'flow-token', 'user@example.com', 'inv-1', - 'valid-password' + 'synthetic-valid-password' ) expect(result.data).toMatchObject({ id: 'inv-1', passwordUpdated: true }) diff --git a/scripts/tests/test-minikube-t2-contract.sh b/scripts/tests/test-minikube-t2-contract.sh index bb61235068..c97f126704 100755 --- a/scripts/tests/test-minikube-t2-contract.sh +++ b/scripts/tests/test-minikube-t2-contract.sh @@ -701,7 +701,7 @@ fixture_base="$(git -C "$fixture_repo" rev-parse HEAD)" printf '%s\n' "const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL ?? 'postgresql://postgres@127.0.0.1/postgres'" printf '%s\n' "const databaseUrl = 'postgresql://postgres@127.0.0.7:5432/postgres'" printf '%s\n' "const localDatabaseUrl = 'postgresql://localhost/tracing_test'" - printf '%s\n' "const issued = { token: 'replacement-token', password: 'valid-password' }" + printf '%s\n' "const issued = { token: 'synthetic-replacement-token', password: 'synthetic-valid-password' }" printf '%s\n' "const callbackUrl = 'http://127.0.0.1:36148/callback'" } >"$fixture_repo/control-api/test/example.realPostgres.integration.test.ts" if ! T2_PUBLIC_ROOT="$fixture_repo" T2_PUBLIC_BASE_REF="$fixture_base" \ @@ -710,9 +710,9 @@ if ! T2_PUBLIC_ROOT="$fixture_repo" T2_PUBLIC_BASE_REF="$fixture_base" \ exit 1 fi -assert_public_boundary_rejects_postgres_fixture() { +assert_public_boundary_rejects_source_fixture() { local name="$1" source_line="$2" expected_reason="$3" - local case_repo="$tmp/public-postgres-${name}" + local case_repo="$tmp/public-source-${name}" mkdir -p "$case_repo/control-api/test" git init -q -b dev "$case_repo" git -C "$case_repo" config user.email test@example.invalid @@ -722,52 +722,38 @@ assert_public_boundary_rejects_postgres_fixture() { git -C "$case_repo" commit -q -m base local case_base case_base="$(git -C "$case_repo" rev-parse HEAD)" - printf '%s\n' "$source_line" >"$case_repo/control-api/test/postgresFixture.test.ts" + printf '%s\n' "$source_line" >"$case_repo/control-api/test/sourceFixture.test.ts" if T2_PUBLIC_ROOT="$case_repo" T2_PUBLIC_BASE_REF="$case_base" \ bash "$ROOT/scripts/tests/test-minikube-t2-public-boundary.sh" \ >"$tmp/${name}.out" 2>"$tmp/${name}.err"; then - echo "FAIL: public boundary accepted unsafe PostgreSQL fixture: $name" >&2 + echo "FAIL: public boundary accepted unsafe source fixture: $name" >&2 exit 1 fi grep -Fq "$expected_reason" "$tmp/${name}.err" } -assert_public_boundary_rejects_postgres_fixture \ +assert_public_boundary_rejects_source_fixture \ nearby-admin-url \ "const adminUrl = 'postgresql://postgres@database.""internal/catalog'" \ 'private PostgreSQL URL' -assert_public_boundary_rejects_postgres_fixture \ +assert_public_boundary_rejects_source_fixture \ nearby-example \ "const example = 'postgresql://postgres@10.""20.30.40/catalog'" \ 'private PostgreSQL URL' -assert_public_boundary_rejects_postgres_fixture \ +assert_public_boundary_rejects_source_fixture \ later-private-url \ "const urls = ['postgresql://localhost/catalog', 'postgresql://database.""internal/catalog']" \ 'private PostgreSQL URL' -assert_public_boundary_rejects_postgres_fixture \ +assert_public_boundary_rejects_source_fixture \ credentialed-loopback \ "const fixture = 'postgresql://app:""secret@127.0.0.1/catalog'" \ 'credentialed PostgreSQL URL' - -contract_control_repo="$tmp/public-contract-control-repo" -mkdir -p "$contract_control_repo/scripts/tests" -git init -q -b dev "$contract_control_repo" -git -C "$contract_control_repo" config user.email test@example.invalid -git -C "$contract_control_repo" config user.name boundary-test -printf 'base\n' >"$contract_control_repo/README.md" -git -C "$contract_control_repo" add README.md -git -C "$contract_control_repo" commit -q -m base -contract_control_base="$(git -C "$contract_control_repo" rev-parse HEAD)" -printf '%s\n' \ - "const urls = ['postgresql://postgres@127.0.0.1/postgres', 'postgresql://database.""internal/catalog']" \ - >"$contract_control_repo/scripts/tests/test-minikube-t2-public-boundary.sh" -if T2_PUBLIC_ROOT="$contract_control_repo" T2_PUBLIC_BASE_REF="$contract_control_base" \ - bash "$ROOT/scripts/tests/test-minikube-t2-public-boundary.sh" \ - >"$tmp/contract-control.out" 2>"$tmp/contract-control.err"; then - echo 'FAIL: public boundary let one contract control mask a later unsafe URL' >&2 - exit 1 -fi -grep -Fq 'private PostgreSQL URL' "$tmp/contract-control.err" +# A test path does not exempt a credential value: only the shared fixture +# marker convention (for example a `synthetic-` value) does. +assert_public_boundary_rejects_source_fixture \ + unmarked-test-credential \ + "const issued = { pass""word: 'valid-password' }" \ + 'credential assignment' public_repo="$tmp/public-repo" mkdir -p "$public_repo" @@ -793,13 +779,15 @@ printf 'base\n' >"$malicious_repo/README.md" git -C "$malicious_repo" add README.md git -C "$malicious_repo" commit -q -m base malicious_base="$(git -C "$malicious_repo" rev-parse HEAD)" -cat >"$malicious_repo/materialized.txt" <<'EOF' -DATABASE_URL=postgresql://db_user:prod_password@127.0.0.1/postgres -PUBLIC_CALLBACK=http://127.0.0.1:18443/status -password: "ProdCustomerPassword123" -Authorization: Bearer abcdefghijklmnopqrstuvwxyz1234567890 ------BEGIN PRIVATE KEY----- -EOF +# Split literals keep these values out of this script's own diff, which the +# boundary scans; the materialized file holds them whole. +printf '%s\n' \ + 'DATABASE_URL=postgresql://db_user:''prod_password@127.0.0.1/postgres' \ + 'PUBLIC_CALLBACK=http://127.0.0.1:18443/status' \ + 'pass''word: "ProdCustomerPassword123"' \ + 'Authorization: Bear''er abcdefghijklmnopqrstuvwxyz1234567890' \ + '-----BEGIN PRIVATE'' KEY-----' \ + >"$malicious_repo/materialized.txt" printf 'empty\n' >"$malicious_repo/.env" if T2_PUBLIC_ROOT="$malicious_repo" T2_PUBLIC_BASE_REF="$malicious_base" \ bash "$ROOT/scripts/tests/test-minikube-t2-public-boundary.sh" 2>"$tmp/malicious-public.err"; then diff --git a/scripts/tests/test-minikube-t2-public-boundary.sh b/scripts/tests/test-minikube-t2-public-boundary.sh index 72dc868f0b..30cdc9f115 100755 --- a/scripts/tests/test-minikube-t2-public-boundary.sh +++ b/scripts/tests/test-minikube-t2-public-boundary.sh @@ -88,71 +88,6 @@ safe_source_paths = { source_fixture_path = re.compile( r"(?i)(?:^|/)(?:tests?|__tests__)/|(?:\.test|\.spec|\.integration\.test)\.(?:cjs|cts|js|jsx|mjs|mts|ts|tsx)$" ) -safe_fixture_literals = { - "body-user-token", - "consumed-member-token", - "correct-password", - "created-token", - "current-password", - "flow-token", - "google-session", - "google-session-jwt", - "inert-until-activation", - "invitation-link-token", - "invitation-token", - "live-secret-capability", - "member-registration-flow-token", - "member-setup-token", - "must-never-leave-control-api", - "must-never-reach-browser-route", - "must-never-serialize", - "old-password", - "one-use-flow", - "password-reset-flow", - "password-reset-token", - "password-session", - "replacement-token", - "rotated-session", - "rpc-token", - "session-token", - "single-use-token", - "source-token", - "stale-token", - "successor", - "switched-token", - "team-b-token", - "unexpected-token", - "user123!", - "v2-token", - "valid-password", - "wrong-password", -} -contract_control_values = { - "private PostgreSQL URL": ( - "postgresql://postgres@127.0.0.1/postgres", - "postgres://secret@internal/var/run/service.sock", - "DATABASE_URL=%s://private-host:5432/db", - "DATABASE_URL=postgresql://db_user:prod_password@127.0.0.1/postgres", - ), - "credentialed PostgreSQL URL": ( - "DATABASE_URL=postgresql://db_user:prod_password@127.0.0.1/postgres", - ), - "private runtime URL": ( - "PUBLIC_CALLBACK=http://127.0.0.1:18443/status", - ), - "credential assignment": ( - "token: 'replacement-token'", - "password: 'valid-password'", - 'password: "ProdCustomerPassword123"', - ), - "bearer token": ( - "Authorization: Bearer abcdefghijklmnopqrstuvwxyz1234567890", - ), - "private key": ( - "-----BEGIN PRIVATE KEY-----", - ), -} - def is_source_fixture(path: str) -> bool: lowered = path.lower() return bool(source_fixture_path.search(lowered)) @@ -177,22 +112,6 @@ def is_synthetic_postgres_fixture_url(text: str) -> bool: except ValueError: return False -def safe_contract_control_value( - path: str, reason: str, value: str, match: re.Match[str] -) -> bool: - if path not in { - "scripts/tests/test-minikube-t2-contract.sh", - "scripts/tests/test-minikube-t2-public-boundary.sh", - }: - return False - for control in contract_control_values.get(reason, ()): - offset = value.find(control) - while offset >= 0: - if offset <= match.start() and match.end() <= offset + len(control): - return True - offset = value.find(control, offset + 1) - return False - def safe_source_fixture_value(path: str, reason: str, value: str, match: re.Match[str]) -> bool: if not is_source_fixture(path): return False @@ -208,9 +127,6 @@ def safe_source_fixture_value(path: str, reason: str, value: str, match: re.Matc if is_credentialed_postgres_url(matched_url): return False return is_synthetic_postgres_fixture_url(matched_url) - if reason == "credential assignment": - literal = match.group(1) if match.lastindex else "" - return literal in safe_fixture_literals return False for line in diff.splitlines(): @@ -353,8 +269,6 @@ for line in diff.splitlines(): continue if safe_source_fixture_value(current, reason, value, match): continue - if safe_contract_control_value(current, reason, value, match): - continue flagged = True break if flagged: From c7b9876f924fce3979a9e7e8ac0ac733d05c1b4c Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Fri, 23 Oct 2026 15:48:49 +0200 Subject: [PATCH 367/528] test(profile-ui): read the invitation password contract at runtime The Profile UI image builder type-checks the test files with only profile-ui/ in its build context, so importing the repository contract failed with TS2307. Read the document with readFileSync from the package root instead, as the control-api contract test already does. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/InvitationClient.reset.test.tsx | 13 +++++++++++-- profile-ui/package-lock.json | 4 ++-- profile-ui/package.json | 2 +- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/profile-ui/app/components/__tests__/InvitationClient.reset.test.tsx b/profile-ui/app/components/__tests__/InvitationClient.reset.test.tsx index 72929bfb25..fab2911365 100644 --- a/profile-ui/app/components/__tests__/InvitationClient.reset.test.tsx +++ b/profile-ui/app/components/__tests__/InvitationClient.reset.test.tsx @@ -1,9 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' import { PROFILE_ROUTES } from '@constants/routes' import { ProfileApiError } from '@lib/api' import type { InvitationPreview } from '@/app/types/api' -import contractDocument from '../../../../tests/contracts/invitation-password-public-errors.json' import { InvitationClient } from '../../invitations/[token]/InvitationClient' type ContractEntry = { @@ -18,7 +19,15 @@ type ContractEntry = { uiMessage: string } -const contract = contractDocument as Record +// Read at runtime, not imported: the image build type-checks this file with +// only profile-ui/ in its context, where the repository contract is absent. +// jsdom gives import.meta.url a non-file scheme, so resolve from the package root. +const contract = JSON.parse( + readFileSync( + resolve(process.cwd(), '../tests/contracts/invitation-password-public-errors.json'), + 'utf8' + ) +) as Record const mocks = vi.hoisted(() => ({ acceptInvitation: vi.fn(), diff --git a/profile-ui/package-lock.json b/profile-ui/package-lock.json index 73afd48f0c..137ea26544 100644 --- a/profile-ui/package-lock.json +++ b/profile-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-profile-ui", - "version": "0.1.188", + "version": "0.1.189", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-profile-ui", - "version": "0.1.188", + "version": "0.1.189", "dependencies": { "@clerum/desktop-app-links": "file:../packages/desktop-app-links", "@clerum/frontend-components": "file:../packages/frontend-components", diff --git a/profile-ui/package.json b/profile-ui/package.json index ef762db450..2c610989cb 100644 --- a/profile-ui/package.json +++ b/profile-ui/package.json @@ -1,6 +1,6 @@ { "name": "clerum-profile-ui", - "version": "0.1.188", + "version": "0.1.189", "private": true, "scripts": { "dev": "next dev", From 025bf43e3354ed5d1e56c094506b4ef3e00816c6 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Fri, 23 Oct 2026 16:38:14 +0200 Subject: [PATCH 368/528] fix: remove dead code flagged by CodeQL on PR #334 - control-api membership: drop the unused insertInvitationTeams import left after the invitation store became the single source. - migrationExecutionPolicy real-PG test: drop passwordMigrationPrivileges, whose only callers went with the historical Task 106 producer test in the PR1 migration fold. Runtime password privileges stay covered in db.realPostgresMigration.integration.test.ts. - external-rest-api teamService: the role pre-check GET result was assigned and never read; await it without the unused local. Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- .../src/services/directory/membership.ts | 1 - ...ionPolicy.realPostgres.integration.test.ts | 19 ------------------- external-rest-api/package-lock.json | 4 ++-- external-rest-api/package.json | 2 +- external-rest-api/src/releaseManifest.ts | 2 +- external-rest-api/src/services/teamService.ts | 4 ++-- 8 files changed, 9 insertions(+), 29 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index a58ec0225d..ec20421665 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.824", + "version": "0.1.825", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.824", + "version": "0.1.825", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 7b6dd017bc..c6a69e4f76 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.824", + "version": "0.1.825", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/directory/membership.ts b/control-api/src/services/directory/membership.ts index 7725d8b0d0..d7e2262656 100644 --- a/control-api/src/services/directory/membership.ts +++ b/control-api/src/services/directory/membership.ts @@ -19,7 +19,6 @@ import { getInvitationRecordByToken, getInvitationRecordByTokenOrId, insertInvitationForTeams, - insertInvitationTeams, invitationForTeamsResponse, invitationResponse, invitationUserIsRetired, diff --git a/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts index b56ed5fe71..4590275b5d 100644 --- a/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts +++ b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts @@ -241,25 +241,6 @@ async function withMigrationApplyObserver( } } -async function passwordMigrationPrivileges(pool: Pool): Promise { - const result = await pool.query<{ privilege: string; allowed: boolean }>( - `SELECT relation || ':' || privilege AS privilege, - has_table_privilege('control_api_runtime', relation, privilege) AS allowed - FROM (VALUES - ('password_identifier_state'::text, 'SELECT'::text), - ('password_identifier_state', 'INSERT'), - ('password_identifier_state', 'UPDATE'), - ('password_identifier_state', 'DELETE'), - ('password_verification_work', 'SELECT'), - ('password_verification_work', 'INSERT'), - ('password_verification_work', 'UPDATE'), - ('password_verification_work', 'DELETE') - ) AS required(relation, privilege)`, - [] - ) - return result.rows.map(row => `${row.privilege}:${row.allowed}`).sort() -} - async function versions(pool: Pool): Promise { const result = await pool.query<{ version: string }>( 'SELECT version FROM schema_migrations ORDER BY version' diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index aa22455ec6..7f26ffd4e2 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.156", + "version": "0.1.157", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.156", + "version": "0.1.157", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index 95b4d67d45..ed2541ebe9 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.156", + "version": "0.1.157", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index 96ddf1cae0..2c8649be5f 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.11.0', - externalRestApiVersion: '0.1.156', + externalRestApiVersion: '0.1.157', rpcProxyVersion: '0.1.133', desktopVersion: '0.11.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/src/services/teamService.ts b/external-rest-api/src/services/teamService.ts index af2bb43f4c..45c8101a7f 100644 --- a/external-rest-api/src/services/teamService.ts +++ b/external-rest-api/src/services/teamService.ts @@ -146,9 +146,9 @@ export async function updateMemberRole( if (targetUserId === auth.userId) { return { error: 'bad_request' } } - let target: { role: TeamRole } + // Existence and visibility check only: the PATCH below carries the change. try { - target = await controlApiRequest<{ role: TeamRole }>( + await controlApiRequest<{ role: TeamRole }>( 'GET', `/external/teams/${auth.teamId}/members/${encodeURIComponent(targetUserId)}/role`, { userSessionToken: auth.sessionToken } From eeddba9315f68beec0790cfd260ca741a68ba10e Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Fri, 23 Oct 2026 21:23:41 +0200 Subject: [PATCH 369/528] fix(deploy): match the migration Job identity to the stored Job The T2 bootstrap on a Kubernetes v1.35.1 profile died right after creating control-api-db-migrate with "active migration Job configuration annotation mismatch". The apiserver stores a defaulted Job: it adds spec.podReplacementPolicy=TerminatingOrFailed and drops the empty value of CONTROL_API_PG_CONNECTION_STRING, so the stored Job never matched the digest computed from the submitted manifest. - Emit CONTROL_API_PG_CONNECTION_STRING as a name-only env entry, which still overrides envFrom with an empty string and is the form the server stores. - Normalize the default podReplacementPolicy in both identity normalizers. - Bounds test: add a fixture with the defaults a v1.35.1 apiserver applied to this Job under create --dry-run=server, plus a non-default control that must still be rejected. The previous fake kubectl only echoed the submitted manifest, so no fixture ever carried server defaults. Co-Authored-By: Claude Opus 5.5 --- .../scripts/run-control-api-db-migration.sh | 11 +++++- .../test-control-api-migration-bounds.sh | 34 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/deploy/scripts/run-control-api-db-migration.sh b/deploy/scripts/run-control-api-db-migration.sh index bae2edb5fb..36b44f3fc4 100755 --- a/deploy/scripts/run-control-api-db-migration.sh +++ b/deploy/scripts/run-control-api-db-migration.sh @@ -245,6 +245,9 @@ verify_active_job_identity() { spec.delete("parallelism") if [nil, 1].include?(spec["parallelism"]) spec.delete("completions") if [nil, 1].include?(spec["completions"]) spec.delete("completionMode") if [nil, "NonIndexed"].include?(spec["completionMode"]) + # API server default without a podFailurePolicy (Kubernetes 1.34+ stores it). + spec.delete("podReplacementPolicy") if + [nil, "TerminatingOrFailed"].include?(spec["podReplacementPolicy"]) spec.delete("suspend") if [nil, false].include?(spec["suspend"]) template = spec.fetch("template") metadata = Marshal.load(Marshal.dump(template["metadata"] || {})) @@ -539,7 +542,10 @@ build_job_manifest() { } end migration_env.concat([ - { "name" => "CONTROL_API_PG_CONNECTION_STRING", "value" => "" }, + # No "value" key: the API server drops an empty value, and the stored Job + # must match this manifest for verify_active_job_identity. A name-only + # entry still overrides the envFrom value with an empty string. + { "name" => "CONTROL_API_PG_CONNECTION_STRING" }, { "name" => "CONTROL_API_MIGRATION_PG_HOST", "value" => "control-postgres.control-plane.svc.cluster.local", @@ -660,6 +666,9 @@ build_job_manifest() { normalized.delete("completions") if [nil, 1].include?(normalized["completions"]) normalized.delete("completionMode") if [nil, "NonIndexed"].include?(normalized["completionMode"]) + # API server default without a podFailurePolicy (Kubernetes 1.34+ stores it). + normalized.delete("podReplacementPolicy") if + [nil, "TerminatingOrFailed"].include?(normalized["podReplacementPolicy"]) normalized.delete("suspend") if [nil, false].include?(normalized["suspend"]) template = normalized.fetch("template") metadata = Marshal.load(Marshal.dump(template["metadata"] || {})) diff --git a/scripts/tests/test-control-api-migration-bounds.sh b/scripts/tests/test-control-api-migration-bounds.sh index d6ccb80b00..ee94d60e9d 100755 --- a/scripts/tests/test-control-api-migration-bounds.sh +++ b/scripts/tests/test-control-api-migration-bounds.sh @@ -316,6 +316,40 @@ if run_fixture active-env-order-mismatch >/dev/null; then fi mv "$fixture_root/reordered-matched.json" "$capture" +# The API server stores a defaulted Job, not the submitted manifest. These are the +# fields a Kubernetes v1.35.1 apiserver added or dropped for this Job under +# `create --dry-run=server` (T2 run1 on 92f6dc886 died on them): defaults are added +# and empty env values are omitted. The stored Job must still be reused; a +# non-default podReplacementPolicy must still be rejected. +server_default_job() { + ruby -rjson -e ' + job = JSON.parse(File.read(ARGV.fetch(0))) + spec = job["spec"] + spec.merge!("completionMode" => "NonIndexed", "completions" => 1, "manualSelector" => false, + "parallelism" => 1, "suspend" => false, + "podReplacementPolicy" => ARGV.fetch(1)) + pod = spec["template"]["spec"] + pod.merge!("dnsPolicy" => "ClusterFirst", "schedulerName" => "default-scheduler", + "serviceAccount" => pod["serviceAccountName"], "terminationGracePeriodSeconds" => 30) + pod["containers"].each do |container| + container.merge!("terminationMessagePath" => "/dev/termination-log", + "terminationMessagePolicy" => "File") + (container["env"] || []).each { |entry| entry.delete("value") if entry["value"] == "" } + end + File.write(ARGV.fetch(0), JSON.generate(job)) + ' "$1" "$2" +} +cp "$capture" "$fixture_root/server-default-matched.json" +server_default_job "$capture" "TerminatingOrFailed" || fail "could not build the server-defaulted fixture" +run_fixture active-server-defaulted-matches >/dev/null || \ + fail "active migration Job stored with API server defaults was not reused" +cp "$fixture_root/server-default-matched.json" "$capture" +server_default_job "$capture" "Failed" || fail "could not build the non-default fixture" +if run_fixture active-server-defaulted-mismatch >/dev/null; then + fail "active migration Job with a non-default podReplacementPolicy was accepted" +fi +mv "$fixture_root/server-default-matched.json" "$capture" + status_output="$(expect_fixture_failure 'existing migration Job status check' \ run_fixture active-status-error)" [[ "$status_output" == *'could not determine existing migration Job status'* ]] || \ From 796b827c16716de9e12b674863b1ef9ef2683cf5 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Mon, 26 Oct 2026 22:29:59 +0100 Subject: [PATCH 370/528] fix(deploy): read migration policy next to the migration script run-control-api-db-migration.sh read its Job bounds from control-api/src/migrations/migrationExecutionPolicy.json through REPO_ROOT. Deploy hosts that copy only deploy/ and scripts/ into their own tree run the script without control-api sources, so the gate stopped with "migration policy not found" before creating the Job. The script now reads deploy/scripts/control-api-migration-execution-policy.json, a byte-identical copy of the runtime policy (same SHA-256, so the clerum.io/migration-policy-sha256 Job annotation does not change). A control-api test and the migration bounds contract fail when the two files differ, as the runtime access profile TSVs in deploy/scripts are already pinned by control-api tests. Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- control-api/test/migrationExecutionPolicy.test.ts | 14 ++++++++++++++ .../control-api-migration-execution-policy.json | 11 +++++++++++ deploy/scripts/run-control-api-db-migration.sh | 3 +-- scripts/tests/test-control-api-migration-bounds.sh | 3 +++ 6 files changed, 32 insertions(+), 5 deletions(-) create mode 100644 deploy/scripts/control-api-migration-execution-policy.json diff --git a/control-api/package-lock.json b/control-api/package-lock.json index ec20421665..0c102d4f52 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.825", + "version": "0.1.826", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.825", + "version": "0.1.826", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index c6a69e4f76..0099df54b1 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.825", + "version": "0.1.826", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index 849a0e8ae7..db802fde47 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -72,6 +72,20 @@ describe('D34 migration execution policy', () => { }) }) + // Deploy hosts that copy only deploy/ and scripts/ (evenfire-infra, operator + // overlays) run the migration script without control-api sources, so the + // script reads the copy kept next to it. + it('keeps the deploy script policy byte-identical to the runtime policy', async () => { + const [runtimePolicy, deployPolicy] = await Promise.all([ + readFile(new URL('../src/migrations/migrationExecutionPolicy.json', import.meta.url)), + readFile( + new URL('../../deploy/scripts/control-api-migration-execution-policy.json', import.meta.url) + ), + ]) + expect(JSON.parse(runtimePolicy.toString('utf8'))).toEqual(MIGRATION_EXECUTION_POLICY) + expect(deployPolicy.equals(runtimePolicy)).toBe(true) + }) + it('classifies exactly 25 existing-table indexes and no fresh-table index', () => { expect(PR1_ONLINE_INDEX_PLAN).toHaveLength(25) expect(new Set(PR1_ONLINE_INDEX_PLAN.map(index => index.name))).toHaveLength(25) diff --git a/deploy/scripts/control-api-migration-execution-policy.json b/deploy/scripts/control-api-migration-execution-policy.json new file mode 100644 index 0000000000..0213fe705c --- /dev/null +++ b/deploy/scripts/control-api-migration-execution-policy.json @@ -0,0 +1,11 @@ +{ + "lockTimeoutMs": 10000, + "ordinaryStatementTimeoutMs": 15000, + "onlineIndexStatementTimeoutMs": 120000, + "idleInTransactionTimeoutMs": 15000, + "jobActiveDeadlineSeconds": 300, + "clientWaitSeconds": 360, + "terminationProofSeconds": 60, + "backoffLimit": 2, + "ttlSecondsAfterFinished": 600 +} diff --git a/deploy/scripts/run-control-api-db-migration.sh b/deploy/scripts/run-control-api-db-migration.sh index 36b44f3fc4..65a3ef5160 100755 --- a/deploy/scripts/run-control-api-db-migration.sh +++ b/deploy/scripts/run-control-api-db-migration.sh @@ -2,8 +2,7 @@ set -euo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." && pwd)" -MIGRATION_POLICY_FILE="${REPO_ROOT}/control-api/src/migrations/migrationExecutionPolicy.json" +MIGRATION_POLICY_FILE="${SCRIPT_DIR}/control-api-migration-execution-policy.json" RUNTIME_ACCESS_PROFILES_FILE="${SCRIPT_DIR}/control-api-runtime-access-profiles.tsv" RUNTIME_SEQUENCE_ACCESS_PROFILES_FILE="${SCRIPT_DIR}/control-api-runtime-sequence-access-profiles.tsv" MAINTENANCE_ACCESS_PROFILES_FILE="${SCRIPT_DIR}/trace-maintenance-runtime-access-profiles.tsv" diff --git a/scripts/tests/test-control-api-migration-bounds.sh b/scripts/tests/test-control-api-migration-bounds.sh index ee94d60e9d..afc63a214e 100755 --- a/scripts/tests/test-control-api-migration-bounds.sh +++ b/scripts/tests/test-control-api-migration-bounds.sh @@ -7,6 +7,7 @@ RUNNER_FILE="$ROOT_DIR/control-api/src/migrations/migrationRunner.ts" INDEX_PLAN_FILE="$ROOT_DIR/control-api/src/migrations/pr1OnlineIndexPlan.ts" DB_FILE="$ROOT_DIR/control-api/src/db.ts" DEPLOY_FILE="$ROOT_DIR/deploy/scripts/run-control-api-db-migration.sh" +DEPLOY_POLICY_FILE="$ROOT_DIR/deploy/scripts/control-api-migration-execution-policy.json" fail() { printf 'FAIL: %s\n' "$*" >&2 @@ -16,6 +17,8 @@ fail() { [ -f "$POLICY_FILE" ] || fail "canonical migration policy is missing" [ -f "$RUNNER_FILE" ] || fail "bounded migration runner is missing" [ -f "$INDEX_PLAN_FILE" ] || fail "PR1 online index plan is missing" +cmp -s "$POLICY_FILE" "$DEPLOY_POLICY_FILE" || \ + fail "deploy migration policy differs from the canonical control-api policy" node - "$POLICY_FILE" <<'NODE' const fs = require('node:fs') From 579688ff0cb077877cc255211bb845a68651dc8e Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Tue, 27 Oct 2026 13:43:04 +0100 Subject: [PATCH 371/528] fix(e2e): resolve the Host better-sqlite3 module in the gateway journey The RPC gateway journey (scripts/e2e/e2e-rpc-gateway-host-paths.sh, the T2 healthcheck) checks the persisted user turn by running node inside the chatllm pod with require("/app/node_modules/better-sqlite3"). That path does not exist in the chatllm image: the module lives in /app/mcp-host/node_modules, and the container works from /app/mcp-host. The check failed with MODULE_NOT_FOUND while the message and artifact journeys and the gateway access-log checks passed (T2 on 1f8fe6fb5: 11/12). Resolve the module by name from the container working directory instead of a fixed path. Against the same profile the journey passes 12/12, and the user turn is found exactly once in state.db. Co-Authored-By: Claude Opus 5.5 --- scripts/e2e/e2e-rpc-gateway-host-paths.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/e2e/e2e-rpc-gateway-host-paths.sh b/scripts/e2e/e2e-rpc-gateway-host-paths.sh index bf9bc2885c..ef110ad1a8 100755 --- a/scripts/e2e/e2e-rpc-gateway-host-paths.sh +++ b/scripts/e2e/e2e-rpc-gateway-host-paths.sh @@ -310,8 +310,10 @@ else if kctl exec "$pod" -n "$MCP_HOST_NS" -- sh -c 'command -v sqlite3' >/dev/null 2>&1; then count="$(kctl exec "$pod" -n "$MCP_HOST_NS" -- sqlite3 -readonly "$db_path" "$sql" || true)" else + # Resolve the Host's own better-sqlite3 from the container working directory + # (/app/mcp-host/node_modules in the chatllm image), not a fixed path. count="$(kctl exec "$pod" -n "$MCP_HOST_NS" -- env "DB=${db_path}" "SQL=${sql}" node -e \ - 'const D=require("/app/node_modules/better-sqlite3");const db=new D(process.env.DB,{readonly:true});db.pragma("busy_timeout=5000");const row=db.prepare(process.env.SQL).get();console.log(row?Object.values(row)[0]:0);' || true)" + 'const D=require("better-sqlite3");const db=new D(process.env.DB,{readonly:true});db.pragma("busy_timeout=5000");const row=db.prepare(process.env.SQL).get();console.log(row?Object.values(row)[0]:0);' || true)" fi count="$(printf '%s' "$count" | tr -d '[:space:]')" if [ "$count" = "1" ]; then From 3a94ab8a261d91053602f21241cbecfa4dd4fa2d Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Tue, 27 Oct 2026 17:22:13 +0100 Subject: [PATCH 372/528] fix: restore dev rate-limit parity on PR1 routes PR1 layered new frequency limits on routes that dev already serves: a per-user/per-IP Control API policy on top of dev's edge buckets (members, teams, users, invitations, preferences, OAuth grants, approval mediums, shared filesystems, directory), new limits on routes dev leaves unlimited (Google login, session verify, session token, RPC token, workflow reads), the shared 60/min Host message admission and the 30/min artifact read limits. A dev Desktop exceeds the RPC-token and team-read budgets during ordinary sign-in, so old clients regress against the new backend. Every route that exists in dev now carries exactly dev's limits. The new access, session and Host routes take the limit of their dev-equivalent endpoint: capabilities and resolve use the users edge family, the catalog keeps the team-directory 10/user budget, the External REST catalog gains the dev team-directory edge, and lifecycle, message-resolution and artifact-read routes have none, as their dev equivalents. Code that only served the removed limits is deleted: the per-route policy registry (the authenticated-context guard stays), the Host message admission service, its config knobs, the rpc-proxy artifact limiter and typed admission errors, and the rpc-proxy express-rate-limit dependency. No schema change: counters live in the generic rate_limit_buckets table. Tests assert the parity: >60 Host sends and >30 artifact reads proceed without a limiter, and the affected routes reach their business work with a denying PG limiter in place. Dev limits (password admission, password reset, team directory, workflow trigger, edge buckets) are unchanged. Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- control-api/src/config.ts | 9 - .../middleware/externalUserRateLimitPolicy.ts | 127 +-------- control-api/src/routes/external/access.ts | 33 +-- control-api/src/routes/external/auth.ts | 159 +++++------ control-api/src/routes/external/directory.ts | 21 -- .../src/routes/external/invitations.ts | 11 +- control-api/src/routes/external/members.ts | 239 ++++++++--------- .../routes/external/notifications.routes.ts | 9 +- .../src/routes/external/oauthGrants.ts | 4 - .../src/routes/external/sharedFilesystems.ts | 6 - control-api/src/routes/external/teams.ts | 34 +-- control-api/src/routes/external/users.ts | 7 - .../workflow-approval-mediums.routes.ts | 34 +-- .../routes/external/workflows/admission.ts | 7 +- control-api/src/routes/rpc-access/users.ts | 76 +----- .../src/services/hostMessageAdmission.ts | 55 ---- .../test/externalUserRateLimitPolicy.test.ts | 192 +------------- .../test/routes.external.oauthGrants.test.ts | 53 +--- ...s.external.workflowApprovalMediums.test.ts | 51 +--- ...s.externalAccess.rateLimitOrdering.test.ts | 207 ++++++++------- .../test/routes.externalAccess.test.ts | 15 +- .../test/routes.externalDirectory.test.ts | 40 +-- ...es.externalInvitations.unavailable.test.ts | 25 +- .../test/routes.externalMembers.test.ts | 50 +--- ....externalMembersInvitationContract.test.ts | 5 +- ...alNotificationPreferencesRateLimit.test.ts | 48 +--- ...utes.externalSessionAtomicExchange.test.ts | 9 - ...es.externalSessionRenewalRateLimit.test.ts | 40 +-- .../routes.externalSharedFilesystems.test.ts | 27 +- .../routes.externalTeamsInvitations.test.ts | 49 +--- .../test/routes.externalWorkflows.test.ts | 32 ++- .../test/routes.hostMessageAdmission.test.ts | 198 ++------------ control-api/test/routes.profile.test.ts | 144 ++++------ ...es.rpcAccessUsersArtifactAdmission.test.ts | 11 +- ...ccessUsersArtifactRateLimitDefense.test.ts | 97 ------- .../test/routes.rpcAccessUsersMcpHost.test.ts | 248 +++--------------- ...teLimiter.realPostgres.integration.test.ts | 78 +----- external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- external-rest-api/src/releaseManifest.ts | 4 +- external-rest-api/src/routes/access.ts | 11 +- external-rest-api/test/routes.access.test.ts | 40 ++- external-rest-api/test/routes.auth.test.ts | 26 +- rpc-proxy/package-lock.json | 56 +--- rpc-proxy/package.json | 3 +- .../src/__tests__/approval-artifacts.test.ts | 32 +-- .../host-message-admission-transport.test.ts | 49 +--- .../src/__tests__/sender-assignment.test.ts | 47 +--- .../src/__tests__/wake-and-hold-route.test.ts | 8 +- rpc-proxy/src/routes/rpc.ts | 56 ---- .../src/services/controlApiRestService.ts | 72 ----- rpc-proxy/test/routes.rpc.test.ts | 10 - .../services.controlApiHostAccess.test.ts | 6 +- 55 files changed, 685 insertions(+), 2197 deletions(-) delete mode 100644 control-api/src/services/hostMessageAdmission.ts delete mode 100644 control-api/test/routes.rpcAccessUsersArtifactRateLimitDefense.test.ts diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 41ca58423a..2448b01bef 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.828", + "version": "0.1.829", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.828", + "version": "0.1.829", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index f54b934cc3..03c4a55795 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.828", + "version": "0.1.829", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/config.ts b/control-api/src/config.ts index 024ab61a2a..3340e19b10 100644 --- a/control-api/src/config.ts +++ b/control-api/src/config.ts @@ -271,10 +271,6 @@ type Config = { // Stateless-agent wake endpoint: per-host wake rate limit + server-side // coalescence window for the wake-annotation projection. hostWakeRlPerMin: number - // Host artifact reads have an independent, user-and-Host-scoped budget. - // It is deliberately separate from the capacity-impacting wake budget. - hostArtifactReadRlPerMin: number - hostMessageRlPerMin: number hostWakeCoalesceWindowMs: number approvalMediumChallengeTtlSec: number telegramProviderEventChallengeTtlSec: number @@ -1244,11 +1240,6 @@ export const config: Config = { // bursts from a few concurrent devices (4 x 3 = 12) plus a real message. // Derivation is regression-guarded by test/config.hostWakeRateLimit.test.ts. hostWakeRlPerMin: positiveIntegerFromEnv('CONTROL_API_HOST_WAKE_RL_PER_MIN', 150), - // Owner-approved Host artifact-read policy. This durable control-plane - // budget is enforced after live user/Host authorization and is shared by - // list and download reads across rpc-proxy replicas. - hostArtifactReadRlPerMin: 30, - hostMessageRlPerMin: positiveIntegerFromEnv('CONTROL_API_HOST_MESSAGE_RL_PER_MIN', 60), hostWakeCoalesceWindowMs: Number(process.env.CONTROL_API_HOST_WAKE_COALESCE_WINDOW_MS || 2000), approvalMediumChallengeTtlSec: Number( process.env.WORKFLOW_APPROVAL_MEDIUM_CHALLENGE_TTL_SEC || 60 * 60 diff --git a/control-api/src/middleware/externalUserRateLimitPolicy.ts b/control-api/src/middleware/externalUserRateLimitPolicy.ts index 47d0687114..75ee7789ac 100644 --- a/control-api/src/middleware/externalUserRateLimitPolicy.ts +++ b/control-api/src/middleware/externalUserRateLimitPolicy.ts @@ -3,130 +3,7 @@ import { sendPublicApiError } from '../http/publicApiError.js' type AuthenticatedRequest = Request & { externalAuth?: { userId?: string } } -export type ExternalUserRateLimitOperation = - | 'session_lifecycle' - | 'invitation_mutation' - | 'invitation_sensitive_action' - | 'invitation_read' - | 'access_capabilities' - | 'oauth_grant_read' - | 'oauth_grant_mutation' - | 'member_read' - | 'member_mutation' - | 'shared_filesystem_read' - | 'workflow_approval_medium_read' - | 'workflow_approval_medium_mutation' - | 'workflow_read' - | 'notification_preference_read' - | 'notification_preference_mutation' - | 'authentication_attempt' - | 'session_verify' - | 'rpc_token' - | 'team_user_read' - | 'team_user_mutation' - -export type ExternalUserRateLimitStage = 'pre_auth' | 'authenticated' - -type Policy = Readonly<{ bucketType: string; maxPerMinute: number }> - -// These reuse the established 10/minute sensitive access envelope; no new -// production limit is selected here. -const POLICIES: Readonly> = { - session_lifecycle: { bucketType: 'external_session_lifecycle', maxPerMinute: 10 }, - invitation_mutation: { bucketType: 'external_invitation_mutation', maxPerMinute: 10 }, - invitation_sensitive_action: { - bucketType: 'external_invitation_sensitive_action', - maxPerMinute: 10, - }, - invitation_read: { bucketType: 'external_invitation_read', maxPerMinute: 10 }, - access_capabilities: { bucketType: 'external_access_capabilities', maxPerMinute: 10 }, - oauth_grant_read: { bucketType: 'external_oauth_grant_read', maxPerMinute: 30 }, - oauth_grant_mutation: { bucketType: 'external_oauth_grant_mutation', maxPerMinute: 10 }, - member_read: { bucketType: 'external_member_read', maxPerMinute: 30 }, - member_mutation: { bucketType: 'external_member_mutation', maxPerMinute: 10 }, - shared_filesystem_read: { - bucketType: 'external_shared_filesystem_read', - maxPerMinute: 30, - }, - workflow_approval_medium_read: { - bucketType: 'external_workflow_approval_medium_read', - maxPerMinute: 30, - }, - workflow_approval_medium_mutation: { - bucketType: 'external_workflow_approval_medium_mutation', - maxPerMinute: 10, - }, - workflow_read: { bucketType: 'external_workflow_read', maxPerMinute: 30 }, - notification_preference_read: { - bucketType: 'external_notification_preference_read', - maxPerMinute: 30, - }, - notification_preference_mutation: { - bucketType: 'external_notification_preference_mutation', - maxPerMinute: 10, - }, - authentication_attempt: { - bucketType: 'external_authentication_attempt', - maxPerMinute: 5, - }, - session_verify: { bucketType: 'external_session_verify', maxPerMinute: 10 }, - rpc_token: { bucketType: 'external_rpc_token', maxPerMinute: 10 }, - team_user_read: { bucketType: 'external_team_user_read', maxPerMinute: 30 }, - team_user_mutation: { bucketType: 'external_team_user_mutation', maxPerMinute: 10 }, -} - -function boundedIp(req: Request): string { - const forwardedClientIp = - req.internalService?.name === 'external-rest-api' - ? String(req.header('x-evenfire-client-ip') || '').trim() - : '' - const value = forwardedClientIp || req.ip || req.socket?.remoteAddress || 'unknown' - return String(value).slice(0, 128) -} - -function keyFor( - operation: ExternalUserRateLimitOperation, - stage: ExternalUserRateLimitStage, - req: AuthenticatedRequest -): string { - const policy = POLICIES[operation] - if (stage === 'pre_auth') return `${policy.bucketType}:ip:${boundedIp(req)}` - // Post-auth routes are composed after live authentication. The sentinel is - // deliberately counted if wiring is wrong rather than silently failing open. - return `${policy.bucketType}:user:${req.externalAuth?.userId || 'missing'}` -} - -function sendLimited(req: Request, res: Response, retryAfterSeconds: number): void { - sendPublicApiError( - req, - res, - 429, - 'rate_limited', - 'Too many security-sensitive requests; retry later.', - true, - { retryAfterSeconds } - ) -} - -/** - * Typed policy selection for explicit route composition. The route passes - * these options to the one canonical rateLimitMiddleware enforcement path. - */ -export function externalUserRateLimitOptions( - operation: ExternalUserRateLimitOperation, - stage: ExternalUserRateLimitStage -) { - const policy = POLICIES[operation] - return { - bucketType: policy.bucketType, - maxPerMinute: policy.maxPerMinute, - onBackendUnavailable: 'process-memory' as const, - getBucketKey: (req: Request) => keyFor(operation, stage, req as AuthenticatedRequest), - onLimited: sendLimited, - } -} - -/** Compose before an authenticated-stage limiter to fail closed on bad wiring. */ +/** Fail closed if authenticated route composition omitted the user context. */ export function requireAuthenticatedExternalUserRateLimitContext( req: AuthenticatedRequest, res: Response, @@ -138,5 +15,3 @@ export function requireAuthenticatedExternalUserRateLimitContext( } next() } - -export const externalUserRateLimitPolicy = Object.freeze({ keyFor }) diff --git a/control-api/src/routes/external/access.ts b/control-api/src/routes/external/access.ts index d7e74a226a..64827f85a9 100644 --- a/control-api/src/routes/external/access.ts +++ b/control-api/src/routes/external/access.ts @@ -1,17 +1,16 @@ import { Router } from 'express' +import { config } from '../../config.js' import { asyncHandler } from '../../http/asyncHandler.js' import { sendPublicApiError } from '../../http/publicApiError.js' import type { K8sGateway } from '../../k8s.js' import { attachAccessExecutionBudget } from '../../middleware/accessExecutionBudget.js' +import { createExternalClientRateLimiters } from '../../middleware/externalClientIdentity.js' import { type ExternalAuthedRequest, requireCompletedExternalSessionAuthenticationWithPublicErrors, requireExternalSessionLimiterIdentityWithPublicErrors, } from '../../middleware/externalSessionAuth.js' -import { - externalUserRateLimitOptions, - requireAuthenticatedExternalUserRateLimitContext, -} from '../../middleware/externalUserRateLimitPolicy.js' +import { requireAuthenticatedExternalUserRateLimitContext } from '../../middleware/externalUserRateLimitPolicy.js' import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' import { AccessCatalogRequestError, @@ -32,7 +31,6 @@ import { userAccessCapabilityManifest } from '../../services/access/userAccessPo import { resolveEffectiveUserAccessPolicy } from '../../services/access/userAccessRuntimePolicy.js' const CATALOG_RATE_LIMIT_PER_MINUTE = 10 -const RESOLVE_RATE_LIMIT_PER_MINUTE = 10 const ACCESS_PATH_PATTERN = /^ap1_[A-Za-z0-9_-]{43}$/ function catalogFamilies(value: unknown): CatalogFamily[] | null | undefined { @@ -142,13 +140,17 @@ function sendCatalogError( export function createExternalAccessRouter(gateway: K8sGateway): Router { const router = Router() - router.use('/external/access', attachAccessExecutionBudget) + const externalAccessRateLimits = createExternalClientRateLimiters( + 'users', + config.approvalRlExternalClientIpPerMin, + config.approvalRlExternalEdgePerMin + ) + router.use('/external/access', ...externalAccessRateLimits, attachAccessExecutionBudget) router.get( '/external/access/capabilities', requireExternalSessionLimiterIdentityWithPublicErrors, requireAuthenticatedExternalUserRateLimitContext, - rateLimitMiddleware(externalUserRateLimitOptions('access_capabilities', 'authenticated')), requireCompletedExternalSessionAuthenticationWithPublicErrors, asyncHandler(async (req: ExternalAuthedRequest, res) => { try { @@ -226,23 +228,6 @@ export function createExternalAccessRouter(gateway: K8sGateway): Router { '/external/access/resolve', requireExternalSessionLimiterIdentityWithPublicErrors, requireAuthenticatedExternalUserRateLimitContext, - rateLimitMiddleware({ - bucketType: 'external_access_resolve', - maxPerMinute: RESOLVE_RATE_LIMIT_PER_MINUTE, - onBackendUnavailable: 'process-memory', - getBucketKey: req => - `external_access_resolve:${(req as ExternalAuthedRequest).externalAuth!.userId}`, - onLimited: (req, res, retryAfterSeconds) => - sendPublicApiError( - req, - res, - 429, - 'rate_limited', - 'Too many authorization requests; retry later.', - true, - { retryAfterSeconds } - ), - }), requireCompletedExternalSessionAuthenticationWithPublicErrors, asyncHandler((req, res, next) => requireEffectiveV2Contract('action', req, res, next)), asyncHandler(async (req: ExternalAuthedRequest, res) => { diff --git a/control-api/src/routes/external/auth.ts b/control-api/src/routes/external/auth.ts index 3ac2a7a334..0b071f0e56 100644 --- a/control-api/src/routes/external/auth.ts +++ b/control-api/src/routes/external/auth.ts @@ -10,10 +10,7 @@ import { requireExternalSessionRateLimitContext, } from '../../middleware/externalSessionAuth.js' import { isCurrentExternalSession } from '../../middleware/externalSessionAuth.js' -import { - externalUserRateLimitOptions, - requireAuthenticatedExternalUserRateLimitContext, -} from '../../middleware/externalUserRateLimitPolicy.js' +import { requireAuthenticatedExternalUserRateLimitContext } from '../../middleware/externalUserRateLimitPolicy.js' import { passwordLoginSourceAdmission, sendPasswordAdmissionError, @@ -133,57 +130,53 @@ async function requireLegacyRpcSessionRateLimitContext( export function createExternalAuthRouter(gateway: K8sGateway): Router { const router = Router() - router.post( - '/external/auth/google-login', - rateLimitMiddleware(externalUserRateLimitOptions('authentication_attempt', 'pre_auth')), - async (req, res, next) => { - try { - const idToken = String(req.body?.idToken || '').trim() - if (!idToken) return res.status(400).json({ error: 'idToken is required' }) - const google = await verifyGoogleIdToken(idToken) - const policy = await resolveEffectiveUserAccessPolicy() - const login = await googleLoginData({ - email: google.email, - name: google.name, - picture: google.picture, - }) - if ('error' in login) return sendExternalLoginError(res, login.error) - const role = login.membership.role - const selection = selectExternalSessionRepresentation(externalSessionClient(req), policy) - if (selection.status !== 'selected') { - return res.status(426).json({ error: 'upgrade_required' }) - } - const issued = await issueExternalUserSession( - { - contract: selection.contract, - userId: login.user.id, - email: google.email, - teamId: login.membership.team_id || null, - role, - authGeneration: login.authGeneration, - authenticationMethods: ['google'], - }, - { policy } - ) - return res.status(200).json({ - token: issued.token, - sessionContract: issued.contract, - me: { - id: login.user.id, - email: google.email, - name: login.user.name, - picture: login.user.picture, - teamId: login.membership.team_id, - teamName: login.membership.team_name, - role, - }, - isNewUser: login.isNewUser, - }) - } catch (error) { - return next(error) + router.post('/external/auth/google-login', async (req, res, next) => { + try { + const idToken = String(req.body?.idToken || '').trim() + if (!idToken) return res.status(400).json({ error: 'idToken is required' }) + const google = await verifyGoogleIdToken(idToken) + const policy = await resolveEffectiveUserAccessPolicy() + const login = await googleLoginData({ + email: google.email, + name: google.name, + picture: google.picture, + }) + if ('error' in login) return sendExternalLoginError(res, login.error) + const role = login.membership.role + const selection = selectExternalSessionRepresentation(externalSessionClient(req), policy) + if (selection.status !== 'selected') { + return res.status(426).json({ error: 'upgrade_required' }) } + const issued = await issueExternalUserSession( + { + contract: selection.contract, + userId: login.user.id, + email: google.email, + teamId: login.membership.team_id || null, + role, + authGeneration: login.authGeneration, + authenticationMethods: ['google'], + }, + { policy } + ) + return res.status(200).json({ + token: issued.token, + sessionContract: issued.contract, + me: { + id: login.user.id, + email: google.email, + name: login.user.name, + picture: login.user.picture, + teamId: login.membership.team_id, + teamName: login.membership.team_name, + role, + }, + isNewUser: login.isNewUser, + }) + } catch (error) { + return next(error) } - ) + }) router.post( '/external/auth/password-login', @@ -270,42 +263,36 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { } ) - router.post( - '/external/auth/verify', - rateLimitMiddleware(externalUserRateLimitOptions('session_verify', 'pre_auth')), - async (req, res, next) => { - try { - const token = String(req.body?.token || '').trim() - if (!token || token.length > 4096) { - sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') - return - } - const authentication = await authenticateExternalUserSession(token, { - purpose: 'verify', - client: externalSessionClient(req), - }) - if (authentication.status === 'upgrade_required') { - sendPublicApiError(req, res, 426, 'upgrade_required', 'A newer client is required.') - return - } - if (authentication.status !== 'authenticated') { - sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') - return - } - return res.status(200).json({ claims: authentication.claims }) - } catch (error) { - handleExternalSessionBackendFailure(error, req, res, next) + router.post('/external/auth/verify', async (req, res, next) => { + try { + const token = String(req.body?.token || '').trim() + if (!token || token.length > 4096) { + sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') + return } + const authentication = await authenticateExternalUserSession(token, { + purpose: 'verify', + client: externalSessionClient(req), + }) + if (authentication.status === 'upgrade_required') { + sendPublicApiError(req, res, 426, 'upgrade_required', 'A newer client is required.') + return + } + if (authentication.status !== 'authenticated') { + sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') + return + } + return res.status(200).json({ claims: authentication.claims }) + } catch (error) { + handleExternalSessionBackendFailure(error, req, res, next) } - ) + }) router.post( '/external/auth/session-token', - rateLimitMiddleware(externalUserRateLimitOptions('session_lifecycle', 'pre_auth')), requireLegacySessionTokenPayload, requireExternalSessionRateLimitContext({ purpose: 'switch', client: externalSessionClient }), requireAuthenticatedExternalUserRateLimitContext, - rateLimitMiddleware(externalUserRateLimitOptions('session_lifecycle', 'authenticated')), async (req: ExternalAuthedRequest, res, next) => { try { const userId = String(req.body?.userId || '').trim() @@ -390,10 +377,8 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { router.post( '/external/auth/session/renew', - rateLimitMiddleware(externalUserRateLimitOptions('session_lifecycle', 'pre_auth')), requireExternalSessionRateLimitContext({ purpose: 'renew', client: externalSessionClient }), requireAuthenticatedExternalUserRateLimitContext, - rateLimitMiddleware(externalUserRateLimitOptions('session_lifecycle', 'authenticated')), async (req: ExternalAuthedRequest, res) => { try { const token = sessionTokenFromRequest(req) @@ -435,13 +420,11 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { router.post( '/external/auth/session/logout', - rateLimitMiddleware(externalUserRateLimitOptions('session_lifecycle', 'pre_auth')), requireExternalSessionRateLimitContext({ purpose: 'revoke_cleanup', client: externalSessionClient, }), requireAuthenticatedExternalUserRateLimitContext, - rateLimitMiddleware(externalUserRateLimitOptions('session_lifecycle', 'authenticated')), async (req: ExternalAuthedRequest, res) => { try { const token = sessionTokenFromRequest(req) @@ -476,10 +459,8 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { router.post( '/external/auth/sessions/revoke-all', - rateLimitMiddleware(externalUserRateLimitOptions('session_lifecycle', 'pre_auth')), requireExternalSessionRateLimitContext({ purpose: 'protected', client: externalSessionClient }), requireAuthenticatedExternalUserRateLimitContext, - rateLimitMiddleware(externalUserRateLimitOptions('session_lifecycle', 'authenticated')), async (req: ExternalAuthedRequest, res) => { try { const token = sessionTokenFromRequest(req) @@ -510,14 +491,12 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { router.post( '/external/auth/sessions/:sid/revoke', - rateLimitMiddleware(externalUserRateLimitOptions('session_lifecycle', 'pre_auth')), requireExternalSessionRateLimitContext({ purpose: 'protected', client: externalSessionClient, requireV2: true, }), requireAuthenticatedExternalUserRateLimitContext, - rateLimitMiddleware(externalUserRateLimitOptions('session_lifecycle', 'authenticated')), async (req: ExternalAuthedRequest, res) => { try { const authentication = req.externalSessionAuthentication! @@ -544,11 +523,9 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { router.post( '/external/rpc/token', - rateLimitMiddleware(externalUserRateLimitOptions('rpc_token', 'pre_auth')), attachAccessExecutionBudget, requireLegacyRpcSessionRateLimitContext, requireAuthenticatedExternalUserRateLimitContext, - rateLimitMiddleware(externalUserRateLimitOptions('rpc_token', 'authenticated')), async (req: ExternalAuthedRequest, res, next) => { try { const authentication = req.externalSessionAuthentication! diff --git a/control-api/src/routes/external/directory.ts b/control-api/src/routes/external/directory.ts index cf51962fa2..6442c2eb78 100644 --- a/control-api/src/routes/external/directory.ts +++ b/control-api/src/routes/external/directory.ts @@ -9,7 +9,6 @@ import { requireExternalTeamParamMatchWithPublicErrors, requireValidExternalSessionTokenWithPublicErrors, } from '../../middleware/externalSessionAuth.js' -import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' import { DIRECTORY_SEARCH_MAX_QUERY_LENGTH, DirectorySearchCursorError, @@ -34,26 +33,6 @@ export function createExternalDirectoryRouter(): Router { '/external/directory/search', requireExternalTeamParamMatchWithPublicErrors(), requireExternalRoleWithPublicErrors(['admin', 'inviter']), - rateLimitMiddleware({ - bucketType: 'external_directory_search', - maxPerMinute: 30, - onBackendUnavailable: 'process-memory', - getBucketKey: req => { - const external = req as ExternalAuthedRequest - return `directory-search:${external.externalAuth?.userId || 'unknown'}` - }, - onLimited: (req, res, retryAfterSeconds) => { - sendPublicApiError( - req, - res, - 429, - 'rate_limited', - 'Too many directory searches; retry later.', - true, - { retryAfterSeconds } - ) - }, - }), async (req: ExternalAuthedRequest, res, next) => { try { const teamId = String(req.query.teamId || '').trim() diff --git a/control-api/src/routes/external/invitations.ts b/control-api/src/routes/external/invitations.ts index 4991e34c9e..8f6b466120 100644 --- a/control-api/src/routes/external/invitations.ts +++ b/control-api/src/routes/external/invitations.ts @@ -11,10 +11,7 @@ import { rejectBodyUserTeamMismatch, requireValidExternalSessionToken, } from '../../middleware/externalSessionAuth.js' -import { - externalUserRateLimitOptions, - requireAuthenticatedExternalUserRateLimitContext, -} from '../../middleware/externalUserRateLimitPolicy.js' +import { requireAuthenticatedExternalUserRateLimitContext } from '../../middleware/externalUserRateLimitPolicy.js' import { sendPasswordAdmissionError } from '../../middleware/passwordLoginAdmission.js' import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' import { resolveEffectiveUserAccessPolicy } from '../../services/access/userAccessRuntimePolicy.js' @@ -188,7 +185,6 @@ export function createExternalInvitationsRouter(): Router { requireValidExternalSessionToken, rejectBodyUserTeamMismatch, requireAuthenticatedExternalUserRateLimitContext, - rateLimitMiddleware(externalUserRateLimitOptions('invitation_mutation', 'authenticated')), async (req, res, next) => { try { const userId = String(req.body?.userId || '').trim() @@ -247,9 +243,7 @@ export function createExternalInvitationsRouter(): Router { requireValidExternalSessionToken, rejectBodyUserTeamMismatch, requireAuthenticatedExternalUserRateLimitContext, - rateLimitMiddleware( - externalUserRateLimitOptions('invitation_sensitive_action', 'authenticated') - ), + async (req, res, next) => { try { const userId = String(req.body?.userId || '').trim() @@ -293,7 +287,6 @@ export function createExternalInvitationsRouter(): Router { ...externalInvitationsEdgeRateLimits, requireValidExternalSessionToken, requireAuthenticatedExternalUserRateLimitContext, - rateLimitMiddleware(externalUserRateLimitOptions('invitation_read', 'authenticated')), async (req, res, next) => { try { const email = String((req as ExternalAuthedRequest).externalAuth?.email || '') diff --git a/control-api/src/routes/external/members.ts b/control-api/src/routes/external/members.ts index 1941c03c69..9606192850 100644 --- a/control-api/src/routes/external/members.ts +++ b/control-api/src/routes/external/members.ts @@ -5,8 +5,6 @@ import { type ExternalAuthedRequest, requireValidExternalSessionToken, } from '../../middleware/externalSessionAuth.js' -import { externalUserRateLimitOptions } from '../../middleware/externalUserRateLimitPolicy.js' -import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' import { createManagedInvitationForUser, deleteManagedMemberForUser, @@ -40,7 +38,6 @@ export function createExternalMembersRouter(): Router { router.get( '/external/members/manageable-teams', - rateLimitMiddleware(externalUserRateLimitOptions('member_read', 'authenticated')), async (req: ExternalAuthedRequest, res, next) => { try { const userId = currentUserId(req) @@ -52,103 +49,86 @@ export function createExternalMembersRouter(): Router { } ) - router.get( - '/external/members', - rateLimitMiddleware(externalUserRateLimitOptions('member_read', 'authenticated')), - async (req: ExternalAuthedRequest, res, next) => { - try { - const userId = currentUserId(req) - if (!userId) return res.status(403).json({ error: 'Forbidden' }) - return res.status(200).json({ items: await listManagedMembersForUser(userId) }) - } catch (error) { - return next(error) - } + router.get('/external/members', async (req: ExternalAuthedRequest, res, next) => { + try { + const userId = currentUserId(req) + if (!userId) return res.status(403).json({ error: 'Forbidden' }) + return res.status(200).json({ items: await listManagedMembersForUser(userId) }) + } catch (error) { + return next(error) } - ) + }) - router.get( - '/external/members/invitations', - rateLimitMiddleware(externalUserRateLimitOptions('member_read', 'authenticated')), - async (req: ExternalAuthedRequest, res, next) => { - try { - const userId = currentUserId(req) - if (!userId) return res.status(403).json({ error: 'Forbidden' }) - return res.status(200).json({ items: await listManagedPendingInvitationsForUser(userId) }) - } catch (error) { - return next(error) - } + router.get('/external/members/invitations', async (req: ExternalAuthedRequest, res, next) => { + try { + const userId = currentUserId(req) + if (!userId) return res.status(403).json({ error: 'Forbidden' }) + return res.status(200).json({ items: await listManagedPendingInvitationsForUser(userId) }) + } catch (error) { + return next(error) } - ) + }) - router.get( - '/external/members/:userId', - rateLimitMiddleware(externalUserRateLimitOptions('member_read', 'authenticated')), - async (req: ExternalAuthedRequest, res, next) => { - try { - const managerUserId = currentUserId(req) - if (!managerUserId) return res.status(403).json({ error: 'Forbidden' }) - const items = await listManagedMembersForUser(managerUserId, req.params.userId) - const member = items[0] || null - if (!member) return res.status(404).json({ error: 'not_found' }) - return res.status(200).json(member) - } catch (error) { - return next(error) - } + router.get('/external/members/:userId', async (req: ExternalAuthedRequest, res, next) => { + try { + const managerUserId = currentUserId(req) + if (!managerUserId) return res.status(403).json({ error: 'Forbidden' }) + const items = await listManagedMembersForUser(managerUserId, req.params.userId) + const member = items[0] || null + if (!member) return res.status(404).json({ error: 'not_found' }) + return res.status(200).json(member) + } catch (error) { + return next(error) } - ) + }) - router.post( - '/external/members/invitations', - rateLimitMiddleware(externalUserRateLimitOptions('member_mutation', 'authenticated')), - async (req: ExternalAuthedRequest, res, next) => { - try { - const managerUserId = currentUserId(req) - if (!managerUserId) return res.status(403).json({ error: 'Forbidden' }) - const email = normalizeInvitationEmail(req.body?.email) - if (!email) { - return res.status(400).json({ error: 'invalid_email' }) - } - const name = String(req.body?.name || '').trim() - const teams: unknown[] = Array.isArray(req.body?.teams) ? req.body.teams : [] - if (name.length > MAX_INVITEE_NAME_LENGTH) { - return res.status(400).json({ error: 'invalid_name' }) - } - if (teams.length > MAX_INVITATION_TEAM_ASSIGNMENTS) { - return res.status(400).json({ error: 'too_many_teams' }) - } - const teamAssignments = teams - .map((item: unknown) => { - const row = item && typeof item === 'object' ? (item as Record) : {} - const teamId = String(row.teamId || row.id || '').trim() - const role = normalizeTeamRoleInput(row.role) || 'member' - return teamId ? { teamId, role } : null - }) - .filter((item): item is { teamId: string; role: 'admin' | 'inviter' | 'member' } => - Boolean(item) - ) - const result = await createManagedInvitationForUser( - managerUserId, - email, - teamAssignments, - name, - req.externalSessionAuthority + router.post('/external/members/invitations', async (req: ExternalAuthedRequest, res, next) => { + try { + const managerUserId = currentUserId(req) + if (!managerUserId) return res.status(403).json({ error: 'Forbidden' }) + const email = normalizeInvitationEmail(req.body?.email) + if (!email) { + return res.status(400).json({ error: 'invalid_email' }) + } + const name = String(req.body?.name || '').trim() + const teams: unknown[] = Array.isArray(req.body?.teams) ? req.body.teams : [] + if (name.length > MAX_INVITEE_NAME_LENGTH) { + return res.status(400).json({ error: 'invalid_name' }) + } + if (teams.length > MAX_INVITATION_TEAM_ASSIGNMENTS) { + return res.status(400).json({ error: 'too_many_teams' }) + } + const teamAssignments = teams + .map((item: unknown) => { + const row = item && typeof item === 'object' ? (item as Record) : {} + const teamId = String(row.teamId || row.id || '').trim() + const role = normalizeTeamRoleInput(row.role) || 'member' + return teamId ? { teamId, role } : null + }) + .filter((item): item is { teamId: string; role: 'admin' | 'inviter' | 'member' } => + Boolean(item) ) - if ('error' in result) { - if (result.error === 'forbidden') return res.status(403).json({ error: 'forbidden' }) - return res.status(400).json({ error: 'invalid_payload' }) - } - return res - .status(201) - .json(externalManagedInvitationResponse(result.invitation as Record)) - } catch (error) { - return next(error) + const result = await createManagedInvitationForUser( + managerUserId, + email, + teamAssignments, + name, + req.externalSessionAuthority + ) + if ('error' in result) { + if (result.error === 'forbidden') return res.status(403).json({ error: 'forbidden' }) + return res.status(400).json({ error: 'invalid_payload' }) } + return res + .status(201) + .json(externalManagedInvitationResponse(result.invitation as Record)) + } catch (error) { + return next(error) } - ) + }) router.post( '/external/members/invitations/:invitationId/resend', - rateLimitMiddleware(externalUserRateLimitOptions('member_mutation', 'authenticated')), async (req: ExternalAuthedRequest, res, next) => { try { const managerUserId = currentUserId(req) @@ -171,7 +151,6 @@ export function createExternalMembersRouter(): Router { router.delete( '/external/members/invitations/:invitationId', - rateLimitMiddleware(externalUserRateLimitOptions('member_mutation', 'authenticated')), async (req: ExternalAuthedRequest, res, next) => { try { const managerUserId = currentUserId(req) @@ -194,7 +173,6 @@ export function createExternalMembersRouter(): Router { router.patch( '/external/members/:userId/teams/:teamId/role', - rateLimitMiddleware(externalUserRateLimitOptions('member_mutation', 'authenticated')), async (req: ExternalAuthedRequest, res, next) => { try { const managerUserId = currentUserId(req) @@ -223,55 +201,50 @@ export function createExternalMembersRouter(): Router { } ) - router.delete( - '/external/members/:userId', - rateLimitMiddleware(externalUserRateLimitOptions('member_mutation', 'authenticated')), - async (req: ExternalAuthedRequest, res, next) => { - try { - const managerUserId = currentUserId(req) - if (!managerUserId) return res.status(403).json({ error: 'Forbidden' }) - const reason = typeof req.body?.reason === 'string' ? req.body.reason.trim() : '' - if (!reason) return res.status(400).json({ error: 'reason_required' }) - const idempotencyKey = String(req.header('Idempotency-Key') || '').trim() - if (!idempotencyKey) return res.status(400).json({ error: 'idempotency_key_required' }) - const result = await deleteManagedUserForUser( - managerUserId, - req.params.userId, - { - reason, - idempotencyKey, - requestId: req.correlationId ?? null, - }, - req.externalSessionAuthority - ) - if ('error' in result) { - if (result.error === 'forbidden') { - return res.status(403).json({ error: 'forbidden' }) - } - if (result.error === 'forbidden_uncontrolled_teams') { - return res.status(403).json({ error: 'forbidden_uncontrolled_teams' }) - } - if (result.error === 'invalid_target') { - return res.status(400).json({ error: 'invalid_target' }) - } - if (result.error === 'invalid_retirement_input') { - return res.status(400).json({ error: 'invalid_retirement_input' }) - } - if (result.error === 'idempotency_conflict' || result.error === 'retirement_conflict') { - return res.status(409).json({ error: result.error }) - } - return res.status(404).json({ error: 'not_found' }) + router.delete('/external/members/:userId', async (req: ExternalAuthedRequest, res, next) => { + try { + const managerUserId = currentUserId(req) + if (!managerUserId) return res.status(403).json({ error: 'Forbidden' }) + const reason = typeof req.body?.reason === 'string' ? req.body.reason.trim() : '' + if (!reason) return res.status(400).json({ error: 'reason_required' }) + const idempotencyKey = String(req.header('Idempotency-Key') || '').trim() + if (!idempotencyKey) return res.status(400).json({ error: 'idempotency_key_required' }) + const result = await deleteManagedUserForUser( + managerUserId, + req.params.userId, + { + reason, + idempotencyKey, + requestId: req.correlationId ?? null, + }, + req.externalSessionAuthority + ) + if ('error' in result) { + if (result.error === 'forbidden') { + return res.status(403).json({ error: 'forbidden' }) } - return res.status(200).json(result.deleted) - } catch (error) { - return next(error) + if (result.error === 'forbidden_uncontrolled_teams') { + return res.status(403).json({ error: 'forbidden_uncontrolled_teams' }) + } + if (result.error === 'invalid_target') { + return res.status(400).json({ error: 'invalid_target' }) + } + if (result.error === 'invalid_retirement_input') { + return res.status(400).json({ error: 'invalid_retirement_input' }) + } + if (result.error === 'idempotency_conflict' || result.error === 'retirement_conflict') { + return res.status(409).json({ error: result.error }) + } + return res.status(404).json({ error: 'not_found' }) } + return res.status(200).json(result.deleted) + } catch (error) { + return next(error) } - ) + }) router.delete( '/external/members/:userId/teams/:teamId', - rateLimitMiddleware(externalUserRateLimitOptions('member_mutation', 'authenticated')), async (req: ExternalAuthedRequest, res, next) => { try { const managerUserId = currentUserId(req) diff --git a/control-api/src/routes/external/notifications.routes.ts b/control-api/src/routes/external/notifications.routes.ts index 1698285d29..1bc508af53 100644 --- a/control-api/src/routes/external/notifications.routes.ts +++ b/control-api/src/routes/external/notifications.routes.ts @@ -8,7 +8,6 @@ import { handleExternalSessionBackendFailure, requireValidExternalSessionToken, } from '../../middleware/externalSessionAuth.js' -import { externalUserRateLimitOptions } from '../../middleware/externalUserRateLimitPolicy.js' import { mcpHostHttpMetrics } from '../../middleware/mcpHostHttpMetrics.js' import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' import { rootLogger } from '../../observability/logger.js' @@ -451,9 +450,7 @@ export function createExternalNotificationsRouter(): Router { ...externalNotificationsEdgeRateLimits, mcpHostHttpMetrics('external_notification_preferences_get'), requireValidExternalSessionToken, - rateLimitMiddleware( - externalUserRateLimitOptions('notification_preference_read', 'authenticated') - ), + asyncHandler(async (req: Request, res: Response) => { const extReq = req as ExternalAuthedRequest const claims = extReq.externalAuth @@ -470,9 +467,7 @@ export function createExternalNotificationsRouter(): Router { ...externalNotificationsEdgeRateLimits, mcpHostHttpMetrics('external_notification_preferences_put'), requireValidExternalSessionToken, - rateLimitMiddleware( - externalUserRateLimitOptions('notification_preference_mutation', 'authenticated') - ), + asyncHandler(async (req: Request, res: Response) => { const extReq = req as ExternalAuthedRequest const claims = extReq.externalAuth diff --git a/control-api/src/routes/external/oauthGrants.ts b/control-api/src/routes/external/oauthGrants.ts index 39b8f624f9..8e91d37c39 100644 --- a/control-api/src/routes/external/oauthGrants.ts +++ b/control-api/src/routes/external/oauthGrants.ts @@ -6,8 +6,6 @@ import { type ExternalAuthedRequest, requireValidExternalSessionToken, } from '../../middleware/externalSessionAuth.js' -import { externalUserRateLimitOptions } from '../../middleware/externalUserRateLimitPolicy.js' -import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' import { type OAuthOwnerKind, deleteOAuthGrant, listUserOAuthGrants } from '../../oauth/store.js' function dbClient() { @@ -56,7 +54,6 @@ export function createExternalOauthGrantsRouter(): Router { '/external/oauth/grants', ...externalOauthGrantsRateLimits, requireValidExternalSessionToken, - rateLimitMiddleware(externalUserRateLimitOptions('oauth_grant_read', 'authenticated')), (req: ExternalAuthedRequest, res, next) => { void (async () => { try { @@ -102,7 +99,6 @@ export function createExternalOauthGrantsRouter(): Router { '/external/oauth/grants/:recipeNamespace/:recipeName/:oauthClientId', ...externalOauthGrantsRateLimits, requireValidExternalSessionToken, - rateLimitMiddleware(externalUserRateLimitOptions('oauth_grant_mutation', 'authenticated')), (req: ExternalAuthedRequest, res, next) => { void (async () => { try { diff --git a/control-api/src/routes/external/sharedFilesystems.ts b/control-api/src/routes/external/sharedFilesystems.ts index a75f478dbd..613ef2021e 100644 --- a/control-api/src/routes/external/sharedFilesystems.ts +++ b/control-api/src/routes/external/sharedFilesystems.ts @@ -8,8 +8,6 @@ import { type ExternalAuthedRequest, requireValidExternalSessionToken, } from '../../middleware/externalSessionAuth.js' -import { externalUserRateLimitOptions } from '../../middleware/externalUserRateLimitPolicy.js' -import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' import { scheduleAccessCatalogShadow } from '../../services/access/accessCatalogShadow.js' import { getLiveTeamMembership } from '../../services/access/liveTeamAuthorization.js' import { @@ -242,10 +240,6 @@ export function createExternalSharedFilesystemsRouter(gateway: K8sGateway): Rout res.status(405).json({ error: 'Method Not Allowed' }) } ) - router.use( - '/external/contexts/:contextId/shared-filesystems', - rateLimitMiddleware(externalUserRateLimitOptions('shared_filesystem_read', 'authenticated')) - ) router.get( '/external/contexts/:contextId/shared-filesystems', diff --git a/control-api/src/routes/external/teams.ts b/control-api/src/routes/external/teams.ts index 0ee33bcf6e..be87ec67d7 100644 --- a/control-api/src/routes/external/teams.ts +++ b/control-api/src/routes/external/teams.ts @@ -11,8 +11,6 @@ import { requireExternalUserParamMatch, requireValidExternalSessionToken, } from '../../middleware/externalSessionAuth.js' -import { externalUserRateLimitOptions } from '../../middleware/externalUserRateLimitPolicy.js' -import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' import { resolveMcpServersForAgents } from '../../services/access/mcpInvocable.js' import { filterAccessValues, @@ -59,7 +57,6 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { router.get( '/external/teams/:teamId/users/:userId/current', - rateLimitMiddleware(externalUserRateLimitOptions('team_user_read', 'authenticated')), requireExternalTeamParamMatch(), requireExternalUserParamMatch(), async (req, res, next) => { @@ -74,25 +71,19 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { } ) - router.post( - '/external/teams', - rateLimitMiddleware(externalUserRateLimitOptions('team_user_mutation', 'authenticated')), - rejectBodyUserTeamMismatch, - async (req, res, next) => { - try { - const userId = String(req.body?.userId || '').trim() - const name = String(req.body?.name || '').trim() - if (!userId || !name) return res.status(400).json({ error: 'userId and name are required' }) - return res.status(200).json(await createTeamForUser(userId, name)) - } catch (error) { - return next(error) - } + router.post('/external/teams', rejectBodyUserTeamMismatch, async (req, res, next) => { + try { + const userId = String(req.body?.userId || '').trim() + const name = String(req.body?.name || '').trim() + if (!userId || !name) return res.status(400).json({ error: 'userId and name are required' }) + return res.status(200).json(await createTeamForUser(userId, name)) + } catch (error) { + return next(error) } - ) + }) router.put( '/external/teams/:teamId/name', - rateLimitMiddleware(externalUserRateLimitOptions('team_user_mutation', 'authenticated')), requireExternalTeamParamMatch(), requireExternalRole(['admin']), rejectBodyUserTeamMismatch, @@ -119,7 +110,6 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { router.get( '/external/teams/:teamId/members', - rateLimitMiddleware(externalUserRateLimitOptions('team_user_read', 'authenticated')), requireExternalTeamParamMatch(), requireExternalRole(['admin', 'inviter']), async (req, res, next) => { @@ -133,7 +123,6 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { router.get( '/external/teams/:teamId/contexts', - rateLimitMiddleware(externalUserRateLimitOptions('team_user_read', 'authenticated')), requireExternalTeamParamMatch(), async (req, res, next) => { try { @@ -159,7 +148,6 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { router.get( '/external/teams/:teamId/agents', - rateLimitMiddleware(externalUserRateLimitOptions('team_user_read', 'authenticated')), requireExternalTeamParamMatch(), async (req, res, next) => { try { @@ -200,7 +188,6 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { router.get( '/external/teams/:teamId/members/:userId/role', - rateLimitMiddleware(externalUserRateLimitOptions('team_user_read', 'authenticated')), requireExternalTeamParamMatch(), requireExternalRole(['admin', 'inviter']), async (req, res, next) => { @@ -216,7 +203,6 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { router.patch( '/external/teams/:teamId/members/:userId/role', - rateLimitMiddleware(externalUserRateLimitOptions('team_user_mutation', 'authenticated')), requireExternalTeamParamMatch(), requireExternalRole(['admin']), rejectBodyUserTeamMismatch, @@ -245,7 +231,6 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { router.post( '/external/teams/:teamId/invitations', - rateLimitMiddleware(externalUserRateLimitOptions('team_user_mutation', 'authenticated')), requireExternalTeamParamMatch(), requireExternalRole(['admin', 'inviter']), rejectBodyUserTeamMismatch, @@ -281,7 +266,6 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { router.delete( '/external/teams/:teamId/members/:userId', - rateLimitMiddleware(externalUserRateLimitOptions('team_user_mutation', 'authenticated')), requireExternalTeamParamMatch(), requireExternalRole(['admin']), async (req, res, next) => { diff --git a/control-api/src/routes/external/users.ts b/control-api/src/routes/external/users.ts index 351dcaa105..a425fee18f 100644 --- a/control-api/src/routes/external/users.ts +++ b/control-api/src/routes/external/users.ts @@ -8,7 +8,6 @@ import { requireExternalUserParamMatch, requireValidExternalSessionToken, } from '../../middleware/externalSessionAuth.js' -import { externalUserRateLimitOptions } from '../../middleware/externalUserRateLimitPolicy.js' import { sendPasswordAdmissionError } from '../../middleware/passwordLoginAdmission.js' import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' import { type Logger, rootLogger } from '../../observability/logger.js' @@ -103,7 +102,6 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { router.get( '/external/users/:userId/teams', - rateLimitMiddleware(externalUserRateLimitOptions('team_user_read', 'authenticated')), requireExternalUserParamMatch(), async (req, res, next) => { try { @@ -220,7 +218,6 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { router.get( '/external/users/:userId/memberships/:teamId', - rateLimitMiddleware(externalUserRateLimitOptions('team_user_read', 'authenticated')), requireExternalUserParamMatch(), async (req, res, next) => { try { @@ -235,7 +232,6 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { router.get( '/external/users/:userId/me', - rateLimitMiddleware(externalUserRateLimitOptions('team_user_read', 'authenticated')), requireExternalUserParamMatch(), async (req, res, next) => { try { @@ -257,7 +253,6 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { router.get( '/external/users/:userId/contexts', - rateLimitMiddleware(externalUserRateLimitOptions('team_user_read', 'authenticated')), requireExternalUserParamMatch(), async (req, res, next) => { try { @@ -290,7 +285,6 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { router.get( '/external/users/:userId/agents', - rateLimitMiddleware(externalUserRateLimitOptions('team_user_read', 'authenticated')), requireExternalUserParamMatch(), async (req, res, next) => { try { @@ -356,7 +350,6 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { router.put( '/external/users/:userId/profile', - rateLimitMiddleware(externalUserRateLimitOptions('team_user_mutation', 'authenticated')), requireExternalUserParamMatch(), rejectBodyUserTeamMismatch, async (req, res, next) => { diff --git a/control-api/src/routes/external/workflow-approval-mediums.routes.ts b/control-api/src/routes/external/workflow-approval-mediums.routes.ts index 81d38bea20..0cba295859 100644 --- a/control-api/src/routes/external/workflow-approval-mediums.routes.ts +++ b/control-api/src/routes/external/workflow-approval-mediums.routes.ts @@ -6,9 +6,7 @@ import { type ExternalAuthedRequest, requireValidExternalSessionToken, } from '../../middleware/externalSessionAuth.js' -import { externalUserRateLimitOptions } from '../../middleware/externalUserRateLimitPolicy.js' import { mcpHostHttpMetrics } from '../../middleware/mcpHostHttpMetrics.js' -import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' import { confirmMediumChallenge, createMediumChallenge, @@ -85,9 +83,7 @@ export function createExternalWorkflowApprovalMediumsRouter(gateway: K8sGateway) ...externalWorkflowApprovalEdgeRateLimits, mcpHostHttpMetrics('external_workflow_approval_medium_challenge'), requireValidExternalSessionToken, - rateLimitMiddleware( - externalUserRateLimitOptions('workflow_approval_medium_mutation', 'authenticated') - ), + (req, res, next) => { void (async () => { try { @@ -176,9 +172,7 @@ export function createExternalWorkflowApprovalMediumsRouter(gateway: K8sGateway) ...externalWorkflowApprovalEdgeRateLimits, mcpHostHttpMetrics('external_workflow_approval_medium_link_session'), requireValidExternalSessionToken, - rateLimitMiddleware( - externalUserRateLimitOptions('workflow_approval_medium_mutation', 'authenticated') - ), + (req, res, next) => { void (async () => { try { @@ -269,9 +263,7 @@ export function createExternalWorkflowApprovalMediumsRouter(gateway: K8sGateway) ...externalWorkflowApprovalEdgeRateLimits, mcpHostHttpMetrics('external_workflow_approval_medium_confirm'), requireValidExternalSessionToken, - rateLimitMiddleware( - externalUserRateLimitOptions('workflow_approval_medium_mutation', 'authenticated') - ), + (req, res, next) => { void (async () => { try { @@ -318,9 +310,7 @@ export function createExternalWorkflowApprovalMediumsRouter(gateway: K8sGateway) ...externalWorkflowApprovalEdgeRateLimits, mcpHostHttpMetrics('external_workflow_approval_medium_list'), requireValidExternalSessionToken, - rateLimitMiddleware( - externalUserRateLimitOptions('workflow_approval_medium_read', 'authenticated') - ), + (req, res, next) => { void (async () => { try { @@ -355,9 +345,7 @@ export function createExternalWorkflowApprovalMediumsRouter(gateway: K8sGateway) ...externalWorkflowApprovalEdgeRateLimits, mcpHostHttpMetrics('external_workflow_approval_medium_targets'), requireValidExternalSessionToken, - rateLimitMiddleware( - externalUserRateLimitOptions('workflow_approval_medium_read', 'authenticated') - ), + (req, res, next) => { void (async () => { try { @@ -393,9 +381,7 @@ export function createExternalWorkflowApprovalMediumsRouter(gateway: K8sGateway) ...externalWorkflowApprovalEdgeRateLimits, mcpHostHttpMetrics('external_workflow_approval_medium_preference'), requireValidExternalSessionToken, - rateLimitMiddleware( - externalUserRateLimitOptions('workflow_approval_medium_mutation', 'authenticated') - ), + (req, res, next) => { void (async () => { try { @@ -421,9 +407,7 @@ export function createExternalWorkflowApprovalMediumsRouter(gateway: K8sGateway) ...externalWorkflowApprovalEdgeRateLimits, mcpHostHttpMetrics('external_workflow_approval_medium_display_name'), requireValidExternalSessionToken, - rateLimitMiddleware( - externalUserRateLimitOptions('workflow_approval_medium_mutation', 'authenticated') - ), + (req, res, next) => { void (async () => { try { @@ -462,9 +446,7 @@ export function createExternalWorkflowApprovalMediumsRouter(gateway: K8sGateway) ...externalWorkflowApprovalEdgeRateLimits, mcpHostHttpMetrics('external_workflow_approval_medium_delete'), requireValidExternalSessionToken, - rateLimitMiddleware( - externalUserRateLimitOptions('workflow_approval_medium_mutation', 'authenticated') - ), + (req, res, next) => { void (async () => { try { diff --git a/control-api/src/routes/external/workflows/admission.ts b/control-api/src/routes/external/workflows/admission.ts index feaf869d30..0d4daa4a73 100644 --- a/control-api/src/routes/external/workflows/admission.ts +++ b/control-api/src/routes/external/workflows/admission.ts @@ -3,11 +3,7 @@ import { requireCompletedExternalSessionAuthenticationWithPublicErrors, requireExternalSessionLimiterIdentityWithPublicErrors, } from '../../../middleware/externalSessionAuth.js' -import { - externalUserRateLimitOptions, - requireAuthenticatedExternalUserRateLimitContext, -} from '../../../middleware/externalUserRateLimitPolicy.js' -import { rateLimitMiddleware } from '../../../middleware/rateLimitMiddleware.js' +import { requireAuthenticatedExternalUserRateLimitContext } from '../../../middleware/externalUserRateLimitPolicy.js' import { bindCompletedExternalWorkflowCaller } from '../../workflows/shared/auth.js' import { workflowTriggerRateLimit } from '../../workflows/shared/rateLimit.js' @@ -15,7 +11,6 @@ export const externalWorkflowReadAdmission = Object.freeze([ attachAccessExecutionBudget, requireExternalSessionLimiterIdentityWithPublicErrors, requireAuthenticatedExternalUserRateLimitContext, - rateLimitMiddleware(externalUserRateLimitOptions('workflow_read', 'authenticated')), requireCompletedExternalSessionAuthenticationWithPublicErrors, bindCompletedExternalWorkflowCaller, ]) diff --git a/control-api/src/routes/rpc-access/users.ts b/control-api/src/routes/rpc-access/users.ts index d4dacaf260..6ad630a6c4 100644 --- a/control-api/src/routes/rpc-access/users.ts +++ b/control-api/src/routes/rpc-access/users.ts @@ -3,7 +3,6 @@ import type { NextFunction, Request, Response } from 'express' import { config } from '../../config.js' import { pool } from '../../db.js' import { K8sGateway } from '../../k8s.js' -import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' import { requireRpcTokenHostMatch, requireRpcTokenUserMatch, @@ -22,10 +21,6 @@ import { authorizeRpcHostAccess, } from '../../services/access/rpcHostAccessAuthorizer.js' import { getUserAgents, getUserContexts } from '../../services/directory/index.js' -import { - admitHostMessage, - respondHostMessageAdmissionFailure, -} from '../../services/hostMessageAdmission.js' import { type DirectRunAttributionBindingService, DirectRunBindingConflictError, @@ -55,9 +50,6 @@ const HOST_ACCESS_SCOPES = [ ] as const type RpcAuthedRequest = Request & { rpcAuth?: RpcAccessClaims } -type ArtifactReadRequest = RpcAuthedRequest & { - artifactReadConnection?: AuthorizedRpcHostAccess -} type HostMessageResolutionRequest = RpcAuthedRequest & { messageResolutionBinding?: ParsedDirectRunBindingRequest | null } @@ -149,12 +141,10 @@ async function bindDirectRunWithinBudget( /** * The single Host-message resolution path shared by the legacy strict POST and - * the /message-resolution route. The cheap signed-selector checks and admission - * live here, before the expensive live Host authorization, so a caller on - * either route consumes the same verified-subject bucket during the rollout - * window and a mismatched selector never charges it. + * the /message-resolution route. Signed selectors are checked before live + * Host authorization on both paths. */ -async function respondWithAdmittedHostConnection( +async function respondWithAuthorizedHostConnection( req: RpcAuthedRequest, res: Response, gateway: K8sGateway, @@ -171,8 +161,7 @@ async function respondWithAdmittedHostConnection( return } // Same order as /message-resolution's claim-match middleware: a signed - // selector that disagrees with the URL is a request-shape error, never a - // reason to consume the subject's message budget. + // selector that disagrees with the URL is a request-shape error. if (!userId || claims.sub !== userId) { denyHostAccess(req, res, 'subject_mismatch') return @@ -181,11 +170,6 @@ async function respondWithAdmittedHostConnection( denyHostAccess(req, res, 'host_claim_missing') return } - const admission = await admitHostMessage(claims.sub) - if (admission.status !== 'allowed') { - respondHostMessageAdmissionFailure(res, admission) - return - } const authorization = await authorizeRpcHostAccess(gateway, claims, userId, hostRef, directory) if (!authorization.authorized) { denyHostAccess(req, res, authorization.reason) @@ -336,57 +320,18 @@ export function createRpcAccessUsersRouter( } ) - // The subject-wide durable PG bucket protects the expensive live Host - // authorization below. Claim-match middleware runs first so malformed, - // mismatched, or unsigned Host selectors do not consume admission and - // caller-controlled Host refs cannot expand bucket cardinality. router.get( `${hostAccessPath}/artifact-read`, requireValidRpcAccessTokenAny(['host:task:read']), requireRpcTokenUserMatch(), requireRpcTokenHostMatch(), - rateLimitMiddleware({ - bucketType: 'host_artifact_pre_admission', - maxPerMinute: config.hostArtifactReadRlPerMin, - onBackendUnavailable: 'closed', - getBucketKey: req => { - const subject = (req as ArtifactReadRequest).rpcAuth?.sub - // Auth middleware should always populate the subject before this - // limiter. Keep malformed composition attributable to a counted - // sentinel instead of silently bypassing the durable budget. - return subject - ? `host-artifact-pre-admission:${subject}` - : 'host-artifact-pre-admission:unauthenticated' - }, - }), - async (req: ArtifactReadRequest, res, next) => { + async (req: RpcAuthedRequest, res, next) => { try { const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) - if (!connection) return - req.artifactReadConnection = connection - next() + if (connection) res.status(200).json(connection) } catch (error) { next(error) } - }, - // Preserve R29-H1: after live authorization establishes the canonical - // Host, consume its independent subject+Host budget before returning the - // artifact connection. Host wake capacity remains separate. - rateLimitMiddleware({ - bucketType: 'host_artifact_read', - maxPerMinute: config.hostArtifactReadRlPerMin, - onBackendUnavailable: 'closed', - getBucketKey: req => { - const artifactRead = req as ArtifactReadRequest - const subject = artifactRead.rpcAuth?.sub - const hostRef = artifactRead.artifactReadConnection?.hostRef - return subject && hostRef - ? `host-artifact-read:${subject}:${hostRef}` - : 'host-artifact-read:unresolved' - }, - }), - (req: ArtifactReadRequest, res) => { - res.status(200).json(req.artifactReadConnection) } ) @@ -407,7 +352,7 @@ export function createRpcAccessUsersRouter( return } - await respondWithAdmittedHostConnection( + await respondWithAuthorizedHostConnection( req, res, gateway, @@ -422,9 +367,8 @@ export function createRpcAccessUsersRouter( } ) - // A message-only operation so ordinary sends never charge unrelated shared Host reads. - // The empty object represents an ordinary send; the existing exact binding body is - // accepted for direct-run-bound sends. Both consume the same subject-wide bucket. + // The empty object represents an ordinary send; the existing exact binding + // body is accepted for direct-run-bound sends. router.post( `${hostAccessPath}/message-resolution`, requireValidRpcAccessToken('host:message:invoke'), @@ -434,7 +378,7 @@ export function createRpcAccessUsersRouter( requireRpcTokenHostMatch('hostRef', (req, res, reason) => denyHostAccess(req, res, reason)), async (req: HostMessageResolutionRequest, res, next) => { try { - await respondWithAdmittedHostConnection( + await respondWithAuthorizedHostConnection( req, res, gateway, diff --git a/control-api/src/services/hostMessageAdmission.ts b/control-api/src/services/hostMessageAdmission.ts deleted file mode 100644 index cc510e695e..0000000000 --- a/control-api/src/services/hostMessageAdmission.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { Response } from 'express' -import { config } from '../config.js' -import { type RateLimitCheck, checkAndIncrement } from './rateLimiterService.js' - -export function hostMessageAdmissionBucketKey(verifiedSubject: string): string { - return `host-message-admission:${verifiedSubject}` -} - -export type HostMessageAdmission = - | { status: 'allowed'; check: RateLimitCheck } - | { status: 'limited'; check: RateLimitCheck } - | { status: 'unavailable' } - -/** Shared authority for legacy messages and the later v2 chat.message.invoke checkpoint. */ -export async function admitHostMessage( - verifiedSubject: string, - check: typeof checkAndIncrement = checkAndIncrement -): Promise { - if (!verifiedSubject) return { status: 'unavailable' } - let result: RateLimitCheck - try { - result = await check(hostMessageAdmissionBucketKey(verifiedSubject), config.hostMessageRlPerMin) - } catch { - return { status: 'unavailable' } - } - if ( - !result || - result.backendAvailable !== true || - typeof result.allowed !== 'boolean' || - !Number.isSafeInteger(result.count) || - result.count < 1 || - !Number.isSafeInteger(result.resetMs) || - result.allowed !== result.count <= config.hostMessageRlPerMin - ) { - return { status: 'unavailable' } - } - return { status: result.allowed ? 'allowed' : 'limited', check: result } -} - -/** The same response contract can be used by the PR2 checkpoint after cascade. */ -export function respondHostMessageAdmissionFailure( - res: Response, - admission: Exclude -): void { - if (admission.status === 'unavailable') { - res.status(503).json({ error: 'host_message_admission_unavailable' }) - return - } - const retryAfterSeconds = Math.max(1, Math.ceil((admission.check.resetMs - Date.now()) / 1000)) - res.setHeader('Retry-After', String(retryAfterSeconds)) - res.setHeader('X-RateLimit-Limit', String(config.hostMessageRlPerMin)) - res.setHeader('X-RateLimit-Remaining', '0') - res.setHeader('X-RateLimit-Reset', String(Math.floor(admission.check.resetMs / 1000))) - res.status(429).json({ error: 'Too Many Requests', retryAfterSeconds }) -} diff --git a/control-api/test/externalUserRateLimitPolicy.test.ts b/control-api/test/externalUserRateLimitPolicy.test.ts index 526a511ec2..b51347a0a7 100644 --- a/control-api/test/externalUserRateLimitPolicy.test.ts +++ b/control-api/test/externalUserRateLimitPolicy.test.ts @@ -1,162 +1,19 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import express from 'express' import request from 'supertest' -import { - externalUserRateLimitOptions, - requireAuthenticatedExternalUserRateLimitContext, -} from '../src/middleware/externalUserRateLimitPolicy.js' -import { rateLimitMiddleware } from '../src/middleware/rateLimitMiddleware.js' +import { requireAuthenticatedExternalUserRateLimitContext } from '../src/middleware/externalUserRateLimitPolicy.js' -const limiter = vi.hoisted(() => ({ checkAndIncrement: vi.fn() })) -vi.mock('../src/services/rateLimiterService.js', () => limiter) - -const operations = [ - ['session_lifecycle', 'external_session_lifecycle', 10], - ['invitation_mutation', 'external_invitation_mutation', 10], - ['invitation_sensitive_action', 'external_invitation_sensitive_action', 10], - ['invitation_read', 'external_invitation_read', 10], - ['access_capabilities', 'external_access_capabilities', 10], - ['oauth_grant_read', 'external_oauth_grant_read', 30], - ['oauth_grant_mutation', 'external_oauth_grant_mutation', 10], - ['member_read', 'external_member_read', 30], - ['member_mutation', 'external_member_mutation', 10], - ['shared_filesystem_read', 'external_shared_filesystem_read', 30], - ['workflow_approval_medium_read', 'external_workflow_approval_medium_read', 30], - ['workflow_approval_medium_mutation', 'external_workflow_approval_medium_mutation', 10], - ['notification_preference_read', 'external_notification_preference_read', 30], - ['notification_preference_mutation', 'external_notification_preference_mutation', 10], - ['authentication_attempt', 'external_authentication_attempt', 5], - ['session_verify', 'external_session_verify', 10], - ['rpc_token', 'external_rpc_token', 10], - ['team_user_read', 'external_team_user_read', 30], - ['team_user_mutation', 'external_team_user_mutation', 10], -] as const - -function allowedResult() { - return { - allowed: true, - remaining: 9, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - count: 1, - backendAvailable: true, - } -} - -describe('external user rate-limit policy', () => { - beforeEach(() => vi.clearAllMocks()) - - it.each(operations)( - 'keys authenticated %s by the server-authenticated user', - async (operation, bucket, maxPerMinute) => { - limiter.checkAndIncrement.mockResolvedValueOnce(allowedResult()) - const app = express() - app.use((req, _res, next) => { - ;(req as express.Request & { externalAuth?: { userId: string } }).externalAuth = { - userId: 'server-user', - } - next() - }) - app.get( - '/protected', - requireAuthenticatedExternalUserRateLimitContext, - rateLimitMiddleware(externalUserRateLimitOptions(operation, 'authenticated')), - (_req, res) => { - res.status(204).end() - } - ) - - await request(app) - .get('/protected') - .set('x-user-session-token', 'never-used-as-a-key') - .expect(204) - - expect(limiter.checkAndIncrement).toHaveBeenLastCalledWith( - `${bucket}:user:server-user`, - maxPerMinute - ) - } - ) - - it('uses a bounded IP fallback before authentication without reading the session token', async () => { - limiter.checkAndIncrement.mockResolvedValueOnce(allowedResult()) - const app = express() - app.get( - '/pre-auth', - rateLimitMiddleware(externalUserRateLimitOptions('session_lifecycle', 'pre_auth')), - (_req, res) => { - res.status(204).end() - } - ) - - await request(app) - .get('/pre-auth') - .set('x-forwarded-for', '198.51.100.52') - .set('x-user-session-token', 'never-used-as-a-key') - .expect(204) - - expect(limiter.checkAndIncrement).toHaveBeenLastCalledWith( - expect.stringMatching(/^external_session_lifecycle:ip:/), - 10 - ) - expect(limiter.checkAndIncrement.mock.calls.at(-1)?.[0]).not.toContain('never-used-as-a-key') - }) - - it('uses the client IP forwarded only by the authenticated external REST service', async () => { - limiter.checkAndIncrement.mockResolvedValueOnce(allowedResult()) - const app = express() - app.use((req, _res, next) => { - req.internalService = { name: 'external-rest-api' } - next() - }) - app.get( - '/pre-auth', - rateLimitMiddleware(externalUserRateLimitOptions('session_lifecycle', 'pre_auth')), - (_req, res) => { - res.status(204).end() - } - ) - - await request(app).get('/pre-auth').set('x-evenfire-client-ip', '198.51.100.52').expect(204) - - expect(limiter.checkAndIncrement).toHaveBeenLastCalledWith( - 'external_session_lifecycle:ip:198.51.100.52', - 10 - ) - }) - - it('does not trust a forwarded client IP from another internal service', async () => { - limiter.checkAndIncrement.mockResolvedValueOnce(allowedResult()) +describe('authenticated external user context', () => { + it('fails closed when trusted context is absent', async () => { + const handler = vi.fn((_req, res) => res.status(204).end()) const app = express() - app.use((req, _res, next) => { - req.internalService = { name: 'rpc-proxy' } - next() - }) - app.get( - '/pre-auth', - rateLimitMiddleware(externalUserRateLimitOptions('session_lifecycle', 'pre_auth')), - (_req, res) => { - res.status(204).end() - } - ) - - await request(app).get('/pre-auth').set('x-evenfire-client-ip', '198.51.100.52').expect(204) + app.get('/protected', requireAuthenticatedExternalUserRateLimitContext, handler) - expect(limiter.checkAndIncrement).toHaveBeenLastCalledWith( - expect.not.stringContaining('198.51.100.52'), - 10 - ) + await request(app).get('/protected').expect(401) + expect(handler).not.toHaveBeenCalled() }) - it('uses canonical denial behavior and short-circuits the protected handler', async () => { - limiter.checkAndIncrement.mockResolvedValueOnce({ - allowed: false, - remaining: 0, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - backendAvailable: true, - count: 11, - }) + it('continues when a trusted user context is present', async () => { const handler = vi.fn((_req, res) => res.status(204).end()) const app = express() app.use((req, _res, next) => { @@ -165,34 +22,9 @@ describe('external user rate-limit policy', () => { } next() }) - app.get( - '/protected', - requireAuthenticatedExternalUserRateLimitContext, - rateLimitMiddleware(externalUserRateLimitOptions('session_lifecycle', 'authenticated')), - handler - ) + app.get('/protected', requireAuthenticatedExternalUserRateLimitContext, handler) - const response = await request(app).get('/protected').expect(429) - - expect(response.body).toMatchObject({ error: { code: 'rate_limited', retryable: true } }) - expect(response.headers['retry-after']).toBeDefined() - expect(response.headers['x-ratelimit-limit']).toBe('10') - expect(handler).not.toHaveBeenCalled() - }) - - it('fails closed before the authenticated limiter when trusted context is absent', async () => { - const handler = vi.fn((_req, res) => res.status(204).end()) - const app = express() - app.get( - '/protected', - requireAuthenticatedExternalUserRateLimitContext, - rateLimitMiddleware(externalUserRateLimitOptions('session_lifecycle', 'authenticated')), - handler - ) - - await request(app).get('/protected').expect(401) - - expect(limiter.checkAndIncrement).not.toHaveBeenCalled() - expect(handler).not.toHaveBeenCalled() + await request(app).get('/protected').expect(204) + expect(handler).toHaveBeenCalledOnce() }) }) diff --git a/control-api/test/routes.external.oauthGrants.test.ts b/control-api/test/routes.external.oauthGrants.test.ts index 70e6f8fbd0..7ec72d5293 100644 --- a/control-api/test/routes.external.oauthGrants.test.ts +++ b/control-api/test/routes.external.oauthGrants.test.ts @@ -150,50 +150,17 @@ describe('/external/oauth/grants', () => { expect(storeMock.deleteOAuthGrant).not.toHaveBeenCalled() }) - it('rate limits reads by authenticated user before listing grants', async () => { - rateLimitMock.checkAndIncrement.mockResolvedValueOnce({ - allowed: false, - count: 31, - remaining: 0, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - backendAvailable: true, - }) - - const res = await request(makeApp()).get('/external/oauth/grants') - - expect(res.status).toBe(429) - expect(res.headers['retry-after']).toBeDefined() - expect(res.headers['x-ratelimit-limit']).toBe('30') - expect(res.headers['x-ratelimit-remaining']).toBe('0') - expect(res.body).toMatchObject({ error: { code: 'rate_limited', retryable: true } }) - expect(rateLimitMock.checkAndIncrement).toHaveBeenCalledWith( - 'external_oauth_grant_read:user:user-1', - 30 - ) - expect(storeMock.listUserOAuthGrants).not.toHaveBeenCalled() + it('lists owned grants without an added user quota', async () => { + await request(makeApp()).get('/external/oauth/grants').expect(200) + expect(storeMock.listUserOAuthGrants).toHaveBeenCalledWith(expect.anything(), 'user-1', 'all') + expect(rateLimitMock.checkAndIncrement).not.toHaveBeenCalled() }) - it('rate limits revocation by authenticated user before deleting a grant', async () => { - rateLimitMock.checkAndIncrement.mockResolvedValueOnce({ - allowed: false, - count: 11, - remaining: 0, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - backendAvailable: true, - }) - - const res = await request(makeApp()).delete( - '/external/oauth/grants/sandbox-recipes/leadforge/google-gmail' - ) - - expect(res.status).toBe(429) - expect(res.headers['x-ratelimit-limit']).toBe('10') - expect(rateLimitMock.checkAndIncrement).toHaveBeenCalledWith( - 'external_oauth_grant_mutation:user:user-1', - 10 - ) - expect(storeMock.deleteOAuthGrant).not.toHaveBeenCalled() + it('revokes owned grants without an added user quota', async () => { + await request(makeApp()) + .delete('/external/oauth/grants/sandbox-recipes/leadforge/google-gmail') + .expect(204) + expect(storeMock.deleteOAuthGrant).toHaveBeenCalled() + expect(rateLimitMock.checkAndIncrement).not.toHaveBeenCalled() }) }) diff --git a/control-api/test/routes.external.workflowApprovalMediums.test.ts b/control-api/test/routes.external.workflowApprovalMediums.test.ts index eda9d33d4f..c16b5c085d 100644 --- a/control-api/test/routes.external.workflowApprovalMediums.test.ts +++ b/control-api/test/routes.external.workflowApprovalMediums.test.ts @@ -312,47 +312,24 @@ describe('external workflow approval medium routes', () => { ) }) - it('rate limits medium reads before listing accounts', async () => { - rateLimitMock.checkAndIncrement.mockResolvedValueOnce({ - allowed: false, - count: 31, - remaining: 0, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - backendAvailable: true, - }) - - const response = await request(makeApp()).get('/external/workflow-approval-mediums') - - expect(response.status).toBe(429) - expect(rateLimitMock.checkAndIncrement).toHaveBeenCalledWith( - 'external_workflow_approval_medium_read:user:user-1', - 30 - ) - expect(preferenceServiceMock.listVerifiedMediumAccountsWithPreference).not.toHaveBeenCalled() + it('lists medium accounts without an added user quota', async () => { + preferenceServiceMock.listVerifiedMediumAccountsWithPreference.mockResolvedValue([]) + await request(makeApp()).get('/external/workflow-approval-mediums').expect(200) + expect(preferenceServiceMock.listVerifiedMediumAccountsWithPreference).toHaveBeenCalled() + expect(rateLimitMock.checkAndIncrement).not.toHaveBeenCalled() }) - it('rate limits medium mutations before creating challenges', async () => { - rateLimitMock.checkAndIncrement.mockResolvedValueOnce({ - allowed: false, - count: 11, - remaining: 0, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - backendAvailable: true, + it('creates medium challenges without an added user quota', async () => { + identityServiceMock.createMediumChallenge.mockResolvedValue({ + id: 'challenge-parity', + nonce: '123456', }) - - const response = await request(makeApp()) + await request(makeApp()) .post('/external/workflow-approval-mediums/challenges') - .send({ medium: 'email', providerUserId: 'user@example.com' }) - - expect(response.status).toBe(429) - expect(response.headers['retry-after']).toBeDefined() - expect(rateLimitMock.checkAndIncrement).toHaveBeenCalledWith( - 'external_workflow_approval_medium_mutation:user:user-1', - 10 - ) - expect(identityServiceMock.createMediumChallenge).not.toHaveBeenCalled() + .send({ medium: 'email', providerUserId: 'user@example.test' }) + .expect(202) + expect(identityServiceMock.createMediumChallenge).toHaveBeenCalled() + expect(rateLimitMock.checkAndIncrement).not.toHaveBeenCalled() }) it('deletes workflow approval medium records through the Telegram association service', async () => { diff --git a/control-api/test/routes.externalAccess.rateLimitOrdering.test.ts b/control-api/test/routes.externalAccess.rateLimitOrdering.test.ts index fe94405ad6..8163ec3aa7 100644 --- a/control-api/test/routes.externalAccess.rateLimitOrdering.test.ts +++ b/control-api/test/routes.externalAccess.rateLimitOrdering.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import express from 'express' import request from 'supertest' +import { config } from '../src/config.js' import { createExternalAccessRouter } from '../src/routes/external/access.js' import { canonicalEnvironmentId } from '../src/services/access/operationalAccessProjection.js' import { signUserSessionV2Token } from '../src/utils/auth/userSessionV2Token.js' @@ -100,30 +101,45 @@ describe('external access authenticated rate-limit ordering', () => { }) }) - it.each([ - ['GET', '/external/access/capabilities', 'external_access_capabilities'], - ['GET', '/external/access/catalog', 'external_access_catalog'], - ['POST', '/external/access/resolve', 'external_access_resolve'], - ] as const)( - 'returns 429 before runtime policy work for a limited %s %s request', - async (method, path, bucketType) => { - const response = await request(app()) - [method === 'GET' ? 'get' : 'post'](path) - .set('x-user-session-token', sessionToken()) - .send(method === 'POST' ? {} : undefined) + it('limits catalog before runtime policy work using the retained user bucket', async () => { + const response = await request(app()) + .get('/external/access/catalog') + .set('x-user-session-token', sessionToken()) + expect(response.status).toBe(429) + expect(response.body).toMatchObject({ error: { code: 'rate_limited' } }) + expect(response.headers['retry-after']).toBeDefined() + expect(rateLimiter.checkAndIncrement).toHaveBeenCalledWith( + `external_access_catalog:${userId}`, + 10 + ) + expect(mocks.resolvePolicy).not.toHaveBeenCalled() + expect(mocks.resolveAuthorization).not.toHaveBeenCalled() + }) - expect(response.status).toBe(429) - expect(response.body).toMatchObject({ error: { code: 'rate_limited' } }) - expect(response.headers['retry-after']).toBeDefined() - const key = - bucketType === 'external_access_capabilities' - ? `${bucketType}:user:${userId}` - : `${bucketType}:${userId}` - expect(rateLimiter.checkAndIncrement).toHaveBeenCalledWith(key, 10) - expect(mocks.resolvePolicy).not.toHaveBeenCalled() - expect(mocks.resolveAuthorization).not.toHaveBeenCalled() + it('shares the users edge family across capabilities and resolve without per-user PG charges', async () => { + const server = app() + const session = sessionToken() + const limit = config.approvalRlExternalEdgePerMin + expect(limit).toBe(120) + for (let index = 0; index < limit; index += 1) { + const response = + index % 2 === 0 + ? await request(server) + .get('/external/access/capabilities') + .set('x-user-session-token', session) + : await request(server) + .post('/external/access/resolve') + .set('x-user-session-token', session) + .send({}) + expect(response.status).toBe(index % 2 === 0 ? 200 : 400) } - ) + await request(server) + .get('/external/access/capabilities') + .set('x-user-session-token', session) + .expect(429) + expect(rateLimiter.checkAndIncrement).not.toHaveBeenCalled() + expect(mocks.resolvePolicy).toHaveBeenCalled() + }) it.each([ ['GET', '/external/access/capabilities'], @@ -147,60 +163,54 @@ describe('external access authenticated rate-limit ordering', () => { it.each([ ['GET', '/external/access/catalog'], ['POST', '/external/access/resolve'], - ] as const)( - 'preserves 409 only after an allowed %s %s bucket reaches final policy validation', - async (method, path) => { - rateLimiter.checkAndIncrement.mockResolvedValue({ - allowed: true, - remaining: 9, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - count: 1, - backendAvailable: true, - }) - mocks.resolvePolicy.mockResolvedValue( - effectivePolicy({ serveCatalog: false, actionContextV2: false }) - ) + ] as const)('preserves 409 when %s %s reaches final policy validation', async (method, path) => { + rateLimiter.checkAndIncrement.mockResolvedValue({ + allowed: true, + remaining: 9, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + count: 1, + backendAvailable: true, + }) + mocks.resolvePolicy.mockResolvedValue( + effectivePolicy({ serveCatalog: false, actionContextV2: false }) + ) - const response = await request(app()) - [method === 'GET' ? 'get' : 'post'](path) - .set('x-user-session-token', sessionToken()) - .send(method === 'POST' ? {} : undefined) + const response = await request(app()) + [method === 'GET' ? 'get' : 'post'](path) + .set('x-user-session-token', sessionToken()) + .send(method === 'POST' ? {} : undefined) - expect(response.status).toBe(409) - expect(response.body).toMatchObject({ error: { code: 'invalid_request' } }) - expect(mocks.resolvePolicy).toHaveBeenCalledTimes(2) - } - ) + expect(response.status).toBe(409) + expect(response.body).toMatchObject({ error: { code: 'invalid_request' } }) + expect(mocks.resolvePolicy).toHaveBeenCalledTimes(2) + }) it.each([ ['GET', '/external/access/catalog'], ['POST', '/external/access/resolve'], - ] as const)( - 'preserves 503 only after an allowed %s %s bucket reaches final policy validation', - async (method, path) => { - rateLimiter.checkAndIncrement.mockResolvedValue({ - allowed: true, - remaining: 9, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - backendAvailable: true, - count: 1, - }) - mocks.resolvePolicy - .mockResolvedValueOnce(effectivePolicy()) - .mockRejectedValueOnce(new Error('readiness unavailable')) + ] as const)('preserves 503 when %s %s reaches final policy validation', async (method, path) => { + rateLimiter.checkAndIncrement.mockResolvedValue({ + allowed: true, + remaining: 9, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + backendAvailable: true, + count: 1, + }) + mocks.resolvePolicy + .mockResolvedValueOnce(effectivePolicy()) + .mockRejectedValueOnce(new Error('readiness unavailable')) - const response = await request(app()) - [method === 'GET' ? 'get' : 'post'](path) - .set('x-user-session-token', sessionToken()) - .send(method === 'POST' ? {} : undefined) + const response = await request(app()) + [method === 'GET' ? 'get' : 'post'](path) + .set('x-user-session-token', sessionToken()) + .send(method === 'POST' ? {} : undefined) - expect(response.status).toBe(503) - expect(response.body).toMatchObject({ error: { code: 'authority_unavailable' } }) - expect(mocks.resolvePolicy).toHaveBeenCalledTimes(2) - } - ) + expect(response.status).toBe(503) + expect(response.body).toMatchObject({ error: { code: 'authority_unavailable' } }) + expect(mocks.resolvePolicy).toHaveBeenCalledTimes(2) + }) it('continues an allowed resolve request after final policy validation', async () => { rateLimiter.checkAndIncrement.mockResolvedValue({ @@ -242,39 +252,36 @@ describe('external access authenticated rate-limit ordering', () => { it.each([ ['GET', '/external/access/catalog'], ['POST', '/external/access/resolve'], - ] as const)( - 'rechecks current session authority after an allowed %s %s bucket', - async (method, path) => { - rateLimiter.checkAndIncrement.mockResolvedValue({ - allowed: true, - remaining: 9, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - backendAvailable: true, - count: 1, + ] as const)('rechecks current session authority for %s %s', async (method, path) => { + rateLimiter.checkAndIncrement.mockResolvedValue({ + allowed: true, + remaining: 9, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + backendAvailable: true, + count: 1, + }) + mocks.validateV2 + .mockResolvedValueOnce({ + status: 'valid', + identity: { + userId, + email: 'user@example.test', + sid: sessionId, + jti: sessionJti, + sessionVersion: 1, + }, }) - mocks.validateV2 - .mockResolvedValueOnce({ - status: 'valid', - identity: { - userId, - email: 'user@example.test', - sid: sessionId, - jti: sessionJti, - sessionVersion: 1, - }, - }) - .mockResolvedValueOnce({ status: 'revoked', reason: 'logout' }) + .mockResolvedValueOnce({ status: 'revoked', reason: 'logout' }) - const response = await request(app()) - [method === 'GET' ? 'get' : 'post'](path) - .set('x-user-session-token', sessionToken()) - .send(method === 'POST' ? {} : undefined) + const response = await request(app()) + [method === 'GET' ? 'get' : 'post'](path) + .set('x-user-session-token', sessionToken()) + .send(method === 'POST' ? {} : undefined) - expect(response.status).toBe(401) - expect(response.body).toMatchObject({ error: { code: 'invalid_session' } }) - expect(mocks.validateV2).toHaveBeenCalledTimes(2) - expect(mocks.resolveAuthorization).not.toHaveBeenCalled() - } - ) + expect(response.status).toBe(401) + expect(response.body).toMatchObject({ error: { code: 'invalid_session' } }) + expect(mocks.validateV2).toHaveBeenCalledTimes(2) + expect(mocks.resolveAuthorization).not.toHaveBeenCalled() + }) }) diff --git a/control-api/test/routes.externalAccess.test.ts b/control-api/test/routes.externalAccess.test.ts index a84418e274..d5b27c7630 100644 --- a/control-api/test/routes.externalAccess.test.ts +++ b/control-api/test/routes.externalAccess.test.ts @@ -195,7 +195,8 @@ describe('external user-access contracts', () => { expect(response.body.error.code).toBe('invalid_session') }) - it('stops a limited capability read before resolving the capability manifest', async () => { + it('serves capabilities without a per-user PG budget (dev users-family parity)', async () => { + // A denying PG limiter would surface as 429 if the capability read still charged one. rateLimiter.checkAndIncrement.mockResolvedValue({ allowed: false, remaining: 0, @@ -209,14 +210,10 @@ describe('external user-access contracts', () => { .get('/external/access/capabilities') .set('x-user-session-token', 'v1-session') - expect(response.status).toBe(429) - expect(response.body).toMatchObject({ error: { code: 'rate_limited' } }) - expect(response.headers['retry-after']).toBeDefined() - expect(rateLimiter.checkAndIncrement).toHaveBeenCalledWith( - 'external_access_capabilities:user:10000000-0000-4000-8000-000000000001', - 10 - ) - expect(mocks.resolvePolicy).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect(response.body.currentSessionContract).toBe('v1') + expect(mocks.resolvePolicy).toHaveBeenCalled() + expect(rateLimiter.checkAndIncrement).not.toHaveBeenCalled() }) it('admits the mounted default twelve-family catalog at its complete producer cost', async () => { diff --git a/control-api/test/routes.externalDirectory.test.ts b/control-api/test/routes.externalDirectory.test.ts index 847d52b40a..36a6598922 100644 --- a/control-api/test/routes.externalDirectory.test.ts +++ b/control-api/test/routes.externalDirectory.test.ts @@ -87,7 +87,7 @@ describe('external directory authorization', () => { expect(mocks.searchDirectory).toHaveBeenCalledWith('team-1', 'person', undefined) }) - it('uses the authenticated user key and canonical limiter on an allowed search', async () => { + it('keeps dev limits: an allowed search charges no per-user PG bucket', async () => { mocks.membership.mockResolvedValue({ teamId: 'team-1', role: 'admin' }) const response = await request(app()) @@ -95,45 +95,15 @@ describe('external directory authorization', () => { .set('x-user-session-token', 'session') .expect(200) - expect(mocks.checkAndIncrement).toHaveBeenCalledOnce() - expect(mocks.checkAndIncrement).toHaveBeenCalledWith('directory-search:user-1', 30) - expect(response.headers['x-ratelimit-limit']).toBe('30') - expect(response.headers['x-ratelimit-remaining']).toBe('29') - expect(response.headers['x-ratelimit-reset']).toBeDefined() + // Witness: the search ran with live membership and its budget. expect(mocks.membership).toHaveBeenCalledWith( 'user-1', 'team-1', expect.objectContaining({ budget: expect.anything() }) ) expect(mocks.searchDirectory).toHaveBeenCalledWith('team-1', 'person', undefined) - }) - - it('returns the canonical 429 and does not search when the limiter denies', async () => { - const resetMs = Date.now() + 60_000 - mocks.membership.mockResolvedValue({ teamId: 'team-1', role: 'admin' }) - mocks.checkAndIncrement.mockResolvedValueOnce({ - allowed: false, - remaining: 0, - resetMs, - windowStartMs: resetMs - 60_000, - count: 31, - backendAvailable: true, - }) - - const response = await request(app()) - .get('/external/directory/search?teamId=team-1&q=person') - .set('x-user-session-token', 'session') - .expect(429) - - expect(mocks.checkAndIncrement).toHaveBeenCalledOnce() - expect(mocks.checkAndIncrement).toHaveBeenCalledWith('directory-search:user-1', 30) - expect(response.body).toMatchObject({ - error: { code: 'rate_limited', retryable: true }, - }) - expect(Number(response.headers['retry-after'])).toBeGreaterThan(0) - expect(response.headers['x-ratelimit-limit']).toBe('30') - expect(response.headers['x-ratelimit-remaining']).toBe('0') - expect(response.headers['x-ratelimit-reset']).toBe(String(Math.floor(resetMs / 1000))) - expect(mocks.searchDirectory).not.toHaveBeenCalled() + // dev has no per-user directory-search bucket on this route. + expect(mocks.checkAndIncrement).not.toHaveBeenCalled() + expect(response.headers['x-ratelimit-limit']).toBeUndefined() }) }) diff --git a/control-api/test/routes.externalInvitations.unavailable.test.ts b/control-api/test/routes.externalInvitations.unavailable.test.ts index 5a64b594e4..982d7e47e0 100644 --- a/control-api/test/routes.externalInvitations.unavailable.test.ts +++ b/control-api/test/routes.externalInvitations.unavailable.test.ts @@ -278,26 +278,11 @@ describe('external invitation routes when the hub is unavailable', () => { expect(directory.listPendingInvitations).not.toHaveBeenCalledWith('victim@example.com') }) - it('stops an authenticated pending-invitation read before the directory handler', async () => { - rateLimiter.checkAndIncrement.mockResolvedValue({ - allowed: false, - remaining: 0, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - count: 11, - backendAvailable: true, - }) - - const response = await request(app()).get('/external/invitations/pending') - - expect(response.status).toBe(429) - expect(response.body).toMatchObject({ error: { code: 'rate_limited' } }) - expect(response.headers['retry-after']).toBeDefined() - expect(rateLimiter.checkAndIncrement).toHaveBeenCalledWith( - 'external_invitation_read:user:caller-user', - 10 - ) - expect(directory.listPendingInvitations).not.toHaveBeenCalled() + it('reads owned pending invitations without an added user quota', async () => { + directory.listPendingInvitations.mockResolvedValue([]) + await request(app()).get('/external/invitations/pending').expect(200) + expect(directory.listPendingInvitations).toHaveBeenCalledWith('caller@example.com') + expect(rateLimiter.checkAndIncrement).not.toHaveBeenCalled() }) it('REGRESSION: a generic validation error still maps to 400 invalid_invitation', async () => { diff --git a/control-api/test/routes.externalMembers.test.ts b/control-api/test/routes.externalMembers.test.ts index dd5eea2a31..2748d04042 100644 --- a/control-api/test/routes.externalMembers.test.ts +++ b/control-api/test/routes.externalMembers.test.ts @@ -185,47 +185,23 @@ describe('external members routes', () => { expect(directoryMock.createManagedInvitationForUser).not.toHaveBeenCalled() }) - it('rate limits member reads before directory access', async () => { - rateLimitMock.checkAndIncrement.mockResolvedValueOnce({ - allowed: false, - count: 31, - remaining: 0, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - backendAvailable: true, - }) - - const response = await request(makeApp()).get('/external/members') - - expect(response.status).toBe(429) - expect(rateLimitMock.checkAndIncrement).toHaveBeenCalledWith( - 'external_member_read:user:manager-1', - 30 - ) - expect(directoryMock.listManagedMembersForUser).not.toHaveBeenCalled() + it('lists managed members without an added user quota', async () => { + directoryMock.listManagedMembersForUser.mockResolvedValue([]) + await request(makeApp()).get('/external/members').expect(200) + expect(directoryMock.listManagedMembersForUser).toHaveBeenCalled() + expect(rateLimitMock.checkAndIncrement).not.toHaveBeenCalled() }) - it('rate limits member mutations before invitation creation', async () => { - rateLimitMock.checkAndIncrement.mockResolvedValueOnce({ - allowed: false, - count: 11, - remaining: 0, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - backendAvailable: true, + it('creates invitations without an added user quota', async () => { + directoryMock.createManagedInvitationForUser.mockResolvedValue({ + invitation: { id: 'inv-parity' }, }) - - const response = await request(makeApp()) + await request(makeApp()) .post('/external/members/invitations') - .send({ email: 'invitee@example.com', teams: [] }) - - expect(response.status).toBe(429) - expect(response.headers['retry-after']).toBeDefined() - expect(rateLimitMock.checkAndIncrement).toHaveBeenCalledWith( - 'external_member_mutation:user:manager-1', - 10 - ) - expect(directoryMock.createManagedInvitationForUser).not.toHaveBeenCalled() + .send({ email: 'invitee@example.test', teams: [{ teamId: 'team-1', role: 'member' }] }) + .expect(201) + expect(directoryMock.createManagedInvitationForUser).toHaveBeenCalled() + expect(rateLimitMock.checkAndIncrement).not.toHaveBeenCalled() }) it('requires an explicit reason and Idempotency-Key before retiring a managed user', async () => { diff --git a/control-api/test/routes.externalMembersInvitationContract.test.ts b/control-api/test/routes.externalMembersInvitationContract.test.ts index a44f67e01d..a2d9f78190 100644 --- a/control-api/test/routes.externalMembersInvitationContract.test.ts +++ b/control-api/test/routes.externalMembersInvitationContract.test.ts @@ -118,10 +118,7 @@ describe('external member invitation producer contract', () => { .send(body) .expect(expected.status, expected.body) - expect(rateLimitMock.checkAndIncrement).toHaveBeenCalledWith( - 'external_member_mutation:user:manager-1', - 10 - ) + expect(rateLimitMock.checkAndIncrement).not.toHaveBeenCalled() }) it.each([ diff --git a/control-api/test/routes.externalNotificationPreferencesRateLimit.test.ts b/control-api/test/routes.externalNotificationPreferencesRateLimit.test.ts index b9aa261a45..0fb52f18d1 100644 --- a/control-api/test/routes.externalNotificationPreferencesRateLimit.test.ts +++ b/control-api/test/routes.externalNotificationPreferencesRateLimit.test.ts @@ -36,7 +36,7 @@ function makeApp() { return app } -describe('external notification preference rate limits', () => { +describe('external notification preference dev parity', () => { beforeEach(() => { vi.clearAllMocks() preferencesMock.getUserNotificationPreferences.mockResolvedValue({ preferredMedium: null }) @@ -51,46 +51,18 @@ describe('external notification preference rate limits', () => { }) }) - it('rate limits preference reads before loading user preferences', async () => { - rateLimitMock.checkAndIncrement.mockResolvedValueOnce({ - allowed: false, - count: 31, - remaining: 0, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - backendAvailable: true, - }) - - const response = await request(makeApp()).get('/external/me/notification-preferences') - - expect(response.status).toBe(429) - expect(rateLimitMock.checkAndIncrement).toHaveBeenCalledWith( - 'external_notification_preference_read:user:user-1', - 30 - ) - expect(preferencesMock.getUserNotificationPreferences).not.toHaveBeenCalled() + it('preserves uncharged preference get behavior', async () => { + await request(makeApp()).get('/external/me/notification-preferences').expect(200) + expect(preferencesMock.getUserNotificationPreferences).toHaveBeenCalled() + expect(rateLimitMock.checkAndIncrement).not.toHaveBeenCalled() }) - it('rate limits preference mutations before persisting user preferences', async () => { - rateLimitMock.checkAndIncrement.mockResolvedValueOnce({ - allowed: false, - count: 11, - remaining: 0, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - backendAvailable: true, - }) - - const response = await request(makeApp()) + it('preserves uncharged preference put behavior', async () => { + await request(makeApp()) .put('/external/me/notification-preferences') .send({ preferredMedium: 'email' }) - - expect(response.status).toBe(429) - expect(response.headers['retry-after']).toBeDefined() - expect(rateLimitMock.checkAndIncrement).toHaveBeenCalledWith( - 'external_notification_preference_mutation:user:user-1', - 10 - ) - expect(preferencesMock.upsertUserNotificationPreferences).not.toHaveBeenCalled() + .expect(200) + expect(preferencesMock.upsertUserNotificationPreferences).toHaveBeenCalled() + expect(rateLimitMock.checkAndIncrement).not.toHaveBeenCalled() }) }) diff --git a/control-api/test/routes.externalSessionAtomicExchange.test.ts b/control-api/test/routes.externalSessionAtomicExchange.test.ts index ecd7149e54..4fffe8cb94 100644 --- a/control-api/test/routes.externalSessionAtomicExchange.test.ts +++ b/control-api/test/routes.externalSessionAtomicExchange.test.ts @@ -13,7 +13,6 @@ const issuance = vi.hoisted(() => ({ issueExternalUserSession: vi.fn(), selectExternalSessionRepresentation: vi.fn(), })) -const limiter = vi.hoisted(() => ({ checkAndIncrement: vi.fn() })) const database = vi.hoisted(() => ({ query: vi.fn() })) vi.mock('../src/services/auth/externalSessionAuthentication.js', () => ({ @@ -25,7 +24,6 @@ vi.mock('../src/services/auth/userSessionService.js', async importOriginal => { return { ...actual, ...revocations } }) vi.mock('../src/services/auth/externalSessionIssuance.js', () => issuance) -vi.mock('../src/services/rateLimiterService.js', () => limiter) vi.mock('../src/db.js', () => ({ pool: { query: database.query } })) function app() { @@ -48,13 +46,6 @@ describe('legacy external-session replacement exchange', () => { beforeEach(() => { vi.clearAllMocks() - limiter.checkAndIncrement.mockResolvedValue({ - allowed: true, - remaining: 9, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - count: 1, - }) sessions.authenticateExternalUserSession.mockResolvedValue({ status: 'authenticated', contract: 'v1', diff --git a/control-api/test/routes.externalSessionRenewalRateLimit.test.ts b/control-api/test/routes.externalSessionRenewalRateLimit.test.ts index a621172ae5..75da88c3a7 100644 --- a/control-api/test/routes.externalSessionRenewalRateLimit.test.ts +++ b/control-api/test/routes.externalSessionRenewalRateLimit.test.ts @@ -59,42 +59,28 @@ describe('external session route error boundaries and renewal rate limiting', () }) }) - it('stops a rate-limited authenticated renewal before rotating the session', async () => { - limiter.checkAndIncrement - .mockResolvedValueOnce({ - allowed: true, - remaining: 9, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - count: 1, - backendAvailable: true, - }) - .mockResolvedValueOnce({ - allowed: false, - remaining: 0, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - count: 11, - backendAvailable: true, - }) + it('renews an authenticated session without a lifecycle PG budget (dev parity)', async () => { + // A denying limiter would surface as 429 if renewal still charged a bucket. + limiter.checkAndIncrement.mockResolvedValue({ + allowed: false, + remaining: 0, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + count: 11, + backendAvailable: true, + }) const response = await request(app()) .post('/external/auth/session/renew') .set('x-forwarded-for', '203.0.113.44') .send({ token: 'synthetic-session-token' }) - expect(response.status).toBe(429) - expect(response.body).toMatchObject({ error: { code: 'rate_limited' } }) - expect(response.headers['retry-after']).toBeDefined() + expect(response.status).toBe(200) expect(sessions.authenticateExternalUserSession).toHaveBeenCalledWith( 'synthetic-session-token', expect.objectContaining({ purpose: 'renew' }) ) - expect(limiter.checkAndIncrement).toHaveBeenNthCalledWith( - 2, - 'external_session_lifecycle:user:user-1', - 10 - ) - expect(sessions.renewExternalUserSession).not.toHaveBeenCalled() + expect(sessions.renewExternalUserSession).toHaveBeenCalledOnce() + expect(limiter.checkAndIncrement).not.toHaveBeenCalled() }) it('keeps an application authentication error on the sanitized 500 path', async () => { diff --git a/control-api/test/routes.externalSharedFilesystems.test.ts b/control-api/test/routes.externalSharedFilesystems.test.ts index a4b52155e0..2f19d013e4 100644 --- a/control-api/test/routes.externalSharedFilesystems.test.ts +++ b/control-api/test/routes.externalSharedFilesystems.test.ts @@ -115,27 +115,16 @@ const validAuth = () => { } describe('GET /external/contexts/:contextId/shared-filesystems', () => { - it('rate limits filesystem reads before resolving accessible contexts', async () => { + it('preserves context authorization without an added user quota', async () => { validAuth() - rateLimitMock.checkAndIncrement.mockResolvedValueOnce({ - allowed: false, - count: 31, - remaining: 0, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - backendAvailable: true, - }) - - const response = await request(buildApp({ getResource: vi.fn() })) + mockGetUserContexts.mockResolvedValue({ userId: 'user-1', contextIds: [] }) + mockGetTeamContexts.mockResolvedValue({ teamId: 'team-1', contextIds: [] }) + await request(buildApp({ getResource: vi.fn() })) .get('/external/contexts/ctx-a/shared-filesystems') - .set('x-user-session-token', 'dummy') - - expect(response.status).toBe(429) - expect(rateLimitMock.checkAndIncrement).toHaveBeenCalledWith( - 'external_shared_filesystem_read:user:user-1', - 30 - ) - expect(mockGetUserContexts).not.toHaveBeenCalled() + .set('x-user-session-token', 'synthetic-session') + .expect(403) + expect(mockGetUserContexts).toHaveBeenCalled() + expect(rateLimitMock.checkAndIncrement).not.toHaveBeenCalled() }) it('returns merged spec+status for an accessible context', async () => { diff --git a/control-api/test/routes.externalTeamsInvitations.test.ts b/control-api/test/routes.externalTeamsInvitations.test.ts index 7d1508d2f6..a70e78ad26 100644 --- a/control-api/test/routes.externalTeamsInvitations.test.ts +++ b/control-api/test/routes.externalTeamsInvitations.test.ts @@ -83,45 +83,20 @@ describe('legacy external team invitation responses', () => { expect(JSON.stringify(response.body)).not.toContain('synthetic-must-never-leave-control-api') }) - it('rate limits team reads before directory access', async () => { - rateLimitMock.checkAndIncrement.mockResolvedValueOnce({ - allowed: false, - count: 31, - remaining: 0, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - backendAvailable: true, - }) - - const response = await request(app()).get('/external/teams/team-1/members') - - expect(response.status).toBe(429) - expect(rateLimitMock.checkAndIncrement).toHaveBeenCalledWith( - 'external_team_user_read:user:manager-1', - 30 - ) - expect(directory.listMembers).not.toHaveBeenCalled() + it('lists team members without an added user quota', async () => { + directory.listMembers.mockResolvedValue([]) + await request(app()).get('/external/teams/team-1/members').expect(200) + expect(directory.listMembers).toHaveBeenCalled() + expect(rateLimitMock.checkAndIncrement).not.toHaveBeenCalled() }) - it('rate limits team mutations before invitation creation', async () => { - rateLimitMock.checkAndIncrement.mockResolvedValueOnce({ - allowed: false, - count: 11, - remaining: 0, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - backendAvailable: true, - }) - - const response = await request(app()) + it('creates team invitations without an added user quota', async () => { + directory.createManagedInvitationForUser.mockResolvedValue({ invitation: { id: 'inv-parity' } }) + await request(app()) .post('/external/teams/team-1/invitations') - .send({ email: 'invitee@example.com', role: 'member' }) - - expect(response.status).toBe(429) - expect(rateLimitMock.checkAndIncrement).toHaveBeenCalledWith( - 'external_team_user_mutation:user:manager-1', - 10 - ) - expect(directory.createManagedInvitationForUser).not.toHaveBeenCalled() + .send({ email: 'invitee@example.test', role: 'member' }) + .expect(200) + expect(directory.createManagedInvitationForUser).toHaveBeenCalled() + expect(rateLimitMock.checkAndIncrement).not.toHaveBeenCalled() }) }) diff --git a/control-api/test/routes.externalWorkflows.test.ts b/control-api/test/routes.externalWorkflows.test.ts index 212b006c1b..5c5bc464a4 100644 --- a/control-api/test/routes.externalWorkflows.test.ts +++ b/control-api/test/routes.externalWorkflows.test.ts @@ -292,19 +292,7 @@ describe('routes/external/workflows', () => { }) describe('authenticated workflow admission ordering', () => { - it.each([ - ['GET', '/external/workflows', 30], - ['GET', `/external/workflows/${RECIPE_NS}/test-recipe`, 30], - ['GET', `/external/workflows/${RECIPE_NS}/test-recipe/health`, 30], - ['GET', `/external/workflows/${RECIPE_NS}/test-recipe/runs`, 30], - ['GET', `/external/workflows/${RECIPE_NS}/test-recipe/runs/run-1/artifacts`, 30], - [ - 'GET', - `/external/workflows/${RECIPE_NS}/test-recipe/runs/run-1/artifacts/output/download`, - 30, - ], - ['POST', `/external/workflows/${RECIPE_NS}/test-recipe/trigger`, 10], - ] as const)( + it.each([['POST', `/external/workflows/${RECIPE_NS}/test-recipe/trigger`, 10]] as const)( 'limits %s %s before final authority or protected work', async (method, path, limit) => { mockRateLimitCheck.mockResolvedValue({ @@ -329,6 +317,24 @@ describe('routes/external/workflows', () => { expect(mockPoolQuery).not.toHaveBeenCalled() } ) + + it.each([ + ['/external/workflows', 200], + [`/external/workflows/${RECIPE_NS}/test-recipe`, 403], + [`/external/workflows/${RECIPE_NS}/test-recipe/health`, 403], + [`/external/workflows/${RECIPE_NS}/test-recipe/runs`, 403], + [`/external/workflows/${RECIPE_NS}/test-recipe/runs/run-1/artifacts`, 403], + [`/external/workflows/${RECIPE_NS}/test-recipe/runs/run-1/artifacts/output/download`, 403], + ] as const)('authorizes read %s without a PG charge', async (path, status) => { + mockPoolQuery.mockResolvedValue({ rows: [], rowCount: 0 }) + await request(makeApp(gateway)) + .get(path) + .set('x-user-session-token', 'user-session-token') + .expect(status) + expect(mockAuthenticateExternalUserSession).toHaveBeenCalledTimes(2) + expect(mockPoolQuery).toHaveBeenCalled() + expect(mockRateLimitCheck).not.toHaveBeenCalled() + }) }) describe('Cross-user isolation (ensureRecipeAuthorized)', () => { diff --git a/control-api/test/routes.hostMessageAdmission.test.ts b/control-api/test/routes.hostMessageAdmission.test.ts index f56e07e8a4..050c7c4780 100644 --- a/control-api/test/routes.hostMessageAdmission.test.ts +++ b/control-api/test/routes.hostMessageAdmission.test.ts @@ -1,8 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import express from 'express' import request from 'supertest' -import { fetchHostConnectionFromControlApi } from '../../rpc-proxy/src/services/controlApiRestService.js' -import { config } from '../src/config.js' import type { K8sGateway } from '../src/k8s.js' import { createRpcAccessUsersRouter } from '../src/routes/rpc-access/users.js' import { signRpcAccessToken } from '../src/utils/auth/rpcAuthToken.js' @@ -64,7 +62,7 @@ function send( return request(server).post(ROUTE(user, host)).set('x-rpc-access-token', jwt).send(body) } -describe('R44-H1 legacy Host-message admission', () => { +describe('Host-message resolution parity', () => { beforeEach(() => { vi.clearAllMocks() directory.getUserAgents.mockImplementation(async (userId: string) => ({ @@ -73,172 +71,50 @@ describe('R44-H1 legacy Host-message admission', () => { })) directory.getCurrentTeam.mockResolvedValue({ id: 'team-a', role: 'member' }) directory.getTeamAgents.mockResolvedValue({ agentNames: ['host-a', 'host-b'] }) - const counts = new Map() - limiter.checkAndIncrement.mockImplementation(async (key: string, max: number) => { - const count = (counts.get(key) ?? 0) + 1 - counts.set(key, count) - return { - allowed: count <= max, - remaining: Math.max(0, max - count), - resetMs: Date.now() + 30_000, - windowStartMs: Date.now() - 30_000, - count, - backendAvailable: true, - } - }) }) - it('allows 60 ordinary and bound sends in one subject bucket, then denies before Host work', async () => { + it('authorizes more than 60 mixed ordinary and bound sends without a limiter', async () => { const { server, gateway, bindingService } = app() const jwt = token(USER_A, ['host-a', 'host-b']) - for (let index = 0; index < 60; index += 1) { - const host = index % 2 ? 'host-b' : 'host-a' - const body = index % 2 ? { ...BINDING, sessionId: `session-${index}` } : {} - await send(server, USER_A, host, body, jwt).expect(200) - } - const resolverCalls = gateway.listResource.mock.calls.length - const bindingCalls = bindingService.bind.mock.calls.length - const denied = await send( - server, - USER_A, - 'host-b', - { ...BINDING, sessionId: 'rotated' }, - jwt - ).expect(429) - expect(denied.body).toEqual({ - error: 'Too Many Requests', - retryAfterSeconds: expect.any(Number), - }) - expect(Number(denied.headers['retry-after'])).toBeGreaterThan(0) - for (const header of ['x-ratelimit-limit', 'x-ratelimit-remaining', 'x-ratelimit-reset']) { - expect(denied.headers[header]).toBeDefined() - } - expect(gateway.listResource).toHaveBeenCalledTimes(resolverCalls) - expect(bindingService.bind).toHaveBeenCalledTimes(bindingCalls) - expect(new Set(limiter.checkAndIncrement.mock.calls.map(call => call[0])).size).toBe(1) - const fetchImpl = vi.fn(async (url: string, init: RequestInit) => { - const rpcPath = new URL(url).pathname.split('/rpc/access')[1] - expect(rpcPath).toBeDefined() - const produced = await request(server) - .post(`/rpc/access${rpcPath}`) - .set('x-rpc-access-token', jwt) - .send(JSON.parse(String(init.body))) - return new Response(produced.text, { - status: produced.status, - headers: produced.headers as Record, - }) - }) as unknown as typeof fetch - await expect( - fetchHostConnectionFromControlApi(USER_A, 'host-b', jwt, { - messageResolution: true, - fetchImpl, - }) - ).rejects.toMatchObject({ - status: 429, - body: { error: 'Too Many Requests', retryAfterSeconds: expect.any(Number) }, - headers: { 'x-ratelimit-limit': '60' }, + let charges = 0 + limiter.checkAndIncrement.mockImplementation(async (_key: string, maximum: number) => { + charges += 1 + return { + allowed: charges <= maximum, + remaining: Math.max(0, maximum - charges), + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + count: charges, + backendAvailable: true, + } }) - expect(gateway.listResource).toHaveBeenCalledTimes(resolverCalls) - await send(server, USER_B, 'host-a', {}).expect(200) - }) - - it('charges the legacy strict POST into the same subject bucket as message-resolution', async () => { - const { server, gateway, bindingService } = app() - const jwt = token(USER_A, ['host-a', 'host-b']) - const limit = config.hostMessageRlPerMin - const legacyStatuses: number[] = [] - const resolutionStatuses: number[] = [] - for (let index = 0; index < limit; index += 1) { + for (let index = 0; index < 66; index += 1) { const host = index % 2 ? 'host-b' : 'host-a' - if (index % 2 === 0) { - const legacy = await request(server) + const binding = { ...BINDING, sessionId: `session-${index}` } + if (index % 3 === 0) { + await request(server) .post(LEGACY_ROUTE(USER_A, host)) .set('x-rpc-access-token', jwt) - .send({ ...BINDING, sessionId: `legacy-${index}` }) - legacyStatuses.push(legacy.status) + .send(binding) + .expect(200) } else { - const resolution = await send(server, USER_A, host, {}, jwt) - resolutionStatuses.push(resolution.status) + await send(server, USER_A, host, index % 3 === 1 ? binding : {}, jwt).expect(200) } } - // Witness: both routes admitted requests into the shared bucket before it - // filled, so the refusals below cannot come from a route that never worked. - expect(legacyStatuses.length).toBeGreaterThan(0) - expect(resolutionStatuses.length).toBeGreaterThan(0) - expect(legacyStatuses).toEqual(Array.from({ length: legacyStatuses.length }, () => 200)) - expect(resolutionStatuses).toEqual(Array.from({ length: resolutionStatuses.length }, () => 200)) - - const resolverCalls = gateway.listResource.mock.calls.length - const bindingCalls = bindingService.bind.mock.calls.length - // Witness: the admitted half really authorized and bound, so the unchanged - // counts after both refusals are not a zero-against-zero comparison. - expect(resolverCalls).toBeGreaterThan(0) - expect(bindingCalls).toBeGreaterThan(0) - const legacyDenied = await request(server) - .post(LEGACY_ROUTE(USER_A, 'host-a')) - .set('x-rpc-access-token', jwt) - .send({ ...BINDING, sessionId: 'legacy-denied' }) - .expect(429) - const resolutionDenied = await send(server, USER_A, 'host-b', {}, jwt).expect(429) - expect(legacyDenied.body).toEqual({ - error: 'Too Many Requests', - retryAfterSeconds: expect.any(Number), - }) - expect(resolutionDenied.body).toEqual({ - error: 'Too Many Requests', - retryAfterSeconds: expect.any(Number), - }) - expect(gateway.listResource).toHaveBeenCalledTimes(resolverCalls) - expect(bindingService.bind).toHaveBeenCalledTimes(bindingCalls) - expect(new Set(limiter.checkAndIncrement.mock.calls.map(call => call[0]))).toEqual( - new Set([`host-message-admission:${USER_A}`]) - ) - expect(limiter.checkAndIncrement).toHaveBeenCalledTimes(limit + 2) - - // A different verified subject keeps its own bucket on either route. - await request(server) - .post(LEGACY_ROUTE(USER_B, 'host-a')) - .set('x-rpc-access-token', token(USER_B, ['host-a'])) - .send(BINDING) - .expect(200) - }) - - it('leaves the shared GET outside admission and charges the strict POST only after a valid body', async () => { - const { server } = app() - const jwt = token(USER_A, ['host-a']) - await request(server) - .get(`/rpc/access/users/${USER_A}/mcp-hosts/host-a`) - .set('x-rpc-access-token', jwt) - .expect(200) - expect(limiter.checkAndIncrement).not.toHaveBeenCalled() - await request(server) - .post(LEGACY_ROUTE(USER_A, 'host-a')) - .set('x-rpc-access-token', jwt) - .send({}) - .expect(400) + expect(gateway.listResource).toHaveBeenCalledTimes(66) + expect(bindingService.bind).toHaveBeenCalledTimes(44) expect(limiter.checkAndIncrement).not.toHaveBeenCalled() - await request(server) - .post(LEGACY_ROUTE(USER_A, 'host-a')) - .set('x-rpc-access-token', jwt) - .send(BINDING) - .expect(200) - expect(limiter.checkAndIncrement).toHaveBeenCalledOnce() - expect(limiter.checkAndIncrement).toHaveBeenCalledWith( - `host-message-admission:${USER_A}`, - config.hostMessageRlPerMin - ) }) - it('rejects invalid auth, scope, claim, and binding before charging', async () => { + it('rejects invalid auth, scope, claim, and binding before protected work', async () => { const { server, gateway, bindingService } = app() - // Liveness witness: one fully valid bound request proves the limiter, live - // Host authorization, and the binding are observable in this test. + // A valid request proves the Host authorization and binding are observable. await request(server) .post(LEGACY_ROUTE(USER_A, 'host-a')) .set('x-rpc-access-token', token(USER_A, ['host-a'])) .send(BINDING) .expect(200) - expect(limiter.checkAndIncrement).toHaveBeenCalledOnce() + expect(limiter.checkAndIncrement).not.toHaveBeenCalled() expect(gateway.listResource).toHaveBeenCalledOnce() expect(bindingService.bind).toHaveBeenCalledOnce() @@ -273,37 +149,21 @@ describe('R44-H1 legacy Host-message admission', () => { .send('{"runId":') .expect(400) // Every rejected shape above left the witness baseline unchanged. - expect(limiter.checkAndIncrement).toHaveBeenCalledOnce() + expect(limiter.checkAndIncrement).not.toHaveBeenCalled() expect(gateway.listResource).toHaveBeenCalledOnce() expect(bindingService.bind).toHaveBeenCalledOnce() }) - it('charges a valid request before a later live grant denial', async () => { + it('denies a missing live grant without a limiter call', async () => { const { server, gateway } = app() directory.getUserAgents.mockResolvedValue({ userId: USER_A, agentNames: [] }) directory.getTeamAgents.mockResolvedValue({ agentNames: [] }) await send(server, USER_A, 'host-a', {}).expect(403) - expect(limiter.checkAndIncrement).toHaveBeenCalledOnce() - expect(gateway.listResource).not.toHaveBeenCalled() - }) - - it.each([ - ['backend unavailable', { allowed: true, backendAvailable: false, count: 0 }], - ['missing result', undefined], - [ - 'malformed result', - { allowed: true, backendAvailable: true, count: 61, resetMs: Date.now() + 30_000 }, - ], - ])('fails closed when %s', async (_name, result) => { - limiter.checkAndIncrement.mockResolvedValue(result) - const { server, gateway } = app() - await send(server, USER_A, 'host-a', {}) - .expect(503) - .expect({ error: 'host_message_admission_unavailable' }) + expect(limiter.checkAndIncrement).not.toHaveBeenCalled() expect(gateway.listResource).not.toHaveBeenCalled() }) - it('denies unbound user and Host selectors before admission or protected work', async () => { + it('denies unbound user and Host selectors before protected work', async () => { const subjectCase = app() const subjectMismatch = await send( subjectCase.server, diff --git a/control-api/test/routes.profile.test.ts b/control-api/test/routes.profile.test.ts index 8d03010352..e46611af2e 100644 --- a/control-api/test/routes.profile.test.ts +++ b/control-api/test/routes.profile.test.ts @@ -1207,15 +1207,24 @@ describe('routes/profile', () => { .expect(401, { error: 'Unauthorized' }) }) - it('rate limits external credential attempts before authentication work', async () => { - rateLimitMock.checkAndIncrement.mockResolvedValueOnce({ - allowed: false, - remaining: 0, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - backendAvailable: true, - count: 6, - }) + // dev has no per-IP or per-user limiter on these routes; a denying PG limiter + // must never be consulted, and the handler must reach its business work. + const denyingRateLimit = () => ({ + allowed: false, + remaining: 0, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + backendAvailable: true, + count: 99, + }) + const prOnlyLimiterKeys = () => + rateLimitMock.checkAndIncrement.mock.calls + .map(call => String(call[0])) + .filter(key => key.startsWith('external_')) + + it('reaches Google verification without a PR-only attempt limiter (dev parity)', async () => { + rateLimitMock.checkAndIncrement.mockResolvedValue(denyingRateLimit()) + googleAuthMock.verifyGoogleIdToken.mockResolvedValue(null) const app = express() app.use(express.json()) mountInternalRoutes(app, { listResource: vi.fn() }) @@ -1224,73 +1233,48 @@ describe('routes/profile', () => { request(app).post('/external/auth/google-login') ).send({ idToken: 'google-id-token' }) - expect(response.status).toBe(429) - expect(rateLimitMock.checkAndIncrement).toHaveBeenCalledWith( - expect.stringMatching(/^external_authentication_attempt:ip:/), - 5 - ) - expect(googleAuthMock.verifyGoogleIdToken).not.toHaveBeenCalled() + expect(response.status).not.toBe(429) + expect(googleAuthMock.verifyGoogleIdToken).toHaveBeenCalledWith('google-id-token') + expect(prOnlyLimiterKeys()).toEqual([]) }) - it('rate limits session verification before session authority work', async () => { - rateLimitMock.checkAndIncrement.mockResolvedValueOnce({ - allowed: false, - remaining: 0, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - count: 11, - backendAvailable: true, - }) + it('verifies a session without a PR-only verification limiter (dev parity)', async () => { + rateLimitMock.checkAndIncrement.mockResolvedValue(denyingRateLimit()) const app = express() app.use(express.json()) mountInternalRoutes(app, { listResource: vi.fn() }) - const response = await withInternalServiceAuth(request(app).post('/external/auth/verify')).send( - { token: userSessionToken } - ) - - expect(response.status).toBe(429) - expect(rateLimitMock.checkAndIncrement).toHaveBeenCalledWith( - expect.stringMatching(/^external_session_verify:ip:/), - 10 - ) + await withInternalServiceAuth(request(app).post('/external/auth/verify')) + .send({ token: userSessionToken }) + .expect(200) + expect(prOnlyLimiterKeys()).toEqual([]) }) - it('rate limits RPC token issuance by trusted authenticated user before minting', async () => { - rateLimitMock.checkAndIncrement - .mockResolvedValueOnce({ - allowed: true, - remaining: 9, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - backendAvailable: true, - count: 1, - }) - .mockResolvedValueOnce({ - allowed: false, - remaining: 0, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - backendAvailable: true, - count: 11, - }) + it('issues RPC tokens without a PR-only issuance limiter (dev parity)', async () => { + rateLimitMock.checkAndIncrement.mockResolvedValue(denyingRateLimit()) + svc.getUserAgents.mockResolvedValue({ userId: 'u1', agentNames: ['agent-a'] }) + svc.getTeamAgents.mockResolvedValue({ teamId: 't1', agentNames: ['agent-a'] }) + rpcMock.issueRpcAccessToken.mockReturnValue({ + token: 'synthetic-parity-rpc-token', + accessScope: 'user', + teamId: null, + scopes: ['host:message:invoke'], + hostRefs: ['agent-a'], + expiresInSeconds: 300, + }) const app = express() app.use(express.json()) mountInternalRoutes(app, { listResource: vi.fn() }) const response = await withInternalServiceAuth(request(app).post('/external/rpc/token')).send({ sessionToken: userSessionToken, - scopes: ['mcp:servers:list'], + scopes: ['host:message:invoke'], hostRefs: ['agent-a'], }) - expect(response.status).toBe(429) - expect(rateLimitMock.checkAndIncrement).toHaveBeenNthCalledWith( - 2, - 'external_rpc_token:user:u1', - 10 - ) - expect(rpcMock.issueRpcAccessToken).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect(rpcMock.issueRpcAccessToken).toHaveBeenCalledOnce() + expect(prOnlyLimiterKeys()).toEqual([]) }) it('keeps legacy RPC issuance bound to the documented body session token', async () => { @@ -1356,15 +1340,9 @@ describe('routes/profile', () => { ) }) - it('rate limits user-directory reads before listing teams', async () => { - rateLimitMock.checkAndIncrement.mockResolvedValueOnce({ - allowed: false, - remaining: 0, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - backendAvailable: true, - count: 31, - }) + it('lists user teams without a PR-only read limiter (dev parity)', async () => { + rateLimitMock.checkAndIncrement.mockResolvedValue(denyingRateLimit()) + svc.listTeams.mockResolvedValue([]) const app = express() app.use(express.json()) mountInternalRoutes(app, { listResource: vi.fn() }) @@ -1373,23 +1351,14 @@ describe('routes/profile', () => { request(app).get('/external/users/u1/teams') ) - expect(response.status).toBe(429) - expect(rateLimitMock.checkAndIncrement).toHaveBeenCalledWith( - 'external_team_user_read:user:u1', - 30 - ) - expect(svc.listTeams).not.toHaveBeenCalled() + expect(response.status).not.toBe(429) + expect(svc.listTeams).toHaveBeenCalled() + expect(prOnlyLimiterKeys()).toEqual([]) }) - it('rate limits user profile mutations before updating profile data', async () => { - rateLimitMock.checkAndIncrement.mockResolvedValueOnce({ - allowed: false, - remaining: 0, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - backendAvailable: true, - count: 11, - }) + it('updates the user profile without a PR-only mutation limiter (dev parity)', async () => { + rateLimitMock.checkAndIncrement.mockResolvedValue(denyingRateLimit()) + svc.updateProfile.mockResolvedValue({ userId: 'u1', displayName: 'New Name' }) const app = express() app.use(express.json()) mountInternalRoutes(app, { listResource: vi.fn() }) @@ -1398,11 +1367,8 @@ describe('routes/profile', () => { request(app).put('/external/users/u1/profile') ).send({ displayName: 'New Name' }) - expect(response.status).toBe(429) - expect(rateLimitMock.checkAndIncrement).toHaveBeenCalledWith( - 'external_team_user_mutation:user:u1', - 10 - ) - expect(svc.updateProfile).not.toHaveBeenCalled() + expect(response.status).not.toBe(429) + expect(svc.updateProfile).toHaveBeenCalled() + expect(prOnlyLimiterKeys()).toEqual([]) }) }) diff --git a/control-api/test/routes.rpcAccessUsersArtifactAdmission.test.ts b/control-api/test/routes.rpcAccessUsersArtifactAdmission.test.ts index 45a8eb08e2..5a1cf13625 100644 --- a/control-api/test/routes.rpcAccessUsersArtifactAdmission.test.ts +++ b/control-api/test/routes.rpcAccessUsersArtifactAdmission.test.ts @@ -52,17 +52,10 @@ function buildApp() { return { app, gateway } } -describe('artifact-read admission with real RPC auth and claim-match middleware', () => { +describe('artifact-read authentication with real RPC auth and claim-match middleware', () => { beforeEach(() => { Object.values(directory).forEach(mock => mock.mockReset()) rateLimiter.checkAndIncrement.mockReset() - rateLimiter.checkAndIncrement.mockResolvedValue({ - allowed: true, - remaining: 29, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - count: 1, - }) }) it.each([ @@ -71,7 +64,7 @@ describe('artifact-read admission with real RPC auth and claim-match middleware' ['missing host:task:read scope', token({ scopes: ['host:status:read'] }), 403], ['subject mismatch', token({ sub: 'another-user' }), 403], ['Host absent from signed hostRefs', token({ hostRefs: ['another-host'] }), 403], - ])('%s fails before charging pre-admission', async (_case, accessToken, status) => { + ])('%s fails before protected work', async (_case, accessToken, status) => { const { app } = buildApp() let req = request(app).get(`/rpc/access/users/${userId}/mcp-hosts/${hostRef}/artifact-read`) if (accessToken) req = req.set('x-rpc-access-token', accessToken) diff --git a/control-api/test/routes.rpcAccessUsersArtifactRateLimitDefense.test.ts b/control-api/test/routes.rpcAccessUsersArtifactRateLimitDefense.test.ts deleted file mode 100644 index 0618da96d8..0000000000 --- a/control-api/test/routes.rpcAccessUsersArtifactRateLimitDefense.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import express from 'express' -import request from 'supertest' -import type { K8sGateway } from '../src/k8s.js' -import { createRpcAccessUsersRouter } from '../src/routes/rpc-access/users.js' - -const state = vi.hoisted(() => ({ - claims: null as { sub: string; hostRefs: string[] } | null, - authorization: vi.fn(), - checkAndIncrement: vi.fn(), -})) - -vi.mock('../src/middleware/rpcAccessAuth.js', () => ({ - requireValidRpcAccessTokenAny: - () => (req: { rpcAuth?: unknown }, _res: unknown, next: () => void) => { - req.rpcAuth = state.claims ?? undefined - next() - }, - requireRpcTokenUserMatch: () => (_req: unknown, _res: unknown, next: () => void) => { - next() - }, - requireRpcTokenHostMatch: () => (_req: unknown, _res: unknown, next: () => void) => { - next() - }, - requireValidRpcAccessToken: () => (_req: unknown, _res: unknown, next: () => void) => { - next() - }, -})) - -vi.mock('../src/services/access/rpcHostAccessAuthorizer.js', () => ({ - authorizeRpcHostAccess: (...args: unknown[]) => state.authorization(...args), -})) - -vi.mock('../src/services/rateLimiterService.js', () => ({ - checkAndIncrement: (...args: unknown[]) => state.checkAndIncrement(...args), -})) - -function buildApp() { - const gateway = { - listResource: vi.fn(async () => []), - getResource: vi.fn(async () => ({}) as never), - createResource: vi.fn(async () => ({})), - updateResource: vi.fn(async () => ({})), - deleteResource: vi.fn(async () => ({ ok: true })), - } - const app = express() - app.use( - createRpcAccessUsersRouter(gateway as unknown as K8sGateway, { - bindingService: { bind: vi.fn() }, - }) - ) - return app -} - -describe('Host artifact limiter defensive attribution', () => { - beforeEach(() => { - state.claims = null - state.authorization.mockReset() - state.checkAndIncrement.mockReset() - state.checkAndIncrement.mockResolvedValue({ - allowed: true, - remaining: 29, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - count: 1, - backendAvailable: true, - }) - }) - - it('counts a missing subject under the pre-admission sentinel', async () => { - await request(buildApp()) - .get('/rpc/access/users/user-a/mcp-hosts/host-a/artifact-read') - .expect(403) - - expect(state.checkAndIncrement).toHaveBeenCalledWith( - 'host-artifact-pre-admission:unauthenticated', - expect.any(Number) - ) - }) - - it('counts an unresolved post-authorization key under a separate sentinel', async () => { - state.claims = { sub: 'user-a', hostRefs: ['host-a'] } - state.authorization.mockResolvedValue({ - authorized: true, - connection: { url: 'http://host-a.example', hostRef: undefined }, - }) - - await request(buildApp()) - .get('/rpc/access/users/user-a/mcp-hosts/host-a/artifact-read') - .expect(200) - - expect(state.checkAndIncrement.mock.calls.map(call => call[0])).toEqual([ - 'host-artifact-pre-admission:user-a', - 'host-artifact-read:unresolved', - ]) - }) -}) diff --git a/control-api/test/routes.rpcAccessUsersMcpHost.test.ts b/control-api/test/routes.rpcAccessUsersMcpHost.test.ts index 72c34d74a5..8e0c27517d 100644 --- a/control-api/test/routes.rpcAccessUsersMcpHost.test.ts +++ b/control-api/test/routes.rpcAccessUsersMcpHost.test.ts @@ -182,17 +182,6 @@ describe('POST /rpc/access/users/:userId/mcp-hosts/:hostRef — atomic access an userId, })) svc.getTeamAgents.mockReset() - // The strict POST now shares the Host-message admission bucket with - // /message-resolution, so every exercised request must clear it. - rateLimiterMock.checkAndIncrement.mockReset() - rateLimiterMock.checkAndIncrement.mockResolvedValue({ - allowed: true, - remaining: 59, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - count: 1, - backendAvailable: true, - }) }) it('authorizes once, derives identity from verified claims, and records the binding', async () => { @@ -407,233 +396,60 @@ describe('GET /rpc/access/users/:userId/mcp-hosts/:hostRef — happy path', () = describe('GET /rpc/access/users/:userId/mcp-hosts/:hostRef/artifact-read', () => { beforeEach(() => { rateLimiterMock.checkAndIncrement.mockReset() - rateLimiterMock.checkAndIncrement.mockResolvedValue({ - allowed: true, - remaining: 29, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - count: 1, - backendAvailable: true, - }) svc.getUserAgents.mockReset() }) - it('charges subject admission before authorization and the canonical Host bucket after it', async () => { + it('authorizes more than 30 reads without a limiter call', async () => { const userId = 'user-a' const hostRef = 'chatllm' - svc.getUserAgents.mockResolvedValue({ userId, agentNames: [hostRef] }) - const { app, gatewayStub } = buildApp([ - { metadata: { name: hostRef }, spec: { enabled: true } }, - ]) - - const order: string[] = [] - rateLimiterMock.checkAndIncrement.mockImplementation(async (key: string) => { - order.push(`limit:${key}`) + let charges = 0 + rateLimiterMock.checkAndIncrement.mockImplementation(async (_key: string, maximum: number) => { + charges += 1 return { - allowed: true, - remaining: 29, + allowed: charges <= maximum, + remaining: Math.max(0, maximum - charges), resetMs: Date.now() + 60_000, windowStartMs: Date.now(), - count: 1, + count: charges, backendAvailable: true, } }) - svc.getUserAgents.mockImplementation(async () => { - order.push('authorize:directory') - return { userId, agentNames: [hostRef] } - }) - gatewayStub.listResource.mockImplementation(async () => { - order.push('authorize:kubernetes') - return [{ metadata: { name: hostRef }, spec: { enabled: true } }] - }) - - await request(app) - .get(`/rpc/access/users/${userId}/mcp-hosts/${hostRef}/artifact-read`) - .expect(200) - - expect(order).toEqual([ - `limit:host-artifact-pre-admission:${userId}`, - 'authorize:directory', - 'authorize:kubernetes', - `limit:host-artifact-read:${userId}:${hostRef}`, - ]) - }) - - it('denies request 31 before any additional expensive Host authorization', async () => { - const userId = 'user-a' - const hostRef = 'chatllm' svc.getUserAgents.mockResolvedValue({ userId, agentNames: [hostRef] }) const { app, gatewayStub } = buildApp([ { metadata: { name: hostRef }, spec: { enabled: true } }, ]) - const counts = new Map() - rateLimiterMock.checkAndIncrement.mockImplementation(async (key: string, max: number) => { - const count = (counts.get(key) ?? 0) + 1 - counts.set(key, count) - return { - allowed: count <= max, - remaining: Math.max(0, max - count), - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - count, - backendAvailable: true, - } - }) - - const statuses: number[] = [] - for (let attempt = 1; attempt <= 31; attempt += 1) { - const response = await request(app).get( - `/rpc/access/users/${userId}/mcp-hosts/${hostRef}/artifact-read` - ) - statuses.push(response.status) + for (let attempt = 0; attempt < 35; attempt += 1) { + const response = await request(app) + .get(`/rpc/access/users/${userId}/mcp-hosts/${hostRef}/artifact-read`) + .expect(200) + expect(response.body).toMatchObject({ userId, hostRef }) } - - expect({ - statuses: [statuses[0], statuses[29], statuses[30]], - subjectAdmissionCalls: counts.get(`host-artifact-pre-admission:${userId}`), - canonicalHostCalls: counts.get(`host-artifact-read:${userId}:${hostRef}`), - directoryLookups: svc.getUserAgents.mock.calls.length, - kubernetesHostLists: gatewayStub.listResource.mock.calls.length, - }).toEqual({ - statuses: [200, 200, 429], - subjectAdmissionCalls: 31, - canonicalHostCalls: 30, - directoryLookups: 30, - kubernetesHostLists: 30, - }) - }) - - it('shares one subject budget across retained stale signed Host refs', async () => { - const userId = 'user-stale' - const staleRefs = ['deleted-host-a', 'deleted-host-b'] - svc.getUserAgents.mockResolvedValue({ userId, agentNames: staleRefs }) - const { app, gatewayStub } = buildApp([], { sub: userId, hostRefs: staleRefs }) - const counts = new Map() - rateLimiterMock.checkAndIncrement.mockImplementation(async (key: string, max: number) => { - const count = (counts.get(key) ?? 0) + 1 - counts.set(key, count) - return { - allowed: count <= max, - remaining: Math.max(0, max - count), - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - count, - backendAvailable: true, - } - }) - - const statuses: number[] = [] - for (let attempt = 1; attempt <= 31; attempt += 1) { - const hostRef = staleRefs[(attempt - 1) % staleRefs.length] - const response = await request(app).get( - `/rpc/access/users/${userId}/mcp-hosts/${hostRef}/artifact-read` - ) - statuses.push(response.status) - } - - expect({ - statuses: [statuses[0], statuses[29], statuses[30]], - subjectAdmissionCalls: counts.get(`host-artifact-pre-admission:${userId}`), - canonicalHostBuckets: [...counts.keys()].filter(key => key.startsWith('host-artifact-read:')), - directoryLookups: svc.getUserAgents.mock.calls.length, - kubernetesHostLists: gatewayStub.listResource.mock.calls.length, - }).toEqual({ - statuses: [403, 403, 429], - subjectAdmissionCalls: 31, - canonicalHostBuckets: [], - directoryLookups: 30, - kubernetesHostLists: 30, - }) + expect(svc.getUserAgents).toHaveBeenCalledTimes(35) + expect(gatewayStub.listResource).toHaveBeenCalledTimes(35) + expect(rateLimiterMock.checkAndIncrement).not.toHaveBeenCalled() }) - it('does not charge admission for user or signed-Host mismatch', async () => { - authState.claims = { sub: 'user-a', hostRefs: ['host-a', 'host-b'] } - rateLimiterMock.checkAndIncrement.mockResolvedValue({ - allowed: true, - remaining: 29, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - count: 1, - backendAvailable: true, - }) - const { app } = buildApp([], { sub: 'user-a', hostRefs: ['host-a', 'host-b'] }) - + it('rejects mismatched subject and signed Host before authorization', async () => { + authState.claims = { sub: 'user-a', hostRefs: ['host-a'] } + const { app, gatewayStub } = buildApp([]) await request(app).get('/rpc/access/users/user-b/mcp-hosts/host-a/artifact-read').expect(403) - await request(app) - .get('/rpc/access/users/user-a/mcp-hosts/not-signed/artifact-read') - .expect(403) - - expect(rateLimiterMock.checkAndIncrement).not.toHaveBeenCalled() + await request(app).get('/rpc/access/users/user-a/mcp-hosts/other/artifact-read').expect(403) expect(svc.getUserAgents).not.toHaveBeenCalled() + expect(gatewayStub.listResource).not.toHaveBeenCalled() + expect(rateLimiterMock.checkAndIncrement).not.toHaveBeenCalled() }) - it('keeps subject budgets isolated while canonical Host buckets remain subject+Host scoped', async () => { - const hostA = 'host-a' - const hostB = 'host-b' - svc.getUserAgents.mockImplementation(async (userId: string) => ({ - userId, - agentNames: [hostA, hostB], - })) - const hosts = [ - { metadata: { name: hostA }, spec: { enabled: true } }, - { metadata: { name: hostB }, spec: { enabled: true } }, - ] - const appA = buildApp(hosts, { sub: 'subject-a', hostRefs: [hostA, hostB] }) - const appB = buildApp(hosts, { sub: 'subject-b', hostRefs: [hostA] }) - - await request(appA.app) - .get(`/rpc/access/users/subject-a/mcp-hosts/${hostA}/artifact-read`) - .expect(200) - await request(appA.app) - .get(`/rpc/access/users/subject-a/mcp-hosts/${hostB}/artifact-read`) - .expect(200) - await request(appB.app) - .get(`/rpc/access/users/subject-b/mcp-hosts/${hostA}/artifact-read`) - .expect(200) - - const keys = rateLimiterMock.checkAndIncrement.mock.calls.map(([key]) => key) - expect(keys).toEqual([ - 'host-artifact-pre-admission:subject-a', - 'host-artifact-read:subject-a:host-a', - 'host-artifact-pre-admission:subject-a', - 'host-artifact-read:subject-a:host-b', - 'host-artifact-pre-admission:subject-b', - 'host-artifact-read:subject-b:host-a', - ]) - }) - - it('preserves the canonical downstream 429 and Retry-After response', async () => { - const userId = 'user-a' - const hostRef = 'chatllm' - svc.getUserAgents.mockResolvedValue({ userId, agentNames: [hostRef] }) - const { app } = buildApp([{ metadata: { name: hostRef }, spec: { enabled: true } }]) - rateLimiterMock.checkAndIncrement.mockImplementation(async (key: string) => ({ - allowed: !key.startsWith('host-artifact-read:'), - remaining: 0, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - count: 31, - backendAvailable: true, - })) - - await request(app) - .get(`/rpc/access/users/${userId}/mcp-hosts/${hostRef}/artifact-read`) - .expect(429) - .expect(({ body, headers }) => { - expect(body.error).toBe('Too Many Requests') - expect(headers['retry-after']).toBeDefined() - }) - - expect(rateLimiterMock.checkAndIncrement).toHaveBeenNthCalledWith( - 1, - `host-artifact-pre-admission:${userId}`, - 30 - ) - expect(rateLimiterMock.checkAndIncrement).toHaveBeenNthCalledWith( - 2, - `host-artifact-read:${userId}:${hostRef}`, - 30 - ) + it('denies a deleted or disabled Host after checking the live grant', async () => { + svc.getUserAgents.mockResolvedValue({ userId: 'user-a', agentNames: ['host-a'] }) + const deleted = buildApp([]) + const disabled = buildApp([{ metadata: { name: 'host-a' }, spec: { enabled: false } }]) + for (const { app } of [deleted, disabled]) { + await request(app).get('/rpc/access/users/user-a/mcp-hosts/host-a/artifact-read').expect(403) + } + expect(svc.getUserAgents).toHaveBeenCalledTimes(2) + expect(deleted.gatewayStub.listResource).toHaveBeenCalledOnce() + expect(disabled.gatewayStub.listResource).toHaveBeenCalledOnce() + expect(rateLimiterMock.checkAndIncrement).not.toHaveBeenCalled() }) }) diff --git a/control-api/test/services.rateLimiter.realPostgres.integration.test.ts b/control-api/test/services.rateLimiter.realPostgres.integration.test.ts index ee027ec91f..3d792578c3 100644 --- a/control-api/test/services.rateLimiter.realPostgres.integration.test.ts +++ b/control-api/test/services.rateLimiter.realPostgres.integration.test.ts @@ -2,10 +2,6 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { randomBytes } from 'node:crypto' import { Pool } from 'pg' import { initDb } from '../src/db.js' -import { - admitHostMessage, - hostMessageAdmissionBucketKey, -} from '../src/services/hostMessageAdmission.js' import { acquireRateLimitConcurrencyLease, checkAndIncrementWithQuery, @@ -28,9 +24,6 @@ function quoteIdent(value: string): string { describeRealPostgres('rate limiter atomicity on real PostgreSQL', () => { const database = `rate_limiter_${randomBytes(6).toString('hex')}` const bucketKey = `real-pg:${randomBytes(8).toString('hex')}` - const preAdmissionKey = `host-artifact-pre-admission:user-${randomBytes(8).toString('hex')}` - const messageSubject = `user-${randomBytes(8).toString('hex')}` - const messageAdmissionKey = hostMessageAdmissionBucketKey(messageSubject) const connectionString = databaseUrl( adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', database @@ -48,9 +41,7 @@ describeRealPostgres('rate limiter atomicity on real PostgreSQL', () => { afterAll(async () => { try { - await pool?.query('DELETE FROM rate_limit_buckets WHERE bucket_key = ANY($1)', [ - [bucketKey, preAdmissionKey, messageAdmissionKey], - ]) + await pool?.query('DELETE FROM rate_limit_buckets WHERE bucket_key = ANY($1)', [[bucketKey]]) await endPoolAndWaitForClients(pool) if (adminPool) { await adminPool.query( @@ -86,73 +77,6 @@ describeRealPostgres('rate limiter atomicity on real PostgreSQL', () => { expect(Number(persisted.rows[0]?.count)).toBe(32) }) - it('shares subject-only artifact admission atomically across independent PostgreSQL sessions', async () => { - const clients = [await pool.connect(), await pool.connect()] - try { - const results = [] - for (let round = 0; round < 16; round += 1) { - const concurrentResults = await Promise.all( - clients.map(client => - checkAndIncrementWithQuery( - (text, values) => client.query(text, values), - preAdmissionKey, - 30, - windowStartMs, - 1 - ) - ) - ) - results.push(...concurrentResults) - } - const counts = results.map(result => result.count).sort((a, b) => a - b) - expect(counts).toEqual(Array.from({ length: 32 }, (_, index) => index + 1)) - expect(results.filter(result => result.allowed)).toHaveLength(30) - - const persisted = await pool.query<{ count: string }>( - 'SELECT count FROM rate_limit_buckets WHERE bucket_key = $1 AND window_start_ms = $2', - [preAdmissionKey, windowStartMs] - ) - expect(Number(persisted.rows[0]?.count)).toBe(32) - } finally { - clients.forEach(client => client.release()) - } - }) - - it('enforces Host-message admission atomically across independent PostgreSQL sessions', async () => { - const clients = [await pool.connect(), await pool.connect()] - try { - const runs = await Promise.all( - clients.map(async (client, clientIndex) => { - const checks = [] - for (let index = clientIndex; index < 61; index += clients.length) { - checks.push( - await admitHostMessage(messageSubject, (key, max) => - checkAndIncrementWithQuery( - (text, values) => client.query(text, values), - key, - max, - windowStartMs, - 1 - ) - ) - ) - } - return checks - }) - ) - const checks = runs.flat() - expect(checks.filter(check => check.status === 'allowed')).toHaveLength(60) - expect(checks.filter(check => check.status === 'limited')).toHaveLength(1) - const persisted = await pool.query<{ count: string }>( - 'SELECT count FROM rate_limit_buckets WHERE bucket_key = $1 AND window_start_ms = $2', - [messageAdmissionKey, windowStartMs] - ) - expect(Number(persisted.rows[0]?.count)).toBe(61) - } finally { - clients.forEach(client => client.release()) - } - }) - it('serializes the production bounded advisory lease across independent PostgreSQL sessions', async () => { const key = `gfs-upload-slot:${randomBytes(8).toString('hex')}` const first = await pool.connect() diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 7f26ffd4e2..945fc08c1f 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.157", + "version": "0.1.158", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.157", + "version": "0.1.158", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index ed2541ebe9..015bb819fb 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.157", + "version": "0.1.158", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index 1c09ce9292..cb408128a9 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,8 +8,8 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.12.0', - externalRestApiVersion: '0.1.157', - rpcProxyVersion: '0.1.133', + externalRestApiVersion: '0.1.158', + rpcProxyVersion: '0.1.134', desktopVersion: '0.12.0', minimumDesktopVersion: '0.1.252', } diff --git a/external-rest-api/src/routes/access.ts b/external-rest-api/src/routes/access.ts index dd089a8616..40a4544b39 100644 --- a/external-rest-api/src/routes/access.ts +++ b/external-rest-api/src/routes/access.ts @@ -8,6 +8,7 @@ import { sendSanitizedControlApiPublicError, } from '../http/publicApiError.js' import { type AuthedRequest, extractAuthToken, requireAuth } from '../middleware/auth.js' +import { createRateLimiter } from '../middleware/rateLimit.js' const ACCESS_PUBLIC_STATUSES = new Set([400, 401, 403, 404, 409, 429, 503]) const ACCESS_CATALOG_QUERY_FIELDS = ['families', 'limit', 'cursor'] as const @@ -41,6 +42,14 @@ function forwardAccessError(error: unknown, res: Response, next: NextFunction): export function createAccessRouter(): Router { const router = Router() router.use('/me/access', requireAuth) + const catalogRateLimit = createRateLimiter({ + windowMs: 60_000, + maxRequests: 10, + keyFn: req => { + const userId = (req as AuthedRequest).auth?.userId + return userId ? `access-catalog:${userId}` : 'access-catalog:unknown' + }, + }) router.get('/me/access/capabilities', async (req: AuthedRequest, res, next) => { try { @@ -53,7 +62,7 @@ export function createAccessRouter(): Router { } }) - router.get('/me/access/catalog', async (req: AuthedRequest, res, next) => { + router.get('/me/access/catalog', catalogRateLimit, async (req: AuthedRequest, res, next) => { const query = accessCatalogQuery(req) if (!query) { sendPublicApiError(req, res, 400, 'invalid_request', 'The catalog query is not valid.') diff --git a/external-rest-api/test/routes.access.test.ts b/external-rest-api/test/routes.access.test.ts index 9b75d74ac2..c8111eca68 100644 --- a/external-rest-api/test/routes.access.test.ts +++ b/external-rest-api/test/routes.access.test.ts @@ -65,6 +65,40 @@ describe('public access-contract forwarding', () => { ) }) + it('limits catalog per authenticated user while capabilities remain unrestricted', async () => { + const server = app() + mocks.controlApiRequest.mockResolvedValue({ items: [] }) + for (let index = 0; index < 10; index += 1) { + await request(server) + .get('/me/access/catalog') + .set('authorization', 'Bearer session-token') + .expect(200) + } + const rejected = await request(server) + .get('/me/access/catalog') + .set('authorization', 'Bearer session-token') + .expect(429) + expect(rejected.headers['x-ratelimit-limit']).toBe('10') + expect(mocks.controlApiRequest).toHaveBeenCalledTimes(10) + + for (let index = 0; index < 11; index += 1) { + await request(server) + .get('/me/access/capabilities') + .set('authorization', 'Bearer session-token') + .expect(200) + } + mocks.verifyToken.mockReturnValue({ + userId: 'user-2', + email: 'other@example.test', + role: 'member', + }) + await request(server) + .get('/me/access/catalog') + .set('authorization', 'Bearer other-session') + .expect(200) + expect(mocks.controlApiRequest).toHaveBeenCalledTimes(22) + }) + it('rebuilds a bounded public error instead of forwarding upstream fields', async () => { mocks.controlApiRequest.mockRejectedValue( new ControlApiError('upstream', 503, { @@ -125,7 +159,7 @@ describe('public access-contract forwarding', () => { }, { 'retry-after': '17', - 'x-ratelimit-limit': '30', + 'x-ratelimit-limit': '10', 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': '1900000000', 'x-internal-bucket': 'secret-bucket', @@ -134,7 +168,7 @@ describe('public access-contract forwarding', () => { ) const response = await request(app()) - .get('/me/access/capabilities') + .get('/me/access/catalog') .set('authorization', 'Bearer session-token') expect(response.status).toBe(429) @@ -144,7 +178,7 @@ describe('public access-contract forwarding', () => { details: { retryAfterSeconds: 17 }, }) expect(response.headers['retry-after']).toBe('17') - expect(response.headers['x-ratelimit-limit']).toBe('30') + expect(response.headers['x-ratelimit-limit']).toBe('10') expect(response.headers['x-ratelimit-remaining']).toBe('0') expect(response.headers['x-ratelimit-reset']).toBe('1900000000') expect(response.headers['x-internal-bucket']).toBeUndefined() diff --git a/external-rest-api/test/routes.auth.test.ts b/external-rest-api/test/routes.auth.test.ts index 7d187a1855..76fe337520 100644 --- a/external-rest-api/test/routes.auth.test.ts +++ b/external-rest-api/test/routes.auth.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import express from 'express' +import { randomUUID } from 'node:crypto' import request from 'supertest' import { externalRestPublicErrorHandler } from '../src/app.js' import { ControlApiError } from '../src/controlApiClient.js' @@ -10,6 +11,7 @@ const authServiceMock = vi.hoisted(() => ({ loginWithPassword: vi.fn(), requestPasswordReset: vi.fn(), logoutUserSession: vi.fn(), + renewUserSession: vi.fn(), })) vi.mock('../src/services/authService.js', () => authServiceMock) @@ -28,6 +30,7 @@ describe('routes/auth password-login', () => { authServiceMock.loginWithPassword.mockReset() authServiceMock.requestPasswordReset.mockReset() authServiceMock.logoutUserSession.mockReset() + authServiceMock.renewUserSession.mockReset() }) it.each([ @@ -165,7 +168,7 @@ describe('routes/auth password-login', () => { ) }) - it('delegates Google verification behind the Control API limiter with trusted client IP', async () => { + it('delegates Google verification with the trusted client IP', async () => { authServiceMock.loginWithGoogle.mockResolvedValueOnce({ token: 'google-session-test-token', me: { id: 'user-1', email: 'user@example.test' }, @@ -183,6 +186,27 @@ describe('routes/auth password-login', () => { ) }) + it('forwards more than ten session renewals without a local rate limit', async () => { + const session = randomUUID() + authServiceMock.renewUserSession.mockResolvedValue({ + token: session, + expiresInSeconds: 3600, + }) + const app = buildApp() + + for (let attempt = 0; attempt < 12; attempt += 1) { + const response = await request(app) + .post('/api/v1/auth/session/renew') + .set('authorization', `Bearer ${session}`) + .set('x-forwarded-for', '198.51.100.89') + .expect(200) + expect(response.body.expiresInSeconds).toBe(3600) + expect(response.headers['x-ratelimit-limit']).toBeUndefined() + } + expect(authServiceMock.renewUserSession).toHaveBeenCalledTimes(12) + expect(authServiceMock.renewUserSession).toHaveBeenCalledWith(session, '198.51.100.89') + }) + it('documents the password-reset limiter headers and client-IP contract', async () => { authServiceMock.requestPasswordReset.mockResolvedValue({ requested: true }) const app = buildApp() diff --git a/rpc-proxy/package-lock.json b/rpc-proxy/package-lock.json index 26d2d7e0d8..bb5514db93 100644 --- a/rpc-proxy/package-lock.json +++ b/rpc-proxy/package-lock.json @@ -1,17 +1,16 @@ { "name": "clerum-rpc-proxy", - "version": "0.1.133", + "version": "0.1.134", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-rpc-proxy", - "version": "0.1.133", + "version": "0.1.134", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", "express": "^4.21.2", - "express-rate-limit": "^8.5.1", "http-proxy": "^1.18.1", "jsonwebtoken": "^9.0.2" }, @@ -1709,48 +1708,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/express-rate-limit": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", - "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "ip-address": "^10.2.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, - "node_modules/express-rate-limit/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/express-rate-limit/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", @@ -2032,15 +1989,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ip-address": { - "version": "10.7.2", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", - "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/rpc-proxy/package.json b/rpc-proxy/package.json index 9723ef2bc8..982eb99c1e 100644 --- a/rpc-proxy/package.json +++ b/rpc-proxy/package.json @@ -1,6 +1,6 @@ { "name": "clerum-rpc-proxy", - "version": "0.1.133", + "version": "0.1.134", "description": "External-facing RPC proxy for tenant-scoped MCP access", "main": "dist/main.js", "engines": { @@ -20,7 +20,6 @@ "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", "express": "^4.21.2", - "express-rate-limit": "^8.5.1", "http-proxy": "^1.18.1", "jsonwebtoken": "^9.0.2" }, diff --git a/rpc-proxy/src/__tests__/approval-artifacts.test.ts b/rpc-proxy/src/__tests__/approval-artifacts.test.ts index 3545df995c..85f8cc0a72 100644 --- a/rpc-proxy/src/__tests__/approval-artifacts.test.ts +++ b/rpc-proxy/src/__tests__/approval-artifacts.test.ts @@ -408,11 +408,11 @@ describe('POST /rpc/hosts/:hostRef/approvals/deny', () => { // Artifact Routes // ===================================================================== describe('GET /rpc/hosts/:hostRef/artifacts', () => { - it('shares the approved artifact-read budget with downloads', async () => { + it('forwards more than 30 mixed artifact reads', async () => { globalThis.fetch = vi.fn().mockResolvedValue(mockFetchResponse(200, '[]')) const app = makeApp() - for (let requestNumber = 0; requestNumber < 30; requestNumber += 1) { + for (let requestNumber = 0; requestNumber < 35; requestNumber += 1) { const path = requestNumber % 2 === 0 ? '/rpc/hosts/chatllm/artifacts' @@ -420,16 +420,16 @@ describe('GET /rpc/hosts/:hostRef/artifacts', () => { await request(app).get(path).set('authorization', 'Bearer token').expect(200) } - const limited = await request(app) + await request(app) .get('/rpc/hosts/chatllm/artifacts/final.txt/download') .set('authorization', 'Bearer token') - .expect(429) + .expect(200) - expect(limited.body).toMatchObject({ error: 'Too Many Requests' }) - expect(Number(limited.headers['retry-after'])).toBeGreaterThan(0) + expect(serviceMock.resolveArtifactReadHostConnectionForUser).toHaveBeenCalledTimes(36) + expect(globalThis.fetch).toHaveBeenCalledTimes(36) }) - it('keys the shared budget by verified subject and canonical Host, not filename', async () => { + it('preserves access for different users, Hosts, and filenames', async () => { globalThis.fetch = vi.fn().mockResolvedValue(mockFetchResponse(200, '[]')) authTokenMock.verifyRpcToken.mockImplementation(token => token === 'other-user-token' ? { ...VALID_CLAIMS, sub: 'other-user' } : { ...VALID_CLAIMS } @@ -449,7 +449,7 @@ describe('GET /rpc/hosts/:hostRef/artifacts', () => { await request(app) .get('/rpc/hosts/chatllm/artifacts/different-filename.txt/download') .set('authorization', 'Bearer token') - .expect(429) + .expect(200) await request(app) .get('/rpc/hosts/otherhost/artifacts') @@ -522,22 +522,18 @@ describe('GET /rpc/hosts/:hostRef/artifacts', () => { .expect(403) }) - it('preserves the canonical Control API artifact-read 429', async () => { + it('does not forward an artifact read when Host resolution fails', async () => { globalThis.fetch = vi.fn() - const rateLimited = Object.assign(new Error('artifact read limited'), { - name: 'ControlApiArtifactReadRateLimitedError', - retryAfterSeconds: 17, - }) - serviceMock.resolveArtifactReadHostConnectionForUser.mockRejectedValue(rateLimited) + serviceMock.resolveArtifactReadHostConnectionForUser.mockRejectedValue( + new Error('Host resolution unavailable') + ) const app = makeApp() - const response = await request(app) + await request(app) .get('/rpc/hosts/chatllm/artifacts') .set('authorization', 'Bearer token') - .expect(429) + .expect(500) - expect(response.headers['retry-after']).toBe('17') - expect(response.body).toEqual({ error: 'Too Many Requests', retryAfterSeconds: 17 }) expect(globalThis.fetch).not.toHaveBeenCalled() }) diff --git a/rpc-proxy/src/__tests__/host-message-admission-transport.test.ts b/rpc-proxy/src/__tests__/host-message-admission-transport.test.ts index 5e7c6b27ac..7a9c82b2bb 100644 --- a/rpc-proxy/src/__tests__/host-message-admission-transport.test.ts +++ b/rpc-proxy/src/__tests__/host-message-admission-transport.test.ts @@ -1,12 +1,9 @@ import { describe, expect, it, vi } from 'vitest' -import { - ControlApiHostMessageAdmissionError, - fetchHostConnectionFromControlApi, -} from '../services/controlApiRestService.js' +import { fetchHostConnectionFromControlApi } from '../services/controlApiRestService.js' const USER = '00000000-0000-4000-8000-000000000001' -describe('R44-H1 Control API Host-message admission transport', () => { +describe('Control API Host-message resolution transport', () => { it('uses the dedicated POST for ordinary and direct-run-bound sends', async () => { const fetchImpl = vi.fn(async (_url: string, _init: RequestInit) => Response.json({ @@ -38,46 +35,14 @@ describe('R44-H1 Control API Host-message admission transport', () => { expect(JSON.parse(String(calls[1][1]?.body))).toMatchObject({ sessionId: 'session-a' }) }) - it('retains canonical 429 metadata as a typed admission error', async () => { - const fetchImpl = vi.fn(async () => - Response.json( - { error: 'Too Many Requests', retryAfterSeconds: 19 }, - { - status: 429, - headers: { - 'Retry-After': '19', - 'X-RateLimit-Limit': '60', - 'X-RateLimit-Remaining': '0', - 'X-RateLimit-Reset': '1234567890', - }, - } - ) - ) as unknown as typeof fetch + it.each([429, 503])('treats a %s resolution response as a failed lookup', async status => { + const fetchImpl = vi.fn(async () => new Response(null, { status })) as unknown as typeof fetch await expect( - fetchHostConnectionFromControlApi(USER, 'host-a', 'jwt', { + fetchHostConnectionFromControlApi(USER, 'host-a', crypto.randomUUID(), { messageResolution: true, fetchImpl, }) - ).rejects.toMatchObject({ - name: 'ControlApiHostMessageAdmissionError', - status: 429, - body: { error: 'Too Many Requests', retryAfterSeconds: 19 }, - headers: { 'retry-after': '19', 'x-ratelimit-limit': '60' }, - } satisfies Partial) - }) - - it('retains the typed strict-store 503', async () => { - const fetchImpl = vi.fn(async () => - Response.json({ error: 'host_message_admission_unavailable' }, { status: 503 }) - ) as unknown as typeof fetch - await expect( - fetchHostConnectionFromControlApi(USER, 'host-a', 'jwt', { - messageResolution: true, - fetchImpl, - }) - ).rejects.toMatchObject({ - status: 503, - body: { error: 'host_message_admission_unavailable' }, - }) + ).rejects.toThrow(`Control API MCP host lookup failed (${status})`) + expect(fetchImpl).toHaveBeenCalledOnce() }) }) diff --git a/rpc-proxy/src/__tests__/sender-assignment.test.ts b/rpc-proxy/src/__tests__/sender-assignment.test.ts index 64f33c19f8..d29c4bd7e6 100644 --- a/rpc-proxy/src/__tests__/sender-assignment.test.ts +++ b/rpc-proxy/src/__tests__/sender-assignment.test.ts @@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import express from 'express' import request from 'supertest' import { createRpcRouter } from '../routes/rpc.js' -import { ControlApiHostMessageAdmissionError } from '../services/controlApiRestService.js' const authTokenMock = vi.hoisted(() => ({ verifyRpcToken: vi.fn(), @@ -152,46 +151,16 @@ describe('POST /rpc/hosts/:hostRef/messages — sender assignment invariant', () expect(forwardedBody.metadata).toEqual({ accessScope: 'user' }) }) - it('forwards a Control API message-admission 429 without waking or forwarding', async () => { - serviceMock.resolveHostConnectionForUser.mockRejectedValue( - new ControlApiHostMessageAdmissionError( - 429, - { error: 'Too Many Requests', retryAfterSeconds: 19 }, - { - 'retry-after': '19', - 'x-ratelimit-limit': '60', - 'x-ratelimit-remaining': '0', - 'x-ratelimit-reset': '1234567890', - } - ) - ) - const response = await request(makeApp()) - .post('/rpc/hosts/chatllm/messages') - .set('authorization', 'Bearer token') - .send({ content: 'hi' }) - .expect(429) - expect(response.body).toEqual({ error: 'Too Many Requests', retryAfterSeconds: 19 }) - expect(response.headers['retry-after']).toBe('19') - expect(response.headers['x-ratelimit-limit']).toBe('60') - expect(response.headers['x-ratelimit-remaining']).toBe('0') - expect(response.headers['x-ratelimit-reset']).toBe('1234567890') - expect(serviceMock.forwardHostMessageToHost).not.toHaveBeenCalled() - }) + it('does not forward a message when Host resolution fails', async () => { + serviceMock.resolveHostConnectionForUser.mockRejectedValue(new Error('Host resolution failed')) - it('preserves a strict admission-store 503 without forwarding', async () => { - serviceMock.resolveHostConnectionForUser.mockRejectedValue( - new ControlApiHostMessageAdmissionError( - 503, - { error: 'host_message_admission_unavailable' }, - {} - ) - ) - const response = await request(makeApp()) + await request(makeApp()) .post('/rpc/hosts/chatllm/messages') - .set('authorization', 'Bearer token') - .send({ content: 'hi' }) - .expect(503) - expect(response.body).toEqual({ error: 'host_message_admission_unavailable' }) + .set('authorization', `Bearer ${crypto.randomUUID()}`) + .send({ content: 'hello' }) + .expect(502) + + expect(serviceMock.resolveHostConnectionForUser).toHaveBeenCalledOnce() expect(serviceMock.forwardHostMessageToHost).not.toHaveBeenCalled() }) }) diff --git a/rpc-proxy/src/__tests__/wake-and-hold-route.test.ts b/rpc-proxy/src/__tests__/wake-and-hold-route.test.ts index 373189d5a8..9bb66c4fbd 100644 --- a/rpc-proxy/src/__tests__/wake-and-hold-route.test.ts +++ b/rpc-proxy/src/__tests__/wake-and-hold-route.test.ts @@ -53,13 +53,7 @@ const controlApiMock = vi.hoisted(() => ({ vi.mock('../authToken.js', () => authTokenMock) vi.mock('../services/mcpProxyService.js', () => serviceMock) -vi.mock('../services/controlApiRestService.js', async importOriginal => { - const actual = await importOriginal() - return { - ...controlApiMock, - ControlApiHostMessageAdmissionError: actual.ControlApiHostMessageAdmissionError, - } -}) +vi.mock('../services/controlApiRestService.js', () => controlApiMock) // Issue #791 §11.4: a wake-eligible finite operation carries host:wake:write in // addition to its operation scope (Desktop adds it; the route scope guard is diff --git a/rpc-proxy/src/routes/rpc.ts b/rpc-proxy/src/routes/rpc.ts index c7e48424b6..2b917f690e 100644 --- a/rpc-proxy/src/routes/rpc.ts +++ b/rpc-proxy/src/routes/rpc.ts @@ -1,7 +1,6 @@ import { Router } from 'express' import type { Response as ExpressResponse, NextFunction } from 'express' import { randomUUID } from 'crypto' -import { rateLimit } from 'express-rate-limit' import { config } from '../config.js' import { type AuthedRequest, @@ -14,7 +13,6 @@ import { jsonBody } from '../middleware/jsonBody.js' import { rpcInvocationContext } from '../rpcAccessContext.js' import { ControlApiConnectorsRejectedError, - ControlApiHostMessageAdmissionError, type HostWakeApiResponse, fetchUserConnectorsFromControlApi, requestHostWakeFromControlApi, @@ -59,7 +57,6 @@ const RFC1123_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/ // own copy (the two services share no package, so each names its own). const SESSIONS_LIMIT_CAP = 100 const MESSAGES_LIMIT_CAP = 200 -const HOST_ARTIFACT_READ_LIMIT_PER_MIN = 30 type ArtifactReadRequest = AuthedRequest & { artifactReadWakeDeadlineMs?: number @@ -193,16 +190,6 @@ function controlApiHostAccessRejection(error: unknown): ControlApiHostAccessReje return null } -function controlApiArtifactReadRetryAfterSeconds(error: unknown): number | null { - if (!(error instanceof Error) || error.name !== 'ControlApiArtifactReadRateLimitedError') { - return null - } - const retryAfterSeconds = (error as Error & { retryAfterSeconds?: unknown }).retryAfterSeconds - return typeof retryAfterSeconds === 'number' && Number.isFinite(retryAfterSeconds) - ? Math.max(1, Math.ceil(retryAfterSeconds)) - : null -} - export function respondControlApiHostAccessRejection( res: ExpressResponse, rejection: ControlApiHostAccessRejection @@ -303,29 +290,6 @@ async function readArtifactResponseBuffer(response: Response): Promise { export function createRpcRouter(): Router { const router = Router() - // Control API owns the durable, cross-replica artifact budget. This edge - // backstop uses the same verified identity + canonical Host key so route - // analysis can prove both artifact reads are bounded without replacing the - // authoritative PG limiter or consuming Host wake capacity. - const artifactReadEdgeRateLimit = rateLimit({ - windowMs: 60_000, - limit: HOST_ARTIFACT_READ_LIMIT_PER_MIN, - standardHeaders: 'draft-7', - legacyHeaders: false, - keyGenerator: req => { - const artifactRead = req as ArtifactReadRequest - const subject = artifactRead.auth?.sub - const hostRef = artifactRead.artifactReadHost?.name - return `host-artifact-read:${subject ?? '__missing_subject__'}:${hostRef ?? '__missing_host__'}` - }, - handler: (_req, res) => { - const raw = res.getHeader('Retry-After') - const retryAfterSeconds = - typeof raw === 'string' && /^\d+$/.test(raw) ? Math.max(1, Number(raw)) : 60 - res.status(429).json({ error: 'Too Many Requests', retryAfterSeconds }) - }, - }) - const resolveArtifactReadHost = async ( req: ArtifactReadRequest, res: ExpressResponse, @@ -356,15 +320,6 @@ export function createRpcRouter(): Router { req.artifactReadHost = artifactReadHost next() } catch (error) { - const retryAfterSeconds = controlApiArtifactReadRetryAfterSeconds(error) - if (retryAfterSeconds !== null) { - res.setHeader('Retry-After', String(retryAfterSeconds)) - res.status(429).json({ - error: 'Too Many Requests', - retryAfterSeconds, - }) - return - } guardedNext(res, next, error) } } @@ -652,15 +607,6 @@ export function createRpcRouter(): Router { // the wake path (which would re-forward an already-delivered message). res.status(200).json(upstreamResponse) } catch (error) { - if (error instanceof ControlApiHostMessageAdmissionError) { - if (error.status === 429) { - for (const [name, value] of Object.entries(error.headers)) { - res.setHeader(name, value) - } - } - res.status(error.status).json(error.body) - return - } const rejection = controlApiHostAccessRejection(error) if (rejection) { respondControlApiHostAccessRejection(res, rejection) @@ -1572,7 +1518,6 @@ export function createRpcRouter(): Router { requireRpcAuth, requireScope('host:task:read'), resolveArtifactReadHost, - artifactReadEdgeRateLimit, async (req: AuthedRequest, res, next) => { try { const auth = req.auth! @@ -1639,7 +1584,6 @@ export function createRpcRouter(): Router { next() }, resolveArtifactReadHost, - artifactReadEdgeRateLimit, async (req: AuthedRequest, res, next) => { try { const auth = req.auth! diff --git a/rpc-proxy/src/services/controlApiRestService.ts b/rpc-proxy/src/services/controlApiRestService.ts index b7d45f431f..775bc1074c 100644 --- a/rpc-proxy/src/services/controlApiRestService.ts +++ b/rpc-proxy/src/services/controlApiRestService.ts @@ -68,24 +68,6 @@ export class ControlApiHostAccessRejectedError extends Error { } } -export class ControlApiArtifactReadRateLimitedError extends Error { - constructor(readonly retryAfterSeconds: number) { - super(`Control API rate limited artifact reads for ${retryAfterSeconds} seconds`) - this.name = 'ControlApiArtifactReadRateLimitedError' - } -} - -export class ControlApiHostMessageAdmissionError extends Error { - constructor( - readonly status: 429 | 503, - readonly body: { error: string; retryAfterSeconds?: number }, - readonly headers: Record - ) { - super(`Control API Host-message admission returned ${status}`) - this.name = 'ControlApiHostMessageAdmissionError' - } -} - // Typed rejection for the connectors read-model, mirroring the host rail above. // A generic Error collapses to 500 in the app error handler, which the desktop // reads as non-refreshable — so an expired/rotated rpc access token (401) would @@ -304,60 +286,6 @@ async function fetchHostConnectionForPath( } ) - if (messageResolution && (response.status === 429 || response.status === 503)) { - const body = (await response.json().catch(() => null)) as { - error?: unknown - retryAfterSeconds?: unknown - } | null - if (response.status === 503 && body?.error === 'host_message_admission_unavailable') { - throw new ControlApiHostMessageAdmissionError( - 503, - { error: 'host_message_admission_unavailable' }, - {} - ) - } - const headerNames = [ - 'retry-after', - 'x-ratelimit-limit', - 'x-ratelimit-remaining', - 'x-ratelimit-reset', - ] as const - const headers = Object.fromEntries( - headerNames.map(name => [name, response.headers.get(name)]) - ) as Record - if ( - response.status === 429 && - body?.error === 'Too Many Requests' && - typeof body.retryAfterSeconds === 'number' && - Number.isSafeInteger(body.retryAfterSeconds) && - body.retryAfterSeconds > 0 && - headerNames.every(name => headers[name] !== null) - ) { - throw new ControlApiHostMessageAdmissionError( - 429, - { error: 'Too Many Requests', retryAfterSeconds: body.retryAfterSeconds }, - headers as Record - ) - } - throw new Error('Control API Host-message admission response was invalid') - } - - if (options.artifactRead && response.status === 429) { - const retryAfterHeader = Number(response.headers.get('retry-after')) - let retryAfterBody = Number.NaN - try { - const body = (await response.json()) as { retryAfterSeconds?: unknown } - if (typeof body.retryAfterSeconds === 'number') retryAfterBody = body.retryAfterSeconds - } catch { - /* use the header below when the canonical JSON body is unavailable */ - } - const retryAfterSeconds = - Number.isFinite(retryAfterHeader) && retryAfterHeader > 0 ? retryAfterHeader : retryAfterBody - if (!Number.isFinite(retryAfterSeconds) || retryAfterSeconds <= 0) { - throw new Error('Control API artifact-read limit returned 429 without Retry-After') - } - throw new ControlApiArtifactReadRateLimitedError(retryAfterSeconds) - } if (response.status === 404) { await drainBody(response) throw new ControlApiHostVersionMismatchError(operation) diff --git a/rpc-proxy/test/routes.rpc.test.ts b/rpc-proxy/test/routes.rpc.test.ts index 0e086ea961..fd5b08720f 100644 --- a/rpc-proxy/test/routes.rpc.test.ts +++ b/rpc-proxy/test/routes.rpc.test.ts @@ -20,16 +20,6 @@ const serviceMock = vi.hoisted(() => ({ })) const controlApiMock = vi.hoisted(() => ({ - ControlApiHostMessageAdmissionError: class ControlApiHostMessageAdmissionError extends Error { - constructor( - public readonly status: 429 | 503, - public readonly body: { error: string; retryAfterSeconds?: number }, - public readonly headers: Record - ) { - super(`Control API Host-message admission returned ${status}`) - this.name = 'ControlApiHostMessageAdmissionError' - } - }, ControlApiHostAccessRejectedError: class ControlApiHostAccessRejectedError extends Error { constructor( public readonly status: number, diff --git a/rpc-proxy/test/services.controlApiHostAccess.test.ts b/rpc-proxy/test/services.controlApiHostAccess.test.ts index 62f413f75c..11f0d3f757 100644 --- a/rpc-proxy/test/services.controlApiHostAccess.test.ts +++ b/rpc-proxy/test/services.controlApiHostAccess.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from 'vitest' import { - ControlApiArtifactReadRateLimitedError, ControlApiHostAccessRejectedError, fetchArtifactReadHostConnectionFromControlApi, fetchHostConnectionFromControlApi, @@ -14,7 +13,7 @@ const BINDING = { } describe('control-api canonical host access client', () => { - it('preserves the canonical artifact-read 429 and Retry-After', async () => { + it('treats an artifact-read upstream failure as a failed lookup', async () => { const fetchImpl = vi.fn( async () => new Response(JSON.stringify({ error: 'Too Many Requests', retryAfterSeconds: 17 }), { @@ -28,8 +27,7 @@ describe('control-api canonical host access client', () => { fetchImpl: fetchImpl as typeof fetch, }) ).rejects.toMatchObject({ - name: ControlApiArtifactReadRateLimitedError.name, - retryAfterSeconds: 17, + message: 'Control API MCP host lookup failed (429)', }) expect(String(fetchImpl.mock.calls[0]![0])).toMatch( From e335cf3cb89cdccaf4172dfa2a4602807c4c3991 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Tue, 27 Oct 2026 17:22:39 +0100 Subject: [PATCH 373/528] docs: record the dev rate-limit parity decision for PR1 Document that PR1 keeps dev's limits on every existing route and gives new routes the limits of their dev equivalents, that the shared Host message and artifact admission from the Task 106 plan is superseded for these routes, and that older CodeQL dispositions citing those limits no longer describe an enforcing limiter. Co-Authored-By: Claude Opus 5.5 --- .../how-to/codeql-js-missing-rate-limiting.md | 46 +++++++++++++++---- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/docs/how-to/codeql-js-missing-rate-limiting.md b/docs/how-to/codeql-js-missing-rate-limiting.md index 1db5645637..948bbcdb53 100644 --- a/docs/how-to/codeql-js-missing-rate-limiting.md +++ b/docs/how-to/codeql-js-missing-rate-limiting.md @@ -7,14 +7,42 @@ This document records audited dispositions for [CodeQL](https://codeql.github.co ## Product invariant -Authenticated control-api routes that perform expensive work **must** be rate -limited. Enforcement uses the Postgres-backed `rateLimitMiddleware` -(`services/rateLimiterService.ts`) so limits are consistent across replicas. - -CodeQL 2.x models `express-rate-limit` (and some framework plugins) but **does -not** model our custom PG limiter. Routes that only use `rateLimitMiddleware` -may still alert until an `express-rate-limit` **edge backstop** is added (see -`control-api/src/routes/external/gfs.ts`). +Retain the rate limits inherited from dev. An owner-authorized parity decision +also removes limits that PR #334 added to existing dev routes and assigns new +routes the limits of their dev equivalents. Some authenticated operations +therefore have no frequency limit; authentication and execution budgets are +not substitutes for one. This accepts the corresponding exposure to repeated +work while preserving the dev client's existing limiter contract. + +The stock CodeQL query models `express-rate-limit` but may not recognize the +Postgres-backed `rateLimitMiddleware`. The repository's custom query models +the canonical middleware separately. Cross-service and edge controls can still +be invisible to an individual finding; review the actual enforcement path. + +## PR #334 owner parity decision + +The Task 106 plan (§D.6) proposed shared admission for Host messages and +artifact reads. That proposal is superseded for these PR1 routes: neither the +legacy Host message routes nor the dev artifact paths had those frequency +limits. Authentication, live Host authorization, body and transfer bounds, +wake limits, and the rule against automatically resending a message remain. +Rolling out a new Control API next to old rpc-proxy pods therefore has no +rate-limit compatibility gap; dedicated-operation compatibility remains a +separate concern. + +Access capabilities and resolution retain the dev users-family edge limits; +catalog also retains the aggregate-directory user limit and gains the matching +External REST catalog limit. Workflow reads and session lifecycle paths do not +gain PR-only frequency limits. Existing password, directory, workflow-trigger +and other inherited gates remain in force. The future Spec 76 service/session +admission proposal is outside this PR1 change; it does not establish a limiter +on the corresponding pinned dev routes. + +Historical explanations that cite a 60/minute Host-message bucket or a +10/minute RPC-token issuer limit no longer establish protection for these +paths. Treat the absence of admission as an accepted parity decision, not as a +false positive caused by an enforcing limiter. This document does not change +GitHub alert state. ## Approved patterns @@ -160,5 +188,5 @@ Re-audit when: - `rateLimitMiddleware` signature or call pattern changes repo-wide - A listed route removes its PG limiter -- CodeQL adds modeling for our PG limiter (then remove redundant backstops) +- CodeQL changes modeling for our PG limiter or cross-service edge controls - New authenticated route is added without an entry in this registry From e2c4b8d6ab2bd9159a98c67e573b3cb2e3bc216b Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Tue, 27 Oct 2026 17:45:51 +0100 Subject: [PATCH 374/528] fix: run the workflow trigger limiter before session work, as dev does dev applies the external workflow trigger limiter before any session check, keyed on the signature-verified user or the source IP, so a replayed but rejected session still consumes the bucket. PR1 had moved it after live identity authentication, so rejected sessions reached database work without paying. The trigger admission runs the limiter first again, with the same key, limit, window and fallback. Also drop limiter mocks that only served the removed Host message admission, and correct two rpc-proxy comments that still described a shared artifact-read budget. Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- .../routes/external/workflows/admission.ts | 4 +++- ....realPostgresMigration.integration.test.ts | 17 +------------- .../test/routes.externalWorkflows.test.ts | 22 +++++++++++++++++-- ...outes.rpcAccessUsersSignedIdentity.test.ts | 12 ---------- external-rest-api/src/releaseManifest.ts | 2 +- rpc-proxy/package-lock.json | 4 ++-- rpc-proxy/package.json | 2 +- .../src/services/controlApiRestService.ts | 4 ++-- rpc-proxy/src/services/mcpProxyService.ts | 2 +- 11 files changed, 34 insertions(+), 41 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 2448b01bef..11a4080f97 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.829", + "version": "0.1.830", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.829", + "version": "0.1.830", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 03c4a55795..b43cb6d2b2 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.829", + "version": "0.1.830", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/routes/external/workflows/admission.ts b/control-api/src/routes/external/workflows/admission.ts index 0d4daa4a73..787e564e69 100644 --- a/control-api/src/routes/external/workflows/admission.ts +++ b/control-api/src/routes/external/workflows/admission.ts @@ -15,11 +15,13 @@ export const externalWorkflowReadAdmission = Object.freeze([ bindCompletedExternalWorkflowCaller, ]) +// The trigger limiter runs before any live session work, as in dev: it keys on +// the signature-verified user (or the source IP), so rejected sessions still pay. export const externalWorkflowTriggerAdmission = Object.freeze([ attachAccessExecutionBudget, + workflowTriggerRateLimit(), requireExternalSessionLimiterIdentityWithPublicErrors, requireAuthenticatedExternalUserRateLimitContext, - workflowTriggerRateLimit(), requireCompletedExternalSessionAuthenticationWithPublicErrors, bindCompletedExternalWorkflowCaller, ]) diff --git a/control-api/test/db.realPostgresMigration.integration.test.ts b/control-api/test/db.realPostgresMigration.integration.test.ts index 0db344edb8..1917411d33 100644 --- a/control-api/test/db.realPostgresMigration.integration.test.ts +++ b/control-api/test/db.realPostgresMigration.integration.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' import express, { type Request as ExpressRequest } from 'express' import { execFileSync } from 'node:child_process' import { randomBytes, randomUUID } from 'node:crypto' @@ -38,21 +38,6 @@ import { signRpcAccessToken } from '../src/utils/auth/rpcAuthToken.js' import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' import './realPostgres.requirement.ts' -// This suite owns migrations, live authority and binding against its own -// database. The global Host-message admission pool points at the deployment -// DSN, not this suite's random database, and the limiter has its own real-PG -// suite; mock it here so the legacy send path exercises real authorization. -const rateLimiterMock = vi.hoisted(() => ({ checkAndIncrement: vi.fn() })) -vi.mock('../src/services/rateLimiterService.js', () => rateLimiterMock) -rateLimiterMock.checkAndIncrement.mockResolvedValue({ - allowed: true, - remaining: 59, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - count: 1, - backendAvailable: true, -}) - type PrivilegeExpectation = Record> const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL diff --git a/control-api/test/routes.externalWorkflows.test.ts b/control-api/test/routes.externalWorkflows.test.ts index 5c5bc464a4..b78f4e7f94 100644 --- a/control-api/test/routes.externalWorkflows.test.ts +++ b/control-api/test/routes.externalWorkflows.test.ts @@ -293,7 +293,7 @@ describe('routes/external/workflows', () => { describe('authenticated workflow admission ordering', () => { it.each([['POST', `/external/workflows/${RECIPE_NS}/test-recipe/trigger`, 10]] as const)( - 'limits %s %s before final authority or protected work', + 'limits %s %s before any session work, as dev does', async (method, path, limit) => { mockRateLimitCheck.mockResolvedValue({ allowed: false, @@ -313,11 +313,29 @@ describe('routes/external/workflows', () => { expect(response.body).toMatchObject( method === 'POST' ? { error: 'Too Many Requests' } : { error: { code: 'rate_limited' } } ) - expect(mockAuthenticateExternalUserSession).toHaveBeenCalledTimes(1) + expect(mockRateLimitCheck).toHaveBeenCalledOnce() + expect(mockAuthenticateExternalUserSession).not.toHaveBeenCalled() expect(mockPoolQuery).not.toHaveBeenCalled() } ) + it('charges the trigger bucket even when the session is then rejected', async () => { + const response = await request(makeApp(gateway)) + .post(`/external/workflows/${RECIPE_NS}/test-recipe/trigger`) + .set('x-user-session-token', 'rejected-session-token') + .send({}) + + expect(response.status).toBe(401) + // Witness: the session was evaluated and rejected after the limiter ran. + expect(mockAuthenticateExternalUserSession).toHaveBeenCalledWith( + 'rejected-session-token', + expect.anything() + ) + expect(mockRateLimitCheck).toHaveBeenCalledOnce() + expect(String(mockRateLimitCheck.mock.calls[0][0])).toMatch(/^workflow_trigger:/) + expect(mockPoolQuery).not.toHaveBeenCalled() + }) + it.each([ ['/external/workflows', 200], [`/external/workflows/${RECIPE_NS}/test-recipe`, 403], diff --git a/control-api/test/routes.rpcAccessUsersSignedIdentity.test.ts b/control-api/test/routes.rpcAccessUsersSignedIdentity.test.ts index f503ccd047..cd91fbb20a 100644 --- a/control-api/test/routes.rpcAccessUsersSignedIdentity.test.ts +++ b/control-api/test/routes.rpcAccessUsersSignedIdentity.test.ts @@ -12,11 +12,9 @@ const directory = vi.hoisted(() => ({ getTeamAgents: vi.fn(), })) const db = vi.hoisted(() => ({ query: vi.fn(), connect: vi.fn() })) -const limiter = vi.hoisted(() => ({ checkAndIncrement: vi.fn() })) vi.mock('../src/services/directory/index.js', () => directory) vi.mock('../src/db.js', () => ({ pool: db })) -vi.mock('../src/services/rateLimiterService.js', () => limiter) type SigningClaims = Parameters[0] const HOST_REF = 'host-a' @@ -52,16 +50,6 @@ const BLANK_IDENTITIES = [ beforeEach(() => { vi.clearAllMocks() - // Identity is the subject of this file; Host-message admission is a separate - // concern and must not turn a signed-identity assertion into a 503. - limiter.checkAndIncrement.mockResolvedValue({ - allowed: true, - remaining: 59, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - count: 1, - backendAvailable: true, - }) // Deliberately grant the host at the external directory boundary, including // for an empty user id: invalid identity rejection must come from real auth. directory.getUserAgents.mockImplementation(async (userId: string) => ({ diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index cb408128a9..4b855fb5c4 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -9,7 +9,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.12.0', externalRestApiVersion: '0.1.158', - rpcProxyVersion: '0.1.134', + rpcProxyVersion: '0.1.135', desktopVersion: '0.12.0', minimumDesktopVersion: '0.1.252', } diff --git a/rpc-proxy/package-lock.json b/rpc-proxy/package-lock.json index bb5514db93..76b121959b 100644 --- a/rpc-proxy/package-lock.json +++ b/rpc-proxy/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-rpc-proxy", - "version": "0.1.134", + "version": "0.1.135", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-rpc-proxy", - "version": "0.1.134", + "version": "0.1.135", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/rpc-proxy/package.json b/rpc-proxy/package.json index 982eb99c1e..d72035baed 100644 --- a/rpc-proxy/package.json +++ b/rpc-proxy/package.json @@ -1,6 +1,6 @@ { "name": "clerum-rpc-proxy", - "version": "0.1.134", + "version": "0.1.135", "description": "External-facing RPC proxy for tenant-scoped MCP access", "main": "dist/main.js", "engines": { diff --git a/rpc-proxy/src/services/controlApiRestService.ts b/rpc-proxy/src/services/controlApiRestService.ts index 775bc1074c..9c03c6ffe6 100644 --- a/rpc-proxy/src/services/controlApiRestService.ts +++ b/rpc-proxy/src/services/controlApiRestService.ts @@ -233,8 +233,8 @@ export async function fetchHostConnectionFromControlApi( } /** - * Resolves a Host connection after Control API has enforced the durable, - * cross-replica artifact-read budget for this verified user and canonical Host. + * Resolves a Host connection through Control API's dedicated artifact-read + * operation, which authenticates the RPC token and live-authorizes the Host. */ export async function fetchArtifactReadHostConnectionFromControlApi( userId: string, diff --git a/rpc-proxy/src/services/mcpProxyService.ts b/rpc-proxy/src/services/mcpProxyService.ts index 5e15ab1223..329e0a5ba4 100644 --- a/rpc-proxy/src/services/mcpProxyService.ts +++ b/rpc-proxy/src/services/mcpProxyService.ts @@ -110,7 +110,7 @@ export async function resolveHostConnectionForUser( } } -/** Resolve the Host only after the Control API's shared artifact-read budget. */ +/** Resolve the Host through the Control API's dedicated artifact-read operation. */ export async function resolveArtifactReadHostConnectionForUser( userId: string, hostRef: string, From 4fc7d7ecd2584c9bb746bcee5a874f02826e7624 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Tue, 27 Oct 2026 17:52:19 +0100 Subject: [PATCH 375/528] fix: key pre-auth limiters on the V2 session user too The workflow trigger limiter now runs before live authentication and derives its key with a signature-only check. That check verified only V1 sessions, so a V2 session would have fallen back to the shared source-IP bucket. Verify the V2 signature as well and key both contracts on the signed user. No database work happens in the check; V1 behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- .../src/services/auth/externalSessionAuthentication.ts | 7 ++++++- control-api/test/externalSessionAuthentication.test.ts | 10 ++++++++++ 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 11a4080f97..b391306f19 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.830", + "version": "0.1.831", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.830", + "version": "0.1.831", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index b43cb6d2b2..30a338ebf1 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.830", + "version": "0.1.831", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/auth/externalSessionAuthentication.ts b/control-api/src/services/auth/externalSessionAuthentication.ts index c069e739ac..c5e9ea3a9d 100644 --- a/control-api/src/services/auth/externalSessionAuthentication.ts +++ b/control-api/src/services/auth/externalSessionAuthentication.ts @@ -85,8 +85,13 @@ export type ExternalSessionAuthorityContext = Readonly< * This does not establish current session authority; protected work must still * pass through authenticateExternalUserSession and its live lifecycle checks. */ +/** + * Signature-only subject for limiters that run before live authentication. + * Both session contracts key on the user, so V2 sessions never fall back to a + * shared source-IP bucket. No database work happens here. + */ export function externalSessionUserIdForRateLimit(token: string): string | null { - return verifyExternalSessionToken(token)?.userId ?? null + return verifyUserSessionV2Token(token)?.sub ?? verifyExternalSessionToken(token)?.userId ?? null } function clientMeetsMinimum( diff --git a/control-api/test/externalSessionAuthentication.test.ts b/control-api/test/externalSessionAuthentication.test.ts index 41574b55fc..d0b021f39c 100644 --- a/control-api/test/externalSessionAuthentication.test.ts +++ b/control-api/test/externalSessionAuthentication.test.ts @@ -5,6 +5,7 @@ import { config } from '../src/config.js' import type { EffectiveUserAccessPolicy } from '../src/services/access/userAccessPolicy.js' import { authenticateExternalUserSession, + externalSessionUserIdForRateLimit, renewExternalUserSession, } from '../src/services/auth/externalSessionAuthentication.js' import { signExternalSessionToken } from '../src/utils/auth/externalSessionAuthToken.js' @@ -224,3 +225,12 @@ describe('external user-session authentication boundary', () => { ).resolves.toMatchObject({ status: 'renewed', session: { token: 'synthetic-successor' } }) }) }) + +describe('externalSessionUserIdForRateLimit', () => { + it('keys V1 and V2 sessions on the signed user, never on a shared fallback', () => { + const identity = validIdentity() + expect(externalSessionUserIdForRateLimit(v2Token(identity))).toBe(identity.userId) + expect(externalSessionUserIdForRateLimit(v1Token())).toBe(userId) + expect(externalSessionUserIdForRateLimit('not-a-session-token')).toBeNull() + }) +}) From 5a3043b15e6948a717bd4d1d32affed16ce140d7 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Tue, 27 Oct 2026 18:42:08 +0100 Subject: [PATCH 376/528] fix(ui): stop tracking the generated next-env.d.ts (#733) control-ui/next-env.d.ts and profile-ui/next-env.d.ts are generated by Next, and their import path depends on the last command that ran: `next dev` writes .next/dev/types, `next build` and `next typegen` write .next/types. Committing either form meant every other command dirtied the tree. The minikube T2 lane runs `next build` in T0 and then refuses to certify a dirty worktree, so any branch carrying the dev form failed after T0 and T1 had already run. The file has been flipped between the two forms several times (e630a5a7d, ea01156c8, 55e2ad8f2). Untrack both files and ignore them. Every consumer regenerates them: `next build` (CI control-ui job, minikube T0, Docker images), `next dev` and `next typegen`. profile-ui's CI `tsc --noEmit` passes without the file. Closes #733. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 6 ++++++ control-ui/next-env.d.ts | 7 ------- control-ui/package-lock.json | 4 ++-- control-ui/package.json | 2 +- profile-ui/next-env.d.ts | 7 ------- profile-ui/package-lock.json | 4 ++-- profile-ui/package.json | 2 +- 7 files changed, 12 insertions(+), 20 deletions(-) delete mode 100644 control-ui/next-env.d.ts delete mode 100644 profile-ui/next-env.d.ts diff --git a/.gitignore b/.gitignore index d76880fa0c..b58a3acf5c 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,12 @@ npm-debug.log* */.next/ .next/ +# Generated by every `next dev`, `next build` and `next typegen`, with a +# different import path for each; tracking it dirtied the tree on every +# build (#733). +control-ui/next-env.d.ts +profile-ui/next-env.d.ts + # Profile UI test build emits imported app helpers under .test-dist. profile-ui/.test-dist/ diff --git a/control-ui/next-env.d.ts b/control-ui/next-env.d.ts deleted file mode 100644 index a419cbe4e3..0000000000 --- a/control-ui/next-env.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -/// -/// -import "./.next/dev/types/routes.d.ts"; -import "./.next/dev/types/root-params.d.ts"; - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/control-ui/package-lock.json b/control-ui/package-lock.json index 52422f78a9..c129ea9283 100644 --- a/control-ui/package-lock.json +++ b/control-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-ui", - "version": "0.1.751", + "version": "0.1.752", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-ui", - "version": "0.1.751", + "version": "0.1.752", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/frontend-components": "file:../packages/frontend-components", diff --git a/control-ui/package.json b/control-ui/package.json index 319c441da8..9d53ecb798 100644 --- a/control-ui/package.json +++ b/control-ui/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-ui", - "version": "0.1.751", + "version": "0.1.752", "private": true, "scripts": { "dev": "NEXT_IGNORE_INCORRECT_LOCKFILE=1 next dev", diff --git a/profile-ui/next-env.d.ts b/profile-ui/next-env.d.ts deleted file mode 100644 index a419cbe4e3..0000000000 --- a/profile-ui/next-env.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -/// -/// -import "./.next/dev/types/routes.d.ts"; -import "./.next/dev/types/root-params.d.ts"; - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/profile-ui/package-lock.json b/profile-ui/package-lock.json index b4b44203cb..6a9397a948 100644 --- a/profile-ui/package-lock.json +++ b/profile-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-profile-ui", - "version": "0.1.190", + "version": "0.1.191", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-profile-ui", - "version": "0.1.190", + "version": "0.1.191", "dependencies": { "@clerum/desktop-app-links": "file:../packages/desktop-app-links", "@clerum/frontend-components": "file:../packages/frontend-components", diff --git a/profile-ui/package.json b/profile-ui/package.json index dbf5f2e583..d11257ff94 100644 --- a/profile-ui/package.json +++ b/profile-ui/package.json @@ -1,6 +1,6 @@ { "name": "clerum-profile-ui", - "version": "0.1.190", + "version": "0.1.191", "private": true, "scripts": { "dev": "next dev", From b31a0766f152e7676b2ec03ab2539ad7cd22d42e Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Tue, 27 Oct 2026 19:20:18 +0100 Subject: [PATCH 377/528] fix(control-ui): keep test fixtures out of the image build context `!control-ui/` in control-ui/Dockerfile.dockerignore re-includes the whole directory through parent-path matching, so control-ui/test/ reaches the image context and `next build` type-checks it. Since #858 a fixture there imports control-api/src, which the image context does not carry, and the control-ui image fails with TS2307. Build & Publish on dev has failed the same way since 7b95d7cf5, and the minikube T2 lane dies in minikube-setup. Exclude control-ui/test/ explicitly. This is the dockerignore change of #1198, carried here so the T2 lane can certify this branch before #1198 lands; the CI step of #1198 is not duplicated. Co-Authored-By: Claude Opus 5.5 --- control-ui/Dockerfile.dockerignore | 4 ++++ control-ui/package-lock.json | 4 ++-- control-ui/package.json | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/control-ui/Dockerfile.dockerignore b/control-ui/Dockerfile.dockerignore index 22b974be2a..1528abaaa1 100644 --- a/control-ui/Dockerfile.dockerignore +++ b/control-ui/Dockerfile.dockerignore @@ -39,3 +39,7 @@ control-ui/node_modules/ control-ui/coverage/ control-ui/playwright-report/ control-ui/test-results/ +# `!control-ui/` re-includes the whole directory, so test trees must be excluded +# explicitly: `next build` type-checks them, and fixtures import control-api/, +# which is not in this context. +control-ui/test/ diff --git a/control-ui/package-lock.json b/control-ui/package-lock.json index c129ea9283..6f440d7b7a 100644 --- a/control-ui/package-lock.json +++ b/control-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-ui", - "version": "0.1.752", + "version": "0.1.753", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-ui", - "version": "0.1.752", + "version": "0.1.753", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/frontend-components": "file:../packages/frontend-components", diff --git a/control-ui/package.json b/control-ui/package.json index 9d53ecb798..5ba46ebfa3 100644 --- a/control-ui/package.json +++ b/control-ui/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-ui", - "version": "0.1.752", + "version": "0.1.753", "private": true, "scripts": { "dev": "NEXT_IGNORE_INCORRECT_LOCKFILE=1 next dev", From 58c0182c235a56fed12a08e3de2d11c554271c98 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Tue, 27 Oct 2026 21:08:48 +0100 Subject: [PATCH 378/528] test(control-api): hold reciprocal retirements on a PostgreSQL gate The reciprocal managed-user retirement test used the external member mutation rate limiter mock as its barrier: each request counted itself there and waited for the other, so both were past authentication before either retirement ran. The dev rate-limit parity change (3a94ab8a2) removed that limiter from DELETE /external/members/:userId, the mock was never called, and the test failed in T1 with `expected +0 to be 2`. Hold both principal rows FOR UPDATE in a gate transaction instead. Session validation does not lock users, so both requests authenticate and then wait on the retirement's principal-pair lock. The test waits for two lock waiters before releasing the gate; waiters on one row queue behind each other, so it counts every waiting backend rather than those blocked by the gate pid. Removing the manager/target pair lock now fails the test with [200, 500] instead of [200, 403]. Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- ...Authority.realPostgres.integration.test.ts | 80 ++++++++++++------- 3 files changed, 52 insertions(+), 34 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index b8766483a1..0d3da0557b 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.840", + "version": "0.1.841", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.840", + "version": "0.1.841", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 010737ff7d..f50e3600db 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.840", + "version": "0.1.841", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts b/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts index da4b5ddfe1..7930a64c2d 100644 --- a/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts +++ b/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts @@ -108,6 +108,22 @@ async function waitForBlockedBy(pool: Pool, blockerPid: number): Promise { throw new Error('expected final team mutation authority to block on user lock') } +// Waiters on one row queue behind each other, so only the first reports the +// lock holder in pg_blocking_pids; count every backend waiting on any lock. +async function waitForLockWaiters(pool: Pool, expected: number): Promise { + for (let attempt = 0; attempt < 500; attempt += 1) { + const result = await pool.query<{ waiting: number }>( + `SELECT COUNT(*)::int AS waiting + FROM pg_stat_activity activity + WHERE activity.datname = current_database() + AND cardinality(pg_blocking_pids(activity.pid)) > 0` + ) + if ((result.rows[0]?.waiting ?? 0) >= expected) return + await new Promise(resolve => setTimeout(resolve, 10)) + } + throw new Error(`expected ${expected} retirement requests to wait on the principal lock`) +} + async function retireAdmin( db: Pick, userId: string, @@ -631,38 +647,40 @@ describeRealPostgres('external team mutations final source authority on real Pos ), ]) - let admitted = 0 - let releaseRequests!: () => void - const bothAuthenticated = new Promise(resolve => { - releaseRequests = resolve - }) - rateLimit.checkAndIncrement.mockImplementation(async () => { - admitted += 1 - if (admitted === 2) releaseRequests() - await bothAuthenticated - return { - allowed: true, - count: 1, - remaining: 9, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - } - }) - - const [firstResponse, secondResponse] = await Promise.all([ - request(app()) - .delete(`/external/members/${secondUserId}`) - .set('x-user-session-token', firstSession.token) - .set('Idempotency-Key', `reciprocal-${firstUserId}-${secondUserId}`) - .send({ reason: 'reciprocal retirement serialization' }), - request(app()) - .delete(`/external/members/${firstUserId}`) - .set('x-user-session-token', secondSession.token) - .set('Idempotency-Key', `reciprocal-${secondUserId}-${firstUserId}`) - .send({ reason: 'reciprocal retirement serialization' }), - ]) + // Hold both principal rows so each request authenticates (session validation + // does not lock users) and then waits on the retirement's principal-pair lock. + // Two waiting backends prove both requests are past authentication before + // either retirement runs. + const gate = await databasePool.connect() + let firstResponse: request.Response + let secondResponse: request.Response + try { + await gate.query('BEGIN') + await gate.query(`SELECT id FROM users WHERE id = ANY($1::uuid[]) ORDER BY id FOR UPDATE`, [ + [firstUserId, secondUserId], + ]) + const pending = Promise.all([ + request(app()) + .delete(`/external/members/${secondUserId}`) + .set('x-user-session-token', firstSession.token) + .set('Idempotency-Key', `reciprocal-${firstUserId}-${secondUserId}`) + .send({ reason: 'reciprocal retirement serialization' }) + .then(response => response), + request(app()) + .delete(`/external/members/${firstUserId}`) + .set('x-user-session-token', secondSession.token) + .set('Idempotency-Key', `reciprocal-${secondUserId}-${firstUserId}`) + .send({ reason: 'reciprocal retirement serialization' }) + .then(response => response), + ]) + await waitForLockWaiters(databasePool, 2) + await gate.query('COMMIT') + ;[firstResponse, secondResponse] = await pending + } finally { + await gate.query('ROLLBACK').catch(() => undefined) + gate.release() + } - expect(admitted).toBe(2) expect([firstResponse.status, secondResponse.status].sort((a, b) => a - b)).toEqual([200, 403]) const remaining = await databasePool.query<{ id: string }>( `SELECT id::text AS id FROM users WHERE id = ANY($1::uuid[]) ORDER BY id`, From 364df8f450b12aec4dbdfaffc0cd61b7cd7d8b5a Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 28 Oct 2026 23:07:03 +0100 Subject: [PATCH 379/528] refactor(control-api): renumber PR1 migrations after dev's 0130 Dev now ends at 0130_password_login_success_admission (#1197), which shared slot 0130 with the PR1 foundation migration. No shared environment has applied the PR1 migrations, so they move to 0131-0135 with their names unchanged: - 0131_user_access_foundation - 0132_invitation_delivery_commands - 0133_catalog_utf8_ordering - 0134_composable_catalog_revisions - 0135_legacy_password_security_epoch_backfill The registry, the PR1 runner list, the online index plan, the bounds contract and the tests follow. The ordering test now requires 0129, then dev's 0130, then the first PR1 version. The two pinned migration bodies do not change, so the D34 hashes stay as they are. Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- control-api/src/db.ts | 10 ++-- control-api/src/migrations/migrationRunner.ts | 10 ++-- .../src/migrations/pr1OnlineIndexPlan.ts | 54 +++++++++---------- .../db.catalogUtf8OrderingMigration.test.ts | 6 +-- ...composableCatalogRevisionMigration.test.ts | 6 +-- control-api/test/db.initDb.test.ts | 4 +- control-api/test/db.migrationsOrder.test.ts | 14 ++--- .../db.oauthGrantsOwnerGeneralization.test.ts | 2 +- .../db.userAccessFoundationMigration.test.ts | 8 +-- ...hBackfill.realPostgres.integration.test.ts | 4 +- ...ionPolicy.realPostgres.integration.test.ts | 28 +++++----- .../test/migrationExecutionPolicy.test.ts | 27 +++++----- ...tionLocks.realPostgres.integration.test.ts | 10 ++-- .../test-control-api-migration-bounds.sh | 2 +- 16 files changed, 97 insertions(+), 94 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 35807dbb43..8878ef690f 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.846", + "version": "0.1.847", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.846", + "version": "0.1.847", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 1d0311618c..fd8b363536 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.846", + "version": "0.1.847", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/db.ts b/control-api/src/db.ts index 79f0452845..1aaadf90fc 100644 --- a/control-api/src/db.ts +++ b/control-api/src/db.ts @@ -6420,23 +6420,23 @@ export const CONTROL_API_MIGRATIONS: DbMigration[] = [ apply: applyPasswordSuccessAdmissionSchema, }, { - version: '0130_user_access_foundation', + version: '0131_user_access_foundation', apply: applyUserAccessFoundationSchema, }, { - version: '0131_invitation_delivery_commands', + version: '0132_invitation_delivery_commands', apply: applyInvitationDeliveryCommandFoundation, }, { - version: '0132_catalog_utf8_ordering', + version: '0133_catalog_utf8_ordering', apply: applyCatalogUtf8OrderingSchema, }, { - version: '0133_composable_catalog_revisions', + version: '0134_composable_catalog_revisions', apply: applyComposableCatalogRevisionSchema, }, { - version: '0134_legacy_password_security_epoch_backfill', + version: '0135_legacy_password_security_epoch_backfill', apply: backfillLegacyPasswordSecurityEpochs, }, ] diff --git a/control-api/src/migrations/migrationRunner.ts b/control-api/src/migrations/migrationRunner.ts index de122f9532..a3a57346a3 100644 --- a/control-api/src/migrations/migrationRunner.ts +++ b/control-api/src/migrations/migrationRunner.ts @@ -3,11 +3,11 @@ import { migrationSessionBoundsSql } from './migrationExecutionPolicy.js' import { preparePr1Migration } from './pr1OnlineIndexPlan.js' export const PR1_MIGRATION_VERSIONS = Object.freeze([ - '0130_user_access_foundation', - '0131_invitation_delivery_commands', - '0132_catalog_utf8_ordering', - '0133_composable_catalog_revisions', - '0134_legacy_password_security_epoch_backfill', + '0131_user_access_foundation', + '0132_invitation_delivery_commands', + '0133_catalog_utf8_ordering', + '0134_composable_catalog_revisions', + '0135_legacy_password_security_epoch_backfill', ] as const) // Every migration up to and including dev's last migration runs in initDb's diff --git a/control-api/src/migrations/pr1OnlineIndexPlan.ts b/control-api/src/migrations/pr1OnlineIndexPlan.ts index 584929df03..6833b11997 100644 --- a/control-api/src/migrations/pr1OnlineIndexPlan.ts +++ b/control-api/src/migrations/pr1OnlineIndexPlan.ts @@ -5,7 +5,7 @@ import { } from './migrationExecutionPolicy.js' export type OnlineIndexDefinition = Readonly<{ - migrationVersion: '0130_user_access_foundation' | '0132_catalog_utf8_ordering' + migrationVersion: '0131_user_access_foundation' | '0133_catalog_utf8_ordering' name: string table: string unique?: boolean @@ -14,70 +14,70 @@ export type OnlineIndexDefinition = Readonly<{ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.freeze([ { - migrationVersion: '0130_user_access_foundation', + migrationVersion: '0131_user_access_foundation', name: 'team_members_user_active_idx', table: 'team_members', createSql: `CREATE INDEX CONCURRENTLY team_members_user_active_idx ON team_members (user_id, status, team_id) INCLUDE (role, updated_at)`, }, { - migrationVersion: '0130_user_access_foundation', + migrationVersion: '0131_user_access_foundation', name: 'user_contexts_context_user_idx', table: 'user_contexts', createSql: `CREATE INDEX CONCURRENTLY user_contexts_context_user_idx ON user_contexts (context_id, user_id)`, }, { - migrationVersion: '0130_user_access_foundation', + migrationVersion: '0131_user_access_foundation', name: 'team_contexts_context_team_idx', table: 'team_contexts', createSql: `CREATE INDEX CONCURRENTLY team_contexts_context_team_idx ON team_contexts (context_id, team_id)`, }, { - migrationVersion: '0130_user_access_foundation', + migrationVersion: '0131_user_access_foundation', name: 'user_agents_agent_user_idx', table: 'user_agents', createSql: `CREATE INDEX CONCURRENTLY user_agents_agent_user_idx ON user_agents (agent_name, user_id)`, }, { - migrationVersion: '0130_user_access_foundation', + migrationVersion: '0131_user_access_foundation', name: 'team_agents_agent_team_idx', table: 'team_agents', createSql: `CREATE INDEX CONCURRENTLY team_agents_agent_team_idx ON team_agents (agent_name, team_id)`, }, { - migrationVersion: '0130_user_access_foundation', + migrationVersion: '0131_user_access_foundation', name: 'user_workflow_triggers_recipe_user_idx', table: 'user_workflow_triggers', createSql: `CREATE INDEX CONCURRENTLY user_workflow_triggers_recipe_user_idx ON user_workflow_triggers (recipe_namespace, recipe_name, user_id)`, }, { - migrationVersion: '0130_user_access_foundation', + migrationVersion: '0131_user_access_foundation', name: 'team_workflow_triggers_recipe_team_idx', table: 'team_workflow_triggers', createSql: `CREATE INDEX CONCURRENTLY team_workflow_triggers_recipe_team_idx ON team_workflow_triggers (recipe_namespace, recipe_name, team_id)`, }, { - migrationVersion: '0130_user_access_foundation', + migrationVersion: '0131_user_access_foundation', name: 'user_workflow_triggers_catalog_key_idx', table: 'user_workflow_triggers', createSql: `CREATE INDEX CONCURRENTLY user_workflow_triggers_catalog_key_idx ON user_workflow_triggers (user_id, ((recipe_namespace || '/'::text) || recipe_name))`, }, { - migrationVersion: '0130_user_access_foundation', + migrationVersion: '0131_user_access_foundation', name: 'team_workflow_triggers_catalog_key_idx', table: 'team_workflow_triggers', createSql: `CREATE INDEX CONCURRENTLY team_workflow_triggers_catalog_key_idx ON team_workflow_triggers (((recipe_namespace || '/'::text) || recipe_name), team_id)`, }, { - migrationVersion: '0130_user_access_foundation', + migrationVersion: '0131_user_access_foundation', name: 'workflow_runs_actor_catalog_idx', table: 'workflow_runs', createSql: `CREATE INDEX CONCURRENTLY workflow_runs_actor_catalog_idx @@ -86,7 +86,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr WHERE actor_type = 'user' AND actor_id IS NOT NULL`, }, { - migrationVersion: '0130_user_access_foundation', + migrationVersion: '0131_user_access_foundation', name: 'workflow_runs_team_catalog_idx', table: 'workflow_runs', createSql: `CREATE INDEX CONCURRENTLY workflow_runs_team_catalog_idx @@ -95,7 +95,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr WHERE team_id IS NOT NULL`, }, { - migrationVersion: '0130_user_access_foundation', + migrationVersion: '0131_user_access_foundation', name: 'workflow_runs_usage_team_catalog_idx', table: 'workflow_runs', createSql: `CREATE INDEX CONCURRENTLY workflow_runs_usage_team_catalog_idx @@ -104,7 +104,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr WHERE usage_team_id IS NOT NULL`, }, { - migrationVersion: '0130_user_access_foundation', + migrationVersion: '0131_user_access_foundation', name: 'workflow_approval_user_catalog_idx', table: 'workflow_approval_requests', createSql: `CREATE INDEX CONCURRENTLY workflow_approval_user_catalog_idx @@ -113,7 +113,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr WHERE target_user_id IS NOT NULL`, }, { - migrationVersion: '0130_user_access_foundation', + migrationVersion: '0131_user_access_foundation', name: 'workflow_approval_team_catalog_idx', table: 'workflow_approval_requests', createSql: `CREATE INDEX CONCURRENTLY workflow_approval_team_catalog_idx @@ -122,7 +122,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr WHERE target_team_id IS NOT NULL`, }, { - migrationVersion: '0130_user_access_foundation', + migrationVersion: '0131_user_access_foundation', name: 'notification_user_catalog_idx', table: 'notification_deliveries', createSql: `CREATE INDEX CONCURRENTLY notification_user_catalog_idx @@ -130,7 +130,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr INCLUDE (expires_at, status, event_type) WHERE audience ? 'userId'`, }, { - migrationVersion: '0130_user_access_foundation', + migrationVersion: '0131_user_access_foundation', name: 'notification_team_catalog_idx', table: 'notification_deliveries', createSql: `CREATE INDEX CONCURRENTLY notification_team_catalog_idx @@ -138,7 +138,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr INCLUDE (expires_at, status, event_type) WHERE audience ? 'teamId'`, }, { - migrationVersion: '0130_user_access_foundation', + migrationVersion: '0131_user_access_foundation', name: 'gfs_grants_subject_resource_catalog_idx', table: 'gfs_grants', createSql: `CREATE INDEX CONCURRENTLY gfs_grants_subject_resource_catalog_idx @@ -146,7 +146,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr INCLUDE (id, drive, permissions, inherit)`, }, { - migrationVersion: '0130_user_access_foundation', + migrationVersion: '0131_user_access_foundation', name: 'gfs_shares_subject_resource_catalog_idx', table: 'gfs_shares', createSql: `CREATE INDEX CONCURRENTLY gfs_shares_subject_resource_catalog_idx @@ -154,35 +154,35 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr INCLUDE (id, drive, permissions, include_descendants)`, }, { - migrationVersion: '0132_catalog_utf8_ordering', + migrationVersion: '0133_catalog_utf8_ordering', name: 'user_agents_catalog_utf8_idx', table: 'user_agents', createSql: `CREATE INDEX CONCURRENTLY user_agents_catalog_utf8_idx ON user_agents (user_id, catalog_utf8_bytes(agent_name))`, }, { - migrationVersion: '0132_catalog_utf8_ordering', + migrationVersion: '0133_catalog_utf8_ordering', name: 'team_agents_catalog_utf8_idx', table: 'team_agents', createSql: `CREATE INDEX CONCURRENTLY team_agents_catalog_utf8_idx ON team_agents (catalog_utf8_bytes(agent_name), team_id)`, }, { - migrationVersion: '0132_catalog_utf8_ordering', + migrationVersion: '0133_catalog_utf8_ordering', name: 'user_contexts_catalog_utf8_idx', table: 'user_contexts', createSql: `CREATE INDEX CONCURRENTLY user_contexts_catalog_utf8_idx ON user_contexts (user_id, catalog_utf8_bytes(context_id))`, }, { - migrationVersion: '0132_catalog_utf8_ordering', + migrationVersion: '0133_catalog_utf8_ordering', name: 'team_contexts_catalog_utf8_idx', table: 'team_contexts', createSql: `CREATE INDEX CONCURRENTLY team_contexts_catalog_utf8_idx ON team_contexts (catalog_utf8_bytes(context_id), team_id)`, }, { - migrationVersion: '0132_catalog_utf8_ordering', + migrationVersion: '0133_catalog_utf8_ordering', name: 'user_workflow_triggers_catalog_utf8_idx', table: 'user_workflow_triggers', createSql: `CREATE INDEX CONCURRENTLY user_workflow_triggers_catalog_utf8_idx @@ -190,7 +190,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr (user_id, catalog_utf8_bytes(recipe_namespace || '/' || recipe_name))`, }, { - migrationVersion: '0132_catalog_utf8_ordering', + migrationVersion: '0133_catalog_utf8_ordering', name: 'team_workflow_triggers_catalog_utf8_idx', table: 'team_workflow_triggers', createSql: `CREATE INDEX CONCURRENTLY team_workflow_triggers_catalog_utf8_idx @@ -198,7 +198,7 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr (catalog_utf8_bytes(recipe_namespace || '/' || recipe_name), team_id)`, }, { - migrationVersion: '0132_catalog_utf8_ordering', + migrationVersion: '0133_catalog_utf8_ordering', name: 'operational_relationship_catalog_utf8_target_idx', table: 'operational_resource_relationships', createSql: `CREATE INDEX CONCURRENTLY operational_relationship_catalog_utf8_target_idx @@ -654,6 +654,6 @@ async function prepareCatalogUtf8Function(db: DbClient): Promise { export async function preparePr1Migration(db: DbClient, version: string): Promise { const indexes = PR1_ONLINE_INDEX_PLAN.filter(entry => entry.migrationVersion === version) if (indexes.length === 0) return - if (version === '0132_catalog_utf8_ordering') await prepareCatalogUtf8Function(db) + if (version === '0133_catalog_utf8_ordering') await prepareCatalogUtf8Function(db) for (const index of indexes) await ensureOnlineIndex(db, index) } diff --git a/control-api/test/db.catalogUtf8OrderingMigration.test.ts b/control-api/test/db.catalogUtf8OrderingMigration.test.ts index b3a9dd1e0a..161da5010f 100644 --- a/control-api/test/db.catalogUtf8OrderingMigration.test.ts +++ b/control-api/test/db.catalogUtf8OrderingMigration.test.ts @@ -4,12 +4,12 @@ vi.mock('../src/config.js', () => ({ config: { databaseUrl: 'postgresql://test' }, })) -describe('0131 catalog UTF-8 ordering migration', () => { +describe('0133 catalog UTF-8 ordering migration', () => { it('installs the immutable byte function and plans its index class online', async () => { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const { PR1_ONLINE_INDEX_PLAN } = await import('../src/migrations/pr1OnlineIndexPlan.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0132_catalog_utf8_ordering' + candidate => candidate.version === '0133_catalog_utf8_ordering' ) expect(migration).toBeDefined() @@ -32,7 +32,7 @@ describe('0131 catalog UTF-8 ordering migration', () => { 'operational_relationship_catalog_utf8_target_idx', ] const planned = PR1_ONLINE_INDEX_PLAN.filter( - entry => entry.migrationVersion === '0132_catalog_utf8_ordering' + entry => entry.migrationVersion === '0133_catalog_utf8_ordering' ) expect(planned.map(entry => entry.name).sort()).toEqual([...expectedIndexes].sort()) for (const entry of planned) expect(entry.createSql).toContain('catalog_utf8_bytes(') diff --git a/control-api/test/db.composableCatalogRevisionMigration.test.ts b/control-api/test/db.composableCatalogRevisionMigration.test.ts index f9190d3b7a..b1d4fff8d4 100644 --- a/control-api/test/db.composableCatalogRevisionMigration.test.ts +++ b/control-api/test/db.composableCatalogRevisionMigration.test.ts @@ -4,11 +4,11 @@ vi.mock('../src/config.js', () => ({ config: { databaseUrl: 'postgresql://test' }, })) -describe('0133 composable catalog revisions migration', () => { +describe('0134 composable catalog revisions migration', () => { it('installs scoped transactional writers for every catalog writer table', async () => { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0133_composable_catalog_revisions' + candidate => candidate.version === '0134_composable_catalog_revisions' ) expect(migration).toBeDefined() @@ -51,7 +51,7 @@ describe('0133 composable catalog revisions migration', () => { it('replaces row-level revision triggers without dropping them', async () => { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0133_composable_catalog_revisions' + candidate => candidate.version === '0134_composable_catalog_revisions' ) expect(migration).toBeDefined() diff --git a/control-api/test/db.initDb.test.ts b/control-api/test/db.initDb.test.ts index 2c8ef388f6..bab1cd432b 100644 --- a/control-api/test/db.initDb.test.ts +++ b/control-api/test/db.initDb.test.ts @@ -68,7 +68,7 @@ describe('db.initDb', () => { expect(sqls).not.toContain('ROLLBACK') // The committed password-admission rollout fence, one dev batch through // 0130_password_login_success_admission (as in dev), then five PR1 versions - // plus the 0132 function transaction. + // plus the 0133 function transaction. expect(sqls.filter(sql => sql === 'BEGIN')).toHaveLength(8) expect(sqls.filter(sql => sql === 'COMMIT')).toHaveLength(8) expect(sqls[sqls.length - 2]).toBe('COMMIT') @@ -94,7 +94,7 @@ describe('db.initDb', () => { expect(index, version).toBeGreaterThan(devBatchBegin) expect(index, version).toBeLessThan(devBatchCommit) } - expect(receiptIndex('0130_user_access_foundation')).toBeGreaterThan(devBatchCommit) + expect(receiptIndex('0131_user_access_foundation')).toBeGreaterThan(devBatchCommit) }) it('applies the triggered_at audit index migration on top of an already-migrated baseline', async () => { diff --git a/control-api/test/db.migrationsOrder.test.ts b/control-api/test/db.migrationsOrder.test.ts index 39166dd0eb..ebbfa2361a 100644 --- a/control-api/test/db.migrationsOrder.test.ts +++ b/control-api/test/db.migrationsOrder.test.ts @@ -65,19 +65,21 @@ describe('CONTROL_API_MIGRATIONS ordering invariant', () => { '0128_password_work_ownership', ] const incomingDev = '0129_workflow_schedules_concurrency_policy' + const lastDev = '0130_password_login_success_admission' const task106 = [ - '0130_user_access_foundation', - '0131_invitation_delivery_commands', - '0132_catalog_utf8_ordering', - '0133_composable_catalog_revisions', - '0134_legacy_password_security_epoch_backfill', + '0131_user_access_foundation', + '0132_invitation_delivery_commands', + '0133_catalog_utf8_ordering', + '0134_composable_catalog_revisions', + '0135_legacy_password_security_epoch_backfill', ] expect( versions.slice(versions.indexOf(parentDev[0]!), versions.indexOf(parentDev[0]!) + 3) ).toEqual(parentDev) expect(versions[versions.indexOf(parentDev.at(-1)!) + 1]).toBe(incomingDev) - expect(versions.indexOf(incomingDev)).toBeLessThan(versions.indexOf(task106[0]!)) + expect(versions[versions.indexOf(incomingDev) + 1]).toBe(lastDev) + expect(versions[versions.indexOf(lastDev) + 1]).toBe(task106[0]) expect( versions.slice(versions.indexOf(task106[0]!), versions.indexOf(task106[0]!) + 5) ).toEqual(task106) diff --git a/control-api/test/db.oauthGrantsOwnerGeneralization.test.ts b/control-api/test/db.oauthGrantsOwnerGeneralization.test.ts index 648d32ac22..4394b99270 100644 --- a/control-api/test/db.oauthGrantsOwnerGeneralization.test.ts +++ b/control-api/test/db.oauthGrantsOwnerGeneralization.test.ts @@ -64,7 +64,7 @@ describe('0101 oauth_grants owner generalization migration', () => { versions.indexOf('0108_llm_provider_attempts_sdk_link_on_delete_set_null') ) expect(versions.indexOf('0124_entity_change_definer_search_path')).toBeLessThan( - versions.indexOf('0130_user_access_foundation') + versions.indexOf('0131_user_access_foundation') ) expect(versions).toContain('0099_gfs_upload_finalizing_recovery') }) diff --git a/control-api/test/db.userAccessFoundationMigration.test.ts b/control-api/test/db.userAccessFoundationMigration.test.ts index 737fd038bc..8b433a502b 100644 --- a/control-api/test/db.userAccessFoundationMigration.test.ts +++ b/control-api/test/db.userAccessFoundationMigration.test.ts @@ -10,7 +10,7 @@ vi.mock('../src/config.js', () => ({ async function migrationSql(): Promise { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0130_user_access_foundation' + candidate => candidate.version === '0131_user_access_foundation' ) expect(migration).toBeDefined() @@ -22,7 +22,7 @@ async function migrationSql(): Promise { async function legacyEpochBackfillSql(): Promise { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0134_legacy_password_security_epoch_backfill' + candidate => candidate.version === '0135_legacy_password_security_epoch_backfill' ) expect(migration).toBeDefined() @@ -77,11 +77,11 @@ describe('user-access foundation migration', () => { expect(sql).not.toMatch(/CREATE\s+MATERIALIZED\s+VIEW/i) }) - it('builds keyset and reverse indexes online, outside the 0130 transaction', async () => { + it('builds keyset and reverse indexes online, outside the 0131 transaction', async () => { const sql = await migrationSql() const { PR1_ONLINE_INDEX_PLAN } = await import('../src/migrations/pr1OnlineIndexPlan.js') const planned = PR1_ONLINE_INDEX_PLAN.filter( - entry => entry.migrationVersion === '0130_user_access_foundation' + entry => entry.migrationVersion === '0131_user_access_foundation' ) const expectedIndexes = [ 'team_members_user_active_idx', diff --git a/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts b/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts index 56ca8e1437..e7fe9dc5bb 100644 --- a/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts +++ b/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts @@ -25,7 +25,7 @@ function quoteIdentifier(value: string): string { async function removeLegacyEpochMigrationReceipts(databasePool: Pool): Promise { const removed = await databasePool.query( `DELETE FROM schema_migrations - WHERE version = '0134_legacy_password_security_epoch_backfill'` + WHERE version = '0135_legacy_password_security_epoch_backfill'` ) if (removed.rowCount !== 1) throw new Error('legacy epoch backfill receipt was not recorded') } @@ -133,7 +133,7 @@ describeRealPostgres('legacy password epoch backfill on real PostgreSQL', () => const recorded = await databasePool.query<{ count: string }>( `SELECT COUNT(*)::text AS count FROM schema_migrations - WHERE version = '0134_legacy_password_security_epoch_backfill'` + WHERE version = '0135_legacy_password_security_epoch_backfill'` ) expect(recorded.rows[0]?.count).toBe('1') }) diff --git a/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts index e1de6db58d..6b5889116e 100644 --- a/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts +++ b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts @@ -411,7 +411,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { for (const candidate of cases) { await databasePool.query(`CREATE INDEX ${candidate.name} ON ${table} ${candidate.actual}`) const entry: OnlineIndexDefinition = { - migrationVersion: '0130_user_access_foundation', + migrationVersion: '0131_user_access_foundation', name: candidate.name, table, createSql: `CREATE INDEX CONCURRENTLY ${candidate.name} ON ${table} ${candidate.expected}`, @@ -425,7 +425,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { await databasePool.query(`CREATE INDEX ${uniqueName} ON ${table} (userid)`) await expect( ensureOnlineIndex(databasePool, { - migrationVersion: '0130_user_access_foundation', + migrationVersion: '0131_user_access_foundation', name: uniqueName, table, unique: true, @@ -437,7 +437,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { it('repairs an equivalent interrupted index and enforces the online bound', async () => { const name = `d34_interrupted_${randomBytes(4).toString('hex')}` const entry: OnlineIndexDefinition = { - migrationVersion: '0130_user_access_foundation', + migrationVersion: '0131_user_access_foundation', name, table: 'd34_interrupted_index', unique: true, @@ -486,7 +486,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { const functionName = `d34_slow_index_value_${suffix}` const name = `d34_slow_index_${suffix}_idx` const entry: OnlineIndexDefinition = { - migrationVersion: '0130_user_access_foundation', + migrationVersion: '0131_user_access_foundation', name, table, createSql: `CREATE INDEX CONCURRENTLY ${name} ON ${table} (${functionName}(value))`, @@ -598,10 +598,10 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { it('bounds ordinary DDL lock acquisition and never runs the later version', async () => { const locker = await databasePool.connect() - await removeMigrationReceiptForReplay(databasePool, '0133_composable_catalog_revisions') + await removeMigrationReceiptForReplay(databasePool, '0134_composable_catalog_revisions') await removeMigrationReceiptForReplay( databasePool, - '0134_legacy_password_security_epoch_backfill' + '0135_legacy_password_security_epoch_backfill' ) await locker.query('BEGIN') await locker.query('LOCK TABLE users IN ACCESS EXCLUSIVE MODE') @@ -617,8 +617,8 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { expect(Date.now() - started).toBeGreaterThanOrEqual(9_000) expect(Date.now() - started).toBeLessThan(15_000) const failedVersions = await versions(databasePool) - expect(failedVersions).not.toContain('0133_composable_catalog_revisions') - expect(failedVersions).not.toContain('0134_legacy_password_security_epoch_backfill') + expect(failedVersions).not.toContain('0134_composable_catalog_revisions') + expect(failedVersions).not.toContain('0135_legacy_password_security_epoch_backfill') await initDb({ connect: () => databasePool.connect() }) }, 20_000) @@ -631,7 +631,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { ...PR1_MIGRATION_VERSIONS.map(version => ({ version, apply: async (db: DbClient) => { - if (version === '0133_composable_catalog_revisions') { + if (version === '0134_composable_catalog_revisions') { await db.query('SELECT pg_sleep(20)') } }, @@ -655,7 +655,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { `SELECT version FROM ${recordTable} ORDER BY version` ) expect(recorded.rows).toEqual([]) - expect(appliedVersions).not.toContain('0134_legacy_password_security_epoch_backfill') + expect(appliedVersions).not.toContain('0135_legacy_password_security_epoch_backfill') } finally { client.release(true) } @@ -705,13 +705,13 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { 'latest DEV migration body replay' ).toBe(0) expect(upgrade.applyCounts.get(DEV_PASSWORD_SUCCESS_ADMISSION_VERSION)).toBe(1) - const foundationIndex = upgrade.applyOrder.indexOf('0130_user_access_foundation') + const foundationIndex = upgrade.applyOrder.indexOf('0131_user_access_foundation') const passwordAdmissionIndex = upgrade.applyOrder.indexOf( DEV_PASSWORD_SUCCESS_ADMISSION_VERSION ) expect(passwordAdmissionIndex).toBeGreaterThanOrEqual(0) expect(passwordAdmissionIndex).toBeLessThan(foundationIndex) - expect(upgrade.applyOrder[foundationIndex + 1]).toBe('0131_invitation_delivery_commands') + expect(upgrade.applyOrder[foundationIndex + 1]).toBe('0132_invitation_delivery_commands') await assertDbReady(isolatedPool) // Rollback: the DEV image's migrator runs against the upgraded database. @@ -795,7 +795,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { 'new latest DEV migration applies exactly once from the observed DEV state' ).toBe(1) expect(firstRun.applyCounts.get(DEV_PASSWORD_SUCCESS_ADMISSION_VERSION)).toBe(1) - const foundationIndex = firstRun.applyOrder.indexOf('0130_user_access_foundation') + const foundationIndex = firstRun.applyOrder.indexOf('0131_user_access_foundation') const scheduleIndex = firstRun.applyOrder.indexOf( '0129_workflow_schedules_concurrency_policy' ) @@ -805,7 +805,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { expect(scheduleIndex).toBeGreaterThanOrEqual(0) expect(scheduleIndex).toBeLessThan(passwordAdmissionIndex) expect(passwordAdmissionIndex).toBeLessThan(foundationIndex) - expect(firstRun.applyOrder[foundationIndex + 1]).toBe('0131_invitation_delivery_commands') + expect(firstRun.applyOrder[foundationIndex + 1]).toBe('0132_invitation_delivery_commands') await assertDbReady(isolatedPool) const afterUpgrade = await versions(isolatedPool) diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index 52d8fb7724..23b81680a6 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -29,16 +29,16 @@ const FRESH_TABLE_INDEXES = Object.freeze([ 'invitation_delivery_commands_invitation_idx', ]) -const USER_ACCESS_FOUNDATION_VERSION = '0130_user_access_foundation' +const USER_ACCESS_FOUNDATION_VERSION = '0131_user_access_foundation' describe('D34 migration execution policy', () => { it('classifies the five PR1 migrations that follow the dev batch', () => { expect(PR1_MIGRATION_VERSIONS).toEqual([ - '0130_user_access_foundation', - '0131_invitation_delivery_commands', - '0132_catalog_utf8_ordering', - '0133_composable_catalog_revisions', - '0134_legacy_password_security_epoch_backfill', + '0131_user_access_foundation', + '0132_invitation_delivery_commands', + '0133_catalog_utf8_ordering', + '0134_composable_catalog_revisions', + '0135_legacy_password_security_epoch_backfill', ]) expect(PR1_MIGRATION_VERSIONS).not.toContain('0117_control_admin_invitation_replace_inviter') expect(PR1_MIGRATION_VERSIONS).not.toContain('0118_control_admin_replace_inviter_accept_guard') @@ -76,10 +76,10 @@ describe('D34 migration execution policy', () => { expect(PR1_ONLINE_INDEX_PLAN).toHaveLength(25) expect(new Set(PR1_ONLINE_INDEX_PLAN.map(index => index.name))).toHaveLength(25) expect( - PR1_ONLINE_INDEX_PLAN.filter(index => index.migrationVersion.startsWith('0130')) + PR1_ONLINE_INDEX_PLAN.filter(index => index.migrationVersion.startsWith('0131')) ).toHaveLength(18) expect( - PR1_ONLINE_INDEX_PLAN.filter(index => index.migrationVersion.startsWith('0132')) + PR1_ONLINE_INDEX_PLAN.filter(index => index.migrationVersion.startsWith('0133')) ).toHaveLength(7) expect( PR1_ONLINE_INDEX_PLAN.some(index => index.name.startsWith('external_user_sessions_')) @@ -91,10 +91,11 @@ describe('D34 migration execution policy', () => { it('pins the reviewed PR1 migration bodies byte-for-byte', async () => { // S1d changed the PR1 bodies before any shared environment applied them: - // statement-level catalog triggers are no longer installed by 0130, 0133/0134 - // replace row triggers in place, and planned indexes left the transactions. - // S1e folded 0134, 0135 and 0144 into 0130/0133 and dropped the - // authorization_catalog_revision table and function. + // statement-level catalog triggers are no longer installed by the foundation + // migration, the composable-revision migration replaces row triggers in place, + // and planned indexes left the transactions. S1e folded three later PR1 + // migrations into those two and dropped the authorization_catalog_revision + // table and function. PR1 now occupies slots 0131-0135, after dev's 0130. const files = [ [ 'src/services/access/userAccessFoundationSchema.ts', @@ -313,7 +314,7 @@ describe('D34 PR1 migration runner', () => { expect(applied).toEqual([...PR1_MIGRATION_VERSIONS]) // One transaction per version, plus the catalog_utf8_bytes function that - // 0132 creates and commits before building its online indexes. + // 0133 creates and commits before building its online indexes. const transactions = PR1_MIGRATION_VERSIONS.length + 1 expect(queries.filter(({ sql }) => sql === 'BEGIN')).toHaveLength(transactions) expect(queries.filter(({ sql }) => sql === 'COMMIT')).toHaveLength(transactions) diff --git a/control-api/test/pr1MigrationLocks.realPostgres.integration.test.ts b/control-api/test/pr1MigrationLocks.realPostgres.integration.test.ts index 78b8b78829..b386a1195f 100644 --- a/control-api/test/pr1MigrationLocks.realPostgres.integration.test.ts +++ b/control-api/test/pr1MigrationLocks.realPostgres.integration.test.ts @@ -30,7 +30,7 @@ const runtimeRoles = [ const PR1_VERSIONS = new Set(PR1_MIGRATION_VERSIONS) const ACCESS_FOUNDATION_MARKER = 'CREATE TABLE IF NOT EXISTS external_user_sessions' -const COMPOSABLE_CATALOG_VERSION = '0133_composable_catalog_revisions' +const COMPOSABLE_CATALOG_VERSION = '0134_composable_catalog_revisions' const ROW_REVISION_TRIGGER_TABLES = [ 'users', 'teams', @@ -134,7 +134,7 @@ async function migrate(pool: Pool, observer: Pool): Promise { query: async (text: string, values?: unknown[]) => { if (!accessFoundationSeen && text.includes(ACCESS_FOUNDATION_MARKER)) { accessFoundationSeen = true - // The 0130 transaction has not created anything yet, so the + // The 0131 transaction has not created anything yet, so the // committed catalog holds exactly the tables that predate PR1. run.preExistingTables = await committedTables(observer) } @@ -216,7 +216,7 @@ describeRealPostgres('PR1 migration locks on real PostgreSQL', () => { it('holds no AccessExclusive or Share lock on a pre-existing table in any PR1 transaction', () => { // Liveness: every PR1 version committed under observation, the pre-PR1 - // table set was captured, and the snapshots see the trigger locks 0133 takes. + // table set was captured, and the snapshots see the trigger locks 0134 takes. for (const version of PR1_MIGRATION_VERSIONS) { expect( fresh.commits.some(commit => commit.versions.includes(version)), @@ -233,11 +233,11 @@ describeRealPostgres('PR1 migration locks on real PostgreSQL', () => { expect(blockingLocks(fresh)).toEqual([]) }) - it('lets readers through and holds writers while 0133 is open', () => { + it('lets readers through and holds writers while 0134 is open', () => { expect(fresh.composableCatalogProbes).toEqual({ readUsers: { ok: true, sqlstate: null }, readWorkflowRuns: { ok: true, sqlstate: null }, - // Witness that the probe ran inside the open 0133 transaction. + // Witness that the probe ran inside the open 0134 transaction. writeUsers: { ok: false, sqlstate: '55P03' }, }) }) diff --git a/scripts/tests/test-control-api-migration-bounds.sh b/scripts/tests/test-control-api-migration-bounds.sh index afc63a214e..b586b60dd6 100755 --- a/scripts/tests/test-control-api-migration-bounds.sh +++ b/scripts/tests/test-control-api-migration-bounds.sh @@ -44,7 +44,7 @@ grep -q 'applyPendingPr1Migrations' "$DB_FILE" || \ grep -q 'CREATE INDEX CONCURRENTLY' "$INDEX_PLAN_FILE" || \ fail "existing-table indexes are not prepared concurrently" -index_count="$(grep -c "^ migrationVersion: '013[02]_" "$INDEX_PLAN_FILE")" +index_count="$(grep -c "^ migrationVersion: '013[13]_" "$INDEX_PLAN_FILE")" [ "$index_count" = "25" ] || fail "expected 25 online indexes, found $index_count" grep -q 'activeDeadlineSeconds' "$DEPLOY_FILE" || \ From 4a86d25becf7349de9ac264ffe7bb72b77fc6d9e Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 28 Oct 2026 23:17:16 +0100 Subject: [PATCH 380/528] fix(control-api): drop unused PR1 schema objects and runtime revision writes - C-22 (OD-6): control_api_runtime keeps only SELECT on the user, team and resource revision tables. No application code writes them; every write goes through the SECURITY DEFINER bump functions. Profiles move from upsert to read. - P-12: drop operational_relationship_source_idx, which repeated the relationship primary key column for column. - P-13: drop authorization_catalog_writer_components, its seed and grants; nothing in production read it. The writer-class map moves into the catalogRevisionComponents real-PostgreSQL suite as test data, which still checks every writer table has a revision trigger. Profiles go to 114. - P-14: drop authorization_bump_gfs_subject_revision(), which had no trigger and no caller. Witnesses: runtime DML on a revision table fails with 42501 while a runtime team insert still advances the team revision through the trigger; explicit absence checks for the dropped table and function. The D34 body pin for userAccessFoundationSchema.ts is updated once, after the remaining S1 schema changes land. Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../access/userAccessFoundationSchema.ts | 59 +------------------ ...rformance.realPostgres.integration.test.ts | 7 +-- ...omponents.realPostgres.integration.test.ts | 49 +++++++++------ ...composableCatalogRevisionMigration.test.ts | 27 +-------- ....realPostgresMigration.integration.test.ts | 44 +++++++++++++- .../db.userAccessFoundationMigration.test.ts | 7 +-- ...ionPolicy.realPostgres.integration.test.ts | 7 +-- .../test/migrationExecutionPolicy.test.ts | 4 +- .../control-api-runtime-access-profiles.tsv | 7 +-- ...rkflow-recipes-runtime-access-profiles.tsv | 1 - ...est-control-api-runtime-access-contract.sh | 2 +- scripts/tests/test-gcp-scripts.sh | 2 +- 14 files changed, 98 insertions(+), 124 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 8878ef690f..1a10e8e906 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.847", + "version": "0.1.848", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.847", + "version": "0.1.848", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index fd8b363536..a500d4cbff 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.847", + "version": "0.1.848", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/access/userAccessFoundationSchema.ts b/control-api/src/services/access/userAccessFoundationSchema.ts index b8b863ca5e..bce1bbdc9c 100644 --- a/control-api/src/services/access/userAccessFoundationSchema.ts +++ b/control-api/src/services/access/userAccessFoundationSchema.ts @@ -154,11 +154,6 @@ export async function applyUserAccessFoundationSchema(db: DbClient): Promise '' AND source_resource_version <> '' ) ); - CREATE INDEX IF NOT EXISTS operational_relationship_source_idx - ON operational_resource_relationships ( - environment_id, source_type, source_id, relationship_type, - target_type, target_id, relationship_instance_id - ); CREATE INDEX IF NOT EXISTS operational_relationship_target_idx ON operational_resource_relationships ( environment_id, target_type, target_id, source_type, @@ -227,37 +222,6 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis environment_id TEXT NOT NULL CHECK (environment_id <> '') ); - CREATE TABLE IF NOT EXISTS authorization_catalog_writer_components ( - writer_table TEXT PRIMARY KEY, - component_class TEXT NOT NULL, - rationale TEXT NOT NULL, - CHECK (writer_table <> '' AND component_class <> '' AND rationale <> '') - ); - - INSERT INTO authorization_catalog_writer_components(writer_table, component_class, rationale) - VALUES - ('users', 'user', 'user identity and lifecycle'), - ('teams', 'team', 'team identity'), - ('team_members', 'user+team', 'membership and live role'), - ('user_contexts', 'user', 'direct context access'), - ('team_contexts', 'team', 'team context access'), - ('user_agents', 'user', 'direct agent access'), - ('team_agents', 'team', 'team agent access'), - ('user_workflow_triggers', 'user', 'direct workflow access'), - ('team_workflow_triggers', 'team', 'team workflow access'), - ('workflow_runs', 'user+team', 'visible workflow-run ownership'), - ('workflow_approval_requests', 'user+team', 'approval target authority'), - ('notification_deliveries', 'user+team', 'notification audience'), - ('gfs_resources', 'resource+gfs-subjects', 'resource and current subject visibility'), - ('gfs_grants', 'resource+user+team', 'GFS resource and direct-subject authority'), - ('gfs_shares', 'resource+user+team', 'GFS resource and shared-subject authority'), - ('operational_resource_index', 'resource+source-state', 'promoted source resources'), - ('operational_resource_relationships', 'resource+source-state', 'promoted source edges'), - ('operational_catalog_source_state', 'source-state', 'atomic source generation promotion') - ON CONFLICT (writer_table) DO UPDATE - SET component_class = EXCLUDED.component_class, - rationale = EXCLUDED.rationale; - CREATE OR REPLACE FUNCTION authorization_bump_subject_revision( target_subject_type TEXT, target_subject_id TEXT @@ -391,23 +355,6 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis END; $$; - CREATE OR REPLACE FUNCTION authorization_bump_gfs_subject_revision() - RETURNS TRIGGER - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = pg_catalog, public, pg_temp - AS $$ - BEGIN - IF TG_OP <> 'DELETE' THEN - PERFORM authorization_bump_subject_revision(NEW.subject_type, NEW.subject_id); - END IF; - IF TG_OP <> 'INSERT' THEN - PERFORM authorization_bump_subject_revision(OLD.subject_type, OLD.subject_id); - END IF; - RETURN COALESCE(NEW, OLD); - END; - $$; - CREATE OR REPLACE FUNCTION authorization_bump_gfs_resource_component( target_resource_id UUID ) @@ -534,9 +481,7 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis AFTER INSERT OR UPDATE OR DELETE ON gfs_resources FOR EACH ROW EXECUTE FUNCTION authorization_bump_gfs_resource_revision(); - REVOKE ALL ON TABLE authorization_catalog_writer_components FROM PUBLIC; REVOKE ALL ON TABLE authorization_catalog_environment FROM PUBLIC; - GRANT SELECT ON TABLE authorization_catalog_writer_components TO control_api_runtime; GRANT SELECT ON TABLE authorization_catalog_environment TO control_api_runtime; REVOKE ALL ON FUNCTION authorization_bump_subject_revision(TEXT, TEXT) FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_user_row_revision() FROM PUBLIC; @@ -544,7 +489,6 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis REVOKE ALL ON FUNCTION authorization_bump_workflow_run_revision() FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_workflow_approval_revision() FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_notification_revision() FROM PUBLIC; - REVOKE ALL ON FUNCTION authorization_bump_gfs_subject_revision() FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_gfs_resource_component(UUID) FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_gfs_authority_revision() FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_gfs_resource_subjects(UUID) FROM PUBLIC; @@ -843,7 +787,8 @@ async function applyUserAccessRuntimePrivileges(db: DbClient): Promise { operational_relationships_staging TO control_api_runtime; - GRANT SELECT, INSERT, UPDATE ON TABLE + -- Revision rows are written only by the SECURITY DEFINER bump functions. + GRANT SELECT ON TABLE authorization_user_revisions, authorization_team_revisions, authorization_resource_revisions diff --git a/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts b/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts index 81aeabfde8..202be172e9 100644 --- a/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts @@ -812,10 +812,9 @@ describeRealPostgres('aggregate catalog plans on real PostgreSQL', () => { }) } if (profile === 'high-cardinality') { - for (const requiredIndex of [ - 'operational_relationship_source_idx', - 'operational_resource_index_pkey', - ]) { + // The relationship primary key covers source lookups; P-12 removed the + // duplicate source index. + for (const requiredIndex of ['operational_resource_index_pkey']) { expect(profileIndexes.has(requiredIndex), requiredIndex).toBe(true) } } diff --git a/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts b/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts index e71d66b9de..8783058f02 100644 --- a/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts +++ b/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts @@ -18,6 +18,29 @@ const runtimeRoles = [ 'workflow_recipes_runtime', ] as const +// Catalog writer tables and the revision component each one advances. This +// was a production table with no reader (P-13); it is now test data. +const CATALOG_WRITER_COMPONENTS = Object.freeze([ + ['users', 'user'], + ['teams', 'team'], + ['team_members', 'user+team'], + ['user_contexts', 'user'], + ['team_contexts', 'team'], + ['user_agents', 'user'], + ['team_agents', 'team'], + ['user_workflow_triggers', 'user'], + ['team_workflow_triggers', 'team'], + ['workflow_runs', 'user+team'], + ['workflow_approval_requests', 'user+team'], + ['notification_deliveries', 'user+team'], + ['gfs_resources', 'resource+gfs-subjects'], + ['gfs_grants', 'resource+user+team'], + ['gfs_shares', 'resource+user+team'], + ['operational_resource_index', 'resource+source-state'], + ['operational_resource_relationships', 'resource+source-state'], + ['operational_catalog_source_state', 'source-state'], +] as const) + function databaseUrl(baseUrl: string, database: string): string { const value = new URL(baseUrl) value.pathname = `/${database}` @@ -140,20 +163,11 @@ describeRealPostgres('composable catalog revisions on real PostgreSQL', () => { it('maps the complete writer class without a singleton revision trigger', async () => { await applyComposableCatalogRevisionSchema(databasePool) await applyComposableCatalogRevisionSchema(databasePool) - const mappings = await databasePool.query<{ - writer_table: string - component_class: string - }>( - `SELECT writer_table, component_class - FROM authorization_catalog_writer_components - ORDER BY catalog_utf8_bytes(writer_table)` + expect(CATALOG_WRITER_COMPONENTS).toHaveLength(18) + const registry = await databasePool.query<{ relation: string | null }>( + `SELECT to_regclass('public.authorization_catalog_writer_components')::text AS relation` ) - expect(mappings.rows).toHaveLength(18) - expect(mappings.rows.filter(row => row.writer_table.startsWith('gfs_'))).toEqual([ - { writer_table: 'gfs_grants', component_class: 'resource+user+team' }, - { writer_table: 'gfs_resources', component_class: 'resource+gfs-subjects' }, - { writer_table: 'gfs_shares', component_class: 'resource+user+team' }, - ]) + expect(registry.rows).toEqual([{ relation: null }]) const globalTriggers = await databasePool.query<{ count: string }>( `SELECT count(*)::text AS count @@ -169,11 +183,10 @@ describeRealPostgres('composable catalog revisions on real PostgreSQL', () => { ) const directlyVersioned = new Set(['operational_catalog_source_state']) const triggered = new Set(triggerTables.rows.map(row => row.table_name)) - for (const mapping of mappings.rows) { - expect( - triggered.has(mapping.writer_table) || directlyVersioned.has(mapping.writer_table), - mapping.writer_table - ).toBe(true) + for (const [writerTable] of CATALOG_WRITER_COMPONENTS) { + expect(triggered.has(writerTable) || directlyVersioned.has(writerTable), writerTable).toBe( + true + ) } }) diff --git a/control-api/test/db.composableCatalogRevisionMigration.test.ts b/control-api/test/db.composableCatalogRevisionMigration.test.ts index b1d4fff8d4..018f2504e6 100644 --- a/control-api/test/db.composableCatalogRevisionMigration.test.ts +++ b/control-api/test/db.composableCatalogRevisionMigration.test.ts @@ -15,34 +15,13 @@ describe('0134 composable catalog revisions migration', () => { const query = vi.fn(async () => ({ rows: [], rowCount: 0 })) await migration!.apply({ query }) const sql = query.mock.calls.map(call => String(call[0])).join('\n') - const normalizedSql = sql.replace(/\s+/g, ' ') - for (const table of [ - 'users', - 'teams', - 'team_members', - 'user_contexts', - 'team_contexts', - 'user_agents', - 'team_agents', - 'user_workflow_triggers', - 'team_workflow_triggers', - 'workflow_runs', - 'workflow_approval_requests', - 'notification_deliveries', - 'gfs_resources', - 'gfs_grants', - 'gfs_shares', - 'operational_resource_index', - 'operational_resource_relationships', - 'operational_catalog_source_state', - ]) { - expect(normalizedSql).toContain(`('${table}',`) - } + // The writer-class map is test data in the catalogRevisionComponents + // real-PostgreSQL suite, not a production table. + expect(sql).not.toContain('authorization_catalog_writer_components') expect(sql).toContain('authorization_bump_workflow_run_revision') expect(sql).toContain('authorization_bump_workflow_approval_revision') expect(sql).toContain('authorization_bump_notification_revision') - expect(sql).toContain('authorization_bump_gfs_subject_revision') expect(sql).toContain('authorization_bump_gfs_resource_revision') expect(sql).not.toContain('authorization_catalog_revision') expect(sql).not.toMatch(/\bDROP\s+(?:TRIGGER|TABLE|FUNCTION)\b/i) diff --git a/control-api/test/db.realPostgresMigration.integration.test.ts b/control-api/test/db.realPostgresMigration.integration.test.ts index 51b65cd8f5..755add6639 100644 --- a/control-api/test/db.realPostgresMigration.integration.test.ts +++ b/control-api/test/db.realPostgresMigration.integration.test.ts @@ -51,7 +51,6 @@ const accessFoundationDefinerSignatures = [ 'authorization_bump_workflow_run_revision()', 'authorization_bump_workflow_approval_revision()', 'authorization_bump_notification_revision()', - 'authorization_bump_gfs_subject_revision()', 'authorization_bump_gfs_resource_component(pg_catalog.uuid)', 'authorization_bump_gfs_authority_revision()', 'authorization_bump_gfs_resource_subjects(pg_catalog.uuid)', @@ -832,6 +831,49 @@ describeRealPostgres('control-api real Postgres migrations', () => { } expectPrivileges([...privileges.password_admission_rollout_state], []) expectPrivileges([...privileges.password_verification_work], ['SELECT', 'INSERT', 'DELETE']) + // P-14: the GFS subject bump function had no trigger and no caller. + const obsoleteGfsSubjectRevision = await dbPool.query<{ function_name: string | null }>( + `SELECT to_regprocedure('public.authorization_bump_gfs_subject_revision()')::text + AS function_name` + ) + expect(obsoleteGfsSubjectRevision.rows).toEqual([{ function_name: null }]) + // Revision rows are written only by SECURITY DEFINER bump functions. + for (const relation of [ + 'authorization_user_revisions', + 'authorization_team_revisions', + 'authorization_resource_revisions', + ]) { + expectPrivileges([...privileges[relation]], ['SELECT']) + } + const runtimeClient = await dbPool.connect() + try { + await runtimeClient.query('BEGIN') + await runtimeClient.query('SET LOCAL ROLE control_api_runtime') + const team = await runtimeClient.query<{ id: string }>( + `INSERT INTO teams(name) VALUES ('Synthetic revision grant team') RETURNING id` + ) + const teamId = team.rows[0].id + // Liveness witness: the runtime's own team write advances the revision + // through the trigger. + const bumped = await runtimeClient.query<{ revision: string }>( + 'SELECT revision::text FROM authorization_team_revisions WHERE team_id = $1', + [teamId] + ) + expect(bumped.rows).toHaveLength(1) + for (const statement of [ + 'INSERT INTO authorization_team_revisions(team_id) VALUES ($1)', + 'UPDATE authorization_team_revisions SET revision = revision + 1 WHERE team_id = $1', + ]) { + await runtimeClient.query('SAVEPOINT direct_revision_write') + await expect(runtimeClient.query(statement, [teamId])).rejects.toMatchObject({ + code: '42501', + }) + await runtimeClient.query('ROLLBACK TO SAVEPOINT direct_revision_write') + } + } finally { + await runtimeClient.query('ROLLBACK') + runtimeClient.release() + } try { await dbPool.query('GRANT UPDATE ON password_verification_work TO control_api_runtime') diff --git a/control-api/test/db.userAccessFoundationMigration.test.ts b/control-api/test/db.userAccessFoundationMigration.test.ts index 8b433a502b..e040946329 100644 --- a/control-api/test/db.userAccessFoundationMigration.test.ts +++ b/control-api/test/db.userAccessFoundationMigration.test.ts @@ -140,10 +140,9 @@ describe('user-access foundation migration', () => { ) const expected = new Map([ - ['authorization_catalog_writer_components', 'read'], - ['authorization_resource_revisions', 'upsert'], - ['authorization_team_revisions', 'upsert'], - ['authorization_user_revisions', 'upsert'], + ['authorization_resource_revisions', 'read'], + ['authorization_team_revisions', 'read'], + ['authorization_user_revisions', 'read'], ['external_user_session_security_epochs', 'legacy_dml'], ['external_user_sessions', 'legacy_dml'], ['external_v1_session_revocations', 'legacy_dml'], diff --git a/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts index 6b5889116e..d66192750f 100644 --- a/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts +++ b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts @@ -107,7 +107,6 @@ const FRESH_TABLE_INDEXES = Object.freeze([ 'external_v1_session_revocations_expiry_idx', 'authorization_resource_revisions_updated_idx', 'operational_resource_source_idx', - 'operational_relationship_source_idx', 'operational_relationship_target_idx', 'operational_relationship_catalog_target_idx', 'operational_relationship_generation_idx', @@ -309,10 +308,10 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { it('classifies, creates, and reruns all PR1 indexes without replay', async () => { const firstVersions = await versions(databasePool) expect(PR1_ONLINE_INDEX_PLAN).toHaveLength(25) - expect(FRESH_TABLE_INDEXES).toHaveLength(14) + expect(FRESH_TABLE_INDEXES).toHaveLength(13) const allNames = [...PR1_ONLINE_INDEX_PLAN.map(index => index.name), ...FRESH_TABLE_INDEXES] - expect(new Set(allNames)).toHaveLength(39) + expect(new Set(allNames)).toHaveLength(38) const indexes = await databasePool.query<{ relname: string; indisvalid: boolean }>( `SELECT relation.relname, index.indisvalid FROM pg_class relation @@ -320,7 +319,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { WHERE relation.relname = ANY($1::text[])`, [allNames] ) - expect(indexes.rows).toHaveLength(39) + expect(indexes.rows).toHaveLength(38) expect(indexes.rows.every(row => row.indisvalid)).toBe(true) await initDb({ connect: () => databasePool.connect() }) diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index 23b81680a6..d4affe6a66 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -19,7 +19,6 @@ const FRESH_TABLE_INDEXES = Object.freeze([ 'external_v1_session_revocations_expiry_idx', 'authorization_resource_revisions_updated_idx', 'operational_resource_source_idx', - 'operational_relationship_source_idx', 'operational_relationship_target_idx', 'operational_relationship_catalog_target_idx', 'operational_relationship_generation_idx', @@ -133,7 +132,8 @@ describe('D34 migration execution policy', () => { // every index on a pre-existing table is built online, and only online. expect(inTransactionNames).toEqual([...FRESH_TABLE_INDEXES].sort()) expect(plannedNames).toHaveLength(25) - expect(new Set([...plannedNames, ...inTransactionNames])).toHaveLength(39) + // 25 online plus 13 fresh-table indexes (P-12 removed the duplicate source index). + expect(new Set([...plannedNames, ...inTransactionNames])).toHaveLength(38) // The planned definitions equal the in-transaction definitions S1d removed // (canonical form at 579ed2986), so an index built by either path is equivalent. diff --git a/deploy/scripts/control-api-runtime-access-profiles.tsv b/deploy/scripts/control-api-runtime-access-profiles.tsv index fd4b889b55..0aff9a5d11 100644 --- a/deploy/scripts/control-api-runtime-access-profiles.tsv +++ b/deploy/scripts/control-api-runtime-access-profiles.tsv @@ -2,10 +2,9 @@ administrative_events append agent_run_events append authorization_catalog_environment read -authorization_catalog_writer_components read -authorization_resource_revisions upsert -authorization_team_revisions upsert -authorization_user_revisions upsert +authorization_resource_revisions read +authorization_team_revisions read +authorization_user_revisions read entity_change_feed none entity_change_outbox none entity_change_watermark none diff --git a/deploy/scripts/workflow-recipes-runtime-access-profiles.tsv b/deploy/scripts/workflow-recipes-runtime-access-profiles.tsv index 462992746a..cb7ada1560 100644 --- a/deploy/scripts/workflow-recipes-runtime-access-profiles.tsv +++ b/deploy/scripts/workflow-recipes-runtime-access-profiles.tsv @@ -2,7 +2,6 @@ administrative_events none agent_run_events none authorization_catalog_environment none -authorization_catalog_writer_components none authorization_resource_revisions none authorization_team_revisions none authorization_user_revisions none diff --git a/scripts/tests/test-control-api-runtime-access-contract.sh b/scripts/tests/test-control-api-runtime-access-contract.sh index 8900a3ebb7..f7a493fafe 100755 --- a/scripts/tests/test-control-api-runtime-access-contract.sh +++ b/scripts/tests/test-control-api-runtime-access-contract.sh @@ -10,7 +10,7 @@ duplicate_count="$(awk -F '\t' '!/^[[:space:]]*(#|$)/ { seen[$1]++ } END { for ( invalid_count="$(awk -F '\t' '!/^[[:space:]]*(#|$)/ && (NF != 2 || $1 !~ /^[a-z][a-z0-9_]*$/ || $2 !~ /^(legacy_dml|upsert|append|read|link_lifecycle|insert_delete|none)$/) { count++ } END { print count + 0 }' "$PROFILE_FILE")" # The composed profile includes operational catalog readers and password-admission relations. -if [[ "$relation_count" != "115" || "$duplicate_count" != "0" || "$invalid_count" != "0" ]] || \ +if [[ "$relation_count" != "114" || "$duplicate_count" != "0" || "$invalid_count" != "0" ]] || \ ! grep -qx $'authorization_catalog_environment\tread' "$PROFILE_FILE" || \ ! grep -qx $'dynamic_clients\tlegacy_dml' "$PROFILE_FILE" || \ ! grep -qx $'entity_change_feed\tnone' "$PROFILE_FILE" || \ diff --git a/scripts/tests/test-gcp-scripts.sh b/scripts/tests/test-gcp-scripts.sh index 6264ef7532..12d122c1e2 100755 --- a/scripts/tests/test-gcp-scripts.sh +++ b/scripts/tests/test-gcp-scripts.sh @@ -1548,7 +1548,7 @@ assert_control_api_runtime_access_contract_is_exact() { sequence_duplicate_count="$(awk -F '\t' '!/^[[:space:]]*(#|$)/ { seen[$1]++ } END { for (name in seen) if (seen[name] > 1) count++ } END { print count + 0 }' "$sequence_profile_file")" sequence_invalid_count="$(awk -F '\t' '!/^[[:space:]]*(#|$)/ && (NF != 2 || $1 !~ /^[a-z][a-z0-9_]*$/ || $2 !~ /^(legacy_rw|consume|none)$/) { count++ } END { print count + 0 }' "$sequence_profile_file")" - if [[ "$relation_count" == "115" && "$duplicate_count" == "0" && "$invalid_count" == "0" ]] && \ + if [[ "$relation_count" == "114" && "$duplicate_count" == "0" && "$invalid_count" == "0" ]] && \ grep -qx $'dynamic_clients\tlegacy_dml' "$profile_file" && \ grep -qx $'entity_change_feed\tnone' "$profile_file" && \ grep -qx $'entity_change_outbox\tnone' "$profile_file" && \ From a68bbc84089f0498250483b2722df3d6fd5ff4fe Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 28 Oct 2026 23:20:14 +0100 Subject: [PATCH 381/528] feat(api): cover recipe and audit catalog revisions Co-Authored-By: GLM-5.3 (Codex) --- .../access/userAccessFoundationSchema.ts | 80 ++++++++++++- ...omponents.realPostgres.integration.test.ts | 107 ++++++++++++++++++ ...composableCatalogRevisionMigration.test.ts | 4 + ....realPostgresMigration.integration.test.ts | 1 + ...tionLocks.realPostgres.integration.test.ts | 3 + 5 files changed, 194 insertions(+), 1 deletion(-) diff --git a/control-api/src/services/access/userAccessFoundationSchema.ts b/control-api/src/services/access/userAccessFoundationSchema.ts index bce1bbdc9c..a49dce93e7 100644 --- a/control-api/src/services/access/userAccessFoundationSchema.ts +++ b/control-api/src/services/access/userAccessFoundationSchema.ts @@ -317,6 +317,64 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis END; $$; + CREATE OR REPLACE FUNCTION authorization_bump_workflow_run_audit_revision() + RETURNS TRIGGER + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = pg_catalog, public, pg_temp + AS $$ + DECLARE + row_value RECORD; + revision RECORD; + BEGIN + FOR row_value IN + SELECT triggerer_actor_type, triggerer_user_id::TEXT, + triggerer_team_id::TEXT, usage_team_id + FROM (VALUES ( + NEW.triggerer_actor_type, NEW.triggerer_user_id, + NEW.triggerer_team_id, NEW.usage_team_id + )) AS current_row( + triggerer_actor_type, triggerer_user_id, + triggerer_team_id, usage_team_id + ) + WHERE TG_OP <> 'DELETE' + UNION ALL + SELECT triggerer_actor_type, triggerer_user_id::TEXT, + triggerer_team_id::TEXT, usage_team_id + FROM (VALUES ( + OLD.triggerer_actor_type, OLD.triggerer_user_id, + OLD.triggerer_team_id, OLD.usage_team_id + )) AS prior_row( + triggerer_actor_type, triggerer_user_id, + triggerer_team_id, usage_team_id + ) + WHERE TG_OP <> 'INSERT' + LOOP + FOR revision IN + SELECT subject_type, subject_id + FROM (VALUES + ('team', row_value.triggerer_team_id), + ('team', CASE + WHEN row_value.usage_team_id = 'control-plane-admin-ui' THEN NULL + ELSE row_value.usage_team_id + END), + ('user', CASE + WHEN row_value.triggerer_actor_type = 'user' + THEN row_value.triggerer_user_id + END) + ) AS revision_subjects(subject_type, subject_id) + WHERE subject_id IS NOT NULL AND subject_id <> '' + ORDER BY subject_type, subject_id + LOOP + PERFORM authorization_bump_subject_revision( + revision.subject_type, revision.subject_id + ); + END LOOP; + END LOOP; + RETURN COALESCE(NEW, OLD); + END; + $$; + CREATE OR REPLACE FUNCTION authorization_bump_workflow_approval_revision() RETURNS TRIGGER LANGUAGE plpgsql @@ -468,6 +526,15 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis CREATE OR REPLACE TRIGGER workflow_approval_requests_authorization_revision AFTER INSERT OR UPDATE OR DELETE ON workflow_approval_requests FOR EACH ROW EXECUTE FUNCTION authorization_bump_workflow_approval_revision(); + CREATE OR REPLACE TRIGGER workflow_recipe_allowed_teams_authorization_revision + AFTER INSERT OR UPDATE OR DELETE ON workflow_recipe_allowed_teams + FOR EACH ROW EXECUTE FUNCTION authorization_bump_team_grant_revision(); + CREATE OR REPLACE TRIGGER workflow_approval_medium_accounts_authorization_revision + AFTER INSERT OR UPDATE OR DELETE ON workflow_approval_medium_accounts + FOR EACH ROW EXECUTE FUNCTION authorization_bump_user_grant_revision(); + CREATE OR REPLACE TRIGGER workflow_runs_audit_authorization_revision + AFTER INSERT OR UPDATE OR DELETE ON workflow_runs_audit + FOR EACH ROW EXECUTE FUNCTION authorization_bump_workflow_run_audit_revision(); CREATE OR REPLACE TRIGGER notification_deliveries_authorization_revision AFTER INSERT OR UPDATE OR DELETE ON notification_deliveries FOR EACH ROW EXECUTE FUNCTION authorization_bump_notification_revision(); @@ -487,6 +554,7 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis REVOKE ALL ON FUNCTION authorization_bump_user_row_revision() FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_team_row_revision() FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_workflow_run_revision() FROM PUBLIC; + REVOKE ALL ON FUNCTION authorization_bump_workflow_run_audit_revision() FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_workflow_approval_revision() FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_notification_revision() FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_gfs_resource_component(UUID) FROM PUBLIC; @@ -691,7 +759,9 @@ async function applyAuthorizationRevisionTriggers(db: DbClient): Promise { DECLARE trigger_spec RECORD; BEGIN FOR trigger_spec IN - SELECT * FROM (VALUES + -- The audit writer is installed by 0134 after this 0131 bootstrap. + -- Keep its repair spec here, but skip it until that function exists. + SELECT specs.* FROM (VALUES ('team_members', 'team_members_authorization_revision', 'authorization_bump_team_membership_revision'), ('user_contexts', 'user_contexts_authorization_revision', @@ -706,12 +776,20 @@ async function applyAuthorizationRevisionTriggers(db: DbClient): Promise { 'authorization_bump_team_grant_revision'), ('team_workflow_triggers', 'team_workflow_authorization_revision', 'authorization_bump_team_grant_revision'), + ('workflow_recipe_allowed_teams', 'workflow_recipe_allowed_teams_authorization_revision', + 'authorization_bump_team_grant_revision'), + ('workflow_approval_medium_accounts', + 'workflow_approval_medium_accounts_authorization_revision', + 'authorization_bump_user_grant_revision'), + ('workflow_runs_audit', 'workflow_runs_audit_authorization_revision', + 'authorization_bump_workflow_run_audit_revision'), ('operational_resource_index', 'operational_resource_authorization_revision', 'authorization_bump_operational_resource_revision'), ('operational_resource_relationships', 'operational_relationship_authorization_revision', 'authorization_bump_operational_relationship_revision') ) AS specs(table_name, trigger_name, function_name) + WHERE to_regprocedure(format('public.%I()', specs.function_name)) IS NOT NULL LOOP IF NOT EXISTS ( SELECT 1 FROM pg_trigger WHERE tgname = trigger_spec.trigger_name diff --git a/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts b/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts index 8783058f02..f62bd87db2 100644 --- a/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts +++ b/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts @@ -87,6 +87,14 @@ describeRealPostgres('composable catalog revisions on real PostgreSQL', () => { return Number(result.rows[0]?.revision ?? 1) } + async function teamRevision(teamId: string): Promise { + const result = await databasePool.query<{ revision: string }>( + `SELECT revision::text FROM authorization_team_revisions WHERE team_id = $1`, + [teamId] + ) + return Number(result.rows[0]?.revision ?? 1) + } + async function resourceRevision(resourceId: string): Promise { const result = await databasePool.query<{ revision: string }>( `SELECT revision::text @@ -268,6 +276,105 @@ describeRealPostgres('composable catalog revisions on real PostgreSQL', () => { expect(await userRevision(userIds[0])).toBeGreaterThan(revision) }) + it('bumps the team revision for allowed-team changes', async () => { + const recipeName = `p10-${randomUUID()}` + let revision = await teamRevision(teamIds[0]) + await databasePool.query( + `INSERT INTO workflow_recipe_allowed_teams(recipe_namespace, recipe_name, team_id) + VALUES ('sandbox-recipes', $1, $2)`, + [recipeName, teamIds[0]] + ) + expect(await teamRevision(teamIds[0])).toBeGreaterThan(revision) + + revision = await teamRevision(teamIds[0]) + await databasePool.query( + `DELETE FROM workflow_recipe_allowed_teams + WHERE recipe_namespace = 'sandbox-recipes' AND recipe_name = $1 AND team_id = $2`, + [recipeName, teamIds[0]] + ) + expect(await teamRevision(teamIds[0])).toBeGreaterThan(revision) + }) + + it('bumps the user revision for approval medium-account changes', async () => { + let revision = await userRevision(userIds[1]) + const account = await databasePool.query<{ id: string }>( + `INSERT INTO workflow_approval_medium_accounts( + user_id, medium, provider_user_id, verified_at + ) VALUES ($1, 'slack', $2, NOW()) + RETURNING id::text`, + [userIds[1], `provider-${randomUUID()}`] + ) + expect(await userRevision(userIds[1])).toBeGreaterThan(revision) + + revision = await userRevision(userIds[1]) + await databasePool.query(`DELETE FROM workflow_approval_medium_accounts WHERE id = $1`, [ + account.rows[0]!.id, + ]) + expect(await userRevision(userIds[1])).toBeGreaterThan(revision) + }) + + it('bumps ordered audit-run subjects and permits the Admin UI sentinel', async () => { + const runId = randomUUID() + let userBefore = await userRevision(userIds[0]) + let teamBefore = await teamRevision(teamIds[1]) + await databasePool.query( + `INSERT INTO workflow_runs_audit( + run_id, run_namespace, run_name, recipe_namespace, recipe_name, + triggerer_team_id, usage_team_id, triggerer_user_id, triggerer_actor_type, + trigger_source, idempotency_key, triggered_at, completed_at, + final_phase, step_count, snapshot_sha + ) VALUES ( + $1, 'revision', $2, 'sandbox-recipes', $2, + $3, NULL, $4, 'user', 'onDemand', $5, NOW(), NOW(), + 'Succeeded', 0, 'p15-audit-witness' + )`, + [runId, `audit-${randomUUID()}`, teamIds[1], userIds[0], `audit-${randomUUID()}`] + ) + expect(await userRevision(userIds[0])).toBeGreaterThan(userBefore) + expect(await teamRevision(teamIds[1])).toBeGreaterThan(teamBefore) + + userBefore = await userRevision(userIds[0]) + teamBefore = await teamRevision(teamIds[1]) + await databasePool.query(`DELETE FROM workflow_runs_audit WHERE run_id = $1`, [runId]) + expect(await userRevision(userIds[0])).toBeGreaterThan(userBefore) + expect(await teamRevision(teamIds[1])).toBeGreaterThan(teamBefore) + + const sentinelRunId = randomUUID() + userBefore = await userRevision(userIds[1]) + teamBefore = await teamRevision(teamIds[0]) + await databasePool.query( + `INSERT INTO workflow_runs_audit( + run_id, run_namespace, run_name, recipe_namespace, recipe_name, + triggerer_team_id, usage_team_id, triggerer_user_id, triggerer_actor_type, + trigger_source, idempotency_key, triggered_at, completed_at, + final_phase, step_count, snapshot_sha + ) VALUES ( + $1, 'revision', $2, 'sandbox-recipes', $2, + NULL, 'control-plane-admin-ui', NULL, 'scheduled', 'schedule', $3, + NOW(), NOW(), 'Succeeded', 0, 'p15-sentinel-witness' + )`, + [sentinelRunId, `audit-${randomUUID()}`, `audit-${randomUUID()}`] + ) + expect(await userRevision(userIds[1])).toBe(userBefore) + expect(await teamRevision(teamIds[0])).toBe(teamBefore) + + await expect( + databasePool.query( + `INSERT INTO workflow_runs_audit( + run_id, run_namespace, run_name, recipe_namespace, recipe_name, + triggerer_team_id, usage_team_id, triggerer_user_id, triggerer_actor_type, + trigger_source, idempotency_key, triggered_at, completed_at, + final_phase, step_count, snapshot_sha + ) VALUES ( + $1, 'revision', $2, 'sandbox-recipes', $2, + NULL, 'not-a-uuid', NULL, 'scheduled', 'schedule', $3, + NOW(), NOW(), 'Succeeded', 0, 'p15-invalid-witness' + )`, + [randomUUID(), `audit-${randomUUID()}`, `audit-${randomUUID()}`] + ) + ).rejects.toMatchObject({ code: 'P0001' }) + }) + it('moves GFS resource and subject components for the complete writer class', async () => { const firstResourceId = randomUUID() const secondResourceId = randomUUID() diff --git a/control-api/test/db.composableCatalogRevisionMigration.test.ts b/control-api/test/db.composableCatalogRevisionMigration.test.ts index 018f2504e6..2480df84d0 100644 --- a/control-api/test/db.composableCatalogRevisionMigration.test.ts +++ b/control-api/test/db.composableCatalogRevisionMigration.test.ts @@ -20,6 +20,7 @@ describe('0134 composable catalog revisions migration', () => { // real-PostgreSQL suite, not a production table. expect(sql).not.toContain('authorization_catalog_writer_components') expect(sql).toContain('authorization_bump_workflow_run_revision') + expect(sql).toContain('authorization_bump_workflow_run_audit_revision') expect(sql).toContain('authorization_bump_workflow_approval_revision') expect(sql).toContain('authorization_bump_notification_revision') expect(sql).toContain('authorization_bump_gfs_resource_revision') @@ -48,6 +49,9 @@ describe('0134 composable catalog revisions migration', () => { 'teams_authorization_revision', 'workflow_runs_authorization_revision', 'workflow_approval_requests_authorization_revision', + 'workflow_recipe_allowed_teams_authorization_revision', + 'workflow_approval_medium_accounts_authorization_revision', + 'workflow_runs_audit_authorization_revision', 'notification_deliveries_authorization_revision', 'gfs_grants_authorization_revision', 'gfs_shares_authorization_revision', diff --git a/control-api/test/db.realPostgresMigration.integration.test.ts b/control-api/test/db.realPostgresMigration.integration.test.ts index 755add6639..c21576fd36 100644 --- a/control-api/test/db.realPostgresMigration.integration.test.ts +++ b/control-api/test/db.realPostgresMigration.integration.test.ts @@ -49,6 +49,7 @@ const accessFoundationDefinerSignatures = [ 'authorization_bump_user_row_revision()', 'authorization_bump_team_row_revision()', 'authorization_bump_workflow_run_revision()', + 'authorization_bump_workflow_run_audit_revision()', 'authorization_bump_workflow_approval_revision()', 'authorization_bump_notification_revision()', 'authorization_bump_gfs_resource_component(pg_catalog.uuid)', diff --git a/control-api/test/pr1MigrationLocks.realPostgres.integration.test.ts b/control-api/test/pr1MigrationLocks.realPostgres.integration.test.ts index b386a1195f..4760667817 100644 --- a/control-api/test/pr1MigrationLocks.realPostgres.integration.test.ts +++ b/control-api/test/pr1MigrationLocks.realPostgres.integration.test.ts @@ -36,6 +36,9 @@ const ROW_REVISION_TRIGGER_TABLES = [ 'teams', 'workflow_runs', 'workflow_approval_requests', + 'workflow_recipe_allowed_teams', + 'workflow_approval_medium_accounts', + 'workflow_runs_audit', 'notification_deliveries', 'gfs_grants', 'gfs_shares', From a6e1f332641369b9b060dcbe4d5dbf6193a983f5 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 28 Oct 2026 23:24:30 +0100 Subject: [PATCH 382/528] fix(access): order revision writers and preserve Admin UI attribution Skip only the documented Admin UI usage-team sentinel in workflow run revisions. Acquire GFS resource revisions before subjects and sort every affected workflow, membership and batch grant/share subject before taking its revision lock. Cover the live Admin UI producer, pinned DEV upgrade, staged concurrent writers and revision liveness on PostgreSQL 16. Leave the D34 body pin for integration. Co-Authored-By: GPT-6.1 Sol (Codex) --- control-api/src/routes/gfs/grants.ts | 1 + control-api/src/routes/gfs/shares.ts | 1 + .../access/userAccessFoundationSchema.ts | 81 ++- ...ionPolicy.realPostgres.integration.test.ts | 25 + ...LockOrder.realPostgres.integration.test.ts | 563 ++++++++++++++++++ 5 files changed, 642 insertions(+), 29 deletions(-) create mode 100644 control-api/test/revisionWritersLockOrder.realPostgres.integration.test.ts diff --git a/control-api/src/routes/gfs/grants.ts b/control-api/src/routes/gfs/grants.ts index 56595d44d7..bfb32e54b3 100644 --- a/control-api/src/routes/gfs/grants.ts +++ b/control-api/src/routes/gfs/grants.ts @@ -837,6 +837,7 @@ export async function writeGfsGrantBatchInTransaction( (drive, resource_id, subject_type, subject_id, permissions, inherit, granted_by) SELECT $1, $2::uuid, input.subject_type, input.subject_id, $5::text[], $6, $7 FROM unnest($3::text[], $4::text[]) AS input(subject_type, subject_id) + ORDER BY input.subject_type, input.subject_id ON CONFLICT (drive, resource_id, subject_type, subject_id) DO UPDATE SET permissions = EXCLUDED.permissions, inherit = EXCLUDED.inherit, granted_by = EXCLUDED.granted_by, updated_at = now()`, diff --git a/control-api/src/routes/gfs/shares.ts b/control-api/src/routes/gfs/shares.ts index 73e45d244a..ccea464683 100644 --- a/control-api/src/routes/gfs/shares.ts +++ b/control-api/src/routes/gfs/shares.ts @@ -164,6 +164,7 @@ export async function writeGfsShareBatchInTransaction( (drive, resource_id, subject_type, subject_id, permissions, include_descendants, created_by) SELECT $1, $2::uuid, input.subject_type, input.subject_id, $5::text[], $6, $7 FROM unnest($3::text[], $4::text[]) AS input(subject_type, subject_id) + ORDER BY input.subject_type, input.subject_id ON CONFLICT (drive, resource_id, subject_type, subject_id) DO UPDATE SET permissions = EXCLUDED.permissions, include_descendants = EXCLUDED.include_descendants, diff --git a/control-api/src/services/access/userAccessFoundationSchema.ts b/control-api/src/services/access/userAccessFoundationSchema.ts index a49dce93e7..d56fcb1589 100644 --- a/control-api/src/services/access/userAccessFoundationSchema.ts +++ b/control-api/src/services/access/userAccessFoundationSchema.ts @@ -294,24 +294,34 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis SECURITY DEFINER SET search_path = pg_catalog, public, pg_temp AS $$ - DECLARE row_value RECORD; + DECLARE revision RECORD; BEGIN - FOR row_value IN - SELECT actor_type, actor_id::TEXT, team_id::TEXT, usage_team_id - FROM (VALUES (NEW.actor_type, NEW.actor_id, NEW.team_id, NEW.usage_team_id)) - AS current_row(actor_type, actor_id, team_id, usage_team_id) - WHERE TG_OP <> 'DELETE' - UNION ALL - SELECT actor_type, actor_id::TEXT, team_id::TEXT, usage_team_id - FROM (VALUES (OLD.actor_type, OLD.actor_id, OLD.team_id, OLD.usage_team_id)) - AS prior_row(actor_type, actor_id, team_id, usage_team_id) - WHERE TG_OP <> 'INSERT' + -- Order all affected subjects together, including prior attribution. + FOR revision IN + SELECT subject_type, subject_id + FROM ( + SELECT actor_type, actor_id::TEXT, team_id::TEXT, usage_team_id + FROM (VALUES (NEW.actor_type, NEW.actor_id, NEW.team_id, NEW.usage_team_id)) + AS current_row(actor_type, actor_id, team_id, usage_team_id) + WHERE TG_OP <> 'DELETE' + UNION ALL + SELECT actor_type, actor_id::TEXT, team_id::TEXT, usage_team_id + FROM (VALUES (OLD.actor_type, OLD.actor_id, OLD.team_id, OLD.usage_team_id)) + AS prior_row(actor_type, actor_id, team_id, usage_team_id) + WHERE TG_OP <> 'INSERT' + ) AS row_value + CROSS JOIN LATERAL (VALUES + ('team', row_value.team_id), + ('team', CASE + WHEN row_value.usage_team_id = 'control-plane-admin-ui' THEN NULL + ELSE row_value.usage_team_id + END), + ('user', CASE WHEN row_value.actor_type = 'user' THEN row_value.actor_id END) + ) AS subjects(subject_type, subject_id) + WHERE subject_id IS NOT NULL AND subject_id <> '' + ORDER BY subject_type, subject_id LOOP - IF row_value.actor_type = 'user' THEN - PERFORM authorization_bump_subject_revision('user', row_value.actor_id); - END IF; - PERFORM authorization_bump_subject_revision('team', row_value.team_id); - PERFORM authorization_bump_subject_revision('team', row_value.usage_team_id); + PERFORM authorization_bump_subject_revision(revision.subject_type, revision.subject_id); END LOOP; RETURN COALESCE(NEW, OLD); END; @@ -452,8 +462,8 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis AS $$ BEGIN IF TG_OP <> 'DELETE' THEN - PERFORM authorization_bump_subject_revision(NEW.subject_type, NEW.subject_id); PERFORM authorization_bump_gfs_resource_component(NEW.resource_id); + PERFORM authorization_bump_subject_revision(NEW.subject_type, NEW.subject_id); END IF; IF TG_OP = 'DELETE' OR ( TG_OP = 'UPDATE' AND ( @@ -462,8 +472,8 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis OR OLD.resource_id IS DISTINCT FROM NEW.resource_id ) ) THEN - PERFORM authorization_bump_subject_revision(OLD.subject_type, OLD.subject_id); PERFORM authorization_bump_gfs_resource_component(OLD.resource_id); + PERFORM authorization_bump_subject_revision(OLD.subject_type, OLD.subject_id); END IF; RETURN COALESCE(NEW, OLD); END; @@ -648,18 +658,31 @@ async function applyAuthorizationRevisionTriggers(db: DbClient): Promise { SECURITY DEFINER SET search_path = pg_catalog, public, pg_temp AS $$ + DECLARE revision RECORD; BEGIN - IF TG_OP <> 'DELETE' THEN - PERFORM authorization_bump_user_revision(NEW.user_id); - PERFORM authorization_bump_team_revision(NEW.team_id); - END IF; - IF TG_OP <> 'INSERT' AND ( - TG_OP = 'DELETE' OR OLD.user_id IS DISTINCT FROM NEW.user_id - OR OLD.team_id IS DISTINCT FROM NEW.team_id - ) THEN - PERFORM authorization_bump_user_revision(OLD.user_id); - PERFORM authorization_bump_team_revision(OLD.team_id); - END IF; + FOR revision IN + SELECT subject_type, subject_id + FROM ( + SELECT NEW.team_id AS team_id, NEW.user_id AS user_id + WHERE TG_OP <> 'DELETE' + UNION ALL + SELECT OLD.team_id, OLD.user_id + WHERE TG_OP <> 'INSERT' AND ( + TG_OP = 'DELETE' OR OLD.user_id IS DISTINCT FROM NEW.user_id + OR OLD.team_id IS DISTINCT FROM NEW.team_id + ) + ) AS row_value + CROSS JOIN LATERAL (VALUES + ('team', row_value.team_id), ('user', row_value.user_id) + ) AS subjects(subject_type, subject_id) + ORDER BY subject_type, subject_id + LOOP + IF revision.subject_type = 'team' THEN + PERFORM authorization_bump_team_revision(revision.subject_id); + ELSE + PERFORM authorization_bump_user_revision(revision.subject_id); + END IF; + END LOOP; RETURN COALESCE(NEW, OLD); END; $$; diff --git a/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts index d66192750f..48ce78faff 100644 --- a/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts +++ b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts @@ -669,6 +669,16 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { try { await runPinnedMigrationProducer(LATEST_DEV_RUNNER, isolatedUrl) + // A real pre-PR1 Admin UI row must remain writable after the upgrade. + const legacyAdminRun = await isolatedPool.query<{ run_id: string }>( + `INSERT INTO workflow_runs(recipe_namespace, recipe_name, phase, actor_type, actor_id, + trigger_source, usage_team_id) + VALUES ('sandbox-recipes', 'legacy-admin-revision', 'Pending', 'admin', $1, + 'onDemand', 'control-plane-admin-ui') RETURNING run_id::text`, + [randomUUID()] + ) + expect(legacyAdminRun.rowCount).toBe(1) + const latestDevReceipts = await versions(isolatedPool) expect(latestDevReceipts).toHaveLength(LATEST_DEV_RECEIPT_COUNT) expect(DEV_POST_0106_THROUGH_0129).toContain('0129_workflow_schedules_concurrency_policy') @@ -687,6 +697,21 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { initDb({ connect: () => isolatedPool.connect() }) ) const upgradedReceipts = await versions(isolatedPool) + expect( + ( + await isolatedPool.query( + "UPDATE workflow_runs SET phase = 'Running' WHERE run_id = $1 RETURNING run_id", + [legacyAdminRun.rows[0]!.run_id] + ) + ).rowCount + ).toBe(1) + expect( + ( + await isolatedPool.query('DELETE FROM workflow_runs WHERE run_id = $1 RETURNING run_id', [ + legacyAdminRun.rows[0]!.run_id, + ]) + ).rowCount + ).toBe(1) expect(upgradedReceipts).toHaveLength( LATEST_DEV_RECEIPT_COUNT + PR1_MIGRATION_VERSIONS.length + 1 ) diff --git a/control-api/test/revisionWritersLockOrder.realPostgres.integration.test.ts b/control-api/test/revisionWritersLockOrder.realPostgres.integration.test.ts new file mode 100644 index 0000000000..6514c3c609 --- /dev/null +++ b/control-api/test/revisionWritersLockOrder.realPostgres.integration.test.ts @@ -0,0 +1,563 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import type { Request } from 'express' +import { randomBytes, randomUUID } from 'node:crypto' +import { Pool, type PoolClient } from 'pg' +import { config } from '../src/config.js' +import { initDb, pool } from '../src/db.js' +import type { K8sGateway } from '../src/k8s.js' +import { type GfsSubject, writeGfsGrantBatchInTransaction } from '../src/routes/gfs/grants.js' +import { writeGfsShareBatchInTransaction } from '../src/routes/gfs/shares.js' +import { triggerWorkflow } from '../src/services/workflows/workflowTriggerService.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' + +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const describeRealPostgres = adminUrl ? describe : describe.skip +const barrierClass = 33_405 +const runtimeRoles = [ + 'control_api_runtime', + 'trace_maintenance_runtime', + 'workflow_recipes_runtime', +] + +type Outcome = { status: 'fulfilled' } | { status: 'rejected'; code: string } + +function observe(write: Promise): Promise { + return write.then( + () => ({ status: 'fulfilled' }), + error => ({ status: 'rejected', code: String(error.code) }) + ) +} + +describeRealPostgres('revision writers share one lock order on real PostgreSQL', () => { + const database = `control_api_revision_writers_${randomBytes(6).toString('hex')}` + let adminPool: Pool + let databasePool: Pool + + beforeAll(async () => { + adminPool = new Pool({ connectionString: adminUrl }) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + await adminPool.query(`CREATE DATABASE "${database}"`) + const url = new URL(adminUrl!) + url.pathname = `/${database}` + databasePool = new Pool({ connectionString: url.toString(), max: 12 }) + await initDb({ connect: () => databasePool.connect() }) + // Test-only AFTER triggers pause a writer only after the real revision + // upsert has acquired its row lock. No production bump function is replaced. + await databasePool.query(` + CREATE TABLE revision_writer_barriers ( + application_name TEXT NOT NULL, + revision_table TEXT NOT NULL, + subject_id TEXT NOT NULL, + gate INTEGER NOT NULL + ); + CREATE FUNCTION revision_writer_barrier() RETURNS TRIGGER LANGUAGE plpgsql AS $$ + DECLARE barrier RECORD; + BEGIN + FOR barrier IN + SELECT gate FROM revision_writer_barriers + WHERE application_name = current_setting('application_name') + AND revision_table = TG_TABLE_NAME + AND (subject_id = '' OR subject_id = COALESCE( + to_jsonb(NEW)->>'user_id', to_jsonb(NEW)->>'team_id', + to_jsonb(NEW)->>'resource_id')) + LOOP + PERFORM pg_advisory_xact_lock(${barrierClass}, barrier.gate); + END LOOP; + RETURN NEW; + END; + $$; + CREATE TRIGGER revision_writer_barrier AFTER INSERT OR UPDATE + ON authorization_user_revisions FOR EACH ROW EXECUTE FUNCTION revision_writer_barrier(); + CREATE TRIGGER revision_writer_barrier AFTER INSERT OR UPDATE + ON authorization_team_revisions FOR EACH ROW EXECUTE FUNCTION revision_writer_barrier(); + CREATE TRIGGER revision_writer_barrier AFTER INSERT OR UPDATE + ON authorization_resource_revisions FOR EACH ROW EXECUTE FUNCTION revision_writer_barrier(); + `) + }) + + afterAll(async () => { + try { + await endPoolAndWaitForClients(databasePool) + if (adminPool) { + await adminPool.query( + 'SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1', + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS "${database}"`) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + } + } finally { + await adminPool?.end() + } + }) + + async function fixture() { + const users = [randomUUID(), randomUUID()].sort() + const teams = [randomUUID(), randomUUID()].sort() + const resources = [randomUUID(), randomUUID()] + const drive = `revision-writers-${randomBytes(6).toString('hex')}` + for (const id of users) { + await databasePool.query( + "INSERT INTO users(id, email, name) VALUES ($1, $2, 'Revision writer')", + [id, `revision-${id}@example.test`] + ) + } + for (const id of teams) { + await databasePool.query('INSERT INTO teams(id, name) VALUES ($1, $2)', [id, `Team ${id}`]) + } + for (const [index, id] of resources.entries()) { + await databasePool.query( + "INSERT INTO gfs_resources(resource_id, drive, name, kind) VALUES ($1, $2, 'order', 'file')", + [id, index === 0 ? drive : `${drive}-b`] + ) + } + return { users, teams, resources, drive } + } + + async function revision(type: 'user' | 'team' | 'resource', id: string): Promise { + const result = await databasePool.query<{ revision: string }>( + `SELECT revision::text FROM authorization_${type}_revisions WHERE ${type}_id::text = $1`, + [id] + ) + expect(result.rows.length).toBeLessThanOrEqual(1) + return Number(result.rows[0]?.revision ?? 0) + } + + async function pid(client: PoolClient): Promise { + return (await client.query<{ pid: number }>('SELECT pg_backend_pid() AS pid')).rows[0]!.pid + } + + async function waitForBlock(blocked: number, blockers: number[]): Promise { + const deadline = Date.now() + 5_000 + while (Date.now() < deadline) { + const result = await databasePool.query<{ pids: number[] }>( + 'SELECT pg_blocking_pids($1::int) AS pids', + [blocked] + ) + const blocker = blockers.find(candidate => result.rows[0]!.pids.includes(candidate)) + if (blocker !== undefined) return blocker + await new Promise(resolve => setTimeout(resolve, 10)) + } + throw new Error(`writer ${blocked} did not block on one of ${blockers.join(', ')}`) + } + + async function begin(client: PoolClient, application: string) { + await client.query('BEGIN') + await client.query("SELECT set_config('application_name', $1, true)", [application]) + await client.query("SET LOCAL deadlock_timeout = '100ms'") + await client.query("SET LOCAL statement_timeout = '8s'") + } + + async function finish(client: PoolClient, outcome: Outcome) { + await client.query(outcome.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') + } + + async function barrier(application: string, table: string, id: string, gate: number) { + await databasePool.query('INSERT INTO revision_writer_barriers VALUES ($1, $2, $3, $4)', [ + application, + `authorization_${table}_revisions`, + id, + gate, + ]) + } + + async function stagedWriters( + firstWrite: (client: PoolClient) => Promise, + secondWrite: (client: PoolClient) => Promise, + secondGate = false + ) { + const gateOne = await databasePool.connect() + const gateTwo = await databasePool.connect() + const first = await databasePool.connect() + const second = await databasePool.connect() + const outcomes: Outcome[] = [] + try { + await gateOne.query('SELECT pg_advisory_lock($1, 1)', [barrierClass]) + await gateTwo.query('SELECT pg_advisory_lock($1, 2)', [barrierClass]) + const firstPid = await pid(first) + const secondPid = await pid(second) + const gateOnePid = await pid(gateOne) + const gateTwoPid = await pid(gateTwo) + await begin(first, 'revision-first') + await begin(second, 'revision-second') + const firstOutcome = observe(firstWrite(first)) + await waitForBlock(firstPid, [gateOnePid]) + const secondOutcome = observe(secondWrite(second)) + const secondBlocker = await waitForBlock( + secondPid, + secondGate ? [firstPid, gateTwoPid] : [firstPid] + ) + // Opposite first rows are directly observed through pg_blocking_pids; + // releasing both barriers then lets PostgreSQL detect the real 40P01. + await gateOne.query('SELECT pg_advisory_unlock($1, 1)', [barrierClass]) + if (secondBlocker === gateTwoPid) { + await gateTwo.query('SELECT pg_advisory_unlock($1, 2)', [barrierClass]) + } + const one = await firstOutcome + outcomes.push(one) + await finish(first, one) + if (secondGate && secondBlocker === firstPid) { + await waitForBlock(secondPid, [gateTwoPid]) + await gateTwo.query('SELECT pg_advisory_unlock($1, 2)', [barrierClass]) + } + const two = await secondOutcome + outcomes.push(two) + await finish(second, two) + } finally { + await gateOne.query('SELECT pg_advisory_unlock_all()').catch(() => undefined) + await gateTwo.query('SELECT pg_advisory_unlock_all()').catch(() => undefined) + await first.query('ROLLBACK').catch(() => undefined) + await second.query('ROLLBACK').catch(() => undefined) + for (const client of [gateOne, gateTwo, first, second]) client.release() + await databasePool.query('TRUNCATE revision_writer_barriers') + } + expect(outcomes).toEqual([{ status: 'fulfilled' }, { status: 'fulfilled' }]) + } + + async function grant(drive: string, resource: string, type: string, id: string) { + await databasePool.query( + `INSERT INTO gfs_grants(drive, resource_id, subject_type, subject_id, permissions) + VALUES ($1, $2, $3, $4, ARRAY['read'])`, + [drive, resource, type, id] + ) + } + + it.each( + ['gfs_grants', 'gfs_shares'].flatMap(table => + ['INSERT', 'UPDATE', 'DELETE'].map(operation => [table, operation] as const) + ) + )('takes the resource before the subject for %s %s', async (table, operation) => { + const { + users: [user], + resources: [resource], + drive, + } = await fixture() + // Keep a committed authority visible to the resource writer, including + // when the competing authority row is an uncommitted INSERT or DELETE. + await grant(drive, resource!, 'user', user!) + const authorityId = randomUUID() + if (operation !== 'INSERT') { + await databasePool.query( + `INSERT INTO ${table}(id, drive, resource_id, subject_type, subject_id, permissions) + VALUES ($1, $2, $3, 'user', $4, ARRAY['read']) + ON CONFLICT (drive, resource_id, subject_type, subject_id) DO UPDATE + SET id = EXCLUDED.id`, + [authorityId, drive, resource, user] + ) + } + // INSERT uses the same subject but another authority table to avoid an + // unrelated unique-key conflict with the committed witness above. + if (operation === 'INSERT' && table === 'gfs_grants') { + await databasePool.query( + `INSERT INTO gfs_shares(drive, resource_id, subject_type, subject_id, permissions) + VALUES ($1, $2, 'user', $3, ARRAY['read'])`, + [drive, resource, user] + ) + await databasePool.query('DELETE FROM gfs_grants WHERE resource_id = $1', [resource]) + } + const beforeUser = await revision('user', user!) + const beforeResource = await revision('resource', resource!) + await barrier('revision-first', 'resource', resource!, 1) + await stagedWriters( + client => + client.query('UPDATE gfs_resources SET name = name || $2 WHERE resource_id = $1', [ + resource, + '-resource', + ]), + client => + operation === 'INSERT' + ? client.query( + `INSERT INTO ${table}(drive, resource_id, subject_type, subject_id, permissions) + VALUES ($1, $2, 'user', $3, ARRAY['read'])`, + [drive, resource, user] + ) + : client.query( + operation === 'UPDATE' + ? `UPDATE ${table} SET permissions = ARRAY['read', 'write'] WHERE id = $1` + : `DELETE FROM ${table} WHERE id = $1`, + [authorityId] + ) + ) + expect(await revision('resource', resource!)).toBe(beforeResource + 2) + expect(await revision('user', user!)).toBe(beforeUser + 2) + }) + + it.each(['workflow', 'membership'] as const)( + 'takes teams before users for a %s write competing with a resource UPDATE', + async writer => { + const { + users: [user], + teams: [team], + resources: [resource], + drive, + } = await fixture() + await grant(drive, resource!, 'team', team!) + await grant(drive, resource!, 'user', user!) + let runId: string | undefined + if (writer === 'workflow') { + runId = ( + await databasePool.query<{ run_id: string }>( + `INSERT INTO workflow_runs(recipe_namespace, recipe_name, phase, actor_type, actor_id, + trigger_source, team_id) VALUES ('test', 'revision-order', 'Pending', 'user', $1, + 'onDemand', $2) RETURNING run_id::text`, + [user, team] + ) + ).rows[0]!.run_id + } else { + await databasePool.query( + "INSERT INTO team_members(team_id, user_id, role, status) VALUES ($1, $2, 'member', 'active')", + [team, user] + ) + } + const beforeUser = await revision('user', user!) + const beforeTeam = await revision('team', team!) + const beforeResource = await revision('resource', resource!) + await barrier('revision-first', 'team', team!, 1) + await stagedWriters( + client => + client.query('UPDATE gfs_resources SET name = name || $2 WHERE resource_id = $1', [ + resource, + '-resource', + ]), + client => + writer === 'workflow' + ? client.query("UPDATE workflow_runs SET phase = 'Running' WHERE run_id = $1", [runId]) + : client.query( + "UPDATE team_members SET role = 'admin' WHERE team_id = $1 AND user_id = $2", + [team, user] + ) + ) + const subjectDelta = writer === 'workflow' ? 3 : 2 + expect(await revision('user', user!)).toBe(beforeUser + subjectDelta) + expect(await revision('team', team!)).toBe(beforeTeam + subjectDelta) + expect(await revision('resource', resource!)).toBe(beforeResource + 1) + } + ) + + it.each(['workflow usage team', 'workflow attribution change', 'membership team change'])( + 'orders every affected team for a %s', + async writer => { + const { + users: [user], + teams, + resources: [resource], + drive, + } = await fixture() + for (const team of teams) await grant(drive, resource!, 'team', team) + await grant(drive, resource!, 'user', user!) + let runId: string | undefined + if (writer.startsWith('workflow')) { + runId = ( + await databasePool.query<{ run_id: string }>( + `INSERT INTO workflow_runs(recipe_namespace, recipe_name, phase, actor_type, actor_id, + trigger_source, team_id, usage_team_id) + VALUES ('test', 'team-order', 'Pending', 'user', $1, 'onDemand', $2, $3) + RETURNING run_id::text`, + [ + user, + writer === 'workflow usage team' ? teams[1] : teams[0], + writer === 'workflow usage team' ? teams[0] : null, + ] + ) + ).rows[0]!.run_id + } else { + await databasePool.query( + "INSERT INTO team_members(team_id, user_id, role, status) VALUES ($1, $2, 'member', 'active')", + [teams[0], user] + ) + } + const beforeTeams = await Promise.all(teams.map(team => revision('team', team))) + const beforeUser = await revision('user', user!) + const beforeResource = await revision('resource', resource!) + await barrier('revision-first', 'team', teams[0]!, 1) + await stagedWriters( + client => + client.query('UPDATE gfs_resources SET name = name || $2 WHERE resource_id = $1', [ + resource, + '-team-order', + ]), + client => + writer === 'workflow usage team' + ? client.query("UPDATE workflow_runs SET phase = 'Running' WHERE run_id = $1", [runId]) + : writer === 'workflow attribution change' + ? client.query( + "UPDATE workflow_runs SET team_id = $2, phase = 'Running' WHERE run_id = $1", + [runId, teams[1]] + ) + : client.query( + 'UPDATE team_members SET team_id = $3 WHERE team_id = $1 AND user_id = $2', + [teams[0], user, teams[1]] + ) + ) + for (const [index, team] of teams.entries()) { + expect(await revision('team', team)).toBe( + beforeTeams[index]! + (writer === 'workflow usage team' ? 3 : 2) + ) + } + expect(await revision('user', user!)).toBe(beforeUser + 3) + expect(await revision('resource', resource!)).toBe(beforeResource + 1) + } + ) + + for (const kind of ['grant', 'share'] as const) { + it.each(['user IDs', 'subject types'] as const)( + `orders ${kind} batch %s with opposite overlapping request orders`, + async subjectCase => { + const { + users, + teams: [team], + resources, + drive, + } = await fixture() + const subjects: GfsSubject[] = + subjectCase === 'user IDs' + ? users.map(id => ({ type: 'user', id })) + : [ + { type: 'team', id: team }, + { type: 'user', id: users[0] }, + ] + const before = await Promise.all( + subjects.map(subject => revision(subject.type as 'team' | 'user', subject.id!)) + ) + const beforeResources = await Promise.all(resources.map(id => revision('resource', id))) + for (const table of ['user', 'team']) { + await barrier('revision-first', table, '', 1) + await barrier('revision-second', table, '', 2) + } + const write = async (client: PoolClient, resource: string, targets: GfsSubject[]) => { + const params = { + req: { + ip: '127.0.0.1', + headers: {}, + adminAuth: { sub: users[0] }, + } as unknown as Request, + caller: { isOperator: true, actorKey: 'operator:', subjects: new Set(['operator:']) }, + drive: resource === resources[0] ? drive : `${drive}-b`, + resourceId: resource, + subjects: targets, + permissions: ['read'] as const, + } + const result = + kind === 'grant' + ? await writeGfsGrantBatchInTransaction(client, { ...params, inherit: false }) + : await writeGfsShareBatchInTransaction(client, { + ...params, + includeDescendants: false, + }) + expect(result.error).toBeNull() + } + await stagedWriters( + client => write(client, resources[0]!, [...subjects].reverse()), + client => write(client, resources[1]!, subjects), + true + ) + for (const [index, subject] of subjects.entries()) { + expect(await revision(subject.type as 'team' | 'user', subject.id!)).toBe( + before[index]! + 2 + ) + } + for (const [index, resource] of resources.entries()) { + expect(await revision('resource', resource)).toBe( + beforeResources[index]! + subjects.length + ) + } + const rows = await databasePool.query<{ count: string }>( + `SELECT count(*)::text FROM gfs_${kind === 'grant' ? 'grants' : 'shares'} + WHERE resource_id = ANY($1::uuid[])`, + [resources] + ) + expect(Number(rows.rows[0]!.count)).toBe(subjects.length * 2) + } + ) + } + + it('accepts exactly the Admin UI usage-team sentinel for INSERT, UPDATE and DELETE', async () => { + const adminId = randomUUID() + const query = vi.spyOn(pool, 'query').mockImplementation(databasePool.query.bind(databasePool)) + let runId: string + try { + const result = await triggerWorkflow({ + gateway: { + getResource: async () => ({ + metadata: { name: 'admin-revision', namespace: config.sandboxNamespace }, + spec: { triggers: { onDemand: { requiresApproval: false } } }, + }), + } as unknown as K8sGateway, + caller: { kind: 'admin-ui', userId: adminId }, + recipeNamespace: config.sandboxNamespace, + recipeName: 'admin-revision', + body: {}, + idempotencyKey: randomUUID(), + }) + expect(result.kind).toBe('run') + if (result.kind !== 'run') throw new Error('expected a real Admin UI run') + expect(result.created).toBe(true) + expect(result.row.usage_team_id).toBe('control-plane-admin-ui') + runId = result.row.run_id + expect( + (await databasePool.query('SELECT run_id FROM workflow_runs WHERE run_id = $1', [runId])) + .rowCount + ).toBe(1) + } finally { + query.mockRestore() + } + expect( + ( + await databasePool.query( + "UPDATE workflow_runs SET phase = 'Running' WHERE run_id = $1 RETURNING run_id", + [runId] + ) + ).rowCount + ).toBe(1) + expect( + ( + await databasePool.query('DELETE FROM workflow_runs WHERE run_id = $1 RETURNING run_id', [ + runId, + ]) + ).rowCount + ).toBe(1) + const { + users: [user], + teams: [team], + } = await fixture() + const beforeUser = await revision('user', user!) + const beforeTeam = await revision('team', team!) + expect( + ( + await databasePool.query( + `INSERT INTO workflow_runs(recipe_namespace, recipe_name, phase, actor_type, actor_id, + trigger_source, usage_team_id) VALUES ('test', 'uuid-revision', 'Pending', 'user', $1, + 'onDemand', $2) RETURNING run_id`, + [user, team] + ) + ).rowCount + ).toBe(1) + expect(await revision('user', user!)).toBe(beforeUser + 1) + expect(await revision('team', team!)).toBe(beforeTeam + 1) + for (const invalid of ['not-a-team', 'CONTROL-PLANE-ADMIN-UI', 'control-plane-admin-ui ']) { + await expect( + databasePool.query( + `INSERT INTO workflow_runs(recipe_namespace, recipe_name, phase, actor_type, actor_id, + trigger_source, usage_team_id) VALUES ('test', 'invalid-revision', 'Pending', 'admin', + $1, 'onDemand', $2)`, + [adminId, invalid] + ) + ).rejects.toMatchObject({ + code: 'P0001', + message: expect.stringContaining('invalid catalog team subject identifier'), + }) + } + // The sentinel exception is confined to usage_team_id; it is not a valid actor UUID. + for (const type of ['user', 'team']) { + await expect( + databasePool.query( + "SELECT authorization_bump_subject_revision($1, 'control-plane-admin-ui')", + [type] + ) + ).rejects.toMatchObject({ + code: 'P0001', + message: expect.stringContaining(`invalid catalog ${type} subject identifier`), + }) + } + }) +}) From 08d4d467debdbc3674b297bf9059c1191f4451f5 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 28 Oct 2026 23:20:32 +0100 Subject: [PATCH 383/528] fix(control-api): restore directory parity Co-Authored-By: GPT-6.1 Terra (Codex) --- .../src/routes/external/invitations.ts | 3 + control-api/src/routes/external/members.ts | 12 ++-- control-api/src/routes/external/teams.ts | 3 - .../src/services/directory/membership.ts | 34 +++-------- .../directoryAuthorizationPrivacy.test.ts | 61 +++++++++++++++++++ ...es.externalInvitations.unavailable.test.ts | 46 ++++++++------ .../test/routes.externalMembers.test.ts | 13 ++++ ...Authority.realPostgres.integration.test.ts | 38 ++++++++++++ 8 files changed, 155 insertions(+), 55 deletions(-) diff --git a/control-api/src/routes/external/invitations.ts b/control-api/src/routes/external/invitations.ts index 8f6b466120..9bb0d893d7 100644 --- a/control-api/src/routes/external/invitations.ts +++ b/control-api/src/routes/external/invitations.ts @@ -371,6 +371,9 @@ export function createExternalInvitationsRouter(): Router { if (accepted.error === 'expired') { return res.status(410).json({ error: 'expired' }) } + if (accepted.error === 'user_retired') { + return res.status(401).json({ error: 'Unauthorized' }) + } return res.status(400).json({ error: 'not_pending' }) } if ('replay' in accepted) { diff --git a/control-api/src/routes/external/members.ts b/control-api/src/routes/external/members.ts index 9606192850..02863ee536 100644 --- a/control-api/src/routes/external/members.ts +++ b/control-api/src/routes/external/members.ts @@ -179,6 +179,9 @@ export function createExternalMembersRouter(): Router { if (!managerUserId) return res.status(403).json({ error: 'Forbidden' }) const role = normalizeTeamRoleInput(req.body?.role) if (!role) return res.status(400).json({ error: 'invalid_role' }) + if (managerUserId.trim() === req.params.userId.trim()) { + return res.status(400).json({ error: 'invalid_target' }) + } const result = await updateManagedMemberRoleForUser( managerUserId, req.params.userId, @@ -189,9 +192,6 @@ export function createExternalMembersRouter(): Router { if ('error' in result) { if (result.error === 'forbidden') return res.status(403).json({ error: 'forbidden' }) if (result.error === 'not_found') return res.status(404).json({ error: 'not_found' }) - if (result.error === 'invalid_target') { - return res.status(400).json({ error: 'invalid_target' }) - } return res.status(400).json({ error: 'invalid_role' }) } return res.status(200).json(result.membership) @@ -249,6 +249,9 @@ export function createExternalMembersRouter(): Router { try { const managerUserId = currentUserId(req) if (!managerUserId) return res.status(403).json({ error: 'Forbidden' }) + if (managerUserId.trim() === req.params.userId.trim()) { + return res.status(400).json({ error: 'invalid_target' }) + } const result = await deleteManagedMemberForUser( managerUserId, req.params.userId, @@ -257,9 +260,6 @@ export function createExternalMembersRouter(): Router { ) if ('error' in result) { if (result.error === 'forbidden') return res.status(403).json({ error: 'forbidden' }) - if (result.error === 'invalid_target') { - return res.status(400).json({ error: 'invalid_target' }) - } return res.status(404).json({ error: 'not_found' }) } return res.status(200).json(result.deleted) diff --git a/control-api/src/routes/external/teams.ts b/control-api/src/routes/external/teams.ts index be87ec67d7..7b12897c17 100644 --- a/control-api/src/routes/external/teams.ts +++ b/control-api/src/routes/external/teams.ts @@ -278,9 +278,6 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { ) if ('error' in result) { if (result.error === 'forbidden') return res.status(403).json({ error: 'forbidden' }) - if (result.error === 'invalid_target') { - return res.status(400).json({ error: 'invalid_target' }) - } return res.status(404).json({ error: 'not_found' }) } return res.status(200).json(result.deleted) diff --git a/control-api/src/services/directory/membership.ts b/control-api/src/services/directory/membership.ts index d7e2262656..5404f1ad2d 100644 --- a/control-api/src/services/directory/membership.ts +++ b/control-api/src/services/directory/membership.ts @@ -13,6 +13,7 @@ import { type InvitationTeamRow, type InvitationUserRow, type InvitationWithTeams, + activateDraftInvitation, cleanupStaleDraftInvitations, ensureInvitationUser, getInvitationRecordById, @@ -100,7 +101,10 @@ async function loadInvitationTeamsByInvitationIds( return byInvitationId } -async function invitationListResponse(rows: InvitationRow[], managerUserId?: string) { +async function invitationListResponse( + rows: Array, + managerUserId?: string +) { const teamsByInvitationId = await loadInvitationTeamsByInvitationIds( pool, rows.map(row => row.id), @@ -114,6 +118,7 @@ async function invitationListResponse(rows: InvitationRow[], managerUserId?: str team_id: _hiddenPrimaryTeamId, team_name: _hiddenPrimaryTeamName, role: _hiddenPrimaryRole, + assignment_count: _internalAssignmentCount, ...safeManagerFields } = safeInvitation return safeManagerFields @@ -487,15 +492,7 @@ async function deliverAndActivateInvitation( } ) - const activated = await pool.query( - `UPDATE invitations - SET status = 'pending' - WHERE id = $1 - AND status = 'draft' - RETURNING id, team_id, invitee_name, email, role, token, status, purpose, created_at, expires_at, accepted_at, accepted_user_id`, - [inserted.invitation.id] - ) - const pending = activated.rows[0] as InvitationRow | undefined + const pending = await activateDraftInvitation(pool, inserted.invitation.id) if (!pending) { throw new Error('Failed to activate invitation after registration') } @@ -582,16 +579,7 @@ async function deliverAndActivateManagedInvitation( return { error: 'forbidden' as const } } - const activated = await db.query( - `UPDATE invitations - SET status = 'pending' - WHERE id = $1 - AND status = 'draft' - RETURNING id, team_id, invitee_name, email, role, token, status, purpose, created_at, - expires_at, accepted_at, accepted_user_id`, - [invitation.id] - ) - const pending = activated.rows[0] as InvitationRow | undefined + const pending = await activateDraftInvitation(db, invitation.id) if (!pending) throw new Error('Failed to activate authorized invitation') await finishInvitationDeliveryCommand(db, commandId, 'delivered') return invitationForTeamsResponse({ ...pending, team_name: invitation.team_name }, teams) @@ -1552,9 +1540,6 @@ export async function updateManagedMemberRoleForUser( role: TeamRole, authority?: ExternalSessionAuthorityContext ) { - if (managerUserId.trim() === targetUserId.trim()) { - return { error: 'invalid_target' as const } - } const normalizedRole = normalizeTeamRoleInput(role) if (!normalizedRole) { return { error: 'invalid_role' as const } @@ -1630,9 +1615,6 @@ export async function deleteManagedMemberForUser( teamId: string, authority?: ExternalSessionAuthorityContext ) { - if (managerUserId.trim() === targetUserId.trim()) { - return { error: 'invalid_target' as const } - } return withTransaction(async db => { if (!(await hasValidManagedMutationAuthority(db, managerUserId, authority))) { return { error: 'forbidden' as const } diff --git a/control-api/test/directoryAuthorizationPrivacy.test.ts b/control-api/test/directoryAuthorizationPrivacy.test.ts index e5ad955acf..971036fcd1 100644 --- a/control-api/test/directoryAuthorizationPrivacy.test.ts +++ b/control-api/test/directoryAuthorizationPrivacy.test.ts @@ -3,6 +3,7 @@ import bcrypt from 'bcryptjs' import { DirectorySearchCursorError, acceptInvitationForEmail, + createInvitationForTeams, createManagedInvitationForUser, deleteManagedMemberForUser, listManagedMembersForUser, @@ -162,6 +163,7 @@ describe('directory privacy and atomic authorization', () => { expect(result[0]).not.toHaveProperty('team_id') expect(result[0]).not.toHaveProperty('team_name') expect(result[0]).not.toHaveProperty('role') + expect(result[0]).not.toHaveProperty('assignment_count') expect(JSON.stringify(result)).not.toContain('synthetic-must-never-serialize') expect(result[0]).toMatchObject({ canCancel: false, canResend: false }) }) @@ -192,6 +194,30 @@ describe('directory privacy and atomic authorization', () => { expect(mocks.appendEvents).toHaveBeenCalledTimes(1) }) + it('allows a legacy caller to change their own role through the shared transaction', async () => { + mocks.query + .mockResolvedValueOnce({ rows: [{ user_id: MANAGER, role: 'admin' }], rowCount: 1 }) + .mockResolvedValueOnce({ + rows: [{ team_id: TEAM_A, user_id: MANAGER, role: 'inviter', status: 'active' }], + rowCount: 1, + }) + const result = await updateManagedMemberRoleForUser(MANAGER, MANAGER, TEAM_A, 'inviter') + expect(result).toMatchObject({ membership: { role: 'inviter', status: 'active' } }) + expect(mocks.withTransaction).toHaveBeenCalledOnce() + }) + + it('allows a legacy caller to delete their own membership through the shared transaction', async () => { + mocks.query + .mockResolvedValueOnce({ rows: [{ user_id: MANAGER, role: 'admin' }], rowCount: 1 }) + .mockResolvedValueOnce({ + rows: [{ team_id: TEAM_A, user_id: MANAGER, role: 'admin', status: 'deleted' }], + rowCount: 1, + }) + const result = await deleteManagedMemberForUser(MANAGER, MANAGER, TEAM_A) + expect(result).toMatchObject({ deleted: { status: 'deleted' } }) + expect(mocks.withTransaction).toHaveBeenCalledOnce() + }) + it('revalidates source authority before final role-mutation membership locks', async () => { mocks.query .mockResolvedValueOnce({ @@ -307,6 +333,7 @@ describe('directory privacy and atomic authorization', () => { } } if (sql.includes("SET status = 'pending'")) { + if (!sql.includes("AND status = 'draft'")) return { rows: [], rowCount: 0 } return { rows: [{ ...invitation, status: 'pending' }], rowCount: 1 } } throw new Error(`unexpected query: ${sql.slice(0, 40)}`) @@ -329,6 +356,40 @@ describe('directory privacy and atomic authorization', () => { expect(sql).not.toContain('INSERT INTO profiles') }) + it('activates a delivered generic invitation from the draft state', async () => { + const invitation = { + id: '00000000-0000-4000-8000-000000000200', + team_id: null, + invitee_name: 'Invitee', + email: 'invitee@example.test', + role: 'member', + token: MANAGER, + status: 'draft', + purpose: 'member_invitation', + ...activeInvitationWindow(), + accepted_at: null, + accepted_user_id: null, + team_name: null, + } + mocks.query.mockImplementation(async (sql: string) => { + if (sql.includes("WHERE status = 'draft'")) return { rows: [], rowCount: 0 } + if (sql.includes('WITH inserted AS')) return { rows: [invitation], rowCount: 1 } + if (sql.includes('FROM invitation_teams it')) return { rows: [], rowCount: 0 } + if (sql.includes("SET status = 'pending'")) { + if (!sql.includes("AND status = 'draft'")) return { rows: [], rowCount: 0 } + return { rows: [{ ...invitation, status: 'pending' }], rowCount: 1 } + } + throw new Error(`unexpected query: ${sql.slice(0, 40)}`) + }) + + const result = await createInvitationForTeams({ + inviteeName: 'Invitee', + email: invitation.email, + }) + expect(result).toMatchObject({ id: invitation.id, status: 'pending' }) + expect(mocks.registerInvitation).toHaveBeenCalledOnce() + }) + it('leaves a delivered draft unusable when manager authority changes before activation', async () => { const invitation = { id: '00000000-0000-4000-8000-000000000200', diff --git a/control-api/test/routes.externalInvitations.unavailable.test.ts b/control-api/test/routes.externalInvitations.unavailable.test.ts index 982d7e47e0..a3958bfaa1 100644 --- a/control-api/test/routes.externalInvitations.unavailable.test.ts +++ b/control-api/test/routes.externalInvitations.unavailable.test.ts @@ -171,28 +171,34 @@ describe('external invitation routes when the hub is unavailable', () => { expect(flow.validateInvitationFlowToken).toHaveBeenCalledTimes(2) }) - it('maps a genuine not_pending acceptance to 400 without starting a session', async () => { - flow.validateInvitationFlowToken.mockResolvedValue({ - email: 'a@b.c', - invitationUuid: 'database-capability', - }) - directory.acceptInvitationForEmailInTransaction.mockReset() - directory.acceptInvitationForEmailInTransaction.mockResolvedValue({ error: 'not_pending' }) + it.each([ + ['not_pending', 400, 'not_pending'], + ['user_retired', 401, 'Unauthorized'], + ])( + 'maps %s acceptance without starting a session', + async (error, expectedStatus, responseError) => { + flow.validateInvitationFlowToken.mockResolvedValue({ + email: 'a@b.c', + invitationUuid: 'database-capability', + }) + directory.acceptInvitationForEmailInTransaction.mockReset() + directory.acceptInvitationForEmailInTransaction.mockResolvedValue({ error }) - const res = await request(app()) - .post('/external/invitations/accept') - .send({ email: 'a@b.c', token: 'synthetic-not-pending-flow' }) + const res = await request(app()) + .post('/external/invitations/accept') + .send({ email: 'a@b.c', token: 'synthetic-not-pending-flow' }) - expect(res.status).toBe(400) - expect(res.body).toEqual({ error: 'not_pending' }) - expect(tokenSigner.sign).not.toHaveBeenCalled() - expect(database.query).not.toHaveBeenCalled() - expect(directory.acceptInvitationForEmailInTransaction).toHaveBeenCalledWith( - expect.anything(), - 'a@b.c', - 'database-capability' - ) - }) + expect(res.status).toBe(expectedStatus) + expect(res.body).toEqual({ error: responseError }) + expect(tokenSigner.sign).not.toHaveBeenCalled() + expect(database.query).not.toHaveBeenCalled() + expect(directory.acceptInvitationForEmailInTransaction).toHaveBeenCalledWith( + expect.anything(), + 'a@b.c', + 'database-capability' + ) + } + ) it('binds the secret capability to the public id and never issues on password setup', async () => { flow.validateInvitationFlowToken.mockResolvedValue({ diff --git a/control-api/test/routes.externalMembers.test.ts b/control-api/test/routes.externalMembers.test.ts index 2748d04042..92060fda6c 100644 --- a/control-api/test/routes.externalMembers.test.ts +++ b/control-api/test/routes.externalMembers.test.ts @@ -192,6 +192,19 @@ describe('external members routes', () => { expect(rateLimitMock.checkAndIncrement).not.toHaveBeenCalled() }) + it('rejects managed self-role changes and self-removal before directory work', async () => { + await request(makeApp()) + .patch('/external/members/manager-1/teams/team-1/role') + .send({ role: 'member' }) + .expect(400, { error: 'invalid_target' }) + expect(directoryMock.updateManagedMemberRoleForUser).not.toHaveBeenCalled() + + await request(makeApp()) + .delete('/external/members/manager-1/teams/team-1') + .expect(400, { error: 'invalid_target' }) + expect(directoryMock.deleteManagedMemberForUser).not.toHaveBeenCalled() + }) + it('creates invitations without an added user quota', async () => { directoryMock.createManagedInvitationForUser.mockResolvedValue({ invitation: { id: 'inv-parity' }, diff --git a/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts b/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts index 7930a64c2d..67d474fbce 100644 --- a/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts +++ b/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts @@ -474,6 +474,44 @@ describeRealPostgres('external team mutations final source authority on real Pos }) }) + it('allows a legacy admin to change their own team role', async () => { + await resetPrincipal('legacy-self-role') + await request(app()) + .patch(`/external/teams/${teamId}/members/${adminUserId}/role`) + .set('x-user-session-token', token) + .send({ role: 'inviter' }) + .expect(200) + const result = await databasePool.query<{ role: string }>( + `SELECT role FROM team_members WHERE team_id = $1 AND user_id = $2`, + [teamId, adminUserId] + ) + expect(result.rows[0]?.role).toBe('inviter') + }) + + it('allows a legacy admin to leave their own team', async () => { + await resetPrincipal('legacy-self-delete') + await request(app()) + .delete(`/external/teams/${teamId}/members/${adminUserId}`) + .set('x-user-session-token', token) + .expect(200) + const result = await databasePool.query<{ status: string }>( + `SELECT status FROM team_members WHERE team_id = $1 AND user_id = $2`, + [teamId, adminUserId] + ) + expect(result.rows[0]?.status).toBe('deleted') + }) + + it('activates a managed invitation after delivery', async () => { + await resetPrincipal('managed-invitation-activation') + await prepareInvitation() + const result = await databasePool.query<{ status: string }>( + `SELECT status FROM invitations WHERE id = $1`, + [invitationId] + ) + expect(invitationDelivery.registerAndSendInvitation).toHaveBeenCalledTimes(1) + expect(result.rows[0]?.status).toBe('pending') + }) + it('denies managed invitation activation when source-session invalidation wins during delivery', async () => { await resetPrincipal('managed-invitation-post-delivery-revoke') From 5292efe3742abf8c286ec808075d6bf7fac7bb61 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 28 Oct 2026 23:26:34 +0100 Subject: [PATCH 384/528] refactor(control-api): simplify session transaction interfaces Co-Authored-By: GPT-6.1 Terra (Codex) --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- control-api/src/db.ts | 14 ++--- control-api/src/gfs/externalAuthority.ts | 1 + .../src/middleware/externalSessionAuth.ts | 38 +------------ control-api/src/routes/external/auth.ts | 1 - .../src/routes/workflows/shared/auth.ts | 56 ------------------- .../src/services/auth/userSessionService.ts | 2 +- .../test/externalSessionAuthExports.test.ts | 14 +++++ ...nboarding.realPostgres.integration.test.ts | 8 +-- ...hangeRace.realPostgres.integration.test.ts | 8 +-- ...es.externalEntityChanges.rateLimit.test.ts | 1 - .../test/sessionDatabaseFailure.test.ts | 3 +- .../test/withTransaction.signature.test-d.ts | 7 +++ 14 files changed, 37 insertions(+), 122 deletions(-) create mode 100644 control-api/test/externalSessionAuthExports.test.ts create mode 100644 control-api/test/withTransaction.signature.test-d.ts diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 1a10e8e906..a6572777f3 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.848", + "version": "0.1.849", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.848", + "version": "0.1.849", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index a500d4cbff..b6c415cf15 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.848", + "version": "0.1.849", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/db.ts b/control-api/src/db.ts index 1aaadf90fc..76decd49b6 100644 --- a/control-api/src/db.ts +++ b/control-api/src/db.ts @@ -6950,17 +6950,11 @@ export async function assertDbReady(db: DbClient = pool): Promise { export async function withTransaction( work: (db: DbTransactionClient) => Promise, - txPoolOrOptions: Pool | DbTransactionOptions = pool, - explicitOptions: DbTransactionOptions = {} + // Injectable only so the carrier's client lifecycle can be unit-tested against + // a fake Pool; production always uses the module-level core pool. + txPool: Pool = pool, + options: DbTransactionOptions = {} ): Promise { - // Tests may inject a Pool to exercise client lifecycle behavior. Session - // persistence callers may provide a failure mapper so database errors retain - // typed provenance; cancellable callers use the same bounded pool with signal. - const isPool = typeof (txPoolOrOptions as Pool).connect === 'function' - const txPool = isPool ? (txPoolOrOptions as Pool) : pool - const options: DbTransactionOptions = isPool - ? explicitOptions - : (txPoolOrOptions as DbTransactionOptions) const signal = options.signal const mapDatabaseFailure = (error: unknown): unknown => options.onDatabaseFailure ? options.onDatabaseFailure(error) : error diff --git a/control-api/src/gfs/externalAuthority.ts b/control-api/src/gfs/externalAuthority.ts index 7a714a5b75..6835b7f179 100644 --- a/control-api/src/gfs/externalAuthority.ts +++ b/control-api/src/gfs/externalAuthority.ts @@ -121,6 +121,7 @@ export async function signCurrentExternalGfsUserToken( principalType: 'user', }) }, + undefined, { onDatabaseFailure: externalSessionDatabaseFailure } ) } catch (error) { diff --git a/control-api/src/middleware/externalSessionAuth.ts b/control-api/src/middleware/externalSessionAuth.ts index 56cc460822..fa05529c66 100644 --- a/control-api/src/middleware/externalSessionAuth.ts +++ b/control-api/src/middleware/externalSessionAuth.ts @@ -1,5 +1,4 @@ import { NextFunction, Request, Response } from 'express' -import { pool } from '../db.js' import { sendPublicApiError } from '../http/publicApiError.js' import { rootLogger } from '../observability/logger.js' import { AuthClaims, TeamRole } from '../profileTypes.js' @@ -16,11 +15,7 @@ import type { ExternalSessionIdentityAuthentication, ExternalSessionPurpose, } from '../services/auth/externalSessionAuthentication.js' -import { legacyExternalSessionAuthGeneration } from '../services/auth/legacyV1Generation.js' -import { - isExternalSessionBackendUnavailableError, - runExternalSessionDatabaseOperation, -} from '../services/auth/sessionDatabaseFailure.js' +import { isExternalSessionBackendUnavailableError } from '../services/auth/sessionDatabaseFailure.js' /** Retry-After sent when an external-session database operation is unavailable. */ export const SESSION_BACKEND_RETRY_AFTER_SECONDS = 2 @@ -65,37 +60,6 @@ export type ExternalAuthedRequest = Request & { } } -/** - * The session JWT is only a signed locator. The user row remains authoritative - * for lifecycle and generation, so retirement takes effect before the token's - * nominal expiry. A missing row and an inactive row intentionally share the - * same denial to avoid user enumeration. - */ -export async function isCurrentExternalSession(claims: AuthClaims): Promise { - const authGeneration = legacyExternalSessionAuthGeneration(claims) - if (authGeneration === null) return false - const result = await runExternalSessionDatabaseOperation(() => - pool.query( - `SELECT lifecycle_state, lifecycle_version - FROM users - WHERE id = $1 - LIMIT 1`, - [claims.userId] - ) - ) - const row = result.rows[0] as - | { lifecycle_state?: unknown; lifecycle_version?: unknown } - | undefined - if (row?.lifecycle_state !== 'active') return false - return Number(row.lifecycle_version) === authGeneration -} - -export async function assertCurrentExternalSession(claims: AuthClaims): Promise { - if (!(await isCurrentExternalSession(claims))) { - throw new Error('external session is inactive or stale') - } -} - function extractUserSessionToken(req: Request): string { return String(req.header('x-user-session-token') || '').trim() } diff --git a/control-api/src/routes/external/auth.ts b/control-api/src/routes/external/auth.ts index d179db6e18..9d83de627f 100644 --- a/control-api/src/routes/external/auth.ts +++ b/control-api/src/routes/external/auth.ts @@ -9,7 +9,6 @@ import { handleExternalSessionBackendFailure, requireExternalSessionRateLimitContext, } from '../../middleware/externalSessionAuth.js' -import { isCurrentExternalSession } from '../../middleware/externalSessionAuth.js' import { requireAuthenticatedExternalUserRateLimitContext } from '../../middleware/externalUserRateLimitPolicy.js' import { passwordLoginSourceAdmission, diff --git a/control-api/src/routes/workflows/shared/auth.ts b/control-api/src/routes/workflows/shared/auth.ts index c8235e6af4..f8e8d68270 100644 --- a/control-api/src/routes/workflows/shared/auth.ts +++ b/control-api/src/routes/workflows/shared/auth.ts @@ -1,7 +1,6 @@ import { NextFunction, Request, Response } from 'express' import type { ExternalAuthedRequest } from '../../../middleware/externalSessionAuth.js' import { authenticateAdminSession } from '../../../services/adminSessionAuth.js' -import { authenticateExternalUserSession } from '../../../services/auth/externalSessionAuthentication.js' import type { WorkflowCaller } from '../../../services/workflows/types.js' import { type McpHostControlScope, @@ -10,10 +9,6 @@ import { import { CONTROL_UI_ADMIN_SESSION_COOKIE, readCookie } from '../../../utils/auth/sessionCookies.js' import { extractBearerToken } from '../../../utils/extractBearerToken.js' -function getUserSessionToken(req: Request): string { - return String(req.header('x-user-session-token') || '').trim() -} - // The admin workflow routes bypass requireAuthForControlUI (they resolve a // multi-kind WorkflowCaller), so they must read the same control-ui credential // the rest of the UI uses. Since PR #649 the browser sends an HttpOnly session @@ -85,49 +80,6 @@ export function bindAdminWorkflowAuth(req: Request, res: Response, next: NextFun ) } -export async function requireExternalWorkflowCaller( - req: Request, - res: Response -): Promise | null> { - const userSessionToken = getUserSessionToken(req) - if (!userSessionToken || userSessionToken.length > 4096) return unauthorized(res) - - try { - const authentication = await authenticateExternalUserSession(userSessionToken, { - purpose: 'workflow_user', - client: { version: req.header('x-evenfire-client-version') || undefined }, - }) - if (authentication.status === 'upgrade_required') { - res.status(426).json({ error: 'upgrade_required' }) - return null - } - if (authentication.status !== 'authenticated') return unauthorized(res) - return { - kind: 'user-session', - claims: authentication.claims, - session: authentication.authorityContext, - } - } catch { - res.status(503).json({ error: 'authority_unavailable' }) - return null - } -} - -export async function requireExternalWorkflowCallerMiddleware( - req: ExternalWorkflowAuthedRequest, - res: Response, - next: NextFunction -): Promise { - try { - const caller = await requireExternalWorkflowCaller(req, res) - if (!caller) return - req.externalWorkflowCaller = caller - next() - } catch (error) { - next(error) - } -} - export function externalWorkflowCaller( req: Request ): ExternalWorkflowAuthedRequest['externalWorkflowCaller'] { @@ -145,14 +97,6 @@ export function requireBoundExternalWorkflowCaller(req: Request, res: Response) return caller } -export function bindExternalWorkflowAuth(req: Request, res: Response, next: NextFunction): void { - void requireExternalWorkflowCallerMiddleware( - req as ExternalWorkflowAuthedRequest, - res, - next - ).catch(next) -} - export function bindCompletedExternalWorkflowCaller( req: ExternalWorkflowAuthedRequest & ExternalAuthedRequest, res: Response, diff --git a/control-api/src/services/auth/userSessionService.ts b/control-api/src/services/auth/userSessionService.ts index 5c0f26ad3e..8a4b91768b 100644 --- a/control-api/src/services/auth/userSessionService.ts +++ b/control-api/src/services/auth/userSessionService.ts @@ -326,7 +326,7 @@ export async function validateUserSessionClaims( mode: 'read_write', onDatabaseFailure: options.onDatabaseFailure, }) - : withTransaction(work, { onDatabaseFailure: options.onDatabaseFailure }) + : withTransaction(work, pool, { onDatabaseFailure: options.onDatabaseFailure }) } export async function renewUserSession( diff --git a/control-api/test/externalSessionAuthExports.test.ts b/control-api/test/externalSessionAuthExports.test.ts new file mode 100644 index 0000000000..94d9bc1f18 --- /dev/null +++ b/control-api/test/externalSessionAuthExports.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import * as externalSessionAuth from '../src/middleware/externalSessionAuth.js' +import * as workflowAuth from '../src/routes/workflows/shared/auth.js' + +describe('external session authentication exports', () => { + it('does not expose the unused session and workflow auth adapters', () => { + expect(externalSessionAuth).not.toHaveProperty('isCurrentExternalSession') + expect(externalSessionAuth).not.toHaveProperty('assertCurrentExternalSession') + expect(workflowAuth).not.toHaveProperty('requireExternalWorkflowCaller') + expect(workflowAuth).not.toHaveProperty('requireExternalWorkflowCallerMiddleware') + expect(workflowAuth).not.toHaveProperty('bindExternalWorkflowAuth') + expect(workflowAuth.bindCompletedExternalWorkflowCaller).toBeTypeOf('function') + }) +}) diff --git a/control-api/test/invitationAcceptedOnboarding.realPostgres.integration.test.ts b/control-api/test/invitationAcceptedOnboarding.realPostgres.integration.test.ts index aa780252f5..9e0a505b7a 100644 --- a/control-api/test/invitationAcceptedOnboarding.realPostgres.integration.test.ts +++ b/control-api/test/invitationAcceptedOnboarding.realPostgres.integration.test.ts @@ -29,13 +29,9 @@ vi.mock('../src/db.js', async importOriginal => { } const withTransaction = ( work: (db: DbTransactionClient) => Promise, - txPoolOrOptions?: Pool | DbTransactionOptions, - explicitOptions: DbTransactionOptions = {} + _txPool?: Pool, + options: DbTransactionOptions = {} ): Promise => { - const options: DbTransactionOptions = - txPoolOrOptions && typeof (txPoolOrOptions as Pool).connect !== 'function' - ? (txPoolOrOptions as DbTransactionOptions) - : explicitOptions return actual.withTransaction(work, producerDatabase.pool!, options) } return { ...actual, pool, withTransaction } diff --git a/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts b/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts index 81935cace7..1b77481386 100644 --- a/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts +++ b/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts @@ -43,13 +43,9 @@ vi.mock('../src/db.js', async importOriginal => { } const withTransaction = ( work: (db: DbTransactionClient) => Promise, - txPoolOrOptions?: Pool | DbTransactionOptions, - explicitOptions: DbTransactionOptions = {} + _txPool?: Pool, + options: DbTransactionOptions = {} ): Promise => { - const options: DbTransactionOptions = - txPoolOrOptions && typeof (txPoolOrOptions as Pool).connect !== 'function' - ? (txPoolOrOptions as DbTransactionOptions) - : explicitOptions return actual.withTransaction(work, producerDatabase.pool!, options) } return { ...actual, pool, withTransaction } diff --git a/control-api/test/routes.externalEntityChanges.rateLimit.test.ts b/control-api/test/routes.externalEntityChanges.rateLimit.test.ts index c476805404..d711d81a88 100644 --- a/control-api/test/routes.externalEntityChanges.rateLimit.test.ts +++ b/control-api/test/routes.externalEntityChanges.rateLimit.test.ts @@ -21,7 +21,6 @@ vi.mock('../src/middleware/externalClientIdentity.js', () => ({ createExternalClientRateLimiters: () => [], })) vi.mock('../src/middleware/externalSessionAuth.js', () => ({ - isCurrentExternalSession: vi.fn(), requireValidExternalSessionToken: (_req: unknown, _res: unknown, next: () => void) => next(), })) vi.mock('../src/middleware/rateLimitMiddleware.js', () => ({ diff --git a/control-api/test/sessionDatabaseFailure.test.ts b/control-api/test/sessionDatabaseFailure.test.ts index 33c2a2a47e..f8875ee523 100644 --- a/control-api/test/sessionDatabaseFailure.test.ts +++ b/control-api/test/sessionDatabaseFailure.test.ts @@ -16,7 +16,7 @@ describe('external session database failure provenance', () => { const connect = vi.spyOn(pool, 'connect').mockRejectedValueOnce(acquireFailure) await expect( - withTransaction(async () => undefined, { + withTransaction(async () => undefined, pool, { onDatabaseFailure: externalSessionDatabaseFailure, }) ).rejects.toMatchObject({ @@ -38,6 +38,7 @@ describe('external session database failure provenance', () => { async () => { throw applicationFailure }, + pool, { onDatabaseFailure: externalSessionDatabaseFailure } ) ).rejects.toBe(applicationFailure) diff --git a/control-api/test/withTransaction.signature.test-d.ts b/control-api/test/withTransaction.signature.test-d.ts new file mode 100644 index 0000000000..1b1fe296b1 --- /dev/null +++ b/control-api/test/withTransaction.signature.test-d.ts @@ -0,0 +1,7 @@ +import { expectTypeOf, test } from 'vitest' +import type { Pool } from 'pg' +import { withTransaction } from '../src/db.js' + +test('withTransaction accepts a pool, rather than options, as its second argument', () => { + expectTypeOf[1]>().toEqualTypeOf() +}) From c2ae148b693bb951b77bbf6fe01c787e236012a8 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 28 Oct 2026 23:41:47 +0100 Subject: [PATCH 385/528] fix(control-api): validate the session once on workflow and access routes Workflow reads, workflow triggers, access capabilities and access resolve now use the single session boundary that every other protected route uses (requireValidExternalSessionTokenWithPublicErrors), as dev validates the session once per request. Their two-stage chain had no limiter between the stages after rate-limit parity, so the second stage only repeated the live session read. The boundary checks policy and the minimum-client gate before identity, so an outdated client gets 426 before 401 on these routes too. The access catalog keeps its staged chain because its limiter sits between the stages. Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- control-api/src/routes/external/access.ts | 9 +- .../routes/external/workflows/admission.ts | 16 ++-- ...s.externalAccess.rateLimitOrdering.test.ts | 88 ++++++++++++------- .../test/routes.externalWorkflows.test.ts | 3 +- 6 files changed, 71 insertions(+), 51 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index a6572777f3..4b869e4b9b 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.849", + "version": "0.1.850", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.849", + "version": "0.1.850", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index b6c415cf15..ac2758c793 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.849", + "version": "0.1.850", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/routes/external/access.ts b/control-api/src/routes/external/access.ts index 64827f85a9..03d6fdebe7 100644 --- a/control-api/src/routes/external/access.ts +++ b/control-api/src/routes/external/access.ts @@ -9,6 +9,7 @@ import { type ExternalAuthedRequest, requireCompletedExternalSessionAuthenticationWithPublicErrors, requireExternalSessionLimiterIdentityWithPublicErrors, + requireValidExternalSessionTokenWithPublicErrors, } from '../../middleware/externalSessionAuth.js' import { requireAuthenticatedExternalUserRateLimitContext } from '../../middleware/externalUserRateLimitPolicy.js' import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' @@ -149,9 +150,7 @@ export function createExternalAccessRouter(gateway: K8sGateway): Router { router.get( '/external/access/capabilities', - requireExternalSessionLimiterIdentityWithPublicErrors, - requireAuthenticatedExternalUserRateLimitContext, - requireCompletedExternalSessionAuthenticationWithPublicErrors, + requireValidExternalSessionTokenWithPublicErrors, asyncHandler(async (req: ExternalAuthedRequest, res) => { try { const policy = await resolveEffectiveUserAccessPolicy({ @@ -226,9 +225,7 @@ export function createExternalAccessRouter(gateway: K8sGateway): Router { router.post( '/external/access/resolve', - requireExternalSessionLimiterIdentityWithPublicErrors, - requireAuthenticatedExternalUserRateLimitContext, - requireCompletedExternalSessionAuthenticationWithPublicErrors, + requireValidExternalSessionTokenWithPublicErrors, asyncHandler((req, res, next) => requireEffectiveV2Contract('action', req, res, next)), asyncHandler(async (req: ExternalAuthedRequest, res) => { const body = req.body as Record | undefined diff --git a/control-api/src/routes/external/workflows/admission.ts b/control-api/src/routes/external/workflows/admission.ts index 787e564e69..875c515f07 100644 --- a/control-api/src/routes/external/workflows/admission.ts +++ b/control-api/src/routes/external/workflows/admission.ts @@ -1,17 +1,13 @@ import { attachAccessExecutionBudget } from '../../../middleware/accessExecutionBudget.js' -import { - requireCompletedExternalSessionAuthenticationWithPublicErrors, - requireExternalSessionLimiterIdentityWithPublicErrors, -} from '../../../middleware/externalSessionAuth.js' -import { requireAuthenticatedExternalUserRateLimitContext } from '../../../middleware/externalUserRateLimitPolicy.js' +import { requireValidExternalSessionTokenWithPublicErrors } from '../../../middleware/externalSessionAuth.js' import { bindCompletedExternalWorkflowCaller } from '../../workflows/shared/auth.js' import { workflowTriggerRateLimit } from '../../workflows/shared/rateLimit.js' +// One session validation per request, as in dev and on every other protected +// route: no limiter sits between identity and policy here. export const externalWorkflowReadAdmission = Object.freeze([ attachAccessExecutionBudget, - requireExternalSessionLimiterIdentityWithPublicErrors, - requireAuthenticatedExternalUserRateLimitContext, - requireCompletedExternalSessionAuthenticationWithPublicErrors, + requireValidExternalSessionTokenWithPublicErrors, bindCompletedExternalWorkflowCaller, ]) @@ -20,8 +16,6 @@ export const externalWorkflowReadAdmission = Object.freeze([ export const externalWorkflowTriggerAdmission = Object.freeze([ attachAccessExecutionBudget, workflowTriggerRateLimit(), - requireExternalSessionLimiterIdentityWithPublicErrors, - requireAuthenticatedExternalUserRateLimitContext, - requireCompletedExternalSessionAuthenticationWithPublicErrors, + requireValidExternalSessionTokenWithPublicErrors, bindCompletedExternalWorkflowCaller, ]) diff --git a/control-api/test/routes.externalAccess.rateLimitOrdering.test.ts b/control-api/test/routes.externalAccess.rateLimitOrdering.test.ts index 8163ec3aa7..a507c17a7d 100644 --- a/control-api/test/routes.externalAccess.rateLimitOrdering.test.ts +++ b/control-api/test/routes.externalAccess.rateLimitOrdering.test.ts @@ -141,12 +141,25 @@ describe('external access authenticated rate-limit ordering', () => { expect(mocks.resolvePolicy).toHaveBeenCalled() }) + it('rejects an invalid GET /external/access/catalog session before limiting or policy work', async () => { + const response = await request(app()) + .get('/external/access/catalog') + .set('x-user-session-token', 'invalid-session') + + expect(response.status).toBe(401) + expect(response.body).toMatchObject({ error: { code: 'invalid_session' } }) + expect(rateLimiter.checkAndIncrement).not.toHaveBeenCalled() + expect(mocks.resolvePolicy).not.toHaveBeenCalled() + }) + + // Capabilities and resolve use the single session boundary shared by every + // other protected route: policy and the minimum-client gate are read once, + // before identity, and no PG limiter is charged. it.each([ ['GET', '/external/access/capabilities'], - ['GET', '/external/access/catalog'], ['POST', '/external/access/resolve'], ] as const)( - 'rejects an invalid %s %s session before limiting or policy work', + 'rejects an invalid %s %s session through the single session boundary', async (method, path) => { const response = await request(app()) [method === 'GET' ? 'get' : 'post'](path) @@ -156,7 +169,7 @@ describe('external access authenticated rate-limit ordering', () => { expect(response.status).toBe(401) expect(response.body).toMatchObject({ error: { code: 'invalid_session' } }) expect(rateLimiter.checkAndIncrement).not.toHaveBeenCalled() - expect(mocks.resolvePolicy).not.toHaveBeenCalled() + expect(mocks.resolvePolicy).toHaveBeenCalledOnce() } ) @@ -249,39 +262,54 @@ describe('external access authenticated rate-limit ordering', () => { expect(mocks.resolveAuthorization).toHaveBeenCalledOnce() }) - it.each([ - ['GET', '/external/access/catalog'], - ['POST', '/external/access/resolve'], - ] as const)('rechecks current session authority for %s %s', async (method, path) => { - rateLimiter.checkAndIncrement.mockResolvedValue({ - allowed: true, - remaining: 9, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - backendAvailable: true, - count: 1, - }) - mocks.validateV2 - .mockResolvedValueOnce({ - status: 'valid', - identity: { - userId, - email: 'user@example.test', - sid: sessionId, - jti: sessionJti, - sessionVersion: 1, - }, - }) - .mockResolvedValueOnce({ status: 'revoked', reason: 'logout' }) + it('rejects a session revoked after one validation for POST /external/access/resolve', async () => { + mocks.validateV2.mockResolvedValueOnce({ status: 'revoked', reason: 'logout' }) const response = await request(app()) - [method === 'GET' ? 'get' : 'post'](path) + .post('/external/access/resolve') .set('x-user-session-token', sessionToken()) - .send(method === 'POST' ? {} : undefined) + .send({}) expect(response.status).toBe(401) expect(response.body).toMatchObject({ error: { code: 'invalid_session' } }) - expect(mocks.validateV2).toHaveBeenCalledTimes(2) + // One live session validation per request (P-7). + expect(mocks.validateV2).toHaveBeenCalledOnce() expect(mocks.resolveAuthorization).not.toHaveBeenCalled() }) + + it.each([['GET', '/external/access/catalog']] as const)( + 'rechecks current session authority for %s %s', + async (method, path) => { + rateLimiter.checkAndIncrement.mockResolvedValue({ + allowed: true, + remaining: 9, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + backendAvailable: true, + count: 1, + }) + mocks.validateV2 + .mockResolvedValueOnce({ + status: 'valid', + identity: { + userId, + email: 'user@example.test', + sid: sessionId, + jti: sessionJti, + sessionVersion: 1, + }, + }) + .mockResolvedValueOnce({ status: 'revoked', reason: 'logout' }) + + const response = await request(app()) + [method === 'GET' ? 'get' : 'post'](path) + .set('x-user-session-token', sessionToken()) + .send(method === 'POST' ? {} : undefined) + + expect(response.status).toBe(401) + expect(response.body).toMatchObject({ error: { code: 'invalid_session' } }) + expect(mocks.validateV2).toHaveBeenCalledTimes(2) + expect(mocks.resolveAuthorization).not.toHaveBeenCalled() + } + ) }) diff --git a/control-api/test/routes.externalWorkflows.test.ts b/control-api/test/routes.externalWorkflows.test.ts index b78f4e7f94..f640c4055e 100644 --- a/control-api/test/routes.externalWorkflows.test.ts +++ b/control-api/test/routes.externalWorkflows.test.ts @@ -349,7 +349,8 @@ describe('routes/external/workflows', () => { .get(path) .set('x-user-session-token', 'user-session-token') .expect(status) - expect(mockAuthenticateExternalUserSession).toHaveBeenCalledTimes(2) + // One live session validation per request, as in dev (P-7). + expect(mockAuthenticateExternalUserSession).toHaveBeenCalledOnce() expect(mockPoolQuery).toHaveBeenCalled() expect(mockRateLimitCheck).not.toHaveBeenCalled() }) From 2d3a546b680ee9f72c8e93106e96b540893d325a Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 28 Oct 2026 23:45:07 +0100 Subject: [PATCH 386/528] fix(access): order audit revision locks and restrict runtime writers Sort OLD and NEW audit subjects together to preserve the revision lock order. Revoke direct runtime execution of revision writers while retaining definer trigger writes. Add staged PostgreSQL deadlock and privilege witnesses. Co-Authored-By: GPT-6.1 Sol (Codex) --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../access/userAccessFoundationSchema.ts | 98 +++++++++---------- ....realPostgresMigration.integration.test.ts | 24 +++++ ...LockOrder.realPostgres.integration.test.ts | 61 ++++++++++++ 5 files changed, 137 insertions(+), 52 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 4b869e4b9b..a1cabbaf9c 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.850", + "version": "0.1.851", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.850", + "version": "0.1.851", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index ac2758c793..22ceb56b63 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.850", + "version": "0.1.851", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/access/userAccessFoundationSchema.ts b/control-api/src/services/access/userAccessFoundationSchema.ts index d56fcb1589..acb64ef289 100644 --- a/control-api/src/services/access/userAccessFoundationSchema.ts +++ b/control-api/src/services/access/userAccessFoundationSchema.ts @@ -333,53 +333,51 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis SECURITY DEFINER SET search_path = pg_catalog, public, pg_temp AS $$ - DECLARE - row_value RECORD; - revision RECORD; + DECLARE revision RECORD; BEGIN - FOR row_value IN - SELECT triggerer_actor_type, triggerer_user_id::TEXT, - triggerer_team_id::TEXT, usage_team_id - FROM (VALUES ( - NEW.triggerer_actor_type, NEW.triggerer_user_id, - NEW.triggerer_team_id, NEW.usage_team_id - )) AS current_row( - triggerer_actor_type, triggerer_user_id, - triggerer_team_id, usage_team_id - ) - WHERE TG_OP <> 'DELETE' - UNION ALL - SELECT triggerer_actor_type, triggerer_user_id::TEXT, - triggerer_team_id::TEXT, usage_team_id - FROM (VALUES ( - OLD.triggerer_actor_type, OLD.triggerer_user_id, - OLD.triggerer_team_id, OLD.usage_team_id - )) AS prior_row( - triggerer_actor_type, triggerer_user_id, - triggerer_team_id, usage_team_id - ) - WHERE TG_OP <> 'INSERT' + -- Order current and prior attribution together, as for live runs. + FOR revision IN + SELECT subject_type, subject_id + FROM ( + SELECT triggerer_actor_type, triggerer_user_id::TEXT, + triggerer_team_id::TEXT, usage_team_id + FROM (VALUES ( + NEW.triggerer_actor_type, NEW.triggerer_user_id, + NEW.triggerer_team_id, NEW.usage_team_id + )) AS current_row( + triggerer_actor_type, triggerer_user_id, + triggerer_team_id, usage_team_id + ) + WHERE TG_OP <> 'DELETE' + UNION ALL + SELECT triggerer_actor_type, triggerer_user_id::TEXT, + triggerer_team_id::TEXT, usage_team_id + FROM (VALUES ( + OLD.triggerer_actor_type, OLD.triggerer_user_id, + OLD.triggerer_team_id, OLD.usage_team_id + )) AS prior_row( + triggerer_actor_type, triggerer_user_id, + triggerer_team_id, usage_team_id + ) + WHERE TG_OP <> 'INSERT' + ) AS row_value + CROSS JOIN LATERAL (VALUES + ('team', row_value.triggerer_team_id), + ('team', CASE + WHEN row_value.usage_team_id = 'control-plane-admin-ui' THEN NULL + ELSE row_value.usage_team_id + END), + ('user', CASE + WHEN row_value.triggerer_actor_type = 'user' + THEN row_value.triggerer_user_id + END) + ) AS revision_subjects(subject_type, subject_id) + WHERE subject_id IS NOT NULL AND subject_id <> '' + ORDER BY subject_type, subject_id LOOP - FOR revision IN - SELECT subject_type, subject_id - FROM (VALUES - ('team', row_value.triggerer_team_id), - ('team', CASE - WHEN row_value.usage_team_id = 'control-plane-admin-ui' THEN NULL - ELSE row_value.usage_team_id - END), - ('user', CASE - WHEN row_value.triggerer_actor_type = 'user' - THEN row_value.triggerer_user_id - END) - ) AS revision_subjects(subject_type, subject_id) - WHERE subject_id IS NOT NULL AND subject_id <> '' - ORDER BY subject_type, subject_id - LOOP - PERFORM authorization_bump_subject_revision( - revision.subject_type, revision.subject_id - ); - END LOOP; + PERFORM authorization_bump_subject_revision( + revision.subject_type, revision.subject_id + ); END LOOP; RETURN COALESCE(NEW, OLD); END; @@ -643,10 +641,12 @@ async function applyAuthorizationRevisionFunctions(db: DbClient): Promise REVOKE ALL ON FUNCTION authorization_bump_user_revision(UUID) FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_team_revision(UUID) FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_resource_revision(TEXT, TEXT, TEXT) FROM PUBLIC; - GRANT EXECUTE ON FUNCTION authorization_bump_user_revision(UUID) TO control_api_runtime; - GRANT EXECUTE ON FUNCTION authorization_bump_team_revision(UUID) TO control_api_runtime; - GRANT EXECUTE ON FUNCTION authorization_bump_resource_revision(TEXT, TEXT, TEXT) - TO control_api_runtime; + -- Triggers call these as their definer; the runtime only reads revisions. + -- Explicit revocation also repairs grants retained by CREATE OR REPLACE. + REVOKE ALL ON FUNCTION authorization_bump_user_revision(UUID) FROM control_api_runtime; + REVOKE ALL ON FUNCTION authorization_bump_team_revision(UUID) FROM control_api_runtime; + REVOKE ALL ON FUNCTION authorization_bump_resource_revision(TEXT, TEXT, TEXT) + FROM control_api_runtime; `) } diff --git a/control-api/test/db.realPostgresMigration.integration.test.ts b/control-api/test/db.realPostgresMigration.integration.test.ts index c21576fd36..bf8e7fe716 100644 --- a/control-api/test/db.realPostgresMigration.integration.test.ts +++ b/control-api/test/db.realPostgresMigration.integration.test.ts @@ -871,6 +871,30 @@ describeRealPostgres('control-api real Postgres migrations', () => { }) await runtimeClient.query('ROLLBACK TO SAVEPOINT direct_revision_write') } + const directBumpResults: Array = [] + for (const statement of [ + 'SELECT authorization_bump_user_revision($1::uuid)', + 'SELECT authorization_bump_team_revision($1::uuid)', + "SELECT authorization_bump_resource_revision('synthetic-od6', 'gfs_resource', $1::text)", + ]) { + await runtimeClient.query('SAVEPOINT direct_revision_bump') + try { + await runtimeClient.query(statement, [teamId]) + directBumpResults.push('allowed') + } catch (error) { + directBumpResults.push((error as { code?: string }).code) + } + await runtimeClient.query('ROLLBACK TO SAVEPOINT direct_revision_bump') + } + expect(directBumpResults).toEqual(['42501', '42501', '42501']) + expect( + ( + await runtimeClient.query( + 'SELECT revision::text FROM authorization_team_revisions WHERE team_id = $1', + [teamId] + ) + ).rows + ).toEqual(bumped.rows) } finally { await runtimeClient.query('ROLLBACK') runtimeClient.release() diff --git a/control-api/test/revisionWritersLockOrder.realPostgres.integration.test.ts b/control-api/test/revisionWritersLockOrder.realPostgres.integration.test.ts index 6514c3c609..3a94fcd1ba 100644 --- a/control-api/test/revisionWritersLockOrder.realPostgres.integration.test.ts +++ b/control-api/test/revisionWritersLockOrder.realPostgres.integration.test.ts @@ -399,6 +399,67 @@ describeRealPostgres('revision writers share one lock order on real PostgreSQL', } ) + it.each(['removal', 'change'] as const)( + 'orders OLD and NEW audit subjects together for a team %s', + async operation => { + const { + users: [user], + teams, + resources: [resource], + drive, + } = await fixture() + const affectedTeams = operation === 'removal' ? teams.slice(0, 1) : teams + for (const team of affectedTeams) await grant(drive, resource!, 'team', team) + await grant(drive, resource!, 'user', user!) + const runId = randomUUID() + await databasePool.query( + `INSERT INTO workflow_runs_audit( + run_id, run_namespace, run_name, recipe_namespace, recipe_name, + triggerer_team_id, triggerer_user_id, triggerer_actor_type, + trigger_source, idempotency_key, triggered_at, completed_at, + final_phase, step_count, snapshot_sha + ) VALUES ( + $1, 'revision', $1::uuid::text, 'revision', 'audit-order', + $2, $3, 'user', 'onDemand', $1::uuid::text, NOW(), NOW(), + 'Succeeded', 0, 'audit-order-witness' + )`, + [runId, teams[0], user] + ) + const beforeTeams = await Promise.all(affectedTeams.map(team => revision('team', team))) + const beforeUser = await revision('user', user!) + const beforeResource = await revision('resource', resource!) + const nextTeam = operation === 'removal' ? null : teams[1] + await barrier('revision-first', 'team', teams[0]!, 1) + await stagedWriters( + client => + client.query('UPDATE gfs_resources SET name = name || $2 WHERE resource_id = $1', [ + resource, + '-audit-order', + ]), + // NULL is also the row image produced by the audit team's FK + // ON DELETE SET NULL action. Both images must share one sort. + client => + client.query('UPDATE workflow_runs_audit SET triggerer_team_id = $2 WHERE run_id = $1', [ + runId, + nextTeam, + ]) + ) + for (const [index, team] of affectedTeams.entries()) { + expect(await revision('team', team)).toBe(beforeTeams[index]! + 2) + } + expect(await revision('user', user!)).toBe(beforeUser + 3) + expect(await revision('resource', resource!)).toBe(beforeResource + 1) + expect( + ( + await databasePool.query( + 'SELECT triggerer_team_id FROM workflow_runs_audit WHERE run_id = $1', + [runId] + ) + ).rows + ).toEqual([{ triggerer_team_id: nextTeam }]) + } + ) + for (const kind of ['grant', 'share'] as const) { it.each(['user IDs', 'subject types'] as const)( `orders ${kind} batch %s with opposite overlapping request orders`, From a948749bfb637054c1c5d928ebacfc56452cc238 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 29 Oct 2026 00:01:18 +0100 Subject: [PATCH 387/528] fix(access): prelock archive batch revision subjects Acquire deduplicated team and user revisions in one order before the batch audit insert and live delete, preserving the existing archive transaction. Cover concurrent archive liveness, subject validation, and runtime grants. Co-Authored-By: GPT-6.1 Sol (Codex) --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../access/userAccessFoundationSchema.ts | 35 +- .../services/workflowRunsArchiveService.ts | 2 + ...LockOrder.realPostgres.integration.test.ts | 457 ++++++++++++++++++ ...composableCatalogRevisionMigration.test.ts | 7 + ....realPostgresMigration.integration.test.ts | 31 +- .../test/services.workflowRunsArchive.test.ts | 11 +- ...eContract.realPostgres.integration.test.ts | 30 ++ 9 files changed, 571 insertions(+), 8 deletions(-) create mode 100644 control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts diff --git a/control-api/package-lock.json b/control-api/package-lock.json index a1cabbaf9c..bf17526ad1 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.851", + "version": "0.1.852", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.851", + "version": "0.1.852", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 22ceb56b63..36c4dbac92 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.851", + "version": "0.1.852", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/access/userAccessFoundationSchema.ts b/control-api/src/services/access/userAccessFoundationSchema.ts index acb64ef289..b1ccbb6680 100644 --- a/control-api/src/services/access/userAccessFoundationSchema.ts +++ b/control-api/src/services/access/userAccessFoundationSchema.ts @@ -254,6 +254,36 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis END; $$; + CREATE OR REPLACE FUNCTION authorization_prelock_workflow_run_revisions(target_run_ids UUID[]) + RETURNS VOID + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = pg_catalog, public, pg_temp + AS $$ + DECLARE revision RECORD; + BEGIN + -- A batch must hold every team before any user, across both archive + -- INSERT and live DELETE triggers. Bumping also creates missing rows. + FOR revision IN + SELECT DISTINCT subject_type, subject_id + FROM workflow_runs AS run + CROSS JOIN LATERAL (VALUES + ('team', run.team_id::TEXT), + ('team', CASE + WHEN run.usage_team_id = 'control-plane-admin-ui' THEN NULL + ELSE run.usage_team_id + END), + ('user', CASE WHEN run.actor_type = 'user' THEN run.actor_id::TEXT END) + ) AS subjects(subject_type, subject_id) + WHERE run.run_id = ANY(target_run_ids) + AND subject_id IS NOT NULL AND subject_id <> '' + ORDER BY subject_type, subject_id + LOOP + PERFORM authorization_bump_subject_revision(revision.subject_type, revision.subject_id); + END LOOP; + END; + $$; + CREATE OR REPLACE FUNCTION authorization_bump_user_row_revision() RETURNS TRIGGER LANGUAGE plpgsql @@ -559,6 +589,9 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis REVOKE ALL ON TABLE authorization_catalog_environment FROM PUBLIC; GRANT SELECT ON TABLE authorization_catalog_environment TO control_api_runtime; REVOKE ALL ON FUNCTION authorization_bump_subject_revision(TEXT, TEXT) FROM PUBLIC; + REVOKE ALL ON FUNCTION authorization_prelock_workflow_run_revisions(UUID[]) FROM PUBLIC; + GRANT EXECUTE ON FUNCTION authorization_prelock_workflow_run_revisions(UUID[]) + TO control_api_runtime; REVOKE ALL ON FUNCTION authorization_bump_user_row_revision() FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_team_row_revision() FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_workflow_run_revision() FROM PUBLIC; @@ -641,7 +674,7 @@ async function applyAuthorizationRevisionFunctions(db: DbClient): Promise REVOKE ALL ON FUNCTION authorization_bump_user_revision(UUID) FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_team_revision(UUID) FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_resource_revision(TEXT, TEXT, TEXT) FROM PUBLIC; - -- Triggers call these as their definer; the runtime only reads revisions. + -- Triggers and scoped definer functions call these; runtime calls stay denied. -- Explicit revocation also repairs grants retained by CREATE OR REPLACE. REVOKE ALL ON FUNCTION authorization_bump_user_revision(UUID) FROM control_api_runtime; REVOKE ALL ON FUNCTION authorization_bump_team_revision(UUID) FROM control_api_runtime; diff --git a/control-api/src/services/workflowRunsArchiveService.ts b/control-api/src/services/workflowRunsArchiveService.ts index 82a12cbc85..9675f26a88 100644 --- a/control-api/src/services/workflowRunsArchiveService.ts +++ b/control-api/src/services/workflowRunsArchiveService.ts @@ -192,6 +192,8 @@ async function archiveOneBatch( const children = selected.rows as ArchivedChild[] const ids = children.map(r => r.run_id) + await client.query('SELECT authorization_prelock_workflow_run_revisions($1::uuid[])', [ids]) + // Copy runs → workflow_runs_audit. Field mapping documented at module top. await client.query( `INSERT INTO workflow_runs_audit ( diff --git a/control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts b/control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts new file mode 100644 index 0000000000..e8bcbbea83 --- /dev/null +++ b/control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts @@ -0,0 +1,457 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { randomBytes, randomUUID } from 'node:crypto' +import { Pool, type PoolClient } from 'pg' +import { initDb, pool } from '../src/db.js' +import type { K8sGateway } from '../src/k8s.js' +import { updateRunPhase } from '../src/services/workflowRunService.js' +import { archiveTerminalRuns } from '../src/services/workflowRunsArchiveService.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' + +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const describeRealPostgres = adminUrl ? describe : describe.skip +const barrierClass = 33_406 +const runtimeRoles = [ + 'control_api_runtime', + 'trace_maintenance_runtime', + 'workflow_recipes_runtime', +] +const gateway = { deleteResource: vi.fn() } as unknown as K8sGateway + +type Outcome = { status: 'fulfilled'; value: unknown } | { status: 'rejected'; code: string } + +function observe(work: Promise): Promise { + return work.then( + value => ({ status: 'fulfilled', value }), + error => ({ status: 'rejected', code: String(error.code) }) + ) +} + +describeRealPostgres('archive batches and phase updates share revision lock order', () => { + const database = `control_api_archive_order_${randomBytes(6).toString('hex')}` + let adminPool: Pool + let databasePool: Pool + + beforeAll(async () => { + adminPool = new Pool({ connectionString: adminUrl }) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + await adminPool.query(`CREATE DATABASE "${database}"`) + const url = new URL(adminUrl!) + url.pathname = `/${database}` + databasePool = new Pool({ connectionString: url.toString(), max: 12 }) + await initDb({ connect: () => databasePool.connect() }) + // Only a test AFTER trigger pauses after a real revision upsert has + // acquired its lock. Production SQL and bump functions run unchanged. + await databasePool.query(` + CREATE TABLE archive_order_barriers ( + application_name TEXT NOT NULL, + revision_table TEXT NOT NULL, + subject_id TEXT NOT NULL, + gate INTEGER NOT NULL + ); + CREATE FUNCTION archive_order_barrier() RETURNS TRIGGER LANGUAGE plpgsql AS $$ + DECLARE barrier RECORD; + BEGIN + FOR barrier IN + SELECT gate FROM archive_order_barriers + WHERE application_name = current_setting('application_name') + AND revision_table = TG_TABLE_NAME + AND (subject_id = '' OR subject_id = COALESCE( + to_jsonb(NEW)->>'user_id', to_jsonb(NEW)->>'team_id')) + LOOP + PERFORM pg_advisory_xact_lock(${barrierClass}, barrier.gate); + END LOOP; + RETURN NEW; + END; + $$; + CREATE TRIGGER archive_order_barrier AFTER INSERT OR UPDATE + ON authorization_user_revisions FOR EACH ROW EXECUTE FUNCTION archive_order_barrier(); + CREATE TRIGGER archive_order_barrier AFTER INSERT OR UPDATE + ON authorization_team_revisions FOR EACH ROW EXECUTE FUNCTION archive_order_barrier(); + `) + }) + + afterAll(async () => { + try { + await endPoolAndWaitForClients(databasePool) + if (adminPool) { + await adminPool.query( + 'SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1', + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS "${database}"`) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + } + } finally { + await adminPool?.end() + } + }) + + async function fixture() { + const users = [randomUUID(), randomUUID()] + const teams = [randomUUID(), randomUUID()] + const archived = [randomUUID(), randomUUID()] + const live = [randomUUID(), randomUUID()] + for (const user of users) { + await databasePool.query( + "INSERT INTO users(id, email, name) VALUES ($1, $2, 'Archive order')", + [user, `archive-${user}@example.test`] + ) + } + for (const team of teams) { + await databasePool.query('INSERT INTO teams(id, name) VALUES ($1, $2)', [ + team, + `Archive team ${team}`, + ]) + } + for (const index of [0, 1]) { + await databasePool.query( + ` + INSERT INTO workflow_runs(run_id, recipe_namespace, recipe_name, phase, actor_type, actor_id, + team_id, trigger_source, completed_at, ttl_seconds_after_finished) + VALUES ($1, 'revision', 'archive-order', 'Succeeded', 'user', $2, $3, 'onDemand', + NOW() - interval '2 hours', 0) + `, + [archived[index], users[index], teams[index]] + ) + await databasePool.query( + ` + INSERT INTO workflow_run_steps(run_id, step_id, phase) + VALUES ($1, 'archive-step', 'Succeeded') + `, + [archived[index]] + ) + // Whichever old row is visited first, a live run shares its user and + // the other old row's team. The test observes, rather than assumes, order. + await databasePool.query( + ` + INSERT INTO workflow_runs(run_id, recipe_namespace, recipe_name, phase, actor_type, actor_id, + team_id, trigger_source) + VALUES ($1, 'revision', 'live-order', 'Pending', 'user', $2, $3, 'onDemand') + `, + [live[index], users[index], teams[1 - index]] + ) + } + return { users, teams, archived, live } + } + + async function revisions(ids: { users: string[]; teams: string[] }) { + const user = await databasePool.query( + 'SELECT user_id::text AS id, revision::int FROM authorization_user_revisions WHERE user_id = ANY($1::uuid[]) ORDER BY user_id', + [ids.users] + ) + const team = await databasePool.query( + 'SELECT team_id::text AS id, revision::int FROM authorization_team_revisions WHERE team_id = ANY($1::uuid[]) ORDER BY team_id', + [ids.teams] + ) + return { user: user.rows, team: team.rows } + } + + async function pid(client: PoolClient): Promise { + return (await client.query('SELECT pg_backend_pid() AS pid')).rows[0].pid + } + + async function waitForBlock(blocked: number, blockers: number[]) { + const deadline = Date.now() + 5_000 + while (Date.now() < deadline) { + const result = await databasePool.query('SELECT pg_blocking_pids($1::int) AS pids', [blocked]) + const blocker = blockers.find(candidate => result.rows[0].pids.includes(candidate)) + if (blocker !== undefined) { + console.info('ARCHIVE_ORDER_BLOCK', { blocked, blocker, observed: result.rows[0].pids }) + return blocker + } + await new Promise(resolve => setTimeout(resolve, 10)) + } + throw new Error(`writer ${blocked} did not block on one of ${blockers.join(', ')}`) + } + + async function configure(client: PoolClient, application: string) { + await client.query("SELECT set_config('application_name', $1, false)", [application]) + await client.query("SET deadlock_timeout = '100ms'") + await client.query("SET statement_timeout = '8s'") + } + + async function archivedWitness(ids: { archived: string[] }) { + expect( + ( + await databasePool.query( + 'SELECT run_id FROM workflow_runs WHERE run_id = ANY($1::uuid[])', + [ids.archived] + ) + ).rows + ).toHaveLength(0) + expect( + ( + await databasePool.query( + 'SELECT run_id FROM workflow_run_steps WHERE run_id = ANY($1::uuid[])', + [ids.archived] + ) + ).rows + ).toHaveLength(0) + expect( + ( + await databasePool.query( + 'SELECT run_id FROM workflow_runs_audit WHERE run_id = ANY($1::uuid[])', + [ids.archived] + ) + ).rows + ).toHaveLength(2) + expect( + ( + await databasePool.query( + 'SELECT run_id FROM workflow_run_step_audit WHERE run_id = ANY($1::uuid[])', + [ids.archived] + ) + ).rows + ).toHaveLength(2) + } + + it('archives two rows and advances revisions with a phase-update liveness witness', async () => { + const ids = await fixture() + const before = await revisions(ids) + const connect = vi.spyOn(pool, 'connect').mockImplementation(() => databasePool.connect()) + try { + expect(await archiveTerminalRuns(gateway, { batchSize: 500, maxBatches: 1 })).toBe(2) + await archivedWitness(ids) + expect((await updateRunPhase(ids.live[0]!, 'Running', databasePool))?.phase).toBe('Running') + const after = await revisions(ids) + for (const kind of ['user', 'team'] as const) { + expect(after[kind]).toHaveLength(2) + for (const [index, row] of after[kind].entries()) { + const updated = kind === 'user' ? ids.users[0] : ids.teams[1] + expect(row.revision).toBe(before[kind][index].revision + (row.id === updated ? 5 : 3)) + } + } + } finally { + connect.mockRestore() + } + }) + + it('commits an archive batch beside updateRunPhase without reversing subject locks', async () => { + const ids = await fixture() + const before = await revisions(ids) + const archiveGate = await databasePool.connect() + const updateGate = await databasePool.connect() + const archive = await databasePool.connect() + const update = await databasePool.connect() + const archivePid = await pid(archive) + const updatePid = await pid(update) + const archiveGatePid = await pid(archiveGate) + const updateGatePid = await pid(updateGate) + let archiveOutcome: Promise | undefined + let updateOutcome: Promise | undefined + let archiveStarted = false + let firstIndex = -1 + let outcomes: Outcome[] = [] + // The connection boundary binds the actual archive service to this real + // database. Neither archiveOneBatch nor updateRunPhase SQL is replaced. + const connect = vi.spyOn(pool, 'connect').mockResolvedValue(archive) + try { + await configure(archive, 'archive-order-archive') + await configure(update, 'archive-order-phase') + await archiveGate.query('SELECT pg_advisory_lock($1, 1), pg_advisory_lock($1, 2)', [ + barrierClass, + ]) + await updateGate.query('SELECT pg_advisory_lock($1, 3)', [barrierClass]) + await databasePool.query( + ` + INSERT INTO archive_order_barriers VALUES + ('archive-order-archive', 'authorization_user_revisions', $1, 1), + ('archive-order-archive', 'authorization_user_revisions', $2, 2), + ('archive-order-phase', 'authorization_team_revisions', '', 3) + `, + ids.users + ) + archiveStarted = true + archiveOutcome = observe(archiveTerminalRuns(gateway, { batchSize: 500, maxBatches: 1 })) + await waitForBlock(archivePid, [archiveGatePid]) + const waiting = await databasePool.query( + ` + SELECT objid::int AS gate FROM pg_locks + WHERE pid = $1 AND locktype = 'advisory' AND NOT granted AND classid = $2::oid + `, + [archivePid, barrierClass] + ) + expect(waiting.rows).toHaveLength(1) + expect([1, 2]).toContain(waiting.rows[0].gate) + firstIndex = waiting.rows[0].gate - 1 + console.info('ARCHIVE_ORDER_FIRST_USER', { + index: firstIndex, + user: ids.users[firstIndex], + team: ids.teams[firstIndex], + }) + await update.query('BEGIN') + updateOutcome = observe(updateRunPhase(ids.live[firstIndex]!, 'Running', update)) + const phaseBlocker = await waitForBlock(updatePid, [updateGatePid, archivePid]) + if (phaseBlocker === updateGatePid) { + // Without the prelock, the phase writer holds the other team's + // revision before waiting on the archive's first user: a cycle. + await updateGate.query('SELECT pg_advisory_unlock_all()') + await waitForBlock(updatePid, [archivePid]) + await archiveGate.query('SELECT pg_advisory_unlock_all()') + } else { + // With the prelock, the archive already holds both teams before + // reaching its first user. The phase writer waits at its first team. + await archiveGate.query('SELECT pg_advisory_unlock_all()') + await waitForBlock(updatePid, [updateGatePid]) + await updateGate.query('SELECT pg_advisory_unlock_all()') + } + const phaseResult = await updateOutcome + await update.query(phaseResult.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') + outcomes = [await archiveOutcome, phaseResult] + console.info( + 'ARCHIVE_ORDER_OUTCOMES', + outcomes.map(result => ({ + status: result.status, + code: result.status === 'rejected' ? result.code : undefined, + })) + ) + } finally { + await archiveGate.query('SELECT pg_advisory_unlock_all()') + await updateGate.query('SELECT pg_advisory_unlock_all()') + await updateOutcome + await update.query('ROLLBACK') + await archiveOutcome + connect.mockRestore() + for (const client of [archiveGate, updateGate, update]) client.release() + if (!archiveStarted) archive.release() + await databasePool.query('TRUNCATE archive_order_barriers') + } + expect(outcomes.map(result => result.status)).toEqual(['fulfilled', 'fulfilled']) + expect(outcomes[0]).toEqual({ status: 'fulfilled', value: 2 }) + await archivedWitness(ids) + const after = await revisions(ids) + for (const kind of ['user', 'team'] as const) { + for (const [index, row] of after[kind].entries()) { + const updated = kind === 'user' ? ids.users[firstIndex] : ids.teams[1 - firstIndex] + expect(row.revision).toBe(before[kind][index].revision + (row.id === updated ? 5 : 3)) + } + } + expect( + ( + await databasePool.query('SELECT phase FROM workflow_runs WHERE run_id = $1', [ + ids.live[firstIndex], + ]) + ).rows + ).toEqual([{ phase: 'Running' }]) + }) + + it('creates missing revisions once per subject in team-then-user order', async () => { + const ids = await fixture() + const usageTeam = randomUUID() + const adminActor = randomUUID() + await databasePool.query("INSERT INTO teams(id, name) VALUES ($1, 'Usage team')", [usageTeam]) + await databasePool.query('INSERT INTO users(id, email) VALUES ($1, $2)', [ + adminActor, + `${adminActor}@example.test`, + ]) + await databasePool.query('UPDATE workflow_runs SET usage_team_id = $2 WHERE run_id = $1', [ + ids.archived[0], + ids.teams[1], + ]) + await databasePool.query('UPDATE workflow_runs SET usage_team_id = $2 WHERE run_id = $1', [ + ids.archived[1], + usageTeam, + ]) + await databasePool.query( + `UPDATE workflow_runs SET actor_type = 'admin', actor_id = $2, + usage_team_id = 'control-plane-admin-ui' WHERE run_id = $1`, + [ids.live[1], adminActor] + ) + const subjects = { users: ids.users, teams: [...ids.teams, usageTeam] } + const adminBefore = await revisions({ users: [adminActor], teams: [] }) + await databasePool.query( + 'DELETE FROM authorization_user_revisions WHERE user_id = ANY($1::uuid[])', + [subjects.users] + ) + await databasePool.query( + 'DELETE FROM authorization_team_revisions WHERE team_id = ANY($1::uuid[])', + [subjects.teams] + ) + await databasePool.query(` + CREATE TABLE archive_prelock_observations ( + position BIGSERIAL, subject_type TEXT, subject_id TEXT + ); + CREATE FUNCTION observe_archive_prelock() RETURNS TRIGGER LANGUAGE plpgsql AS $$ + BEGIN + INSERT INTO archive_prelock_observations(subject_type, subject_id) + VALUES ( + CASE WHEN TG_TABLE_NAME = 'authorization_team_revisions' THEN 'team' ELSE 'user' END, + COALESCE(to_jsonb(NEW)->>'team_id', to_jsonb(NEW)->>'user_id') + ); + RETURN NEW; + END; + $$; + CREATE TRIGGER observe_archive_prelock AFTER INSERT OR UPDATE + ON authorization_team_revisions FOR EACH ROW EXECUTE FUNCTION observe_archive_prelock(); + CREATE TRIGGER observe_archive_prelock AFTER INSERT OR UPDATE + ON authorization_user_revisions FOR EACH ROW EXECUTE FUNCTION observe_archive_prelock(); + `) + const client = await databasePool.connect() + try { + await client.query('BEGIN') + await client.query('SET LOCAL ROLE control_api_runtime') + for (const expectedRevision of [1, 2]) { + await client.query('SELECT authorization_prelock_workflow_run_revisions($1::uuid[])', [ + [...ids.archived, ...ids.live, ids.archived[0], randomUUID()], + ]) + const after = await client.query<{ revision: number }>( + `SELECT revision::int FROM authorization_user_revisions WHERE user_id = ANY($1::uuid[]) + UNION ALL + SELECT revision::int FROM authorization_team_revisions WHERE team_id = ANY($2::uuid[])`, + [subjects.users, subjects.teams] + ) + expect(after.rows).toEqual( + Array.from({ length: 5 }, () => ({ revision: expectedRevision })) + ) + } + await client.query('COMMIT') + const expectedOrder = [ + ...subjects.teams.sort().map(subject_id => ({ subject_type: 'team', subject_id })), + ...subjects.users.sort().map(subject_id => ({ subject_type: 'user', subject_id })), + ] + const observed = await databasePool.query( + 'SELECT subject_type, subject_id FROM archive_prelock_observations ORDER BY position' + ) + expect(observed.rows).toEqual([...expectedOrder, ...expectedOrder]) + expect(await revisions({ users: [adminActor], teams: [] })).toEqual(adminBefore) + } finally { + await client.query('ROLLBACK') + client.release() + await databasePool.query(` + DROP TRIGGER observe_archive_prelock ON authorization_team_revisions; + DROP TRIGGER observe_archive_prelock ON authorization_user_revisions; + DROP FUNCTION observe_archive_prelock(); + DROP TABLE archive_prelock_observations; + `) + } + }) + + it.each(['not-a-uuid', 'CONTROL-PLANE-ADMIN-UI'])( + 'keeps P0001 validation for non-sentinel usage team %s', + async invalidTeam => { + const ids = await fixture() + // Seed a legacy malformed value without invoking the live-row trigger; + // the scoped function must still use the production subject validator. + await databasePool.query( + 'ALTER TABLE workflow_runs DISABLE TRIGGER workflow_runs_authorization_revision' + ) + try { + await databasePool.query('UPDATE workflow_runs SET usage_team_id = $2 WHERE run_id = $1', [ + ids.archived[0], + invalidTeam, + ]) + } finally { + await databasePool.query( + 'ALTER TABLE workflow_runs ENABLE TRIGGER workflow_runs_authorization_revision' + ) + } + const before = await revisions(ids) + await expect( + databasePool.query('SELECT authorization_prelock_workflow_run_revisions($1::uuid[])', [ + ids.archived, + ]) + ).rejects.toMatchObject({ code: 'P0001' }) + expect(await revisions(ids)).toEqual(before) + } + ) +}) diff --git a/control-api/test/db.composableCatalogRevisionMigration.test.ts b/control-api/test/db.composableCatalogRevisionMigration.test.ts index 2480df84d0..429fd32bb4 100644 --- a/control-api/test/db.composableCatalogRevisionMigration.test.ts +++ b/control-api/test/db.composableCatalogRevisionMigration.test.ts @@ -21,6 +21,13 @@ describe('0134 composable catalog revisions migration', () => { expect(sql).not.toContain('authorization_catalog_writer_components') expect(sql).toContain('authorization_bump_workflow_run_revision') expect(sql).toContain('authorization_bump_workflow_run_audit_revision') + expect(sql).toContain('authorization_prelock_workflow_run_revisions(target_run_ids UUID[])') + expect(sql).toContain( + 'REVOKE ALL ON FUNCTION authorization_prelock_workflow_run_revisions(UUID[]) FROM PUBLIC' + ) + expect(sql).toMatch( + /GRANT EXECUTE ON FUNCTION authorization_prelock_workflow_run_revisions\(UUID\[\]\)\s+TO control_api_runtime/ + ) expect(sql).toContain('authorization_bump_workflow_approval_revision') expect(sql).toContain('authorization_bump_notification_revision') expect(sql).toContain('authorization_bump_gfs_resource_revision') diff --git a/control-api/test/db.realPostgresMigration.integration.test.ts b/control-api/test/db.realPostgresMigration.integration.test.ts index bf8e7fe716..d060a2d211 100644 --- a/control-api/test/db.realPostgresMigration.integration.test.ts +++ b/control-api/test/db.realPostgresMigration.integration.test.ts @@ -46,6 +46,7 @@ const accessFoundationDefinerSignatures = [ 'authorization_bump_user_revision(pg_catalog.uuid)', 'authorization_bump_team_revision(pg_catalog.uuid)', 'authorization_bump_subject_revision(pg_catalog.text, pg_catalog.text)', + 'authorization_prelock_workflow_run_revisions(pg_catalog.uuid[])', 'authorization_bump_user_row_revision()', 'authorization_bump_team_row_revision()', 'authorization_bump_workflow_run_revision()', @@ -83,9 +84,9 @@ async function readAccessFoundationDefinerState(pool: Pool) { FROM pg_proc procedure JOIN pg_namespace namespace ON namespace.oid = procedure.pronamespace WHERE namespace.nspname = 'public' - AND procedure.proname = ANY($1::text[]) - ORDER BY procedure.proname`, - [accessFoundationDefinerNames] + AND procedure.prosecdef + AND procedure.proname LIKE 'authorization_%' + ORDER BY procedure.proname` ) } @@ -887,6 +888,30 @@ describeRealPostgres('control-api real Postgres migrations', () => { await runtimeClient.query('ROLLBACK TO SAVEPOINT direct_revision_bump') } expect(directBumpResults).toEqual(['42501', '42501', '42501']) + const revisionExecute = await runtimeClient.query<{ function_name: string }>(` + SELECT routine.proname AS function_name + FROM pg_proc routine + JOIN pg_namespace namespace ON namespace.oid = routine.pronamespace + WHERE namespace.nspname = 'public' + AND routine.prosecdef + AND routine.proname LIKE 'authorization_%' + AND has_function_privilege('control_api_runtime', routine.oid, 'EXECUTE') + ORDER BY routine.proname + `) + expect(revisionExecute.rows).toEqual([ + { function_name: 'authorization_prelock_workflow_run_revisions' }, + ]) + const prelockGrants = await runtimeClient.query<{ grantee: string }>(` + SELECT pg_get_userbyid(privilege.grantee) AS grantee + FROM pg_proc routine + CROSS JOIN LATERAL aclexplode(routine.proacl) privilege + WHERE routine.oid = + 'public.authorization_prelock_workflow_run_revisions(uuid[])'::regprocedure + AND privilege.grantee <> routine.proowner + AND privilege.privilege_type = 'EXECUTE' + ORDER BY grantee + `) + expect(prelockGrants.rows).toEqual([{ grantee: 'control_api_runtime' }]) expect( ( await runtimeClient.query( diff --git a/control-api/test/services.workflowRunsArchive.test.ts b/control-api/test/services.workflowRunsArchive.test.ts index 6df4e90cd0..2a0f1e37f9 100644 --- a/control-api/test/services.workflowRunsArchive.test.ts +++ b/control-api/test/services.workflowRunsArchive.test.ts @@ -138,17 +138,26 @@ describe('archiveTerminalRuns', () => { const total = await archiveTerminalRuns(gateway) expect(total).toBe(2) - // Transaction shape: BEGIN → SELECT → INSERT audit → INSERT step audit → DELETE → COMMIT, + // Transaction shape: BEGIN → SELECT → prelock → INSERT audit → INSERT step audit → DELETE → COMMIT, // then one more drain SELECT returning 0 rows. const selectCalls = clientQuery.mock.calls.filter(([sql]) => /SELECT run_id, child_recipe_name/i.test(String(sql)) ) expect(selectCalls.length).toBe(2) // drain batch + empty-tail expect(String(selectCalls[0]?.[0])).toContain('COALESCE(ttl_seconds_after_finished, $1)') + const prelockCalls = clientQuery.mock.calls.filter(([sql]) => + /SELECT authorization_prelock_workflow_run_revisions/i.test(String(sql)) + ) + expect(prelockCalls).toEqual([ + ['SELECT authorization_prelock_workflow_run_revisions($1::uuid[])', [['uuid-1', 'uuid-2']]], + ]) const insertAuditCalls = clientQuery.mock.calls.filter(([sql]) => /INSERT INTO workflow_runs_audit/i.test(String(sql)) ) expect(insertAuditCalls.length).toBe(1) + const calls = clientQuery.mock.calls + expect(calls.indexOf(prelockCalls[0]!)).toBe(calls.indexOf(selectCalls[0]!) + 1) + expect(calls.indexOf(insertAuditCalls[0]!)).toBe(calls.indexOf(prelockCalls[0]!) + 1) expect(String(insertAuditCalls[0]?.[0])).toContain('wr.started_at') expect(String(insertAuditCalls[0]?.[0])).toContain('wr.completed_at - wr.started_at') expect(String(insertAuditCalls[0]?.[0])).not.toContain('wr.completed_at - wr.created_at') diff --git a/control-api/test/userAccessRuntimeContract.realPostgres.integration.test.ts b/control-api/test/userAccessRuntimeContract.realPostgres.integration.test.ts index e59c4aca10..362c469d15 100644 --- a/control-api/test/userAccessRuntimeContract.realPostgres.integration.test.ts +++ b/control-api/test/userAccessRuntimeContract.realPostgres.integration.test.ts @@ -200,6 +200,36 @@ describeRealPostgres('user-access runtime role contract on real PostgreSQL', () expect(stored.rows).toEqual([{ resources: '1', revisions: '1' }]) }) + it('prelocks archive subjects through the scoped runtime function', async () => { + await runAsControlApiRuntime(async db => { + const teamId = randomUUID() + const runId = randomUUID() + await db.query(`INSERT INTO teams(id, name) VALUES ($1, 'Archive runtime team')`, [teamId]) + await db.query( + `INSERT INTO workflow_runs(run_id, recipe_namespace, recipe_name, phase, + actor_type, actor_id, team_id, usage_team_id, trigger_source) + VALUES ($1, 'revision', 'archive-runtime', 'Pending', 'user', $2, $3::uuid, $3::uuid::text, 'onDemand')`, + [runId, userId, teamId] + ) + const readRevisions = () => + db.query<{ user_revision: string; team_revision: string }>( + `SELECT + (SELECT revision::text FROM authorization_user_revisions WHERE user_id = $1) + AS user_revision, + (SELECT revision::text FROM authorization_team_revisions WHERE team_id = $2) + AS team_revision`, + [userId, teamId] + ) + const before = (await readRevisions()).rows[0]! + await db.query('SELECT authorization_prelock_workflow_run_revisions($1::uuid[])', [ + [runId, runId], + ]) + const after = (await readRevisions()).rows[0]! + expect(Number(after.user_revision)).toBe(Number(before.user_revision) + 1) + expect(Number(after.team_revision)).toBe(Number(before.team_revision) + 1) + }) + }) + it('reads catalog and resolver authority as the runtime role', async () => { const catalog = await buildAccessCatalog( { session: authority, families: ['user'], limit: 10 }, From ed2c66f56fdd479185ff8cff6a484009ddb7c5d5 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 29 Oct 2026 00:04:36 +0100 Subject: [PATCH 388/528] test(control-api): pin the reviewed S1 foundation migration body The 2026-10-28 review changed the unapplied PR1 foundation and composable revision bodies: unused objects dropped, runtime revision writes and bump EXECUTE removed, revision writers ordered, the Admin UI attribution sentinel kept, three revision triggers added and an archive-batch pre-lock added. This updates the D34 byte pin once for all of them. Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- control-api/test/migrationExecutionPolicy.test.ts | 6 +++++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index bf17526ad1..675cc31801 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.852", + "version": "0.1.853", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.852", + "version": "0.1.853", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 36c4dbac92..68a7e8b06d 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.852", + "version": "0.1.853", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index d4affe6a66..5867d35ae9 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -95,10 +95,14 @@ describe('D34 migration execution policy', () => { // and planned indexes left the transactions. S1e folded three later PR1 // migrations into those two and dropped the authorization_catalog_revision // table and function. PR1 now occupies slots 0131-0135, after dev's 0130. + // The 2026-10-28 review (still unapplied anywhere) dropped unused objects, + // made revision tables read-only for the runtime, ordered revision writers, + // kept the Admin UI attribution sentinel, added three revision triggers and + // a pre-lock for archive batches. const files = [ [ 'src/services/access/userAccessFoundationSchema.ts', - 'eeb09ac027c7a893ea99a53141f097bba1d76fd4694342f56c20515a3a8b2020', + '89307fd494ed2c7936a04d888f962684ac97fad8a57fa1e731e04013d3349e43', ], [ 'src/services/directory/invitationDeliverySchema.ts', From d1e75bc5afaa10aeb8b06bbbae7e1b4319187de0 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 29 Oct 2026 00:09:46 +0100 Subject: [PATCH 389/528] fix(deploy): own the migration Job by UID and wire its contracts An attached caller now leaves a queued or active migration Job to its active deadline instead of deleting a Job it did not create. The creating caller deletes through the raw API with a UID precondition, so a Job replaced after create cannot be removed by the previous owner. Cover both ownership paths in the bounds contract, replace the verify_active_job_identity name check with a create-replaced-before-wait behavior case, and assert the name-only CONTROL_API_PG_CONNECTION_STRING env entry. Wire both shell contracts into the CI shell job. Co-Authored-By: DeepSeek V4.1 Flash --- .github/workflows/ci-public.yml | 6 ++ .../scripts/run-control-api-db-migration.sh | 51 ++++++++-- .../test-control-api-migration-bounds.sh | 94 +++++++++++++++++-- scripts/tests/test-gcp-scripts.sh | 33 +++++-- 4 files changed, 162 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci-public.yml b/.github/workflows/ci-public.yml index 2eebae1533..b3c31ddfb3 100644 --- a/.github/workflows/ci-public.yml +++ b/.github/workflows/ci-public.yml @@ -966,6 +966,12 @@ jobs: - name: Test single-writer validator fails loud on an absent local deploy run: bash scripts/tests/test-postgres-single-writer-strategy.sh + - name: Test control-api migration bounds and Job ownership contract + run: bash scripts/tests/test-control-api-migration-bounds.sh + + - name: Test GCP deploy scripts (migration and runtime access contracts) + run: bash scripts/tests/test-gcp-scripts.sh + - name: Test post-reset convergence isolates DB-dependent controllers run: bash scripts/tests/test-converge-control-db-after-reset.sh diff --git a/deploy/scripts/run-control-api-db-migration.sh b/deploy/scripts/run-control-api-db-migration.sh index 65a3ef5160..972d1c13af 100755 --- a/deploy/scripts/run-control-api-db-migration.sh +++ b/deploy/scripts/run-control-api-db-migration.sh @@ -152,18 +152,25 @@ job_exists() { } create_job() { - local output status + local output diagnostics status diagnostics_file + diagnostics_file="$(mktemp)" set +e - output="$({ JOB_NAME="$JOB_NAME" build_job_manifest | kctl create -f -; } 2>&1)" + output="$({ JOB_NAME="$JOB_NAME" build_job_manifest | \ + kctl create -f - -o jsonpath='{.metadata.uid}'; } 2>"$diagnostics_file")" status=$? set -e + diagnostics="$(cat "$diagnostics_file")" + rm -f "$diagnostics_file" if [ "$status" -eq 0 ]; then - [ -n "$output" ] && printf '%s\n' "$output" >&2 + JOB_UID="$(printf '%s' "$output" | tr -d '[:space:]')" + [ -n "$JOB_UID" ] || \ + die "created migration Job did not report a UID; refusing ownership of its cleanup" + JOB_ROLE=owner return 0 fi - if [[ "$output" == *"AlreadyExists"* ]]; then + if [[ "$diagnostics" == *"AlreadyExists"* ]]; then if ! verify_active_job_identity; then die "concurrently created migration Job identity does not match the requested migration" fi @@ -171,7 +178,9 @@ create_job() { return 0 fi - [ -n "$output" ] && printf '%s\n' "$output" >&2 + if [ -n "$diagnostics" ]; then + printf '%s\n' "$diagnostics" >&2 + fi return "$status" } @@ -290,12 +299,29 @@ verify_active_job_identity() { } terminate_migration_job() { - local deadline now remaining - deadline="$(( SECONDS + MIGRATION_TERMINATION_PROOF_SECONDS ))" - if ! kctl delete job "$JOB_NAME" -n control-plane \ - --cascade=foreground --wait=false --ignore-not-found >/dev/null; then + local deadline now remaining body_file delete_output delete_status + [ "$JOB_ROLE" = owner ] || return 1 + [ -n "$JOB_UID" ] || return 1 + body_file="$(mktemp)" + printf '%s' \ + '{"apiVersion":"v1","kind":"DeleteOptions","propagationPolicy":"Foreground","preconditions":{"uid":"'"$JOB_UID"'"}}' \ + >"$body_file" + set +e + delete_output="$(kctl delete --raw \ + "/apis/batch/v1/namespaces/control-plane/jobs/$JOB_NAME" -f "$body_file" 2>&1)" + delete_status=$? + set -e + rm -f "$body_file" + if [ "$delete_status" -ne 0 ]; then + if [[ "$delete_output" == *"Precondition failed"* || "$delete_output" == *"NotFound"* ]]; then + die "migration Job was replaced or removed by another caller; not deleting it" + fi + if [ -n "$delete_output" ]; then + printf '%s\n' "$delete_output" >&2 + fi return 1 fi + deadline="$(( SECONDS + MIGRATION_TERMINATION_PROOF_SECONDS ))" now="$SECONDS" remaining="$(( deadline - now ))" [ "$remaining" -gt 0 ] || return 1 @@ -316,6 +342,10 @@ OVERLAY="" load_migration_policy JOB_NAME="${JOB_NAME:-control-api-db-migrate}" +# Only the caller whose create succeeded owns cleanup. Attaching to a queued, +# active or concurrently created Job leaves it to its active deadline. +JOB_ROLE=observer +JOB_UID="" TIMEOUT="${TIMEOUT:-${MIGRATION_CLIENT_WAIT_SECONDS}s}" while [ $# -gt 0 ]; do @@ -1533,6 +1563,9 @@ if ! kctl wait --for=condition=complete --timeout="$TIMEOUT" "job/$JOB_NAME" -n if [[ "$job_logs" == *"Cannot find module '/app/dist/migrate.js'"* ]]; then failure_message="control-api image ${CONTROL_API_IMAGE} is missing dist/migrate.js; rebuild/push the current image and ensure deploy-dev resolves the correct sha tag before rerunning the gate" fi + if [ "$JOB_ROLE" != "owner" ]; then + die "migration Job was not created by this caller; leaving it to its active deadline instead of deleting it" + fi terminate_migration_job || \ die "migration Job cleanup could not prove Job and pod termination within ${MIGRATION_TERMINATION_PROOF_SECONDS}s" die "$failure_message" diff --git a/scripts/tests/test-control-api-migration-bounds.sh b/scripts/tests/test-control-api-migration-bounds.sh index b586b60dd6..48e5a46f33 100755 --- a/scripts/tests/test-control-api-migration-bounds.sh +++ b/scripts/tests/test-control-api-migration-bounds.sh @@ -49,12 +49,10 @@ index_count="$(grep -c "^ migrationVersion: '013[13]_" "$INDEX_PLAN_FILE")" grep -q 'activeDeadlineSeconds' "$DEPLOY_FILE" || \ fail "migration Job has no active deadline" -grep -q -- '--cascade=foreground' "$DEPLOY_FILE" || \ +grep -q 'DeleteOptions.*propagationPolicy":"Foreground' "$DEPLOY_FILE" || \ fail "migration timeout does not use foreground deletion" grep -q 'wait --for=delete' "$DEPLOY_FILE" && grep -q 'job-name=' "$DEPLOY_FILE" || \ fail "migration timeout does not prove pod termination" -grep -q 'verify_active_job_identity' "$DEPLOY_FILE" || \ - fail "existing active migration Job identity is not verified" fixture_root="$(mktemp -d)" trap 'rm -rf "$fixture_root"' EXIT @@ -69,6 +67,8 @@ set -euo pipefail mode="${TEST_MODE:?}" capture="${TEST_JOB_CAPTURE:?}" log_file="${TEST_KUBECTL_LOG:?}" +fixture_uid="11111111-1111-1111-1111-111111111111" +args="$*" printf '%s\n' "$*" >>"$log_file" if [[ "${1:-}" == --context=* ]]; then shift; fi @@ -113,7 +113,15 @@ if [[ "${1:-}" == "get" && "${2:-}" == "pvc" ]]; then exit 0; fi if [[ "${1:-}" == "apply" || "${1:-}" == "rollout" ]]; then cat >/dev/null || true; exit 0; fi if [[ "${1:-}" == "get" && "${2:-}" == "secret" ]]; then exit 0; fi if [[ "${1:-}" == "get" && "${2:-}" == "job" ]]; then - if [[ "$mode" == active-* || "$mode" == queued-* || "$mode" == terminal-* ]]; then + if [[ "$mode" == "create-replaced-before-wait" && -f "$capture.replaced" && "$args" == *"-o json"* ]]; then + ruby -rjson -e ' + job = JSON.parse(STDIN.read) + job["metadata"]["annotations"]["clerum.io/migration-configuration-sha256"] = "replaced" + puts JSON.generate(job) + ' <"$capture" + exit 0 + fi + if [[ "$mode" == active-* || "$mode" == queued-* || "$mode" == terminal-* || "$mode" == attach-* ]]; then if [[ "$mode" == "active-status-error" && "$*" == *"-o json"* ]]; then exit 2; fi if [[ "$*" == *"-o json"* ]]; then cat "$capture"; exit 0; fi exit 0 @@ -131,6 +139,14 @@ if [[ "${1:-}" == "create" ]]; then exit 1 fi cat >"$capture" + if [[ "$mode" == "create-replaced-before-wait" ]]; then + : >"$capture.replaced" + fi + if [[ "$args" == *"jsonpath={.metadata.uid}"* ]]; then + printf '%s\n' "$fixture_uid" + else + printf 'job.batch/control-api-db-migrate created\n' + fi exit 0 fi if [[ "${1:-}" == "logs" ]]; then @@ -149,8 +165,24 @@ if [[ "${1:-}" == "delete" && "${2:-}" == "job" ]]; then if [[ "$mode" == "timeout-delete-fails" ]]; then exit 1; fi exit 0 fi +if [[ "${1:-}" == "delete" && "${2:-}" == "--raw" ]]; then + body_path="${!#}" + body="$(cat "$body_path")" + printf '%s' "$body" >"$capture.delete-body" + if [[ "$mode" == "timeout-delete-fails" ]]; then exit 1; fi + if [[ "$mode" == "owner-replaced" && \ + "$body" == *'"preconditions":{"uid":"'"$fixture_uid"'"}'* ]]; then + printf 'Error from server (Conflict): Precondition failed: UID in precondition: %s, UID in object meta: 22222222-2222-2222-2222-222222222222\n' \ + "$fixture_uid" >&2 + exit 1 + fi + exit 0 +fi if [[ "${1:-}" == "wait" ]]; then - if [[ "$*" == *"--for=condition=complete"* && "$mode" == timeout-* ]]; then exit 1; fi + if [[ "$*" == *"--for=condition=complete"* ]] && \ + [[ "$mode" == timeout-* || "$mode" == attach-wait-fails || "$mode" == owner-replaced ]]; then + exit 1 + fi if [[ "$*" == *"--for=delete"* && "$mode" == timeout-cleanup-fails ]]; then exit 1; fi if [[ "$*" == *"--for=delete"* && "$mode" == timeout-job-wait-fails && "$*" == *"job/"* ]]; then exit 1; fi if [[ "$*" == *"--for=delete"* && "$mode" == timeout-shared-deadline && "$*" == *"job/"* ]]; then sleep 2; fi @@ -208,6 +240,20 @@ grep -q 'wait --for=condition=complete.*job/control-api-db-migrate' "$log_file" if grep -Eq 'delete job control-api-db-migrate|create -f -' "$log_file"; then fail "matching queued migration Job was deleted or recreated" fi + +# An attached observer must never delete a Job it did not create: the owner's +# active deadline bounds the wait, and this caller only reports the failure. +: >"$log_file" +attach_output="$(expect_fixture_failure 'attached migration Job wait' \ + run_fixture attach-wait-fails)" +[[ "$attach_output" == *'was not created by this caller; leaving it to its active deadline'* ]] || \ + fail "attached migration Job wait did not report observer ownership" +grep -q 'wait --for=condition=complete' "$log_file" || \ + fail "attached migration Job was not awaited (witness)" +if grep -Eq '(^| )delete (job|--raw)' "$log_file"; then + fail "attached migration Job was deleted by an observer" +fi + cp "$capture" "$fixture_root/mismatched.json" ruby -rjson -e ' job = JSON.parse(File.read(ARGV.fetch(0))) @@ -371,11 +417,45 @@ if run_fixture create-race-mismatch >/dev/null; then fi mv "$fixture_root/race-matched.json" "$capture" +# A caller that created the Job must not wait on an object another caller +# replaced between the create and the wait. +: >"$log_file" +replaced_before_wait_output="$(expect_fixture_failure 'migration Job replaced before wait' \ + run_fixture create-replaced-before-wait)" +[[ "$replaced_before_wait_output" == *'identity does not match immediately before wait'* ]] || \ + fail "migration Job replaced before wait was not rejected" +grep -q 'create -f -' "$log_file" || \ + fail "replaced-before-wait fixture did not create the Job (witness)" +if grep -q -- '--for=condition=complete' "$log_file"; then + fail "migration Job replaced before wait was still awaited" +fi +rm -f "$capture.replaced" + +# The owner cleans up by UID: a precondition failure means another caller owns +# the Job name now, so this caller must not delete it. +: >"$log_file" +owner_replaced_output="$(expect_fixture_failure 'migration Job replaced before cleanup' \ + run_fixture owner-replaced)" +[[ "$owner_replaced_output" == *'was replaced or removed by another caller; not deleting it'* ]] || \ + fail "replaced migration Job cleanup did not fail closed" +grep -q 'delete --raw /apis/batch/v1/namespaces/control-plane/jobs/control-api-db-migrate -f ' "$log_file" || \ + fail "owner cleanup did not issue a raw Job deletion (witness)" +replaced_body="$(cat "$capture.delete-body")" +[[ "$replaced_body" == *'"propagationPolicy":"Foreground"'* ]] || \ + fail "owner cleanup DELETE body did not request foreground propagation" +[[ "$replaced_body" == *'"preconditions":{"uid":"11111111-1111-1111-1111-111111111111"}'* ]] || \ + fail "owner cleanup DELETE body did not carry the created UID" + : >"$log_file" timeout_output="$(expect_fixture_failure 'migration client timeout' \ run_fixture timeout-cleanup-succeeds)" -grep -q 'delete job control-api-db-migrate.*--cascade=foreground.*--wait=false' "$log_file" || \ - fail "client timeout did not issue foreground Job deletion" +grep -q 'delete --raw /apis/batch/v1/namespaces/control-plane/jobs/control-api-db-migrate -f ' "$log_file" || \ + fail "client timeout did not issue a raw Job deletion" +timeout_body="$(cat "$capture.delete-body")" +[[ "$timeout_body" == *'"propagationPolicy":"Foreground"'* ]] || \ + fail "client timeout DELETE body did not request foreground propagation" +[[ "$timeout_body" == *'"preconditions":{"uid":"11111111-1111-1111-1111-111111111111"}'* ]] || \ + fail "client timeout DELETE body did not carry the created UID" grep -Eq 'wait --for=delete --timeout=(59|60)s job/control-api-db-migrate' "$log_file" || \ fail "client timeout did not bound Job termination proof" grep -Eq 'wait --for=delete --timeout=(59|60)s pod -l job-name=control-api-db-migrate' "$log_file" || \ diff --git a/scripts/tests/test-gcp-scripts.sh b/scripts/tests/test-gcp-scripts.sh index 12d122c1e2..a5a3e31c61 100755 --- a/scripts/tests/test-gcp-scripts.sh +++ b/scripts/tests/test-gcp-scripts.sh @@ -314,7 +314,11 @@ if [[ "${1:-}" == "create" && "${2:-}" == "-f" && "${3:-}" == "-" ]]; then manifest="$(cat)" printf '%s' "$manifest" >"$JOB_CAPTURE" printf '%s\n' "$manifest" >>"$LOG_FILE" - echo "job.batch/control-api-db-migrate created" + if [[ "$args" == *"jsonpath={.metadata.uid}"* ]]; then + echo "11111111-1111-1111-1111-111111111111" + else + echo "job.batch/control-api-db-migrate created" + fi exit 0 fi @@ -638,7 +642,11 @@ fi if [[ "${1:-}" == "create" && "${2:-}" == "-f" && "${3:-}" == "-" ]]; then cat >"$JOB_CAPTURE" - echo "job.batch/control-api-db-migrate created" + if [[ "$args" == *"jsonpath={.metadata.uid}"* ]]; then + echo "11111111-1111-1111-1111-111111111111" + else + echo "job.batch/control-api-db-migrate created" + fi exit 0 fi @@ -785,7 +793,11 @@ if [[ "${1:-}" == "create" && "${2:-}" == "-f" && "${3:-}" == "-" ]]; then job_manifest="$(cat)" printf '%s' "$job_manifest" >"$JOB_CAPTURE" printf 'JOB_MANIFEST %s\n' "$job_manifest" >>"$LOG_FILE" - echo "job.batch/control-api-db-migrate created" + if [[ "$args" == *"jsonpath={.metadata.uid}"* ]]; then + echo "11111111-1111-1111-1111-111111111111" + else + echo "job.batch/control-api-db-migrate created" + fi exit 0 fi @@ -843,7 +855,8 @@ STUB grep -q "relation.relkind IN ('r', 'p', 'v', 'm')" "$log_file" && \ grep -q 'JOB_MANIFEST.*DATABASE_URL' "$log_file" && \ grep -q 'JOB_MANIFEST.*control-api-config' "$log_file" && \ - grep -q 'JOB_MANIFEST.*CONTROL_API_PG_CONNECTION_STRING.*"value":""' "$log_file" && \ + grep -q 'JOB_MANIFEST.*{"name":"CONTROL_API_PG_CONNECTION_STRING"}' "$log_file" && \ + ! grep -q 'JOB_MANIFEST.*{"name":"CONTROL_API_PG_CONNECTION_STRING","value"' "$log_file" && \ grep -q 'JOB_MANIFEST.*CONTROL_API_MIGRATION_PG_HOST' "$log_file" && \ grep -q 'JOB_MANIFEST.*control-postgres.*POSTGRES_PASSWORD' "$log_file" && \ grep -q -- '--context=fake-context get secret control-postgres -n control-plane' "$log_file" && \ @@ -965,7 +978,11 @@ if [[ "${1:-}" == "create" && "${2:-}" == "-f" && "${3:-}" == "-" ]]; then echo "unexpected pvc create" >&2 exit 98 fi - echo "job.batch/control-api-db-migrate created" + if [[ "$args" == *"jsonpath={.metadata.uid}"* ]]; then + echo "11111111-1111-1111-1111-111111111111" + else + echo "job.batch/control-api-db-migrate created" + fi exit 0 fi @@ -1110,7 +1127,11 @@ if [[ "${1:-}" == "create" && "${2:-}" == "-f" && "${3:-}" == "-" ]]; then exit 0 fi printf 'JOB_CREATE\n%s\n' "$manifest" >>"$LOG_FILE" - echo "job.batch/control-api-db-migrate created" + if [[ "$args" == *"jsonpath={.metadata.uid}"* ]]; then + echo "11111111-1111-1111-1111-111111111111" + else + echo "job.batch/control-api-db-migrate created" + fi exit 0 fi From 2a65769de22f96e9ad9490c988b5aedd6be6bedf Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 29 Oct 2026 00:09:51 +0100 Subject: [PATCH 390/528] refactor(control-api): remove the dead staged trigger limiter branch The external trigger limiter is mounted before the session middleware, so req.externalAuth is never set when it keys the bucket. Key the external trigger lane only from the presented credential and drop the unit test that fabricated the staged V2 identity. Co-Authored-By: DeepSeek V4.1 Flash --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- .../src/routes/workflows/shared/rateLimit.ts | 11 ++++------- .../test/workflows.shared.rateLimit.test.ts | 14 -------------- 4 files changed, 7 insertions(+), 24 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 675cc31801..c74239051f 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.853", + "version": "0.1.854", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.853", + "version": "0.1.854", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 68a7e8b06d..fa42b8616f 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.853", + "version": "0.1.854", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/routes/workflows/shared/rateLimit.ts b/control-api/src/routes/workflows/shared/rateLimit.ts index 789e797506..e28a7d6a7b 100644 --- a/control-api/src/routes/workflows/shared/rateLimit.ts +++ b/control-api/src/routes/workflows/shared/rateLimit.ts @@ -447,15 +447,12 @@ function verifiedUserSessionRateLimitSubject(token: string): string | null { } /** - * External trigger lane: prefer the canonical authenticated session identity - * staged before this limiter for either V1 or V2. A raw unverified session - * header falls back to IP so rotation cannot evade the cap. + * External trigger lane: derive the budget key from the presented credential. + * This limiter runs before the session middleware, so a V1/V2 session token is + * signature-verified here; a raw unverified session header falls back to IP so + * rotation cannot evade the cap. */ export function workflowTriggerRateLimitCredential(req: Request): string | null { - const stagedUserId = (req as Request & { externalAuth?: { userId?: string } }).externalAuth - ?.userId - if (stagedUserId) return `user:${stagedUserId}` - const userSessionToken = String(req.header('x-user-session-token') || '').trim() if (userSessionToken) { return ( diff --git a/control-api/test/workflows.shared.rateLimit.test.ts b/control-api/test/workflows.shared.rateLimit.test.ts index a53cdeb4d7..fe6149970f 100644 --- a/control-api/test/workflows.shared.rateLimit.test.ts +++ b/control-api/test/workflows.shared.rateLimit.test.ts @@ -1076,20 +1076,6 @@ describe('routes/workflows/shared/rateLimit', () => { expect(workflowTriggerRateLimitCredential(reqFor('forged-not-a-jwt'))).not.toBe('service-token') }) - it('workflowTriggerRateLimitCredential uses the authenticated staged V2 identity', () => { - const req = { - externalAuth: { userId: 'user-v2-staged', sessionContract: 'v2' }, - ip: '203.0.113.10', - header(name: string) { - if (name.toLowerCase() === 'authorization') return 'Bearer service-token' - if (name.toLowerCase() === 'x-user-session-token') return 'signed-v2-session' - return undefined - }, - } as express.Request - - expect(workflowTriggerRateLimitCredential(req)).toBe('user:user-v2-staged') - }) - it('workflowTriggerRateLimitCredential ignores whitespace user tokens and falls back to bearer', () => { const req = { header(name: string) { From 21fbc477ade205205d43aa37ca9ea51658ca6de2 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 29 Oct 2026 00:12:45 +0100 Subject: [PATCH 391/528] fix(control-api,rpc-proxy): restore the parent Host access path Drop the /message-resolution and /artifact-read sub-paths and the control_api_version_mismatch fence so Host lookup uses dev's parent path again. Point the J4 gateway check at that same path. Co-Authored-By: Grok 4.7 --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- control-api/src/app.ts | 5 +- control-api/src/routes/rpc-access/users.ts | 230 +++++------------- .../access/rpcHostAccessAuthorizer.ts | 30 +-- control-api/test/app.test.ts | 21 -- .../test/routes.hostMessageAdmission.test.ts | 206 ---------------- ...es.rpcAccessUsersArtifactAdmission.test.ts | 77 ------ .../test/routes.rpcAccessUsersMcpHost.test.ts | 82 ++----- .../services.rpcHostAccessAuthorizer.test.ts | 10 +- deploy/base/control-plane/configmaps.yaml | 27 -- external-rest-api/src/releaseManifest.ts | 2 +- rpc-proxy/package-lock.json | 4 +- rpc-proxy/package.json | 2 +- .../src/__tests__/approval-artifacts.test.ts | 12 +- .../control-api-gateway-parity.test.ts | 51 ++-- rpc-proxy/src/__tests__/errorHandler.test.ts | 9 - .../host-access-denial-code.route.test.ts | 32 +-- .../host-message-admission-transport.test.ts | 48 ---- ...route-upstream-deadlines.real-http.test.ts | 7 +- .../src/__tests__/sender-assignment.test.ts | 2 +- ...and-hold-idempotent-gets.real-http.test.ts | 2 - .../src/__tests__/wake-and-hold-route.test.ts | 3 - .../src/__tests__/wake-route-coverage.test.ts | 2 - rpc-proxy/src/errorHandler.ts | 10 - rpc-proxy/src/routes/rpc.ts | 127 ++++------ rpc-proxy/src/routes/rpcHostActivityStream.ts | 5 - rpc-proxy/src/routes/rpcHostProgressStream.ts | 5 - rpc-proxy/src/routes/rpcHostStatusStream.ts | 5 - .../src/services/controlApiRestService.ts | 64 +---- .../src/services/controlApiVersionError.ts | 20 -- rpc-proxy/src/services/mcpProxyService.ts | 31 --- rpc-proxy/test/routes.rpc.test.ts | 2 - .../services.controlApiHostAccess.test.ts | 125 ++++------ scripts/e2e/e2e-rpc-gateway-host-paths.sh | 38 +-- 35 files changed, 268 insertions(+), 1034 deletions(-) delete mode 100644 control-api/test/routes.hostMessageAdmission.test.ts delete mode 100644 control-api/test/routes.rpcAccessUsersArtifactAdmission.test.ts delete mode 100644 rpc-proxy/src/__tests__/host-message-admission-transport.test.ts delete mode 100644 rpc-proxy/src/services/controlApiVersionError.ts diff --git a/control-api/package-lock.json b/control-api/package-lock.json index c74239051f..aed16c5635 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.854", + "version": "0.1.855", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.854", + "version": "0.1.855", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index fa42b8616f..fd6ccd36ea 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.854", + "version": "0.1.855", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/app.ts b/control-api/src/app.ts index 29f423514f..5459f27598 100644 --- a/control-api/src/app.ts +++ b/control-api/src/app.ts @@ -62,8 +62,7 @@ import { } from './services/tracing/wrcInfrastructureBindingResolver.js' const TRACING_INTERNAL_PATH_PREFIX = '/api/v1/internal/tracing/' -const RPC_HOST_ACCESS_OWN_BODY_PARSER_PATH = - /^\/api\/v1\/rpc\/access\/users\/[^/]+\/mcp-hosts\/[^/]+(?:\/message-resolution)?\/?$/i +const RPC_HOST_ACCESS_PATH = /^\/api\/v1\/rpc\/access\/users\/[^/]+\/mcp-hosts\/[^/]+\/?$/i // Upload v2 part bodies are raw octet streams. They must reach the streaming // proxy untouched; parsing them as JSON would either reject the first binary // byte or buffer the whole part in the control plane. @@ -104,7 +103,7 @@ export function createApp(gateway: K8sGateway) { tracingInFlightLimiter(req, res, next) return } - if (req.method === 'POST' && RPC_HOST_ACCESS_OWN_BODY_PARSER_PATH.test(req.path)) { + if (req.method === 'POST' && RPC_HOST_ACCESS_PATH.test(req.path)) { next() return } diff --git a/control-api/src/routes/rpc-access/users.ts b/control-api/src/routes/rpc-access/users.ts index 6ad630a6c4..5dad840a15 100644 --- a/control-api/src/routes/rpc-access/users.ts +++ b/control-api/src/routes/rpc-access/users.ts @@ -1,10 +1,9 @@ import express, { Router } from 'express' -import type { NextFunction, Request, Response } from 'express' +import type { Request, Response } from 'express' import { config } from '../../config.js' import { pool } from '../../db.js' import { K8sGateway } from '../../k8s.js' import { - requireRpcTokenHostMatch, requireRpcTokenUserMatch, requireValidRpcAccessToken, requireValidRpcAccessTokenAny, @@ -15,7 +14,6 @@ import { resolveInvocableMcpServersForContexts, } from '../../services/access/mcpInvocable.js' import { - type AuthorizedRpcHostAccess, type RpcHostAccessDenialReason, type RpcHostAccessDirectory, authorizeRpcHostAccess, @@ -26,7 +24,6 @@ import { DirectRunBindingConflictError, } from '../../services/tracing/directRunAttributionBindingService.js' import { parseDirectRunBindingRequest } from '../../services/tracing/directRunBindingRequest.js' -import type { ParsedDirectRunBindingRequest } from '../../services/tracing/directRunBindingRequest.js' type RpcAccessUsersRouterOptions = { bindingService: Pick @@ -50,9 +47,6 @@ const HOST_ACCESS_SCOPES = [ ] as const type RpcAuthedRequest = Request & { rpcAuth?: RpcAccessClaims } -type HostMessageResolutionRequest = RpcAuthedRequest & { - messageResolutionBinding?: ParsedDirectRunBindingRequest | null -} /** * Response header that tells rpc-proxy why control-api denied Host access. The @@ -75,47 +69,6 @@ function denyHostAccess( res.status(403).json({ error: 'Forbidden' }) } -function validateMessageResolutionBody( - req: HostMessageResolutionRequest, - res: Response, - next: NextFunction -): void { - const body = req.body - const ordinary = - body !== null && - typeof body === 'object' && - !Array.isArray(body) && - Object.keys(body).length === 0 - const binding = ordinary ? null : parseDirectRunBindingRequest(body) - if (!ordinary && !binding) { - res.status(400).json({ error: 'invalid_direct_run_binding' }) - return - } - req.messageResolutionBinding = binding - next() -} - -async function resolveAuthorizedHostConnection( - req: RpcAuthedRequest, - res: Response, - gateway: K8sGateway, - directory: RpcHostAccessDirectory | undefined -): Promise { - const userId = String(req.params.userId || '').trim() - const hostRef = String(req.params.hostRef || '').trim() - const claims = req.rpcAuth - if (!claims) { - denyHostAccess(req, res, 'claims_missing') - return null - } - const authorization = await authorizeRpcHostAccess(gateway, claims, userId, hostRef, directory) - if (!authorization.authorized) { - denyHostAccess(req, res, authorization.reason) - return null - } - return authorization.connection -} - async function bindDirectRunWithinBudget( bindingService: Pick, input: Parameters[0], @@ -139,77 +92,6 @@ async function bindDirectRunWithinBudget( } } -/** - * The single Host-message resolution path shared by the legacy strict POST and - * the /message-resolution route. Signed selectors are checked before live - * Host authorization on both paths. - */ -async function respondWithAuthorizedHostConnection( - req: RpcAuthedRequest, - res: Response, - gateway: K8sGateway, - directory: RpcHostAccessDirectory | undefined, - bindingService: Pick, - bindingBudgetMs: number, - binding: ParsedDirectRunBindingRequest | null -): Promise { - const userId = String(req.params.userId || '').trim() - const hostRef = String(req.params.hostRef || '').trim() - const claims = req.rpcAuth - if (!claims) { - denyHostAccess(req, res, 'claims_missing') - return - } - // Same order as /message-resolution's claim-match middleware: a signed - // selector that disagrees with the URL is a request-shape error. - if (!userId || claims.sub !== userId) { - denyHostAccess(req, res, 'subject_mismatch') - return - } - if (!hostRef || !claims.hostRefs.includes(hostRef)) { - denyHostAccess(req, res, 'host_claim_missing') - return - } - const authorization = await authorizeRpcHostAccess(gateway, claims, userId, hostRef, directory) - if (!authorization.authorized) { - denyHostAccess(req, res, authorization.reason) - return - } - if (!binding) { - res.status(200).json(authorization.connection) - return - } - const bindingStatus = await bindDirectRunWithinBudget( - bindingService, - { - ...binding, - hostRef, - identityIssuer: config.rpcJwtIssuer, - actorHumanSub: claims.sub, - userId: claims.sub, - teamId: claims.teamId, - }, - bindingBudgetMs - ) - if (bindingStatus === 'recorded') { - res.status(200).json({ ...authorization.connection, bindingStatus: 'recorded' }) - return - } - if (bindingStatus === 'conflict') { - res.status(409).json({ error: 'direct_run_binding_conflict' }) - return - } - req.log?.warn( - { - event: 'governed_trace_operational_error', - scope: 'agent_run', - reason: 'attribution_binding_unavailable', - }, - 'direct run attribution binding unavailable after host authorization' - ) - res.status(200).json({ ...authorization.connection, bindingStatus: 'unavailable' }) -} - export function createRpcAccessUsersRouter( gateway: K8sGateway, options: RpcAccessUsersRouterOptions @@ -312,23 +194,25 @@ export function createRpcAccessUsersRouter( requireValidRpcAccessTokenAny([...HOST_ACCESS_SCOPES]), async (req: RpcAuthedRequest, res, next) => { try { - const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) - if (connection) res.status(200).json(connection) - } catch (error) { - next(error) - } - } - ) - - router.get( - `${hostAccessPath}/artifact-read`, - requireValidRpcAccessTokenAny(['host:task:read']), - requireRpcTokenUserMatch(), - requireRpcTokenHostMatch(), - async (req: RpcAuthedRequest, res, next) => { - try { - const connection = await resolveAuthorizedHostConnection(req, res, gateway, directory) - if (connection) res.status(200).json(connection) + const userId = String(req.params.userId || '').trim() + const hostRef = String(req.params.hostRef || '').trim() + const claims = req.rpcAuth + if (!claims) { + denyHostAccess(req, res, 'claims_missing') + return + } + const authorization = await authorizeRpcHostAccess( + gateway, + claims, + userId, + hostRef, + directory + ) + if (!authorization.authorized) { + denyHostAccess(req, res, authorization.reason) + return + } + res.status(200).json(authorization.connection) } catch (error) { next(error) } @@ -341,6 +225,8 @@ export function createRpcAccessUsersRouter( express.json({ limit: '2kb', strict: true }), async (req: RpcAuthedRequest, res, next) => { try { + const userId = String(req.params.userId || '').trim() + const hostRef = String(req.params.hostRef || '').trim() const claims = req.rpcAuth const binding = parseDirectRunBindingRequest(req.body) if (!claims) { @@ -352,41 +238,53 @@ export function createRpcAccessUsersRouter( return } - await respondWithAuthorizedHostConnection( - req, - res, + const authorization = await authorizeRpcHostAccess( gateway, - directory, - bindingService, - bindingBudgetMs, - binding + claims, + userId, + hostRef, + directory ) - } catch (error) { - next(error) - } - } - ) + if (!authorization.authorized) { + denyHostAccess(req, res, authorization.reason) + return + } - // The empty object represents an ordinary send; the existing exact binding - // body is accepted for direct-run-bound sends. - router.post( - `${hostAccessPath}/message-resolution`, - requireValidRpcAccessToken('host:message:invoke'), - express.json({ limit: '2kb', strict: true }), - validateMessageResolutionBody, - requireRpcTokenUserMatch('userId', (req, res, reason) => denyHostAccess(req, res, reason)), - requireRpcTokenHostMatch('hostRef', (req, res, reason) => denyHostAccess(req, res, reason)), - async (req: HostMessageResolutionRequest, res, next) => { - try { - await respondWithAuthorizedHostConnection( - req, - res, - gateway, - directory, + const bindingStatus = await bindDirectRunWithinBudget( bindingService, - bindingBudgetMs, - req.messageResolutionBinding ?? null + { + ...binding, + hostRef, + identityIssuer: config.rpcJwtIssuer, + actorHumanSub: claims.sub, + userId: claims.sub, + teamId: claims.teamId, + }, + bindingBudgetMs ) + if (bindingStatus === 'recorded') { + res.status(200).json({ + ...authorization.connection, + bindingStatus: 'recorded', + }) + return + } + if (bindingStatus === 'conflict') { + res.status(409).json({ error: 'direct_run_binding_conflict' }) + return + } + req.log?.warn( + { + event: 'governed_trace_operational_error', + scope: 'agent_run', + reason: 'attribution_binding_unavailable', + }, + 'direct run attribution binding unavailable after host authorization' + ) + res.status(200).json({ + ...authorization.connection, + bindingStatus: 'unavailable', + }) } catch (error) { next(error) } diff --git a/control-api/src/services/access/rpcHostAccessAuthorizer.ts b/control-api/src/services/access/rpcHostAccessAuthorizer.ts index 5c0383e5c9..590616abd1 100644 --- a/control-api/src/services/access/rpcHostAccessAuthorizer.ts +++ b/control-api/src/services/access/rpcHostAccessAuthorizer.ts @@ -2,7 +2,6 @@ import { config } from '../../config.js' import type { K8sGateway } from '../../k8s.js' import type { RpcAccessClaims } from '../../profileTypes.js' import { getCurrentTeam, getTeamAgents, getUserAgents } from '../directory/index.js' -import { extractK8sStatus } from '../resourceServiceHelpers.js' export type RpcHostAccessDenialReason = | 'subject_mismatch' @@ -28,33 +27,6 @@ export type RpcHostAccessAuthorization = | { authorized: true; connection: AuthorizedRpcHostAccess } | { authorized: false; reason: RpcHostAccessDenialReason } -/** - * The Host directory could not be read from the cluster. A Kubernetes 404 on - * the namespaced Host listing means the configured namespace or the Host CRD is - * absent: an operational dependency failure, not a caller-visible "not found" - * for this RPC path. It must stay 5xx, because rpc-proxy maps any 404 here to a - * peer version mismatch and that verdict would hide a broken deployment. - */ -export class HostDirectoryUnavailableError extends Error { - readonly httpStatus = 500 - - constructor(cause: unknown) { - super('Host directory could not be read', { cause }) - this.name = 'HostDirectoryUnavailableError' - } -} - -async function listHostDirectory(gateway: K8sGateway): Promise { - try { - return await gateway.listResource('hosts', config.hostsNamespace) - } catch (error) { - if (extractK8sStatus(error) === 404) { - throw new HostDirectoryUnavailableError(error) - } - throw error - } -} - const defaultDirectory: RpcHostAccessDirectory = { getUserAgents, getCurrentTeam, @@ -90,7 +62,7 @@ export async function authorizeRpcHostAccess( } } - const hosts = (await listHostDirectory(gateway)) as Array<{ + const hosts = (await gateway.listResource('hosts', config.hostsNamespace)) as Array<{ metadata?: { name?: string } spec?: { enabled?: boolean } }> diff --git a/control-api/test/app.test.ts b/control-api/test/app.test.ts index da8370be8b..d19051230a 100644 --- a/control-api/test/app.test.ts +++ b/control-api/test/app.test.ts @@ -159,27 +159,6 @@ describe('app router wiring', () => { .expect(413) }) - it('uses the dedicated 2 KiB parser for Host message-resolution requests', async () => { - const app = createApp(new MockGateway('mcp-server') as never) - const rpcToken = signRpcAccessToken({ - sub: 'user-1', - typ: 'user', - teamId: 'team-1', - role: 'member', - scopes: ['host:message:invoke'], - hostRefs: ['host-a'], - jti: 'app-message-resolution-body-limit', - }) - - await request(app) - .post('/api/v1/rpc/access/users/user-1/mcp-hosts/host-a/message-resolution') - .set('authorization', 'Bearer dev-rpc-proxy-token') - .set('x-service-token', 'rpc-proxy') - .set('x-rpc-access-token', rpcToken) - .send({ sessionId: 'x'.repeat(3_000) }) - .expect(413) - }) - it('rejects an HCC health transition when no server-verifiable Host binding source exists', async () => { const app = createApp(new MockGateway('mcp-server') as never) diff --git a/control-api/test/routes.hostMessageAdmission.test.ts b/control-api/test/routes.hostMessageAdmission.test.ts deleted file mode 100644 index 050c7c4780..0000000000 --- a/control-api/test/routes.hostMessageAdmission.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import express from 'express' -import request from 'supertest' -import type { K8sGateway } from '../src/k8s.js' -import { createRpcAccessUsersRouter } from '../src/routes/rpc-access/users.js' -import { signRpcAccessToken } from '../src/utils/auth/rpcAuthToken.js' - -const directory = vi.hoisted(() => ({ - getUserAgents: vi.fn(), - getCurrentTeam: vi.fn(), - getTeamAgents: vi.fn(), - getUserContexts: vi.fn(), -})) -const limiter = vi.hoisted(() => ({ checkAndIncrement: vi.fn() })) - -vi.mock('../src/services/directory/index.js', () => directory) -vi.mock('../src/services/rateLimiterService.js', () => limiter) - -const USER_A = '00000000-0000-4000-8000-000000000001' -const USER_B = '00000000-0000-4000-8000-000000000002' -const ROUTE = (user: string, host: string) => - `/rpc/access/users/${user}/mcp-hosts/${host}/message-resolution` -const LEGACY_ROUTE = (user: string, host: string) => `/rpc/access/users/${user}/mcp-hosts/${host}` -const BINDING = { - runId: '00000000-0000-4000-8000-000000000123', - sessionId: 'session-a', - origin: 'direct_chat', -} - -function token(user: string, hosts: string[], scopes = ['host:message:invoke']) { - return signRpcAccessToken({ - sub: user, - typ: 'user', - teamId: 'team-a', - role: 'member', - scopes: scopes as ['host:message:invoke'], - hostRefs: hosts, - jti: `jti-${user}`, - }) -} - -function app() { - const gateway = { - listResource: vi.fn(async () => [ - { metadata: { name: 'host-a' }, spec: { enabled: true } }, - { metadata: { name: 'host-b' }, spec: { enabled: true } }, - ]), - } - const bindingService = { bind: vi.fn(async () => ({ status: 'created' })) } - const server = express() - server.use(createRpcAccessUsersRouter(gateway as unknown as K8sGateway, { bindingService })) - return { server, gateway, bindingService } -} - -function send( - server: express.Express, - user: string, - host: string, - body: object, - jwt = token(user, [host]) -) { - return request(server).post(ROUTE(user, host)).set('x-rpc-access-token', jwt).send(body) -} - -describe('Host-message resolution parity', () => { - beforeEach(() => { - vi.clearAllMocks() - directory.getUserAgents.mockImplementation(async (userId: string) => ({ - userId, - agentNames: ['host-a', 'host-b'], - })) - directory.getCurrentTeam.mockResolvedValue({ id: 'team-a', role: 'member' }) - directory.getTeamAgents.mockResolvedValue({ agentNames: ['host-a', 'host-b'] }) - }) - - it('authorizes more than 60 mixed ordinary and bound sends without a limiter', async () => { - const { server, gateway, bindingService } = app() - const jwt = token(USER_A, ['host-a', 'host-b']) - let charges = 0 - limiter.checkAndIncrement.mockImplementation(async (_key: string, maximum: number) => { - charges += 1 - return { - allowed: charges <= maximum, - remaining: Math.max(0, maximum - charges), - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - count: charges, - backendAvailable: true, - } - }) - for (let index = 0; index < 66; index += 1) { - const host = index % 2 ? 'host-b' : 'host-a' - const binding = { ...BINDING, sessionId: `session-${index}` } - if (index % 3 === 0) { - await request(server) - .post(LEGACY_ROUTE(USER_A, host)) - .set('x-rpc-access-token', jwt) - .send(binding) - .expect(200) - } else { - await send(server, USER_A, host, index % 3 === 1 ? binding : {}, jwt).expect(200) - } - } - expect(gateway.listResource).toHaveBeenCalledTimes(66) - expect(bindingService.bind).toHaveBeenCalledTimes(44) - expect(limiter.checkAndIncrement).not.toHaveBeenCalled() - }) - - it('rejects invalid auth, scope, claim, and binding before protected work', async () => { - const { server, gateway, bindingService } = app() - // A valid request proves the Host authorization and binding are observable. - await request(server) - .post(LEGACY_ROUTE(USER_A, 'host-a')) - .set('x-rpc-access-token', token(USER_A, ['host-a'])) - .send(BINDING) - .expect(200) - expect(limiter.checkAndIncrement).not.toHaveBeenCalled() - expect(gateway.listResource).toHaveBeenCalledOnce() - expect(bindingService.bind).toHaveBeenCalledOnce() - - await request(server).post(ROUTE(USER_A, 'host-a')).send({}).expect(401) - await send(server, USER_A, 'host-a', {}, 'bad-token').expect(401) - await send( - server, - USER_A, - 'host-a', - {}, - token(USER_A, ['host-a'], ['host:status:read']) - ).expect(403) - await send(server, USER_A, 'host-a', {}, token(USER_B, ['host-a'])).expect(403) - await send(server, USER_A, 'host-b', {}, token(USER_A, ['host-a'])).expect(403) - await request(server) - .post(LEGACY_ROUTE(USER_A, 'host-a')) - .set('x-rpc-access-token', token(USER_B, ['host-a'])) - .send(BINDING) - .expect(403) - .expect('x-host-access-denial-reason', 'subject_mismatch') - await request(server) - .post(LEGACY_ROUTE(USER_A, 'host-b')) - .set('x-rpc-access-token', token(USER_A, ['host-a'])) - .send(BINDING) - .expect(403) - .expect('x-host-access-denial-reason', 'host_claim_missing') - await send(server, USER_A, 'host-a', { runId: 'bad' }).expect(400) - await request(server) - .post(ROUTE(USER_A, 'host-a')) - .set('x-rpc-access-token', token(USER_A, ['host-a'])) - .set('content-type', 'application/json') - .send('{"runId":') - .expect(400) - // Every rejected shape above left the witness baseline unchanged. - expect(limiter.checkAndIncrement).not.toHaveBeenCalled() - expect(gateway.listResource).toHaveBeenCalledOnce() - expect(bindingService.bind).toHaveBeenCalledOnce() - }) - - it('denies a missing live grant without a limiter call', async () => { - const { server, gateway } = app() - directory.getUserAgents.mockResolvedValue({ userId: USER_A, agentNames: [] }) - directory.getTeamAgents.mockResolvedValue({ agentNames: [] }) - await send(server, USER_A, 'host-a', {}).expect(403) - expect(limiter.checkAndIncrement).not.toHaveBeenCalled() - expect(gateway.listResource).not.toHaveBeenCalled() - }) - - it('denies unbound user and Host selectors before protected work', async () => { - const subjectCase = app() - const subjectMismatch = await send( - subjectCase.server, - USER_B, - 'host-a', - {}, - token(USER_A, ['host-a']) - ).expect(403) - expect(subjectMismatch.headers['x-host-access-denial-reason']).toBe('subject_mismatch') - expect(limiter.checkAndIncrement).not.toHaveBeenCalled() - expect(subjectCase.gateway.listResource).not.toHaveBeenCalled() - - const hostCase = app() - const hostMismatch = await send( - hostCase.server, - USER_A, - 'host-b', - {}, - token(USER_A, ['host-a']) - ).expect(403) - expect(hostMismatch.headers['x-host-access-denial-reason']).toBe('host_claim_missing') - expect(limiter.checkAndIncrement).not.toHaveBeenCalled() - expect(hostCase.gateway.listResource).not.toHaveBeenCalled() - }) - - it('keeps deterministic body rejection ahead of the token-bound selectors', async () => { - const { server, gateway } = app() - const response = await send( - server, - USER_B, - 'host-b', - { invalid: true }, - token(USER_A, ['host-a']) - ).expect(400) - expect(response.body).toEqual({ error: 'invalid_direct_run_binding' }) - expect(response.headers['x-host-access-denial-reason']).toBeUndefined() - expect(limiter.checkAndIncrement).not.toHaveBeenCalled() - expect(gateway.listResource).not.toHaveBeenCalled() - }) -}) diff --git a/control-api/test/routes.rpcAccessUsersArtifactAdmission.test.ts b/control-api/test/routes.rpcAccessUsersArtifactAdmission.test.ts deleted file mode 100644 index 5a1cf13625..0000000000 --- a/control-api/test/routes.rpcAccessUsersArtifactAdmission.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import express from 'express' -import { randomUUID } from 'node:crypto' -import request from 'supertest' -import type { K8sGateway } from '../src/k8s.js' -import { createRpcAccessUsersRouter } from '../src/routes/rpc-access/users.js' -import { signRpcAccessToken } from '../src/utils/auth/rpcAuthToken.js' - -const directory = vi.hoisted(() => ({ - getUserAgents: vi.fn(), - getUserContexts: vi.fn(), - getCurrentTeam: vi.fn(), - getTeamAgents: vi.fn(), -})) - -vi.mock('../src/services/directory/index.js', () => directory) - -const rateLimiter = vi.hoisted(() => ({ checkAndIncrement: vi.fn() })) - -vi.mock('../src/services/rateLimiterService.js', () => rateLimiter) - -const userId = 'user-1' -const hostRef = 'host-a' - -function token(overrides: Partial[0]> = {}) { - return signRpcAccessToken({ - sub: userId, - typ: 'user', - teamId: 'team-1', - role: 'member', - scopes: ['host:task:read'], - hostRefs: [hostRef], - jti: `artifact-admission-${randomUUID()}`, - ...overrides, - }) -} - -function buildApp() { - const gateway = { - listResource: vi.fn(async () => [{ metadata: { name: hostRef }, spec: { enabled: true } }]), - getResource: vi.fn(async () => ({}) as never), - createResource: vi.fn(async () => ({})), - updateResource: vi.fn(async () => ({})), - deleteResource: vi.fn(async () => ({ ok: true })), - } - const app = express() - app.use( - createRpcAccessUsersRouter(gateway as unknown as K8sGateway, { - bindingService: { bind: vi.fn() }, - }) - ) - return { app, gateway } -} - -describe('artifact-read authentication with real RPC auth and claim-match middleware', () => { - beforeEach(() => { - Object.values(directory).forEach(mock => mock.mockReset()) - rateLimiter.checkAndIncrement.mockReset() - }) - - it.each([ - ['missing token', undefined, 401], - ['invalid token', 'not-a-jwt', 401], - ['missing host:task:read scope', token({ scopes: ['host:status:read'] }), 403], - ['subject mismatch', token({ sub: 'another-user' }), 403], - ['Host absent from signed hostRefs', token({ hostRefs: ['another-host'] }), 403], - ])('%s fails before protected work', async (_case, accessToken, status) => { - const { app } = buildApp() - let req = request(app).get(`/rpc/access/users/${userId}/mcp-hosts/${hostRef}/artifact-read`) - if (accessToken) req = req.set('x-rpc-access-token', accessToken) - - await req.expect(status) - - expect(rateLimiter.checkAndIncrement).not.toHaveBeenCalled() - expect(directory.getUserAgents).not.toHaveBeenCalled() - }) -}) diff --git a/control-api/test/routes.rpcAccessUsersMcpHost.test.ts b/control-api/test/routes.rpcAccessUsersMcpHost.test.ts index 8e0c27517d..4f82d4e84e 100644 --- a/control-api/test/routes.rpcAccessUsersMcpHost.test.ts +++ b/control-api/test/routes.rpcAccessUsersMcpHost.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import express from 'express' import request from 'supertest' +import { config } from '../src/config.js' import { clerumErrorHandler } from '../src/http/errorHandler.js' import type { K8sGateway } from '../src/k8s.js' import { createRpcAccessUsersRouter } from '../src/routes/rpc-access/users.js' @@ -393,63 +394,32 @@ describe('GET /rpc/access/users/:userId/mcp-hosts/:hostRef — happy path', () = }) }) -describe('GET /rpc/access/users/:userId/mcp-hosts/:hostRef/artifact-read', () => { - beforeEach(() => { - rateLimiterMock.checkAndIncrement.mockReset() - svc.getUserAgents.mockReset() - }) - - it('authorizes more than 30 reads without a limiter call', async () => { +describe('removed Host sub-paths are not served', () => { + it('answers 404 for message-resolution and artifact-read, and 200 for the parent path', async () => { const userId = 'user-a' - const hostRef = 'chatllm' - let charges = 0 - rateLimiterMock.checkAndIncrement.mockImplementation(async (_key: string, maximum: number) => { - charges += 1 - return { - allowed: charges <= maximum, - remaining: Math.max(0, maximum - charges), - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - count: charges, - backendAvailable: true, - } - }) + const hostRef = 'host-a' + svc.getUserAgents.mockReset() svc.getUserAgents.mockResolvedValue({ userId, agentNames: [hostRef] }) - const { app, gatewayStub } = buildApp([ - { metadata: { name: hostRef }, spec: { enabled: true } }, - ]) - for (let attempt = 0; attempt < 35; attempt += 1) { - const response = await request(app) - .get(`/rpc/access/users/${userId}/mcp-hosts/${hostRef}/artifact-read`) - .expect(200) - expect(response.body).toMatchObject({ userId, hostRef }) - } - expect(svc.getUserAgents).toHaveBeenCalledTimes(35) - expect(gatewayStub.listResource).toHaveBeenCalledTimes(35) - expect(rateLimiterMock.checkAndIncrement).not.toHaveBeenCalled() - }) + const { app } = buildApp([{ metadata: { name: hostRef }, spec: { enabled: true } }]) - it('rejects mismatched subject and signed Host before authorization', async () => { - authState.claims = { sub: 'user-a', hostRefs: ['host-a'] } - const { app, gatewayStub } = buildApp([]) - await request(app).get('/rpc/access/users/user-b/mcp-hosts/host-a/artifact-read').expect(403) - await request(app).get('/rpc/access/users/user-a/mcp-hosts/other/artifact-read').expect(403) + await request(app) + .post(`/rpc/access/users/${userId}/mcp-hosts/${hostRef}/message-resolution`) + .send({ content: 'hi' }) + .expect(404) + await request(app) + .get(`/rpc/access/users/${userId}/mcp-hosts/${hostRef}/artifact-read`) + .expect(404) expect(svc.getUserAgents).not.toHaveBeenCalled() - expect(gatewayStub.listResource).not.toHaveBeenCalled() - expect(rateLimiterMock.checkAndIncrement).not.toHaveBeenCalled() - }) - it('denies a deleted or disabled Host after checking the live grant', async () => { - svc.getUserAgents.mockResolvedValue({ userId: 'user-a', agentNames: ['host-a'] }) - const deleted = buildApp([]) - const disabled = buildApp([{ metadata: { name: 'host-a' }, spec: { enabled: false } }]) - for (const { app } of [deleted, disabled]) { - await request(app).get('/rpc/access/users/user-a/mcp-hosts/host-a/artifact-read').expect(403) - } - expect(svc.getUserAgents).toHaveBeenCalledTimes(2) - expect(deleted.gatewayStub.listResource).toHaveBeenCalledOnce() - expect(disabled.gatewayStub.listResource).toHaveBeenCalledOnce() - expect(rateLimiterMock.checkAndIncrement).not.toHaveBeenCalled() + const parent = await request(app) + .get(`/rpc/access/users/${userId}/mcp-hosts/${hostRef}`) + .expect(200) + expect(parent.body).toEqual({ + userId, + hostRef, + url: `http://${hostRef}.${config.hostsNamespace}.svc.cluster.local:8080`, + }) + expect(svc.getUserAgents).toHaveBeenCalledOnce() }) }) @@ -632,7 +602,7 @@ describe('GET /rpc/access/users/:userId/mcp-hosts/:hostRef — error propagation await request(app).get(`/rpc/access/users/u/mcp-hosts/product`).expect(500) }) - it('returns an operational 5xx, never 404, when the Host listing dependency answers 404', async () => { + it('forwards a Kubernetes 404 from the Host listing with its status', async () => { svc.getUserAgents.mockResolvedValue({ userId: 'u', agentNames: ['product'] }) const k8sNotFound = Object.assign(new Error('namespaces "mcp-host" not found'), { code: 404, @@ -664,11 +634,7 @@ describe('GET /rpc/access/users/:userId/mcp-hosts/:hostRef — error propagation app.use(clerumErrorHandler) const dependencyFailure = await request(app).get(`/rpc/access/users/u/mcp-hosts/product`) - // A Kubernetes 404 is a dependency failure on this RPC path: forwarding it - // as 404 would make rpc-proxy report a peer version mismatch. - expect(dependencyFailure.status).not.toBe(404) - expect(dependencyFailure.status).toBe(500) - expect(dependencyFailure.body).toMatchObject({ error: 'Internal Server Error' }) + expect(dependencyFailure.status).toBe(404) // Positive control in the same test: once the dependency answers, the same // route and handler resolve the Host normally. diff --git a/control-api/test/services.rpcHostAccessAuthorizer.test.ts b/control-api/test/services.rpcHostAccessAuthorizer.test.ts index 5787cdac3d..953c7eeeac 100644 --- a/control-api/test/services.rpcHostAccessAuthorizer.test.ts +++ b/control-api/test/services.rpcHostAccessAuthorizer.test.ts @@ -1,10 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { K8sGateway } from '../src/k8s.js' import type { RpcAccessClaims } from '../src/profileTypes.js' -import { - HostDirectoryUnavailableError, - authorizeRpcHostAccess, -} from '../src/services/access/rpcHostAccessAuthorizer.js' +import { authorizeRpcHostAccess } from '../src/services/access/rpcHostAccessAuthorizer.js' const CLAIMS: RpcAccessClaims = { sub: 'user-1', @@ -99,7 +96,7 @@ describe('authorizeRpcHostAccess', () => { ).resolves.toEqual({ authorized: false, reason }) }) - it('turns a Kubernetes 404 from the Host listing into an operational failure, never a denial', async () => { + it('propagates a Kubernetes 404 from the Host listing', async () => { const notFound = Object.assign(new Error('namespaces "mcp-host" not found'), { code: 404, body: { kind: 'Status', status: 'Failure', reason: 'NotFound' }, @@ -112,8 +109,7 @@ describe('authorizeRpcHostAccess', () => { const { directory } = dependencies() const rejection = authorizeRpcHostAccess(gateway, CLAIMS, 'user-1', 'host-a', directory) - await expect(rejection).rejects.toBeInstanceOf(HostDirectoryUnavailableError) - await expect(rejection).rejects.toMatchObject({ httpStatus: 500, cause: notFound }) + await expect(rejection).rejects.toBe(notFound) // Positive control in the same test: the same dependency path and Host // resolution work normally once the listing answers. diff --git a/deploy/base/control-plane/configmaps.yaml b/deploy/base/control-plane/configmaps.yaml index 933e015897..6f80b3653b 100644 --- a/deploy/base/control-plane/configmaps.yaml +++ b/deploy/base/control-plane/configmaps.yaml @@ -250,33 +250,6 @@ data: proxy_set_header X-Forwarded-Proto $scheme; } - # Host artifact reads and message sends resolve the Host through these - # sub-paths so control-api charges the matching admission bucket. The - # parent location above is anchored at the Host ref, so without these - # blocks both fall through to the default deny. rpc-proxy's - # gateway parity test fails when an emitted path has no location here. - location ~ ^/api/v1/rpc/access/users/[^/]+/mcp-hosts/[^/]+/artifact-read$ { - limit_except GET { - deny all; - } - proxy_pass http://control_api_upstream; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - location ~ ^/api/v1/rpc/access/users/[^/]+/mcp-hosts/[^/]+/message-resolution$ { - limit_except POST { - deny all; - } - proxy_pass http://control_api_upstream; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - # Stateless Agents — wake trigger for a suspended/draining host. # rpc-proxy forwards the caller's rpc access token; control-api # authorizes via requireRpcTokenHostMatch before any mutation. diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index 4b855fb5c4..b7a31e3198 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -9,7 +9,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.12.0', externalRestApiVersion: '0.1.158', - rpcProxyVersion: '0.1.135', + rpcProxyVersion: '0.1.136', desktopVersion: '0.12.0', minimumDesktopVersion: '0.1.252', } diff --git a/rpc-proxy/package-lock.json b/rpc-proxy/package-lock.json index 76b121959b..58b18683e7 100644 --- a/rpc-proxy/package-lock.json +++ b/rpc-proxy/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-rpc-proxy", - "version": "0.1.135", + "version": "0.1.136", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-rpc-proxy", - "version": "0.1.135", + "version": "0.1.136", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/rpc-proxy/package.json b/rpc-proxy/package.json index d72035baed..8123e1bcc1 100644 --- a/rpc-proxy/package.json +++ b/rpc-proxy/package.json @@ -1,6 +1,6 @@ { "name": "clerum-rpc-proxy", - "version": "0.1.135", + "version": "0.1.136", "description": "External-facing RPC proxy for tenant-scoped MCP access", "main": "dist/main.js", "engines": { diff --git a/rpc-proxy/src/__tests__/approval-artifacts.test.ts b/rpc-proxy/src/__tests__/approval-artifacts.test.ts index 85f8cc0a72..6b5d1ff412 100644 --- a/rpc-proxy/src/__tests__/approval-artifacts.test.ts +++ b/rpc-proxy/src/__tests__/approval-artifacts.test.ts @@ -14,7 +14,6 @@ const serviceMock = vi.hoisted(() => ({ listAllowedServersForUser: vi.fn(), resolveServerConnectionForUser: vi.fn(), resolveHostConnectionForUser: vi.fn(), - resolveArtifactReadHostConnectionForUser: vi.fn(), validateRpcRequest: vi.fn(), forwardRpcToServer: vi.fn(), forwardHostMessageToHost: vi.fn(), @@ -117,7 +116,6 @@ beforeEach(() => { // Default: auth passes, host resolves authTokenMock.verifyRpcToken.mockReturnValue({ ...VALID_CLAIMS }) serviceMock.resolveHostConnectionForUser.mockResolvedValue({ ...HOST_CONNECTION }) - serviceMock.resolveArtifactReadHostConnectionForUser.mockResolvedValue({ ...HOST_CONNECTION }) }) afterEach(() => { @@ -425,7 +423,7 @@ describe('GET /rpc/hosts/:hostRef/artifacts', () => { .set('authorization', 'Bearer token') .expect(200) - expect(serviceMock.resolveArtifactReadHostConnectionForUser).toHaveBeenCalledTimes(36) + expect(serviceMock.resolveHostConnectionForUser).toHaveBeenCalledTimes(36) expect(globalThis.fetch).toHaveBeenCalledTimes(36) }) @@ -434,7 +432,7 @@ describe('GET /rpc/hosts/:hostRef/artifacts', () => { authTokenMock.verifyRpcToken.mockImplementation(token => token === 'other-user-token' ? { ...VALID_CLAIMS, sub: 'other-user' } : { ...VALID_CLAIMS } ) - serviceMock.resolveArtifactReadHostConnectionForUser.mockImplementation( + serviceMock.resolveHostConnectionForUser.mockImplementation( async (_userId: string, hostRef: string) => ({ ...HOST_CONNECTION, name: hostRef }) ) const app = makeApp() @@ -510,7 +508,7 @@ describe('GET /rpc/hosts/:hostRef/artifacts', () => { }) it('returns 403 when host is not accessible', async () => { - serviceMock.resolveArtifactReadHostConnectionForUser.mockResolvedValue({ + serviceMock.resolveHostConnectionForUser.mockResolvedValue({ denied: true, code: 'host_access_denied', }) @@ -524,7 +522,7 @@ describe('GET /rpc/hosts/:hostRef/artifacts', () => { it('does not forward an artifact read when Host resolution fails', async () => { globalThis.fetch = vi.fn() - serviceMock.resolveArtifactReadHostConnectionForUser.mockRejectedValue( + serviceMock.resolveHostConnectionForUser.mockRejectedValue( new Error('Host resolution unavailable') ) const app = makeApp() @@ -974,7 +972,7 @@ describe('GET /rpc/hosts/:hostRef/artifacts/:filename/download', () => { }) it('returns 403 when host is not accessible', async () => { - serviceMock.resolveArtifactReadHostConnectionForUser.mockResolvedValue({ + serviceMock.resolveHostConnectionForUser.mockResolvedValue({ denied: true, code: 'host_access_denied', }) diff --git a/rpc-proxy/src/__tests__/control-api-gateway-parity.test.ts b/rpc-proxy/src/__tests__/control-api-gateway-parity.test.ts index 4cbfbb1b71..f56be076d4 100644 --- a/rpc-proxy/src/__tests__/control-api-gateway-parity.test.ts +++ b/rpc-proxy/src/__tests__/control-api-gateway-parity.test.ts @@ -8,7 +8,6 @@ import { config } from '../config.js' import { createMcpOauthRouter } from '../routes/mcpOauth.js' import { createSandboxUiSessionRouter } from '../routes/sandboxUi.js' import { - fetchArtifactReadHostConnectionFromControlApi, fetchHostConnectionFromControlApi, fetchUserAllowedServersFromControlApi, fetchUserConnectorsFromControlApi, @@ -204,8 +203,6 @@ async function emittedRequests(): Promise { { method: 'GET', path: `/api/v1/rpc/access/users/${USER}/mcp-connectors` }, { method: 'GET', path: `/api/v1/rpc/access/users/${USER}/mcp-hosts/${HOST}` }, { method: 'POST', path: `/api/v1/rpc/access/users/${USER}/mcp-hosts/${HOST}` }, - { - method: 'POST', - path: `/api/v1/rpc/access/users/${USER}/mcp-hosts/${HOST}/message-resolution`, - }, - { method: 'GET', path: `/api/v1/rpc/access/users/${USER}/mcp-hosts/${HOST}/artifact-read` }, { method: 'POST', path: `/api/v1/rpc/hosts/${HOST}/wake` }, { method: 'GET', path: '/api/v1/internal/sandbox-ui/registry/sandbox-recipes/r1' }, { method: 'GET', path: '/api/v1/internal/sandbox-ui/apps' }, @@ -281,24 +273,29 @@ describe('control-api-rpc-gateway parity with the rpc-proxy emitter', () => { it('keeps the default deny for paths and methods rpc-proxy does not emit', () => { const locations = gatewayLocations(gatewayNginxConf()) // Witness that the matcher can deny: these would pass a matcher that - // admits everything. - expect(gatewayAdmits(locations, 'GET', `/api/v1/rpc/access/users/${USER}/contexts`)).toBe(false) - expect( - gatewayAdmits( - locations, - 'POST', - `/api/v1/rpc/access/users/${USER}/mcp-hosts/${HOST}/artifact-read` - ) - ).toBe(false) - expect( - gatewayAdmits( - locations, - 'GET', - `/api/v1/rpc/access/users/${USER}/mcp-hosts/${HOST}/message-resolution` - ) - ).toBe(false) - expect( - gatewayAdmits(locations, 'GET', `/api/v1/rpc/access/users/${USER}/mcp-hosts/${HOST}/other`) - ).toBe(false) + // admits everything. The parent Host path stays admitted (GET and POST) + // in the emitter test above. Both methods of the removed sub-paths are + // denied, so restoring either location block turns this red. Reintroducing + // a messageResolution emission would request /message-resolution, which + // selects no location and fails the emitter test. + const admits = (method: string, path: string): boolean => { + try { + return gatewayAdmits(locations, method, path) + } catch (error) { + if (error instanceof Error && error.message.startsWith('no gateway location selects ')) { + return false + } + throw error + } + } + expect(admits('GET', `/api/v1/rpc/access/users/${USER}/contexts`)).toBe(false) + const removed = `/api/v1/rpc/access/users/${USER}/mcp-hosts/${HOST}` + for (const method of ['GET', 'POST']) { + expect(admits(method, `${removed}/artifact-read`)).toBe(false) + expect(admits(method, `${removed}/message-resolution`)).toBe(false) + } + expect(gatewayAdmits(locations, 'GET', removed)).toBe(true) + expect(gatewayAdmits(locations, 'POST', removed)).toBe(true) + expect(admits('GET', `${removed}/other`)).toBe(false) }) }) diff --git a/rpc-proxy/src/__tests__/errorHandler.test.ts b/rpc-proxy/src/__tests__/errorHandler.test.ts index 89d56594b4..ae923027b4 100644 --- a/rpc-proxy/src/__tests__/errorHandler.test.ts +++ b/rpc-proxy/src/__tests__/errorHandler.test.ts @@ -3,7 +3,6 @@ import express from 'express' import request from 'supertest' import { createApp } from '../app.js' import { apiErrorHandler } from '../errorHandler.js' -import { ControlApiHostVersionMismatchError } from '../services/controlApiVersionError.js' function appThrowing(error: unknown) { const app = express() @@ -44,14 +43,6 @@ describe('apiErrorHandler', () => { expect(res.body).toEqual({ error: 'Internal Server Error', message: 'kaput' }) }) - it('names a control-api Host operation version mismatch as a 503, never a denial', async () => { - const res = await request( - appThrowing(new ControlApiHostVersionMismatchError('host-message-resolution')) - ).get('/boom') - expect(res.status).toBe(503) - expect(res.body).toEqual({ error: 'control_api_version_mismatch' }) - }) - it('is the terminal handler of the real app (tests and production share it)', () => { // Express 4 keeps its layer stack on `_router`; the public `router` getter // throws a deprecation error. diff --git a/rpc-proxy/src/__tests__/host-access-denial-code.route.test.ts b/rpc-proxy/src/__tests__/host-access-denial-code.route.test.ts index e21b620007..a0853a74a0 100644 --- a/rpc-proxy/src/__tests__/host-access-denial-code.route.test.ts +++ b/rpc-proxy/src/__tests__/host-access-denial-code.route.test.ts @@ -249,7 +249,7 @@ describe('host authorization 403 carries a machine-readable code at every site', ) it.each(DENYING_ROUTES)( - '$label: a missing control-api operation (404) -> typed version mismatch', + '$label: an unbound control-api 404 -> host_access_denied', async route => { await proveAuthorizedForwarding(route) // Witness: an authorized request reached control-api and the route then @@ -265,14 +265,14 @@ describe('host authorization 403 carries a machine-readable code at every site', // ran) and contacted no Host rail. expect(controlApiCalls).toHaveLength(2) expect(hostCalls).toHaveLength(1) - expect(res.status).toBe(503) - expect(res.body).toEqual({ error: 'control_api_version_mismatch' }) + expect(res.status).toBe(403) + expect(res.body).toEqual({ error: DENIED_ERROR, code: 'host_access_denied' }) } ) it('messages: a real 403 denial stays host_access_denied', async () => { - // Witness for the 404 test above: the 403 mapping is still live on the same - // route, so the version-mismatch body is not a blanket replacement. + // Witness for the 404 test above: a coded 403 is still mapped on the same + // route, so the 404 denial is not a blanket replacement of every refusal. const messages = DENYING_ROUTES.find(route => route.label === 'messages')! await proveAuthorizedForwarding(messages) expect(controlApiCalls).toHaveLength(1) @@ -293,7 +293,7 @@ describe('host authorization 403 carries a machine-readable code at every site', expect(res.body).toEqual({ error: DENIED_ERROR, code: 'host_access_denied' }) }) - it('messages: a threaded send (direct-run-bound) 404 -> typed version mismatch, never a 502', async () => { + it('messages: a threaded send posts the binding to the parent path, and a 404 is an upstream failure', async () => { const threaded: RouteCase = { label: 'messages with threadId', method: 'post', @@ -304,9 +304,9 @@ describe('host authorization 403 carries a machine-readable code at every site', expect(controlApiCalls).toHaveLength(1) expect(hostCalls).toHaveLength(1) - // The threaded send is the direct-run-bound production shape: it must have - // POSTed the derived binding to /message-resolution before forwarding. - expect(String(controlApiRequests[0]!.url)).toMatch(/\/message-resolution$/) + // The threaded send is the direct-run-bound production shape: it POSTs + // the derived binding to the parent Host path before forwarding. + expect(String(controlApiRequests[0]!.url)).toMatch(/\/mcp-hosts\/chatllm$/) expect(controlApiRequests[0]!.init?.method).toBe('POST') expect(JSON.parse(String(controlApiRequests[0]!.init?.body))).toMatchObject({ runId: expect.any(String), @@ -320,13 +320,13 @@ describe('host authorization 403 carries a machine-readable code at every site', expect(controlApiCalls).toHaveLength(2) expect(hostCalls).toHaveLength(1) - expect(res.status).toBe(503) - expect(res.body).toEqual({ error: 'control_api_version_mismatch' }) + expect(res.status).toBe(502) + expect(res.body).toEqual({ error: 'Upstream host unavailable' }) }) // These two answer their own 404 (rather than a coded 403) on a Host denial, - // so they live outside DENYING_ROUTES; their outer catches used to turn the - // same control-api 404 into 502. Both must reach the terminal 503 mapping. + // so they live outside DENYING_ROUTES. An unbound control-api 404 is that + // denial, not an upstream failure. it.each([ { label: 'task result', @@ -338,7 +338,7 @@ describe('host authorization 403 carries a machine-readable code at every site', method: 'post' as const, path: '/rpc/hosts/chatllm/tasks/task-1/cancel', }, - ])('$label: a missing control-api operation (404) -> typed version mismatch', async route => { + ])('$label: an unbound control-api 404 stays Host-not-accessible', async route => { await proveAuthorizedForwarding(route) expect(controlApiCalls).toHaveLength(1) expect(hostCalls).toHaveLength(1) @@ -349,8 +349,8 @@ describe('host authorization 403 carries a machine-readable code at every site', expect(controlApiCalls).toHaveLength(2) expect(hostCalls).toHaveLength(1) - expect(res.status).toBe(503) - expect(res.body).toEqual({ error: 'control_api_version_mismatch' }) + expect(res.status).toBe(404) + expect(res.body).toEqual({ error: 'Host not found or not accessible' }) }) }) diff --git a/rpc-proxy/src/__tests__/host-message-admission-transport.test.ts b/rpc-proxy/src/__tests__/host-message-admission-transport.test.ts deleted file mode 100644 index 7a9c82b2bb..0000000000 --- a/rpc-proxy/src/__tests__/host-message-admission-transport.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { fetchHostConnectionFromControlApi } from '../services/controlApiRestService.js' - -const USER = '00000000-0000-4000-8000-000000000001' - -describe('Control API Host-message resolution transport', () => { - it('uses the dedicated POST for ordinary and direct-run-bound sends', async () => { - const fetchImpl = vi.fn(async (_url: string, _init: RequestInit) => - Response.json({ - userId: USER, - hostRef: 'host-a', - url: 'http://host-a.mcp-host.svc.cluster.local:8080', - bindingStatus: 'recorded', - }) - ) as unknown as typeof fetch - - await fetchHostConnectionFromControlApi(USER, 'host-a', 'jwt', { - messageResolution: true, - fetchImpl, - }) - await fetchHostConnectionFromControlApi(USER, 'host-a', 'jwt', { - messageResolution: true, - directRunBinding: { - runId: '00000000-0000-4000-8000-000000000123', - sessionId: 'session-a', - origin: 'direct_chat', - }, - fetchImpl, - }) - const calls = vi.mocked(fetchImpl).mock.calls - expect(calls).toHaveLength(2) - expect(String(calls[0][0])).toMatch(/\/mcp-hosts\/host-a\/message-resolution$/) - expect(calls[0][1]).toMatchObject({ method: 'POST', body: '{}' }) - expect(calls[1][1]).toMatchObject({ method: 'POST' }) - expect(JSON.parse(String(calls[1][1]?.body))).toMatchObject({ sessionId: 'session-a' }) - }) - - it.each([429, 503])('treats a %s resolution response as a failed lookup', async status => { - const fetchImpl = vi.fn(async () => new Response(null, { status })) as unknown as typeof fetch - await expect( - fetchHostConnectionFromControlApi(USER, 'host-a', crypto.randomUUID(), { - messageResolution: true, - fetchImpl, - }) - ).rejects.toThrow(`Control API MCP host lookup failed (${status})`) - expect(fetchImpl).toHaveBeenCalledOnce() - }) -}) diff --git a/rpc-proxy/src/__tests__/route-upstream-deadlines.real-http.test.ts b/rpc-proxy/src/__tests__/route-upstream-deadlines.real-http.test.ts index c483916fef..e85e8f225c 100644 --- a/rpc-proxy/src/__tests__/route-upstream-deadlines.real-http.test.ts +++ b/rpc-proxy/src/__tests__/route-upstream-deadlines.real-http.test.ts @@ -31,7 +31,6 @@ const serviceMock = vi.hoisted(() => ({ listAllowedServersForUser: vi.fn(), resolveServerConnectionForUser: vi.fn(), resolveHostConnectionForUser: vi.fn(), - resolveArtifactReadHostConnectionForUser: vi.fn(), validateRpcRequest: vi.fn(), forwardRpcToServer: vi.fn(), forwardHostMessageToHost: vi.fn(), @@ -119,7 +118,6 @@ async function startUpstream(handler: Handler): Promise { headers: {}, } serviceMock.resolveHostConnectionForUser.mockResolvedValue(connection) - serviceMock.resolveArtifactReadHostConnectionForUser.mockResolvedValue(connection) } /** Ends the TCP connection without a response: undici reports it as `fetch failed`. */ @@ -723,10 +721,7 @@ describe.each(HELD_ROUTES)('R3-L7 $label pins the hold deadline before host reso // the wake retry is never answered }) const { port } = server!.address() as AddressInfo - const resolveHost = - route.label === 'list artifacts' || route.label === 'artifact download' - ? serviceMock.resolveArtifactReadHostConnectionForUser - : serviceMock.resolveHostConnectionForUser + const resolveHost = serviceMock.resolveHostConnectionForUser resolveHost.mockImplementation(async () => { await new Promise(resolve => setTimeout(resolve, HOST_RESOLUTION_LATENCY_MS)) return { name: 'chatllm', url: `http://127.0.0.1:${port}`, headers: {} } diff --git a/rpc-proxy/src/__tests__/sender-assignment.test.ts b/rpc-proxy/src/__tests__/sender-assignment.test.ts index d29c4bd7e6..c306feee62 100644 --- a/rpc-proxy/src/__tests__/sender-assignment.test.ts +++ b/rpc-proxy/src/__tests__/sender-assignment.test.ts @@ -145,7 +145,7 @@ describe('POST /rpc/hosts/:hostRef/messages — sender assignment invariant', () 'user-uuid-abc', 'chatllm', 'token', - { teamId: null, messageResolution: true } + { teamId: null } ) const forwardedBody = serviceMock.forwardHostMessageToHost.mock.calls[0][1] expect(forwardedBody.metadata).toEqual({ accessScope: 'user' }) diff --git a/rpc-proxy/src/__tests__/wake-and-hold-idempotent-gets.real-http.test.ts b/rpc-proxy/src/__tests__/wake-and-hold-idempotent-gets.real-http.test.ts index 87abf3bde1..1f009f4fd2 100644 --- a/rpc-proxy/src/__tests__/wake-and-hold-idempotent-gets.real-http.test.ts +++ b/rpc-proxy/src/__tests__/wake-and-hold-idempotent-gets.real-http.test.ts @@ -18,7 +18,6 @@ const serviceMock = vi.hoisted(() => ({ listAllowedServersForUser: vi.fn(), resolveServerConnectionForUser: vi.fn(), resolveHostConnectionForUser: vi.fn(), - resolveArtifactReadHostConnectionForUser: vi.fn(), validateRpcRequest: vi.fn(), forwardRpcToServer: vi.fn(), forwardHostMessageToHost: vi.fn(), @@ -99,7 +98,6 @@ async function startUpstream(handler: Handler): Promise { headers: {}, } serviceMock.resolveHostConnectionForUser.mockResolvedValue(connection) - serviceMock.resolveArtifactReadHostConnectionForUser.mockResolvedValue(connection) } /** Ends the TCP connection without a response: undici reports it as `fetch failed`. */ diff --git a/rpc-proxy/src/__tests__/wake-and-hold-route.test.ts b/rpc-proxy/src/__tests__/wake-and-hold-route.test.ts index 9bb66c4fbd..07a7f87f97 100644 --- a/rpc-proxy/src/__tests__/wake-and-hold-route.test.ts +++ b/rpc-proxy/src/__tests__/wake-and-hold-route.test.ts @@ -134,9 +134,6 @@ describe('POST /rpc/hosts/:hostRef/messages wake-and-hold triggers', () => { expect(response.body).toEqual({ success: true, taskId: 't-1' }) expect(serviceMock.resolveHostConnectionForUser).toHaveBeenCalledTimes(1) - expect(serviceMock.resolveHostConnectionForUser.mock.calls[0]?.[3]).toMatchObject({ - messageResolution: true, - }) expect(controlApiMock.requestHostWakeFromControlApi).toHaveBeenCalledTimes(1) expect(controlApiMock.requestHostWakeFromControlApi).toHaveBeenCalledWith('chatllm', 'token') expect(serviceMock.forwardHostMessageToHost).toHaveBeenCalledTimes(2) diff --git a/rpc-proxy/src/__tests__/wake-route-coverage.test.ts b/rpc-proxy/src/__tests__/wake-route-coverage.test.ts index ef0e046c03..c68ff812eb 100644 --- a/rpc-proxy/src/__tests__/wake-route-coverage.test.ts +++ b/rpc-proxy/src/__tests__/wake-route-coverage.test.ts @@ -20,7 +20,6 @@ const serviceMock = vi.hoisted(() => ({ listAllowedServersForUser: vi.fn(), resolveServerConnectionForUser: vi.fn(), resolveHostConnectionForUser: vi.fn(), - resolveArtifactReadHostConnectionForUser: vi.fn(), validateRpcRequest: vi.fn(), forwardRpcToServer: vi.fn(), forwardHostMessageToHost: vi.fn(), @@ -107,7 +106,6 @@ const originalUpstreamTimeoutMs = config.upstreamTimeoutMs beforeEach(() => { vi.clearAllMocks() serviceMock.resolveHostConnectionForUser.mockResolvedValue({ ...HOST_CONNECTION }) - serviceMock.resolveArtifactReadHostConnectionForUser.mockResolvedValue({ ...HOST_CONNECTION }) controlApiMock.requestHostWakeFromControlApi.mockResolvedValue({ kind: 'active', wakeGeneration: null, diff --git a/rpc-proxy/src/errorHandler.ts b/rpc-proxy/src/errorHandler.ts index baf2cfd265..9d588dc422 100644 --- a/rpc-proxy/src/errorHandler.ts +++ b/rpc-proxy/src/errorHandler.ts @@ -1,5 +1,4 @@ import type { NextFunction, Request, Response } from 'express' -import { ControlApiHostVersionMismatchError } from './services/controlApiVersionError.js' import { UpstreamBodyReadError } from './services/upstreamBody.js' import { isUpstreamTimeoutError } from './services/wakeAndHold.js' @@ -33,15 +32,6 @@ export function apiErrorHandler( return } - // control-api answered 404 for a Host-access operation this edge requires: - // the peer predates the route. That is a deployment version mismatch, not a - // refusal of Host access, so it must never collapse into a 403 denial or an - // opaque 500. The public body names the mismatch; the operation stays local. - if (err instanceof ControlApiHostVersionMismatchError) { - res.status(503).json({ error: 'control_api_version_mismatch' }) - return - } - // The upstream answered with headers and its body then failed: a gateway // failure on a request the host received, never an rpc-proxy fault (500). if (err instanceof UpstreamBodyReadError) { diff --git a/rpc-proxy/src/routes/rpc.ts b/rpc-proxy/src/routes/rpc.ts index 2b917f690e..9c2907c7b3 100644 --- a/rpc-proxy/src/routes/rpc.ts +++ b/rpc-proxy/src/routes/rpc.ts @@ -17,7 +17,6 @@ import { fetchUserConnectorsFromControlApi, requestHostWakeFromControlApi, } from '../services/controlApiRestService.js' -import { ControlApiHostVersionMismatchError } from '../services/controlApiVersionError.js' import { type HostAccessDenialCode, isHostAccessDenied, @@ -34,7 +33,6 @@ import { forwardRpcToServer, forwardTaskResultFromHost, listAllowedServersForUser, - resolveArtifactReadHostConnectionForUser, resolveHostConnectionForUser, resolveServerConnectionForUser, validateRpcRequest, @@ -58,14 +56,6 @@ const RFC1123_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/ const SESSIONS_LIMIT_CAP = 100 const MESSAGES_LIMIT_CAP = 200 -type ArtifactReadRequest = AuthedRequest & { - artifactReadWakeDeadlineMs?: number - artifactReadHost?: Extract< - Awaited>, - { url: string } - > -} - function isSafeUpstreamPathSegment(value: string): boolean { return ( value.length > 0 && @@ -290,40 +280,6 @@ async function readArtifactResponseBuffer(response: Response): Promise { export function createRpcRouter(): Router { const router = Router() - const resolveArtifactReadHost = async ( - req: ArtifactReadRequest, - res: ExpressResponse, - next: NextFunction - ) => { - try { - const auth = req.auth! - const hostRef = String(req.params.hostRef || '').trim() - if (!hostRef) { - res.status(400).json({ error: 'hostRef is required' }) - return - } - if (!RFC1123_RE.test(hostRef)) { - res.status(400).json({ error: 'Invalid host reference' }) - return - } - req.artifactReadWakeDeadlineMs = Date.now() + config.wakeMaxHoldMs - const artifactReadHost = await resolveArtifactReadHostConnectionForUser( - auth.sub, - hostRef, - extractAuthToken(req), - { teamId: auth.teamId } - ) - if (isHostAccessDenied(artifactReadHost)) { - respondHostAccessDenied(res, artifactReadHost) - return - } - req.artifactReadHost = artifactReadHost - next() - } catch (error) { - guardedNext(res, next, error) - } - } - router.get( '/rpc/servers', requireRpcAuth, @@ -431,7 +387,7 @@ export function createRpcRouter(): Router { requireRpcAuth, requireScope('host:message:invoke'), chatJsonBody, - async (req: AuthedRequest, res, next) => { + async (req: AuthedRequest, res) => { // Host runtime write path (REST-oriented): // - separate from read-only status stream // - explicitly scoped to host:message:invoke @@ -525,7 +481,6 @@ export function createRpcRouter(): Router { const wakeDeadlineMs = Date.now() + config.wakeMaxHoldMs const host = await resolveHostConnectionForUser(auth.sub, hostRef, rpcAccessToken, { teamId: auth.teamId, - messageResolution: true, ...(traceContext.sessionId ? { directRunBinding: { @@ -612,12 +567,6 @@ export function createRpcRouter(): Router { respondControlApiHostAccessRejection(res, rejection) return } - if (error instanceof ControlApiHostVersionMismatchError) { - // Let the terminal handler name the deployment mismatch as a 503 - // instead of misreporting it as an unavailable Host (502) or denial. - next(error) - return - } console.warn( `[RPC_PROXY] host message forward failed host=${sanitizeHostRefForLog(String(req.params.hostRef || '').trim())} error=${describeErrorForLog( error @@ -1365,7 +1314,7 @@ export function createRpcRouter(): Router { '/rpc/hosts/:hostRef/tasks/:taskId/result', requireRpcAuth, requireScope('host:message:invoke'), - async (req: AuthedRequest, res, next) => { + async (req: AuthedRequest, res) => { try { const auth = req.auth! const rpcAccessToken = extractAuthToken(req) @@ -1422,10 +1371,6 @@ export function createRpcRouter(): Router { respondUpstreamUnavailable(res, error) } } catch (error) { - if (error instanceof ControlApiHostVersionMismatchError) { - next(error) - return - } console.warn(`[RPC_PROXY] task result forward failed error=${describeErrorForLog(error)}`) respondUpstreamUnavailable(res, error) } @@ -1436,7 +1381,7 @@ export function createRpcRouter(): Router { '/rpc/hosts/:hostRef/tasks/:taskId/cancel', requireRpcAuth, requireScope('host:message:invoke'), - async (req: AuthedRequest, res, next) => { + async (req: AuthedRequest, res) => { try { const auth = req.auth! const rpcAccessToken = extractAuthToken(req) @@ -1502,10 +1447,6 @@ export function createRpcRouter(): Router { respondUpstreamUnavailable(res, error) } } catch (error) { - if (error instanceof ControlApiHostVersionMismatchError) { - next(error) - return - } console.warn(`[RPC_PROXY] cancel forward failed error=${describeErrorForLog(error)}`) respondUpstreamUnavailable(res, error) } @@ -1517,14 +1458,27 @@ export function createRpcRouter(): Router { '/rpc/hosts/:hostRef/artifacts', requireRpcAuth, requireScope('host:task:read'), - resolveArtifactReadHost, async (req: AuthedRequest, res, next) => { try { const auth = req.auth! const rpcAccessToken = extractAuthToken(req) const hostRef = String(req.params.hostRef || '').trim() - const host = (req as ArtifactReadRequest).artifactReadHost! - const wakeDeadlineMs = (req as ArtifactReadRequest).artifactReadWakeDeadlineMs! + if (!hostRef) { + res.status(400).json({ error: 'hostRef is required' }) + return + } + if (!RFC1123_RE.test(hostRef)) { + res.status(400).json({ error: 'Invalid host reference' }) + return + } + const wakeDeadlineMs = Date.now() + config.wakeMaxHoldMs + const host = await resolveHostConnectionForUser(auth.sub, hostRef, rpcAccessToken, { + teamId: auth.teamId, + }) + if (isHostAccessDenied(host)) { + respondHostAccessDenied(res, host) + return + } const baseUrl = host.url.replace(/\/+$/, '') // Wake-eligible finite operation (§11.4): scope stays host:task:read. // Unlike approve/deny/model this is a read-only list of small JSON: a @@ -1569,29 +1523,38 @@ export function createRpcRouter(): Router { '/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, requireScope('host:task:read'), - (req: AuthedRequest, res, next) => { - const filename = String(req.params.filename || '').trim() - if ( - !filename || - filename.includes('..') || - filename.includes('/') || - filename.includes('\\') || - filename.includes('\0') - ) { - res.status(400).json({ error: 'Invalid filename' }) - return - } - next() - }, - resolveArtifactReadHost, async (req: AuthedRequest, res, next) => { try { const auth = req.auth! const rpcAccessToken = extractAuthToken(req) const hostRef = String(req.params.hostRef || '').trim() const filename = String(req.params.filename || '').trim() - const host = (req as ArtifactReadRequest).artifactReadHost! - const wakeDeadlineMs = (req as ArtifactReadRequest).artifactReadWakeDeadlineMs! + if (!hostRef) { + res.status(400).json({ error: 'hostRef is required' }) + return + } + if (!RFC1123_RE.test(hostRef)) { + res.status(400).json({ error: 'Invalid host reference' }) + return + } + if ( + !filename || + filename.includes('..') || + filename.includes('/') || + filename.includes('\\') || + filename.includes('\0') + ) { + res.status(400).json({ error: 'Invalid filename' }) + return + } + const wakeDeadlineMs = Date.now() + config.wakeMaxHoldMs + const host = await resolveHostConnectionForUser(auth.sub, hostRef, rpcAccessToken, { + teamId: auth.teamId, + }) + if (isHostAccessDenied(host)) { + respondHostAccessDenied(res, host) + return + } const baseUrl = host.url.replace(/\/+$/, '') // Wake-eligible finite operation (§11.4): scope stays host:task:read. // The success path only commits (`res.send`) at the very end, so a diff --git a/rpc-proxy/src/routes/rpcHostActivityStream.ts b/rpc-proxy/src/routes/rpcHostActivityStream.ts index c4e48fdfed..abbb25f6c8 100644 --- a/rpc-proxy/src/routes/rpcHostActivityStream.ts +++ b/rpc-proxy/src/routes/rpcHostActivityStream.ts @@ -6,7 +6,6 @@ import { requireRpcAuth, requireScope, } from '../middleware/auth.js' -import { ControlApiHostVersionMismatchError } from '../services/controlApiVersionError.js' import { isHostAccessDenied, respondHostAccessDenied } from '../services/hostAccessDenial.js' import { resolveHostConnectionForUser } from '../services/mcpProxyService.js' @@ -242,10 +241,6 @@ export function createRpcHostActivityStreamRouter(): Router { cleanup('client-disconnect') }) } catch (error) { - if (!res.headersSent && error instanceof ControlApiHostVersionMismatchError) { - next(error) - return - } if (!res.headersSent) { res.status(500).json({ error: 'Internal Server Error' }) return diff --git a/rpc-proxy/src/routes/rpcHostProgressStream.ts b/rpc-proxy/src/routes/rpcHostProgressStream.ts index 2f5bb8890e..d0d5ca1505 100644 --- a/rpc-proxy/src/routes/rpcHostProgressStream.ts +++ b/rpc-proxy/src/routes/rpcHostProgressStream.ts @@ -6,7 +6,6 @@ import { requireRpcAuth, requireScope, } from '../middleware/auth.js' -import { ControlApiHostVersionMismatchError } from '../services/controlApiVersionError.js' import { isHostAccessDenied, respondHostAccessDenied } from '../services/hostAccessDenial.js' import { resolveHostConnectionForUser } from '../services/mcpProxyService.js' @@ -210,10 +209,6 @@ export function createRpcHostProgressStreamRouter(): Router { cleanup('client-disconnect') }) } catch (error) { - if (!res.headersSent && error instanceof ControlApiHostVersionMismatchError) { - next(error) - return - } if (!res.headersSent) { res.status(500).json({ error: 'Internal Server Error' }) return diff --git a/rpc-proxy/src/routes/rpcHostStatusStream.ts b/rpc-proxy/src/routes/rpcHostStatusStream.ts index 7deb90b6c6..3352425420 100644 --- a/rpc-proxy/src/routes/rpcHostStatusStream.ts +++ b/rpc-proxy/src/routes/rpcHostStatusStream.ts @@ -6,7 +6,6 @@ import { requireRpcAuth, requireScope, } from '../middleware/auth.js' -import { ControlApiHostVersionMismatchError } from '../services/controlApiVersionError.js' import { isHostAccessDenied, respondHostAccessDenied } from '../services/hostAccessDenial.js' import { UpstreamHostError, @@ -223,10 +222,6 @@ export function createRpcHostStatusStreamRouter(): Router { cleanup('client-disconnect') }) } catch (error) { - if (!res.headersSent && error instanceof ControlApiHostVersionMismatchError) { - next(error) - return - } console.warn( `[RPC_PROXY] host status stream setup failed host=${String(req.params.hostRef || '').trim()} error=${ error instanceof Error ? error.message : String(error) diff --git a/rpc-proxy/src/services/controlApiRestService.ts b/rpc-proxy/src/services/controlApiRestService.ts index 9c03c6ffe6..961d798f48 100644 --- a/rpc-proxy/src/services/controlApiRestService.ts +++ b/rpc-proxy/src/services/controlApiRestService.ts @@ -1,9 +1,5 @@ import { config } from '../config.js' import { ResolvedServerConnection } from '../types.js' -import { - type ControlApiHostOperation, - ControlApiHostVersionMismatchError, -} from './controlApiVersionError.js' import { type HostAccessDenial, type HostAccessDenialCode, @@ -225,74 +221,30 @@ export async function fetchHostConnectionFromControlApi( rpcAccessToken: string, options: { directRunBinding?: DirectRunBindingRequest - messageResolution?: boolean fetchImpl?: typeof fetch } = {} -): Promise { - return fetchHostConnectionForPath(userId, hostRef, rpcAccessToken, options) -} - -/** - * Resolves a Host connection through Control API's dedicated artifact-read - * operation, which authenticates the RPC token and live-authorizes the Host. - */ -export async function fetchArtifactReadHostConnectionFromControlApi( - userId: string, - hostRef: string, - rpcAccessToken: string, - options: { fetchImpl?: typeof fetch } = {} -): Promise { - return fetchHostConnectionForPath(userId, hostRef, rpcAccessToken, { - ...options, - artifactRead: true, - }) -} - -async function fetchHostConnectionForPath( - userId: string, - hostRef: string, - rpcAccessToken: string, - options: { - directRunBinding?: DirectRunBindingRequest - fetchImpl?: typeof fetch - artifactRead?: boolean - messageResolution?: boolean - } = {} ): Promise { const directRunBinding = options.directRunBinding - const hostAccessPath = `${controlApiBaseUrl()}/rpc/access/users/${encodeURIComponent(userId)}/mcp-hosts/${encodeURIComponent(hostRef)}` - const messageResolution = options.messageResolution === true - const operation: ControlApiHostOperation = options.artifactRead - ? 'host-artifact-read' - : messageResolution - ? 'host-message-resolution' - : 'host-access' const response = await (options.fetchImpl ?? fetch)( - options.artifactRead - ? `${hostAccessPath}/artifact-read` - : messageResolution - ? `${hostAccessPath}/message-resolution` - : hostAccessPath, + `${controlApiBaseUrl()}/rpc/access/users/${encodeURIComponent(userId)}/mcp-hosts/${encodeURIComponent(hostRef)}`, { - method: messageResolution || directRunBinding ? 'POST' : 'GET', + method: directRunBinding ? 'POST' : 'GET', headers: { ...controlApiHeaders(rpcAccessToken), - ...(messageResolution || directRunBinding ? { 'content-type': 'application/json' } : {}), + ...(directRunBinding ? { 'content-type': 'application/json' } : {}), }, - ...(messageResolution || directRunBinding - ? { body: JSON.stringify(directRunBinding ?? {}) } - : {}), + ...(directRunBinding ? { body: JSON.stringify(directRunBinding) } : {}), signal: upstreamAbortSignal(), } ) - if (response.status === 404) { - await drainBody(response) - throw new ControlApiHostVersionMismatchError(operation) - } if (!directRunBinding && response.status === 403) { return hostAccessDenied(await readHostAccessDenialCode(response)) } + if (!directRunBinding && response.status === 404) { + await drainBody(response) + return hostAccessDenied('host_access_denied') + } if (response.status === 403) { throw new ControlApiHostAccessRejectedError(403, await readHostAccessDenialCode(response)) } diff --git a/rpc-proxy/src/services/controlApiVersionError.ts b/rpc-proxy/src/services/controlApiVersionError.ts deleted file mode 100644 index 0d40c26232..0000000000 --- a/rpc-proxy/src/services/controlApiVersionError.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * A Host-access operation this rpc-proxy requires answered 404 from control-api. - * The peer predates the route, so this is a producer/consumer version mismatch, - * never an authorization decision about the user or the Host. - * - * It lives in its own module so every consumer imports one class identity - * without depending on the HTTP client module, whose surface route tests - * replace with a partial mock. - */ -export type ControlApiHostOperation = - | 'host-access' - | 'host-message-resolution' - | 'host-artifact-read' - -export class ControlApiHostVersionMismatchError extends Error { - constructor(readonly operation: ControlApiHostOperation) { - super(`Control API does not implement ${operation} (version mismatch, HTTP 404)`) - this.name = 'ControlApiHostVersionMismatchError' - } -} diff --git a/rpc-proxy/src/services/mcpProxyService.ts b/rpc-proxy/src/services/mcpProxyService.ts index 329e0a5ba4..7cf8595b1a 100644 --- a/rpc-proxy/src/services/mcpProxyService.ts +++ b/rpc-proxy/src/services/mcpProxyService.ts @@ -3,7 +3,6 @@ import { ResolvedServerConnection } from '../types.js' import { type DirectRunBindingRequest, type UserAllowedServers, - fetchArtifactReadHostConnectionFromControlApi, fetchHostConnectionFromControlApi, fetchUserAllowedServersFromControlApi, } from './controlApiRestService.js' @@ -84,12 +83,10 @@ export async function resolveHostConnectionForUser( teamId?: string | null requestId?: string directRunBinding?: DirectRunBindingRequest - messageResolution?: boolean } ): Promise { const host = await fetchHostConnectionFromControlApi(userId, hostRef, rpcAccessToken, { directRunBinding: edgeContext?.directRunBinding, - messageResolution: edgeContext?.messageResolution, }) if (isHostAccessDenied(host)) return host @@ -109,31 +106,3 @@ export async function resolveHostConnectionForUser( headers, } } - -/** Resolve the Host through the Control API's dedicated artifact-read operation. */ -export async function resolveArtifactReadHostConnectionForUser( - userId: string, - hostRef: string, - rpcAccessToken: string, - edgeContext?: { - accessScope?: 'team' | 'user' - teamId?: string | null - requestId?: string - } -): Promise { - const host = await fetchArtifactReadHostConnectionFromControlApi(userId, hostRef, rpcAccessToken) - if (isHostAccessDenied(host)) return host - - const headers: Record = { - ...host.headers, - 'x-clerum-edge-caller': 'rpc-proxy', - 'x-clerum-edge-host-ref': hostRef, - 'x-clerum-edge-user-id': userId, - } - if (edgeContext?.teamId) headers['x-clerum-edge-team-id'] = edgeContext.teamId - headers['x-clerum-edge-access-scope'] = - edgeContext?.accessScope ?? (edgeContext?.teamId ? 'team' : 'user') - if (edgeContext?.requestId) headers['x-clerum-edge-request-id'] = edgeContext.requestId - - return { ...host, headers } -} diff --git a/rpc-proxy/test/routes.rpc.test.ts b/rpc-proxy/test/routes.rpc.test.ts index fd5b08720f..3527e6a7fb 100644 --- a/rpc-proxy/test/routes.rpc.test.ts +++ b/rpc-proxy/test/routes.rpc.test.ts @@ -247,7 +247,6 @@ describe('routes/rpc', () => { 'agent2', 'token', { - messageResolution: true, teamId: 'team-1', } ) @@ -284,7 +283,6 @@ describe('routes/rpc', () => { 'agent2', 'rpc-token', { - messageResolution: true, teamId: 'team-1', directRunBinding: { runId: expect.any(String), diff --git a/rpc-proxy/test/services.controlApiHostAccess.test.ts b/rpc-proxy/test/services.controlApiHostAccess.test.ts index 11f0d3f757..79343698f1 100644 --- a/rpc-proxy/test/services.controlApiHostAccess.test.ts +++ b/rpc-proxy/test/services.controlApiHostAccess.test.ts @@ -1,10 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import { ControlApiHostAccessRejectedError, - fetchArtifactReadHostConnectionFromControlApi, fetchHostConnectionFromControlApi, } from '../src/services/controlApiRestService.js' -import { ControlApiHostVersionMismatchError } from '../src/services/controlApiVersionError.js' const BINDING = { runId: '00000000-0000-4000-8000-000000000123', @@ -13,26 +11,33 @@ const BINDING = { } describe('control-api canonical host access client', () => { - it('treats an artifact-read upstream failure as a failed lookup', async () => { + it('resolves an ordinary send with one GET to the parent host-access URL', async () => { const fetchImpl = vi.fn( async () => - new Response(JSON.stringify({ error: 'Too Many Requests', retryAfterSeconds: 17 }), { - status: 429, - headers: { 'content-type': 'application/json', 'retry-after': '17' }, - }) + new Response( + JSON.stringify({ + userId: 'user-1', + hostRef: 'host-a', + url: 'http://host-a.mcp-host.svc.cluster.local:8080', + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) ) await expect( - fetchArtifactReadHostConnectionFromControlApi('user-1', 'host-a', 'signed-rpc-token', { + fetchHostConnectionFromControlApi('user-1', 'host-a', 'signed-rpc-token', { fetchImpl: fetchImpl as typeof fetch, }) - ).rejects.toMatchObject({ - message: 'Control API MCP host lookup failed (429)', + ).resolves.toEqual({ + name: 'host-a', + url: 'http://host-a.mcp-host.svc.cluster.local:8080', + headers: {}, }) - expect(String(fetchImpl.mock.calls[0]![0])).toMatch( - /\/rpc\/access\/users\/user-1\/mcp-hosts\/host-a\/artifact-read$/ - ) + expect(fetchImpl).toHaveBeenCalledOnce() + const [url, init] = fetchImpl.mock.calls[0]! + expect(String(url)).toMatch(/\/rpc\/access\/users\/user-1\/mcp-hosts\/host-a$/) + expect(init).toMatchObject({ method: 'GET' }) }) it('resolves and binds with one POST to the existing host-access URL', async () => { @@ -177,79 +182,39 @@ describe('control-api canonical host access client', () => { expect(fetchImpl).toHaveBeenCalledOnce() }) - it.each([ - ['the legacy host read', {}, 'host-access'], - ['message resolution', { messageResolution: true }, 'host-message-resolution'], - ] as const)( - 'a 404 on %s is a typed version mismatch, never a denial', - async (_label, options, operation) => { - const fetchImpl = jsonResponse(404, { error: 'not found' }) - - const rejection = fetchHostConnectionFromControlApi('user-1', 'host-a', 'signed-rpc-token', { - ...options, - fetchImpl: fetchImpl as typeof fetch, - }) - - await expect(rejection).rejects.toBeInstanceOf(ControlApiHostVersionMismatchError) - await expect(rejection).rejects.toMatchObject({ operation }) - expect(fetchImpl).toHaveBeenCalledOnce() - } - ) + it('a non-direct-run 404 resolves to host_access_denied on the parent path', async () => { + const fetchImpl = jsonResponse(404, { error: 'not found' }) - // The direct-run-bound 404 is the common send shape during the rollout: it - // must reach the typed fence instead of the old 502. Both binding operations - // are covered, and the request shape proves the binding was actually POSTed. - it.each([ - [ - 'a direct-run-bound legacy send', - { directRunBinding: BINDING }, - 'host-access', - '/mcp-hosts/host-a', - ], - [ - 'a direct-run-bound message resolution', - { messageResolution: true, directRunBinding: BINDING }, - 'host-message-resolution', - '/mcp-hosts/host-a/message-resolution', - ], - ] as const)( - 'a 404 on %s is a typed version mismatch and posts the binding', - async (_label, options, operation, pathSuffix) => { - const fetchImpl = vi.fn( - async (_url: string | URL | Request, _init?: RequestInit) => - new Response(JSON.stringify({ error: 'not found' }), { - status: 404, - headers: { 'content-type': 'application/json' }, - }) - ) - - const rejection = fetchHostConnectionFromControlApi('user-1', 'host-a', 'signed-rpc-token', { - ...options, + await expect( + fetchHostConnectionFromControlApi('user-1', 'host-a', 'signed-rpc-token', { fetchImpl: fetchImpl as typeof fetch, }) + ).resolves.toEqual({ denied: true, code: 'host_access_denied' }) + expect(fetchImpl).toHaveBeenCalledOnce() + const [url, init] = fetchImpl.mock.calls[0]! + expect(String(url)).toMatch(/\/rpc\/access\/users\/user-1\/mcp-hosts\/host-a$/) + expect(init).toMatchObject({ method: 'GET' }) + }) - await expect(rejection).rejects.toBeInstanceOf(ControlApiHostVersionMismatchError) - await expect(rejection).rejects.toMatchObject({ operation }) - expect(fetchImpl).toHaveBeenCalledOnce() - const [url, init] = fetchImpl.mock.calls[0]! - expect(String(url)).toMatch(new RegExp(`${pathSuffix}$`)) - expect(init).toMatchObject({ method: 'POST', body: JSON.stringify(BINDING) }) - } - ) - - it('a 404 on artifact-read names the artifact operation as a version mismatch', async () => { - const fetchImpl = jsonResponse(404, { error: 'not found' }) - - const rejection = fetchArtifactReadHostConnectionFromControlApi( - 'user-1', - 'host-a', - 'signed-rpc-token', - { fetchImpl: fetchImpl as typeof fetch } + it('a direct-run-bound 404 is a lookup failure and posts the binding to the parent path', async () => { + const fetchImpl = vi.fn( + async (_url: string | URL | Request, _init?: RequestInit) => + new Response(JSON.stringify({ error: 'not found' }), { + status: 404, + headers: { 'content-type': 'application/json' }, + }) ) - await expect(rejection).rejects.toBeInstanceOf(ControlApiHostVersionMismatchError) - await expect(rejection).rejects.toMatchObject({ operation: 'host-artifact-read' }) - expect(String(fetchImpl.mock.calls[0]![0])).toMatch(/\/artifact-read$/) + const rejection = fetchHostConnectionFromControlApi('user-1', 'host-a', 'signed-rpc-token', { + directRunBinding: BINDING, + fetchImpl: fetchImpl as typeof fetch, + }) + + await expect(rejection).rejects.toThrow('Control API MCP host lookup failed (404)') + expect(fetchImpl).toHaveBeenCalledOnce() + const [url, init] = fetchImpl.mock.calls[0]! + expect(String(url)).toMatch(/\/rpc\/access\/users\/user-1\/mcp-hosts\/host-a$/) + expect(init).toMatchObject({ method: 'POST', body: JSON.stringify(BINDING) }) }) it.each([ diff --git a/scripts/e2e/e2e-rpc-gateway-host-paths.sh b/scripts/e2e/e2e-rpc-gateway-host-paths.sh index ef110ad1a8..f028cc9fa5 100755 --- a/scripts/e2e/e2e-rpc-gateway-host-paths.sh +++ b/scripts/e2e/e2e-rpc-gateway-host-paths.sh @@ -1,15 +1,19 @@ #!/usr/bin/env bash # ====================================================================== -# J4 — rpc-proxy Host sub-paths through control-api-rpc-gateway +# J4 — rpc-proxy Host parent path through control-api-rpc-gateway # ====================================================================== # A user message and an artifact list go through rpc-proxy, which resolves -# the Host through control-api-rpc-gateway: -# POST /api/v1/rpc/access/users/:userId/mcp-hosts/:hostRef/message-resolution -# GET /api/v1/rpc/access/users/:userId/mcp-hosts/:hostRef/artifact-read +# the Host through control-api-rpc-gateway on the parent path: +# POST /api/v1/rpc/access/users/:userId/mcp-hosts/:hostRef +# (a message with threadId carries a direct-run binding) +# GET /api/v1/rpc/access/users/:userId/mcp-hosts/:hostRef +# (an artifact list has no binding) # The gateway ends in a default `return 403`, so a missing location turns -# both journeys into an opaque denial. Signals per journey: +# both journeys into an opaque denial. The access-log check is anchored at +# the Host ref, so a sub-path such as /message-resolution does not match. +# Signals per journey: # 1. rpc-proxy answers HTTP 200; -# 2. the gateway access log has the sub-path with status 200 and no 403; +# 2. the gateway access log has the parent path with status 200 and no 403; # 3. for the message, the user turn is persisted in the Host's state.db. # # Meant as a T2 healthcheck: @@ -217,7 +221,7 @@ fi # The gateway log window starts here, before the first journey request. LOG_SINCE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" -header "Journey: message through message-resolution" +header "Journey: message through the parent Host path" attempts=0 message_sent=false while :; do @@ -255,7 +259,7 @@ if [ "$message_sent" = true ]; then fi fi -header "Journey: artifact list through artifact-read" +header "Journey: artifact list through the parent Host path" within_deadline "artifact read" if ! mint_rpc_token; then fail "rpc/token re-mint → HTTP ${HTTP_STATUS}; artifact list not attempted" @@ -279,21 +283,23 @@ fi # Here-strings, not `printf | grep -q`: under pipefail grep -q exits on the # first match, printf can then die of SIGPIPE, and the pipeline reports no match. check_gateway_line() { - local method=$1 suffix=$2 pattern - pattern="\"${method} /api/v1/rpc/access/users/[^/ ]+/mcp-hosts/${HOST_REF}/${suffix} HTTP/[0-9.]+\" " + local method=$1 pattern + # The space before HTTP anchors the path at the Host ref. A sub-path + # (/message-resolution, /artifact-read) does not match. + pattern="\"${method} /api/v1/rpc/access/users/[^/ ]+/mcp-hosts/${HOST_REF} HTTP/[0-9.]+\" " if grep -Eq "${pattern}200 " <<<"$gateway_log"; then - ok "gateway logged ${method} .../${suffix} → 200" + ok "gateway logged ${method} .../mcp-hosts/${HOST_REF} → 200" else - fail "gateway has no ${method} .../${suffix} line with 200 since ${LOG_SINCE}" + fail "gateway has no ${method} .../mcp-hosts/${HOST_REF} line with 200 since ${LOG_SINCE}" fi if grep -Eq "${pattern}403 " <<<"$gateway_log"; then - fail "gateway denied ${method} .../${suffix} with 403 (missing or wrong location)" + fail "gateway denied ${method} .../mcp-hosts/${HOST_REF} with 403 (missing or wrong location)" else - ok "gateway logged no 403 for ${method} .../${suffix}" + ok "gateway logged no 403 for ${method} .../mcp-hosts/${HOST_REF}" fi } -check_gateway_line POST message-resolution -check_gateway_line GET artifact-read +check_gateway_line POST +check_gateway_line GET header "Persisted user turn" within_deadline "state.db read" From 5ee629a92dbe6218f363fed0cd098788c1b39558 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 29 Oct 2026 00:29:51 +0100 Subject: [PATCH 392/528] test(rpc-proxy): keep a method-gate witness on the parent Host location After the Host sub-paths were removed, the default-deny test only reached the catch-all location. It now also asserts that DELETE on the proxying parent Host path is denied, so widening its limit_except turns it red. Co-Authored-By: Claude Opus 5.5 --- external-rest-api/src/releaseManifest.ts | 2 +- rpc-proxy/package-lock.json | 4 ++-- rpc-proxy/package.json | 2 +- rpc-proxy/src/__tests__/control-api-gateway-parity.test.ts | 2 ++ 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index b7a31e3198..6ee37558d4 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -9,7 +9,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.12.0', externalRestApiVersion: '0.1.158', - rpcProxyVersion: '0.1.136', + rpcProxyVersion: '0.1.137', desktopVersion: '0.12.0', minimumDesktopVersion: '0.1.252', } diff --git a/rpc-proxy/package-lock.json b/rpc-proxy/package-lock.json index 58b18683e7..20c76cbb9e 100644 --- a/rpc-proxy/package-lock.json +++ b/rpc-proxy/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-rpc-proxy", - "version": "0.1.136", + "version": "0.1.137", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-rpc-proxy", - "version": "0.1.136", + "version": "0.1.137", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/rpc-proxy/package.json b/rpc-proxy/package.json index 8123e1bcc1..f690d9b5a7 100644 --- a/rpc-proxy/package.json +++ b/rpc-proxy/package.json @@ -1,6 +1,6 @@ { "name": "clerum-rpc-proxy", - "version": "0.1.136", + "version": "0.1.137", "description": "External-facing RPC proxy for tenant-scoped MCP access", "main": "dist/main.js", "engines": { diff --git a/rpc-proxy/src/__tests__/control-api-gateway-parity.test.ts b/rpc-proxy/src/__tests__/control-api-gateway-parity.test.ts index f56be076d4..b172dc901e 100644 --- a/rpc-proxy/src/__tests__/control-api-gateway-parity.test.ts +++ b/rpc-proxy/src/__tests__/control-api-gateway-parity.test.ts @@ -296,6 +296,8 @@ describe('control-api-rpc-gateway parity with the rpc-proxy emitter', () => { } expect(gatewayAdmits(locations, 'GET', removed)).toBe(true) expect(gatewayAdmits(locations, 'POST', removed)).toBe(true) + // Method gate on the proxying parent location itself. + expect(admits('DELETE', removed)).toBe(false) expect(admits('GET', `${removed}/other`)).toBe(false) }) }) From fdbd662cc6b2287cafb1505e00bcbcfbc19451b9 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 29 Oct 2026 00:20:57 +0100 Subject: [PATCH 393/528] fix(access): lock archive principals before revisions Take ordered team and user KEY SHARE locks before archive revision bumps to match principal-delete cascades and audit foreign-key checks. Reproduce concurrent user and team deletion on real PostgreSQL with liveness and exact revision witnesses, and update the D34 schema pin. Co-Authored-By: GPT-6.1 Sol (Codex) --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../access/userAccessFoundationSchema.ts | 25 +++ ...LockOrder.realPostgres.integration.test.ts | 158 ++++++++++++++++++ .../test/migrationExecutionPolicy.test.ts | 4 +- 5 files changed, 188 insertions(+), 5 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index aed16c5635..9a1998bbb2 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.855", + "version": "0.1.856", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.855", + "version": "0.1.856", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index fd6ccd36ea..0432aac648 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.855", + "version": "0.1.856", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/access/userAccessFoundationSchema.ts b/control-api/src/services/access/userAccessFoundationSchema.ts index b1ccbb6680..ee57c6d654 100644 --- a/control-api/src/services/access/userAccessFoundationSchema.ts +++ b/control-api/src/services/access/userAccessFoundationSchema.ts @@ -262,6 +262,31 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis AS $$ DECLARE revision RECORD; BEGIN + -- Parent locks must precede revisions and the audit INSERT's FK checks, + -- matching principal deletion's parent-before-cascade order. + PERFORM team.id + FROM teams AS team + WHERE team.id::TEXT IN ( + SELECT run.team_id::TEXT FROM workflow_runs AS run + WHERE run.run_id = ANY(target_run_ids) + UNION + -- Keep malformed usage IDs for the existing subject validator below. + SELECT lower(run.usage_team_id) FROM workflow_runs AS run + WHERE run.run_id = ANY(target_run_ids) + AND run.usage_team_id <> 'control-plane-admin-ui' + ) + ORDER BY team.id + FOR KEY SHARE; + + PERFORM principal.id + FROM users AS principal + WHERE principal.id IN ( + SELECT run.actor_id FROM workflow_runs AS run + WHERE run.run_id = ANY(target_run_ids) AND run.actor_type = 'user' + ) + ORDER BY principal.id + FOR KEY SHARE; + -- A batch must hold every team before any user, across both archive -- INSERT and live DELETE triggers. Bumping also creates missing rows. FOR revision IN diff --git a/control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts b/control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts index e8bcbbea83..59501c78d7 100644 --- a/control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts +++ b/control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts @@ -48,9 +48,20 @@ describeRealPostgres('archive batches and phase updates share revision lock orde subject_id TEXT NOT NULL, gate INTEGER NOT NULL ); + CREATE TABLE archive_delete_revision_observations ( + position BIGSERIAL, application_name TEXT, revision_table TEXT, + subject_id TEXT, revision BIGINT + ); CREATE FUNCTION archive_order_barrier() RETURNS TRIGGER LANGUAGE plpgsql AS $$ DECLARE barrier RECORD; BEGIN + IF current_setting('application_name') LIKE 'archive-delete-%' + OR current_setting('application_name') LIKE 'delete-principal-%' THEN + INSERT INTO archive_delete_revision_observations + (application_name, revision_table, subject_id, revision) + VALUES (current_setting('application_name'), TG_TABLE_NAME, + COALESCE(to_jsonb(NEW)->>'user_id', to_jsonb(NEW)->>'team_id'), NEW.revision); + END IF; FOR barrier IN SELECT gate FROM archive_order_barriers WHERE application_name = current_setting('application_name') @@ -335,6 +346,153 @@ describeRealPostgres('archive batches and phase updates share revision lock orde ).toEqual([{ phase: 'Running' }]) }) + it.each(['user', 'team'] as const)( + 'commits an archive batch beside %s deletion with parent-before-revision locks', + async kind => { + const user = randomUUID() + const team = randomUUID() + const run = randomUUID() + await databasePool.query('INSERT INTO users(id, email) VALUES ($1, $2)', [ + user, + `${user}@example.test`, + ]) + await databasePool.query("INSERT INTO teams(id, name) VALUES ($1, 'Archive delete')", [team]) + await databasePool.query( + `INSERT INTO workflow_runs(run_id, recipe_namespace, recipe_name, phase, actor_type, + actor_id, team_id, trigger_source, completed_at, ttl_seconds_after_finished) + VALUES ($1, 'revision', 'archive-delete', 'Succeeded', 'user', $2, $3, + 'onDemand', NOW() - interval '3 hours', 0)`, + [run, user, team] + ) + await databasePool.query( + "INSERT INTO workflow_run_steps(run_id, step_id, phase) VALUES ($1, 'delete-step', 'Succeeded')", + [run] + ) + const ids = { users: [user], teams: [team] } + const before = await revisions(ids) + const archiveGate = await databasePool.connect() + const archive = await databasePool.connect() + const deletion = await databasePool.connect() + const archivePid = await pid(archive) + const deletionPid = await pid(deletion) + const gatePid = await pid(archiveGate) + const subject = kind === 'user' ? user : team + const revisionTable = `authorization_${kind}_revisions` + let archiveOutcome: Promise | undefined + let deletionOutcome: Promise | undefined + const connect = vi.spyOn(pool, 'connect').mockResolvedValue(archive) + try { + await configure(archive, `archive-delete-${kind}`) + await configure(deletion, `delete-principal-${kind}`) + await archiveGate.query('SELECT pg_advisory_lock($1, 10)', [barrierClass]) + await databasePool.query('INSERT INTO archive_order_barriers VALUES ($1, $2, $3, 10)', [ + `archive-delete-${kind}`, + revisionTable, + subject, + ]) + archiveOutcome = observe(archiveTerminalRuns(gateway, { batchSize: 1, maxBatches: 1 })) + // The real prelock has bumped the subject and holds its revision row. + await waitForBlock(archivePid, [gatePid]) + deletionOutcome = observe( + deletion.query(`DELETE FROM ${kind === 'user' ? 'users' : 'teams'} WHERE id = $1`, [ + subject, + ]) + ) + await waitForBlock(deletionPid, [archivePid]) + await archiveGate.query('SELECT pg_advisory_unlock_all()') + const outcomes = await Promise.all([archiveOutcome, deletionOutcome]) + console.info('ARCHIVE_DELETE_OUTCOMES', { kind, outcomes }) + expect(outcomes.map(result => result.status)).toEqual(['fulfilled', 'fulfilled']) + expect(outcomes[0]).toEqual({ status: 'fulfilled', value: 1 }) + expect(outcomes[1]).toMatchObject({ status: 'fulfilled', value: { rowCount: 1 } }) + expect( + ( + await databasePool.query( + `SELECT id FROM ${kind === 'user' ? 'users' : 'teams'} WHERE id = $1`, + [subject] + ) + ).rows + ).toHaveLength(0) + expect( + (await databasePool.query('SELECT run_id FROM workflow_runs WHERE run_id = $1', [run])) + .rows + ).toHaveLength(0) + expect( + ( + await databasePool.query('SELECT run_id FROM workflow_run_steps WHERE run_id = $1', [ + run, + ]) + ).rows + ).toHaveLength(0) + expect( + ( + await databasePool.query( + 'SELECT run_id, triggerer_user_id, triggerer_team_id FROM workflow_runs_audit WHERE run_id = $1', + [run] + ) + ).rows + ).toEqual([ + { + run_id: run, + triggerer_user_id: kind === 'user' ? null : user, + triggerer_team_id: kind === 'team' ? null : team, + }, + ]) + expect( + ( + await databasePool.query( + 'SELECT step_id FROM workflow_run_step_audit WHERE run_id = $1', + [run] + ) + ).rows + ).toEqual([{ step_id: 'delete-step' }]) + const after = await revisions(ids) + const deleted = kind === 'user' ? 'user' : 'team' + const surviving = kind === 'user' ? 'team' : 'user' + expect(after[deleted]).toEqual([]) + // Archive: prelock + audit INSERT + live DELETE. Principal deletion + // nulls audit attribution, bumping its surviving subject twice. + expect(after[surviving]).toEqual([ + { + id: before[surviving][0].id, + revision: before[surviving][0].revision + 5, + }, + ]) + for (const revisionKind of ['user', 'team'] as const) { + const recorded = await databasePool.query( + `SELECT revision::int FROM archive_delete_revision_observations + WHERE application_name = $1 AND revision_table = $2 AND subject_id = $3 + ORDER BY position`, + [ + `archive-delete-${kind}`, + `authorization_${revisionKind}_revisions`, + revisionKind === 'user' ? user : team, + ] + ) + expect(recorded.rows).toEqual( + [1, 2, 3].map(delta => ({ revision: before[revisionKind][0].revision + delta })) + ) + } + console.info( + 'ARCHIVE_DELETE_WITNESSES', + JSON.stringify({ kind, before, after, archived: run, deleted: subject }) + ) + } finally { + await archiveGate.query('SELECT pg_advisory_unlock_all()') + await Promise.all([archiveOutcome, deletionOutcome]) + connect.mockRestore() + if (!archiveOutcome) archive.release() + archiveGate.release() + deletion.release() + await databasePool.query('TRUNCATE archive_order_barriers') + await databasePool.query('DELETE FROM workflow_runs WHERE run_id = $1', [run]) + await databasePool.query('DELETE FROM workflow_runs_audit WHERE run_id = $1', [run]) + await databasePool.query('DELETE FROM users WHERE id = $1', [user]) + await databasePool.query('DELETE FROM teams WHERE id = $1', [team]) + } + } + ) + it('creates missing revisions once per subject in team-then-user order', async () => { const ids = await fixture() const usageTeam = randomUUID() diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index 5867d35ae9..19133f3c25 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -98,11 +98,11 @@ describe('D34 migration execution policy', () => { // The 2026-10-28 review (still unapplied anywhere) dropped unused objects, // made revision tables read-only for the runtime, ordered revision writers, // kept the Admin UI attribution sentinel, added three revision triggers and - // a pre-lock for archive batches. + // a pre-lock for archive batches, now locking parents before revisions. const files = [ [ 'src/services/access/userAccessFoundationSchema.ts', - '89307fd494ed2c7936a04d888f962684ac97fad8a57fa1e731e04013d3349e43', + 'f345f81dcb274246b0fb86fe345447addd9182d71f3fba140d087a33877a1df9', ], [ 'src/services/directory/invitationDeliverySchema.ts', From 4d9d3f697692d40fbf1876f2d0e7ea5accf665c3 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 29 Oct 2026 00:33:14 +0100 Subject: [PATCH 394/528] fix(control-api): read own runs and key V1 approvals as dev does - OD-13 D: the run reader, the audit reader and the artifact gate let the author always read their own runs (actor_type user, actor_id = caller), as dev's run reader does. Other users' runs still need live authority. - F-2c: V1 user-session approvals use dev's caller key verbatim (external-rest-api:user::team:, including team:null), whatever the grant source, so approvals created by dev replay as the same approval. V2 sessions keep the :direct key. Real-PostgreSQL coverage uses rows and approvals written by the pinned dev producer; vacuity mutations for both changes turn the tests red. Co-Authored-By: GLM-5.3 (Codex) Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../workflows/workflowRunReadService.ts | 28 +- .../workflows/workflowTriggerService.ts | 12 +- .../test/routes.externalWorkflows.test.ts | 2 +- ...atibility.realPostgres.integration.test.ts | 647 ++++++++++++++++++ .../test/workflowRunReadAuthorization.test.ts | 11 +- 7 files changed, 673 insertions(+), 33 deletions(-) create mode 100644 control-api/test/workflowRunReadAndApprovalV1Compatibility.realPostgres.integration.test.ts diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 9a1998bbb2..9533bcb21a 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.856", + "version": "0.1.857", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.856", + "version": "0.1.857", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 0432aac648..df281facc0 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.856", + "version": "0.1.857", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/workflows/workflowRunReadService.ts b/control-api/src/services/workflows/workflowRunReadService.ts index bc0f5d904b..87e1cf1983 100644 --- a/control-api/src/services/workflows/workflowRunReadService.ts +++ b/control-api/src/services/workflows/workflowRunReadService.ts @@ -69,10 +69,6 @@ function isTeamScopedRun(row: Pick): boolean { return row.actor_type === 'scheduled' || row.actor_type === 'autonomous' } -function hasTeamAttribution(row: Pick): boolean { - return Boolean(row.team_id || row.usage_team_id) -} - function approvalTargetMatchesUserScope( row: ApprovalScopedWorkflowRunRow, scope: UserRunScope @@ -91,9 +87,7 @@ function runMatchesUserScope(row: ApprovalScopedWorkflowRunRow, scope: UserRunSc const matchesTeam = Boolean(scope.teamId) && (row.team_id === scope.teamId || row.usage_team_id === scope.teamId) if (row.actor_type === 'user') { - const matchesActor = - Boolean(row.actor_id) && scope.principalIds.includes(row.actor_id as string) - if (matchesActor) return !hasTeamAttribution(row) || matchesTeam + if (row.actor_id && scope.principalIds.includes(row.actor_id)) return true return !row.actor_id && matchesTeam } if (isTeamScopedRun(row)) { @@ -111,8 +105,7 @@ function auditRunMatchesUserScope(row: Record, scope: UserRunSc Boolean(scope.teamId) && (teamId === scope.teamId || usageTeamId === scope.teamId) if (actorType === 'user') { - const matchesActor = Boolean(userId) && scope.principalIds.includes(userId) - if (matchesActor) return (!teamId && !usageTeamId) || matchesTeam + if (userId && scope.principalIds.includes(userId)) return true return !userId && matchesTeam } if (actorType === 'scheduled' || actorType === 'autonomous') { @@ -128,8 +121,9 @@ export async function canCallerReadWorkflowRun( ): Promise { const scope = userRunScope(caller) if (!scope) return true - const directScope = { ...scope, teamId: null } - if (runMatchesUserScope(row, directScope)) return true + if (row.actor_type === 'user' && row.actor_id && scope.principalIds.includes(row.actor_id)) { + return true + } if (!runMatchesUserScope(row, scope)) return false return hasLiveWorkflowTeamGrant(scope, row.recipe_namespace, row.recipe_name, db) } @@ -180,8 +174,9 @@ export async function canCallerReadWorkflowRunWithApprovalTarget( const scope = userRunScope(caller) if (!scope) return true - const directScope = { ...scope, teamId: null } - if (runMatchesUserScope(row, directScope)) return true + if (row.actor_type === 'user' && row.actor_id && scope.principalIds.includes(row.actor_id)) { + return true + } if ( runMatchesUserScope(row, scope) && (await hasLiveWorkflowTeamGrant(scope, row.recipe_namespace, row.recipe_name)) @@ -366,6 +361,7 @@ export async function listAuditRunsByRecipe( const scope = caller ? userRunScope(caller) : null const auditResult = await pool.query( `SELECT run_id, triggerer_user_id, triggerer_admin_user_id, triggerer_team_id, + usage_team_id, triggerer_actor_type, triggerer_host_ref, triggered_at, started_at, completed_at, final_phase, error_message FROM workflow_runs_audit @@ -374,8 +370,7 @@ export async function listAuditRunsByRecipe( scope ? `AND ( (triggerer_actor_type = 'user' AND ( - (triggerer_user_id::text = ANY($4::text[]) - AND triggerer_team_id IS NULL AND usage_team_id IS NULL) OR + triggerer_user_id::text = ANY($4::text[]) OR ((triggerer_user_id::text = ANY($4::text[]) OR triggerer_user_id IS NULL) AND (triggerer_team_id::text = $5 OR usage_team_id = $5) AND EXISTS ( @@ -581,8 +576,7 @@ async function listRunsByRecipeForUserScope( WHERE wr.recipe_namespace = $1 AND wr.recipe_name = $2 AND ( (wr.actor_type = 'user' AND ( - (wr.actor_id::text = ANY($4::text[]) - AND wr.team_id IS NULL AND wr.usage_team_id IS NULL) OR + wr.actor_id::text = ANY($4::text[]) OR ((wr.actor_id::text = ANY($4::text[]) OR wr.actor_id IS NULL) AND (wr.team_id::text = $5 OR wr.usage_team_id = $5) AND scope_twt.team_id IS NOT NULL) diff --git a/control-api/src/services/workflows/workflowTriggerService.ts b/control-api/src/services/workflows/workflowTriggerService.ts index 247a3c388f..e0b6fa3c2e 100644 --- a/control-api/src/services/workflows/workflowTriggerService.ts +++ b/control-api/src/services/workflows/workflowTriggerService.ts @@ -105,12 +105,12 @@ function getWorkflowUsageTeamIdForCaller( } function getExternalWorkflowTriggerCallerKey( - caller: Extract, - userSessionGrant: WorkflowTriggerGrantResult + caller: Extract ): string { - return userSessionGrant.granted && userSessionGrant.source === 'team' - ? `external-rest-api:user:${caller.claims.userId}:team:${userSessionGrant.teamId}` - : `external-rest-api:user:${caller.claims.userId}:direct` + if (caller.claims.sessionContract === 'v2') { + return `external-rest-api:user:${caller.claims.userId}:direct` + } + return `external-rest-api:user:${caller.claims.userId}:team:${caller.claims.teamId}` } type WorkflowTriggerResult = @@ -335,7 +335,7 @@ export async function triggerWorkflow(params: { }) } - const callerKey = getExternalWorkflowTriggerCallerKey(caller, userSessionGrant) + const callerKey = getExternalWorkflowTriggerCallerKey(caller) const approval = await createWorkflowTriggerApprovalRequest({ recipeNamespace: ns, recipeName: name, diff --git a/control-api/test/routes.externalWorkflows.test.ts b/control-api/test/routes.externalWorkflows.test.ts index f640c4055e..8deb02ab14 100644 --- a/control-api/test/routes.externalWorkflows.test.ts +++ b/control-api/test/routes.externalWorkflows.test.ts @@ -576,7 +576,7 @@ describe('routes/external/workflows', () => { } as never, RECIPE_NS ) - const callerKey = `external-rest-api:user:${USER_SESSION_CLAIMS.userId}:direct` + const callerKey = `external-rest-api:user:${USER_SESSION_CLAIMS.userId}:team:${USER_SESSION_CLAIMS.teamId}` const idempotencyKey = 'external-key-stale-approval' const payload = { message: `Approve ${RECIPE_NS}/test-recipe workflow trigger.`, diff --git a/control-api/test/workflowRunReadAndApprovalV1Compatibility.realPostgres.integration.test.ts b/control-api/test/workflowRunReadAndApprovalV1Compatibility.realPostgres.integration.test.ts new file mode 100644 index 0000000000..efe42e7b80 --- /dev/null +++ b/control-api/test/workflowRunReadAndApprovalV1Compatibility.realPostgres.integration.test.ts @@ -0,0 +1,647 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { execFileSync, spawn } from 'node:child_process' +import { createHash, generateKeyPairSync, randomBytes, randomUUID } from 'node:crypto' +import { existsSync, readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { Pool } from 'pg' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' + +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const describeRealPostgres = adminUrl ? describe : describe.skip +const OBSERVED_DEV_SOURCE_DIR = process.env.TASK106_R61_B1_DEV_SOURCE_DIR +const OBSERVED_DEV_SOURCE_COMMIT = '0b26101eb247adcc44f70d39451f3e82c3225d47' +const OBSERVED_DEV_DB_SOURCE_SHA256 = + '1948ad306fe56b368f120edbc70ddbac6cb9b981d9fd218e7ded2575ed3b2beb' + +if ( + process.env.CONTROL_API_REAL_PG_REQUIRED === '1' && + (!OBSERVED_DEV_SOURCE_DIR || !process.env.TASK106_R61_B1_LATEST_DEV_SOURCE_DIR) +) { + throw new Error( + 'R61-B1 real PostgreSQL lane requires both pinned DEV producer source directories' + ) +} + +const NS = 'sandbox-recipes' +const TEAM_ID = randomUUID() +const OWNER_ID = randomUUID() +const OTHER_USER_ID = randomUUID() +const database = `control_api_pr334_s2_${randomBytes(6).toString('hex')}` + +function databaseUrl(baseUrl: string, databaseName: string): string { + const url = new URL(baseUrl) + url.pathname = `/${databaseName}` + return url.toString() +} + +function quoteIdentifier(value: string): string { + return `"${value.replace(/"/g, '""')}"` +} + +function sha256File(path: string): string { + return createHash('sha256').update(readFileSync(path)).digest('hex') +} + +function privateKey(): string { + return generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey.export({ + type: 'pkcs8', + format: 'pem', + }) as string +} + +function verifyPinnedObservedProducer(): string { + if (!OBSERVED_DEV_SOURCE_DIR) + throw new Error('Test did not receive observed DEV source directory') + const sourceDir = resolve(OBSERVED_DEV_SOURCE_DIR) + expect( + execFileSync('git', ['-C', sourceDir, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() + ).toBe(OBSERVED_DEV_SOURCE_COMMIT) + expect( + execFileSync('git', ['-C', sourceDir, 'status', '--porcelain'], { encoding: 'utf8' }).trim() + ).toBe('') + expect(sha256File(join(sourceDir, 'control-api/src/db.ts'))).toBe(OBSERVED_DEV_DB_SOURCE_SHA256) + const runner = join(sourceDir, 'control-api/dist/migrate.js') + expect(existsSync(runner), 'observed DEV migration runner build').toBe(true) + return runner +} + +async function runObservedProducer(runner: string, connectionString: string): Promise { + const runnerDir = resolve(runner, '..') + const child = spawn(process.execPath, [runner], { + cwd: runnerDir, + env: { + NODE_ENV: 'test', + CONTROL_API_PG_CONNECTION_STRING: connectionString, + CONTROL_API_RPC_JWT_PRIVATE_KEY: privateKey(), + CONTROL_API_SESSION_JWT_PRIVATE_KEY: privateKey(), + CONTROL_API_ADMIN_JWT_PRIVATE_KEY: privateKey(), + TZ: 'UTC', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + const output: string[] = [] + const collect = (chunk: Buffer) => { + output.push(chunk.toString('utf8')) + } + child.stdout?.on('data', collect) + child.stderr?.on('data', collect) + + await new Promise((resolvePromise, rejectPromise) => { + const timeout = setTimeout(() => child.kill('SIGKILL'), 240_000) + timeout.unref() + child.once('error', error => { + clearTimeout(timeout) + rejectPromise(error) + }) + child.once('close', (code, signal) => { + clearTimeout(timeout) + if (code === 0) { + resolvePromise() + return + } + rejectPromise( + new Error( + `observed DEV migration runner exited ${code ?? signal}\n${output + .join('') + .replaceAll(connectionString, '') + .slice(-8_000)}` + ) + ) + }) + }) +} + +async function runObservedFixtureWriter( + connectionString: string, + recipesToWrite: RecipeFixture[] +): Promise> { + const sourceDir = resolve(OBSERVED_DEV_SOURCE_DIR!) + const script = ` + import { pathToFileURL } from 'node:url' + + const fixture = JSON.parse(process.env.PR334_S2_FIXTURE) + const oldTriggerUrl = pathToFileURL(process.env.PR334_S2_OLD_TRIGGER) + const oldDbUrl = pathToFileURL(process.env.PR334_S2_OLD_DB) + const oldTrigger = await import(oldTriggerUrl) + const oldDb = await import(oldDbUrl) + const resources = new Map() + for (const recipe of fixture.recipes) { + resources.set(recipe.name, { + metadata: { name: recipe.name, namespace: fixture.ns }, + spec: { + workloads: [], + triggers: { + onDemand: { + requiresApproval: recipe.requiresApproval, + ...(recipe.requiresApproval ? { allowedActors: ['user'] } : {}), + }, + }, + }, + }) + } + const gateway = { + async getResource(plural, name, namespace) { + if (plural === 'workflowrecipes' && namespace === fixture.ns) { + const resource = resources.get(name) + if (resource) return resource + } + throw Object.assign(new Error(plural + '/' + name + ' not found'), { statusCode: 404 }) + }, + } + function claims(userId, teamId) { + return { + userId, + email: userId + '@example.invalid', + teamId, + role: 'member', + authGeneration: 1, + exp: Math.floor(Date.now() / 1000) + 3600, + } + } + async function trigger(recipeName, callerClaims, key) { + return oldTrigger.triggerWorkflow({ + gateway, + caller: { kind: 'user-session', claims: callerClaims }, + recipeNamespace: fixture.ns, + recipeName, + body: { inputs: { value: key } }, + idempotencyKey: key, + }) + } + async function run(recipeName, callerClaims, key) { + const result = await trigger(recipeName, callerClaims, key) + if (result.kind !== 'run') throw new Error('Expected old producer run for ' + key) + return result.row.run_id + } + async function approval(recipeName, callerClaims, key) { + const result = await trigger(recipeName, callerClaims, key) + if (result.kind !== 'approval') throw new Error('Expected old producer approval for ' + key) + } + const owner = claims(fixture.ownerId, fixture.teamId) + const other = claims(fixture.otherUserId, fixture.teamId) + const teamless = claims(fixture.ownerId, null) + const output = { + ownerRunId: await run(fixture.recipes[0].name, owner, 'pr334-s2-owner-live'), + otherRunId: await run(fixture.recipes[0].name, other, 'pr334-s2-other-live'), + teamlessRunId: await run(fixture.recipes[1].name, teamless, 'pr334-s2-teamless-live'), + archiveRunId: await run(fixture.recipes[2].name, owner, 'pr334-s2-owner-archive'), + } + await approval(fixture.recipes[3].name, owner, 'pr334-s2-v1-3') + await approval(fixture.recipes[4].name, teamless, 'pr334-s2-v1-4') + await approval(fixture.recipes[5].name, owner, 'pr334-s2-v1-5') + await oldDb.pool.end() + await oldDb.rateLimitPool.end() + process.stdout.write(JSON.stringify(output)) + ` + const fixture = { + ns: NS, + ownerId: OWNER_ID, + otherUserId: OTHER_USER_ID, + teamId: TEAM_ID, + recipes: recipesToWrite, + } + const child = spawn(process.execPath, ['--input-type=module', '-e', script], { + cwd: sourceDir, + env: { + NODE_ENV: 'test', + CONTROL_API_PG_CONNECTION_STRING: connectionString, + CONTROL_API_RPC_JWT_PRIVATE_KEY: privateKey(), + CONTROL_API_SESSION_JWT_PRIVATE_KEY: privateKey(), + CONTROL_API_ADMIN_JWT_PRIVATE_KEY: privateKey(), + PR334_S2_FIXTURE: JSON.stringify(fixture), + PR334_S2_OLD_TRIGGER: join( + sourceDir, + 'control-api/dist/services/workflows/workflowTriggerService.js' + ), + PR334_S2_OLD_DB: join(sourceDir, 'control-api/dist/db.js'), + TZ: 'UTC', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + const stdout: Buffer[] = [] + const stderr: Buffer[] = [] + child.stdout?.on('data', chunk => stdout.push(chunk)) + child.stderr?.on('data', chunk => stderr.push(chunk)) + const close = new Promise<[number | null, NodeJS.Signals | null]>( + (resolvePromise, rejectPromise) => { + child.once('error', rejectPromise) + child.once('close', (code, signal) => resolvePromise([code, signal])) + } + ) + const [code, signal] = await close + if (code !== 0) { + throw new Error( + `observed DEV fixture writer exited ${code ?? signal}\n${Buffer.concat(stderr) + .toString('utf8') + .replaceAll(connectionString, '') + .slice(-8_000)}` + ) + } + return JSON.parse(Buffer.concat(stdout).toString('utf8')) as Record +} + +type RecipeFixture = { + name: string + requiresApproval: boolean +} + +function recipeResource(recipe: RecipeFixture): Record { + return { + metadata: { name: recipe.name, namespace: NS }, + spec: { + workloads: [], + triggers: { + onDemand: { + requiresApproval: recipe.requiresApproval, + ...(recipe.requiresApproval ? { allowedActors: ['user'] } : {}), + }, + }, + }, + } +} + +function makeGateway(recipes: RecipeFixture[]): Record { + const resources = new Map>() + for (const recipe of recipes) { + resources.set(`${NS}/${recipe.name}`, recipeResource(recipe)) + resources.set( + `${NS}/${recipe.name}-child`, + recipeResource({ ...recipe, name: `${recipe.name}-child`, requiresApproval: false }) + ) + } + return { + async getResource(plural: string, name: string, namespace: string) { + if (plural === 'workflowrecipes' && namespace === NS) { + const resource = resources.get(`${namespace}/${name}`) + if (resource) return resource + } + throw Object.assign(new Error(`${plural}/${name} not found`), { statusCode: 404 }) + }, + } +} + +type OldClaims = { + userId: string + email: string + teamId: string | null + role: 'member' + authGeneration: number + exp: number +} + +function v1Claims(userId: string, teamId: string | null): OldClaims { + return { + userId, + email: `${userId}@example.invalid`, + teamId, + role: 'member', + authGeneration: 1, + exp: Math.floor(Date.now() / 1000) + 3_600, + } +} + +describeRealPostgres('PR334 S2 workflow run visibility and V1 approval replay', () => { + const connectionString = databaseUrl( + adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', + database + ) + const previousPgConnectionString = process.env.CONTROL_API_PG_CONNECTION_STRING + if (adminUrl) process.env.CONTROL_API_PG_CONNECTION_STRING = connectionString + + let adminPool: Pool + let databasePool: Pool + let db: typeof import('../src/db.js') + let reader: typeof import('../src/services/workflows/workflowRunReadService.js') + let trigger: typeof import('../src/services/workflows/workflowTriggerService.js') + let approvalService: typeof import('../src/services/workflows/workflowTriggerApprovalService.js') + let artifacts: typeof import('../src/services/workflows/workflowRunArtifactService.js') + let archive: typeof import('../src/services/workflowRunsArchiveService.js') + let gateway: Record + let recipes: RecipeFixture[] + let ownerRunId: string + let otherRunId: string + let teamlessRunId: string + let archiveRunId: string + + beforeAll(async () => { + adminPool = new Pool({ connectionString: adminUrl }) + await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) + databasePool = new Pool({ connectionString }) + + const runner = verifyPinnedObservedProducer() + await runObservedProducer(runner, connectionString) + + recipes = [ + { name: `s2-visibility-${randomUUID()}`, requiresApproval: false }, + { name: `s2-teamless-${randomUUID()}`, requiresApproval: false }, + { name: `s2-archive-${randomUUID()}`, requiresApproval: false }, + { name: `s2-v1-team-${randomUUID()}`, requiresApproval: true }, + { name: `s2-v1-teamless-${randomUUID()}`, requiresApproval: true }, + { name: `s2-v1-removed-membership-${randomUUID()}`, requiresApproval: true }, + { name: `s2-v2-${randomUUID()}`, requiresApproval: true }, + ] + gateway = makeGateway(recipes) + + await databasePool.query( + `INSERT INTO users(id, email, name) VALUES + ($1, 'pr334-s2-owner@example.invalid', 'PR334 S2 Owner'), + ($2, 'pr334-s2-other@example.invalid', 'PR334 S2 Other')`, + [OWNER_ID, OTHER_USER_ID] + ) + await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, 'PR334 S2 Team')`, [TEAM_ID]) + await databasePool.query( + `INSERT INTO team_members(team_id, user_id, role, status) VALUES + ($1, $2, 'member', 'active'), + ($1, $3, 'member', 'active')`, + [TEAM_ID, OWNER_ID, OTHER_USER_ID] + ) + for (const recipe of recipes) { + await databasePool.query( + `INSERT INTO user_workflow_triggers(user_id, recipe_namespace, recipe_name) + VALUES ($1, $2, $3), ($4, $2, $3)`, + [OWNER_ID, NS, recipe.name, OTHER_USER_ID] + ) + } + + const fixture = await runObservedFixtureWriter(connectionString, recipes) + ownerRunId = fixture.ownerRunId! + otherRunId = fixture.otherRunId! + teamlessRunId = fixture.teamlessRunId! + archiveRunId = fixture.archiveRunId! + await databasePool.query(`DELETE FROM team_members WHERE team_id = $1 AND user_id = $2`, [ + TEAM_ID, + OWNER_ID, + ]) + + db = await import('../src/db.js') + await db.initDb({ connect: () => databasePool.connect() }) + reader = await import('../src/services/workflows/workflowRunReadService.js') + trigger = await import('../src/services/workflows/workflowTriggerService.js') + approvalService = await import('../src/services/workflows/workflowTriggerApprovalService.js') + artifacts = await import('../src/services/workflows/workflowRunArtifactService.js') + archive = await import('../src/services/workflowRunsArchiveService.js') + + await databasePool.query( + `UPDATE workflow_runs + SET child_recipe_name = recipe_name || '-child', + child_recipe_namespace = recipe_namespace + WHERE run_id IN ($1::uuid, $2::uuid)`, + [ownerRunId, otherRunId] + ) + }) + + afterAll(async () => { + try { + await endPoolAndWaitForClients(databasePool) + await endPoolAndWaitForClients(db?.pool) + await endPoolAndWaitForClients(db?.rateLimitPool) + if (previousPgConnectionString === undefined) { + delete process.env.CONTROL_API_PG_CONNECTION_STRING + } else { + process.env.CONTROL_API_PG_CONNECTION_STRING = previousPgConnectionString + } + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + } + } finally { + await adminPool?.end() + } + }) + + it('shows an author an old team-attributed run without a live team grant, but not another author run', async () => { + const teamGrants = await databasePool.query( + 'SELECT count(*)::int AS count FROM team_workflow_triggers' + ) + expect(teamGrants.rows[0]).toEqual({ count: 0 }) + + const ownerRow = ( + await databasePool.query('SELECT * FROM workflow_runs WHERE run_id = $1', [ownerRunId]) + ).rows[0] + const otherRow = ( + await databasePool.query('SELECT * FROM workflow_runs WHERE run_id = $1', [otherRunId]) + ).rows[0] + const caller = { + kind: 'user-session' as const, + claims: v1Claims(OWNER_ID, TEAM_ID), + session: {} as never, + } + + await expect(reader.canCallerReadWorkflowRun(caller, ownerRow)).resolves.toBe(true) + await expect(reader.canCallerReadWorkflowRunWithApprovalTarget(caller, ownerRow)).resolves.toBe( + true + ) + await expect(reader.canCallerReadWorkflowRun(caller, otherRow)).resolves.toBe(false) + await expect(reader.canCallerReadWorkflowRunWithApprovalTarget(caller, otherRow)).resolves.toBe( + false + ) + + const visibilityRuns = await reader.listCanonicalRuns(NS, recipes[0]!.name, 10, caller) + expect(visibilityRuns.map(run => run.id)).toEqual([ownerRunId]) + expect((await reader.getLatestRun(NS, recipes[0]!.name, caller))?.id).toBe(ownerRunId) + expect((await reader.getWorkflowHealth(NS, recipes[0]!.name, caller)).lastRun?.id).toBe( + ownerRunId + ) + + const teamlessRuns = await reader.listCanonicalRuns(NS, recipes[1]!.name, 10, caller) + expect(teamlessRuns.map(run => run.id)).toEqual([teamlessRunId]) + + const artifactContext = await artifacts.resolveWorkflowArtifactContext({ + gateway: gateway as never, + caller, + recipeNamespace: NS, + recipeName: recipes[0]!.name, + runId: ownerRunId, + }) + expect(artifactContext.run.run_id).toBe(ownerRunId) + await expect( + artifacts.resolveWorkflowArtifactContext({ + gateway: gateway as never, + caller, + recipeNamespace: NS, + recipeName: recipes[0]!.name, + runId: otherRunId, + }) + ).rejects.toMatchObject({ status: 404 }) + }) + + it('shows the author an archived own run through the audit reader', async () => { + await databasePool.query( + `UPDATE workflow_runs + SET phase = 'Succeeded', + started_at = NOW() - interval '2 seconds', + completed_at = NOW() - interval '1 second', + ttl_seconds_after_finished = 0 + WHERE run_id = $1`, + [archiveRunId] + ) + const archived = await archive.archiveTerminalRuns(gateway as never, { + graceMs: 1, + batchSize: 10, + maxBatches: 1, + }) + expect(archived).toBe(1) + const live = await databasePool.query( + 'SELECT count(*)::int AS count FROM workflow_runs WHERE run_id = $1', + [archiveRunId] + ) + const audit = await databasePool.query( + 'SELECT count(*)::int AS count FROM workflow_runs_audit WHERE run_id = $1', + [archiveRunId] + ) + expect(live.rows[0]).toEqual({ count: 0 }) + expect(audit.rows[0]).toEqual({ count: 1 }) + + const caller = { + kind: 'user-session' as const, + claims: v1Claims(OWNER_ID, TEAM_ID), + session: {} as never, + } + const runs = await reader.listCanonicalRuns(NS, recipes[2]!.name, 10, caller) + expect(runs.map(run => run.id)).toEqual([archiveRunId]) + expect(runs[0]?.source).toBe('audit') + expect((await reader.getLatestRun(NS, recipes[2]!.name, caller))?.id).toBe(archiveRunId) + expect((await reader.getWorkflowHealth(NS, recipes[2]!.name, caller)).lastRun?.id).toBe( + archiveRunId + ) + }) + + it.each([ + { + label: 'active team membership', + recipeIndex: 3, + claims: () => v1Claims(OWNER_ID, TEAM_ID), + expectedKey: `external-rest-api:user:${OWNER_ID}:team:${TEAM_ID}`, + membershipDeleted: false, + }, + { + label: 'teamless V1 session', + recipeIndex: 4, + claims: () => v1Claims(OWNER_ID, null), + expectedKey: `external-rest-api:user:${OWNER_ID}:team:null`, + membershipDeleted: false, + }, + { + label: 'removed team membership', + recipeIndex: 5, + claims: () => v1Claims(OWNER_ID, TEAM_ID), + expectedKey: `external-rest-api:user:${OWNER_ID}:team:${TEAM_ID}`, + membershipDeleted: true, + }, + ])('replays a base-produced V1 approval with the base caller key ($label)', async scenario => { + const recipe = recipes[scenario.recipeIndex]! + const key = `pr334-s2-v1-${scenario.recipeIndex}` + const persistedKey = ( + await databasePool.query( + `SELECT wati.trigger_caller_key + FROM workflow_approval_trigger_intents wati + JOIN workflow_approval_requests war ON war.id = wati.approval_request_id + WHERE war.recipe_namespace = $1 AND war.recipe_name = $2 AND war.idempotency_key = $3`, + [NS, recipe.name, key] + ) + ).rows[0]?.trigger_caller_key + expect(persistedKey).toBe(scenario.expectedKey) + if (scenario.membershipDeleted) { + const member = await databasePool.query( + 'SELECT count(*)::int AS count FROM team_members WHERE team_id = $1 AND user_id = $2', + [TEAM_ID, OWNER_ID] + ) + expect(member.rows[0]).toEqual({ count: 0 }) + } + + const replay = await trigger.triggerWorkflow({ + gateway: gateway as never, + caller: { + kind: 'user-session', + claims: scenario.claims(), + session: {} as never, + }, + recipeNamespace: NS, + recipeName: recipe.name, + body: { inputs: { value: key } }, + idempotencyKey: key, + }) + expect(replay).toMatchObject({ + kind: 'approval', + approvalRequestId: expect.any(String), + existing: true, + }) + + const mismatch = await approvalService.createWorkflowTriggerApprovalRequest({ + recipeNamespace: NS, + recipeName: recipe.name, + callerKey: scenario.expectedKey, + targetUserId: OWNER_ID, + payload: { + message: 'Changed PR334 S2 replay payload', + metadata: { + workflowTrigger: { + namespace: NS, + name: recipe.name, + caller: scenario.expectedKey, + }, + }, + }, + idempotencyKey: key, + correlation: { taskId: key }, + runIntent: { + actorType: 'user', + actorId: OWNER_ID, + teamId: scenario.claims().teamId, + usageTeamId: scenario.claims().teamId, + triggerSource: 'onDemand', + inputs: {}, + intermediateParameters: null, + outputOverrides: null, + maxDurationSeconds: null, + ttlSecondsAfterFinished: null, + }, + }) + expect(mismatch).toMatchObject({ + kind: 'mismatch', + reason: 'payload_hash_mismatch', + }) + }) + + it('keeps the direct caller key for V2 user sessions', async () => { + const recipe = recipes[6]! + const key = 'pr334-s2-v2' + const claims = { + ...v1Claims(OWNER_ID, null), + sessionContract: 'v2' as const, + } + const created = await trigger.triggerWorkflow({ + gateway: gateway as never, + caller: { kind: 'user-session', claims, session: {} as never }, + recipeNamespace: NS, + recipeName: recipe.name, + body: { inputs: { value: key } }, + idempotencyKey: key, + }) + expect(created.kind).toBe('approval') + const persistedKey = ( + await databasePool.query( + `SELECT wati.trigger_caller_key + FROM workflow_approval_trigger_intents wati + JOIN workflow_approval_requests war ON war.id = wati.approval_request_id + WHERE war.recipe_namespace = $1 AND war.recipe_name = $2 AND war.idempotency_key = $3`, + [NS, recipe.name, key] + ) + ).rows[0]?.trigger_caller_key + expect(persistedKey).toBe(`external-rest-api:user:${OWNER_ID}:direct`) + + const replay = await trigger.triggerWorkflow({ + gateway: gateway as never, + caller: { kind: 'user-session', claims, session: {} as never }, + recipeNamespace: NS, + recipeName: recipe.name, + body: { inputs: { value: key } }, + idempotencyKey: key, + }) + expect(replay).toMatchObject({ kind: 'approval', existing: true }) + }) +}) diff --git a/control-api/test/workflowRunReadAuthorization.test.ts b/control-api/test/workflowRunReadAuthorization.test.ts index e5c78cecb6..6dd8931ccd 100644 --- a/control-api/test/workflowRunReadAuthorization.test.ts +++ b/control-api/test/workflowRunReadAuthorization.test.ts @@ -25,7 +25,7 @@ function run(overrides: Record = {}) { } as never } -describe('workflow run live team authorization', () => { +describe('workflow run user authorization', () => { it('keeps a directly attributed user run readable without a team lookup', async () => { const db = { query: vi.fn() } @@ -33,8 +33,8 @@ describe('workflow run live team authorization', () => { expect(db.query).not.toHaveBeenCalled() }) - it('does not treat a direct recipe grant as authority for a stale team-attributed run', async () => { - const db = { query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }) } + it('lets the author read their own historical team-attributed run without a live team grant', async () => { + const db = { query: vi.fn() } await expect( canCallerReadWorkflowRun( @@ -42,9 +42,8 @@ describe('workflow run live team authorization', () => { run({ team_id: 'team-1', usage_team_id: 'team-1' }), db as never ) - ).resolves.toBe(false) - expect(String(db.query.mock.calls[0][0])).toContain("tm.status = 'active'") - expect(String(db.query.mock.calls[0][0])).toContain('team_workflow_triggers') + ).resolves.toBe(true) + expect(db.query).not.toHaveBeenCalled() }) it('allows the same team-attributed run only with a current membership and recipe grant', async () => { From a4b00d60c344852a85c8f84ec64e985f8190acfe Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 29 Oct 2026 00:33:41 +0100 Subject: [PATCH 395/528] fix(control-api): match dev readers in three catalog families The aggregate access catalog (off by default, no client calls it yet) now lists what the matching dev readers show for: - C-7: team workflow runs apply the same actor boundary as dev's run reader in the producer, hydration and database authority candidates. - C-12: direct GFS grant and share arms are duplicate-capable, so pages do not drop rows that repeat across drives. - TERRA-1: team approval paths use workflow_recipe_allowed_teams with active membership, as dev does, instead of team_workflow_triggers. The remaining catalog differences (own and archived runs, run-completed and SDK notifications, MCP invocability and standalone hosts) need a read-only capability per path, notification target resolution and MCP projection fields; they are catalog activation blockers tracked in #1222. Co-Authored-By: GPT-6.1 Terra (Codex) Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../services/access/catalogHydrationSql.ts | 4 +- .../src/services/access/catalogProducerSql.ts | 16 +- .../access/databaseAuthorityCandidates.ts | 10 +- ...gFamilies.realPostgres.integration.test.ts | 5 + ...agination.realPostgres.integration.test.ts | 61 ++++++++ ...Producers.realPostgres.integration.test.ts | 140 ++++++++++++++++++ 8 files changed, 229 insertions(+), 13 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 9533bcb21a..c8ffe0bc48 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.857", + "version": "0.1.858", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.857", + "version": "0.1.858", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index df281facc0..fc7513ef90 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.857", + "version": "0.1.858", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/access/catalogHydrationSql.ts b/control-api/src/services/access/catalogHydrationSql.ts index 9593fd184c..5a0cc3ded9 100644 --- a/control-api/src/services/access/catalogHydrationSql.ts +++ b/control-api/src/services/access/catalogHydrationSql.ts @@ -516,6 +516,8 @@ export const WORKFLOW_RUN_HYDRATION_SQL = ` ON access_grant.team_id = membership.team_id AND access_grant.recipe_namespace = run.recipe_namespace AND access_grant.recipe_name = run.recipe_name + WHERE run.actor_type IN ('scheduled', 'autonomous') + OR (run.actor_type = 'user' AND (run.actor_id = $1 OR run.actor_id IS NULL)) ) all_paths ORDER BY logical_id, kind, team_id NULLS FIRST, grant_id LIMIT $4 @@ -567,7 +569,7 @@ export const WORKFLOW_APPROVAL_HYDRATION_SQL = ` JOIN team_members membership ON membership.team_id = approval.target_team_id AND membership.user_id = $1::uuid AND membership.status = 'active' - JOIN team_workflow_triggers access_grant + JOIN workflow_recipe_allowed_teams access_grant ON access_grant.team_id = membership.team_id AND access_grant.recipe_namespace = approval.recipe_namespace AND access_grant.recipe_name = approval.recipe_name diff --git a/control-api/src/services/access/catalogProducerSql.ts b/control-api/src/services/access/catalogProducerSql.ts index 6230a270d9..9c92ff993e 100644 --- a/control-api/src/services/access/catalogProducerSql.ts +++ b/control-api/src/services/access/catalogProducerSql.ts @@ -253,7 +253,9 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f ON twt.team_id = tm.team_id AND twt.recipe_namespace = wr.recipe_namespace AND twt.recipe_name = wr.recipe_name - WHERE wr.run_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000')`, + WHERE (wr.actor_type IN ('scheduled', 'autonomous') + OR (wr.actor_type = 'user' AND (wr.actor_id = $1 OR wr.actor_id IS NULL))) + AND wr.run_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000')`, orderBy: 'cursor_id', }, { @@ -267,6 +269,8 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND twt.recipe_namespace = wr.recipe_namespace AND twt.recipe_name = wr.recipe_name WHERE wr.usage_team_id IS NOT NULL + AND (wr.actor_type IN ('scheduled', 'autonomous') + OR (wr.actor_type = 'user' AND (wr.actor_id = $1 OR wr.actor_id IS NULL))) AND wr.run_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000')`, orderBy: 'cursor_id', }, @@ -295,10 +299,10 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f AND EXISTS ( SELECT 1 FROM team_members tm - JOIN team_workflow_triggers twt - ON twt.team_id = tm.team_id - AND twt.recipe_namespace = war.recipe_namespace - AND twt.recipe_name = war.recipe_name + JOIN workflow_recipe_allowed_teams wat + ON wat.team_id = tm.team_id + AND wat.recipe_namespace = war.recipe_namespace + AND wat.recipe_name = war.recipe_name WHERE tm.team_id = war.target_team_id AND tm.user_id = $1 AND tm.status = 'active' )`, @@ -339,6 +343,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f WHERE g.subject_type = 'user' AND g.subject_id = $1::text AND g.resource_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000')`, orderBy: 'cursor_id', + duplicateCapable: true, }, { sql: `SELECT share.resource_id::text AS logical_id, share.resource_id AS cursor_id @@ -348,6 +353,7 @@ export const CATALOG_KEY_SQL: Readonly> = Object.f WHERE share.subject_type = 'user' AND share.subject_id = $1::text AND share.resource_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000')`, orderBy: 'cursor_id', + duplicateCapable: true, }, ]), shared_filesystem: boundedKeyUnionSql([ diff --git a/control-api/src/services/access/databaseAuthorityCandidates.ts b/control-api/src/services/access/databaseAuthorityCandidates.ts index 20d498a7d1..18e0ac5d28 100644 --- a/control-api/src/services/access/databaseAuthorityCandidates.ts +++ b/control-api/src/services/access/databaseAuthorityCandidates.ts @@ -109,6 +109,8 @@ export async function loadDatabaseCandidates(input: { AND twt.recipe_namespace = wr.recipe_namespace AND twt.recipe_name = wr.recipe_name WHERE $2::text = 'workflow_run' AND wr.run_id::text = $3 + AND (wr.actor_type IN ('scheduled', 'autonomous') + OR (wr.actor_type = 'user' AND (wr.actor_id = $1 OR wr.actor_id IS NULL))) UNION ALL SELECT 'direct', 'workflow_approvals:user:' || war.id, NULL, NULL, NULL, war.recipe_namespace, war.recipe_name, war.expires_at, NULL, NULL, NULL, NULL @@ -126,10 +128,10 @@ export async function loadDatabaseCandidates(input: { FROM workflow_approval_requests war JOIN team_members tm ON tm.team_id = war.target_team_id AND tm.user_id = $1 AND tm.status = 'active' - JOIN team_workflow_triggers twt - ON twt.team_id = tm.team_id - AND twt.recipe_namespace = war.recipe_namespace - AND twt.recipe_name = war.recipe_name + JOIN workflow_recipe_allowed_teams wat + ON wat.team_id = tm.team_id + AND wat.recipe_namespace = war.recipe_namespace + AND wat.recipe_name = war.recipe_name WHERE $2::text = 'workflow_approval' AND war.id::text = $3 AND war.status = 'pending' AND war.expires_at > NOW() UNION ALL diff --git a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts index 97d9f02399..23d300168a 100644 --- a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts @@ -260,6 +260,11 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { VALUES ($1, $2, 'catalog-recipe')`, [teamId, config.sandboxNamespace] ) + await databasePool.query( + `INSERT INTO workflow_recipe_allowed_teams(team_id, recipe_namespace, recipe_name) + VALUES ($1, $2, 'catalog-recipe')`, + [teamId, config.sandboxNamespace] + ) await databasePool.query( `INSERT INTO workflow_runs( run_id, recipe_namespace, recipe_name, phase, actor_type, actor_id, diff --git a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts index dc30c4de4a..939b74a495 100644 --- a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts +++ b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts @@ -313,6 +313,67 @@ describeRealPostgres('distinct catalog key pagination on real PostgreSQL', () => } }) + it('S3 C12 refills duplicate direct GFS grants and shares before ending a page', async () => { + for (const mode of ['grant', 'share'] as const) { + const actor = await principal(`s3-gfs-${mode}`) + const outsider = await principal(`s3-gfs-outsider-${mode}`) + const first = + mode === 'grant' + ? '30000000-0000-4000-8000-000000000001' + : '40000000-0000-4000-8000-000000000001' + const second = + mode === 'grant' + ? '30000000-0000-4000-8000-000000000002' + : '40000000-0000-4000-8000-000000000002' + const drive = `s3-${mode}-drive` + await databasePool.query( + `INSERT INTO gfs_resources(resource_id, drive, name, kind) + VALUES ($1, $3, '/', 'directory'), ($2, $4, '/', 'directory')`, + [first, second, drive, `${drive}-second`] + ) + const table = mode === 'grant' ? 'gfs_grants' : 'gfs_shares' + await databasePool.query( + `INSERT INTO ${table}(drive, resource_id, subject_type, subject_id, permissions) + VALUES ($3, $1, 'user', $4, ARRAY['read']::text[]), + ($5, $1, 'user', $4, ARRAY['read']::text[]), + ($3, $2, 'user', $4, ARRAY['read']::text[])`, + [first, second, drive, actor.userId, `${drive}-other`] + ) + const session = (userId: string): ExternalSessionAuthorityContext => ({ + contract: 'v1', + userId, + tokenHash: randomBytes(32).toString('hex'), + issuedAt: Math.floor(Date.now() / 1_000), + authGeneration: 1, + }) + const currentSession = session(actor.userId) + const page1 = await buildAccessCatalog( + { session: currentSession, families: ['gfs_resource'], limit: 1 }, + { transaction: transaction(databasePool) } + ) + expect(page1.complete).toBe(true) + expect(page1.items.map(item => item.resource.logicalId)).toEqual([first]) + expect(page1.nextCursor).toBeTruthy() + const page2 = await buildAccessCatalog( + { + session: currentSession, + families: ['gfs_resource'], + limit: 1, + cursor: page1.nextCursor!, + }, + { transaction: transaction(databasePool) } + ) + expect(page2.complete).toBe(true) + expect(page2.items.map(item => item.resource.logicalId)).toEqual([second]) + const otherPage = await buildAccessCatalog( + { session: session(outsider.userId), families: ['gfs_resource'], limit: 1 }, + { transaction: transaction(databasePool) } + ) + expect(otherPage.complete).toBe(true) + expect(otherPage.items).toEqual([]) + } + }) + it('refills bounded duplicate team host paths without hiding later canonical IDs', async () => { await databasePool.query( `INSERT INTO user_agents(user_id, agent_name) VALUES ($1, 'x'), ($1, 'y'), ($1, 'z')`, diff --git a/control-api/test/catalogProducers.realPostgres.integration.test.ts b/control-api/test/catalogProducers.realPostgres.integration.test.ts index 5ca19d66ec..9b06fa2af4 100644 --- a/control-api/test/catalogProducers.realPostgres.integration.test.ts +++ b/control-api/test/catalogProducers.realPostgres.integration.test.ts @@ -13,12 +13,14 @@ import { catalogKey, } from '../src/services/access/catalogContracts.js' import { requireCatalogProducer } from '../src/services/access/catalogProducers.js' +import { loadDatabaseCandidates } from '../src/services/access/databaseAuthorityCandidates.js' import { OperationalAccessIndex } from '../src/services/access/operationalAccessIndex.js' import { OPERATIONAL_SOURCE_FAMILIES, canonicalEnvironmentId, projectOperationalObject, } from '../src/services/access/operationalAccessProjection.js' +import { canonicalResourceIdentity } from '../src/services/access/resourceIdentity.js' import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL @@ -50,6 +52,70 @@ describeRealPostgres('catalog producer SQL on real PostgreSQL', () => { let adminPool: Pool let databasePool: Pool + async function insertUser(): Promise { + const id = randomUUID() + await databasePool.query(`INSERT INTO users(id, email, name) VALUES ($1, $2, 'Catalog User')`, [ + id, + `${id}@example.test`, + ]) + return id + } + + async function observe(user: string, family: CatalogFamily, ids: readonly string[]) { + const budget = AccessExecutionBudget.create('catalog') + const principal = { + userId: user, + sessionContract: 'v1' as const, + sessionRevision: '1', + userRevision: '1', + authorizationRevision: '1', + memberships: [], + } + const context: CatalogRequestContext = { + db: databasePool, + budget, + principal, + environmentId, + sourceStates: new Map(), + } + try { + const producer = requireCatalogProducer(family) + const page = await producer.listCanonicalKeys( + context, + { afterKey: null, exhausted: false }, + 100 + ) + const hydrated = await producer.hydrateCanonicalKeys( + context, + [...ids].sort().map(id => catalogKey(environmentId, family, id)) + ) + const resources = Array.isArray(hydrated) ? hydrated : hydrated.resources + const authority: string[] = [] + for (const id of ids) { + const result = await loadDatabaseCandidates({ + db: databasePool, + budget, + snapshot: { ...principal, sessionLive: true, resourceRevision: '1' }, + resource: canonicalResourceIdentity({ environmentId, type: family, logicalId: id }), + }) + if (result.exists) authority.push(id) + } + return { + keys: page.candidates.map(candidate => candidate.key[2]).filter(id => ids.includes(id)), + hydrated: resources.map(resource => resource.key[2]), + authority, + } + } finally { + budget.close() + } + } + + function expectVisible(result: Awaited>, expected: readonly string[]) { + expect(result.keys.sort()).toEqual([...expected].sort()) + expect(result.hydrated.sort()).toEqual([...expected].sort()) + expect(result.authority.sort()).toEqual([...expected].sort()) + } + beforeAll(async () => { adminPool = new Pool({ connectionString: adminUrl }) await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) @@ -76,6 +142,80 @@ describeRealPostgres('catalog producer SQL on real PostgreSQL', () => { } }) + it('S3 C7 restricts team run paths to the actor or team-scoped runs', async () => { + const alice = await insertUser() + const bob = await insertUser() + const team = randomUUID() + const ids = Array.from({ length: 7 }, () => randomUUID()) + await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, 'Run Team')`, [team]) + await databasePool.query( + `INSERT INTO team_members(team_id, user_id, role, status) + VALUES ($1, $2, 'member', 'active'), ($1, $3, 'member', 'active')`, + [team, alice, bob] + ) + await databasePool.query( + `INSERT INTO team_workflow_triggers(team_id, recipe_namespace, recipe_name) + VALUES ($1, 's3', 'team-run')`, + [team] + ) + await databasePool.query( + `INSERT INTO workflow_runs( + run_id, recipe_namespace, recipe_name, phase, actor_type, actor_id, + team_id, usage_team_id, trigger_source + ) VALUES + ($1, 's3', 'team-run', 'Succeeded', 'user', $8, $10, NULL, 'onDemand'), + ($2, 's3', 'team-run', 'Succeeded', 'user', $8, NULL, $10::text, 'onDemand'), + ($3, 's3', 'team-run', 'Succeeded', 'user', $9, $10, NULL, 'onDemand'), + ($4, 's3', 'team-run', 'Succeeded', 'scheduled', NULL, $10, NULL, 'schedule'), + ($5, 's3', 'team-run', 'Succeeded', 'autonomous', NULL, NULL, $10::text, 'autonomous'), + ($6, 's3', 'team-run', 'Succeeded', 'user', NULL, $10, NULL, 'onDemand'), + ($7, 's3', 'team-run', 'Succeeded', 'admin', NULL, $10, NULL, 'onDemand')`, + [...ids, alice, bob, team] + ) + expectVisible(await observe(bob, 'workflow_run', ids), [ids[2]!, ids[3]!, ids[4]!, ids[5]!]) + expectVisible(await observe(alice, 'workflow_run', ids), [ + ids[0]!, + ids[1]!, + ids[3]!, + ids[4]!, + ids[5]!, + ]) + }) + + it('S3 TERRA1 uses the approval allowlist instead of team trigger grants', async () => { + const member = await insertUser() + const outsider = await insertUser() + const team = randomUUID() + const allowed = randomUUID() + const denied = randomUUID() + await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, 'Approval Team')`, [team]) + await databasePool.query( + `INSERT INTO team_members(team_id, user_id, role, status) VALUES ($1, $2, 'member', 'active')`, + [team, member] + ) + await databasePool.query( + `INSERT INTO workflow_recipe_allowed_teams(recipe_namespace, recipe_name, team_id) + VALUES ('s3', 'allowed', $1)`, + [team] + ) + await databasePool.query( + `INSERT INTO team_workflow_triggers(team_id, recipe_namespace, recipe_name) + VALUES ($1, 's3', 'trigger-only')`, + [team] + ) + await databasePool.query( + `INSERT INTO workflow_approval_requests(id, recipe_namespace, recipe_name, expires_at, + status, target_team_id, payload, idempotency_key, payload_hash) + VALUES ($1, 's3', 'allowed', NOW() + INTERVAL '1 hour', 'pending', $3, '{}', $1::uuid::text, 'hash'), + ($2, 's3', 'trigger-only', NOW() + INTERVAL '1 hour', 'pending', $3, '{}', $2::uuid::text, 'hash')`, + [allowed, denied, team] + ) + expectVisible(await observe(member, 'workflow_approval', [allowed, denied]), [allowed]) + expectVisible(await observe(outsider, 'workflow_approval', [allowed, denied]), []) + await databasePool.query(`DELETE FROM workflow_recipe_allowed_teams WHERE team_id = $1`, [team]) + expectVisible(await observe(member, 'workflow_approval', [allowed, denied]), []) + }) + it('orders and resumes arbitrary PostgreSQL text by exact UTF-8 bytes', async () => { const generated = fc.sample( fc From a8fa655dc07931a8a4d6e85e4e8da3dd9266ea2f Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 29 Oct 2026 00:49:43 +0100 Subject: [PATCH 396/528] test(ci): register the three new real-PostgreSQL suites in the census The suite census must list every control-api realPostgres file; the Minikube T1 lane and CI refuse unregistered suites before running npm. Adds the archive lock-order, revision-writer lock-order and V1 run-read and approval compatibility suites added by the S1 and S2 review fixes. Co-Authored-By: Claude Opus 5.5 --- tests/contracts/password-recovery-suites.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/contracts/password-recovery-suites.json b/tests/contracts/password-recovery-suites.json index 1cb4a0eae5..51a8ae550d 100644 --- a/tests/contracts/password-recovery-suites.json +++ b/tests/contracts/password-recovery-suites.json @@ -124,7 +124,10 @@ "control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts", "control-api/test/routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts", "control-api/test/userAccessRuntimeContract.realPostgres.integration.test.ts", - "control-api/test/userSessionDatabaseClock.realPostgres.integration.test.ts" + "control-api/test/userSessionDatabaseClock.realPostgres.integration.test.ts", + "control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts", + "control-api/test/revisionWritersLockOrder.realPostgres.integration.test.ts", + "control-api/test/workflowRunReadAndApprovalV1Compatibility.realPostgres.integration.test.ts" ], "privatePostgres": { "local": "control-api/test/passwordResetPayload.externalRest.realPostgres.integration.test.ts", From 85889fd3b7fca31101615bc040e5c371b221c358 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 29 Oct 2026 00:47:04 +0100 Subject: [PATCH 397/528] fix(minikube): align branch-profile External REST browser origin The base profiles manifest pins EXTERNAL_REST_API_CORS_ORIGIN to http://localhost:3001 while a branch-owned Minikube profile serves Profile UI on a generated port. external-rest-api's browser mutation guard compares the request Origin with that value, so password login and every other cookie-bearing POST from the profile UI were rejected with 403 (review C-8, OD-8). sync-external-rest-api-cors-origin.sh resolves PROFILE_UI_URL from the profile's persisted ports.env and, only for a branch-owned profile, re-asserts it on profiles/external-rest-api after the kustomize overlay is applied: full-setup step 6d and minikube-deploy-all-body, the path pre-gate-sync uses. Re-running the reconcile after each apply is what stops a later sync from merging the field back to the manifest literal. Shared and protected contexts are skipped or refused before any kubectl call, the browser mutation guard and CORS parsing are untouched, and production/clerum-dev overlays do not change. The new contract drives the real script with a kubectl stub: drift patch, idempotent no-op, non-branch skip, missing ports fail-loud, unusable origin refusal, missing deployment, dry run, and the manifest/guard names it relies on. Co-Authored-By: DeepSeek V4.1 Flash --- Makefile | 12 + scripts/minikube/full-setup.sh | 13 + .../sync-external-rest-api-cors-origin.sh | 228 ++++++++++++++ scripts/tests/test-minikube-makefile.sh | 2 + ...kube-sync-external-rest-api-cors-origin.sh | 294 ++++++++++++++++++ 5 files changed, 549 insertions(+) create mode 100755 scripts/minikube/sync-external-rest-api-cors-origin.sh create mode 100755 scripts/tests/test-minikube-sync-external-rest-api-cors-origin.sh diff --git a/Makefile b/Makefile index ab73001c85..0ee43aa0da 100644 --- a/Makefile +++ b/Makefile @@ -479,6 +479,14 @@ minikube-deploy-all-body: @# rpc-proxy-secrets after each full overlay apply so Desktop/rpc-proxy/mcp-host @# stay on the same JWT validation key. @MINIKUBE_GFS_MUTATION="$(MINIKUBE_GFS_MUTATION)" $(MAKE) --no-print-directory minikube-sync-auth-key + @# external-rest-api's browser mutation guard compares the request Origin with + @# EXTERNAL_REST_API_CORS_ORIGIN, and the base manifest pins localhost:3001. A + @# branch profile serves Profile UI on a generated port, so the overlay apply + @# above would otherwise leave every profile UI login on a 403 (review C-8, + @# OD-8). Re-assert the profile-owned origin after the apply; the script is a + @# no-op without a branch profile ports.env. Plain bash, not $(MAKE), so a + @# dry run never executes it. + @bash scripts/minikube/sync-external-rest-api-cors-origin.sh --context=$(MINIKUBE_PROFILE) @# The pre-overlay helper migrates legacy last-applied ownership without @# removing the provisioning-owned connection-string. When the GFS stack is @# deployed AND control-api is Ready (migration 0048 applied), re-provision @@ -724,6 +732,10 @@ minikube-sync-auth-key-if-present: ## Sync JWT public key only when minikube aut minikube-sync-codex-subscription-url: ## Resolve branch Control UI URL and sync CONTROL_API_CONTROL_UI_BASE_URL for Codex OAuth @bash scripts/minikube/sync-codex-subscription-control-ui-url.sh --context=$(MINIKUBE_PROFILE) +.PHONY: minikube-sync-external-rest-api-cors-origin +minikube-sync-external-rest-api-cors-origin: ## Align profiles/external-rest-api EXTERNAL_REST_API_CORS_ORIGIN with a branch profile's Profile UI + @bash scripts/minikube/sync-external-rest-api-cors-origin.sh --context=$(MINIKUBE_PROFILE) + # ── Minikube Port Forwards ────────────────────────────────────────── .PHONY: minikube-pf-control-ui minikube-pf-control-ui: ## Port-forward Control UI → localhost:3000 diff --git a/scripts/minikube/full-setup.sh b/scripts/minikube/full-setup.sh index 393152e294..dc9dfee406 100755 --- a/scripts/minikube/full-setup.sh +++ b/scripts/minikube/full-setup.sh @@ -1901,6 +1901,19 @@ log "Re-applying inter-service tokens after kustomize deploy..." CONTEXT="${PROFILE}" bash "${PROJECT_DIR}/deploy/scripts/apply-inter-service-tokens.sh" ok "Inter-service tokens re-applied after kustomize deploy" +# 6d. Align the profile's External REST browser origin with its Profile UI. +# The base manifest pins EXTERNAL_REST_API_CORS_ORIGIN to http://localhost:3001, +# while a branch profile serves Profile UI on a generated port; external-rest-api +# rejects cookie-bearing POSTs whose Origin is not configured, so the profile +# password login returned 403 on every branch profile (review C-8, OD-8). The +# value belongs to the branch-profile harness, so it is re-asserted here after +# every overlay apply: a later `kubectl apply` would otherwise merge the field +# back to the manifest literal. Non-branch profiles have no persisted ports.env +# and the sync exits without touching the cluster. +log "Aligning the Profile UI browser origin on profiles/external-rest-api..." +bash "${SCRIPT_DIR}/sync-external-rest-api-cors-origin.sh" --context="${PROFILE}" +ok "External REST browser origin aligned for the branch profile (no-op otherwise)" + # 6e. Deploy CRD instances log "Applying CRD instances..." $KC apply -f "${PROJECT_DIR}/deploy/overlays/minikube/instances/" diff --git a/scripts/minikube/sync-external-rest-api-cors-origin.sh b/scripts/minikube/sync-external-rest-api-cors-origin.sh new file mode 100755 index 0000000000..470e1125b5 --- /dev/null +++ b/scripts/minikube/sync-external-rest-api-cors-origin.sh @@ -0,0 +1,228 @@ +#!/usr/bin/env bash +# Align the profiles/external-rest-api browser origin with a branch profile's +# Profile UI origin. +# +# WHY THIS EXISTS +# The base manifest pins EXTERNAL_REST_API_CORS_ORIGIN to http://localhost:3001 +# (deploy/base/profiles/external-rest-api.yaml) while a branch-owned Minikube +# profile serves Profile UI on a generated port (scripts/minikube-profiles/ +# branch-profile.sh: PROFILE_UI_URL). external-rest-api's browser mutation +# guard requires the request Origin to be one of the configured origins +# (external-rest-api/src/middleware/browserMutationGuard.ts), and Next's rewrite +# proxy forwards the browser Origin unchanged. Password login and every other +# cookie-bearing POST from the profile UI therefore returned 403 on a branch +# profile (review item C-8, owner decision OD-8). +# +# The guard and the CORS checks stay exactly as they are: this script gives the +# profile-scoped deployment the one origin it actually serves. Production and +# clerum-dev keep using their own manifests and never reach this path. +# +# WHERE IT RUNS +# The branch-profile harness owns the value inside its own deploy paths, after +# the kustomize overlay is applied: +# * scripts/minikube/full-setup.sh (make minikube-setup, branch-profile-setup) +# * Makefile minikube-deploy-all-body (make minikube-deploy-all, pre-gate-sync) +# A plain `kubectl apply` of the overlay three-way merges the field back to the +# manifest literal, so a later sync would otherwise revert the value; running +# the reconcile after every apply is what keeps it. +# +# NOT A BRANCH PROFILE -> NO-OP +# A branch-owned profile always carries a persisted ports.env written by +# scripts/minikube-profiles/branch-profile.sh. Without one this script logs and +# exits 0 before touching the cluster, so shared profiles (clerum-test, +# clerum-dev, ...) keep their manifest value. A profile whose name is +# branch-scoped but whose ports are missing fails loud instead: silently +# skipping there would leave the profile UI login broken with a 403. +# +# Usage: +# scripts/minikube/sync-external-rest-api-cors-origin.sh --context +# scripts/minikube/sync-external-rest-api-cors-origin.sh --context --dry-run +# CLERUM_PROFILE_PORTS_ENV=/path/to/ports.env ... (explicit ports file) +set -euo pipefail + +ENV_NAME="EXTERNAL_REST_API_CORS_ORIGIN" +NAMESPACE="profiles" +DEPLOYMENT="external-rest-api" +CONTAINER="external-rest-api" +ROLLOUT_TIMEOUT_SECONDS=180 + +PROFILE="${MINIKUBE_PROFILE:-${KUBECONTEXT:-}}" +PORTS_ENV="${CLERUM_PROFILE_PORTS_ENV:-}" +PROFILE_CACHE_ROOT="${CLERUM_PROFILE_CACHE_ROOT:-${HOME}/.cache/clerum/minikube-profiles}" +PORTS_ENV_EXPLICIT=false +DRY_RUN=false + +log() { + printf '[external-rest-api-cors-origin] %s\n' "$*" +} + +die() { + log "ERROR: $*" >&2 + exit 1 +} + +usage() { + cat <<'EOF' +Usage: scripts/minikube/sync-external-rest-api-cors-origin.sh [options] + +Options: + --context Kubernetes context / Minikube profile (default: MINIKUBE_PROFILE) + --ports-env Branch profile ports.env (default: CLERUM_PROFILE_PORTS_ENV or + $HOME/.cache/clerum/minikube-profiles//ports.env) + --dry-run Print the resolved Profile UI origin without patching the cluster + -h, --help Show this help + +The Deployment env value is resolved in this order: + 1. PROFILE_UI_URL already exported in the environment + 2. PROFILE_UI_URL from the branch profile ports.env + 3. http://127.0.0.1:${PROFILE_UI_PORT} from the same file + +Without a branch profile ports.env this is a no-op. Shared or protected +contexts are refused outright. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --context) + PROFILE="${2:-}" + shift 2 + ;; + --context=*) + PROFILE="${1#--context=}" + shift + ;; + --ports-env) + PORTS_ENV="${2:-}" + PORTS_ENV_EXPLICIT=true + shift 2 + ;; + --ports-env=*) + PORTS_ENV="${1#--ports-env=}" + PORTS_ENV_EXPLICIT=true + shift + ;; + --dry-run) + DRY_RUN=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + die "unknown argument: $1" + ;; + esac +done + +[[ -n "${PROFILE}" ]] || die "MINIKUBE_PROFILE or --context is required" + +if [[ -z "${PORTS_ENV}" ]]; then + PORTS_ENV="${PROFILE_CACHE_ROOT}/${PROFILE}/ports.env" +fi + +is_branch_scoped_profile() { + [[ "$1" =~ ^clerum-[a-z0-9][a-z0-9._-]*-[0-9a-f]{7,8}$ ]] +} + +# Shared and protected contexts keep their manifest value. They are checked +# only after a ports.env was found, so an ordinary `make minikube-deploy-all` +# against a shared profile stays a plain no-op instead of failing the deploy. +is_shared_or_protected_profile() { + case "$1" in + clerum | clerum-dev | clerum-test | minikube | docker-desktop | default | *gke* | *prod* | *staging*) return 0 ;; + *) return 1 ;; + esac +} + +read_ports_env_value() { + local key="$1" + awk -F= -v expected_key="${key}" '$1 == expected_key { sub(/^[^=]*=/, ""); print; exit }' "${PORTS_ENV}" +} + +if [[ ! -f "${PORTS_ENV}" ]]; then + if [[ "${PORTS_ENV_EXPLICIT}" == "true" ]]; then + die "profile ports file does not exist: ${PORTS_ENV}" + fi + if is_branch_scoped_profile "${PROFILE}"; then + die "PROFILE_PORTS_MISSING: branch-scoped profile ${PROFILE} has no persisted ports at ${PORTS_ENV}; refusing to leave the profile UI login on the shared manifest origin" + fi + log "profile ${PROFILE} is not branch-owned (no ${PORTS_ENV}); nothing to sync" + exit 0 +fi + +is_shared_or_protected_profile "${PROFILE}" && + die "refusing shared or protected context ${PROFILE}: a branch profile ports file is not its owner" + +resolve_profile_ui_origin() { + local origin port + origin="${PROFILE_UI_URL:-}" + if [[ -z "${origin}" ]]; then + origin="$(read_ports_env_value PROFILE_UI_URL)" + fi + if [[ -z "${origin}" ]]; then + port="$(read_ports_env_value PROFILE_UI_PORT)" + if [[ "${port}" =~ ^[0-9]{2,5}$ ]]; then + origin="http://127.0.0.1:${port}" + fi + fi + printf '%s' "${origin%/}" +} + +validate_origin() { + local origin="$1" + # Exactly what a browser sends in the Origin header: scheme, host and an + # optional port. No wildcard, no path, no list, no whitespace. + [[ "${origin}" =~ ^https?://[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?(:[0-9]{2,5})?$ ]] || + die "refusing an unusable browser origin: ${origin}" +} + +# The env var is a literal value in the profile Deployment (see +# deploy/base/profiles/external-rest-api.yaml), so the live value can be read +# back and compared before writing. +current_cors_origin() { + local jsonpath + jsonpath="{.spec.template.spec.containers[?(@.name==\"${CONTAINER}\")].env[?(@.name==\"${ENV_NAME}\")].value}" + "${KCTL[@]}" get deployment "${DEPLOYMENT}" -n "${NAMESPACE}" -o "jsonpath=${jsonpath}" +} + +PROFILE_UI_ORIGIN="$(resolve_profile_ui_origin)" +[[ -n "${PROFILE_UI_ORIGIN}" ]] || + die "could not resolve PROFILE_UI_URL from ${PORTS_ENV}; the profile UI login origin is unknown" +validate_origin "${PROFILE_UI_ORIGIN}" + +log "Profile: ${PROFILE}" +log "Profile UI origin: ${PROFILE_UI_ORIGIN}" + +if [[ "${DRY_RUN}" == "true" ]]; then + log "Dry run complete; cluster unchanged" + exit 0 +fi + +KCTL=(kubectl --context="${PROFILE}") + +if ! "${KCTL[@]}" get deployment "${DEPLOYMENT}" -n "${NAMESPACE}" >/dev/null 2>&1; then + die "missing ${NAMESPACE}/${DEPLOYMENT}; apply the minikube overlay before syncing the profile UI origin" +fi + +current_origin="$(current_cors_origin || true)" +if [[ "${current_origin}" == "${PROFILE_UI_ORIGIN}" ]]; then + log "${NAMESPACE}/${DEPLOYMENT} ${ENV_NAME} already matches ${PROFILE_UI_ORIGIN}" + exit 0 +fi + +# Strategic merge so the env entry is replaced BY NAME and every other env +# entry the overlay manages stays untouched. +patch_payload="$(printf '{"spec":{"template":{"spec":{"containers":[{"name":"%s","env":[{"name":"%s","value":"%s"}]}]}}}}' \ + "${CONTAINER}" "${ENV_NAME}" "${PROFILE_UI_ORIGIN}")" + +log "Patching ${NAMESPACE}/${DEPLOYMENT} ${ENV_NAME} (was '${current_origin:-}')" +"${KCTL[@]}" patch deployment "${DEPLOYMENT}" -n "${NAMESPACE}" \ + --type=strategic --patch "${patch_payload}" >/dev/null + +log "Waiting for the ${NAMESPACE}/${DEPLOYMENT} rollout" +"${KCTL[@]}" rollout status "deployment/${DEPLOYMENT}" -n "${NAMESPACE}" \ + --timeout="${ROLLOUT_TIMEOUT_SECONDS}s" >/dev/null + +log "Profile UI origin synced on ${NAMESPACE}/${DEPLOYMENT}" diff --git a/scripts/tests/test-minikube-makefile.sh b/scripts/tests/test-minikube-makefile.sh index 6cde6d992b..02b9e318ec 100755 --- a/scripts/tests/test-minikube-makefile.sh +++ b/scripts/tests/test-minikube-makefile.sh @@ -258,6 +258,8 @@ assert_contains minikube-start 'open -a "Docker Desktop"' assert_not_contains minikube-start "docker info" assert_contains minikube-deploy-all "with-t2-mutation-lock.sh" assert_contains minikube-deploy-all-body "minikube-sync-auth-key" +assert_contains minikube-deploy-all-body "scripts/minikube/sync-external-rest-api-cors-origin.sh" +assert_contains minikube-sync-external-rest-api-cors-origin "scripts/minikube/sync-external-rest-api-cors-origin.sh" assert_contains minikube-build-images "with-t2-mutation-lock.sh" assert_contains minikube-build-images-body "require-t2-mutation-lock.sh" assert_contains minikube-build-custom-coordinator-fixture "with-t2-mutation-lock.sh" diff --git a/scripts/tests/test-minikube-sync-external-rest-api-cors-origin.sh b/scripts/tests/test-minikube-sync-external-rest-api-cors-origin.sh new file mode 100755 index 0000000000..8d67056676 --- /dev/null +++ b/scripts/tests/test-minikube-sync-external-rest-api-cors-origin.sh @@ -0,0 +1,294 @@ +#!/usr/bin/env bash +# Contract for scripts/minikube/sync-external-rest-api-cors-origin.sh. +# +# Review item C-8 / owner decision OD-8: a branch profile serves Profile UI on a +# generated port, so EXTERNAL_REST_API_CORS_ORIGIN must be the Profile UI origin +# on the profile's external-rest-api or every cookie-bearing login POST is +# rejected by the browser mutation guard. This suite drives the real script with +# a PATH kubectl stub: no cluster, no context, no credentials. +# +# Every negative assertion is paired with a positive witness proving the path +# under test actually ran (the stub call log and the captured patch payload). +set -euo pipefail + +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)" +SCRIPT="${ROOT}/scripts/minikube/sync-external-rest-api-cors-origin.sh" +MANIFEST="${ROOT}/deploy/base/profiles/external-rest-api.yaml" +GUARD="${ROOT}/external-rest-api/src/middleware/browserMutationGuard.ts" + +for file in "${SCRIPT}" "${MANIFEST}" "${GUARD}"; do + [[ -f "${file}" ]] || { printf 'FAIL: missing file under test: %s\n' "${file}" >&2; exit 1; } +done + +TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/external-rest-api-cors-origin.XXXXXX")" +cleanup() { rm -rf "${TMP_DIR}"; } +trap cleanup EXIT + +STUB_BIN="${TMP_DIR}/bin" +STATE_DIR="${TMP_DIR}/state" +LOG_FILE="${TMP_DIR}/kubectl.log" +OUT_DIR="${TMP_DIR}/out" +PORTS_ENV="${TMP_DIR}/ports.env" +mkdir -p "${STUB_BIN}" "${STATE_DIR}" "${OUT_DIR}" + +PROFILE="clerum-feat-cors-1234abcd" +EXPECTED_ORIGIN="http://127.0.0.1:23118" + +cat >"${STUB_BIN}/kubectl" <<'SH' +#!/usr/bin/env bash +set -euo pipefail + +while [[ "${1:-}" == --context* ]]; do + if [[ "${1}" == "--context" ]]; then + shift 2 + else + shift + fi +done + +printf '%s\n' "$*" >>"${TEST_KUBECTL_LOG}" + +if [[ "${1:-}" == "get" && "${2:-}" == "deployment" && "${3:-}" == "external-rest-api" ]]; then + if printf '%s\n' "$@" | grep -q '^jsonpath='; then + [[ "${TEST_MISSING_DEPLOYMENT:-false}" != "true" ]] || exit 1 + printf '%s' "${TEST_CURRENT_ORIGIN:-}" + exit 0 + fi + if [[ "${TEST_MISSING_DEPLOYMENT:-false}" == "true" ]]; then + echo 'Error from server (NotFound): deployments.apps "external-rest-api" not found' >&2 + exit 1 + fi + exit 0 +fi + +if [[ "${1:-}" == "patch" && "${2:-}" == "deployment" && "${3:-}" == "external-rest-api" ]]; then + next_is_patch=false + for arg in "$@"; do + if [[ "${next_is_patch}" == "true" ]]; then + printf '%s' "${arg}" >"${TEST_STATE_DIR}/last-patch.json" + exit 0 + fi + if [[ "${arg}" == "--patch" || "${arg}" == "-p" ]]; then + next_is_patch=true + elif [[ "${arg}" == --patch=* || "${arg}" == -p=* ]]; then + printf '%s' "${arg#*=}" >"${TEST_STATE_DIR}/last-patch.json" + exit 0 + fi + done + echo 'patch invoked without a payload' >&2 + exit 98 +fi + +if [[ "${1:-}" == "rollout" && "${2:-}" == "status" && "${3:-}" == "deployment/external-rest-api" ]]; then + printf '%s\n' "$*" >"${TEST_STATE_DIR}/rollout-status" + exit 0 +fi + +echo "unexpected kubectl invocation: $*" >&2 +exit 99 +SH +chmod +x "${STUB_BIN}/kubectl" + +cat >"${PORTS_ENV}" <<'EOF' +PORT_BASE=23117 +PROFILE_UI_PORT=23118 +EXTERNAL_REST_API_PORT=23208 +PROFILE_UI_URL=http://127.0.0.1:23118 +PROFILE_UI_BASE_URL=http://127.0.0.1:23118 +EXTERNAL_REST_API_URL=http://127.0.0.1:23208 +EOF + +export TEST_KUBECTL_LOG="${LOG_FILE}" +export TEST_STATE_DIR="${STATE_DIR}" +export PATH="${STUB_BIN}:${PATH}" +# The default profile cache must never resolve to the operator's real profiles. +export CLERUM_PROFILE_CACHE_ROOT="${TMP_DIR}/cache-root" +unset MINIKUBE_PROFILE KUBECONTEXT CLERUM_PROFILE_PORTS_ENV PROFILE_UI_URL + +ASSERTIONS=0 +fail() { + printf 'FAIL: %s\n' "$*" >&2 + if [[ -n "${CASE_OUT:-}" && -f "${CASE_OUT}" ]]; then + printf -- '--- %s ---\n' "${CASE_OUT##*/}" >&2 + sed -n '1,40p' "${CASE_OUT}" >&2 + fi + printf -- '--- kubectl stub log ---\n' >&2 + sed -n '1,40p' "${LOG_FILE}" >&2 2>/dev/null || true + exit 1 +} +ok() { ASSERTIONS=$((ASSERTIONS + 1)); } + +CASE_RC=0 +CASE_OUT="" +reset_case() { + CASE_RC=0 + : >"${LOG_FILE}" + rm -rf "${STATE_DIR}" + mkdir -p "${STATE_DIR}" +} + +run_sync() { + local name="$1" + shift + CASE_OUT="${OUT_DIR}/${name}.out" + reset_case + PATH="${STUB_BIN}:${PATH}" bash "${SCRIPT}" "$@" >"${CASE_OUT}" 2>&1 || CASE_RC=$? +} + +assert_rc() { + local expected="$1" label="$2" + [[ "${CASE_RC}" == "${expected}" ]] || fail "${label}: expected exit ${expected}, got ${CASE_RC}" + ok +} + +assert_out_has() { + local needle="$1" label="$2" + grep -Fq -- "${needle}" "${CASE_OUT}" || fail "${label}: output lacks '${needle}'" + ok +} + +assert_patch_has() { + local needle="$1" label="$2" + [[ -f "${STATE_DIR}/last-patch.json" ]] || fail "${label}: no patch payload was captured" + grep -Fq -- "${needle}" "${STATE_DIR}/last-patch.json" || { + printf 'FAIL: %s: patch payload lacks %s\n' "${label}" "${needle}" >&2 + cat "${STATE_DIR}/last-patch.json" >&2 + exit 1 + } + ok +} + +assert_patch_lacks() { + local needle="$1" label="$2" + if grep -Fq -- "${needle}" "${STATE_DIR}/last-patch.json" 2>/dev/null; then + fail "${label}: patch payload must not contain '${needle}'" + fi + ok +} + +assert_no_patch() { + local label="$1" + [[ ! -f "${STATE_DIR}/last-patch.json" ]] || fail "${label}: unexpected patch payload" + ok +} + +assert_no_kubectl_calls() { + local label="$1" + if [[ -s "${LOG_FILE}" ]]; then + fail "${label}: kubectl must not be called" + fi + ok +} + +assert_log_has() { + local needle="$1" label="$2" + grep -Fq -- "${needle}" "${LOG_FILE}" || fail "${label}: kubectl log lacks '${needle}'" + ok +} + +# ---- The contract this change is built on ---------------------------------- +# The env var must be the one the manifest and the guard already use; a rename +# upstream has to break this suite, not silently disable the reconcile. +grep -Fq 'name: EXTERNAL_REST_API_CORS_ORIGIN' "${MANIFEST}" || { + printf 'FAIL: base manifest no longer sets EXTERNAL_REST_API_CORS_ORIGIN\n' >&2 + exit 1 +} +ok +grep -Fq 'configuredOrigins().has(origin)' "${GUARD}" || { + printf 'FAIL: browser mutation guard no longer checks the configured origin\n' >&2 + exit 1 +} +ok +grep -Fq 'EXTERNAL_REST_API_CORS_ORIGIN' "${ROOT}/external-rest-api/src/config.ts" || { + printf 'FAIL: external-rest-api config no longer reads EXTERNAL_REST_API_CORS_ORIGIN\n' >&2 + exit 1 +} +ok + +ports_hash_before="$(shasum -a 256 "${PORTS_ENV}" | awk '{print $1}')" + +# ---- 1. Drift: the shared manifest origin is replaced by the profile origin -- +export TEST_CURRENT_ORIGIN='http://localhost:3001' +run_sync drift --context "${PROFILE}" --ports-env "${PORTS_ENV}" +assert_rc 0 'drift' +assert_out_has "Profile UI origin: ${EXPECTED_ORIGIN}" 'drift' +assert_patch_has "\"name\":\"EXTERNAL_REST_API_CORS_ORIGIN\",\"value\":\"${EXPECTED_ORIGIN}\"" 'drift' +assert_patch_lacks 'localhost:3001' 'drift' +assert_log_has 'get deployment external-rest-api -n profiles' 'drift probes the profile deployment' +assert_log_has 'patch deployment external-rest-api -n profiles --type=strategic' 'drift patches the profile deployment' +assert_log_has 'rollout status deployment/external-rest-api -n profiles' 'drift waits for the rollout' +ok + +# ---- 2. Idempotent: a matching value performs no mutation ------------------- +export TEST_CURRENT_ORIGIN="${EXPECTED_ORIGIN}" +run_sync steady --context "${PROFILE}" --ports-env "${PORTS_ENV}" +assert_rc 0 'steady' +assert_out_has 'already matches' 'steady' +assert_no_patch 'steady' +[[ ! -f "${STATE_DIR}/rollout-status" ]] || fail 'steady: unexpected rollout wait' +ok + +# ---- 3. Derived from PROFILE_UI_PORT when the URL line is absent ------------ +cat >"${TMP_DIR}/ports-minimal.env" <<'EOF' +PROFILE_UI_PORT=24007 +EOF +export TEST_CURRENT_ORIGIN='' +run_sync derived --context "${PROFILE}" --ports-env "${TMP_DIR}/ports-minimal.env" +assert_rc 0 'derived' +assert_patch_has '"value":"http://127.0.0.1:24007"' 'derived' + +# ---- 4. Non-branch profile: a plain no-op with zero kubectl calls ---------- +run_sync non-branch --context 'clerum-feature-noports' +assert_rc 0 'non-branch' +assert_out_has 'nothing to sync' 'non-branch' +assert_no_kubectl_calls 'non-branch' + +# ---- 5. Branch-scoped name without ports: fail loud, never silently skip ---- +run_sync missing-ports --context "${PROFILE}" +assert_rc 1 'missing-ports' +assert_out_has 'PROFILE_PORTS_MISSING' 'missing-ports' +assert_no_kubectl_calls 'missing-ports' + +# ---- 6. A branch ports file never lands on a shared or protected context ---- +run_sync shared-context --context 'clerum-dev' --ports-env "${PORTS_ENV}" +assert_rc 1 'shared-context' +assert_out_has 'refusing shared or protected context' 'shared-context' +assert_no_kubectl_calls 'shared-context' + +# ---- 7. Unusable origins are refused before any cluster mutation ----------- +for bad in '*' 'http://127.0.0.1:23118/login' 'http://127.0.0.1:23118,http://evil.test'; do + cat >"${TMP_DIR}/ports-bad.env" < Date: Thu, 29 Oct 2026 01:05:33 +0100 Subject: [PATCH 398/528] test(control-api): cover live workflow read authority Co-Authored-By: GLM-5.3 (Codex) --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- ...atibility.realPostgres.integration.test.ts | 26 +++--- .../test/workflowRunReadAuthorization.test.ts | 79 ++++++++++++++++++- 4 files changed, 94 insertions(+), 17 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index c8ffe0bc48..1fcc289148 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.858", + "version": "0.1.859", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.858", + "version": "0.1.859", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index fc7513ef90..e50c2c9d5a 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.858", + "version": "0.1.859", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/workflowRunReadAndApprovalV1Compatibility.realPostgres.integration.test.ts b/control-api/test/workflowRunReadAndApprovalV1Compatibility.realPostgres.integration.test.ts index efe42e7b80..601d786230 100644 --- a/control-api/test/workflowRunReadAndApprovalV1Compatibility.realPostgres.integration.test.ts +++ b/control-api/test/workflowRunReadAndApprovalV1Compatibility.realPostgres.integration.test.ts @@ -368,10 +368,11 @@ describeRealPostgres('PR334 S2 workflow run visibility and V1 approval replay', otherRunId = fixture.otherRunId! teamlessRunId = fixture.teamlessRunId! archiveRunId = fixture.archiveRunId! - await databasePool.query(`DELETE FROM team_members WHERE team_id = $1 AND user_id = $2`, [ - TEAM_ID, - OWNER_ID, - ]) + await databasePool.query( + `UPDATE team_members SET status = 'deleted' + WHERE team_id = $1 AND user_id = $2`, + [TEAM_ID, OWNER_ID] + ) db = await import('../src/db.js') await db.initDb({ connect: () => databasePool.connect() }) @@ -516,21 +517,21 @@ describeRealPostgres('PR334 S2 workflow run visibility and V1 approval replay', recipeIndex: 3, claims: () => v1Claims(OWNER_ID, TEAM_ID), expectedKey: `external-rest-api:user:${OWNER_ID}:team:${TEAM_ID}`, - membershipDeleted: false, + membershipStatus: 'active', }, { label: 'teamless V1 session', recipeIndex: 4, claims: () => v1Claims(OWNER_ID, null), expectedKey: `external-rest-api:user:${OWNER_ID}:team:null`, - membershipDeleted: false, + membershipStatus: null, }, { label: 'removed team membership', recipeIndex: 5, claims: () => v1Claims(OWNER_ID, TEAM_ID), expectedKey: `external-rest-api:user:${OWNER_ID}:team:${TEAM_ID}`, - membershipDeleted: true, + membershipStatus: 'deleted', }, ])('replays a base-produced V1 approval with the base caller key ($label)', async scenario => { const recipe = recipes[scenario.recipeIndex]! @@ -545,12 +546,17 @@ describeRealPostgres('PR334 S2 workflow run visibility and V1 approval replay', ) ).rows[0]?.trigger_caller_key expect(persistedKey).toBe(scenario.expectedKey) - if (scenario.membershipDeleted) { + if (scenario.membershipStatus) { + await databasePool.query( + `UPDATE team_members SET status = $3 + WHERE team_id = $1 AND user_id = $2`, + [TEAM_ID, OWNER_ID, scenario.membershipStatus] + ) const member = await databasePool.query( - 'SELECT count(*)::int AS count FROM team_members WHERE team_id = $1 AND user_id = $2', + 'SELECT status FROM team_members WHERE team_id = $1 AND user_id = $2', [TEAM_ID, OWNER_ID] ) - expect(member.rows[0]).toEqual({ count: 0 }) + expect(member.rows[0]).toEqual({ status: scenario.membershipStatus }) } const replay = await trigger.triggerWorkflow({ diff --git a/control-api/test/workflowRunReadAuthorization.test.ts b/control-api/test/workflowRunReadAuthorization.test.ts index 6dd8931ccd..1361f8c8f6 100644 --- a/control-api/test/workflowRunReadAuthorization.test.ts +++ b/control-api/test/workflowRunReadAuthorization.test.ts @@ -46,11 +46,82 @@ describe('workflow run user authorization', () => { expect(db.query).not.toHaveBeenCalled() }) - it('allows the same team-attributed run only with a current membership and recipe grant', async () => { - const db = { query: vi.fn().mockResolvedValue({ rows: [{ '?column?': 1 }], rowCount: 1 }) } + it.each([ + { + label: 'scheduled team-attributed run', + overrides: { actor_type: 'scheduled', actor_id: 'user-1', team_id: 'team-1' }, + }, + { + label: 'user run without an actor ID', + overrides: { actor_type: 'user', actor_id: null, team_id: 'team-1' }, + }, + ])( + 'allows a $label only through a current membership and recipe grant', + async ({ overrides }) => { + const db = { query: vi.fn().mockResolvedValue({ rows: [{ '?column?': 1 }], rowCount: 1 }) } + + await expect( + canCallerReadWorkflowRun( + caller, + run({ ...overrides, usage_team_id: 'team-1' }), + db as never + ) + ).resolves.toBe(true) + expect(db.query).toHaveBeenCalledTimes(1) + expect(String(db.query.mock.calls[0]?.[0])).toContain('FROM team_members tm') + expect(String(db.query.mock.calls[0]?.[0])).toContain('JOIN team_workflow_triggers twt') + } + ) + + it.each([ + { + label: 'scheduled team-attributed run', + overrides: { actor_type: 'scheduled', actor_id: 'user-1', team_id: 'team-1' }, + }, + { + label: 'user run without an actor ID', + overrides: { actor_type: 'user', actor_id: null, team_id: 'team-1' }, + }, + ])('denies a $label after membership removal', async ({ overrides }) => { + const db = { query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }) } await expect( - canCallerReadWorkflowRun(caller, run({ team_id: 'team-1' }), db as never) - ).resolves.toBe(true) + canCallerReadWorkflowRun(caller, run({ ...overrides, usage_team_id: 'team-1' }), db as never) + ).resolves.toBe(false) + expect(db.query).toHaveBeenCalledTimes(1) }) + + it.each([ + { + label: 'scheduled team-attributed run', + overrides: { actor_type: 'scheduled', actor_id: 'user-1', team_id: 'team-1' }, + }, + { + label: 'user run without an actor ID', + overrides: { actor_type: 'user', actor_id: null, team_id: 'team-1' }, + }, + ])('denies a $label after recipe grant removal', async ({ overrides }) => { + const db = { query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }) } + + await expect( + canCallerReadWorkflowRun(caller, run({ ...overrides, usage_team_id: 'team-1' }), db as never) + ).resolves.toBe(false) + expect(db.query).toHaveBeenCalledTimes(1) + }) + + it.each(['scheduled', 'autonomous'])( + 'does not give a caller the own-run shortcut for a %s run with a matching actor ID', + async actorType => { + const db = { query: vi.fn() } + + await expect( + canCallerReadWorkflowRun( + caller, + run({ actor_type: actorType, actor_id: 'user-1', team_id: null }), + db as never + ) + ).resolves.toBe(false) + expect(db.query).not.toHaveBeenCalled() + } + ) }) From 8d0a00af0f1c499d992b9e15654c3001bfe8192b Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 29 Oct 2026 01:08:55 +0100 Subject: [PATCH 399/528] fix(minikube): harden branch-profile External REST origin reconcile Read the Profile UI origin only from the branch profile's persisted ports.env and refuse an ambient PROFILE_UI_URL that disagrees with it, so the caller's shell cannot rewrite the origin the profile owns. Fold the shared/protected context name with LC_ALL=C tr before the name case, matching refuse_shared_profile_name in branch-profile.sh, so CLERUM-DEV and GKE_* are refused exactly as their lowercase forms are. Strengthen the contract: the kubectl stub logs the raw argv, every call must carry the profile's --context, steady and missing-deployment must prove the deployment get, refusals must prove zero kubectl calls, the dry run must prove the dry-run line with no mutation, and assert_patch_lacks fails when no patch payload was captured. Co-Authored-By: DeepSeek V4.1 Flash --- .../sync-external-rest-api-cors-origin.sh | 38 +++++--- ...kube-sync-external-rest-api-cors-origin.sh | 88 +++++++++++++++++-- 2 files changed, 107 insertions(+), 19 deletions(-) diff --git a/scripts/minikube/sync-external-rest-api-cors-origin.sh b/scripts/minikube/sync-external-rest-api-cors-origin.sh index 470e1125b5..2c7b04eb9c 100755 --- a/scripts/minikube/sync-external-rest-api-cors-origin.sh +++ b/scripts/minikube/sync-external-rest-api-cors-origin.sh @@ -26,6 +26,13 @@ # manifest literal, so a later sync would otherwise revert the value; running # the reconcile after every apply is what keeps it. # +# ORIGIN SOURCE +# The origin comes only from the branch profile's own ports.env: PROFILE_UI_URL +# first, then http://127.0.0.1:${PROFILE_UI_PORT}. An ambient PROFILE_UI_URL +# (operator shell, wrapper, dotenv) must not diverge from that file, so a +# disagreement is refused before any cluster call instead of being written onto +# the profile Deployment. +# # NOT A BRANCH PROFILE -> NO-OP # A branch-owned profile always carries a persisted ports.env written by # scripts/minikube-profiles/branch-profile.sh. Without one this script logs and @@ -72,13 +79,13 @@ Options: --dry-run Print the resolved Profile UI origin without patching the cluster -h, --help Show this help -The Deployment env value is resolved in this order: - 1. PROFILE_UI_URL already exported in the environment - 2. PROFILE_UI_URL from the branch profile ports.env - 3. http://127.0.0.1:${PROFILE_UI_PORT} from the same file +The Deployment env value is resolved from the branch profile ports.env: + 1. PROFILE_UI_URL from ports.env + 2. http://127.0.0.1:${PROFILE_UI_PORT} from the same file -Without a branch profile ports.env this is a no-op. Shared or protected -contexts are refused outright. +An ambient PROFILE_UI_URL that disagrees with that file is refused. Without a +branch profile ports.env this is a no-op. Shared or protected contexts are +refused outright, case-insensitively. EOF } @@ -129,8 +136,12 @@ is_branch_scoped_profile() { # Shared and protected contexts keep their manifest value. They are checked # only after a ports.env was found, so an ordinary `make minikube-deploy-all` # against a shared profile stays a plain no-op instead of failing the deploy. +# The name list is case-insensitive, exactly like refuse_shared_profile_name in +# scripts/minikube-profiles/branch-profile.sh. is_shared_or_protected_profile() { - case "$1" in + local normalized + normalized="$(printf '%s' "$1" | LC_ALL=C tr '[:upper:]' '[:lower:]')" + case "${normalized}" in clerum | clerum-dev | clerum-test | minikube | docker-desktop | default | *gke* | *prod* | *staging*) return 0 ;; *) return 1 ;; esac @@ -157,10 +168,7 @@ is_shared_or_protected_profile "${PROFILE}" && resolve_profile_ui_origin() { local origin port - origin="${PROFILE_UI_URL:-}" - if [[ -z "${origin}" ]]; then - origin="$(read_ports_env_value PROFILE_UI_URL)" - fi + origin="$(read_ports_env_value PROFILE_UI_URL)" if [[ -z "${origin}" ]]; then port="$(read_ports_env_value PROFILE_UI_PORT)" if [[ "${port}" =~ ^[0-9]{2,5}$ ]]; then @@ -188,6 +196,14 @@ current_cors_origin() { } PROFILE_UI_ORIGIN="$(resolve_profile_ui_origin)" + +# The branch profile owns its Profile UI port. Refuse an ambient value that +# disagrees with the file before any cluster call instead of writing it onto +# the profile Deployment. +if [[ -n "${PROFILE_UI_URL:-}" && "${PROFILE_UI_URL%/}" != "${PROFILE_UI_ORIGIN}" ]]; then + die "refusing ambient PROFILE_UI_URL='${PROFILE_UI_URL}': ${PORTS_ENV} records '${PROFILE_UI_ORIGIN:-}'" +fi + [[ -n "${PROFILE_UI_ORIGIN}" ]] || die "could not resolve PROFILE_UI_URL from ${PORTS_ENV}; the profile UI login origin is unknown" validate_origin "${PROFILE_UI_ORIGIN}" diff --git a/scripts/tests/test-minikube-sync-external-rest-api-cors-origin.sh b/scripts/tests/test-minikube-sync-external-rest-api-cors-origin.sh index 8d67056676..8c94a9fd73 100755 --- a/scripts/tests/test-minikube-sync-external-rest-api-cors-origin.sh +++ b/scripts/tests/test-minikube-sync-external-rest-api-cors-origin.sh @@ -9,6 +9,8 @@ # # Every negative assertion is paired with a positive witness proving the path # under test actually ran (the stub call log and the captured patch payload). +# The stub logs the raw argv, so every call is also checked for the profile's +# --context: dropping it from the script must turn this suite red. set -euo pipefail ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)" @@ -38,6 +40,10 @@ cat >"${STUB_BIN}/kubectl" <<'SH' #!/usr/bin/env bash set -euo pipefail +# Log the raw argv first, --context included, so the contract proves every call +# targeted the profile under test. +printf '%s\n' "$*" >>"${TEST_KUBECTL_LOG}" + while [[ "${1:-}" == --context* ]]; do if [[ "${1}" == "--context" ]]; then shift 2 @@ -46,8 +52,6 @@ while [[ "${1:-}" == --context* ]]; do fi done -printf '%s\n' "$*" >>"${TEST_KUBECTL_LOG}" - if [[ "${1:-}" == "get" && "${2:-}" == "deployment" && "${3:-}" == "external-rest-api" ]]; then if printf '%s\n' "$@" | grep -q '^jsonpath='; then [[ "${TEST_MISSING_DEPLOYMENT:-false}" != "true" ]] || exit 1 @@ -135,6 +139,14 @@ run_sync() { PATH="${STUB_BIN}:${PATH}" bash "${SCRIPT}" "$@" >"${CASE_OUT}" 2>&1 || CASE_RC=$? } +run_sync_env() { + local name="$1" assignment="$2" + shift 2 + CASE_OUT="${OUT_DIR}/${name}.out" + reset_case + PATH="${STUB_BIN}:${PATH}" env "${assignment}" bash "${SCRIPT}" "$@" >"${CASE_OUT}" 2>&1 || CASE_RC=$? +} + assert_rc() { local expected="$1" label="$2" [[ "${CASE_RC}" == "${expected}" ]] || fail "${label}: expected exit ${expected}, got ${CASE_RC}" @@ -160,7 +172,8 @@ assert_patch_has() { assert_patch_lacks() { local needle="$1" label="$2" - if grep -Fq -- "${needle}" "${STATE_DIR}/last-patch.json" 2>/dev/null; then + [[ -f "${STATE_DIR}/last-patch.json" ]] || fail "${label}: no patch payload was captured" + if grep -Fq -- "${needle}" "${STATE_DIR}/last-patch.json"; then fail "${label}: patch payload must not contain '${needle}'" fi ok @@ -186,6 +199,32 @@ assert_log_has() { ok } +# Every recorded call must target the profile under test, and no call may carry +# a second, different --context. The stub logs the raw argv for this. +assert_every_call_used_context() { + local label="$1" line rest calls=0 + while IFS= read -r line; do + [[ -n "${line}" ]] || continue + calls=$((calls + 1)) + rest="${line#--context=${PROFILE}}" + [[ "${line}" == "--context=${PROFILE} "* && "${rest}" != *"--context="* ]] || + fail "${label}: kubectl call did not target --context=${PROFILE}: ${line}" + done <"${LOG_FILE}" + [[ "${calls}" -gt 0 ]] || fail "${label}: no kubectl call was recorded to witness the context" + ok +} + +# A dry run may probe the deployment at most; an empty log is also valid. +assert_log_get_only() { + local label="$1" line + while IFS= read -r line; do + [[ -n "${line}" ]] || continue + [[ "${line}" == "--context=${PROFILE} get deployment external-rest-api "* ]] || + fail "${label}: dry run must not call kubectl beyond a deployment probe: ${line}" + done <"${LOG_FILE}" + ok +} + # ---- The contract this change is built on ---------------------------------- # The env var must be the one the manifest and the guard already use; a rename # upstream has to break this suite, not silently disable the reconcile. @@ -217,15 +256,17 @@ assert_patch_lacks 'localhost:3001' 'drift' assert_log_has 'get deployment external-rest-api -n profiles' 'drift probes the profile deployment' assert_log_has 'patch deployment external-rest-api -n profiles --type=strategic' 'drift patches the profile deployment' assert_log_has 'rollout status deployment/external-rest-api -n profiles' 'drift waits for the rollout' -ok +assert_every_call_used_context 'drift' # ---- 2. Idempotent: a matching value performs no mutation ------------------- export TEST_CURRENT_ORIGIN="${EXPECTED_ORIGIN}" run_sync steady --context "${PROFILE}" --ports-env "${PORTS_ENV}" assert_rc 0 'steady' assert_out_has 'already matches' 'steady' +assert_log_has 'get deployment external-rest-api -n profiles' 'steady probes the profile deployment' assert_no_patch 'steady' [[ ! -f "${STATE_DIR}/rollout-status" ]] || fail 'steady: unexpected rollout wait' +assert_every_call_used_context 'steady' ok # ---- 3. Derived from PROFILE_UI_PORT when the URL line is absent ------------ @@ -236,6 +277,7 @@ export TEST_CURRENT_ORIGIN='' run_sync derived --context "${PROFILE}" --ports-env "${TMP_DIR}/ports-minimal.env" assert_rc 0 'derived' assert_patch_has '"value":"http://127.0.0.1:24007"' 'derived' +assert_every_call_used_context 'derived' # ---- 4. Non-branch profile: a plain no-op with zero kubectl calls ---------- run_sync non-branch --context 'clerum-feature-noports' @@ -255,7 +297,31 @@ assert_rc 1 'shared-context' assert_out_has 'refusing shared or protected context' 'shared-context' assert_no_kubectl_calls 'shared-context' -# ---- 7. Unusable origins are refused before any cluster mutation ----------- +# ---- 7. That barrier folds case, exactly like branch-profile.sh ------------- +for shared_name in 'CLERUM-DEV' 'GKE_proj_zone_cluster'; do + run_sync shared-context-fold --context "${shared_name}" --ports-env "${PORTS_ENV}" + assert_rc 1 "shared-context ${shared_name}" + assert_out_has 'refusing shared or protected context' "shared-context ${shared_name}" + assert_no_kubectl_calls "shared-context ${shared_name}" +done + +# ---- 8. The profile file owns the origin; an ambient value never wins ------ +export TEST_CURRENT_ORIGIN='http://localhost:3001' +run_sync_env ambient-mismatch 'PROFILE_UI_URL=http://evil.example:8080' \ + --context "${PROFILE}" --ports-env "${PORTS_ENV}" +assert_rc 1 'ambient-mismatch' +assert_out_has 'refusing ambient PROFILE_UI_URL' 'ambient-mismatch' +assert_no_kubectl_calls 'ambient-mismatch' +assert_no_patch 'ambient-mismatch' + +# The matching value is tolerated and the file's origin is what gets written. +run_sync_env ambient-match "PROFILE_UI_URL=${EXPECTED_ORIGIN}" \ + --context "${PROFILE}" --ports-env "${PORTS_ENV}" +assert_rc 0 'ambient-match' +assert_patch_has "\"value\":\"${EXPECTED_ORIGIN}\"" 'ambient-match' +assert_every_call_used_context 'ambient-match' + +# ---- 9. Unusable origins are refused before any cluster call -------------- for bad in '*' 'http://127.0.0.1:23118/login' 'http://127.0.0.1:23118,http://evil.test'; do cat >"${TMP_DIR}/ports-bad.env" < Date: Thu, 29 Oct 2026 00:52:24 +0100 Subject: [PATCH 400/528] fix(ci): validate pinned real postgres suites from JSON Replace the console-name loop with the existing Vitest report gate for every previously checked suite. Co-Authored-By: GPT-6.1 Terra (Codex) --- .github/workflows/ci-public.yml | 73 +++++++++++---------------------- 1 file changed, 24 insertions(+), 49 deletions(-) diff --git a/.github/workflows/ci-public.yml b/.github/workflows/ci-public.yml index b3c31ddfb3..b2b78111c8 100644 --- a/.github/workflows/ci-public.yml +++ b/.github/workflows/ci-public.yml @@ -584,54 +584,8 @@ jobs: echo 'real-Postgres control-api lane reported skipped tests' >&2 exit 1 fi - # Critical suites must stay inside the real-Postgres filter. A rename or - # move that drops one from `*realPostgres*` would otherwise still pass - # the aggregate counts above. - for suite in \ - accessCatalogCoordinator.realPostgres.integration \ - accessCatalogFamilies.realPostgres.integration \ - accessCatalogPerformance.realPostgres.integration \ - accessCatalogShadow.realPostgres.integration \ - accessDatabaseCancellation.realPostgres.integration \ - catalogDistinctPagination.realPostgres.integration \ - catalogProducers.realPostgres.integration \ - catalogRevisionComponents.realPostgres.integration \ - db.codexSubscriptionConnection.realPostgres.integration \ - externalSessionSingleTouch.realPostgres.integration \ - finalAuthorityLifecycle.realPostgres.integration \ - gfsRevisionLockOrder.realPostgres.integration \ - invitationAcceptedOnboarding.realPostgres.integration \ - legacyPasswordEpochBackfill.realPostgres.integration \ - legacySessionExchangeRace.realPostgres.integration \ - migrationExecutionPolicy.realPostgres.integration \ - migrationPreviousImageCompatibility.realPostgres.integration \ - pr1MigrationLocks.realPostgres.integration \ - routes.externalTeamsFinalAuthority.realPostgres.integration \ - routes.externalTeamsLegacyAdminRevocation.realPostgres.integration \ - services.codexSubscriptionOAuth.realPostgres.integration \ - services.codexSubscriptionCatalog.realPostgres.integration \ - services.llmProviderAttemptAuthorization.realPostgres.integration \ - services.llmProviderAttemptStore.realPostgres.integration \ - services.llmProviderAttemptRedemption.realPostgres.integration \ - userAccessRuntimeContract.realPostgres.integration \ - userSessionDatabaseClock.realPostgres.integration \ - pluginWorkloadSdkCodexDualLedger.realPostgres.integration \ - pluginWorkloadSdkGrokDualLedger.realPostgres.integration \ - services.grokSubscriptionConnection.realPostgres.integration \ - services.grokProviderAttemptRedemption.realPostgres.integration \ - services.grokProviderAttemptRedemption.refresh.realPostgres.integration \ - services.codexSubscriptionLifecycle.realPostgres.integration \ - db.llmProviderAttemptConnectionIntegrity.realPostgres.integration \ - services.llmCatalogSync.realPostgres.integration - do - grep -Fq "$suite" "$normalized_log" || { - echo "real-Postgres control-api lane omitted critical suite ${suite}" >&2 - exit 1 - } - done - # Pinned suites (Codex/Grok subscription, provider attempts, and the - # external GFS rate-limit suites) must stay inside the real-Postgres - # filter and run. The check reads the JSON report, not the console: + # Pinned suites must stay inside the real-Postgres filter and run. + # The check reads the JSON report, not the console: # a file whose tests are all skipped is still printed by name. node ../scripts/ci/assert-pinned-vitest-suites.mjs "$report" \ db.codexSubscriptionConnection.realPostgres.integration.test.ts \ @@ -663,7 +617,28 @@ jobs: entityChangeFeed.realPostgres.integration.test.ts \ routes.mcp-host.renewal.realPostgres.integration.test.ts \ routes.mcpOauth.brokerBinding.realPostgres.integration.test.ts \ - db.realPostgresMigration.integration.test.ts + db.realPostgresMigration.integration.test.ts \ + accessCatalogCoordinator.realPostgres.integration.test.ts \ + accessCatalogFamilies.realPostgres.integration.test.ts \ + accessCatalogPerformance.realPostgres.integration.test.ts \ + accessCatalogShadow.realPostgres.integration.test.ts \ + accessDatabaseCancellation.realPostgres.integration.test.ts \ + catalogDistinctPagination.realPostgres.integration.test.ts \ + catalogProducers.realPostgres.integration.test.ts \ + catalogRevisionComponents.realPostgres.integration.test.ts \ + externalSessionSingleTouch.realPostgres.integration.test.ts \ + finalAuthorityLifecycle.realPostgres.integration.test.ts \ + gfsRevisionLockOrder.realPostgres.integration.test.ts \ + invitationAcceptedOnboarding.realPostgres.integration.test.ts \ + legacyPasswordEpochBackfill.realPostgres.integration.test.ts \ + legacySessionExchangeRace.realPostgres.integration.test.ts \ + migrationExecutionPolicy.realPostgres.integration.test.ts \ + migrationPreviousImageCompatibility.realPostgres.integration.test.ts \ + pr1MigrationLocks.realPostgres.integration.test.ts \ + routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts \ + routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts \ + userAccessRuntimeContract.realPostgres.integration.test.ts \ + userSessionDatabaseClock.realPostgres.integration.test.ts - name: Install gfs-controller dependencies working-directory: gfs-controller From 01271d020995175f403d67d2f1440c3da9609841 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 29 Oct 2026 01:27:03 +0100 Subject: [PATCH 401/528] test(ci): pin the three PR1 lock-order and run-read real-PostgreSQL suites Add archiveRevisionLockOrder, revisionWritersLockOrder and workflowRunReadAndApprovalV1Compatibility to the explicit pinned list read from the real-PostgreSQL JSON report. The census already requires them; the pin also fails if a file and its census entry are removed together. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci-public.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci-public.yml b/.github/workflows/ci-public.yml index b2b78111c8..7337cc8091 100644 --- a/.github/workflows/ci-public.yml +++ b/.github/workflows/ci-public.yml @@ -638,7 +638,10 @@ jobs: routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts \ routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts \ userAccessRuntimeContract.realPostgres.integration.test.ts \ - userSessionDatabaseClock.realPostgres.integration.test.ts + userSessionDatabaseClock.realPostgres.integration.test.ts \ + archiveRevisionLockOrder.realPostgres.integration.test.ts \ + revisionWritersLockOrder.realPostgres.integration.test.ts \ + workflowRunReadAndApprovalV1Compatibility.realPostgres.integration.test.ts - name: Install gfs-controller dependencies working-directory: gfs-controller From 0261c584c02a3d7895d479c872b3e342c749d633 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 29 Oct 2026 01:07:38 +0100 Subject: [PATCH 402/528] fix(ci): restore dev CodeQL query configuration Use the stock security-and-quality suite with the dev system-prompt-injection exclusion, and remove the repository query pack. Co-Authored-By: GPT-6.1 Terra (Codex) --- .github/codeql/codeql-config.yml | 12 - .../queries/EvenfireMissingRateLimiting.ql | 105 -------- .../queries/EvenfireUserControlledBypass.ql | 246 ------------------ .../queries/evenfire-security-and-quality.qls | 8 - .github/codeql/queries/qlpack.yml | 5 - .../EvenfireMissingRateLimiting.expected | 19 -- .../EvenfireMissingRateLimiting.qlref | 1 - .../src/middleware/externalSessionAuth.ts | 37 --- .../src/middleware/rateLimitMiddleware.ts | 3 - .../src/middleware/unrelatedMiddleware.ts | 3 - .../src/routes/external/composition.ts | 190 -------------- .../src/routes/external/modeling.ts | 62 ----- .../SystemPromptInjection.expected | 14 - .../SystemPromptInjection.qlref | 1 - .../mcp-host/src/llm/systemPromptInjection.ts | 30 --- .../EvenfireUserControlledBypass.expected | 147 ----------- .../EvenfireUserControlledBypass.qlref | 1 - .../control-api/src/http/publicApiError.ts | 7 - .../control-api/src/routes/external/auth.ts | 162 ------------ .github/codeql/queries/test/qlpack.yml | 6 - .../test/verify-security-and-quality-suite.sh | 94 ------- .github/workflows/codeql.yml | 54 +--- 22 files changed, 10 insertions(+), 1197 deletions(-) delete mode 100644 .github/codeql/codeql-config.yml delete mode 100644 .github/codeql/queries/EvenfireMissingRateLimiting.ql delete mode 100644 .github/codeql/queries/EvenfireUserControlledBypass.ql delete mode 100644 .github/codeql/queries/evenfire-security-and-quality.qls delete mode 100644 .github/codeql/queries/qlpack.yml delete mode 100644 .github/codeql/queries/test/MissingRateLimiting/EvenfireMissingRateLimiting.expected delete mode 100644 .github/codeql/queries/test/MissingRateLimiting/EvenfireMissingRateLimiting.qlref delete mode 100644 .github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/externalSessionAuth.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/rateLimitMiddleware.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/unrelatedMiddleware.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimiting/control-api/src/routes/external/composition.ts delete mode 100644 .github/codeql/queries/test/MissingRateLimiting/control-api/src/routes/external/modeling.ts delete mode 100644 .github/codeql/queries/test/SystemPromptInjection/SystemPromptInjection.expected delete mode 100644 .github/codeql/queries/test/SystemPromptInjection/SystemPromptInjection.qlref delete mode 100644 .github/codeql/queries/test/SystemPromptInjection/mcp-host/src/llm/systemPromptInjection.ts delete mode 100644 .github/codeql/queries/test/UserControlledBypass/EvenfireUserControlledBypass.expected delete mode 100644 .github/codeql/queries/test/UserControlledBypass/EvenfireUserControlledBypass.qlref delete mode 100644 .github/codeql/queries/test/UserControlledBypass/control-api/src/http/publicApiError.ts delete mode 100644 .github/codeql/queries/test/UserControlledBypass/control-api/src/routes/external/auth.ts delete mode 100644 .github/codeql/queries/test/qlpack.yml delete mode 100755 .github/codeql/queries/test/verify-security-and-quality-suite.sh diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml deleted file mode 100644 index d18e36556c..0000000000 --- a/.github/codeql/codeql-config.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: Evenfire CodeQL configuration - -# Run the stock security-and-quality suite exactly once, replacing only the -# missing-rate-limiting query with Evenfire's canonical middleware model. -disable-default-queries: true -queries: - - uses: ./.github/codeql/queries/evenfire-security-and-quality.qls - -# Query-test fixtures intentionally contain vulnerable examples. Keep them in -# the checkout for `codeql test run`, but out of the production JS/TS database. -paths-ignore: - - .github/codeql/queries/test/** diff --git a/.github/codeql/queries/EvenfireMissingRateLimiting.ql b/.github/codeql/queries/EvenfireMissingRateLimiting.ql deleted file mode 100644 index bea7f8d4d9..0000000000 --- a/.github/codeql/queries/EvenfireMissingRateLimiting.ql +++ /dev/null @@ -1,105 +0,0 @@ -/** - * @name Missing rate limiting - * @description An HTTP request handler that performs expensive operations without - * restricting the rate at which operations can be carried out is vulnerable - * to denial-of-service attacks. - * @kind problem - * @problem.severity warning - * @security-severity 7.5 - * @precision high - * @id js/missing-rate-limiting - * @tags security - * external/cwe/cwe-770 - * external/cwe/cwe-307 - * external/cwe/cwe-400 - */ - -import javascript -import semmle.javascript.security.dataflow.MissingRateLimiting -import semmle.javascript.RestrictedLocations - -private predicate isCanonicalEvenfireRateLimitImport(ImportSpecifier spec) { - spec.getImportedName() = "rateLimitMiddleware" and - spec.getImportDeclaration().getImportedFile().getRelativePath() = - "control-api/src/middleware/rateLimitMiddleware.ts" -} - -private class EvenfireRateLimitingMiddleware extends RateLimitingMiddleware, DataFlow::CallNode { - EvenfireRateLimitingMiddleware() { - exists(ImportSpecifier spec | - isCanonicalEvenfireRateLimitImport(spec) and - DataFlow::valueNode(spec).(DataFlow::SourceNode).flowsTo(this.getCalleeNode()) - ) - } - - override Routing::Node getRoutingNode() { - exists(DataFlow::Node ref | this.flowsTo(ref) and result = Routing::getNode(ref)) - } -} - -private predicate sameSourceFile(Routing::Node left, Routing::Node right) { - exists( - string path, int leftStartLine, int leftStartColumn, int leftEndLine, int leftEndColumn, - int rightStartLine, int rightStartColumn, int rightEndLine, int rightEndColumn - | - left.hasLocationInfo(path, leftStartLine, leftStartColumn, leftEndLine, leftEndColumn) and - right.hasLocationInfo(path, rightStartLine, rightStartColumn, rightEndLine, rightEndColumn) - ) -} - -/* D33 recognition removed under Spec 60. */ -/* -private predicate isCanonicalExternalLimiterIdentityImport(ImportSpecifier spec) { - spec.getImportDeclaration().getImportedFile().getRelativePath() = - "control-api/src/middleware/externalSessionAuth.ts" and - spec.getImportedName() = "requireExternalSessionLimiterIdentityWithPublicErrors" -} - -private predicate isCanonicalExternalLimiterIdentityHandler(Routing::Node useSite) { - exists(ImportSpecifier spec, DataFlow::Node installedNode | - isCanonicalExternalLimiterIdentityImport(spec) and - useSite = Routing::getNode(installedNode) and - DataFlow::valueNode(spec).(DataFlow::SourceNode).flowsTo(installedNode) - ) -} - -private predicate hasSameRouteEvenfireLimiterAfterContext(Routing::Node useSite) { - exists(EvenfireRateLimitingMiddleware middleware, Routing::Node limiterNode | - limiterNode = middleware.getRoutingNode() and - limiterNode = useSite.getNextSibling+() and - sameSourceFile(useSite, limiterNode) and - not exists(Routing::Node earlierNode | - earlierNode = useSite.getNextSibling+() and - limiterNode = earlierNode.getNextSibling+() and - not earlierNode.mayResumeDispatch() - ) - ) -} - -*/ - -private predicate hasEvenfireRateLimitingGuard(Routing::Node useSite) { - exists(EvenfireRateLimitingMiddleware middleware | - useSite.isGuardedByNode(middleware.getRoutingNode()) and - sameSourceFile(useSite, middleware.getRoutingNode()) - ) -} - - -private predicate hasRateLimitingGuard(Routing::Node useSite) { - exists(RateLimitingMiddleware middleware | - useSite.isGuardedByNode(middleware.getRoutingNode()) and - not middleware instanceof EvenfireRateLimitingMiddleware - ) or - hasEvenfireRateLimitingGuard(useSite) -} - -from - Routing::Node useSite, ExpensiveRouteHandler r, string explanation, DataFlow::Node reference, - string referenceLabel -where - useSite = Routing::getNode(r).getRouteInstallation() and - r.explain(explanation, reference, referenceLabel) and - not hasRateLimitingGuard(useSite) -select useSite, "This route handler " + explanation + ", but is not rate-limited.", reference, - referenceLabel diff --git a/.github/codeql/queries/EvenfireUserControlledBypass.ql b/.github/codeql/queries/EvenfireUserControlledBypass.ql deleted file mode 100644 index fdf5f875e9..0000000000 --- a/.github/codeql/queries/EvenfireUserControlledBypass.ql +++ /dev/null @@ -1,246 +0,0 @@ -/** - * @name User-controlled bypass of security check - * @description Conditions that the user controls are not suited for making security-related decisions. - * @kind path-problem - * @problem.severity error - * @security-severity 7.8 - * @precision medium - * @id js/user-controlled-bypass - * @tags security - * external/cwe/cwe-807 - * external/cwe/cwe-290 - */ - -import javascript -import semmle.javascript.security.dataflow.ConditionalBypassQuery -import ConditionalBypassFlow::PathGraph - -private predicate isLiteral4xx(Expr status) { - exists(int value | value = status.getIntValue() and value >= 400 and value <= 499) -} - -private predicate isLiteral2xx(Expr status) { - exists(int value | value = status.getIntValue() and value >= 200 and value <= 299) -} - -private predicate isDirectFixed4xxResponse(ReturnStmt ret) { - exists(MethodCallExpr statusCall, MethodCallExpr jsonCall | - statusCall.getMethodName() = "status" and - isLiteral4xx(statusCall.getArgument(0)) and - jsonCall.getMethodName() = "json" and - statusCall.getParentExpr*() = jsonCall and - jsonCall.getParentExpr*() = ret.getExpr() and - // The branch cannot call a producer of protected data before responding. - not exists(CallExpr nested | - nested.getParentExpr*() = ret.getExpr() and - nested != statusCall and - nested != jsonCall - ) - ) -} - -private predicate isDirectFixed4xxResponseCall(CallExpr call) { - exists(MethodCallExpr statusCall, MethodCallExpr jsonCall | - statusCall.getMethodName() = "status" and - isLiteral4xx(statusCall.getArgument(0)) and - jsonCall.getMethodName() = "json" and - statusCall.getParentExpr*() = jsonCall and - jsonCall.getParentExpr*() = call and - not exists(CallExpr nested | - nested.getParentExpr*() = call and - nested != statusCall and - nested != jsonCall - ) - ) -} - -private predicate isCanonicalPublicErrorImport(ImportSpecifier spec) { - spec.getImportedName() = "sendPublicApiError" and - spec.getImportDeclaration().getImportedFile().getRelativePath() = - "control-api/src/http/publicApiError.ts" -} - -private predicate isFixedPublicErrorCall(CallExpr call) { - exists(ImportSpecifier spec | - isCanonicalPublicErrorImport(spec) and - DataFlow::valueNode(spec).(DataFlow::SourceNode).flowsTo(DataFlow::valueNode(call.getCallee())) - ) and - isLiteral4xx(call.getArgument(2)) and - call.getArgument(3) instanceof StringLiteral and - call.getArgument(4) instanceof StringLiteral and - not exists(call.getArgument(5)) -} - -private predicate isFixedFailureBranch(Stmt branch) { - exists(ReturnStmt ret | branch = ret and isDirectFixed4xxResponse(ret)) - or - exists(BlockStmt block, ReturnStmt ret | - branch = block and - block.getNumStmt() = 1 and - ret = block.getStmt(0) and - isDirectFixed4xxResponse(ret) - ) - or - exists(BlockStmt block, ExprStmt response, ReturnStmt ret | - branch = block and - block.getNumStmt() = 2 and - response = block.getStmt(0) and - isDirectFixed4xxResponseCall(response.getExpr().(CallExpr)) and - ret = block.getStmt(1) and - not exists(ret.getExpr()) - ) - or - exists(BlockStmt block, ExprStmt response, ReturnStmt ret | - branch = block and - block.getNumStmt() = 2 and - response = block.getStmt(0) and - isFixedPublicErrorCall(response.getExpr().(CallExpr)) and - ret = block.getStmt(1) and - not exists(ret.getExpr()) - ) -} - -private predicate isDirect2xxResponse(ReturnStmt ret) { - exists(MethodCallExpr statusCall | - statusCall.getMethodName() = "status" and - isLiteral2xx(statusCall.getArgument(0)) and - statusCall.getParentExpr*() = ret.getExpr() - ) -} - -/** Stock early-abort modeling must not hide a branch that proves 2xx success. */ -private predicate isEvenfireSuccessEarlyAbortGuardNode( - ConditionalBypassFlow::PathNode e, SensitiveAction action -) { - exists(IfStmt guard, ReturnStmt ret | - e.getNode().(Sink).asExpr().getParentExpr*() = guard.getCondition() and - not exists(guard.getElse()) and - not action.asExpr().getEnclosingStmt().nestedIn(guard) and - ( - guard.getThen() = ret - or - exists(BlockStmt block | guard.getThen() = block and ret = block.getStmt(_)) - ) and - isDirect2xxResponse(ret) - ) -} - -private predicate isAuthenticationStateMutation(CallExpr call) { - exists(string name | - name = call.getCalleeName() and - name.regexpMatch("(?i).*(set|mark|establish).*(auth|authorized|session|principal).*") - ) -} - -/** A branch that mutates authentication state is never a fail-closed guard. */ -private predicate isEvenfireStateMutationEarlyAbortGuardNode( - ConditionalBypassFlow::PathNode e, SensitiveAction action -) { - exists(IfStmt guard, BlockStmt block, ExprStmt mutation, ReturnStmt ret | - e.getNode().(Sink).asExpr().getParentExpr*() = guard.getCondition() and - not exists(guard.getElse()) and - not action.asExpr().getEnclosingStmt().nestedIn(guard) and - guard.getThen() = block and - mutation = block.getStmt(_) and - isAuthenticationStateMutation(mutation.getExpr().(CallExpr)) and - ret = block.getStmt(_) and - isDirectFixed4xxResponse(ret) - ) -} - -/** A response that calls another function may expose protected data. */ -private predicate isEvenfirePayloadEarlyAbortGuardNode( - ConditionalBypassFlow::PathNode e, SensitiveAction action -) { - exists(IfStmt guard, ReturnStmt ret, MethodCallExpr statusCall, MethodCallExpr jsonCall | - e.getNode().(Sink).asExpr().getParentExpr*() = guard.getCondition() and - not exists(guard.getElse()) and - not action.asExpr().getEnclosingStmt().nestedIn(guard) and - guard.getThen() = ret and - statusCall.getMethodName() = "status" and - isLiteral4xx(statusCall.getArgument(0)) and - jsonCall.getMethodName() = "json" and - statusCall.getParentExpr*() = jsonCall and - jsonCall.getParentExpr*() = ret.getExpr() and - exists(CallExpr nested | - nested.getParentExpr*() = ret.getExpr() and - nested != statusCall and - nested != jsonCall - ) - ) - or - exists( - IfStmt guard, BlockStmt block, ExprStmt response, ReturnStmt ret, MethodCallExpr statusCall, - MethodCallExpr jsonCall - | - e.getNode().(Sink).asExpr().getParentExpr*() = guard.getCondition() and - not exists(guard.getElse()) and - not action.asExpr().getEnclosingStmt().nestedIn(guard) and - guard.getThen() = block and - response = block.getStmt(0) and - ret = block.getStmt(1) and - not exists(ret.getExpr()) and - statusCall.getMethodName() = "status" and - isLiteral4xx(statusCall.getArgument(0)) and - jsonCall.getMethodName() = "json" and - statusCall.getParentExpr*() = jsonCall and - jsonCall.getParentExpr*() = response.getExpr() and - exists(CallExpr nested | - nested.getParentExpr*() = response.getExpr() and - nested != statusCall and - nested != jsonCall - ) - ) - or - exists(IfStmt guard, BlockStmt block, ExprStmt response, ReturnStmt ret, CallExpr helper | - e.getNode().(Sink).asExpr().getParentExpr*() = guard.getCondition() and - not exists(guard.getElse()) and - not action.asExpr().getEnclosingStmt().nestedIn(guard) and - guard.getThen() = block and - response = block.getStmt(0) and - helper = response.getExpr().(CallExpr) and - exists(ImportSpecifier spec | - isCanonicalPublicErrorImport(spec) and - DataFlow::valueNode(spec) - .(DataFlow::SourceNode) - .flowsTo(DataFlow::valueNode(helper.getCallee())) - ) and - isLiteral4xx(helper.getArgument(2)) and - exists(helper.getArgument(5)) and - ret = block.getStmt(1) and - not exists(ret.getExpr()) - ) -} - -/** - * Extends the stock early-abort treatment only for a structurally proven fixed - * 4xx response. The sensitive action remains outside the terminating branch. - */ -private predicate isEvenfireFailClosedGuardNode( - ConditionalBypassFlow::PathNode e, SensitiveAction action -) { - exists(IfStmt guard | - e.getNode().(Sink).asExpr().getParentExpr*() = guard.getCondition() and - not exists(guard.getElse()) and - isFixedFailureBranch(guard.getThen()) and - not action.asExpr().getEnclosingStmt().nestedIn(guard) - ) -} - -from - ConditionalBypassFlow::PathNode source, ConditionalBypassFlow::PathNode sink, - SensitiveAction action -where - isTaintedGuardNodeForSensitiveAction(sink, source, action) and - ( - not isEarlyAbortGuardNode(sink, action) and - not isEvenfireFailClosedGuardNode(sink, action) - or - isEvenfireSuccessEarlyAbortGuardNode(sink, action) - or - isEvenfireStateMutationEarlyAbortGuardNode(sink, action) - or - isEvenfirePayloadEarlyAbortGuardNode(sink, action) - ) -select sink.getNode(), source, sink, "This condition guards a sensitive $@, but a $@ controls it.", - action, "action", source.getNode(), "user-provided value" diff --git a/.github/codeql/queries/evenfire-security-and-quality.qls b/.github/codeql/queries/evenfire-security-and-quality.qls deleted file mode 100644 index 30a494efb9..0000000000 --- a/.github/codeql/queries/evenfire-security-and-quality.qls +++ /dev/null @@ -1,8 +0,0 @@ -- import: codeql-suites/javascript-security-and-quality.qls - from: codeql/javascript-queries -- exclude: - id: js/missing-rate-limiting -- query: EvenfireMissingRateLimiting.ql -- exclude: - id: js/user-controlled-bypass -- query: EvenfireUserControlledBypass.ql diff --git a/.github/codeql/queries/qlpack.yml b/.github/codeql/queries/qlpack.yml deleted file mode 100644 index 323093504a..0000000000 --- a/.github/codeql/queries/qlpack.yml +++ /dev/null @@ -1,5 +0,0 @@ -name: evenfire/codeql-queries -version: 0.0.1 -dependencies: - codeql/javascript-all: 2.9.0 - codeql/javascript-queries: 2.4.3 diff --git a/.github/codeql/queries/test/MissingRateLimiting/EvenfireMissingRateLimiting.expected b/.github/codeql/queries/test/MissingRateLimiting/EvenfireMissingRateLimiting.expected deleted file mode 100644 index 53bc0c7cae..0000000000 --- a/.github/codeql/queries/test/MissingRateLimiting/EvenfireMissingRateLimiting.expected +++ /dev/null @@ -1,19 +0,0 @@ -| control-api/src/routes/external/composition.ts:19:3:19:66 | require ... ted' }) | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:25:5:25:11 | login() | authorization | -| control-api/src/routes/external/composition.ts:35:39:35:70 | require ... onToken | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:10:3:10:9 | login() | authorization | -| control-api/src/routes/external/composition.ts:60:3:60:50 | require ... cErrors | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:19:3:19:9 | login() | authorization | -| control-api/src/routes/external/composition.ts:67:55:67:75 | incompl ... otected | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/composition.ts:53:3:53:74 | fs.writ ... value') | a file system access | -| control-api/src/routes/external/composition.ts:75:29:75:60 | require ... onToken | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:10:3:10:9 | login() | authorization | -| control-api/src/routes/external/composition.ts:88:38:88:69 | require ... onToken | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:10:3:10:9 | login() | authorization | -| control-api/src/routes/external/composition.ts:89:38:89:51 | lateGlobalWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/composition.ts:83:3:83:63 | fs.writ ... value') | a file system access | -| control-api/src/routes/external/composition.ts:102:3:102:31 | require ... amMatch | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:35:3:35:62 | fs.writ ... value') | a file system access | -| control-api/src/routes/external/composition.ts:113:39:113:70 | require ... onToken | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:10:3:10:9 | login() | authorization | -| control-api/src/routes/external/composition.ts:128:3:128:34 | require ... onToken | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:10:3:10:9 | login() | authorization | -| control-api/src/routes/external/composition.ts:129:3:129:19 | lateSameRouteWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/composition.ts:121:3:121:67 | fs.writ ... value') | a file system access | -| control-api/src/routes/external/composition.ts:139:36:139:67 | require ... onToken | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:10:3:10:9 | login() | authorization | -| control-api/src/routes/external/composition.ts:142:3:142:15 | lateChildWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/composition.ts:134:3:134:62 | fs.writ ... value') | a file system access | -| control-api/src/routes/external/composition.ts:162:39:162:59 | nestedU ... tedWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/composition.ts:152:3:152:70 | fs.writ ... value') | a file system access | -| control-api/src/routes/external/composition.ts:165:42:165:73 | require ... onToken | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:10:3:10:9 | login() | authorization | -| control-api/src/routes/external/composition.ts:169:35:169:66 | require ... onToken | This route handler performs $@, but is not rate-limited. | control-api/src/middleware/externalSessionAuth.ts:10:3:10:9 | login() | authorization | -| control-api/src/routes/external/modeling.ts:39:52:39:66 | unprotectedWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/modeling.ts:14:3:14:63 | fs.writ ... value') | a file system access | -| control-api/src/routes/external/modeling.ts:43:3:43:18 | orderedWrongWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/modeling.ts:19:3:19:65 | fs.writ ... value') | a file system access | -| control-api/src/routes/external/modeling.ts:47:50:47:62 | unrelatedWork | This route handler performs $@, but is not rate-limited. | control-api/src/routes/external/modeling.ts:24:3:24:61 | fs.writ ... value') | a file system access | diff --git a/.github/codeql/queries/test/MissingRateLimiting/EvenfireMissingRateLimiting.qlref b/.github/codeql/queries/test/MissingRateLimiting/EvenfireMissingRateLimiting.qlref deleted file mode 100644 index 7d3c25d5ca..0000000000 --- a/.github/codeql/queries/test/MissingRateLimiting/EvenfireMissingRateLimiting.qlref +++ /dev/null @@ -1 +0,0 @@ -query: EvenfireMissingRateLimiting.ql diff --git a/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/externalSessionAuth.ts b/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/externalSessionAuth.ts deleted file mode 100644 index 3849e03edd..0000000000 --- a/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/externalSessionAuth.ts +++ /dev/null @@ -1,37 +0,0 @@ -import fs from 'fs' - -declare function login(): void - -export function requireValidExternalSessionToken( - _req: unknown, - _res: unknown, - next: () => void, -) { - login() - next() -} - -export function requireValidExternalSessionTokenWithPublicErrors( - _req: unknown, - _res: unknown, - next: () => void, -) { - login() - next() -} - -export function requireExternalSessionRateLimitContext(_options: unknown) { - return (_req: unknown, _res: unknown, next: () => void) => { - login() - next() - } -} - -export function requireExternalTeamParamMatch( - _req: unknown, - _res: unknown, - next: () => void, -) { - fs.writeFileSync('/tmp/evenfire-codeql-team-match', 'value') - next() -} diff --git a/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/rateLimitMiddleware.ts b/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/rateLimitMiddleware.ts deleted file mode 100644 index 29c4cf01c7..0000000000 --- a/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/rateLimitMiddleware.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function rateLimitMiddleware(_policy: unknown) { - return (_req: unknown, _res: unknown, next: () => void) => next() -} diff --git a/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/unrelatedMiddleware.ts b/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/unrelatedMiddleware.ts deleted file mode 100644 index 05e83e8ae9..0000000000 --- a/.github/codeql/queries/test/MissingRateLimiting/control-api/src/middleware/unrelatedMiddleware.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function unrelatedMiddleware() { - return (_req: unknown, _res: unknown, next: () => void) => next() -} diff --git a/.github/codeql/queries/test/MissingRateLimiting/control-api/src/routes/external/composition.ts b/.github/codeql/queries/test/MissingRateLimiting/control-api/src/routes/external/composition.ts deleted file mode 100644 index d342e1ed9d..0000000000 --- a/.github/codeql/queries/test/MissingRateLimiting/control-api/src/routes/external/composition.ts +++ /dev/null @@ -1,190 +0,0 @@ -import express from 'express' -import fs from 'fs' -import { - requireExternalSessionRateLimitContext, - requireExternalTeamParamMatch, - requireValidExternalSessionToken, - requireValidExternalSessionTokenWithPublicErrors, -} from '../../middleware/externalSessionAuth' -import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware' - -function sameRouteWork(_req: express.Request, res: express.Response) { - fs.writeFileSync('/tmp/evenfire-codeql-same-route', 'value') - res.sendStatus(204) -} - -const sameRouteRouter = express.Router() -sameRouteRouter.post( - '/same-route', - requireExternalSessionRateLimitContext({ purpose: 'protected' }), - rateLimitMiddleware({ type: 'external_member_mutation', max: 10 }), - sameRouteWork, -) - -function completeRead(_req: express.Request, res: express.Response) { - fs.writeFileSync('/tmp/evenfire-codeql-complete-read', 'value') - res.sendStatus(204) -} - -function completeWrite(_req: express.Request, res: express.Response) { - fs.writeFileSync('/tmp/evenfire-codeql-complete-write', 'value') - res.sendStatus(204) -} - -const completeParentRouter = express.Router() -completeParentRouter.use('/complete', requireValidExternalSessionToken) -completeParentRouter.get( - '/complete/read', - rateLimitMiddleware({ type: 'external_member_read', max: 60 }), - completeRead, -) -completeParentRouter.post( - '/complete/write', - rateLimitMiddleware({ type: 'external_member_mutation', max: 10 }), - completeWrite, -) - -function incompleteProtected(_req: express.Request, res: express.Response) { - fs.writeFileSync('/tmp/evenfire-codeql-incomplete-protected', 'value') - res.sendStatus(204) -} - -function incompleteUnprotected(_req: express.Request, res: express.Response) { - fs.writeFileSync('/tmp/evenfire-codeql-incomplete-unprotected', 'value') - res.sendStatus(204) -} - -const incompleteParentRouter = express.Router() -incompleteParentRouter.use( - '/incomplete', - requireValidExternalSessionTokenWithPublicErrors, -) -incompleteParentRouter.get( - '/incomplete/protected', - rateLimitMiddleware({ type: 'external_member_read', max: 60 }), - incompleteProtected, -) -incompleteParentRouter.get('/incomplete/unprotected', incompleteUnprotected) - -function globalWork(_req: express.Request, res: express.Response) { - fs.writeFileSync('/tmp/evenfire-codeql-global', 'value') - res.sendStatus(204) -} - -const globalRouter = express.Router() -globalRouter.use('/global', requireValidExternalSessionToken) -globalRouter.use( - '/global', - rateLimitMiddleware({ type: 'external_shared_filesystem_read', max: 60 }), -) -globalRouter.get('/global', globalWork) - -function lateGlobalWork(_req: express.Request, res: express.Response) { - fs.writeFileSync('/tmp/evenfire-codeql-late-global', 'value') - res.sendStatus(204) -} - -const lateGlobalRouter = express.Router() -lateGlobalRouter.use('/late-global', requireValidExternalSessionToken) -lateGlobalRouter.get('/late-global', lateGlobalWork) -lateGlobalRouter.use( - '/late-global', - rateLimitMiddleware({ type: 'external_shared_filesystem_read', max: 60 }), -) - -function sameModuleAuthorizationWork(_req: express.Request, res: express.Response) { - res.sendStatus(204) -} - -const sameModuleAuthorizationRouter = express.Router() -sameModuleAuthorizationRouter.get( - '/same-module-authorization', - requireExternalTeamParamMatch, - rateLimitMiddleware({ type: 'external_member_read', max: 60 }), - sameModuleAuthorizationWork, -) - -function unrelatedPathWork(_req: express.Request, res: express.Response) { - fs.writeFileSync('/tmp/evenfire-codeql-unrelated-path', 'value') - res.sendStatus(204) -} - -const unrelatedPathRouter = express.Router() -unrelatedPathRouter.use('/sensitive', requireValidExternalSessionToken) -unrelatedPathRouter.get( - '/other', - rateLimitMiddleware({ type: 'external_member_read', max: 60 }), - unrelatedPathWork, -) - -function lateSameRouteWork(_req: express.Request, res: express.Response) { - fs.writeFileSync('/tmp/evenfire-codeql-late-same-route', 'value') - res.sendStatus(204) -} - -const lateSameRouteRouter = express.Router() -lateSameRouteRouter.get( - '/late-same-route', - requireValidExternalSessionToken, - lateSameRouteWork, - rateLimitMiddleware({ type: 'external_member_read', max: 60 }), -) - -function lateChildWork(_req: express.Request, res: express.Response) { - fs.writeFileSync('/tmp/evenfire-codeql-late-child', 'value') - res.sendStatus(204) -} - -const lateChildRouter = express.Router() -lateChildRouter.use('/late-child', requireValidExternalSessionToken) -lateChildRouter.get( - '/late-child/read', - lateChildWork, - rateLimitMiddleware({ type: 'external_member_read', max: 60 }), -) - -function nestedProtectedWork(_req: express.Request, res: express.Response) { - fs.writeFileSync('/tmp/evenfire-codeql-nested-protected', 'value') - res.sendStatus(204) -} - -function nestedUnprotectedWork(_req: express.Request, res: express.Response) { - fs.writeFileSync('/tmp/evenfire-codeql-nested-unprotected', 'value') - res.sendStatus(204) -} - -const nestedChildRouter = express.Router() -nestedChildRouter.get( - '/protected', - rateLimitMiddleware({ type: 'external_member_read', max: 60 }), - nestedProtectedWork, -) -nestedChildRouter.get('/unprotected', nestedUnprotectedWork) - -const nestedGlobalRouter = express.Router() -nestedGlobalRouter.use('/nested-global', requireValidExternalSessionToken) -nestedGlobalRouter.use('/nested-global', nestedChildRouter) - -const nestedContextChildRouter = express.Router() -nestedContextChildRouter.use('/', requireValidExternalSessionToken) - -const nestedContextParentRouter = express.Router() -nestedContextParentRouter.use('/nested-context', nestedContextChildRouter) -nestedContextParentRouter.use( - '/nested-context', - rateLimitMiddleware({ type: 'external_member_read', max: 60 }), -) - -export { - completeParentRouter, - globalRouter, - incompleteParentRouter, - lateChildRouter, - lateGlobalRouter, - lateSameRouteRouter, - nestedContextParentRouter, - nestedGlobalRouter, - sameRouteRouter, - sameModuleAuthorizationRouter, - unrelatedPathRouter, -} diff --git a/.github/codeql/queries/test/MissingRateLimiting/control-api/src/routes/external/modeling.ts b/.github/codeql/queries/test/MissingRateLimiting/control-api/src/routes/external/modeling.ts deleted file mode 100644 index 04bab93767..0000000000 --- a/.github/codeql/queries/test/MissingRateLimiting/control-api/src/routes/external/modeling.ts +++ /dev/null @@ -1,62 +0,0 @@ -import express from 'express' -import fs from 'fs' -import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware' -import { unrelatedMiddleware } from '../../middleware/unrelatedMiddleware' - -const router = express.Router() - -function protectedWork(_req: express.Request, res: express.Response) { - fs.writeFileSync('/tmp/evenfire-codeql-protected', 'value') - res.sendStatus(204) -} - -function unprotectedWork(_req: express.Request, res: express.Response) { - fs.writeFileSync('/tmp/evenfire-codeql-unprotected', 'value') - res.sendStatus(204) -} - -function orderedWrongWork(_req: express.Request, res: express.Response) { - fs.writeFileSync('/tmp/evenfire-codeql-ordered-wrong', 'value') - res.sendStatus(204) -} - -function unrelatedWork(_req: express.Request, res: express.Response) { - fs.writeFileSync('/tmp/evenfire-codeql-unrelated', 'value') - res.sendStatus(204) -} - -function specializedWork(_req: express.Request, res: express.Response) { - fs.writeFileSync('/tmp/evenfire-codeql-specialized', 'value') - res.sendStatus(204) -} - -router.post( - '/protected', - rateLimitMiddleware({ type: 'external_member_mutation', max: 10 }), - protectedWork, -) - -router.post('/unprotected', unrelatedMiddleware(), unprotectedWork) - -router.post( - '/ordered-wrong', - orderedWrongWork, - rateLimitMiddleware({ type: 'external_member_mutation', max: 10 }), -) - -router.post('/unrelated', unrelatedMiddleware(), unrelatedWork) - -router.post( - '/specialized', - rateLimitMiddleware({ type: 'external_credential_authentication', max: 5 }), - specializedWork, -) - -const assignedRateLimit = rateLimitMiddleware({ - type: 'external_member_read', - max: 60, -}) - -router.get('/assigned', assignedRateLimit, specializedWork) - -export default router diff --git a/.github/codeql/queries/test/SystemPromptInjection/SystemPromptInjection.expected b/.github/codeql/queries/test/SystemPromptInjection/SystemPromptInjection.expected deleted file mode 100644 index 1de24b6428..0000000000 --- a/.github/codeql/queries/test/SystemPromptInjection/SystemPromptInjection.expected +++ /dev/null @@ -1,14 +0,0 @@ -edges -| mcp-host/src/llm/systemPromptInjection.ts:8:9:8:15 | persona | mcp-host/src/llm/systemPromptInjection.ts:12:73:12:79 | persona | provenance | | -| mcp-host/src/llm/systemPromptInjection.ts:8:19:8:49 | String( ... \|\| '') | mcp-host/src/llm/systemPromptInjection.ts:8:9:8:15 | persona | provenance | | -| mcp-host/src/llm/systemPromptInjection.ts:8:26:8:42 | req.query.persona | mcp-host/src/llm/systemPromptInjection.ts:8:19:8:49 | String( ... \|\| '') | provenance | | -| mcp-host/src/llm/systemPromptInjection.ts:12:73:12:79 | persona | mcp-host/src/llm/systemPromptInjection.ts:12:34:12:82 | `You ar ... sona}.` | provenance | | -nodes -| mcp-host/src/llm/systemPromptInjection.ts:8:9:8:15 | persona | semmle.label | persona | -| mcp-host/src/llm/systemPromptInjection.ts:8:19:8:49 | String( ... \|\| '') | semmle.label | String( ... \|\| '') | -| mcp-host/src/llm/systemPromptInjection.ts:8:26:8:42 | req.query.persona | semmle.label | req.query.persona | -| mcp-host/src/llm/systemPromptInjection.ts:12:34:12:82 | `You ar ... sona}.` | semmle.label | `You ar ... sona}.` | -| mcp-host/src/llm/systemPromptInjection.ts:12:73:12:79 | persona | semmle.label | persona | -subpaths -#select -| mcp-host/src/llm/systemPromptInjection.ts:12:34:12:82 | `You ar ... sona}.` | mcp-host/src/llm/systemPromptInjection.ts:8:26:8:42 | req.query.persona | mcp-host/src/llm/systemPromptInjection.ts:12:34:12:82 | `You ar ... sona}.` | This system prompt depends on a $@. | mcp-host/src/llm/systemPromptInjection.ts:8:26:8:42 | req.query.persona | user-provided value | diff --git a/.github/codeql/queries/test/SystemPromptInjection/SystemPromptInjection.qlref b/.github/codeql/queries/test/SystemPromptInjection/SystemPromptInjection.qlref deleted file mode 100644 index d8ef59e125..0000000000 --- a/.github/codeql/queries/test/SystemPromptInjection/SystemPromptInjection.qlref +++ /dev/null @@ -1 +0,0 @@ -Security/CWE-1427/SystemPromptInjection.ql diff --git a/.github/codeql/queries/test/SystemPromptInjection/mcp-host/src/llm/systemPromptInjection.ts b/.github/codeql/queries/test/SystemPromptInjection/mcp-host/src/llm/systemPromptInjection.ts deleted file mode 100644 index a69f540d65..0000000000 --- a/.github/codeql/queries/test/SystemPromptInjection/mcp-host/src/llm/systemPromptInjection.ts +++ /dev/null @@ -1,30 +0,0 @@ -import express from 'express' -import OpenAI from 'openai' - -const app = express() -const client = new OpenAI() - -app.get('/unsafe', async (req, res) => { - const persona = String(req.query.persona || '') - const response = await client.chat.completions.create({ - model: 'gpt-4.1', - messages: [ - { role: 'system', content: `You are a helpful assistant. Act as ${persona}.` }, - { role: 'user', content: String(req.query.message || '') }, - ], - }) - res.json(response) -}) - -app.get('/safe', async (req, res) => { - const persona = String(req.query.persona || '') - const response = await client.chat.completions.create({ - model: 'gpt-4.1', - messages: [ - { role: 'system', content: 'You are a helpful assistant.' }, - { role: 'user', content: `Persona to act as: ${persona}` }, - { role: 'user', content: String(req.query.message || '') }, - ], - }) - res.json(response) -}) diff --git a/.github/codeql/queries/test/UserControlledBypass/EvenfireUserControlledBypass.expected b/.github/codeql/queries/test/UserControlledBypass/EvenfireUserControlledBypass.expected deleted file mode 100644 index 6404522524..0000000000 --- a/.github/codeql/queries/test/UserControlledBypass/EvenfireUserControlledBypass.expected +++ /dev/null @@ -1,147 +0,0 @@ -edges -| control-api/src/routes/external/auth.ts:18:9:18:13 | email | control-api/src/routes/external/auth.ts:20:8:20:12 | email | provenance | | -| control-api/src/routes/external/auth.ts:18:17:18:45 | String( ... \|\| '') | control-api/src/routes/external/auth.ts:18:17:18:52 | String( ... .trim() | provenance | | -| control-api/src/routes/external/auth.ts:18:17:18:52 | String( ... .trim() | control-api/src/routes/external/auth.ts:18:9:18:13 | email | provenance | | -| control-api/src/routes/external/auth.ts:18:24:18:31 | req.body | control-api/src/routes/external/auth.ts:18:17:18:45 | String( ... \|\| '') | provenance | | -| control-api/src/routes/external/auth.ts:19:9:19:16 | password | control-api/src/routes/external/auth.ts:20:18:20:25 | password | provenance | | -| control-api/src/routes/external/auth.ts:19:20:19:51 | String( ... \|\| '') | control-api/src/routes/external/auth.ts:19:9:19:16 | password | provenance | | -| control-api/src/routes/external/auth.ts:19:27:19:34 | req.body | control-api/src/routes/external/auth.ts:19:20:19:51 | String( ... \|\| '') | provenance | | -| control-api/src/routes/external/auth.ts:26:9:26:13 | token | control-api/src/routes/external/auth.ts:27:8:27:12 | token | provenance | | -| control-api/src/routes/external/auth.ts:26:9:26:13 | token | control-api/src/routes/external/auth.ts:27:17:27:21 | token | provenance | | -| control-api/src/routes/external/auth.ts:26:17:26:45 | String( ... \|\| '') | control-api/src/routes/external/auth.ts:26:17:26:52 | String( ... .trim() | provenance | | -| control-api/src/routes/external/auth.ts:26:17:26:52 | String( ... .trim() | control-api/src/routes/external/auth.ts:26:9:26:13 | token | provenance | | -| control-api/src/routes/external/auth.ts:26:24:26:31 | req.body | control-api/src/routes/external/auth.ts:26:17:26:45 | String( ... \|\| '') | provenance | | -| control-api/src/routes/external/auth.ts:27:17:27:21 | token | control-api/src/routes/external/auth.ts:27:17:27:28 | token.length | provenance | | -| control-api/src/routes/external/auth.ts:27:17:27:21 | token | control-api/src/routes/external/auth.ts:27:17:27:28 | token.length | provenance | | -| control-api/src/routes/external/auth.ts:27:17:27:28 | token.length | control-api/src/routes/external/auth.ts:27:17:27:35 | token.length > 4096 | provenance | Config | -| control-api/src/routes/external/auth.ts:36:9:36:13 | token | control-api/src/routes/external/auth.ts:37:8:37:12 | token | provenance | | -| control-api/src/routes/external/auth.ts:36:17:36:45 | String( ... \|\| '') | control-api/src/routes/external/auth.ts:36:9:36:13 | token | provenance | | -| control-api/src/routes/external/auth.ts:36:24:36:31 | req.body | control-api/src/routes/external/auth.ts:36:17:36:45 | String( ... \|\| '') | provenance | | -| control-api/src/routes/external/auth.ts:50:11:50:15 | email | control-api/src/routes/external/auth.ts:52:10:52:14 | email | provenance | | -| control-api/src/routes/external/auth.ts:50:19:50:47 | String( ... \|\| '') | control-api/src/routes/external/auth.ts:50:19:50:54 | String( ... .trim() | provenance | | -| control-api/src/routes/external/auth.ts:50:19:50:54 | String( ... .trim() | control-api/src/routes/external/auth.ts:50:11:50:15 | email | provenance | | -| control-api/src/routes/external/auth.ts:50:26:50:33 | req.body | control-api/src/routes/external/auth.ts:50:19:50:47 | String( ... \|\| '') | provenance | | -| control-api/src/routes/external/auth.ts:51:11:51:18 | password | control-api/src/routes/external/auth.ts:52:20:52:27 | password | provenance | | -| control-api/src/routes/external/auth.ts:51:22:51:53 | String( ... \|\| '') | control-api/src/routes/external/auth.ts:51:11:51:18 | password | provenance | | -| control-api/src/routes/external/auth.ts:51:29:51:36 | req.body | control-api/src/routes/external/auth.ts:51:22:51:53 | String( ... \|\| '') | provenance | | -| control-api/src/routes/external/auth.ts:64:11:64:15 | token | control-api/src/routes/external/auth.ts:65:10:65:14 | token | provenance | | -| control-api/src/routes/external/auth.ts:64:11:64:15 | token | control-api/src/routes/external/auth.ts:65:19:65:23 | token | provenance | | -| control-api/src/routes/external/auth.ts:64:19:64:47 | String( ... \|\| '') | control-api/src/routes/external/auth.ts:64:19:64:54 | String( ... .trim() | provenance | | -| control-api/src/routes/external/auth.ts:64:19:64:54 | String( ... .trim() | control-api/src/routes/external/auth.ts:64:11:64:15 | token | provenance | | -| control-api/src/routes/external/auth.ts:64:26:64:33 | req.body | control-api/src/routes/external/auth.ts:64:19:64:47 | String( ... \|\| '') | provenance | | -| control-api/src/routes/external/auth.ts:65:19:65:23 | token | control-api/src/routes/external/auth.ts:65:19:65:30 | token.length | provenance | | -| control-api/src/routes/external/auth.ts:65:19:65:23 | token | control-api/src/routes/external/auth.ts:65:19:65:30 | token.length | provenance | | -| control-api/src/routes/external/auth.ts:65:19:65:30 | token.length | control-api/src/routes/external/auth.ts:65:19:65:37 | token.length > 4096 | provenance | Config | -| control-api/src/routes/external/auth.ts:77:9:77:13 | token | control-api/src/routes/external/auth.ts:78:8:78:12 | token | provenance | | -| control-api/src/routes/external/auth.ts:77:17:77:45 | String( ... \|\| '') | control-api/src/routes/external/auth.ts:77:9:77:13 | token | provenance | | -| control-api/src/routes/external/auth.ts:77:24:77:31 | req.body | control-api/src/routes/external/auth.ts:77:17:77:45 | String( ... \|\| '') | provenance | | -| control-api/src/routes/external/auth.ts:84:9:84:16 | password | control-api/src/routes/external/auth.ts:85:8:85:15 | password | provenance | | -| control-api/src/routes/external/auth.ts:84:20:84:51 | String( ... \|\| '') | control-api/src/routes/external/auth.ts:84:9:84:16 | password | provenance | | -| control-api/src/routes/external/auth.ts:84:27:84:34 | req.body | control-api/src/routes/external/auth.ts:84:20:84:51 | String( ... \|\| '') | provenance | | -| control-api/src/routes/external/auth.ts:91:9:91:13 | token | control-api/src/routes/external/auth.ts:92:7:92:11 | token | provenance | | -| control-api/src/routes/external/auth.ts:91:17:91:45 | String( ... \|\| '') | control-api/src/routes/external/auth.ts:91:9:91:13 | token | provenance | | -| control-api/src/routes/external/auth.ts:91:24:91:31 | req.body | control-api/src/routes/external/auth.ts:91:17:91:45 | String( ... \|\| '') | provenance | | -| control-api/src/routes/external/auth.ts:101:9:101:13 | token | control-api/src/routes/external/auth.ts:102:8:102:12 | token | provenance | | -| control-api/src/routes/external/auth.ts:101:17:101:45 | String( ... \|\| '') | control-api/src/routes/external/auth.ts:101:9:101:13 | token | provenance | | -| control-api/src/routes/external/auth.ts:101:24:101:31 | req.body | control-api/src/routes/external/auth.ts:101:17:101:45 | String( ... \|\| '') | provenance | | -| control-api/src/routes/external/auth.ts:111:9:111:13 | token | control-api/src/routes/external/auth.ts:112:8:112:12 | token | provenance | | -| control-api/src/routes/external/auth.ts:111:17:111:45 | String( ... \|\| '') | control-api/src/routes/external/auth.ts:111:9:111:13 | token | provenance | | -| control-api/src/routes/external/auth.ts:111:24:111:31 | req.body | control-api/src/routes/external/auth.ts:111:17:111:45 | String( ... \|\| '') | provenance | | -| control-api/src/routes/external/auth.ts:128:9:128:13 | token | control-api/src/routes/external/auth.ts:130:8:130:12 | token | provenance | | -| control-api/src/routes/external/auth.ts:128:17:128:45 | String( ... \|\| '') | control-api/src/routes/external/auth.ts:128:9:128:13 | token | provenance | | -| control-api/src/routes/external/auth.ts:128:24:128:31 | req.body | control-api/src/routes/external/auth.ts:128:17:128:45 | String( ... \|\| '') | provenance | | -| control-api/src/routes/external/auth.ts:136:9:136:13 | token | control-api/src/routes/external/auth.ts:137:8:137:12 | token | provenance | | -| control-api/src/routes/external/auth.ts:136:17:136:45 | String( ... \|\| '') | control-api/src/routes/external/auth.ts:136:9:136:13 | token | provenance | | -| control-api/src/routes/external/auth.ts:136:24:136:31 | req.body | control-api/src/routes/external/auth.ts:136:17:136:45 | String( ... \|\| '') | provenance | | -| control-api/src/routes/external/auth.ts:146:9:146:13 | token | control-api/src/routes/external/auth.ts:147:8:147:12 | token | provenance | | -| control-api/src/routes/external/auth.ts:146:17:146:45 | String( ... \|\| '') | control-api/src/routes/external/auth.ts:146:9:146:13 | token | provenance | | -| control-api/src/routes/external/auth.ts:146:24:146:31 | req.body | control-api/src/routes/external/auth.ts:146:17:146:45 | String( ... \|\| '') | provenance | | -| control-api/src/routes/external/auth.ts:153:9:153:13 | token | control-api/src/routes/external/auth.ts:154:8:154:12 | token | provenance | | -| control-api/src/routes/external/auth.ts:153:17:153:45 | String( ... \|\| '') | control-api/src/routes/external/auth.ts:153:9:153:13 | token | provenance | | -| control-api/src/routes/external/auth.ts:153:24:153:31 | req.body | control-api/src/routes/external/auth.ts:153:17:153:45 | String( ... \|\| '') | provenance | | -nodes -| control-api/src/routes/external/auth.ts:18:9:18:13 | email | semmle.label | email | -| control-api/src/routes/external/auth.ts:18:17:18:45 | String( ... \|\| '') | semmle.label | String( ... \|\| '') | -| control-api/src/routes/external/auth.ts:18:17:18:52 | String( ... .trim() | semmle.label | String( ... .trim() | -| control-api/src/routes/external/auth.ts:18:24:18:31 | req.body | semmle.label | req.body | -| control-api/src/routes/external/auth.ts:19:9:19:16 | password | semmle.label | password | -| control-api/src/routes/external/auth.ts:19:20:19:51 | String( ... \|\| '') | semmle.label | String( ... \|\| '') | -| control-api/src/routes/external/auth.ts:19:27:19:34 | req.body | semmle.label | req.body | -| control-api/src/routes/external/auth.ts:20:8:20:12 | email | semmle.label | email | -| control-api/src/routes/external/auth.ts:20:18:20:25 | password | semmle.label | password | -| control-api/src/routes/external/auth.ts:26:9:26:13 | token | semmle.label | token | -| control-api/src/routes/external/auth.ts:26:17:26:45 | String( ... \|\| '') | semmle.label | String( ... \|\| '') | -| control-api/src/routes/external/auth.ts:26:17:26:52 | String( ... .trim() | semmle.label | String( ... .trim() | -| control-api/src/routes/external/auth.ts:26:24:26:31 | req.body | semmle.label | req.body | -| control-api/src/routes/external/auth.ts:27:8:27:12 | token | semmle.label | token | -| control-api/src/routes/external/auth.ts:27:17:27:21 | token | semmle.label | token | -| control-api/src/routes/external/auth.ts:27:17:27:28 | token.length | semmle.label | token.length | -| control-api/src/routes/external/auth.ts:27:17:27:28 | token.length | semmle.label | token.length | -| control-api/src/routes/external/auth.ts:27:17:27:35 | token.length > 4096 | semmle.label | token.length > 4096 | -| control-api/src/routes/external/auth.ts:36:9:36:13 | token | semmle.label | token | -| control-api/src/routes/external/auth.ts:36:17:36:45 | String( ... \|\| '') | semmle.label | String( ... \|\| '') | -| control-api/src/routes/external/auth.ts:36:24:36:31 | req.body | semmle.label | req.body | -| control-api/src/routes/external/auth.ts:37:8:37:12 | token | semmle.label | token | -| control-api/src/routes/external/auth.ts:50:11:50:15 | email | semmle.label | email | -| control-api/src/routes/external/auth.ts:50:19:50:47 | String( ... \|\| '') | semmle.label | String( ... \|\| '') | -| control-api/src/routes/external/auth.ts:50:19:50:54 | String( ... .trim() | semmle.label | String( ... .trim() | -| control-api/src/routes/external/auth.ts:50:26:50:33 | req.body | semmle.label | req.body | -| control-api/src/routes/external/auth.ts:51:11:51:18 | password | semmle.label | password | -| control-api/src/routes/external/auth.ts:51:22:51:53 | String( ... \|\| '') | semmle.label | String( ... \|\| '') | -| control-api/src/routes/external/auth.ts:51:29:51:36 | req.body | semmle.label | req.body | -| control-api/src/routes/external/auth.ts:52:10:52:14 | email | semmle.label | email | -| control-api/src/routes/external/auth.ts:52:20:52:27 | password | semmle.label | password | -| control-api/src/routes/external/auth.ts:64:11:64:15 | token | semmle.label | token | -| control-api/src/routes/external/auth.ts:64:19:64:47 | String( ... \|\| '') | semmle.label | String( ... \|\| '') | -| control-api/src/routes/external/auth.ts:64:19:64:54 | String( ... .trim() | semmle.label | String( ... .trim() | -| control-api/src/routes/external/auth.ts:64:26:64:33 | req.body | semmle.label | req.body | -| control-api/src/routes/external/auth.ts:65:10:65:14 | token | semmle.label | token | -| control-api/src/routes/external/auth.ts:65:19:65:23 | token | semmle.label | token | -| control-api/src/routes/external/auth.ts:65:19:65:30 | token.length | semmle.label | token.length | -| control-api/src/routes/external/auth.ts:65:19:65:30 | token.length | semmle.label | token.length | -| control-api/src/routes/external/auth.ts:65:19:65:37 | token.length > 4096 | semmle.label | token.length > 4096 | -| control-api/src/routes/external/auth.ts:77:9:77:13 | token | semmle.label | token | -| control-api/src/routes/external/auth.ts:77:17:77:45 | String( ... \|\| '') | semmle.label | String( ... \|\| '') | -| control-api/src/routes/external/auth.ts:77:24:77:31 | req.body | semmle.label | req.body | -| control-api/src/routes/external/auth.ts:78:8:78:12 | token | semmle.label | token | -| control-api/src/routes/external/auth.ts:84:9:84:16 | password | semmle.label | password | -| control-api/src/routes/external/auth.ts:84:20:84:51 | String( ... \|\| '') | semmle.label | String( ... \|\| '') | -| control-api/src/routes/external/auth.ts:84:27:84:34 | req.body | semmle.label | req.body | -| control-api/src/routes/external/auth.ts:85:8:85:15 | password | semmle.label | password | -| control-api/src/routes/external/auth.ts:91:9:91:13 | token | semmle.label | token | -| control-api/src/routes/external/auth.ts:91:17:91:45 | String( ... \|\| '') | semmle.label | String( ... \|\| '') | -| control-api/src/routes/external/auth.ts:91:24:91:31 | req.body | semmle.label | req.body | -| control-api/src/routes/external/auth.ts:92:7:92:11 | token | semmle.label | token | -| control-api/src/routes/external/auth.ts:101:9:101:13 | token | semmle.label | token | -| control-api/src/routes/external/auth.ts:101:17:101:45 | String( ... \|\| '') | semmle.label | String( ... \|\| '') | -| control-api/src/routes/external/auth.ts:101:24:101:31 | req.body | semmle.label | req.body | -| control-api/src/routes/external/auth.ts:102:8:102:12 | token | semmle.label | token | -| control-api/src/routes/external/auth.ts:111:9:111:13 | token | semmle.label | token | -| control-api/src/routes/external/auth.ts:111:17:111:45 | String( ... \|\| '') | semmle.label | String( ... \|\| '') | -| control-api/src/routes/external/auth.ts:111:24:111:31 | req.body | semmle.label | req.body | -| control-api/src/routes/external/auth.ts:112:8:112:12 | token | semmle.label | token | -| control-api/src/routes/external/auth.ts:128:9:128:13 | token | semmle.label | token | -| control-api/src/routes/external/auth.ts:128:17:128:45 | String( ... \|\| '') | semmle.label | String( ... \|\| '') | -| control-api/src/routes/external/auth.ts:128:24:128:31 | req.body | semmle.label | req.body | -| control-api/src/routes/external/auth.ts:130:8:130:12 | token | semmle.label | token | -| control-api/src/routes/external/auth.ts:136:9:136:13 | token | semmle.label | token | -| control-api/src/routes/external/auth.ts:136:17:136:45 | String( ... \|\| '') | semmle.label | String( ... \|\| '') | -| control-api/src/routes/external/auth.ts:136:24:136:31 | req.body | semmle.label | req.body | -| control-api/src/routes/external/auth.ts:137:8:137:12 | token | semmle.label | token | -| control-api/src/routes/external/auth.ts:146:9:146:13 | token | semmle.label | token | -| control-api/src/routes/external/auth.ts:146:17:146:45 | String( ... \|\| '') | semmle.label | String( ... \|\| '') | -| control-api/src/routes/external/auth.ts:146:24:146:31 | req.body | semmle.label | req.body | -| control-api/src/routes/external/auth.ts:147:8:147:12 | token | semmle.label | token | -| control-api/src/routes/external/auth.ts:153:9:153:13 | token | semmle.label | token | -| control-api/src/routes/external/auth.ts:153:17:153:45 | String( ... \|\| '') | semmle.label | String( ... \|\| '') | -| control-api/src/routes/external/auth.ts:153:24:153:31 | req.body | semmle.label | req.body | -| control-api/src/routes/external/auth.ts:154:8:154:12 | token | semmle.label | token | -subpaths -#select -| control-api/src/routes/external/auth.ts:78:8:78:12 | token | control-api/src/routes/external/auth.ts:77:24:77:31 | req.body | control-api/src/routes/external/auth.ts:78:8:78:12 | token | This condition guards a sensitive $@, but a $@ controls it. | control-api/src/routes/external/auth.ts:79:3:79:40 | authent ... (token) | action | control-api/src/routes/external/auth.ts:77:24:77:31 | req.body | user-provided value | -| control-api/src/routes/external/auth.ts:85:8:85:15 | password | control-api/src/routes/external/auth.ts:84:27:84:34 | req.body | control-api/src/routes/external/auth.ts:85:8:85:15 | password | This condition guards a sensitive $@, but a $@ controls it. | control-api/src/routes/external/auth.ts:86:3:86:68 | authent ... ssword) | action | control-api/src/routes/external/auth.ts:84:27:84:34 | req.body | user-provided value | -| control-api/src/routes/external/auth.ts:92:7:92:11 | token | control-api/src/routes/external/auth.ts:91:24:91:31 | req.body | control-api/src/routes/external/auth.ts:92:7:92:11 | token | This condition guards a sensitive $@, but a $@ controls it. | control-api/src/routes/external/auth.ts:93:5:93:42 | authent ... (token) | action | control-api/src/routes/external/auth.ts:91:24:91:31 | req.body | user-provided value | -| control-api/src/routes/external/auth.ts:102:8:102:12 | token | control-api/src/routes/external/auth.ts:101:24:101:31 | req.body | control-api/src/routes/external/auth.ts:102:8:102:12 | token | This condition guards a sensitive $@, but a $@ controls it. | control-api/src/routes/external/auth.ts:106:3:106:40 | authent ... (token) | action | control-api/src/routes/external/auth.ts:101:24:101:31 | req.body | user-provided value | -| control-api/src/routes/external/auth.ts:112:8:112:12 | token | control-api/src/routes/external/auth.ts:111:24:111:31 | req.body | control-api/src/routes/external/auth.ts:112:8:112:12 | token | This condition guards a sensitive $@, but a $@ controls it. | control-api/src/routes/external/auth.ts:115:3:115:40 | authent ... (token) | action | control-api/src/routes/external/auth.ts:111:24:111:31 | req.body | user-provided value | -| control-api/src/routes/external/auth.ts:130:8:130:12 | token | control-api/src/routes/external/auth.ts:128:24:128:31 | req.body | control-api/src/routes/external/auth.ts:130:8:130:12 | token | This condition guards a sensitive $@, but a $@ controls it. | control-api/src/routes/external/auth.ts:131:3:131:40 | authent ... (token) | action | control-api/src/routes/external/auth.ts:128:24:128:31 | req.body | user-provided value | -| control-api/src/routes/external/auth.ts:147:8:147:12 | token | control-api/src/routes/external/auth.ts:146:24:146:31 | req.body | control-api/src/routes/external/auth.ts:147:8:147:12 | token | This condition guards a sensitive $@, but a $@ controls it. | control-api/src/routes/external/auth.ts:148:3:148:40 | authent ... (token) | action | control-api/src/routes/external/auth.ts:146:24:146:31 | req.body | user-provided value | diff --git a/.github/codeql/queries/test/UserControlledBypass/EvenfireUserControlledBypass.qlref b/.github/codeql/queries/test/UserControlledBypass/EvenfireUserControlledBypass.qlref deleted file mode 100644 index 6417d0f8de..0000000000 --- a/.github/codeql/queries/test/UserControlledBypass/EvenfireUserControlledBypass.qlref +++ /dev/null @@ -1 +0,0 @@ -query: EvenfireUserControlledBypass.ql diff --git a/.github/codeql/queries/test/UserControlledBypass/control-api/src/http/publicApiError.ts b/.github/codeql/queries/test/UserControlledBypass/control-api/src/http/publicApiError.ts deleted file mode 100644 index 3dd08d53f5..0000000000 --- a/.github/codeql/queries/test/UserControlledBypass/control-api/src/http/publicApiError.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function sendPublicApiError( - _req: unknown, - _res: unknown, - _status: number, - _code: string, - _message: string, -) {} diff --git a/.github/codeql/queries/test/UserControlledBypass/control-api/src/routes/external/auth.ts b/.github/codeql/queries/test/UserControlledBypass/control-api/src/routes/external/auth.ts deleted file mode 100644 index c624039a92..0000000000 --- a/.github/codeql/queries/test/UserControlledBypass/control-api/src/routes/external/auth.ts +++ /dev/null @@ -1,162 +0,0 @@ -import express from 'express' -import { sendPublicApiError } from '../../http/publicApiError' - -declare function authenticatePasswordAndIssueSession(email: string, password: string): void -declare function authenticateExternalUserSession(token: string): void -declare function issueSession(): string -declare function readProtectedData(): string -declare function setAuthenticated(): void -declare function mayReturnSuccess( - req: express.Request, - res: express.Response, - status: number, -): void - -const router = express.Router() - -router.post('/safe-password', (req, res) => { - const email = String(req.body?.email || '').trim() - const password = String(req.body?.password || '') - if (!email || !password) return res.status(400).json({ error: 'invalid_credentials' }) - authenticatePasswordAndIssueSession(email, password) - return res.sendStatus(204) -}) - -router.post('/safe-session', (req, res) => { - const token = String(req.body?.token || '').trim() - if (!token || token.length > 4096) { - sendPublicApiError(req, res, 401, 'invalid_session', 'Invalid session.') - return - } - authenticateExternalUserSession(token) - return res.sendStatus(204) -}) - -router.post('/safe-block-response', (req, res) => { - const token = String(req.body?.token || '') - if (!token) { - res.status(401).json({ error: 'invalid_session' }) - return - } - authenticateExternalUserSession(token) - return res.sendStatus(204) -}) - -// Mirrors the production handler shape: stock early-abort recognition loses -// this path through the surrounding try/catch, so the repository model must -// prove the fixed failure response itself. -router.post('/safe-braced-password-in-try', (req, res) => { - try { - const email = String(req.body?.email || '').trim() - const password = String(req.body?.password || '') - if (!email || !password) { - return res.status(400).json({ error: 'invalid_credentials' }) - } - authenticatePasswordAndIssueSession(email, password) - return res.sendStatus(204) - } catch { - return res.status(503).json({ error: 'unavailable' }) - } -}) - -router.post('/safe-helper-token-in-try', (req, res) => { - try { - const token = String(req.body?.token || '').trim() - if (!token || token.length > 4096) { - sendPublicApiError(req, res, 401, 'invalid_session', 'Invalid session.') - return - } - authenticateExternalUserSession(token) - return res.sendStatus(204) - } catch { - return res.status(503).json({ error: 'unavailable' }) - } -}) - -router.post('/bypass-protected-data', (req, res) => { - const token = String(req.body?.token || '') - if (!token) return res.status(200).json({ data: readProtectedData() }) - authenticateExternalUserSession(token) - return res.sendStatus(204) -}) - -router.post('/bypass-session-issuance', (req, res) => { - const password = String(req.body?.password || '') - if (!password) return res.status(200).json({ token: issueSession() }) - authenticatePasswordAndIssueSession('user@example.test', password) - return res.sendStatus(204) -}) - -router.post('/single-branch-auth', (req, res) => { - const token = String(req.body?.token || '') - if (token) { - authenticateExternalUserSession(token) - } else { - return res.status(200).json({ data: readProtectedData() }) - } - return res.sendStatus(204) -}) - -router.post('/sets-authenticated', (req, res) => { - const token = String(req.body?.token || '') - if (!token) { - setAuthenticated() - return res.status(401).json({ error: 'invalid_session' }) - } - authenticateExternalUserSession(token) - return res.sendStatus(204) -}) - -router.post('/alternate-issuance', (req, res) => { - const token = String(req.body?.token || '') - if (!token) { - return res.status(200).json({ token: issueSession() }) - } - authenticateExternalUserSession(token) - return res.sendStatus(204) -}) - -router.post('/unrelated-400', (req, res) => { - const token = String(req.body?.token || '') - res.status(400).json({ error: 'unrelated' }) - if (!token) readProtectedData() - authenticateExternalUserSession(token) - return res.sendStatus(204) -}) - -router.post('/unrelated-400-does-not-sanitize', (req, res) => { - const token = String(req.body?.token || '') - res.status(400).json({ error: 'unrelated' }) - if (!token) return res.status(200).json({ data: readProtectedData() }) - authenticateExternalUserSession(token) - return res.sendStatus(204) -}) - -router.post('/post-work-4xx', (req, res) => { - const token = String(req.body?.token || '') - if (!token) { - readProtectedData() - return res.status(401).json({ error: 'invalid_session' }) - } - authenticateExternalUserSession(token) - return res.sendStatus(204) -}) - -router.post('/failure-response-leaks-data', (req, res) => { - const token = String(req.body?.token || '') - if (!token) return res.status(401).json({ data: readProtectedData() }) - authenticateExternalUserSession(token) - return res.sendStatus(204) -}) - -router.post('/unproven-helper', (req, res) => { - const token = String(req.body?.token || '') - if (!token) { - mayReturnSuccess(req, res, 401) - return - } - authenticateExternalUserSession(token) - return res.sendStatus(204) -}) - -export default router diff --git a/.github/codeql/queries/test/qlpack.yml b/.github/codeql/queries/test/qlpack.yml deleted file mode 100644 index 5289e0b9fc..0000000000 --- a/.github/codeql/queries/test/qlpack.yml +++ /dev/null @@ -1,6 +0,0 @@ -name: evenfire/codeql-query-tests -version: 0.0.1 -dependencies: - evenfire/codeql-queries: "*" - codeql/javascript-queries: 2.4.3 -extractor: javascript diff --git a/.github/codeql/queries/test/verify-security-and-quality-suite.sh b/.github/codeql/queries/test/verify-security-and-quality-suite.sh deleted file mode 100755 index 722952773b..0000000000 --- a/.github/codeql/queries/test/verify-security-and-quality-suite.sh +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -codeql=${1:?usage: verify-security-and-quality-suite.sh } -repo_root=$(cd "$(dirname "$0")/../../../.." && pwd) -suite="$repo_root/.github/codeql/queries/evenfire-security-and-quality.qls" -stock_suite="codeql/javascript-queries:codeql-suites/javascript-security-and-quality.qls" - -resolved_query_json=$("$codeql" resolve queries --format=json "$suite") -jq -e 'type == "array" and length > 0' >/dev/null <<<"$resolved_query_json" -resolved_queries=() -while IFS= read -r query_path; do - resolved_queries+=("$query_path") -done < <(jq -r '.[]' <<<"$resolved_query_json") - -stock_query_json=$("$codeql" resolve queries --format=json "$stock_suite") -jq -e 'type == "array" and length > 0' >/dev/null <<<"$stock_query_json" -stock_queries=() -while IFS= read -r query_path; do - stock_queries+=("$query_path") -done < <(jq -r '.[]' <<<"$stock_query_json") - -count_suffix() { - local suffix=$1 - local path - local count=0 - for path in "${resolved_queries[@]}"; do - [[ $path == *"$suffix" ]] && ((count += 1)) - done - printf '%s\n' "$count" -} - -single_query_with_id() { - local suffix=$1 - local expected_id=$2 - local path - local count - count=$(count_suffix "$suffix") - [[ $count == 1 ]] || { - echo "expected exactly one resolved query ending in $suffix; found $count" >&2 - return 1 - } - for path in "${resolved_queries[@]}"; do - [[ $path == *"$suffix" ]] || continue - [[ $("$codeql" resolve metadata "$path" | jq -r '.id // empty') == "$expected_id" ]] || { - echo "resolved query $path does not declare $expected_id" >&2 - return 1 - } - done -} - -resolved_query_contains_exact_path() { - local expected=$1 - local path - for path in "${resolved_queries[@]}"; do - [[ $path == "$expected" ]] && return 0 - done - return 1 -} - -for stock_path in "${stock_queries[@]}"; do - resolved_query_contains_exact_path "$stock_path" && continue - stock_id=$("$codeql" resolve metadata "$stock_path" | jq -r '.id // empty') - case "$stock_id" in - js/missing-rate-limiting | js/user-controlled-bypass) - ;; - *) - echo "stock security query is missing without an approved replacement: $stock_path ($stock_id)" >&2 - exit 1 - ;; - esac -done - -while IFS= read -r excluded_id; do - case "$excluded_id" in - js/missing-rate-limiting) - single_query_with_id "/.github/codeql/queries/EvenfireMissingRateLimiting.ql" "$excluded_id" - ;; - js/user-controlled-bypass) - single_query_with_id "/.github/codeql/queries/EvenfireUserControlledBypass.ql" "$excluded_id" - ;; - *) - echo "stock security query exclusion lacks an approved repository replacement: $excluded_id" >&2 - exit 1 - ;; - esac -done < <(awk ' - /^- exclude:/ { awaiting_id = 1; next } - awaiting_id && $1 == "id:" { print $2; awaiting_id = 0 } -' "$suite") - -single_query_with_id "/Security/CWE-1427/SystemPromptInjection.ql" "js/system-prompt-injection" -single_query_with_id "/.github/codeql/queries/EvenfireMissingRateLimiting.ql" "js/missing-rate-limiting" -single_query_with_id "/.github/codeql/queries/EvenfireUserControlledBypass.ql" "js/user-controlled-bypass" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 1f43371f82..fd97cc5bea 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -62,56 +62,22 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Initialize CodeQL - id: codeql-init uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 with: languages: javascript-typescript build-mode: none - config-file: ./.github/codeql/codeql-config.yml - db-location: ${{ runner.temp }}/codeql-databases - - - name: Test repository CodeQL queries - env: - CODEQL: ${{ steps.codeql-init.outputs.codeql-path }} - run: | - "$CODEQL" pack install .github/codeql/queries - "$CODEQL" pack install \ - --additional-packs=.github/codeql/queries \ - .github/codeql/queries/test - .github/codeql/queries/test/verify-security-and-quality-suite.sh "$CODEQL" - "$CODEQL" test run \ - --additional-packs=.github/codeql/queries \ - .github/codeql/queries/test + queries: security-and-quality + # mcp-host LLM adapters must forward operator soul/system text to + # the model. CodeQL traces that from the WRC-JWT configure/execute + # body and labels it js/system-prompt-injection. That HTTP source + # is not an open prompt sink; do not concatenate untrusted input + # into tool descriptions. Keep js/missing-rate-limiting enabled. + config: | + query-filters: + - exclude: + id: js/system-prompt-injection - name: Perform CodeQL analysis uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 with: category: '/language:javascript-typescript' - - - name: Verify production CodeQL source scope - env: - CODEQL_DATABASE_ROOT: ${{ runner.temp }}/codeql-databases - run: | - source_archive="$(find "$CODEQL_DATABASE_ROOT" -type f -name src.zip -print -quit)" - if [[ -z "$source_archive" ]]; then - echo '::error::CodeQL source archive was not found' - exit 1 - fi - - source_listing="$RUNNER_TEMP/codeql-production-sources.txt" - unzip -Z1 "$source_archive" > "$source_listing" - if grep -Fq '/.github/codeql/queries/test/' "$source_listing"; then - echo '::error::Synthetic CodeQL query fixtures entered production analysis' - exit 1 - fi - - for expected_source in \ - '/control-api/src/main.ts' \ - '/external-rest-api/src/main.ts' \ - '/desktop-app/src/main.ts' - do - if ! grep -Fq "$expected_source" "$source_listing"; then - echo "::error::Expected application source is missing: $expected_source" - exit 1 - fi - done From 8113e4b99acae2b0f77a65c880bbedcdecaf642f Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 29 Oct 2026 01:47:03 +0100 Subject: [PATCH 403/528] fix(external-rest-api): keep upstream no-store on sanitized authority 503 The session backend answers a session-token mint outage with 503, Retry-After, and Cache-Control: no-store. The edge client forwards that no-store, but sanitizeControlApiPublicError only published cache-control for session_issuance_temporarily_unavailable, so the authority_unavailable 503 emitted through /api/v1/me/switch-team and /api/v1/team dropped it and let an intermediary cache a transient authority failure. Forward only the literal no-store value for 503, next to the existing Retry-After allowlist, so bodies and statuses are unchanged. Covered end to end through the full edge app in test/routes.sessionBackendUnavailableNoStore.test.ts: 503 with no-store keeps it on switch-team and create-team, and a 503 without no-store still emits no cache-control header. --- external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- external-rest-api/src/http/publicApiError.ts | 21 ++- external-rest-api/src/releaseManifest.ts | 2 +- ...s.sessionBackendUnavailableNoStore.test.ts | 148 ++++++++++++++++++ 5 files changed, 171 insertions(+), 6 deletions(-) create mode 100644 external-rest-api/test/routes.sessionBackendUnavailableNoStore.test.ts diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 945fc08c1f..2a709a8fb2 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.158", + "version": "0.1.159", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.158", + "version": "0.1.159", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index 015bb819fb..b78087a2be 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.158", + "version": "0.1.159", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/http/publicApiError.ts b/external-rest-api/src/http/publicApiError.ts index 617f9ca876..138ec11210 100644 --- a/external-rest-api/src/http/publicApiError.ts +++ b/external-rest-api/src/http/publicApiError.ts @@ -128,6 +128,18 @@ function safePublicRateLimitHeaders(headers: Record): Record): Record { + const raw = headers['cache-control'] + if (typeof raw !== 'string' || raw.trim().toLowerCase() !== 'no-store') return {} + return { 'cache-control': 'no-store' } +} + function safeUnsignedIntegerHeader( headers: Record, name: string @@ -241,8 +253,13 @@ export function sanitizeControlApiPublicError( ? safeHeaders : error.status === 413 ? safeUnsignedIntegerHeader(responseHeaders, 'upload-length') - : retryable && safeHeaders['retry-after'] - ? { 'retry-after': safeHeaders['retry-after'] } + : retryable + ? { + ...(safeHeaders['retry-after'] + ? { 'retry-after': safeHeaders['retry-after'] } + : {}), + ...(error.status === 503 ? safeNoStoreHeader(responseHeaders) : {}), + } : {} return { diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index 6ee37558d4..9d012f7061 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.12.0', - externalRestApiVersion: '0.1.158', + externalRestApiVersion: '0.1.159', rpcProxyVersion: '0.1.137', desktopVersion: '0.12.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/test/routes.sessionBackendUnavailableNoStore.test.ts b/external-rest-api/test/routes.sessionBackendUnavailableNoStore.test.ts new file mode 100644 index 0000000000..67cf984573 --- /dev/null +++ b/external-rest-api/test/routes.sessionBackendUnavailableNoStore.test.ts @@ -0,0 +1,148 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { type Server, createServer } from 'node:http' +import type { AddressInfo } from 'node:net' +import request from 'supertest' +import { createApp } from '../src/app.js' +import { config } from '../src/config.js' + +// The edge verifies the caller's session locally; only the session backend is +// replaced here so the full app wiring (routes, sanitizer, global handler) runs +// unchanged. +vi.mock('../src/authToken.js', () => ({ + verifyToken: (token: string) => + token === 'synthetic-session-token' + ? { + userId: 'user-1', + email: 'user@example.invalid', + teamId: 'team-a', + role: 'admin', + exp: 9_999_999_999, + sessionContract: 'v1', + } + : null, +})) + +type UpstreamResponse = { + status: number + body: unknown + headers?: Record +} + +function createControlApiProducer() { + let sessionTokenResponse: UpstreamResponse | undefined + const server = createServer((req, res) => { + req.resume() + const url = req.url ?? '' + const respond = (response: UpstreamResponse) => { + res.statusCode = response.status + res.setHeader('content-type', 'application/json') + for (const [name, value] of Object.entries(response.headers ?? {})) { + res.setHeader(name, value) + } + res.end(JSON.stringify(response.body)) + } + + if (req.method === 'GET' && url === '/external/users/user-1/memberships/team-b') { + respond({ status: 200, body: { team_id: 'team-b', role: 'admin', team_name: 'Team B' } }) + return + } + if (req.method === 'POST' && url === '/external/teams') { + respond({ status: 201, body: { id: 'team-new', name: 'Team New' } }) + return + } + if (req.method === 'POST' && url === '/external/auth/session-token') { + if (!sessionTokenResponse) { + respond({ status: 500, body: { error: 'missing_test_upstream_response' } }) + return + } + const response = sessionTokenResponse + sessionTokenResponse = undefined + respond(response) + return + } + respond({ status: 500, body: { error: 'unexpected_test_upstream_request' } }) + }) + + return { + server, + respondWithSessionToken(response: UpstreamResponse) { + sessionTokenResponse = response + }, + } +} + +const sessionBackendUnavailableResponse: UpstreamResponse = { + status: 503, + body: { error: 'session_backend_unavailable', retryAfterSeconds: 2 }, + headers: { 'retry-after': '2', 'cache-control': 'no-store' }, +} + +describe('External REST session backend 503 cache semantics', () => { + const originalControlApiBaseUrl = config.controlApiBaseUrl + const producer = createControlApiProducer() + let server: Server + + beforeAll(async () => { + server = producer.server + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.off('error', reject) + resolve() + }) + }) + const address = server.address() as AddressInfo + config.controlApiBaseUrl = `http://127.0.0.1:${address.port}` + }) + + beforeEach(() => producer.respondWithSessionToken(sessionBackendUnavailableResponse)) + + afterAll(async () => { + config.controlApiBaseUrl = originalControlApiBaseUrl + await new Promise((resolve, reject) => { + server.close(error => (error ? reject(error) : resolve())) + }) + }) + + it('keeps upstream no-store on the sanitized switch-team authority 503', async () => { + const response = await request(createApp()) + .post('/api/v1/me/switch-team') + .set('authorization', 'Bearer synthetic-session-token') + .send({ teamId: 'team-b' }) + + expect(response.status).toBe(503) + expect(response.body.error).toMatchObject({ code: 'authority_unavailable', retryable: true }) + expect(response.headers['retry-after']).toBe('2') + expect(response.headers['cache-control']).toBe('no-store') + }) + + it('keeps upstream no-store on the sanitized create-team authority 503', async () => { + const response = await request(createApp()) + .post('/api/v1/team') + .set('authorization', 'Bearer synthetic-session-token') + .send({ name: 'Team New' }) + + expect(response.status).toBe(503) + expect(response.body.error).toMatchObject({ code: 'authority_unavailable', retryable: true }) + expect(response.headers['retry-after']).toBe('2') + expect(response.headers['cache-control']).toBe('no-store') + }) + + it('does not invent no-store when the upstream 503 omits it', async () => { + producer.respondWithSessionToken({ + status: 503, + body: { error: 'authority_unavailable', retryAfterSeconds: 2 }, + headers: { 'retry-after': '2' }, + }) + + const response = await request(createApp()) + .post('/api/v1/me/switch-team') + .set('authorization', 'Bearer synthetic-session-token') + .send({ teamId: 'team-b' }) + + expect(response.status).toBe(503) + expect(response.body.error).toMatchObject({ code: 'authority_unavailable', retryable: true }) + expect(response.headers['retry-after']).toBe('2') + expect(response.headers['cache-control']).toBeUndefined() + }) +}) From 07555a7e0e62c218fda594b7280a0d7411969c83 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 29 Oct 2026 02:02:35 +0100 Subject: [PATCH 404/528] fix(control-api): restore dev verify body and session-token role check POST /external/auth/verify returns dev's `{"error":"Unauthorized"}` body for a missing, oversized, invalid or non-current token while keeping the 426 upgrade branch and the backend-failure handling unchanged. POST /external/auth/session-token validates the body `role` against TEAM_ROLES again, so a missing or invalid role returns dev's `400 {"error":"invalid payload"}`. A valid body role still yields to the live membership role. --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- control-api/src/routes/external/auth.ts | 37 ++++++++---- ...utes.externalSessionAtomicExchange.test.ts | 60 ++++++++++++++++++- ...es.externalSessionRenewalRateLimit.test.ts | 41 ++++++++----- 5 files changed, 114 insertions(+), 30 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 1fcc289148..2a3097c758 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.859", + "version": "0.1.860", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.859", + "version": "0.1.860", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index e50c2c9d5a..824a494105 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.859", + "version": "0.1.860", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/routes/external/auth.ts b/control-api/src/routes/external/auth.ts index 9d83de627f..93cd69d693 100644 --- a/control-api/src/routes/external/auth.ts +++ b/control-api/src/routes/external/auth.ts @@ -16,7 +16,7 @@ import { validatePasswordLogin, } from '../../middleware/passwordLoginAdmission.js' import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' -import { RpcScope } from '../../profileTypes.js' +import { type AuthClaims, RpcScope, TEAM_ROLES } from '../../profileTypes.js' import { getLiveTeamMembership } from '../../services/access/liveTeamAuthorization.js' import { resolveEffectiveUserAccessPolicy } from '../../services/access/userAccessRuntimePolicy.js' import { authenticateExternalUserSession } from '../../services/auth/externalSessionAuthentication.js' @@ -75,12 +75,25 @@ function sessionTokenFromRequest(req: { } function requireLegacySessionTokenPayload(req: Request, res: Response, next: NextFunction): void { - const body = (req.body ?? {}) as { userId?: unknown; email?: unknown; teamId?: unknown } + const body = (req.body ?? {}) as { + userId?: unknown + email?: unknown + teamId?: unknown + role?: unknown + } const userId = String(body.userId || '').trim() const email = String(body.email || '').trim() const teamId = String(body.teamId || '').trim() + const role = String(body.role || '').trim() const token = sessionTokenFromRequest(req) - if (!userId || !email || !teamId || !token || token.length > 4096) { + if ( + !userId || + !email || + !teamId || + !TEAM_ROLES.includes(role as AuthClaims['role']) || + !token || + token.length > 4096 + ) { res.status(400).json({ error: 'invalid payload' }) return } @@ -265,10 +278,7 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { router.post('/external/auth/verify', async (req, res, next) => { try { const token = String(req.body?.token || '').trim() - if (!token || token.length > 4096) { - sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') - return - } + if (!token || token.length > 4096) return res.status(401).json({ error: 'Unauthorized' }) const authentication = await authenticateExternalUserSession(token, { purpose: 'verify', client: externalSessionClient(req), @@ -278,8 +288,7 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { return } if (authentication.status !== 'authenticated') { - sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') - return + return res.status(401).json({ error: 'Unauthorized' }) } return res.status(200).json({ claims: authentication.claims }) } catch (error) { @@ -299,8 +308,16 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { .trim() .toLowerCase() const teamId = String(req.body?.teamId || '').trim() + const role = String(req.body?.role || '').trim() const currentToken = sessionTokenFromRequest(req) - if (!userId || !email || !teamId || !currentToken || currentToken.length > 4096) { + if ( + !userId || + !email || + !teamId || + !TEAM_ROLES.includes(role as AuthClaims['role']) || + !currentToken || + currentToken.length > 4096 + ) { return res.status(400).json({ error: 'invalid payload' }) } const authentication = req.externalSessionAuthentication! diff --git a/control-api/test/routes.externalSessionAtomicExchange.test.ts b/control-api/test/routes.externalSessionAtomicExchange.test.ts index 4fffe8cb94..6895c64fa5 100644 --- a/control-api/test/routes.externalSessionAtomicExchange.test.ts +++ b/control-api/test/routes.externalSessionAtomicExchange.test.ts @@ -78,7 +78,7 @@ describe('legacy external-session replacement exchange', () => { .post('/external/auth/session-token') .set('x-forwarded-for', '203.0.113.44') .set('x-user-session-token', 'synthetic-source-token') - .send({ userId: claims.userId, email: claims.email, teamId: 'team-new' }) + .send({ userId: claims.userId, email: claims.email, teamId: 'team-new', role: 'admin' }) expect(response.status).toBe(401) expect(response.body).toMatchObject({ error: { code: 'invalid_session' } }) @@ -100,7 +100,7 @@ describe('legacy external-session replacement exchange', () => { .post('/external/auth/session-token') .set('x-forwarded-for', '203.0.113.44') .set('x-user-session-token', 'synthetic-source-token') - .send({ userId: claims.userId, email: claims.email, teamId: 'team-new' }) + .send({ userId: claims.userId, email: claims.email, teamId: 'team-new', role: 'admin' }) .expect(200) expect(response.body).toEqual({ @@ -119,12 +119,66 @@ describe('legacy external-session replacement exchange', () => { .post('/external/auth/session-token') .set('x-forwarded-for', '203.0.113.44') .set('x-user-session-token', 'synthetic-source-token') - .send({ userId: claims.userId, email: claims.email, teamId: 'team-new' }) + .send({ userId: claims.userId, email: claims.email, teamId: 'team-new', role: 'admin' }) .expect(403, { error: 'membership_not_found' }) expect(issuance.issueExternalUserSession).not.toHaveBeenCalled() }) + it('rejects a missing body role with dev invalid payload and accepts a valid role', async () => { + issuance.exchangeLegacyExternalUserSession.mockResolvedValue({ + status: 'issued', + token: 'synthetic-replacement-token', + role: 'admin', + }) + const payload = { userId: claims.userId, email: claims.email, teamId: 'team-new' } + + const missingRole = await request(app()) + .post('/external/auth/session-token') + .set('x-forwarded-for', '203.0.113.44') + .set('x-user-session-token', 'synthetic-source-token') + .send(payload) + + expect(missingRole.status).toBe(400) + expect(missingRole.body).toEqual({ error: 'invalid payload' }) + expect(issuance.exchangeLegacyExternalUserSession).not.toHaveBeenCalled() + + const control = await request(app()) + .post('/external/auth/session-token') + .set('x-forwarded-for', '203.0.113.44') + .set('x-user-session-token', 'synthetic-source-token') + .send({ ...payload, role: 'admin' }) + + expect(control.status).toBe(200) + }) + + it('rejects an invalid body role with dev invalid payload and accepts a valid role', async () => { + issuance.exchangeLegacyExternalUserSession.mockResolvedValue({ + status: 'issued', + token: 'synthetic-replacement-token', + role: 'admin', + }) + const payload = { userId: claims.userId, email: claims.email, teamId: 'team-new' } + + const invalidRole = await request(app()) + .post('/external/auth/session-token') + .set('x-forwarded-for', '203.0.113.44') + .set('x-user-session-token', 'synthetic-source-token') + .send({ ...payload, role: 'owner' }) + + expect(invalidRole.status).toBe(400) + expect(invalidRole.body).toEqual({ error: 'invalid payload' }) + expect(issuance.exchangeLegacyExternalUserSession).not.toHaveBeenCalled() + + const control = await request(app()) + .post('/external/auth/session-token') + .set('x-forwarded-for', '203.0.113.44') + .set('x-user-session-token', 'synthetic-source-token') + .send({ ...payload, role: 'member' }) + + expect(control.status).toBe(200) + }) + it('binds revoke-all to the authenticated actor authority context', async () => { const authorityContext = { contract: 'v1' as const, diff --git a/control-api/test/routes.externalSessionRenewalRateLimit.test.ts b/control-api/test/routes.externalSessionRenewalRateLimit.test.ts index 75da88c3a7..1b04263da8 100644 --- a/control-api/test/routes.externalSessionRenewalRateLimit.test.ts +++ b/control-api/test/routes.externalSessionRenewalRateLimit.test.ts @@ -145,20 +145,33 @@ describe('external session route error boundaries and renewal rate limiting', () }) }) - it.each([ - ['invalid', 401, 'invalid_session'], - ['upgrade_required', 426, 'upgrade_required'], - ] as const)( - 'preserves the %s authentication result', - async (status, expectedStatus, expectedCode) => { - sessions.authenticateExternalUserSession.mockResolvedValueOnce({ status }) + it('returns the dev Unauthorized body when the session is invalid', async () => { + sessions.authenticateExternalUserSession.mockResolvedValueOnce({ status: 'invalid' }) - const response = await request(app()) - .post('/external/auth/verify') - .send({ token: 'synthetic-session-token' }) + const response = await request(app()) + .post('/external/auth/verify') + .send({ token: 'synthetic-session-token' }) - expect(response.status).toBe(expectedStatus) - expect(response.body.error.code).toBe(expectedCode) - } - ) + expect(response.status).toBe(401) + expect(response.body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns the dev Unauthorized body when the token is missing', async () => { + const response = await request(app()).post('/external/auth/verify').send({}) + + expect(response.status).toBe(401) + expect(response.body).toEqual({ error: 'Unauthorized' }) + expect(sessions.authenticateExternalUserSession).not.toHaveBeenCalled() + }) + + it('preserves the upgrade_required envelope for a client below the minimum', async () => { + sessions.authenticateExternalUserSession.mockResolvedValueOnce({ status: 'upgrade_required' }) + + const response = await request(app()) + .post('/external/auth/verify') + .send({ token: 'synthetic-session-token' }) + + expect(response.status).toBe(426) + expect(response.body.error.code).toBe('upgrade_required') + }) }) From 09313cf0387e7627a1713e0e35df7b525b58a61f Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 29 Oct 2026 02:06:25 +0100 Subject: [PATCH 405/528] fix(control-api): answer 503 only for authority outages on external auth routes The session-token, renew, logout, revoke-all, targeted revoke and rpc-token handlers answered 503 authority_unavailable for any thrown error. A programmer or policy failure was therefore reported as an outage and the sanitized 500 path was bypassed. Gate those catches on the repository's existing database-outage provenance (isExternalSessionBackendUnavailableError) and forward every other error to next(error). Attach that provenance at the real DB boundaries the handlers call: exchange and renewal transactions, the revocation transactions, the live-membership query and the v2 membership query, all through externalSessionDatabaseFailure. Tests cover a classified outage (503, unchanged authority_unavailable body) and a plain dependency error (sanitized 500) for each route. --- control-api/src/routes/external/auth.ts | 128 +++--- .../services/access/liveTeamAuthorization.ts | 18 +- .../auth/externalSessionAuthentication.ts | 9 +- .../services/auth/externalSessionIssuance.ts | 8 +- .../src/services/auth/userSessionService.ts | 40 +- ....externalAuth.authorityUnavailable.test.ts | 376 ++++++++++++++++++ ...utes.externalSessionAtomicExchange.test.ts | 11 +- 7 files changed, 522 insertions(+), 68 deletions(-) create mode 100644 control-api/test/routes.externalAuth.authorityUnavailable.test.ts diff --git a/control-api/src/routes/external/auth.ts b/control-api/src/routes/external/auth.ts index 93cd69d693..156f1b9bfb 100644 --- a/control-api/src/routes/external/auth.ts +++ b/control-api/src/routes/external/auth.ts @@ -27,6 +27,11 @@ import { selectExternalSessionRepresentation, } from '../../services/auth/externalSessionIssuance.js' import { authenticatePasswordAndIssueSession } from '../../services/auth/passwordSessionAuthentication.js' +import { + externalSessionDatabaseFailure, + isExternalSessionBackendUnavailableError, + runExternalSessionDatabaseOperation, +} from '../../services/auth/sessionDatabaseFailure.js' import { revokeAllUserSessions, revokeLegacyUserSession, @@ -107,6 +112,26 @@ function sendExternalLoginError(res: Response, error: unknown): Response { return res.status(403).json({ error: 'membership_not_found' }) } +/** + * Answers 503 `authority_unavailable` only when the failure carries the + * repository's database-outage provenance. Every other error keeps the + * sanitized 500 path so a programmer or policy failure is never reported as an + * outage. + */ +function handleExternalAuthorityUnavailable( + error: unknown, + req: Request, + res: Response, + next: NextFunction, + message: string +): void { + if (!isExternalSessionBackendUnavailableError(error)) { + next(error) + return + } + sendPublicApiError(req, res, 503, 'authority_unavailable', message, true) +} + async function requireLegacyRpcSessionRateLimitContext( req: ExternalAuthedRequest, res: Response, @@ -337,7 +362,10 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { email, teamId, }, - { policy: authentication.policy } + { + policy: authentication.policy, + onDatabaseFailure: externalSessionDatabaseFailure, + } ) if (exchange.status === 'invalid_session') { sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') @@ -353,8 +381,9 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { }) } - const membership = await pool.query( - `SELECT tm.role, u.lifecycle_version + const membership = await runExternalSessionDatabaseOperation(() => + pool.query( + `SELECT tm.role, u.lifecycle_version FROM users u JOIN team_members tm ON tm.user_id = u.id WHERE u.id = $1 @@ -363,7 +392,8 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { AND tm.status = 'active' AND u.lifecycle_state = 'active' LIMIT 1`, - [userId, email, teamId] + [userId, email, teamId] + ) ) const liveRole = ( membership.rows[0] as @@ -378,14 +408,13 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { sessionContract: 'v2', deprecated: true, }) - } catch { - sendPublicApiError( + } catch (error) { + handleExternalAuthorityUnavailable( + error, req, res, - 503, - 'authority_unavailable', - 'Authorization is temporarily unavailable.', - true + next, + 'Authorization is temporarily unavailable.' ) } } @@ -395,7 +424,7 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { '/external/auth/session/renew', requireExternalSessionRateLimitContext({ purpose: 'renew', client: externalSessionClient }), requireAuthenticatedExternalUserRateLimitContext, - async (req: ExternalAuthedRequest, res) => { + async (req: ExternalAuthedRequest, res, next) => { try { const token = sessionTokenFromRequest(req) if (!token || token.length > 4096) { @@ -406,6 +435,7 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { const renewal = await renewExternalUserSession(token, { client: externalSessionClient(req), policy: authentication.policy, + onDatabaseFailure: externalSessionDatabaseFailure, }) if (renewal.status === 'upgrade_required') { sendPublicApiError(req, res, 426, 'upgrade_required', 'A newer client is required.') @@ -421,14 +451,13 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { expiresInSeconds: renewal.session.expiresInSeconds, absoluteExpiresAt: renewal.session.identity.absoluteExpiresAt.toISOString(), }) - } catch { - sendPublicApiError( + } catch (error) { + handleExternalAuthorityUnavailable( + error, req, res, - 503, - 'authority_unavailable', - 'Session authority is temporarily unavailable.', - true + next, + 'Session authority is temporarily unavailable.' ) } } @@ -441,7 +470,7 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { client: externalSessionClient, }), requireAuthenticatedExternalUserRateLimitContext, - async (req: ExternalAuthedRequest, res) => { + async (req: ExternalAuthedRequest, res, next) => { try { const token = sessionTokenFromRequest(req) if (!token || token.length > 4096) { @@ -456,18 +485,20 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { authentication.claims.sid, 'logout', undefined, - authentication.authorityContext + authentication.authorityContext, + { onDatabaseFailure: externalSessionDatabaseFailure } ) - : await revokeLegacyUserSession(token, authentication.claims, 'logout') + : await revokeLegacyUserSession(token, authentication.claims, 'logout', undefined, { + onDatabaseFailure: externalSessionDatabaseFailure, + }) return res.status(200).json({ revoked }) - } catch { - sendPublicApiError( + } catch (error) { + handleExternalAuthorityUnavailable( + error, req, res, - 503, - 'authority_unavailable', - 'Session authority is temporarily unavailable.', - true + next, + 'Session authority is temporarily unavailable.' ) } } @@ -477,7 +508,7 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { '/external/auth/sessions/revoke-all', requireExternalSessionRateLimitContext({ purpose: 'protected', client: externalSessionClient }), requireAuthenticatedExternalUserRateLimitContext, - async (req: ExternalAuthedRequest, res) => { + async (req: ExternalAuthedRequest, res, next) => { try { const token = sessionTokenFromRequest(req) if (!token || token.length > 4096) { @@ -489,17 +520,18 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { authentication.claims.userId, 'user_revoked_all', undefined, - authentication.authorityContext + authentication.authorityContext, + undefined, + { onDatabaseFailure: externalSessionDatabaseFailure } ) return res.status(200).json({ revoked }) - } catch { - sendPublicApiError( + } catch (error) { + handleExternalAuthorityUnavailable( + error, req, res, - 503, - 'authority_unavailable', - 'Session authority is temporarily unavailable.', - true + next, + 'Session authority is temporarily unavailable.' ) } } @@ -513,7 +545,7 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { requireV2: true, }), requireAuthenticatedExternalUserRateLimitContext, - async (req: ExternalAuthedRequest, res) => { + async (req: ExternalAuthedRequest, res, next) => { try { const authentication = req.externalSessionAuthentication! const revoked = await revokeUserSession( @@ -521,17 +553,17 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { String(req.params.sid || '').trim(), 'user_revoked', undefined, - authentication.authorityContext + authentication.authorityContext, + { onDatabaseFailure: externalSessionDatabaseFailure } ) return res.status(200).json({ revoked }) - } catch { - sendPublicApiError( + } catch (error) { + handleExternalAuthorityUnavailable( + error, req, res, - 503, - 'authority_unavailable', - 'Session authority is temporarily unavailable.', - true + next, + 'Session authority is temporarily unavailable.' ) } } @@ -551,16 +583,16 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { liveTeam = claims.teamId ? await getLiveTeamMembership(claims.userId, claims.teamId, { budget: req.accessExecutionBudget, + onDatabaseFailure: externalSessionDatabaseFailure, }) : null - } catch { - sendPublicApiError( + } catch (error) { + handleExternalAuthorityUnavailable( + error, req, res, - 503, - 'authority_unavailable', - 'Authorization is temporarily unavailable.', - true + next, + 'Authorization is temporarily unavailable.' ) return } diff --git a/control-api/src/services/access/liveTeamAuthorization.ts b/control-api/src/services/access/liveTeamAuthorization.ts index cefa6c4611..c6509a15b0 100644 --- a/control-api/src/services/access/liveTeamAuthorization.ts +++ b/control-api/src/services/access/liveTeamAuthorization.ts @@ -14,6 +14,7 @@ export async function getLiveTeamMembership( options: { db?: Pick budget?: AccessExecutionBudget + onDatabaseFailure?: (error: unknown) => unknown } = {} ): Promise { const db = options.db ?? pool @@ -24,9 +25,20 @@ export async function getLiveTeamMembership( AND tm.status = 'active' LIMIT 1` const values = [userId, teamId] - const result = options.budget - ? await runAccessDatabaseQuery(db, options.budget, text, values) - : await db.query(text, values) + let result + if (options.budget) { + result = await runAccessDatabaseQuery(db, options.budget, text, values, { + onDatabaseFailure: options.onDatabaseFailure, + }) + } else if (options.onDatabaseFailure) { + try { + result = await db.query(text, values) + } catch (error) { + throw options.onDatabaseFailure(error) + } + } else { + result = await db.query(text, values) + } const row = result.rows[0] as { team_id: string; role: TeamRole } | undefined return row ? { teamId: row.team_id, role: row.role } : null } diff --git a/control-api/src/services/auth/externalSessionAuthentication.ts b/control-api/src/services/auth/externalSessionAuthentication.ts index c5e9ea3a9d..42c8e4af7e 100644 --- a/control-api/src/services/auth/externalSessionAuthentication.ts +++ b/control-api/src/services/auth/externalSessionAuthentication.ts @@ -296,7 +296,9 @@ export async function authenticateExternalUserSession( export async function renewExternalUserSession( token: string, - options: Omit = {} + options: Omit & { + onDatabaseFailure?: (error: unknown) => unknown + } = {} ): Promise { const policy = options.policy ?? (await resolveEffectiveUserAccessPolicy({ budget: options.budget })) @@ -308,7 +310,10 @@ export async function renewExternalUserSession( } const claims = verifyUserSessionV2Token(token) if (!claims) return { status: 'invalid', reason: 'invalid_representation' } - const result = await renewUserSession(claims, { budget: options.budget }) + const result = await renewUserSession(claims, { + budget: options.budget, + onDatabaseFailure: options.onDatabaseFailure, + }) if (!('token' in result)) { if (result.status === 'valid') { return { status: 'invalid', reason: 'session_renewal_failed' } diff --git a/control-api/src/services/auth/externalSessionIssuance.ts b/control-api/src/services/auth/externalSessionIssuance.ts index 489f9ab8b2..f971b6d618 100644 --- a/control-api/src/services/auth/externalSessionIssuance.ts +++ b/control-api/src/services/auth/externalSessionIssuance.ts @@ -1,4 +1,4 @@ -import { type DbClient, withTransaction } from '../../db.js' +import { type DbClient, pool, withTransaction } from '../../db.js' import type { AuthClaims, TeamRole } from '../../profileTypes.js' import { signExternalSessionToken } from '../../utils/auth/externalSessionAuthToken.js' import { @@ -154,6 +154,7 @@ export async function exchangeLegacyExternalUserSession( options: { policy: EffectiveUserAccessPolicy transaction?: SessionTransaction + onDatabaseFailure?: (error: unknown) => unknown } ): Promise { if ( @@ -162,7 +163,10 @@ export async function exchangeLegacyExternalUserSession( ) { return { status: 'invalid_session' } } - const run = options.transaction ?? withTransaction + const run = + options.transaction ?? + ((work: (db: DbClient) => Promise) => + withTransaction(work, pool, { onDatabaseFailure: options.onDatabaseFailure })) return run(async db => { const validation = await validateLegacyUserSession(input.token, input.claims, { db, diff --git a/control-api/src/services/auth/userSessionService.ts b/control-api/src/services/auth/userSessionService.ts index 8a4b91768b..953bd8a6d8 100644 --- a/control-api/src/services/auth/userSessionService.ts +++ b/control-api/src/services/auth/userSessionService.ts @@ -331,7 +331,11 @@ export async function validateUserSessionClaims( export async function renewUserSession( claims: UserSessionV2Claims, - options: { db?: SessionDatabase; budget?: AccessExecutionBudget } = {} + options: { + db?: SessionDatabase + budget?: AccessExecutionBudget + onDatabaseFailure?: (error: unknown) => unknown + } = {} ): Promise { const work = async (db: SessionDatabase) => { const row = await loadSessionForUpdate(db, claims.sid) @@ -407,11 +411,18 @@ export async function renewUserSession( } } if (options.db) { - return work(options.budget ? budgetedSessionDatabase(options.db, options.budget) : options.db) + return work( + options.budget + ? budgetedSessionDatabase(options.db, options.budget, options.onDatabaseFailure) + : sessionDatabaseWithFailureProvenance(options.db, options.onDatabaseFailure) + ) } return options.budget - ? withAccessDatabaseTransaction(options.budget, work, { mode: 'read_write' }) - : withTransaction(work) + ? withAccessDatabaseTransaction(options.budget, work, { + mode: 'read_write', + onDatabaseFailure: options.onDatabaseFailure, + }) + : withTransaction(work, pool, { onDatabaseFailure: options.onDatabaseFailure }) } export async function revokeUserSession( @@ -419,7 +430,8 @@ export async function revokeUserSession( sid: string, reason: string, db?: SessionDatabase, - actorContext?: ExternalSessionAuthorityContext + actorContext?: ExternalSessionAuthorityContext, + options: { onDatabaseFailure?: (error: unknown) => unknown } = {} ): Promise { const work = async (transaction: SessionDatabase): Promise => { if (actorContext && !(await isCurrentRevocationActor(transaction, userId, actorContext))) { @@ -448,7 +460,9 @@ export async function revokeUserSession( ) return (result.rowCount ?? 0) > 0 } - return db ? work(db) : withTransaction(work) + return db + ? work(sessionDatabaseWithFailureProvenance(db, options.onDatabaseFailure)) + : withTransaction(work, pool, { onDatabaseFailure: options.onDatabaseFailure }) } export async function revokeAllUserSessions( @@ -456,7 +470,8 @@ export async function revokeAllUserSessions( reason: string, db?: SessionDatabase, actorContext?: ExternalSessionAuthorityContext, - expectedLifecycleVersion?: string | number + expectedLifecycleVersion?: string | number, + options: { onDatabaseFailure?: (error: unknown) => unknown } = {} ): Promise { const work = async (transaction: SessionDatabase) => { const userLock = expectedLifecycleVersion === undefined ? 'FOR UPDATE' : 'FOR NO KEY UPDATE' @@ -529,7 +544,9 @@ export async function revokeAllUserSessions( return result.rowCount ?? 0 } - return db ? work(db) : withTransaction(work) + return db + ? work(sessionDatabaseWithFailureProvenance(db, options.onDatabaseFailure)) + : withTransaction(work, pool, { onDatabaseFailure: options.onDatabaseFailure }) } async function isCurrentRevocationActor( @@ -736,7 +753,8 @@ export async function revokeLegacyUserSession( token: string, claims: AuthClaims, reason: string, - db?: SessionDatabase + db?: SessionDatabase, + options: { onDatabaseFailure?: (error: unknown) => unknown } = {} ): Promise { const work = async (transaction: SessionDatabase): Promise => { const user = await transaction.query(`SELECT id FROM users WHERE id = $1 FOR UPDATE`, [ @@ -755,5 +773,7 @@ export async function revokeLegacyUserSession( ) return (result.rowCount ?? 0) > 0 } - return db ? work(db) : withTransaction(work) + return db + ? work(sessionDatabaseWithFailureProvenance(db, options.onDatabaseFailure)) + : withTransaction(work, pool, { onDatabaseFailure: options.onDatabaseFailure }) } diff --git a/control-api/test/routes.externalAuth.authorityUnavailable.test.ts b/control-api/test/routes.externalAuth.authorityUnavailable.test.ts new file mode 100644 index 0000000000..0b90a41412 --- /dev/null +++ b/control-api/test/routes.externalAuth.authorityUnavailable.test.ts @@ -0,0 +1,376 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import request from 'supertest' +import { clerumErrorHandler } from '../src/http/errorHandler.js' +import { createExternalAuthRouter } from '../src/routes/external/auth.js' +import { ExternalSessionBackendUnavailableError } from '../src/services/auth/sessionDatabaseFailure.js' + +const sessions = vi.hoisted(() => ({ + authenticateExternalUserSession: vi.fn(), + authenticateExternalUserSessionIdentity: vi.fn(), + renewExternalUserSession: vi.fn(), +})) +const revocations = vi.hoisted(() => ({ + revokeAllUserSessions: vi.fn(), + revokeLegacyUserSession: vi.fn(), + revokeUserSession: vi.fn(), +})) +const issuance = vi.hoisted(() => ({ + exchangeLegacyExternalUserSession: vi.fn(), + issueExternalUserSession: vi.fn(), + selectExternalSessionRepresentation: vi.fn(), +})) +const liveTeam = vi.hoisted(() => ({ getLiveTeamMembership: vi.fn() })) +const directory = vi.hoisted(() => ({ + getTeamAgents: vi.fn(), + getUserAgents: vi.fn(), + googleLoginData: vi.fn(), + requestProfilePasswordReset: vi.fn(), +})) +const database = vi.hoisted(() => ({ query: vi.fn() })) + +vi.mock('../src/services/auth/externalSessionAuthentication.js', () => sessions) +vi.mock('../src/services/auth/externalSessionIssuance.js', () => issuance) +vi.mock('../src/services/auth/userSessionService.js', async importOriginal => { + const actual = await importOriginal() + return { ...actual, ...revocations } +}) +vi.mock('../src/services/access/liveTeamAuthorization.js', () => liveTeam) +vi.mock('../src/services/directory/index.js', () => directory) +vi.mock('../src/db.js', () => ({ + pool: { query: (...args: unknown[]) => database.query(...args) }, + withTransaction: vi.fn(), +})) + +const POLICY = { issueV1: true, issueV2: true, acceptV1: true, acceptV2: true } + +const v1Claims = { + userId: 'user-1', + email: 'user@example.test', + teamId: 'team-1', + role: 'admin' as const, + authGeneration: 1, + iat: 1_700_000_000, + exp: 1_800_000_000, +} + +const v2Claims = { + userId: 'user-1', + email: 'user@example.test', + teamId: null, + role: 'member' as const, + sid: 'sid-1', + jti: 'jti-1', +} + +function authenticated(claims: typeof v1Claims | typeof v2Claims, contract: 'v1' | 'v2') { + return { + status: 'authenticated' as const, + contract, + claims, + authorityContext: + contract === 'v2' + ? { + contract: 'v2' as const, + userId: claims.userId, + sid: 'sid-1', + jti: 'jti-1', + sessionVersion: 1, + } + : { + contract: 'v1' as const, + userId: claims.userId, + tokenHash: 'source-hash', + issuedAt: 1_700_000_000, + authGeneration: 1, + }, + policy: POLICY, + } +} + +function unavailable(): ExternalSessionBackendUnavailableError { + return new ExternalSessionBackendUnavailableError(new Error('private pg-pool sentinel')) +} + +function app() { + const value = express() + value.use(express.json()) + value.use(createExternalAuthRouter({} as never)) + value.use(clerumErrorHandler) + return value +} + +function sessionTokenRequest(overrides: Record = {}) { + return request(app()) + .post('/external/auth/session-token') + .set('x-forwarded-for', '203.0.113.44') + .set('x-user-session-token', 'synthetic-source-token') + .send({ userId: v1Claims.userId, email: v1Claims.email, teamId: 'team-1', ...overrides }) +} + +beforeEach(() => { + vi.clearAllMocks() + sessions.authenticateExternalUserSession.mockResolvedValue(authenticated(v1Claims, 'v1')) + issuance.exchangeLegacyExternalUserSession.mockResolvedValue({ + status: 'issued', + token: 'synthetic-replacement-token', + role: 'admin', + }) + revocations.revokeUserSession.mockResolvedValue(true) + revocations.revokeAllUserSessions.mockResolvedValue(1) + liveTeam.getLiveTeamMembership.mockResolvedValue({ teamId: 'team-1', role: 'admin' }) + database.query.mockResolvedValue({ + rows: [{ role: 'admin', lifecycle_version: 1 }], + rowCount: 1, + }) +}) + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe('external auth authority unavailability parity', () => { + describe('POST /external/auth/session-token', () => { + it('answers 503 authority_unavailable for a classified database outage', async () => { + issuance.exchangeLegacyExternalUserSession.mockRejectedValue(unavailable()) + + const response = await sessionTokenRequest() + + expect(response.status).toBe(503) + expect(response.body).toMatchObject({ + error: { + code: 'authority_unavailable', + message: 'Authorization is temporarily unavailable.', + retryable: true, + }, + }) + expect(JSON.stringify(response.body)).not.toContain('private pg-pool sentinel') + }) + + it('keeps a plain dependency error on the sanitized 500 path', async () => { + vi.stubEnv('NODE_ENV', 'production') + issuance.exchangeLegacyExternalUserSession.mockRejectedValue(new Error('boom')) + + const response = await sessionTokenRequest() + + expect(response.status).toBe(500) + expect(response.body).toMatchObject({ error: 'Internal Server Error' }) + expect(response.body.error).not.toBe('authority_unavailable') + expect(JSON.stringify(response.body)).not.toContain('boom') + expect(response.headers['retry-after']).toBeUndefined() + }) + + it('classifies a v2 membership transport failure at the database boundary', async () => { + sessions.authenticateExternalUserSession.mockResolvedValue(authenticated(v2Claims, 'v2')) + database.query.mockRejectedValue(new Error('private pg-pool sentinel')) + + const response = await sessionTokenRequest() + + expect(response.status).toBe(503) + expect(response.body).toMatchObject({ + error: { + code: 'authority_unavailable', + message: 'Authorization is temporarily unavailable.', + retryable: true, + }, + }) + expect(JSON.stringify(response.body)).not.toContain('private pg-pool sentinel') + }) + }) + + describe('POST /external/auth/session/renew', () => { + function renewRequest() { + return request(app()) + .post('/external/auth/session/renew') + .set('x-forwarded-for', '203.0.113.44') + .set('x-user-session-token', 'synthetic-session-token') + } + + beforeEach(() => { + sessions.authenticateExternalUserSession.mockResolvedValue(authenticated(v2Claims, 'v2')) + }) + + it('answers 503 authority_unavailable for a classified database outage', async () => { + sessions.renewExternalUserSession.mockRejectedValue(unavailable()) + + const response = await renewRequest() + + expect(response.status).toBe(503) + expect(response.body).toMatchObject({ + error: { + code: 'authority_unavailable', + message: 'Session authority is temporarily unavailable.', + retryable: true, + }, + }) + }) + + it('keeps a plain dependency error on the sanitized 500 path', async () => { + vi.stubEnv('NODE_ENV', 'production') + sessions.renewExternalUserSession.mockRejectedValue(new Error('boom')) + + const response = await renewRequest() + + expect(response.status).toBe(500) + expect(response.body).toMatchObject({ error: 'Internal Server Error' }) + expect(JSON.stringify(response.body)).not.toContain('boom') + expect(response.headers['retry-after']).toBeUndefined() + }) + }) + + describe('POST /external/auth/session/logout', () => { + function logoutRequest() { + return request(app()) + .post('/external/auth/session/logout') + .set('x-forwarded-for', '203.0.113.44') + .set('x-user-session-token', 'synthetic-session-token') + } + + beforeEach(() => { + sessions.authenticateExternalUserSession.mockResolvedValue(authenticated(v2Claims, 'v2')) + }) + + it('answers 503 authority_unavailable for a classified database outage', async () => { + revocations.revokeUserSession.mockRejectedValue(unavailable()) + + const response = await logoutRequest() + + expect(response.status).toBe(503) + expect(response.body).toMatchObject({ + error: { + code: 'authority_unavailable', + message: 'Session authority is temporarily unavailable.', + retryable: true, + }, + }) + }) + + it('keeps a plain dependency error on the sanitized 500 path', async () => { + vi.stubEnv('NODE_ENV', 'production') + revocations.revokeUserSession.mockRejectedValue(new Error('boom')) + + const response = await logoutRequest() + + expect(response.status).toBe(500) + expect(response.body).toMatchObject({ error: 'Internal Server Error' }) + expect(JSON.stringify(response.body)).not.toContain('boom') + expect(response.headers['retry-after']).toBeUndefined() + }) + }) + + describe('POST /external/auth/sessions/revoke-all', () => { + function revokeAllRequest() { + return request(app()) + .post('/external/auth/sessions/revoke-all') + .set('x-forwarded-for', '203.0.113.44') + .set('x-user-session-token', 'synthetic-session-token') + } + + it('answers 503 authority_unavailable for a classified database outage', async () => { + revocations.revokeAllUserSessions.mockRejectedValue(unavailable()) + + const response = await revokeAllRequest() + + expect(response.status).toBe(503) + expect(response.body).toMatchObject({ + error: { + code: 'authority_unavailable', + message: 'Session authority is temporarily unavailable.', + retryable: true, + }, + }) + }) + + it('keeps a plain dependency error on the sanitized 500 path', async () => { + vi.stubEnv('NODE_ENV', 'production') + revocations.revokeAllUserSessions.mockRejectedValue(new Error('boom')) + + const response = await revokeAllRequest() + + expect(response.status).toBe(500) + expect(response.body).toMatchObject({ error: 'Internal Server Error' }) + expect(JSON.stringify(response.body)).not.toContain('boom') + expect(response.headers['retry-after']).toBeUndefined() + }) + }) + + describe('POST /external/auth/sessions/:sid/revoke', () => { + function revokeTargetRequest() { + return request(app()) + .post('/external/auth/sessions/target-session/revoke') + .set('x-forwarded-for', '203.0.113.44') + .set('x-user-session-token', 'synthetic-session-token') + } + + beforeEach(() => { + sessions.authenticateExternalUserSession.mockResolvedValue(authenticated(v2Claims, 'v2')) + }) + + it('answers 503 authority_unavailable for a classified database outage', async () => { + revocations.revokeUserSession.mockRejectedValue(unavailable()) + + const response = await revokeTargetRequest() + + expect(response.status).toBe(503) + expect(response.body).toMatchObject({ + error: { + code: 'authority_unavailable', + message: 'Session authority is temporarily unavailable.', + retryable: true, + }, + }) + }) + + it('keeps a plain dependency error on the sanitized 500 path', async () => { + vi.stubEnv('NODE_ENV', 'production') + revocations.revokeUserSession.mockRejectedValue(new Error('boom')) + + const response = await revokeTargetRequest() + + expect(response.status).toBe(500) + expect(response.body).toMatchObject({ error: 'Internal Server Error' }) + expect(JSON.stringify(response.body)).not.toContain('boom') + expect(response.headers['retry-after']).toBeUndefined() + }) + }) + + describe('POST /external/rpc/token', () => { + function rpcTokenRequest() { + return request(app()) + .post('/external/rpc/token') + .set('x-forwarded-for', '203.0.113.44') + .send({ + sessionToken: 'synthetic-session-token', + scopes: ['host:message:invoke'], + hostRefs: ['agent-a'], + }) + } + + it('answers 503 authority_unavailable for a classified live-membership outage', async () => { + liveTeam.getLiveTeamMembership.mockRejectedValue(unavailable()) + + const response = await rpcTokenRequest() + + expect(response.status).toBe(503) + expect(response.body).toMatchObject({ + error: { + code: 'authority_unavailable', + message: 'Authorization is temporarily unavailable.', + retryable: true, + }, + }) + }) + + it('keeps a plain live-membership error on the sanitized 500 path', async () => { + vi.stubEnv('NODE_ENV', 'production') + liveTeam.getLiveTeamMembership.mockRejectedValue(new Error('boom')) + + const response = await rpcTokenRequest() + + expect(response.status).toBe(500) + expect(response.body).toMatchObject({ error: 'Internal Server Error' }) + expect(JSON.stringify(response.body)).not.toContain('boom') + expect(response.headers['retry-after']).toBeUndefined() + }) + }) +}) diff --git a/control-api/test/routes.externalSessionAtomicExchange.test.ts b/control-api/test/routes.externalSessionAtomicExchange.test.ts index 6895c64fa5..484880e27e 100644 --- a/control-api/test/routes.externalSessionAtomicExchange.test.ts +++ b/control-api/test/routes.externalSessionAtomicExchange.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import express from 'express' import request from 'supertest' import { createExternalAuthRouter } from '../src/routes/external/auth.js' +import { externalSessionDatabaseFailure } from '../src/services/auth/sessionDatabaseFailure.js' const sessions = vi.hoisted(() => ({ authenticateExternalUserSession: vi.fn() })) const revocations = vi.hoisted(() => ({ @@ -205,7 +206,9 @@ describe('legacy external-session replacement exchange', () => { claims.userId, 'user_revoked_all', undefined, - authorityContext + authorityContext, + undefined, + { onDatabaseFailure: externalSessionDatabaseFailure } ) }) @@ -241,7 +244,8 @@ describe('legacy external-session replacement exchange', () => { 'target-session', 'user_revoked', undefined, - authorityContext + authorityContext, + { onDatabaseFailure: externalSessionDatabaseFailure } ) }) @@ -277,7 +281,8 @@ describe('legacy external-session replacement exchange', () => { v2Claims.sid, 'logout', undefined, - authorityContext + authorityContext, + { onDatabaseFailure: externalSessionDatabaseFailure } ) }) }) From 0845c6c721207489e9afeb836b01e7d9c4de07c8 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 29 Oct 2026 02:22:57 +0100 Subject: [PATCH 406/528] test(control-api): send a valid role in the authority-unavailable session-token cases The session-token role check restored from dev rejects a missing role with 400 before the authority lookup runs, so the outage and plain-error cases must send a valid role to reach the code they test. Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- .../test/routes.externalAuth.authorityUnavailable.test.ts | 8 +++++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 2a3097c758..474a6fdc2d 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.860", + "version": "0.1.861", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.860", + "version": "0.1.861", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 824a494105..06f1341f1e 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.860", + "version": "0.1.861", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/routes.externalAuth.authorityUnavailable.test.ts b/control-api/test/routes.externalAuth.authorityUnavailable.test.ts index 0b90a41412..90c999b235 100644 --- a/control-api/test/routes.externalAuth.authorityUnavailable.test.ts +++ b/control-api/test/routes.externalAuth.authorityUnavailable.test.ts @@ -105,7 +105,13 @@ function sessionTokenRequest(overrides: Record = {}) { .post('/external/auth/session-token') .set('x-forwarded-for', '203.0.113.44') .set('x-user-session-token', 'synthetic-source-token') - .send({ userId: v1Claims.userId, email: v1Claims.email, teamId: 'team-1', ...overrides }) + .send({ + userId: v1Claims.userId, + email: v1Claims.email, + teamId: 'team-1', + role: 'admin', + ...overrides, + }) } beforeEach(() => { From 23dcf73f412a7c99467294c7ec8fc341b2fac211 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 29 Oct 2026 02:56:50 +0100 Subject: [PATCH 407/528] test(deploy): isolate the migration bounds fixtures from CI job-name suffixes run-control-api-db-migration.sh appends GITHUB_RUN_ID (or MIGRATION_JOB_SUFFIX) to the Job name, so in GitHub Actions the owner cleanup deleted control-api-db-migrate- and the raw-delete witness, which names the unsuffixed Job, failed. Clear both variables in run_fixture so every fixture sees the same Job name locally and in CI. Co-Authored-By: Claude Opus 5.5 --- scripts/tests/test-control-api-migration-bounds.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/tests/test-control-api-migration-bounds.sh b/scripts/tests/test-control-api-migration-bounds.sh index 48e5a46f33..e89fa535c8 100755 --- a/scripts/tests/test-control-api-migration-bounds.sh +++ b/scripts/tests/test-control-api-migration-bounds.sh @@ -204,7 +204,10 @@ STUB chmod +x "$fixture_root/kubectl" run_fixture() { - TEST_MODE="$1" TEST_JOB_CAPTURE="$capture" TEST_KUBECTL_LOG="$log_file" \ + # The script suffixes the Job name with GITHUB_RUN_ID or MIGRATION_JOB_SUFFIX; + # clear both so the fixtures see the same Job name locally and in CI. + env -u GITHUB_RUN_ID -u MIGRATION_JOB_SUFFIX \ + TEST_MODE="$1" TEST_JOB_CAPTURE="$capture" TEST_KUBECTL_LOG="$log_file" \ PATH="$fixture_root:$PATH" CONTEXT=d34-context ALLOWED_CONTEXTS=d34-context \ bash "$DEPLOY_FILE" --overlay "$overlay" 2>&1 } From 686277c6b3f3cd7c994b6ed66901f12009220cc1 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 29 Oct 2026 03:15:57 +0100 Subject: [PATCH 408/528] fix(minikube): keep a private origin literal out of the CORS reconcile comment The public-boundary scanner rejects a loopback URL with a numeric port in non-test sources. Describe the base manifest's default Profile UI origin in words instead of quoting the URL. Co-Authored-By: Claude Opus 5.5 --- scripts/minikube/sync-external-rest-api-cors-origin.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/minikube/sync-external-rest-api-cors-origin.sh b/scripts/minikube/sync-external-rest-api-cors-origin.sh index 2c7b04eb9c..5be59eac43 100755 --- a/scripts/minikube/sync-external-rest-api-cors-origin.sh +++ b/scripts/minikube/sync-external-rest-api-cors-origin.sh @@ -3,8 +3,9 @@ # Profile UI origin. # # WHY THIS EXISTS -# The base manifest pins EXTERNAL_REST_API_CORS_ORIGIN to http://localhost:3001 -# (deploy/base/profiles/external-rest-api.yaml) while a branch-owned Minikube +# The base manifest pins EXTERNAL_REST_API_CORS_ORIGIN to the default local +# Profile UI origin, port 3001 (deploy/base/profiles/external-rest-api.yaml), +# while a branch-owned Minikube # profile serves Profile UI on a generated port (scripts/minikube-profiles/ # branch-profile.sh: PROFILE_UI_URL). external-rest-api's browser mutation # guard requires the request Origin to be one of the configured origins From b9e11a1828439bfa6d16833c6fa021423a9c091a Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 29 Oct 2026 11:18:44 +0100 Subject: [PATCH 409/528] fix(control-api): answer 401 for rejected Google identities The Google verifier refuses a token without an email or with an unverified email, but threw a plain Error, so google-login answered a sanitized 500 for a login the server deliberately refused. Throw GoogleIdentityRejectedError for those two cases and answer 401 {"error":"Unauthorized"}. Errors raised by verifyIdToken (malformed, expired, wrong audience, certificate fetch) keep the existing 500 path, as on dev. Co-Authored-By: DeepSeek V4.1 Flash Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- control-api/src/routes/external/auth.ts | 15 ++- control-api/src/utils/auth/googleAuth.ts | 19 ++- control-api/test/googleAuth.test.ts | 26 ++++- ....externalAuth.googleLoginRejection.test.ts | 110 ++++++++++++++++++ control-api/test/routes.profile.test.ts | 3 +- 7 files changed, 168 insertions(+), 11 deletions(-) create mode 100644 control-api/test/routes.externalAuth.googleLoginRejection.test.ts diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 474a6fdc2d..d8e543510b 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.861", + "version": "0.1.862", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.861", + "version": "0.1.862", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 06f1341f1e..a5e87f2f58 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.861", + "version": "0.1.862", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/routes/external/auth.ts b/control-api/src/routes/external/auth.ts index 156f1b9bfb..6fcf44eacd 100644 --- a/control-api/src/routes/external/auth.ts +++ b/control-api/src/routes/external/auth.ts @@ -43,7 +43,7 @@ import { googleLoginData, requestProfilePasswordReset, } from '../../services/directory/index.js' -import { verifyGoogleIdToken } from '../../utils/auth/googleAuth.js' +import { GoogleIdentityRejectedError, verifyGoogleIdToken } from '../../utils/auth/googleAuth.js' import { SANDBOX_UI_RPC_HOST_REF, classifyRpcTokenDenial, @@ -171,7 +171,18 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { try { const idToken = String(req.body?.idToken || '').trim() if (!idToken) return res.status(400).json({ error: 'idToken is required' }) - const google = await verifyGoogleIdToken(idToken) + let google: Awaited> + try { + google = await verifyGoogleIdToken(idToken) + } catch (error) { + // Only the verifier's own identity rejections are a client-facing + // credential failure. Library verification errors keep the sanitized + // 500 path through the outer catch. + if (error instanceof GoogleIdentityRejectedError) { + return res.status(401).json({ error: 'Unauthorized' }) + } + throw error + } const policy = await resolveEffectiveUserAccessPolicy() const login = await googleLoginData({ email: google.email, diff --git a/control-api/src/utils/auth/googleAuth.ts b/control-api/src/utils/auth/googleAuth.ts index f0e031fb43..41e749862d 100644 --- a/control-api/src/utils/auth/googleAuth.ts +++ b/control-api/src/utils/auth/googleAuth.ts @@ -9,6 +9,21 @@ export type VerifiedGoogleProfile = { picture?: string } +/** + * A Google identity that this verifier itself refuses to authenticate after + * the ID token verified. Callers answer it as a rejected credential (401). + * + * Errors raised by `verifyIdToken` (malformed, expired or wrong-audience + * tokens, certificate fetch failures) are deliberately not wrapped: they stay + * plain errors and keep the existing sanitized 500 path. + */ +export class GoogleIdentityRejectedError extends Error { + constructor(message: string) { + super(message) + this.name = 'GoogleIdentityRejectedError' + } +} + export async function verifyGoogleIdToken(idToken: string): Promise { const ticket = await googleClient.verifyIdToken({ idToken, @@ -16,10 +31,10 @@ export async function verifyGoogleIdToken(idToken: string): Promise ({ verifyIdToken: vi.fn() })) @@ -42,8 +42,28 @@ describe('verifyGoogleIdToken', () => { }), }) - await expect(verifyGoogleIdToken('unverified-token')).rejects.toThrow( - 'Google token email is not verified' + const rejection = verifyGoogleIdToken('unverified-token') + await expect(rejection).rejects.toBeInstanceOf(GoogleIdentityRejectedError) + await expect(rejection).rejects.toThrow('Google token email is not verified') + }) + + it('rejects a verified payload without an email', async () => { + googleMock.verifyIdToken.mockResolvedValue({ + getPayload: () => ({ email_verified: true }), + }) + + await expect(verifyGoogleIdToken('emailless-token')).rejects.toBeInstanceOf( + GoogleIdentityRejectedError + ) + }) + + it('leaves library verification errors as plain errors', async () => { + const libraryError = new Error('Wrong number of segments in token: secret-token') + googleMock.verifyIdToken.mockRejectedValue(libraryError) + + await expect(verifyGoogleIdToken('malformed-token')).rejects.toBe(libraryError) + await expect(verifyGoogleIdToken('malformed-token')).rejects.not.toBeInstanceOf( + GoogleIdentityRejectedError ) }) }) diff --git a/control-api/test/routes.externalAuth.googleLoginRejection.test.ts b/control-api/test/routes.externalAuth.googleLoginRejection.test.ts new file mode 100644 index 0000000000..bfa49ebb58 --- /dev/null +++ b/control-api/test/routes.externalAuth.googleLoginRejection.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import request from 'supertest' +import { clerumErrorHandler } from '../src/http/errorHandler.js' +import { createExternalAuthRouter } from '../src/routes/external/auth.js' + +const googleVerify = vi.hoisted(() => ({ verifyIdToken: vi.fn() })) + +vi.mock('google-auth-library', () => ({ + OAuth2Client: class { + verifyIdToken = googleVerify.verifyIdToken + }, +})) + +const directory = vi.hoisted(() => ({ + getTeamAgents: vi.fn(), + getUserAgents: vi.fn(), + googleLoginData: vi.fn(), + requestProfilePasswordReset: vi.fn(), +})) +const database = vi.hoisted(() => ({ query: vi.fn() })) + +vi.mock('../src/services/directory/index.js', () => directory) +vi.mock('../src/db.js', () => ({ + pool: { query: (...args: unknown[]) => database.query(...args) }, + withTransaction: vi.fn(), +})) +vi.mock('../src/services/access/userAccessRuntimePolicy.js', () => ({ + resolveEffectiveUserAccessPolicy: vi.fn(async () => ({ + issueV1: true, + issueV2: true, + acceptV1: true, + acceptV2: true, + })), +})) + +function buildApp() { + const app = express() + app.use(express.json()) + app.use(createExternalAuthRouter({} as never)) + app.use(clerumErrorHandler) + return app +} + +function googleLogin(idToken: string) { + return request(buildApp()).post('/external/auth/google-login').send({ idToken }) +} + +describe('POST /external/auth/google-login credential rejections', () => { + beforeEach(() => { + googleVerify.verifyIdToken.mockReset() + Object.values(directory).forEach(fn => fn.mockReset()) + }) + + it('answers 401 Unauthorized when Google has not verified the email', async () => { + googleVerify.verifyIdToken.mockResolvedValue({ + getPayload: () => ({ email: 'user@example.invalid', email_verified: false }), + }) + + const response = await googleLogin('unverified-token') + + expect(response.status).toBe(401) + expect(response.body).toEqual({ error: 'Unauthorized' }) + expect(directory.googleLoginData).not.toHaveBeenCalled() + }) + + it('answers 401 Unauthorized when the verified payload has no email', async () => { + googleVerify.verifyIdToken.mockResolvedValue({ + getPayload: () => ({ email_verified: true }), + }) + + const response = await googleLogin('emailless-token') + + expect(response.status).toBe(401) + expect(response.body).toEqual({ error: 'Unauthorized' }) + expect(directory.googleLoginData).not.toHaveBeenCalled() + }) + + it('keeps library verification errors on the sanitized 500 path', async () => { + googleVerify.verifyIdToken.mockRejectedValue(new Error('Wrong number of segments in token')) + + const response = await googleLogin('malformed-token') + + expect(response.status).toBe(500) + expect(response.body.error).toBe('Internal Server Error') + expect(typeof response.body.correlationId).toBe('string') + expect(directory.googleLoginData).not.toHaveBeenCalled() + }) + + it('does not answer 401 for a verified Google identity', async () => { + googleVerify.verifyIdToken.mockResolvedValue({ + getPayload: () => ({ + email: 'User@Example.invalid', + email_verified: true, + name: 'User', + }), + }) + directory.googleLoginData.mockResolvedValue({ error: 'user_retired' }) + + const response = await googleLogin('verified-token') + + expect(response.status).toBe(403) + expect(response.body).toEqual({ error: 'membership_not_found' }) + expect(directory.googleLoginData).toHaveBeenCalledWith({ + email: 'user@example.invalid', + name: 'User', + picture: undefined, + }) + }) +}) diff --git a/control-api/test/routes.profile.test.ts b/control-api/test/routes.profile.test.ts index fd171922d1..8b836eba6d 100644 --- a/control-api/test/routes.profile.test.ts +++ b/control-api/test/routes.profile.test.ts @@ -40,6 +40,7 @@ const rpcMock = vi.hoisted(() => ({ const googleAuthMock = vi.hoisted(() => ({ verifyGoogleIdToken: vi.fn(), + GoogleIdentityRejectedError: class GoogleIdentityRejectedError extends Error {}, })) const sandboxUiScopeMock = vi.hoisted(() => ({ @@ -155,7 +156,7 @@ describe('routes/profile', () => { beforeEach(() => { Object.values(svc).forEach(fn => fn.mockReset()) Object.values(rpcMock).forEach(fn => fn.mockReset()) - Object.values(googleAuthMock).forEach(fn => fn.mockReset()) + googleAuthMock.verifyGoogleIdToken.mockReset() Object.values(sandboxUiScopeMock).forEach(fn => fn.mockReset()) Object.values(invitationFlowRegistrationMock).forEach(fn => fn.mockReset()) Object.values(passwordAuthMock).forEach(fn => fn.mockReset()) From eecc120798acbb012deca287174a702c782f48fb Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 29 Oct 2026 11:18:45 +0100 Subject: [PATCH 410/528] fix(external-rest-api): map a refused Google identity like a retired account control-api now answers 401 {"error":"Unauthorized"} when it refuses the Google identity itself. The edge only translated the retired-account 403, so this 401 reached the sanitizer as invalid_session. Answer it with the same 401 {"error":"Unauthorized"} body as a retired account; any other 401 keeps the sanitized public envelope. Co-Authored-By: Claude Opus 5.5 --- external-rest-api/package-lock.json | 4 +-- external-rest-api/package.json | 2 +- external-rest-api/src/releaseManifest.ts | 2 +- external-rest-api/src/routes/auth.ts | 13 +++++++++ external-rest-api/test/routes.auth.test.ts | 31 ++++++++++++++++++++++ 5 files changed, 48 insertions(+), 4 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 2a709a8fb2..1a9e1824b5 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.159", + "version": "0.1.160", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.159", + "version": "0.1.160", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index b78087a2be..9fcf18b33c 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.159", + "version": "0.1.160", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index 9d012f7061..30eceec143 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.12.0', - externalRestApiVersion: '0.1.159', + externalRestApiVersion: '0.1.160', rpcProxyVersion: '0.1.137', desktopVersion: '0.12.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/src/routes/auth.ts b/external-rest-api/src/routes/auth.ts index 4aada60ad1..0d79c2eba8 100644 --- a/external-rest-api/src/routes/auth.ts +++ b/external-rest-api/src/routes/auth.ts @@ -38,6 +38,15 @@ function isRetiredAccountLoginDenial(error: unknown): error is ControlApiError { return 'error' in error.body && error.body.error === 'membership_not_found' } +// control-api answers 401 Unauthorized when it refuses the Google identity itself +// (no email, or an unverified email). +function isRejectedGoogleIdentity(error: unknown): error is ControlApiError { + if (!isControlApiStatus(error, 401) || !error.body || typeof error.body !== 'object') { + return false + } + return 'error' in error.body && error.body.error === 'Unauthorized' +} + type LoginResponse = { token: string me: unknown @@ -98,6 +107,10 @@ export function createAuthRouter(): Router { res.status(403).json({ error: 'membership_not_found' }) return } + if (isRejectedGoogleIdentity(error)) { + res.status(401).json({ error: 'Unauthorized' }) + return + } next(error) } }) diff --git a/external-rest-api/test/routes.auth.test.ts b/external-rest-api/test/routes.auth.test.ts index 76fe337520..be07b28496 100644 --- a/external-rest-api/test/routes.auth.test.ts +++ b/external-rest-api/test/routes.auth.test.ts @@ -81,6 +81,37 @@ describe('routes/auth password-login', () => { expect(res.body).toEqual({ error: 'Unauthorized' }) }) + it('answers a Google identity refused by control-api like a retired account', async () => { + authServiceMock.loginWithGoogle.mockReset() + authServiceMock.loginWithGoogle.mockRejectedValueOnce( + new ControlApiError('private upstream detail', 401, { error: 'Unauthorized' }) + ) + + const res = await request(buildApp()) + .post('/api/v1/auth/google') + .send({ idToken: 'validly-shaped-test-token' }) + + expect(authServiceMock.loginWithGoogle).toHaveBeenCalledTimes(1) + expect(res.status).toBe(401) + expect(res.body).toEqual({ error: 'Unauthorized' }) + }) + + it('keeps any other Google login 401 on the sanitized public envelope', async () => { + authServiceMock.loginWithGoogle.mockReset() + authServiceMock.loginWithGoogle.mockRejectedValueOnce( + new ControlApiError('private upstream detail', 401, { error: 'session_revoked' }) + ) + + const res = await request(buildApp()) + .post('/api/v1/auth/google') + .send({ idToken: 'validly-shaped-test-token' }) + + expect(authServiceMock.loginWithGoogle).toHaveBeenCalledTimes(1) + expect(res.status).toBe(401) + expect(res.body).not.toEqual({ error: 'Unauthorized' }) + expect(res.body.error).toEqual(expect.objectContaining({ code: expect.any(String) })) + }) + it('keeps password setup state opaque on password-login', async () => { authServiceMock.loginWithPassword.mockRejectedValueOnce( new ControlApiError('control-api error (409)', 409, { error: 'password_not_set' }) From 503d05f491e4b00ff9009320fa3c56aad5df5bcc Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 29 Oct 2026 17:26:21 +0100 Subject: [PATCH 411/528] fix(control-api): prelock approval archive subjects before the batch delete Approval archive batches deleted their rows without holding the batch's revision subjects first, so a concurrent run write could take the same team and user revision rows in the opposite order and deadlock (40P01). Add authorization_prelock_workflow_approval_revisions(UUID[]), which mirrors the run prelock: it takes the parent teams then users FOR KEY SHARE, and then bumps the distinct subjects of the approval targets plus, for runs that reference the batch, their team, usage team and user attribution. The DELETE cascade rewrites those runs, so their subjects belong to the same batch. The function is SECURITY DEFINER with the run prelock's search_path, is revoked from PUBLIC and granted to control_api_runtime only. Call it in the archive transaction after the empty-batch return and before the first INSERT. Contract tests cover the definer list, the exact runtime EXECUTE list and its grantee, the call order and the empty batch. The real PostgreSQL interleaving test archives a user-targeted and a team-targeted approval beside updateRunPhase on the same team and user: it deadlocks with 40P01 when the prelock call is removed. Refs: PR #334 review r2, RP-3. --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../access/userAccessFoundationSchema.ts | 83 ++++++ .../userApprovalRequestArchiveService.ts | 6 + ...LockOrder.realPostgres.integration.test.ts | 267 +++++++++++++++++- ...composableCatalogRevisionMigration.test.ts | 11 + ....realPostgresMigration.integration.test.ts | 23 +- .../test/migrationExecutionPolicy.test.ts | 4 +- ...ervices.userApprovalRequestArchive.test.ts | 35 +++ 9 files changed, 425 insertions(+), 10 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index d8e543510b..a99d0ba7d8 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.862", + "version": "0.1.863", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.862", + "version": "0.1.863", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index a5e87f2f58..0e02d56fbc 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.862", + "version": "0.1.863", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/access/userAccessFoundationSchema.ts b/control-api/src/services/access/userAccessFoundationSchema.ts index ee57c6d654..770f2d7e1f 100644 --- a/control-api/src/services/access/userAccessFoundationSchema.ts +++ b/control-api/src/services/access/userAccessFoundationSchema.ts @@ -309,6 +309,86 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis END; $$; + -- Approval batches delete rows whose FK cascade also updates every live run + -- that references them, so the subject set covers both the approval targets + -- and those runs' attribution. Both phases mirror the run prelock. + CREATE OR REPLACE FUNCTION authorization_prelock_workflow_approval_revisions( + target_request_ids UUID[] + ) + RETURNS VOID + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = pg_catalog, public, pg_temp + AS $$ + DECLARE revision RECORD; + BEGIN + -- Parent locks must precede revisions and the FK checks of the bumped + -- rows, matching principal deletion's parent-before-cascade order. + PERFORM team.id + FROM teams AS team + WHERE team.id::TEXT IN ( + SELECT approval.target_team_id::TEXT + FROM workflow_approval_requests AS approval + WHERE approval.id = ANY(target_request_ids) + UNION + SELECT run.team_id::TEXT FROM workflow_runs AS run + WHERE run.approval_request_id = ANY(target_request_ids) + UNION + -- Keep malformed usage IDs for the existing subject validator below. + SELECT lower(run.usage_team_id) FROM workflow_runs AS run + WHERE run.approval_request_id = ANY(target_request_ids) + AND run.usage_team_id <> 'control-plane-admin-ui' + ) + ORDER BY team.id + FOR KEY SHARE; + + PERFORM principal.id + FROM users AS principal + WHERE principal.id IN ( + SELECT approval.target_user_id + FROM workflow_approval_requests AS approval + WHERE approval.id = ANY(target_request_ids) + UNION + SELECT run.actor_id FROM workflow_runs AS run + WHERE run.approval_request_id = ANY(target_request_ids) AND run.actor_type = 'user' + ) + ORDER BY principal.id + FOR KEY SHARE; + + -- A batch must hold every team before any user, across the approval + -- target, the referencing runs, and both the archive INSERT and live + -- DELETE triggers. Bumping also creates missing rows. + FOR revision IN + SELECT DISTINCT batch.subject_type, batch.subject_id + FROM ( + SELECT 'team'::TEXT AS subject_type, approval.target_team_id::TEXT AS subject_id + FROM workflow_approval_requests AS approval + WHERE approval.id = ANY(target_request_ids) + UNION ALL + SELECT 'user'::TEXT, approval.target_user_id::TEXT + FROM workflow_approval_requests AS approval + WHERE approval.id = ANY(target_request_ids) + UNION ALL + SELECT subjects.subject_type, subjects.subject_id + FROM workflow_runs AS run + CROSS JOIN LATERAL (VALUES + ('team', run.team_id::TEXT), + ('team', CASE + WHEN run.usage_team_id = 'control-plane-admin-ui' THEN NULL + ELSE run.usage_team_id + END), + ('user', CASE WHEN run.actor_type = 'user' THEN run.actor_id::TEXT END) + ) AS subjects(subject_type, subject_id) + WHERE run.approval_request_id = ANY(target_request_ids) + ) AS batch(subject_type, subject_id) + WHERE batch.subject_id IS NOT NULL AND batch.subject_id <> '' + ORDER BY batch.subject_type, batch.subject_id + LOOP + PERFORM authorization_bump_subject_revision(revision.subject_type, revision.subject_id); + END LOOP; + END; + $$; + CREATE OR REPLACE FUNCTION authorization_bump_user_row_revision() RETURNS TRIGGER LANGUAGE plpgsql @@ -617,6 +697,9 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis REVOKE ALL ON FUNCTION authorization_prelock_workflow_run_revisions(UUID[]) FROM PUBLIC; GRANT EXECUTE ON FUNCTION authorization_prelock_workflow_run_revisions(UUID[]) TO control_api_runtime; + REVOKE ALL ON FUNCTION authorization_prelock_workflow_approval_revisions(UUID[]) FROM PUBLIC; + GRANT EXECUTE ON FUNCTION authorization_prelock_workflow_approval_revisions(UUID[]) + TO control_api_runtime; REVOKE ALL ON FUNCTION authorization_bump_user_row_revision() FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_team_row_revision() FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_workflow_run_revision() FROM PUBLIC; diff --git a/control-api/src/services/userApprovalRequestArchiveService.ts b/control-api/src/services/userApprovalRequestArchiveService.ts index ec441c0ff1..660c30c94b 100644 --- a/control-api/src/services/userApprovalRequestArchiveService.ts +++ b/control-api/src/services/userApprovalRequestArchiveService.ts @@ -87,6 +87,12 @@ async function archiveOneBatch(olderThanDays: number, batchSize: number): Promis const ids = selected.rows.map(r => (r as { id: string }).id) + // Deadlock prevention: the DELETE below cascades to every live run that + // references an archived approval, and each trigger takes its subjects in + // table order. Hold the batch's whole subject set (teams before users) + // before the first write so a concurrent run writer cannot invert it. + await db.query('SELECT authorization_prelock_workflow_approval_revisions($1::uuid[])', [ids]) + // Copy into archive. Schema columns mirror workflow_approval_requests // plus archived_at (defaulted to NOW() in DDL). Use ON CONFLICT DO NOTHING // so a retry after partial failure is idempotent on PK collisions. diff --git a/control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts b/control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts index 59501c78d7..2b67f52642 100644 --- a/control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts +++ b/control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts @@ -3,6 +3,8 @@ import { randomBytes, randomUUID } from 'node:crypto' import { Pool, type PoolClient } from 'pg' import { initDb, pool } from '../src/db.js' import type { K8sGateway } from '../src/k8s.js' +import { archiveTerminalApprovals } from '../src/services/userApprovalRequestArchiveService.js' +import { expirePendingRequests } from '../src/services/userApprovalRequestService.js' import { updateRunPhase } from '../src/services/workflowRunService.js' import { archiveTerminalRuns } from '../src/services/workflowRunsArchiveService.js' import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' @@ -56,7 +58,9 @@ describeRealPostgres('archive batches and phase updates share revision lock orde DECLARE barrier RECORD; BEGIN IF current_setting('application_name') LIKE 'archive-delete-%' - OR current_setting('application_name') LIKE 'delete-principal-%' THEN + OR current_setting('application_name') LIKE 'delete-principal-%' + OR current_setting('application_name') LIKE 'approval-archive-%' + OR current_setting('application_name') LIKE 'approval-expiry-%' THEN INSERT INTO archive_delete_revision_observations (application_name, revision_table, subject_id, revision) VALUES (current_setting('application_name'), TG_TABLE_NAME, @@ -161,6 +165,16 @@ describeRealPostgres('archive batches and phase updates share revision lock orde return (await client.query('SELECT pg_backend_pid() AS pid')).rows[0].pid } + // Bind the real archive/expiry service to one configured session for its + // first transaction; later drain batches get ordinary pooled connections. + function bindFirstSession(session: PoolClient) { + let connects = 0 + return vi.spyOn(pool, 'connect').mockImplementation(() => { + connects += 1 + return connects === 1 ? Promise.resolve(session) : databasePool.connect() + }) + } + async function waitForBlock(blocked: number, blockers: number[]) { const deadline = Date.now() + 5_000 while (Date.now() < deadline) { @@ -216,6 +230,45 @@ describeRealPostgres('archive batches and phase updates share revision lock orde ).toHaveLength(2) } + // One archive batch holding a user-targeted approval and a team-targeted one, + // drained by the real archive sweep. + async function approvalFixture() { + const [userApprovalId, teamApprovalId] = [randomUUID(), randomUUID()].sort() + const user = randomUUID() + const team = randomUUID() + const live = randomUUID() + await databasePool.query('INSERT INTO users(id, email) VALUES ($1, $2)', [ + user, + `approval-order-${user}@example.test`, + ]) + await databasePool.query("INSERT INTO teams(id, name) VALUES ($1, 'Approval order')", [team]) + // The user-targeted approval is first in heap order and lower in id order, + // so both a sequential and an index scan reach its subject first. + const targets = [ + [userApprovalId, 'target_user_id', user], + [teamApprovalId, 'target_team_id', team], + ] as const + for (const [id, column, target] of targets) { + await databasePool.query( + `INSERT INTO workflow_approval_requests( + id, recipe_namespace, recipe_name, expires_at, status, ${column}, + payload, idempotency_key, requested_at, decided_at + ) VALUES ($1, 'revision', 'approval-order', NOW() - INTERVAL '200 days', + 'approved', $2, '{}'::jsonb, $3, NOW() - INTERVAL '200 days', + NOW() - INTERVAL '200 days')`, + [id, target, `approval-order-${id}`] + ) + } + // A live run shares the team and the user, so a phase write takes T then U. + await databasePool.query( + `INSERT INTO workflow_runs(run_id, recipe_namespace, recipe_name, phase, actor_type, + actor_id, team_id, trigger_source) + VALUES ($1, 'revision', 'approval-order', 'Pending', 'user', $2, $3, 'onDemand')`, + [live, user, team] + ) + return { userApprovalId, teamApprovalId, user, team, live } + } + it('archives two rows and advances revisions with a phase-update liveness witness', async () => { const ids = await fixture() const before = await revisions(ids) @@ -584,6 +637,218 @@ describeRealPostgres('archive batches and phase updates share revision lock orde } }) + it('creates missing revisions once per approval-batch subject in team-then-user order', async () => { + const targetUser = randomUUID() + const runActor = randomUUID() + const targetTeam = randomUUID() + const runTeam = randomUUID() + const usageTeam = randomUUID() + const userApproval = randomUUID() + const teamApproval = randomUUID() + const run = randomUUID() + for (const user of [targetUser, runActor]) { + await databasePool.query('INSERT INTO users(id, email) VALUES ($1, $2)', [ + user, + `approval-prelock-${user}@example.test`, + ]) + } + for (const team of [targetTeam, runTeam, usageTeam]) { + await databasePool.query("INSERT INTO teams(id, name) VALUES ($1, 'Approval prelock')", [ + team, + ]) + } + await databasePool.query( + `INSERT INTO workflow_approval_requests( + id, recipe_namespace, recipe_name, expires_at, status, target_user_id, + payload, idempotency_key + ) VALUES ($1, 'revision', 'approval-prelock', NOW() + INTERVAL '1 hour', 'pending', + $2, '{}'::jsonb, $3)`, + [userApproval, targetUser, `approval-prelock-user-${userApproval}`] + ) + await databasePool.query( + `INSERT INTO workflow_approval_requests( + id, recipe_namespace, recipe_name, expires_at, status, target_team_id, + payload, idempotency_key + ) VALUES ($1, 'revision', 'approval-prelock', NOW() + INTERVAL '1 hour', 'pending', + $2, '{}'::jsonb, $3)`, + [teamApproval, targetTeam, `approval-prelock-team-${teamApproval}`] + ) + // The referencing run's attribution joins the approval targets. + await databasePool.query( + `INSERT INTO workflow_runs(run_id, recipe_namespace, recipe_name, phase, actor_type, + actor_id, team_id, usage_team_id, trigger_source, approval_request_id) + VALUES ($1, 'revision', 'approval-prelock', 'Pending', 'user', $2, $3, $4, 'onDemand', $5)`, + [run, runActor, runTeam, usageTeam, userApproval] + ) + const subjects = { + users: [targetUser, runActor].sort(), + teams: [targetTeam, runTeam, usageTeam].sort(), + } + await databasePool.query( + 'DELETE FROM authorization_user_revisions WHERE user_id = ANY($1::uuid[])', + [subjects.users] + ) + await databasePool.query( + 'DELETE FROM authorization_team_revisions WHERE team_id = ANY($1::uuid[])', + [subjects.teams] + ) + await databasePool.query(` + CREATE TABLE approval_prelock_observations ( + position BIGSERIAL, subject_type TEXT, subject_id TEXT + ); + CREATE FUNCTION observe_approval_prelock() RETURNS TRIGGER LANGUAGE plpgsql AS $$ + BEGIN + INSERT INTO approval_prelock_observations(subject_type, subject_id) + VALUES ( + CASE WHEN TG_TABLE_NAME = 'authorization_team_revisions' THEN 'team' ELSE 'user' END, + COALESCE(to_jsonb(NEW)->>'team_id', to_jsonb(NEW)->>'user_id') + ); + RETURN NEW; + END; + $$; + CREATE TRIGGER observe_approval_prelock AFTER INSERT OR UPDATE + ON authorization_team_revisions FOR EACH ROW EXECUTE FUNCTION observe_approval_prelock(); + CREATE TRIGGER observe_approval_prelock AFTER INSERT OR UPDATE + ON authorization_user_revisions FOR EACH ROW EXECUTE FUNCTION observe_approval_prelock(); + `) + const client = await databasePool.connect() + try { + await client.query('BEGIN') + await client.query('SET LOCAL ROLE control_api_runtime') + const expectedOrder = [ + ...subjects.teams.map(subject_id => ({ subject_type: 'team', subject_id })), + ...subjects.users.map(subject_id => ({ subject_type: 'user', subject_id })), + ] + for (const expectedRevision of [1, 2]) { + // The duplicate first id proves the batch is deduplicated. + await client.query('SELECT authorization_prelock_workflow_approval_revisions($1::uuid[])', [ + [userApproval, teamApproval, userApproval, randomUUID()], + ]) + const after = await client.query<{ revision: number }>( + `SELECT revision::int FROM authorization_user_revisions WHERE user_id = ANY($1::uuid[]) + UNION ALL + SELECT revision::int FROM authorization_team_revisions WHERE team_id = ANY($2::uuid[])`, + [subjects.users, subjects.teams] + ) + expect(after.rows).toEqual( + Array.from({ length: 5 }, () => ({ revision: expectedRevision })) + ) + } + await client.query('COMMIT') + // Each call bumps every subject once, teams before users. + const observed = await databasePool.query( + 'SELECT subject_type, subject_id FROM approval_prelock_observations ORDER BY position' + ) + expect(observed.rows).toEqual([...expectedOrder, ...expectedOrder]) + } finally { + await client.query('ROLLBACK') + client.release() + await databasePool.query(` + DROP TRIGGER observe_approval_prelock ON authorization_team_revisions; + DROP TRIGGER observe_approval_prelock ON authorization_user_revisions; + DROP FUNCTION observe_approval_prelock(); + DROP TABLE approval_prelock_observations; + `) + await databasePool.query('DELETE FROM workflow_runs WHERE run_id = $1', [run]) + await databasePool.query( + 'DELETE FROM workflow_approval_requests WHERE id = ANY($1::uuid[])', + [[userApproval, teamApproval]] + ) + await databasePool.query('DELETE FROM users WHERE id = ANY($1::uuid[])', [subjects.users]) + await databasePool.query('DELETE FROM teams WHERE id = ANY($1::uuid[])', [subjects.teams]) + } + }) + + it('commits an approval archive batch beside a run phase write without reversing subject locks', async () => { + const ids = await approvalFixture() + const before = await revisions({ users: [ids.user], teams: [ids.team] }) + const archiveGate = await databasePool.connect() + const archive = await databasePool.connect() + const update = await databasePool.connect() + const archivePid = await pid(archive) + const updatePid = await pid(update) + const gatePid = await pid(archiveGate) + let archiveOutcome: Promise | undefined + let updateOutcome: Promise | undefined + const connect = bindFirstSession(archive) + try { + await configure(archive, 'approval-archive-order') + await configure(update, 'approval-phase-order') + await archiveGate.query('SELECT pg_advisory_lock($1, 21)', [barrierClass]) + await databasePool.query('INSERT INTO archive_order_barriers VALUES ($1, $2, $3, 21)', [ + 'approval-archive-order', + 'authorization_user_revisions', + ids.user, + ]) + archiveOutcome = observe(archiveTerminalApprovals(180, 500)) + // The prelock pauses on the user only after taking the batch's team. + await waitForBlock(archivePid, [gatePid]) + await update.query('BEGIN') + updateOutcome = observe(updateRunPhase(ids.live, 'Running', update)) + // The phase writer takes its team first and waits there, so it can never + // hold the team this batch already prelocked while asking for its user. + await waitForBlock(updatePid, [archivePid]) + await archiveGate.query('SELECT pg_advisory_unlock_all()') + const phaseResult = await updateOutcome + await update.query(phaseResult.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') + const archiveResult = await archiveOutcome + console.info('APPROVAL_ORDER_OUTCOMES', { + archive: archiveResult, + phase: phaseResult, + }) + expect([archiveResult.status, phaseResult.status]).toEqual(['fulfilled', 'fulfilled']) + expect(archiveResult).toEqual({ status: 'fulfilled', value: 2 }) + expect( + (await databasePool.query('SELECT phase FROM workflow_runs WHERE run_id = $1', [ids.live])) + .rows + ).toEqual([{ phase: 'Running' }]) + expect( + ( + await databasePool.query( + 'SELECT id FROM workflow_approval_requests WHERE id = ANY($1::uuid[])', + [[ids.userApprovalId, ids.teamApprovalId]] + ) + ).rows + ).toHaveLength(0) + // The prelock and the DELETE's row trigger bump each subject once; the + // phase UPDATE adds a NEW and an OLD attribution pair. + const after = await revisions({ users: [ids.user], teams: [ids.team] }) + expect(after.user).toEqual([{ id: ids.user, revision: before.user[0]!.revision + 4 }]) + expect(after.team).toEqual([{ id: ids.team, revision: before.team[0]!.revision + 4 }]) + // The prelock takes the team first; the DELETE then meets the + // user-targeted row first, as the fixture intends. + expect( + ( + await databasePool.query( + `SELECT revision_table, subject_id FROM archive_delete_revision_observations + WHERE application_name = $1 ORDER BY position`, + ['approval-archive-order'] + ) + ).rows + ).toEqual([ + { revision_table: 'authorization_team_revisions', subject_id: ids.team }, + { revision_table: 'authorization_user_revisions', subject_id: ids.user }, + { revision_table: 'authorization_user_revisions', subject_id: ids.user }, + { revision_table: 'authorization_team_revisions', subject_id: ids.team }, + ]) + } finally { + await archiveGate.query('SELECT pg_advisory_unlock_all()') + await Promise.all([archiveOutcome, updateOutcome]) + await update.query('ROLLBACK') + connect.mockRestore() + for (const client of [archiveGate, update]) client.release() + if (!archiveOutcome) archive.release() + await databasePool.query('TRUNCATE archive_order_barriers') + await databasePool.query( + 'DELETE FROM workflow_approval_requests WHERE id = ANY($1::uuid[])', + [[ids.userApprovalId, ids.teamApprovalId]] + ) + await databasePool.query('DELETE FROM workflow_runs WHERE run_id = $1', [ids.live]) + await databasePool.query('DELETE FROM users WHERE id = $1', [ids.user]) + await databasePool.query('DELETE FROM teams WHERE id = $1', [ids.team]) + } + }) + it.each(['not-a-uuid', 'CONTROL-PLANE-ADMIN-UI'])( 'keeps P0001 validation for non-sentinel usage team %s', async invalidTeam => { diff --git a/control-api/test/db.composableCatalogRevisionMigration.test.ts b/control-api/test/db.composableCatalogRevisionMigration.test.ts index 429fd32bb4..8afba8d1d0 100644 --- a/control-api/test/db.composableCatalogRevisionMigration.test.ts +++ b/control-api/test/db.composableCatalogRevisionMigration.test.ts @@ -28,6 +28,17 @@ describe('0134 composable catalog revisions migration', () => { expect(sql).toMatch( /GRANT EXECUTE ON FUNCTION authorization_prelock_workflow_run_revisions\(UUID\[\]\)\s+TO control_api_runtime/ ) + // The approval prelock also covers the runs its DELETE cascade rewrites. + expect(sql).toContain( + 'authorization_prelock_workflow_approval_revisions(\n target_request_ids UUID[]\n )' + ) + expect(sql).toContain('run.approval_request_id = ANY(target_request_ids)') + expect(sql).toContain( + 'REVOKE ALL ON FUNCTION authorization_prelock_workflow_approval_revisions(UUID[]) FROM PUBLIC' + ) + expect(sql).toMatch( + /GRANT EXECUTE ON FUNCTION authorization_prelock_workflow_approval_revisions\(UUID\[\]\)\s+TO control_api_runtime/ + ) expect(sql).toContain('authorization_bump_workflow_approval_revision') expect(sql).toContain('authorization_bump_notification_revision') expect(sql).toContain('authorization_bump_gfs_resource_revision') diff --git a/control-api/test/db.realPostgresMigration.integration.test.ts b/control-api/test/db.realPostgresMigration.integration.test.ts index d060a2d211..9c714f0636 100644 --- a/control-api/test/db.realPostgresMigration.integration.test.ts +++ b/control-api/test/db.realPostgresMigration.integration.test.ts @@ -47,6 +47,7 @@ const accessFoundationDefinerSignatures = [ 'authorization_bump_team_revision(pg_catalog.uuid)', 'authorization_bump_subject_revision(pg_catalog.text, pg_catalog.text)', 'authorization_prelock_workflow_run_revisions(pg_catalog.uuid[])', + 'authorization_prelock_workflow_approval_revisions(pg_catalog.uuid[])', 'authorization_bump_user_row_revision()', 'authorization_bump_team_row_revision()', 'authorization_bump_workflow_run_revision()', @@ -899,19 +900,31 @@ describeRealPostgres('control-api real Postgres migrations', () => { ORDER BY routine.proname `) expect(revisionExecute.rows).toEqual([ + { function_name: 'authorization_prelock_workflow_approval_revisions' }, { function_name: 'authorization_prelock_workflow_run_revisions' }, ]) const prelockGrants = await runtimeClient.query<{ grantee: string }>(` - SELECT pg_get_userbyid(privilege.grantee) AS grantee + SELECT routine.proname AS function_name, pg_get_userbyid(privilege.grantee) AS grantee FROM pg_proc routine CROSS JOIN LATERAL aclexplode(routine.proacl) privilege - WHERE routine.oid = - 'public.authorization_prelock_workflow_run_revisions(uuid[])'::regprocedure + WHERE routine.oid IN ( + 'public.authorization_prelock_workflow_run_revisions(uuid[])'::regprocedure, + 'public.authorization_prelock_workflow_approval_revisions(uuid[])'::regprocedure + ) AND privilege.grantee <> routine.proowner AND privilege.privilege_type = 'EXECUTE' - ORDER BY grantee + ORDER BY grantee, function_name `) - expect(prelockGrants.rows).toEqual([{ grantee: 'control_api_runtime' }]) + expect(prelockGrants.rows).toEqual([ + { + function_name: 'authorization_prelock_workflow_approval_revisions', + grantee: 'control_api_runtime', + }, + { + function_name: 'authorization_prelock_workflow_run_revisions', + grantee: 'control_api_runtime', + }, + ]) expect( ( await runtimeClient.query( diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index 19133f3c25..30e902dc96 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -99,10 +99,12 @@ describe('D34 migration execution policy', () => { // made revision tables read-only for the runtime, ordered revision writers, // kept the Admin UI attribution sentinel, added three revision triggers and // a pre-lock for archive batches, now locking parents before revisions. + // RP-3 (PR #334 review r2) added the approval-batch pre-lock, which also + // covers the runs its DELETE cascade rewrites. const files = [ [ 'src/services/access/userAccessFoundationSchema.ts', - 'f345f81dcb274246b0fb86fe345447addd9182d71f3fba140d087a33877a1df9', + 'af67426f87cdfd442499b394f12421b267cc97bdc9e15fb54c9db86102bb2013', ], [ 'src/services/directory/invitationDeliverySchema.ts', diff --git a/control-api/test/services.userApprovalRequestArchive.test.ts b/control-api/test/services.userApprovalRequestArchive.test.ts index 57d176cb08..7c5f270e4d 100644 --- a/control-api/test/services.userApprovalRequestArchive.test.ts +++ b/control-api/test/services.userApprovalRequestArchive.test.ts @@ -40,6 +40,9 @@ const mockTransactionQuery = vi.fn(async (sql: unknown, params?: unknown[]) => { const ids = (params?.[0] as string[]) ?? [] return { rows: [], rowCount: ids.length } } + if (/SELECT authorization_prelock_workflow_approval_revisions/i.test(text)) { + return { rows: [], rowCount: 1 } + } return { rows: [], rowCount: 0 } }) @@ -71,6 +74,38 @@ describe('archiveTerminalApprovals', () => { expect(total).toBe(0) // Only the SELECT should fire (one batch → 0 rows → loop exits). expect(mockTransactionQuery).toHaveBeenCalledTimes(1) + // An empty batch must not take the approval subject prelock. + expect( + mockTransactionQuery.mock.calls.filter(([sql]) => + /authorization_prelock_workflow_approval_revisions/i.test(String(sql)) + ) + ).toHaveLength(0) + }) + + it('takes the approval subject prelock between the SELECT and the first write', async () => { + eligibleRows.push({ id: 'id-a' }, { id: 'id-b' }) + + await archiveTerminalApprovals(180, 500) + + const calls = mockTransactionQuery.mock.calls + const selectIndex = calls.findIndex(([sql]) => + /SELECT id FROM workflow_approval_requests/i.test(String(sql)) + ) + const prelockIndex = calls.findIndex(([sql]) => + /SELECT authorization_prelock_workflow_approval_revisions/i.test(String(sql)) + ) + const firstWriteIndex = calls.findIndex(([sql]) => + /INSERT INTO workflow_approval_requests_archive/i.test(String(sql)) + ) + const deleteIndex = calls.findIndex(([sql]) => + /DELETE FROM workflow_approval_requests/i.test(String(sql)) + ) + + expect(selectIndex).toBe(0) + expect(prelockIndex).toBe(1) + expect(firstWriteIndex).toBeGreaterThan(prelockIndex) + expect(deleteIndex).toBeGreaterThan(firstWriteIndex) + expect(calls[prelockIndex]?.[1]).toEqual([['id-a', 'id-b']]) }) it('archives all eligible rows in a single batch when count <= batchSize', async () => { From 4c084e5cb58a8e3b60c5430036ee6acfd849d8dd Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 29 Oct 2026 17:33:50 +0100 Subject: [PATCH 412/528] fix(control-api): prelock expiring approval subjects inside the sweep expirePendingRequests ran one autocommit UPDATE selected by predicate, so its approval revision trigger met the batch's subjects in physical order while a concurrent run write took the same team and user in the other order: the same deadlock class as the approval archive, every 60 seconds on every replica. Select the expiring ids FOR UPDATE SKIP LOCKED, return early when the set is empty, take the approval prelock with those ids, and then update by id without re-evaluating the predicate, so a row committed after the prelock cannot be picked up with its subjects unlocked. One transaction holds the locks; the metric and log run after it commits, so they count committed rows only. Refs: PR #334 review r2, RP-3 sibling C5. --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../services/userApprovalRequestService.ts | 37 +++-- ...LockOrder.realPostgres.integration.test.ts | 128 ++++++++++++++++++ ...ervices.userApprovalRequestService.test.ts | 43 +++++- 5 files changed, 198 insertions(+), 16 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index a99d0ba7d8..c8707a83ba 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.863", + "version": "0.1.864", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.863", + "version": "0.1.864", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 0e02d56fbc..fa9f8b8ee7 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.863", + "version": "0.1.864", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/userApprovalRequestService.ts b/control-api/src/services/userApprovalRequestService.ts index 3bd421eaec..f563fe145d 100644 --- a/control-api/src/services/userApprovalRequestService.ts +++ b/control-api/src/services/userApprovalRequestService.ts @@ -1330,14 +1330,35 @@ export async function assertApprovalTriggerBinding(params: { } export async function expirePendingRequests(): Promise { - const result = await pool.query( - `UPDATE workflow_approval_requests - SET status = 'expired', - decided_at = COALESCE(decided_at, NOW()) - WHERE status = 'pending' AND expires_at < NOW() - RETURNING id` - ) - const expired = result.rowCount ?? 0 + const expired = await withTransaction(async db => { + // Select the sweep's rows first: the UPDATE then has a fixed id set, so it + // cannot pick up rows committed after the prelock by re-evaluating the + // predicate under a newer snapshot. SKIP LOCKED lets a second replica, or a + // concurrent decision, keep its rows for the next tick. + const selected = await db.query( + `SELECT id FROM workflow_approval_requests + WHERE status = 'pending' AND expires_at < NOW() + FOR UPDATE SKIP LOCKED` + ) + if ((selected.rowCount ?? 0) === 0) return 0 + + const ids = selected.rows.map(row => (row as { id: string }).id) + + // Same ordered prelock as the approval archive: the UPDATE fires the + // approval revision trigger per row in scan order, so hold the whole batch + // (teams before users) before the first write. + await db.query('SELECT authorization_prelock_workflow_approval_revisions($1::uuid[])', [ids]) + + const updated = await db.query( + `UPDATE workflow_approval_requests + SET status = 'expired', + decided_at = COALESCE(decided_at, NOW()) + WHERE id = ANY($1::uuid[]) + RETURNING id`, + [ids] + ) + return updated.rowCount ?? 0 + }) if (expired > 0) { approvalsExpiredTotal.inc(expired) rootLogger.info( diff --git a/control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts b/control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts index 2b67f52642..4e15c5570d 100644 --- a/control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts +++ b/control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts @@ -269,6 +269,41 @@ describeRealPostgres('archive batches and phase updates share revision lock orde return { userApprovalId, teamApprovalId, user, team, live } } + // Two pending approvals that the expiry sweep already considers expired: the + // user-targeted row is first in heap order and lower in id order. + async function expiryFixture() { + const [userApprovalId, teamApprovalId] = [randomUUID(), randomUUID()].sort() + const user = randomUUID() + const team = randomUUID() + const live = randomUUID() + await databasePool.query('INSERT INTO users(id, email) VALUES ($1, $2)', [ + user, + `approval-expiry-${user}@example.test`, + ]) + await databasePool.query("INSERT INTO teams(id, name) VALUES ($1, 'Approval expiry')", [team]) + const targets = [ + [userApprovalId, 'target_user_id', user], + [teamApprovalId, 'target_team_id', team], + ] as const + for (const [id, column, target] of targets) { + await databasePool.query( + `INSERT INTO workflow_approval_requests( + id, recipe_namespace, recipe_name, expires_at, status, ${column}, + payload, idempotency_key, requested_at + ) VALUES ($1, 'revision', 'approval-expiry', NOW() - INTERVAL '1 hour', + 'pending', $2, '{}'::jsonb, $3, NOW() - INTERVAL '2 hours')`, + [id, target, `approval-expiry-${id}`] + ) + } + await databasePool.query( + `INSERT INTO workflow_runs(run_id, recipe_namespace, recipe_name, phase, actor_type, + actor_id, team_id, trigger_source) + VALUES ($1, 'revision', 'approval-expiry', 'Pending', 'user', $2, $3, 'onDemand')`, + [live, user, team] + ) + return { userApprovalId, teamApprovalId, user, team, live } + } + it('archives two rows and advances revisions with a phase-update liveness witness', async () => { const ids = await fixture() const before = await revisions(ids) @@ -849,6 +884,99 @@ describeRealPostgres('archive batches and phase updates share revision lock orde } }) + it('expires pending approvals beside a run phase write without reversing subject locks', async () => { + const ids = await expiryFixture() + const before = await revisions({ users: [ids.user], teams: [ids.team] }) + const sweepGate = await databasePool.connect() + const sweep = await databasePool.connect() + const update = await databasePool.connect() + const sweepPid = await pid(sweep) + const updatePid = await pid(update) + const gatePid = await pid(sweepGate) + let sweepOutcome: Promise | undefined + let updateOutcome: Promise | undefined + const connect = bindFirstSession(sweep) + try { + await configure(sweep, 'approval-expiry-sweep') + await configure(update, 'approval-phase-order') + // The sweep pauses on the user revision, after taking its team. + await sweepGate.query('SELECT pg_advisory_lock($1, 22)', [barrierClass]) + await databasePool.query('INSERT INTO archive_order_barriers VALUES ($1, $2, $3, 22)', [ + 'approval-expiry-sweep', + 'authorization_user_revisions', + ids.user, + ]) + sweepOutcome = observe(expirePendingRequests()) + await waitForBlock(sweepPid, [gatePid]) + await update.query('BEGIN') + updateOutcome = observe(updateRunPhase(ids.live, 'Running', update)) + // The phase writer takes its team first and waits there, so the sweep + // never has to wait for the user it already holds. + await waitForBlock(updatePid, [sweepPid]) + await sweepGate.query('SELECT pg_advisory_unlock_all()') + const phaseResult = await updateOutcome + await update.query(phaseResult.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') + const sweepResult = await sweepOutcome + console.info('APPROVAL_EXPIRY_OUTCOMES', { sweep: sweepResult, phase: phaseResult }) + expect([sweepResult.status, phaseResult.status]).toEqual(['fulfilled', 'fulfilled']) + expect(sweepResult).toEqual({ status: 'fulfilled', value: 2 }) + expect( + ( + await databasePool.query( + `SELECT id::text AS id, status FROM workflow_approval_requests + WHERE id = ANY($1::uuid[]) ORDER BY id`, + [[ids.userApprovalId, ids.teamApprovalId]] + ) + ).rows + ).toEqual([ + { id: ids.userApprovalId, status: 'expired' }, + { id: ids.teamApprovalId, status: 'expired' }, + ]) + expect( + (await databasePool.query('SELECT phase FROM workflow_runs WHERE run_id = $1', [ids.live])) + .rows + ).toEqual([{ phase: 'Running' }]) + // The prelock takes the team first; the UPDATE then meets the + // user-targeted row first and bumps both sides of its attribution. + expect( + ( + await databasePool.query( + `SELECT revision_table, subject_id FROM archive_delete_revision_observations + WHERE application_name = $1 ORDER BY position`, + ['approval-expiry-sweep'] + ) + ).rows + ).toEqual([ + { revision_table: 'authorization_team_revisions', subject_id: ids.team }, + { revision_table: 'authorization_user_revisions', subject_id: ids.user }, + { revision_table: 'authorization_user_revisions', subject_id: ids.user }, + { revision_table: 'authorization_user_revisions', subject_id: ids.user }, + { revision_table: 'authorization_team_revisions', subject_id: ids.team }, + { revision_table: 'authorization_team_revisions', subject_id: ids.team }, + ]) + // Prelock plus the UPDATE's NEW and OLD attribution pairs, plus the + // phase UPDATE's own NEW and OLD pair. + const after = await revisions({ users: [ids.user], teams: [ids.team] }) + expect(after.user).toEqual([{ id: ids.user, revision: before.user[0]!.revision + 5 }]) + expect(after.team).toEqual([{ id: ids.team, revision: before.team[0]!.revision + 5 }]) + } finally { + await sweepGate.query('SELECT pg_advisory_unlock_all()') + await Promise.all([sweepOutcome, updateOutcome]) + await update.query('ROLLBACK') + connect.mockRestore() + for (const client of [sweepGate, update]) client.release() + if (!sweepOutcome) sweep.release() + await databasePool.query('TRUNCATE archive_order_barriers') + await databasePool.query( + 'DELETE FROM workflow_approval_requests WHERE id = ANY($1::uuid[])', + [[ids.userApprovalId, ids.teamApprovalId]] + ) + await databasePool.query('DELETE FROM workflow_runs WHERE run_id = $1', [ids.live]) + await databasePool.query('DELETE FROM users WHERE id = $1', [ids.user]) + await databasePool.query('DELETE FROM teams WHERE id = $1', [ids.team]) + } + }) + it.each(['not-a-uuid', 'CONTROL-PLANE-ADMIN-UI'])( 'keeps P0001 validation for non-sentinel usage team %s', async invalidTeam => { diff --git a/control-api/test/services.userApprovalRequestService.test.ts b/control-api/test/services.userApprovalRequestService.test.ts index 96db9eb13f..246f3c8c50 100644 --- a/control-api/test/services.userApprovalRequestService.test.ts +++ b/control-api/test/services.userApprovalRequestService.test.ts @@ -1441,13 +1441,46 @@ describe('userApprovalRequestService', () => { }) describe('expirePendingRequests', () => { - it('returns count of expired requests', async () => { - mockedQuery.mockResolvedValueOnce({ - rows: [{ id: 'id-1' }, { id: 'id-2' }], - rowCount: 2, - } as any) + it('expires the selected rows in SELECT, prelock and UPDATE order', async () => { + mockedQuery + .mockResolvedValueOnce({ rows: [{ id: 'id-1' }, { id: 'id-2' }], rowCount: 2 } as any) + .mockResolvedValueOnce({ rows: [], rowCount: 1 } as any) + .mockResolvedValueOnce({ rows: [{ id: 'id-1' }, { id: 'id-2' }], rowCount: 2 } as any) + const count = await expirePendingRequests() + expect(count).toBe(2) + expect(mockedWithTransaction).toHaveBeenCalledTimes(1) + const statements = mockedQuery.mock.calls.map(([sql]) => String(sql)) + expect(statements).toHaveLength(3) + + // The sweep must not re-evaluate its predicate in the UPDATE. + expect(statements[0]).toContain('FROM workflow_approval_requests') + expect(statements[0]).toContain("status = 'pending'") + expect(statements[0]).toContain('expires_at < NOW()') + expect(statements[0]).toContain('FOR UPDATE SKIP LOCKED') + expect(statements[0]).not.toMatch(/\bLIMIT\b/) + + expect(statements[1]).toBe( + 'SELECT authorization_prelock_workflow_approval_revisions($1::uuid[])' + ) + expect(mockedQuery.mock.calls[1]?.[1]).toEqual([['id-1', 'id-2']]) + + expect(statements[2]).toContain('UPDATE workflow_approval_requests') + expect(statements[2]).toContain("SET status = 'expired'") + expect(statements[2]).toContain('WHERE id = ANY($1::uuid[])') + expect(statements[2]).not.toContain("status = 'pending'") + expect(mockedQuery.mock.calls[2]?.[1]).toEqual([['id-1', 'id-2']]) + }) + + it('returns zero without prelock or UPDATE when nothing is expiring', async () => { + mockedQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 } as any) + + await expect(expirePendingRequests()).resolves.toBe(0) + + expect(mockedWithTransaction).toHaveBeenCalledTimes(1) + expect(mockedQuery).toHaveBeenCalledTimes(1) + expect(String(mockedQuery.mock.calls[0]?.[0])).toContain('FOR UPDATE SKIP LOCKED') }) }) }) From f461b107247467d63328ad7067f7dc84afa8eb20 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 29 Oct 2026 17:41:58 +0100 Subject: [PATCH 413/528] fix(control-api): narrow session revocation grants to select and insert control_api_runtime held UPDATE and DELETE on external_v1_session_revocations, but its only writer is the append in userSessionService; the user-deletion cascade runs as the table owner. Grant SELECT and INSERT only and record the append profile in the deployment access contract, so the runtime cannot rewrite or erase revocation history. The real PostgreSQL migration suite runs the deployment verifier against the migrated schema and now witnesses the runtime appending a revocation while its UPDATE and DELETE answer 42501. Refs: PR #334 review r2, RP-12. --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../access/userAccessFoundationSchema.ts | 5 +- ....realPostgresMigration.integration.test.ts | 49 +++++++++++++++++++ .../db.userAccessFoundationMigration.test.ts | 2 +- .../test/migrationExecutionPolicy.test.ts | 5 +- .../control-api-runtime-access-profiles.tsv | 2 +- 7 files changed, 61 insertions(+), 8 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index c8707a83ba..ad0b7f78b1 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.864", + "version": "0.1.865", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.864", + "version": "0.1.865", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index fa9f8b8ee7..5cddbb6c4a 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.864", + "version": "0.1.865", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/access/userAccessFoundationSchema.ts b/control-api/src/services/access/userAccessFoundationSchema.ts index 770f2d7e1f..a846165e86 100644 --- a/control-api/src/services/access/userAccessFoundationSchema.ts +++ b/control-api/src/services/access/userAccessFoundationSchema.ts @@ -1021,7 +1021,6 @@ async function applyUserAccessRuntimePrivileges(db: DbClient): Promise { GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE external_user_sessions, external_user_session_security_epochs, - external_v1_session_revocations, operational_catalog_source_state, operational_resource_index, operational_resource_relationships, @@ -1029,6 +1028,10 @@ async function applyUserAccessRuntimePrivileges(db: DbClient): Promise { operational_relationships_staging TO control_api_runtime; + -- Append-only ledger: the runtime records a revocation but never rewrites or + -- erases one. The user-deletion cascade runs as the table owner. + GRANT SELECT, INSERT ON TABLE external_v1_session_revocations TO control_api_runtime; + -- Revision rows are written only by the SECURITY DEFINER bump functions. GRANT SELECT ON TABLE authorization_user_revisions, diff --git a/control-api/test/db.realPostgresMigration.integration.test.ts b/control-api/test/db.realPostgresMigration.integration.test.ts index 9c714f0636..e25f0f0205 100644 --- a/control-api/test/db.realPostgresMigration.integration.test.ts +++ b/control-api/test/db.realPostgresMigration.integration.test.ts @@ -848,6 +848,8 @@ describeRealPostgres('control-api real Postgres migrations', () => { ]) { expectPrivileges([...privileges[relation]], ['SELECT']) } + // RP-12: the session-revocation ledger is append-only for the runtime. + expectPrivileges([...privileges.external_v1_session_revocations], ['SELECT', 'INSERT']) const runtimeClient = await dbPool.connect() try { await runtimeClient.query('BEGIN') @@ -959,6 +961,53 @@ describeRealPostgres('control-api real Postgres migrations', () => { expect(await violations(relationQuery)).toBe(0) }, 60_000) + it('lets the runtime append session revocations but never rewrite them', async () => { + const { initDb } = await import('../src/db.js') + await initDb({ connect: () => dbPool.connect() }) + const user = randomUUID() + const tokenHash = randomBytes(32).toString('hex') + await dbPool.query('INSERT INTO users(id, email) VALUES ($1, $2)', [ + user, + `session-revocation-${user}@example.test`, + ]) + const runtime = await dbPool.connect() + try { + await runtime.query('BEGIN') + await runtime.query('SET LOCAL ROLE control_api_runtime') + // Liveness witness: the runtime's only writer is this append + // (userSessionService), and it needs INSERT plus SELECT. + const appended = await runtime.query( + `INSERT INTO external_v1_session_revocations( + token_hash, user_id, expires_at, revoked_at, reason + ) + VALUES ($1, $2, NOW() + INTERVAL '1 hour', NOW(), 'access_contract_witness') + ON CONFLICT (token_hash) DO NOTHING + RETURNING token_hash`, + [tokenHash, user] + ) + expect(appended.rows).toEqual([{ token_hash: tokenHash }]) + // Each refusal aborts the transaction, so fence them with savepoints. + await runtime.query('SAVEPOINT rp12_update') + await expect( + runtime.query( + `UPDATE external_v1_session_revocations SET reason = 'rewritten' WHERE token_hash = $1`, + [tokenHash] + ) + ).rejects.toMatchObject({ code: '42501' }) + await runtime.query('ROLLBACK TO SAVEPOINT rp12_update') + await runtime.query('SAVEPOINT rp12_delete') + await expect( + runtime.query('DELETE FROM external_v1_session_revocations WHERE token_hash = $1', [ + tokenHash, + ]) + ).rejects.toMatchObject({ code: '42501' }) + await runtime.query('ROLLBACK TO SAVEPOINT rp12_delete') + await runtime.query('ROLLBACK') + } finally { + runtime.release() + } + }, 60_000) + it('requires the owner stage and preserves history through a staged legacy admission upgrade', async () => { const db = await import('../src/db.js') const legacyDatabase = `control_api_legacy_admission_${randomBytes(6).toString('hex')}` diff --git a/control-api/test/db.userAccessFoundationMigration.test.ts b/control-api/test/db.userAccessFoundationMigration.test.ts index e040946329..c06a3f515b 100644 --- a/control-api/test/db.userAccessFoundationMigration.test.ts +++ b/control-api/test/db.userAccessFoundationMigration.test.ts @@ -145,7 +145,7 @@ describe('user-access foundation migration', () => { ['authorization_user_revisions', 'read'], ['external_user_session_security_epochs', 'legacy_dml'], ['external_user_sessions', 'legacy_dml'], - ['external_v1_session_revocations', 'legacy_dml'], + ['external_v1_session_revocations', 'append'], ['operational_catalog_source_state', 'legacy_dml'], ['operational_relationships_staging', 'legacy_dml'], ['operational_resource_index', 'legacy_dml'], diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index 30e902dc96..49cb9abead 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -100,11 +100,12 @@ describe('D34 migration execution policy', () => { // kept the Admin UI attribution sentinel, added three revision triggers and // a pre-lock for archive batches, now locking parents before revisions. // RP-3 (PR #334 review r2) added the approval-batch pre-lock, which also - // covers the runs its DELETE cascade rewrites. + // covers the runs its DELETE cascade rewrites; RP-12 narrowed the + // session-revocation ledger to SELECT and INSERT. const files = [ [ 'src/services/access/userAccessFoundationSchema.ts', - 'af67426f87cdfd442499b394f12421b267cc97bdc9e15fb54c9db86102bb2013', + 'dd6619f59ed1dc86d701ef55e1fcf2405c62437fcb7fd78b72ce28c96dc7a7ac', ], [ 'src/services/directory/invitationDeliverySchema.ts', diff --git a/deploy/scripts/control-api-runtime-access-profiles.tsv b/deploy/scripts/control-api-runtime-access-profiles.tsv index 0aff9a5d11..5b693dc11b 100644 --- a/deploy/scripts/control-api-runtime-access-profiles.tsv +++ b/deploy/scripts/control-api-runtime-access-profiles.tsv @@ -23,7 +23,7 @@ control_admin_revoked_tokens legacy_dml control_admin_users legacy_dml external_user_session_security_epochs legacy_dml external_user_sessions legacy_dml -external_v1_session_revocations legacy_dml +external_v1_session_revocations append gfs_audit legacy_dml gfs_blob_manifests none gfs_desktop_operator_links link_lifecycle From 139e789b1df173a3af8c3830506bc164db7ab283 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 29 Oct 2026 17:22:22 +0100 Subject: [PATCH 414/528] fix(control-api): wrap Google ID token library errors in a typed error Google's verifyIdToken errors embed the submitted token and its decoded payload in their message and stack. They reached the global error handler, which logged both and, outside production, echoed the message to the client. Catch them in the verifier and throw GoogleTokenVerificationError: a fixed message, no cause, no library text and no status/statusCode/code, so the existing sanitized 500 path is preserved and the identity rejection (401) stays unchanged. Tests assert the wrapped class, a token-free message and stack, and that the route's logger call and response body carry neither the submitted token nor a payload field. --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- control-api/src/routes/external/auth.ts | 5 ++- control-api/src/utils/auth/googleAuth.ts | 40 ++++++++++++++---- control-api/test/googleAuth.test.ts | 37 ++++++++++++---- ....externalAuth.googleLoginRejection.test.ts | 42 +++++++++++++++++-- 6 files changed, 105 insertions(+), 25 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index ad0b7f78b1..0fa6a3cb34 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.865", + "version": "0.1.866", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.865", + "version": "0.1.866", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 5cddbb6c4a..d3b7bd2a88 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.865", + "version": "0.1.866", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/routes/external/auth.ts b/control-api/src/routes/external/auth.ts index 6fcf44eacd..d23b913f97 100644 --- a/control-api/src/routes/external/auth.ts +++ b/control-api/src/routes/external/auth.ts @@ -176,8 +176,9 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { google = await verifyGoogleIdToken(idToken) } catch (error) { // Only the verifier's own identity rejections are a client-facing - // credential failure. Library verification errors keep the sanitized - // 500 path through the outer catch. + // credential failure. Library verification errors arrive as the + // verifier's GoogleTokenVerificationError (fixed message, no token or + // payload) and keep the sanitized 500 path through the outer catch. if (error instanceof GoogleIdentityRejectedError) { return res.status(401).json({ error: 'Unauthorized' }) } diff --git a/control-api/src/utils/auth/googleAuth.ts b/control-api/src/utils/auth/googleAuth.ts index 41e749862d..363239e023 100644 --- a/control-api/src/utils/auth/googleAuth.ts +++ b/control-api/src/utils/auth/googleAuth.ts @@ -12,10 +12,6 @@ export type VerifiedGoogleProfile = { /** * A Google identity that this verifier itself refuses to authenticate after * the ID token verified. Callers answer it as a rejected credential (401). - * - * Errors raised by `verifyIdToken` (malformed, expired or wrong-audience - * tokens, certificate fetch failures) are deliberately not wrapped: they stay - * plain errors and keep the existing sanitized 500 path. */ export class GoogleIdentityRejectedError extends Error { constructor(message: string) { @@ -24,11 +20,39 @@ export class GoogleIdentityRejectedError extends Error { } } +/** + * A Google ID token that the verification library refused (malformed, expired, + * wrong audience, certificate fetch failure). The library's own errors embed + * the submitted token and its decoded payload in their message and stack, so + * they are never propagated: this error carries a fixed message, no `cause` + * and no library text. Callers keep the sanitized 500 path. + * + * It deliberately carries no `status`, `statusCode` or `code`, because the + * global error handler branches on those and would otherwise forward it as a + * client error. + */ +export class GoogleTokenVerificationError extends Error { + constructor() { + super('Google ID token verification failed') + this.name = 'GoogleTokenVerificationError' + } +} + +async function verifyWithLibrary(idToken: string) { + try { + return await googleClient.verifyIdToken({ + idToken, + audience: config.googleClientId, + }) + } catch { + // Never propagate the library error, not even as a cause: its text is the + // submitted token or its decoded payload. + throw new GoogleTokenVerificationError() + } +} + export async function verifyGoogleIdToken(idToken: string): Promise { - const ticket = await googleClient.verifyIdToken({ - idToken, - audience: config.googleClientId, - }) + const ticket = await verifyWithLibrary(idToken) const payload = ticket.getPayload() if (!payload?.email) { throw new GoogleIdentityRejectedError('Google token has no email') diff --git a/control-api/test/googleAuth.test.ts b/control-api/test/googleAuth.test.ts index d32ea4d76b..dfcdb88e6e 100644 --- a/control-api/test/googleAuth.test.ts +++ b/control-api/test/googleAuth.test.ts @@ -1,5 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { GoogleIdentityRejectedError, verifyGoogleIdToken } from '../src/utils/auth/googleAuth.js' +import { + GoogleIdentityRejectedError, + GoogleTokenVerificationError, + verifyGoogleIdToken, +} from '../src/utils/auth/googleAuth.js' const googleMock = vi.hoisted(() => ({ verifyIdToken: vi.fn() })) @@ -57,13 +61,30 @@ describe('verifyGoogleIdToken', () => { ) }) - it('leaves library verification errors as plain errors', async () => { - const libraryError = new Error('Wrong number of segments in token: secret-token') - googleMock.verifyIdToken.mockRejectedValue(libraryError) - - await expect(verifyGoogleIdToken('malformed-token')).rejects.toBe(libraryError) - await expect(verifyGoogleIdToken('malformed-token')).rejects.not.toBeInstanceOf( - GoogleIdentityRejectedError + it('replaces a library verification error with a token-free typed error', async () => { + const submittedToken = 'synthetic-id-token' + const payloadEmail = 'synthetic-payload@example.invalid' + googleMock.verifyIdToken.mockRejectedValue( + new Error(`Wrong number of segments in token: ${submittedToken} ${payloadEmail}`) ) + + const error = await verifyGoogleIdToken(submittedToken).catch((reason: unknown) => reason) + + expect(error).toBeInstanceOf(GoogleTokenVerificationError) + expect(error).not.toBeInstanceOf(GoogleIdentityRejectedError) + expect((error as Error).message).toBe('Google ID token verification failed') + expect((error as Error).message).not.toContain(submittedToken) + expect((error as Error).message).not.toContain(payloadEmail) + expect(String((error as Error).stack)).not.toContain(submittedToken) + expect(String((error as Error).stack)).not.toContain(payloadEmail) + expect((error as Error).cause).toBeUndefined() + expect(error).not.toHaveProperty('status') + expect(error).not.toHaveProperty('statusCode') + expect(error).not.toHaveProperty('code') + // Liveness witness: the library verifier was actually consulted. + expect(googleMock.verifyIdToken).toHaveBeenCalledWith({ + idToken: submittedToken, + audience: 'google-client-id', + }) }) }) diff --git a/control-api/test/routes.externalAuth.googleLoginRejection.test.ts b/control-api/test/routes.externalAuth.googleLoginRejection.test.ts index bfa49ebb58..8d22261357 100644 --- a/control-api/test/routes.externalAuth.googleLoginRejection.test.ts +++ b/control-api/test/routes.externalAuth.googleLoginRejection.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import express from 'express' +import express, { type NextFunction, type Request, type Response } from 'express' import request from 'supertest' import { clerumErrorHandler } from '../src/http/errorHandler.js' import { createExternalAuthRouter } from '../src/routes/external/auth.js' @@ -34,9 +34,24 @@ vi.mock('../src/services/access/userAccessRuntimePolicy.js', () => ({ })), })) +/** + * Stands in for the production request-scoped Pino child that the + * correlation-id middleware installs as `req.log`. + */ +const requestLog = { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), +} + function buildApp() { const app = express() app.use(express.json()) + app.use((req: Request, _res: Response, next: NextFunction) => { + ;(req as Request & { log?: typeof requestLog }).log = requestLog + next() + }) app.use(createExternalAuthRouter({} as never)) app.use(clerumErrorHandler) return app @@ -50,6 +65,7 @@ describe('POST /external/auth/google-login credential rejections', () => { beforeEach(() => { googleVerify.verifyIdToken.mockReset() Object.values(directory).forEach(fn => fn.mockReset()) + Object.values(requestLog).forEach(fn => fn.mockReset()) }) it('answers 401 Unauthorized when Google has not verified the email', async () => { @@ -76,15 +92,33 @@ describe('POST /external/auth/google-login credential rejections', () => { expect(directory.googleLoginData).not.toHaveBeenCalled() }) - it('keeps library verification errors on the sanitized 500 path', async () => { - googleVerify.verifyIdToken.mockRejectedValue(new Error('Wrong number of segments in token')) + it('keeps library verification errors on the sanitized 500 path without logging or returning the token', async () => { + const submittedToken = 'synthetic-id-token' + const payloadEmail = 'synthetic-payload@example.invalid' + googleVerify.verifyIdToken.mockRejectedValue( + new Error(`Wrong number of segments in token: ${submittedToken} ${payloadEmail}`) + ) - const response = await googleLogin('malformed-token') + const response = await googleLogin(submittedToken) expect(response.status).toBe(500) expect(response.body.error).toBe('Internal Server Error') expect(typeof response.body.correlationId).toBe('string') expect(directory.googleLoginData).not.toHaveBeenCalled() + // Liveness witness: the verifier was called with the submitted token. + expect(googleVerify.verifyIdToken).toHaveBeenCalledWith( + expect.objectContaining({ idToken: submittedToken }) + ) + + expect(requestLog.error).toHaveBeenCalledTimes(1) + const logged = JSON.stringify(requestLog.error.mock.calls[0] ?? []) + expect(logged).not.toContain(submittedToken) + expect(logged).not.toContain(payloadEmail) + expect(logged).toContain('unhandled_error') + + const body = JSON.stringify(response.body) + expect(body).not.toContain(submittedToken) + expect(body).not.toContain(payloadEmail) }) it('does not answer 401 for a verified Google identity', async () => { From 6cfafc21b02144804c84ba4381da0d85f0865c60 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 29 Oct 2026 17:28:54 +0100 Subject: [PATCH 415/528] fix(control-api): classify live-membership failures in team authorization The external team-param middleware answered 503 authority_unavailable for any error from getLiveTeamMembership and logged nothing, so a budget refusal or a programmer failure was reported as a database outage. Attach the database-outage provenance at the real DB boundary (onDatabaseFailure: externalSessionDatabaseFailure) as the auth routes do, and gate the 503 on isExternalSessionBackendUnavailableError. A classified outage keeps the current 503 bodies; every other error goes to next(error) and the sanitized 500 path. Tests keep getLiveTeamMembership real and mock the pool: a bare pool Error stays a classified 503, a pg.DatabaseError 42P01 is a 500 with the query witness, and an exhausted access budget is a 500 without touching the pool. --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../src/middleware/externalSessionAuth.ts | 15 ++++- .../externalSessionTeamAuthorization.test.ts | 64 ++++++++++++++++++- 4 files changed, 77 insertions(+), 8 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 0fa6a3cb34..111bfdfdaa 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.866", + "version": "0.1.867", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.866", + "version": "0.1.867", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index d3b7bd2a88..233bf1b4f4 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.866", + "version": "0.1.867", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/middleware/externalSessionAuth.ts b/control-api/src/middleware/externalSessionAuth.ts index fa05529c66..f0e6470507 100644 --- a/control-api/src/middleware/externalSessionAuth.ts +++ b/control-api/src/middleware/externalSessionAuth.ts @@ -15,7 +15,10 @@ import type { ExternalSessionIdentityAuthentication, ExternalSessionPurpose, } from '../services/auth/externalSessionAuthentication.js' -import { isExternalSessionBackendUnavailableError } from '../services/auth/sessionDatabaseFailure.js' +import { + externalSessionDatabaseFailure, + isExternalSessionBackendUnavailableError, +} from '../services/auth/sessionDatabaseFailure.js' /** Retry-After sent when an external-session database operation is unavailable. */ export const SESSION_BACKEND_RETRY_AFTER_SECONDS = 2 @@ -287,6 +290,7 @@ function externalTeamParamMatcher(paramName: string, publicErrors: boolean) { try { const membership = await getLiveTeamMembership(claims.userId, requestedTeamId, { budget: req.accessExecutionBudget, + onDatabaseFailure: externalSessionDatabaseFailure, }) if (!membership) { if (publicErrors) { @@ -298,7 +302,14 @@ function externalTeamParamMatcher(paramName: string, publicErrors: boolean) { } req.externalTeamAuth = membership next() - } catch { + } catch (error) { + // Only the database-outage provenance is an outage. Anything else (an + // exhausted access budget, a programmer or policy failure) keeps the + // global handler's sanitized 500 path, as the auth routes do. + if (!isExternalSessionBackendUnavailableError(error)) { + next(error) + return + } if (publicErrors) { sendPublicApiError( req, diff --git a/control-api/test/externalSessionTeamAuthorization.test.ts b/control-api/test/externalSessionTeamAuthorization.test.ts index 23bc58c283..ec533f8ec0 100644 --- a/control-api/test/externalSessionTeamAuthorization.test.ts +++ b/control-api/test/externalSessionTeamAuthorization.test.ts @@ -1,11 +1,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import express from 'express' +import express, { type NextFunction, type Request, type Response } from 'express' +import { DatabaseError } from 'pg' import request from 'supertest' +import { clerumErrorHandler } from '../src/http/errorHandler.js' import { requireExternalRole, requireExternalTeamParamMatch, requireValidExternalSessionToken, } from '../src/middleware/externalSessionAuth.js' +import { AccessExecutionBudget } from '../src/services/access/accessExecutionBudget.js' const session = vi.hoisted(() => ({ authenticateExternalUserSession: vi.fn() })) const db = vi.hoisted(() => ({ query: vi.fn() })) @@ -13,8 +16,22 @@ const db = vi.hoisted(() => ({ query: vi.fn() })) vi.mock('../src/services/auth/externalSessionAuthentication.js', () => session) vi.mock('../src/db.js', () => ({ pool: db })) -function app() { +/** A server-side error as pg raises it: a DatabaseError carrying a SQLSTATE. */ +function databaseError(sqlstate: string): DatabaseError { + const error = new DatabaseError(`synthetic database failure ${sqlstate}`, 0, 'error') + error.code = sqlstate + return error +} + +function app(budget?: AccessExecutionBudget) { const value = express() + if (budget) { + value.use((req: Request, _res: Response, next: NextFunction) => { + ;(req as Request & { accessExecutionBudget?: AccessExecutionBudget }).accessExecutionBudget = + budget + next() + }) + } value.get( '/teams/:teamId', requireValidExternalSessionToken, @@ -22,6 +39,7 @@ function app() { requireExternalRole(['admin']), (_req, res) => res.status(200).json({ ok: true }) ) + value.use(clerumErrorHandler) return value } @@ -81,7 +99,7 @@ describe('external team authorization', () => { .expect(200) }) - it('fails unavailable instead of trusting stale claims', async () => { + it('classifies a bare pool error as an outage at the database boundary', async () => { db.query.mockRejectedValue(new Error('database unavailable')) await request(app()) @@ -89,5 +107,45 @@ describe('external team authorization', () => { .set('x-user-session-token', 'stale-admin-token') .expect(503) .expect({ error: 'authority_unavailable' }) + + // Liveness witness: the live membership query reached the pool, so the + // database-outage provenance came from the real boundary. + expect(db.query).toHaveBeenCalledWith(expect.stringContaining('team_members'), [ + 'user-1', + 'team-1', + ]) + }) + + it('keeps a plain database error on the sanitized 500 path', async () => { + db.query.mockRejectedValue(databaseError('42P01')) + + const response = await request(app()) + .get('/teams/team-1') + .set('x-user-session-token', 'stale-admin-token') + + expect(response.status).toBe(500) + expect(response.body.error).toBe('Internal Server Error') + expect(response.headers['retry-after']).toBeUndefined() + // Liveness witness: the live membership query reached the pool. + expect(db.query).toHaveBeenCalledWith(expect.stringContaining('team_members'), [ + 'user-1', + 'team-1', + ]) + }) + + it('keeps an exhausted access budget on the sanitized 500 path', async () => { + const budget = AccessExecutionBudget.create('action', { limits: { producerCalls: 1 } }) + budget.charge({ kind: 'producerCalls' }) + + const response = await request(app(budget)) + .get('/teams/team-1') + .set('x-user-session-token', 'stale-admin-token') + budget.close() + + expect(response.status).toBe(500) + expect(response.body.error).toBe('Internal Server Error') + expect(response.headers['retry-after']).toBeUndefined() + // A budget refusal is not a database outage: the query never ran. + expect(db.query).not.toHaveBeenCalled() }) }) From 985a3d8bc50404d225d67cdd839c56827f8bb041 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 29 Oct 2026 17:35:57 +0100 Subject: [PATCH 416/528] fix(control-api): reject a non-string session token as invalid String(body.token) and String(body.sessionToken) coerced whatever value the request carried. A value whose String() throws therefore escaped from an async middleware before the request reached its own 401 boundary, and Express 4 does not catch an async rejection; a non-throwing object was coerced into a token instead of being refused. Type-check the selected value at both sites and answer the 401 each already gives an empty token: the rate-limit context middleware (public invalid_session) and the legacy rpc-token route (Unauthorized). Tests cover the throwing-coercion body at the middleware, a non-string sessionToken at the route, and a valid string witness at each boundary. --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../src/middleware/externalSessionAuth.ts | 6 +- control-api/src/routes/external/auth.ts | 6 +- ...externalSessionAuth.nonStringToken.test.ts | 159 ++++++++++++++++++ 5 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 control-api/test/externalSessionAuth.nonStringToken.test.ts diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 111bfdfdaa..52a5a807cb 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.867", + "version": "0.1.868", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.867", + "version": "0.1.868", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 233bf1b4f4..61cc6c3b89 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.867", + "version": "0.1.868", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/middleware/externalSessionAuth.ts b/control-api/src/middleware/externalSessionAuth.ts index f0e6470507..c4c44a8305 100644 --- a/control-api/src/middleware/externalSessionAuth.ts +++ b/control-api/src/middleware/externalSessionAuth.ts @@ -69,7 +69,11 @@ function extractUserSessionToken(req: Request): string { function extractRouteSessionToken(req: Request): string { const body = (req.body ?? {}) as { token?: unknown; sessionToken?: unknown } - return String(req.header('x-user-session-token') || body.token || body.sessionToken || '').trim() + const selected = req.header('x-user-session-token') || body.token || body.sessionToken || '' + // A non-string body value is an invalid token, never a value to coerce: + // String() on a hostile object throws before this request reaches its 401 + // boundary, and an async rejection is not caught by Express 4. + return typeof selected === 'string' ? selected.trim() : '' } function sendSessionAuthenticationError( diff --git a/control-api/src/routes/external/auth.ts b/control-api/src/routes/external/auth.ts index d23b913f97..dab6382695 100644 --- a/control-api/src/routes/external/auth.ts +++ b/control-api/src/routes/external/auth.ts @@ -137,7 +137,11 @@ async function requireLegacyRpcSessionRateLimitContext( res: Response, next: NextFunction ): Promise { - const sessionToken = String(req.body?.sessionToken || '').trim() + const body = (req.body ?? {}) as { sessionToken?: unknown } + // A non-string value is an invalid token, never a value to coerce: String() + // on a hostile object throws inside an async middleware, which Express 4 does + // not catch. + const sessionToken = typeof body.sessionToken === 'string' ? body.sessionToken.trim() : '' if (!sessionToken || sessionToken.length > 4096) { res.status(401).json({ error: 'Unauthorized' }) return diff --git a/control-api/test/externalSessionAuth.nonStringToken.test.ts b/control-api/test/externalSessionAuth.nonStringToken.test.ts new file mode 100644 index 0000000000..2e0b2093ae --- /dev/null +++ b/control-api/test/externalSessionAuth.nonStringToken.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import express, { type NextFunction, type Request, type Response } from 'express' +import request from 'supertest' +import { clerumErrorHandler } from '../src/http/errorHandler.js' +import { requireExternalSessionRateLimitContext } from '../src/middleware/externalSessionAuth.js' +import { createExternalAuthRouter } from '../src/routes/external/auth.js' + +const sessions = vi.hoisted(() => ({ + authenticateExternalUserSession: vi.fn(), + authenticateExternalUserSessionIdentity: vi.fn(), + renewExternalUserSession: vi.fn(), +})) +const revocations = vi.hoisted(() => ({ + revokeAllUserSessions: vi.fn(), + revokeLegacyUserSession: vi.fn(), + revokeUserSession: vi.fn(), +})) +const issuance = vi.hoisted(() => ({ + exchangeLegacyExternalUserSession: vi.fn(), + issueExternalUserSession: vi.fn(), + selectExternalSessionRepresentation: vi.fn(), +})) +const liveTeam = vi.hoisted(() => ({ getLiveTeamMembership: vi.fn() })) +const directory = vi.hoisted(() => ({ + getTeamAgents: vi.fn(), + getUserAgents: vi.fn(), + googleLoginData: vi.fn(), + requestProfilePasswordReset: vi.fn(), +})) +const database = vi.hoisted(() => ({ query: vi.fn() })) + +vi.mock('../src/services/auth/externalSessionAuthentication.js', () => sessions) +vi.mock('../src/services/auth/externalSessionIssuance.js', () => issuance) +vi.mock('../src/services/auth/userSessionService.js', async importOriginal => { + const actual = await importOriginal() + return { ...actual, ...revocations } +}) +vi.mock('../src/services/access/liveTeamAuthorization.js', () => liveTeam) +vi.mock('../src/services/directory/index.js', () => directory) +vi.mock('../src/db.js', () => ({ + pool: { query: (...args: unknown[]) => database.query(...args) }, + withTransaction: vi.fn(), +})) + +const claims = { + userId: 'user-1', + email: 'user@example.test', + teamId: null, + role: 'member' as const, + authGeneration: 1, + iat: 1_700_000_000, + exp: 1_800_000_000, +} + +function authenticated() { + return { + status: 'authenticated' as const, + contract: 'v1' as const, + claims, + authorityContext: { + contract: 'v1' as const, + userId: claims.userId, + tokenHash: 'synthetic-hash', + issuedAt: 1_700_000_000, + authGeneration: 1, + }, + policy: { issueV1: true, issueV2: true, acceptV1: true, acceptV2: true }, + } +} + +/** + * Mounts the rate-limit context middleware and injects a body that a JSON + * parser cannot produce: an object whose String() coercion throws. This is the + * shape a non-string token takes before the middleware's own 401 boundary. + */ +function rateLimitContextApp() { + const value = express() + value.use(express.json()) + value.use((req: Request, _res: Response, next: NextFunction) => { + if (req.header('x-synthetic-throwing-token-body') === 'true') { + req.body = { + token: { + toString: () => { + throw new TypeError('synthetic token coercion') + }, + }, + } + } + next() + }) + value.post( + '/external/auth/session/renew', + requireExternalSessionRateLimitContext({ purpose: 'renew' }), + (_req, res) => res.status(200).json({ ok: true }) + ) + value.use(clerumErrorHandler) + return value +} + +function authRouterApp() { + const value = express() + value.use(express.json()) + value.use(createExternalAuthRouter({} as never)) + value.use(clerumErrorHandler) + return value +} + +describe('external session token type boundary', () => { + beforeEach(() => { + vi.clearAllMocks() + sessions.authenticateExternalUserSession.mockResolvedValue(authenticated()) + }) + + it('answers the middleware 401 for a non-string body token without coercing it', async () => { + const response = await request(rateLimitContextApp()) + .post('/external/auth/session/renew') + .set('x-synthetic-throwing-token-body', 'true') + + expect(response.status).toBe(401) + expect(response.body).toMatchObject({ error: { code: 'invalid_session' } }) + expect(sessions.authenticateExternalUserSession).not.toHaveBeenCalled() + }) + + it('still authenticates a string body token at the same middleware boundary', async () => { + const response = await request(rateLimitContextApp()) + .post('/external/auth/session/renew') + .send({ token: 'synthetic-id-token' }) + + expect(response.status).toBe(200) + expect(response.body).toEqual({ ok: true }) + expect(sessions.authenticateExternalUserSession).toHaveBeenCalledWith( + 'synthetic-id-token', + expect.objectContaining({ purpose: 'renew' }) + ) + }) + + it('answers the rpc-token 401 for a non-string sessionToken without coercing it', async () => { + const response = await request(authRouterApp()) + .post('/external/rpc/token') + .send({ sessionToken: { synthetic: 'synthetic-non-string-token' } }) + + expect(response.status).toBe(401) + expect(response.body).toEqual({ error: 'Unauthorized' }) + expect(sessions.authenticateExternalUserSession).not.toHaveBeenCalled() + }) + + it('still authenticates a string sessionToken at the rpc-token boundary', async () => { + const response = await request(authRouterApp()) + .post('/external/rpc/token') + .send({ sessionToken: 'synthetic-id-token', scopes: [], hostRefs: [] }) + + expect(sessions.authenticateExternalUserSession).toHaveBeenCalledWith( + 'synthetic-id-token', + expect.objectContaining({ purpose: 'rpc_legacy' }) + ) + expect(response.status).toBe(403) + expect(response.body).toEqual({ error: 'invalid_host_refs' }) + }) +}) From 560c7cc53c2a608547afb4204bade2bdf3c876b7 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 29 Oct 2026 17:19:09 +0100 Subject: [PATCH 417/528] fix(control-api): reject non-canonical member ids on team self-removal DELETE /external/teams/:teamId/members/:userId passed the raw path id to the membership service, which trims it. A caller could therefore send `%20` and still delete a real membership (review RP-16). Reject the path user id unless it is already a canonical UUID, before the service runs, with the managed route's 400 `invalid_target` body. No trim and no lowercase: an uppercase id must not be normalised into the caller's id either. The membership service is unchanged because the sibling managed route also relies on it. The real-PostgreSQL suite adds a padded own id case (400, row still active) next to the existing canonical-id witness. --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- control-api/src/routes/external/teams.ts | 8 ++++++++ ...FinalAuthority.realPostgres.integration.test.ts | 14 ++++++++++++++ 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 52a5a807cb..f7481ad05c 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.868", + "version": "0.1.869", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.868", + "version": "0.1.869", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 61cc6c3b89..74be0e3afc 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.868", + "version": "0.1.869", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/routes/external/teams.ts b/control-api/src/routes/external/teams.ts index 7b12897c17..416072bf0e 100644 --- a/control-api/src/routes/external/teams.ts +++ b/control-api/src/routes/external/teams.ts @@ -31,6 +31,8 @@ import { } from '../../services/directory/index.js' import { normalizeTeamRoleInput } from '../../services/directory/types.js' +const CANONICAL_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + function sendInvitationServiceError( res: { status: (code: number) => { json: (body: unknown) => unknown } }, error: unknown @@ -270,6 +272,12 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { requireExternalRole(['admin']), async (req, res, next) => { try { + // The membership service trims its target, so a padded copy of the + // caller's own id would otherwise delete a real membership. Reject any + // id that is not already canonical instead of normalising it. + if (!CANONICAL_UUID_RE.test(req.params.userId)) { + return res.status(400).json({ error: 'invalid_target' }) + } const result = await deleteManagedMemberForUser( (req as ExternalAuthedRequest).externalAuth!.userId, req.params.userId, diff --git a/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts b/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts index 67d474fbce..3441580dd4 100644 --- a/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts +++ b/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts @@ -501,6 +501,20 @@ describeRealPostgres('external team mutations final source authority on real Pos expect(result.rows[0]?.status).toBe('deleted') }) + it('rejects a padded own id without deleting the membership', async () => { + await resetPrincipal('legacy-self-delete-padded') + const response = await request(app()) + .delete(`/external/teams/${teamId}/members/%20${adminUserId}`) + .set('x-user-session-token', token) + expect(response.status).toBe(400) + expect(response.body).toEqual({ error: 'invalid_target' }) + const result = await databasePool.query<{ status: string }>( + `SELECT status FROM team_members WHERE team_id = $1 AND user_id = $2`, + [teamId, adminUserId] + ) + expect(result.rows[0]?.status).toBe('active') + }) + it('activates a managed invitation after delivery', async () => { await resetPrincipal('managed-invitation-activation') await prepareInvitation() From d9c11037324021f0d43afce8090687591c904eb6 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 29 Oct 2026 17:26:27 +0100 Subject: [PATCH 418/528] fix(control-api): let active team members search the external directory GET /external/directory/search was gated on the admin/inviter role, which dev does not do: any active member of the team may search. The gate came from this branch and hid the PII search from ordinary members for no documented reason (owner decision on review DIR). Remove only the role middleware and its now-unused import. The requireExternalTeamParamMatch() gate still refuses callers without a live team membership, and the handler keeps the branch's behaviour: no `channels` field, escaped wildcards, the query length cap and the cursor. Tests: an ordinary member now gets 200 with searchDirectory called, a caller without a live membership still gets 403 before the search, and the admin/inviter cases stay green. --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- control-api/src/routes/external/directory.ts | 2 -- .../test/routes.externalDirectory.test.ts | 23 ++++++++++++++++++- 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index f7481ad05c..a8003cb04d 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.869", + "version": "0.1.870", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.869", + "version": "0.1.870", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 74be0e3afc..ef6cc9a954 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.869", + "version": "0.1.870", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/routes/external/directory.ts b/control-api/src/routes/external/directory.ts index 6442c2eb78..955367afd5 100644 --- a/control-api/src/routes/external/directory.ts +++ b/control-api/src/routes/external/directory.ts @@ -5,7 +5,6 @@ import { attachAccessExecutionBudget } from '../../middleware/accessExecutionBud import { createExternalClientRateLimiters } from '../../middleware/externalClientIdentity.js' import { type ExternalAuthedRequest, - requireExternalRoleWithPublicErrors, requireExternalTeamParamMatchWithPublicErrors, requireValidExternalSessionTokenWithPublicErrors, } from '../../middleware/externalSessionAuth.js' @@ -32,7 +31,6 @@ export function createExternalDirectoryRouter(): Router { router.get( '/external/directory/search', requireExternalTeamParamMatchWithPublicErrors(), - requireExternalRoleWithPublicErrors(['admin', 'inviter']), async (req: ExternalAuthedRequest, res, next) => { try { const teamId = String(req.query.teamId || '').trim() diff --git a/control-api/test/routes.externalDirectory.test.ts b/control-api/test/routes.externalDirectory.test.ts index 36a6598922..58cde5aa82 100644 --- a/control-api/test/routes.externalDirectory.test.ts +++ b/control-api/test/routes.externalDirectory.test.ts @@ -64,15 +64,36 @@ describe('external directory authorization', () => { mocks.checkAndIncrement.mockResolvedValue(allowedRateLimitResult()) }) - it('denies an ordinary member without executing the PII search', async () => { + it('allows an ordinary member of the team to search', async () => { mocks.membership.mockResolvedValue({ teamId: 'team-1', role: 'member' }) + const response = await request(app()) + .get('/external/directory/search?teamId=team-1&q=person') + .set('x-user-session-token', 'session') + + expect(response.status).toBe(200) + expect(mocks.membership).toHaveBeenCalledWith( + 'user-1', + 'team-1', + expect.objectContaining({ budget: expect.anything() }) + ) + expect(mocks.searchDirectory).toHaveBeenCalledWith('team-1', 'person', undefined) + }) + + it('refuses a caller without a live team membership', async () => { + mocks.membership.mockResolvedValue(null) + const response = await request(app()) .get('/external/directory/search?teamId=team-1&q=person') .set('x-user-session-token', 'session') expect(response.status).toBe(403) expect(response.body.error.code).toBe('forbidden') + expect(mocks.membership).toHaveBeenCalledWith( + 'user-1', + 'team-1', + expect.objectContaining({ budget: expect.anything() }) + ) expect(mocks.searchDirectory).not.toHaveBeenCalled() }) From da67941d8ddb0aa64589475668c8df0a9fc9525a Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 29 Oct 2026 18:41:57 +0100 Subject: [PATCH 419/528] fix(control-api): prelock approval trigger subjects before the decision writes Deciding an approval for a user-targeted trigger bumped the target user's revision in the `approved` UPDATE and then inserted the run, whose trigger takes the run's team before its user. A concurrent run write of the same team and user could therefore take the same two revision rows in the opposite order and deadlock (40P01). Add authorization_prelock_workflow_approval_trigger_revisions(UUID), which takes one approval id and derives its subjects exactly as the run INSERT does: the approval targets plus the stored run intent's team, usage team and actor. It mirrors the run prelock: parent teams then users FOR KEY SHARE, then the distinct subjects in team-then-user order, SECURITY DEFINER with the same search_path, revoked from PUBLIC and granted to control_api_runtime only. Call it in recordDecision before the `approved` UPDATE, only for approve, and in consumeApprovalForTrigger after its checks and before the consume UPDATE, so both approved-trigger call sites (recordDecision and createApprovedRun) hold every subject before they bump any. Contract tests cover the definer list, the runtime EXECUTE list and its grantees, the static migration assertions and the positional query mocks of both call sites. The real PostgreSQL interleaving tests decide an approval and create an approved run beside a run INSERT of the same team and user: each deadlocks with 40P01 when its own prelock call is removed, and each stays green when the other call site is removed. Refs: PR #334 review r2, O1. --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../access/userAccessFoundationSchema.ts | 100 ++++++ .../services/userApprovalRequestService.ts | 18 + ...LockOrder.realPostgres.integration.test.ts | 325 +++++++++++++++++- ...composableCatalogRevisionMigration.test.ts | 13 + ....realPostgresMigration.integration.test.ts | 9 +- .../test/migrationExecutionPolicy.test.ts | 6 +- .../test/routes.adminWorkflows.test.ts | 9 + ...ervices.userApprovalRequestService.test.ts | 63 +++- 10 files changed, 539 insertions(+), 10 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index a8003cb04d..df0cd98a24 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.870", + "version": "0.1.871", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.870", + "version": "0.1.871", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index ef6cc9a954..a30ddde7fb 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.870", + "version": "0.1.871", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/access/userAccessFoundationSchema.ts b/control-api/src/services/access/userAccessFoundationSchema.ts index a846165e86..b24544ba36 100644 --- a/control-api/src/services/access/userAccessFoundationSchema.ts +++ b/control-api/src/services/access/userAccessFoundationSchema.ts @@ -389,6 +389,102 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis END; $$; + -- Deciding an approval takes the target's revision before the run INSERT + -- takes the run's team, so both call sites hold this subject set first. + -- The set mirrors the run INSERT expressions exactly + -- (userApprovalRequestService.ts:723,727,1255 and + -- workflowRunService.ts:299,300). + -- + -- Premise 1: at decision time the decision_maker column is still NULL + -- when this function reads it. Both deciders either pass the approval's own + -- target team or omit the team (decisions.routes.ts:130, + -- workflowApprovalProviderDecisionService.ts:177,192), so the fallback is + -- the same value at decision and INSERT time. + -- Premise 2: an approval without a run intent reaches an INSERT only + -- through createApprovedRun, which is always mcp-host-control with a null + -- team, null usage team and 'autonomous' actor + -- (workflowTriggerService.ts:84-104,297-305); its subjects then come from + -- the approval alone. Approvals without run intents are still created when + -- the request carries no workflowTriggerRunIntent + -- (user-approval-requests.routes.ts:651 versus :715-729). + CREATE OR REPLACE FUNCTION authorization_prelock_workflow_approval_trigger_revisions( + target_request_id UUID + ) + RETURNS VOID + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = pg_catalog, public, pg_temp + AS $$ + DECLARE revision RECORD; + BEGIN + -- Parent locks must precede revisions and the bumped rows' FK checks, + -- matching principal deletion's parent-before-cascade order. + PERFORM team.id + FROM teams AS team + WHERE team.id::TEXT IN ( + SELECT lower(subject.subject_id) + FROM workflow_approval_requests AS war + LEFT JOIN workflow_approval_trigger_run_intents AS watri + ON watri.approval_request_id = war.id + CROSS JOIN LATERAL (VALUES + ('team', war.target_team_id::TEXT), + ('team', COALESCE( + watri.team_id::TEXT, war.target_team_id::TEXT, war.decision_maker->>'teamId' + )), + ('team', NULLIF(COALESCE( + watri.usage_team_id, watri.team_id::TEXT, + war.target_team_id::TEXT, war.decision_maker->>'teamId' + ), 'control-plane-admin-ui')) + ) AS subject(subject_type, subject_id) + WHERE war.id = target_request_id + AND subject.subject_type = 'team' + AND subject.subject_id IS NOT NULL AND subject.subject_id <> '' + ) + ORDER BY team.id + FOR KEY SHARE; + + PERFORM principal.id + FROM users AS principal + WHERE principal.id IN ( + SELECT war.target_user_id + FROM workflow_approval_requests AS war + WHERE war.id = target_request_id + UNION + SELECT watri.actor_id + FROM workflow_approval_trigger_run_intents AS watri + WHERE watri.approval_request_id = target_request_id + AND watri.actor_type = 'user' + ) + ORDER BY principal.id + FOR KEY SHARE; + + -- Hold every team before any user. Bumping also creates missing rows. + FOR revision IN + SELECT DISTINCT subject.subject_type, subject.subject_id + FROM workflow_approval_requests AS war + LEFT JOIN workflow_approval_trigger_run_intents AS watri + ON watri.approval_request_id = war.id + CROSS JOIN LATERAL (VALUES + ('team', war.target_team_id::TEXT), + ('team', COALESCE( + watri.team_id::TEXT, war.target_team_id::TEXT, war.decision_maker->>'teamId' + )), + ('team', NULLIF(COALESCE( + watri.usage_team_id, watri.team_id::TEXT, + war.target_team_id::TEXT, war.decision_maker->>'teamId' + ), 'control-plane-admin-ui')), + ('user', war.target_user_id::TEXT), + ('user', CASE WHEN watri.actor_type = 'user' THEN watri.actor_id::TEXT END) + ) AS subject(subject_type, subject_id) + WHERE war.id = target_request_id + AND subject.subject_id IS NOT NULL AND subject.subject_id <> '' + ORDER BY subject.subject_type, subject.subject_id + LOOP + PERFORM authorization_bump_subject_revision(revision.subject_type, revision.subject_id); + END LOOP; + END; + $$; + CREATE OR REPLACE FUNCTION authorization_bump_user_row_revision() RETURNS TRIGGER LANGUAGE plpgsql @@ -700,6 +796,10 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis REVOKE ALL ON FUNCTION authorization_prelock_workflow_approval_revisions(UUID[]) FROM PUBLIC; GRANT EXECUTE ON FUNCTION authorization_prelock_workflow_approval_revisions(UUID[]) TO control_api_runtime; + REVOKE ALL ON FUNCTION authorization_prelock_workflow_approval_trigger_revisions(UUID) + FROM PUBLIC; + GRANT EXECUTE ON FUNCTION authorization_prelock_workflow_approval_trigger_revisions(UUID) + TO control_api_runtime; REVOKE ALL ON FUNCTION authorization_bump_user_row_revision() FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_team_row_revision() FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_workflow_run_revision() FROM PUBLIC; diff --git a/control-api/src/services/userApprovalRequestService.ts b/control-api/src/services/userApprovalRequestService.ts index f563fe145d..c4f8257372 100644 --- a/control-api/src/services/userApprovalRequestService.ts +++ b/control-api/src/services/userApprovalRequestService.ts @@ -825,6 +825,16 @@ export async function recordDecision( return { ok: false, error: 'approval_requester_mismatch' } } + if (decision === 'approve') { + // The `approved` UPDATE below bumps the approval target's revision, and + // the run INSERT it can trigger takes the run's team before its user, so + // deciding first would hold the user while asking for the team. Take the + // same subject set the INSERT will bump, teams before users, first. + await db.query('SELECT authorization_prelock_workflow_approval_trigger_revisions($1::uuid)', [ + id, + ]) + } + const decidedAt = new Date() const decidedAtIso = decidedAt.toISOString() await db.query( @@ -1189,6 +1199,14 @@ export async function consumeApprovalForTrigger( ) } + // The approval row is locked and every check passed. Take the revision + // subjects, teams before users, before the consume UPDATE bumps the target: + // the run INSERT that follows takes the run's team first, so consuming first + // would hold the target user while asking for that team. + await dbTx.query('SELECT authorization_prelock_workflow_approval_trigger_revisions($1::uuid)', [ + params.approvalRequestId, + ]) + const consumed = await dbTx.query( `UPDATE workflow_approval_requests war SET status = 'consumed' diff --git a/control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts b/control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts index 4e15c5570d..7c178c7719 100644 --- a/control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts +++ b/control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts @@ -4,8 +4,11 @@ import { Pool, type PoolClient } from 'pg' import { initDb, pool } from '../src/db.js' import type { K8sGateway } from '../src/k8s.js' import { archiveTerminalApprovals } from '../src/services/userApprovalRequestArchiveService.js' -import { expirePendingRequests } from '../src/services/userApprovalRequestService.js' -import { updateRunPhase } from '../src/services/workflowRunService.js' +import { + expirePendingRequests, + recordDecision, +} from '../src/services/userApprovalRequestService.js' +import { createApprovedRun, createRun, updateRunPhase } from '../src/services/workflowRunService.js' import { archiveTerminalRuns } from '../src/services/workflowRunsArchiveService.js' import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' @@ -60,7 +63,9 @@ describeRealPostgres('archive batches and phase updates share revision lock orde IF current_setting('application_name') LIKE 'archive-delete-%' OR current_setting('application_name') LIKE 'delete-principal-%' OR current_setting('application_name') LIKE 'approval-archive-%' - OR current_setting('application_name') LIKE 'approval-expiry-%' THEN + OR current_setting('application_name') LIKE 'approval-expiry-%' + OR current_setting('application_name') LIKE 'approval-decision-%' + OR current_setting('application_name') LIKE 'approval-approved-run-%' THEN INSERT INTO archive_delete_revision_observations (application_name, revision_table, subject_id, revision) VALUES (current_setting('application_name'), TG_TABLE_NAME, @@ -977,6 +982,320 @@ describeRealPostgres('archive batches and phase updates share revision lock orde } }) + // A user-targeted approval whose stored run intent is team T, usage team T + // and actor U, plus the trigger grant the approved-trigger paths require. + async function triggerApprovalFixture(decision: 'pending' | 'approved') { + const approvalId = randomUUID() + const user = randomUUID() + const team = randomUUID() + const recipeName = `approval-trigger-${randomBytes(4).toString('hex')}` + const idempotencyKey = `approval-trigger-${randomUUID()}` + await databasePool.query('INSERT INTO users(id, email) VALUES ($1, $2)', [ + user, + `approval-trigger-${user}@example.test`, + ]) + await databasePool.query("INSERT INTO teams(id, name) VALUES ($1, 'Approval trigger')", [team]) + await databasePool.query( + `INSERT INTO user_workflow_triggers(user_id, recipe_namespace, recipe_name) + VALUES ($1, 'revision', $2)`, + [user, recipeName] + ) + await databasePool.query( + `INSERT INTO workflow_approval_requests( + id, recipe_namespace, recipe_name, expires_at, status, target_user_id, + payload, idempotency_key, requested_at, decided_at, decided_by_user_id + ) VALUES ($1, 'revision', $2, NOW() + INTERVAL '1 hour', $3, $4::uuid, '{}'::jsonb, $5, + NOW(), CASE WHEN $3 = 'approved' THEN NOW() END, + CASE WHEN $3 = 'approved' THEN $4::uuid::text END)`, + [approvalId, recipeName, decision, user, idempotencyKey] + ) + await databasePool.query( + `INSERT INTO workflow_approval_trigger_intents( + approval_request_id, trigger_namespace, trigger_name, trigger_caller_key + ) VALUES ($1, 'revision', $2, 'revision/' || $2)`, + [approvalId, recipeName] + ) + await databasePool.query( + `INSERT INTO workflow_approval_trigger_run_intents( + approval_request_id, actor_type, actor_id, team_id, usage_team_id, + trigger_source, idempotency_key, idempotency_payload_hash, + ttl_seconds_after_finished + ) VALUES ($1, 'user', $2, $3, $4, 'onDemand', $5, 'hash-approval-trigger', 2592000)`, + [approvalId, user, team, team, idempotencyKey] + ) + return { approvalId, user, team, recipeName, idempotencyKey } + } + + async function observedRevisions(application: string) { + return ( + await databasePool.query( + `SELECT revision_table, subject_id FROM archive_delete_revision_observations + WHERE application_name = $1 ORDER BY position`, + [application] + ) + ).rows + } + + async function triggerApprovalCleanup(ids: { approvalId: string; user: string; team: string }) { + await databasePool.query('DELETE FROM workflow_runs WHERE approval_request_id = $1', [ + ids.approvalId, + ]) + await databasePool.query('DELETE FROM workflow_approval_requests WHERE id = $1', [ + ids.approvalId, + ]) + await databasePool.query('DELETE FROM user_workflow_triggers WHERE user_id = $1', [ids.user]) + await databasePool.query('DELETE FROM users WHERE id = $1', [ids.user]) + await databasePool.query('DELETE FROM teams WHERE id = $1', [ids.team]) + } + + // Returns the witness rows so the caller can assert them before cleanup. + async function triggerApprovalWitness(ids: { approvalId: string; user: string; team: string }) { + const runs = ( + await databasePool.query( + `SELECT run_id, phase, approval_request_id, team_id, usage_team_id, actor_id + FROM workflow_runs WHERE approval_request_id = $1`, + [ids.approvalId] + ) + ).rows + const approval = ( + await databasePool.query('SELECT status FROM workflow_approval_requests WHERE id = $1', [ + ids.approvalId, + ]) + ).rows + return { runs, approval } + } + + // A phase-only UPDATE no longer bumps the run's subjects, so the competing + // writer is a run INSERT of the same team, usage team and actor: it takes the + // team before the user, exactly as the approved-trigger paths do. + function competingRunInsert( + ids: { user: string; team: string }, + recipeName: string + ): Parameters[0] { + return { + recipe_namespace: 'revision', + recipe_name: recipeName, + actor_type: 'user', + team_id: ids.team, + usage_team_id: ids.team, + actor_id: ids.user, + idempotency_key: null, + trigger_source: 'onDemand', + ttl_seconds_after_finished: 2_592_000, + } + } + + it('decides a user-targeted approval beside a run INSERT without reversing subject locks', async () => { + const ids = await triggerApprovalFixture('pending') + const decisionGate = await databasePool.connect() + const competitorGate = await databasePool.connect() + const decision = await databasePool.connect() + const competitor = await databasePool.connect() + const decisionPid = await pid(decision) + const competitorPid = await pid(competitor) + const decisionGatePid = await pid(decisionGate) + const competitorGatePid = await pid(competitorGate) + let decisionOutcome: Promise | undefined + let competitorOutcome: Promise | undefined + let decisionSettled = false + let outcomes: Outcome[] = [] + let witness: Awaited> | undefined + try { + await configure(decision, 'approval-decision-order') + await configure(competitor, 'approval-create-run') + await decisionGate.query('SELECT pg_advisory_lock($1, 31)', [barrierClass]) + await competitorGate.query('SELECT pg_advisory_lock($1, 32)', [barrierClass]) + await databasePool.query( + `INSERT INTO archive_order_barriers VALUES + ('approval-decision-order', 'authorization_user_revisions', $1, 31), + ('approval-create-run', 'authorization_team_revisions', '', 32)`, + [ids.user] + ) + await decision.query('BEGIN') + decisionOutcome = observe( + recordDecision( + ids.approvalId, + 'approve', + { userId: ids.user }, + undefined, + undefined, + decision + ) + ) + // The decision locks the approval row and then its subjects; it pauses on + // the user only after the team bump. + await waitForBlock(decisionPid, [decisionGatePid]) + await competitor.query('BEGIN') + competitorOutcome = observe( + createRun(competingRunInsert(ids, 'approval-decision-competitor'), competitor) + ) + const competitorBlocker = await waitForBlock(competitorPid, [decisionPid, competitorGatePid]) + if (competitorBlocker === competitorGatePid) { + // Without the prelock the decision holds only the user, so the INSERT + // takes the team and waits behind the barrier before asking for it. + await competitorGate.query('SELECT pg_advisory_unlock_all()') + await waitForBlock(competitorPid, [decisionPid]) + await decisionGate.query('SELECT pg_advisory_unlock_all()') + } else { + // With the prelock the decision already holds the team, so the INSERT + // waits on the decision itself. + await decisionGate.query('SELECT pg_advisory_unlock_all()') + const decisionResult = await decisionOutcome + await decision.query(decisionResult.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') + decisionSettled = true + await waitForBlock(competitorPid, [competitorGatePid]) + await competitorGate.query('SELECT pg_advisory_unlock_all()') + } + const decisionResult = await decisionOutcome + if (!decisionSettled) { + await decision.query(decisionResult.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') + decisionSettled = true + } + const competitorResult = await competitorOutcome + await competitor.query(competitorResult.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') + outcomes = [decisionResult, competitorResult] + console.info( + 'APPROVAL_TRIGGER_DECISION_OUTCOMES', + outcomes.map(result => ({ + status: result.status, + code: result.status === 'rejected' ? result.code : undefined, + })) + ) + witness = await triggerApprovalWitness(ids) + } finally { + await decisionGate.query('SELECT pg_advisory_unlock_all()') + await competitorGate.query('SELECT pg_advisory_unlock_all()') + await Promise.all([decisionOutcome, competitorOutcome]) + if (!decisionSettled) await decision.query('ROLLBACK') + await competitor.query('ROLLBACK') + for (const client of [decisionGate, competitorGate, decision, competitor]) client.release() + await databasePool.query('TRUNCATE archive_order_barriers') + await triggerApprovalCleanup(ids) + } + expect(outcomes.map(result => result.status)).toEqual(['fulfilled', 'fulfilled']) + expect(witness).toEqual({ + runs: [ + { + run_id: expect.any(String), + phase: 'Pending', + approval_request_id: ids.approvalId, + team_id: ids.team, + usage_team_id: ids.team, + actor_id: ids.user, + }, + ], + approval: [{ status: 'consumed' }], + }) + // The prelock holds the team before the user it pauses on; without it the + // first subject is the approval target and this list starts with the user. + const observed = await observedRevisions('approval-decision-order') + expect(observed.slice(0, 2)).toEqual([ + { revision_table: 'authorization_team_revisions', subject_id: ids.team }, + { revision_table: 'authorization_user_revisions', subject_id: ids.user }, + ]) + }) + + it('creates an approved run beside a run INSERT without reversing subject locks', async () => { + const ids = await triggerApprovalFixture('approved') + const approvedGate = await databasePool.connect() + const competitorGate = await databasePool.connect() + const approved = await databasePool.connect() + const competitor = await databasePool.connect() + const approvedPid = await pid(approved) + const competitorPid = await pid(competitor) + const approvedGatePid = await pid(approvedGate) + const competitorGatePid = await pid(competitorGate) + let approvedOutcome: Promise | undefined + let competitorOutcome: Promise | undefined + let outcomes: Outcome[] = [] + let witness: Awaited> | undefined + const connect = vi.spyOn(pool, 'connect').mockResolvedValue(approved) + try { + await configure(approved, 'approval-approved-run-order') + await configure(competitor, 'approval-create-run') + await approvedGate.query('SELECT pg_advisory_lock($1, 33)', [barrierClass]) + await competitorGate.query('SELECT pg_advisory_lock($1, 34)', [barrierClass]) + await databasePool.query( + `INSERT INTO archive_order_barriers VALUES + ('approval-approved-run-order', 'authorization_user_revisions', $1, 33), + ('approval-create-run', 'authorization_team_revisions', '', 34)`, + [ids.user] + ) + approvedOutcome = observe( + createApprovedRun({ + ...competingRunInsert(ids, ids.recipeName), + idempotency_key: ids.idempotencyKey, + approval_request_id: ids.approvalId, + idempotency_payload_hash: 'hash-approval-trigger', + approval_caller_key: `revision/${ids.recipeName}`, + correlation_id: 'approval-approved-run-order', + }) + ) + // The consume prelock takes the team before the user it pauses on. + await waitForBlock(approvedPid, [approvedGatePid]) + await competitor.query('BEGIN') + competitorOutcome = observe( + createRun(competingRunInsert(ids, 'approval-approved-run-competitor'), competitor) + ) + const competitorBlocker = await waitForBlock(competitorPid, [approvedPid, competitorGatePid]) + if (competitorBlocker === competitorGatePid) { + // Without the prelock the consumer holds only the user, so the INSERT + // takes the team and waits behind the barrier before asking for it. + await competitorGate.query('SELECT pg_advisory_unlock_all()') + await waitForBlock(competitorPid, [approvedPid]) + await approvedGate.query('SELECT pg_advisory_unlock_all()') + } else { + await approvedGate.query('SELECT pg_advisory_unlock_all()') + await approvedOutcome + await waitForBlock(competitorPid, [competitorGatePid]) + await competitorGate.query('SELECT pg_advisory_unlock_all()') + } + const approvedResult = await approvedOutcome + const competitorResult = await competitorOutcome + await competitor.query(competitorResult.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') + outcomes = [approvedResult, competitorResult] + console.info( + 'APPROVAL_TRIGGER_APPROVED_RUN_OUTCOMES', + outcomes.map(result => ({ + status: result.status, + code: result.status === 'rejected' ? result.code : undefined, + })) + ) + witness = await triggerApprovalWitness(ids) + } finally { + await approvedGate.query('SELECT pg_advisory_unlock_all()') + await competitorGate.query('SELECT pg_advisory_unlock_all()') + await Promise.all([approvedOutcome, competitorOutcome]) + await competitor.query('ROLLBACK') + connect.mockRestore() + // The pool spy hands this client to createApprovedRun, which releases it + // in its own finally; release it only when the call never started. + if (!approvedOutcome) approved.release() + for (const client of [approvedGate, competitorGate, competitor]) client.release() + await databasePool.query('TRUNCATE archive_order_barriers') + await triggerApprovalCleanup(ids) + } + expect(outcomes.map(result => result.status)).toEqual(['fulfilled', 'fulfilled']) + expect(witness).toEqual({ + runs: [ + { + run_id: expect.any(String), + phase: 'Pending', + approval_request_id: ids.approvalId, + team_id: ids.team, + usage_team_id: ids.team, + actor_id: ids.user, + }, + ], + approval: [{ status: 'consumed' }], + }) + const observed = await observedRevisions('approval-approved-run-order') + expect(observed.slice(0, 2)).toEqual([ + { revision_table: 'authorization_team_revisions', subject_id: ids.team }, + { revision_table: 'authorization_user_revisions', subject_id: ids.user }, + ]) + }) + it.each(['not-a-uuid', 'CONTROL-PLANE-ADMIN-UI'])( 'keeps P0001 validation for non-sentinel usage team %s', async invalidTeam => { diff --git a/control-api/test/db.composableCatalogRevisionMigration.test.ts b/control-api/test/db.composableCatalogRevisionMigration.test.ts index 8afba8d1d0..000aaa5253 100644 --- a/control-api/test/db.composableCatalogRevisionMigration.test.ts +++ b/control-api/test/db.composableCatalogRevisionMigration.test.ts @@ -39,6 +39,19 @@ describe('0134 composable catalog revisions migration', () => { expect(sql).toMatch( /GRANT EXECUTE ON FUNCTION authorization_prelock_workflow_approval_revisions\(UUID\[\]\)\s+TO control_api_runtime/ ) + // The approval-trigger prelock covers the run INSERT that a decision or a + // consume performs, so its body reads the run intent and the decision team. + expect(sql).toContain( + 'authorization_prelock_workflow_approval_trigger_revisions(\n target_request_id UUID\n )' + ) + expect(sql).toContain('workflow_approval_trigger_run_intents') + expect(sql).toContain("war.decision_maker->>'teamId'") + expect(sql).toContain( + 'REVOKE ALL ON FUNCTION authorization_prelock_workflow_approval_trigger_revisions(UUID)\n FROM PUBLIC' + ) + expect(sql).toMatch( + /GRANT EXECUTE ON FUNCTION authorization_prelock_workflow_approval_trigger_revisions\(UUID\)\s+TO control_api_runtime/ + ) expect(sql).toContain('authorization_bump_workflow_approval_revision') expect(sql).toContain('authorization_bump_notification_revision') expect(sql).toContain('authorization_bump_gfs_resource_revision') diff --git a/control-api/test/db.realPostgresMigration.integration.test.ts b/control-api/test/db.realPostgresMigration.integration.test.ts index e25f0f0205..c7564d6c13 100644 --- a/control-api/test/db.realPostgresMigration.integration.test.ts +++ b/control-api/test/db.realPostgresMigration.integration.test.ts @@ -48,6 +48,7 @@ const accessFoundationDefinerSignatures = [ 'authorization_bump_subject_revision(pg_catalog.text, pg_catalog.text)', 'authorization_prelock_workflow_run_revisions(pg_catalog.uuid[])', 'authorization_prelock_workflow_approval_revisions(pg_catalog.uuid[])', + 'authorization_prelock_workflow_approval_trigger_revisions(pg_catalog.uuid)', 'authorization_bump_user_row_revision()', 'authorization_bump_team_row_revision()', 'authorization_bump_workflow_run_revision()', @@ -903,6 +904,7 @@ describeRealPostgres('control-api real Postgres migrations', () => { `) expect(revisionExecute.rows).toEqual([ { function_name: 'authorization_prelock_workflow_approval_revisions' }, + { function_name: 'authorization_prelock_workflow_approval_trigger_revisions' }, { function_name: 'authorization_prelock_workflow_run_revisions' }, ]) const prelockGrants = await runtimeClient.query<{ grantee: string }>(` @@ -911,7 +913,8 @@ describeRealPostgres('control-api real Postgres migrations', () => { CROSS JOIN LATERAL aclexplode(routine.proacl) privilege WHERE routine.oid IN ( 'public.authorization_prelock_workflow_run_revisions(uuid[])'::regprocedure, - 'public.authorization_prelock_workflow_approval_revisions(uuid[])'::regprocedure + 'public.authorization_prelock_workflow_approval_revisions(uuid[])'::regprocedure, + 'public.authorization_prelock_workflow_approval_trigger_revisions(uuid)'::regprocedure ) AND privilege.grantee <> routine.proowner AND privilege.privilege_type = 'EXECUTE' @@ -922,6 +925,10 @@ describeRealPostgres('control-api real Postgres migrations', () => { function_name: 'authorization_prelock_workflow_approval_revisions', grantee: 'control_api_runtime', }, + { + function_name: 'authorization_prelock_workflow_approval_trigger_revisions', + grantee: 'control_api_runtime', + }, { function_name: 'authorization_prelock_workflow_run_revisions', grantee: 'control_api_runtime', diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index 49cb9abead..f9776f38bf 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -101,11 +101,13 @@ describe('D34 migration execution policy', () => { // a pre-lock for archive batches, now locking parents before revisions. // RP-3 (PR #334 review r2) added the approval-batch pre-lock, which also // covers the runs its DELETE cascade rewrites; RP-12 narrowed the - // session-revocation ledger to SELECT and INSERT. + // session-revocation ledger to SELECT and INSERT. O1 added the + // approval-trigger pre-lock that both decision call sites take before they + // bump the approval target. const files = [ [ 'src/services/access/userAccessFoundationSchema.ts', - 'dd6619f59ed1dc86d701ef55e1fcf2405c62437fcb7fd78b72ce28c96dc7a7ac', + '90e72c677c32dbe68326885f54cca09e43350fcc1ef0b3df2ab30fc675701503', ], [ 'src/services/directory/invitationDeliverySchema.ts', diff --git a/control-api/test/routes.adminWorkflows.test.ts b/control-api/test/routes.adminWorkflows.test.ts index 4e28b7cfc4..8067487a58 100644 --- a/control-api/test/routes.adminWorkflows.test.ts +++ b/control-api/test/routes.adminWorkflows.test.ts @@ -732,6 +732,7 @@ describe('routes/admin/workflows', () => { rowCount: 1, }) .mockResolvedValueOnce({ rows: [{}], rowCount: 1 }) + .mockResolvedValueOnce({ rows: [], rowCount: 0 }) .mockResolvedValueOnce({ rows: [{ teamId: null }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [row], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: null }) @@ -842,6 +843,14 @@ describe('routes/admin/workflows', () => { 'test-recipe', `${RECIPE_NS}/test-recipe`, ]) + // The consume prelock runs before the UPDATE that bumps the target. + const statements = txQuery.mock.calls.map(([sql]) => String(sql)) + const prelock = statements.findIndex(sql => + sql.includes('authorization_prelock_workflow_approval_trigger_revisions') + ) + const consumed = statements.findIndex(sql => sql.includes("SET status = 'consumed'")) + expect(prelock).toBeGreaterThan(-1) + expect(prelock).toBeLessThan(consumed) }) it('rejects mcp-host-control triggers without approvalRequestId', async () => { diff --git a/control-api/test/services.userApprovalRequestService.test.ts b/control-api/test/services.userApprovalRequestService.test.ts index 246f3c8c50..7fcc7bc70c 100644 --- a/control-api/test/services.userApprovalRequestService.test.ts +++ b/control-api/test/services.userApprovalRequestService.test.ts @@ -795,6 +795,7 @@ describe('userApprovalRequestService', () => { rows: [{ status: 'pending', isExpired: false }], rowCount: 1, } as any) + .mockResolvedValueOnce({ rows: [], rowCount: 0 } as any) .mockResolvedValueOnce({ rows: [], rowCount: 1 } as any) .mockResolvedValueOnce({ rows: [], rowCount: 0 } as any) @@ -802,6 +803,34 @@ describe('userApprovalRequestService', () => { const result = await recordDecision('id-1', 'approve', { userId: 'u1' }) expect(result).toMatchObject({ ok: true }) + // The approval row is locked, then its revision subjects are taken, and + // only then the decision UPDATE bumps the target. + const statements = mockDb.query.mock.calls.map(([sql]) => String(sql)) + expect(statements[0]).toContain('FOR UPDATE') + expect(statements[1]).toContain('authorization_prelock_workflow_approval_trigger_revisions') + expect(mockDb.query.mock.calls[1]?.[1]).toEqual(['id-1']) + expect(statements[2]).toContain('SET status = $2') + }) + + it('denies without prelocking revision subjects', async () => { + const mockDb = { query: vi.fn() } + mockDb.query + .mockResolvedValueOnce({ + rows: [{ status: 'pending', isExpired: false }], + rowCount: 1, + } as any) + .mockResolvedValueOnce({ rows: [], rowCount: 1 } as any) + + mockedWithTransaction.mockImplementationOnce(async (work: any) => work(mockDb)) + + const result = await recordDecision('id-1', 'deny', { userId: 'u1' }) + expect(result).toMatchObject({ ok: true }) + // A denial bumps one subject, so no prelock is needed; the UPDATE is the + // witness that the decision still ran. + const statements = mockDb.query.mock.calls.map(([sql]) => String(sql)) + expect(statements).toHaveLength(2) + expect(statements.some(sql => sql.includes('authorization_prelock'))).toBe(false) + expect(statements[1]).toContain('SET status = $2') }) it('does not record a team workflow decision from a different requester', async () => { @@ -874,6 +903,7 @@ describe('userApprovalRequestService', () => { rows: [{ status: 'pending', isExpired: false }], rowCount: 1, } as any) + .mockResolvedValueOnce({ rows: [], rowCount: 0 } as any) .mockResolvedValueOnce({ rows: [], rowCount: 1 } as any) .mockResolvedValueOnce({ rows: [ @@ -919,6 +949,7 @@ describe('userApprovalRequestService', () => { rowCount: 1, } as any) .mockResolvedValueOnce({ rows: [{ '1': 1 }], rowCount: 1 } as any) + .mockResolvedValueOnce({ rows: [], rowCount: 0 } as any) .mockResolvedValueOnce({ rows: [{ teamId: null }], rowCount: 1 } as any) .mockResolvedValueOnce({ rows: [runRow], rowCount: 1 } as any) @@ -929,6 +960,17 @@ describe('userApprovalRequestService', () => { ok: true, workflowRun: { row: expect.objectContaining({ run_id: 'run-1' }), created: true }, }) + const statements = mockDb.query.mock.calls.map(([sql]) => String(sql)) + // Both call sites prelock before the UPDATE they precede: the decision + // before the `approved` bump and the consume before the `consumed` one. + const prelocks = statements + .map((sql, index) => ({ sql, index })) + .filter(({ sql }) => sql.includes('authorization_prelock_workflow_approval_trigger')) + expect(prelocks).toHaveLength(2) + const approvedUpdate = statements.findIndex(sql => sql.includes('SET status = $2')) + const consumedUpdate = statements.findIndex(sql => sql.includes("SET status = 'consumed'")) + expect(prelocks[0]!.index).toBeLessThan(approvedUpdate) + expect(prelocks[1]!.index).toBeLessThan(consumedUpdate) expect(mockDb.query).toHaveBeenCalledWith( expect.stringContaining('UPDATE workflow_approval_requests war'), ['id-1', 'ns', 'recipe', 'external-rest-api:user:u1:team:t1'] @@ -1079,6 +1121,11 @@ describe('userApprovalRequestService', () => { mockDb as any ) ).rejects.toThrow("cannot be consumed: status is 'pending'") + // The early throw happens before the prelock: the locked SELECT is the + // only statement, so a rejected consume holds no revision row. + expect(mockDb.query).toHaveBeenCalledTimes(1) + expect(String(mockDb.query.mock.calls[0]?.[0])).toContain('FOR UPDATE OF war') + expect(String(mockDb.query.mock.calls[0]?.[0])).not.toContain('authorization_prelock') }) it("throws if approval status is 'denied'", async () => { @@ -1265,6 +1312,7 @@ describe('userApprovalRequestService', () => { mockDb.query .mockResolvedValueOnce({ rows: [approvalRow()], rowCount: 1 } as any) .mockResolvedValueOnce({ rows: [{}], rowCount: 1 } as any) + .mockResolvedValueOnce({ rows: [], rowCount: 0 } as any) .mockResolvedValueOnce({ rows: [{ teamId: '11111111-1111-4111-8111-111111111111' }], rowCount: 1, @@ -1291,7 +1339,12 @@ describe('userApprovalRequestService', () => { expect(selectSql).toContain('LEFT JOIN workflow_approval_trigger_intents') expect(selectSql).toContain('FOR UPDATE OF war') expect(selectSql).toContain('war.payload') - const updateSql = mockDb.query.mock.calls[2]?.[0] as string + // The prelock sits between the locked read and the consume UPDATE. + expect(mockDb.query.mock.calls[2]?.[0]).toContain( + 'authorization_prelock_workflow_approval_trigger_revisions' + ) + expect(mockDb.query.mock.calls[2]?.[1]).toEqual(['id-approved']) + const updateSql = mockDb.query.mock.calls[3]?.[0] as string expect(updateSql).toContain('workflow_approval_trigger_intents') expect(updateSql).toContain('user_workflow_triggers trigger_uwt') expect(updateSql).toContain('war.target_user_id::text = war.decided_by_user_id') @@ -1310,6 +1363,7 @@ describe('userApprovalRequestService', () => { .mockResolvedValueOnce({ rows: [approvalRow()], rowCount: 1 } as any) .mockResolvedValueOnce({ rows: [{}], rowCount: 1 } as any) .mockResolvedValueOnce({ rows: [], rowCount: 0 } as any) + .mockResolvedValueOnce({ rows: [], rowCount: 0 } as any) await expect( consumeApprovalForTrigger( @@ -1340,6 +1394,7 @@ describe('userApprovalRequestService', () => { rowCount: 1, } as any) .mockResolvedValueOnce({ rows: [{ teamGranted: true }], rowCount: 1 } as any) + .mockResolvedValueOnce({ rows: [], rowCount: 0 } as any) .mockResolvedValueOnce({ rows: [{ teamId: 'team-1' }], rowCount: 1 } as any) await expect( @@ -1356,6 +1411,11 @@ describe('userApprovalRequestService', () => { ).resolves.toEqual({ teamId: 'team-1' }) expect(mockDb.query.mock.calls[1]?.[1]).toEqual(['user-1', 'team-1', 'ns', 'recipe']) + expect(mockDb.query.mock.calls[2]?.[0]).toContain( + 'authorization_prelock_workflow_approval_trigger_revisions' + ) + expect(mockDb.query.mock.calls[2]?.[1]).toEqual(['id-team']) + expect(mockDb.query.mock.calls[3]?.[0]).toContain("SET status = 'consumed'") }) it('rejects target-team approvals when another user decides a requester-bound workflow', async () => { @@ -1416,6 +1476,7 @@ describe('userApprovalRequestService', () => { rowCount: 1, } as any) .mockResolvedValueOnce({ rows: [{}], rowCount: 1 } as any) + .mockResolvedValueOnce({ rows: [], rowCount: 0 } as any) .mockResolvedValueOnce({ rows: [{ teamId: null }], rowCount: 1 } as any) await expect( From 16bc77d6d9220543a42e8e5e9c8293f1cb76a1dc Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 29 Oct 2026 18:42:19 +0100 Subject: [PATCH 420/528] fix(control-api): stop run heartbeats from bumping team and user revisions authorization_bump_workflow_run_revision fired on every UPDATE of workflow_runs with no column filter, so the workflow-recipes reconciler's per-tick heartbeat (last_reconciled_at, updated_at) and its claim and terminal writes bumped the team, usage team and actor revisions twice each. Every catalog cursor and access path id derived from those revisions was invalidated by every heartbeat of any live run of the team. Return early when an UPDATE changes none of the columns catalog consumers read: run_id, recipe_namespace, recipe_name, actor_type, actor_id, team_id and usage_team_id (catalogProducerSql.ts:236-274, catalogHydrationSql.ts:491-535, databaseAuthorityCandidates.ts:85-113). INSERT and DELETE still bump, a subject move still bumps both images, and the guard uses the file's same in-function TG_OP idiom instead of a WHEN clause, which this INSERT-or-DELETE trigger cannot use to compare OLD and NEW. A phase-only run UPDATE therefore no longer bumps a revision. The real PostgreSQL lock-order tests that used one as their team-then-user competitor now insert a run with createRun, and the two sentinel UPDATEs change actor_id as well as phase so the revision trigger still runs on UPDATE. The new heartbeat test runs the literal reconciler SQL as workflow_recipes_runtime: revisions stay unchanged while the rows really change, and a subject change still bumps all three subjects. The approval pre-lock comment no longer claims the FK cascade's run updates bump revisions; its behaviour is unchanged. Refs: PR #334 review r2, Addendum 2 and amendment 5b. --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../access/userAccessFoundationSchema.ts | 25 +- ...LockOrder.realPostgres.integration.test.ts | 286 ++++++++++++------ ...omponents.realPostgres.integration.test.ts | 104 +++++++ ...ionPolicy.realPostgres.integration.test.ts | 6 +- .../test/migrationExecutionPolicy.test.ts | 6 +- ...LockOrder.realPostgres.integration.test.ts | 82 +++-- 8 files changed, 384 insertions(+), 131 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index df0cd98a24..ad1ee24508 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.871", + "version": "0.1.872", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.871", + "version": "0.1.872", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index a30ddde7fb..0933f5e0d1 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.871", + "version": "0.1.872", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/access/userAccessFoundationSchema.ts b/control-api/src/services/access/userAccessFoundationSchema.ts index b24544ba36..e7ebfbe6ed 100644 --- a/control-api/src/services/access/userAccessFoundationSchema.ts +++ b/control-api/src/services/access/userAccessFoundationSchema.ts @@ -309,9 +309,13 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis END; $$; - -- Approval batches delete rows whose FK cascade also updates every live run - -- that references them, so the subject set covers both the approval targets - -- and those runs' attribution. Both phases mirror the run prelock. + -- Approval batches delete rows whose FK cascade also rewrites every live run + -- that references them. Those run rows take their subjects in this + -- transaction too, so the subject set covers both the approval targets and + -- the referencing runs' attribution, which keeps one global order against + -- run writers. The cascade's attribution change no longer bumps a revision + -- on its own; the batch holds these subjects anyway. Both phases mirror the + -- run prelock. CREATE OR REPLACE FUNCTION authorization_prelock_workflow_approval_revisions( target_request_ids UUID[] ) @@ -527,6 +531,21 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis AS $$ DECLARE revision RECORD; BEGIN + -- Catalog producers, hydration and the live resolver read only these + -- columns (catalogProducerSql.ts:236-274, catalogHydrationSql.ts:491-535, + -- databaseAuthorityCandidates.ts:85-113); heartbeats and phase writes + -- change none of them. This is the same idiom as the guards below, an + -- in-function TG_OP test, because this trigger handles INSERT and DELETE + -- too and a WHEN clause cannot compare OLD and NEW there. + IF TG_OP = 'UPDATE' AND ( + OLD.run_id, OLD.recipe_namespace, OLD.recipe_name, OLD.actor_type, + OLD.actor_id, OLD.team_id, OLD.usage_team_id + ) IS NOT DISTINCT FROM ( + NEW.run_id, NEW.recipe_namespace, NEW.recipe_name, NEW.actor_type, + NEW.actor_id, NEW.team_id, NEW.usage_team_id + ) THEN + RETURN NEW; + END IF; -- Order all affected subjects together, including prior attribution. FOR revision IN SELECT subject_type, subject_id diff --git a/control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts b/control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts index 7c178c7719..7da071d0ab 100644 --- a/control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts +++ b/control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts @@ -8,7 +8,7 @@ import { expirePendingRequests, recordDecision, } from '../src/services/userApprovalRequestService.js' -import { createApprovedRun, createRun, updateRunPhase } from '../src/services/workflowRunService.js' +import { createApprovedRun, createRun } from '../src/services/workflowRunService.js' import { archiveTerminalRuns } from '../src/services/workflowRunsArchiveService.js' import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' @@ -170,6 +170,10 @@ describeRealPostgres('archive batches and phase updates share revision lock orde return (await client.query('SELECT pg_backend_pid() AS pid')).rows[0].pid } + function byId(rows: Array<{ id: string; revision: number }>): Record { + return Object.fromEntries(rows.map(row => [row.id, row.revision])) + } + // Bind the real archive/expiry service to one configured session for its // first transaction; later drain batches get ordinary pooled connections. function bindFirstSession(session: PoolClient) { @@ -309,59 +313,79 @@ describeRealPostgres('archive batches and phase updates share revision lock orde return { userApprovalId, teamApprovalId, user, team, live } } - it('archives two rows and advances revisions with a phase-update liveness witness', async () => { + it('archives two rows and advances revisions with a run-insert liveness witness', async () => { const ids = await fixture() const before = await revisions(ids) const connect = vi.spyOn(pool, 'connect').mockImplementation(() => databasePool.connect()) try { expect(await archiveTerminalRuns(gateway, { batchSize: 500, maxBatches: 1 })).toBe(2) await archivedWitness(ids) - expect((await updateRunPhase(ids.live[0]!, 'Running', databasePool))?.phase).toBe('Running') + // A phase-only UPDATE no longer bumps revisions, so the witness is a run + // INSERT of the same user and the other live run's team. + const created = await createRun( + { + recipe_namespace: 'revision', + recipe_name: 'archive-order-witness', + actor_type: 'user', + team_id: ids.teams[1], + usage_team_id: ids.teams[1], + actor_id: ids.users[0], + idempotency_key: null, + trigger_source: 'onDemand', + ttl_seconds_after_finished: 2_592_000, + }, + databasePool + ) + expect(created.created).toBe(true) const after = await revisions(ids) - for (const kind of ['user', 'team'] as const) { - expect(after[kind]).toHaveLength(2) - for (const [index, row] of after[kind].entries()) { - const updated = kind === 'user' ? ids.users[0] : ids.teams[1] - expect(row.revision).toBe(before[kind][index].revision + (row.id === updated ? 5 : 3)) - } - } + // Archive: prelock + live DELETE + audit INSERT, one bump each per + // subject. The INSERT adds one bump for the user and, because team and + // usage team are the same subject, two for that team. + expect(byId(after.user)).toEqual({ + [ids.users[0]!]: byId(before.user)[ids.users[0]!]! + 4, + [ids.users[1]!]: byId(before.user)[ids.users[1]!]! + 3, + }) + expect(byId(after.team)).toEqual({ + [ids.teams[0]!]: byId(before.team)[ids.teams[0]!]! + 3, + [ids.teams[1]!]: byId(before.team)[ids.teams[1]!]! + 5, + }) } finally { connect.mockRestore() } }) - it('commits an archive batch beside updateRunPhase without reversing subject locks', async () => { + it('commits an archive batch beside a run INSERT without reversing subject locks', async () => { const ids = await fixture() const before = await revisions(ids) const archiveGate = await databasePool.connect() - const updateGate = await databasePool.connect() + const competitorGate = await databasePool.connect() const archive = await databasePool.connect() - const update = await databasePool.connect() + const competitor = await databasePool.connect() const archivePid = await pid(archive) - const updatePid = await pid(update) + const competitorPid = await pid(competitor) const archiveGatePid = await pid(archiveGate) - const updateGatePid = await pid(updateGate) + const competitorGatePid = await pid(competitorGate) let archiveOutcome: Promise | undefined - let updateOutcome: Promise | undefined + let competitorOutcome: Promise | undefined let archiveStarted = false let firstIndex = -1 let outcomes: Outcome[] = [] // The connection boundary binds the actual archive service to this real - // database. Neither archiveOneBatch nor updateRunPhase SQL is replaced. + // database. Neither archiveOneBatch nor the INSERT SQL is replaced. const connect = vi.spyOn(pool, 'connect').mockResolvedValue(archive) try { await configure(archive, 'archive-order-archive') - await configure(update, 'archive-order-phase') + await configure(competitor, 'archive-order-insert') await archiveGate.query('SELECT pg_advisory_lock($1, 1), pg_advisory_lock($1, 2)', [ barrierClass, ]) - await updateGate.query('SELECT pg_advisory_lock($1, 3)', [barrierClass]) + await competitorGate.query('SELECT pg_advisory_lock($1, 3)', [barrierClass]) await databasePool.query( ` INSERT INTO archive_order_barriers VALUES ('archive-order-archive', 'authorization_user_revisions', $1, 1), ('archive-order-archive', 'authorization_user_revisions', $2, 2), - ('archive-order-phase', 'authorization_team_revisions', '', 3) + ('archive-order-insert', 'authorization_team_revisions', '', 3) `, ids.users ) @@ -383,25 +407,42 @@ describeRealPostgres('archive batches and phase updates share revision lock orde user: ids.users[firstIndex], team: ids.teams[firstIndex], }) - await update.query('BEGIN') - updateOutcome = observe(updateRunPhase(ids.live[firstIndex]!, 'Running', update)) - const phaseBlocker = await waitForBlock(updatePid, [updateGatePid, archivePid]) - if (phaseBlocker === updateGatePid) { - // Without the prelock, the phase writer holds the other team's - // revision before waiting on the archive's first user: a cycle. - await updateGate.query('SELECT pg_advisory_unlock_all()') - await waitForBlock(updatePid, [archivePid]) + await competitor.query('BEGIN') + // A phase-only UPDATE no longer bumps revisions, so the competitor is a + // run INSERT of the user the archive holds and the other live team. + competitorOutcome = observe( + createRun( + { + recipe_namespace: 'revision', + recipe_name: 'archive-order-competitor', + actor_type: 'user', + team_id: ids.teams[1 - firstIndex], + usage_team_id: ids.teams[1 - firstIndex], + actor_id: ids.users[firstIndex], + idempotency_key: null, + trigger_source: 'onDemand', + ttl_seconds_after_finished: 2_592_000, + }, + competitor + ) + ) + const competitorBlocker = await waitForBlock(competitorPid, [competitorGatePid, archivePid]) + if (competitorBlocker === competitorGatePid) { + // Without the prelock, the INSERT holds the other team's revision + // before waiting on the archive's first user: a cycle. + await competitorGate.query('SELECT pg_advisory_unlock_all()') + await waitForBlock(competitorPid, [archivePid]) await archiveGate.query('SELECT pg_advisory_unlock_all()') } else { // With the prelock, the archive already holds both teams before - // reaching its first user. The phase writer waits at its first team. + // reaching its first user. The INSERT waits at its first team. await archiveGate.query('SELECT pg_advisory_unlock_all()') - await waitForBlock(updatePid, [updateGatePid]) - await updateGate.query('SELECT pg_advisory_unlock_all()') + await waitForBlock(competitorPid, [competitorGatePid]) + await competitorGate.query('SELECT pg_advisory_unlock_all()') } - const phaseResult = await updateOutcome - await update.query(phaseResult.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') - outcomes = [await archiveOutcome, phaseResult] + const competitorResult = await competitorOutcome + await competitor.query(competitorResult.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') + outcomes = [await archiveOutcome, competitorResult] console.info( 'ARCHIVE_ORDER_OUTCOMES', outcomes.map(result => ({ @@ -411,12 +452,12 @@ describeRealPostgres('archive batches and phase updates share revision lock orde ) } finally { await archiveGate.query('SELECT pg_advisory_unlock_all()') - await updateGate.query('SELECT pg_advisory_unlock_all()') - await updateOutcome - await update.query('ROLLBACK') + await competitorGate.query('SELECT pg_advisory_unlock_all()') + await competitorOutcome + await competitor.query('ROLLBACK') await archiveOutcome connect.mockRestore() - for (const client of [archiveGate, updateGate, update]) client.release() + for (const client of [archiveGate, competitorGate, competitor]) client.release() if (!archiveStarted) archive.release() await databasePool.query('TRUNCATE archive_order_barriers') } @@ -424,19 +465,26 @@ describeRealPostgres('archive batches and phase updates share revision lock orde expect(outcomes[0]).toEqual({ status: 'fulfilled', value: 2 }) await archivedWitness(ids) const after = await revisions(ids) - for (const kind of ['user', 'team'] as const) { - for (const [index, row] of after[kind].entries()) { - const updated = kind === 'user' ? ids.users[firstIndex] : ids.teams[1 - firstIndex] - expect(row.revision).toBe(before[kind][index].revision + (row.id === updated ? 5 : 3)) - } - } + // Archive: prelock + live DELETE + audit INSERT, one bump each per subject. + // The INSERT adds one bump for the user it names and, because team and + // usage team are one subject, two for that team. + expect(byId(after.user)).toEqual({ + [ids.users[firstIndex]!]: byId(before.user)[ids.users[firstIndex]!]! + 4, + [ids.users[1 - firstIndex]!]: byId(before.user)[ids.users[1 - firstIndex]!]! + 3, + }) + expect(byId(after.team)).toEqual({ + [ids.teams[1 - firstIndex]!]: byId(before.team)[ids.teams[1 - firstIndex]!]! + 5, + [ids.teams[firstIndex]!]: byId(before.team)[ids.teams[firstIndex]!]! + 3, + }) expect( ( - await databasePool.query('SELECT phase FROM workflow_runs WHERE run_id = $1', [ - ids.live[firstIndex], - ]) + await databasePool.query( + `SELECT phase FROM workflow_runs WHERE actor_id = $1 AND team_id = $2 + AND recipe_name = 'archive-order-competitor'`, + [ids.users[firstIndex], ids.teams[1 - firstIndex]] + ) ).rows - ).toEqual([{ phase: 'Running' }]) + ).toEqual([{ phase: 'Pending' }]) }) it.each(['user', 'team'] as const)( @@ -799,21 +847,21 @@ describeRealPostgres('archive batches and phase updates share revision lock orde } }) - it('commits an approval archive batch beside a run phase write without reversing subject locks', async () => { + it('commits an approval archive batch beside a run INSERT without reversing subject locks', async () => { const ids = await approvalFixture() const before = await revisions({ users: [ids.user], teams: [ids.team] }) const archiveGate = await databasePool.connect() const archive = await databasePool.connect() - const update = await databasePool.connect() + const competitor = await databasePool.connect() const archivePid = await pid(archive) - const updatePid = await pid(update) + const competitorPid = await pid(competitor) const gatePid = await pid(archiveGate) let archiveOutcome: Promise | undefined - let updateOutcome: Promise | undefined + let competitorOutcome: Promise | undefined const connect = bindFirstSession(archive) try { await configure(archive, 'approval-archive-order') - await configure(update, 'approval-phase-order') + await configure(competitor, 'approval-insert-order') await archiveGate.query('SELECT pg_advisory_lock($1, 21)', [barrierClass]) await databasePool.query('INSERT INTO archive_order_barriers VALUES ($1, $2, $3, 21)', [ 'approval-archive-order', @@ -823,25 +871,47 @@ describeRealPostgres('archive batches and phase updates share revision lock orde archiveOutcome = observe(archiveTerminalApprovals(180, 500)) // The prelock pauses on the user only after taking the batch's team. await waitForBlock(archivePid, [gatePid]) - await update.query('BEGIN') - updateOutcome = observe(updateRunPhase(ids.live, 'Running', update)) - // The phase writer takes its team first and waits there, so it can never - // hold the team this batch already prelocked while asking for its user. - await waitForBlock(updatePid, [archivePid]) + await competitor.query('BEGIN') + // A phase-only UPDATE no longer bumps revisions, so the competitor is a + // run INSERT of the same user and team. + competitorOutcome = observe( + createRun( + { + recipe_namespace: 'revision', + recipe_name: 'approval-insert-competitor', + actor_type: 'user', + team_id: ids.team, + usage_team_id: ids.team, + actor_id: ids.user, + idempotency_key: null, + trigger_source: 'onDemand', + ttl_seconds_after_finished: 2_592_000, + }, + competitor + ) + ) + // The INSERT takes its team first and waits there, so it can never hold + // the team this batch already prelocked while asking for its user. + await waitForBlock(competitorPid, [archivePid]) await archiveGate.query('SELECT pg_advisory_unlock_all()') - const phaseResult = await updateOutcome - await update.query(phaseResult.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') + const competitorResult = await competitorOutcome + await competitor.query(competitorResult.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') const archiveResult = await archiveOutcome console.info('APPROVAL_ORDER_OUTCOMES', { archive: archiveResult, - phase: phaseResult, + competitor: competitorResult, }) - expect([archiveResult.status, phaseResult.status]).toEqual(['fulfilled', 'fulfilled']) + expect([archiveResult.status, competitorResult.status]).toEqual(['fulfilled', 'fulfilled']) expect(archiveResult).toEqual({ status: 'fulfilled', value: 2 }) expect( - (await databasePool.query('SELECT phase FROM workflow_runs WHERE run_id = $1', [ids.live])) - .rows - ).toEqual([{ phase: 'Running' }]) + ( + await databasePool.query( + `SELECT phase FROM workflow_runs WHERE actor_id = $1 AND team_id = $2 + AND recipe_name = 'approval-insert-competitor'`, + [ids.user, ids.team] + ) + ).rows + ).toEqual([{ phase: 'Pending' }]) expect( ( await databasePool.query( @@ -851,9 +921,10 @@ describeRealPostgres('archive batches and phase updates share revision lock orde ).rows ).toHaveLength(0) // The prelock and the DELETE's row trigger bump each subject once; the - // phase UPDATE adds a NEW and an OLD attribution pair. + // INSERT adds one user bump and two team bumps (team and usage team are + // the same subject). const after = await revisions({ users: [ids.user], teams: [ids.team] }) - expect(after.user).toEqual([{ id: ids.user, revision: before.user[0]!.revision + 4 }]) + expect(after.user).toEqual([{ id: ids.user, revision: before.user[0]!.revision + 3 }]) expect(after.team).toEqual([{ id: ids.team, revision: before.team[0]!.revision + 4 }]) // The prelock takes the team first; the DELETE then meets the // user-targeted row first, as the fixture intends. @@ -873,37 +944,41 @@ describeRealPostgres('archive batches and phase updates share revision lock orde ]) } finally { await archiveGate.query('SELECT pg_advisory_unlock_all()') - await Promise.all([archiveOutcome, updateOutcome]) - await update.query('ROLLBACK') + await Promise.all([archiveOutcome, competitorOutcome]) + await competitor.query('ROLLBACK') connect.mockRestore() - for (const client of [archiveGate, update]) client.release() + for (const client of [archiveGate, competitor]) client.release() if (!archiveOutcome) archive.release() await databasePool.query('TRUNCATE archive_order_barriers') await databasePool.query( 'DELETE FROM workflow_approval_requests WHERE id = ANY($1::uuid[])', [[ids.userApprovalId, ids.teamApprovalId]] ) + await databasePool.query( + 'DELETE FROM workflow_runs WHERE recipe_name = $1 AND actor_id = $2', + ['approval-insert-competitor', ids.user] + ) await databasePool.query('DELETE FROM workflow_runs WHERE run_id = $1', [ids.live]) await databasePool.query('DELETE FROM users WHERE id = $1', [ids.user]) await databasePool.query('DELETE FROM teams WHERE id = $1', [ids.team]) } }) - it('expires pending approvals beside a run phase write without reversing subject locks', async () => { + it('expires pending approvals beside a run INSERT without reversing subject locks', async () => { const ids = await expiryFixture() const before = await revisions({ users: [ids.user], teams: [ids.team] }) const sweepGate = await databasePool.connect() const sweep = await databasePool.connect() - const update = await databasePool.connect() + const competitor = await databasePool.connect() const sweepPid = await pid(sweep) - const updatePid = await pid(update) + const competitorPid = await pid(competitor) const gatePid = await pid(sweepGate) let sweepOutcome: Promise | undefined - let updateOutcome: Promise | undefined + let competitorOutcome: Promise | undefined const connect = bindFirstSession(sweep) try { await configure(sweep, 'approval-expiry-sweep') - await configure(update, 'approval-phase-order') + await configure(competitor, 'approval-insert-order') // The sweep pauses on the user revision, after taking its team. await sweepGate.query('SELECT pg_advisory_lock($1, 22)', [barrierClass]) await databasePool.query('INSERT INTO archive_order_barriers VALUES ($1, $2, $3, 22)', [ @@ -913,17 +988,34 @@ describeRealPostgres('archive batches and phase updates share revision lock orde ]) sweepOutcome = observe(expirePendingRequests()) await waitForBlock(sweepPid, [gatePid]) - await update.query('BEGIN') - updateOutcome = observe(updateRunPhase(ids.live, 'Running', update)) - // The phase writer takes its team first and waits there, so the sweep - // never has to wait for the user it already holds. - await waitForBlock(updatePid, [sweepPid]) + await competitor.query('BEGIN') + // A phase-only UPDATE no longer bumps revisions, so the competitor is a + // run INSERT of the same user and team. + competitorOutcome = observe( + createRun( + { + recipe_namespace: 'revision', + recipe_name: 'approval-expiry-competitor', + actor_type: 'user', + team_id: ids.team, + usage_team_id: ids.team, + actor_id: ids.user, + idempotency_key: null, + trigger_source: 'onDemand', + ttl_seconds_after_finished: 2_592_000, + }, + competitor + ) + ) + // The INSERT takes its team first and waits there, so the sweep never has + // to wait for the user it already holds. + await waitForBlock(competitorPid, [sweepPid]) await sweepGate.query('SELECT pg_advisory_unlock_all()') - const phaseResult = await updateOutcome - await update.query(phaseResult.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') + const competitorResult = await competitorOutcome + await competitor.query(competitorResult.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') const sweepResult = await sweepOutcome - console.info('APPROVAL_EXPIRY_OUTCOMES', { sweep: sweepResult, phase: phaseResult }) - expect([sweepResult.status, phaseResult.status]).toEqual(['fulfilled', 'fulfilled']) + console.info('APPROVAL_EXPIRY_OUTCOMES', { sweep: sweepResult, competitor: competitorResult }) + expect([sweepResult.status, competitorResult.status]).toEqual(['fulfilled', 'fulfilled']) expect(sweepResult).toEqual({ status: 'fulfilled', value: 2 }) expect( ( @@ -938,9 +1030,14 @@ describeRealPostgres('archive batches and phase updates share revision lock orde { id: ids.teamApprovalId, status: 'expired' }, ]) expect( - (await databasePool.query('SELECT phase FROM workflow_runs WHERE run_id = $1', [ids.live])) - .rows - ).toEqual([{ phase: 'Running' }]) + ( + await databasePool.query( + `SELECT phase FROM workflow_runs WHERE actor_id = $1 AND team_id = $2 + AND recipe_name = 'approval-expiry-competitor'`, + [ids.user, ids.team] + ) + ).rows + ).toEqual([{ phase: 'Pending' }]) // The prelock takes the team first; the UPDATE then meets the // user-targeted row first and bumps both sides of its attribution. expect( @@ -960,16 +1057,17 @@ describeRealPostgres('archive batches and phase updates share revision lock orde { revision_table: 'authorization_team_revisions', subject_id: ids.team }, ]) // Prelock plus the UPDATE's NEW and OLD attribution pairs, plus the - // phase UPDATE's own NEW and OLD pair. + // INSERT's one user bump and two team bumps (team and usage team are the + // same subject). const after = await revisions({ users: [ids.user], teams: [ids.team] }) - expect(after.user).toEqual([{ id: ids.user, revision: before.user[0]!.revision + 5 }]) + expect(after.user).toEqual([{ id: ids.user, revision: before.user[0]!.revision + 4 }]) expect(after.team).toEqual([{ id: ids.team, revision: before.team[0]!.revision + 5 }]) } finally { await sweepGate.query('SELECT pg_advisory_unlock_all()') - await Promise.all([sweepOutcome, updateOutcome]) - await update.query('ROLLBACK') + await Promise.all([sweepOutcome, competitorOutcome]) + await competitor.query('ROLLBACK') connect.mockRestore() - for (const client of [sweepGate, update]) client.release() + for (const client of [sweepGate, competitor]) client.release() if (!sweepOutcome) sweep.release() await databasePool.query('TRUNCATE archive_order_barriers') await databasePool.query( @@ -977,6 +1075,10 @@ describeRealPostgres('archive batches and phase updates share revision lock orde [[ids.userApprovalId, ids.teamApprovalId]] ) await databasePool.query('DELETE FROM workflow_runs WHERE run_id = $1', [ids.live]) + await databasePool.query( + 'DELETE FROM workflow_runs WHERE recipe_name = $1 AND actor_id = $2', + ['approval-expiry-competitor', ids.user] + ) await databasePool.query('DELETE FROM users WHERE id = $1', [ids.user]) await databasePool.query('DELETE FROM teams WHERE id = $1', [ids.team]) } diff --git a/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts b/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts index f62bd87db2..04a07f2b02 100644 --- a/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts +++ b/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts @@ -313,6 +313,110 @@ describeRealPostgres('composable catalog revisions on real PostgreSQL', () => { expect(await userRevision(userIds[1])).toBeGreaterThan(revision) }) + it('leaves run subject revisions unchanged on heartbeats and bumps them on a subject change', async () => { + const team = teamIds[0] + const nextTeam = teamIds[1] + const user = userIds[0] + const run = randomUUID() + await databasePool.query( + `INSERT INTO workflow_runs(run_id, recipe_namespace, recipe_name, phase, actor_type, + actor_id, team_id, usage_team_id, trigger_source) + VALUES ($1, 'sandbox-recipes', 'revision-heartbeat', 'Pending', 'user', $2, $3, $4, + 'onDemand')`, + [run, user, team, team] + ) + const beforeUser = await userRevision(user) + const beforeTeam = await teamRevision(team) + const beforeNextTeam = await teamRevision(nextTeam) + + // The workflow-recipes reconciler SQL, run as its runtime role. None of + // these statements changes a column a catalog consumer reads. + const client = await databasePool.connect() + try { + await client.query('BEGIN') + await client.query('SET LOCAL ROLE workflow_recipes_runtime') + const progress = await client.query( + `UPDATE workflow_runs + SET last_reconciled_at = now(), + updated_at = now() + WHERE run_id = $1 + AND phase IN ('Pending', 'Running')`, + [run] + ) + expect(progress.rowCount).toBe(1) + const claim = await client.query( + `UPDATE workflow_runs + SET phase = 'Running', + owner_instance_id = $1, + child_recipe_name = $2, + child_recipe_namespace = $3, + started_at = now(), + last_reconciled_at = now(), + updated_at = now() + WHERE run_id = $4 + AND phase = 'Pending' + AND (owner_instance_id IS NULL OR owner_instance_id = $1)`, + ['owner-heartbeat', null, null, run] + ) + expect(claim.rowCount).toBe(1) + const terminal = await client.query( + `UPDATE workflow_runs + SET phase = $1, + completed_at = COALESCE($2::timestamptz, now()), + last_reconciled_at = now(), + updated_at = now() + WHERE run_id = $3 + AND ( + phase IN ('Pending', 'Running') + OR (phase = 'Failed' AND $1 = 'Succeeded') + )`, + ['Succeeded', null, run] + ) + expect(terminal.rowCount).toBe(1) + await client.query('COMMIT') + } finally { + await client.query('ROLLBACK').catch(() => undefined) + client.release() + } + + // Write witness: the heartbeat rows really changed. + expect( + ( + await databasePool.query( + `SELECT phase, last_reconciled_at IS NOT NULL AS reconciled, + started_at IS NOT NULL AS started, completed_at IS NOT NULL AS completed, + owner_instance_id + FROM workflow_runs WHERE run_id = $1`, + [run] + ) + ).rows + ).toEqual([ + { + phase: 'Succeeded', + reconciled: true, + started: true, + completed: true, + owner_instance_id: 'owner-heartbeat', + }, + ]) + // Negative witness: no subject revision moved. + expect(await userRevision(user)).toBe(beforeUser) + expect(await teamRevision(team)).toBe(beforeTeam) + expect(await teamRevision(nextTeam)).toBe(beforeNextTeam) + + // Liveness witness in the same test: a subject change still bumps. The + // team moves to nextTeam while the usage team stays on the old team, so the + // NEW image bumps nextTeam and the old usage team, and the OLD image bumps + // the old team and the old usage team: the old team moves three times. + await databasePool.query('UPDATE workflow_runs SET team_id = $2 WHERE run_id = $1', [ + run, + nextTeam, + ]) + expect(await userRevision(user)).toBe(beforeUser + 2) + expect(await teamRevision(team)).toBe(beforeTeam + 3) + expect(await teamRevision(nextTeam)).toBe(beforeNextTeam + 1) + }) + it('bumps ordered audit-run subjects and permits the Admin UI sentinel', async () => { const runId = randomUUID() let userBefore = await userRevision(userIds[0]) diff --git a/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts index 48ce78faff..c4bf5a611d 100644 --- a/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts +++ b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts @@ -700,8 +700,10 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { expect( ( await isolatedPool.query( - "UPDATE workflow_runs SET phase = 'Running' WHERE run_id = $1 RETURNING run_id", - [legacyAdminRun.rows[0]!.run_id] + // Keep the Admin UI usage-team sentinel while changing a guarded + // column, so the UPDATE still runs the revision trigger. + "UPDATE workflow_runs SET phase = 'Running', actor_id = $2 WHERE run_id = $1 RETURNING run_id", + [legacyAdminRun.rows[0]!.run_id, randomUUID()] ) ).rowCount ).toBe(1) diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index f9776f38bf..4a184a23ba 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -103,11 +103,13 @@ describe('D34 migration execution policy', () => { // covers the runs its DELETE cascade rewrites; RP-12 narrowed the // session-revocation ledger to SELECT and INSERT. O1 added the // approval-trigger pre-lock that both decision call sites take before they - // bump the approval target. + // bump the approval target. The run revision trigger now returns early on + // an UPDATE that changes none of the columns catalog consumers read, so + // reconciler heartbeats stop bumping team and user revisions. const files = [ [ 'src/services/access/userAccessFoundationSchema.ts', - '90e72c677c32dbe68326885f54cca09e43350fcc1ef0b3df2ab30fc675701503', + 'ba1f0dd29f325759a1a32650b1140afdfcde1e60a14807651b04a1ac735ffb19', ], [ 'src/services/directory/invitationDeliverySchema.ts', diff --git a/control-api/test/revisionWritersLockOrder.realPostgres.integration.test.ts b/control-api/test/revisionWritersLockOrder.realPostgres.integration.test.ts index 3a94fcd1ba..b1bf02d525 100644 --- a/control-api/test/revisionWritersLockOrder.realPostgres.integration.test.ts +++ b/control-api/test/revisionWritersLockOrder.realPostgres.integration.test.ts @@ -7,6 +7,7 @@ import { initDb, pool } from '../src/db.js' import type { K8sGateway } from '../src/k8s.js' import { type GfsSubject, writeGfsGrantBatchInTransaction } from '../src/routes/gfs/grants.js' import { writeGfsShareBatchInTransaction } from '../src/routes/gfs/shares.js' +import { createRun } from '../src/services/workflowRunService.js' import { triggerWorkflow } from '../src/services/workflows/workflowTriggerService.js' import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' @@ -293,17 +294,7 @@ describeRealPostgres('revision writers share one lock order on real PostgreSQL', } = await fixture() await grant(drive, resource!, 'team', team!) await grant(drive, resource!, 'user', user!) - let runId: string | undefined - if (writer === 'workflow') { - runId = ( - await databasePool.query<{ run_id: string }>( - `INSERT INTO workflow_runs(recipe_namespace, recipe_name, phase, actor_type, actor_id, - trigger_source, team_id) VALUES ('test', 'revision-order', 'Pending', 'user', $1, - 'onDemand', $2) RETURNING run_id::text`, - [user, team] - ) - ).rows[0]!.run_id - } else { + if (writer === 'membership') { await databasePool.query( "INSERT INTO team_members(team_id, user_id, role, status) VALUES ($1, $2, 'member', 'active')", [team, user] @@ -321,15 +312,31 @@ describeRealPostgres('revision writers share one lock order on real PostgreSQL', ]), client => writer === 'workflow' - ? client.query("UPDATE workflow_runs SET phase = 'Running' WHERE run_id = $1", [runId]) + ? // A phase-only UPDATE bumps no revision, so the workflow writer is + // a run INSERT of the same team and user: it takes the team first. + createRun( + { + recipe_namespace: 'test', + recipe_name: 'revision-order', + actor_type: 'user', + team_id: team, + usage_team_id: team, + actor_id: user, + idempotency_key: null, + trigger_source: 'onDemand', + ttl_seconds_after_finished: 2_592_000, + }, + client + ) : client.query( "UPDATE team_members SET role = 'admin' WHERE team_id = $1 AND user_id = $2", [team, user] ) ) - const subjectDelta = writer === 'workflow' ? 3 : 2 - expect(await revision('user', user!)).toBe(beforeUser + subjectDelta) - expect(await revision('team', team!)).toBe(beforeTeam + subjectDelta) + // The resource UPDATE bumps the resource and its two subjects once each; + // the run INSERT adds one user bump and two team bumps. + expect(await revision('user', user!)).toBe(beforeUser + 2) + expect(await revision('team', team!)).toBe(beforeTeam + (writer === 'workflow' ? 3 : 2)) expect(await revision('resource', resource!)).toBe(beforeResource + 1) } ) @@ -346,21 +353,17 @@ describeRealPostgres('revision writers share one lock order on real PostgreSQL', for (const team of teams) await grant(drive, resource!, 'team', team) await grant(drive, resource!, 'user', user!) let runId: string | undefined - if (writer.startsWith('workflow')) { + if (writer === 'workflow attribution change') { runId = ( await databasePool.query<{ run_id: string }>( `INSERT INTO workflow_runs(recipe_namespace, recipe_name, phase, actor_type, actor_id, trigger_source, team_id, usage_team_id) VALUES ('test', 'team-order', 'Pending', 'user', $1, 'onDemand', $2, $3) RETURNING run_id::text`, - [ - user, - writer === 'workflow usage team' ? teams[1] : teams[0], - writer === 'workflow usage team' ? teams[0] : null, - ] + [user, teams[0], null] ) ).rows[0]!.run_id - } else { + } else if (writer === 'membership team change') { await databasePool.query( "INSERT INTO team_members(team_id, user_id, role, status) VALUES ($1, $2, 'member', 'active')", [teams[0], user] @@ -378,7 +381,23 @@ describeRealPostgres('revision writers share one lock order on real PostgreSQL', ]), client => writer === 'workflow usage team' - ? client.query("UPDATE workflow_runs SET phase = 'Running' WHERE run_id = $1", [runId]) + ? // A phase-only UPDATE bumps no revision, so the two-team workflow + // writer is a run INSERT whose team and usage team are reversed + // against the sorted order the trigger must still follow. + createRun( + { + recipe_namespace: 'test', + recipe_name: 'team-order', + actor_type: 'user', + team_id: teams[1], + usage_team_id: teams[0], + actor_id: user, + idempotency_key: null, + trigger_source: 'onDemand', + ttl_seconds_after_finished: 2_592_000, + }, + client + ) : writer === 'workflow attribution change' ? client.query( "UPDATE workflow_runs SET team_id = $2, phase = 'Running' WHERE run_id = $1", @@ -389,12 +408,15 @@ describeRealPostgres('revision writers share one lock order on real PostgreSQL', [teams[0], user, teams[1]] ) ) + // The resource UPDATE bumps both teams and the user once; the run INSERT + // adds one bump per team and one for the user. The two UPDATE writers + // bump a NEW and an OLD attribution pair. for (const [index, team] of teams.entries()) { - expect(await revision('team', team)).toBe( - beforeTeams[index]! + (writer === 'workflow usage team' ? 3 : 2) - ) + expect(await revision('team', team)).toBe(beforeTeams[index]! + 2) } - expect(await revision('user', user!)).toBe(beforeUser + 3) + expect(await revision('user', user!)).toBe( + beforeUser + (writer === 'workflow usage team' ? 2 : 3) + ) expect(await revision('resource', resource!)).toBe(beforeResource + 1) } ) @@ -565,8 +587,10 @@ describeRealPostgres('revision writers share one lock order on real PostgreSQL', expect( ( await databasePool.query( - "UPDATE workflow_runs SET phase = 'Running' WHERE run_id = $1 RETURNING run_id", - [runId] + // A phase-only UPDATE skips the run revision trigger, so the sentinel + // is witnessed on UPDATE only when a guarded column changes too. + "UPDATE workflow_runs SET phase = 'Running', actor_id = $2 WHERE run_id = $1 RETURNING run_id", + [runId, randomUUID()] ) ).rowCount ).toBe(1) From 3e4891bd4cd07e6005e98f1df194068fecddfb9a Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 29 Oct 2026 18:42:44 +0100 Subject: [PATCH 421/528] fix(control-api): stop GFS content saves from bumping resource revisions authorization_bump_gfs_resource_revision bumped the resource component and every direct grant or share subject on every UPDATE of gfs_resources, so each file save (version, bytes, blob_key, content_sha256, updated_at) and each descendant path_cache rewrite invalidated the resource revision and its subjects for every caller. Return early when an UPDATE keeps resource_id, name, parent_resource_id and deleted_at unchanged, which is exactly what catalog consumers read (catalogProducerSql.ts:340-352, catalogHydrationSql.ts:654-655,692,697-701, databaseAuthorityCandidates.ts:142-158). The gfs-controller content save only rewrites version, bytes, blob_key, content_sha256 and updated_at (gfs-controller/src/db/writeStore.ts:400-405). INSERT and DELETE still bump, and a rename, a move or a tombstone change still bumps both the resource and its subjects. The new real PostgreSQL test performs a content save: the row really changes while the resource and its grant subject revisions stay put, and a rename in the same test still bumps both. Refs: PR #334 review r2, M1. --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../access/userAccessFoundationSchema.ts | 15 +++++ ...omponents.realPostgres.integration.test.ts | 60 +++++++++++++++++++ .../test/migrationExecutionPolicy.test.ts | 6 +- 5 files changed, 82 insertions(+), 5 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index ad1ee24508..e0638b33cc 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.872", + "version": "0.1.873", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.872", + "version": "0.1.873", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 0933f5e0d1..f0cda08bac 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.872", + "version": "0.1.873", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/access/userAccessFoundationSchema.ts b/control-api/src/services/access/userAccessFoundationSchema.ts index e7ebfbe6ed..d025c8d018 100644 --- a/control-api/src/services/access/userAccessFoundationSchema.ts +++ b/control-api/src/services/access/userAccessFoundationSchema.ts @@ -757,6 +757,21 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis SET search_path = pg_catalog, public, pg_temp AS $$ BEGIN + -- Catalog producers, hydration and the live resolver read only this + -- resource's identity, name, parent and tombstone + -- (catalogProducerSql.ts:340-352, catalogHydrationSql.ts:654-655,692,697-701, + -- databaseAuthorityCandidates.ts:142-158). A content save rewrites only + -- version, bytes, blob_key, content_sha256 and updated_at + -- (gfs-controller/src/db/writeStore.ts:400-405) and a descendant rename + -- rewrites path_cache, so neither needs a revision bump. Same in-function + -- TG_OP idiom as the run guard above. + IF TG_OP = 'UPDATE' AND ( + OLD.resource_id, OLD.name, OLD.parent_resource_id, OLD.deleted_at + ) IS NOT DISTINCT FROM ( + NEW.resource_id, NEW.name, NEW.parent_resource_id, NEW.deleted_at + ) THEN + RETURN NEW; + END IF; IF TG_OP <> 'DELETE' THEN PERFORM authorization_bump_gfs_resource_component(NEW.resource_id); PERFORM authorization_bump_gfs_resource_subjects(NEW.resource_id); diff --git a/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts b/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts index 04a07f2b02..118fc13ffa 100644 --- a/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts +++ b/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts @@ -578,6 +578,66 @@ describeRealPostgres('composable catalog revisions on real PostgreSQL', () => { ) }) + it('leaves GFS resource revisions unchanged on content writes and bumps them on a rename', async () => { + const resourceId = randomUUID() + const drive = `r6-${randomBytes(4).toString('hex')}` + await databasePool.query( + `INSERT INTO gfs_resources(resource_id, drive, name, kind) + VALUES ($1, $2, 'content-resource', 'file')`, + [resourceId, drive] + ) + await databasePool.query( + `INSERT INTO gfs_grants(drive, resource_id, subject_type, subject_id, permissions) + VALUES ($3, $1, 'user', $2, ARRAY['read']::text[])`, + [resourceId, userIds[0], drive] + ) + const beforeResource = await resourceRevision(resourceId) + const beforeUser = await userRevision(userIds[0]) + + const blobKey = `${resourceId.replaceAll('-', '')}/${randomUUID()}` + const contentSha = 'a'.repeat(64) + // The gfs-controller content save: version, bytes, blob_key, content_sha256. + await databasePool.query( + `UPDATE gfs_resources + SET version = version + 1, + bytes = 4096, + blob_key = $2, + content_sha256 = $3, + updated_at = clock_timestamp() + WHERE resource_id = $1`, + [resourceId, blobKey, contentSha] + ) + // Write witness: the content save really changed the row. + expect( + ( + await databasePool.query( + `SELECT version, bytes::text AS bytes, blob_key, content_sha256 + FROM gfs_resources WHERE resource_id = $1`, + [resourceId] + ) + ).rows + ).toEqual([ + { + version: 1, + bytes: '4096', + blob_key: blobKey, + content_sha256: contentSha, + }, + ]) + // Negative witness: a content save is not a catalog change, neither for the + // resource component nor for its direct grant subjects. + expect(await resourceRevision(resourceId)).toBe(beforeResource) + expect(await userRevision(userIds[0])).toBe(beforeUser) + + // Liveness witness in the same test: a rename still bumps both. + await databasePool.query( + `UPDATE gfs_resources SET name = 'content-resource-renamed' WHERE resource_id = $1`, + [resourceId] + ) + expect(await resourceRevision(resourceId)).toBe(beforeResource + 1) + expect(await userRevision(userIds[0])).toBe(beforeUser + 1) + }) + it('preserves concurrent GFS invalidation increments on one resource component', async () => { const resourceId = randomUUID() const drive = `r6-${randomBytes(4).toString('hex')}` diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index 4a184a23ba..35c617a198 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -105,11 +105,13 @@ describe('D34 migration execution policy', () => { // approval-trigger pre-lock that both decision call sites take before they // bump the approval target. The run revision trigger now returns early on // an UPDATE that changes none of the columns catalog consumers read, so - // reconciler heartbeats stop bumping team and user revisions. + // reconciler heartbeats stop bumping team and user revisions; the GFS + // resource trigger does the same for content saves that keep its identity, + // name, parent and tombstone. const files = [ [ 'src/services/access/userAccessFoundationSchema.ts', - 'ba1f0dd29f325759a1a32650b1140afdfcde1e60a14807651b04a1ac735ffb19', + 'e8c0e74cd98063fbd4e19e05a301ee32a421cff7cd1414e9740bb221da8a55c5', ], [ 'src/services/directory/invitationDeliverySchema.ts', From 3fc0e177a631e425a2adc3af7d7a69f94d17f8b8 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 29 Oct 2026 19:57:37 +0100 Subject: [PATCH 422/528] fix(profile-ui): report an incomplete sign-out when the logout fence cannot persist The profile session is an HttpOnly cookie, so when the logout request gets no server response the page can only fence the session locally. That fence is written to localStorage and mirrored in module memory. If setItem throws, only module memory holds it: the UI still reported a completed sign-out, and a reload restored the old session through the surviving cookie. markLogoutUnconfirmed() now reports whether the fence reached browser storage, and logoutProfileUI() returns fencePersisted alongside revocationConfirmed. When revocation is unconfirmed and the fence was not persisted, AuthContext keeps the immediate local sign-out but sets signOutIncomplete, and LoginPanel shows a role="alert" banner with a Retry sign-out action. A successful login clears the flag. Confirmed logout and the working-storage unconfirmed path are unchanged. Detection note: the swallowing catch that hid this came from 52fa7ce9f (r55-h2, 2026-10-02). The page-lifetime fence is the spec-required local credential abandonment (Spec C.1/D.1), so it stays; only its silent fallback is replaced by an honest, observable result. --- .../app/components/AuthContext/index.tsx | 56 +++++++--- .../app/components/AuthContext/types.ts | 2 + .../app/components/LoginPanel/index.tsx | 15 ++- .../__tests__/AuthContext.logout.test.tsx | 104 +++++++++++++++++- .../components/__tests__/AuthGate.test.tsx | 2 + .../LoginPanel.signOutIncomplete.test.tsx | 64 +++++++++++ .../components/__tests__/api.logout.test.ts | 49 ++++++++- profile-ui/lib/api.ts | 17 ++- profile-ui/package-lock.json | 4 +- profile-ui/package.json | 2 +- 10 files changed, 282 insertions(+), 33 deletions(-) create mode 100644 profile-ui/app/components/__tests__/LoginPanel.signOutIncomplete.test.tsx diff --git a/profile-ui/app/components/AuthContext/index.tsx b/profile-ui/app/components/AuthContext/index.tsx index 6fced929c0..c684b14c31 100644 --- a/profile-ui/app/components/AuthContext/index.tsx +++ b/profile-ui/app/components/AuthContext/index.tsx @@ -25,6 +25,9 @@ import type { AuthCheckResult, AuthContextValue, AuthState } from './types' const AuthContext = createContext(undefined) +const UNCONFIRMED_REVOCATION_MESSAGE = + 'You are signed out here, but the server could not confirm that your session was revoked. The old session may remain valid until it expires.' + function meFromPasswordLoginResponse(me: PasswordLoginResponse['me']): Me { return { id: me.id, @@ -57,6 +60,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { isLoading: true, me: null, }) + const [signOutIncomplete, setSignOutIncomplete] = useState(false) const checkAuth = useCallback(async (): Promise => { const sequence = ++authCheckSequenceRef.current @@ -100,6 +104,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { return meFromPasswordLoginResponse(result.me) }) sessionExpiredHandledRef.current = false + setSignOutIncomplete(false) setAuthState({ isLoggedIn: true, isLoading: false, @@ -113,32 +118,47 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } }, []) + const requestRevocation = useCallback(() => { + void logoutProfileUI() + .then(({ revocationConfirmed, fencePersisted }) => { + if (revocationConfirmed) { + setSignOutIncomplete(false) + return + } + if (!fencePersisted) { + // Browser storage could not record the logout fence, so the local sign-out + // outlives only this page. The login screen says so and offers a retry. + setSignOutIncomplete(true) + return + } + setSignOutIncomplete(false) + showToast(UNCONFIRMED_REVOCATION_MESSAGE, { tone: 'error', durationMs: 7000 }) + }) + .catch(() => { + showToast(UNCONFIRMED_REVOCATION_MESSAGE, { tone: 'error', durationMs: 7000 }) + }) + }, [showToast]) + const logout = useCallback(() => { authCheckSequenceRef.current += 1 sessionExpiredHandledRef.current = false resetProfileAccessCache(authState.me?.id) clearToken() + setSignOutIncomplete(false) setAuthState({ isLoggedIn: false, isLoading: false, me: null }) - void logoutProfileUI() - .then(({ revocationConfirmed }) => { - if (!revocationConfirmed) { - showToast( - 'You are signed out here, but the server could not confirm that your session was revoked. The old session may remain valid until it expires.', - { tone: 'error', durationMs: 7000 } - ) - } - }) - .catch(() => { - showToast( - 'You are signed out here, but the server could not confirm that your session was revoked. The old session may remain valid until it expires.', - { tone: 'error', durationMs: 7000 } - ) - }) - }, [authState.me?.id, showToast]) + requestRevocation() + }, [authState.me?.id, requestRevocation]) const value = useMemo( - () => ({ authState, login, logout, checkAuth }), - [authState, checkAuth, login, logout] + () => ({ + authState, + login, + logout, + checkAuth, + signOutIncomplete, + retrySignOut: requestRevocation, + }), + [authState, checkAuth, login, logout, signOutIncomplete, requestRevocation] ) return {children} diff --git a/profile-ui/app/components/AuthContext/types.ts b/profile-ui/app/components/AuthContext/types.ts index 7e85451f5b..4099e93f64 100644 --- a/profile-ui/app/components/AuthContext/types.ts +++ b/profile-ui/app/components/AuthContext/types.ts @@ -18,4 +18,6 @@ export type AuthContextValue = { login: (email: string, password: string) => Promise logout: () => void checkAuth: () => Promise + signOutIncomplete: boolean + retrySignOut: () => void } diff --git a/profile-ui/app/components/LoginPanel/index.tsx b/profile-ui/app/components/LoginPanel/index.tsx index baac1fbde0..7b9973cace 100644 --- a/profile-ui/app/components/LoginPanel/index.tsx +++ b/profile-ui/app/components/LoginPanel/index.tsx @@ -13,7 +13,7 @@ import type { LoginPanelProps } from './types' export function LoginPanel({ description = 'Sign in with your Evenfire account credentials.', }: LoginPanelProps) { - const { login } = useAuth() + const { login, signOutIncomplete, retrySignOut } = useAuth() const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [error, setError] = useState('') @@ -97,6 +97,19 @@ export function LoginPanel({
+ {signOutIncomplete ? ( +
+

+ Sign-out is not complete. The server could not confirm it, and this browser could not + save the sign-out, so this session may come back after a reload or stay active in other + Profile tabs. +

+ +
+ ) : null} + {error ?
{error}
: null} ) diff --git a/profile-ui/app/components/__tests__/AuthContext.logout.test.tsx b/profile-ui/app/components/__tests__/AuthContext.logout.test.tsx index abd8b0cdfd..66d2e2c915 100644 --- a/profile-ui/app/components/__tests__/AuthContext.logout.test.tsx +++ b/profile-ui/app/components/__tests__/AuthContext.logout.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { AuthProvider, useAuth } from '@components/AuthContext' import { ToastProvider } from '@components/Toast' @@ -25,13 +25,20 @@ vi.mock('@lib/api', () => ({ vi.mock('@lib/profileAccess', () => ({ resetProfileAccessCache: mocks.resetProfileAccessCache })) function AuthProbe() { - const { authState, logout } = useAuth() + const { authState, login, logout, signOutIncomplete, retrySignOut } = useAuth() return (
{authState.isLoggedIn ? 'signed-in' : 'signed-out'} + + {signOutIncomplete ?

Sign-out is not complete

: null} +
) } @@ -39,6 +46,7 @@ function AuthProbe() { describe('Profile logout consumer', () => { beforeEach(() => { vi.clearAllMocks() + mocks.logoutProfileUI.mockReset() mocks.getMe.mockResolvedValue({ id: 'user-1', email: 'user@example.invalid' }) mocks.isSilentApiError.mockReturnValue(false) }) @@ -46,7 +54,10 @@ describe('Profile logout consumer', () => { afterEach(cleanup) it('stays signed out and explains unconfirmed revocation', async () => { - mocks.logoutProfileUI.mockResolvedValueOnce({ revocationConfirmed: false }) + mocks.logoutProfileUI.mockResolvedValueOnce({ + revocationConfirmed: false, + fencePersisted: true, + }) render( @@ -86,4 +97,91 @@ describe('Profile logout consumer', () => { ) ) }) + + it('keeps the local sign-out and offers a retry when the fence cannot be saved', async () => { + mocks.logoutProfileUI + .mockResolvedValueOnce({ revocationConfirmed: false, fencePersisted: false }) + .mockResolvedValueOnce({ revocationConfirmed: true, fencePersisted: false }) + render( + + + + + + ) + + await screen.findByText('signed-in') + fireEvent.click(screen.getByRole('button', { name: 'Log out' })) + + expect(screen.getByText('signed-out')).toBeInTheDocument() + await screen.findByRole('alert') + expect(mocks.logoutProfileUI).toHaveBeenCalledOnce() + // The working-storage toast would claim a sign-out this browser cannot keep. + expect(screen.queryByText(/signed out here/i)).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: 'Retry sign-out' })) + + expect(mocks.logoutProfileUI).toHaveBeenCalledTimes(2) + await waitFor(() => expect(screen.queryByRole('alert')).toBeNull()) + expect(screen.getByText('signed-out')).toBeInTheDocument() + }) + + it('a new login clears the incomplete sign-out', async () => { + mocks.logoutProfileUI.mockResolvedValueOnce({ + revocationConfirmed: false, + fencePersisted: false, + }) + mocks.loginWithPassword.mockResolvedValueOnce({ + token: 'test-token', + me: { id: 'user-1', email: 'user@example.invalid' }, + }) + render( + + + + + + ) + + await screen.findByText('signed-in') + fireEvent.click(screen.getByRole('button', { name: 'Log out' })) + await screen.findByRole('alert') + + fireEvent.click(screen.getByRole('button', { name: 'Log in' })) + + await waitFor(() => expect(mocks.loginWithPassword).toHaveBeenCalledOnce()) + await screen.findByText('signed-in') + expect(screen.queryByRole('alert')).toBeNull() + + // A later 401 must not revive the stale banner on the login screen. + const handleGlobalAuthError = mocks.setGlobalAuthErrorHandler.mock.calls[0]?.[0] as () => void + act(() => handleGlobalAuthError()) + + expect(screen.getByText('signed-out')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('confirmed logout shows no warning', async () => { + mocks.logoutProfileUI.mockResolvedValueOnce({ + revocationConfirmed: true, + fencePersisted: true, + }) + render( + + + + + + ) + + await screen.findByText('signed-in') + fireEvent.click(screen.getByRole('button', { name: 'Log out' })) + + expect(screen.getByText('signed-out')).toBeInTheDocument() + await mocks.logoutProfileUI.mock.results[0]?.value + await act(async () => {}) + + expect(screen.queryByRole('status')).toBeNull() + expect(screen.queryByRole('alert')).toBeNull() + }) }) diff --git a/profile-ui/app/components/__tests__/AuthGate.test.tsx b/profile-ui/app/components/__tests__/AuthGate.test.tsx index f48e668fa0..c6b6753fbf 100644 --- a/profile-ui/app/components/__tests__/AuthGate.test.tsx +++ b/profile-ui/app/components/__tests__/AuthGate.test.tsx @@ -15,6 +15,8 @@ describe('AuthGate', () => { checkAuth: vi.fn(), login: vi.fn(), logout: vi.fn(), + signOutIncomplete: false, + retrySignOut: vi.fn(), }) const view = render( diff --git a/profile-ui/app/components/__tests__/LoginPanel.signOutIncomplete.test.tsx b/profile-ui/app/components/__tests__/LoginPanel.signOutIncomplete.test.tsx new file mode 100644 index 0000000000..6b8a4619b7 --- /dev/null +++ b/profile-ui/app/components/__tests__/LoginPanel.signOutIncomplete.test.tsx @@ -0,0 +1,64 @@ +import React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { LoginPanel } from '@components/LoginPanel' + +const mocks = vi.hoisted(() => ({ + authState: { signOutIncomplete: false, retrySignOut: vi.fn() }, +})) + +vi.mock('@components/AuthContext', () => ({ + useAuth: () => ({ + login: vi.fn(), + signOutIncomplete: mocks.authState.signOutIncomplete, + retrySignOut: mocks.authState.retrySignOut, + }), +})) + +vi.mock('next/image', () => ({ + default: ({ alt, ...props }: React.ImgHTMLAttributes) => ( + {alt} + ), +})) + +vi.mock('next/link', () => ({ + default: ({ + children, + href, + ...props + }: React.AnchorHTMLAttributes & { href: string }) => ( + + {children} + + ), +})) + +beforeEach(() => { + mocks.authState.signOutIncomplete = false + mocks.authState.retrySignOut.mockReset() +}) + +afterEach(cleanup) + +describe('LoginPanel incomplete sign-out banner', () => { + it('explains the incomplete sign-out and retries it', () => { + mocks.authState.signOutIncomplete = true + + render() + + const alert = screen.getByRole('alert') + expect(alert).toHaveTextContent(/sign-out is not complete/i) + expect(alert).toHaveTextContent(/reload/i) + + fireEvent.click(screen.getByRole('button', { name: 'Retry sign-out' })) + + expect(mocks.authState.retrySignOut).toHaveBeenCalledOnce() + }) + + it('renders the ordinary login form when the sign-out completed', () => { + render() + + expect(screen.getByRole('button', { name: 'Sign in' })).toBeInTheDocument() + expect(screen.queryByRole('alert')).toBeNull() + }) +}) diff --git a/profile-ui/app/components/__tests__/api.logout.test.ts b/profile-ui/app/components/__tests__/api.logout.test.ts index 893f2408f2..d39b2fd293 100644 --- a/profile-ui/app/components/__tests__/api.logout.test.ts +++ b/profile-ui/app/components/__tests__/api.logout.test.ts @@ -12,6 +12,7 @@ import { describe('Profile logout client boundary', () => { beforeEach(() => { window.localStorage.clear() + clearUnconfirmedLogout() vi.stubGlobal('fetch', vi.fn()) }) @@ -26,13 +27,54 @@ describe('Profile logout client boundary', () => { vi.mocked(fetch).mockImplementationOnce(response) window.localStorage.setItem('external_session_token', 'legacy-token') - await expect(logoutProfileUI()).resolves.toEqual({ revocationConfirmed: false }) + await expect(logoutProfileUI()).resolves.toEqual({ + revocationConfirmed: false, + fencePersisted: true, + }) expect(window.localStorage.getItem('external_session_token')).toBeNull() await expect(apiGet('/api/v1/me')).rejects.toBeInstanceOf(AuthExpiredError) expect(fetch).toHaveBeenCalledOnce() }) + it.each([ + ['transport failure', () => Promise.reject(new TypeError('network unavailable'))], + ['503 response', () => Promise.resolve(new Response('', { status: 503 }))], + ])( + 'fences only this page when browser storage rejects the logout fence after a %s', + async (_caseName, response) => { + const storage = { + length: 0, + getItem: vi.fn(() => null), + setItem: vi.fn(() => { + throw new DOMException('Quota exceeded', 'QuotaExceededError') + }), + removeItem: vi.fn(), + clear: vi.fn(), + key: vi.fn(() => null), + } + vi.stubGlobal('localStorage', storage) + vi.mocked(fetch).mockImplementationOnce(response) + + await expect(logoutProfileUI()).resolves.toEqual({ + revocationConfirmed: false, + fencePersisted: false, + }) + + // Liveness: the API boundary wrote through the stubbed browser storage. + expect(window.localStorage).toBe(storage) + expect(storage.setItem).toHaveBeenCalledWith('profile_logout_unconfirmed', 'true') + expect(fetch).toHaveBeenCalledOnce() + expect(String(vi.mocked(fetch).mock.calls[0]?.[0])).toContain('/api/v1/auth/logout') + expect(vi.mocked(fetch).mock.calls[0]?.[1]?.method).toBe('POST') + expect(vi.mocked(fetch).mock.calls[0]?.[1]?.credentials).toBe('include') + + // The in-memory fence still abandons the credential for this page lifetime. + await expect(apiGet('/api/v1/me')).rejects.toBeInstanceOf(AuthExpiredError) + expect(fetch).toHaveBeenCalledOnce() + } + ) + it('restores session requests only after a successful new login', async () => { vi.mocked(fetch) .mockResolvedValueOnce(new Response('', { status: 503 })) @@ -66,7 +108,10 @@ describe('Profile logout client boundary', () => { }) ) - await expect(logoutProfileUI()).resolves.toEqual({ revocationConfirmed: false }) + await expect(logoutProfileUI()).resolves.toEqual({ + revocationConfirmed: false, + fencePersisted: true, + }) expect(window.localStorage.getItem('profile_logout_unconfirmed')).toBe('true') await expect(apiGet('/api/v1/me')).rejects.toBeInstanceOf(AuthExpiredError) expect(fetch).toHaveBeenCalledOnce() diff --git a/profile-ui/lib/api.ts b/profile-ui/lib/api.ts index d9289c2ed5..1bc1a14077 100644 --- a/profile-ui/lib/api.ts +++ b/profile-ui/lib/api.ts @@ -54,14 +54,16 @@ function hasUnconfirmedLogout(): boolean { } } -function markLogoutUnconfirmed(): void { - if (typeof window === 'undefined') return +function markLogoutUnconfirmed(): boolean { + if (typeof window === 'undefined') return false logoutUnconfirmedInMemory = true try { window.localStorage.setItem(PROFILE_LOGOUT_UNCONFIRMED_KEY, 'true') } catch { - // Auth state still signs out in memory when browser storage is unavailable. + // Only this page is fenced; the caller must not report a durable sign-out. + return false } + return true } /** @@ -285,8 +287,11 @@ export async function loginWithPassword( return (await res.json()) as PasswordLoginResponse } -export async function logoutProfileUI(): Promise<{ revocationConfirmed: boolean }> { - markLogoutUnconfirmed() +export async function logoutProfileUI(): Promise<{ + revocationConfirmed: boolean + fencePersisted: boolean +}> { + const fencePersisted = markLogoutUnconfirmed() let revocationConfirmed = false try { const response = await fetch(`${EXTERNAL_REST_API_BASE_URL}/api/v1/auth/logout`, { @@ -301,7 +306,7 @@ export async function logoutProfileUI(): Promise<{ revocationConfirmed: boolean clearToken() } if (revocationConfirmed) clearUnconfirmedLogout() - return { revocationConfirmed } + return { revocationConfirmed, fencePersisted } } export async function requestPasswordReset(email: string): Promise<{ requested: true }> { diff --git a/profile-ui/package-lock.json b/profile-ui/package-lock.json index 6a9397a948..30b9a624d2 100644 --- a/profile-ui/package-lock.json +++ b/profile-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-profile-ui", - "version": "0.1.191", + "version": "0.1.192", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-profile-ui", - "version": "0.1.191", + "version": "0.1.192", "dependencies": { "@clerum/desktop-app-links": "file:../packages/desktop-app-links", "@clerum/frontend-components": "file:../packages/frontend-components", diff --git a/profile-ui/package.json b/profile-ui/package.json index d11257ff94..1550248f99 100644 --- a/profile-ui/package.json +++ b/profile-ui/package.json @@ -1,6 +1,6 @@ { "name": "clerum-profile-ui", - "version": "0.1.191", + "version": "0.1.192", "private": true, "scripts": { "dev": "next dev", From 95d8efb1f7d649f7f5a2c9d3945a63d310ec697f Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Thu, 29 Oct 2026 20:10:15 +0100 Subject: [PATCH 423/528] fix(control-api): prelock recipe policy subjects before the delete cleanup Deleting a workflow recipe cancelled its live approvals and removed its trigger grants in one transaction. The user-grant DELETE took user revision rows before the team-grant DELETEs took team revision rows, and the multi-row approval UPDATE took targets in scan order, so a concurrent run INSERT of the same team and user took the same rows in the opposite order and deadlocked (40P01). The cleanup's catch only logs, so the recipe's grants and live approvals survived the deletion. Add authorization_prelock_workflow_recipe_policy_revisions(TEXT, TEXT), which the caller runs after locking the approval rows it will cancel. The function derives the policy subjects from the recipe's grants and live approvals and takes them in one order: parents (teams, then users) FOR KEY SHARE, the grant rows FOR UPDATE inside the function because the runtime role cannot lock or read them, then the ordered team and user revisions. It is SECURITY DEFINER with the other prelocks' search_path, revoked from PUBLIC and granted to workflow_recipes_runtime only. The reconciler locks the approval rows right after BEGIN and calls the prelock before its first triggered write; the deletes and the cancellation UPDATE stay unchanged. Contract tests cover the definer list, the runtime EXECUTE envelope and its grantee, the static migration assertions and the call order. Three real PostgreSQL interleavings exercise the copied cleanup statements, bound to the reconciler source: grants and approvals against a run INSERT, and a grant revoke against the cleanup. Each deadlocks with 40P01 when the prelock call is removed; removing the grant-row phase reddens only the revoke case. Refs: PR #334 review r2, CR-1. --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../access/userAccessFoundationSchema.ts | 131 +++++ ...LockOrder.realPostgres.integration.test.ts | 513 +++++++++++++++++- ...composableCatalogRevisionMigration.test.ts | 24 + ....realPostgresMigration.integration.test.ts | 49 +- .../test/migrationExecutionPolicy.test.ts | 7 +- workflow-recipes/package-lock.json | 4 +- workflow-recipes/package.json | 2 +- .../src/workflow/workflowReconciler.ts | 19 + .../unit/workflow/workflowReconciler.test.ts | 28 + 11 files changed, 773 insertions(+), 10 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 43293602a7..738a15d62a 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.874", + "version": "0.1.875", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.874", + "version": "0.1.875", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 9a7b0c645d..4348775d82 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.874", + "version": "0.1.875", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/access/userAccessFoundationSchema.ts b/control-api/src/services/access/userAccessFoundationSchema.ts index d025c8d018..e441804ed4 100644 --- a/control-api/src/services/access/userAccessFoundationSchema.ts +++ b/control-api/src/services/access/userAccessFoundationSchema.ts @@ -489,6 +489,131 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis END; $$; + -- Recipe deletion (workflow-recipes workflowReconciler.ts reconcileDelete) + -- deletes the recipe's trigger grants and cancels its live approvals in one + -- transaction, after its caller locks those approval rows. Its user-grant + -- DELETE would take users before the team-grant DELETEs take teams, and its + -- multi-row approval UPDATE takes targets in scan order; run INSERTs and the + -- other prelocks take teams first. Grant writers lock the grant row before + -- its revision (workflowGrants.ts:106-111,238-242; + -- workflowGrantManagementService.ts:258-262), so these grant rows are + -- locked after their parents and before any revision. A row committed after + -- this function's reads is still deleted or cancelled, but its subject is + -- taken after the prelocked set (one trailing subject per late row). + CREATE OR REPLACE FUNCTION authorization_prelock_workflow_recipe_policy_revisions( + target_recipe_namespace TEXT, + target_recipe_name TEXT + ) + RETURNS VOID + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = pg_catalog, public, pg_temp + AS $$ + DECLARE revision RECORD; + BEGIN + -- Parent locks must precede grant rows and revisions, matching principal + -- deletion's parent-before-cascade order. + PERFORM team.id + FROM teams AS team + WHERE team.id::TEXT IN ( + SELECT trigger_grant.team_id::TEXT + FROM team_workflow_triggers AS trigger_grant + WHERE trigger_grant.recipe_namespace = target_recipe_namespace + AND trigger_grant.recipe_name = target_recipe_name + UNION + SELECT approval_target.team_id::TEXT + FROM workflow_recipe_allowed_teams AS approval_target + WHERE approval_target.recipe_namespace = target_recipe_namespace + AND approval_target.recipe_name = target_recipe_name + UNION + SELECT approval.target_team_id::TEXT + FROM workflow_approval_requests AS approval + WHERE approval.recipe_namespace = target_recipe_namespace + AND approval.recipe_name = target_recipe_name + AND approval.status IN ('pending', 'approved') + ) + ORDER BY team.id + FOR KEY SHARE; + + PERFORM principal.id + FROM users AS principal + WHERE principal.id IN ( + SELECT trigger_grant.user_id + FROM user_workflow_triggers AS trigger_grant + WHERE trigger_grant.recipe_namespace = target_recipe_namespace + AND trigger_grant.recipe_name = target_recipe_name + UNION + SELECT approval.target_user_id + FROM workflow_approval_requests AS approval + WHERE approval.recipe_namespace = target_recipe_namespace + AND approval.recipe_name = target_recipe_name + AND approval.status IN ('pending', 'approved') + ) + ORDER BY principal.id + FOR KEY SHARE; + + -- Grant writers take the grant row before its revision, so these rows are + -- held now, after their parents. The runtime role cannot lock or read the + -- subject columns, so this phase runs as the function owner. + PERFORM 1 + FROM user_workflow_triggers AS trigger_grant + WHERE trigger_grant.recipe_namespace = target_recipe_namespace + AND trigger_grant.recipe_name = target_recipe_name + ORDER BY trigger_grant.user_id + FOR UPDATE; + PERFORM 1 + FROM team_workflow_triggers AS trigger_grant + WHERE trigger_grant.recipe_namespace = target_recipe_namespace + AND trigger_grant.recipe_name = target_recipe_name + ORDER BY trigger_grant.team_id + FOR UPDATE; + PERFORM 1 + FROM workflow_recipe_allowed_teams AS approval_target + WHERE approval_target.recipe_namespace = target_recipe_namespace + AND approval_target.recipe_name = target_recipe_name + ORDER BY approval_target.team_id + FOR UPDATE; + + -- Hold every team before any user, as the other prelocks do. Bumping also + -- creates missing rows. + FOR revision IN + SELECT DISTINCT subject.subject_type, subject.subject_id + FROM ( + SELECT 'team'::TEXT AS subject_type, trigger_grant.team_id::TEXT AS subject_id + FROM team_workflow_triggers AS trigger_grant + WHERE trigger_grant.recipe_namespace = target_recipe_namespace + AND trigger_grant.recipe_name = target_recipe_name + UNION ALL + SELECT 'team', approval_target.team_id::TEXT + FROM workflow_recipe_allowed_teams AS approval_target + WHERE approval_target.recipe_namespace = target_recipe_namespace + AND approval_target.recipe_name = target_recipe_name + UNION ALL + SELECT 'user', trigger_grant.user_id::TEXT + FROM user_workflow_triggers AS trigger_grant + WHERE trigger_grant.recipe_namespace = target_recipe_namespace + AND trigger_grant.recipe_name = target_recipe_name + UNION ALL + SELECT 'team', approval.target_team_id::TEXT + FROM workflow_approval_requests AS approval + WHERE approval.recipe_namespace = target_recipe_namespace + AND approval.recipe_name = target_recipe_name + AND approval.status IN ('pending', 'approved') + UNION ALL + SELECT 'user', approval.target_user_id::TEXT + FROM workflow_approval_requests AS approval + WHERE approval.recipe_namespace = target_recipe_namespace + AND approval.recipe_name = target_recipe_name + AND approval.status IN ('pending', 'approved') + ) AS subject(subject_type, subject_id) + WHERE subject.subject_id IS NOT NULL AND subject.subject_id <> '' + ORDER BY subject.subject_type, subject.subject_id + LOOP + PERFORM authorization_bump_subject_revision(revision.subject_type, revision.subject_id); + END LOOP; + END; + $$; + CREATE OR REPLACE FUNCTION authorization_bump_user_row_revision() RETURNS TRIGGER LANGUAGE plpgsql @@ -834,6 +959,12 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis FROM PUBLIC; GRANT EXECUTE ON FUNCTION authorization_prelock_workflow_approval_trigger_revisions(UUID) TO control_api_runtime; + -- Recipe deletion runs on the Workflow Recipes pool, so only that runtime + -- role may lock its grant rows and bump its subjects first. + REVOKE ALL ON FUNCTION authorization_prelock_workflow_recipe_policy_revisions(TEXT, TEXT) + FROM PUBLIC; + GRANT EXECUTE ON FUNCTION authorization_prelock_workflow_recipe_policy_revisions(TEXT, TEXT) + TO workflow_recipes_runtime; REVOKE ALL ON FUNCTION authorization_bump_user_row_revision() FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_team_row_revision() FROM PUBLIC; REVOKE ALL ON FUNCTION authorization_bump_workflow_run_revision() FROM PUBLIC; diff --git a/control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts b/control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts index 7da071d0ab..73e35d0eb2 100644 --- a/control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts +++ b/control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts @@ -1,5 +1,6 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { randomBytes, randomUUID } from 'node:crypto' +import { readFileSync } from 'node:fs' import { Pool, type PoolClient } from 'pg' import { initDb, pool } from '../src/db.js' import type { K8sGateway } from '../src/k8s.js' @@ -65,7 +66,8 @@ describeRealPostgres('archive batches and phase updates share revision lock orde OR current_setting('application_name') LIKE 'approval-archive-%' OR current_setting('application_name') LIKE 'approval-expiry-%' OR current_setting('application_name') LIKE 'approval-decision-%' - OR current_setting('application_name') LIKE 'approval-approved-run-%' THEN + OR current_setting('application_name') LIKE 'approval-approved-run-%' + OR current_setting('application_name') LIKE 'recipe-delete-%' THEN INSERT INTO archive_delete_revision_observations (application_name, revision_table, subject_id, revision) VALUES (current_setting('application_name'), TG_TABLE_NAME, @@ -1398,6 +1400,515 @@ describeRealPostgres('archive batches and phase updates share revision lock orde ]) }) + // The cleanup statements of WorkflowReconciler.reconcileDelete's transaction, + // copied verbatim. The first new case below binds this copy to the production + // file in the same order, so these tests exercise the real cleanup, not a + // divergent local variant. + const recipeDeleteStatements = { + approvalLock: `SELECT id FROM workflow_approval_requests + WHERE recipe_namespace = $1 AND recipe_name = $2 + AND status IN ('pending', 'approved') + ORDER BY id + FOR UPDATE`, + prelock: 'SELECT authorization_prelock_workflow_recipe_policy_revisions($1, $2)', + deleteUserGrants: `DELETE FROM user_workflow_triggers + WHERE recipe_namespace = $1 AND recipe_name = $2`, + deleteTeamGrants: `DELETE FROM team_workflow_triggers + WHERE recipe_namespace = $1 AND recipe_name = $2`, + deleteAllowedTeams: `DELETE FROM workflow_recipe_allowed_teams + WHERE recipe_namespace = $1 AND recipe_name = $2`, + legacyLookup: `SELECT to_regclass('public.workflow_recipe_allowed_users')::text AS "regclass"`, + deleteLegacyGrants: `DELETE FROM workflow_recipe_allowed_users + WHERE recipe_namespace = $1 AND recipe_name = $2`, + cancelApprovals: `UPDATE workflow_approval_requests + SET status = 'cancelled', + cancelled_at = COALESCE(cancelled_at, NOW()), + cancelled_by = COALESCE(cancelled_by, 'workflow-recipe-delete') + WHERE recipe_namespace = $1 + AND recipe_name = $2 + AND status IN ('pending', 'approved')`, + } as const + + async function runRecipeDeleteCleanup( + client: PoolClient, + recipe: { namespace: string; name: string } + ): Promise { + await client.query(recipeDeleteStatements.approvalLock, [recipe.namespace, recipe.name]) + await client.query(recipeDeleteStatements.prelock, [recipe.namespace, recipe.name]) + for (const statement of [ + recipeDeleteStatements.deleteUserGrants, + recipeDeleteStatements.deleteTeamGrants, + recipeDeleteStatements.deleteAllowedTeams, + ]) { + await client.query(statement, [recipe.namespace, recipe.name]) + } + const legacy = await client.query<{ regclass?: string | null }>( + recipeDeleteStatements.legacyLookup + ) + if (legacy.rows[0]?.regclass) { + await client.query(recipeDeleteStatements.deleteLegacyGrants, [recipe.namespace, recipe.name]) + } + await client.query(recipeDeleteStatements.cancelApprovals, [recipe.namespace, recipe.name]) + } + + async function recipePolicyWitness(recipe: { namespace: string; name: string }) { + const grants = await databasePool.query<{ table_name: string; rows: number }>( + `SELECT 'user_workflow_triggers' AS table_name, + count(*)::int AS rows + FROM user_workflow_triggers + WHERE recipe_namespace = $1 AND recipe_name = $2 + UNION ALL + SELECT 'team_workflow_triggers', count(*)::int + FROM team_workflow_triggers + WHERE recipe_namespace = $1 AND recipe_name = $2 + UNION ALL + SELECT 'workflow_recipe_allowed_teams', count(*)::int + FROM workflow_recipe_allowed_teams + WHERE recipe_namespace = $1 AND recipe_name = $2 + ORDER BY table_name`, + [recipe.namespace, recipe.name] + ) + const approvals = await databasePool.query<{ status: string; cancelled_by: string | null }>( + `SELECT status, cancelled_by FROM workflow_approval_requests + WHERE recipe_namespace = $1 AND recipe_name = $2 + ORDER BY id`, + [recipe.namespace, recipe.name] + ) + return { grants: grants.rows, approvals: approvals.rows } + } + + async function removeRecipePolicyFixture( + recipe: { namespace: string; name: string }, + fixture: { runRecipes?: string[]; approvals?: string[]; users?: string[]; teams?: string[] } + ) { + await databasePool.query('TRUNCATE archive_order_barriers') + await databasePool.query('DELETE FROM workflow_runs WHERE recipe_name = ANY($1::text[])', [ + fixture.runRecipes ?? [], + ]) + await databasePool.query('DELETE FROM workflow_approval_requests WHERE id = ANY($1::uuid[])', [ + fixture.approvals ?? [], + ]) + for (const table of [ + 'user_workflow_triggers', + 'team_workflow_triggers', + 'workflow_recipe_allowed_teams', + ]) { + await databasePool.query( + `DELETE FROM ${table} WHERE recipe_namespace = $1 AND recipe_name = $2`, + [recipe.namespace, recipe.name] + ) + } + await databasePool.query('DELETE FROM users WHERE id = ANY($1::uuid[])', [fixture.users ?? []]) + await databasePool.query('DELETE FROM teams WHERE id = ANY($1::uuid[])', [fixture.teams ?? []]) + } + + it('runs the cleanup statements the reconciler executes, in the same order', () => { + const source = readFileSync( + new URL('../../workflow-recipes/src/workflow/workflowReconciler.ts', import.meta.url), + 'utf8' + ) + const normalize = (value: string) => value.replace(/\s+/g, ' ').trim() + const normalizedSource = normalize(source) + let position = -1 + for (const statement of [ + recipeDeleteStatements.approvalLock, + recipeDeleteStatements.prelock, + recipeDeleteStatements.deleteUserGrants, + recipeDeleteStatements.deleteTeamGrants, + recipeDeleteStatements.deleteAllowedTeams, + recipeDeleteStatements.legacyLookup, + recipeDeleteStatements.deleteLegacyGrants, + recipeDeleteStatements.cancelApprovals, + ]) { + const index = normalizedSource.indexOf(normalize(statement), position + 1) + expect(index, `missing after the previous statement: ${statement}`).toBeGreaterThan(position) + position = index + } + }) + + it('deletes grant rows beside a run INSERT without reversing subject locks', async () => { + const recipe = { + namespace: 'sandbox-recipes', + name: `recipe-delete-grants-${randomBytes(4).toString('hex')}`, + } + const runRecipe = `recipe-delete-grants-run-${randomBytes(4).toString('hex')}` + const user = randomUUID() + const team = randomUUID() + await databasePool.query('INSERT INTO users(id, email) VALUES ($1, $2)', [ + user, + `recipe-delete-${user}@example.test`, + ]) + await databasePool.query("INSERT INTO teams(id, name) VALUES ($1, 'Recipe delete grants')", [ + team, + ]) + await databasePool.query( + `INSERT INTO user_workflow_triggers(user_id, recipe_namespace, recipe_name) + VALUES ($1, $2, $3)`, + [user, recipe.namespace, recipe.name] + ) + await databasePool.query( + `INSERT INTO team_workflow_triggers(team_id, recipe_namespace, recipe_name) + VALUES ($1, $2, $3)`, + [team, recipe.namespace, recipe.name] + ) + await databasePool.query( + `INSERT INTO workflow_recipe_allowed_teams(team_id, recipe_namespace, recipe_name) + VALUES ($1, $2, $3)`, + [team, recipe.namespace, recipe.name] + ) + const cleanupGate = await databasePool.connect() + const competitorGate = await databasePool.connect() + const cleanup = await databasePool.connect() + const competitor = await databasePool.connect() + const cleanupPid = await pid(cleanup) + const competitorPid = await pid(competitor) + const cleanupGatePid = await pid(cleanupGate) + const competitorGatePid = await pid(competitorGate) + let cleanupOutcome: Promise | undefined + let competitorOutcome: Promise | undefined + let cleanupSettled = false + let outcomes: Outcome[] = [] + let witness: + | { + policy: Awaited> + runs: Array<{ run_id: string }> + observed: Array<{ revision_table: string; subject_id: string }> + } + | undefined + try { + await configure(cleanup, 'recipe-delete-grants') + await configure(competitor, 'recipe-delete-run-insert') + await cleanupGate.query('SELECT pg_advisory_lock($1, 41)', [barrierClass]) + await competitorGate.query('SELECT pg_advisory_lock($1, 42)', [barrierClass]) + await databasePool.query( + `INSERT INTO archive_order_barriers VALUES + ('recipe-delete-grants', 'authorization_user_revisions', $1, 41), + ('recipe-delete-run-insert', 'authorization_team_revisions', '', 42)`, + [user] + ) + await cleanup.query('BEGIN') + await cleanup.query('SET LOCAL ROLE workflow_recipes_runtime') + cleanupOutcome = observe(runRecipeDeleteCleanup(cleanup, recipe)) + // The prelock holds the team before the user it pauses on. + await waitForBlock(cleanupPid, [cleanupGatePid]) + await competitor.query('BEGIN') + competitorOutcome = observe( + createRun(competingRunInsert({ user, team }, runRecipe), competitor) + ) + const competitorBlocker = await waitForBlock(competitorPid, [cleanupPid, competitorGatePid]) + if (competitorBlocker === competitorGatePid) { + // Without the prelock the cleanup holds only the user grant row and the + // user revision, so the INSERT takes the team and waits behind the + // barrier before asking for that user: a cycle. + await competitorGate.query('SELECT pg_advisory_unlock_all()') + await waitForBlock(competitorPid, [cleanupPid]) + await cleanupGate.query('SELECT pg_advisory_unlock_all()') + } else { + // With the prelock the cleanup already holds the team, so the INSERT + // waits on the cleanup itself. + await cleanupGate.query('SELECT pg_advisory_unlock_all()') + const settled = await cleanupOutcome + await cleanup.query(settled.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') + cleanupSettled = true + await waitForBlock(competitorPid, [competitorGatePid]) + await competitorGate.query('SELECT pg_advisory_unlock_all()') + } + const cleanupResult = await cleanupOutcome + if (!cleanupSettled) { + await cleanup.query(cleanupResult.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') + cleanupSettled = true + } + const competitorResult = await competitorOutcome + await competitor.query(competitorResult.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') + outcomes = [cleanupResult, competitorResult] + console.info( + 'RECIPE_DELETE_GRANTS_OUTCOMES', + outcomes.map(result => ({ + status: result.status, + code: result.status === 'rejected' ? result.code : undefined, + })) + ) + witness = { + policy: await recipePolicyWitness(recipe), + runs: ( + await databasePool.query( + `SELECT run_id FROM workflow_runs + WHERE recipe_name = $1 AND team_id = $2 AND actor_id = $3`, + [runRecipe, team, user] + ) + ).rows, + observed: (await observedRevisions('recipe-delete-grants')).slice(0, 2), + } + } finally { + await cleanupGate.query('SELECT pg_advisory_unlock_all()') + await competitorGate.query('SELECT pg_advisory_unlock_all()') + await Promise.all([cleanupOutcome, competitorOutcome]) + if (!cleanupSettled) await cleanup.query('ROLLBACK') + await competitor.query('ROLLBACK') + for (const client of [cleanupGate, competitorGate, cleanup, competitor]) client.release() + await removeRecipePolicyFixture(recipe, { + runRecipes: [runRecipe], + users: [user], + teams: [team], + }) + } + expect(outcomes.map(result => result.status)).toEqual(['fulfilled', 'fulfilled']) + expect(witness.policy).toEqual({ + grants: [ + { table_name: 'team_workflow_triggers', rows: 0 }, + { table_name: 'user_workflow_triggers', rows: 0 }, + { table_name: 'workflow_recipe_allowed_teams', rows: 0 }, + ], + approvals: [], + }) + expect(witness.runs).toHaveLength(1) + // The prelock holds the team first; without it the cleanup's first user + // DELETE bumps the user revision first. + expect(witness.observed).toEqual([ + { revision_table: 'authorization_team_revisions', subject_id: team }, + { revision_table: 'authorization_user_revisions', subject_id: user }, + ]) + }) + + it('cancels approvals beside a run INSERT without reversing subject locks', async () => { + const recipe = { + namespace: 'sandbox-recipes', + name: `recipe-delete-approvals-${randomBytes(4).toString('hex')}`, + } + const runRecipe = `recipe-delete-approvals-run-${randomBytes(4).toString('hex')}` + const [userApprovalId, teamApprovalId] = [randomUUID(), randomUUID()].sort() as [string, string] + const user = randomUUID() + const team = randomUUID() + await databasePool.query('INSERT INTO users(id, email) VALUES ($1, $2)', [ + user, + `recipe-delete-approval-${user}@example.test`, + ]) + await databasePool.query("INSERT INTO teams(id, name) VALUES ($1, 'Recipe delete approvals')", [ + team, + ]) + // The user-targeted approval is first in heap order and lower in id order, + // so both a sequential and an index scan reach its subject first. + const targets = [ + [userApprovalId, 'target_user_id', user], + [teamApprovalId, 'target_team_id', team], + ] as const + for (const [id, column, target] of targets) { + await databasePool.query( + `INSERT INTO workflow_approval_requests( + id, recipe_namespace, recipe_name, expires_at, status, ${column}, + payload, idempotency_key, requested_at + ) VALUES ($1, $2, $3, NOW() + INTERVAL '1 hour', 'pending', $4, + '{}'::jsonb, $5, NOW())`, + [id, recipe.namespace, recipe.name, target, `recipe-delete-approval-${id}`] + ) + } + const cleanupGate = await databasePool.connect() + const competitorGate = await databasePool.connect() + const cleanup = await databasePool.connect() + const competitor = await databasePool.connect() + const cleanupPid = await pid(cleanup) + const competitorPid = await pid(competitor) + const cleanupGatePid = await pid(cleanupGate) + const competitorGatePid = await pid(competitorGate) + let cleanupOutcome: Promise | undefined + let competitorOutcome: Promise | undefined + let cleanupSettled = false + let outcomes: Outcome[] = [] + let witness: + | { + policy: Awaited> + runs: Array<{ run_id: string }> + observed: Array<{ revision_table: string; subject_id: string }> + } + | undefined + try { + await configure(cleanup, 'recipe-delete-approvals') + await configure(competitor, 'recipe-delete-approvals-run') + await cleanupGate.query('SELECT pg_advisory_lock($1, 43)', [barrierClass]) + await competitorGate.query('SELECT pg_advisory_lock($1, 44)', [barrierClass]) + await databasePool.query( + `INSERT INTO archive_order_barriers VALUES + ('recipe-delete-approvals', 'authorization_user_revisions', $1, 43), + ('recipe-delete-approvals-run', 'authorization_team_revisions', '', 44)`, + [user] + ) + await cleanup.query('BEGIN') + await cleanup.query('SET LOCAL ROLE workflow_recipes_runtime') + cleanupOutcome = observe(runRecipeDeleteCleanup(cleanup, recipe)) + await waitForBlock(cleanupPid, [cleanupGatePid]) + await competitor.query('BEGIN') + competitorOutcome = observe( + createRun(competingRunInsert({ user, team }, runRecipe), competitor) + ) + const competitorBlocker = await waitForBlock(competitorPid, [cleanupPid, competitorGatePid]) + if (competitorBlocker === competitorGatePid) { + // Without the prelock the first cancelled approval is the user-targeted + // row, so the cleanup holds the user and the INSERT takes the team + // before asking for it: a cycle. + await competitorGate.query('SELECT pg_advisory_unlock_all()') + await waitForBlock(competitorPid, [cleanupPid]) + await cleanupGate.query('SELECT pg_advisory_unlock_all()') + } else { + await cleanupGate.query('SELECT pg_advisory_unlock_all()') + const settled = await cleanupOutcome + await cleanup.query(settled.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') + cleanupSettled = true + await waitForBlock(competitorPid, [competitorGatePid]) + await competitorGate.query('SELECT pg_advisory_unlock_all()') + } + const cleanupResult = await cleanupOutcome + if (!cleanupSettled) { + await cleanup.query(cleanupResult.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') + cleanupSettled = true + } + const competitorResult = await competitorOutcome + await competitor.query(competitorResult.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') + outcomes = [cleanupResult, competitorResult] + console.info( + 'RECIPE_DELETE_APPROVALS_OUTCOMES', + outcomes.map(result => ({ + status: result.status, + code: result.status === 'rejected' ? result.code : undefined, + })) + ) + witness = { + policy: await recipePolicyWitness(recipe), + runs: ( + await databasePool.query( + `SELECT run_id FROM workflow_runs + WHERE recipe_name = $1 AND team_id = $2 AND actor_id = $3`, + [runRecipe, team, user] + ) + ).rows, + observed: (await observedRevisions('recipe-delete-approvals')).slice(0, 2), + } + } finally { + await cleanupGate.query('SELECT pg_advisory_unlock_all()') + await competitorGate.query('SELECT pg_advisory_unlock_all()') + await Promise.all([cleanupOutcome, competitorOutcome]) + if (!cleanupSettled) await cleanup.query('ROLLBACK') + await competitor.query('ROLLBACK') + for (const client of [cleanupGate, competitorGate, cleanup, competitor]) client.release() + await removeRecipePolicyFixture(recipe, { + runRecipes: [runRecipe], + approvals: [userApprovalId, teamApprovalId], + users: [user], + teams: [team], + }) + } + expect(outcomes.map(result => result.status)).toEqual(['fulfilled', 'fulfilled']) + expect(witness.policy).toEqual({ + grants: [ + { table_name: 'team_workflow_triggers', rows: 0 }, + { table_name: 'user_workflow_triggers', rows: 0 }, + { table_name: 'workflow_recipe_allowed_teams', rows: 0 }, + ], + approvals: [ + { status: 'cancelled', cancelled_by: 'workflow-recipe-delete' }, + { status: 'cancelled', cancelled_by: 'workflow-recipe-delete' }, + ], + }) + expect(witness.runs).toHaveLength(1) + expect(witness.observed).toEqual([ + { revision_table: 'authorization_team_revisions', subject_id: team }, + { revision_table: 'authorization_user_revisions', subject_id: user }, + ]) + }) + + it('keeps its grant rows beside an approval-target revoke without reversing locks', async () => { + const recipe = { + namespace: 'sandbox-recipes', + name: `recipe-delete-revoke-${randomBytes(4).toString('hex')}`, + } + const team = randomUUID() + await databasePool.query("INSERT INTO teams(id, name) VALUES ($1, 'Recipe delete revoke')", [ + team, + ]) + await databasePool.query( + `INSERT INTO team_workflow_triggers(team_id, recipe_namespace, recipe_name) + VALUES ($1, $2, $3)`, + [team, recipe.namespace, recipe.name] + ) + await databasePool.query( + `INSERT INTO workflow_recipe_allowed_teams(team_id, recipe_namespace, recipe_name) + VALUES ($1, $2, $3)`, + [team, recipe.namespace, recipe.name] + ) + const cleanupGate = await databasePool.connect() + const cleanup = await databasePool.connect() + const revoke = await databasePool.connect() + const cleanupPid = await pid(cleanup) + const revokePid = await pid(revoke) + const cleanupGatePid = await pid(cleanupGate) + let cleanupOutcome: Promise | undefined + let revokeOutcome: Promise | undefined + let cleanupSettled = false + let outcomes: Outcome[] = [] + let witness: Awaited> | undefined + try { + await configure(cleanup, 'recipe-delete-revoke') + await configure(revoke, 'recipe-delete-revoke-grant') + await cleanupGate.query('SELECT pg_advisory_lock($1, 45)', [barrierClass]) + await databasePool.query( + `INSERT INTO archive_order_barriers VALUES + ('recipe-delete-revoke', 'authorization_team_revisions', $1, 45)`, + [team] + ) + await cleanup.query('BEGIN') + await cleanup.query('SET LOCAL ROLE workflow_recipes_runtime') + cleanupOutcome = observe(runRecipeDeleteCleanup(cleanup, recipe)) + // The prelock holds the grant row before it bumps the paused team. + await waitForBlock(cleanupPid, [cleanupGatePid]) + await revoke.query('BEGIN') + // The production approval-target revoke (workflowGrantManagementService + // revokeWorkflowRecipeApprovalTeam) without its audit CTE. + revokeOutcome = observe( + revoke.query( + `DELETE FROM workflow_recipe_allowed_teams + WHERE recipe_namespace = $1 AND recipe_name = $2 AND team_id = $3::uuid + RETURNING team_id`, + [recipe.namespace, recipe.name, team] + ) + ) + // With the grant-row phase the revoke waits on the cleanup's row lock. + // Without it the revoke holds the row and waits on the team revision the + // cleanup holds: a cycle. + await waitForBlock(revokePid, [cleanupPid]) + await cleanupGate.query('SELECT pg_advisory_unlock_all()') + const cleanupResult = await cleanupOutcome + await cleanup.query(cleanupResult.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') + cleanupSettled = true + const revokeResult = await revokeOutcome + await revoke.query(revokeResult.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') + outcomes = [cleanupResult, revokeResult] + console.info( + 'RECIPE_DELETE_REVOKE_OUTCOMES', + outcomes.map(result => ({ + status: result.status, + code: result.status === 'rejected' ? result.code : undefined, + })) + ) + expect(revokeResult).toMatchObject({ status: 'fulfilled', value: { rowCount: 0 } }) + witness = await recipePolicyWitness(recipe) + } finally { + await cleanupGate.query('SELECT pg_advisory_unlock_all()') + await Promise.all([cleanupOutcome, revokeOutcome]) + if (!cleanupSettled) await cleanup.query('ROLLBACK') + await revoke.query('ROLLBACK') + for (const client of [cleanupGate, cleanup, revoke]) client.release() + await removeRecipePolicyFixture(recipe, { teams: [team] }) + } + expect(outcomes.map(result => result.status)).toEqual(['fulfilled', 'fulfilled']) + expect(witness).toEqual({ + grants: [ + { table_name: 'team_workflow_triggers', rows: 0 }, + { table_name: 'user_workflow_triggers', rows: 0 }, + { table_name: 'workflow_recipe_allowed_teams', rows: 0 }, + ], + approvals: [], + }) + }) + it.each(['not-a-uuid', 'CONTROL-PLANE-ADMIN-UI'])( 'keeps P0001 validation for non-sentinel usage team %s', async invalidTeam => { diff --git a/control-api/test/db.composableCatalogRevisionMigration.test.ts b/control-api/test/db.composableCatalogRevisionMigration.test.ts index 000aaa5253..a41b9ed469 100644 --- a/control-api/test/db.composableCatalogRevisionMigration.test.ts +++ b/control-api/test/db.composableCatalogRevisionMigration.test.ts @@ -52,6 +52,30 @@ describe('0134 composable catalog revisions migration', () => { expect(sql).toMatch( /GRANT EXECUTE ON FUNCTION authorization_prelock_workflow_approval_trigger_revisions\(UUID\)\s+TO control_api_runtime/ ) + // Recipe deletion runs on the Workflow Recipes pool: its prelock derives the + // recipe's grant and approval subjects, and only that runtime role executes + // it. The grant-row phase must sit in the function, because the runtime role + // cannot lock or read the subject columns. + expect(sql).toContain( + 'authorization_prelock_workflow_recipe_policy_revisions(\n target_recipe_namespace TEXT,\n target_recipe_name TEXT\n )' + ) + expect(sql).toContain( + 'REVOKE ALL ON FUNCTION authorization_prelock_workflow_recipe_policy_revisions(TEXT, TEXT)\n FROM PUBLIC' + ) + expect(sql).toMatch( + /GRANT EXECUTE ON FUNCTION authorization_prelock_workflow_recipe_policy_revisions\(TEXT, TEXT\)\s+TO workflow_recipes_runtime/ + ) + expect(sql).not.toMatch( + /GRANT EXECUTE ON FUNCTION authorization_prelock_workflow_recipe_policy_revisions\(TEXT, TEXT\)\s+TO control_api_runtime/ + ) + const recipePolicyFunction = sql.slice( + sql.indexOf('FUNCTION authorization_prelock_workflow_recipe_policy_revisions'), + sql.indexOf('CREATE OR REPLACE FUNCTION authorization_bump_user_row_revision') + ) + expect(recipePolicyFunction).toContain('ORDER BY trigger_grant.user_id\n FOR UPDATE') + expect(recipePolicyFunction).toContain('ORDER BY trigger_grant.team_id\n FOR UPDATE') + expect(recipePolicyFunction).toContain('ORDER BY approval_target.team_id\n FOR UPDATE') + expect(recipePolicyFunction).not.toContain('workflow_recipe_allowed_users') expect(sql).toContain('authorization_bump_workflow_approval_revision') expect(sql).toContain('authorization_bump_notification_revision') expect(sql).toContain('authorization_bump_gfs_resource_revision') diff --git a/control-api/test/db.realPostgresMigration.integration.test.ts b/control-api/test/db.realPostgresMigration.integration.test.ts index c7564d6c13..f63d144e9a 100644 --- a/control-api/test/db.realPostgresMigration.integration.test.ts +++ b/control-api/test/db.realPostgresMigration.integration.test.ts @@ -49,6 +49,7 @@ const accessFoundationDefinerSignatures = [ 'authorization_prelock_workflow_run_revisions(pg_catalog.uuid[])', 'authorization_prelock_workflow_approval_revisions(pg_catalog.uuid[])', 'authorization_prelock_workflow_approval_trigger_revisions(pg_catalog.uuid)', + 'authorization_prelock_workflow_recipe_policy_revisions(pg_catalog.text, pg_catalog.text)', 'authorization_bump_user_row_revision()', 'authorization_bump_team_row_revision()', 'authorization_bump_workflow_run_revision()', @@ -914,7 +915,8 @@ describeRealPostgres('control-api real Postgres migrations', () => { WHERE routine.oid IN ( 'public.authorization_prelock_workflow_run_revisions(uuid[])'::regprocedure, 'public.authorization_prelock_workflow_approval_revisions(uuid[])'::regprocedure, - 'public.authorization_prelock_workflow_approval_trigger_revisions(uuid)'::regprocedure + 'public.authorization_prelock_workflow_approval_trigger_revisions(uuid)'::regprocedure, + 'public.authorization_prelock_workflow_recipe_policy_revisions(text, text)'::regprocedure ) AND privilege.grantee <> routine.proowner AND privilege.privilege_type = 'EXECUTE' @@ -933,6 +935,10 @@ describeRealPostgres('control-api real Postgres migrations', () => { function_name: 'authorization_prelock_workflow_run_revisions', grantee: 'control_api_runtime', }, + { + function_name: 'authorization_prelock_workflow_recipe_policy_revisions', + grantee: 'workflow_recipes_runtime', + }, ]) expect( ( @@ -3010,11 +3016,37 @@ describeRealPostgres('control-api real Postgres migrations', () => { VALUES ($1, 'sandbox-recipes', 'runtime-cleanup-test')`, [cleanupTeamId] ) + const cleanupApprovalId = randomUUID() + await dbPool.query( + `INSERT INTO workflow_approval_requests( + id, recipe_namespace, recipe_name, expires_at, status, target_user_id, + payload, idempotency_key, requested_at + ) VALUES ($1, 'sandbox-recipes', 'runtime-cleanup-test', NOW() + INTERVAL '1 hour', + 'pending', $2, '{}'::jsonb, $3, NOW())`, + [cleanupApprovalId, cleanupUserId, `runtime-cleanup-${cleanupApprovalId}`] + ) const cleanupClient = await dbPool.connect() try { await cleanupClient.query('BEGIN') await cleanupClient.query('SET LOCAL ROLE workflow_recipes_runtime') + // The real cleanup transaction first locks the approval rows it cancels, + // then runs the prelock. Both statements need the runtime grants the + // deployment pins here; a missing EXECUTE or UPDATE would roll back the + // recipe deletion in production and be swallowed there. + const approvalLock = await cleanupClient.query( + `SELECT id FROM workflow_approval_requests + WHERE recipe_namespace = $1 AND recipe_name = $2 + AND status IN ('pending', 'approved') + ORDER BY id + FOR UPDATE`, + ['sandbox-recipes', 'runtime-cleanup-test'] + ) + expect(approvalLock.rowCount).toBe(1) + await cleanupClient.query( + 'SELECT authorization_prelock_workflow_recipe_policy_revisions($1, $2)', + ['sandbox-recipes', 'runtime-cleanup-test'] + ) for (const table of [ 'user_workflow_triggers', 'team_workflow_triggers', @@ -3059,6 +3091,21 @@ describeRealPostgres('control-api real Postgres migrations', () => { `) expect(functionAccess.rows[0]?.violations).toBe('') + // The recipe-delete prelock is the only authorization function the Workflow + // Recipes runtime may execute; every other one stays owner-only. + const recipePolicyFunctionAccess = await dbPool.query<{ function_name: string }>(` + SELECT routine.proname AS function_name + FROM pg_proc routine + JOIN pg_namespace namespace ON namespace.oid = routine.pronamespace + WHERE namespace.nspname = 'public' + AND routine.proname LIKE 'authorization_%' + AND has_function_privilege('workflow_recipes_runtime', routine.oid, 'EXECUTE') + ORDER BY routine.proname + `) + expect(recipePolicyFunctionAccess.rows).toEqual([ + { function_name: 'authorization_prelock_workflow_recipe_policy_revisions' }, + ]) + const traceService = new AgentRunEventService({ transaction: async () => { throw new Error('integration fixture supplies the transaction explicitly') diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index 35c617a198..4eb8a7b0ce 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -103,7 +103,10 @@ describe('D34 migration execution policy', () => { // covers the runs its DELETE cascade rewrites; RP-12 narrowed the // session-revocation ledger to SELECT and INSERT. O1 added the // approval-trigger pre-lock that both decision call sites take before they - // bump the approval target. The run revision trigger now returns early on + // bump the approval target. CR-1 (PR #334 review r2) added the recipe-delete + // pre-lock, which the Workflow Recipes runtime executes and which locks the + // recipe's grant rows inside the function before any revision. The run + // revision trigger now returns early on // an UPDATE that changes none of the columns catalog consumers read, so // reconciler heartbeats stop bumping team and user revisions; the GFS // resource trigger does the same for content saves that keep its identity, @@ -111,7 +114,7 @@ describe('D34 migration execution policy', () => { const files = [ [ 'src/services/access/userAccessFoundationSchema.ts', - 'e8c0e74cd98063fbd4e19e05a301ee32a421cff7cd1414e9740bb221da8a55c5', + '3d40cc6e9d131a86b484c1f5eb9ef047dd00ffff794cd8c0a62b199ecc94dd4b', ], [ 'src/services/directory/invitationDeliverySchema.ts', diff --git a/workflow-recipes/package-lock.json b/workflow-recipes/package-lock.json index 420f12ace5..e050a35493 100644 --- a/workflow-recipes/package-lock.json +++ b/workflow-recipes/package-lock.json @@ -1,12 +1,12 @@ { "name": "workload-recipes-controller", - "version": "0.1.479", + "version": "0.1.480", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "workload-recipes-controller", - "version": "0.1.479", + "version": "0.1.480", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@aws-sdk/client-s3": "^3.1009.0", diff --git a/workflow-recipes/package.json b/workflow-recipes/package.json index 2bd68b096c..32ab012bc4 100644 --- a/workflow-recipes/package.json +++ b/workflow-recipes/package.json @@ -1,6 +1,6 @@ { "name": "workload-recipes-controller", - "version": "0.1.479", + "version": "0.1.480", "description": "Workload Recipes Controller (WRC) - WorkloadRecipe lifecycle management and MCP server interface", "main": "dist/main.js", "engines": { diff --git a/workflow-recipes/src/workflow/workflowReconciler.ts b/workflow-recipes/src/workflow/workflowReconciler.ts index a11a7a4bd2..7d70eb6796 100644 --- a/workflow-recipes/src/workflow/workflowReconciler.ts +++ b/workflow-recipes/src/workflow/workflowReconciler.ts @@ -4447,6 +4447,25 @@ export class WorkflowReconciler { const client = await this.deps.pgPool.connect() try { await client.query('BEGIN') + // Take the global lock order before the first triggered write: the + // approval rows this transaction cancels, then the recipe's policy + // subjects (parent teams, then users, then the grant rows) and their + // revisions through the SECURITY DEFINER prelock. Without it the + // user-grant DELETE takes users before the team-grant DELETEs take + // teams, the approval UPDATE takes targets in scan order, and a + // concurrent run INSERT of the same team and user deadlocks (40P01). + await client.query( + `SELECT id FROM workflow_approval_requests + WHERE recipe_namespace = $1 AND recipe_name = $2 + AND status IN ('pending', 'approved') + ORDER BY id + FOR UPDATE`, + [recipeNamespace, recipeName] + ) + await client.query( + 'SELECT authorization_prelock_workflow_recipe_policy_revisions($1, $2)', + [recipeNamespace, recipeName] + ) await client.query( `DELETE FROM user_workflow_triggers WHERE recipe_namespace = $1 AND recipe_name = $2`, diff --git a/workflow-recipes/tests/unit/workflow/workflowReconciler.test.ts b/workflow-recipes/tests/unit/workflow/workflowReconciler.test.ts index 2364c645d1..8c395c3201 100644 --- a/workflow-recipes/tests/unit/workflow/workflowReconciler.test.ts +++ b/workflow-recipes/tests/unit/workflow/workflowReconciler.test.ts @@ -9198,6 +9198,34 @@ describe('WorkflowReconciler — reconcile loop', () => { const sqls = query.mock.calls.map(([sql]) => String(sql)) expect(sqls).toContainEqual('BEGIN') + // The cleanup takes the global order before its first triggered write: + // approval rows, then the definer prelock of the policy subjects, then the + // user-grant DELETE that would otherwise take users before teams. + const beginIndex = sqls.indexOf('BEGIN') + const approvalLockIndex = sqls.findIndex( + sql => + sql.includes('SELECT id FROM workflow_approval_requests') && + sql.includes('ORDER BY id') && + sql.includes('FOR UPDATE') + ) + const prelockIndex = sqls.indexOf( + 'SELECT authorization_prelock_workflow_recipe_policy_revisions($1, $2)' + ) + const userGrantDeleteIndex = sqls.findIndex(sql => + sql.includes('DELETE FROM user_workflow_triggers') + ) + expect(beginIndex).toBeGreaterThanOrEqual(0) + expect(approvalLockIndex).toBeGreaterThan(beginIndex) + expect(prelockIndex).toBeGreaterThan(approvalLockIndex) + expect(userGrantDeleteIndex).toBeGreaterThan(prelockIndex) + expect(query.mock.calls[approvalLockIndex]).toEqual([ + expect.stringContaining('FOR UPDATE'), + ['sandbox-recipes', 'test-wf'], + ]) + expect(query.mock.calls[prelockIndex]).toEqual([ + 'SELECT authorization_prelock_workflow_recipe_policy_revisions($1, $2)', + ['sandbox-recipes', 'test-wf'], + ]) expect(sqls).toContainEqual(expect.stringContaining('DELETE FROM user_workflow_triggers')) expect(sqls).toContainEqual(expect.stringContaining('DELETE FROM team_workflow_triggers')) expect(sqls).toContainEqual( From 742f0a419faa57f02f5e9a4b4d5c5632c5944232 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Fri, 30 Oct 2026 10:45:44 +0100 Subject: [PATCH 424/528] fix(control-api): publish only the caller's catalog relationships The operational hydration SQL returns every edge of an item's graph: a direct Context edge for every enabled Context that includes the item and a Host edge for every enabled Host that uses one of those Contexts. The producer serialized all of them, so a caller learned the namespace/name of another team's Context, and the instance hashes and counts of foreign Hosts and mounts. Serialize only the rows the caller reaches through the item's own access paths. Derived families (mcp_server, shared_filesystem) keep the rows whose instance id is one of the item's own edge_instance or host_edge_instance values. Simple operational families (host, context, workflow_recipe, sandbox_app) reuse the exact predicate that already builds their runtime descriptor, so the published relationships and runtimeRef cannot drift. Context items therefore stop serializing inbound uses_context rows of any principal. The authorization relationships revision still hashes every hydrated row: the live reader recomputes it over the resource's unfiltered relationship set, and hashing foreign edges is not a disclosure. --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../src/services/access/catalogProducers.ts | 101 +++++- ...gFamilies.realPostgres.integration.test.ts | 291 ++++++++++++++++++ 4 files changed, 384 insertions(+), 14 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 738a15d62a..026151fda0 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.875", + "version": "0.1.876", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.875", + "version": "0.1.876", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 4348775d82..6c9bff4280 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.875", + "version": "0.1.876", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/access/catalogProducers.ts b/control-api/src/services/access/catalogProducers.ts index e05038ade8..c69390d0c3 100644 --- a/control-api/src/services/access/catalogProducers.ts +++ b/control-api/src/services/access/catalogProducers.ts @@ -237,22 +237,90 @@ function capabilitiesForFamily(family: CatalogFamily): readonly AccessCapability } } +type SimpleOperationalFamily = 'host' | 'context' | 'workflow_recipe' | 'sandbox_app' + +/** + * Selects the relationship rows that are the simple operational item's own + * configuration. The published runtime descriptor and the serialized + * relationships share this predicate, so an item cannot expose an edge that is + * not on one of its own access paths. + */ +function ownsSimpleOperationalRelationship(input: { + family: SimpleOperationalFamily + logicalId: string + row: JsonRecord +}): boolean { + if (input.family === 'sandbox_app') { + return input.row.target_type === 'sandbox_app' && input.row.target_id === input.logicalId + } + if (input.family === 'workflow_recipe') return true + return ( + String(input.row.source_type ?? '') === input.family && + String(input.row.source_id ?? '') === input.logicalId + ) +} + +/** + * Selects the relationships a derived access path actually traverses: the + * Context edge that carries the item and, for an agent path, the Host edge that + * carries that Context. Edge instance ids are unique per source/target pair, so + * membership is exact without comparing serialized payloads. + */ +function ownsDerivedOperationalRelationship(input: { + pathRows: readonly JsonRecord[] + row: JsonRecord +}): boolean { + const instance = boundedString(input.row.relationship_instance_id, 'relationship_instance', 256) + return input.pathRows.some(pathRow => { + if (boundedString(pathRow.edge_instance, 'edge_instance', 256) === instance) return true + return nullableString(pathRow.host_edge_instance, 256) === instance + }) +} + +/** + * Only the rows the caller reaches through this item's own paths are published. + * The authorization relationships revision below still covers every hydrated + * row, because the live reader hashes the unfiltered graph. + */ +function serializableRelationshipRows(input: { + family: CatalogFamily + logicalId: string + pathRows: readonly JsonRecord[] + relationshipRows: readonly JsonRecord[] +}): readonly JsonRecord[] { + const family = input.family + if (family === 'mcp_server' || family === 'shared_filesystem') { + return input.relationshipRows.filter(row => + ownsDerivedOperationalRelationship({ pathRows: input.pathRows, row }) + ) + } + if ( + family === 'host' || + family === 'context' || + family === 'workflow_recipe' || + family === 'sandbox_app' + ) { + return input.relationshipRows.filter(row => + ownsSimpleOperationalRelationship({ family, logicalId: input.logicalId, row }) + ) + } + return input.relationshipRows +} + function simpleOperationalCandidates(input: { context: CatalogRequestContext - family: 'host' | 'context' | 'workflow_recipe' | 'sandbox_app' + family: SimpleOperationalFamily logicalId: string pathRows: readonly JsonRecord[] relationshipRows: readonly JsonRecord[] }): AuthorityCandidate[] { - const relevantRelationships = input.relationshipRows.filter(row => { - const sourceType = String(row.source_type ?? '') - const sourceId = String(row.source_id ?? '') - if (input.family === 'sandbox_app') { - return row.target_type === 'sandbox_app' && row.target_id === input.logicalId - } - if (input.family === 'workflow_recipe') return true - return sourceType === input.family && sourceId === input.logicalId - }) + const relevantRelationships = input.relationshipRows.filter(row => + ownsSimpleOperationalRelationship({ + family: input.family, + logicalId: input.logicalId, + row, + }) + ) const runtimeRef = JSON.stringify( relevantRelationships .map(row => boundedString(row.relationship_instance_id, 'relationship_instance', 256)) @@ -468,7 +536,14 @@ function hydrateRows(input: { pathRows, }) const paths = canonicalAccessPathSeeds(accessPaths) - const relationships = canonicalRelationships(relationshipRows) + const relationships = canonicalRelationships( + serializableRelationshipRows({ + family: input.family, + logicalId, + pathRows, + relationshipRows, + }) + ) representedPaths += paths.length representedRelationships += relationships.length const validUntil = row.valid_until ? new Date(String(row.valid_until)).toISOString() : null @@ -490,6 +565,10 @@ function hydrateRows(input: { 128 ), authorizationSourceRevision: input.sourceRevision, + // The operational families hash every hydrated row, not the published + // subset: the live reader recomputes this revision over the resource's + // whole relationship set, so a filtered revision would make every + // catalog access path id stale when a foreign edge exists. authorizationRelationshipsRevision: input.family === 'host' || input.family === 'context' || diff --git a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts index 23d300168a..0d2ad0fe9b 100644 --- a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts @@ -845,6 +845,297 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { } }) + it('publishes only the relationships the caller reaches through its own paths', async () => { + const suffix = randomBytes(5).toString('hex') + const foreignTeamId = randomUUID() + const foreignContextName = `i7-foreign-context-${suffix}` + const foreignHostName = `i7-foreign-host-${suffix}` + const unownedHostName = `i7-unowned-host-${suffix}` + const foreignContextId = `${config.contextsNamespace}/${foreignContextName}` + const mcpLogicalId = `${config.mcpServersNamespace}/catalog-mcp` + const contextLogicalId = `${config.contextsNamespace}/catalog-context` + const sharedFilesystemLogicalId = `${config.sharedFilesystemsNamespace}/catalog-files` + const contextSource = operationalSourceSpecs.find(value => value.family === 'context')! + const hostSource = operationalSourceSpecs.find(value => value.family === 'host')! + const foreignContext = fixture({ + plural: 'contexts', + namespace: config.contextsNamespace, + name: foreignContextName, + uid: randomUUID(), + spec: { + mcpServers: ['catalog-mcp'], + sharedFileSystems: [{ name: 'catalog-files', mountPath: '/workspace/foreign' }], + }, + }) + const foreignHost = fixture({ + plural: 'hosts', + namespace: config.hostsNamespace, + name: foreignHostName, + uid: randomUUID(), + spec: { contextRef: foreignContextName }, + }) + const unownedHost = fixture({ + plural: 'hosts', + namespace: config.hostsNamespace, + name: unownedHostName, + uid: randomUUID(), + spec: { contextRef: 'catalog-context' }, + }) + + const relationshipInstanceId = async (input: { + sourceType: string + sourceId: string + relationshipType: string + targetType: string + targetId: string + mountPath?: string + }): Promise => { + const result = await databasePool.query<{ + relationship_instance_id: string + behavior_attributes: Record + }>( + `SELECT relationship_instance_id, behavior_attributes + FROM operational_resource_relationships + WHERE environment_id = $1 AND source_type = $2 AND source_id = $3 + AND relationship_type = $4 AND target_type = $5 AND target_id = $6 + ORDER BY relationship_instance_id`, + [ + environmentId, + input.sourceType, + input.sourceId, + input.relationshipType, + input.targetType, + input.targetId, + ] + ) + const matches = input.mountPath + ? result.rows.filter(row => row.behavior_attributes.mountPath === input.mountPath) + : result.rows + expect(matches).toHaveLength(1) + return matches[0]!.relationship_instance_id + } + const catalogItem = async (family: CatalogFamily, logicalId: string) => { + const catalog = await buildAccessCatalog( + { session, families: [family], limit: 100 }, + { transaction: transaction(databasePool) } + ) + expect(catalog.complete).toBe(true) + const item = catalog.items.find(value => value.resource.logicalId === logicalId) + expect(item).toBeDefined() + return item! + } + const instanceIds = (relationships: readonly { instanceId?: string }[]) => + relationships.flatMap(relationship => + relationship.instanceId ? [relationship.instanceId] : [] + ) + + kubernetesApi.put(foreignContext.plural, foreignContext.namespace, foreignContext.object) + kubernetesApi.put(foreignHost.plural, foreignHost.namespace, foreignHost.object) + kubernetesApi.put(unownedHost.plural, unownedHost.namespace, unownedHost.object) + await indexer.reconcileSource(contextSource) + await indexer.reconcileSource(hostSource) + await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, 'I7 Foreign Team')`, [ + foreignTeamId, + ]) + await databasePool.query(`INSERT INTO team_contexts(team_id, context_id) VALUES ($1, $2)`, [ + foreignTeamId, + foreignContextName, + ]) + await databasePool.query(`INSERT INTO team_agents(team_id, agent_name) VALUES ($1, $2)`, [ + foreignTeamId, + foreignHostName, + ]) + + try { + const ownMcpEdge = await relationshipInstanceId({ + sourceType: 'context', + sourceId: contextLogicalId, + relationshipType: 'includes_mcp_server', + targetType: 'mcp_server', + targetId: mcpLogicalId, + }) + const ownHostEdge = await relationshipInstanceId({ + sourceType: 'host', + sourceId: `${config.hostsNamespace}/catalog-host`, + relationshipType: 'uses_context', + targetType: 'context', + targetId: contextLogicalId, + }) + const ownMountA = await relationshipInstanceId({ + sourceType: 'context', + sourceId: contextLogicalId, + relationshipType: 'mounts_shared_filesystem', + targetType: 'shared_filesystem', + targetId: sharedFilesystemLogicalId, + mountPath: '/workspace/a', + }) + const ownMountB = await relationshipInstanceId({ + sourceType: 'context', + sourceId: contextLogicalId, + relationshipType: 'mounts_shared_filesystem', + targetType: 'shared_filesystem', + targetId: sharedFilesystemLogicalId, + mountPath: '/workspace/b', + }) + const foreignMcpEdge = await relationshipInstanceId({ + sourceType: 'context', + sourceId: foreignContextId, + relationshipType: 'includes_mcp_server', + targetType: 'mcp_server', + targetId: mcpLogicalId, + }) + const foreignHostEdge = await relationshipInstanceId({ + sourceType: 'host', + sourceId: `${config.hostsNamespace}/${foreignHostName}`, + relationshipType: 'uses_context', + targetType: 'context', + targetId: foreignContextId, + }) + const foreignMountEdge = await relationshipInstanceId({ + sourceType: 'context', + sourceId: foreignContextId, + relationshipType: 'mounts_shared_filesystem', + targetType: 'shared_filesystem', + targetId: sharedFilesystemLogicalId, + mountPath: '/workspace/foreign', + }) + const unownedHostEdge = await relationshipInstanceId({ + sourceType: 'host', + sourceId: `${config.hostsNamespace}/${unownedHostName}`, + relationshipType: 'uses_context', + targetType: 'context', + targetId: contextLogicalId, + }) + expect( + new Set([ownMcpEdge, ownHostEdge, ownMountA, ownMountB, foreignMcpEdge, foreignHostEdge]) + .size + ).toBe(6) + + const mcpItem = await catalogItem('mcp_server', mcpLogicalId) + const mcpInstances = instanceIds(mcpItem.relationships) + expect(mcpInstances).toContain(ownMcpEdge) + expect(mcpInstances).toContain(ownHostEdge) + expect(mcpInstances).not.toContain(foreignMcpEdge) + expect(mcpInstances).not.toContain(foreignHostEdge) + expect(mcpInstances).not.toContain(unownedHostEdge) + expect(mcpItem.relationships.map(value => value.targetResourceId)).not.toContain( + `context:${foreignContextId}` + ) + + const sharedFilesystemItem = await catalogItem('shared_filesystem', sharedFilesystemLogicalId) + const sharedFilesystemInstances = instanceIds(sharedFilesystemItem.relationships) + expect(sharedFilesystemInstances).toContain(ownMountA) + expect(sharedFilesystemInstances).toContain(ownMountB) + expect(sharedFilesystemInstances).not.toContain(foreignMountEdge) + + const contextItem = await catalogItem('context', contextLogicalId) + const contextInstances = instanceIds(contextItem.relationships) + expect(contextInstances).toContain(ownMcpEdge) + expect(contextInstances).toContain(ownMountA) + expect(contextInstances).not.toContain(unownedHostEdge) + + for (const [item, capability] of [ + [mcpItem, 'mcp_server.read'], + [sharedFilesystemItem, 'shared_filesystem.read'], + ] as const) { + expect(item.accessPaths.length).toBeGreaterThan(0) + for (const path of item.accessPaths) { + await expect( + resolveLiveAuthorization( + { + session, + requiredCapability: capability, + resource: canonicalResourceIdentity(item.resource), + requestedAccessPathId: path.accessPathId, + }, + { transaction: transaction(databasePool), gateway } + ) + ).resolves.toEqual( + expect.objectContaining({ + status: 'allowed', + selectedPath: expect.objectContaining({ id: path.accessPathId }), + }) + ) + } + } + + const unexplainedRelationships: string[] = [] + let checkedRelationships = 0 + for (const family of [ + 'host', + 'context', + 'mcp_server', + 'workflow_recipe', + 'shared_filesystem', + 'sandbox_app', + ] as const) { + const catalog = await buildAccessCatalog( + { session, families: [family], limit: 100 }, + { transaction: transaction(databasePool) } + ) + expect(catalog.complete).toBe(true) + for (const item of catalog.items) { + const pathInstances = new Set() + for (const path of item.accessPaths) { + for (const dimension of [ + path.behaviorDescriptors.runtime, + path.behaviorDescriptors.filesystemScope, + ]) { + if (dimension.state !== 'known' || typeof dimension.value !== 'string') continue + for (const match of dimension.value.matchAll(/rel1_[A-Za-z0-9_-]{43}/gu)) { + pathInstances.add(match[0]) + } + } + } + for (const relationship of item.relationships) { + if (!relationship.instanceId) continue + checkedRelationships += 1 + if (!pathInstances.has(relationship.instanceId)) { + unexplainedRelationships.push( + `${family}:${item.resource.logicalId}:${relationship.instanceId}` + ) + } + } + } + } + expect(checkedRelationships).toBeGreaterThan(0) + expect(unexplainedRelationships).toEqual([]) + + await databasePool.query( + `INSERT INTO team_members(team_id, user_id, role, status) + VALUES ($1, $2, 'member', 'active')`, + [foreignTeamId, userId] + ) + try { + const joinedMcpItem = await catalogItem('mcp_server', mcpLogicalId) + const joinedInstances = instanceIds(joinedMcpItem.relationships) + expect(joinedInstances).toContain(foreignMcpEdge) + expect(joinedInstances).toContain(foreignHostEdge) + expect(joinedMcpItem.relationships.map(value => value.targetResourceId)).toContain( + `context:${foreignContextId}` + ) + } finally { + await databasePool.query(`DELETE FROM team_members WHERE team_id = $1 AND user_id = $2`, [ + foreignTeamId, + userId, + ]) + } + } finally { + await databasePool.query(`DELETE FROM team_members WHERE team_id = $1 AND user_id = $2`, [ + foreignTeamId, + userId, + ]) + await databasePool.query(`DELETE FROM team_contexts WHERE team_id = $1`, [foreignTeamId]) + await databasePool.query(`DELETE FROM team_agents WHERE team_id = $1`, [foreignTeamId]) + await databasePool.query(`DELETE FROM teams WHERE id = $1`, [foreignTeamId]) + kubernetesApi.delete(unownedHost.plural, unownedHost.namespace, unownedHostName) + kubernetesApi.delete(foreignHost.plural, foreignHost.namespace, foreignHostName) + kubernetesApi.delete(foreignContext.plural, foreignContext.namespace, foreignContextName) + await indexer.reconcileSource(contextSource) + await indexer.reconcileSource(hostSource) + } + }) + it('round-trips a catalog write path when capability selection excludes a read-only sibling', async () => { const catalog = await buildAccessCatalog( { session, families: ['gfs_resource'], limit: 10 }, From 96aa0696a0bef1c2844d9df0e431e8e76490d461 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Fri, 30 Oct 2026 10:48:07 +0100 Subject: [PATCH 425/528] fix(control-api): fail a Host closed when its Context does not resolve The live operational reader dropped every uses_context row whose canonical Context id did not resolve, and exact validation only checks the edges that remain. A Host whose configured Context is missing or ambiguous therefore resolved host.read as allowed, while the catalog marks the same Host incomplete and omits it. After the resource-enabled check, a Host now looks for its own uses_context rows whose canonical Context id is NULL and answers the existing operational_source_unavailable safe code, which the resolver already maps to the retryable operational_resource_store unavailable result. The relationship filter stays: removing it would serialize a NULL target as the string 'null' and change the revision of MCP servers and recipes. The real-PostgreSQL family test covers a missing Context, an ambiguous Context (a real Context plus an alias claiming its name), the allowed resolvable Host, and the catalog parity: complete=false with the same retryable host/operational_source_unavailable partial error and both incomplete Hosts absent. --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../access/operationalAccessReader.ts | 23 ++++ ...gFamilies.realPostgres.integration.test.ts | 123 ++++++++++++++++++ .../test/accessLiveAuthorization.test.ts | 50 +++++++ 5 files changed, 199 insertions(+), 3 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 026151fda0..62968b1672 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.876", + "version": "0.1.877", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.876", + "version": "0.1.877", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 6c9bff4280..ca56d5c5da 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.876", + "version": "0.1.877", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/access/operationalAccessReader.ts b/control-api/src/services/access/operationalAccessReader.ts index 38f8f1193c..d59a59ae8d 100644 --- a/control-api/src/services/access/operationalAccessReader.ts +++ b/control-api/src/services/access/operationalAccessReader.ts @@ -192,6 +192,29 @@ export async function loadOperationalResourceGraph(input: { return { status: 'not_found', sourceStateRevision } } + // A Host whose configured Context reference does not resolve to exactly one + // canonical Context is incomplete in the catalog, so it must fail closed here + // as well instead of validating only the edges that still resolve. + if (input.resourceType === 'host') { + const unresolvedContexts = await budgetedQuery( + input.db, + input.budget, + `SELECT 1 AS unresolved_host_context + FROM operational_resource_relationships relationship + WHERE relationship.environment_id = $1 + AND relationship.source_type = 'host' + AND relationship.source_id = $2 + AND relationship.relationship_type = 'uses_context' + AND relationship.target_type = 'context' + AND ${canonicalContextLogicalIdSql('$1', 'relationship.target_id')} IS NULL + LIMIT 1`, + [input.environmentId, input.logicalId] + ) + if (unresolvedContexts.rows.length > 0) { + return { status: 'unavailable', safeCode: 'operational_source_unavailable' } + } + } + const relationshipResult = await budgetedQuery( input.db, input.budget, diff --git a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts index 0d2ad0fe9b..82177ca792 100644 --- a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts +++ b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts @@ -1136,6 +1136,129 @@ describeRealPostgres('all aggregate catalog families on real producers', () => { } }) + it('fails a Host closed when its configured Context does not resolve', async () => { + const suffix = randomBytes(5).toString('hex') + const unresolvedHostName = `i7-unresolved-host-${suffix}` + const missingContextRef = `i7-missing-context-${suffix}` + const ambiguousContextName = `i7-amb-context-${suffix}` + const ambiguousAliasName = `i7-amb-alias-${suffix}` + const ambiguousHostName = `i7-amb-host-${suffix}` + const unresolvedHostId = `${config.hostsNamespace}/${unresolvedHostName}` + const ambiguousHostId = `${config.hostsNamespace}/${ambiguousHostName}` + const contextSource = operationalSourceSpecs.find(value => value.family === 'context')! + const hostSource = operationalSourceSpecs.find(value => value.family === 'host')! + const unresolvedHost = fixture({ + plural: 'hosts', + namespace: config.hostsNamespace, + name: unresolvedHostName, + uid: randomUUID(), + spec: { contextRef: missingContextRef }, + }) + const ambiguousContext = fixture({ + plural: 'contexts', + namespace: config.contextsNamespace, + name: ambiguousContextName, + uid: randomUUID(), + spec: { mcpServers: [], sharedFileSystems: [] }, + }) + const ambiguousAlias = fixture({ + plural: 'contexts', + namespace: config.contextsNamespace, + name: ambiguousAliasName, + uid: randomUUID(), + spec: { contextId: ambiguousContextName, mcpServers: [], sharedFileSystems: [] }, + }) + const ambiguousHost = fixture({ + plural: 'hosts', + namespace: config.hostsNamespace, + name: ambiguousHostName, + uid: randomUUID(), + spec: { contextRef: ambiguousContextName }, + }) + + kubernetesApi.put(ambiguousContext.plural, ambiguousContext.namespace, ambiguousContext.object) + kubernetesApi.put(ambiguousAlias.plural, ambiguousAlias.namespace, ambiguousAlias.object) + kubernetesApi.put(unresolvedHost.plural, unresolvedHost.namespace, unresolvedHost.object) + kubernetesApi.put(ambiguousHost.plural, ambiguousHost.namespace, ambiguousHost.object) + await indexer.reconcileSource(contextSource) + await indexer.reconcileSource(hostSource) + await databasePool.query( + `INSERT INTO user_agents(user_id, agent_name) VALUES ($1, $2), ($1, $3)`, + [userId, unresolvedHostName, ambiguousHostName] + ) + + try { + for (const logicalId of [unresolvedHostId, ambiguousHostId]) { + await expect( + resolveLiveAuthorization( + { + session, + requiredCapability: 'host.read', + resource: canonicalResourceIdentity({ + environmentId, + type: 'host', + logicalId, + }), + }, + { transaction: transaction(databasePool), gateway } + ) + ).resolves.toEqual({ + status: 'unavailable', + dependencyClass: 'operational_resource_store', + retryable: true, + }) + } + + const hostCatalog = await buildAccessCatalog( + { session, families: ['host'], limit: 100 }, + { transaction: transaction(databasePool) } + ) + expect(hostCatalog.complete).toBe(false) + expect(hostCatalog.partialErrors).toEqual([ + { producer: 'host', code: 'operational_source_unavailable', retryable: true }, + ]) + expect(hostCatalog.items.map(item => item.resource.logicalId)).not.toContain(unresolvedHostId) + expect(hostCatalog.items.map(item => item.resource.logicalId)).not.toContain(ambiguousHostId) + const resolvableHost = hostCatalog.items.find( + item => item.resource.logicalId === `${config.hostsNamespace}/catalog-host` + ) + expect(resolvableHost).toBeDefined() + for (const path of resolvableHost!.accessPaths) { + await expect( + resolveLiveAuthorization( + { + session, + requiredCapability: 'host.read', + resource: canonicalResourceIdentity(resolvableHost!.resource), + requestedAccessPathId: path.accessPathId, + }, + { transaction: transaction(databasePool), gateway } + ) + ).resolves.toEqual( + expect.objectContaining({ + status: 'allowed', + selectedPath: expect.objectContaining({ id: path.accessPathId }), + }) + ) + } + } finally { + await databasePool.query( + `DELETE FROM user_agents WHERE user_id = $1 AND agent_name = ANY($2::text[])`, + [userId, [unresolvedHostName, ambiguousHostName]] + ) + kubernetesApi.delete(unresolvedHost.plural, unresolvedHost.namespace, unresolvedHostName) + kubernetesApi.delete(ambiguousHost.plural, ambiguousHost.namespace, ambiguousHostName) + kubernetesApi.delete(ambiguousAlias.plural, ambiguousAlias.namespace, ambiguousAliasName) + kubernetesApi.delete( + ambiguousContext.plural, + ambiguousContext.namespace, + ambiguousContextName + ) + await indexer.reconcileSource(contextSource) + await indexer.reconcileSource(hostSource) + } + }) + it('round-trips a catalog write path when capability selection excludes a read-only sibling', async () => { const catalog = await buildAccessCatalog( { session, families: ['gfs_resource'], limit: 10 }, diff --git a/control-api/test/accessLiveAuthorization.test.ts b/control-api/test/accessLiveAuthorization.test.ts index 4704b75c41..25444b9d47 100644 --- a/control-api/test/accessLiveAuthorization.test.ts +++ b/control-api/test/accessLiveAuthorization.test.ts @@ -74,6 +74,7 @@ type FakeDbOptions = Readonly<{ sessionLive?: boolean hostGrantRows?: Record[] providerUid?: string + unresolvedContextReference?: boolean }> function fakeTransaction(options: FakeDbOptions = {}) { @@ -110,6 +111,10 @@ function fakeTransaction(options: FakeDbOptions = {}) { rowCount: 1, } } + if (text.includes('AS unresolved_host_context')) { + const rows = options.unresolvedContextReference ? [{ unresolved_host_context: 1 }] : [] + return { rows, rowCount: rows.length } + } if (text.includes('FROM operational_resource_relationships relationship')) { return { rows: [], rowCount: 0 } } @@ -230,6 +235,51 @@ describe('live user-access resolution', () => { ).toBe(false) }) + it('fails a Host closed when its configured Context does not resolve', async () => { + const unresolved = fakeTransaction({ unresolvedContextReference: true }) + + await expect( + resolveLiveAuthorization(hostRequest(), { transaction: unresolved.transaction }) + ).resolves.toEqual({ + status: 'unavailable', + dependencyClass: 'operational_resource_store', + retryable: true, + }) + expect( + unresolved.query.mock.calls.some(([sql]) => + String(sql).includes('AS unresolved_host_context') + ) + ).toBe(true) + expect( + unresolved.query.mock.calls.some(([sql]) => + String(sql).includes('relationship.relationship_instance_id') + ) + ).toBe(false) + + const resolved = fakeTransaction() + const gateway = { + getResourceExact: vi.fn().mockResolvedValue({ + metadata: { + name: 'host-a', + namespace: 'mcp-host', + uid: 'uid-host-a', + resourceVersion: '91', + generation: 1, + }, + spec: { enabled: true }, + }), + } + await expect( + resolveLiveAuthorization(hostRequest(), { + transaction: resolved.transaction, + gateway, + }) + ).resolves.toEqual(expect.objectContaining({ status: 'allowed' })) + expect( + resolved.query.mock.calls.some(([sql]) => String(sql).includes('AS unresolved_host_context')) + ).toBe(true) + }) + it('requires an explicit path when direct and team runtime behavior is unknown', async () => { const db = fakeTransaction({ memberships: [ From d9847ce643857e5da81e0210ad5b87538d480c6e Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Fri, 30 Oct 2026 10:51:47 +0100 Subject: [PATCH 426/528] fix(control-api): bind the team approval candidate to its requester The team arm of the workflow_approval candidates required active membership and a workflow_recipe_allowed_teams entry, but not the requester that the decision path enforces. The live resolver therefore answered allowed for a team-targeted approval that recordDecision and consumeApprovalForTrigger both refuse with approval_requester_mismatch. Add the missing predicate to the team arm: the payload has no metadata.workflowTrigger.requesterUserId, or its trimmed value is empty or the caller. The trim set is the exact set of code points String.prototype.trim removes, so the SQL agrees with parseWorkflowTriggerIntent on surrounding whitespace, including U+00A0 and U+3000, and a non-string value reads as absent. The default btrim set only removes U+0020 and would disagree. The direct arm already requires war.target_user_id = $1, and every decide guard that restricts the requester applies to team targets only, so the direct arm needs no predicate. A non-requester team member now has no candidate and resolves not_found rather than denied. --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../access/databaseAuthorityCandidates.ts | 25 +++- ...Producers.realPostgres.integration.test.ts | 112 ++++++++++++++++++ 4 files changed, 139 insertions(+), 4 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 62968b1672..06cff80a4f 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.877", + "version": "0.1.878", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.877", + "version": "0.1.878", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index ca56d5c5da..6a89372e01 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.877", + "version": "0.1.878", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/access/databaseAuthorityCandidates.ts b/control-api/src/services/access/databaseAuthorityCandidates.ts index 18e0ac5d28..427e50c759 100644 --- a/control-api/src/services/access/databaseAuthorityCandidates.ts +++ b/control-api/src/services/access/databaseAuthorityCandidates.ts @@ -25,6 +25,16 @@ export type ResourceAuthorityResult = Readonly<{ validUntil: Date | null }> +/** + * The exact code points `String.prototype.trim` removes (ECMA-262 WhiteSpace + * and LineTerminator), so `btrim(value, ...)` and `parseWorkflowTriggerIntent` + * agree on what an absent or present approval requester is. `btrim`'s default + * set only removes U+0020. + */ +const WORKFLOW_TRIGGER_TRIM_CODE_POINTS = + '\u0009\u000a\u000b\u000c\u000d\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005' + + '\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff' + export async function loadDatabaseCandidates(input: { db: Pick budget: AccessExecutionBudget @@ -134,6 +144,13 @@ export async function loadDatabaseCandidates(input: { AND wat.recipe_name = war.recipe_name WHERE $2::text = 'workflow_approval' AND war.id::text = $3 AND war.status = 'pending' AND war.expires_at > NOW() + AND ( + jsonb_typeof(war.payload #> '{metadata,workflowTrigger,requesterUserId}') + IS DISTINCT FROM 'string' + OR btrim( + war.payload #>> '{metadata,workflowTrigger,requesterUserId}', $5::text + ) IN ('', $1::text) + ) UNION ALL SELECT CASE WHEN g.subject_type = 'user' THEN 'direct' ELSE 'team' END, 'gfs_grants:' || g.id, @@ -183,7 +200,13 @@ export async function loadDatabaseCandidates(input: { SELECT * FROM candidates ORDER BY kind, team_id NULLS FIRST, grant_id LIMIT $4`, - [snapshot.userId, resource.type, resource.logicalId, input.budget.limits.accessPaths + 1] + [ + snapshot.userId, + resource.type, + resource.logicalId, + input.budget.limits.accessPaths + 1, + WORKFLOW_TRIGGER_TRIM_CODE_POINTS, + ] ) const rows = result.rows as Record[] const candidates = rows.flatMap(row => { diff --git a/control-api/test/catalogProducers.realPostgres.integration.test.ts b/control-api/test/catalogProducers.realPostgres.integration.test.ts index 9b06fa2af4..9c1c5d9787 100644 --- a/control-api/test/catalogProducers.realPostgres.integration.test.ts +++ b/control-api/test/catalogProducers.realPostgres.integration.test.ts @@ -21,6 +21,7 @@ import { projectOperationalObject, } from '../src/services/access/operationalAccessProjection.js' import { canonicalResourceIdentity } from '../src/services/access/resourceIdentity.js' +import { parseWorkflowTriggerIntent } from '../src/services/userApprovalRequestService.js' import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL @@ -216,6 +217,117 @@ describeRealPostgres('catalog producer SQL on real PostgreSQL', () => { expectVisible(await observe(member, 'workflow_approval', [allowed, denied]), []) }) + it('S3 I7 binds the team approval candidate to the trigger requester', async () => { + const requester = await insertUser() + const teammate = await insertUser() + const team = randomUUID() + await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, 'Requester Team')`, [team]) + await databasePool.query( + `INSERT INTO team_members(team_id, user_id, role, status) + VALUES ($1, $2, 'member', 'active'), ($1, $3, 'member', 'active')`, + [team, requester, teammate] + ) + await databasePool.query( + `INSERT INTO workflow_recipe_allowed_teams(recipe_namespace, recipe_name, team_id) + VALUES ('s3', 'requester', $1)`, + [team] + ) + const trigger = (requesterUserId?: unknown) => ({ + namespace: 's3', + name: 'requester-trigger', + caller: `user:${requester}`, + ...(requesterUserId === undefined ? {} : { requesterUserId }), + }) + const variants: ReadonlyArray<{ + name: string + payload: Record + requesterVisible: boolean + teammateVisible: boolean + }> = [ + { + name: 'requester B', + payload: { metadata: { workflowTrigger: trigger(requester) } }, + requesterVisible: true, + teammateVisible: false, + }, + { + name: 'no workflowTrigger', + payload: { metadata: {} }, + requesterVisible: true, + teammateVisible: true, + }, + { + name: "requester ' B '", + payload: { metadata: { workflowTrigger: trigger(` ${requester} `) } }, + requesterVisible: true, + teammateVisible: false, + }, + { + name: 'requester U+00A0/U+3000 padded', + payload: { metadata: { workflowTrigger: trigger(`\u00a0${requester}\u3000`) } }, + requesterVisible: true, + teammateVisible: false, + }, + { + name: 'whitespace-only requester', + payload: { metadata: { workflowTrigger: trigger(' \u00a0\u3000 ') } }, + requesterVisible: true, + teammateVisible: true, + }, + { + name: "requester ''", + payload: { metadata: { workflowTrigger: trigger('') } }, + requesterVisible: true, + teammateVisible: true, + }, + { + name: 'requester 123', + payload: { metadata: { workflowTrigger: trigger(123) } }, + requesterVisible: true, + teammateVisible: true, + }, + ] + const authorityFor = async (user: string, approvalId: string) => + (await observe(user, 'workflow_approval', [approvalId])).authority + + try { + for (const variant of variants) { + const approvalId = randomUUID() + await databasePool.query( + `INSERT INTO workflow_approval_requests( + id, recipe_namespace, recipe_name, expires_at, status, target_team_id, + payload, idempotency_key, payload_hash + ) VALUES ($1, 's3', 'requester', NOW() + INTERVAL '1 hour', 'pending', $2, + $3::jsonb, $4, 'hash')`, + [approvalId, team, JSON.stringify(variant.payload), `i7-${approvalId}`] + ) + // The candidate SQL must agree with the parser the decide guard uses. + const parsedRequester = parseWorkflowTriggerIntent(variant.payload)?.requesterUserId ?? null + expect(parsedRequester === null || parsedRequester === requester).toBe( + variant.requesterVisible + ) + expect(parsedRequester === null || parsedRequester === teammate).toBe( + variant.teammateVisible + ) + expect(await authorityFor(requester, approvalId)).toEqual( + variant.requesterVisible ? [approvalId] : [] + ) + expect(await authorityFor(teammate, approvalId)).toEqual( + variant.teammateVisible ? [approvalId] : [] + ) + } + } finally { + await databasePool.query(`DELETE FROM workflow_approval_requests WHERE target_team_id = $1`, [ + team, + ]) + await databasePool.query(`DELETE FROM workflow_recipe_allowed_teams WHERE team_id = $1`, [ + team, + ]) + await databasePool.query(`DELETE FROM team_members WHERE team_id = $1`, [team]) + await databasePool.query(`DELETE FROM teams WHERE id = $1`, [team]) + } + }) + it('orders and resumes arbitrary PostgreSQL text by exact UTF-8 bytes', async () => { const generated = fc.sample( fc From 3861fdbfaf523622cd8a967fe6cd47132093c2b1 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Fri, 30 Oct 2026 15:49:05 +0100 Subject: [PATCH 427/528] fix(control-api): stop notification delivery-state writes from bumping revisions The 0134 notification trigger bumped the delivery's user or team revision on every row change. Delivery claims, acks and terminal writes only change status, attempts, next_attempt_at and delivered_medium, which no catalog reader reads, so each claim and each ack invalidated every subject's catalog cursor and took that subject's revision row until the transaction ended. Two claims take their subjects in row order while run INSERTs and the prelocks take teams before users, so a claim and a concurrent run INSERT deadlocked (40P01), and any open transaction holding one of the claim's subjects delayed the batch. Return early on an UPDATE that keeps id, event_type, audience and expires_at, the four columns catalogProducerSql, catalogHydrationSql and databaseAuthorityCandidates read, with the same in-function TG_OP idiom as the run and GFS resource guards. INSERT and DELETE keep bumping: a new delivery has a random id that can sort before a client cursor, and the bump is what invalidates it. No producer UPDATEs one of those four columns. Tests: the real exported claim, ack and terminal writers leave both the user and team revisions unchanged while their rows really change state, and an expiry, event-type, audience, DELETE or INSERT change still bumps. A second real-PostgreSQL test runs the literal claim tail while an uncommitted run INSERT holds both of its subjects. Both go red with the pre-fix function (revision +2 per state write; claim 55P03), and the event-type step pins the full read set. Refs: PR #334 review r2, B4.1. --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../access/userAccessFoundationSchema.ts | 15 ++ ...omponents.realPostgres.integration.test.ts | 150 +++++++++++++++ .../test/migrationExecutionPolicy.test.ts | 6 +- ...LockOrder.realPostgres.integration.test.ts | 178 ++++++++++++++++++ 6 files changed, 350 insertions(+), 5 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 06cff80a4f..2370ebbe31 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.878", + "version": "0.1.879", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.878", + "version": "0.1.879", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 6a89372e01..e2c0b86343 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.878", + "version": "0.1.879", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/access/userAccessFoundationSchema.ts b/control-api/src/services/access/userAccessFoundationSchema.ts index e441804ed4..f02793cb48 100644 --- a/control-api/src/services/access/userAccessFoundationSchema.ts +++ b/control-api/src/services/access/userAccessFoundationSchema.ts @@ -784,6 +784,21 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis SET search_path = pg_catalog, public, pg_temp AS $$ BEGIN + -- Catalog producers, hydration and the live resolver read only these + -- columns (catalogProducerSql.ts:313-336, catalogHydrationSql.ts:599-648, + -- databaseAuthorityCandidates.ts:182-198). Delivery claims, acks and + -- terminal writes change only status, attempts, next_attempt_at and + -- delivered_medium (notificationDeliveryQueueService.ts:315-321,698-704, + -- 725-776, notificationDeliveryTerminalService.ts:47-50,74-78, + -- notificationAckService.ts:21-30), so they need no revision bump. Same + -- in-function TG_OP idiom as the run guard above. + IF TG_OP = 'UPDATE' AND ( + OLD.id, OLD.event_type, OLD.audience, OLD.expires_at + ) IS NOT DISTINCT FROM ( + NEW.id, NEW.event_type, NEW.audience, NEW.expires_at + ) THEN + RETURN NEW; + END IF; IF TG_OP <> 'DELETE' THEN PERFORM authorization_bump_subject_revision('user', NEW.audience->>'userId'); PERFORM authorization_bump_subject_revision('team', NEW.audience->>'teamId'); diff --git a/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts b/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts index 118fc13ffa..59d3cb9b42 100644 --- a/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts +++ b/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts @@ -8,6 +8,12 @@ import { GFS_HYDRATION_SQL } from '../src/services/access/catalogHydrationSql.js import { canonicalEnvironmentId } from '../src/services/access/operationalAccessProjection.js' import { applyComposableCatalogRevisionSchema } from '../src/services/access/userAccessFoundationSchema.js' import type { ExternalSessionAuthorityContext } from '../src/services/auth/externalSessionAuthentication.js' +import { acknowledgeDesktopNotificationDelivery } from '../src/services/notificationAckService.js' +import { + markUserBoundNotificationDeliveryFailed, + markUserBoundNotificationDeliverySent, + markUserBoundNotificationDeliverySkippedNoBot, +} from '../src/services/notificationDeliveryQueueService.js' import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL @@ -638,6 +644,150 @@ describeRealPostgres('composable catalog revisions on real PostgreSQL', () => { expect(await userRevision(userIds[0])).toBe(beforeUser + 1) }) + it('leaves notification subject revisions unchanged on delivery state writes and bumps them when a catalog column changes', async () => { + const notificationUser = userIds[0] + const notificationTeam = teamIds[0] + async function delivery( + eventType: string, + audience: Record, + payload: Record + ): Promise { + const result = await databasePool.query<{ id: string }>( + `INSERT INTO notification_deliveries( + event_type, dedupe_key, audience, payload, status, next_attempt_at + ) VALUES ($1, $2, $3::jsonb, $4::jsonb, 'queued', NOW() - INTERVAL '1 minute') + RETURNING id::text`, + [ + eventType, + `notification-revision-${randomUUID()}`, + JSON.stringify(audience), + JSON.stringify(payload), + ] + ) + return result.rows[0]!.id + } + + // One row per writer (N6): every mark* only writes while its row is still + // queued/retrying, so a row reused after `sent` would make the next call a + // silent no-op and its negative assertion vacuous. + const sentRow = await delivery('approval.requested', { userId: notificationUser }, {}) + const failedRow = await delivery('approval.requested', { userId: notificationUser }, {}) + // At least one state write targets a team audience, so the team negative + // has a write witness of its own. + const skippedRow = await delivery('approval.requested', { teamId: notificationTeam }, {}) + const claimRow = await delivery('approval.requested', { userId: notificationUser }, {}) + // No `notificationId` in the payload: the ack then skips + // markSdkNotificationDelivered, which writes through the module pool + // instead of the test client (notificationAckService.ts:36-39). + const desktopRow = await delivery( + 'plugin_workload_sdk.notification', + { userId: notificationUser }, + { origin: 'plugin_workload_sdk', title: 'no invocation' } + ) + const beforeUser = await userRevision(notificationUser) + const beforeTeam = await teamRevision(notificationTeam) + + // The real exported writers, each against the test pool as their `db`. + expect(await markUserBoundNotificationDeliverySent(sentRow, databasePool)).toBe(true) + expect(await markUserBoundNotificationDeliveryFailed(failedRow, databasePool)).toBe(true) + expect(await markUserBoundNotificationDeliverySkippedNoBot(skippedRow, databasePool)).toBe(true) + expect( + await acknowledgeDesktopNotificationDelivery(notificationUser, desktopRow, databasePool) + ).toEqual({ status: 'acked' }) + // The claims' literal SET clause (notificationDeliveryQueueService.ts:315-321, + // :698-704); both claims change only these three columns. + await databasePool.query( + `UPDATE notification_deliveries nd + SET status = 'retrying', + attempts = nd.attempts + 1, + next_attempt_at = NOW() + INTERVAL '30 seconds' + WHERE nd.id = $1`, + [claimRow] + ) + + // Write witness: the state writes really changed the rows. + const written = await databasePool.query<{ + id: string + status: string + attempts: number + delivered_medium: string | null + }>( + `SELECT id::text, status, attempts, delivered_medium + FROM notification_deliveries + WHERE id = ANY($1::uuid[])`, + [[sentRow, failedRow, skippedRow, claimRow, desktopRow]] + ) + expect(Object.fromEntries(written.rows.map(row => [row.id, row]))).toEqual({ + [sentRow]: { id: sentRow, status: 'sent', attempts: 0, delivered_medium: null }, + [failedRow]: { id: failedRow, status: 'retrying', attempts: 0, delivered_medium: null }, + [skippedRow]: { + id: skippedRow, + status: 'skipped_no_bot', + attempts: 0, + delivered_medium: null, + }, + [claimRow]: { id: claimRow, status: 'retrying', attempts: 1, delivered_medium: null }, + [desktopRow]: { id: desktopRow, status: 'sent', attempts: 0, delivered_medium: 'desktop' }, + }) + // Negative witness: none of the delivery state writes is a catalog change. + expect(await userRevision(notificationUser)).toBe(beforeUser) + expect(await teamRevision(notificationTeam)).toBe(beforeTeam) + + // Liveness in the same test. Each step starts from a fresh row, and each + // UPDATE bumps the NEW and the OLD subject of its own row. + const expiresRow = await delivery('approval.requested', { userId: notificationUser }, {}) + let userBefore = await userRevision(notificationUser) + await databasePool.query( + `UPDATE notification_deliveries + SET expires_at = NOW() + INTERVAL '1 hour' + WHERE id = $1`, + [expiresRow] + ) + // One user audience: NEW user +1 and OLD user +1. + expect(await userRevision(notificationUser)).toBe(userBefore + 2) + + const eventTypeRow = await delivery('approval.requested', { userId: notificationUser }, {}) + userBefore = await userRevision(notificationUser) + await databasePool.query( + `UPDATE notification_deliveries SET event_type = 'approval.updated' WHERE id = $1`, + [eventTypeRow] + ) + // event_type is the hydration display name (catalogHydrationSql.ts:629). + expect(await userRevision(notificationUser)).toBe(userBefore + 2) + + const audienceRow = await delivery('approval.requested', { userId: notificationUser }, {}) + userBefore = await userRevision(notificationUser) + let teamBefore = await teamRevision(notificationTeam) + await databasePool.query( + `UPDATE notification_deliveries SET audience = $2::jsonb WHERE id = $1`, + [audienceRow, JSON.stringify({ teamId: notificationTeam })] + ) + // user -> team: OLD user +1, NEW team +1. + expect(await userRevision(notificationUser)).toBe(userBefore + 1) + expect(await teamRevision(notificationTeam)).toBe(teamBefore + 1) + userBefore = await userRevision(notificationUser) + teamBefore = await teamRevision(notificationTeam) + await databasePool.query( + `UPDATE notification_deliveries SET audience = $2::jsonb WHERE id = $1`, + [audienceRow, JSON.stringify({ userId: notificationUser })] + ) + // team -> user: OLD team +1, NEW user +1; the round trip is user +2, team +2. + expect(await userRevision(notificationUser)).toBe(userBefore + 1) + expect(await teamRevision(notificationTeam)).toBe(teamBefore + 1) + + const deleteRow = await delivery('approval.requested', { teamId: notificationTeam }, {}) + teamBefore = await teamRevision(notificationTeam) + await databasePool.query('DELETE FROM notification_deliveries WHERE id = $1', [deleteRow]) + // DELETE bumps the removed row's subject once. + expect(await teamRevision(notificationTeam)).toBe(teamBefore + 1) + + userBefore = await userRevision(notificationUser) + await delivery('approval.requested', { userId: notificationUser }, {}) + // A new delivery has a random id that can sort before a client cursor, so + // INSERT keeps bumping once. + expect(await userRevision(notificationUser)).toBe(userBefore + 1) + }) + it('preserves concurrent GFS invalidation increments on one resource component', async () => { const resourceId = randomUUID() const drive = `r6-${randomBytes(4).toString('hex')}` diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index 4eb8a7b0ce..6ec37412f7 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -110,11 +110,13 @@ describe('D34 migration execution policy', () => { // an UPDATE that changes none of the columns catalog consumers read, so // reconciler heartbeats stop bumping team and user revisions; the GFS // resource trigger does the same for content saves that keep its identity, - // name, parent and tombstone. + // name, parent and tombstone, and the notification trigger does the same + // for delivery-state writes that keep its id, event type, audience and + // expiry. const files = [ [ 'src/services/access/userAccessFoundationSchema.ts', - '3d40cc6e9d131a86b484c1f5eb9ef047dd00ffff794cd8c0a62b199ecc94dd4b', + 'f86f9d57136a6925196bca2312a33fad4dfceef77517823fe445a24a028b4316', ], [ 'src/services/directory/invitationDeliverySchema.ts', diff --git a/control-api/test/revisionWritersLockOrder.realPostgres.integration.test.ts b/control-api/test/revisionWritersLockOrder.realPostgres.integration.test.ts index b1bf02d525..d1950e8325 100644 --- a/control-api/test/revisionWritersLockOrder.realPostgres.integration.test.ts +++ b/control-api/test/revisionWritersLockOrder.realPostgres.integration.test.ts @@ -645,4 +645,182 @@ describeRealPostgres('revision writers share one lock order on real PostgreSQL', }) } }) + + it('a delivery claim takes no revision row while a run INSERT holds its subjects', async () => { + const { + users: [user], + teams: [team], + } = await fixture() + // Two deliveries, one per audience an approval delivery can name + // (notificationEmitter.ts:18-23). The user row is older, so the claim's + // ORDER BY visits it first: the cycle shape the review probe reproduced as + // P1/P2. + const inserted = await databasePool.query<{ id: string; event_type: string }>( + `INSERT INTO notification_deliveries( + event_type, dedupe_key, audience, payload, status, next_attempt_at, created_at + ) VALUES + ('approval.requested', $1, jsonb_build_object('userId', $2::text), '{}'::jsonb, + 'queued', NOW() - INTERVAL '1 minute', NOW() - INTERVAL '2 minutes'), + ('approval.requested', $3, jsonb_build_object('teamId', $4::text), '{}'::jsonb, + 'queued', NOW() - INTERVAL '1 minute', NOW() - INTERVAL '1 minute') + RETURNING id::text`, + [`claim-user-${randomUUID()}`, user, `claim-team-${randomUUID()}`, team] + ) + const deliveryIds = inserted.rows.map(row => row.id) + + // The claim tail, copied from notificationDeliveryQueueService.ts:299-321 + // (`locked` + `claimed`); only the candidates source is a test fixture. + const claim = ` + WITH candidates AS ( + SELECT nd.id, + 'telegram'::text AS medium, + 'provider-user'::text AS provider_user_id, + NULL::text AS provider_workspace_id, + 'provider-channel'::text AS provider_channel_id, + NULL::text AS communication_channel_ref, + 'sandbox-recipes/claim'::text AS mcp_host_ref, + 1 AS sort_priority, + nd.created_at + FROM notification_deliveries nd + WHERE nd.id = ANY($1::uuid[]) + ), + locked AS ( + SELECT nd.id, + c.medium, + c.provider_user_id, + c.provider_workspace_id, + c.provider_channel_id, + c.communication_channel_ref, + c.mcp_host_ref + FROM notification_deliveries nd + JOIN candidates c ON c.id = nd.id + ORDER BY c.sort_priority ASC, + c.created_at ASC, + nd.id ASC + LIMIT $2 + FOR UPDATE OF nd SKIP LOCKED + ), + claimed AS ( + UPDATE notification_deliveries nd + SET status = 'retrying', + attempts = nd.attempts + 1, + next_attempt_at = NOW() + INTERVAL '30 seconds' + FROM locked c + WHERE nd.id = c.id + RETURNING nd.id + ) + SELECT id::text FROM claimed` + + async function revisionRowHeld(table: string, column: string, id: string): Promise { + const witness = await databasePool.connect() + try { + await witness.query('BEGIN') + await witness.query(`SELECT 1 FROM ${table} WHERE ${column} = $1 FOR UPDATE NOWAIT`, [id]) + return 'acquired' + } catch (error) { + return String((error as { code?: string }).code) + } finally { + await witness.query('ROLLBACK').catch(() => undefined) + witness.release() + } + } + + const beforeUser = await revision('user', user!) + const beforeTeam = await revision('team', team!) + const runInsert = await databasePool.connect() + const claimSession = await databasePool.connect() + let claimSettled = false + try { + // Session B: the competing run INSERT of (team, user), uncommitted. The + // run trigger takes the team revision before the user revision. + await begin(runInsert, 'claim-run-insert') + const runOutcome = observe( + createRun( + { + recipe_namespace: 'test', + recipe_name: 'claim-order', + actor_type: 'user', + team_id: team, + usage_team_id: team, + actor_id: user, + idempotency_key: null, + trigger_source: 'onDemand', + ttl_seconds_after_finished: 2_592_000, + }, + runInsert + ) + ) + // N8 witness: before the claim runs, the uncommitted run INSERT really + // holds both revision rows. `FOR UPDATE NOWAIT` is the direct probe. + const deadline = Date.now() + 5_000 + let held = ['', ''] + while (Date.now() < deadline) { + held = [ + await revisionRowHeld('authorization_user_revisions', 'user_id', user!), + await revisionRowHeld('authorization_team_revisions', 'team_id', team!), + ] + if (held.every(code => code === '55P03')) break + await new Promise(resolve => setTimeout(resolve, 10)) + } + expect(held).toEqual(['55P03', '55P03']) + + // Session A: the claim with a 1 s lock timeout. Without the guard the + // claim's first row (the user audience) waits for the run INSERT's user + // revision and fails with 55P03, as probe P2 measured at 1.5 s (57014). + await begin(claimSession, 'claim-session') + await claimSession.query("SET LOCAL lock_timeout = '1s'") + const claimOutcome = claimSession.query<{ id: string }>(claim, [deliveryIds, 2]).then( + result => ({ status: 'fulfilled' as const, ids: result.rows.map(row => row.id) }), + error => ({ + status: 'rejected' as const, + code: String((error as { code?: string }).code), + ids: [] as string[], + }) + ) + const claimResult = await claimOutcome + await claimSession.query(claimResult.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') + claimSettled = true + const runResult = await runOutcome + await runInsert.query('ROLLBACK') + console.info('DELIVERY_CLAIM_OUTCOMES', { + claim: claimResult, + runInsert: runResult.status, + }) + expect(claimResult.status).toBe('fulfilled') + expect(claimResult.ids.sort()).toEqual([...deliveryIds].sort()) + expect(runResult.status).toBe('fulfilled') + + // Write witness: the claim really claimed both rows. + const claimedRows = await databasePool.query<{ + id: string + status: string + attempts: number + }>( + `SELECT id::text, status, attempts FROM notification_deliveries + WHERE id = ANY($1::uuid[])`, + [deliveryIds] + ) + expect( + Object.fromEntries( + claimedRows.rows.map(row => [row.id, { status: row.status, attempts: row.attempts }]) + ) + ).toEqual({ + [deliveryIds[0]!]: { status: 'retrying', attempts: 1 }, + [deliveryIds[1]!]: { status: 'retrying', attempts: 1 }, + }) + // Negative witness: the claim took no revision row, and the run INSERT + // rolled back, so both subjects stay where the fixture left them. + expect(await revision('user', user!)).toBe(beforeUser) + expect(await revision('team', team!)).toBe(beforeTeam) + } finally { + await Promise.resolve() + if (!claimSettled) await claimSession.query('ROLLBACK').catch(() => undefined) + await runInsert.query('ROLLBACK').catch(() => undefined) + claimSession.release() + runInsert.release() + await databasePool.query('DELETE FROM notification_deliveries WHERE id = ANY($1::uuid[])', [ + deliveryIds, + ]) + } + }) }) From f3d8f93c636e19b97dd3f90c3bfbe06fb932c72e Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Fri, 30 Oct 2026 15:55:56 +0100 Subject: [PATCH 428/528] fix(workflow-recipes): prelock approval subjects before failing stuck runs checkStuckRuns fails up to 50 runs in one transaction. Each phase change of a run whose approval is provider-bound makes the run trigger INSERT one workflow.run.completed delivery, and that INSERT bumps the delivery's subject, one per run in FIND_STUCK_RUNS row order (no ORDER BY). A concurrent run INSERT takes its team before its user, so the sweep and the INSERT deadlocked (40P01). A failed sweep only logs and retries on the next tick. FIND_STUCK_RUNS now also returns approval_request_id. Before the first FAIL_STUCK_RUN, the sweep collects the distinct non-null ids and calls the existing authorization_prelock_workflow_approval_revisions, whose subject set covers every audience the nested INSERT can bump and takes parents, then teams, then users. A batch with no approval id makes no call, and the FAIL loop is unchanged. The 0134 body grants the function's EXECUTE to workflow_recipes_runtime, because the sweep runs on that pool, and the contract tests now list both authorization functions that runtime may execute. Tests: a real-PostgreSQL interleaving runs the literal sweep statements as workflow_recipes_runtime while a competing run INSERT of the same team and user waits on it; one copied statement case binds that SQL to the processor source. The unit test pins FIND -> prelock with the distinct non-null ids -> FAIL per row, and the no-approval and no-row cases keep their witnesses. Removing the prelock call reddens the unit and binding tests, removing the GRANT statement reddens the real-PostgreSQL case and the contract tests. Refs: PR #334 review r2, B4.1 residual. --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../access/userAccessFoundationSchema.ts | 4 + ...LockOrder.realPostgres.integration.test.ts | 268 +++++++++++++++++- ...composableCatalogRevisionMigration.test.ts | 5 + ....realPostgresMigration.integration.test.ts | 10 +- .../test/migrationExecutionPolicy.test.ts | 6 +- workflow-recipes/package-lock.json | 4 +- workflow-recipes/package.json | 2 +- .../src/reconciler/dbRunProcessor.test.ts | 76 +++++ .../src/reconciler/dbRunProcessor.ts | 23 +- 11 files changed, 391 insertions(+), 13 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 2370ebbe31..31dcda09da 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.879", + "version": "0.1.880", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.879", + "version": "0.1.880", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index e2c0b86343..6e0857ceab 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.879", + "version": "0.1.880", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/access/userAccessFoundationSchema.ts b/control-api/src/services/access/userAccessFoundationSchema.ts index f02793cb48..dcbcab1900 100644 --- a/control-api/src/services/access/userAccessFoundationSchema.ts +++ b/control-api/src/services/access/userAccessFoundationSchema.ts @@ -970,6 +970,10 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis REVOKE ALL ON FUNCTION authorization_prelock_workflow_approval_revisions(UUID[]) FROM PUBLIC; GRANT EXECUTE ON FUNCTION authorization_prelock_workflow_approval_revisions(UUID[]) TO control_api_runtime; + -- The stuck-run sweep runs on the Workflow Recipes pool, so that runtime + -- role may hold the batch's approval subjects before failing the runs. + GRANT EXECUTE ON FUNCTION authorization_prelock_workflow_approval_revisions(UUID[]) + TO workflow_recipes_runtime; REVOKE ALL ON FUNCTION authorization_prelock_workflow_approval_trigger_revisions(UUID) FROM PUBLIC; GRANT EXECUTE ON FUNCTION authorization_prelock_workflow_approval_trigger_revisions(UUID) diff --git a/control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts b/control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts index 73e35d0eb2..97e84fa0ec 100644 --- a/control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts +++ b/control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts @@ -67,7 +67,8 @@ describeRealPostgres('archive batches and phase updates share revision lock orde OR current_setting('application_name') LIKE 'approval-expiry-%' OR current_setting('application_name') LIKE 'approval-decision-%' OR current_setting('application_name') LIKE 'approval-approved-run-%' - OR current_setting('application_name') LIKE 'recipe-delete-%' THEN + OR current_setting('application_name') LIKE 'recipe-delete-%' + OR current_setting('application_name') LIKE 'stuck-run-%' THEN INSERT INTO archive_delete_revision_observations (application_name, revision_table, subject_id, revision) VALUES (current_setting('application_name'), TG_TABLE_NAME, @@ -1937,4 +1938,269 @@ describeRealPostgres('archive batches and phase updates share revision lock orde expect(await revisions(ids)).toEqual(before) } ) + + // The stuck-run sweep's statements, copied from + // workflow-recipes/src/reconciler/dbRunProcessor.ts:153-162 (FIND_STUCK_RUNS, + // now also returning approval_request_id) and :164-172 (FAIL_STUCK_RUN). The + // runner below executes them in the same order as :566-597. + const stuckRunStatements = { + find: `SELECT run_id, started_at, max_duration_seconds, approval_request_id + FROM workflow_runs + WHERE phase = 'Running' + AND owner_instance_id = $1 + AND max_duration_seconds IS NOT NULL + AND started_at IS NOT NULL + AND started_at < now() - (max_duration_seconds || ' seconds')::interval + FOR UPDATE SKIP LOCKED + LIMIT 50`, + prelock: 'SELECT authorization_prelock_workflow_approval_revisions($1::uuid[])', + fail: `UPDATE workflow_runs + SET phase = 'Failed', + completed_at = now(), + last_reconciled_at = now(), + updated_at = now() + WHERE run_id = $1 + AND phase = 'Running' + AND owner_instance_id = $2`, + } as const + + // T3 executes a copy of the sweep, so bind that copy to the processor source: + // removing the prelock call from checkStuckRuns must redden this test as well + // as the unit test (mutation M-B1a), not only the real-PostgreSQL case. + it('runs the stuck-run sweep statements the processor executes, in the same order', () => { + const source = readFileSync( + new URL('../../workflow-recipes/src/reconciler/dbRunProcessor.ts', import.meta.url), + 'utf8' + ) + const normalize = (value: string) => value.replace(/\s+/g, ' ').trim() + const normalizedSource = normalize(source) + for (const statement of [stuckRunStatements.find, stuckRunStatements.fail]) { + expect(normalizedSource, `missing statement: ${statement}`).toContain(normalize(statement)) + } + expect(normalizedSource).toContain(stuckRunStatements.prelock) + // FIND must return the column the prelock feeds on. + expect(normalize(stuckRunStatements.find)).toContain('approval_request_id') + const sweepStart = normalizedSource.indexOf('async function checkStuckRuns()') + expect(sweepStart).toBeGreaterThan(-1) + const sweepBody = normalizedSource.slice( + sweepStart, + normalizedSource.indexOf('return {', sweepStart) + ) + let position = -1 + for (const call of [ + 'FIND_STUCK_RUNS', + 'authorization_prelock_workflow_approval_revisions($1::uuid[])', + 'FAIL_STUCK_RUN', + ]) { + const index = sweepBody.indexOf(call, position + 1) + expect(index, `missing after the previous statement: ${call}`).toBeGreaterThan(position) + position = index + } + // The prelock is conditional: a batch with no approval ids makes no call. + expect(sweepBody).toContain('if (approvalRequestIds.length > 0)') + }) + + it('fails stuck provider-bound runs beside a run INSERT without reversing subject locks', async () => { + const instanceId = `stuck-sweep-${randomBytes(4).toString('hex')}` + const competitorRecipe = `stuck-run-competitor-${randomBytes(4).toString('hex')}` + const recipeName = `stuck-run-${randomBytes(4).toString('hex')}` + const user = randomUUID() + const team = randomUUID() + // N3: the user-targeted run carries the smaller explicit run_id and is + // inserted first, so the heap scan FIND_STUCK_RUNS (no ORDER BY) uses + // reaches it first. + const userRunId = '00000000-0000-4000-8000-000000000001' + const teamRunId = '00000000-0000-4000-8000-000000000002' + await databasePool.query('INSERT INTO users(id, email) VALUES ($1, $2)', [ + user, + `stuck-run-${user}@example.test`, + ]) + await databasePool.query("INSERT INTO teams(id, name) VALUES ($1, 'Stuck run sweep')", [team]) + const approvalIds: string[] = [] + for (const [runId, column, target] of [ + [userRunId, 'target_user_id', user], + [teamRunId, 'target_team_id', team], + ] as const) { + const approvalId = randomUUID() + approvalIds.push(approvalId) + await databasePool.query( + `INSERT INTO workflow_approval_requests( + id, recipe_namespace, recipe_name, expires_at, status, ${column}, + payload, idempotency_key, requested_at, decided_at + ) VALUES ($1, 'sandbox-recipes', $2, NOW() + INTERVAL '1 hour', 'consumed', $3, + $4::jsonb, $5, NOW() - INTERVAL '2 hours', NOW() - INTERVAL '2 hours')`, + [ + approvalId, + recipeName, + target, + JSON.stringify({ + metadata: { + workflowTrigger: { + providerBinding: { + medium: 'telegram', + providerChannelId: `channel-${randomUUID()}`, + }, + }, + }, + }), + `stuck-run-${approvalId}`, + ] + ) + await databasePool.query( + `INSERT INTO workflow_approval_trigger_intents( + approval_request_id, trigger_namespace, trigger_name, trigger_caller_key + ) VALUES ($1, 'sandbox-recipes', $2, 'sandbox-recipes/' || $2)`, + [approvalId, recipeName] + ) + await databasePool.query( + `INSERT INTO workflow_runs( + run_id, recipe_namespace, recipe_name, phase, actor_type, actor_id, team_id, + trigger_source, approval_request_id, owner_instance_id, max_duration_seconds, started_at + ) VALUES ($1, 'sandbox-recipes', $2, 'Running', 'user', $3, $4, 'onDemand', $5, $6, 1, + NOW() - INTERVAL '1 hour')`, + [runId, recipeName, user, team, approvalId, instanceId] + ) + } + const before = await revisions({ users: [user], teams: [team] }) + + const sweep = await databasePool.connect() + const competitor = await databasePool.connect() + const sweepPid = await pid(sweep) + const competitorPid = await pid(competitor) + let sweepFailure: unknown + let competitorWork: Promise | undefined + let competitorOutcome: Outcome | undefined + let phases: { + foundFirst?: string + firstFailed?: boolean + competitorBlocker?: number + committed?: boolean + } = {} + let witness: + | { + runs: Array<{ run_id: string; phase: string }> + deliveries: Array> + observed: Array<{ revision_table: string; subject_id: string }> + after: Awaited> + } + | undefined + try { + await configure(sweep, 'stuck-run-sweep') + await configure(competitor, 'stuck-run-insert') + await sweep.query('BEGIN') + // The sweep runs on the Workflow Recipes pool (dbRunProcessor.ts:566). + await sweep.query('SET LOCAL ROLE workflow_recipes_runtime') + const found = await sweep.query<{ run_id: string; approval_request_id: string | null }>( + stuckRunStatements.find, + [instanceId] + ) + phases.foundFirst = found.rows[0]?.run_id + const foundApprovalIds = [ + ...new Set( + found.rows + .map(row => row.approval_request_id) + .filter((id): id is string => typeof id === 'string' && id.length > 0) + ), + ] + await sweep.query(stuckRunStatements.prelock, [foundApprovalIds]) + const firstFailure = await sweep.query(stuckRunStatements.fail, [ + found.rows[0]!.run_id, + instanceId, + ]) + phases.firstFailed = firstFailure.rowCount === 1 + + // B: the competing run INSERT of (team, user), started while A holds the + // prelocked subjects; it must block on A. + await competitor.query('BEGIN') + competitorWork = observe( + createRun(competingRunInsert({ user, team }, competitorRecipe), competitor) + ) + phases.competitorBlocker = await waitForBlock(competitorPid, [sweepPid]) + + // A: the remaining stuck run, then COMMIT. + for (const row of found.rows.slice(1)) { + const update = await sweep.query(stuckRunStatements.fail, [row.run_id, instanceId]) + expect(update.rowCount).toBe(1) + } + await sweep.query('COMMIT') + phases.committed = true + competitorOutcome = await competitorWork + await competitor.query(competitorOutcome.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') + + witness = { + runs: ( + await databasePool.query<{ run_id: string; phase: string }>( + `SELECT run_id, phase FROM workflow_runs WHERE run_id = ANY($1::uuid[]) ORDER BY run_id`, + [[userRunId, teamRunId]] + ) + ).rows, + deliveries: ( + await databasePool.query<{ audience: Record }>( + `SELECT audience FROM notification_deliveries + WHERE dedupe_key = ANY($1::text[]) ORDER BY audience::text`, + [ + [ + `workflow.run.completed:${userRunId}:Failed`, + `workflow.run.completed:${teamRunId}:Failed`, + ], + ] + ) + ).rows.map(row => row.audience), + observed: (await observedRevisions('stuck-run-sweep')).slice(0, 2), + after: await revisions({ users: [user], teams: [team] }), + } + } catch (error) { + sweepFailure = error + } finally { + if (!phases.committed) await sweep.query('ROLLBACK').catch(() => undefined) + await competitor.query('ROLLBACK').catch(() => undefined) + if (competitorWork) await competitorWork + sweep.release() + competitor.release() + await databasePool.query( + `DELETE FROM notification_deliveries + WHERE dedupe_key = ANY($1::text[])`, + [ + [ + `workflow.run.completed:${userRunId}:Failed`, + `workflow.run.completed:${teamRunId}:Failed`, + ], + ] + ) + await databasePool.query('DELETE FROM workflow_runs WHERE recipe_name = ANY($1::text[])', [ + [recipeName, competitorRecipe], + ]) + await databasePool.query( + 'DELETE FROM workflow_approval_requests WHERE id = ANY($1::uuid[])', + [approvalIds] + ) + await databasePool.query('DELETE FROM users WHERE id = $1', [user]) + await databasePool.query('DELETE FROM teams WHERE id = $1', [team]) + } + expect(sweepFailure).toBeUndefined() + expect(phases).toMatchObject({ + foundFirst: userRunId, + firstFailed: true, + competitorBlocker: sweepPid, + committed: true, + }) + expect(competitorOutcome?.status).toBe('fulfilled') + expect(witness!.runs).toEqual([ + { run_id: userRunId, phase: 'Failed' }, + { run_id: teamRunId, phase: 'Failed' }, + ]) + // Liveness of the nested INSERT: one delivery per failed provider-bound run, + // with the audience the trigger's approval target names. + expect(witness!.deliveries).toEqual([{ teamId: team }, { userId: user }]) + // The prelock takes the batch's subjects in the global order: the team + // revision first, then the user revision. + expect(witness!.observed).toEqual([ + { revision_table: 'authorization_team_revisions', subject_id: team }, + { revision_table: 'authorization_user_revisions', subject_id: user }, + ]) + // Both subjects advanced. B's own INSERT also bumps them, so this is a + // liveness check, not the ordering witness (N3). + expect(byId(witness!.after.user)[user]!).toBeGreaterThan(byId(before.user)[user]!) + expect(byId(witness!.after.team)[team]!).toBeGreaterThan(byId(before.team)[team]!) + }) }) diff --git a/control-api/test/db.composableCatalogRevisionMigration.test.ts b/control-api/test/db.composableCatalogRevisionMigration.test.ts index a41b9ed469..43fadc0b26 100644 --- a/control-api/test/db.composableCatalogRevisionMigration.test.ts +++ b/control-api/test/db.composableCatalogRevisionMigration.test.ts @@ -39,6 +39,11 @@ describe('0134 composable catalog revisions migration', () => { expect(sql).toMatch( /GRANT EXECUTE ON FUNCTION authorization_prelock_workflow_approval_revisions\(UUID\[\]\)\s+TO control_api_runtime/ ) + // The stuck-run sweep runs on the Workflow Recipes pool and holds its + // batch's approval subjects there before the first run phase change. + expect(sql).toMatch( + /GRANT EXECUTE ON FUNCTION authorization_prelock_workflow_approval_revisions\(UUID\[\]\)\s+TO workflow_recipes_runtime/ + ) // The approval-trigger prelock covers the run INSERT that a decision or a // consume performs, so its body reads the run intent and the decision team. expect(sql).toContain( diff --git a/control-api/test/db.realPostgresMigration.integration.test.ts b/control-api/test/db.realPostgresMigration.integration.test.ts index f63d144e9a..58a77c6f69 100644 --- a/control-api/test/db.realPostgresMigration.integration.test.ts +++ b/control-api/test/db.realPostgresMigration.integration.test.ts @@ -935,6 +935,10 @@ describeRealPostgres('control-api real Postgres migrations', () => { function_name: 'authorization_prelock_workflow_run_revisions', grantee: 'control_api_runtime', }, + { + function_name: 'authorization_prelock_workflow_approval_revisions', + grantee: 'workflow_recipes_runtime', + }, { function_name: 'authorization_prelock_workflow_recipe_policy_revisions', grantee: 'workflow_recipes_runtime', @@ -3091,8 +3095,9 @@ describeRealPostgres('control-api real Postgres migrations', () => { `) expect(functionAccess.rows[0]?.violations).toBe('') - // The recipe-delete prelock is the only authorization function the Workflow - // Recipes runtime may execute; every other one stays owner-only. + // The Workflow Recipes runtime may execute exactly two authorization + // functions: the stuck-run sweep's approval prelock and the recipe-delete + // prelock. Every other one stays owner-only. const recipePolicyFunctionAccess = await dbPool.query<{ function_name: string }>(` SELECT routine.proname AS function_name FROM pg_proc routine @@ -3103,6 +3108,7 @@ describeRealPostgres('control-api real Postgres migrations', () => { ORDER BY routine.proname `) expect(recipePolicyFunctionAccess.rows).toEqual([ + { function_name: 'authorization_prelock_workflow_approval_revisions' }, { function_name: 'authorization_prelock_workflow_recipe_policy_revisions' }, ]) diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index 6ec37412f7..c40efb997d 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -112,11 +112,13 @@ describe('D34 migration execution policy', () => { // resource trigger does the same for content saves that keep its identity, // name, parent and tombstone, and the notification trigger does the same // for delivery-state writes that keep its id, event type, audience and - // expiry. + // expiry. B4.1 (PR #334 review r2) then granted the approval prelock to the + // Workflow Recipes runtime, which holds the stuck-run sweep's approval + // subjects before its first run phase change. const files = [ [ 'src/services/access/userAccessFoundationSchema.ts', - 'f86f9d57136a6925196bca2312a33fad4dfceef77517823fe445a24a028b4316', + '406f41f6f056970ca1faa65e425e673aaea4dfea378b06b4512dc99321ee299d', ], [ 'src/services/directory/invitationDeliverySchema.ts', diff --git a/workflow-recipes/package-lock.json b/workflow-recipes/package-lock.json index e050a35493..866d5f1f14 100644 --- a/workflow-recipes/package-lock.json +++ b/workflow-recipes/package-lock.json @@ -1,12 +1,12 @@ { "name": "workload-recipes-controller", - "version": "0.1.480", + "version": "0.1.481", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "workload-recipes-controller", - "version": "0.1.480", + "version": "0.1.481", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@aws-sdk/client-s3": "^3.1009.0", diff --git a/workflow-recipes/package.json b/workflow-recipes/package.json index 32ab012bc4..fc49db48cf 100644 --- a/workflow-recipes/package.json +++ b/workflow-recipes/package.json @@ -1,6 +1,6 @@ { "name": "workload-recipes-controller", - "version": "0.1.480", + "version": "0.1.481", "description": "Workload Recipes Controller (WRC) - WorkloadRecipe lifecycle management and MCP server interface", "main": "dist/main.js", "engines": { diff --git a/workflow-recipes/src/reconciler/dbRunProcessor.test.ts b/workflow-recipes/src/reconciler/dbRunProcessor.test.ts index fcb979f57a..8dadcae29d 100644 --- a/workflow-recipes/src/reconciler/dbRunProcessor.test.ts +++ b/workflow-recipes/src/reconciler/dbRunProcessor.test.ts @@ -594,10 +594,19 @@ describe('createDbRunProcessor', () => { const failed = await proc.checkStuckRuns() expect(failed).toBe(0) + // N7 witness: the sweep really issued its SELECT and committed, so the + // absent prelock and FAIL are not the result of a helper that never ran. + expect( + client.calls.some(c => /FROM workflow_runs[\s\S]*FOR UPDATE SKIP LOCKED/i.test(c.sql)) + ).toBe(true) + expect(client.calls.map(c => c.sql)).toContain('COMMIT') const anyFail = client.calls.find(c => /UPDATE workflow_runs[\s\S]*SET phase = 'Failed'/i.test(c.sql) ) expect(anyFail).toBeUndefined() + expect( + client.calls.find(c => /authorization_prelock_workflow_approval_revisions/.test(c.sql)) + ).toBeUndefined() }) // ─── 7) checkStuckRuns flips to Failed when started_at + max_duration elapsed @@ -635,6 +644,73 @@ describe('createDbRunProcessor', () => { /UPDATE workflow_runs[\s\S]*SET phase = 'Failed'/i.test(c.sql) ) expect(failUpdate?.params).toEqual(['run-stuck-1', 'wrc-1']) + // A row without an approval id makes no prelock call; FIND and FAIL both ran. + expect( + client.calls.some(c => /FROM workflow_runs[\s\S]*FOR UPDATE SKIP LOCKED/i.test(c.sql)) + ).toBe(true) + expect( + client.calls.find(c => /authorization_prelock_workflow_approval_revisions/.test(c.sql)) + ).toBeUndefined() + }) + + // The stuck-run sweep holds its batch's approval subjects before the first + // FAIL, because each provider-bound failure makes the run trigger INSERT one + // delivery and bump that delivery's subject. + it('checkStuckRuns prelocks the distinct non-null approval ids before failing the batch', async () => { + const stuckRows = [ + { run_id: 'run-stuck-user', approval_request_id: 'approval-1' }, + { run_id: 'run-stuck-team', approval_request_id: 'approval-2' }, + { run_id: 'run-stuck-shared', approval_request_id: 'approval-1' }, + { run_id: 'run-stuck-plain', approval_request_id: null }, + ] + const client = makeClient(async sql => { + if (/^BEGIN$/i.test(sql) || /^COMMIT$/i.test(sql)) return { rows: [], rowCount: 0 } + if (/FROM workflow_runs[\s\S]*FOR UPDATE SKIP LOCKED/i.test(sql)) { + return { rows: stuckRows, rowCount: stuckRows.length } + } + if (/UPDATE workflow_runs[\s\S]*SET phase = 'Failed'/i.test(sql)) { + return { rows: [], rowCount: 1 } + } + return { rows: [], rowCount: 0 } + }) + const pool = { + connect: vi.fn(async () => client as unknown as PoolClient), + } as unknown as Pool + const onRunTerminal = vi.fn() + const proc = spawn({ + instanceId: 'wrc-1', + pool, + runPollMs: 30_000, + createChildRecipe: vi.fn(), + logger: silentLogger(), + onRunTerminal, + }) + + expect(await proc.checkStuckRuns()).toBe(4) + + const findIndex = client.calls.findIndex(c => + /FROM workflow_runs[\s\S]*FOR UPDATE SKIP LOCKED/i.test(c.sql) + ) + const prelockIndex = client.calls.findIndex(c => + /authorization_prelock_workflow_approval_revisions\(\$1::uuid\[\]\)/.test(c.sql) + ) + const failIndexes = client.calls + .map((call, index) => ({ call, index })) + .filter(({ call }) => /UPDATE workflow_runs[\s\S]*SET phase = 'Failed'/i.test(call.sql)) + .map(({ index }) => index) + expect(findIndex).toBeGreaterThan(-1) + expect(prelockIndex).toBeGreaterThan(findIndex) + expect(failIndexes).toHaveLength(4) + expect(Math.min(...failIndexes)).toBeGreaterThan(prelockIndex) + // Distinct, non-null, first-appearance order. + expect(client.calls[prelockIndex]!.params).toEqual([['approval-1', 'approval-2']]) + expect(failIndexes.map(index => client.calls[index]!.params?.[0])).toEqual([ + 'run-stuck-user', + 'run-stuck-team', + 'run-stuck-shared', + 'run-stuck-plain', + ]) + expect(onRunTerminal).toHaveBeenCalledTimes(4) }) // ─── 8) LISTEN reconnect: on session error, a new LISTEN session is diff --git a/workflow-recipes/src/reconciler/dbRunProcessor.ts b/workflow-recipes/src/reconciler/dbRunProcessor.ts index 810127eba4..ab250b6401 100644 --- a/workflow-recipes/src/reconciler/dbRunProcessor.ts +++ b/workflow-recipes/src/reconciler/dbRunProcessor.ts @@ -151,7 +151,7 @@ const UPSERT_STEP = ` )` const FIND_STUCK_RUNS = ` - SELECT run_id, started_at, max_duration_seconds + SELECT run_id, started_at, max_duration_seconds, approval_request_id FROM workflow_runs WHERE phase = 'Running' AND owner_instance_id = $1 @@ -569,7 +569,26 @@ export function createDbRunProcessor(opts: DbRunProcessorOptions): DbRunProcesso const terminalNotifications: Array<{ runId: string; phase: WorkflowRunPhase }> = [] try { await client.query('BEGIN') - const res = await client.query<{ run_id: string }>(FIND_STUCK_RUNS, [opts.instanceId]) + const res = await client.query<{ run_id: string; approval_request_id: string | null }>( + FIND_STUCK_RUNS, + [opts.instanceId] + ) + // A failed provider-bound run makes notify_workflow_run_update INSERT one + // delivery, which bumps that delivery's subject; hold this batch's + // approval subjects in the global team-then-user order before the loop so + // a concurrent run INSERT cannot reverse it. + const approvalRequestIds = [ + ...new Set( + res.rows + .map(row => row.approval_request_id) + .filter((id): id is string => typeof id === 'string' && id.length > 0) + ), + ] + if (approvalRequestIds.length > 0) { + await client.query('SELECT authorization_prelock_workflow_approval_revisions($1::uuid[])', [ + approvalRequestIds, + ]) + } for (const row of res.rows) { const upd = await client.query(FAIL_STUCK_RUN, [row.run_id, opts.instanceId]) if (upd.rowCount && upd.rowCount > 0) { From d01be5ba3dd9c134f90fc1a1c8f4519301ed088b Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Fri, 30 Oct 2026 15:59:41 +0100 Subject: [PATCH 429/528] test(external-rest-api): cover an idle native A to B to A team switch The existing compatibility case covers A -> B and then a mutation with the minted B token. It never switches back, so a regression that keeps serving the previous team's token after a second switch was invisible; the old-Desktop smoke C090 A hit exactly that sequence. Add a native V1 A -> B -> A sequence with no browser cookie and no V2 opt-in. Three distinct tokens: the original A token, the B token A -> B mints, and a new A token B -> A mints. The Control API double answers /me and /me/teams from the forwarded bearer's token-to-team map and asserts the forwarded team query matches, so neither read can pass by call order. Each switch asserts its minted token, that the body carries one, and that a non-browser caller gets no set-cookie header; the first switch also asserts the new A token differs from the original. Limit: verifyToken and controlApiRequest are mocked, so this pins the edge's forwarding contract, not Control API token issuance. Returning the caller's token from switchTeam reddens the new sequence at the first switch. Refs: PR #334 review r2, B4.1 compatibility coverage. --- external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- external-rest-api/src/releaseManifest.ts | 2 +- .../routes.teamSwitchCompatibility.test.ts | 142 +++++++++++++++--- 4 files changed, 126 insertions(+), 24 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 1a9e1824b5..89b4f2f90f 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.160", + "version": "0.1.161", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.160", + "version": "0.1.161", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index 9fcf18b33c..632fad44d9 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.160", + "version": "0.1.161", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index 30eceec143..b8d5710266 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.12.0', - externalRestApiVersion: '0.1.160', + externalRestApiVersion: '0.1.161', rpcProxyVersion: '0.1.137', desktopVersion: '0.12.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/test/routes.teamSwitchCompatibility.test.ts b/external-rest-api/test/routes.teamSwitchCompatibility.test.ts index 21324181d6..28a6c6daeb 100644 --- a/external-rest-api/test/routes.teamSwitchCompatibility.test.ts +++ b/external-rest-api/test/routes.teamSwitchCompatibility.test.ts @@ -28,28 +28,26 @@ describe('legacy team-switch compatibility', () => { beforeEach(() => { mocks.controlApiRequest.mockReset() mocks.verifyToken.mockReset() + // Native V1 tokens: the original A token, the B token that A -> B mints, and + // the new A token that B -> A mints. All three are accepted here, so a + // regression that keeps returning the previous team's token is observable. + const tokenTeams: Record = { + 'team-a-token': 'team-a', + 'team-b-token': 'team-b', + 'team-a-return-token': 'team-a', + 'synthetic-team-b-token': 'team-b', + } mocks.verifyToken.mockImplementation((token: string) => { - if (token === 'team-a-token') { - return { - userId: 'user-1', - email: 'user@example.com', - teamId: 'team-a', - role: 'admin', - exp: 9_999_999_999, - sessionContract: 'v1', - } - } - if (token === 'synthetic-team-b-token') { - return { - userId: 'user-1', - email: 'user@example.com', - teamId: 'team-b', - role: 'admin', - exp: 9_999_999_999, - sessionContract: 'v1', - } + const teamId = tokenTeams[token] + if (!teamId) return null + return { + userId: 'user-1', + email: 'user@example.com', + teamId, + role: 'admin', + exp: 9_999_999_999, + sessionContract: 'v1', } - return null }) }) @@ -92,4 +90,108 @@ describe('legacy team-switch compatibility', () => { expect(paths).toContain('/external/teams/team-b/name') expect(paths).not.toContain('/external/teams/team-a/name') }) + + // Native V1 A -> B -> A with no browser cookie and no V2 opt-in. The edge's + // verifyToken and controlApiRequest are mocked, so this pins the forwarding + // contract only: each read must carry the newest token a switch returned and + // land on the requested team. It does not prove Control API token issuance. + it('keeps A -> B -> A native v1 switches on the newest returned token', async () => { + const tokenTeams: Record = { + 'team-a-token': 'team-a', + 'team-b-token': 'team-b', + 'team-a-return-token': 'team-a', + } + // The minted token is a pure function of the bearer used and the requested + // team, never of the call order. + const mintedTokens: Record = { + 'team-a-token:team-b': 'team-b-token', + 'team-b-token:team-a': 'team-a-return-token', + } + const teamNames: Record = { 'team-a': 'Team A', 'team-b': 'Team B' } + let lastMembership: { token: string; teamId: string } | undefined + + mocks.controlApiRequest.mockImplementation( + async (method: string, path: string, options: Record) => { + if (method === 'GET' && path.startsWith('/external/users/user-1/memberships/')) { + const teamId = decodeURIComponent( + path.slice('/external/users/user-1/memberships/'.length) + ) + lastMembership = { token: String(options.userSessionToken), teamId } + return { team_id: teamId, team_name: teamNames[teamId], role: 'admin' } + } + if (method === 'POST' && path === '/external/auth/session-token') { + const sessionToken = String(options.userSessionToken) + const body = options.body as { userId: string; teamId: string; role: string } + // The switch forwards the bearer it was called with and the team its + // own membership lookup resolved. + expect(lastMembership).toEqual({ token: sessionToken, teamId: body.teamId }) + expect(tokenTeams[sessionToken]).toBeDefined() + expect(body.teamId).not.toBe(tokenTeams[sessionToken]) + return { + token: mintedTokens[`${sessionToken}:${body.teamId}`], + sessionContract: 'v1', + deprecated: true, + } + } + if (method === 'GET' && path === '/external/users/user-1/me') { + // Answer from the forwarded bearer, never from call order. + const teamId = tokenTeams[String(options.userSessionToken)] + expect(options.query).toMatchObject({ teamId }) + return { + id: 'user-1', + email: 'user@example.com', + name: 'User One', + teamId, + teamName: teamNames[teamId], + role: 'admin', + profile: { displayName: 'User One', channels: null }, + } + } + if (method === 'GET' && path === '/external/users/user-1/teams') { + const teamId = tokenTeams[String(options.userSessionToken)] + expect(options.query).toMatchObject({ currentTeamId: teamId }) + return { + currentTeamId: teamId, + items: [ + { id: 'team-a', name: 'Team A', role: 'admin' }, + { id: 'team-b', name: 'Team B', role: 'admin' }, + ], + } + } + throw new Error(`unexpected Control API request: ${method} ${path}`) + } + ) + + async function switchTo(token: string, teamId: string): Promise { + // No origin, sec-fetch-site or x-evenfire-session-contract header: this is + // the idle native client path. + const response = await request(app()) + .post('/me/switch-team') + .set('authorization', `Bearer ${token}`) + .send({ teamId }) + .expect(200) + // Liveness: the switch really returned a token, and a non-browser caller + // gets no cookie. + expect(response.body.token).toBe(mintedTokens[`${token}:${teamId}`]) + expect(response.headers['set-cookie']).toBeUndefined() + return String(response.body.token) + } + + async function expectTeamReads(token: string, teamId: string): Promise { + const me = await request(app()).get('/me').set('authorization', `Bearer ${token}`).expect(200) + expect(me.body.teamId).toBe(teamId) + const teams = await request(app()) + .get('/me/teams') + .set('authorization', `Bearer ${token}`) + .expect(200) + expect(teams.body.currentTeamId).toBe(teamId) + } + + await expectTeamReads('team-a-token', 'team-a') + const teamBToken = await switchTo('team-a-token', 'team-b') + await expectTeamReads(teamBToken, 'team-b') + const teamAReturnToken = await switchTo(teamBToken, 'team-a') + expect(teamAReturnToken).not.toBe('team-a-token') + await expectTeamReads(teamAReturnToken, 'team-a') + }) }) From c6d857d2f086d2e6b1151556b55f9168a8c69773 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Fri, 30 Oct 2026 21:07:27 +0100 Subject: [PATCH 430/528] test(control-api): answer the session reads the contexts wiring test now triggers The contexts-namespace wiring test arrived with dev #1244 and mocks the pool. On this branch a V1 session is validated against the database clock, the user's security epoch and logout revocations, and team-scoped routes check the caller's live team membership. Answer those reads in the fixture so the two session-catalog cases reach the allowlist assertions again. Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- .../routes.contextsNamespace.wiring.test.ts | 24 ++++++++++++++++++- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 6374e14cbc..6c9443d403 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.881", + "version": "0.1.882", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.881", + "version": "0.1.882", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index f7ea22d65c..e034b28ad9 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.881", + "version": "0.1.882", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/routes.contextsNamespace.wiring.test.ts b/control-api/test/routes.contextsNamespace.wiring.test.ts index 70453197e7..0a0952153b 100644 --- a/control-api/test/routes.contextsNamespace.wiring.test.ts +++ b/control-api/test/routes.contextsNamespace.wiring.test.ts @@ -97,12 +97,34 @@ function answerPool(): void { agents: { [USER]: [HOST] }, }) if (membership) return Promise.resolve(membership) + // Team-scoped routes check the caller's live membership (liveTeamAuthorization.ts). + if (text.includes('FROM team_members tm')) { + const [userId, teamId] = Array.isArray(values) ? values : [] + return Promise.resolve( + userId === USER && teamId === TEAM + ? { rows: [{ team_id: TEAM, role: 'member' }], rowCount: 1 } + : { rows: [], rowCount: 0 } + ) + } if (text.includes('FROM team_agents')) { return Promise.resolve({ rows: [{ agent_name: HOST }], rowCount: 1 }) } + // V1 session validation reads the database clock, then the user's lifecycle, + // security epoch and logout revocation (userSessionService.ts validateLegacyUserSession). + if (text.includes('clock_timestamp()')) { + return Promise.resolve({ rows: [{ db_now: new Date() }], rowCount: 1 }) + } if (text.includes('lifecycle_version')) { return Promise.resolve({ - rows: [{ lifecycle_state: 'active', lifecycle_version: 1 }], + rows: [ + { + id: USER, + lifecycle_state: 'active', + lifecycle_version: 1, + valid_after: null, + token_revoked: false, + }, + ], rowCount: 1, }) } From f7a2462261d62c0c32a8e872610157f6dc9653f4 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Sat, 31 Oct 2026 12:19:47 +0100 Subject: [PATCH 431/528] fix: restore member visibility for external team reads --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- control-api/src/routes/external/teams.ts | 2 - control-api/src/routes/external/users.ts | 8 +- .../routes.externalMemberVisibility.test.ts | 237 ++++++++++++++++++ control-api/test/routes.profile.test.ts | 26 +- .../ui/src/pages/__tests__/FilesPage.test.tsx | 108 ++++---- 7 files changed, 318 insertions(+), 69 deletions(-) create mode 100644 control-api/test/routes.externalMemberVisibility.test.ts diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 6c9443d403..3c84d30206 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.882", + "version": "0.1.883", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.882", + "version": "0.1.883", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index e034b28ad9..ebd68f97af 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.882", + "version": "0.1.883", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/routes/external/teams.ts b/control-api/src/routes/external/teams.ts index e2db6582f6..3902ac1ab6 100644 --- a/control-api/src/routes/external/teams.ts +++ b/control-api/src/routes/external/teams.ts @@ -114,7 +114,6 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { router.get( '/external/teams/:teamId/members', requireExternalTeamParamMatch(), - requireExternalRole(['admin', 'inviter']), async (req, res, next) => { try { return res.status(200).json({ items: await listMembers(req.params.teamId) }) @@ -193,7 +192,6 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { router.get( '/external/teams/:teamId/members/:userId/role', requireExternalTeamParamMatch(), - requireExternalRole(['admin', 'inviter']), async (req, res, next) => { try { const role = await findMemberRole(req.params.teamId, req.params.userId) diff --git a/control-api/src/routes/external/users.ts b/control-api/src/routes/external/users.ts index 74cdb8615b..955e53d460 100644 --- a/control-api/src/routes/external/users.ts +++ b/control-api/src/routes/external/users.ts @@ -140,9 +140,8 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { } const mapped = await mapWithConcurrencyLimit(teams, 4, async team => { - const canReadMembers = team.role === 'admin' || team.role === 'inviter' const [members, contexts, agents] = await Promise.allSettled([ - canReadMembers ? listMembers(team.id) : Promise.resolve(null), + listMembers(team.id), getTeamContexts(team.id), getTeamAgents(team.id), ]) @@ -163,10 +162,7 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { return { item: { team, - members: - canReadMembers && members.status === 'fulfilled' - ? projectTeamMembers(members.value) - : [], + members: members.status === 'fulfilled' ? projectTeamMembers(members.value) : [], contextIds: contexts.status === 'fulfilled' ? filterAccessValues(contexts.value?.contextIds, activeContextIds) diff --git a/control-api/test/routes.externalMemberVisibility.test.ts b/control-api/test/routes.externalMemberVisibility.test.ts new file mode 100644 index 0000000000..0285f1f247 --- /dev/null +++ b/control-api/test/routes.externalMemberVisibility.test.ts @@ -0,0 +1,237 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import request from 'supertest' +import { createExternalTeamsRouter } from '../src/routes/external/teams.js' +import { createExternalUsersRouter } from '../src/routes/external/users.js' +import { signExternalSessionToken } from '../src/utils/auth/externalSessionAuthToken.js' + +const directory = vi.hoisted(() => ({ + listTeams: vi.fn(), + listMembers: vi.fn(), + findMemberRole: vi.fn(), + getTeamContexts: vi.fn(), + getTeamAgents: vi.fn(), +})) +const authority = vi.hoisted(() => ({ + validateLegacyUserSession: vi.fn(), + getLiveTeamMembership: vi.fn(), +})) +const rateLimit = vi.hoisted(() => ({ checkAndIncrement: vi.fn() })) + +vi.mock('../src/services/directory/index.js', async importOriginal => ({ + ...(await importOriginal()), + ...directory, +})) +vi.mock('../src/services/auth/userSessionService.js', async importOriginal => ({ + ...(await importOriginal()), + validateLegacyUserSession: authority.validateLegacyUserSession, +})) +vi.mock('../src/services/access/liveTeamAuthorization.js', () => ({ + getLiveTeamMembership: authority.getLiveTeamMembership, +})) +vi.mock('../src/services/rateLimiterService.js', () => rateLimit) + +const member = { + id: 'teammate-1', + email: 'teammate@example.com', + name: 'Teammate', + role: 'member', + status: 'active', +} +const memberReads = [ + ['member list', '/external/teams/team-1/members'], + ['member role', '/external/teams/team-1/members/teammate-1/role'], +] as const +const directoryPath = '/external/users/user-1/team-directory' + +function app() { + const value = express() + const gateway = { listResource: vi.fn(async () => []) } + value.use(createExternalUsersRouter(gateway as never)) + value.use(createExternalTeamsRouter(gateway as never)) + return value +} + +function sessionToken(userId = 'user-1') { + // A selected-team claim is not authority for any of these reads. + return signExternalSessionToken({ + userId, + email: `${userId}@example.com`, + teamId: 'team-1', + role: 'admin', + authGeneration: 1, + }) +} + +function expectLiveMembership(userId: string, teamId: string) { + expect(authority.getLiveTeamMembership).toHaveBeenCalledWith( + userId, + teamId, + expect.objectContaining({ budget: expect.anything() }) + ) +} + +describe('external member visibility', () => { + beforeEach(() => { + vi.resetAllMocks() + authority.validateLegacyUserSession.mockImplementation(async (_token, claims) => ({ + status: 'valid', + identity: { jti: 'member-visibility-session', userId: claims.userId }, + })) + authority.getLiveTeamMembership.mockResolvedValue({ teamId: 'team-1', role: 'member' }) + directory.listTeams.mockResolvedValue({ + currentTeamId: 'team-1', + items: [{ id: 'team-1', name: 'Core Team', role: 'member' }], + }) + directory.listMembers.mockResolvedValue([member]) + directory.findMemberRole.mockResolvedValue({ role: 'member' }) + directory.getTeamContexts.mockResolvedValue({ contextIds: [] }) + directory.getTeamAgents.mockResolvedValue({ agentNames: [] }) + rateLimit.checkAndIncrement.mockResolvedValue({ + allowed: true, + remaining: 9, + resetMs: Date.now() + 60_000, + windowStartMs: Date.now(), + count: 1, + backendAvailable: true, + }) + }) + + describe.each(['admin', 'inviter', 'member'] as const)('current %s', role => { + beforeEach(() => { + authority.getLiveTeamMembership.mockResolvedValue({ teamId: 'team-1', role }) + directory.listTeams.mockResolvedValue({ + currentTeamId: 'team-1', + items: [{ id: 'team-1', name: 'Core Team', role }], + }) + }) + + it('lists directory members with only the dev member fields', async () => { + directory.listMembers.mockResolvedValue([ + { ...member, display_name: 'Private display name', channels: { emails: ['private'] } }, + ]) + + const response = await request(app()) + .get(directoryPath) + .set('x-user-session-token', sessionToken()) + .expect(200) + + expect(response.body).toEqual({ + currentTeamId: 'team-1', + truncated: false, + complete: true, + partialErrors: [], + items: [ + { + team: { id: 'team-1', name: 'Core Team', role }, + members: [member], + contextIds: [], + agentNames: [], + }, + ], + }) + expect(directory.listTeams).toHaveBeenCalledWith('user-1', 'team-1') + expect(directory.listMembers).toHaveBeenCalledWith('team-1') + }) + + it('reads the team member list', async () => { + const response = await request(app()) + .get(memberReads[0][1]) + .set('x-user-session-token', sessionToken()) + .expect(200) + + expect(response.body).toEqual({ items: [member] }) + expectLiveMembership('user-1', 'team-1') + expect(directory.listMembers).toHaveBeenCalledWith('team-1') + }) + + it('reads another team member role', async () => { + const response = await request(app()) + .get(memberReads[1][1]) + .set('x-user-session-token', sessionToken()) + .expect(200) + + expect(response.body).toEqual({ role: 'member' }) + expectLiveMembership('user-1', 'team-1') + expect(directory.findMemberRole).toHaveBeenCalledWith('team-1', 'teammate-1') + }) + }) + + it('reports a failed member source as incomplete for a plain member', async () => { + directory.listMembers.mockRejectedValue(new Error('member source unavailable')) + + const response = await request(app()) + .get(directoryPath) + .set('x-user-session-token', sessionToken()) + .expect(200) + + expect(directory.listMembers).toHaveBeenCalledWith('team-1') + expect(response.body.complete).toBe(false) + expect(response.body.partialErrors).toEqual([ + { teamId: 'team-1', source: 'members', code: 'unavailable' }, + ]) + expect(response.body.items[0].members).toEqual([]) + }) + + it('denies another user directory after checking live session authority', async () => { + const token = sessionToken() + await request(app()) + .get('/external/users/unrelated-user/team-directory') + .set('x-user-session-token', token) + .expect(403, { error: 'Forbidden' }) + + // User matching runs before directory membership enumeration. + expect(authority.validateLegacyUserSession).toHaveBeenCalledWith( + token, + expect.objectContaining({ userId: 'user-1' }), + expect.anything() + ) + expect(directory.listTeams).not.toHaveBeenCalled() + expect(directory.listMembers).not.toHaveBeenCalled() + }) + + describe.each(memberReads)('%s authorization', (_name, path) => { + it.each([ + ['an unrelated user', 'unrelated-user', 'team-1'], + ['a removed member with stale admin claims', 'user-1', 'team-1'], + ['a cross-team id', 'user-1', 'other-team'], + ])('denies %s after reading live membership', async (_case, userId, teamId) => { + authority.getLiveTeamMembership.mockResolvedValue(null) + + await request(app()) + .get(path.replace('team-1', teamId)) + .set('x-user-session-token', sessionToken(userId)) + .expect(403, { error: 'Forbidden' }) + + expectLiveMembership(userId, teamId) + expect(directory.listMembers).not.toHaveBeenCalled() + expect(directory.findMemberRole).not.toHaveBeenCalled() + }) + }) + + it.each([['directory', directoryPath], ...memberReads])( + 'denies a revoked session before the %s handler', + async (_name, path) => { + const token = sessionToken() + authority.validateLegacyUserSession.mockResolvedValue({ + status: 'revoked', + reason: 'legacy_token_revoked', + }) + + await request(app()).get(path).set('x-user-session-token', token).expect(401, { + error: 'Unauthorized', + }) + + // A revoked session short-circuits before live team membership or data reads. + expect(authority.validateLegacyUserSession).toHaveBeenCalledWith( + token, + expect.objectContaining({ userId: 'user-1' }), + expect.anything() + ) + expect(authority.getLiveTeamMembership).not.toHaveBeenCalled() + expect(directory.listTeams).not.toHaveBeenCalled() + expect(directory.listMembers).not.toHaveBeenCalled() + expect(directory.findMemberRole).not.toHaveBeenCalled() + } + ) +}) diff --git a/control-api/test/routes.profile.test.ts b/control-api/test/routes.profile.test.ts index c76fc466de..c640df5089 100644 --- a/control-api/test/routes.profile.test.ts +++ b/control-api/test/routes.profile.test.ts @@ -487,7 +487,7 @@ describe('routes/profile', () => { expect(svc.renameTeam).not.toHaveBeenCalled() }) - it('does not expose the member directory to an ordinary member', async () => { + it('exposes the member directory to an ordinary live member', async () => { svc.listMembers.mockResolvedValue([{ id: 'u2', email: 'peer@example.com', role: 'member' }]) const app = express() app.use(express.json()) @@ -495,9 +495,14 @@ describe('routes/profile', () => { await withInternalServiceAuthAndUserSession( request(app).get('/external/teams/t1/members') - ).expect(403) + ).expect(200, { items: [{ id: 'u2', email: 'peer@example.com', role: 'member' }] }) - expect(svc.listMembers).not.toHaveBeenCalled() + expect(svc.listMembers).toHaveBeenCalledWith('t1') + expect(liveTeamAuthorizationMock.getLiveTeamMembership).toHaveBeenCalledWith( + 'u1', + 't1', + expect.objectContaining({ budget: expect.anything() }) + ) }) it('does not allow an inviter to create an admin invitation', async () => { @@ -605,7 +610,15 @@ describe('routes/profile', () => { items: [ { team: { id: 't1', name: 'Alpha', role: 'member' }, - members: [], + members: [ + { + id: 'u1', + email: 'u@example.com', + name: 'User', + role: 'member', + status: 'active', + }, + ], contextIds: ['ctx-a'], agentNames: ['agent-a'], }, @@ -628,7 +641,7 @@ describe('routes/profile', () => { expect(svc.getTeamContexts).toHaveBeenCalledWith('t2') expect(svc.getTeamAgents).toHaveBeenCalledWith('t2') expect(svc.listMembers).toHaveBeenCalledWith('t2') - expect(svc.listMembers).not.toHaveBeenCalledWith('t1') + expect(svc.listMembers).toHaveBeenCalledWith('t1') }) it('logs team-directory partial failures through the structured request logger', async () => { @@ -694,7 +707,8 @@ describe('routes/profile', () => { expect(res.body.partialErrors).toEqual([]) expect(res.body.items).toHaveLength(50) expect(res.body.items.at(-1)?.team.id).toBe('t50') - expect(svc.listMembers).not.toHaveBeenCalled() + expect(svc.listMembers).toHaveBeenCalledTimes(50) + expect(svc.listMembers).not.toHaveBeenCalledWith('t51') expect(svc.getTeamContexts).toHaveBeenCalledTimes(50) expect(svc.getTeamAgents).toHaveBeenCalledTimes(50) }) diff --git a/desktop-app/ui/src/pages/__tests__/FilesPage.test.tsx b/desktop-app/ui/src/pages/__tests__/FilesPage.test.tsx index a50130f530..ef689f4607 100644 --- a/desktop-app/ui/src/pages/__tests__/FilesPage.test.tsx +++ b/desktop-app/ui/src/pages/__tests__/FilesPage.test.tsx @@ -2139,62 +2139,66 @@ describe('FilesPage', () => { expect(pushToast).toHaveBeenCalledWith('Renamed to report-renamed.txt', 'success') }) - it('uses the visible team directory for user delegation subjects', async () => { - Object.defineProperty(window, 'clerum', { - configurable: true, - value: { - team: { - directory: vi.fn(async () => ({ - currentTeamId: 'team-1', - items: [ - { - team: { id: 'team-1', name: 'Core Team', role: 'admin' }, - members: [ - { - id: 'user-2', - email: 'test2@clerum.io', - name: 'Test Two', - role: 'member', - status: 'active', - }, - ], - contextIds: [], - agentNames: [], - }, - ], - })), + it.each(['admin', 'inviter', 'member'] as const)( + 'shows selectable teammates from the directory for a current %s', + async role => { + Object.defineProperty(window, 'clerum', { + configurable: true, + value: { + team: { + directory: vi.fn(async () => ({ + currentTeamId: 'team-1', + items: [ + { + team: { id: 'team-1', name: 'Core Team', role }, + members: [ + { + id: 'user-2', + email: 'test2@clerum.io', + name: 'Test Two', + role: 'member', + status: 'active', + }, + ], + contextIds: [], + agentNames: [], + }, + ], + })), + }, }, - }, - }) - hookMock.useGfsBrowserController.mockReturnValue({ - ...baseController(), - current: { - resourceId: 'folder-1', - gfsUri: 'gfs://main/folder', - name: 'Team folder', - kind: 'directory', - }, - affordances: { - held: ['read', 'manage_acl'], - canDelegate: true, - grantableBits: ['read'], - canCreateShare: false, - }, - }) + }) + hookMock.useGfsBrowserController.mockReturnValue({ + ...baseController(), + current: { + resourceId: 'folder-1', + gfsUri: 'gfs://main/folder', + name: 'Team folder', + kind: 'directory', + }, + affordances: { + held: ['read', 'manage_acl'], + canDelegate: true, + grantableBits: ['read'], + canCreateShare: false, + }, + }) - renderFilesPage() + renderFilesPage() - await openManageDialog('Team folder') + await openManageDialog('Team folder') - const subjectPicker = await screen.findByRole('combobox', { - name: 'Add people, teams, or agents', - }) - await waitFor(() => expect(subjectPicker).toHaveProperty('disabled', false)) - fireEvent.focus(subjectPicker) - const userLabel = await screen.findByText('Test Two') - expect(userLabel.closest('[role="option"]')).toBeTruthy() - expect(screen.queryByPlaceholderText(/uuid/i)).toBeNull() - }) + const subjectPicker = await screen.findByRole('combobox', { + name: 'Add people, teams, or agents', + }) + await waitFor(() => expect(subjectPicker).toHaveProperty('disabled', false)) + fireEvent.focus(subjectPicker) + const teammate = await screen.findByRole('option', { name: /Test Two/ }) + fireEvent.click(teammate) + expect(screen.getByRole('button', { name: 'Remove Test Two' })).toBeTruthy() + expect(screen.queryByPlaceholderText(/uuid/i)).toBeNull() + } + ) it('loads my agents when the manage dialog opens and offers only valid gfs subjects', async () => { const listMine = vi.fn(async () => [ From 98981a84faeabe1b43536efb95c7cf41c84fe31e Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Sat, 31 Oct 2026 15:30:41 +0100 Subject: [PATCH 432/528] fix(control-api): guard 0134 backfill against oversized gfs_resources --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../access/userAccessFoundationSchema.ts | 34 ++- .../test/migrationExecutionPolicy.test.ts | 5 +- ...fillBound.realPostgres.integration.test.ts | 220 ++++++++++++++++++ 5 files changed, 251 insertions(+), 14 deletions(-) create mode 100644 control-api/test/migrationGfsBackfillBound.realPostgres.integration.test.ts diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 3c84d30206..286264e5e4 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.883", + "version": "0.1.884", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.883", + "version": "0.1.884", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index ebd68f97af..690cedae65 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.883", + "version": "0.1.884", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/access/userAccessFoundationSchema.ts b/control-api/src/services/access/userAccessFoundationSchema.ts index dcbcab1900..c0023c87e8 100644 --- a/control-api/src/services/access/userAccessFoundationSchema.ts +++ b/control-api/src/services/access/userAccessFoundationSchema.ts @@ -216,6 +216,31 @@ export async function applyCatalogUtf8OrderingSchema(db: DbClient): Promise { + // Bound the backfill before trigger DDL takes writer-blocking locks. Larger + // datasets need a separately planned batched backfill, not a longer timeout. + await db.query(` + DO $$ + DECLARE + resource_count BIGINT; + resource_limit CONSTANT BIGINT := 100000; + BEGIN + SELECT count(*) INTO resource_count FROM gfs_resources; + IF resource_count > resource_limit THEN + RAISE EXCEPTION '0134 gfs_resources backfill refused: count % exceeds supported limit %; a batched backfill is required', + resource_count, resource_limit; + END IF; + END; + $$; + `) + await db.query( + `INSERT INTO authorization_resource_revisions( + environment_id, resource_type, resource_id, revision, updated_at + ) + SELECT $1, 'gfs_resource', resource_id::text, 1, clock_timestamp() + FROM gfs_resources + ON CONFLICT (environment_id, resource_type, resource_id) DO NOTHING`, + [canonicalEnvironmentId()] + ) await db.query(` CREATE TABLE IF NOT EXISTS authorization_catalog_environment ( singleton BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (singleton), @@ -1001,15 +1026,6 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis ON CONFLICT (singleton) DO UPDATE SET environment_id = EXCLUDED.environment_id`, [canonicalEnvironmentId()] ) - await db.query( - `INSERT INTO authorization_resource_revisions( - environment_id, resource_type, resource_id, revision, updated_at - ) - SELECT $1, 'gfs_resource', resource_id::text, 1, clock_timestamp() - FROM gfs_resources - ON CONFLICT (environment_id, resource_type, resource_id) DO NOTHING`, - [canonicalEnvironmentId()] - ) } async function applyAuthorizationRevisionFunctions(db: DbClient): Promise { diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index c40efb997d..5bf0f75fbf 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -114,11 +114,12 @@ describe('D34 migration execution policy', () => { // for delivery-state writes that keep its id, event type, audience and // expiry. B4.1 (PR #334 review r2) then granted the approval prelock to the // Workflow Recipes runtime, which holds the stuck-run sweep's approval - // subjects before its first run phase change. + // subjects before its first run phase change. DB-2 B0 now guards the GFS + // backfill at 100000 resources and runs it before trigger DDL. const files = [ [ 'src/services/access/userAccessFoundationSchema.ts', - '406f41f6f056970ca1faa65e425e673aaea4dfea378b06b4512dc99321ee299d', + '23c041f3836530622ba43494e4978e6a79c57d9b33a83c3b536ef5c72423018c', ], [ 'src/services/directory/invitationDeliverySchema.ts', diff --git a/control-api/test/migrationGfsBackfillBound.realPostgres.integration.test.ts b/control-api/test/migrationGfsBackfillBound.realPostgres.integration.test.ts new file mode 100644 index 0000000000..d4724cf1c4 --- /dev/null +++ b/control-api/test/migrationGfsBackfillBound.realPostgres.integration.test.ts @@ -0,0 +1,220 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { randomBytes } from 'node:crypto' +import { Pool } from 'pg' +import { CONTROL_API_MIGRATIONS, assertDbReady, initDb } from '../src/db.js' +import { canonicalEnvironmentId } from '../src/services/access/operationalAccessProjection.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' + +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const describeRealPostgres = adminUrl ? describe : describe.skip +const VERSION = '0134_composable_catalog_revisions' +const NEXT_VERSION = '0135_legacy_password_security_epoch_backfill' + +function databaseUrl(baseUrl: string, database: string): string { + const value = new URL(baseUrl) + value.pathname = `/${database}` + return value.toString() +} + +describeRealPostgres('0134 GFS backfill bound on real PostgreSQL', () => { + let database: string + let adminPool: Pool + let databasePool: Pool + + beforeEach(async () => { + database = `control_api_gfs_bound_${randomBytes(6).toString('hex')}` + adminPool = new Pool({ connectionString: adminUrl }) + await adminPool.query(`CREATE DATABASE "${database}"`) + databasePool = new Pool({ connectionString: databaseUrl(adminUrl!, database) }) + + // Build the actual pre-0134 schema and receipts through initDb. Abort at + // entry to 0134 rather than running it and faking an older database state. + const migration = CONTROL_API_MIGRATIONS.find(item => item.version === VERSION)! + const original = migration.apply + const stop = new Error('fixture stops before 0134') + migration.apply = async () => { + throw stop + } + try { + await expect(initDb({ connect: () => databasePool.connect() })).rejects.toBe(stop) + } finally { + migration.apply = original + } + const receipts = await databasePool.query<{ version: string }>( + 'SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1' + ) + expect(receipts.rows).toEqual([{ version: '0133_catalog_utf8_ordering' }]) + expect((await databasePool.query('SELECT count(*) FROM gfs_resources')).rows).toEqual([ + { count: '0' }, + ]) + }) + + afterEach(async () => { + try { + await endPoolAndWaitForClients(databasePool) + if (adminPool && database) { + await adminPool.query(`DROP DATABASE IF EXISTS "${database}"`) + } + } finally { + await adminPool?.end() + } + }) + + async function seedResources(count: number): Promise { + // Separate drives give each root a valid, unique structural identity. + const inserted = await databasePool.query( + `INSERT INTO gfs_resources(drive, name, kind) + SELECT 'bound-' || n, 'root', 'directory' FROM generate_series(1, $1::int) AS n`, + [count] + ) + expect(inserted.rowCount).toBe(count) + const resources = await databasePool.query('SELECT count(*) FROM gfs_resources') + expect(resources.rows).toEqual([{ count: String(count) }]) + } + + async function shareRowExclusiveLocks(pid: number): Promise { + const result = await databasePool.query( + `SELECT relation::regclass::text AS relation, granted + FROM pg_locks + WHERE pid = $1 AND mode = 'ShareRowExclusiveLock'`, + [pid] + ) + return result.rows + } + + async function migrate() { + const triggerDdl: string[] = [] + let locksBeforeBackfill: unknown[] | undefined + let locksAfterRollback: unknown[] | undefined + let statementTimeout: string | undefined + let error: unknown + const started = Date.now() + try { + await initDb({ + connect: async () => { + const client = await databasePool.connect() + const { rows } = await client.query<{ pid: number }>('SELECT pg_backend_pid() AS pid') + const pid = rows[0]!.pid + return { + release: (destroy?: boolean | Error) => client.release(destroy), + query: async (sql: string, values?: unknown[]) => { + if (/\bCREATE\s+(?:OR\s+REPLACE\s+)?TRIGGER\b/i.test(sql)) triggerDdl.push(sql) + if (sql.trimStart().startsWith('INSERT INTO authorization_resource_revisions(')) { + locksBeforeBackfill = await shareRowExclusiveLocks(pid) + const setting = await client.query('SHOW statement_timeout') + statementTimeout = setting.rows[0].statement_timeout + } + const result = await client.query(sql, values) + if (sql.trim() === 'ROLLBACK') { + locksAfterRollback = await shareRowExclusiveLocks(pid) + } + return result + }, + } + }, + }) + } catch (caught) { + error = caught + } + return { + error, + triggerDdl, + locksBeforeBackfill, + locksAfterRollback, + statementTimeout, + elapsedMs: Date.now() - started, + } + } + + it.each([0, 662, 100000])('backfills %i resources and commits its receipt', async count => { + await seedResources(count) + // Existing non-default revisions must survive ON CONFLICT unchanged. + await databasePool.query( + `INSERT INTO authorization_resource_revisions(environment_id, resource_type, resource_id, revision) + SELECT $1, 'gfs_resource', resource_id::text, 7 FROM gfs_resources LIMIT 1`, + [canonicalEnvironmentId()] + ) + + const run = await migrate() + + expect(run.error).toBeUndefined() + expect(run.locksBeforeBackfill).toEqual([]) + expect(run.statementTimeout).toBe('15s') + expect(run.triggerDdl).toHaveLength(1) + const coverage = await databasePool.query( + `SELECT count(*)::int AS resources, count(revision.resource_id)::int AS covered, + count(*) FILTER (WHERE revision.revision = 7)::int AS preserved + FROM gfs_resources AS resource + LEFT JOIN authorization_resource_revisions AS revision + ON revision.environment_id = $1 AND revision.resource_type = 'gfs_resource' + AND revision.resource_id = resource.resource_id::text`, + [canonicalEnvironmentId()] + ) + expect(coverage.rows).toEqual([{ resources: count, covered: count, preserved: count ? 1 : 0 }]) + const receipts = await databasePool.query( + 'SELECT version FROM schema_migrations WHERE version = ANY($1::text[]) ORDER BY version', + [[VERSION, NEXT_VERSION]] + ) + expect(receipts.rows).toEqual([{ version: VERSION }, { version: NEXT_VERSION }]) + await expect(assertDbReady(databasePool)).resolves.toBeUndefined() + console.info('B0 backfill', { + count, + ...coverage.rows[0], + elapsedMs: run.elapsedMs, + statementTimeout: run.statementTimeout, + }) + }) + + it('refuses 100001 resources before trigger DDL and rolls back without a receipt or locks', async () => { + await seedResources(100001) + await databasePool.query( + `INSERT INTO authorization_resource_revisions(environment_id, resource_type, resource_id, revision) + SELECT $1, 'gfs_resource', resource_id::text, 7 FROM gfs_resources LIMIT 1`, + [canonicalEnvironmentId()] + ) + const before = await databasePool.query('SELECT * FROM authorization_resource_revisions') + // Make the unguarded backfill deterministically exceed the real 15s bound. + // This test-only trigger turns guard removal into 57014, independent of + // host speed; it must never run when the count guard is present. + await databasePool.query(` + CREATE FUNCTION test_slow_resource_backfill() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + PERFORM pg_sleep(16); + RETURN NULL; + END; + $$; + CREATE TRIGGER test_slow_resource_backfill BEFORE INSERT ON authorization_resource_revisions + FOR EACH STATEMENT EXECUTE FUNCTION test_slow_resource_backfill(); + `) + + const run = await migrate() + const error = run.error as { code?: string; message?: string } | undefined + console.info('B0 refusal', { + code: error?.code, + message: error?.message, + elapsedMs: run.elapsedMs, + triggerDdl: run.triggerDdl.length, + locksAfterRollback: run.locksAfterRollback, + }) + + expect(error).toMatchObject({ code: 'P0001' }) + expect(error?.message).toContain('0134 gfs_resources backfill refused') + expect(error?.message).toContain('count 100001') + expect(error?.message).toContain('limit 100000') + expect(run.triggerDdl).toEqual([]) + expect(run.locksBeforeBackfill).toBeUndefined() + expect(run.locksAfterRollback).toEqual([]) + const after = await databasePool.query('SELECT * FROM authorization_resource_revisions') + expect(after.rows).toEqual(before.rows) + const receipts = await databasePool.query( + 'SELECT version FROM schema_migrations WHERE version = ANY($1::text[])', + [[VERSION, NEXT_VERSION]] + ) + expect(receipts.rows).toEqual([]) + await expect(assertDbReady(databasePool)).rejects.toThrow(VERSION) + const environment = await databasePool.query( + "SELECT to_regclass('authorization_catalog_environment') AS relation" + ) + expect(environment.rows).toEqual([{ relation: null }]) + }) +}) From d615fdf92264cc0c120bc700f3f11238129f9d02 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Sat, 31 Oct 2026 16:42:35 +0100 Subject: [PATCH 433/528] fix(control-api): prelock user retirement revisions and close hand-over races --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../access/userAccessFoundationSchema.ts | 116 ++ control-api/src/services/adminAuthService.ts | 116 +- .../src/services/directory/membership.ts | 3 + control-api/src/services/directory/users.ts | 6 + .../test/migrationExecutionPolicy.test.ts | 6 +- ...Revisions.realPostgres.integration.test.ts | 223 +++ .../test/services.directory.teams.test.ts | 21 +- .../services.directory.userRetirement.test.ts | 40 +- ...LockOrder.realPostgres.integration.test.ts | 1774 +++++++++++++++++ 11 files changed, 2274 insertions(+), 37 deletions(-) create mode 100644 control-api/test/migrationUserRetirementRevisions.realPostgres.integration.test.ts create mode 100644 control-api/test/userRetirementRevisionLockOrder.realPostgres.integration.test.ts diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 286264e5e4..b9a8309eb3 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.884", + "version": "0.1.885", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.884", + "version": "0.1.885", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 690cedae65..bbf0461d1f 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.884", + "version": "0.1.885", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/access/userAccessFoundationSchema.ts b/control-api/src/services/access/userAccessFoundationSchema.ts index c0023c87e8..00e68ae4c5 100644 --- a/control-api/src/services/access/userAccessFoundationSchema.ts +++ b/control-api/src/services/access/userAccessFoundationSchema.ts @@ -279,6 +279,70 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis END; $$; + CREATE OR REPLACE FUNCTION public.authorization_prelock_user_retirement_revisions(target_user_ids UUID[]) + RETURNS VOID + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = pg_catalog, public, pg_temp + AS $$ + DECLARE + team_keys TEXT[]; + team_key TEXT; + BEGIN + -- Lock the complete principal batch before discovering its affected teams. + -- Callers must not have locked a member of this batch out of UUID order. + PERFORM principal.id FROM public.users AS principal + WHERE principal.id = ANY(target_user_ids) + ORDER BY principal.id FOR UPDATE; + + SELECT array_agg(subject_id ORDER BY subject_id) INTO team_keys + FROM ( + SELECT member.team_id::TEXT AS subject_id + FROM public.team_members AS member + WHERE member.user_id = ANY(target_user_ids) + UNION + SELECT subjects.subject_id + FROM public.workflow_runs_audit AS audit + CROSS JOIN LATERAL (VALUES + (audit.triggerer_team_id::TEXT), + (CASE WHEN audit.usage_team_id = 'control-plane-admin-ui' + THEN NULL ELSE audit.usage_team_id END) + ) AS subjects(subject_id) + WHERE audit.triggerer_user_id = ANY(target_user_ids) + AND subjects.subject_id IS NOT NULL AND subjects.subject_id <> '' + ) AS affected; + + -- Match authorization_bump_subject_revision's validation and raw TEXT order. + -- Do not lowercase before sorting: usage_team_id is TEXT, not a UUID FK. + FOREACH team_key IN ARRAY COALESCE(team_keys, ARRAY[]::TEXT[]) LOOP + IF team_key !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' THEN + RAISE EXCEPTION 'invalid catalog % subject identifier', 'team'; + END IF; + END LOOP; + + -- Parent locks before revision locks, as in the workflow prelock idiom. + PERFORM team.id FROM public.teams AS team + WHERE team.id IN (SELECT key::UUID FROM unnest(team_keys) AS keys(key)) + ORDER BY team.id FOR KEY SHARE; + + -- Child-row writers take their row before the AFTER ROW revision trigger. + -- Lock every status, including deleted, before taking any team revision. + PERFORM member.user_id FROM public.team_members AS member + WHERE member.user_id = ANY(target_user_ids) + ORDER BY member.user_id, member.team_id FOR UPDATE; + + -- Residual pre-existing createRun risk: case variants of one UUID retain raw + -- TEXT keys; collation can invert physical team order. Inventory before rollout. + FOREACH team_key IN ARRAY COALESCE(team_keys, ARRAY[]::TEXT[]) LOOP + PERFORM revision.team_id FROM public.authorization_team_revisions AS revision + WHERE revision.team_id = team_key::UUID FOR UPDATE; + END LOOP; + -- No user-revision prelock: medium/row triggers acquire it after child rows. + END; + $$; + REVOKE ALL ON FUNCTION public.authorization_prelock_user_retirement_revisions(UUID[]) FROM PUBLIC; + GRANT EXECUTE ON FUNCTION public.authorization_prelock_user_retirement_revisions(UUID[]) TO control_api_runtime; + CREATE OR REPLACE FUNCTION authorization_prelock_workflow_run_revisions(target_run_ids UUID[]) RETURNS VOID LANGUAGE plpgsql @@ -1026,6 +1090,58 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis ON CONFLICT (singleton) DO UPDATE SET environment_id = EXCLUDED.environment_id`, [canonicalEnvironmentId()] ) + // Ship coverage and the definer atomically with the 0134 receipt. The DDL + // already fences writers; a bounded wait aborts the entire unit on contention. + // Rerunning initDb after rollback preserves existing revisions and fills gaps. + await db.query(` + DO $$ + DECLARE + previous_lock_timeout TEXT := current_setting('lock_timeout'); + lock_wait_ms INTEGER := floor( + extract(epoch FROM current_setting('deadlock_timeout')::interval) * 500 + ); + BEGIN + IF lock_wait_ms < 1 THEN + RAISE EXCEPTION '0134 revision backfill requires deadlock_timeout >= 2ms'; + END IF; + PERFORM set_config('lock_timeout', LEAST(lock_wait_ms, 500)::TEXT, true); + + -- Teams before users, all lifecycle states. Never reset a bumped revision. + INSERT INTO public.authorization_team_revisions(team_id, revision, updated_at) + SELECT id, 1, clock_timestamp() FROM public.teams ORDER BY id + ON CONFLICT (team_id) DO NOTHING; + INSERT INTO public.authorization_user_revisions(user_id, revision, updated_at) + SELECT id, 1, clock_timestamp() FROM public.users ORDER BY id + ON CONFLICT (user_id) DO NOTHING; + + IF EXISTS (SELECT 1 FROM public.users u LEFT JOIN public.authorization_user_revisions r + ON r.user_id = u.id WHERE r.user_id IS NULL) + OR EXISTS (SELECT 1 FROM public.teams t LEFT JOIN public.authorization_team_revisions r + ON r.team_id = t.id WHERE r.team_id IS NULL) THEN + RAISE EXCEPTION '0134 revision backfill left uncovered users or teams'; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_proc p + WHERE p.oid = 'public.authorization_prelock_user_retirement_revisions(uuid[])'::regprocedure + AND p.prosecdef + AND p.proowner = (SELECT relowner FROM pg_class + WHERE oid = 'public.authorization_user_revisions'::regclass) + AND p.proowner = (SELECT relowner FROM pg_class + WHERE oid = 'public.authorization_team_revisions'::regclass) + AND p.proconfig = ARRAY['search_path=pg_catalog, public, pg_temp'] + AND NOT EXISTS (SELECT 1 FROM aclexplode(COALESCE(p.proacl, acldefault('f', p.proowner))) + WHERE grantee = 0) + AND has_function_privilege('control_api_runtime', p.oid, 'EXECUTE') + ) OR NOT has_table_privilege('control_api_runtime', 'public.authorization_user_revisions', 'SELECT') + OR NOT has_table_privilege('control_api_runtime', 'public.authorization_team_revisions', 'SELECT') + OR has_table_privilege('control_api_runtime', 'public.authorization_user_revisions', 'INSERT, UPDATE, DELETE, TRUNCATE') + OR has_table_privilege('control_api_runtime', 'public.authorization_team_revisions', 'INSERT, UPDATE, DELETE, TRUNCATE') THEN + RAISE EXCEPTION '0134 user retirement prelock owner or runtime privileges are invalid'; + END IF; + PERFORM set_config('lock_timeout', previous_lock_timeout, true); + END; + $$; + `) } async function applyAuthorizationRevisionFunctions(db: DbClient): Promise { diff --git a/control-api/src/services/adminAuthService.ts b/control-api/src/services/adminAuthService.ts index 4593882690..c1319cb113 100644 --- a/control-api/src/services/adminAuthService.ts +++ b/control-api/src/services/adminAuthService.ts @@ -1168,6 +1168,68 @@ export const CONTROL_ADMIN_REPLACED_REASON = 'control_admin_replaced' const TEAM_ROLE_RANK = (column: string) => `(CASE ${column} WHEN 'admin' THEN 3 WHEN 'inviter' THEN 2 ELSE 1 END)` +export class HandoverIdentityChangedError extends Error { + readonly code = 'handover_identity_changed' + + constructor(phase: 'after-prelock' | 'pinned-sender-lookup' | 'before-membership-copy') { + super(`handover identity changed: ${phase}`) + this.name = 'HandoverIdentityChangedError' + } +} + +type HandoverDesktopIdentity = { id: string; email: string | null; lifecycle_state: string } +type HandoverLinkIdentity = HandoverDesktopIdentity & { + link_id: string + generation: number + state: string + row_version: string +} + +// Discovery never locks desktop users. UNIQUE(email) is case-sensitive, so +// every active case-insensitive receiver belongs to the batch, not just one. +// Keep the complete latest-link mapping: even a same-user version change must +// invalidate the snapshot that defined the ordered prelock. +async function discoverHandover(db: DbClient, inviterId: string, receiverEmail: string) { + const inviter = ( + await db.query('SELECT id::text AS id, email, status FROM control_admin_users WHERE id=$1', [ + inviterId, + ]) + ).rows[0] as { id: string; email: string | null; status: string } | undefined + const links = ( + await db.query( + `SELECT link.id::text AS link_id, link.generation, link.state, link.row_version, + u.id::text AS id, u.email, u.lifecycle_state + FROM gfs_desktop_operator_links link JOIN users u ON u.id=link.user_id + WHERE link.control_admin_id=$1::uuid + ORDER BY link.generation DESC, link.created_at DESC LIMIT 1`, + [inviterId] + ) + ).rows as HandoverLinkIdentity[] + let sender: HandoverDesktopIdentity | undefined = + links[0]?.lifecycle_state === 'active' ? links[0] : undefined + if (!links.length && inviter?.email) { + sender = ( + await db.query( + `SELECT u.id::text AS id, u.email, u.lifecycle_state + FROM users u WHERE lower(u.email)=lower($1) AND u.lifecycle_state='active' + AND NOT EXISTS (SELECT 1 FROM gfs_desktop_operator_links l + WHERE l.user_id=u.id AND l.control_admin_id<>$2::uuid) + ORDER BY u.id LIMIT 1`, + [inviter.email, inviterId] + ) + ).rows[0] as HandoverDesktopIdentity | undefined + } + const receivers = ( + await db.query( + `SELECT id::text AS id, email, lifecycle_state FROM users + WHERE lower(email)=lower($1) AND lifecycle_state='active' + AND id IS DISTINCT FROM $2::uuid ORDER BY id`, + [receiverEmail, sender?.id ?? null] + ) + ).rows as HandoverDesktopIdentity[] + return { inviter, links, sender: sender ?? null, receivers } +} + /** * Hand the inviter's access to the invitee and retire the inviter, inside the * transaction that created the invitee's control admin. @@ -1205,6 +1267,26 @@ async function replaceInviterInTransaction( return null } + // Lock the complete sender/receiver union before any membership copy or + // desktop-user lock. Repeated retirement prelocks then consume a held subset. + const newAdminEmail = input.newAdmin.email ?? '' + const handover = await discoverHandover(db, inviter.id, newAdminEmail) + const principalIds = [ + ...new Set([ + ...(handover.sender ? [handover.sender.id] : []), + ...handover.receivers.map(receiver => receiver.id), + ]), + ] + await db.query('SELECT public.authorization_prelock_user_retirement_revisions($1::uuid[])', [ + principalIds, + ]) + if ( + JSON.stringify(handover) !== + JSON.stringify(await discoverHandover(db, inviter.id, newAdminEmail)) + ) { + throw new HandoverIdentityChangedError('after-prelock') + } + // The most recent operator link names this admin's desktop user, revoked or // not. An admin email change never updates users.email, and a revoked link // must not fall through to that email. Email is only the mapping for an @@ -1215,10 +1297,10 @@ async function replaceInviterInTransaction( FROM gfs_desktop_operator_links link JOIN users u ON u.id = link.user_id WHERE link.control_admin_id = $1::uuid + AND u.id = $2::uuid ORDER BY link.generation DESC, link.created_at DESC - LIMIT 1 - FOR UPDATE OF u`, - [inviter.id] + LIMIT 1`, + [inviter.id, handover.links[0]?.id ?? null] ) const linked = linkHistory.rows[0] as | { id: string; email: string | null; lifecycle_state: string } @@ -1238,17 +1320,18 @@ async function replaceInviterInTransaction( WHERE l.user_id = u.id AND l.control_admin_id <> $2::uuid ) + AND u.id = $3::uuid ORDER BY u.id - LIMIT 1 - FOR UPDATE OF u`, - [inviter.email, inviter.id] + LIMIT 1`, + [inviter.email, inviter.id, handover.sender?.id ?? null] ) inviterDesktop = emailMatch.rows[0] as { id: string; email: string | null } | undefined } const inviterDesktopUserId = inviterDesktop?.id ?? null const inviterDesktopEmail = inviterDesktop?.email ?? null - // completeControlAdminInvitation always inserts the normalized invitation email. - const newAdminEmail = input.newAdmin.email ?? '' + if (inviterDesktopUserId !== (handover.sender?.id ?? null)) { + throw new HandoverIdentityChangedError('pinned-sender-lookup') + } if (inviterDesktopUserId) { // The invitee is the inviter's own desktop user. Copying roles onto that @@ -1297,13 +1380,22 @@ async function replaceInviterInTransaction( } if (hasRoles && !hasReceiver) return 'replace_inviter_receiver_missing' + // Close identity drift after the initial revalidation, including link row + // versions. No copy may run on a changed batch; the caller rolls back admin + // creation and invitation acceptance as well. Never retry or enlarge it. + const verified = await discoverHandover(db, inviter.id, newAdminEmail) + if (JSON.stringify(handover) !== JSON.stringify(verified)) { + throw new HandoverIdentityChangedError('before-membership-copy') + } + const receiverIds = verified.receivers.map(receiver => receiver.id) + // (1a) The invitee already has a desktop user: raise its memberships. await db.query( `INSERT INTO team_members(team_id, user_id, role, status) SELECT source.team_id, target.id, source.role, 'active' FROM team_members source JOIN users target - ON lower(target.email) = lower($2) + ON target.id = ANY($2::uuid[]) AND target.lifecycle_state = 'active' AND target.id <> $1::uuid WHERE source.user_id = $1::uuid @@ -1317,7 +1409,7 @@ async function replaceInviterInTransaction( END, status = 'active', updated_at = NOW()`, - [inviterDesktopUserId, newAdminEmail] + [inviterDesktopUserId, receiverIds] ) // (1b) The desktop invitation is accepted after this transaction commits // (POST /admin/auth/control-admin-invitations/complete) and upserts @@ -1352,7 +1444,7 @@ async function replaceInviterInTransaction( ON source.user_id = $1::uuid AND source.status = 'active' LEFT JOIN users target - ON lower(target.email) = lower(i.email) + ON target.id = ANY($3::uuid[]) AND target.lifecycle_state = 'active' LEFT JOIN team_members held ON held.user_id = target.id @@ -1367,7 +1459,7 @@ async function replaceInviterInTransaction( THEN EXCLUDED.role ELSE invitation_teams.role END`, - [inviterDesktopUserId, newAdminEmail] + [inviterDesktopUserId, newAdminEmail, receiverIds] ) // (2) Retire, never hard-delete, the inviter's desktop identity. diff --git a/control-api/src/services/directory/membership.ts b/control-api/src/services/directory/membership.ts index 5404f1ad2d..05a3e30744 100644 --- a/control-api/src/services/directory/membership.ts +++ b/control-api/src/services/directory/membership.ts @@ -1701,6 +1701,9 @@ export async function deleteManagedUserForUser( const principalIds = [...new Set([normalizedManagerUserId, normalizedTargetUserId])] .filter(id => USER_LOCK_UUID_PATTERN.test(id)) .map(id => id.toLowerCase()) + await db.query('SELECT public.authorization_prelock_user_retirement_revisions($1::uuid[])', [ + principalIds, + ]) await db.query( `SELECT id FROM users diff --git a/control-api/src/services/directory/users.ts b/control-api/src/services/directory/users.ts index 6076b04d67..25c8ee206b 100644 --- a/control-api/src/services/directory/users.ts +++ b/control-api/src/services/directory/users.ts @@ -495,6 +495,9 @@ export async function retireDesktopUser( ) } const lifecycleVersion = requireLifecycleVersion(user.lifecycle_version) + await db.query('SELECT public.authorization_prelock_user_retirement_revisions($1::uuid[])', [ + [targetUserId], + ]) await options.authorize?.(db) const history = await db.query( @@ -678,6 +681,9 @@ export async function adminDeleteUserInTransaction( db: DbClient, userId: string ): Promise { + await db.query('SELECT public.authorization_prelock_user_retirement_revisions($1::uuid[])', [ + [userId], + ]) const exists = await db.query(`SELECT 1 FROM users WHERE id = $1 LIMIT 1`, [userId]) if ((exists.rowCount ?? 0) === 0) { return { error: 'not_found' } diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index 5bf0f75fbf..baa637fc4c 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -115,11 +115,13 @@ describe('D34 migration execution policy', () => { // expiry. B4.1 (PR #334 review r2) then granted the approval prelock to the // Workflow Recipes runtime, which holds the stuck-run sweep's approval // subjects before its first run phase change. DB-2 B0 now guards the GFS - // backfill at 100000 resources and runs it before trigger DDL. + // backfill at 100000 resources and runs it before trigger DDL. DB-1 + // (D-D1: no shared 0134 receipts) adds the retirement definer and atomic + // user/team coverage with fail-closed receipt postconditions in 0134. const files = [ [ 'src/services/access/userAccessFoundationSchema.ts', - '23c041f3836530622ba43494e4978e6a79c57d9b33a83c3b536ef5c72423018c', + 'd0245a5ac51ef2e782c91923db538466f6fce1ff4975043e3b5dd71c9fa06908', ], [ 'src/services/directory/invitationDeliverySchema.ts', diff --git a/control-api/test/migrationUserRetirementRevisions.realPostgres.integration.test.ts b/control-api/test/migrationUserRetirementRevisions.realPostgres.integration.test.ts new file mode 100644 index 0000000000..1cb2f7108b --- /dev/null +++ b/control-api/test/migrationUserRetirementRevisions.realPostgres.integration.test.ts @@ -0,0 +1,223 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { randomBytes, randomUUID } from 'node:crypto' +import { Pool } from 'pg' +import { CONTROL_API_MIGRATIONS, assertDbReady, initDb } from '../src/db.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' + +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const describeRealPostgres = adminUrl ? describe : describe.skip +const VERSION = '0134_composable_catalog_revisions' +const isBackfill = (sql: string) => sql.includes('previous_lock_timeout TEXT') + +describeRealPostgres('0134 user retirement revision coverage on PostgreSQL 16', () => { + let database: string + let admin: Pool + let db: Pool + const user = randomUUID(), + retired = randomUUID(), + team = randomUUID() + + beforeEach(async () => { + expect(process.version).toMatch(/^v24\./) + database = `retirement_migration_${randomBytes(6).toString('hex')}` + admin = new Pool({ connectionString: adminUrl }) + const version = Number( + (await admin.query('SHOW server_version_num')).rows[0].server_version_num + ) + expect(version).toBeGreaterThanOrEqual(160000) + expect(version).toBeLessThan(170000) + await admin.query(`CREATE DATABASE "${database}"`) + const url = new URL(adminUrl!) + url.pathname = `/${database}` + db = new Pool({ connectionString: url.toString() }) + const migration = CONTROL_API_MIGRATIONS.find(m => m.version === VERSION)! + const apply = migration.apply + const stop = new Error('stop before 0134') + migration.apply = async () => { + throw stop + } + try { + await expect(initDb({ connect: () => db.connect() })).rejects.toBe(stop) + } finally { + migration.apply = apply + } + await db.query( + "INSERT INTO users(id,email) VALUES($1,'active@example.test'),($2,'retired@example.test')", + [user, retired] + ) + await db.query("INSERT INTO teams(id,name) VALUES($1,'team without memberships')", [team]) + // A valid retained lifecycle from before 0134, without relying on the new caller. + const actor = randomUUID(), + operation = randomUUID() + await db.query( + `INSERT INTO control_admin_users(id,username,password_hash,role,status) + VALUES($1,$2,'test-unusable-hash','admin','disabled')`, + [actor, actor] + ) + await db.query( + `INSERT INTO desktop_user_retirement_operations(id,operation,actor_type, + actor_control_admin_id,target_user_id,idempotency_key_hash,request_fingerprint,reason) + VALUES($1,'retire_desktop_user','control_admin',$2,$3,repeat('a',64),repeat('b',64),'legacy retirement')`, + [operation, actor, retired] + ) + await db.query( + `UPDATE users SET lifecycle_state='retired',retired_at=NOW(), + retirement_reason='legacy retirement',retired_by_type='control_admin', + retired_by_control_admin_id=$2,retirement_operation_id=$3 WHERE id=$1`, + [retired, actor, operation] + ) + await db.query('DELETE FROM authorization_user_revisions') + await db.query('DELETE FROM authorization_team_revisions') + }) + afterEach(async () => { + try { + await endPoolAndWaitForClients(db) + if (admin && database) await admin.query(`DROP DATABASE IF EXISTS "${database}"`) + } finally { + await endPoolAndWaitForClients(admin) + } + }) + async function coverage() { + return ( + await db.query(`SELECT + (SELECT count(*)::int FROM users u LEFT JOIN authorization_user_revisions r ON r.user_id=u.id WHERE r.user_id IS NULL) AS users, + (SELECT count(*)::int FROM teams t LEFT JOIN authorization_team_revisions r ON r.team_id=t.id WHERE r.team_id IS NULL) AS teams`) + ).rows[0] + } + async function assertNoReceipt() { + expect( + (await db.query('SELECT version FROM schema_migrations WHERE version >= $1', [VERSION])).rows + ).toEqual([]) + await expect(assertDbReady(db)).rejects.toThrow(VERSION) + expect( + ( + await db.query( + "SELECT to_regprocedure('public.authorization_prelock_user_retirement_revisions(uuid[])')::text AS function" + ) + ).rows + ).toEqual([{ function: null }]) + } + it('covers every lifecycle and teams without memberships, preserves revisions and commits one receipt', async () => { + await db.query('INSERT INTO authorization_user_revisions(user_id,revision) VALUES($1,7)', [ + user, + ]) + await initDb({ connect: () => db.connect() }) + expect(await coverage()).toEqual({ users: 0, teams: 0 }) + expect( + (await db.query('SELECT user_id,revision FROM authorization_user_revisions ORDER BY user_id')) + .rows + ).toEqual( + [ + { user_id: user, revision: '7' }, + { user_id: retired, revision: '1' }, + ].sort((a, b) => a.user_id.localeCompare(b.user_id)) + ) + expect( + (await db.query('SELECT revision FROM authorization_team_revisions WHERE team_id=$1', [team])) + .rows + ).toEqual([{ revision: '1' }]) + const before = (await db.query('SELECT * FROM authorization_user_revisions ORDER BY user_id')) + .rows + await initDb({ connect: () => db.connect() }) + expect( + (await db.query('SELECT * FROM authorization_user_revisions ORDER BY user_id')).rows + ).toEqual(before) + expect( + ( + await db.query('SELECT count(*)::int AS count FROM schema_migrations WHERE version=$1', [ + VERSION, + ]) + ).rows + ).toEqual([{ count: 1 }]) + await expect(assertDbReady(db)).resolves.toBeUndefined() + }) + it('rolls back interrupted coverage and function DDL without a receipt, then resumes', async () => { + const interruption = new Error('interrupted after coverage') + await expect( + initDb({ + connect: async () => { + const c = await db.connect() + return { + release: () => c.release(), + query: async (sql: string, values?: unknown[]) => { + const result = await c.query(sql, values) + if (isBackfill(sql)) throw interruption + return result + }, + } + }, + }) + ).rejects.toBe(interruption) + await assertNoReceipt() + expect(await coverage()).toEqual({ users: 2, teams: 1 }) + await initDb({ connect: () => db.connect() }) + expect(await coverage()).toEqual({ users: 0, teams: 0 }) + }) + it('refuses a receipt when runtime EXECUTE is missing and restores the whole unit on rerun', async () => { + await expect( + initDb({ + connect: async () => { + const c = await db.connect() + return { + release: () => c.release(), + query: async (sql: string, values?: unknown[]) => { + if (isBackfill(sql)) + await c.query( + 'REVOKE EXECUTE ON FUNCTION public.authorization_prelock_user_retirement_revisions(uuid[]) FROM control_api_runtime' + ) + return c.query(sql, values) + }, + } + }, + }) + ).rejects.toThrow('prelock owner or runtime privileges are invalid') + await assertNoReceipt() + expect(await coverage()).toEqual({ users: 2, teams: 1 }) + await initDb({ connect: () => db.connect() }) + expect(await coverage()).toEqual({ users: 0, teams: 0 }) + }) + it('bounds the real migration backfill lock wait below deadlock detection and resumes after rollback', async () => { + const blocker = await db.connect() + let migrationPid: number | undefined + const blockerPid = (await blocker.query('SELECT pg_backend_pid() AS pid')).rows[0].pid + let observed: unknown + let migration: Promise | undefined + try { + await blocker.query('BEGIN') + await blocker.query('SELECT id FROM users WHERE id=$1 FOR UPDATE', [user]) + migration = initDb({ + connect: async () => { + const c = await db.connect() + await c.query("SET deadlock_timeout='2s'") + migrationPid = (await c.query('SELECT pg_backend_pid() AS pid')).rows[0].pid + return c + }, + }).then( + () => null, + error => error + ) + const deadline = Date.now() + 8000 + while (Date.now() < deadline) { + if (migrationPid) { + const row = (await db.query('SELECT pg_blocking_pids($1) AS blockers', [migrationPid])) + .rows[0] + if (row.blockers.includes(blockerPid)) { + observed = row + break + } + } + await new Promise(resolve => setTimeout(resolve, 5)) + } + expect(observed).toBeDefined() + expect(await migration).toMatchObject({ code: '55P03' }) + await assertNoReceipt() + expect(await coverage()).toEqual({ users: 2, teams: 1 }) + } finally { + await blocker.query('ROLLBACK') + await migration + blocker.release() + } + await initDb({ connect: () => db.connect() }) + expect(await coverage()).toEqual({ users: 0, teams: 0 }) + }) +}) diff --git a/control-api/test/services.directory.teams.test.ts b/control-api/test/services.directory.teams.test.ts index 8ddd339fad..821a7353b9 100644 --- a/control-api/test/services.directory.teams.test.ts +++ b/control-api/test/services.directory.teams.test.ts @@ -295,11 +295,13 @@ describe('services/directory team management unit tests', () => { }) it('adminDeleteUser returns not_found when user missing', async () => { + dbMocks.txQuery.mockResolvedValueOnce({ rows: [], rowCount: 1 }) dbMocks.txQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }) await expect(adminDeleteUser('u1')).resolves.toEqual({ error: 'not_found' }) }) - it('adminDeleteUser does not inspect team memberships before deleting the user row', async () => { + it('adminDeleteUser delegates ordered revision locking to the definer before deleting', async () => { + dbMocks.txQuery.mockResolvedValueOnce({ rows: [], rowCount: 1 }) dbMocks.txQuery.mockResolvedValueOnce({ rows: [{ x: 1 }], rowCount: 1 }) dbMocks.txQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }) dbMocks.txQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }) @@ -307,11 +309,17 @@ describe('services/directory team management unit tests', () => { dbMocks.txQuery.mockResolvedValueOnce({ rows: [{ id: 'u1' }], rowCount: 1 }) await expect(adminDeleteUser('u1')).resolves.toEqual({ ok: true, id: 'u1' }) + expect(dbMocks.txQuery).toHaveBeenNthCalledWith( + 1, + 'SELECT public.authorization_prelock_user_retirement_revisions($1::uuid[])', + [['u1']] + ) const sqls = dbMocks.txQuery.mock.calls.map(([sql]) => String(sql)) expect(sqls.some(sql => sql.includes('FROM team_members'))).toBe(false) }) it('adminDeleteUser disables approval mediums before deleting the user row', async () => { + dbMocks.txQuery.mockResolvedValueOnce({ rows: [], rowCount: 1 }) dbMocks.txQuery.mockResolvedValueOnce({ rows: [{ x: 1 }], rowCount: 1 }) dbMocks.txQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }) dbMocks.txQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }) @@ -319,35 +327,36 @@ describe('services/directory team management unit tests', () => { dbMocks.txQuery.mockResolvedValueOnce({ rows: [{ id: 'u1' }], rowCount: 1 }) await expect(adminDeleteUser('u1')).resolves.toEqual({ ok: true, id: 'u1' }) expect(dbMocks.txQuery).toHaveBeenNthCalledWith( - 2, + 3, expect.stringContaining('FROM gfs_desktop_operator_links'), ['u1'] ) expect(dbMocks.txQuery).toHaveBeenNthCalledWith( - 3, + 4, expect.stringContaining('UPDATE workflow_approval_medium_accounts'), ['u1'] ) expect(dbMocks.txQuery).toHaveBeenNthCalledWith( - 4, + 5, expect.stringContaining('UPDATE workflow_approval_medium_challenges'), ['u1'] ) expect(dbMocks.txQuery).toHaveBeenNthCalledWith( - 5, + 6, expect.stringContaining('DELETE FROM users'), ['u1'] ) }) it('refuses the legacy hard-delete when operator-link history is retained', async () => { + dbMocks.txQuery.mockResolvedValueOnce({ rows: [], rowCount: 1 }) dbMocks.txQuery.mockResolvedValueOnce({ rows: [{ x: 1 }], rowCount: 1 }) dbMocks.txQuery.mockResolvedValueOnce({ rows: [{ '?column?': 1 }], rowCount: 1 }) await expect(adminDeleteUser('u1')).resolves.toEqual({ error: 'gfs_operator_link_history_retained', }) - expect(dbMocks.txQuery).toHaveBeenCalledTimes(2) + expect(dbMocks.txQuery).toHaveBeenCalledTimes(3) expect(dbMocks.txQuery).not.toHaveBeenCalledWith(expect.stringContaining('DELETE FROM users'), [ 'u1', ]) diff --git a/control-api/test/services.directory.userRetirement.test.ts b/control-api/test/services.directory.userRetirement.test.ts index e81e806699..be078bb14b 100644 --- a/control-api/test/services.directory.userRetirement.test.ts +++ b/control-api/test/services.directory.userRetirement.test.ts @@ -77,10 +77,16 @@ describe('retireDesktopUser', () => { it('retires an active linked user atomically with exact control-admin actor and audit correlation', async () => { const { retireDesktopUser } = await import('../src/services/directory/users.js') - const authorize = vi.fn(async () => undefined) + const authorize = vi.fn(async () => { + expect(mocks.txQuery.mock.calls.at(-1)).toEqual([ + 'SELECT public.authorization_prelock_user_retirement_revisions($1::uuid[])', + [[USER_ID]], + ]) + }) mocks.txQuery .mockResolvedValueOnce({ rows: [{ id: OPERATION_ID }], rowCount: 1 }) .mockResolvedValueOnce(activeUser()) + .mockResolvedValueOnce({ rows: [], rowCount: 1 }) .mockResolvedValueOnce({ rows: [{ has_link_history: true }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }) @@ -117,18 +123,18 @@ describe('retireDesktopUser', () => { operationId: OPERATION_ID, }) ) - const userUpdate = String(mocks.txQuery.mock.calls[5]?.[0]) + const userUpdate = String(mocks.txQuery.mock.calls[6]?.[0]) expect(userUpdate).toContain("lifecycle_state = 'retired'") - expect(String(mocks.txQuery.mock.calls[3]?.[0])).toContain( + expect(String(mocks.txQuery.mock.calls[4]?.[0])).toContain( 'UPDATE workflow_approval_medium_accounts' ) - expect(String(mocks.txQuery.mock.calls[4]?.[0])).toContain( + expect(String(mocks.txQuery.mock.calls[5]?.[0])).toContain( 'UPDATE workflow_approval_medium_challenges' ) expect(userUpdate).toContain('retired_by_control_admin_id = $4::uuid') expect(userUpdate).toContain('retired_by_desktop_user_id = $5::uuid') expect(userUpdate).toContain('lifecycle_version = $8') - expect(mocks.txQuery.mock.calls[5]?.[1]).toEqual([ + expect(mocks.txQuery.mock.calls[6]?.[1]).toEqual([ USER_ID, 'policy retirement', 'control_admin', @@ -145,6 +151,7 @@ describe('retireDesktopUser', () => { mocks.txQuery .mockResolvedValueOnce({ rows: [{ id: OPERATION_ID }], rowCount: 1 }) .mockResolvedValueOnce(activeUser()) + .mockResolvedValueOnce({ rows: [], rowCount: 1 }) .mockResolvedValueOnce({ rows: [{ has_link_history: true }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }) @@ -166,7 +173,7 @@ describe('retireDesktopUser', () => { actor: { kind: 'platform_user', desktopUserId: MANAGER_ID }, }) ) - expect(mocks.txQuery.mock.calls[5]?.[1]).toEqual([ + expect(mocks.txQuery.mock.calls[6]?.[1]).toEqual([ USER_ID, 'manager retirement', 'platform_user', @@ -242,6 +249,7 @@ describe('retireDesktopUser', () => { mocks.txQuery .mockResolvedValueOnce({ rows: [{ id: OPERATION_ID }], rowCount: 1 }) .mockResolvedValueOnce(activeUser()) + .mockResolvedValueOnce({ rows: [], rowCount: 1 }) .mockResolvedValueOnce({ rows: [{ has_link_history: false }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }) @@ -264,8 +272,8 @@ describe('retireDesktopUser', () => { replayed: false, }) expect(mocks.retireParentInTransaction).not.toHaveBeenCalled() - expect(String(mocks.txQuery.mock.calls[5]?.[0])).toContain('DELETE FROM users') - expect(String(mocks.txQuery.mock.calls[6]?.[0])).toContain("outcome = 'deleted'") + expect(String(mocks.txQuery.mock.calls[6]?.[0])).toContain('DELETE FROM users') + expect(String(mocks.txQuery.mock.calls[7]?.[0])).toContain("outcome = 'deleted'") }) it('retires a user after the Control UI already revoked the operator generation', async () => { @@ -273,6 +281,7 @@ describe('retireDesktopUser', () => { mocks.txQuery .mockResolvedValueOnce({ rows: [{ id: OPERATION_ID }], rowCount: 1 }) .mockResolvedValueOnce(activeUser()) + .mockResolvedValueOnce({ rows: [], rowCount: 1 }) .mockResolvedValueOnce({ rows: [{ has_link_history: true }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }) @@ -318,6 +327,7 @@ describe('retireDesktopUser', () => { mocks.txQuery .mockResolvedValueOnce({ rows: [{ id: OPERATION_ID }], rowCount: 1 }) .mockResolvedValueOnce(activeUser()) + .mockResolvedValueOnce({ rows: [], rowCount: 1 }) .mockResolvedValueOnce({ rows: [{ has_link_history: true }], rowCount: 1 }) mocks.retireParentInTransaction.mockRejectedValueOnce(new Error('audit unavailable')) @@ -341,6 +351,7 @@ describe('retireDesktopUser', () => { mocks.txQuery .mockResolvedValueOnce({ rows: [{ id: OPERATION_ID }], rowCount: 1 }) .mockResolvedValueOnce(activeUser(7)) + .mockResolvedValueOnce({ rows: [], rowCount: 1 }) .mockResolvedValueOnce({ rows: [{ has_link_history: true }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }) @@ -356,8 +367,8 @@ describe('retireDesktopUser', () => { 'request-cas-1' ) ).rejects.toMatchObject({ code: 'retirement_conflict' }) - expect(mocks.txQuery).toHaveBeenCalledTimes(6) - expect(String(mocks.txQuery.mock.calls[5]?.[0])).toContain('lifecycle_version = $8') + expect(mocks.txQuery).toHaveBeenCalledTimes(7) + expect(String(mocks.txQuery.mock.calls[6]?.[0])).toContain('lifecycle_version = $8') }) it('rejects an actor with the wrong typed identity field before opening a transaction', async () => { @@ -381,6 +392,7 @@ describe('retireDesktopUser', () => { mocks.txQuery .mockResolvedValueOnce({ rows: [{ id: OPERATION_ID }], rowCount: 1 }) .mockResolvedValueOnce(activeUser()) + .mockResolvedValueOnce({ rows: [], rowCount: 1 }) .mockResolvedValueOnce({ rows: [{ has_link_history: false }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }) @@ -409,9 +421,9 @@ describe('retireDesktopUser', () => { expect(mocks.withTransaction).not.toHaveBeenCalled() const sqls = mocks.txQuery.mock.calls.map(([sql]) => String(sql)) expect(sqls.some(sql => sql.includes('DELETE FROM users'))).toBe(false) - expect(sqls[3]).toContain('UPDATE workflow_approval_medium_accounts') - expect(sqls[4]).toContain('UPDATE workflow_approval_medium_challenges') - expect(sqls[5]).toContain("SET lifecycle_state = 'retired'") - expect(sqls[6]).toContain("outcome = 'retired'") + expect(sqls[4]).toContain('UPDATE workflow_approval_medium_accounts') + expect(sqls[5]).toContain('UPDATE workflow_approval_medium_challenges') + expect(sqls[6]).toContain("SET lifecycle_state = 'retired'") + expect(sqls[7]).toContain("outcome = 'retired'") }) }) diff --git a/control-api/test/userRetirementRevisionLockOrder.realPostgres.integration.test.ts b/control-api/test/userRetirementRevisionLockOrder.realPostgres.integration.test.ts new file mode 100644 index 0000000000..8ceb6ba621 --- /dev/null +++ b/control-api/test/userRetirementRevisionLockOrder.realPostgres.integration.test.ts @@ -0,0 +1,1774 @@ +// Real service transactions, triggers and deterministic PostgreSQL contention witnesses. +// Fixed cases observe production SQL unchanged. Only the seven historical +// characterizations suppress prelock calls to reproduce the original cycles. +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import express from 'express' +import { AsyncLocalStorage } from 'node:async_hooks' +import { randomBytes, randomUUID } from 'node:crypto' +import { appendFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { Pool, type PoolClient } from 'pg' +import request from 'supertest' +import { initDb, pool } from '../src/db.js' +import { createAdminUsersRouter } from '../src/routes/admin/users.js' +import { applyComposableCatalogRevisionSchema } from '../src/services/access/userAccessFoundationSchema.js' +import { completeControlAdminInvitation } from '../src/services/adminAuthService.js' +import type { ExternalSessionAuthorityContext } from '../src/services/auth/externalSessionAuthentication.js' +import { createUserSession } from '../src/services/auth/userSessionService.js' +import { deleteManagedUserForUser, updateMemberRole } from '../src/services/directory/membership.js' +import { adminDeleteUserInTransaction, retireDesktopUser } from '../src/services/directory/users.js' +import { preferVerifiedMediumAccount } from '../src/services/workflowApprovalMediumPreferenceService.js' +import { createRun } from '../src/services/workflowRunService.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' + +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const evidence = process.env.PR334_DB1_TRACE_EVIDENCE +const describeRealPostgres = adminUrl ? describe : describe.skip +const target = new URL(adminUrl ?? 'postgresql://localhost/postgres') +const events: any[] = [] +const barrierClass = 334_031 +const database = `pr334_db1_${randomBytes(6).toString('hex')}` +const actorId = randomUUID() +let adminPool: Pool +let databasePool: Pool + +function save(name: string, data: unknown) { + if (evidence) writeFileSync(join(evidence, `${name}.json`), JSON.stringify(data, null, 2) + '\n') +} +function event(data: unknown) { + events.push(data) + if (evidence) appendFileSync(join(evidence, 'trace-events.jsonl'), JSON.stringify(data) + '\n') +} +type Outcome = + | { status: 'fulfilled'; value: unknown; transaction: string } + | { + status: 'rejected' + code: string + errorName?: string + message: string + detail?: string + where?: string + transaction: string + } +function observe(client: PoolClient, write: Promise): Promise { + return write.then( + async value => { + const result = await client.query('COMMIT') + return { status: 'fulfilled', value, transaction: result.command } as const + }, + async error => { + await client.query('ROLLBACK') + return { + status: 'rejected', + code: String(error.code), + message: error.message, + errorName: error.name, + detail: error.detail, + where: error.where, + transaction: 'ROLLBACK', + } as const + } + ) +} +async function pid(client: PoolClient) { + return (await client.query<{ pid: number }>('SELECT pg_backend_pid() AS pid')).rows[0]!.pid +} +async function begin(client: PoolClient, application: string) { + await client.query('BEGIN') + await client.query("SELECT set_config('application_name', $1, true)", [application]) + // Long enough to capture BOTH edges before PostgreSQL chooses a victim. + await client.query("SET LOCAL deadlock_timeout = '2s'") + await client.query("SET LOCAL statement_timeout = '12s'") + await client.query("SET LOCAL idle_in_transaction_session_timeout = '25s'") +} +async function waitForEdges(edges: [number, number][]) { + const deadline = Date.now() + 4_000 + while (Date.now() < deadline) { + const { rows } = await databasePool.query<{ pid: number; blockers: number[] }>( + 'SELECT pid, pg_blocking_pids(pid) AS blockers FROM pg_stat_activity WHERE pid = ANY($1::int[])', + [edges.map(([blocked]) => blocked)] + ) + if ( + edges.every(([blocked, blocker]) => + rows.some(row => row.pid === blocked && row.blockers.includes(blocker)) + ) + ) + return + await new Promise(resolve => setTimeout(resolve, 10)) + } + throw new Error(`Expected pg_blocking_pids edges not seen: ${JSON.stringify(edges)}`) +} +type Fixture = { user: string; team: string; secondTeam: string; label: string } +async function snapshot(label: string, phase: string, pids: number[], fixture: Fixture) { + const activity = ( + await databasePool.query( + ` + SELECT pid, application_name, state, backend_xid::text, wait_event_type, wait_event, + query, pg_blocking_pids(pid) AS blockers + FROM pg_stat_activity WHERE pid = ANY($1::int[]) ORDER BY pid`, + [pids] + ) + ).rows + const locks = ( + await databasePool.query( + ` + SELECT l.pid, a.application_name, l.locktype, l.mode, l.granted, + l.relation::regclass::text AS relation, l.page, l.tuple, + l.virtualxid, l.transactionid::text, l.classid::text, l.objid::text, + l.objsubid, l.fastpath, l.waitstart + FROM pg_locks l LEFT JOIN pg_stat_activity a ON a.pid = l.pid + WHERE l.pid = ANY($1::int[]) + ORDER BY l.pid, l.locktype, relation, l.transactionid::text, l.granted`, + [pids] + ) + ).rows + const xidEdges = ( + await databasePool.query( + ` + SELECT w.pid AS waiter, w.mode AS wait_mode, w.transactionid::text AS xid, + h.pid AS holder, h.mode AS held_mode + FROM pg_locks w JOIN pg_locks h + ON w.locktype = 'transactionid' AND h.locktype = 'transactionid' + AND w.transactionid = h.transactionid AND w.pid <> h.pid + WHERE NOT w.granted AND h.granted AND h.mode = 'ExclusiveLock' + AND w.pid = ANY($1::int[]) ORDER BY w.pid`, + [pids] + ) + ).rows + // pg_locks does NOT enumerate normally held tuple locks. Keep its raw data + // intact and supplement with PostgreSQL's pgrowlocks to identify held rows. + const rowLocks: Record = {} + for (const table of [ + 'users', + 'team_members', + 'authorization_user_revisions', + 'authorization_team_revisions', + 'workflow_approval_medium_accounts', + ]) { + rowLocks[table] = (await databasePool.query(`SELECT * FROM pgrowlocks('${table}')`)).rows + } + const visibleRows = ( + await databasePool.query( + ` + SELECT 'users' AS table_name, id::text AS subject, ctid::text, xmin::text, xmax::text + FROM users WHERE id = $1::uuid + UNION ALL SELECT 'authorization_user_revisions', user_id::text, ctid::text, xmin::text, xmax::text + FROM authorization_user_revisions WHERE user_id = $1::uuid + UNION ALL SELECT 'authorization_team_revisions', team_id::text, ctid::text, xmin::text, xmax::text + FROM authorization_team_revisions WHERE team_id = $2::uuid + UNION ALL SELECT 'team_members', user_id::text, ctid::text, xmin::text, xmax::text + FROM team_members WHERE user_id = $1::uuid`, + [fixture.user, fixture.team] + ) + ).rows + const data = { + label, + phase, + utc: new Date().toISOString(), + fixture, + activity, + locks, + xidEdges, + rowLocks, + visibleRows, + } + save(`${label}.${phase}`, data) + event({ label, phase, activity, xidEdges }) + return data +} +async function fixture(label: string, legacy = false, medium = true): Promise { + const user = randomUUID(), + team = randomUUID(), + secondTeam = randomUUID() + await databasePool.query("INSERT INTO users(id, email, name) VALUES ($1, $2, 'DB1 trace')", [ + user, + `db1-${user}@example.test`, + ]) + for (const id of [team, secondTeam]) + await databasePool.query("INSERT INTO teams(id, name) VALUES ($1, 'DB1 trace')", [id]) + await databasePool.query( + "INSERT INTO team_members(team_id, user_id, role, status) VALUES ($1, $2, 'member', 'active')", + [team, user] + ) + if (medium) + await databasePool.query( + `INSERT INTO workflow_approval_medium_accounts + (user_id, medium, provider_user_id, communication_channel_ref, verified_at) + VALUES ($1, 'telegram', $2, 'db1-test-channel', NOW())`, + [user, `db1-${user}`] + ) + if (legacy) { + // Simulate pre-0134 subjects after all fixture inserts; keep every real + // trigger/FK enabled. No production bump implementation is replaced. + await databasePool.query('DELETE FROM authorization_user_revisions WHERE user_id = $1', [user]) + await databasePool.query('DELETE FROM authorization_team_revisions WHERE team_id = $1', [team]) + } + return { user, team, secondTeam, label } +} +async function revisionBarrier(application: string, table: string, subject: string) { + await databasePool.query('INSERT INTO db1_trace_barriers VALUES ($1, $2, $3)', [ + application, + table, + subject, + ]) +} +function runWrite(client: PoolClient, f: Fixture) { + return createRun( + { + recipe_namespace: 'db1-trace', + recipe_name: f.label, + actor_type: 'user', + actor_id: f.user, + team_id: f.team, + usage_team_id: f.team, + idempotency_key: null, + trigger_source: 'onDemand', + ttl_seconds_after_finished: 2592000, + }, + client + ) +} +function retirementWrite( + client: PoolClient, + f: Fixture, + options: { authorize?: (db: PoolClient) => Promise; tombstone?: boolean } = {} +) { + return retireDesktopUser( + { kind: 'control_admin', controlAdminId: actorId }, + f.user, + 'DB1 local diagnostic', + `db1-${f.label}-${f.user}`, + null, + { + db: client, + retainWithoutLinkHistory: options.tombstone, + authorize: options.authorize ? db => options.authorize!(db as PoolClient) : undefined, + } + ) +} +async function finalState(f: Fixture) { + return { + users: ( + await databasePool.query('SELECT id, lifecycle_state FROM users WHERE id = $1', [f.user]) + ).rows, + memberships: ( + await databasePool.query('SELECT team_id, status FROM team_members WHERE user_id = $1', [ + f.user, + ]) + ).rows, + medium: ( + await databasePool.query( + 'SELECT disabled_at IS NOT NULL AS disabled FROM workflow_approval_medium_accounts WHERE user_id = $1', + [f.user] + ) + ).rows, + runs: ( + await databasePool.query( + 'SELECT run_id, actor_id, phase FROM workflow_runs WHERE recipe_namespace = $1 AND recipe_name = $2', + ['db1-trace', f.label] + ) + ).rows, + operations: ( + await databasePool.query( + 'SELECT status, outcome FROM desktop_user_retirement_operations WHERE target_user_id = $1', + [f.user] + ) + ).rows, + } +} + +// Mutation mode only selects a deterministic failure schedule. Mutations of +// the fix itself are made to the production source, never injected by this suite. +const mutation = process.env.PR334_DB1_MUTATION ?? '' +async function backfillRevisions() { + const c = await databasePool.connect() + try { + await c.query('BEGIN') + await applyComposableCatalogRevisionSchema(c) + await c.query('COMMIT') + } finally { + await c.query('ROLLBACK') + c.release() + } +} +type Spec = { + label: string + legacy?: boolean + writer?: 'retirement' | 'admin' | 'route' | 'managed' | 'handover' + tombstone?: boolean + audit?: boolean + deleted?: boolean + twoTeam?: boolean + baseline?: boolean + order?: 'writer-first' | 'retirement-first' + child?: 'membership' | 'medium' + runTeam?: string +} +type ExpandedFixture = Fixture & { + receiver: string + manager: string + thirdTeam: string + auditId?: string + inviterAdmin?: string + invitation?: string + receiverEmail?: string +} + +async function prelock(client: PoolClient, ids: string[]) { + const role = (await client.query('SELECT current_user')).rows[0].current_user + expect(role).toBe('control_api_runtime') + await client.query('SELECT public.authorization_prelock_user_retirement_revisions($1::uuid[])', [ + ids, + ]) + event({ phase: 'prelock-executed', role, ids }) +} +async function expandedFixture(spec: Spec): Promise { + const f = (await fixture(spec.label, false, !spec.legacy)) as ExpandedFixture + f.receiver = randomUUID() + f.manager = randomUUID() + f.thirdTeam = randomUUID() + for (const id of [f.receiver, f.manager]) { + await databasePool.query("INSERT INTO users(id, email, name) VALUES ($1, $2, 'DB1 fixture')", [ + id, + `db1-${id}@example.test`, + ]) + } + await databasePool.query("INSERT INTO teams(id, name) VALUES ($1, 'DB1 third team')", [ + f.thirdTeam, + ]) + if (spec.deleted) + await databasePool.query("UPDATE team_members SET status='deleted' WHERE user_id=$1", [f.user]) + if (spec.writer === 'managed') { + await databasePool.query( + "INSERT INTO team_members(team_id,user_id,role,status) VALUES ($1,$2,'admin','active')", + [f.team, f.manager] + ) + } + if (spec.writer === 'handover') { + await databasePool.query( + "INSERT INTO team_members(team_id,user_id,role,status) VALUES ($1,$2,'member','active'),($3,$4,'member','active')", + [f.secondTeam, f.user, f.thirdTeam, f.receiver] + ) + f.inviterAdmin = randomUUID() + f.invitation = randomUUID() + f.receiverEmail = `db1-${f.receiver}@example.test` + await databasePool.query( + `INSERT INTO control_admin_users(id,username,email,password_hash,role,status) + VALUES ($1,$2,$3,'scratch-unusable-hash','admin','active')`, + [f.inviterAdmin, `db1-${f.inviterAdmin}`, `db1-${f.user}@example.test`] + ) + await databasePool.query( + `INSERT INTO control_admin_invitations(id,email,invited_by_admin_id,replace_inviter) + VALUES ($1,$2,$3,true)`, + [f.invitation, f.receiverEmail, f.inviterAdmin] + ) + } + if (spec.audit || spec.twoTeam) { + f.auditId = randomUUID() + await databasePool.query( + `INSERT INTO workflow_runs_audit(run_id,run_namespace,run_name,recipe_namespace,recipe_name, + triggerer_user_id,triggerer_actor_type,triggerer_team_id,usage_team_id,trigger_source,idempotency_key, + triggered_at,completed_at,final_phase,step_count,snapshot_sha) + VALUES ($1,'db1-trace',$2,'db1-trace',$2,$3,'user',$4,$5,'onDemand',$2,NOW(),NOW(),'Succeeded',0,'scratch-design')`, + [ + f.auditId, + spec.label, + f.user, + spec.twoTeam ? f.thirdTeam : null, + spec.twoTeam ? f.secondTeam.toUpperCase() : f.secondTeam, + ] + ) + } + if (spec.legacy) { + await databasePool.query('DELETE FROM authorization_user_revisions WHERE user_id=$1', [f.user]) + await databasePool.query('DELETE FROM authorization_team_revisions WHERE team_id=$1', [f.team]) + } + + if (receiverSetFixture && spec.writer === 'handover') { + const additional = randomUUID() + await databasePool.query( + "INSERT INTO users(id,email,name) VALUES($1,$2,'case variant receiver')", + [additional, f.receiverEmail!.toUpperCase()] + ) + await seedLink(f.user, f.inviterAdmin!) + await databasePool.query('UPDATE control_admin_users SET email=$2 WHERE id=$1', [ + f.inviterAdmin, + `changed-admin-${f.inviterAdmin}@example.test`, + ]) + extraReceivers.set(f.label, additional) + } + if (spec.writer === 'managed') await seedAuthority(f.manager) + return f +} +async function expandedFinal(f: ExpandedFixture) { + return { + ...(await finalState(f)), + receiverMemberships: ( + await databasePool.query( + 'SELECT team_id,role,status FROM team_members WHERE user_id=$1 ORDER BY team_id', + [f.receiver] + ) + ).rows, + receiver: ( + await databasePool.query('SELECT id,lifecycle_state FROM users WHERE id=$1', [f.receiver]) + ).rows, + inviter: ( + await databasePool.query( + 'SELECT status,session_version FROM control_admin_users WHERE id=$1', + [f.inviterAdmin ?? actorId] + ) + ).rows, + audit: ( + await databasePool.query( + 'SELECT triggerer_user_id,triggerer_team_id,usage_team_id FROM workflow_runs_audit WHERE run_id=$1', + [f.auditId ?? randomUUID()] + ) + ).rows, + } +} +function actorRun(client: PoolClient, f: ExpandedFixture, spec: Spec) { + let team = spec.audit || spec.deleted ? (spec.deleted ? f.team : f.secondTeam) : f.team + let usage = team + if (spec.twoTeam) { + // Intentionally reverse attribution; trigger order still uses TEXT keys. + team = f.thirdTeam + usage = f.secondTeam.toUpperCase() + } + if (spec.writer === 'handover') team = usage = spec.runTeam ?? f.secondTeam + return createRun( + { + recipe_namespace: 'db1-trace', + recipe_name: f.label, + actor_type: 'user', + actor_id: spec.writer === 'handover' ? f.receiver : f.user, + team_id: team, + usage_team_id: usage, + idempotency_key: null, + trigger_source: 'onDemand', + ttl_seconds_after_finished: 2592000, + }, + client + ) +} +async function runtimeBegin(client: PoolClient, application: string) { + await begin(client, application) + await client.query('SET LOCAL ROLE control_api_runtime') +} +function deletionWrite(client: PoolClient, f: ExpandedFixture, spec: Spec) { + if (spec.writer === 'admin') return adminDeleteUserInTransaction(client, f.user) + if (spec.writer === 'managed') + return deleteManagedUserForUser( + f.manager, + f.user, + { reason: 'DB1 regression', idempotencyKey: `phase1-${f.user}`, requestId: null }, + authorities.get(f.manager) + ) + if (spec.writer === 'handover') + return completeControlAdminInvitation({ + email: f.receiverEmail!, + invitationId: f.invitation!, + username: `receiver-${f.receiver}`, + passwordHash: 'scratch-unusable-hash', + }) + if (spec.writer === 'route') { + const app = express() + app.use(express.json()) + app.use((req, _res, next) => { + ;(req as any).adminAuth = { sub: actorId } + next() + }) + app.use(createAdminUsersRouter({} as any)) + app.use((err: any, _req: any, res: any, _next: any) => res.status(500).json({ code: err.code })) + return request(app) + .delete(`/admin/users/${f.user}`) + .set('Idempotency-Key', `phase1-${f.user}`) + .send({ reason: 'DB1 regression' }) + .then(response => { + if (response.status !== 200) + throw Object.assign(new Error('Retirement route failed'), { + code: response.body.code ?? response.status, + }) + return { status: response.status, body: response.body } + }) + } + return retirementWrite(client, f, { tombstone: spec.tombstone }) +} + +// Only pool ownership and scheduling are test-specific. Every +// internal service query, BEGIN/COMMIT, authorize call and trigger still runs. +const serviceScope = new AsyncLocalStorage() +const authorities = new Map() +let discoveryHook: (() => Promise) | undefined +let beforeCopyHook: (() => Promise) | undefined +let afterCopyValidationHook: (() => Promise) | undefined +function installPoolRouting() { + vi.spyOn(pool, 'connect').mockImplementation((() => { + const client = serviceScope.getStore() + if (!client) throw new Error('Service transaction escaped its test connection') + return Promise.resolve(client) + }) as any) + vi.spyOn(pool, 'query').mockImplementation(((sql: string, values?: unknown[]) => { + const client = serviceScope.getStore() + if (!client) throw new Error('Service query escaped its test connection') + return client.query(sql, values) + }) as any) +} +async function seedAuthority(userId: string) { + const c = await databasePool.connect() + try { + await c.query('BEGIN') + const issued = await createUserSession( + { userId, email: `db1-${userId}@example.test`, authenticationMethods: ['password'] }, + { db: c } + ) + const { sid, jti, sessionVersion } = issued.identity + authorities.set(userId, { contract: 'v2', userId, sid, jti, sessionVersion }) + await c.query('COMMIT') + } finally { + await c.query('ROLLBACK') + c.release() + } +} + +// Observe query boundaries only; do not add prelocks, change predicates, +// revalidate identities, or replace production errors in fixed cases. +function installObservation( + client: PoolClient, + spec: Spec, + enabled: boolean, + afterFirst?: () => Promise +) { + const raw = client.query.bind(client) + const state = { count: 0, events: [] as any[], spy: undefined as any } + let discoveryCount = 0 + let inviter: any, links: any[], sender: any + let expected: any + state.spy = vi.spyOn(client, 'query').mockImplementation((async (sql: string, values?: any[]) => { + const isPrelock = sql.includes('SELECT public.authorization_prelock_user_retirement_revisions(') + if (isPrelock && !enabled) { + event({ phase: 'baseline-prelock-disabled', label: spec.label }) + return { rows: [], rowCount: 0 } + } + if (isPrelock && state.count === 0) await discoveryHook?.() + const discoveryStart = + sql === 'SELECT id::text AS id, email, status FROM control_admin_users WHERE id=$1' + if (discoveryStart && discoveryCount === 2) await beforeCopyHook?.() + const membershipCopy = sql.includes('INSERT INTO team_members(team_id, user_id, role, status)') + if (membershipCopy) { + await afterCopyValidationHook?.() + state.events.push({ placement: 'handover-copy-bound', ids: values![1], sql }) + } + const result = await raw(sql, values) + if (isPrelock) { + state.count++ + state.events.push({ placement: 'production-prelock', ids: values![0] }) + event({ phase: 'prelock-executed', label: spec.label, ids: values![0] }) + if (state.count === 1) await afterFirst?.() + } + if (discoveryStart) { + inviter = result.rows[0] + links = [] + sender = null + } + if ( + sql.includes( + 'SELECT link.id::text AS link_id, link.generation, link.state, link.row_version,' + ) + ) { + links = result.rows + sender = links[0]?.lifecycle_state === 'active' ? links[0] : null + } + if ( + sql.includes('SELECT u.id::text AS id, u.email, u.lifecycle_state') && + sql.includes('l.control_admin_id<>$2::uuid') + ) + sender = result.rows[0] ?? null + if (sql.includes('SELECT id::text AS id, email, lifecycle_state FROM users')) { + const snapshot = { inviter, links, sender, receivers: result.rows } + discoveryCount++ + if (discoveryCount === 1) { + expected = snapshot + event({ phase: 'handover-discovery', label: spec.label, discovered: snapshot }) + } else if (discoveryCount === 2) { + event({ phase: 'handover-revalidated', label: spec.label, snapshot }) + } else if (discoveryCount === 3) { + event({ + phase: 'handover-copy-revalidation', + label: spec.label, + identical: JSON.stringify(expected) === JSON.stringify(snapshot), + expected, + current: snapshot, + }) + } + } + if (membershipCopy) + state.events.push({ placement: 'handover-copy-executed', rows: result.rowCount }) + if ( + sql.includes('FROM external_user_sessions') || + sql.includes("AND lifecycle_state = 'active'") + ) { + event({ + phase: 'internal-authority-query-executed', + label: spec.label, + sql, + rows: result.rowCount, + }) + } + if (sql === 'BEGIN' || sql === 'COMMIT' || sql === 'ROLLBACK') + event({ phase: 'service-transaction', label: spec.label, sql, command: result.command }) + return result + }) as any) + return state +} + +async function contention(spec: Spec, mode: 'baseline' | 'fixed' | 'mutation') { + const f = await expandedFixture(spec) + const actualMutation = mode === 'mutation' ? mutation : '' + if (mode !== 'baseline' && actualMutation !== 'no-backfill') { + const start = performance.now() + const before = ( + await databasePool.query( + 'SELECT user_id,revision FROM authorization_user_revisions ORDER BY user_id' + ) + ).rows + await backfillRevisions() + const covered = ( + await databasePool.query( + 'SELECT user_id,revision,updated_at FROM authorization_user_revisions ORDER BY user_id' + ) + ).rows + await backfillRevisions() + const rerun = ( + await databasePool.query( + 'SELECT user_id,revision,updated_at FROM authorization_user_revisions ORDER BY user_id' + ) + ).rows + save(`${spec.label}.backfill`, { elapsedMs: performance.now() - start, before, covered, rerun }) + expect(rerun).toEqual(covered) + } + const clients = await Promise.all([ + databasePool.connect(), + databasePool.connect(), + databasePool.connect(), + ]) + const [gate, deletion, run] = clients as [PoolClient, PoolClient, PoolClient] + const retain = vi.spyOn(deletion, 'release').mockImplementation(() => undefined) + const pending: Promise[] = [] + let querySpy: ReturnType | undefined + const raw = deletion.query.bind(deletion) + let paused = false + const usePrelock = + mode !== 'baseline' && actualMutation !== 'no-prelock' && actualMutation !== 'late-handover' + const failureSchedule = mode === 'baseline' || mode === 'mutation' + const app = `${spec.label}-delete` + try { + const [gatePid, deletionPid, runPid] = await Promise.all(clients.map(pid)) + await gate.query('SELECT pg_advisory_lock($1,1)', [barrierClass]) + await runtimeBegin(deletion, app) + await runtimeBegin(run, `${spec.label}-run`) + + const observation = installObservation( + deletion, + spec, + usePrelock || actualMutation === 'late-handover', + (failureSchedule && !['no-audit', 'no-deleted'].includes(actualMutation)) || + spec.order === 'retirement-first' + ? async () => { + paused = true + await raw('SELECT pg_advisory_xact_lock($1,1)', [barrierClass]) + } + : undefined + ) + querySpy = observation.spy + if (failureSchedule && (!usePrelock || ['no-audit', 'no-deleted'].includes(actualMutation))) { + if (spec.legacy) { + if (spec.writer === 'admin') + await databasePool.query('INSERT INTO db1_parent_barriers VALUES ($1,$2)', [app, f.user]) + else { + // Take the same real parent lock used by retireDesktopUser; the + // barrier here precedes its medium/cascade work, never a fake bump. + await raw('SELECT id FROM users WHERE id=$1 FOR UPDATE', [f.user]) + } + } else if (spec.writer !== 'handover') + await revisionBarrier(app, 'authorization_user_revisions', f.user) + else await revisionBarrier(app, 'authorization_user_revisions', f.receiver) + } + const deleteOutcome = () => + observe( + deletion, + (async () => { + if (failureSchedule && !usePrelock && spec.legacy && spec.writer !== 'admin') { + await raw('SELECT pg_advisory_xact_lock($1,1)', [barrierClass]) + } + return serviceScope.run(deletion, () => deletionWrite(deletion, f, spec)) + })() + ) + let one: Promise, two: Promise + if (failureSchedule) { + one = deleteOutcome() + pending.push(one) + await waitForEdges([[deletionPid, gatePid]]) + await snapshot(spec.label, '01-delete-held', [gatePid, deletionPid, runPid], f) + if (spec.writer === 'handover' && !usePrelock) { + // INSERT SELECT has no ORDER BY in production. Inspect which source + // team the first real copy has touched, then contend on the OTHER + // source team. Do not replace or order the production copy query. + const held = ( + await databasePool.query( + `SELECT r.team_id FROM pgrowlocks('authorization_team_revisions') p + JOIN authorization_team_revisions r ON r.ctid=p.locked_row WHERE $1=ANY(p.pids)`, + [deletionPid] + ) + ).rows.map(r => r.team_id) + spec.runTeam = [f.team, f.secondTeam].find(id => !held.includes(id)) + expect(spec.runTeam).toBeDefined() + event({ + phase: 'handover-real-copy-order', + label: spec.label, + held, + contendedTeam: spec.runTeam, + }) + } + two = observe(run, actorRun(run, f, spec)) + pending.push(two) + await waitForEdges([[runPid, deletionPid]]) + await snapshot(spec.label, '02-run-blocked', [gatePid, deletionPid, runPid], f) + await gate.query('SELECT pg_advisory_unlock($1,1)', [barrierClass]) + await waitForEdges([ + [runPid, deletionPid], + [deletionPid, runPid], + ]) + await snapshot(spec.label, '03-cycle', [gatePid, deletionPid, runPid], f) + } else if (spec.order === 'retirement-first') { + // Pause after the complete prelock; hard delete without a medium has no + // later user-revision UPDATE on which to put a revision-trigger barrier. + one = deleteOutcome() + pending.push(one) + await waitForEdges([[deletionPid, gatePid]]) + two = observe(run, actorRun(run, f, spec)) + pending.push(two) + await waitForEdges([[runPid, deletionPid]]) + await snapshot(spec.label, '01-serialized-contention', [gatePid, deletionPid, runPid], f) + await gate.query('SELECT pg_advisory_unlock($1,1)', [barrierClass]) + } else { + const firstTeam = + spec.writer === 'handover' + ? f.secondTeam + : spec.twoTeam + ? ( + await databasePool.query( + 'SELECT key FROM unnest($1::text[]) AS keys(key) ORDER BY key LIMIT 1', + [[f.thirdTeam, f.secondTeam.toUpperCase()]] + ) + ).rows[0].key.toLowerCase() + : spec.audit + ? f.secondTeam + : f.team + await revisionBarrier(`${spec.label}-run`, 'authorization_team_revisions', firstTeam) + two = observe(run, actorRun(run, f, spec)) + pending.push(two) + await waitForEdges([[runPid, gatePid]]) + one = deleteOutcome() + pending.push(one) + await waitForEdges([[deletionPid, runPid]]) + await snapshot(spec.label, '01-serialized-contention', [gatePid, deletionPid, runPid], f) + await gate.query('SELECT pg_advisory_unlock($1,1)', [barrierClass]) + } + const outcomes = await Promise.all([one, two]) + const final = await expandedFinal(f) + save(`${spec.label}.outcome`, { + spec, + mode, + mutation: actualMutation, + fixture: f, + deletionPid, + runPid, + prelocked: observation.count > 0, + observations: observation.events, + paused, + outcomes, + final, + runtimeRole: 'control_api_runtime', + }) + console.info( + 'DB1_RESULT', + spec.label, + outcomes.map(o => (o.status === 'rejected' ? o.code : o.status)) + ) + if (mode === 'baseline') { + expect(outcomes.filter(o => o.status === 'rejected' && o.code === '40P01')).toHaveLength(1) + expect(outcomes.filter(o => o.status === 'fulfilled')).toHaveLength(1) + } else { + // Deliberately unchanged under each mutation: a real deadlock turns RED. + expect(outcomes.map(o => o.status)).toEqual(['fulfilled', 'fulfilled']) + expect(observation.count).toBeGreaterThan(0) + expect(outcomes.every(o => o.transaction === 'COMMIT')).toBe(true) + if (spec.tombstone || spec.writer === 'handover') + expect(final.users[0]?.lifecycle_state).toBe('retired') + else { + expect(final.users).toHaveLength(0) + expect(final.memberships).toHaveLength(0) + } + expect(final.medium.every(m => m.disabled)).toBe(true) + expect(final.runs).toHaveLength(1) + if (spec.audit || spec.twoTeam) expect(final.audit[0]?.triggerer_user_id).toBeNull() + if (spec.writer === 'route') + expect(outcomes[0]).toMatchObject({ + value: { status: 200, body: { deleted: true, id: f.user } }, + }) + if (spec.writer === 'managed') + expect(outcomes[0]).toMatchObject({ value: { deleted: { ok: true, id: f.user } } }) + if (spec.writer === 'handover') { + expect(final.receiver[0]?.lifecycle_state).toBe('active') + expect(final.receiverMemberships).toHaveLength(3) + expect(final.inviter[0]?.status).toBe('disabled') + if (extraReceivers.has(f.label)) { + const extra = ( + await databasePool.query( + 'SELECT team_id,status FROM team_members WHERE user_id=$1 ORDER BY team_id', + [extraReceivers.get(f.label)] + ) + ).rows + expect(extra).toHaveLength(2) + expect(extra.every(r => r.status === 'active')).toBe(true) + save(`${spec.label}.receiver-set`, { extra, user: extraReceivers.get(f.label) }) + } + } + } + } finally { + await gate.query('SELECT pg_advisory_unlock_all()') + await Promise.allSettled(pending) + querySpy?.mockRestore() + retain.mockRestore() + for (const c of clients) { + await c.query('ROLLBACK') + c.release() + } + await databasePool.query('TRUNCATE db1_trace_barriers, db1_parent_barriers') + } +} + +describeRealPostgres('user retirement revision lock order on real PostgreSQL 16', () => { + beforeAll(async () => { + adminPool = new Pool({ connectionString: adminUrl }) + const version = Number( + (await adminPool.query('SHOW server_version_num')).rows[0].server_version_num + ) + expect(version).toBeGreaterThanOrEqual(160000) + expect(version).toBeLessThan(170000) + expect(process.version).toMatch(/^v24\./) + await adminPool.query(`CREATE DATABASE "${database}"`) + target.pathname = `/${database}` + databasePool = new Pool({ connectionString: target.toString(), max: 12 }) + await initDb({ connect: () => databasePool.connect() }) + await databasePool.query('CREATE EXTENSION pgrowlocks') + await databasePool.query( + `INSERT INTO control_admin_users(id,username,password_hash,role,status) + VALUES ($1,$2,'scratch-unusable-hash','admin','active')`, + [actorId, `db1-${actorId}`] + ) + await databasePool.query(` + CREATE TABLE db1_trace_barriers(application_name TEXT, revision_table TEXT, subject_id TEXT); + CREATE TABLE db1_parent_barriers(application_name TEXT, subject_id UUID); + CREATE FUNCTION db1_trace_after_bump() RETURNS TRIGGER LANGUAGE plpgsql AS $$ + BEGIN + IF EXISTS (SELECT 1 FROM db1_trace_barriers + WHERE application_name = current_setting('application_name') AND revision_table = TG_TABLE_NAME + AND subject_id = COALESCE(to_jsonb(NEW)->>'user_id',to_jsonb(NEW)->>'team_id')) + THEN PERFORM pg_advisory_xact_lock(${barrierClass},1); END IF; + RETURN NEW; + END; $$; + CREATE TRIGGER zz_db1_trace_after_bump AFTER INSERT OR UPDATE ON authorization_user_revisions + FOR EACH ROW EXECUTE FUNCTION db1_trace_after_bump(); + CREATE TRIGGER zz_db1_trace_after_bump AFTER INSERT OR UPDATE ON authorization_team_revisions + FOR EACH ROW EXECUTE FUNCTION db1_trace_after_bump(); + CREATE FUNCTION db1_before_parent_delete() RETURNS TRIGGER LANGUAGE plpgsql AS $$ + BEGIN + IF EXISTS (SELECT 1 FROM db1_parent_barriers WHERE application_name=current_setting('application_name') AND subject_id=OLD.id) + THEN PERFORM pg_advisory_xact_lock(${barrierClass},1); END IF; + RETURN OLD; + END; $$; + CREATE TRIGGER zz_db1_before_parent_delete BEFORE DELETE ON users FOR EACH ROW EXECUTE FUNCTION db1_before_parent_delete(); + GRANT SELECT ON db1_parent_barriers TO control_api_runtime; + `) + installPoolRouting() + await installChildBarriers() + save('environment', { + node: process.version, + nodeExecPath: process.execPath, + postgres: (await databasePool.query('SELECT version()')).rows, + mutation, + privileges: ( + await databasePool.query(`SELECT relname, + has_table_privilege('control_api_runtime',oid,'SELECT') AS can_select, + has_table_privilege('control_api_runtime',oid,'UPDATE') AS can_update + FROM pg_class WHERE relname IN ('authorization_user_revisions','authorization_team_revisions')`) + ).rows, + function: ( + await databasePool.query(`SELECT proowner::regrole::text,prosecdef,proconfig,proacl FROM pg_proc + WHERE proname='authorization_prelock_user_retirement_revisions'`) + ).rows, + constraints: ( + await databasePool.query(`SELECT conrelid::regclass::text,conname,confrelid::regclass::text,pg_get_constraintdef(oid) + FROM pg_constraint WHERE contype='f' AND confrelid='users'::regclass ORDER BY conname`) + ).rows, + triggers: ( + await databasePool.query(`SELECT tgrelid::regclass::text,tgname,pg_get_triggerdef(oid) + FROM pg_trigger WHERE tgrelid IN ('users'::regclass,'team_members'::regclass,'workflow_runs'::regclass,'workflow_runs_audit'::regclass) + ORDER BY tgrelid,tgname`) + ).rows, + }) + }, 60_000) + afterAll(async () => { + try { + vi.restoreAllMocks() + await endPoolAndWaitForClients(databasePool) + if (adminPool) await adminPool.query(`DROP DATABASE IF EXISTS "${database}"`) + } finally { + await endPoolAndWaitForClients(adminPool) + } + }) + for (const spec of [ + { label: 'baseline-A-admin', legacy: true, writer: 'admin' }, + { label: 'baseline-A-hard-delete', legacy: true }, + { label: 'baseline-B-medium' }, + { label: 'baseline-B-admin', writer: 'admin' }, + { label: 'baseline-A-tombstone', legacy: true, tombstone: true }, + { label: 'baseline-B-route', writer: 'route' }, + { label: 'baseline-handover', writer: 'handover' }, + ] as Spec[]) + it(spec.label + ' reproduces 40P01', () => contention(spec, 'baseline')) + for (const spec of [ + { label: 'fixed-A-hard-delete', legacy: true }, + { label: 'fixed-A-admin', legacy: true, writer: 'admin' }, + { label: 'fixed-B-medium' }, + { label: 'fixed-B-admin', writer: 'admin' }, + { label: 'fixed-A-tombstone', legacy: true, tombstone: true }, + { label: 'fixed-B-tombstone', tombstone: true }, + { label: 'fixed-route', writer: 'route' }, + { label: 'fixed-managed', writer: 'managed' }, + { label: 'fixed-handover', writer: 'handover' }, + { label: 'fixed-deleted-membership', deleted: true }, + { label: 'fixed-audit-team', audit: true }, + { label: 'fixed-two-team-run', twoTeam: true }, + ] as Spec[]) + for (const order of ['writer-first', 'retirement-first'] as const) { + const ordered = { ...spec, order, label: `${spec.label}-${order}` } + it(ordered.label + ' serializes under runtime role', () => + contention(ordered, mutation && !mutation.startsWith('toctou-') ? 'mutation' : 'fixed') + ) + } + for (const spec of [ + { label: 'fixed-membership-route', child: 'membership', writer: 'route' }, + { label: 'fixed-membership-managed', child: 'membership', writer: 'managed' }, + { label: 'fixed-membership-handover', child: 'membership', writer: 'handover' }, + { label: 'fixed-medium-hard-delete', child: 'medium' }, + { label: 'fixed-medium-tombstone', child: 'medium', tombstone: true }, + { label: 'fixed-medium-handover', child: 'medium', writer: 'handover' }, + ] as Spec[]) + for (const order of ['writer-first', 'retirement-first'] as const) { + const ordered = { ...spec, order, label: `${spec.label}-${order}` } + it(ordered.label, () => childContention(ordered, false)) + } + registerErrors() + registerOnlineBackfill() + registerToctouTests() + registerCollationTest() + + it('runtime role cannot lock revision tables directly; scoped definer succeeds without revision changes', async () => { + const f = await expandedFixture({ label: 'privilege-witness', deleted: true, twoTeam: true }) + const c = await databasePool.connect() + try { + await runtimeBegin(c, 'privilege-witness') + await c.query('SAVEPOINT privilege_probe') + const forbidden = await c + .query('SELECT * FROM authorization_team_revisions WHERE team_id=$1 FOR UPDATE', [f.team]) + .then( + () => null, + e => ({ code: e.code, message: e.message }) + ) + expect(forbidden?.code).toBe('42501') + await c.query('ROLLBACK TO SAVEPOINT privilege_probe') + const before = ( + await c.query('SELECT * FROM authorization_user_revisions WHERE user_id=$1', [f.user]) + ).rows + await prelock(c, [f.user]) + const after = ( + await c.query('SELECT * FROM authorization_user_revisions WHERE user_id=$1', [f.user]) + ).rows + expect(after).toEqual(before) + save('runtime-privilege-witness', { + forbidden, + before, + after, + role: (await c.query('SELECT current_user')).rows, + held: (await databasePool.query("SELECT * FROM pgrowlocks('authorization_team_revisions')")) + .rows, + }) + await c.query('ROLLBACK') + } finally { + await c.query('ROLLBACK') + c.release() + } + }) + it('union mapping includes deleted memberships and raw audit TEXT while excluding sentinel NULL and empty', async () => { + const f = await expandedFixture({ label: 'union-mapping', deleted: true, twoTeam: true }) + for (const usage of [null, '', 'control-plane-admin-ui']) { + await databasePool.query( + `INSERT INTO workflow_runs_audit(run_id,run_namespace,run_name,recipe_namespace,recipe_name, + triggerer_user_id,triggerer_actor_type,usage_team_id,trigger_source,idempotency_key,triggered_at,completed_at, + final_phase,step_count,snapshot_sha) VALUES (gen_random_uuid(),'db1-trace','mapping','db1-trace','mapping', + $1,'user',$2,'onDemand','mapping',NOW(),NOW(),'Succeeded',0,'scratch-design')`, + [f.user, usage] + ) + } + const keys = ( + await databasePool.query( + `SELECT subject_id FROM ( + SELECT team_id::text AS subject_id FROM team_members WHERE user_id=$1 + UNION SELECT s.subject_id FROM workflow_runs_audit a CROSS JOIN LATERAL (VALUES + (a.triggerer_team_id::text),(CASE WHEN a.usage_team_id='control-plane-admin-ui' THEN NULL ELSE a.usage_team_id END)) s(subject_id) + WHERE a.triggerer_user_id=$1 AND s.subject_id IS NOT NULL AND s.subject_id<>'' + ) affected ORDER BY subject_id`, + [f.user] + ) + ).rows.map(r => r.subject_id) + expect(keys).toHaveLength(3) + expect(keys).toEqual(expect.arrayContaining([f.team, f.thirdTeam, f.secondTeam.toUpperCase()])) + const c = await databasePool.connect() + try { + await runtimeBegin(c, 'union-mapping') + await prelock(c, [f.user]) + const pidValue = await pid(c) + const locks = ( + await databasePool.query( + `SELECT r.team_id::text,p.* FROM pgrowlocks('authorization_team_revisions') p + JOIN authorization_team_revisions r ON r.ctid=p.locked_row WHERE $1=ANY(p.pids) ORDER BY r.team_id`, + [pidValue] + ) + ).rows + expect(locks.map(r => r.team_id).sort()).toEqual([f.team, f.secondTeam, f.thirdTeam].sort()) + save('union-mapping-witness', { fixture: f, keys, locks }) + await c.query('ROLLBACK') + } finally { + await c.query('ROLLBACK') + c.release() + } + }) + it('definer parent lock blocks a new membership at the real users KEY SHARE FK', async () => { + const f = await expandedFixture({ label: 'parent-fk-witness' }) + const clients = await Promise.all([databasePool.connect(), databasePool.connect()]) + const [owner, insert] = clients as [PoolClient, PoolClient] + let pending: Promise | undefined + try { + await runtimeBegin(owner, 'parent-fk-owner') + await prelock(owner, [f.user]) + await runtimeBegin(insert, 'parent-fk-insert') + const ownerPid = await pid(owner), + insertPid = await pid(insert) + pending = observe( + insert, + insert.query( + "INSERT INTO team_members(team_id,user_id,role,status) VALUES($1,$2,'member','active')", + [f.secondTeam, f.user] + ) + ) + await waitForEdges([[insertPid, ownerPid]]) + await snapshot('parent-fk-witness', '01-membership-blocked', [ownerPid, insertPid], f) + await databasePool.query('SELECT pg_cancel_backend($1)', [insertPid]) + const outcome = await pending + expect(outcome).toMatchObject({ + status: 'rejected', + code: '57014', + where: expect.stringContaining('FOR KEY SHARE'), + }) + save('parent-fk-witness.outcome', { outcome }) + } finally { + await owner.query('ROLLBACK') + await pending + for (const c of clients) { + await c.query('ROLLBACK') + c.release() + } + } + }) + it('uncontended control commits real run then retirement', async () => { + const f = await expandedFixture({ label: 'uncontended-control' }) + const c = await databasePool.connect() + try { + await runtimeBegin(c, 'uncontended-run') + const run = await observe(c, runWrite(c, f)) + await runtimeBegin(c, 'uncontended-retirement') + await prelock(c, [f.user]) + const retirement = await observe(c, retirementWrite(c, f)) + expect([run.status, retirement.status]).toEqual(['fulfilled', 'fulfilled']) + save('uncontended-control.outcome', { run, retirement, final: await finalState(f) }) + } finally { + await c.query('ROLLBACK') + c.release() + } + }) +}) + +async function installChildBarriers() { + await databasePool.query(` + CREATE TABLE db1_child_barriers(application_name TEXT, child_table TEXT, user_id UUID); + CREATE FUNCTION db1_before_child_update() RETURNS TRIGGER LANGUAGE plpgsql AS $$ + BEGIN + IF EXISTS (SELECT 1 FROM db1_child_barriers WHERE application_name=current_setting('application_name') + AND child_table=TG_TABLE_NAME AND user_id=NEW.user_id) + THEN PERFORM pg_advisory_xact_lock(${barrierClass},2); END IF; + RETURN NEW; + END; $$; + CREATE TRIGGER db1_child_pause BEFORE UPDATE ON team_members FOR EACH ROW EXECUTE FUNCTION db1_before_child_update(); + CREATE TRIGGER db1_child_pause BEFORE UPDATE ON workflow_approval_medium_accounts FOR EACH ROW EXECUTE FUNCTION db1_before_child_update(); + GRANT SELECT ON db1_child_barriers TO control_api_runtime; + `) +} +async function childContention(spec: Spec, mutated: boolean) { + const f = await expandedFixture(spec) + await backfillRevisions() + const clients = await Promise.all([ + databasePool.connect(), + databasePool.connect(), + databasePool.connect(), + ]) + const [gate, deletion, writer] = clients as [PoolClient, PoolClient, PoolClient] + const retains = [deletion, writer].map(c => + vi.spyOn(c, 'release').mockImplementation(() => undefined) + ) + const pending: Promise[] = [] + const raw = deletion.query.bind(deletion) + const writerFirst = spec.order !== 'retirement-first' + const observation = installObservation( + deletion, + spec, + true, + mutated + ? async () => { + await raw('SELECT pg_advisory_xact_lock($1,1)', [barrierClass]) + } + : undefined + ) + try { + const [gatePid, deletionPid, writerPid] = await Promise.all(clients.map(pid)) + await gate.query('SELECT pg_advisory_lock($1,1), pg_advisory_lock($1,2)', [barrierClass]) + await runtimeBegin(deletion, `${spec.label}-delete`) + await runtimeBegin(writer, `${spec.label}-writer`) + const account = ( + await databasePool.query( + 'SELECT id FROM workflow_approval_medium_accounts WHERE user_id=$1', + [f.user] + ) + ).rows[0]?.id + const write = () => + observe( + writer, + serviceScope.run(writer, () => + spec.child === 'membership' + ? updateMemberRole(f.team, f.user, 'inviter', actorId) + : preferVerifiedMediumAccount({ userId: f.user, accountId: account }) + ) + ) + const retire = () => + observe( + deletion, + serviceScope.run(deletion, () => deletionWrite(deletion, f, spec)) + ) + let one: Promise, two: Promise + if (writerFirst) { + await databasePool.query('INSERT INTO db1_child_barriers VALUES($1,$2,$3)', [ + `${spec.label}-writer`, + spec.child === 'membership' ? 'team_members' : 'workflow_approval_medium_accounts', + f.user, + ]) + two = write() + pending.push(two) + await waitForEdges([[writerPid, gatePid]]) + one = retire() + pending.push(one) + if (mutated) { + await waitForEdges([[deletionPid, gatePid]]) + await snapshot(spec.label, '01-prelock-held', [gatePid, deletionPid, writerPid], f) + await gate.query('SELECT pg_advisory_unlock($1,1)', [barrierClass]) + await waitForEdges([[deletionPid, writerPid]]) + await snapshot(spec.label, '02-child-blocked', [gatePid, deletionPid, writerPid], f) + await gate.query('SELECT pg_advisory_unlock($1,2)', [barrierClass]) + await waitForEdges([ + [deletionPid, writerPid], + [writerPid, deletionPid], + ]) + await snapshot(spec.label, '03-cycle', [gatePid, deletionPid, writerPid], f) + } else { + await waitForEdges([[deletionPid, writerPid]]) + await snapshot(spec.label, '01-serialized-contention', [gatePid, deletionPid, writerPid], f) + await gate.query('SELECT pg_advisory_unlock($1,2)', [barrierClass]) + } + } else { + // Retire first through its actual medium disable / receiver copy. The + // second writer then sees the committed disabled/deleted/retired state. + await revisionBarrier(`${spec.label}-delete`, 'authorization_user_revisions', f.user) + one = retire() + pending.push(one) + await waitForEdges([[deletionPid, gatePid]]) + two = write() + pending.push(two) + await waitForEdges([[writerPid, deletionPid]]) + await snapshot(spec.label, '01-serialized-contention', [gatePid, deletionPid, writerPid], f) + await gate.query('SELECT pg_advisory_unlock($1,1)', [barrierClass]) + } + const outcomes = await Promise.all([one, two]) + const final = await expandedFinal(f) + save(`${spec.label}.outcome`, { + spec, + mutation: mutated ? mutation : '', + fixture: f, + deletionPid, + writerPid, + outcomes, + final, + observations: observation.events, + runtimeRole: 'control_api_runtime', + }) + console.info( + 'DB1_CHILD_RESULT', + spec.label, + outcomes.map(o => (o.status === 'rejected' ? o.code : o.transaction)) + ) + // These fixed assertions remain unchanged for mutations; 40P01 makes RED. + expect(outcomes.map(o => o.status)).toEqual(['fulfilled', 'fulfilled']) + expect(outcomes.every(o => o.transaction === 'COMMIT')).toBe(true) + if (spec.tombstone || spec.writer === 'handover') + expect(final.users[0]?.lifecycle_state).toBe('retired') + else { + expect(final.users).toHaveLength(0) + expect(final.memberships).toHaveLength(0) + } + expect(final.medium.every(m => m.disabled)).toBe(true) + if (spec.writer === 'managed') + expect(outcomes[0]).toMatchObject({ value: { deleted: { ok: true, id: f.user } } }) + if (spec.writer === 'handover') { + expect(final.receiver[0]?.lifecycle_state).toBe('active') + expect(final.receiverMemberships).toHaveLength(3) + expect(final.inviter[0]?.status).toBe('disabled') + } + if (writerFirst) expect((outcomes[1] as any).value).toBeTruthy() + else if (spec.child === 'medium') expect((outcomes[1] as any).value).toBeNull() + } finally { + await gate.query('SELECT pg_advisory_unlock_all()') + await Promise.allSettled(pending) + observation.spy.mockRestore() + retains.forEach(s => s.mockRestore()) + for (const c of clients) { + await c.query('ROLLBACK') + c.release() + } + await databasePool.query('TRUNCATE db1_child_barriers,db1_trace_barriers,db1_parent_barriers') + } +} + +async function withObservedClient( + spec: Spec, + work: (c: PoolClient, observation: ReturnType) => Promise +) { + const c = await databasePool.connect() + const retain = vi.spyOn(c, 'release').mockImplementation(() => undefined) + const observation = installObservation(c, spec, true) + try { + await runtimeBegin(c, spec.label) + return await serviceScope.run(c, () => work(c, observation)) + } finally { + observation.spy.mockRestore() + retain.mockRestore() + await c.query('ROLLBACK') + c.release() + } +} +async function seedLink(userId: string, adminId: string) { + await databasePool.query( + `INSERT INTO gfs_desktop_operator_links + (id,lineage_id,generation,user_id,control_admin_id,state,source,created_by,row_version) + VALUES(gen_random_uuid(),gen_random_uuid(),1,$1,$2,'active','initial_setup',$2,1)`, + [userId, adminId] + ) +} +function registerErrors() { + for (const which of ['target', 'manager'] as const) + it(`managed malformed ${which} preserves error contract and filters principalIds`, async () => { + const spec: Spec = { label: `error-managed-${which}`, writer: 'managed' } + const f = await expandedFixture(spec) + const manager = which === 'manager' ? 'malformed-manager' : f.manager + const target = which === 'target' ? 'not-a-uuid' : f.user.toUpperCase() + const result = await withObservedClient(spec, async (_c, observation) => { + const value = await deleteManagedUserForUser( + manager, + target, + { reason: 'DB1 error', idempotencyKey: randomUUID(), requestId: null }, + which === 'target' ? authorities.get(f.manager) : undefined + ) + const batch = (observation.events[0] as any).ids + expect(batch).toEqual([which === 'target' ? f.manager : f.user]) + return { value, batch } + }) + expect(result.value).toEqual({ error: 'invalid_retirement_input' }) + expect((await expandedFinal(f)).users[0]?.lifecycle_state).toBe('active') + save(spec.label, result) + }) + for (const which of ['not-found', 'retained-history'] as const) + it(`admin-delete ${which} preserves refusal`, async () => { + const spec: Spec = { label: `error-admin-${which}`, writer: 'admin' } + const f = await expandedFixture(spec) + if (which === 'retained-history') await seedLink(f.user, actorId) + const id = which === 'not-found' ? randomUUID() : f.user + const result = await withObservedClient(spec, async (c, observation) => ({ + outcome: await observe(c, adminDeleteUserInTransaction(c, id)), + observations: observation.events, + })) + expect(result.outcome).toMatchObject({ + status: 'fulfilled', + transaction: 'COMMIT', + value: { + error: which === 'not-found' ? 'not_found' : 'gfs_operator_link_history_retained', + }, + }) + const final = await expandedFinal(f) + expect(final.users[0]?.lifecycle_state).toBe('active') + expect(final.medium).toEqual([{ disabled: false }]) + save(spec.label, { ...result, final }) + }) + it('completed-operation replay returns before retirement prelock even after hard delete', async () => { + const spec: Spec = { label: 'error-completed-replay' } + const f = await expandedFixture(spec) + const first = await withObservedClient(spec, async (c, observation) => ({ + outcome: await observe(c, retirementWrite(c, f)), + count: observation.count, + })) + const second = await withObservedClient(spec, async (c, observation) => ({ + outcome: await observe(c, retirementWrite(c, f)), + count: observation.count, + })) + expect(first.outcome).toMatchObject({ + status: 'fulfilled', + value: { replayed: false, outcome: 'deleted' }, + }) + expect(second.outcome).toMatchObject({ + status: 'fulfilled', + value: { replayed: true, outcome: 'deleted' }, + }) + expect(first.count).toBe(1) + expect(second.count).toBe(0) + expect((await expandedFinal(f)).operations).toEqual([ + { status: 'completed', outcome: 'deleted' }, + ]) + save(spec.label, { first, second, final: await expandedFinal(f) }) + }) + for (const which of ['self', 'missing'] as const) + it(`real handover discovery refuses ${which} receiver`, async () => { + const spec: Spec = { label: `error-handover-${which}`, writer: 'handover' } + const f = await expandedFixture(spec) + if (which === 'self') { + await seedLink(f.user, f.inviterAdmin!) + await databasePool.query('UPDATE control_admin_users SET email=$2 WHERE id=$1', [ + f.inviterAdmin, + `other-${f.inviterAdmin}@example.test`, + ]) + f.receiverEmail = `db1-${f.user}@example.test` + await databasePool.query('UPDATE control_admin_invitations SET email=$2 WHERE id=$1', [ + f.invitation, + f.receiverEmail, + ]) + } else await databasePool.query('DELETE FROM users WHERE id=$1', [f.receiver]) + const outcome = await withObservedClient(spec, async c => + observe(c, Promise.resolve(deletionWrite(c, f, spec))) + ) + expect(outcome).toMatchObject({ + status: 'fulfilled', + value: { error: 'replace_inviter_receiver_missing' }, + }) + const final = await expandedFinal(f) + expect(final.users[0]?.lifecycle_state).toBe('active') + expect(final.inviter[0]?.status).toBe('active') + const invitation = ( + await databasePool.query( + 'SELECT status,accepted_admin_id FROM control_admin_invitations WHERE id=$1', + [f.invitation] + ) + ).rows + expect(invitation).toEqual([{ status: 'pending', accepted_admin_id: null }]) + save(spec.label, { outcome, final, invitation }) + }) + for (const which of ['email', 'link'] as const) + it(`real handover discovery fails closed on concurrent ${which} change`, async () => { + const spec: Spec = { label: `error-handover-race-${which}`, writer: 'handover' } + const f = await expandedFixture(spec) + if (which === 'link') await seedLink(f.user, f.inviterAdmin!) + const changer = await databasePool.connect() + let changed!: () => void + const changeStarted = new Promise(resolve => (changed = resolve)) + let pending: Promise | undefined + try { + await begin(changer, `${spec.label}-change`) + discoveryHook = async () => { + // Both operations use existing rows. Email changes hold the receiver + // user row; link version changes hold its real FK-referenced user. + if (which === 'email') + await changer.query('UPDATE users SET email=$2 WHERE id=$1', [ + f.receiver, + `changed-${f.receiver}@example.test`, + ]) + else { + await changer.query('SELECT id FROM users WHERE id=$1 FOR UPDATE', [f.user]) + await changer.query( + 'UPDATE gfs_desktop_operator_links SET row_version=row_version+1 WHERE user_id=$1', + [f.user] + ) + } + changed() + } + pending = withObservedClient(spec, async c => + observe(c, Promise.resolve(deletionWrite(c, f, spec))) + ) + await changeStarted + const changerPid = await pid(changer) + const d = ( + await databasePool.query('SELECT pid FROM pg_stat_activity WHERE application_name=$1', [ + spec.label, + ]) + ).rows[0].pid + await waitForEdges([[d, changerPid]]) + await snapshot(spec.label, '01-identity-change-blocked', [d, changerPid], f) + await changer.query('COMMIT') + const outcome = await pending + expect(outcome).toMatchObject({ + status: 'rejected', + code: 'handover_identity_changed', + transaction: 'ROLLBACK', + }) + const final = await expandedFinal(f) + expect(final.users[0]?.lifecycle_state).toBe('active') + expect(final.inviter[0]?.status).toBe('active') + expect(final.receiverMemberships).toHaveLength(1) + const invitation = ( + await databasePool.query( + 'SELECT status,accepted_admin_id FROM control_admin_invitations WHERE id=$1', + [f.invitation] + ) + ).rows + expect(invitation).toEqual([{ status: 'pending', accepted_admin_id: null }]) + save(spec.label, { outcome, final, invitation }) + } finally { + discoveryHook = undefined + await changer.query('ROLLBACK') + await pending + changer.release() + } + }) + for (const order of ['writer-first', 'retirement-first'] as const) + it(`handover discovers case-insensitive receiver SET and linked sender ${order}`, async () => { + const spec: Spec = { label: `fixed-handover-set-${order}`, writer: 'handover', order } + // The common fixture creates the sender email mapping. Add a second active + // receiver differing only by case; UNIQUE(email) permits both identities. + receiverSetFixture = true + try { + await contention(spec, 'fixed') + } finally { + receiverSetFixture = false + } + }) +} +let receiverSetFixture = false + +function registerOnlineBackfill() { + it('Q1 short teams-before-users backfill aborts lock wait before deadlock and resumes with coverage', async () => { + const spec: Spec = { label: 'online-backfill-timeout', legacy: true } + const f = await expandedFixture(spec) + const clients = await Promise.all([ + databasePool.connect(), + databasePool.connect(), + databasePool.connect(), + ]) + const [gate, retire, batch] = clients as [PoolClient, PoolClient, PoolClient] + const raw = retire.query.bind(retire) + const observation = installObservation(retire, spec, true, () => + raw('SELECT pg_advisory_xact_lock($1,1)', [barrierClass]).then(() => undefined) + ) + const pending: Promise[] = [] + try { + const [gatePid, retirePid, batchPid] = await Promise.all(clients.map(pid)) + await gate.query('SELECT pg_advisory_lock($1,1)', [barrierClass]) + await runtimeBegin(retire, 'online-backfill-retirement') + await begin(batch, 'online-backfill-batch') + await batch.query("SET LOCAL lock_timeout='750ms'") + const retirement = observe(retire, retirementWrite(retire, f)) + pending.push(retirement) + await waitForEdges([[retirePid, gatePid]]) + // Deliberately overlap missing-row coverage with a retirement. A small + // batch and lock_timeout < deadlock_timeout fail closed, then resume. + await batch.query( + `INSERT INTO authorization_team_revisions(team_id,revision,updated_at) + SELECT id,1,clock_timestamp() FROM teams WHERE id=$1 ORDER BY id ON CONFLICT DO NOTHING`, + [f.team] + ) + const backfill = observe( + batch, + batch.query( + `INSERT INTO authorization_user_revisions(user_id,revision,updated_at) + SELECT id,1,clock_timestamp() FROM users WHERE id=$1 ORDER BY id ON CONFLICT DO NOTHING`, + [f.user] + ) + ) + pending.push(backfill) + await waitForEdges([[batchPid, retirePid]]) + await gate.query('SELECT pg_advisory_unlock($1,1)', [barrierClass]) + await waitForEdges([ + [batchPid, retirePid], + [retirePid, batchPid], + ]) + await snapshot(spec.label, '01-timeout-protected-cycle', [gatePid, retirePid, batchPid], f) + const outcomes = await Promise.all([retirement, backfill]) + expect(outcomes[0]).toMatchObject({ status: 'fulfilled', transaction: 'COMMIT' }) + expect(outcomes[1]).toMatchObject({ + status: 'rejected', + code: '55P03', + transaction: 'ROLLBACK', + }) + await backfillRevisions() + const uncovered = ( + await databasePool.query(`SELECT + (SELECT count(*)::int FROM users u LEFT JOIN authorization_user_revisions r ON r.user_id=u.id WHERE r.user_id IS NULL) AS users, + (SELECT count(*)::int FROM teams t LEFT JOIN authorization_team_revisions r ON r.team_id=t.id WHERE r.team_id IS NULL) AS teams`) + ).rows[0] + expect(uncovered).toEqual({ users: 0, teams: 0 }) + save(spec.label, { + outcomes, + uncovered, + lockTimeoutMs: 750, + deadlockTimeoutMs: 2000, + final: await expandedFinal(f), + }) + } finally { + await gate.query('SELECT pg_advisory_unlock_all()') + await Promise.allSettled(pending) + observation.spy.mockRestore() + for (const c of clients) { + await c.query('ROLLBACK') + c.release() + } + } + }) +} + +const extraReceivers = new Map() + +function registerToctouTests() { + for (const phase of [ + 'email-before-copy', + 'link-before-copy', + 'email-after-validation', + ] as const) { + it(`TOCTOU ${phase} closes handover identity set`, async () => { + const spec: Spec = { label: `toctou-${phase}`, writer: 'handover' } + const f = await expandedFixture(spec) + await seedLink(f.user, f.inviterAdmin!) + const phantom = f.manager, + phantomTeam = randomUUID() + await databasePool.query("INSERT INTO teams(id,name) VALUES($1,'TOCTOU phantom team')", [ + phantomTeam, + ]) + await databasePool.query( + "INSERT INTO team_members(team_id,user_id,role,status) VALUES($1,$2,'member','active')", + [phantomTeam, phantom] + ) + await backfillRevisions() + const before = await expandedFinal(f) + const changer = await databasePool.connect() + const changerPid = await pid(changer) + let prelockedIds: string[] = [], + heldTeams: string[] = [] + let observations: unknown[] = [] + try { + await begin(changer, `${spec.label}-change`) + const change = async () => { + const handoverPid = ( + await databasePool.query('SELECT pid FROM pg_stat_activity WHERE application_name=$1', [ + spec.label, + ]) + ).rows[0].pid + prelockedIds = ( + await databasePool.query( + `SELECT u.id FROM pgrowlocks('users') p JOIN users u ON u.ctid=p.locked_row + WHERE $1=ANY(p.pids) ORDER BY u.id`, + [handoverPid] + ) + ).rows.map(r => r.id) + heldTeams = ( + await databasePool.query( + `SELECT r.team_id FROM pgrowlocks('authorization_team_revisions') p + JOIN authorization_team_revisions r ON r.ctid=p.locked_row WHERE $1=ANY(p.pids) ORDER BY r.team_id`, + [handoverPid] + ) + ).rows.map(r => r.team_id) + expect(prelockedIds).toEqual([f.user, f.receiver].sort()) + expect(prelockedIds).not.toContain(phantom) + expect(heldTeams).not.toContain(phantomTeam) + await snapshot(spec.label, '01-after-prelock-before-change', [handoverPid, changerPid], f) + const result = + phase === 'link-before-copy' + ? await changer.query( + 'UPDATE gfs_desktop_operator_links SET row_version=row_version+1 WHERE user_id=$1', + [f.user] + ) + : await changer.query('UPDATE users SET email=$2 WHERE id=$1', [ + phantom, + f.receiverEmail!.toUpperCase(), + ]) + expect(result.rowCount).toBe(1) + await changer.query('COMMIT') + const matching = ( + await databasePool.query( + "SELECT id FROM users WHERE lower(email)=lower($1) AND lifecycle_state='active' ORDER BY id", + [f.receiverEmail] + ) + ).rows.map(r => r.id) + event({ + phase: 'toctou-change-committed', + label: spec.label, + moment: phase, + prelockedIds, + heldTeams, + phantom, + phantomTeam, + matching, + }) + } + if (phase === 'email-after-validation') afterCopyValidationHook = change + else beforeCopyHook = change + const outcome = await withObservedClient(spec, async (c, observation) => { + const result = await observe(c, Promise.resolve(deletionWrite(c, f, spec))) + observations = observation.events + return result + }) + const final = await expandedFinal(f) + const phantomMemberships = ( + await databasePool.query( + 'SELECT team_id,role,status FROM team_members WHERE user_id=$1 ORDER BY team_id', + [phantom] + ) + ).rows + const invitation = ( + await databasePool.query( + 'SELECT status,accepted_admin_id FROM control_admin_invitations WHERE id=$1', + [f.invitation] + ) + ).rows + const newAdmins = ( + await databasePool.query('SELECT id,status FROM control_admin_users WHERE email=$1', [ + f.receiverEmail, + ]) + ).rows + const trace = events.filter(e => e.label === spec.label) + // Persist the actual wrong copy/COMMIT before an unchanged assertion + // rejects the mutation. A RED is not inferred from a test exit alone. + save(spec.label, { + mutation, + phase, + fixture: f, + phantom, + phantomTeam, + prelockedIds, + heldTeams, + outcome, + before, + final, + phantomMemberships, + invitation, + newAdmins, + observations, + trace, + }) + expect(trace.filter(e => e.phase === 'handover-revalidated')).toHaveLength(1) + const comparisons = trace.filter(e => e.phase === 'handover-copy-revalidation') + expect(comparisons).toHaveLength(1) + if (phase === 'email-after-validation') { + expect(comparisons[0].identical).toBe(true) + expect(outcome).toMatchObject({ status: 'fulfilled', transaction: 'COMMIT' }) + expect(final.users[0]?.lifecycle_state).toBe('retired') + expect(final.receiverMemberships).toHaveLength(3) + expect(phantomMemberships).toEqual([ + { team_id: phantomTeam, role: 'member', status: 'active' }, + ]) + const copy = observations.find((e: any) => e.placement === 'handover-copy-bound') as any + expect(copy.ids).toEqual([f.receiver]) + expect(copy.sql).toContain('ON target.id = ANY($2::uuid[])') + } else { + expect(comparisons[0].identical).toBe(false) + expect(outcome).toMatchObject({ + status: 'rejected', + code: 'handover_identity_changed', + errorName: 'HandoverIdentityChangedError', + message: 'handover identity changed: before-membership-copy', + transaction: 'ROLLBACK', + }) + expect(final).toEqual(before) + expect(phantomMemberships).toEqual([ + { team_id: phantomTeam, role: 'member', status: 'active' }, + ]) + expect(invitation).toEqual([{ status: 'pending', accepted_admin_id: null }]) + expect(newAdmins).toEqual([]) + expect(observations.filter((e: any) => e.placement === 'handover-copy-executed')).toEqual( + [] + ) + } + } finally { + beforeCopyHook = undefined + afterCopyValidationHook = undefined + await changer.query('ROLLBACK') + changer.release() + } + }) + } +} + +function registerCollationTest() { + it('COLLATE omission uses the same raw TEXT ordering as both real triggers', async () => { + const definitions = ( + await databasePool.query(`SELECT proname,pg_get_functiondef(oid) AS definition FROM pg_proc + WHERE proname IN ('authorization_bump_workflow_run_revision','authorization_bump_workflow_run_audit_revision', + 'authorization_prelock_user_retirement_revisions') ORDER BY proname`) + ).rows + expect(definitions).toHaveLength(3) + for (const f of definitions) { + expect(f.definition).not.toMatch(/COLLATE\s+"/i) + expect(f.definition).toContain( + f.proname === 'authorization_prelock_user_retirement_revisions' + ? 'array_agg(subject_id ORDER BY subject_id)' + : 'ORDER BY subject_type, subject_id' + ) + } + const ordering = ( + await databasePool.query(`WITH keys(subject_type,subject_id) AS (VALUES + ('team'::text,'aaaaaaaa-0000-4000-8000-000000000001'::text), + ('team','BBBBBBBB-0000-4000-8000-000000000002'), + ('team','bbbbbbbb-0000-4000-8000-000000000002'), + ('team','00000000-0000-4000-8000-000000000000')) + SELECT array_agg(subject_id ORDER BY subject_id) AS prelock, + array_agg(subject_id ORDER BY subject_type,subject_id) AS triggers, + array_agg(DISTINCT pg_collation_for(subject_id)::text) AS collations FROM keys`) + ).rows[0] + expect(ordering.prelock).toEqual(ordering.triggers) + expect(ordering.collations).toEqual(['"default"']) + const db = ( + await databasePool.query( + 'SELECT datcollate,datctype,datlocprovider FROM pg_database WHERE datname=current_database()' + ) + ).rows[0] + save('collation-witness', { + decision: 'omit COLLATE to match unchanged triggers', + db, + ordering, + definitions, + limitation: + 'Local PG16 ordering witness only; no live-cluster locale probe or normalization claim.', + }) + }) +} From 7d7119a26a3b6960fdefbb8530a53f3ae1c56452 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Sat, 31 Oct 2026 17:16:18 +0100 Subject: [PATCH 434/528] test(control-api): add retirement prelock to privilege inventory and close gfs coverage gap - Add authorization_prelock_user_retirement_revisions to the expected SECURITY DEFINER inventory, runtime EXECUTE allowlist, and prelock grant assertions in db.realPostgresMigration.integration.test.ts (blind review F1). - Catch gfs_resources inserted between the early backfill and trigger creation, and assert full resource coverage in the 0134 postcondition (blind review F3). - Update the pinned userAccessFoundationSchema.ts hash. --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- .../access/userAccessFoundationSchema.ts | 20 +++++++++++++++++-- ....realPostgresMigration.integration.test.ts | 7 +++++++ .../test/migrationExecutionPolicy.test.ts | 2 +- 5 files changed, 29 insertions(+), 6 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index b9a8309eb3..89957f5188 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.885", + "version": "0.1.886", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.885", + "version": "0.1.886", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index bbf0461d1f..3f02340cb1 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.885", + "version": "0.1.886", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/access/userAccessFoundationSchema.ts b/control-api/src/services/access/userAccessFoundationSchema.ts index 00e68ae4c5..f3bd743698 100644 --- a/control-api/src/services/access/userAccessFoundationSchema.ts +++ b/control-api/src/services/access/userAccessFoundationSchema.ts @@ -1114,11 +1114,27 @@ export async function applyComposableCatalogRevisionSchema(db: DbClient): Promis SELECT id, 1, clock_timestamp() FROM public.users ORDER BY id ON CONFLICT (user_id) DO NOTHING; + -- Catch gfs_resources inserted between the early backfill and trigger creation. + INSERT INTO public.authorization_resource_revisions( + environment_id, resource_type, resource_id, revision, updated_at + ) + SELECT env.environment_id, 'gfs_resource', g.resource_id::text, 1, clock_timestamp() + FROM public.gfs_resources g + CROSS JOIN public.authorization_catalog_environment env + WHERE env.singleton = TRUE + ON CONFLICT (environment_id, resource_type, resource_id) DO NOTHING; + IF EXISTS (SELECT 1 FROM public.users u LEFT JOIN public.authorization_user_revisions r ON r.user_id = u.id WHERE r.user_id IS NULL) OR EXISTS (SELECT 1 FROM public.teams t LEFT JOIN public.authorization_team_revisions r - ON r.team_id = t.id WHERE r.team_id IS NULL) THEN - RAISE EXCEPTION '0134 revision backfill left uncovered users or teams'; + ON r.team_id = t.id WHERE r.team_id IS NULL) + OR EXISTS (SELECT 1 FROM public.gfs_resources g + LEFT JOIN public.authorization_resource_revisions r + ON r.environment_id = (SELECT environment_id FROM public.authorization_catalog_environment WHERE singleton) + AND r.resource_type = 'gfs_resource' + AND r.resource_id = g.resource_id::text + WHERE r.resource_id IS NULL) THEN + RAISE EXCEPTION '0134 revision backfill left uncovered users, teams, or gfs resources'; END IF; IF NOT EXISTS ( SELECT 1 FROM pg_proc p diff --git a/control-api/test/db.realPostgresMigration.integration.test.ts b/control-api/test/db.realPostgresMigration.integration.test.ts index 58a77c6f69..21a3e70f71 100644 --- a/control-api/test/db.realPostgresMigration.integration.test.ts +++ b/control-api/test/db.realPostgresMigration.integration.test.ts @@ -46,6 +46,7 @@ const accessFoundationDefinerSignatures = [ 'authorization_bump_user_revision(pg_catalog.uuid)', 'authorization_bump_team_revision(pg_catalog.uuid)', 'authorization_bump_subject_revision(pg_catalog.text, pg_catalog.text)', + 'authorization_prelock_user_retirement_revisions(pg_catalog.uuid[])', 'authorization_prelock_workflow_run_revisions(pg_catalog.uuid[])', 'authorization_prelock_workflow_approval_revisions(pg_catalog.uuid[])', 'authorization_prelock_workflow_approval_trigger_revisions(pg_catalog.uuid)', @@ -904,6 +905,7 @@ describeRealPostgres('control-api real Postgres migrations', () => { ORDER BY routine.proname `) expect(revisionExecute.rows).toEqual([ + { function_name: 'authorization_prelock_user_retirement_revisions' }, { function_name: 'authorization_prelock_workflow_approval_revisions' }, { function_name: 'authorization_prelock_workflow_approval_trigger_revisions' }, { function_name: 'authorization_prelock_workflow_run_revisions' }, @@ -913,6 +915,7 @@ describeRealPostgres('control-api real Postgres migrations', () => { FROM pg_proc routine CROSS JOIN LATERAL aclexplode(routine.proacl) privilege WHERE routine.oid IN ( + 'public.authorization_prelock_user_retirement_revisions(uuid[])'::regprocedure, 'public.authorization_prelock_workflow_run_revisions(uuid[])'::regprocedure, 'public.authorization_prelock_workflow_approval_revisions(uuid[])'::regprocedure, 'public.authorization_prelock_workflow_approval_trigger_revisions(uuid)'::regprocedure, @@ -923,6 +926,10 @@ describeRealPostgres('control-api real Postgres migrations', () => { ORDER BY grantee, function_name `) expect(prelockGrants.rows).toEqual([ + { + function_name: 'authorization_prelock_user_retirement_revisions', + grantee: 'control_api_runtime', + }, { function_name: 'authorization_prelock_workflow_approval_revisions', grantee: 'control_api_runtime', diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index baa637fc4c..85d1efa235 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -121,7 +121,7 @@ describe('D34 migration execution policy', () => { const files = [ [ 'src/services/access/userAccessFoundationSchema.ts', - 'd0245a5ac51ef2e782c91923db538466f6fce1ff4975043e3b5dd71c9fa06908', + '07fd931ec209d101282501d3fe148183cc9377b1e8f70ed9e2b039628e0a71f3', ], [ 'src/services/directory/invitationDeliverySchema.ts', From ac179d6e19e193d5f588fc49d43679a05459f3dc Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Sat, 31 Oct 2026 17:34:22 +0100 Subject: [PATCH 435/528] ci(control-api): pin new real-PostgreSQL suites for PR1 fix stack --- .github/workflows/ci-public.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci-public.yml b/.github/workflows/ci-public.yml index 7776f7b78a..3009cca74d 100644 --- a/.github/workflows/ci-public.yml +++ b/.github/workflows/ci-public.yml @@ -651,7 +651,9 @@ jobs: legacyPasswordEpochBackfill.realPostgres.integration.test.ts \ legacySessionExchangeRace.realPostgres.integration.test.ts \ migrationExecutionPolicy.realPostgres.integration.test.ts \ + migrationGfsBackfillBound.realPostgres.integration.test.ts \ migrationPreviousImageCompatibility.realPostgres.integration.test.ts \ + migrationUserRetirementRevisions.realPostgres.integration.test.ts \ pr1MigrationLocks.realPostgres.integration.test.ts \ routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts \ routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts \ @@ -659,6 +661,7 @@ jobs: userSessionDatabaseClock.realPostgres.integration.test.ts \ archiveRevisionLockOrder.realPostgres.integration.test.ts \ revisionWritersLockOrder.realPostgres.integration.test.ts \ + userRetirementRevisionLockOrder.realPostgres.integration.test.ts \ workflowRunReadAndApprovalV1Compatibility.realPostgres.integration.test.ts - name: Install gfs-controller dependencies From b1c65a2fa75d77026e430f7b9a63bd6d65f4dc00 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Sat, 31 Oct 2026 17:59:21 +0100 Subject: [PATCH 436/528] test(control-api): guarantee real postgres fixture cleanup --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- ...kfillBound.realPostgres.integration.test.ts | 17 ++++++++++++++--- ...tRevisions.realPostgres.integration.test.ts | 18 ++++++++++++++---- ...nLockOrder.realPostgres.integration.test.ts | 2 +- 5 files changed, 32 insertions(+), 11 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 89957f5188..18aaa54340 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.886", + "version": "0.1.887", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.886", + "version": "0.1.887", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 3f02340cb1..a469456119 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.886", + "version": "0.1.887", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/migrationGfsBackfillBound.realPostgres.integration.test.ts b/control-api/test/migrationGfsBackfillBound.realPostgres.integration.test.ts index d4724cf1c4..e50a8a1032 100644 --- a/control-api/test/migrationGfsBackfillBound.realPostgres.integration.test.ts +++ b/control-api/test/migrationGfsBackfillBound.realPostgres.integration.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' import { randomBytes } from 'node:crypto' import { Pool } from 'pg' import { CONTROL_API_MIGRATIONS, assertDbReady, initDb } from '../src/db.js' @@ -21,9 +21,12 @@ describeRealPostgres('0134 GFS backfill bound on real PostgreSQL', () => { let adminPool: Pool let databasePool: Pool + beforeAll(() => { + adminPool = new Pool({ connectionString: adminUrl }) + }) + beforeEach(async () => { database = `control_api_gfs_bound_${randomBytes(6).toString('hex')}` - adminPool = new Pool({ connectionString: adminUrl }) await adminPool.query(`CREATE DATABASE "${database}"`) databasePool = new Pool({ connectionString: databaseUrl(adminUrl!, database) }) @@ -50,8 +53,16 @@ describeRealPostgres('0134 GFS backfill bound on real PostgreSQL', () => { }) afterEach(async () => { + await endPoolAndWaitForClients(databasePool) + if (adminPool && database) { + await adminPool.query(`DROP DATABASE IF EXISTS "${database}"`) + database = '' + } + }) + + afterAll(async () => { try { - await endPoolAndWaitForClients(databasePool) + // Retry the last database cleanup if a per-test teardown failed. if (adminPool && database) { await adminPool.query(`DROP DATABASE IF EXISTS "${database}"`) } diff --git a/control-api/test/migrationUserRetirementRevisions.realPostgres.integration.test.ts b/control-api/test/migrationUserRetirementRevisions.realPostgres.integration.test.ts index 1cb2f7108b..d8133ca88f 100644 --- a/control-api/test/migrationUserRetirementRevisions.realPostgres.integration.test.ts +++ b/control-api/test/migrationUserRetirementRevisions.realPostgres.integration.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' import { randomBytes, randomUUID } from 'node:crypto' import { Pool } from 'pg' import { CONTROL_API_MIGRATIONS, assertDbReady, initDb } from '../src/db.js' @@ -17,10 +17,13 @@ describeRealPostgres('0134 user retirement revision coverage on PostgreSQL 16', retired = randomUUID(), team = randomUUID() + beforeAll(() => { + admin = new Pool({ connectionString: adminUrl }) + }) + beforeEach(async () => { expect(process.version).toMatch(/^v24\./) database = `retirement_migration_${randomBytes(6).toString('hex')}` - admin = new Pool({ connectionString: adminUrl }) const version = Number( (await admin.query('SHOW server_version_num')).rows[0].server_version_num ) @@ -70,11 +73,18 @@ describeRealPostgres('0134 user retirement revision coverage on PostgreSQL 16', await db.query('DELETE FROM authorization_team_revisions') }) afterEach(async () => { + await endPoolAndWaitForClients(db) + if (admin && database) { + await admin.query(`DROP DATABASE IF EXISTS "${database}"`) + database = '' + } + }) + afterAll(async () => { try { - await endPoolAndWaitForClients(db) + // Retry the last database cleanup if a per-test teardown failed. if (admin && database) await admin.query(`DROP DATABASE IF EXISTS "${database}"`) } finally { - await endPoolAndWaitForClients(admin) + await admin?.end() } }) async function coverage() { diff --git a/control-api/test/userRetirementRevisionLockOrder.realPostgres.integration.test.ts b/control-api/test/userRetirementRevisionLockOrder.realPostgres.integration.test.ts index 8ceb6ba621..ca3c323bd7 100644 --- a/control-api/test/userRetirementRevisionLockOrder.realPostgres.integration.test.ts +++ b/control-api/test/userRetirementRevisionLockOrder.realPostgres.integration.test.ts @@ -925,7 +925,7 @@ describeRealPostgres('user retirement revision lock order on real PostgreSQL 16' await endPoolAndWaitForClients(databasePool) if (adminPool) await adminPool.query(`DROP DATABASE IF EXISTS "${database}"`) } finally { - await endPoolAndWaitForClients(adminPool) + await adminPool?.end() } }) for (const spec of [ From 23abf47ab399b8e88dfbd6ec6b63738bacef014c Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Sat, 31 Oct 2026 20:01:29 +0100 Subject: [PATCH 437/528] test: include PR1 PostgreSQL suites in census --- tests/contracts/password-recovery-suites.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/contracts/password-recovery-suites.json b/tests/contracts/password-recovery-suites.json index 30ab367061..3be543f824 100644 --- a/tests/contracts/password-recovery-suites.json +++ b/tests/contracts/password-recovery-suites.json @@ -121,6 +121,8 @@ "control-api/test/legacyV1Generation.realPostgres.integration.test.ts", "control-api/test/membershipInvitationIdempotency.realPostgres.integration.test.ts", "control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts", + "control-api/test/migrationGfsBackfillBound.realPostgres.integration.test.ts", + "control-api/test/migrationUserRetirementRevisions.realPostgres.integration.test.ts", "control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts", "control-api/test/pr1MigrationLocks.realPostgres.integration.test.ts", "control-api/test/routes.externalGfsV2Lifecycle.realPostgres.integration.test.ts", @@ -128,6 +130,7 @@ "control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts", "control-api/test/routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts", "control-api/test/userAccessRuntimeContract.realPostgres.integration.test.ts", + "control-api/test/userRetirementRevisionLockOrder.realPostgres.integration.test.ts", "control-api/test/userSessionDatabaseClock.realPostgres.integration.test.ts", "control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts", "control-api/test/revisionWritersLockOrder.realPostgres.integration.test.ts", From 5491fc9e3ced093470042cf2a073f1c5725b8648 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Sat, 31 Oct 2026 21:38:05 +0100 Subject: [PATCH 438/528] test(desktop): isolate permission refresh across sessions --- .../ui/src/pages/__tests__/FilePreviewPage.test.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/desktop-app/ui/src/pages/__tests__/FilePreviewPage.test.tsx b/desktop-app/ui/src/pages/__tests__/FilePreviewPage.test.tsx index c1142c98b1..a680f0b330 100644 --- a/desktop-app/ui/src/pages/__tests__/FilePreviewPage.test.tsx +++ b/desktop-app/ui/src/pages/__tests__/FilePreviewPage.test.tsx @@ -675,6 +675,16 @@ describe('FilePreviewPage', () => { controllerState.sessionGeneration = 2 view.rerender() + // A new generation is allowed to refresh its own resource and parent permissions. + await waitFor(() => { + expect(affordances.mock.calls.slice(permissionReadsBeforeReplacement)).toEqual( + expect.arrayContaining([ + ['0123456789abcdef0123456789abcdef', 'main'], + ['preview-parent', 'main'], + ]) + ) + }) + const permissionReadsAfterReplacement = affordances.mock.calls.length await act(async () => releaseDigest(new ArrayBuffer(0))) await act(async () => { await Promise.resolve() @@ -682,7 +692,7 @@ describe('FilePreviewPage', () => { }) expect(renameResource).not.toHaveBeenCalled() - expect(affordances.mock.calls.length).toBe(permissionReadsBeforeReplacement) + expect(affordances.mock.calls.length).toBe(permissionReadsAfterReplacement) } finally { releaseDigest(new ArrayBuffer(0)) digestSpy.mockRestore() From 6af8737f313e6c9ccd47f75b0d43d06572f539a7 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Mon, 2 Nov 2026 12:56:36 +0100 Subject: [PATCH 439/528] test(harness): check certification inputs before T0 and keep T1 reporter evidence Certification lanes discovered missing inputs late: unexported historical producers killed T1 after a full bootstrap, uninstalled package dependencies failed T0, and the hosted and local suite selections drifted. - tests/contracts/certification-inputs.json declares the producer pins, the package dependencies and the native modules each scope needs, and scripts/ci/certification-inputs.mjs checks them before T0 (local T2), before T1 (standalone lane) and in the hosted jobs. It builds the pinned producers with a provenance receipt and lists the affected packages for t0.sh. It never installs packages or touches a cluster. - The two real-PostgreSQL suites read their producer pins from the same contract instead of repeating them. - T1 writes a projection of each Vitest reporter to its evidence directory: files, tests, statuses, durations and counters, without the free-text failure messages (only their count). A lane whose evidence cannot be written does not pass. - CI runs the certification-input and projection tests; the hosted real-PostgreSQL job is checked against the same public suite inventory as Minikube. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci-public.yml | 58 ++- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- ...ionPolicy.realPostgres.integration.test.ts | 23 +- ...atibility.realPostgres.integration.test.ts | 16 +- scripts/ci/certification-inputs.mjs | 395 ++++++++++++++++++ scripts/e2e/minikube-real-postgres.sh | 13 + scripts/e2e/t1-reporter-projection.py | 125 ++++++ scripts/minikube/t0.sh | 38 +- scripts/minikube/t2.sh | 19 + scripts/tests/certification-inputs.test.mjs | 319 ++++++++++++++ .../password-recovery-suite-contract.test.mjs | 24 ++ scripts/tests/t1-reporter-projection.test.mjs | 116 +++++ .../tests/test-minikube-t1-reporter-report.sh | 10 + scripts/tests/test-minikube-t2-contract.sh | 8 +- tests/contracts/certification-inputs.json | 115 +++++ 16 files changed, 1225 insertions(+), 60 deletions(-) create mode 100644 scripts/ci/certification-inputs.mjs create mode 100644 scripts/e2e/t1-reporter-projection.py create mode 100644 scripts/tests/certification-inputs.test.mjs create mode 100644 scripts/tests/t1-reporter-projection.test.mjs create mode 100644 tests/contracts/certification-inputs.json diff --git a/.github/workflows/ci-public.yml b/.github/workflows/ci-public.yml index 3009cca74d..2523f533c6 100644 --- a/.github/workflows/ci-public.yml +++ b/.github/workflows/ci-public.yml @@ -259,6 +259,11 @@ jobs: working-directory: desktop-app run: npm run verify:electron + - name: Validate declared test inputs + env: + CERTIFICATION_SERVICE: ${{ matrix.service }} + run: node scripts/ci/certification-inputs.mjs check --scope matrix --service "$CERTIFICATION_SERVICE" + - name: Build working-directory: ${{ matrix.service }} run: npm run build @@ -463,27 +468,30 @@ jobs: steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - # R61-B1 acceptance runs exact migration producers for observed DEV - # history and the latest fetched DEV graph. Immutable pins keep clean CI - # portable. + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version-file: .nvmrc + cache: npm + cache-dependency-path: control-api/package-lock.json + + # The same validated pin declaration is consumed by local preflight and + # the real-PostgreSQL tests; these checkouts stay read-only producers. + - name: Resolve historical producer pins + id: certification_inputs + run: node scripts/ci/certification-inputs.mjs pins >> "$GITHUB_OUTPUT" + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: - ref: 0b26101eb247adcc44f70d39451f3e82c3225d47 + ref: ${{ steps.certification_inputs.outputs.observed_commit }} path: r61-b1-producers/observed-dev persist-credentials: false - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: - ref: 87f962fb999ed5dbcab90bd52562de0eba4aa564 + ref: ${{ steps.certification_inputs.outputs.latest_commit }} path: r61-b1-producers/latest-dev persist-credentials: false - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version-file: .nvmrc - cache: npm - cache-dependency-path: control-api/package-lock.json - - name: Install dependencies working-directory: control-api run: npm ci @@ -508,9 +516,10 @@ jobs: working-directory: r61-b1-producers/observed-dev/control-api run: npm ci - - name: Build observed DEV migration runner - working-directory: r61-b1-producers/observed-dev/control-api - run: npm run build + - name: Build observed DEV producer with provenance + env: + TASK106_R61_B1_DEV_SOURCE_DIR: ${{ github.workspace }}/r61-b1-producers/observed-dev + run: node scripts/ci/certification-inputs.mjs build-producer --producer observedDev - name: Install latest DEV producer dependencies working-directory: r61-b1-producers/latest-dev/packages/workflow-runtime-core @@ -524,9 +533,10 @@ jobs: working-directory: r61-b1-producers/latest-dev/control-api run: npm ci - - name: Build latest DEV migration runner - working-directory: r61-b1-producers/latest-dev/control-api - run: npm run build + - name: Build latest DEV producer with provenance + env: + TASK106_R61_B1_LATEST_DEV_SOURCE_DIR: ${{ github.workspace }}/r61-b1-producers/latest-dev + run: node scripts/ci/certification-inputs.mjs build-producer --producer latestDev - name: Install External REST contract-test dependencies working-directory: external-rest-api @@ -536,10 +546,20 @@ jobs: working-directory: external-rest-api run: npm run build - - name: Validate recovery suite selection contract + - name: Validate Real PostgreSQL certification inputs + env: + TASK106_R61_B1_DEV_SOURCE_DIR: ${{ github.workspace }}/r61-b1-producers/observed-dev + TASK106_R61_B1_LATEST_DEV_SOURCE_DIR: ${{ github.workspace }}/r61-b1-producers/latest-dev + run: node scripts/ci/certification-inputs.mjs check --scope ci-t1 + + - name: Validate recovery suite selection, certification input and T1 evidence contracts env: CODEX_SCRATCH_ROOT: ${{ runner.temp }} - run: node --test scripts/tests/password-recovery-suite-contract.test.mjs + run: >- + node --test + scripts/tests/password-recovery-suite-contract.test.mjs + scripts/tests/certification-inputs.test.mjs + scripts/tests/t1-reporter-projection.test.mjs - name: Run control-api real Postgres suites (migration + GFS) working-directory: control-api diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 18aaa54340..dd87bb8034 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.887", + "version": "0.1.888", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.887", + "version": "0.1.888", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index a469456119..6a10ba8e2e 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.887", + "version": "0.1.888", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts index c4bf5a611d..382f16285d 100644 --- a/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts +++ b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts @@ -19,17 +19,22 @@ import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip -const OBSERVED_DEV_SOURCE_COMMIT = '0b26101eb247adcc44f70d39451f3e82c3225d47' -const OBSERVED_DEV_DB_SOURCE_SHA256 = - '1948ad306fe56b368f120edbc70ddbac6cb9b981d9fd218e7ded2575ed3b2beb' +const certificationInputs = JSON.parse( + readFileSync(new URL('../../tests/contracts/certification-inputs.json', import.meta.url), 'utf8') +) as { + producers: Record }> +} +const observedProducer = certificationInputs.producers.observedDev +const latestProducer = certificationInputs.producers.latestDev +const OBSERVED_DEV_SOURCE_COMMIT = observedProducer.commit +const OBSERVED_DEV_DB_SOURCE_SHA256 = observedProducer.sourceHashes['control-api/src/db.ts'] const OBSERVED_DEV_RECEIPT_COUNT = 133 const OBSERVED_DEV_RECEIPT_SET_SHA256 = 'acb72ad12e1d342310832cf7628ab8351a80cee7311111039b4c2b9720077307' -const LATEST_DEV_SOURCE_COMMIT = '87f962fb999ed5dbcab90bd52562de0eba4aa564' -const LATEST_DEV_DB_SOURCE_SHA256 = - '616678c106f762a8b82ea45f42e01ddc42fb90dda935d62fd0f4b72c2a2eeebc' +const LATEST_DEV_SOURCE_COMMIT = latestProducer.commit +const LATEST_DEV_DB_SOURCE_SHA256 = latestProducer.sourceHashes['control-api/src/db.ts'] const LATEST_DEV_MIGRATION_ENTRY_SOURCE_SHA256 = - '2b582252753c6455fc85d7cb4563313e3b63ecf7c7630aa6252f70f0bb286a81' + latestProducer.sourceHashes['control-api/src/migrate.ts'] const LATEST_DEV_RECEIPT_COUNT = 134 const DEV_PASSWORD_SUCCESS_ADMISSION_VERSION = '0130_password_login_success_admission' // Dev migrations after 0106 that both pinned DEV producers predate or include. @@ -41,8 +46,8 @@ const DEV_POST_0106_THROUGH_0129 = CONTROL_API_MIGRATIONS.map( version <= '0129_workflow_schedules_concurrency_policy' ) -const OBSERVED_DEV_SOURCE_DIR = process.env.TASK106_R61_B1_DEV_SOURCE_DIR -const LATEST_DEV_SOURCE_DIR = process.env.TASK106_R61_B1_LATEST_DEV_SOURCE_DIR +const OBSERVED_DEV_SOURCE_DIR = process.env[observedProducer.env] +const LATEST_DEV_SOURCE_DIR = process.env[latestProducer.env] if ( process.env.CONTROL_API_REAL_PG_REQUIRED === '1' && (!OBSERVED_DEV_SOURCE_DIR || !LATEST_DEV_SOURCE_DIR) diff --git a/control-api/test/workflowRunReadAndApprovalV1Compatibility.realPostgres.integration.test.ts b/control-api/test/workflowRunReadAndApprovalV1Compatibility.realPostgres.integration.test.ts index 601d786230..e1fa936a1d 100644 --- a/control-api/test/workflowRunReadAndApprovalV1Compatibility.realPostgres.integration.test.ts +++ b/control-api/test/workflowRunReadAndApprovalV1Compatibility.realPostgres.integration.test.ts @@ -8,14 +8,20 @@ import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip -const OBSERVED_DEV_SOURCE_DIR = process.env.TASK106_R61_B1_DEV_SOURCE_DIR -const OBSERVED_DEV_SOURCE_COMMIT = '0b26101eb247adcc44f70d39451f3e82c3225d47' -const OBSERVED_DEV_DB_SOURCE_SHA256 = - '1948ad306fe56b368f120edbc70ddbac6cb9b981d9fd218e7ded2575ed3b2beb' +const certificationInputs = JSON.parse( + readFileSync(new URL('../../tests/contracts/certification-inputs.json', import.meta.url), 'utf8') +) as { + producers: Record }> +} +const observedProducer = certificationInputs.producers.observedDev +const latestProducer = certificationInputs.producers.latestDev +const OBSERVED_DEV_SOURCE_DIR = process.env[observedProducer.env] +const OBSERVED_DEV_SOURCE_COMMIT = observedProducer.commit +const OBSERVED_DEV_DB_SOURCE_SHA256 = observedProducer.sourceHashes['control-api/src/db.ts'] if ( process.env.CONTROL_API_REAL_PG_REQUIRED === '1' && - (!OBSERVED_DEV_SOURCE_DIR || !process.env.TASK106_R61_B1_LATEST_DEV_SOURCE_DIR) + (!OBSERVED_DEV_SOURCE_DIR || !process.env[latestProducer.env]) ) { throw new Error( 'R61-B1 real PostgreSQL lane requires both pinned DEV producer source directories' diff --git a/scripts/ci/certification-inputs.mjs b/scripts/ci/certification-inputs.mjs new file mode 100644 index 0000000000..37b226c877 --- /dev/null +++ b/scripts/ci/certification-inputs.mjs @@ -0,0 +1,395 @@ +#!/usr/bin/env node +// Shared, read-only certification input checks. Building producers is a +// separate explicit command; checking inputs never installs or touches a cluster. +import { createHash } from 'node:crypto' +import { spawnSync } from 'node:child_process' +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + realpathSync, + renameSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { verifySuiteManifest } from './password-recovery-suite-contract.mjs' + +const DEFAULT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const CONTRACT_PATH = 'tests/contracts/certification-inputs.json' +const RECEIPT_PATH = 'control-api/dist/.certification-producer-receipt.json' +const HEX40 = /^[a-f0-9]{40}$/ +const HEX64 = /^[a-f0-9]{64}$/ +const ENV_NAME = /^[A-Z][A-Z0-9_]*$/ + +function inside(root, candidate) { + const rel = relative(root, candidate) + return rel === '' || (!rel.startsWith('..' + sep) && rel !== '..' && !isAbsolute(rel)) +} + +function validRelative(value) { + return ( + typeof value === 'string' && + value.length > 0 && + !isAbsolute(value) && + !value.includes('\\') && + value.split('/').every(part => part && part !== '.' && part !== '..' && /^[A-Za-z0-9._-]+$/.test(part)) + ) +} + +function localPath(root, name, expected = 'file') { + if (!validRelative(name)) throw new Error('invalid relative path: ' + String(name)) + const path = resolve(root, name) + if (!inside(resolve(root), path)) throw new Error('path escapes root: ' + name) + const stat = lstatSync(path) + if (stat.isSymbolicLink()) throw new Error('symbolic link is not accepted: ' + name) + if (expected === 'file' && !stat.isFile()) throw new Error('expected file: ' + name) + if (expected === 'directory' && !stat.isDirectory()) throw new Error('expected directory: ' + name) + if (!inside(realpathSync(root), realpathSync(path))) throw new Error('resolved path escapes root: ' + name) + return path +} + +function uniquePaths(values, label, allowEmpty = false) { + if (!Array.isArray(values) || (!allowEmpty && values.length === 0)) throw new Error(label + ' must be a nonempty array') + if (values.some(value => !validRelative(value)) || new Set(values).size !== values.length) + throw new Error(label + ' contains invalid or duplicate paths') + return values +} + +export function loadCertificationContract(root) { + const contract = JSON.parse(readFileSync(localPath(root, CONTRACT_PATH), 'utf8')) + const expected = ['version', 'suiteManifest', 'preGateScript', 't1Packages', 't1TestRoots', 'localT1ExtraPackages', 'consumerDependencies', 'consumerArtifacts', 'nativeChecks', 'producers'] + if (!contract || Object.keys(contract).sort().join() !== expected.sort().join() || contract.version !== 1) + throw new Error('unknown certification input contract schema') + if (!validRelative(contract.suiteManifest) || !validRelative(contract.preGateScript)) + throw new Error('invalid certification contract path') + uniquePaths(contract.t1Packages, 't1Packages') + uniquePaths(contract.t1TestRoots, 't1TestRoots') + if (contract.t1TestRoots.some(pkg => !contract.t1Packages.includes(pkg))) throw new Error('T1 test roots must be declared packages') + uniquePaths(contract.localT1ExtraPackages, 'localT1ExtraPackages') + for (const key of ['consumerDependencies', 'consumerArtifacts']) { + const map = contract[key] + if (!map || typeof map !== 'object' || Array.isArray(map)) throw new Error(key + ' must be an object') + for (const [consumer, paths] of Object.entries(map)) { + if (!validRelative(consumer)) throw new Error('invalid consumer: ' + consumer) + uniquePaths(paths, key + '.' + consumer) + } + } + if (!contract.nativeChecks || typeof contract.nativeChecks !== 'object' || Array.isArray(contract.nativeChecks)) + throw new Error('nativeChecks must be an object') + for (const [consumer, checks] of Object.entries(contract.nativeChecks)) { + if (!validRelative(consumer) || !Array.isArray(checks) || !checks.length) throw new Error('invalid native check declaration') + for (const check of checks) { + if (!check || Object.keys(check).sort().join() !== 'kind,package' || !validRelative(check.package) || !['sqlite', 'canvas'].includes(check.kind)) + throw new Error('invalid native check: ' + consumer) + } + } + if (!contract.producers || Object.keys(contract.producers).sort().join() !== 'latestDev,observedDev') + throw new Error('expected the two historical producer declarations') + const envs = new Set() + for (const [id, producer] of Object.entries(contract.producers)) { + const keys = ['env', 'commit', 'sourceHashes', 'buildPackages', 'artifactDirectories', 'lockfiles', 'runner'] + if (!producer || Object.keys(producer).sort().join() !== keys.sort().join()) + throw new Error('invalid producer schema: ' + id) + if (!ENV_NAME.test(producer.env) || envs.has(producer.env) || !HEX40.test(producer.commit)) + throw new Error('invalid producer identity: ' + id) + envs.add(producer.env) + if (!producer.sourceHashes || typeof producer.sourceHashes !== 'object' || Array.isArray(producer.sourceHashes) || !Object.keys(producer.sourceHashes).length) + throw new Error('missing producer source hashes: ' + id) + for (const [name, hash] of Object.entries(producer.sourceHashes)) + if (!validRelative(name) || !HEX64.test(hash)) throw new Error('invalid producer source hash: ' + id) + uniquePaths(producer.buildPackages, id + '.buildPackages') + uniquePaths(producer.artifactDirectories, id + '.artifactDirectories') + uniquePaths(producer.lockfiles, id + '.lockfiles') + if (!validRelative(producer.runner)) throw new Error('invalid producer runner: ' + id) + } + return contract +} + +function run(command, args, options = {}) { + return spawnSync(command, args, { + encoding: 'utf8', + timeout: 30000, + maxBuffer: 8 * 1024 * 1024, + ...options, + }) +} + +function git(root, args) { + const result = run('git', ['-C', root, ...args]) + if (result.error || result.status !== 0) throw new Error('Git inspection failed') + return result.stdout.trim() +} + +function sha256(path) { + return createHash('sha256').update(readFileSync(path)).digest('hex') +} + +function treeDigest(root, rel) { + const directory = localPath(root, rel, 'directory') + const files = [] + function walk(dir, prefix) { + for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => Buffer.from(a.name).compare(Buffer.from(b.name)))) { + const child = join(dir, entry.name) + const name = prefix ? prefix + '/' + entry.name : entry.name + if (rel + '/' + name === RECEIPT_PATH) continue + if (entry.isSymbolicLink()) throw new Error('symbolic link in build output: ' + rel + '/' + name) + if (entry.isDirectory()) walk(child, name) + else if (entry.isFile()) files.push([name, sha256(child)]) + else throw new Error('non-file in build output: ' + rel + '/' + name) + } + } + walk(directory, '') + if (!files.length) throw new Error('empty build output: ' + rel) + const hash = createHash('sha256') + for (const [name, digest] of files) hash.update(name).update('\0').update(digest).update('\n') + return hash.digest('hex') +} + +function producerRoot(env, producer, id) { + const value = env[producer.env] + if (!value || !isAbsolute(value)) throw new Error(id + ': missing absolute ' + producer.env) + const stat = lstatSync(value) + if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(id + ': producer path is not a directory') + return realpathSync(value) +} + +function producerState(root, id, producer) { + if (git(root, ['rev-parse', 'HEAD']) !== producer.commit) throw new Error(id + ': producer commit mismatch') + if (git(root, ['status', '--porcelain=v1'])) throw new Error(id + ': producer checkout is dirty') + const sources = {} + for (const [name, expected] of Object.entries(producer.sourceHashes)) { + const actual = sha256(localPath(root, name)) + if (actual !== expected) throw new Error(id + ': source hash mismatch: ' + name) + sources[name] = actual + } + return sources +} + +function receiptState(root, id, producer, sources) { + const locks = {} + for (const name of producer.lockfiles) locks[name] = sha256(localPath(root, name)) + const artifacts = {} + for (const name of producer.artifactDirectories) artifacts[name] = treeDigest(root, name) + localPath(root, producer.runner) + return { version: 1, producer: id, commit: producer.commit, node: process.version, sources, locks, artifacts } +} + +export function buildProducer(root, id, env = process.env) { + const contract = loadCertificationContract(root) + const producer = contract.producers[id] + if (!producer) throw new Error('unknown producer: ' + id) + const input = producerRoot(env, producer, id) + const sources = producerState(input, id, producer) + const receipt = resolve(input, RECEIPT_PATH) + if (existsSync(receipt)) { + if (lstatSync(receipt).isSymbolicLink() || !inside(realpathSync(input), realpathSync(receipt))) + throw new Error(id + ': unsafe existing receipt') + unlinkSync(receipt) + } + for (const pkg of producer.buildPackages) { + const cwd = localPath(input, pkg, 'directory') + const result = run('npm', ['run', 'build'], { cwd, env, stdio: 'inherit', timeout: 600000 }) + if (result.error || result.status !== 0) throw new Error(id + ': build failed: ' + pkg) + } + // Recheck tracked inputs after the build before binding the ignored outputs. + const after = producerState(input, id, producer) + if (JSON.stringify(sources) !== JSON.stringify(after)) throw new Error(id + ': producer sources changed during build') + const data = receiptState(input, id, producer, after) + const directory = dirname(receipt) + localPath(input, relative(input, directory), 'directory') + const temporary = receipt + '.' + process.pid + '.tmp' + try { + writeFileSync(temporary, JSON.stringify(data, null, 2) + '\n', { flag: 'wx', mode: 0o600 }) + renameSync(temporary, receipt) + } finally { + if (existsSync(temporary)) unlinkSync(temporary) + } + return data +} + +function verifyProducer(root, id, producer, env) { + const input = producerRoot(env, producer, id) + const sources = producerState(input, id, producer) + const receipt = JSON.parse(readFileSync(localPath(input, RECEIPT_PATH), 'utf8')) + const expected = receiptState(input, id, producer, sources) + if (JSON.stringify(receipt) !== JSON.stringify(expected)) throw new Error(id + ': producer build receipt does not match current inputs and outputs') +} + +export function listAffectedPackages(root, base, head) { + if (!base || !head || base.startsWith('-') || head.startsWith('-') || /[\r\n\0]/.test(base + head)) + throw new Error('invalid Git range') + const result = run('git', ['-C', root, 'diff', '--name-only', '-z', '--diff-filter=ACMRTUXB', base + '...' + head]) + if (result.error || result.status !== 0) throw new Error('cannot inspect affected packages') + const found = new Set() + for (const name of result.stdout.split('\0').filter(Boolean)) { + if (name.includes('\n') || name.includes('\r')) throw new Error('unsupported newline in changed path') + let current = resolve(root, name) + if (!inside(resolve(root), current)) throw new Error('changed path escapes repository') + if (!existsSync(current) || !lstatSync(current).isDirectory()) current = dirname(current) + while (current !== resolve(root) && inside(resolve(root), current)) { + if (existsSync(join(current, 'package.json'))) { + const packageName = relative(resolve(root), current).split(sep).join('/') + if (!validRelative(packageName)) throw new Error('invalid affected package path') + found.add(packageName) + break + } + current = dirname(current) + } + } + return [...found] +} + +export function listPreGatePackages(root, contract) { + const text = readFileSync(localPath(root, contract.preGateScript), 'utf8') + const calls = text.split(/\r?\n/).filter(line => + !/^\s*#/.test(line) && /\brun_if_changed\b/.test(line) && !/^\s*run_if_changed\(\)\s*\{/.test(line) + ) + if (!calls.length) throw new Error('no pre-gate package declarations found') + const packages = [] + for (const line of calls) { + const match = /^\s*run_if_changed\s+([A-Za-z0-9._/-]+)\s+"(npm test(?: && npm run build)?)"\s*$/.exec(line) + if (!match || !validRelative(match[1])) throw new Error('unrecognized pre-gate package declaration') + packages.push(match[1]) + } + if (new Set(packages).size !== packages.length) throw new Error('duplicate pre-gate package declaration') + return packages +} + +function addDependencies(packages, contract) { + // These are test-consumer edges, not transitive runtime edges: importing a + // sibling's source does not execute that sibling's own test suite. + for (const consumer of [...packages]) + for (const dependency of contract.consumerDependencies[consumer] || []) packages.add(dependency) +} + +function npmInstalled(root, pkg, env) { + const cwd = localPath(root, pkg, 'directory') + localPath(root, pkg + '/package.json') + const result = run('npm', ['ls', '--depth=0', '--json'], { cwd, env }) + if (result.error || result.status !== 0) throw new Error('direct dependencies are incomplete: ' + pkg) +} + +export function inspectCertificationInputs(options) { + const { root, scope, service, base, head, env = process.env } = options + const contract = loadCertificationContract(root) + if (!['t2', 't0', 't1', 'ci-t1', 'matrix'].includes(scope)) throw new Error('unknown certification scope') + const errors = [] + const packages = new Set() + const testRoots = new Set() + if (scope === 'matrix') { + if (!validRelative(service)) throw new Error('invalid matrix service') + packages.add(service) + testRoots.add(service) + addDependencies(packages, contract) + } else { + if (scope !== 't0') { + for (const pkg of contract.t1Packages) packages.add(pkg) + for (const pkg of contract.t1TestRoots) testRoots.add(pkg) + if (scope !== 'ci-t1') for (const pkg of contract.localT1ExtraPackages) packages.add(pkg) + } + if (scope === 't2' || scope === 't0') { + for (const pkg of listPreGatePackages(root, contract)) packages.add(pkg) + for (const pkg of listAffectedPackages(root, base, head)) { + if (!/^tests\/e2e(?:\/|$)/.test(pkg) && !/\/tests\/e2e(?:\/|$)/.test(pkg)) packages.add(pkg) + } + for (const pkg of packages) testRoots.add(pkg) + addDependencies(packages, contract) + } + try { + const manifest = JSON.parse(readFileSync(localPath(root, contract.suiteManifest), 'utf8')) + verifySuiteManifest(root, manifest) + } catch (error) { + errors.push('suite census/manifest: ' + error.message) + } + if (scope !== 't0') { + for (const [id, producer] of Object.entries(contract.producers)) { + try { + verifyProducer(root, id, producer, env) + const input = producerRoot(env, producer, id) + for (const pkg of producer.buildPackages) npmInstalled(input, pkg, env) + } catch (error) { errors.push(id + ': ' + error.message) } + } + } + } + for (const pkg of packages) { + try { npmInstalled(root, pkg, env) } catch (error) { errors.push(error.message) } + } + for (const pkg of testRoots) { + for (const artifact of contract.consumerArtifacts[pkg] || []) { + try { localPath(root, artifact) } catch { errors.push('required build artifact is missing: ' + artifact) } + } + } + const nativeCommands = { + sqlite: "const DB=require('better-sqlite3');const db=new DB(':memory:');db.close()", + canvas: "require('@napi-rs/canvas')", + } + const seenNative = new Set() + for (const consumer of testRoots) { + for (const check of contract.nativeChecks[consumer] || []) { + const identity = check.package + ':' + check.kind + if (seenNative.has(identity)) continue + seenNative.add(identity) + try { + const cwd = localPath(root, check.package, 'directory') + const result = run(process.execPath, ['-e', nativeCommands[check.kind]], { cwd, env }) + if (result.error || result.status !== 0) errors.push('native dependency is not loadable: ' + identity) + } catch (error) { errors.push('native dependency is not loadable: ' + identity) } + } + } + if (packages.has('desktop-app')) { + try { + const result = run('npm', ['run', 'verify:electron'], { cwd: localPath(root, 'desktop-app', 'directory'), env }) + if (result.error || result.status !== 0) errors.push('Desktop Electron runtime verification failed') + } catch { errors.push('Desktop Electron runtime verification failed') } + } + return { errors, packages: [...packages] } +} + +function parseOptions(args) { + const allowed = new Set(['root', 'scope', 'service', 'base', 'head', 'producer']) + const options = {} + for (let i = 0; i < args.length; i += 2) { + const name = args[i] + if (!name || !name.startsWith('--') || !allowed.has(name.slice(2)) || options[name.slice(2)] !== undefined || args[i + 1] === undefined) + throw new Error('invalid certification input arguments') + options[name.slice(2)] = args[i + 1] + } + return options +} + +function main() { + const [command, ...args] = process.argv.slice(2) + const options = parseOptions(args) + const root = resolve(options.root || DEFAULT_ROOT) + if (command === 'pins') { + const contract = loadCertificationContract(root) + console.log('observed_commit=' + contract.producers.observedDev.commit) + console.log('latest_commit=' + contract.producers.latestDev.commit) + } else if (command === 'build-producer') { + buildProducer(root, options.producer) + console.log('PRODUCER_BUILD_RECEIPT=PASS') + } else if (command === 'list-affected') { + for (const pkg of listAffectedPackages(root, options.base, options.head)) console.log(pkg) + } else if (command === 'check') { + const result = inspectCertificationInputs({ root, ...options }) + if (result.errors.length) { + for (const error of result.errors) console.error('CERTIFICATION_INPUT_ERROR: ' + error) + process.exitCode = 1 + } else { + console.log('CERTIFICATION_INPUTS=PASS packages=' + result.packages.length + ' scope=' + options.scope) + } + } else { + throw new Error('usage: certification-inputs.mjs pins | build-producer | list-affected | check [options]') + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { main() } catch (error) { + console.error('CERTIFICATION_INPUT_ERROR: ' + error.message) + process.exitCode = 1 + } +} diff --git a/scripts/e2e/minikube-real-postgres.sh b/scripts/e2e/minikube-real-postgres.sh index 89c8a53a40..037f00fcef 100755 --- a/scripts/e2e/minikube-real-postgres.sh +++ b/scripts/e2e/minikube-real-postgres.sh @@ -223,6 +223,10 @@ require_t1_commands() { T1_NEXT_COMMAND='repair the reported local prerequisite, then re-run the standalone Real PostgreSQL lane' die_t1 "$REAL_PG_PREFLIGHT_ERROR_CODE" "$REAL_PG_PREFLIGHT_ERROR_MESSAGE" fi + if ! node "$SCRIPT_DIR/../ci/certification-inputs.mjs" check --root "$PROJECT_DIR" --scope t1; then + T1_NEXT_COMMAND='prepare the reported local inputs explicitly, then re-run the standalone Real PostgreSQL lane' + die_t1 LOCAL_DEPENDENCY_MISSING 'certification input preflight failed before T1' + fi } secret_field() { @@ -565,6 +569,15 @@ PY fi read -r total_suites passed_suites failed_suites passed_tests failed_tests \ pending_files pending_tests total_tests success expected_files reported_files <<< "$stats" + # Durable evidence: the structure and results of this reporter, without its + # free-text messages. Written before the verdict so a failing lane keeps it + # too; a lane whose evidence cannot be kept does not pass. + if [ -z "${T2_EVIDENCE_DIR:-}" ] || ! python3 "$SCRIPT_DIR/t1-reporter-projection.py" \ + "$json_file" "$PROJECT_DIR/$package" "$package" "$lane" \ + "$T2_EVIDENCE_DIR/logs/t1-$(printf '%s' "$package-$lane" | tr / _).results.json"; then + T1_NEXT_COMMAND='restore a writable evidence directory, then re-run T1' + die_t1 REAL_PG_REPORT_INCOMPLETE "T1 reporter evidence could not be persisted for $package ($lane)" + fi T1_EXPECTED_FILES=$((T1_EXPECTED_FILES + expected_files)) T1_REPORTED_FILES=$((T1_REPORTED_FILES + reported_files)) T1_TOTAL_TESTS=$((T1_TOTAL_TESTS + total_tests)) diff --git a/scripts/e2e/t1-reporter-projection.py b/scripts/e2e/t1-reporter-projection.py new file mode 100644 index 0000000000..470b9e04f9 --- /dev/null +++ b/scripts/e2e/t1-reporter-projection.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Write the durable T1 evidence projection of one Vitest JSON reporter. + +The projection keeps the structure and the results of the run: every reported +file with its status and duration, every test with its titles, status and +duration, and the reporter counters. It drops every free-text field the +reporter carries (file-level `message`, assertion `failureMessages`), keeping +only how many there were, because those texts can contain values from the +system under test. Test and suite titles are authored in the repository and +are kept. + +Usage: t1-reporter-projection.py + +Exits non-zero, without writing a partial file, when the reporter cannot be +read or the projection cannot be written: T1 must fail rather than lose the +evidence. +""" + +import json +import os +import sys +import tempfile +from pathlib import Path + +COUNTERS = ( + "success", + "numTotalTestSuites", + "numPassedTestSuites", + "numFailedTestSuites", + "numPendingTestSuites", + "numTotalTests", + "numPassedTests", + "numFailedTests", + "numPendingTests", +) + + +def duration_ms(item): + start, end = item.get("startTime"), item.get("endTime") + if isinstance(start, (int, float)) and isinstance(end, (int, float)): + return int(end - start) + value = item.get("duration") + return int(value) if isinstance(value, (int, float)) else None + + +def relative_name(name, package_root): + path = Path(name) + if not path.is_absolute(): + return path.as_posix() + try: + return path.resolve().relative_to(package_root).as_posix() + except ValueError: + return path.name + + +def project(result, package_root, package, lane): + files = [] + for item in result.get("testResults") or []: + if not isinstance(item, dict) or not isinstance(item.get("name"), str): + raise ValueError("reporter contains a test result without a file name") + tests = [] + for assertion in item.get("assertionResults") or []: + tests.append( + { + "ancestorTitles": [str(t) for t in assertion.get("ancestorTitles") or []], + "title": str(assertion.get("title") or ""), + "status": assertion.get("status"), + "durationMs": assertion.get("duration") + if isinstance(assertion.get("duration"), (int, float)) + else None, + "failureMessageCount": len(assertion.get("failureMessages") or []), + } + ) + files.append( + { + "file": relative_name(item["name"], package_root), + "status": item.get("status"), + "durationMs": duration_ms(item), + "fileMessageOmitted": bool(item.get("message")), + "tests": tests, + } + ) + if not files: + raise ValueError("reporter testResults is missing or empty") + return { + "schema": "t1-reporter-projection/v1", + "package": package, + "lane": lane, + "counters": {key: result.get(key) for key in COUNTERS}, + "omitted": "file messages and failure messages (counts kept)", + "files": files, + } + + +def main(argv): + if len(argv) != 6: + print( + "usage: t1-reporter-projection.py ", + file=sys.stderr, + ) + return 2 + reporter, package_root, package, lane, output = argv[1:] + try: + result = json.loads(Path(reporter).read_text()) + projection = project(result, Path(package_root).resolve(), package, lane) + except (OSError, ValueError) as error: + print(f"T1 reporter projection failed: {error}", file=sys.stderr) + return 1 + out = Path(output) + try: + out.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=out.parent, prefix=".t1-projection-") + with os.fdopen(fd, "w") as handle: + json.dump(projection, handle, indent=2, sort_keys=True) + handle.write("\n") + os.chmod(tmp, 0o600) + os.replace(tmp, out) + except OSError as error: + print(f"T1 reporter projection could not be written: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/scripts/minikube/t0.sh b/scripts/minikube/t0.sh index c22c3cc5da..ad746e781c 100755 --- a/scripts/minikube/t0.sh +++ b/scripts/minikube/t0.sh @@ -3,13 +3,25 @@ set -euo pipefail set +x -PROJECT_DIR="${T0_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)}" +SCRIPT_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)" +PROJECT_DIR="${T0_PROJECT_DIR:-$SCRIPT_ROOT}" ORIGIN_DEV="${T0_ORIGIN_DEV:-origin/dev}" HEAD="${T0_HEAD:-HEAD}" shell_files=() package_dirs=() -seen_packages="|" +package_list="$(node "$SCRIPT_ROOT/scripts/ci/certification-inputs.mjs" list-affected --root "$PROJECT_DIR" --base "$ORIGIN_DEV" --head "$HEAD")" +while IFS= read -r package; do + if [ -n "$package" ]; then package_dirs+=("$package"); fi +done <<< "$package_list" +if [ "${1:-}" = '--list-packages' ] && [ "$#" -eq 1 ]; then + if [ -n "$package_list" ]; then printf '%s\n' "$package_list"; fi + exit 0 +fi +if [ "$#" -ne 0 ]; then + printf 'usage: t0.sh [--list-packages]\n' >&2 + exit 2 +fi while IFS= read -r path; do [ -n "$path" ] || continue absolute_path="$PROJECT_DIR/$path" @@ -25,26 +37,6 @@ while IFS= read -r path; do ;; esac - # Resolve the nearest package.json by walking upward from the changed file. - # This covers nested packages such as packages/workflow-sdk instead of - # incorrectly mapping them to packages/package.json. - candidate="$absolute_path" - if [ ! -d "$candidate" ]; then - candidate="$(dirname -- "$candidate")" - fi - while [ "$candidate" != "$PROJECT_DIR" ] && [[ "$candidate" == "$PROJECT_DIR"/* ]]; do - if [ -f "$candidate/package.json" ]; then - package="${candidate#"$PROJECT_DIR/"}" - if [[ "$seen_packages" != *"|$package|"* ]]; then - seen_packages="${seen_packages}${package}|" - package_dirs+=("$package") - fi - break - fi - parent="$(dirname -- "$candidate")" - [ "$parent" != "$candidate" ] || break - candidate="$parent" - done done < <(git -C "$PROJECT_DIR" diff --name-only --diff-filter=ACMRTUXB "$ORIGIN_DEV...$HEAD") if [ "${#shell_files[@]}" -gt 0 ]; then @@ -65,7 +57,7 @@ has_npm_script() { "$package_dir/package.json" "$script_name" } -if [ "${#package_dirs[@]}" -gt 0 ]; then +if [ -n "$package_list" ]; then for package in "${package_dirs[@]}"; do # Live Vitest/Playwright E2E is the T2 opt-in lane. T0 is unit/build only. case "$package" in diff --git a/scripts/minikube/t2.sh b/scripts/minikube/t2.sh index 4b55eb2e3e..657018155a 100755 --- a/scripts/minikube/t2.sh +++ b/scripts/minikube/t2.sh @@ -81,6 +81,24 @@ run_t1_local_preflight() { "Node/package/Docker prerequisites passed in ${REAL_PG_PREFLIGHT_DURATION_SECONDS}s; T1 workers=1" } +run_certification_inputs_preflight() { + local scope + if [ "$T2_RUN_T1" = true ]; then + scope=t2 + elif [ "$T2_RUN_T0" = true ]; then + scope=t0 + else + return 0 + fi + if ! node "$SCRIPT_DIR/../ci/certification-inputs.mjs" check \ + --root "$T2_PROJECT_DIR" --scope "$scope" --base "$T2_ORIGIN_DEV" --head "$T2_HEAD"; then + T2_NEXT_COMMAND='prepare the reported local inputs explicitly, then re-run the canonical gate' + t2_evidence_write local-preflight FAIL 'certification inputs are incomplete' + t2_fail LOCAL_DEPENDENCY_MISSING 'certification input preflight failed before T0' + return 1 + fi +} + run_t0() { local phase_started_seconds="$SECONDS" if [ "$T2_RUN_T0" != true ]; then @@ -551,6 +569,7 @@ main() { t2_evidence_init run_t1_local_preflight + run_certification_inputs_preflight run_t0 if [ "$T2_RUN_T0" = true ] && ! t2_lane_completed "$T2_T0_STATUS"; then T2_NEXT_COMMAND='re-run make minikube-t2 with T2_RUN_T0=true so the static lane is executed' diff --git a/scripts/tests/certification-inputs.test.mjs b/scripts/tests/certification-inputs.test.mjs new file mode 100644 index 0000000000..e644ef38e6 --- /dev/null +++ b/scripts/tests/certification-inputs.test.mjs @@ -0,0 +1,319 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import test from 'node:test' + +const sourceRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const cli = join(sourceRoot, 'scripts/ci/certification-inputs.mjs') +const sha256 = text => createHash('sha256').update(text).digest('hex') +const write = (path, value) => { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, value) } + +function git(dir, ...args) { + const r = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' }) + assert.equal(r.status, 0, r.stderr) + return r.stdout.trim() +} + +function initGit(dir) { + mkdirSync(dir, { recursive: true }) + git(dir, 'init', '-q') + git(dir, 'config', 'user.name', 'Synthetic Fixture') + git(dir, 'config', 'user.email', 'fixture@example.test') +} + +function producerFixture(dir, label) { + initGit(dir) + write(join(dir, '.gitignore'), 'node_modules/\ndist/\n') + write(join(dir, 'control-api/src/db.ts'), `// ${label} db\n`) + write(join(dir, 'control-api/src/migrate.ts'), `// ${label} migration\n`) + for (const pkg of ['control-api', 'packages/workflow-runtime-core']) { + write(join(dir, pkg, 'package.json'), JSON.stringify({ name: pkg.replaceAll('/', '-'), version: '1.0.0' })) + write(join(dir, pkg, 'package-lock.json'), '{}\n') + } + git(dir, 'add', '.') + git(dir, 'commit', '-qm', 'fixture source') + return { + commit: git(dir, 'rev-parse', 'HEAD'), + sourceHashes: { + 'control-api/src/db.ts': sha256(readFileSync(join(dir, 'control-api/src/db.ts'))), + 'control-api/src/migrate.ts': sha256(readFileSync(join(dir, 'control-api/src/migrate.ts'))), + }, + buildPackages: ['packages/workflow-runtime-core', 'control-api'], + artifactDirectories: ['packages/workflow-runtime-core/dist', 'control-api/dist'], + lockfiles: ['packages/workflow-runtime-core/package-lock.json', 'control-api/package-lock.json'], + runner: 'control-api/dist/migrate.js', + } +} + +function fixture(t) { + const scratch = process.env.CODEX_SCRATCH_ROOT + assert.ok(scratch, 'CODEX_SCRATCH_ROOT is required') + mkdirSync(scratch, { recursive: true }) + const dir = mkdtempSync(join(scratch, 'certification-inputs-')) + t.after(() => rmSync(dir, { recursive: true, force: true })) + const root = join(dir, 'project') + const observed = join(dir, 'observed') + const latest = join(dir, 'latest') + const bin = join(dir, 'bin') + const audit = join(dir, 'audit.log') + mkdirSync(bin) + const npm = join(bin, 'npm') + write(npm, [ + '#!/bin/sh', + 'printf "%s %s\n" "$PWD" "$1" >> "$FIXTURE_AUDIT"', + 'if [ "$1" = ls ]; then [ ! -e .missing-dependency ] || exit 9; printf "{}\n"; exit 0; fi', + 'if [ "$1" = run ] && [ "$2" = build ]; then', + ' mkdir -p dist', + ' case "$PWD" in */control-api) printf built > dist/migrate.js ;; */workflow-runtime-core) printf built > dist/index.js ;; esac', + ' exit 0', + 'fi', + 'exit 90', + ].join('\n') + '\n') + chmodSync(npm, 0o755) + for (const binary of ['docker', 'kubectl']) { + const path = join(bin, binary) + write(path, '#!/bin/sh\nprintf mutation >> "$FIXTURE_MUTATION"\nexit 99\n') + chmodSync(path, 0o755) + } + const producerObserved = producerFixture(observed, 'observed') + const producerLatest = producerFixture(latest, 'latest') + initGit(root) + write(join(root, '.gitignore'), 'node_modules/\ndist/\n') + for (const pkg of ['mcp-host', 'codex-llm-proxy', 'grok-llm-proxy', 'control-api', 'gfs-controller', 'external-rest-api', 'packages/workflow-runtime-core']) { + write(join(root, pkg, 'package.json'), JSON.stringify({ name: pkg.replaceAll('/', '-'), version: '1.0.0' })) + write(join(root, pkg, 'package-lock.json'), '{}\n') + } + write(join(root, 'mcp-host/node_modules/better-sqlite3/index.js'), 'module.exports = class { close() {} }\n') + write(join(root, 'mcp-host/node_modules/@napi-rs/canvas/index.js'), 'module.exports = {}\n') + write(join(root, 'external-rest-api/dist/app.js'), '// synthetic build\n') + write(join(root, 'packages/workflow-runtime-core/dist/index.js'), '// synthetic build\n') + write(join(root, 'scripts/minikube/pre-gate-sync.sh'), 'run_if_changed mcp-host "npm test"\n') + const suites = { + version: 1, + publicPostgres: ['control-api/test/public.realPostgres.test.ts'], + privatePostgres: { local: 'control-api/test/local.realPostgres.test.ts', configured: 'control-api/test/configured.realPostgres.test.ts' }, + composedContract: ['control-api/test/contract.test.ts'], + consumer: ['external-rest-api/test/consumer.test.ts'], + } + for (const file of [...suites.publicPostgres, ...Object.values(suites.privatePostgres), ...suites.composedContract, ...suites.consumer]) write(join(root, file), '// synthetic suite\n') + write(join(root, 'tests/contracts/password-recovery-suites.json'), JSON.stringify(suites)) + const contract = { + version: 1, + suiteManifest: 'tests/contracts/password-recovery-suites.json', + preGateScript: 'scripts/minikube/pre-gate-sync.sh', + t1Packages: ['control-api', 'external-rest-api', 'packages/workflow-runtime-core'], + t1TestRoots: ['control-api'], + localT1ExtraPackages: ['gfs-controller'], + consumerDependencies: { 'mcp-host': ['codex-llm-proxy', 'grok-llm-proxy'] }, + consumerArtifacts: { 'control-api': ['external-rest-api/dist/app.js'] }, + nativeChecks: { 'mcp-host': [{ package: 'mcp-host', kind: 'sqlite' }, { package: 'mcp-host', kind: 'canvas' }] }, + producers: { + observedDev: { env: 'TASK106_R61_B1_DEV_SOURCE_DIR', ...producerObserved }, + latestDev: { env: 'TASK106_R61_B1_LATEST_DEV_SOURCE_DIR', ...producerLatest }, + }, + } + write(join(root, 'tests/contracts/certification-inputs.json'), JSON.stringify(contract)) + write(join(root, 'mcp-host/src/example.ts'), '// baseline\n') + write(join(root, 'tests/e2e/fixtures/package.json'), JSON.stringify({ name: 'synthetic-e2e', version: '1.0.0' })) + write(join(root, 'tests/e2e/fixtures/example.ts'), '// baseline\n') + git(root, 'add', '.') + git(root, 'commit', '-qm', 'fixture baseline') + const base = git(root, 'rev-parse', 'HEAD') + write(join(root, 'mcp-host/src/example.ts'), '// changed\n') + write(join(root, 'tests/e2e/fixtures/example.ts'), '// changed\n') + git(root, 'add', 'mcp-host/src/example.ts', 'tests/e2e/fixtures/example.ts') + git(root, 'commit', '-qm', 'fixture change') + const head = git(root, 'rev-parse', 'HEAD') + const env = { ...process.env, PATH: bin + ':' + process.env.PATH, FIXTURE_AUDIT: audit, FIXTURE_MUTATION: join(dir, 'mutation'), TASK106_R61_B1_DEV_SOURCE_DIR: observed, TASK106_R61_B1_LATEST_DEV_SOURCE_DIR: latest } + const invoke = (...args) => spawnSync(process.execPath, [cli, ...args, '--root', root], { encoding: 'utf8', env, timeout: 30000 }) + return { root, observed, latest, audit, env, base, head, invoke, mutation: join(dir, 'mutation') } +} + +function ready(f) { + for (const name of ['observedDev', 'latestDev']) { + const result = f.invoke('build-producer', '--producer', name) + assert.equal(result.status, 0, result.stderr) + } +} + +function check(f, ...args) { + return f.invoke('check', '--scope', 't2', '--base', f.base, '--head', f.head, ...args) +} + +test('complete inputs pass and preflight never calls a cluster mutation', t => { + const f = fixture(t); ready(f) + const result = check(f) + assert.equal(result.status, 0, result.stderr) + assert.match(result.stdout, /CERTIFICATION_INPUTS=PASS/) + assert.equal(existsSync(f.mutation), false) + assert.equal(git(f.root, 'status', '--porcelain'), '') +}) + +test('missing cross-service dependency is reported before any cluster mutation', t => { + const f = fixture(t); ready(f) + write(join(f.root, 'grok-llm-proxy/.missing-dependency'), '') + const result = check(f) + assert.notEqual(result.status, 0) + assert.match(result.stderr, /grok-llm-proxy/) + assert.equal(existsSync(f.mutation), false) +}) + +test('missing producer directory is reported before any cluster mutation', t => { + const f = fixture(t); ready(f) + delete f.env.TASK106_R61_B1_LATEST_DEV_SOURCE_DIR + const result = check(f) + assert.notEqual(result.status, 0) + assert.match(result.stderr, /TASK106_R61_B1_LATEST_DEV_SOURCE_DIR/) + assert.equal(existsSync(f.mutation), false) +}) + +test('dirty producer and changed build artifacts invalidate provenance', t => { + const f = fixture(t); ready(f) + write(join(f.observed, 'control-api/src/db.ts'), '// changed after pin\n') + let result = check(f) + assert.notEqual(result.status, 0) + assert.match(result.stderr, /observedDev/) + git(f.observed, 'checkout', '--', 'control-api/src/db.ts') + write(join(f.latest, 'control-api/dist/migrate.js'), '// changed after build\n') + result = check(f) + assert.notEqual(result.status, 0) + assert.match(result.stderr, /latestDev/) + assert.equal(existsSync(f.mutation), false) +}) + +test('an unregistered real PostgreSQL suite fails before any cluster mutation', t => { + const f = fixture(t); ready(f) + write(join(f.root, 'control-api/test/new.realPostgres.test.ts'), '// newly discovered suite\n') + const result = check(f) + assert.notEqual(result.status, 0) + assert.match(result.stderr, /census|manifest/i) + assert.equal(existsSync(f.mutation), false) +}) + +test('matrix scope checks cross-service dependencies without requiring T1 producers', t => { + const f = fixture(t) + delete f.env.TASK106_R61_B1_DEV_SOURCE_DIR + delete f.env.TASK106_R61_B1_LATEST_DEV_SOURCE_DIR + let result = f.invoke('check', '--scope', 'matrix', '--service', 'mcp-host') + assert.equal(result.status, 0, result.stderr) + write(join(f.root, 'codex-llm-proxy/.missing-dependency'), '') + result = f.invoke('check', '--scope', 'matrix', '--service', 'mcp-host') + assert.notEqual(result.status, 0) + assert.match(result.stderr, /codex-llm-proxy/) + assert.equal(existsSync(f.mutation), false) +}) + +test('an unloadable native dependency fails without attempting a rebuild', t => { + const f = fixture(t); ready(f) + write(join(f.root, 'mcp-host/node_modules/better-sqlite3/index.js'), 'throw new Error("synthetic ABI failure")\n') + const result = check(f) + assert.notEqual(result.status, 0) + assert.match(result.stderr, /mcp-host.*sqlite/i) + assert.equal(existsSync(f.mutation), false) + assert.doesNotMatch(readFileSync(f.audit, 'utf8'), /rebuild|install/) +}) + +test('T0 and preflight share affected-package discovery without executing tests', t => { + const f = fixture(t) + const listed = f.invoke('list-affected', '--base', f.base, '--head', f.head) + assert.equal(listed.status, 0, listed.stderr) + const actual = spawnSync('/bin/bash', [join(sourceRoot, 'scripts/minikube/t0.sh'), '--list-packages'], { + encoding: 'utf8', timeout: 30000, + env: { ...f.env, T0_PROJECT_DIR: f.root, T0_ORIGIN_DEV: f.base, T0_HEAD: f.head }, + }) + assert.equal(actual.status, 0, actual.stderr) + assert.deepEqual(actual.stdout.trim().split('\n'), listed.stdout.trim().split('\n')) + assert.deepEqual(actual.stdout.trim().split('\n').sort(), ['mcp-host', 'tests/e2e/fixtures']) + assert.equal(existsSync(f.audit), false, 'listing packages must not run npm') + assert.equal(existsSync(f.mutation), false) +}) + +test('T0-only preflight does not require producers reserved for T1', t => { + const f = fixture(t) + delete f.env.TASK106_R61_B1_DEV_SOURCE_DIR + delete f.env.TASK106_R61_B1_LATEST_DEV_SOURCE_DIR + const result = f.invoke('check', '--scope', 't0', '--base', f.base, '--head', f.head) + assert.equal(result.status, 0, result.stderr) + assert.equal(existsSync(f.mutation), false) +}) + +test('orchestrator rejects missing T1 inputs before T0 or bootstrap', t => { + const f = fixture(t); ready(f) + delete f.env.TASK106_R61_B1_LATEST_DEV_SOURCE_DIR + const t0Marker = join(dirname(f.root), 't0-started') + const bootstrapMarker = join(dirname(f.root), 'bootstrap-started') + const shell = [ + 'source "$FIXTURE_T2_SOURCE"', + 't2_require_commands() { :; }', + 't2_repo_metadata() { T2_PROJECT_DIR="$FIXTURE_ROOT"; T2_ORIGIN_DEV="$FIXTURE_BASE"; T2_HEAD="$FIXTURE_HEAD"; }', + 't2_profile_scope() { :; }', + 't2_profile_status() { T2_BOOTSTRAP_REQUIRED=true; }', + 't2_context_check() { :; }', + 't2_mutation_lock() { :; }', + 't2_prior_targeted_health_pending() { printf false; }', + 't2_evidence_init() { :; }', + 't2_evidence_write() { :; }', + 't2_lock_release() { :; }', + 'real_pg_local_preflight() { return 0; }', + 't2_fail() { printf "%s\n" "$*" >&2; return 1; }', + 'run_t0() { printf started > "$FIXTURE_T0"; return 97; }', + 'run_bootstrap_or_reconcile() { printf started > "$FIXTURE_BOOTSTRAP"; return 98; }', + 'main', + ].join('\n') + const shellEnv = { ...f.env, T2_RUN_T0: 'true', T2_RUN_T1: 'true', FIXTURE_T2_SOURCE: join(sourceRoot, 'scripts/minikube/t2.sh'), FIXTURE_ROOT: f.root, FIXTURE_BASE: f.base, FIXTURE_HEAD: f.head, FIXTURE_T0: t0Marker, FIXTURE_BOOTSTRAP: bootstrapMarker } + delete shellEnv.BASH_ENV + delete shellEnv.ENV + const result = spawnSync('/bin/bash', ['--noprofile', '--norc', '-c', shell], { + encoding: 'utf8', timeout: 30000, env: shellEnv, + }) + assert.notEqual(result.status, 0) + assert.match(result.stderr, /TASK106_R61_B1_LATEST_DEV_SOURCE_DIR/) + assert.equal(existsSync(t0Marker), false, 'T0 must not start with missing producer inputs') + assert.equal(existsSync(bootstrapMarker), false, 'bootstrap must not start with missing producer inputs') + assert.equal(existsSync(f.mutation), false) +}) + +test('a source-only dependency does not impose the sibling test suite artifacts', t => { + const f = fixture(t) + const contractPath = join(f.root, 'tests/contracts/certification-inputs.json') + const contract = JSON.parse(readFileSync(contractPath, 'utf8')) + contract.consumerDependencies['external-rest-api'] = ['control-api'] + contract.consumerArtifacts['control-api'] = ['packages/workflow-runtime-core/dist/index.js'] + write(contractPath, JSON.stringify(contract)) + rmSync(join(f.root, 'packages/workflow-runtime-core/dist/index.js')) + const result = f.invoke('check', '--scope', 'matrix', '--service', 'external-rest-api') + assert.equal(result.status, 0, result.stderr) + assert.equal(existsSync(f.mutation), false) +}) + +test('an unrecognized pre-gate declaration fails closed', t => { + const f = fixture(t); ready(f) + write(join(f.root, 'scripts/minikube/pre-gate-sync.sh'), 'run_if_changed "$dynamic" "npm test"\n') + const result = check(f) + assert.notEqual(result.status, 0) + assert.match(result.stderr, /pre-gate package declaration/i) + assert.equal(existsSync(f.mutation), false) +}) + +test('empty affected-package selection is valid and does not start npm', t => { + const f = fixture(t) + const actual = spawnSync('/bin/bash', [join(sourceRoot, 'scripts/minikube/t0.sh'), '--list-packages'], { + encoding: 'utf8', timeout: 30000, + env: { ...f.env, T0_PROJECT_DIR: f.root, T0_ORIGIN_DEV: f.head, T0_HEAD: f.head }, + }) + assert.equal(actual.status, 0, actual.stderr) + assert.equal(actual.stdout.trim(), '') + assert.equal(existsSync(f.audit), false) +}) + +test('Git range arguments cannot be interpreted as command options', t => { + const f = fixture(t) + const result = f.invoke('list-affected', '--base', '--help', '--head', f.head) + assert.notEqual(result.status, 0) + assert.match(result.stderr, /invalid Git range/) + assert.equal(existsSync(f.audit), false) + assert.equal(existsSync(f.mutation), false) +}) diff --git a/scripts/tests/password-recovery-suite-contract.test.mjs b/scripts/tests/password-recovery-suite-contract.test.mjs index 15ab36cdbf..3daaa7e98f 100644 --- a/scripts/tests/password-recovery-suite-contract.test.mjs +++ b/scripts/tests/password-recovery-suite-contract.test.mjs @@ -394,3 +394,27 @@ for (const mode of ['local', 'configured']) { }) } } + + +test('hosted real-PostgreSQL job enforces the same public inventory as Minikube', () => { + const workflow = readFileSync(join(root, '.github/workflows/ci-public.yml'), 'utf8') + const job = workflow.split(/^ control-api-migration:\s*$/m)[1]?.split(/^ [a-zA-Z0-9_-]+:\s*$/m)[0] + assert.ok(job, 'control-api-migration job must exist') + const step = job.split(/^ - name: Run control-api real Postgres suites \(migration \+ GFS\)\s*$/m)[1]?.split(/^ - /m)[0] + assert.ok(step, 'real PostgreSQL step must exist') + const commands = step.split('\n').filter(line => !/^\s*#/.test(line)).join('\n') + const verify = commands.indexOf('node ../scripts/ci/password-recovery-suite-contract.mjs verify') + const privateSelection = commands.indexOf('node ../scripts/ci/password-recovery-suite-contract.mjs private all') + const run = commands.indexOf('npm test -- --run realPostgres') + const report = commands.indexOf('node ../scripts/ci/password-recovery-suite-contract.mjs report publicPostgres "$report"') + assert.ok(verify >= 0 && verify < privateSelection && privateSelection < run && run < report, + 'CI must validate the inventory, exclude the declared private suites, run PostgreSQL, and verify the exact public report') + assert.match(commands, /"\$\{exclude_args\[@\]\}"/) + const pinBlock = commands.split('node ../scripts/ci/assert-pinned-vitest-suites.mjs "$report"')[1] + assert.ok(pinBlock, 'pinned security suites must still be checked') + const pins = pinBlock.match(/[A-Za-z0-9_.-]+realPostgres[A-Za-z0-9_.-]*\.test\.ts/g) || [] + const publicNames = new Set(manifest.publicPostgres.map(path => path.split('/').pop())) + assert.ok(pins.length > 0, 'the pinned suite list must not be empty') + assert.equal(new Set(pins).size, pins.length, 'pinned suites must be unique') + assert.deepEqual(pins.filter(pin => !publicNames.has(pin)), [], 'every pinned suite must be in the public inventory') +}) diff --git a/scripts/tests/t1-reporter-projection.test.mjs b/scripts/tests/t1-reporter-projection.test.mjs new file mode 100644 index 0000000000..4faac2f650 --- /dev/null +++ b/scripts/tests/t1-reporter-projection.test.mjs @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') +const projector = path.join(root, 'scripts/e2e/t1-reporter-projection.py') +const omittedText = 'synthetic-omitted-free-text-marker' + +function scratch() { + return mkdtempSync(path.join(process.env.CODEX_SCRATCH_ROOT || tmpdir(), 't1-projection-')) +} + +function reporter(dir) { + const packageRoot = path.join(dir, 'control-api') + const report = { + success: false, + numTotalTestSuites: 3, + numPassedTestSuites: 1, + numFailedTestSuites: 2, + numPendingTestSuites: 0, + numTotalTests: 3, + numPassedTests: 1, + numFailedTests: 1, + numPendingTests: 0, + testResults: [ + { + name: path.join(packageRoot, 'test/a.realPostgres.integration.test.ts'), + status: 'failed', + startTime: 1000, + endTime: 1250, + message: '', + assertionResults: [ + { ancestorTitles: ['a'], title: 'passes', status: 'passed', duration: 12, failureMessages: [] }, + { + ancestorTitles: ['a'], + title: 'fails', + status: 'failed', + duration: 30, + failureMessages: [`AssertionError: ${omittedText}`, `stack ${omittedText}`], + }, + ], + }, + { + name: path.join(packageRoot, 'test/b.realPostgres.integration.test.ts'), + status: 'failed', + startTime: 2000, + endTime: 2010, + message: `beforeAll failed: ${omittedText}`, + assertionResults: [], + }, + ], + } + const file = path.join(dir, 'reporter.json') + writeFileSync(file, JSON.stringify(report)) + return { file, packageRoot } +} + +function run(args) { + return spawnSync('python3', [projector, ...args], { encoding: 'utf8' }) +} + +test('projection keeps files, tests, statuses and counters without free text', () => { + const dir = scratch() + const { file, packageRoot } = reporter(dir) + const out = path.join(dir, 'evidence', 'logs', 't1-control-api-isolated.results.json') + const result = run([file, packageRoot, 'control-api', 'isolated', out]) + assert.equal(result.status, 0, result.stderr) + const raw = readFileSync(out, 'utf8') + assert.ok(!raw.includes(omittedText), 'free-text messages must not reach the evidence') + const projection = JSON.parse(raw) + assert.equal(projection.schema, 't1-reporter-projection/v1') + assert.equal(projection.package, 'control-api') + assert.equal(projection.lane, 'isolated') + assert.equal(projection.counters.numFailedTests, 1) + assert.deepEqual( + projection.files.map(f => [f.file, f.status, f.durationMs, f.fileMessageOmitted, f.tests.length]), + [ + ['test/a.realPostgres.integration.test.ts', 'failed', 250, false, 2], + ['test/b.realPostgres.integration.test.ts', 'failed', 10, true, 0], + ], + ) + assert.deepEqual( + projection.files[0].tests.map(t => [t.title, t.status, t.failureMessageCount]), + [ + ['passes', 'passed', 0], + ['fails', 'failed', 2], + ], + ) +}) + +test('projection fails and writes nothing when the destination is not writable', () => { + const dir = scratch() + const { file, packageRoot } = reporter(dir) + const blocker = path.join(dir, 'not-a-directory') + writeFileSync(blocker, 'file') + const out = path.join(blocker, 'logs', 'results.json') + const result = run([file, packageRoot, 'control-api', 'isolated', out]) + assert.notEqual(result.status, 0) + assert.match(result.stderr, /could not be written/) + assert.equal(existsSync(out), false) +}) + +test('projection fails on a reporter without test results', () => { + const dir = scratch() + const file = path.join(dir, 'empty.json') + writeFileSync(file, JSON.stringify({ success: true, testResults: [] })) + const out = path.join(dir, 'results.json') + const result = run([file, dir, 'control-api', 'isolated', out]) + assert.notEqual(result.status, 0) + assert.match(result.stderr, /missing or empty/) + assert.equal(existsSync(out), false) +}) diff --git a/scripts/tests/test-minikube-t1-reporter-report.sh b/scripts/tests/test-minikube-t1-reporter-report.sh index 77cea2c3fb..7edde09439 100755 --- a/scripts/tests/test-minikube-t1-reporter-report.sh +++ b/scripts/tests/test-minikube-t1-reporter-report.sh @@ -336,6 +336,7 @@ PY } export -f npm export RUN_PROJECT="$run_project" RUN_SCRIPT="$ROOT/scripts/e2e/minikube-real-postgres.sh" RUN_TMP="$T1_TMP_DIR" + export RUN_EVIDENCE="$TEST_DIR/evidence" run_err="$TEST_DIR/run-suite.err" # A child bash, so that set -e is really in force: this subshell is itself the # left side of a || list, where set -e is ignored and die_t1 would not stop @@ -347,6 +348,8 @@ PY PROJECT_DIR="$RUN_PROJECT" T1_TMP_DIR="$RUN_TMP" T1_REDACT_PASSWORD="$RUN_PASSWORD" + # Production T1 always calls t2_evidence_init before any suite runs. + T2_EVIDENCE_DIR="$RUN_EVIDENCE" # This fixture tests reporter failures; inventory validation has its own # real-selector contract in password-recovery-suite-contract.test.mjs. list_real_pg_files() { printf "%s\n" "$RUN_FILE"; } @@ -354,6 +357,13 @@ PY ' bash "$dsn_scheme://isolated" >"$TEST_DIR/run-suite.out" 2>"$run_err" run_status=$? [ -s "$npm_marker" ] || fail 'the npm stand-in never ran (liveness witness missing)' + # The failing lane keeps its durable projection, and the projection carries + # no secret from the reporter's free-text message. + projection="$RUN_EVIDENCE/logs/t1-control-api-isolated.results.json" + [ -s "$projection" ] || fail 'run_suite did not persist the T1 reporter projection' + grep -Fq '"fileMessageOmitted": true' "$projection" || fail 'the projection lost the failing file record' + grep -Fq "$run_password" "$projection" && fail 'the password reached the T1 evidence projection' + grep -Fq "$run_bearer" "$projection" && fail 'the bearer token reached the T1 evidence projection' grep -Fq 'REAL_PG_SUITE_FAILED' "$run_err" || fail 'run_suite did not fail on the failing reporter' [ "$run_status" -ne 0 ] || fail 'run_suite returned success on a failing reporter' grep -Fq "FAILED FILE: $run_file" "$run_err" \ diff --git a/scripts/tests/test-minikube-t2-contract.sh b/scripts/tests/test-minikube-t2-contract.sh index c97f126704..0c5c116898 100755 --- a/scripts/tests/test-minikube-t2-contract.sh +++ b/scripts/tests/test-minikube-t2-contract.sh @@ -92,10 +92,14 @@ finally: pass PY grep -Fq 't0.sh' "$T2" +# T1 keeps durable reporter evidence (structure and results, no free text) and +# fails when it cannot be written. +grep -Fq 't1-reporter-projection.py' "$T1" +grep -Fq 'T1 reporter evidence could not be persisted' "$T1" grep -Fq 'T0_SHELLCHECK=PASS' "$T0" grep -Fq 'npm run build' "$T0" grep -Fq 'npm test' "$T0" -grep -Fq 'if [ "${#package_dirs[@]}" -gt 0 ]' "$T0" +grep -Fq 'if [ -n "$package_list" ]' "$T0" grep -Fq 'Ready pod owner ReplicaSet could not be verified' "$ROOT/deploy/scripts/lib/gfs-credential-rollout.sh" grep -Fq 'treating marker as stale' "$ROOT/scripts/minikube/pre-gate-sync.sh" for identity_file in "$COMMON" "$ROOT/scripts/minikube/sync-auth-key.sh" "$ROOT/scripts/minikube/require-t2-mutation-lock.sh" "$ROOT/scripts/minikube/pre-gate-sync.sh"; do @@ -654,6 +658,8 @@ for exit_code in 1 2 7 126 137; do source "$1" PROJECT_DIR="$T1_EXIT_ROOT" T1_TMP_DIR="$(mktemp -d)" + # Production T1 always calls t2_evidence_init before any suite runs. + T2_EVIDENCE_DIR="$T1_TMP_DIR/evidence" trap '\''rm -rf "$T1_TMP_DIR"'\'' EXIT list_real_pg_files() { printf "%s\\n" "$T1_EXIT_ROOT/control-api/test/fake.realPostgres.test.ts" diff --git a/tests/contracts/certification-inputs.json b/tests/contracts/certification-inputs.json new file mode 100644 index 0000000000..581e71567c --- /dev/null +++ b/tests/contracts/certification-inputs.json @@ -0,0 +1,115 @@ +{ + "version": 1, + "suiteManifest": "tests/contracts/password-recovery-suites.json", + "preGateScript": "scripts/minikube/pre-gate-sync.sh", + "t1Packages": [ + "packages/workflow-runtime-core", + "control-api", + "external-rest-api" + ], + "localT1ExtraPackages": [ + "gfs-controller" + ], + "consumerDependencies": { + "control-api": [ + "packages/workflow-runtime-core", + "rpc-proxy", + "external-rest-api" + ], + "external-rest-api": [ + "control-api" + ], + "mcp-host": [ + "codex-llm-proxy", + "grok-llm-proxy" + ], + "desktop-app": [ + "mcp-host" + ], + "profile-ui": [ + "packages/frontend-components" + ], + "workflow-recipes": [ + "packages/workflow-runtime-core" + ], + "packages/workflow-sdk": [ + "packages/workflow-runtime-core" + ] + }, + "consumerArtifacts": { + "control-api": [ + "packages/workflow-runtime-core/dist/index.js", + "external-rest-api/dist/app.js" + ], + "workflow-recipes": [ + "packages/workflow-runtime-core/dist/index.js" + ], + "packages/workflow-sdk": [ + "packages/workflow-runtime-core/dist/index.js" + ] + }, + "producers": { + "observedDev": { + "env": "TASK106_R61_B1_DEV_SOURCE_DIR", + "commit": "0b26101eb247adcc44f70d39451f3e82c3225d47", + "sourceHashes": { + "control-api/src/db.ts": "1948ad306fe56b368f120edbc70ddbac6cb9b981d9fd218e7ded2575ed3b2beb" + }, + "buildPackages": [ + "packages/workflow-runtime-core", + "control-api" + ], + "artifactDirectories": [ + "packages/workflow-runtime-core/dist", + "control-api/dist" + ], + "lockfiles": [ + "packages/workflow-runtime-core/package-lock.json", + "control-api/package-lock.json" + ], + "runner": "control-api/dist/migrate.js" + }, + "latestDev": { + "env": "TASK106_R61_B1_LATEST_DEV_SOURCE_DIR", + "commit": "87f962fb999ed5dbcab90bd52562de0eba4aa564", + "sourceHashes": { + "control-api/src/db.ts": "616678c106f762a8b82ea45f42e01ddc42fb90dda935d62fd0f4b72c2a2eeebc", + "control-api/src/migrate.ts": "2b582252753c6455fc85d7cb4563313e3b63ecf7c7630aa6252f70f0bb286a81" + }, + "buildPackages": [ + "packages/workflow-runtime-core", + "control-api" + ], + "artifactDirectories": [ + "packages/workflow-runtime-core/dist", + "control-api/dist" + ], + "lockfiles": [ + "packages/workflow-runtime-core/package-lock.json", + "control-api/package-lock.json" + ], + "runner": "control-api/dist/migrate.js" + } + }, + "nativeChecks": { + "mcp-host": [ + { + "package": "mcp-host", + "kind": "sqlite" + }, + { + "package": "mcp-host", + "kind": "canvas" + } + ], + "desktop-app": [ + { + "package": "mcp-host", + "kind": "canvas" + } + ] + }, + "t1TestRoots": [ + "control-api" + ] +} From 5dae55d3133af8e4b0ffb1c4ab82c0223fef9336 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Tue, 3 Nov 2026 16:19:52 +0100 Subject: [PATCH 440/528] fix(workflow-recipes): return run and recipe writers to dev Restore dbRunProcessor and workflowReconciler, plus their tests, to dev's shape: the stuck-run sweep no longer selects approval_request_id nor prelocks approval subjects, and reconcileDelete no longer locks approval rows or calls the recipe-policy prelock. Those calls depend on the authorization_prelock_* functions, which leave the schema, so the writers return with them. Recipe deletion and stuck-run sweeps go back to dev locking and cost. --- workflow-recipes/package-lock.json | 4 +- workflow-recipes/package.json | 2 +- .../src/reconciler/dbRunProcessor.test.ts | 76 ------------------- .../src/reconciler/dbRunProcessor.ts | 23 +----- .../src/workflow/workflowReconciler.ts | 19 ----- .../unit/workflow/workflowReconciler.test.ts | 28 ------- 6 files changed, 5 insertions(+), 147 deletions(-) diff --git a/workflow-recipes/package-lock.json b/workflow-recipes/package-lock.json index 565d6688f6..2165726f9b 100644 --- a/workflow-recipes/package-lock.json +++ b/workflow-recipes/package-lock.json @@ -1,12 +1,12 @@ { "name": "workload-recipes-controller", - "version": "0.1.482", + "version": "0.1.483", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "workload-recipes-controller", - "version": "0.1.482", + "version": "0.1.483", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@aws-sdk/client-s3": "^3.1009.0", diff --git a/workflow-recipes/package.json b/workflow-recipes/package.json index e5084b354e..825861bafe 100644 --- a/workflow-recipes/package.json +++ b/workflow-recipes/package.json @@ -1,6 +1,6 @@ { "name": "workload-recipes-controller", - "version": "0.1.482", + "version": "0.1.483", "description": "Workload Recipes Controller (WRC) - WorkloadRecipe lifecycle management and MCP server interface", "main": "dist/main.js", "engines": { diff --git a/workflow-recipes/src/reconciler/dbRunProcessor.test.ts b/workflow-recipes/src/reconciler/dbRunProcessor.test.ts index 8dadcae29d..fcb979f57a 100644 --- a/workflow-recipes/src/reconciler/dbRunProcessor.test.ts +++ b/workflow-recipes/src/reconciler/dbRunProcessor.test.ts @@ -594,19 +594,10 @@ describe('createDbRunProcessor', () => { const failed = await proc.checkStuckRuns() expect(failed).toBe(0) - // N7 witness: the sweep really issued its SELECT and committed, so the - // absent prelock and FAIL are not the result of a helper that never ran. - expect( - client.calls.some(c => /FROM workflow_runs[\s\S]*FOR UPDATE SKIP LOCKED/i.test(c.sql)) - ).toBe(true) - expect(client.calls.map(c => c.sql)).toContain('COMMIT') const anyFail = client.calls.find(c => /UPDATE workflow_runs[\s\S]*SET phase = 'Failed'/i.test(c.sql) ) expect(anyFail).toBeUndefined() - expect( - client.calls.find(c => /authorization_prelock_workflow_approval_revisions/.test(c.sql)) - ).toBeUndefined() }) // ─── 7) checkStuckRuns flips to Failed when started_at + max_duration elapsed @@ -644,73 +635,6 @@ describe('createDbRunProcessor', () => { /UPDATE workflow_runs[\s\S]*SET phase = 'Failed'/i.test(c.sql) ) expect(failUpdate?.params).toEqual(['run-stuck-1', 'wrc-1']) - // A row without an approval id makes no prelock call; FIND and FAIL both ran. - expect( - client.calls.some(c => /FROM workflow_runs[\s\S]*FOR UPDATE SKIP LOCKED/i.test(c.sql)) - ).toBe(true) - expect( - client.calls.find(c => /authorization_prelock_workflow_approval_revisions/.test(c.sql)) - ).toBeUndefined() - }) - - // The stuck-run sweep holds its batch's approval subjects before the first - // FAIL, because each provider-bound failure makes the run trigger INSERT one - // delivery and bump that delivery's subject. - it('checkStuckRuns prelocks the distinct non-null approval ids before failing the batch', async () => { - const stuckRows = [ - { run_id: 'run-stuck-user', approval_request_id: 'approval-1' }, - { run_id: 'run-stuck-team', approval_request_id: 'approval-2' }, - { run_id: 'run-stuck-shared', approval_request_id: 'approval-1' }, - { run_id: 'run-stuck-plain', approval_request_id: null }, - ] - const client = makeClient(async sql => { - if (/^BEGIN$/i.test(sql) || /^COMMIT$/i.test(sql)) return { rows: [], rowCount: 0 } - if (/FROM workflow_runs[\s\S]*FOR UPDATE SKIP LOCKED/i.test(sql)) { - return { rows: stuckRows, rowCount: stuckRows.length } - } - if (/UPDATE workflow_runs[\s\S]*SET phase = 'Failed'/i.test(sql)) { - return { rows: [], rowCount: 1 } - } - return { rows: [], rowCount: 0 } - }) - const pool = { - connect: vi.fn(async () => client as unknown as PoolClient), - } as unknown as Pool - const onRunTerminal = vi.fn() - const proc = spawn({ - instanceId: 'wrc-1', - pool, - runPollMs: 30_000, - createChildRecipe: vi.fn(), - logger: silentLogger(), - onRunTerminal, - }) - - expect(await proc.checkStuckRuns()).toBe(4) - - const findIndex = client.calls.findIndex(c => - /FROM workflow_runs[\s\S]*FOR UPDATE SKIP LOCKED/i.test(c.sql) - ) - const prelockIndex = client.calls.findIndex(c => - /authorization_prelock_workflow_approval_revisions\(\$1::uuid\[\]\)/.test(c.sql) - ) - const failIndexes = client.calls - .map((call, index) => ({ call, index })) - .filter(({ call }) => /UPDATE workflow_runs[\s\S]*SET phase = 'Failed'/i.test(call.sql)) - .map(({ index }) => index) - expect(findIndex).toBeGreaterThan(-1) - expect(prelockIndex).toBeGreaterThan(findIndex) - expect(failIndexes).toHaveLength(4) - expect(Math.min(...failIndexes)).toBeGreaterThan(prelockIndex) - // Distinct, non-null, first-appearance order. - expect(client.calls[prelockIndex]!.params).toEqual([['approval-1', 'approval-2']]) - expect(failIndexes.map(index => client.calls[index]!.params?.[0])).toEqual([ - 'run-stuck-user', - 'run-stuck-team', - 'run-stuck-shared', - 'run-stuck-plain', - ]) - expect(onRunTerminal).toHaveBeenCalledTimes(4) }) // ─── 8) LISTEN reconnect: on session error, a new LISTEN session is diff --git a/workflow-recipes/src/reconciler/dbRunProcessor.ts b/workflow-recipes/src/reconciler/dbRunProcessor.ts index ab250b6401..810127eba4 100644 --- a/workflow-recipes/src/reconciler/dbRunProcessor.ts +++ b/workflow-recipes/src/reconciler/dbRunProcessor.ts @@ -151,7 +151,7 @@ const UPSERT_STEP = ` )` const FIND_STUCK_RUNS = ` - SELECT run_id, started_at, max_duration_seconds, approval_request_id + SELECT run_id, started_at, max_duration_seconds FROM workflow_runs WHERE phase = 'Running' AND owner_instance_id = $1 @@ -569,26 +569,7 @@ export function createDbRunProcessor(opts: DbRunProcessorOptions): DbRunProcesso const terminalNotifications: Array<{ runId: string; phase: WorkflowRunPhase }> = [] try { await client.query('BEGIN') - const res = await client.query<{ run_id: string; approval_request_id: string | null }>( - FIND_STUCK_RUNS, - [opts.instanceId] - ) - // A failed provider-bound run makes notify_workflow_run_update INSERT one - // delivery, which bumps that delivery's subject; hold this batch's - // approval subjects in the global team-then-user order before the loop so - // a concurrent run INSERT cannot reverse it. - const approvalRequestIds = [ - ...new Set( - res.rows - .map(row => row.approval_request_id) - .filter((id): id is string => typeof id === 'string' && id.length > 0) - ), - ] - if (approvalRequestIds.length > 0) { - await client.query('SELECT authorization_prelock_workflow_approval_revisions($1::uuid[])', [ - approvalRequestIds, - ]) - } + const res = await client.query<{ run_id: string }>(FIND_STUCK_RUNS, [opts.instanceId]) for (const row of res.rows) { const upd = await client.query(FAIL_STUCK_RUN, [row.run_id, opts.instanceId]) if (upd.rowCount && upd.rowCount > 0) { diff --git a/workflow-recipes/src/workflow/workflowReconciler.ts b/workflow-recipes/src/workflow/workflowReconciler.ts index 7d70eb6796..a11a7a4bd2 100644 --- a/workflow-recipes/src/workflow/workflowReconciler.ts +++ b/workflow-recipes/src/workflow/workflowReconciler.ts @@ -4447,25 +4447,6 @@ export class WorkflowReconciler { const client = await this.deps.pgPool.connect() try { await client.query('BEGIN') - // Take the global lock order before the first triggered write: the - // approval rows this transaction cancels, then the recipe's policy - // subjects (parent teams, then users, then the grant rows) and their - // revisions through the SECURITY DEFINER prelock. Without it the - // user-grant DELETE takes users before the team-grant DELETEs take - // teams, the approval UPDATE takes targets in scan order, and a - // concurrent run INSERT of the same team and user deadlocks (40P01). - await client.query( - `SELECT id FROM workflow_approval_requests - WHERE recipe_namespace = $1 AND recipe_name = $2 - AND status IN ('pending', 'approved') - ORDER BY id - FOR UPDATE`, - [recipeNamespace, recipeName] - ) - await client.query( - 'SELECT authorization_prelock_workflow_recipe_policy_revisions($1, $2)', - [recipeNamespace, recipeName] - ) await client.query( `DELETE FROM user_workflow_triggers WHERE recipe_namespace = $1 AND recipe_name = $2`, diff --git a/workflow-recipes/tests/unit/workflow/workflowReconciler.test.ts b/workflow-recipes/tests/unit/workflow/workflowReconciler.test.ts index 8c395c3201..2364c645d1 100644 --- a/workflow-recipes/tests/unit/workflow/workflowReconciler.test.ts +++ b/workflow-recipes/tests/unit/workflow/workflowReconciler.test.ts @@ -9198,34 +9198,6 @@ describe('WorkflowReconciler — reconcile loop', () => { const sqls = query.mock.calls.map(([sql]) => String(sql)) expect(sqls).toContainEqual('BEGIN') - // The cleanup takes the global order before its first triggered write: - // approval rows, then the definer prelock of the policy subjects, then the - // user-grant DELETE that would otherwise take users before teams. - const beginIndex = sqls.indexOf('BEGIN') - const approvalLockIndex = sqls.findIndex( - sql => - sql.includes('SELECT id FROM workflow_approval_requests') && - sql.includes('ORDER BY id') && - sql.includes('FOR UPDATE') - ) - const prelockIndex = sqls.indexOf( - 'SELECT authorization_prelock_workflow_recipe_policy_revisions($1, $2)' - ) - const userGrantDeleteIndex = sqls.findIndex(sql => - sql.includes('DELETE FROM user_workflow_triggers') - ) - expect(beginIndex).toBeGreaterThanOrEqual(0) - expect(approvalLockIndex).toBeGreaterThan(beginIndex) - expect(prelockIndex).toBeGreaterThan(approvalLockIndex) - expect(userGrantDeleteIndex).toBeGreaterThan(prelockIndex) - expect(query.mock.calls[approvalLockIndex]).toEqual([ - expect.stringContaining('FOR UPDATE'), - ['sandbox-recipes', 'test-wf'], - ]) - expect(query.mock.calls[prelockIndex]).toEqual([ - 'SELECT authorization_prelock_workflow_recipe_policy_revisions($1, $2)', - ['sandbox-recipes', 'test-wf'], - ]) expect(sqls).toContainEqual(expect.stringContaining('DELETE FROM user_workflow_triggers')) expect(sqls).toContainEqual(expect.stringContaining('DELETE FROM team_workflow_triggers')) expect(sqls).toContainEqual( From 0be9d59fbd3c81e15e868f1b86602e11ad6374a0 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Tue, 3 Nov 2026 16:26:08 +0100 Subject: [PATCH 441/528] fix(scripts): derive runtime access relation count from the peer TSV The control-api profile checks compared the relation count with a handwritten 114. The expected count is now the row count of workflow-recipes-runtime-access-profiles.tsv, and the two name sets must match. --- ...est-control-api-runtime-access-contract.sh | 63 ++++++++++++----- scripts/tests/test-gcp-scripts.sh | 68 ++++++++++++------- 2 files changed, 92 insertions(+), 39 deletions(-) diff --git a/scripts/tests/test-control-api-runtime-access-contract.sh b/scripts/tests/test-control-api-runtime-access-contract.sh index f7a493fafe..fcea7baa52 100755 --- a/scripts/tests/test-control-api-runtime-access-contract.sh +++ b/scripts/tests/test-control-api-runtime-access-contract.sh @@ -3,26 +3,44 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" PROFILE_FILE="$ROOT_DIR/deploy/scripts/control-api-runtime-access-profiles.tsv" +PEER_FILE="$ROOT_DIR/deploy/scripts/workflow-recipes-runtime-access-profiles.tsv" MIGRATION_SCRIPT="$ROOT_DIR/deploy/scripts/run-control-api-db-migration.sh" relation_count="$(awk -F '\t' '!/^[[:space:]]*(#|$)/ { count++ } END { print count + 0 }' "$PROFILE_FILE")" +peer_count="$(awk -F '\t' '!/^[[:space:]]*(#|$)/ { count++ } END { print count + 0 }' "$PEER_FILE")" duplicate_count="$(awk -F '\t' '!/^[[:space:]]*(#|$)/ { seen[$1]++ } END { for (name in seen) if (seen[name] > 1) count++ } END { print count + 0 }' "$PROFILE_FILE")" invalid_count="$(awk -F '\t' '!/^[[:space:]]*(#|$)/ && (NF != 2 || $1 !~ /^[a-z][a-z0-9_]*$/ || $2 !~ /^(legacy_dml|upsert|append|read|link_lifecycle|insert_delete|none)$/) { count++ } END { print count + 0 }' "$PROFILE_FILE")" +name_diff="$(comm -3 \ + <(awk -F '\t' '!/^[[:space:]]*(#|$)/ { print $1 }' "$PROFILE_FILE" | sort) \ + <(awk -F '\t' '!/^[[:space:]]*(#|$)/ { print $1 }' "$PEER_FILE" | sort))" -# The composed profile includes operational catalog readers and password-admission relations. -if [[ "$relation_count" != "114" || "$duplicate_count" != "0" || "$invalid_count" != "0" ]] || \ - ! grep -qx $'authorization_catalog_environment\tread' "$PROFILE_FILE" || \ - ! grep -qx $'dynamic_clients\tlegacy_dml' "$PROFILE_FILE" || \ - ! grep -qx $'entity_change_feed\tnone' "$PROFILE_FILE" || \ - ! grep -qx $'entity_change_outbox\tnone' "$PROFILE_FILE" || \ - ! grep -qx $'entity_change_watermark\tnone' "$PROFILE_FILE" || \ - ! grep -qx $'mcp_secret_rollback_permits\tlegacy_dml' "$PROFILE_FILE" || \ - ! grep -qx $'gfs_desktop_operator_links\tlink_lifecycle' "$PROFILE_FILE" || \ - ! grep -qx $'desktop_user_retirement_operations\tlink_lifecycle' "$PROFILE_FILE" || \ - ! grep -qx $'password_identifier_state\tlegacy_dml' "$PROFILE_FILE" || \ - ! grep -qx $'password_verification_pace\tlegacy_dml' "$PROFILE_FILE" || \ - ! grep -qx $'password_verification_work\tinsert_delete' "$PROFILE_FILE" || \ - ! grep -Fq '$2 !~ /^(legacy_dml|upsert|append|read|link_lifecycle|insert_delete|none)$/' "$MIGRATION_SCRIPT" || \ +# Existing sentinel rows, plus the three session rows. The expected relation +# count is the peer workflow-recipes profile, not a handwritten literal. +sentinels=( + $'authorization_catalog_environment\tread' + $'dynamic_clients\tlegacy_dml' + $'entity_change_feed\tnone' + $'entity_change_outbox\tnone' + $'entity_change_watermark\tnone' + $'mcp_secret_rollback_permits\tlegacy_dml' + $'gfs_desktop_operator_links\tlink_lifecycle' + $'desktop_user_retirement_operations\tlink_lifecycle' + $'password_identifier_state\tlegacy_dml' + $'password_verification_pace\tlegacy_dml' + $'password_verification_work\tinsert_delete' + $'external_user_sessions\tlegacy_dml' + $'external_user_session_security_epochs\tlegacy_dml' + $'external_v1_session_revocations\tappend' +) +missing_sentinels=() +for sentinel in "${sentinels[@]}"; do + if ! grep -qx "$sentinel" "$PROFILE_FILE"; then + missing_sentinels+=("$sentinel") + fi +done + +contract_fragments_ok=1 +if ! grep -Fq '$2 !~ /^(legacy_dml|upsert|append|read|link_lifecycle|insert_delete|none)$/' "$MIGRATION_SCRIPT" || \ ! grep -Fq 'has_any_column_privilege(' "$MIGRATION_SCRIPT" || \ ! grep -Fq "('INSERT', expected.access_profile IN ('legacy_dml', 'upsert', 'append', 'link_lifecycle', 'insert_delete'))" "$MIGRATION_SCRIPT" || \ ! grep -Fq "('UPDATE', expected.access_profile IN ('legacy_dml', 'upsert', 'link_lifecycle'))" "$MIGRATION_SCRIPT" || \ @@ -30,8 +48,21 @@ if [[ "$relation_count" != "114" || "$duplicate_count" != "0" || "$invalid_count ! grep -Fq "('TRUNCATE', false)" "$MIGRATION_SCRIPT" || \ ! grep -Fq "('REFERENCES', false)" "$MIGRATION_SCRIPT" || \ ! grep -Fq "('TRIGGER', false)" "$MIGRATION_SCRIPT"; then - printf 'FAIL: control-api runtime access contract is incomplete or not exact\n' >&2 + contract_fragments_ok=0 +fi + +if [[ "$relation_count" -le 0 || "$relation_count" != "$peer_count" || -n "$name_diff" || "$duplicate_count" != "0" || "$invalid_count" != "0" || ${#missing_sentinels[@]} -gt 0 || "$contract_fragments_ok" != "1" ]]; then + printf 'FAIL: control-api runtime access contract is incomplete or not exact (%s has %s relations, %s has %s relations; duplicates=%s invalid=%s)\n' \ + "$PROFILE_FILE" "$relation_count" "$PEER_FILE" "$peer_count" "$duplicate_count" "$invalid_count" >&2 + if [[ -n "$name_diff" ]]; then + printf 'relation name sets differ:\n%s\n' "$name_diff" >&2 + fi + if [[ ${#missing_sentinels[@]} -gt 0 ]]; then + printf 'missing sentinel rows:\n' >&2 + printf '%s\n' "${missing_sentinels[@]}" >&2 + fi exit 1 fi -printf 'PASS: control-api runtime access contract is explicit and least privilege\n' +printf 'PASS: control-api runtime access contract is explicit and least privilege (%s relations, peer %s relations)\n' \ + "$relation_count" "$peer_count" diff --git a/scripts/tests/test-gcp-scripts.sh b/scripts/tests/test-gcp-scripts.sh index a5a3e31c61..6206503937 100755 --- a/scripts/tests/test-gcp-scripts.sh +++ b/scripts/tests/test-gcp-scripts.sh @@ -1556,38 +1556,54 @@ assert_trace_maintenance_runtime_access_contract_is_exact() { } assert_control_api_runtime_access_contract_is_exact() { - local profile_file sequence_profile_file migration_script - local relation_count duplicate_count invalid_count sequence_count sequence_duplicate_count sequence_invalid_count + local profile_file peer_file sequence_profile_file migration_script + local relation_count peer_count duplicate_count invalid_count sequence_count sequence_duplicate_count sequence_invalid_count + local name_diff sentinel missing_sentinels="" profile_file="deploy/scripts/control-api-runtime-access-profiles.tsv" + peer_file="deploy/scripts/workflow-recipes-runtime-access-profiles.tsv" sequence_profile_file="deploy/scripts/control-api-runtime-sequence-access-profiles.tsv" migration_script="$(cat deploy/scripts/run-control-api-db-migration.sh)" relation_count="$(awk -F '\t' '!/^[[:space:]]*(#|$)/ { count++ } END { print count + 0 }' "$profile_file")" + peer_count="$(awk -F '\t' '!/^[[:space:]]*(#|$)/ { count++ } END { print count + 0 }' "$peer_file")" duplicate_count="$(awk -F '\t' '!/^[[:space:]]*(#|$)/ { seen[$1]++ } END { for (name in seen) if (seen[name] > 1) count++ } END { print count + 0 }' "$profile_file")" invalid_count="$(awk -F '\t' '!/^[[:space:]]*(#|$)/ && (NF != 2 || $1 !~ /^[a-z][a-z0-9_]*$/ || $2 !~ /^(legacy_dml|upsert|append|read|link_lifecycle|insert_delete|none)$/) { count++ } END { print count + 0 }' "$profile_file")" + name_diff="$(comm -3 \ + <(awk -F '\t' '!/^[[:space:]]*(#|$)/ { print $1 }' "$profile_file" | sort) \ + <(awk -F '\t' '!/^[[:space:]]*(#|$)/ { print $1 }' "$peer_file" | sort))" + local -a sentinels=( + $'dynamic_clients\tlegacy_dml' + $'entity_change_feed\tnone' + $'entity_change_outbox\tnone' + $'entity_change_watermark\tnone' + $'gfs_upload_parts\tnone' + $'gfs_upload_sessions\tnone' + $'llm_allowed_models\tlegacy_dml' + $'member_registration_credentials\tupsert' + $'mcp_secret_rollback_permits\tlegacy_dml' + $'llm_allowed_models_audit\tappend' + $'llm_catalog_sync_runs\tappend' + $'plugin_workload_sdk_spend_outcomes\tappend' + $'gfs_desktop_operator_links\tlink_lifecycle' + $'desktop_user_retirement_operations\tlink_lifecycle' + $'password_identifier_state\tlegacy_dml' + $'password_admission_rollout_state\tnone' + $'password_verification_pace\tlegacy_dml' + $'password_verification_work\tinsert_delete' + $'external_user_sessions\tlegacy_dml' + $'external_user_session_security_epochs\tlegacy_dml' + $'external_v1_session_revocations\tappend' + ) + for sentinel in "${sentinels[@]}"; do + if ! grep -qx "$sentinel" "$profile_file"; then + missing_sentinels+="${missing_sentinels:+$'\n'}${sentinel}" + fi + done sequence_count="$(awk -F '\t' '!/^[[:space:]]*(#|$)/ { count++ } END { print count + 0 }' "$sequence_profile_file")" sequence_duplicate_count="$(awk -F '\t' '!/^[[:space:]]*(#|$)/ { seen[$1]++ } END { for (name in seen) if (seen[name] > 1) count++ } END { print count + 0 }' "$sequence_profile_file")" sequence_invalid_count="$(awk -F '\t' '!/^[[:space:]]*(#|$)/ && (NF != 2 || $1 !~ /^[a-z][a-z0-9_]*$/ || $2 !~ /^(legacy_rw|consume|none)$/) { count++ } END { print count + 0 }' "$sequence_profile_file")" - if [[ "$relation_count" == "114" && "$duplicate_count" == "0" && "$invalid_count" == "0" ]] && \ - grep -qx $'dynamic_clients\tlegacy_dml' "$profile_file" && \ - grep -qx $'entity_change_feed\tnone' "$profile_file" && \ - grep -qx $'entity_change_outbox\tnone' "$profile_file" && \ - grep -qx $'entity_change_watermark\tnone' "$profile_file" && \ - grep -qx $'gfs_upload_parts\tnone' "$profile_file" && \ - grep -qx $'gfs_upload_sessions\tnone' "$profile_file" && \ - grep -qx $'llm_allowed_models\tlegacy_dml' "$profile_file" && \ - grep -qx $'member_registration_credentials\tupsert' "$profile_file" && \ - grep -qx $'mcp_secret_rollback_permits\tlegacy_dml' "$profile_file" && \ - grep -qx $'llm_allowed_models_audit\tappend' "$profile_file" && \ - grep -qx $'llm_catalog_sync_runs\tappend' "$profile_file" && \ - grep -qx $'plugin_workload_sdk_spend_outcomes\tappend' "$profile_file" && \ - grep -qx $'gfs_desktop_operator_links\tlink_lifecycle' "$profile_file" && \ - grep -qx $'desktop_user_retirement_operations\tlink_lifecycle' "$profile_file" && \ - grep -qx $'password_identifier_state\tlegacy_dml' "$profile_file" && \ - grep -qx $'password_admission_rollout_state\tnone' "$profile_file" && \ - grep -qx $'password_verification_pace\tlegacy_dml' "$profile_file" && \ - grep -qx $'password_verification_work\tinsert_delete' "$profile_file" && \ + if [[ "$relation_count" -gt 0 && "$relation_count" == "$peer_count" && -z "$name_diff" && -z "$missing_sentinels" && "$duplicate_count" == "0" && "$invalid_count" == "0" ]] && \ [[ "$sequence_count" == "9" && "$sequence_duplicate_count" == "0" && "$sequence_invalid_count" == "0" ]] && \ grep -qx $'dynamic_clients_id_seq\tlegacy_rw' "$sequence_profile_file" && \ grep -qx $'entity_change_outbox_id_seq\tnone' "$sequence_profile_file" && \ @@ -1604,9 +1620,15 @@ assert_control_api_runtime_access_contract_is_exact() { [[ "$migration_script" == *"('TRUNCATE', false)"* ]] && \ [[ "$migration_script" == *"('REFERENCES', false)"* ]] && \ [[ "$migration_script" == *"('TRIGGER', false)"* ]]; then - pass "control-api runtime access contract rejects missing and excessive relation privileges" + pass "control-api runtime access contract rejects missing and excessive relation privileges (${profile_file} has ${relation_count} relations, ${peer_file} has ${peer_count} relations)" else - fail "control-api runtime access contract is incomplete or not exact" + fail "control-api runtime access contract is incomplete or not exact: ${profile_file} has ${relation_count} relations, ${peer_file} has ${peer_count} relations; duplicates=${duplicate_count} invalid=${invalid_count}" + if [[ -n "$name_diff" ]]; then + printf 'relation name sets differ:\n%s\n' "$name_diff" >&2 + fi + if [[ -n "$missing_sentinels" ]]; then + printf 'missing sentinel rows:\n%s\n' "$missing_sentinels" >&2 + fi fi } From ff2d08e15d29a42067260eb246275c045fdac02f Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Tue, 3 Nov 2026 16:32:02 +0100 Subject: [PATCH 442/528] fix(deploy): share one migration Job identity normalizer verify_active_job_identity and build_job_manifest each defined the Job identity normalizer, so a Kubernetes default had to be edited in both copies. Both ruby invocations now evaluate the single JOB_IDENTITY_RUBY heredoc. --- .../scripts/run-control-api-db-migration.sh | 249 +++++++----------- .../test-control-api-migration-bounds.sh | 4 + 2 files changed, 93 insertions(+), 160 deletions(-) diff --git a/deploy/scripts/run-control-api-db-migration.sh b/deploy/scripts/run-control-api-db-migration.sh index 972d1c13af..4f1c8f612e 100755 --- a/deploy/scripts/run-control-api-db-migration.sh +++ b/deploy/scripts/run-control-api-db-migration.sh @@ -184,6 +184,87 @@ create_job() { return "$status" } +# Shared by verify_active_job_identity and build_job_manifest. +# read -d '' returns 1 at EOF under set -e; an empty body still fails below. +read -r -d '' JOB_IDENTITY_RUBY <<'RUBY' || true +def deep_sort(value) + case value + when Hash + value.keys.sort.each_with_object({}) do |key, acc| + acc[key] = deep_sort(value[key]) + end + when Array + value.map { |entry| deep_sort(entry) }.sort_by { |entry| JSON.generate(entry) } + else + value + end +end +def normalize_container(value) + container = Marshal.load(Marshal.dump(value)) + container.delete("terminationMessagePath") if + [nil, "/dev/termination-log"].include?(container["terminationMessagePath"]) + container.delete("terminationMessagePolicy") if + [nil, "File"].include?(container["terminationMessagePolicy"]) + %w[env envFrom ports volumeDevices volumeMounts].each do |key| + container.delete(key) if container[key] == [] + end + %w[resources securityContext].each do |key| + container.delete(key) if container[key].nil? || container[key] == {} + end + container +end +def normalize_pod(value) + pod = Marshal.load(Marshal.dump(value)) + containers = pod["containers"] || [] + abort("migration Job must contain exactly one migrate container") unless + containers.length == 1 && containers.first["name"] == "migrate" + pod["containers"] = containers.map { |item| normalize_container(item) } + %w[initContainers ephemeralContainers imagePullSecrets tolerations topologySpreadConstraints + readinessGates resourceClaims schedulingGates volumes].each do |key| + pod.delete(key) if pod[key] == [] + end + pod.delete("securityContext") if pod["securityContext"].nil? || pod["securityContext"] == {} + pod.delete("serviceAccount") if pod["serviceAccount"] == pod["serviceAccountName"] + pod.delete("dnsPolicy") if [nil, "ClusterFirst"].include?(pod["dnsPolicy"]) + pod.delete("schedulerName") if [nil, "default-scheduler"].include?(pod["schedulerName"]) + pod.delete("terminationGracePeriodSeconds") if + [nil, 30].include?(pod["terminationGracePeriodSeconds"]) + pod.delete("enableServiceLinks") if [nil, true].include?(pod["enableServiceLinks"]) + pod.delete("preemptionPolicy") if [nil, "PreemptLowerPriority"].include?(pod["preemptionPolicy"]) + %w[hostNetwork hostPID hostIPC shareProcessNamespace setHostnameAsFQDN].each do |key| + pod.delete(key) if [nil, false].include?(pod[key]) + end + pod +end +def normalize_job_spec(value) + spec = Marshal.load(Marshal.dump(value)) + spec.delete("selector") + spec.delete("manualSelector") if [nil, false].include?(spec["manualSelector"]) + spec.delete("parallelism") if [nil, 1].include?(spec["parallelism"]) + spec.delete("completions") if [nil, 1].include?(spec["completions"]) + spec.delete("completionMode") if [nil, "NonIndexed"].include?(spec["completionMode"]) + # API server default without a podFailurePolicy (Kubernetes 1.34+ stores it). + spec.delete("podReplacementPolicy") if + [nil, "TerminatingOrFailed"].include?(spec["podReplacementPolicy"]) + spec.delete("suspend") if [nil, false].include?(spec["suspend"]) + template = spec.fetch("template") + metadata = Marshal.load(Marshal.dump(template["metadata"] || {})) + labels = Marshal.load(Marshal.dump(metadata["labels"] || {})) + %w[batch.kubernetes.io/controller-uid batch.kubernetes.io/job-name + controller-uid job-name].each { |key| labels.delete(key) } + metadata["labels"] = labels + metadata.delete("annotations") if metadata["annotations"] == {} + template["metadata"] = metadata + template["spec"] = normalize_pod(template["spec"] || {}) + spec["template"] = template + spec +end +RUBY +if [ -z "$JOB_IDENTITY_RUBY" ]; then + printf '%s\n' "migration Job identity normalizer is empty" >&2 + exit 1 +fi + verify_active_job_identity() { local expected_file actual_file status expected_file="$(mktemp)" @@ -194,83 +275,9 @@ verify_active_job_identity() { rm -f "$expected_file" "$actual_file" return 1 fi - ruby -rjson -rdigest -e ' - # Same canonical form as build_job_manifest: the API server does not keep - # the submitted key order, so the identity digest must not depend on it. - def deep_sort(value) - case value - when Hash - value.keys.sort.each_with_object({}) do |key, acc| - acc[key] = deep_sort(value[key]) - end - when Array - value.map { |entry| deep_sort(entry) }.sort_by { |entry| JSON.generate(entry) } - else - value - end - end - normalize_container = lambda do |value| - container = Marshal.load(Marshal.dump(value)) - container.delete("terminationMessagePath") if - [nil, "/dev/termination-log"].include?(container["terminationMessagePath"]) - container.delete("terminationMessagePolicy") if - [nil, "File"].include?(container["terminationMessagePolicy"]) - %w[env envFrom ports volumeDevices volumeMounts].each do |key| - container.delete(key) if container[key] == [] - end - %w[resources securityContext].each do |key| - container.delete(key) if container[key].nil? || container[key] == {} - end - container - end - normalize_pod = lambda do |value| - pod = Marshal.load(Marshal.dump(value)) - containers = pod["containers"] || [] - abort("migration Job must contain exactly one migrate container") unless - containers.length == 1 && containers.first["name"] == "migrate" - pod["containers"] = containers.map { |item| normalize_container.call(item) } - %w[initContainers ephemeralContainers imagePullSecrets tolerations topologySpreadConstraints - readinessGates resourceClaims schedulingGates volumes].each do |key| - pod.delete(key) if pod[key] == [] - end - pod.delete("securityContext") if pod["securityContext"].nil? || pod["securityContext"] == {} - pod.delete("serviceAccount") if pod["serviceAccount"] == pod["serviceAccountName"] - pod.delete("dnsPolicy") if [nil, "ClusterFirst"].include?(pod["dnsPolicy"]) - pod.delete("schedulerName") if [nil, "default-scheduler"].include?(pod["schedulerName"]) - pod.delete("terminationGracePeriodSeconds") if - [nil, 30].include?(pod["terminationGracePeriodSeconds"]) - pod.delete("enableServiceLinks") if [nil, true].include?(pod["enableServiceLinks"]) - pod.delete("preemptionPolicy") if [nil, "PreemptLowerPriority"].include?(pod["preemptionPolicy"]) - %w[hostNetwork hostPID hostIPC shareProcessNamespace setHostnameAsFQDN].each do |key| - pod.delete(key) if [nil, false].include?(pod[key]) - end - pod - end - normalize_job_spec = lambda do |value| - spec = Marshal.load(Marshal.dump(value)) - spec.delete("selector") - spec.delete("manualSelector") if [nil, false].include?(spec["manualSelector"]) - spec.delete("parallelism") if [nil, 1].include?(spec["parallelism"]) - spec.delete("completions") if [nil, 1].include?(spec["completions"]) - spec.delete("completionMode") if [nil, "NonIndexed"].include?(spec["completionMode"]) - # API server default without a podFailurePolicy (Kubernetes 1.34+ stores it). - spec.delete("podReplacementPolicy") if - [nil, "TerminatingOrFailed"].include?(spec["podReplacementPolicy"]) - spec.delete("suspend") if [nil, false].include?(spec["suspend"]) - template = spec.fetch("template") - metadata = Marshal.load(Marshal.dump(template["metadata"] || {})) - labels = Marshal.load(Marshal.dump(metadata["labels"] || {})) - %w[batch.kubernetes.io/controller-uid batch.kubernetes.io/job-name - controller-uid job-name].each { |key| labels.delete(key) } - metadata["labels"] = labels - metadata.delete("annotations") if metadata["annotations"] == {} - template["metadata"] = metadata - template["spec"] = normalize_pod.call(template["spec"] || {}) - spec["template"] = template - spec - end + ruby -rjson -rdigest -e "$JOB_IDENTITY_RUBY" -e ' execution_contract = lambda do |job| - { "jobSpec" => normalize_job_spec.call(job["spec"] || {}) } + { "jobSpec" => normalize_job_spec(job["spec"] || {}) } end expected = JSON.parse(File.read(ARGV.fetch(0))) actual = JSON.parse(File.read(ARGV.fetch(1))) @@ -522,23 +529,11 @@ extract_control_api_image() { } build_job_manifest() { - ruby -ryaml -rjson -rdigest -e ' - # Canonical form for the configuration digest, shared with - # verify_active_job_identity (copied from verify-networkpolicies.sh). - # Array order is not part of the digest; verify_active_job_identity still - # compares the normalized contracts directly, which keeps it significant. - def deep_sort(value) - case value - when Hash - value.keys.sort.each_with_object({}) do |key, acc| - acc[key] = deep_sort(value[key]) - end - when Array - value.map { |entry| deep_sort(entry) }.sort_by { |entry| JSON.generate(entry) } - else - value - end - end + ruby -ryaml -rjson -rdigest -e "$JOB_IDENTITY_RUBY" -e ' + # Canonical form for the configuration digest is JOB_IDENTITY_RUBY, shared + # with verify_active_job_identity. Array order is not part of the digest; + # verify_active_job_identity still compares the normalized contracts + # directly, which keeps it significant. job_name = ENV.fetch("JOB_NAME") docs = [] YAML.load_stream(File.read(ARGV[0])) do |doc| @@ -645,73 +640,7 @@ build_job_manifest() { job["spec"]["template"]["spec"][key] = value if value end - normalize_container = lambda do |value| - normalized = Marshal.load(Marshal.dump(value)) - normalized.delete("terminationMessagePath") if - [nil, "/dev/termination-log"].include?(normalized["terminationMessagePath"]) - normalized.delete("terminationMessagePolicy") if - [nil, "File"].include?(normalized["terminationMessagePolicy"]) - %w[env envFrom ports volumeDevices volumeMounts].each do |key| - normalized.delete(key) if normalized[key] == [] - end - %w[resources securityContext].each do |key| - normalized.delete(key) if normalized[key].nil? || normalized[key] == {} - end - normalized - end - normalize_pod = lambda do |value| - normalized = Marshal.load(Marshal.dump(value)) - containers = normalized["containers"] || [] - abort("migration Job must contain exactly one migrate container") unless - containers.length == 1 && containers.first["name"] == "migrate" - normalized["containers"] = containers.map { |item| normalize_container.call(item) } - %w[initContainers ephemeralContainers imagePullSecrets tolerations topologySpreadConstraints - readinessGates resourceClaims schedulingGates volumes].each do |key| - normalized.delete(key) if normalized[key] == [] - end - normalized.delete("securityContext") if - normalized["securityContext"].nil? || normalized["securityContext"] == {} - normalized.delete("serviceAccount") if - normalized["serviceAccount"] == normalized["serviceAccountName"] - normalized.delete("dnsPolicy") if [nil, "ClusterFirst"].include?(normalized["dnsPolicy"]) - normalized.delete("schedulerName") if - [nil, "default-scheduler"].include?(normalized["schedulerName"]) - normalized.delete("terminationGracePeriodSeconds") if - [nil, 30].include?(normalized["terminationGracePeriodSeconds"]) - normalized.delete("enableServiceLinks") if - [nil, true].include?(normalized["enableServiceLinks"]) - normalized.delete("preemptionPolicy") if - [nil, "PreemptLowerPriority"].include?(normalized["preemptionPolicy"]) - %w[hostNetwork hostPID hostIPC shareProcessNamespace setHostnameAsFQDN].each do |key| - normalized.delete(key) if [nil, false].include?(normalized[key]) - end - normalized - end - normalize_job_spec = lambda do |value| - normalized = Marshal.load(Marshal.dump(value)) - normalized.delete("selector") - normalized.delete("manualSelector") if [nil, false].include?(normalized["manualSelector"]) - normalized.delete("parallelism") if [nil, 1].include?(normalized["parallelism"]) - normalized.delete("completions") if [nil, 1].include?(normalized["completions"]) - normalized.delete("completionMode") if - [nil, "NonIndexed"].include?(normalized["completionMode"]) - # API server default without a podFailurePolicy (Kubernetes 1.34+ stores it). - normalized.delete("podReplacementPolicy") if - [nil, "TerminatingOrFailed"].include?(normalized["podReplacementPolicy"]) - normalized.delete("suspend") if [nil, false].include?(normalized["suspend"]) - template = normalized.fetch("template") - metadata = Marshal.load(Marshal.dump(template["metadata"] || {})) - labels = Marshal.load(Marshal.dump(metadata["labels"] || {})) - %w[batch.kubernetes.io/controller-uid batch.kubernetes.io/job-name - controller-uid job-name].each { |key| labels.delete(key) } - metadata["labels"] = labels - metadata.delete("annotations") if metadata["annotations"] == {} - template["metadata"] = metadata - template["spec"] = normalize_pod.call(template["spec"] || {}) - normalized["template"] = template - normalized - end - configuration_contract = { "jobSpec" => normalize_job_spec.call(job.fetch("spec")) } + configuration_contract = { "jobSpec" => normalize_job_spec(job.fetch("spec")) } job["metadata"]["annotations"]["clerum.io/migration-configuration-sha256"] = Digest::SHA256.hexdigest(JSON.generate(deep_sort(configuration_contract))) diff --git a/scripts/tests/test-control-api-migration-bounds.sh b/scripts/tests/test-control-api-migration-bounds.sh index e89fa535c8..922ea60f68 100755 --- a/scripts/tests/test-control-api-migration-bounds.sh +++ b/scripts/tests/test-control-api-migration-bounds.sh @@ -368,6 +368,10 @@ if run_fixture active-env-order-mismatch >/dev/null; then fi mv "$fixture_root/reordered-matched.json" "$capture" +normalize_def_count="$(grep -c 'def normalize_job_spec' "$DEPLOY_FILE" || true)" +[ "$normalize_def_count" = "1" ] || \ + fail "expected one normalize_job_spec definition, found ${normalize_def_count}" + # The API server stores a defaulted Job, not the submitted manifest. These are the # fields a Kubernetes v1.35.1 apiserver added or dropped for this Job under # `create --dry-run=server` (T2 run1 on 92f6dc886 died on them): defaults are added From cd7112960a98e0d7b414aa1627f25b505bb58bab Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Tue, 3 Nov 2026 16:35:17 +0100 Subject: [PATCH 443/528] test(clients): pin the CG-6 GFS verdict and the 404 logout fence Desktop: drive the real `requestJson` and `GfsClient.grant` with a stubbed `fetch` so a 403 `{"error":"desktop_user_retired"}` composes the same IPC message the renderer classifies. A bare `forbidden` 403 is the negative control on the operation surface, and the single upstream call witnesses that the body came over the wire. Profile UI: add the base control-api 404 to the logout fence table, so a rollout-window logout keeps `revocationConfirmed:false`, persists the fence and still rejects `/api/v1/me` with `AuthExpiredError`. --- desktop-app/test/gfsUriHandler.test.ts | 51 ++++++++++++++++++- .../components/__tests__/api.logout.test.ts | 2 + profile-ui/package-lock.json | 4 +- profile-ui/package.json | 2 +- 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/desktop-app/test/gfsUriHandler.test.ts b/desktop-app/test/gfsUriHandler.test.ts index 8a91adaed8..1d5a8bcef3 100644 --- a/desktop-app/test/gfsUriHandler.test.ts +++ b/desktop-app/test/gfsUriHandler.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { isGfsSessionAuthorityFailure } from '@lib/gfsGrantErrors' import { GfsClient, type GfsTransport, @@ -7,7 +8,11 @@ import { parseGfsUri, parseSubjectKey, } from '../src/gfs/uriHandler.js' -import { ApiError } from '../src/httpClient.js' +import { ApiError, requestJson } from '../src/httpClient.js' + +afterEach(() => { + vi.unstubAllGlobals() +}) /** * P2-S06 — Desktop gfs:// resolve + read. Acceptance: a gfs:/// link @@ -345,6 +350,48 @@ describe('GfsClient.grant', () => { ).rejects.toThrow(/subjects_invalid.*invalidIndexes=\[0,2\]/) }) + it('keeps a typed session-authority verdict classifiable after the real transport composes the IPC message', async () => { + const fetchSpy = vi.fn( + async () => + new Response('{"error":"desktop_user_retired"}', { + status: 403, + statusText: 'Forbidden', + }) + ) + vi.stubGlobal('fetch', fetchSpy) + + let rejection: unknown + try { + await new GfsClient( + transport({ requestJson: requestJson as GfsTransport['requestJson'] }) + ).grant( + { + resourceId: RID, + subjects: [{ type: 'user', id: 'u2' }], + permissions: ['read'], + }, + 'tok' + ) + } catch (error) { + rejection = error + } + + // Witness: the body-derived reason came over the wire exactly once. + expect(fetchSpy).toHaveBeenCalledOnce() + expect(rejection).toBeInstanceOf(GfsUriError) + const message = (rejection as Error).message + expect(message).toBe('403 Forbidden: desktop_user_retired httpStatus=403') + expect(isGfsSessionAuthorityFailure(message, 'operation')).toBe(true) + // Negative control: the same status and message shape with an untyped code + // is a per-resource denial, so the operation surface keeps the session. + expect( + isGfsSessionAuthorityFailure( + message.replace('desktop_user_retired', 'forbidden'), + 'operation' + ) + ).toBe(false) + }) + it('keeps the server verdict and the original error when the body carries no structured fields', async () => { const original = new ApiError( '403 Forbidden: escalation_rejected', diff --git a/profile-ui/app/components/__tests__/api.logout.test.ts b/profile-ui/app/components/__tests__/api.logout.test.ts index d39b2fd293..6362b798b3 100644 --- a/profile-ui/app/components/__tests__/api.logout.test.ts +++ b/profile-ui/app/components/__tests__/api.logout.test.ts @@ -22,6 +22,7 @@ describe('Profile logout client boundary', () => { it.each([ ['503 response', () => Promise.resolve(new Response('', { status: 503 }))], + ['404 response', () => Promise.resolve(new Response('{"error":"Not Found"}', { status: 404 }))], ['transport failure', () => Promise.reject(new TypeError('network unavailable'))], ])('abandons local credentials after a logout %s', async (_caseName, response) => { vi.mocked(fetch).mockImplementationOnce(response) @@ -32,6 +33,7 @@ describe('Profile logout client boundary', () => { fencePersisted: true, }) + expect(window.localStorage.getItem('profile_logout_unconfirmed')).toBe('true') expect(window.localStorage.getItem('external_session_token')).toBeNull() await expect(apiGet('/api/v1/me')).rejects.toBeInstanceOf(AuthExpiredError) expect(fetch).toHaveBeenCalledOnce() diff --git a/profile-ui/package-lock.json b/profile-ui/package-lock.json index 252743a69d..5c5e9c7752 100644 --- a/profile-ui/package-lock.json +++ b/profile-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-profile-ui", - "version": "0.1.193", + "version": "0.1.194", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-profile-ui", - "version": "0.1.193", + "version": "0.1.194", "dependencies": { "@clerum/desktop-app-links": "file:../packages/desktop-app-links", "@clerum/frontend-components": "file:../packages/frontend-components", diff --git a/profile-ui/package.json b/profile-ui/package.json index 7c50e23d52..d7ea514aca 100644 --- a/profile-ui/package.json +++ b/profile-ui/package.json @@ -1,6 +1,6 @@ { "name": "clerum-profile-ui", - "version": "0.1.193", + "version": "0.1.194", "private": true, "scripts": { "dev": "next dev", From a98a641ed555ba7185fa89462469aab94e5bad97 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Tue, 3 Nov 2026 16:35:39 +0100 Subject: [PATCH 444/528] fix(profile-ui): restore the Download Evenfire link on accepted invitations Dev renders a Download Evenfire link for every accepted link with the password set; PR1 replaced that panel with Sign in only. Restore the releases URL, `getEvenfireDownloadUrl` and the anchor on the password-updated screen for invitations (not resets) and on the used screen for both purposes, as a secondary `cu-btn` so the Sign in action stays primary. Cover the invitation screen with the download link, the reset screen with its absence plus the Sign in witness, and both revisited states, and document the restored link in the Profile UI surface page. --- docs/surfaces/profile-ui.md | 5 +++-- .../InvitationClient.entryStates.test.tsx | 7 +++++++ .../__tests__/InvitationClient.reset.test.tsx | 9 +++++++++ profile-ui/app/constants/api.ts | 2 ++ .../[token]/InvitationActionPanel.tsx | 16 +++++++++++++++- .../app/invitations/[token]/InvitationClient.tsx | 10 +++++++++- profile-ui/lib/api.ts | 5 +++++ profile-ui/package-lock.json | 4 ++-- profile-ui/package.json | 2 +- 9 files changed, 53 insertions(+), 7 deletions(-) diff --git a/docs/surfaces/profile-ui.md b/docs/surfaces/profile-ui.md index 1aa15e3df2..00b8252089 100644 --- a/docs/surfaces/profile-ui.md +++ b/docs/surfaces/profile-ui.md @@ -38,8 +38,9 @@ Surfaces](README.md) for how the three consoles divide the platform. /api/v1/invitations/password-reset`) follows the same post-password contract. 5. **The person signs in with the new password** and lands on the profile home. - Profile UI does not hand off automatically; the member starts the - [Desktop App](desktop-app.md) and completes step 6 from there. + Profile UI offers a Download Evenfire link — not an automatic handoff. The + member installs the [Desktop App](desktop-app.md) themselves and completes + step 6 from there. 6. **The Desktop App completes the handoff later**, from its own sign-in screen: entering the invited email there makes the Desktop App look up the invitation profile and open Profile UI's `/desktop-setup?email=...` diff --git a/profile-ui/app/components/__tests__/InvitationClient.entryStates.test.tsx b/profile-ui/app/components/__tests__/InvitationClient.entryStates.test.tsx index dbe52d9ac3..6d67007959 100644 --- a/profile-ui/app/components/__tests__/InvitationClient.entryStates.test.tsx +++ b/profile-ui/app/components/__tests__/InvitationClient.entryStates.test.tsx @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render, screen } from '@testing-library/react' +import { EVENFIRE_RELEASES_URL } from '@constants/api' import { PROFILE_ROUTES } from '@constants/routes' import type { InvitationPreview } from '@/app/types/api' import { InvitationClient } from '../../invitations/[token]/InvitationClient' @@ -72,6 +73,9 @@ describe('InvitationClient entry states', () => { expect(screen.getByRole('link', { name: 'Sign in' }).getAttribute('href')).toBe( PROFILE_ROUTES.login({ email: baseInvitation.email }) ) + const downloadLink = screen.getByRole('link', { name: 'Download Evenfire' }) + expect(downloadLink.getAttribute('href')).toBe(EVENFIRE_RELEASES_URL) + expect(downloadLink.getAttribute('target')).toBe('_blank') expect(screen.queryByRole('link', { name: 'Request a new recovery link' })).toBeNull() }) @@ -94,6 +98,9 @@ describe('InvitationClient entry states', () => { expect( screen.getByRole('link', { name: 'Request a new recovery link' }).getAttribute('href') ).toBe(PROFILE_ROUTES.forgotPassword({ email: baseInvitation.email })) + const downloadLink = screen.getByRole('link', { name: 'Download Evenfire' }) + expect(downloadLink.getAttribute('href')).toBe(EVENFIRE_RELEASES_URL) + expect(downloadLink.getAttribute('target')).toBe('_blank') }) it('shows the not-found state for an invented token without a password form', () => { diff --git a/profile-ui/app/components/__tests__/InvitationClient.reset.test.tsx b/profile-ui/app/components/__tests__/InvitationClient.reset.test.tsx index 584b88620f..bbf1ec0655 100644 --- a/profile-ui/app/components/__tests__/InvitationClient.reset.test.tsx +++ b/profile-ui/app/components/__tests__/InvitationClient.reset.test.tsx @@ -31,6 +31,7 @@ const contract = JSON.parse( const mocks = vi.hoisted(() => ({ acceptInvitation: vi.fn(), + downloadUrl: 'https://downloads.example.invalid/evenfire', setupAcceptedInvitationPasswordWithToken: vi.fn(), setupInvitationPasswordWithToken: vi.fn(), })) @@ -40,6 +41,7 @@ vi.mock('@lib/api', async () => { return { ...actual, acceptInvitation: mocks.acceptInvitation, + getEvenfireDownloadUrl: () => mocks.downloadUrl, setupAcceptedInvitationPasswordWithToken: mocks.setupAcceptedInvitationPasswordWithToken, setupInvitationPasswordWithToken: mocks.setupInvitationPasswordWithToken, } @@ -162,6 +164,10 @@ describe('InvitationClient guided password reauthentication', () => { fireEvent.click(screen.getByRole('button', { name: 'Reset password' })) await waitFor(() => expectPasswordUpdatedScreen(passwordReset.email)) + // The recovery screen is not the post-invitation screen: no download link, + // and the Sign in link on the same screen is the liveness witness. + expect(screen.queryByRole('link', { name: 'Download Evenfire' })).toBeNull() + expect(screen.getByRole('link', { name: 'Sign in' })).toBeInTheDocument() expect(mocks.setupInvitationPasswordWithToken).toHaveBeenCalledWith( 'verified-reset-proof', passwordReset.email, @@ -185,6 +191,9 @@ describe('InvitationClient guided password reauthentication', () => { fireEvent.click(screen.getByRole('button', { name: 'Set password and continue' })) await waitFor(() => expectPasswordUpdatedScreen(acceptedMemberInvitation.email)) + const downloadLink = screen.getByRole('link', { name: 'Download Evenfire' }) + expect(downloadLink.getAttribute('href')).toBe(mocks.downloadUrl) + expect(downloadLink.getAttribute('target')).toBe('_blank') expect(mocks.setupAcceptedInvitationPasswordWithToken).toHaveBeenCalledWith( 'member-invitation-proof', acceptedMemberInvitation.email, diff --git a/profile-ui/app/constants/api.ts b/profile-ui/app/constants/api.ts index e8d3854323..7e3157e0d3 100644 --- a/profile-ui/app/constants/api.ts +++ b/profile-ui/app/constants/api.ts @@ -4,3 +4,5 @@ export const EXTERNAL_REST_API_BASE_URL = export const EXTERNAL_SESSION_TOKEN_KEY = 'external_session_token' export const EXTERNAL_SESSION_TOKEN_EXPIRES_AT_KEY = 'external_session_token_expires_at' export const EXTERNAL_SESSION_TOKEN_TTL_SECONDS = 60 * 60 * 24 + +export const EVENFIRE_RELEASES_URL = 'https://github.com/evenfire-ai/evenfire/releases' diff --git a/profile-ui/app/invitations/[token]/InvitationActionPanel.tsx b/profile-ui/app/invitations/[token]/InvitationActionPanel.tsx index b4874310d4..2b42e34f40 100644 --- a/profile-ui/app/invitations/[token]/InvitationActionPanel.tsx +++ b/profile-ui/app/invitations/[token]/InvitationActionPanel.tsx @@ -26,10 +26,16 @@ type InvitationActionPanelProps = event: MouseEvent | PointerEvent ) => void } - | { mode: 'password-updated'; loginHref: string } + | { + mode: 'password-updated' + loginHref: string + downloadUrl: string + isPasswordReset: boolean + } | { mode: 'used' loginHref: string + downloadUrl: string isPasswordReset: boolean forgotPasswordHref: string } @@ -126,6 +132,11 @@ export function InvitationActionPanel(props: InvitationActionPanelProps) { Sign in + {!props.isPasswordReset ? ( + + Download Evenfire + + ) : null}
) case 'used': @@ -137,6 +148,9 @@ export function InvitationActionPanel(props: InvitationActionPanelProps) { Sign in + + Download Evenfire + {props.isPasswordReset ? ( Request a new recovery link diff --git a/profile-ui/app/invitations/[token]/InvitationClient.tsx b/profile-ui/app/invitations/[token]/InvitationClient.tsx index 5aac807159..71ac1f3a3e 100644 --- a/profile-ui/app/invitations/[token]/InvitationClient.tsx +++ b/profile-ui/app/invitations/[token]/InvitationClient.tsx @@ -14,6 +14,7 @@ import { ProfileApiError, acceptInvitation, clearUnconfirmedLogout, + getEvenfireDownloadUrl, setupAcceptedInvitationPasswordWithToken, setupInvitationPasswordWithToken, } from '@lib/api' @@ -137,6 +138,7 @@ export function InvitationClient({ ? [invitation.teamName] : [] const hasMultipleTeams = teamNames.length > 1 + const downloadUrl = getEvenfireDownloadUrl() const profileLoginHref = PROFILE_ROUTES.login({ email: invitation?.email.trim().toLowerCase(), }) @@ -412,7 +414,12 @@ export function InvitationClient({ {!invitation ? ( ) : passwordUpdated ? ( - + ) : recoveryOutcomeUnknown ? ( diff --git a/profile-ui/lib/api.ts b/profile-ui/lib/api.ts index 1bc1a14077..dc7b694eaa 100644 --- a/profile-ui/lib/api.ts +++ b/profile-ui/lib/api.ts @@ -1,6 +1,7 @@ 'use client' import { + EVENFIRE_RELEASES_URL, EXTERNAL_REST_API_BASE_URL, EXTERNAL_SESSION_TOKEN_EXPIRES_AT_KEY, EXTERNAL_SESSION_TOKEN_KEY, @@ -541,3 +542,7 @@ export function getConfiguredExternalRestApiBaseUrl(): string { return EXTERNAL_REST_API_BASE_URL.replace(/\/+$/, '') } } + +export function getEvenfireDownloadUrl(): string { + return EVENFIRE_RELEASES_URL +} diff --git a/profile-ui/package-lock.json b/profile-ui/package-lock.json index 5c5e9c7752..0b09563066 100644 --- a/profile-ui/package-lock.json +++ b/profile-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-profile-ui", - "version": "0.1.194", + "version": "0.1.195", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-profile-ui", - "version": "0.1.194", + "version": "0.1.195", "dependencies": { "@clerum/desktop-app-links": "file:../packages/desktop-app-links", "@clerum/frontend-components": "file:../packages/frontend-components", diff --git a/profile-ui/package.json b/profile-ui/package.json index d7ea514aca..8af41238b8 100644 --- a/profile-ui/package.json +++ b/profile-ui/package.json @@ -1,6 +1,6 @@ { "name": "clerum-profile-ui", - "version": "0.1.194", + "version": "0.1.195", "private": true, "scripts": { "dev": "next dev", From 7fc7fda6a1663ee7b8c49681f9414a01aff6a2f6 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Tue, 3 Nov 2026 16:51:16 +0100 Subject: [PATCH 445/528] fix(external-rest-api): forward control-api error bodies as dev did Restore dev's error forwarding for M-10 (X-01..X-16). - The global handler forwards a ControlApiError's status and body verbatim, keeps the invalid-request envelope for ControlApiPathError, answers any other 4xx with its own message and keeps the sanitized 500. - control-api response headers are captured verbatim again (the seven dev names) plus cache-control: no-store on a 503, so the restored GFS relay can forward them. - The six simple routes, the GFS route and /rpc/token forward upstream bodies again; GFS keeps its own header relay and 5xx handling and rethrows a path error to the global rule. - The team, me and invitation services answer dev's not-found/not-pending results and rethrow only the typed 503; members restores the local invitation pre-validation with the 320-character bound first. - Add the mixed-version suite (head edge against the base producer) and pin it in CI, and add the GFS session-authority denial case for CG-6. --- .github/workflows/ci-public.yml | 2 +- external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- .../src/__tests__/publicErrorContract.test.ts | 92 ++---- external-rest-api/src/app.ts | 74 ++--- external-rest-api/src/controlApiClient.ts | 41 +-- external-rest-api/src/http/publicApiError.ts | 15 - external-rest-api/src/releaseManifest.ts | 2 +- .../src/routes/contextSharedFilesystems.ts | 17 +- external-rest-api/src/routes/gfs.ts | 57 +++- external-rest-api/src/routes/invitations.ts | 2 +- external-rest-api/src/routes/members.ts | 45 +-- external-rest-api/src/routes/notifications.ts | 24 +- external-rest-api/src/routes/oauthGrants.ts | 20 +- external-rest-api/src/routes/rpc.ts | 18 +- .../src/routes/userApprovalDecisions.ts | 20 +- .../src/routes/workflowApprovalMediums.ts | 31 +- external-rest-api/src/routes/workflows.ts | 30 +- .../src/services/invitationsService.ts | 19 +- external-rest-api/src/services/meService.ts | 8 +- external-rest-api/src/services/rpcService.ts | 31 +- external-rest-api/src/services/teamService.ts | 31 +- .../test/controlApiClient.test.ts | 33 --- .../test/globalPublicErrorHandler.test.ts | 158 ++++++---- .../test/meUnavailable.contract.test.ts | 28 +- .../test/mixedVersion.baseControlApi.test.ts | 277 ++++++++++++++++++ .../test/passwordAdmissionError.test.ts | 40 ++- .../profileUiPublicErrorCompatibility.test.ts | 87 +----- external-rest-api/test/routes.auth.test.ts | 6 +- .../routes.contextSharedFilesystems.test.ts | 28 +- external-rest-api/test/routes.gfs.test.ts | 111 ++++--- .../test/routes.invitations.test.ts | 37 +++ external-rest-api/test/routes.members.test.ts | 193 +++--------- .../routes.membersInvitationContract.test.ts | 221 -------------- .../test/routes.notifications.test.ts | 34 ++- .../test/routes.oauthGrants.test.ts | 7 +- external-rest-api/test/routes.rpc.test.ts | 58 +--- ...s.sessionBackendUnavailableNoStore.test.ts | 10 +- .../test/routes.userApprovalDecisions.test.ts | 28 +- .../routes.workflowApprovalMediums.test.ts | 12 +- .../test/routes.workflows.test.ts | 34 ++- .../test/services.invitationsService.test.ts | 16 + .../services.legacyAuthorityErrors.test.ts | 73 +++-- external-rest-api/test/services.rpc.test.ts | 16 +- 44 files changed, 953 insertions(+), 1139 deletions(-) create mode 100644 external-rest-api/test/mixedVersion.baseControlApi.test.ts delete mode 100644 external-rest-api/test/routes.membersInvitationContract.test.ts diff --git a/.github/workflows/ci-public.yml b/.github/workflows/ci-public.yml index 2523f533c6..07618476cd 100644 --- a/.github/workflows/ci-public.yml +++ b/.github/workflows/ci-public.yml @@ -146,7 +146,7 @@ jobs: pinned_suites: helpers.realPostgresTeardown.test.ts routes.mcp-host.llmProviderAttempts.test.ts passwordRecovery.http.contract.test.ts pinned_cases: ../scripts/ci/pinned-vitest-cases/control-api.json - service: external-rest-api - pinned_suites: invitations.passwordRecovery.public.test.ts + pinned_suites: invitations.passwordRecovery.public.test.ts mixedVersion.baseControlApi.test.ts - service: codex-llm-proxy pinned_suites: originPolicy.test.ts server.security.test.ts pinned_cases: ../scripts/ci/pinned-vitest-cases/codex-llm-proxy.json diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 15b21d691c..f8512f57eb 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.162", + "version": "0.1.163", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.162", + "version": "0.1.163", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index d9f30addd2..cffaeba044 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.162", + "version": "0.1.163", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/__tests__/publicErrorContract.test.ts b/external-rest-api/src/__tests__/publicErrorContract.test.ts index 038f24e0e8..820139f1df 100644 --- a/external-rest-api/src/__tests__/publicErrorContract.test.ts +++ b/external-rest-api/src/__tests__/publicErrorContract.test.ts @@ -20,9 +20,7 @@ describe('External REST public error contract', () => { afterEach(() => vi.unstubAllGlobals()) it('preserves only bounded public correlation IDs through the mounted global handler', async () => { - const valid = await request( - appThrowing(new ControlApiError('private', 400, { error: { code: 'invalid_request' } })) - ) + const valid = await request(appThrowing(new Error('private'))) .get('/failure') .set('x-correlation-id', 'request_ID-42') expect(valid.body.error.correlationId).toBe('request_ID-42') @@ -58,24 +56,19 @@ describe('External REST public error contract', () => { expect(JSON.stringify(response.body)).not.toContain('/var/run/internal') }) - it('preserves authority unavailability without reflecting the Control API body', async () => { + it('forwards a control-api 503 body verbatim and never the internal wrapper', async () => { const response = await request( appThrowing( - new ControlApiError('raw postgres failure', 503, { - error: `raw failure at ${privatePostgresSentinel()}`, + new ControlApiError('raw postgres failure at ' + privatePostgresSentinel(), 503, { + error: 'session_backend_unavailable', }) ) ).get('/failure') expect(response.status).toBe(503) - expect(response.body.error).toEqual({ - code: 'authority_unavailable', - message: 'Authorization is temporarily unavailable.', - correlationId: expect.any(String), - retryable: true, - }) + expect(response.body).toEqual({ error: 'session_backend_unavailable' }) expect(JSON.stringify(response.body)).not.toContain('postgres') - expect(JSON.stringify(response.body)).not.toContain('secret') + expect(JSON.stringify(response.body)).not.toContain('Control API') }) it('preserves bounded retry guidance for inadmissible V1 session issuance', async () => { @@ -114,17 +107,19 @@ describe('External REST public error contract', () => { expect(response.status).toBe(503) expect(response.headers['retry-after']).toBe('2') expect(response.headers['cache-control']).toBe('no-store') - expect(response.body.error).toEqual({ - code: 'session_issuance_temporarily_unavailable', - message: 'A session could not be issued right now. Try again in two seconds.', - correlationId: 'correlation_123', - retryable: true, + expect(response.body).toEqual({ + error: { + code: 'session_issuance_temporarily_unavailable', + message: 'private upstream message', + correlationId: 'correlation_123', + retryable: true, + }, }) expect(response.headers['x-internal-secret']).toBeUndefined() - expect(JSON.stringify(response.body)).not.toContain('private upstream') + expect(JSON.stringify(response.body)).not.toContain('Control API') }) - it('maps upstream throttling to the stable retryable rate-limit error', async () => { + it('forwards an upstream throttling body verbatim without relaying private state', async () => { const upstream = new ControlApiError( 'raw rate limiter state', 429, @@ -145,19 +140,16 @@ describe('External REST public error contract', () => { const response = await request(appThrowing(upstream)).get('/failure') expect(response.status).toBe(429) - expect(response.body.error).toEqual({ - code: 'rate_limited', - message: 'Too many requests; retry later.', - correlationId: expect.any(String), - retryable: true, - details: { retryAfterSeconds: 17 }, + expect(response.body).toEqual({ + error: { + code: 'rate_limited', + details: { retryAfterSeconds: 17, bucket: 'secret-internal-bucket' }, + }, }) - expect(response.headers['retry-after']).toBe('17') - expect(response.headers['x-ratelimit-limit']).toBe('30') - expect(response.headers['x-ratelimit-remaining']).toBe('0') - expect(response.headers['x-ratelimit-reset']).toBe('1900000000') + // The global handler relays outage headers for a 503 only; the GFS route + // keeps its own allowlist. The internal bucket never crosses. + expect(response.headers['retry-after']).toBeUndefined() expect(response.headers['x-internal-bucket']).toBeUndefined() - expect(JSON.stringify(response.body)).not.toContain('secret-internal-bucket') }) it('preserves only a bounded retry delay from legacy rate-limit bodies', () => { @@ -255,44 +247,6 @@ describe('External REST public error contract', () => { } ) - it('preserves bounded upload size through the real Control API client and sanitizer boundary', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue( - new Response( - JSON.stringify({ - error: { - code: 'payload_too_large', - message: 'private upstream upload detail', - }, - }), - { - status: 413, - headers: { - 'content-type': 'application/json', - 'upload-length': '209715200', - 'x-internal-secret': 'must-not-cross', - }, - } - ) - ) - ) - - const upstream = await controlApiRequest('PUT', '/external/gfs/resources/id/content').catch( - (error: unknown) => error - ) - expect(upstream).toBeInstanceOf(ControlApiError) - - const sanitized = sanitizeControlApiPublicError(upstream, new Set([413])) - expect(sanitized).toMatchObject({ - status: 413, - headers: { 'upload-length': '209715200' }, - body: { error: { code: 'payload_too_large', retryable: false } }, - }) - expect(JSON.stringify(sanitized)).not.toContain('private upstream') - expect(JSON.stringify(sanitized)).not.toContain('must-not-cross') - }) - it('preserves only bounded access-path descriptors for an ambiguity response', () => { const pathId = `ap1_${'a'.repeat(43)}` const sanitized = sanitizeControlApiPublicError( diff --git a/external-rest-api/src/app.ts b/external-rest-api/src/app.ts index 4ca1d66ac2..c04de5cb82 100644 --- a/external-rest-api/src/app.ts +++ b/external-rest-api/src/app.ts @@ -1,13 +1,10 @@ import express, { NextFunction, Request, Response } from 'express' import cors from 'cors' +import { STATUS_CODES } from 'node:http' import { config } from './config.js' +import { ControlApiError, ControlApiPathError } from './controlApiClient.js' import { invitationPasswordErrorHandler } from './http/passwordAdmissionError.js' -import { - publicCorrelationId, - sanitizeSupportedControlApiPublicError, - sendPublicApiError, - sendSanitizedControlApiPublicError, -} from './http/publicApiError.js' +import { sendPublicApiError } from './http/publicApiError.js' import { requireTrustedBrowserMutation } from './middleware/browserMutationGuard.js' import { withExternalRequestContext } from './requestContext.js' import { createAccessRouter } from './routes/access.js' @@ -109,53 +106,44 @@ export function externalRestPublicErrorHandler( res: Response, _next: NextFunction ): void { - const propagatedError = sanitizeSupportedControlApiPublicError(err, publicCorrelationId(req)) - if (propagatedError) { - sendSanitizedControlApiPublicError(res, propagatedError) + // 1. A caller-influenced control-api path is this edge's own client error. It + // must be answered before the ControlApiError rule because + // ControlApiPathError extends ControlApiError. + if (err instanceof ControlApiPathError) { + sendPublicApiError(req, res, 400, 'invalid_request', 'The request is not valid.') return } + // 2. Forward control-api's own status and body. Never `String(err.message)`: + // for a ControlApiError that is the internal request wrapper, which exposes + // control-api paths and ids (#190). + if (err instanceof ControlApiError) { + if (err.status === 503) { + const retryAfter = err.headers?.['retry-after'] + if (retryAfter) res.setHeader('retry-after', retryAfter) + if (err.headers?.['cache-control']?.trim().toLowerCase() === 'no-store') { + res.setHeader('cache-control', 'no-store') + } + } + if (err.body !== null && typeof err.body === 'object') { + res.status(err.status).json(err.body) + return + } + res.status(err.status).json({ error: STATUS_CODES[err.status] }) + return + } + + // 3. Any other client error keeps dev's message (body-parser SyntaxError 400, + // `entity.too.large` 413). const status = err instanceof Error && typeof (err as Error & { status?: unknown }).status === 'number' ? (err as Error & { status: number }).status : undefined if (status !== undefined && status >= 400 && status < 500) { - const code = - status === 401 - ? 'invalid_session' - : status === 403 - ? 'forbidden' - : status === 404 - ? 'not_found' - : status === 429 - ? 'rate_limited' - : 'invalid_request' - sendPublicApiError( - req, - res, - status, - code, - status === 404 - ? 'The resource was not found.' - : status === 429 - ? 'Too many requests; retry later.' - : 'The request could not be completed.', - status === 429 - ) - return - } - - if (status === 503) { - sendPublicApiError( - req, - res, - 503, - 'authority_unavailable', - 'Authorization is temporarily unavailable.', - true - ) + res.status(status).json({ error: err instanceof Error ? err.message : 'Bad Request' }) return } + // 4. Everything else keeps the sanitized 500 envelope. sendPublicApiError(req, res, 500, 'internal_error', 'The request could not be completed.') } diff --git a/external-rest-api/src/controlApiClient.ts b/external-rest-api/src/controlApiClient.ts index f8a43a61e0..da1ac941eb 100644 --- a/external-rest-api/src/controlApiClient.ts +++ b/external-rest-api/src/controlApiClient.ts @@ -126,36 +126,25 @@ export function assertControlApiRawQueryAllowed(rawQueryString: string): void { if (RAW_QUERY_CONTROL_CHARACTER.test(rawQueryString)) throw new ControlApiPathError() } -const SAFE_RATE_LIMIT_RESPONSE_HEADERS = [ +const FORWARDABLE_RESPONSE_HEADERS = [ 'retry-after', + 'ratelimit', + 'ratelimit-policy', 'x-ratelimit-limit', 'x-ratelimit-remaining', 'x-ratelimit-reset', + 'x-request-id', ] as const -function safeForwardableResponseHeaders(headers: Headers, status: number): Record { +function forwardableResponseHeaders(headers: Headers, status: number): Record { const result: Record = {} - for (const name of SAFE_RATE_LIMIT_RESPONSE_HEADERS) { - const raw = headers.get(name) - if (!raw || !/^\d{1,10}$/.test(raw)) continue - const value = Number(raw) - if (!Number.isSafeInteger(value) || value < 0) continue - if (name === 'retry-after' && (value < 1 || value > 86_400)) continue - result[name] = String(value) - } - const requestId = headers.get('x-request-id') - if (requestId && /^[A-Za-z0-9_-]{1,128}$/.test(requestId)) { - result['x-request-id'] = requestId - } - if (status === 413) { - const rawUploadLength = headers.get('upload-length') - if (rawUploadLength && /^\d{1,16}$/.test(rawUploadLength)) { - const uploadLength = Number(rawUploadLength) - if (Number.isSafeInteger(uploadLength) && uploadLength >= 0) { - result['upload-length'] = String(uploadLength) - } - } + for (const name of FORWARDABLE_RESPONSE_HEADERS) { + const value = headers.get(name) + if (value) result[name] = value } + // Caching guidance is an outage contract, not free-form metadata: control-api + // sends `no-store` on a session-backend 503 so no intermediary caches a + // transient authority failure. Only that literal value is relayed. if (status === 503 && headers.get('cache-control')?.trim().toLowerCase() === 'no-store') { result['cache-control'] = 'no-store' } @@ -263,7 +252,7 @@ export async function controlApiRequestWithStatus( `Control API ${method} ${path} returned non-JSON body (status=${response.status})`, 502, { error: 'Upstream returned non-JSON body', status: response.status }, - safeForwardableResponseHeaders(response.headers, 502) + forwardableResponseHeaders(response.headers, 502) ) } @@ -276,7 +265,7 @@ export async function controlApiRequestWithStatus( `Control API ${method} ${path} failed (${response.status}): ${errorMessage}`, response.status, parsed, - safeForwardableResponseHeaders(response.headers, response.status) + forwardableResponseHeaders(response.headers, response.status) ) } @@ -333,7 +322,7 @@ export async function controlApiBinaryRequestWithStatus( `Control API ${method} ${path} failed (${response.status})`, response.status, parsed, - safeForwardableResponseHeaders(response.headers, response.status) + forwardableResponseHeaders(response.headers, response.status) ) } @@ -401,7 +390,7 @@ export async function controlApiStreamRequest( `Control API ${method} ${path} failed (${response.status})`, response.status, parsed, - safeForwardableResponseHeaders(response.headers, response.status) + forwardableResponseHeaders(response.headers, response.status) ) } return response diff --git a/external-rest-api/src/http/publicApiError.ts b/external-rest-api/src/http/publicApiError.ts index 138ec11210..895595dfee 100644 --- a/external-rest-api/src/http/publicApiError.ts +++ b/external-rest-api/src/http/publicApiError.ts @@ -33,10 +33,6 @@ const PUBLIC_CODE_BY_STATUS: Readonly> = { 507: 'insufficient_storage', } -const SUPPORTED_CONTROL_API_PUBLIC_STATUSES = new Set( - Object.keys(PUBLIC_CODE_BY_STATUS).map(Number) -) - const PUBLIC_MESSAGE_BY_CODE: Readonly> = { invalid_request: 'The request is not valid.', invalid_session: 'The session is not valid.', @@ -277,17 +273,6 @@ export function sanitizeControlApiPublicError( } } -export function sanitizeSupportedControlApiPublicError( - error: unknown, - fallbackCorrelationId = '' -): SanitizedControlApiPublicError | null { - return sanitizeControlApiPublicError( - error, - SUPPORTED_CONTROL_API_PUBLIC_STATUSES, - fallbackCorrelationId - ) -} - export function sendSanitizedControlApiPublicError( res: Response, sanitized: SanitizedControlApiPublicError diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index e884fc8efd..4c7a281172 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.12.0', - externalRestApiVersion: '0.1.162', + externalRestApiVersion: '0.1.163', rpcProxyVersion: '0.1.138', desktopVersion: '0.12.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/src/routes/contextSharedFilesystems.ts b/external-rest-api/src/routes/contextSharedFilesystems.ts index ecc92cb570..034c947da5 100644 --- a/external-rest-api/src/routes/contextSharedFilesystems.ts +++ b/external-rest-api/src/routes/contextSharedFilesystems.ts @@ -1,7 +1,6 @@ import type { NextFunction, Response } from 'express' import { Router } from 'express' import { controlApiRequest, controlApiStreamRequest } from '../controlApiClient.js' -import { publicCorrelationId, sanitizeControlApiPublicError } from '../http/publicApiError.js' import { type AuthedRequest, extractAuthToken, requireAuth } from '../middleware/auth.js' /** @@ -22,7 +21,6 @@ export type ContextSharedFilesystemSummary = { message: string | null } -const PROPAGATED_STATUSES = new Set([400, 403, 404, 409, 410, 422]) const PASSTHROUGH_HEADERS = [ 'retry-after', 'ratelimit', @@ -33,19 +31,6 @@ const PASSTHROUGH_HEADERS = [ 'x-request-id', ] as const -function forwardControlApiError(error: unknown, res: Response, next: NextFunction): void { - const sanitized = sanitizeControlApiPublicError( - error, - PROPAGATED_STATUSES, - publicCorrelationId(res.req) - ) - if (sanitized) { - res.status(sanitized.status).json(sanitized.body) - return - } - next(error) -} - export function createContextSharedFilesystemsRouter(): Router { const router = Router() @@ -74,7 +59,7 @@ export function createContextSharedFilesystemsRouter(): Router { ) res.status(200).json(data) } catch (error) { - forwardControlApiError(error, res, next) + next(error) } } ) diff --git a/external-rest-api/src/routes/gfs.ts b/external-rest-api/src/routes/gfs.ts index 8978cbfede..87cce6345c 100644 --- a/external-rest-api/src/routes/gfs.ts +++ b/external-rest-api/src/routes/gfs.ts @@ -2,16 +2,17 @@ import { Router } from 'express' import type { NextFunction, Request, Response } from 'express' import { type AugmentedRequest, ipKeyGenerator, rateLimit } from 'express-rate-limit' import { randomUUID } from 'node:crypto' +import { STATUS_CODES } from 'node:http' import { isIP } from 'node:net' import { Readable } from 'node:stream' import { pipeline } from 'node:stream/promises' import { config } from '../config.js' -import { controlApiRequest, controlApiStreamRequest } from '../controlApiClient.js' import { - publicCorrelationId, - sanitizeControlApiPublicError, - sendSanitizedControlApiPublicError, -} from '../http/publicApiError.js' + ControlApiError, + ControlApiPathError, + controlApiRequest, + controlApiStreamRequest, +} from '../controlApiClient.js' import { type AuthedRequest, extractAuthToken, requireAuth } from '../middleware/auth.js' type GfsRouterOptions = { @@ -36,6 +37,15 @@ type GfsRouterOptions = { // explicit; every 5xx is safe to forward because it is already a ControlApiError // produced by the authenticated control-plane request. const PROPAGATED = new Set([400, 401, 403, 404, 408, 409, 410, 411, 412, 413, 422, 425, 429, 507]) +const CONTROL_API_ERROR_HEADERS = [ + 'retry-after', + 'ratelimit', + 'ratelimit-policy', + 'x-ratelimit-limit', + 'x-ratelimit-remaining', + 'x-ratelimit-reset', + 'x-request-id', +] as const const STREAM_HEADERS = [ 'content-type', 'content-length', @@ -135,17 +145,32 @@ function gfsControlApiRequest( } function forwardControlApiError(error: unknown, res: Response, next: NextFunction): void { - const statuses = - error instanceof Error && - 'status' in error && - typeof (error as { status?: unknown }).status === 'number' && - (error as { status: number }).status >= 500 && - (error as { status: number }).status <= 599 - ? new Set([...PROPAGATED, (error as { status: number }).status]) - : PROPAGATED - const sanitized = sanitizeControlApiPublicError(error, statuses, publicCorrelationId(res.req)) - if (sanitized) { - sendSanitizedControlApiPublicError(res, sanitized) + // A caller-influenced path stays the edge's own 400 envelope (rule 1 of the + // global handler) instead of the flat `{error:'invalid_request'}` body. + if (error instanceof ControlApiPathError) { + next(error) + return + } + const shouldPropagate = + error instanceof ControlApiError && + (PROPAGATED.has(error.status) || (error.status >= 500 && error.status <= 599)) + if (shouldPropagate) { + // The internal client has already filtered this map. Keep the allowlist at + // this last hop too so a hand-constructed/mocked ControlApiError cannot + // accidentally turn arbitrary internal response headers into public ones. + for (const name of CONTROL_API_ERROR_HEADERS) { + const value = error.headers?.[name] + if (value) res.setHeader(name, value) + } + const body = + error.body && typeof error.body === 'object' + ? error.body + : { error: STATUS_CODES[error.status] } + for (const header of STREAM_HEADERS) { + const value = error.headers?.[header] + if (value) res.setHeader(header, value) + } + res.status(error.status).json(body) return } next(error) diff --git a/external-rest-api/src/routes/invitations.ts b/external-rest-api/src/routes/invitations.ts index 4cffc77d1e..c812daf291 100644 --- a/external-rest-api/src/routes/invitations.ts +++ b/external-rest-api/src/routes/invitations.ts @@ -281,7 +281,7 @@ async function completePasswordReset( return } if (!result.data) { - res.status(502).json({ error: 'Password recovery service returned an invalid response' }) + res.status(502).json({ error: 'Failed to restore account session' }) return } clearProfileSessionCookie(req, res) diff --git a/external-rest-api/src/routes/members.ts b/external-rest-api/src/routes/members.ts index 319418ca7c..398f834fc8 100644 --- a/external-rest-api/src/routes/members.ts +++ b/external-rest-api/src/routes/members.ts @@ -18,6 +18,9 @@ import { import { TEAM_ROLES, TeamRole } from '../types.js' const MAX_INVITATION_EMAIL_LENGTH = 320 +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ +const MAX_INVITATION_TEAM_ASSIGNMENTS = 50 +const MAX_INVITEE_NAME_LENGTH = 120 const UUID_ANY_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i const MEMBER_RETIREMENT_EDGE_RATE_LIMIT_PER_MINUTE = 30 const MEMBER_RETIREMENT_SUBJECT_RATE_LIMIT_PER_MINUTE = 30 @@ -106,12 +109,6 @@ function validateMemberRetirementRequest( next() } -function isControlApiBadRequest(error: unknown, code: string): boolean { - if (!(error instanceof ControlApiError) || error.status !== 400) return false - if (!error.body || typeof error.body !== 'object') return false - return (error.body as { error?: unknown }).error === code -} - export function createMembersRouter(): Router { const router = Router() @@ -154,34 +151,44 @@ export function createMembersRouter(): Router { router.post('/members/invite', requireAuth, async (req: AuthedRequest, res, next) => { try { + // The 320-character bound comes first: EMAIL_PATTERN alone accepts longer + // addresses, and control-api rejects them. const rawEmail = req.body?.email if (typeof rawEmail !== 'string' || rawEmail.length > MAX_INVITATION_EMAIL_LENGTH) { res.status(400).json({ error: 'Valid email is required' }) return } const email = rawEmail.trim().toLowerCase() - res - .status(201) - .json( - await inviteManagedMember(email, req.body?.name, req.body?.teams, extractAuthToken(req)) - ) - } catch (error) { - if (isControlApiBadRequest(error, 'invalid_email')) { + const name = String(req.body?.name || '').trim() + const teams: unknown[] = Array.isArray(req.body?.teams) ? req.body.teams : [] + if (!EMAIL_PATTERN.test(email)) { res.status(400).json({ error: 'Valid email is required' }) return } - if (isControlApiBadRequest(error, 'invalid_payload')) { - res.status(400).json({ error: 'Email and at least one team are required' }) - return - } - if (isControlApiBadRequest(error, 'invalid_name')) { + if (name.length > MAX_INVITEE_NAME_LENGTH) { res.status(400).json({ error: 'Name is too long' }) return } - if (isControlApiBadRequest(error, 'too_many_teams')) { + if (teams.length > MAX_INVITATION_TEAM_ASSIGNMENTS) { res.status(400).json({ error: 'Too many teams selected' }) return } + const assignments = teams + .map((item: unknown) => { + const row = item && typeof item === 'object' ? (item as Record) : {} + const teamId = String(row.teamId || row.id || '').trim() + const role = String(row.role || 'member').trim() as TeamRole + return teamId && TEAM_ROLES.includes(role) ? { teamId, role } : null + }) + .filter((item): item is { teamId: string; role: TeamRole } => Boolean(item)) + if (!email || assignments.length === 0) { + res.status(400).json({ error: 'Email and at least one team are required' }) + return + } + res + .status(201) + .json(await inviteManagedMember(email, name, assignments, extractAuthToken(req))) + } catch (error) { const message = error instanceof Error ? error.message : '' if (message.includes('(403)')) { res.status(403).json({ error: 'You are not allowed to invite with those permissions' }) diff --git a/external-rest-api/src/routes/notifications.ts b/external-rest-api/src/routes/notifications.ts index 3d4fd80c7b..2c9ff39df3 100644 --- a/external-rest-api/src/routes/notifications.ts +++ b/external-rest-api/src/routes/notifications.ts @@ -1,24 +1,8 @@ import type { NextFunction, Response } from 'express' import { Router } from 'express' import { controlApiStreamRequest } from '../controlApiClient.js' -import { publicCorrelationId, sanitizeControlApiPublicError } from '../http/publicApiError.js' import { type AuthedRequest, extractAuthToken, requireAuth } from '../middleware/auth.js' -const PROPAGATED_STATUSES = new Set([400, 401, 403, 404, 409, 410, 422]) - -function forwardControlApiError(error: unknown, res: Response, next: NextFunction): void { - const sanitized = sanitizeControlApiPublicError( - error, - PROPAGATED_STATUSES, - publicCorrelationId(res.req) - ) - if (sanitized) { - res.status(sanitized.status).json(sanitized.body) - return - } - next(error) -} - function setStreamHeaders(res: Response): void { res.status(200) res.setHeader('content-type', 'application/x-ndjson; charset=utf-8') @@ -70,7 +54,7 @@ export function createNotificationsRouter(): Router { res.end() return } - forwardControlApiError(error, res, next) + next(error) } finally { req.off('close', onClose) req.off('aborted', onClose) @@ -89,7 +73,7 @@ export function createNotificationsRouter(): Router { ) res.status(200).json(result) } catch (error) { - forwardControlApiError(error, res, next) + next(error) } }) @@ -102,7 +86,7 @@ export function createNotificationsRouter(): Router { }) res.status(200).json(result) } catch (error) { - forwardControlApiError(error, res, next) + next(error) } }) @@ -116,7 +100,7 @@ export function createNotificationsRouter(): Router { }) res.status(200).json(result) } catch (error) { - forwardControlApiError(error, res, next) + next(error) } }) diff --git a/external-rest-api/src/routes/oauthGrants.ts b/external-rest-api/src/routes/oauthGrants.ts index 5902a313af..c7a1b530d3 100644 --- a/external-rest-api/src/routes/oauthGrants.ts +++ b/external-rest-api/src/routes/oauthGrants.ts @@ -1,6 +1,5 @@ import type { NextFunction, Response } from 'express' import { Router } from 'express' -import { publicCorrelationId, sanitizeControlApiPublicError } from '../http/publicApiError.js' import { type AuthedRequest, extractAuthToken, requireAuth } from '../middleware/auth.js' import { type OauthGrantSummary, @@ -8,8 +7,6 @@ import { revokeOauthGrant, } from '../services/oauthGrantsService.js' -const PROPAGATED_STATUSES = new Set([400, 401, 403, 404, 409, 422]) - /** * Project a control-api grant onto the exact public wire shape (spec 04 U2). * This is an explicit allowlist — NOT a blind spread — so a new field added to @@ -30,19 +27,6 @@ function toGrantView(g: OauthGrantSummary): OauthGrantSummary { return view } -function forwardControlApiError(error: unknown, res: Response, next: NextFunction): void { - const sanitized = sanitizeControlApiPublicError( - error, - PROPAGATED_STATUSES, - publicCorrelationId(res.req) - ) - if (sanitized) { - res.status(sanitized.status).json(sanitized.body) - return - } - next(error) -} - export function createOauthGrantsRouter(): Router { const router = Router() @@ -51,7 +35,7 @@ export function createOauthGrantsRouter(): Router { const { grants } = await listOauthGrants(extractAuthToken(req)) res.json({ grants: grants.map(toGrantView) }) } catch (err) { - forwardControlApiError(err, res, next) + next(err) } }) @@ -71,7 +55,7 @@ export function createOauthGrantsRouter(): Router { ) res.status(204).send() } catch (err) { - forwardControlApiError(err, res, next) + next(err) } } ) diff --git a/external-rest-api/src/routes/rpc.ts b/external-rest-api/src/routes/rpc.ts index 2ddad4a8cd..3bf9598e64 100644 --- a/external-rest-api/src/routes/rpc.ts +++ b/external-rest-api/src/routes/rpc.ts @@ -1,10 +1,7 @@ import { Router } from 'express' -import { publicCorrelationId, sanitizeControlApiPublicError } from '../http/publicApiError.js' import { type AuthedRequest, extractAuthToken, requireAuth } from '../middleware/auth.js' import { issueRpcAccessToken } from '../services/rpcService.js' -const RPC_PUBLIC_STATUSES = new Set([403]) - export function createRpcRouter(): Router { const router = Router() @@ -21,17 +18,14 @@ export function createRpcRouter(): Router { req.body?.hostRefs, req.ip ) - res.status(200).json(result) - } catch (error) { - const sanitized = sanitizeControlApiPublicError( - error, - RPC_PUBLIC_STATUSES, - publicCorrelationId(req) - ) - if (sanitized) { - res.status(sanitized.status).json(sanitized.body) + if ('error' in result) { + // Relay control-api's specific reason (e.g. desktop_requires_team) so the + // desktop app can act on it instead of surfacing an opaque "no access". + res.status(403).json({ error: result.error }) return } + res.status(200).json(result) + } catch (error) { next(error) } }) diff --git a/external-rest-api/src/routes/userApprovalDecisions.ts b/external-rest-api/src/routes/userApprovalDecisions.ts index d6f1e4ecd3..934fe74c63 100644 --- a/external-rest-api/src/routes/userApprovalDecisions.ts +++ b/external-rest-api/src/routes/userApprovalDecisions.ts @@ -1,27 +1,11 @@ import type { NextFunction, Response } from 'express' import { Router } from 'express' -import { publicCorrelationId, sanitizeControlApiPublicError } from '../http/publicApiError.js' import { type AuthedRequest, extractAuthToken, requireAuth } from '../middleware/auth.js' import { decideUserApprovalDecision, listPendingUserApprovalDecisions, } from '../services/userApprovalDecisionsService.js' -const PROPAGATED_STATUSES = new Set([400, 403, 404, 409, 410, 422]) - -function forwardControlApiError(error: unknown, res: Response, next: NextFunction): void { - const sanitized = sanitizeControlApiPublicError( - error, - PROPAGATED_STATUSES, - publicCorrelationId(res.req) - ) - if (sanitized) { - res.status(sanitized.status).json(sanitized.body) - return - } - next(error) -} - export function createUserApprovalDecisionsRouter(): Router { const router = Router() @@ -36,7 +20,7 @@ export function createUserApprovalDecisionsRouter(): Router { } res.status(200).json(await listPendingUserApprovalDecisions(sessionToken, parsedLimit)) } catch (error) { - forwardControlApiError(error, res, next) + next(error) } }) @@ -66,7 +50,7 @@ export function createUserApprovalDecisionsRouter(): Router { .status(200) .json(await decideUserApprovalDecision(sessionToken, approvalId, decision, note)) } catch (error) { - forwardControlApiError(error, res, next) + next(error) } } ) diff --git a/external-rest-api/src/routes/workflowApprovalMediums.ts b/external-rest-api/src/routes/workflowApprovalMediums.ts index fec1839ef4..a3ad1225df 100644 --- a/external-rest-api/src/routes/workflowApprovalMediums.ts +++ b/external-rest-api/src/routes/workflowApprovalMediums.ts @@ -1,6 +1,5 @@ import type { NextFunction, Response } from 'express' import { Router } from 'express' -import { publicCorrelationId, sanitizeControlApiPublicError } from '../http/publicApiError.js' import { type AuthedRequest, extractAuthToken, requireAuth } from '../middleware/auth.js' import { confirmWorkflowApprovalMediumChallenge, @@ -13,24 +12,10 @@ import { updateWorkflowApprovalMediumDisplayName, } from '../services/workflowApprovalMediumsService.js' -const PROPAGATED_STATUSES = new Set([400, 401, 403, 404, 409, 422]) const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i const CODE_RE = /^\d{6}$/ const DISPLAY_NAME_MAX_LENGTH = 120 -function forwardControlApiError(error: unknown, res: Response, next: NextFunction): void { - const sanitized = sanitizeControlApiPublicError( - error, - PROPAGATED_STATUSES, - publicCorrelationId(res.req) - ) - if (sanitized) { - res.status(sanitized.status).json(sanitized.body) - return - } - next(error) -} - function optionalString(value: unknown): string | null { if (value === undefined || value === null) return null const normalized = String(value).trim() @@ -72,7 +57,7 @@ export function createWorkflowApprovalMediumsRouter(): Router { }) res.status(202).json(result) } catch (error) { - forwardControlApiError(error, res, next) + next(error) } } ) @@ -122,7 +107,7 @@ export function createWorkflowApprovalMediumsRouter(): Router { }) res.status(202).json(result) } catch (error) { - forwardControlApiError(error, res, next) + next(error) } } ) @@ -149,7 +134,7 @@ export function createWorkflowApprovalMediumsRouter(): Router { .status(200) .json(await confirmWorkflowApprovalMediumChallenge(sessionToken, challengeId, code)) } catch (error) { - forwardControlApiError(error, res, next) + next(error) } } ) @@ -161,7 +146,7 @@ export function createWorkflowApprovalMediumsRouter(): Router { .status(200) .json(await listWorkflowApprovalMediums(extractAuthToken(req), { includeDisabled })) } catch (error) { - forwardControlApiError(error, res, next) + next(error) } }) @@ -172,7 +157,7 @@ export function createWorkflowApprovalMediumsRouter(): Router { try { res.status(200).json(await listApprovalChannelTargets(extractAuthToken(req))) } catch (error) { - forwardControlApiError(error, res, next) + next(error) } } ) @@ -189,7 +174,7 @@ export function createWorkflowApprovalMediumsRouter(): Router { } res.status(200).json(await preferWorkflowApprovalMedium(extractAuthToken(req), accountId)) } catch (error) { - forwardControlApiError(error, res, next) + next(error) } } ) @@ -224,7 +209,7 @@ export function createWorkflowApprovalMediumsRouter(): Router { res.status(400).json({ error: error.message }) return } - forwardControlApiError(error, res, next) + next(error) } } ) @@ -242,7 +227,7 @@ export function createWorkflowApprovalMediumsRouter(): Router { await disableWorkflowApprovalMedium(extractAuthToken(req), accountId) res.status(204).send() } catch (error) { - forwardControlApiError(error, res, next) + next(error) } } ) diff --git a/external-rest-api/src/routes/workflows.ts b/external-rest-api/src/routes/workflows.ts index 29e8685c30..e2a7fd7a34 100644 --- a/external-rest-api/src/routes/workflows.ts +++ b/external-rest-api/src/routes/workflows.ts @@ -5,24 +5,8 @@ import { controlApiRequest, controlApiRequestWithStatus, } from '../controlApiClient.js' -import { publicCorrelationId, sanitizeControlApiPublicError } from '../http/publicApiError.js' import { type AuthedRequest, extractAuthToken, requireAuth } from '../middleware/auth.js' -const PROPAGATED_STATUSES = new Set([400, 403, 404, 409, 410, 422]) - -function forwardControlApiError(error: unknown, res: Response, next: NextFunction): void { - const sanitized = sanitizeControlApiPublicError( - error, - PROPAGATED_STATUSES, - publicCorrelationId(res.req) - ) - if (sanitized) { - res.status(sanitized.status).json(sanitized.body) - return - } - next(error) -} - export function createExternalWorkflowsRouter(): Router { const router = Router() @@ -37,7 +21,7 @@ export function createExternalWorkflowsRouter(): Router { }) res.status(200).json(result) } catch (error) { - forwardControlApiError(error, res, next) + next(error) } } ) @@ -63,7 +47,7 @@ export function createExternalWorkflowsRouter(): Router { ) res.status(200).json(result) } catch (error) { - forwardControlApiError(error, res, next) + next(error) } } ) @@ -89,7 +73,7 @@ export function createExternalWorkflowsRouter(): Router { ) res.status(200).json(result) } catch (error) { - forwardControlApiError(error, res, next) + next(error) } } ) @@ -122,7 +106,7 @@ export function createExternalWorkflowsRouter(): Router { ) res.status(status).json(data) } catch (error) { - forwardControlApiError(error, res, next) + next(error) } } ) @@ -151,7 +135,7 @@ export function createExternalWorkflowsRouter(): Router { ) res.status(200).json(result) } catch (error) { - forwardControlApiError(error, res, next) + next(error) } } ) @@ -178,7 +162,7 @@ export function createExternalWorkflowsRouter(): Router { ) res.status(200).json(result) } catch (error) { - forwardControlApiError(error, res, next) + next(error) } } ) @@ -210,7 +194,7 @@ export function createExternalWorkflowsRouter(): Router { } res.end(result.body) } catch (error) { - forwardControlApiError(error, res, next) + next(error) } } ) diff --git a/external-rest-api/src/services/invitationsService.ts b/external-rest-api/src/services/invitationsService.ts index 4bac9a85d0..d70c01c2ce 100644 --- a/external-rest-api/src/services/invitationsService.ts +++ b/external-rest-api/src/services/invitationsService.ts @@ -83,13 +83,18 @@ export async function acceptInvitation( }) return { data } } catch (error) { - if (!(error instanceof ControlApiError)) throw error - if (error.status === 400) return { error: 'invalid' } - if (error.status === 404) return { error: 'not_found' } - if (error.status === 403) return { error: 'forbidden' } - if (error.status === 410) return { error: 'expired' } - if (error.status === 409) return { error: 'not_pending' } - throw error + // A typed 503 is the class-B authority outage: it must keep its status and + // body instead of becoming "not pending". + if (error instanceof ControlApiError && error.status === 503) throw error + if (error instanceof ControlApiError) { + if (error.status === 400) return { error: 'invalid' } + if (error.status === 404) return { error: 'not_found' } + if (error.status === 403) return { error: 'forbidden' } + if (error.status === 410) return { error: 'expired' } + } + // Every other failure, including a revoked session's upstream 401, is a + // not-pending invitation, as in dev. + return { error: 'not_pending' } } } diff --git a/external-rest-api/src/services/meService.ts b/external-rest-api/src/services/meService.ts index f1bf86f45d..91949a8ec8 100644 --- a/external-rest-api/src/services/meService.ts +++ b/external-rest-api/src/services/meService.ts @@ -84,8 +84,8 @@ export async function switchTeam( { userSessionToken: sessionToken } ) } catch (error) { - if (error instanceof ControlApiError && error.status === 404) return null - throw error + if (error instanceof ControlApiError && error.status === 503) throw error + return null } const role = membership.role as TeamRole @@ -133,8 +133,8 @@ export async function getMe(userId: string, teamId: string, sessionToken: string }, } } catch (error) { - if (error instanceof ControlApiError && error.status === 404) return null - throw error + if (error instanceof ControlApiError && error.status === 503) throw error + return null } } diff --git a/external-rest-api/src/services/rpcService.ts b/external-rest-api/src/services/rpcService.ts index f27bb19b84..f127571963 100644 --- a/external-rest-api/src/services/rpcService.ts +++ b/external-rest-api/src/services/rpcService.ts @@ -1,4 +1,4 @@ -import { controlApiRequest } from '../controlApiClient.js' +import { ControlApiError, controlApiRequest } from '../controlApiClient.js' import { RpcAccessScope, RpcScope } from '../types.js' type IssuedRpcToken = { @@ -11,7 +11,7 @@ type IssuedRpcToken = { droppedScopes?: RpcScope[] } -export type RpcAccessTokenResult = IssuedRpcToken +export type RpcAccessTokenResult = IssuedRpcToken | { error: string } export async function issueRpcAccessToken( sessionToken: string, @@ -19,12 +19,23 @@ export async function issueRpcAccessToken( requestedHostRefsInput: unknown, clientIp?: string ): Promise { - return controlApiRequest('POST', '/external/rpc/token', { - body: { - sessionToken, - scopes: requestedScopesInput, - hostRefs: requestedHostRefsInput, - }, - ...(clientIp ? { extraHeaders: { 'x-evenfire-client-ip': clientIp } } : {}), - }) + try { + return await controlApiRequest('POST', '/external/rpc/token', { + body: { + sessionToken, + scopes: requestedScopesInput, + hostRefs: requestedHostRefsInput, + }, + ...(clientIp ? { extraHeaders: { 'x-evenfire-client-ip': clientIp } } : {}), + }) + } catch (error) { + // Relay control-api's specific denial reason (e.g. `desktop_requires_team`) + // instead of collapsing every 403 to a generic message. The desktop app and + // logs can then distinguish "needs a team" from a genuine auth failure. + if (error instanceof ControlApiError && error.status === 403) { + const reason = error.code ?? 'forbidden' + return { error: reason } + } + throw error + } } diff --git a/external-rest-api/src/services/teamService.ts b/external-rest-api/src/services/teamService.ts index 45c8101a7f..4751c5e3b5 100644 --- a/external-rest-api/src/services/teamService.ts +++ b/external-rest-api/src/services/teamService.ts @@ -29,8 +29,8 @@ export async function getCurrentTeam(auth: AuthContext) { { userSessionToken: auth.sessionToken } ) } catch (error) { - if (error instanceof ControlApiError && error.status === 404) return null - throw error + if (error instanceof ControlApiError && error.status === 503) throw error + return null } } @@ -91,13 +91,8 @@ export async function renameTeam( } ) } catch (error) { - if (error instanceof ControlApiError && error.status === 403) { - return { error: 'forbidden' as const } - } - if (error instanceof ControlApiError && error.status === 404) { - return { error: 'not_found' as const } - } - throw error + if (error instanceof ControlApiError && error.status === 503) throw error + return { error: 'not_found' as const } } return { @@ -154,13 +149,8 @@ export async function updateMemberRole( { userSessionToken: auth.sessionToken } ) } catch (error) { - if (error instanceof ControlApiError && error.status === 403) { - return { error: 'forbidden' } - } - if (error instanceof ControlApiError && error.status === 404) { - return { error: 'not_found' } - } - throw error + if (error instanceof ControlApiError && error.status === 503) throw error + return { error: 'not_found' } } const updated = await controlApiRequest( 'PATCH', @@ -212,13 +202,8 @@ export async function deleteMember(auth: AuthContext, userId: string) { } ) } catch (error) { - if (error instanceof ControlApiError && error.status === 403) { - return { error: 'forbidden' as const } - } - if (error instanceof ControlApiError && error.status === 404) { - return { error: 'not_found' as const } - } - throw error + if (error instanceof ControlApiError && error.status === 503) throw error + return { error: 'not_found' as const } } return { deleted } } diff --git a/external-rest-api/test/controlApiClient.test.ts b/external-rest-api/test/controlApiClient.test.ts index ba593ffe4e..dc330eddf5 100644 --- a/external-rest-api/test/controlApiClient.test.ts +++ b/external-rest-api/test/controlApiClient.test.ts @@ -72,39 +72,6 @@ describe('controlApiClient error headers', () => { expect(error.headers).not.toHaveProperty('server') expect(error.headers).not.toHaveProperty('x-internal-debug') }) - - it.each([ - [413, '209715200', '209715200'], - [413, '0007', '7'], - [413, '-1', undefined], - [413, '9007199254740992', undefined], - [413, 'not-a-number', undefined], - [409, '209715200', undefined], - ] as const)( - 'for status %s forwards only a bounded 413 upload-length value %s', - async (status, uploadLength, expected) => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue( - new Response(JSON.stringify({ error: 'upstream' }), { - status, - headers: { - 'content-type': 'application/json', - 'upload-length': uploadLength, - 'x-internal-secret': 'must-not-leak', - }, - }) - ) - ) - - const error = await captureError(() => - controlApiRequestWithStatus('PUT', '/external/gfs/resources/id/content') - ) - - expect(error.responseHeaders['upload-length']).toBe(expected) - expect(error.responseHeaders).not.toHaveProperty('x-internal-secret') - } - ) }) describe('ControlApiError.code', () => { diff --git a/external-rest-api/test/globalPublicErrorHandler.test.ts b/external-rest-api/test/globalPublicErrorHandler.test.ts index 15a436ca44..5797b3625b 100644 --- a/external-rest-api/test/globalPublicErrorHandler.test.ts +++ b/external-rest-api/test/globalPublicErrorHandler.test.ts @@ -5,11 +5,11 @@ import type { AddressInfo } from 'node:net' import request from 'supertest' import { externalRestPublicErrorHandler } from '../src/app.js' import { config } from '../src/config.js' -import { controlApiRequest } from '../src/controlApiClient.js' +import { ControlApiError, ControlApiPathError, controlApiRequest } from '../src/controlApiClient.js' type ProducerResponse = { status: number - body: Record + body: unknown headers?: Record } @@ -42,6 +42,8 @@ function createControlApiWireProducer() { function createRelayApp() { const app = express() + // Every case below travels the real control-api client and the real global + // handler; only the wire producer is a stub. app.get('/relay', async (_req, res, next) => { try { await controlApiRequest('GET', '/external/test-error') @@ -50,26 +52,26 @@ function createRelayApp() { next(error) } }) + app.get('/path-error', () => { + throw new ControlApiPathError() + }) + app.get('/control-api-400', () => { + throw new ControlApiError('wrapper must not be public', 400, { error: 'invalid_email' }) + }) + app.get('/client-error', () => { + throw Object.assign(new SyntaxError('Unexpected token in request body'), { status: 400 }) + }) + app.get('/plain-error', () => { + throw new Error('internal-detail-sentinel-not-for-clients') + }) app.use(externalRestPublicErrorHandler) return app } -const acceptedStatuses = [ - [408, 'request_timeout', 'The request timed out.', true], - [409, 'conflict', 'The request conflicts with current state.', false], - [410, 'gone', 'The resource is no longer available.', false], - [411, 'length_required', 'A content length is required.', false], - [412, 'precondition_failed', 'The request precondition did not match current state.', false], - [413, 'payload_too_large', 'The request payload is too large.', false], - [425, 'too_early', 'The request is not ready to be processed.', true], - [502, 'upstream_unavailable', 'The upstream service is temporarily unavailable.', true], - [504, 'upstream_timeout', 'The upstream service timed out.', true], - [ - 507, - 'insufficient_storage', - 'The requested operation cannot be completed because storage is full.', - false, - ], +const forwardedObjectBodies = [ + [400, { error: 'invalid_email' }], + [409, { error: 'conflict', detail: 'invitation already accepted' }], + [422, { error: { code: 'invalid_teams', message: 'one team is required' } }], ] as const describe('global External REST public error handler', () => { @@ -100,63 +102,111 @@ describe('global External REST public error handler', () => { }) }) - it.each(acceptedStatuses)( - 'preserves canonical public semantics for Control API status %i', - async (status, code, message, retryable) => { - producer.respondWith({ - status, - body: { - error: { - code: 'private_upstream_code', - message: 'private upstream detail', - correlationId: 'unsafe/upstream-correlation', - }, - }, - headers: { - 'retry-after': '17', - 'upload-length': '2048', - 'x-private-upstream': 'secret', - }, - }) + it.each(forwardedObjectBodies)( + 'forwards the control-api body verbatim with status %i', + async (status, body) => { + producer.respondWith({ status, body }) const response = await request(createRelayApp()) .get('/relay') .set('x-correlation-id', requestCorrelationId) + // Two statuses in one table: the body follows the upstream status instead + // of a fixed envelope. expect(response.status).toBe(status) - expect(response.body).toEqual({ - error: { - code, - message, - correlationId: requestCorrelationId, - retryable, - }, - }) - expect(JSON.stringify(response.body)).not.toMatch(/private_upstream|unsafe\/upstream/) - expect(response.headers['x-private-upstream']).toBeUndefined() - expect(response.headers['retry-after']).toBe(retryable ? '17' : undefined) - expect(response.headers['upload-length']).toBe(status === 413 ? '2048' : undefined) + expect(response.body).toEqual(body) + expect(JSON.stringify(response.body)).not.toContain('Control API') } ) - it('keeps unaccepted 426 semantics outside the PR1 canonical status vocabulary', async () => { + it('keeps the 503 outage headers only when control-api sent them', async () => { producer.respondWith({ - status: 426, - body: { error: { code: 'upgrade_required', message: 'private upgrade policy' } }, + status: 503, + body: { error: 'session_backend_unavailable' }, + headers: { 'retry-after': '2', 'cache-control': 'no-store' }, }) + const withHeaders = await request(createRelayApp()).get('/relay') + + expect(withHeaders.status).toBe(503) + expect(withHeaders.body).toEqual({ error: 'session_backend_unavailable' }) + expect(withHeaders.headers['retry-after']).toBe('2') + expect(withHeaders.headers['cache-control']).toBe('no-store') + + producer.respondWith({ status: 503, body: { error: 'authority_unavailable' } }) + + const withoutHeaders = await request(createRelayApp()).get('/relay') + + expect(withoutHeaders.status).toBe(503) + expect(withoutHeaders.body).toEqual({ error: 'authority_unavailable' }) + expect(withoutHeaders.headers['retry-after']).toBeUndefined() + expect(withoutHeaders.headers['cache-control']).toBeUndefined() + }) + + it('never exposes the internal request wrapper for a non-object body', async () => { + producer.respondWith({ status: 502, body: null }) + const response = await request(createRelayApp()) .get('/relay') .set('x-correlation-id', requestCorrelationId) - expect(response.status).toBe(426) - expect(response.body).toEqual({ + // The 502 status proves the ControlApiError rule ran; the body is the status + // sentence, not `Control API GET /external/... failed (...)`. + expect(response.status).toBe(502) + expect(response.body).toEqual({ error: 'Bad Gateway' }) + expect(JSON.stringify(response.body)).not.toMatch(/Control API|\/external\/|GET/) + }) + + it('answers a non-control-api client error with its own message and a plain error with 500', async () => { + const clientError = await request(createRelayApp()).get('/client-error') + + expect(clientError.status).toBe(400) + expect(clientError.body).toEqual({ error: 'Unexpected token in request body' }) + + const serverError = await request(createRelayApp()) + .get('/plain-error') + .set('x-correlation-id', requestCorrelationId) + + expect(serverError.status).toBe(500) + expect(serverError.body).toEqual({ error: { - code: 'invalid_request', + code: 'internal_error', message: 'The request could not be completed.', correlationId: requestCorrelationId, retryable: false, }, }) }) + + it('keeps the invalid-request envelope for a path error and not for a plain control-api 400', async () => { + const pathError = await request(createRelayApp()) + .get('/path-error') + .set('x-correlation-id', requestCorrelationId) + + expect(pathError.status).toBe(400) + expect(pathError.body).toEqual({ + error: { + code: 'invalid_request', + message: 'The request is not valid.', + correlationId: requestCorrelationId, + retryable: false, + }, + }) + + const controlApi400 = await request(createRelayApp()).get('/control-api-400') + + expect(controlApi400.status).toBe(400) + expect(controlApi400.body).toEqual({ error: 'invalid_email' }) + }) + + it('does not leak an internal error message in the sanitized 500', async () => { + const response = await request(createRelayApp()) + .get('/plain-error') + .set('x-correlation-id', requestCorrelationId) + + expect(response.status).toBe(500) + expect(JSON.stringify(response.body)).not.toContain('internal-detail-sentinel-not-for-clients') + expect(response.body.error.code).toBe('internal_error') + expect(typeof response.body.error.correlationId).toBe('string') + }) }) diff --git a/external-rest-api/test/meUnavailable.contract.test.ts b/external-rest-api/test/meUnavailable.contract.test.ts index a8e72bc652..888e941fb5 100644 --- a/external-rest-api/test/meUnavailable.contract.test.ts +++ b/external-rest-api/test/meUnavailable.contract.test.ts @@ -30,7 +30,7 @@ describe('External REST /me unavailable producer contract', () => { afterEach(() => vi.restoreAllMocks()) - it('preserves a Control API outage through the sanitized public error handler', async () => { + it('forwards a Control API outage body verbatim through the public error handler', async () => { mocks.controlApiRequest.mockRejectedValue( new ControlApiError( 'private Control API failure', @@ -50,10 +50,10 @@ describe('External REST /me unavailable producer contract', () => { .set('authorization', 'Bearer synthetic-session-token') expect(response.status).toBe(503) - expect(response.body.error).toMatchObject({ code: 'authority_unavailable', retryable: true }) + expect(response.body).toEqual({ error: 'authority_failure' }) expect(response.headers['retry-after']).toBe('60') expect(JSON.stringify(response.body)).not.toMatch( - /private Control API failure|authority_failure|synthetic-member-id|synthetic-session-token/ + /private Control API failure|synthetic-member-id|synthetic-session-token/ ) expect(mocks.controlApiRequest).toHaveBeenCalledWith( 'GET', @@ -62,6 +62,28 @@ describe('External REST /me unavailable producer contract', () => { ) }) + it('answers a revoked upstream session with the dev not-found response', async () => { + mocks.controlApiRequest.mockRejectedValue( + new ControlApiError('private upstream detail', 401, { error: 'Unauthorized' }) + ) + const app = express() + app.use(createMeRouter()) + app.use(externalRestPublicErrorHandler) + + const response = await request(app) + .get('/me') + .set('authorization', 'Bearer synthetic-session-token') + + expect(response.status).toBe(404) + expect(response.body).toEqual({ error: 'User not found' }) + expect(JSON.stringify(response.body)).not.toMatch(/private upstream detail|Unauthorized/) + expect(mocks.controlApiRequest).toHaveBeenCalledWith( + 'GET', + '/external/users/synthetic-member-id/me', + expect.objectContaining({ userSessionToken: 'synthetic-session-token' }) + ) + }) + it('preserves an authenticated /me response with no active team', async () => { mocks.controlApiRequest.mockResolvedValue({ id: 'synthetic-member-id', diff --git a/external-rest-api/test/mixedVersion.baseControlApi.test.ts b/external-rest-api/test/mixedVersion.baseControlApi.test.ts new file mode 100644 index 0000000000..d169604e19 --- /dev/null +++ b/external-rest-api/test/mixedVersion.baseControlApi.test.ts @@ -0,0 +1,277 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { type IncomingMessage, type Server, createServer } from 'node:http' +import type { AddressInfo } from 'node:net' +import request from 'supertest' +import { createApp } from '../src/app.js' +import { config } from '../src/config.js' + +/** + * The head External REST edge against the base control-api producer. + * + * The fixture bodies below are a snapshot of the base control-api at + * `56c5e49cd` (identical to `562d2e480` for these files). The seam is a loopback + * `node:http` stub with the real `createApp()`, `controlApiClient` and `fetch`; + * only the edge's local session verifier is mocked, so a red comes from the base + * producer contract rather than from a mocked client. + * + * The stub enforces the two base preconditions the edge depends on: + * - 401 `{"error":"Unauthorized"}` without `x-user-session-token` on + * `/external/users/*` and `/external/teams`; + * - 400 `{"error":"invalid payload"}` when a `/external/auth/session-token` + * body is missing one of `userId`, `email`, `teamId`, `role`. + */ + +const BASE_SESSION_TOKEN = 'synthetic-base-session-token' +const MEMBER_ID = 'synthetic-member-id' +const MEMBER_EMAIL = 'member@example.invalid' +const CURRENT_TEAM_ID = 'synthetic-team-id' +const NEXT_TEAM_ID = 'synthetic-next-team-id' +const CREATED_TEAM_ID = 'synthetic-created-team-id' +const INVITATION_PROOF = 'synthetic-invitation-proof' +const INVITATION_ROW_ID = '00000000-0000-4000-8000-000000000001' + +vi.mock('../src/authToken.js', () => ({ + verifyToken: (token: string) => + token === 'synthetic-base-session-token' + ? { + userId: 'synthetic-member-id', + email: 'member@example.invalid', + teamId: 'synthetic-team-id', + role: 'member', + exp: 9_999_999_999, + sessionContract: 'v1', + } + : null, +})) + +type RecordedCall = { + method: string + path: string + sessionToken: string | undefined + body: Record | undefined +} + +async function readJsonBody(req: IncomingMessage): Promise | undefined> { + const chunks: Buffer[] = [] + for await (const chunk of req) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + } + const raw = Buffer.concat(chunks).toString('utf8') + return raw ? (JSON.parse(raw) as Record) : undefined +} + +function createBaseControlApiProducer() { + const calls: RecordedCall[] = [] + const server = createServer(async (req, res) => { + const body = await readJsonBody(req) + const path = req.url ?? '' + const sessionTokenHeader = req.headers['x-user-session-token'] + const sessionToken = typeof sessionTokenHeader === 'string' ? sessionTokenHeader : undefined + calls.push({ method: req.method ?? '', path, sessionToken, body }) + + const respond = (status: number, payload: unknown) => { + res.statusCode = status + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify(payload)) + } + + // Base preconditions: the session-plane routes require the forwarded + // session token, and the session-token mint requires its four body fields. + if ( + (path.startsWith('/api/v1/external/users/') || path === '/api/v1/external/teams') && + !sessionToken + ) { + respond(401, { error: 'Unauthorized' }) + return + } + if (path === '/api/v1/external/auth/session-token') { + const missing = ['userId', 'email', 'teamId', 'role'].some( + field => !String(body?.[field] ?? '').trim() + ) + if (missing) { + respond(400, { error: 'invalid payload' }) + return + } + respond(200, { token: 'synthetic-issued-session-token' }) + return + } + if (req.method === 'GET' && path.startsWith('/api/v1/external/users/')) { + respond(200, { team_id: NEXT_TEAM_ID, role: 'member', team_name: 'Synthetic Next Team' }) + return + } + if (req.method === 'POST' && path === '/api/v1/external/teams') { + respond(201, { id: CREATED_TEAM_ID, name: 'Synthetic Created Team' }) + return + } + if (req.method === 'POST' && path === '/api/v1/external/invitations/password-token') { + respond(200, { + id: INVITATION_ROW_ID, + email: MEMBER_EMAIL, + purpose: 'password_reset', + status: 'accepted', + passwordUpdated: true, + token: 'synthetic-producer-session-token', + sessionContext: { userId: MEMBER_ID, email: MEMBER_EMAIL, authGeneration: 2 }, + }) + return + } + if (req.method === 'POST' && path === '/api/v1/external/invitations/accept') { + respond(200, { + accepted: true, + userId: MEMBER_ID, + email: MEMBER_EMAIL, + teamId: CURRENT_TEAM_ID, + teamName: 'Synthetic Team', + role: 'member', + token: 'synthetic-accepted-session-token', + }) + return + } + // Base has no session/logout route: its app answers this 404. + if (req.method === 'POST' && path === '/api/v1/external/auth/session/logout') { + respond(404, { error: 'Not Found' }) + return + } + respond(500, { error: 'unexpected_test_upstream_request' }) + }) + return { server, calls } +} + +describe('head External REST against the base control-api', () => { + const originalControlApiBaseUrl = config.controlApiBaseUrl + const producer = createBaseControlApiProducer() + let server: Server + + beforeAll(async () => { + server = producer.server + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.off('error', reject) + resolve() + }) + }) + const address = server.address() as AddressInfo + config.controlApiBaseUrl = `http://127.0.0.1:${address.port}/api/v1` + }) + + beforeEach(() => { + producer.calls.length = 0 + }) + + afterAll(async () => { + config.controlApiBaseUrl = originalControlApiBaseUrl + await new Promise((resolve, reject) => { + server.close(error => (error ? reject(error) : resolve())) + }) + }) + + it('switches team against the base membership and session-token routes', async () => { + const response = await request(createApp()) + .post('/api/v1/me/switch-team') + .set('authorization', `Bearer ${BASE_SESSION_TOKEN}`) + .send({ teamId: NEXT_TEAM_ID }) + + expect(response.status).toBe(200) + expect(response.body).toEqual({ + token: 'synthetic-issued-session-token', + team: { id: NEXT_TEAM_ID, name: 'Synthetic Next Team', role: 'member' }, + }) + expect(response.headers['set-cookie']).toBeUndefined() + expect(producer.calls.map(call => `${call.method} ${call.path}`)).toEqual([ + `GET /api/v1/external/users/${MEMBER_ID}/memberships/${NEXT_TEAM_ID}`, + 'POST /api/v1/external/auth/session-token', + ]) + // The session token is a head behaviour, not a base incompatibility: both + // upstream calls carry it. + expect(producer.calls.map(call => call.sessionToken)).toEqual([ + BASE_SESSION_TOKEN, + BASE_SESSION_TOKEN, + ]) + }) + + it('creates a team against the base teams and session-token routes', async () => { + const response = await request(createApp()) + .post('/api/v1/team') + .set('authorization', `Bearer ${BASE_SESSION_TOKEN}`) + .send({ name: 'Synthetic Created Team' }) + + expect(response.status).toBe(201) + expect(response.body).toEqual({ + token: 'synthetic-issued-session-token', + team: { id: CREATED_TEAM_ID, name: 'Synthetic Created Team', role: 'admin' }, + }) + expect(producer.calls.map(call => `${call.method} ${call.path}`)).toEqual([ + 'POST /api/v1/external/teams', + 'POST /api/v1/external/auth/session-token', + ]) + expect(producer.calls[1].body).toEqual({ + userId: MEMBER_ID, + email: MEMBER_EMAIL, + teamId: CREATED_TEAM_ID, + role: 'admin', + }) + }) + + it('never publishes the base session token from a password reset', async () => { + const response = await request(createApp()).post('/api/v1/invitations/password-reset').send({ + token: INVITATION_PROOF, + email: MEMBER_EMAIL, + invitationId: INVITATION_ROW_ID, + password: 'Synthetic-Replacement-Password-123', + }) + + expect(response.status).toBe(200) + expect(response.body).toEqual({ + id: INVITATION_ROW_ID, + email: MEMBER_EMAIL, + purpose: 'password_reset', + status: 'accepted', + passwordUpdated: true, + reauthenticationRequired: true, + }) + expect(response.body).not.toHaveProperty('token') + expect(String(response.headers['set-cookie'])).toContain('profile_session=;') + expect(producer.calls.map(call => `${call.method} ${call.path}`)).toEqual([ + 'POST /api/v1/external/invitations/password-token', + ]) + }) + + it('accepts an invitation through the base producer and sets the session cookie', async () => { + const response = await request(createApp()) + .post('/api/v1/invitations/accept') + .send({ token: INVITATION_PROOF, email: MEMBER_EMAIL }) + + expect(response.status).toBe(200) + expect(response.body).toEqual({ + accepted: true, + userId: MEMBER_ID, + email: MEMBER_EMAIL, + teamId: CURRENT_TEAM_ID, + teamName: 'Synthetic Team', + role: 'member', + }) + expect(response.body).not.toHaveProperty('token') + expect(String(response.headers['set-cookie'])).toContain( + 'profile_session=synthetic-accepted-session-token' + ) + expect(producer.calls.map(call => `${call.method} ${call.path}`)).toEqual([ + 'POST /api/v1/external/invitations/accept', + ]) + }) + + it('answers a logout against the base 404 with the forwarded body and clears the cookie', async () => { + const response = await request(createApp()) + .post('/api/v1/auth/logout') + .set('authorization', `Bearer ${BASE_SESSION_TOKEN}`) + + expect(response.status).toBe(404) + expect(response.body).toStrictEqual({ error: 'Not Found' }) + expect(response.body).not.toHaveProperty('ok') + expect(String(response.headers['set-cookie'])).toContain('profile_session=;') + expect(producer.calls.map(call => `${call.method} ${call.path}`)).toEqual([ + 'POST /api/v1/external/auth/session/logout', + ]) + expect(producer.calls[0].sessionToken).toBe(BASE_SESSION_TOKEN) + }) +}) diff --git a/external-rest-api/test/passwordAdmissionError.test.ts b/external-rest-api/test/passwordAdmissionError.test.ts index adf3a488e8..a613800fe3 100644 --- a/external-rest-api/test/passwordAdmissionError.test.ts +++ b/external-rest-api/test/passwordAdmissionError.test.ts @@ -16,12 +16,16 @@ function buildApp() { return app } +const SESSION_ISSUANCE_SENTENCE = + 'A session could not be issued right now. Try again in two seconds.' + function stubControlApiError( status: number, code: PublicApiErrorCode, - headers: Record + headers: Record, + message = 'private upstream message' ) { - const body = publicApiErrorBody('issuance_ID-42', code, 'private upstream message', true) + const body = publicApiErrorBody('issuance_ID-42', code, message, true) vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue( @@ -37,10 +41,15 @@ describe('password admission error forwarding', () => { afterEach(() => vi.unstubAllGlobals()) it('preserves the typed temporary V1 session issuance contract', async () => { - stubControlApiError(503, 'session_issuance_temporarily_unavailable', { - 'retry-after': '2', - 'cache-control': 'no-store', - }) + stubControlApiError( + 503, + 'session_issuance_temporarily_unavailable', + { + 'retry-after': '2', + 'cache-control': 'no-store', + }, + SESSION_ISSUANCE_SENTENCE + ) const response = await request(buildApp()) .post('/api/v1/auth/password-login') @@ -50,15 +59,16 @@ describe('password admission error forwarding', () => { expect(response.headers['cache-control']).toBe('no-store') expect(response.headers['retry-after']).toBe('2') expect(response.headers['set-cookie']).toBeUndefined() - expect(response.body).toEqual({ - error: { - code: 'session_issuance_temporarily_unavailable', - message: 'A session could not be issued right now. Try again in two seconds.', - correlationId: 'issuance_ID-42', - retryable: true, - }, - }) - expect(JSON.stringify(response.body)).not.toContain('private upstream message') + // The producer's own sentence reaches the client: the edge no longer + // rebuilds a fixed copy for a forwarded 503. + expect(response.body).toEqual( + publicApiErrorBody( + 'issuance_ID-42', + 'session_issuance_temporarily_unavailable', + SESSION_ISSUANCE_SENTENCE, + true + ) + ) }) it('keeps generic authority failures on the bounded admission response', async () => { diff --git a/external-rest-api/test/profileUiPublicErrorCompatibility.test.ts b/external-rest-api/test/profileUiPublicErrorCompatibility.test.ts index 46347eb202..7e2cf284b6 100644 --- a/external-rest-api/test/profileUiPublicErrorCompatibility.test.ts +++ b/external-rest-api/test/profileUiPublicErrorCompatibility.test.ts @@ -100,20 +100,17 @@ describe('Profile UI public error compatibility', () => { }) it.each(Object.entries(contract))( - 'preserves the safe %s producer reason through External REST and Profile UI', + 'forwards the safe %s producer reason through External REST to Profile UI', async (_name, expected) => { producer.respondWith(expected.control) const relayed = await request(createRelayApp()) .post('/relay') .set('x-correlation-id', requestCorrelationId) + // The edge forwards control-api's body verbatim; Profile UI is what maps + // the code to the operator-facing copy. expect(relayed.status).toBe(expected.control.status) - expect(relayed.body.error).toMatchObject({ - code: expected.public.code, - message: expected.public.message, - correlationId: requestCorrelationId, - retryable: true, - }) + expect(relayed.body).toEqual(expected.control.body) vi.stubGlobal( 'fetch', @@ -129,87 +126,23 @@ describe('Profile UI public error compatibility', () => { await expect(apiSend('POST', '/api/v1/members/invite', {})).rejects.toThrow( expected.profileMessage ) - expect(JSON.stringify(relayed.body)).not.toContain('private') } ) - it('keeps an unapproved upstream 503 generic across both public boundaries', async () => { + it('forwards a 503 body it does not recognise instead of inventing an envelope', async () => { producer.respondWith({ status: 503, body: { error: 'private_database_failure', detail: 'private topology' }, }) - const relayed = await request(createRelayApp()) - .post('/relay') - .set('x-correlation-id', requestCorrelationId) - expect(relayed.status).toBe(503) - expect(relayed.body.error).toMatchObject({ - code: 'authority_unavailable', - message: 'Authorization is temporarily unavailable.', - correlationId: requestCorrelationId, - retryable: true, - }) - expect(JSON.stringify(relayed.body)).not.toMatch(/private_database_failure|private topology/) - }) - - it('keeps a valid upstream correlation ahead of the request fallback', async () => { - producer.respondWith({ - status: 503, - body: { - error: { - code: 'authority_unavailable', - message: 'private upstream message', - correlationId: 'upstream-correlation-456', - }, - }, - }) - const relayed = await request(createRelayApp()) - .post('/relay') - .set('x-correlation-id', requestCorrelationId) - - expect(relayed.body.error.correlationId).toBe('upstream-correlation-456') - expect(JSON.stringify(relayed.body)).not.toContain('private upstream message') - }) - - it('uses the valid request fallback when the upstream correlation is unsafe', async () => { - producer.respondWith({ - status: 503, - body: { - error: { - code: 'authority_unavailable', - correlationId: 'unsafe/upstream-correlation', - }, - }, - }) - const relayed = await request(createRelayApp()) - .post('/relay') - .set('x-correlation-id', requestCorrelationId) - - expect(relayed.body.error.correlationId).toBe(requestCorrelationId) - expect(JSON.stringify(relayed.body)).not.toContain('unsafe/upstream-correlation') - }) - - it('replaces an oversized request correlation with a bounded generated identifier', async () => { - const oversizedCorrelationId = 'x'.repeat(129) - producer.respondWith({ status: 503, body: { error: 'member_registration_unavailable' } }) - const relayed = await request(createRelayApp()) - .post('/relay') - .set('x-correlation-id', oversizedCorrelationId) - - expect(relayed.body.error.correlationId).toMatch(/^[A-Za-z0-9_-]{1,128}$/) - expect(relayed.body.error.correlationId).not.toBe(oversizedCorrelationId) - }) - - it('preserves the request correlation for the propagated 429 sibling', async () => { - producer.respondWith({ status: 429, body: { error: { code: 'rate_limited' } } }) const relayed = await request(createRelayApp()) .post('/relay') .set('x-correlation-id', requestCorrelationId) - expect(relayed.status).toBe(429) - expect(relayed.body.error).toMatchObject({ - code: 'rate_limited', - correlationId: requestCorrelationId, - retryable: true, + // Witness: the 503 status and the producer's own body both cross the edge. + expect(relayed.status).toBe(503) + expect(relayed.body).toEqual({ + error: 'private_database_failure', + detail: 'private topology', }) }) }) diff --git a/external-rest-api/test/routes.auth.test.ts b/external-rest-api/test/routes.auth.test.ts index be07b28496..c9cf08f68a 100644 --- a/external-rest-api/test/routes.auth.test.ts +++ b/external-rest-api/test/routes.auth.test.ts @@ -96,7 +96,7 @@ describe('routes/auth password-login', () => { expect(res.body).toEqual({ error: 'Unauthorized' }) }) - it('keeps any other Google login 401 on the sanitized public envelope', async () => { + it('forwards any other Google login 401 body verbatim', async () => { authServiceMock.loginWithGoogle.mockReset() authServiceMock.loginWithGoogle.mockRejectedValueOnce( new ControlApiError('private upstream detail', 401, { error: 'session_revoked' }) @@ -108,8 +108,8 @@ describe('routes/auth password-login', () => { expect(authServiceMock.loginWithGoogle).toHaveBeenCalledTimes(1) expect(res.status).toBe(401) - expect(res.body).not.toEqual({ error: 'Unauthorized' }) - expect(res.body.error).toEqual(expect.objectContaining({ code: expect.any(String) })) + expect(res.body).toEqual({ error: 'session_revoked' }) + expect(JSON.stringify(res.body)).not.toContain('private upstream detail') }) it('keeps password setup state opaque on password-login', async () => { diff --git a/external-rest-api/test/routes.contextSharedFilesystems.test.ts b/external-rest-api/test/routes.contextSharedFilesystems.test.ts index 68219a40a3..ee69a4ed46 100644 --- a/external-rest-api/test/routes.contextSharedFilesystems.test.ts +++ b/external-rest-api/test/routes.contextSharedFilesystems.test.ts @@ -1,16 +1,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import express from 'express' import request from 'supertest' +import { externalRestPublicErrorHandler } from '../src/app.js' import { createContextSharedFilesystemsRouter } from '../src/routes/contextSharedFilesystems.js' const authTokenMock = vi.hoisted(() => ({ verifyToken: vi.fn(), })) -const controlApiClientMock = vi.hoisted(() => ({ - controlApiRequest: vi.fn(), - controlApiStreamRequest: vi.fn(), - ControlApiError: class ControlApiError extends Error { +const controlApiClientMock = vi.hoisted(() => { + class ControlApiError extends Error { status: number body: unknown constructor(message: string, status: number, body: unknown) { @@ -18,8 +17,19 @@ const controlApiClientMock = vi.hoisted(() => ({ this.status = status this.body = body } - }, -})) + } + class ControlApiPathError extends ControlApiError { + constructor() { + super('Control API request path is not allowed', 400, { error: 'invalid_request' }) + } + } + return { + controlApiRequest: vi.fn(), + controlApiStreamRequest: vi.fn(), + ControlApiError, + ControlApiPathError, + } +}) vi.mock('../src/authToken.js', () => authTokenMock) vi.mock('../src/controlApiClient.js', () => ({ @@ -38,6 +48,7 @@ function buildApp() { const app = express() app.use(express.json()) app.use(createContextSharedFilesystemsRouter()) + app.use(externalRestPublicErrorHandler) return app } @@ -81,7 +92,7 @@ describe('GET /me/contexts/:contextId/shared-filesystems', () => { expect(controlApiClientMock.controlApiRequest).not.toHaveBeenCalled() }) - it('uses the request correlation when a sanitized upstream error has none', async () => { + it('forwards an upstream error body verbatim', async () => { authTokenMock.verifyToken.mockReturnValue(claims) controlApiClientMock.controlApiRequest.mockRejectedValueOnce( new controlApiClientMock.ControlApiError('private upstream detail', 403, { @@ -95,7 +106,8 @@ describe('GET /me/contexts/:contextId/shared-filesystems', () => { .set('x-correlation-id', 'shared_filesystems_ID-42') .expect(403) - expect(response.body.error.correlationId).toBe('shared_filesystems_ID-42') + expect(response.body).toEqual({ error: { code: 'forbidden' } }) + expect(controlApiClientMock.controlApiRequest).toHaveBeenCalledTimes(1) }) }) diff --git a/external-rest-api/test/routes.gfs.test.ts b/external-rest-api/test/routes.gfs.test.ts index d7b1fdd007..1e36c88cf1 100644 --- a/external-rest-api/test/routes.gfs.test.ts +++ b/external-rest-api/test/routes.gfs.test.ts @@ -27,6 +27,12 @@ vi.mock('../src/controlApiClient.js', () => ({ controlApiRequest: clientMock.controlApiRequest, controlApiStreamRequest: clientMock.controlApiStreamRequest, ControlApiError, + ControlApiPathError: class ControlApiPathError extends ControlApiError { + constructor() { + super('Control API request path is not allowed', 400, { error: 'invalid_request' }) + this.name = 'ControlApiPathError' + } + }, })) const authTokenMock = vi.hoisted(() => ({ verifyToken: vi.fn() })) @@ -171,7 +177,7 @@ describe('routes/gfs /me/gfs/* (user session passthrough → /external/gfs/*)', expect(missing.status).toBe(200) expect(missing.body).toEqual({ upload: { resumableV2: { enabled: true } } }) expect(mismatched.status).toBe(400) - expect(mismatched.body.error.code).toBe('invalid_request') + expect(mismatched.body).toEqual({ error: 'drive_mismatch' }) expect(clientMock.controlApiStreamRequest).toHaveBeenCalledTimes(2) }) @@ -193,13 +199,13 @@ describe('routes/gfs /me/gfs/* (user session passthrough → /external/gfs/*)', .get(`/me/gfs/capabilities?drive=${encodeURIComponent(drive)}`) .set('authorization', 'Bearer sess-xyz') expect(response.status).toBe(400) - expect(response.body.error.code).toBe('invalid_request') + expect(response.body).toEqual({ error: 'drive_required' }) } const arrayValue = await request(buildApp()) .get('/me/gfs/capabilities?drive=archive&drive=main') .set('authorization', 'Bearer sess-xyz') expect(arrayValue.status).toBe(400) - expect(arrayValue.body.error.code).toBe('invalid_request') + expect(arrayValue.body).toEqual({ error: 'drive_required' }) expect(clientMock.controlApiStreamRequest).toHaveBeenCalledTimes(6) expect(forwardedDrives).toEqual([...malformed, undefined]) }) @@ -286,10 +292,11 @@ describe('routes/gfs /me/gfs/* (user session passthrough → /external/gfs/*)', expect(response.status).toBe(429) expect(response.headers['retry-after']).toBe('7') expect(response.headers['x-ratelimit-limit']).toBe('512') - expect(response.headers['x-gfs-ratelimit-scope']).toBeUndefined() - expect(response.body.error).toMatchObject({ - code: 'rate_limited', - details: { retryAfterSeconds: 7 }, + expect(response.headers['x-gfs-ratelimit-scope']).toBe('principal_bytes') + expect(response.body).toEqual({ + error: 'gfs_upload_rate_limited', + limit: 'principal_bytes', + retryAfterSeconds: 7, }) }) @@ -562,10 +569,32 @@ describe('routes/gfs /me/gfs/* (user session passthrough → /external/gfs/*)', permissions: ['write'], }) expect(res.status).toBe(403) - expect(res.body.error).toMatchObject({ - code: 'forbidden', - details: { reason: 'escalation_rejected' }, - }) + expect(res.body).toEqual({ error: 'escalation_rejected' }) + }) + + it('forwards the GFS session-authority denial codes the Desktop acts on', async () => { + // CG-6: the Desktop revokes access and purges the preview when either code + // reaches it. Both travel the GFS route forwarder, not the global handler. + for (const token of ['desktop_user_retired', 'gfs_operator_link_invalid']) { + clientMock.controlApiRequest.mockRejectedValueOnce( + new ControlApiError('private gfsc detail', 403, { error: token }) + ) + + const res = await request(buildApp()) + .get('/me/gfs/resolve') + .set('authorization', 'Bearer sess-xyz') + + expect(res.status).toBe(403) + expect(res.body).toStrictEqual({ error: token }) + } + + expect(clientMock.controlApiRequest).toHaveBeenCalledTimes(2) + expect(clientMock.controlApiRequest).toHaveBeenNthCalledWith( + 1, + 'GET', + '/external/gfs/resolve', + expect.objectContaining({ userSessionToken: 'sess-xyz' }) + ) }) it('preserves a public 413 payload_too_large response and its safe transport headers', async () => { @@ -594,21 +623,14 @@ describe('routes/gfs /me/gfs/* (user session passthrough → /external/gfs/*)', const res = await request(buildApp()) .put('/me/gfs/resources/abc/content?drive=main') .set('authorization', 'Bearer sess-xyz') - .set('x-correlation-id', 'gfs_ID-42') .send({ contentBase64: 'AAAA' }) expect(res.status).toBe(413) - expect(res.body.error).toEqual({ - code: 'payload_too_large', - message: 'The request payload is too large.', - correlationId: 'gfs_ID-42', - retryable: false, - }) - expect(res.headers['retry-after']).toBeUndefined() + expect(res.body).toEqual(body) + expect(res.headers['retry-after']).toBe('9') expect(res.headers['upload-length']).toBe('209715200') - expect(res.headers['x-ratelimit-limit']).toBeUndefined() + expect(res.headers['x-ratelimit-limit']).toBe('16') expect(res.headers['x-internal-secret']).toBeUndefined() - expect(JSON.stringify(res.body)).not.toContain('200 MiB') }) it('propagates GFS transport statuses/body/headers without changing retry semantics', async () => { @@ -617,7 +639,6 @@ describe('routes/gfs /me/gfs/* (user session passthrough → /external/gfs/*)', // a generic 500 before the client can apply that policy. for (const [status, error] of [ [408, 'request_timeout'], - [411, 'length_required'], [425, 'too_early'], [500, 'gfsc_internal'], [502, 'gfsc_unreachable'], @@ -640,33 +661,9 @@ describe('routes/gfs /me/gfs/* (user session passthrough → /external/gfs/*)', .set('authorization', 'Bearer sess-xyz') .send({ contentBase64: 'AAAA' }) expect(res.status).toBe(status) - const expectedCode = - status === 408 - ? 'request_timeout' - : status === 411 - ? 'length_required' - : status === 425 - ? 'too_early' - : status === 502 - ? 'upstream_unavailable' - : status === 503 - ? 'authority_unavailable' - : status === 504 - ? 'upstream_timeout' - : status === 507 - ? 'insufficient_storage' - : 'internal_error' - expect(res.body.error.code).toBe(expectedCode) - expect(res.headers['retry-after']).toBe( - status === 408 || status === 425 || status === 502 || status === 503 || status === 504 - ? '7' - : undefined - ) - expect(res.headers['x-ratelimit-limit']).toBeUndefined() - expect(res.body.error.retryable).toBe( - status === 408 || status === 425 || status === 502 || status === 503 || status === 504 - ) - expect(JSON.stringify(res.body)).not.toContain('gfsc failure') + expect(res.body).toEqual({ error }) + expect(res.headers['retry-after']).toBe('7') + expect(res.headers['x-ratelimit-limit']).toBe('16') } }) @@ -812,10 +809,7 @@ describe('GET /me/gfs/grants (delegation list passthrough)', () => { .get('/me/gfs/grants?drive=main&resourceId=11111111-1111-1111-1111-111111111111') .set('authorization', 'Bearer sess-xyz') expect(res.status).toBe(403) - expect(res.body.error).toMatchObject({ - code: 'forbidden', - details: { reason: 'manage_acl_required' }, - }) + expect(res.body).toEqual({ error: 'manage_acl_required' }) }) it('forwards only the allowlisted rate-limit and request headers on a control-api 429', async () => { @@ -843,15 +837,12 @@ describe('GET /me/gfs/grants (delegation list passthrough)', () => { .get('/me/gfs/grants?drive=main&resourceId=11111111-1111-1111-1111-111111111111') .set('authorization', 'Bearer sess-xyz') expect(res.status).toBe(429) - expect(res.body.error).toMatchObject({ - code: 'rate_limited', - details: { retryAfterSeconds: 17 }, - }) + expect(res.body).toEqual({ error: 'Too Many Requests', retryAfterSeconds: 17 }) expect(res.headers['retry-after']).toBe('17') expect(res.headers['x-ratelimit-limit']).toBe('30') expect(res.headers['x-ratelimit-remaining']).toBe('0') expect(res.headers['x-ratelimit-reset']).toBe('1776000000') - expect(res.headers['x-request-id']).toMatch(UUID_RE) + expect(res.headers['x-request-id']).toBe(REQUEST_ID) expect(res.headers['set-cookie']).toBeUndefined() expect(res.headers.server).toBeUndefined() expect(res.headers['x-internal-debug']).toBeUndefined() @@ -930,9 +921,7 @@ describe('GET /me/gfs/shares (delegation list passthrough)', () => { .set('x-request-id', REQUEST_ID) expect(res.status).toBe(status) - const expectedCode = - status === 401 ? 'invalid_session' : status === 403 ? 'forbidden' : 'authority_unavailable' - expect(res.body.error.code).toBe(expectedCode) + expect(res.body).toEqual({ error }) expect(res.headers['x-request-id']).toBe(REQUEST_ID) }) }) diff --git a/external-rest-api/test/routes.invitations.test.ts b/external-rest-api/test/routes.invitations.test.ts index 3ff4416d76..266d08ec6c 100644 --- a/external-rest-api/test/routes.invitations.test.ts +++ b/external-rest-api/test/routes.invitations.test.ts @@ -622,6 +622,43 @@ describe('routes/invitations', () => { expect(String(res.headers['set-cookie'])).toContain('HttpOnly') }) + it('answers a not-pending acceptance as 400 instead of signing the caller out', async () => { + // X-16: a retired invitee's upstream 401 is mapped to not_pending by the + // service, and the accept route must answer 400 so Profile UI's apiSend does + // not treat the response as an expired session. + invitationsServiceMock.acceptInvitation.mockResolvedValueOnce({ error: 'not_pending' }) + + const rejected = await request(makeApp()) + .post('/invitations/accept') + .set('x-forwarded-for', '198.51.100.231') + .send({ token: 'synthetic-invitation-link-token', email: 'invitee@example.com' }) + .expect(400) + + expect(rejected.body).toEqual({ error: 'Invitation is not pending' }) + expect(rejected.headers['set-cookie']).toBeUndefined() + + // Witness: the same route still accepts a pending invitation. + invitationsServiceMock.acceptInvitation.mockResolvedValueOnce({ + data: { + accepted: true, + userId: 'user-1', + email: 'invitee@example.com', + teamId: 'team-1', + teamName: 'Team 1', + role: 'member', + token: 'accepted-session-token', + }, + }) + + await request(makeApp()) + .post('/invitations/accept') + .set('x-forwarded-for', '198.51.100.232') + .send({ token: 'synthetic-invitation-link-token', email: 'invitee@example.com' }) + .expect(200) + + expect(invitationsServiceMock.acceptInvitation).toHaveBeenCalledTimes(2) + }) + it('accepts a replay without publishing a profile session', async () => { invitationsServiceMock.acceptInvitation .mockResolvedValueOnce({ diff --git a/external-rest-api/test/routes.members.test.ts b/external-rest-api/test/routes.members.test.ts index 67549581fa..b30c109eeb 100644 --- a/external-rest-api/test/routes.members.test.ts +++ b/external-rest-api/test/routes.members.test.ts @@ -49,7 +49,7 @@ describe('routes/members', () => { Object.values(memberManagementMock).forEach(fn => fn.mockReset()) }) - it('forwards invitee name to the managed invitation service', async () => { + it('forwards the trimmed invitee name to the managed invitation service', async () => { authTokenMock.verifyToken.mockReturnValueOnce(claims) memberManagementMock.inviteManagedMember.mockResolvedValueOnce({ id: 'inv-1' }) @@ -65,13 +65,13 @@ describe('routes/members', () => { expect(memberManagementMock.inviteManagedMember).toHaveBeenCalledWith( 'invitee@example.com', - ' Full Name ', + 'Full Name', [{ teamId: 'team-1', role: 'inviter' }], 'good-token' ) }) - it('forwards bounded invitation role candidates to the Control API authority unchanged', async () => { + it('filters invitation role candidates by TEAM_ROLES and defaults to member', async () => { authTokenMock.verifyToken.mockReturnValueOnce(claims) memberManagementMock.inviteManagedMember.mockResolvedValueOnce({ id: 'inv-roles' }) @@ -92,13 +92,10 @@ describe('routes/members', () => { expect(memberManagementMock.inviteManagedMember).toHaveBeenCalledWith( 'invitee@example.com', - undefined, + '', [ { teamId: 'team-admin', role: 'admin' }, - { teamId: 'team-alias', role: 'leader' }, - { teamId: 'team-case', role: 'ADMIN' }, - { teamId: 'team-invalid', role: 'unexpected' }, - { teamId: 'team-default' }, + { teamId: 'team-default', role: 'member' }, ], 'good-token' ) @@ -150,122 +147,54 @@ describe('routes/members', () => { expect(memberManagementMock.inviteManagedMember).not.toHaveBeenCalled() }) - it('forwards bounded invalid email input to the authoritative Control API validator', async () => { - authTokenMock.verifyToken.mockReturnValueOnce(claims) - memberManagementMock.inviteManagedMember.mockRejectedValueOnce( - new ControlApiError('invalid invitation email', 400, { error: 'invalid_email' }) - ) - - await request(makeApp()) - .post('/members/invite') - .set('authorization', 'Bearer good-token') - .send({ - email: 'not-an-email', - teams: [{ teamId: 'team-1', role: 'member' }], - }) - .expect(400, { error: 'Valid email is required' }) - - expect(memberManagementMock.inviteManagedMember).toHaveBeenCalledWith( - 'not-an-email', - undefined, - [{ teamId: 'team-1', role: 'member' }], - 'good-token' - ) - }) - - it('preserves invalid-email precedence when no valid team assignment is provided', async () => { - authTokenMock.verifyToken.mockReturnValueOnce(claims) - memberManagementMock.inviteManagedMember.mockRejectedValueOnce( - new ControlApiError('invalid invitation email', 400, { error: 'invalid_email' }) - ) - - await request(makeApp()) - .post('/members/invite') - .set('authorization', 'Bearer good-token') - .send({ email: 'not-an-email', teams: [] }) - .expect(400, { error: 'Valid email is required' }) - - expect(memberManagementMock.inviteManagedMember).toHaveBeenCalledWith( - 'not-an-email', - undefined, - [], - 'good-token' - ) - }) - it.each([ - ['an overlong name', { name: 'a'.repeat(121), teams: [] }], + [ + 'a malformed email', + { email: 'not-an-email', teams: [{ teamId: 'team-1', role: 'member' }] }, + 'Valid email is required', + ], + [ + 'an overlong name', + { + email: 'invitee@example.com', + name: 'a'.repeat(121), + teams: [{ teamId: 'team-1', role: 'member' }], + }, + 'Name is too long', + ], [ 'too many teams', { + email: 'invitee@example.com', teams: Array.from({ length: 51 }, (_, index) => ({ teamId: `team-${index}`, role: 'member', })), }, + 'Too many teams selected', ], - ])('preserves invalid-email precedence with %s', async (_case, extraBody) => { - authTokenMock.verifyToken.mockReturnValueOnce(claims) - memberManagementMock.inviteManagedMember.mockRejectedValueOnce( - new ControlApiError('invalid invitation email', 400, { error: 'invalid_email' }) - ) - - await request(makeApp()) - .post('/members/invite') - .set('authorization', 'Bearer good-token') - .send({ email: 'not-an-email', ...extraBody }) - .expect(400, { error: 'Valid email is required' }) - - expect(memberManagementMock.inviteManagedMember).toHaveBeenCalledOnce() - }) - - it('preserves the public assignment error after authoritative validation', async () => { - authTokenMock.verifyToken.mockReturnValueOnce(claims) - memberManagementMock.inviteManagedMember.mockRejectedValueOnce( - new ControlApiError('invalid invitation payload', 400, { error: 'invalid_payload' }) - ) - - await request(makeApp()) - .post('/members/invite') - .set('authorization', 'Bearer good-token') - .send({ email: 'invitee@example.com', teams: [] }) - .expect(400, { error: 'Email and at least one team are required' }) - - expect(memberManagementMock.inviteManagedMember).toHaveBeenCalledWith( - 'invitee@example.com', - undefined, - [], - 'good-token' - ) - }) - - it('rejects overlong invitee names', async () => { + [ + 'no valid team assignment', + { email: 'invitee@example.com', teams: [{ teamId: 'team-1', role: 'unexpected' }] }, + 'Email and at least one team are required', + ], + ] as const)('rejects %s locally before any Control API work', async (_case, body, message) => { authTokenMock.verifyToken.mockReturnValueOnce(claims) - memberManagementMock.inviteManagedMember.mockRejectedValueOnce( - new ControlApiError('invalid invitation name', 400, { error: 'invalid_name' }) - ) await request(makeApp()) .post('/members/invite') .set('authorization', 'Bearer good-token') - .send({ - email: 'invitee@example.com', - name: 'a'.repeat(121), - teams: [{ teamId: 'team-1', role: 'member' }], - }) - .expect(400, { error: 'Name is too long' }) + .send(body) + .expect(400, { error: message }) - expect(memberManagementMock.inviteManagedMember).toHaveBeenCalledWith( - 'invitee@example.com', - 'a'.repeat(121), - [{ teamId: 'team-1', role: 'member' }], - 'good-token' - ) + expect(memberManagementMock.inviteManagedMember).not.toHaveBeenCalled() }) - it('preserves a short name after trimming an overlong raw padded value', async () => { + it('lets a valid invitation reach the managed invitation service', async () => { + // Witness for the local rejections above: every input that passes the edge + // checks still reaches control-api through the service. authTokenMock.verifyToken.mockReturnValueOnce(claims) - memberManagementMock.inviteManagedMember.mockResolvedValueOnce({ id: 'inv-padded' }) + memberManagementMock.inviteManagedMember.mockResolvedValueOnce({ id: 'inv-ok' }) await request(makeApp()) .post('/members/invite') @@ -275,66 +204,16 @@ describe('routes/members', () => { name: `${' '.repeat(121)}Alice`, teams: [{ teamId: 'team-1', role: 'member' }], }) - .expect(201, { id: 'inv-padded' }) + .expect(201, { id: 'inv-ok' }) expect(memberManagementMock.inviteManagedMember).toHaveBeenCalledWith( 'invitee@example.com', - `${' '.repeat(121)}Alice`, + 'Alice', [{ teamId: 'team-1', role: 'member' }], 'good-token' ) }) - it('forwards the original overlength name instead of fabricating a sentinel', async () => { - authTokenMock.verifyToken.mockReturnValueOnce(claims) - memberManagementMock.inviteManagedMember.mockRejectedValueOnce( - new ControlApiError('invalid invitation name', 400, { error: 'invalid_name' }) - ) - - await request(makeApp()) - .post('/members/invite') - .set('authorization', 'Bearer good-token') - .send({ - email: 'invitee@example.com', - name: `${' '.repeat(10)}${'a'.repeat(121)}`, - teams: [{ teamId: 'team-1', role: 'member' }], - }) - .expect(400, { error: 'Name is too long' }) - - const [, forwardedName] = memberManagementMock.inviteManagedMember.mock.calls[0] - expect(forwardedName).toBe(`${' '.repeat(10)}${'a'.repeat(121)}`) - }) - - it('preserves the public team-count error after authoritative validation', async () => { - authTokenMock.verifyToken.mockReturnValueOnce(claims) - memberManagementMock.inviteManagedMember.mockRejectedValueOnce( - new ControlApiError('too many invitation teams', 400, { error: 'too_many_teams' }) - ) - - await request(makeApp()) - .post('/members/invite') - .set('authorization', 'Bearer good-token') - .send({ - email: 'invitee@example.com', - teams: Array.from({ length: 51 }, (_, index) => ({ - teamId: `team-${index}`, - role: 'member', - })), - }) - .expect(400, { error: 'Too many teams selected' }) - - const originalTeams = Array.from({ length: 51 }, (_, index) => ({ - teamId: `team-${index}`, - role: 'member', - })) - expect(memberManagementMock.inviteManagedMember).toHaveBeenCalledWith( - 'invitee@example.com', - undefined, - originalTeams, - 'good-token' - ) - }) - it('forwards a governed member-retirement reason, replay key, and correlation id', async () => { authTokenMock.verifyToken.mockReturnValueOnce(claims) memberManagementMock.deleteManagedUser.mockResolvedValueOnce({ ok: true }) diff --git a/external-rest-api/test/routes.membersInvitationContract.test.ts b/external-rest-api/test/routes.membersInvitationContract.test.ts deleted file mode 100644 index 8eef8c54d5..0000000000 --- a/external-rest-api/test/routes.membersInvitationContract.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -import express from 'express' -import { readFileSync } from 'node:fs' -import { type Server, createServer } from 'node:http' -import type { AddressInfo } from 'node:net' -import request from 'supertest' -import { config } from '../src/config.js' -import { createMembersRouter } from '../src/routes/members.js' - -type InvitationErrorContract = Record< - string, - { - control: { status: number; body: { error: string } } - external: { status: number; body: { error: string } } - } -> - -const invitationErrorContract = JSON.parse( - readFileSync( - new URL('../../tests/contracts/external-member-invitation-errors.json', import.meta.url), - 'utf8' - ) -) as InvitationErrorContract - -const authTokenMock = vi.hoisted(() => ({ - verifyToken: vi.fn(), -})) - -vi.mock('../src/authToken.js', () => authTokenMock) - -const claims = { - userId: 'user-1', - email: 'user@example.com', - teamId: 'team-1', - role: 'admin' as const, - exp: 9999999999, -} - -type ProducerResponse = { - status: number - body: Record -} - -type ProducerRequest = { - method: string | undefined - path: string | undefined - body: unknown -} - -function makeExternalApp() { - const app = express() - app.use(express.json()) - app.use(createMembersRouter()) - return app -} - -function createControlApiWireProducer() { - let nextResponse: ProducerResponse | undefined - const requests: ProducerRequest[] = [] - const server = createServer((req, res) => { - const chunks: Buffer[] = [] - req.on('data', chunk => chunks.push(Buffer.from(chunk))) - req.on('end', () => { - const raw = Buffer.concat(chunks).toString('utf8') - requests.push({ - method: req.method, - path: req.url, - body: raw ? JSON.parse(raw) : undefined, - }) - - if (!nextResponse) { - res.statusCode = 500 - res.end(JSON.stringify({ error: 'missing_test_response' })) - return - } - - res.statusCode = nextResponse.status - res.setHeader('content-type', 'application/json') - res.end(JSON.stringify(nextResponse.body)) - nextResponse = undefined - }) - }) - - return { - server, - requests, - respondWith(response: ProducerResponse) { - nextResponse = response - }, - reset() { - nextResponse = undefined - requests.length = 0 - }, - } -} - -describe('member invitation cross-service error contract', () => { - const originalControlApiBaseUrl = config.controlApiBaseUrl - const producer = createControlApiWireProducer() - let server: Server - - beforeAll(async () => { - server = producer.server - await new Promise((resolve, reject) => { - server.once('error', reject) - server.listen(0, '127.0.0.1', () => { - server.off('error', reject) - resolve() - }) - }) - const address = server.address() as AddressInfo - config.controlApiBaseUrl = `http://127.0.0.1:${address.port}` - }) - - beforeEach(() => { - vi.clearAllMocks() - producer.reset() - authTokenMock.verifyToken.mockReturnValue(claims) - }) - - afterAll(async () => { - config.controlApiBaseUrl = originalControlApiBaseUrl - await new Promise((resolve, reject) => { - server.close(error => (error ? reject(error) : resolve())) - }) - }) - - it('preserves the authoritative invalid-email response through the real client parser', async () => { - const contract = invitationErrorContract.invalidEmail - producer.respondWith(contract.control) - - await request(makeExternalApp()) - .post('/members/invite') - .set('authorization', 'Bearer good-token') - .send({ email: 'not-an-email', teams: [] }) - .expect(contract.external.status, contract.external.body) - - expect(producer.requests).toEqual([ - { - method: 'POST', - path: '/external/members/invitations', - body: { - email: 'not-an-email', - teams: [], - }, - }, - ]) - }) - - it.each([ - { - label: 'empty assignments', - contract: 'invalidPayload', - body: { email: 'invitee@example.com', teams: [] }, - }, - { - label: 'an overlong name', - contract: 'invalidName', - body: { - email: 'invitee@example.com', - name: 'a'.repeat(121), - teams: [{ teamId: 'team-1', role: 'member' }], - }, - }, - { - label: 'too many teams', - contract: 'tooManyTeams', - body: { - email: 'invitee@example.com', - teams: Array.from({ length: 51 }, (_, index) => ({ - teamId: `team-${index}`, - role: 'member', - })), - }, - }, - ])( - 'preserves the authoritative response for $label through the real client parser', - async ({ body, contract: contractName }) => { - const contract = invitationErrorContract[contractName] - producer.respondWith(contract.control) - - await request(makeExternalApp()) - .post('/members/invite') - .set('authorization', 'Bearer good-token') - .send(body) - .expect(contract.external.status, contract.external.body) - - expect(producer.requests).toHaveLength(1) - expect(producer.requests[0]).toMatchObject({ - method: 'POST', - path: '/external/members/invitations', - }) - } - ) - - it('preserves the padded-name candidate across the Control API wire boundary', async () => { - producer.respondWith({ status: 201, body: { id: 'inv-padded' } }) - - await request(makeExternalApp()) - .post('/members/invite') - .set('authorization', 'Bearer good-token') - .send({ - email: 'invitee@example.com', - name: `${' '.repeat(121)}Alice`, - teams: [{ teamId: 'team-1', role: 'member' }], - }) - .expect(201, { id: 'inv-padded' }) - - expect(producer.requests).toEqual([ - { - method: 'POST', - path: '/external/members/invitations', - body: { - email: 'invitee@example.com', - name: `${' '.repeat(121)}Alice`, - teams: [{ teamId: 'team-1', role: 'member' }], - }, - }, - ]) - }) -}) diff --git a/external-rest-api/test/routes.notifications.test.ts b/external-rest-api/test/routes.notifications.test.ts index 1b550fc221..1cd0ef1237 100644 --- a/external-rest-api/test/routes.notifications.test.ts +++ b/external-rest-api/test/routes.notifications.test.ts @@ -1,16 +1,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import express from 'express' import request from 'supertest' +import { externalRestPublicErrorHandler } from '../src/app.js' import { createNotificationsRouter } from '../src/routes/notifications.js' const authTokenMock = vi.hoisted(() => ({ verifyToken: vi.fn(), })) -const controlApiClientMock = vi.hoisted(() => ({ - controlApiStreamRequest: vi.fn(), - controlApiRequest: vi.fn(), - ControlApiError: class ControlApiError extends Error { +const controlApiClientMock = vi.hoisted(() => { + class ControlApiError extends Error { status: number body: unknown constructor(message: string, status: number, body: unknown) { @@ -18,8 +17,19 @@ const controlApiClientMock = vi.hoisted(() => ({ this.status = status this.body = body } - }, -})) + } + class ControlApiPathError extends ControlApiError { + constructor() { + super('Control API request path is not allowed', 400, { error: 'invalid_request' }) + } + } + return { + controlApiStreamRequest: vi.fn(), + controlApiRequest: vi.fn(), + ControlApiError, + ControlApiPathError, + } +}) vi.mock('../src/authToken.js', () => authTokenMock) vi.mock('../src/controlApiClient.js', () => controlApiClientMock) @@ -53,6 +63,7 @@ describe('routes/notifications', () => { const app = express() app.use(express.json()) app.use(createNotificationsRouter()) + app.use(externalRestPublicErrorHandler) return app } @@ -146,7 +157,7 @@ describe('routes/notifications', () => { ) }) - it('sanitizes partial preference PUT validation errors from control-api', async () => { + it('forwards partial preference PUT validation errors from control-api', async () => { authTokenMock.verifyToken.mockReturnValueOnce(claims) controlApiClientMock.controlApiRequest.mockRejectedValueOnce( new controlApiClientMock.ControlApiError('invalid_channel_fallback_enabled', 400, { @@ -161,14 +172,7 @@ describe('routes/notifications', () => { .send({ preferredMedium: 'telegram' }) .expect(400) - expect(response.body).toEqual({ - error: { - code: 'invalid_request', - message: 'The request is not valid.', - correlationId: 'notifications_ID-42', - retryable: false, - }, - }) + expect(response.body).toEqual({ error: 'invalid_channel_fallback_enabled' }) expect(controlApiClientMock.controlApiRequest).toHaveBeenCalledWith( 'PUT', '/external/me/notification-preferences', diff --git a/external-rest-api/test/routes.oauthGrants.test.ts b/external-rest-api/test/routes.oauthGrants.test.ts index faeef16d42..c4753f6061 100644 --- a/external-rest-api/test/routes.oauthGrants.test.ts +++ b/external-rest-api/test/routes.oauthGrants.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import express from 'express' import request from 'supertest' +import { externalRestPublicErrorHandler } from '../src/app.js' import { ControlApiError } from '../src/controlApiClient.js' import { createOauthGrantsRouter } from '../src/routes/oauthGrants.js' @@ -17,6 +18,7 @@ function makeApp() { const app = express() app.use(express.json()) app.use(createOauthGrantsRouter()) + app.use(externalRestPublicErrorHandler) return app } @@ -32,7 +34,7 @@ describe('routes/oauthGrants', () => { }) }) - it('uses the request correlation when a sanitized upstream error has none', async () => { + it('forwards an upstream error body verbatim', async () => { serviceMock.listOauthGrants.mockRejectedValueOnce( new ControlApiError('private upstream detail', 403, { error: { code: 'forbidden' } }) ) @@ -43,6 +45,7 @@ describe('routes/oauthGrants', () => { .set('x-correlation-id', 'oauth_grants_ID-42') .expect(403) - expect(response.body.error.correlationId).toBe('oauth_grants_ID-42') + expect(response.body).toEqual({ error: { code: 'forbidden' } }) + expect(serviceMock.listOauthGrants).toHaveBeenCalledTimes(1) }) }) diff --git a/external-rest-api/test/routes.rpc.test.ts b/external-rest-api/test/routes.rpc.test.ts index 8f3cbee4c0..9be6edad89 100644 --- a/external-rest-api/test/routes.rpc.test.ts +++ b/external-rest-api/test/routes.rpc.test.ts @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import express from 'express' import request from 'supertest' -import { ControlApiError } from '../src/controlApiClient.js' import { createRpcRouter } from '../src/routes/rpc.js' const rpcServiceMock = vi.hoisted(() => ({ issueRpcAccessToken: vi.fn() })) @@ -17,6 +16,17 @@ function buildApp() { return app } +const rpcDenialReasons = [ + 'invalid_host_refs', + 'sandbox_ui_host_ref_required', + 'sandbox_ui_scope_required', + 'sandbox_ui_host_ref_exclusive', + 'host_access_denied', + 'direct_host_access_required', + 'desktop_requires_team', + 'no_permitted_scopes', +] as const + describe('routes/rpc /rpc/token', () => { beforeEach(() => { rpcServiceMock.issueRpcAccessToken.mockReset() @@ -30,39 +40,10 @@ describe('routes/rpc /rpc/token', () => { }) }) - it('returns a bounded public envelope without reflecting the control-api denial body', async () => { - rpcServiceMock.issueRpcAccessToken.mockRejectedValue( - new ControlApiError('secret internal topology', 403, { - error: 'secret-internal-reason', - path: '/internal/control-api', - }) - ) - - const res = await request(buildApp()) - .post('/rpc/token') - .set('authorization', 'Bearer session-xyz') - .set('x-correlation-id', 'rpc_ID-42') - .send({ scopes: ['desktop:view'], hostRefs: ['pro-agent'] }) - - expect(res.status).toBe(403) - expect(res.body).toEqual({ - error: { - code: 'forbidden', - message: 'The requested operation is not allowed.', - correlationId: 'rpc_ID-42', - retryable: false, - }, - }) - expect(JSON.stringify(res.body)).not.toContain('secret-internal-reason') - expect(JSON.stringify(res.body)).not.toContain('/internal/control-api') - }) - - it.each(['desktop_requires_team', 'no_permitted_scopes'])( - 'preserves approved safe RPC denial reason %s', + it.each(rpcDenialReasons)( + 'relays the control-api denial reason %s instead of a generic 403', async reason => { - rpcServiceMock.issueRpcAccessToken.mockRejectedValue( - new ControlApiError('private upstream denial', 403, { error: reason }) - ) + rpcServiceMock.issueRpcAccessToken.mockResolvedValue({ error: reason }) const res = await request(buildApp()) .post('/rpc/token') @@ -70,16 +51,7 @@ describe('routes/rpc /rpc/token', () => { .send({ scopes: ['desktop:view'], hostRefs: ['pro-agent'] }) expect(res.status).toBe(403) - expect(res.body).toEqual({ - error: { - code: 'forbidden', - message: 'The requested operation is not allowed.', - correlationId: expect.any(String), - retryable: false, - details: { reason }, - }, - }) - expect(JSON.stringify(res.body)).not.toContain('private upstream denial') + expect(res.body).toEqual({ error: reason }) } ) diff --git a/external-rest-api/test/routes.sessionBackendUnavailableNoStore.test.ts b/external-rest-api/test/routes.sessionBackendUnavailableNoStore.test.ts index 67cf984573..cbf44b59a6 100644 --- a/external-rest-api/test/routes.sessionBackendUnavailableNoStore.test.ts +++ b/external-rest-api/test/routes.sessionBackendUnavailableNoStore.test.ts @@ -104,26 +104,26 @@ describe('External REST session backend 503 cache semantics', () => { }) }) - it('keeps upstream no-store on the sanitized switch-team authority 503', async () => { + it('keeps upstream no-store on the forwarded switch-team authority 503', async () => { const response = await request(createApp()) .post('/api/v1/me/switch-team') .set('authorization', 'Bearer synthetic-session-token') .send({ teamId: 'team-b' }) expect(response.status).toBe(503) - expect(response.body.error).toMatchObject({ code: 'authority_unavailable', retryable: true }) + expect(response.body).toEqual({ error: 'session_backend_unavailable', retryAfterSeconds: 2 }) expect(response.headers['retry-after']).toBe('2') expect(response.headers['cache-control']).toBe('no-store') }) - it('keeps upstream no-store on the sanitized create-team authority 503', async () => { + it('keeps upstream no-store on the forwarded create-team authority 503', async () => { const response = await request(createApp()) .post('/api/v1/team') .set('authorization', 'Bearer synthetic-session-token') .send({ name: 'Team New' }) expect(response.status).toBe(503) - expect(response.body.error).toMatchObject({ code: 'authority_unavailable', retryable: true }) + expect(response.body).toEqual({ error: 'session_backend_unavailable', retryAfterSeconds: 2 }) expect(response.headers['retry-after']).toBe('2') expect(response.headers['cache-control']).toBe('no-store') }) @@ -141,7 +141,7 @@ describe('External REST session backend 503 cache semantics', () => { .send({ teamId: 'team-b' }) expect(response.status).toBe(503) - expect(response.body.error).toMatchObject({ code: 'authority_unavailable', retryable: true }) + expect(response.body).toEqual({ error: 'authority_unavailable', retryAfterSeconds: 2 }) expect(response.headers['retry-after']).toBe('2') expect(response.headers['cache-control']).toBeUndefined() }) diff --git a/external-rest-api/test/routes.userApprovalDecisions.test.ts b/external-rest-api/test/routes.userApprovalDecisions.test.ts index 8ce3eca521..6cf0cfbcd4 100644 --- a/external-rest-api/test/routes.userApprovalDecisions.test.ts +++ b/external-rest-api/test/routes.userApprovalDecisions.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import express from 'express' import request from 'supertest' +import { externalRestPublicErrorHandler } from '../src/app.js' import { ControlApiError } from '../src/controlApiClient.js' import { createUserApprovalDecisionsRouter } from '../src/routes/userApprovalDecisions.js' @@ -35,6 +36,7 @@ describe('routes/userApprovalDecisions', () => { const app = express() app.use(express.json()) app.use(createUserApprovalDecisionsRouter()) + app.use(externalRestPublicErrorHandler) return app } @@ -169,7 +171,7 @@ describe('routes/userApprovalDecisions', () => { expect(userApprovalDecisionsServiceMock.decideUserApprovalDecision).not.toHaveBeenCalled() }) - it('sanitizes expected control-api errors from pending list', async () => { + it('forwards expected control-api errors from pending list', async () => { authTokenMock.verifyToken.mockReturnValueOnce(claims) userApprovalDecisionsServiceMock.listPendingUserApprovalDecisions.mockRejectedValueOnce( new ControlApiError('gone', 410, { error: 'expired' }) @@ -182,17 +184,13 @@ describe('routes/userApprovalDecisions', () => { .set('x-correlation-id', 'approval_decisions_ID-42') .expect(410) - expect(response.body).toEqual({ - error: { - code: 'gone', - message: 'The resource is no longer available.', - correlationId: 'approval_decisions_ID-42', - retryable: false, - }, - }) + expect(response.body).toEqual({ error: 'expired' }) + expect(userApprovalDecisionsServiceMock.listPendingUserApprovalDecisions).toHaveBeenCalledTimes( + 1 + ) }) - it('sanitizes expected control-api errors with message fallback bodies', async () => { + it('answers the status sentence for a non-object upstream body', async () => { authTokenMock.verifyToken.mockReturnValueOnce(claims) userApprovalDecisionsServiceMock.decideUserApprovalDecision.mockRejectedValueOnce( new ControlApiError('conflict', 409, 'not-json') @@ -205,14 +203,8 @@ describe('routes/userApprovalDecisions', () => { .send({ decision: 'deny' }) .expect(409) - expect(response.body).toEqual({ - error: { - code: 'conflict', - message: 'The request conflicts with current state.', - correlationId: expect.any(String), - retryable: false, - }, - }) + expect(response.body).toEqual({ error: 'Conflict' }) + expect(userApprovalDecisionsServiceMock.decideUserApprovalDecision).toHaveBeenCalledTimes(1) }) it('delegates unexpected service errors to the express error handler', async () => { diff --git a/external-rest-api/test/routes.workflowApprovalMediums.test.ts b/external-rest-api/test/routes.workflowApprovalMediums.test.ts index beb4933a8a..4238b743f2 100644 --- a/external-rest-api/test/routes.workflowApprovalMediums.test.ts +++ b/external-rest-api/test/routes.workflowApprovalMediums.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import express from 'express' import request from 'supertest' +import { externalRestPublicErrorHandler } from '../src/app.js' import { ControlApiError } from '../src/controlApiClient.js' import { createWorkflowApprovalMediumsRouter } from '../src/routes/workflowApprovalMediums.js' @@ -25,11 +26,7 @@ function makeApp() { const app = express() app.use(express.json()) app.use(createWorkflowApprovalMediumsRouter()) - app.use( - (err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { - res.status(500).json({ error: err instanceof Error ? err.message : String(err) }) - } - ) + app.use(externalRestPublicErrorHandler) return app } @@ -284,7 +281,7 @@ describe('routes/workflowApprovalMediums', () => { expect(serviceMock.listApprovalChannelTargets).toHaveBeenCalledWith('session-token') }) - it('uses the request correlation when a sanitized upstream error has none', async () => { + it('forwards an upstream error body verbatim', async () => { authTokenMock.verifyToken.mockReturnValueOnce({ userId: 'user-1', exp: 9999999999 }) serviceMock.listWorkflowApprovalMediums.mockRejectedValueOnce( new ControlApiError('private upstream detail', 403, { error: { code: 'forbidden' } }) @@ -296,6 +293,7 @@ describe('routes/workflowApprovalMediums', () => { .set('x-correlation-id', 'approval_mediums_ID-42') .expect(403) - expect(response.body.error.correlationId).toBe('approval_mediums_ID-42') + expect(response.body).toEqual({ error: { code: 'forbidden' } }) + expect(serviceMock.listWorkflowApprovalMediums).toHaveBeenCalledTimes(1) }) }) diff --git a/external-rest-api/test/routes.workflows.test.ts b/external-rest-api/test/routes.workflows.test.ts index f0532cb8a0..684a370ad1 100644 --- a/external-rest-api/test/routes.workflows.test.ts +++ b/external-rest-api/test/routes.workflows.test.ts @@ -1,17 +1,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import express from 'express' import request from 'supertest' +import { externalRestPublicErrorHandler } from '../src/app.js' import { createExternalWorkflowsRouter } from '../src/routes/workflows.js' const authTokenMock = vi.hoisted(() => ({ verifyToken: vi.fn(), })) -const controlApiClientMock = vi.hoisted(() => ({ - controlApiRequest: vi.fn(), - controlApiRequestWithStatus: vi.fn(), - controlApiBinaryRequestWithStatus: vi.fn(), - ControlApiError: class ControlApiError extends Error { +const controlApiClientMock = vi.hoisted(() => { + class ControlApiError extends Error { status: number body: unknown constructor(message: string, status: number, body: unknown) { @@ -19,8 +17,20 @@ const controlApiClientMock = vi.hoisted(() => ({ this.status = status this.body = body } - }, -})) + } + class ControlApiPathError extends ControlApiError { + constructor() { + super('Control API request path is not allowed', 400, { error: 'invalid_request' }) + } + } + return { + controlApiRequest: vi.fn(), + controlApiRequestWithStatus: vi.fn(), + controlApiBinaryRequestWithStatus: vi.fn(), + ControlApiError, + ControlApiPathError, + } +}) vi.mock('../src/authToken.js', () => authTokenMock) vi.mock('../src/controlApiClient.js', () => controlApiClientMock) @@ -45,6 +55,7 @@ describe('routes/workflows', () => { const app = express() app.use(express.json()) app.use('/api/v1', createExternalWorkflowsRouter()) + app.use(externalRestPublicErrorHandler) return app } @@ -68,7 +79,7 @@ describe('routes/workflows', () => { ) }) - it('uses the bounded request correlation ID when a route-local upstream error has none', async () => { + it('forwards a route-local upstream error body verbatim', async () => { authTokenMock.verifyToken.mockReturnValueOnce(claims) controlApiClientMock.controlApiRequest.mockRejectedValueOnce( new controlApiClientMock.ControlApiError('private', 404, { error: { code: 'not_found' } }) @@ -80,7 +91,12 @@ describe('routes/workflows', () => { .set('x-correlation-id', 'workflow_ID-42') expect(res.status).toBe(404) - expect(res.body.error.correlationId).toBe('workflow_ID-42') + expect(res.body).toEqual({ error: { code: 'not_found' } }) + expect(controlApiClientMock.controlApiRequest).toHaveBeenCalledWith( + 'GET', + '/external/workflows/sandbox-recipes/recipe-a/runs/run-123/artifacts', + { userSessionToken: 'user-session-token' } + ) }) it('proxies run-scoped workflow artifact downloads without using latest-run routes', async () => { diff --git a/external-rest-api/test/services.invitationsService.test.ts b/external-rest-api/test/services.invitationsService.test.ts index 61a812b7e6..10ea2f0a01 100644 --- a/external-rest-api/test/services.invitationsService.test.ts +++ b/external-rest-api/test/services.invitationsService.test.ts @@ -41,6 +41,22 @@ describe('invitation service error and token boundaries', () => { } }) + it('classifies every non-outage acceptance failure as not pending', async () => { + for (const status of [401, 500]) { + client.request.mockRejectedValueOnce( + new ControlApiError( + `Control API POST /external/invitations/accept failed (${status})`, + status, + { error: status === 401 ? 'Unauthorized' : 'internal_error' } + ) + ) + + await expect(acceptInvitation('flow-token', 'user@example.com', 'v2')).resolves.toEqual({ + error: 'not_pending', + }) + } + }) + it('classifies a producer password_already_set rejection as a terminal domain error', async () => { client.request.mockRejectedValueOnce( new ControlApiError( diff --git a/external-rest-api/test/services.legacyAuthorityErrors.test.ts b/external-rest-api/test/services.legacyAuthorityErrors.test.ts index 8445706692..5e533e9325 100644 --- a/external-rest-api/test/services.legacyAuthorityErrors.test.ts +++ b/external-rest-api/test/services.legacyAuthorityErrors.test.ts @@ -34,71 +34,80 @@ describe('legacy authority adapters', () => { clientMock.controlApiRequest.mockReset() }) - it('maps only an explicit missing user or membership to absence', async () => { - clientMock.controlApiRequest.mockRejectedValueOnce(upstream(404)) - await expect(getMe(auth.userId, auth.teamId, auth.sessionToken)).resolves.toBeNull() + it('maps every non-outage /me and switch-team failure to absence', async () => { + for (const status of [404, 401, 500]) { + clientMock.controlApiRequest.mockRejectedValueOnce(upstream(status)) + await expect(getMe(auth.userId, auth.teamId, auth.sessionToken)).resolves.toBeNull() + + clientMock.controlApiRequest.mockRejectedValueOnce(upstream(status)) + await expect( + switchTeam(auth.userId, auth.email, 'team-2', auth.sessionToken) + ).resolves.toBeNull() + } clientMock.controlApiRequest.mockRejectedValueOnce(upstream(503)) await expect(getMe(auth.userId, auth.teamId, auth.sessionToken)).rejects.toMatchObject({ status: 503, }) - clientMock.controlApiRequest.mockRejectedValueOnce(upstream(404)) - await expect( - switchTeam(auth.userId, auth.email, 'team-2', auth.sessionToken) - ).resolves.toBeNull() - clientMock.controlApiRequest.mockRejectedValueOnce(upstream(503)) await expect( switchTeam(auth.userId, auth.email, 'team-2', auth.sessionToken) ).rejects.toMatchObject({ status: 503 }) + + expect(clientMock.controlApiRequest).toHaveBeenCalledTimes(8) }) it('does not convert current-team authority outages into an empty team', async () => { - clientMock.controlApiRequest.mockRejectedValueOnce(upstream(404)) - await expect(getCurrentTeam(auth)).resolves.toBeNull() + for (const status of [404, 401, 500]) { + clientMock.controlApiRequest.mockRejectedValueOnce(upstream(status)) + await expect(getCurrentTeam(auth)).resolves.toBeNull() + } clientMock.controlApiRequest.mockRejectedValueOnce(upstream(503)) await expect(getCurrentTeam(auth)).rejects.toMatchObject({ status: 503 }) - }) - it('keeps rename denial, absence, and authority unavailability distinct', async () => { - clientMock.controlApiRequest.mockRejectedValueOnce(upstream(403)) - await expect(renameTeam(auth, 'Renamed')).resolves.toEqual({ error: 'forbidden' }) + expect(clientMock.controlApiRequest).toHaveBeenCalledTimes(4) + }) - clientMock.controlApiRequest.mockRejectedValueOnce(upstream(404)) - await expect(renameTeam(auth, 'Renamed')).resolves.toEqual({ error: 'not_found' }) + it('maps every rename failure except an outage to not_found', async () => { + for (const status of [403, 404, 401, 500]) { + clientMock.controlApiRequest.mockRejectedValueOnce(upstream(status)) + await expect(renameTeam(auth, 'Renamed')).resolves.toEqual({ error: 'not_found' }) + } clientMock.controlApiRequest.mockRejectedValueOnce(upstream(503)) await expect(renameTeam(auth, 'Renamed')).rejects.toMatchObject({ status: 503 }) - }) - it('keeps member-read denial distinct from missing targets and outages', async () => { - clientMock.controlApiRequest.mockRejectedValueOnce(upstream(403)) - await expect(updateMemberRole(auth, 'user-2', 'member')).resolves.toEqual({ - error: 'forbidden', - }) + expect(clientMock.controlApiRequest).toHaveBeenCalledTimes(5) + }) - clientMock.controlApiRequest.mockRejectedValueOnce(upstream(404)) - await expect(updateMemberRole(auth, 'user-2', 'member')).resolves.toEqual({ - error: 'not_found', - }) + it('maps every member-read failure except an outage to not_found', async () => { + for (const status of [403, 404, 401, 500]) { + clientMock.controlApiRequest.mockRejectedValueOnce(upstream(status)) + await expect(updateMemberRole(auth, 'user-2', 'member')).resolves.toEqual({ + error: 'not_found', + }) + } clientMock.controlApiRequest.mockRejectedValueOnce(upstream(503)) await expect(updateMemberRole(auth, 'user-2', 'member')).rejects.toMatchObject({ status: 503, }) - }) - it('keeps member-delete denial distinct from missing targets and outages', async () => { - clientMock.controlApiRequest.mockRejectedValueOnce(upstream(403)) - await expect(deleteMember(auth, 'user-2')).resolves.toEqual({ error: 'forbidden' }) + expect(clientMock.controlApiRequest).toHaveBeenCalledTimes(5) + }) - clientMock.controlApiRequest.mockRejectedValueOnce(upstream(404)) - await expect(deleteMember(auth, 'user-2')).resolves.toEqual({ error: 'not_found' }) + it('maps every member-delete failure except an outage to not_found', async () => { + for (const status of [403, 404, 401, 500]) { + clientMock.controlApiRequest.mockRejectedValueOnce(upstream(status)) + await expect(deleteMember(auth, 'user-2')).resolves.toEqual({ error: 'not_found' }) + } clientMock.controlApiRequest.mockRejectedValueOnce(upstream(503)) await expect(deleteMember(auth, 'user-2')).rejects.toMatchObject({ status: 503 }) + + expect(clientMock.controlApiRequest).toHaveBeenCalledTimes(5) }) it('does not convert desktop-authorization authority outages into bad passwords', async () => { diff --git a/external-rest-api/test/services.rpc.test.ts b/external-rest-api/test/services.rpc.test.ts index 376ecddb2b..6f25951307 100644 --- a/external-rest-api/test/services.rpc.test.ts +++ b/external-rest-api/test/services.rpc.test.ts @@ -13,21 +13,21 @@ describe('rpcService.issueRpcAccessToken', () => { clientMock.controlApiRequest.mockReset() }) - it('does not convert a control-api 403 body into a public result', async () => { + it('relays a control-api 403 body as a result value', async () => { clientMock.controlApiRequest.mockRejectedValueOnce( new ControlApiError( - 'Control API POST /external/rpc/token failed (403): secret-internal-reason', + 'Control API POST /external/rpc/token failed (403): desktop_requires_team', 403, - { error: 'secret-internal-reason' } + { error: 'desktop_requires_team' } ) ) - await expect( - issueRpcAccessToken('session', ['desktop:view'], ['pro-agent']) - ).rejects.toBeInstanceOf(ControlApiError) + await expect(issueRpcAccessToken('session', ['desktop:view'], ['pro-agent'])).resolves.toEqual({ + error: 'desktop_requires_team', + }) }) - it('propagates malformed 403 bodies for the route sanitizer to reject safely', async () => { + it('maps malformed 403 bodies to the generic forbidden reason', async () => { for (const body of [{ error: ['desktop_requires_team'] }, { error: 42 }]) { clientMock.controlApiRequest.mockRejectedValueOnce( new ControlApiError( @@ -39,7 +39,7 @@ describe('rpcService.issueRpcAccessToken', () => { await expect( issueRpcAccessToken('session', ['desktop:view'], ['pro-agent']) - ).rejects.toBeInstanceOf(ControlApiError) + ).resolves.toEqual({ error: 'forbidden' }) } }) From 403774ec68c90b7d4ee4d112c2542b642918980c Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Tue, 3 Nov 2026 16:54:23 +0100 Subject: [PATCH 446/528] fix(control-api): restore dev error bodies for P-01..P-08 Return the flat `{error: ""}` bodies dev emits on the external surfaces: the global handler no longer rewrites thrown 4xx into the public envelope, workflows admission and directory search use the plain session/team matchers, notifications and the entity-change stream answer their dev bodies, and a retired Google login answers 401. Team rename follows dev again: any live member renames, `renameTeamForUser` and its export leave, and `teams.ts` is byte-identical to base. The single D34 re-pin moves `PRE_D34_TEAMS_WRITER_SHA256` to that base hash and the test title follows. --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- control-api/src/http/errorHandler.ts | 25 ----------- .../src/middleware/externalSessionAuth.ts | 33 +++----------- control-api/src/routes/entityChangeStream.ts | 16 +++---- control-api/src/routes/external/auth.ts | 2 +- control-api/src/routes/external/directory.ts | 13 +++--- .../routes/external/notifications.routes.ts | 3 +- control-api/src/routes/external/teams.ts | 17 ++----- .../routes/external/workflows/admission.ts | 6 +-- control-api/src/services/directory/index.ts | 1 - control-api/src/services/directory/teams.ts | 39 ---------------- .../test/http.errorHandler.k8sStatus.test.ts | 30 ++++++++----- ...atibility.realPostgres.integration.test.ts | 4 +- .../test/routes.entityChangeStream.test.ts | 20 ++++++++- ....externalAuth.googleLoginRejection.test.ts | 6 +-- .../test/routes.externalDirectory.test.ts | 25 ++++++++++- ....externalNotifications.currentness.test.ts | 15 +++++++ ...Authority.realPostgres.integration.test.ts | 19 -------- .../routes.externalTeamsInvitations.test.ts | 2 +- ...evocation.realPostgres.integration.test.ts | 44 +++++++++++++++---- .../test/routes.externalWorkflows.test.ts | 4 +- control-api/test/routes.profile.test.ts | 15 +++++-- .../test/services.directory.teams.test.ts | 32 -------------- 24 files changed, 161 insertions(+), 216 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index dd87bb8034..c6e59849f9 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.888", + "version": "0.1.889", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.888", + "version": "0.1.889", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 6a10ba8e2e..b16a842fd6 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.888", + "version": "0.1.889", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/http/errorHandler.ts b/control-api/src/http/errorHandler.ts index cc95ca7b39..5743fd11e3 100644 --- a/control-api/src/http/errorHandler.ts +++ b/control-api/src/http/errorHandler.ts @@ -240,31 +240,6 @@ export function clerumErrorHandler( }, 'forwarded client error from upstream' ) - if (req.originalUrl.startsWith('/api/v1/external')) { - const publicCode = - errStatus === 401 - ? 'invalid_session' - : errStatus === 403 - ? 'forbidden' - : errStatus === 404 - ? 'not_found' - : errStatus === 429 - ? 'rate_limited' - : 'invalid_request' - sendPublicApiError( - req, - res, - errStatus as number, - publicCode, - errStatus === 404 - ? 'The resource was not found.' - : errStatus === 429 - ? 'Too many requests; retry later.' - : 'The request could not be completed.', - errStatus === 429 - ) - return - } res.status(errStatus as number).json({ error: clientErrorMessage(err, errStatus as number), ...(forwardedCode ? { code: forwardedCode } : {}), diff --git a/control-api/src/middleware/externalSessionAuth.ts b/control-api/src/middleware/externalSessionAuth.ts index c4c44a8305..692074c119 100644 --- a/control-api/src/middleware/externalSessionAuth.ts +++ b/control-api/src/middleware/externalSessionAuth.ts @@ -279,16 +279,12 @@ export function requireExternalUserParamMatch(paramName = 'userId') { } } -function externalTeamParamMatcher(paramName: string, publicErrors: boolean) { +function externalTeamParamMatcher(paramName: string) { return async (req: ExternalAuthedRequest, res: Response, next: NextFunction): Promise => { const claims = req.externalAuth const requestedTeamId = String(req.params?.[paramName] || req.query?.[paramName] || '').trim() if (!claims || !requestedTeamId) { - if (publicErrors) { - sendPublicApiError(req, res, 403, 'forbidden', 'The requested operation is not allowed.') - } else { - res.status(403).json({ error: 'Forbidden' }) - } + res.status(403).json({ error: 'Forbidden' }) return } try { @@ -297,11 +293,7 @@ function externalTeamParamMatcher(paramName: string, publicErrors: boolean) { onDatabaseFailure: externalSessionDatabaseFailure, }) if (!membership) { - if (publicErrors) { - sendPublicApiError(req, res, 403, 'forbidden', 'The requested operation is not allowed.') - } else { - res.status(403).json({ error: 'Forbidden' }) - } + res.status(403).json({ error: 'Forbidden' }) return } req.externalTeamAuth = membership @@ -314,28 +306,13 @@ function externalTeamParamMatcher(paramName: string, publicErrors: boolean) { next(error) return } - if (publicErrors) { - sendPublicApiError( - req, - res, - 503, - 'authority_unavailable', - 'Authorization is temporarily unavailable.', - true - ) - } else { - res.status(503).json({ error: 'authority_unavailable' }) - } + res.status(503).json({ error: 'authority_unavailable' }) } } } export function requireExternalTeamParamMatch(paramName = 'teamId') { - return externalTeamParamMatcher(paramName, false) -} - -export function requireExternalTeamParamMatchWithPublicErrors(paramName = 'teamId') { - return externalTeamParamMatcher(paramName, true) + return externalTeamParamMatcher(paramName) } export function rejectBodyUserTeamMismatch( diff --git a/control-api/src/routes/entityChangeStream.ts b/control-api/src/routes/entityChangeStream.ts index abca26d953..a7da562a43 100644 --- a/control-api/src/routes/entityChangeStream.ts +++ b/control-api/src/routes/entityChangeStream.ts @@ -34,12 +34,6 @@ function authorizationStatus( : observation.status } -function sendSessionBackendUnavailable(res: Response): void { - res.setHeader('retry-after', '2') - res.setHeader('cache-control', 'no-store') - res.status(503).json({ error: 'session_backend_unavailable', retryAfterSeconds: 2 }) -} - type EntityChangeStreamMessage = | { schemaVersion: 1 @@ -313,11 +307,13 @@ export function streamEntityChanges( } try { - const initialAuthorization = authorizationStatus(await isAuthorized()) + const initialObservation = await isAuthorized() + const initialAuthorization = authorizationStatus(initialObservation) if (initialAuthorization === 'unavailable') { - sendSessionBackendUnavailable(res) - cleanup() - return + // Rethrow so the catch below answers dev's fixed 500 body; the head 503 + // is not in the approved typed-503 scope. + const error = typeof initialObservation === 'boolean' ? undefined : initialObservation.error + throw error ?? new Error('session authority unavailable') } if (initialAuthorization === 'denied') { res.status(401).end() diff --git a/control-api/src/routes/external/auth.ts b/control-api/src/routes/external/auth.ts index dab6382695..b4c1b531f4 100644 --- a/control-api/src/routes/external/auth.ts +++ b/control-api/src/routes/external/auth.ts @@ -194,7 +194,7 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { name: google.name, picture: google.picture, }) - if ('error' in login) return sendExternalLoginError(res, login.error) + if ('error' in login) return res.status(401).json({ error: 'Unauthorized' }) const role = login.membership.role const selection = selectExternalSessionRepresentation(externalSessionClient(req), policy) if (selection.status !== 'selected') { diff --git a/control-api/src/routes/external/directory.ts b/control-api/src/routes/external/directory.ts index 955367afd5..410232bf71 100644 --- a/control-api/src/routes/external/directory.ts +++ b/control-api/src/routes/external/directory.ts @@ -1,12 +1,11 @@ import { Router } from 'express' import { config } from '../../config.js' -import { sendPublicApiError } from '../../http/publicApiError.js' import { attachAccessExecutionBudget } from '../../middleware/accessExecutionBudget.js' import { createExternalClientRateLimiters } from '../../middleware/externalClientIdentity.js' import { type ExternalAuthedRequest, - requireExternalTeamParamMatchWithPublicErrors, - requireValidExternalSessionTokenWithPublicErrors, + requireExternalTeamParamMatch, + requireValidExternalSessionToken, } from '../../middleware/externalSessionAuth.js' import { DIRECTORY_SEARCH_MAX_QUERY_LENGTH, @@ -24,26 +23,26 @@ export function createExternalDirectoryRouter(): Router { router.use( '/external/directory', ...externalDirectoryRateLimits, - requireValidExternalSessionTokenWithPublicErrors, + requireValidExternalSessionToken, attachAccessExecutionBudget ) router.get( '/external/directory/search', - requireExternalTeamParamMatchWithPublicErrors(), + requireExternalTeamParamMatch(), async (req: ExternalAuthedRequest, res, next) => { try { const teamId = String(req.query.teamId || '').trim() const q = String(req.query.q || '').trim() const cursor = typeof req.query.cursor === 'string' ? req.query.cursor.trim() : undefined if (!teamId || !q || q.length > DIRECTORY_SEARCH_MAX_QUERY_LENGTH) { - sendPublicApiError(req, res, 400, 'invalid_request', 'Invalid directory search request.') + res.status(400).json({ error: 'Invalid directory search request.' }) return } return res.status(200).json(await searchDirectory(teamId, q, cursor)) } catch (error) { if (error instanceof DirectorySearchCursorError) { - sendPublicApiError(req, res, 400, 'invalid_request', 'Invalid directory search cursor.') + res.status(400).json({ error: 'Invalid directory search cursor.' }) return } return next(error) diff --git a/control-api/src/routes/external/notifications.routes.ts b/control-api/src/routes/external/notifications.routes.ts index 1bc508af53..e6e2a3320e 100644 --- a/control-api/src/routes/external/notifications.routes.ts +++ b/control-api/src/routes/external/notifications.routes.ts @@ -1,7 +1,6 @@ import { Request, Response, Router } from 'express' import { config } from '../../config.js' import { asyncHandler } from '../../http/asyncHandler.js' -import { sendPublicApiError } from '../../http/publicApiError.js' import { createExternalClientRateLimiters } from '../../middleware/externalClientIdentity.js' import { type ExternalAuthedRequest, @@ -129,7 +128,7 @@ export function createExternalNotificationsRouter(): Router { const initialAuthority = await observeExternalSessionCurrentness(authentication) if (initialAuthority.status === 'denied') { - sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') + res.status(401).json({ error: 'Unauthorized' }) return } if (initialAuthority.status === 'unavailable') { diff --git a/control-api/src/routes/external/teams.ts b/control-api/src/routes/external/teams.ts index 3902ac1ab6..5781a2e5a5 100644 --- a/control-api/src/routes/external/teams.ts +++ b/control-api/src/routes/external/teams.ts @@ -27,7 +27,7 @@ import { getTeamAgents, getTeamContexts, listMembers, - renameTeamForUser, + renameTeam, updateManagedMemberRoleForUser, } from '../../services/directory/index.js' import { normalizeTeamRoleInput } from '../../services/directory/types.js' @@ -88,23 +88,14 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { router.put( '/external/teams/:teamId/name', requireExternalTeamParamMatch(), - requireExternalRole(['admin']), rejectBodyUserTeamMismatch, async (req, res, next) => { try { const name = String(req.body?.name || '').trim() if (!name) return res.status(400).json({ error: 'name is required' }) - const result = await renameTeamForUser( - (req as ExternalAuthedRequest).externalAuth!.userId, - req.params.teamId, - name, - (req as ExternalAuthedRequest).externalSessionAuthority - ) - if ('error' in result) { - if (result.error === 'forbidden') return res.status(403).json({ error: 'forbidden' }) - return res.status(404).json({ error: 'not_found' }) - } - return res.status(200).json(result.team) + const updated = await renameTeam(req.params.teamId, name) + if (!updated) return res.status(404).json({ error: 'not_found' }) + return res.status(200).json(updated) } catch (error) { return next(error) } diff --git a/control-api/src/routes/external/workflows/admission.ts b/control-api/src/routes/external/workflows/admission.ts index 875c515f07..6f4026eb44 100644 --- a/control-api/src/routes/external/workflows/admission.ts +++ b/control-api/src/routes/external/workflows/admission.ts @@ -1,5 +1,5 @@ import { attachAccessExecutionBudget } from '../../../middleware/accessExecutionBudget.js' -import { requireValidExternalSessionTokenWithPublicErrors } from '../../../middleware/externalSessionAuth.js' +import { requireValidExternalSessionToken } from '../../../middleware/externalSessionAuth.js' import { bindCompletedExternalWorkflowCaller } from '../../workflows/shared/auth.js' import { workflowTriggerRateLimit } from '../../workflows/shared/rateLimit.js' @@ -7,7 +7,7 @@ import { workflowTriggerRateLimit } from '../../workflows/shared/rateLimit.js' // route: no limiter sits between identity and policy here. export const externalWorkflowReadAdmission = Object.freeze([ attachAccessExecutionBudget, - requireValidExternalSessionTokenWithPublicErrors, + requireValidExternalSessionToken, bindCompletedExternalWorkflowCaller, ]) @@ -16,6 +16,6 @@ export const externalWorkflowReadAdmission = Object.freeze([ export const externalWorkflowTriggerAdmission = Object.freeze([ attachAccessExecutionBudget, workflowTriggerRateLimit(), - requireValidExternalSessionTokenWithPublicErrors, + requireValidExternalSessionToken, bindCompletedExternalWorkflowCaller, ]) diff --git a/control-api/src/services/directory/index.ts b/control-api/src/services/directory/index.ts index 7255d5b817..7157c4045c 100644 --- a/control-api/src/services/directory/index.ts +++ b/control-api/src/services/directory/index.ts @@ -28,7 +28,6 @@ export { listAllTeams, listTeams, renameTeam, - renameTeamForUser, } from './teams.js' export { diff --git a/control-api/src/services/directory/teams.ts b/control-api/src/services/directory/teams.ts index 9d1003a759..9996698f77 100644 --- a/control-api/src/services/directory/teams.ts +++ b/control-api/src/services/directory/teams.ts @@ -1,7 +1,5 @@ import { pool, withTransaction } from '../../db.js' import type { DbClient } from '../../db.js' -import type { ExternalSessionAuthorityContext } from '../auth/externalSessionAuthentication.js' -import { validateExternalSessionAuthorityContext } from '../auth/userSessionService.js' import type { AdminDeleteTeamResult, TeamRole } from './types.js' export async function listTeams(userId: string, currentTeamId: string) { @@ -103,43 +101,6 @@ export async function renameTeam(teamId: string, name: string) { return (updated.rows[0] as { id: string; name: string } | undefined) || null } -export async function renameTeamForUser( - userId: string, - teamId: string, - name: string, - authority?: ExternalSessionAuthorityContext -) { - return withTransaction(async db => { - if (authority) { - const session = await validateExternalSessionAuthorityContext(authority, { db }) - if (session.status !== 'valid' || session.identity.userId !== userId.trim()) { - return { error: 'forbidden' as const } - } - } - const membership = await db.query( - `SELECT role - FROM team_members - WHERE team_id = $1 - AND user_id = $2 - AND status = 'active' - FOR UPDATE`, - [teamId, userId] - ) - if ((membership.rows[0] as { role?: string } | undefined)?.role !== 'admin') { - return { error: 'forbidden' as const } - } - const updated = await db.query( - `UPDATE teams - SET name = $2 - WHERE id = $1 - RETURNING id, name`, - [teamId, name] - ) - const team = (updated.rows[0] as { id: string; name: string } | undefined) || null - return team ? { team } : { error: 'not_found' as const } - }) -} - /** * Hard-delete an empty team and all dependent rows (CASCADE from schema: invitations, * team_contexts, team_agents). Active memberships block deletion. diff --git a/control-api/test/http.errorHandler.k8sStatus.test.ts b/control-api/test/http.errorHandler.k8sStatus.test.ts index 2bf9357cb0..56f2f5623f 100644 --- a/control-api/test/http.errorHandler.k8sStatus.test.ts +++ b/control-api/test/http.errorHandler.k8sStatus.test.ts @@ -4,6 +4,7 @@ import type { Request } from 'express' import { ApiException } from '@kubernetes/client-node' import request from 'supertest' import { clerumErrorHandler } from '../src/http/errorHandler.js' +import { ExternalSessionIssuanceUnavailableError } from '../src/services/auth/externalSessionIssuanceError.js' import { K8sNotFoundError } from '../src/services/resourceService.js' /** @@ -69,20 +70,25 @@ describe('clerumErrorHandler — K8s status forwarding (UT-2a)', () => { expect(res.status).toBe(404) }) - it('preserves the external public 4xx error contract after handler extraction', async () => { - const res = await request(externalAppThrowing({ status: 401, message: 'upstream detail' })).get( - '/api/v1/external/boom' - ) + it("forwards a thrown 4xx on /api/v1/external as dev's flat body", async () => { + const res = await request( + externalAppThrowing(Object.assign(new Error('upstream detail'), { status: 401 })) + ).get('/api/v1/external/boom') expect(res.status).toBe(401) - expect(res.body).toMatchObject({ - error: { - code: 'invalid_session', - message: 'The request could not be completed.', - retryable: false, - }, - }) - expect(JSON.stringify(res.body)).not.toContain('upstream detail') + expect(res.body.error).toBe('upstream detail') + expect(typeof res.body.correlationId).toBe('string') + expect(res.body.error.code).toBeUndefined() + }) + + it('keeps the typed 503 envelope and Retry-After for session issuance', async () => { + const res = await request( + externalAppThrowing(new ExternalSessionIssuanceUnavailableError()) + ).get('/api/v1/external/boom') + + expect(res.status).toBe(503) + expect(res.headers['retry-after']).toBe('2') + expect(res.body.error.code).toBe('session_issuance_temporarily_unavailable') }) /** diff --git a/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts b/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts index ec3d33fa33..ad311aa529 100644 --- a/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts +++ b/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts @@ -8,7 +8,7 @@ import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip const PRE_D34_TEAMS_WRITER_SHA256 = - 'b91e7c4cec80a4c4e7be7d92b380d1a28fda2493e96a6047aca9be864c9cde5b' + '3f73f787acd4b7aac85b9a489c540403518256460d34c5ae2761121b3ff69843' function databaseUrl(baseUrl: string, database: string): string { const value = new URL(baseUrl) @@ -74,7 +74,7 @@ describeRealPostgres('D34 previous-image writer compatibility on real PostgreSQL } }) - it('runs the byte-identical pre-D34 team writer against the final additive schema', async () => { + it("runs dev's team writer against the PR1 schema", async () => { const writerSource = readFileSync( new URL('../src/services/directory/teams.ts', import.meta.url) ) diff --git a/control-api/test/routes.entityChangeStream.test.ts b/control-api/test/routes.entityChangeStream.test.ts index 6b775cb738..df902596e6 100644 --- a/control-api/test/routes.entityChangeStream.test.ts +++ b/control-api/test/routes.entityChangeStream.test.ts @@ -61,7 +61,9 @@ class FakeResponse extends EventEmitter { this.writableEnded = true this.emit('close') } - json(): this { + jsonBody: unknown + json(body: unknown): this { + this.jsonBody = body return this } } @@ -101,6 +103,22 @@ describe('routes/entityChangeStream', () => { expect(res.frames).toEqual([]) }) + it("answers dev's fixed 500 when the initial authority read is unavailable", async () => { + const req = new FakeRequest() + const res = new FakeResponse() + streamEntityChanges( + req as unknown as Request, + res as unknown as Response, + null, + async () => ({ status: 'unavailable', error: new Error('database unavailable') }), + 'user' + ) + await vi.waitFor(() => expect(res.statusCode).toBe(500)) + expect(res.jsonBody).toEqual({ error: 'Entity change stream unavailable' }) + expect(res.headersSent).toBe(false) + expect(serviceMock.subscribeEntityChangeFeedWake).not.toHaveBeenCalled() + }) + it('does not acquire a feed connection when the client disconnects during authorization', async () => { let finishAuthorization: (authorized: boolean) => void = () => undefined const authorization = new Promise(resolve => { diff --git a/control-api/test/routes.externalAuth.googleLoginRejection.test.ts b/control-api/test/routes.externalAuth.googleLoginRejection.test.ts index 8d22261357..1b0756a063 100644 --- a/control-api/test/routes.externalAuth.googleLoginRejection.test.ts +++ b/control-api/test/routes.externalAuth.googleLoginRejection.test.ts @@ -121,7 +121,7 @@ describe('POST /external/auth/google-login credential rejections', () => { expect(body).not.toContain(payloadEmail) }) - it('does not answer 401 for a verified Google identity', async () => { + it("answers dev's 401 for a retired Google account", async () => { googleVerify.verifyIdToken.mockResolvedValue({ getPayload: () => ({ email: 'User@Example.invalid', @@ -133,8 +133,8 @@ describe('POST /external/auth/google-login credential rejections', () => { const response = await googleLogin('verified-token') - expect(response.status).toBe(403) - expect(response.body).toEqual({ error: 'membership_not_found' }) + expect(response.status).toBe(401) + expect(response.body).toEqual({ error: 'Unauthorized' }) expect(directory.googleLoginData).toHaveBeenCalledWith({ email: 'user@example.invalid', name: 'User', diff --git a/control-api/test/routes.externalDirectory.test.ts b/control-api/test/routes.externalDirectory.test.ts index 58cde5aa82..2edf40af49 100644 --- a/control-api/test/routes.externalDirectory.test.ts +++ b/control-api/test/routes.externalDirectory.test.ts @@ -88,7 +88,7 @@ describe('external directory authorization', () => { .set('x-user-session-token', 'session') expect(response.status).toBe(403) - expect(response.body.error.code).toBe('forbidden') + expect(response.body).toEqual({ error: 'Forbidden' }) expect(mocks.membership).toHaveBeenCalledWith( 'user-1', 'team-1', @@ -97,6 +97,29 @@ describe('external directory authorization', () => { expect(mocks.searchDirectory).not.toHaveBeenCalled() }) + it("answers dev's flat 401 when no session token is supplied", async () => { + const response = await request(app()).get('/external/directory/search?teamId=team-1&q=person') + + expect(response.status).toBe(401) + expect(response.body).toEqual({ error: 'Unauthorized' }) + expect(mocks.membership).not.toHaveBeenCalled() + expect(mocks.searchDirectory).not.toHaveBeenCalled() + }) + + it('answers a flat 400 for an oversize query, keeping the head bound', async () => { + mocks.membership.mockResolvedValue({ teamId: 'team-1', role: 'member' }) + + const response = await request(app()) + .get(`/external/directory/search?teamId=team-1&q=${'x'.repeat(101)}`) + .set('x-user-session-token', 'session') + + expect(response.status).toBe(400) + expect(response.body).toEqual({ error: 'Invalid directory search request.' }) + // Witness: the request reached live membership before the bound rejected it. + expect(mocks.membership).toHaveBeenCalledOnce() + expect(mocks.searchDirectory).not.toHaveBeenCalled() + }) + it.each(['admin', 'inviter'] as const)('allows a current %s to search', async role => { mocks.membership.mockResolvedValue({ teamId: 'team-1', role }) diff --git a/control-api/test/routes.externalNotifications.currentness.test.ts b/control-api/test/routes.externalNotifications.currentness.test.ts index 86303494a3..8dc8b88654 100644 --- a/control-api/test/routes.externalNotifications.currentness.test.ts +++ b/control-api/test/routes.externalNotifications.currentness.test.ts @@ -147,6 +147,21 @@ describe('external notification stream currentness', () => { expect(streamState.backendFailure).toHaveBeenCalledOnce() }) + it("answers dev's flat 401 when the original authority is denied", async () => { + streamState.observer.mockResolvedValueOnce({ + status: 'denied', + reason: 'v2_session_revoked_or_changed', + }) + + const response = await request(makeApp()).get('/external/notifications/stream') + + expect(response.status).toBe(401) + expect(response.body).toEqual({ error: 'Unauthorized' }) + // Witness: authority was observed, but no listener was ever opened. + expect(streamState.observer).toHaveBeenCalledOnce() + expect(streamState.openListener).not.toHaveBeenCalled() + }) + it('drops an awaited notification batch when original authority is revoked', async () => { const batch = deferred() streamState.observer diff --git a/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts b/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts index 3441580dd4..b1a71bbe26 100644 --- a/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts +++ b/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts @@ -254,25 +254,6 @@ describeRealPostgres('external team mutations final source authority on real Pos function mutations(): readonly AdminMutation[] { return [ - { - name: 'team rename', - invoke: server => - request(server).put(`/external/teams/${teamId}/name`).send({ name: 'Renamed' }), - assertMutation: async () => { - const result = await databasePool.query<{ name: string }>( - `SELECT name FROM teams WHERE id = $1`, - [teamId] - ) - expect(result.rows[0]?.name).toBe('Renamed') - }, - assertNoMutation: async () => { - const result = await databasePool.query<{ name: string }>( - `SELECT name FROM teams WHERE id = $1`, - [teamId] - ) - expect(result.rows[0]?.name).toBe('Original') - }, - }, { name: 'member role change', invoke: server => diff --git a/control-api/test/routes.externalTeamsInvitations.test.ts b/control-api/test/routes.externalTeamsInvitations.test.ts index a70e78ad26..a6afe1d776 100644 --- a/control-api/test/routes.externalTeamsInvitations.test.ts +++ b/control-api/test/routes.externalTeamsInvitations.test.ts @@ -12,7 +12,7 @@ const directory = vi.hoisted(() => ({ getTeamAgents: vi.fn(), getTeamContexts: vi.fn(), listMembers: vi.fn(), - renameTeamForUser: vi.fn(), + renameTeam: vi.fn(), updateManagedMemberRoleForUser: vi.fn(), })) diff --git a/control-api/test/routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts b/control-api/test/routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts index 3c07156acc..da2681cdea 100644 --- a/control-api/test/routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts +++ b/control-api/test/routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts @@ -46,7 +46,7 @@ const directory = vi.hoisted(() => ({ getTeamAgents: vi.fn(), getTeamContexts: vi.fn(), listMembers: vi.fn(), - renameTeamForUser: vi.fn(), + renameTeam: vi.fn(), updateManagedMemberRoleForUser: vi.fn(), })) @@ -73,17 +73,11 @@ type AdminMutation = Readonly<{ invoke: (server: express.Express) => request.Test handler: keyof Pick< typeof directory, - 'renameTeamForUser' | 'updateManagedMemberRoleForUser' | 'deleteManagedMemberForUser' + 'updateManagedMemberRoleForUser' | 'deleteManagedMemberForUser' > }> const mutations: readonly AdminMutation[] = [ - { - name: 'rename team', - invoke: server => - request(server).put(`/external/teams/${teamId}/name`).send({ name: 'Renamed' }), - handler: 'renameTeamForUser', - }, { name: 'change member role', invoke: server => @@ -211,7 +205,7 @@ describeRealPostgres('legacy V1 live-admin revocation on real external team rout resetMs: Date.now() + 60_000, windowStartMs: Date.now(), }) - directory.renameTeamForUser.mockResolvedValue({ team: { id: teamId, name: 'Renamed' } }) + directory.renameTeam.mockResolvedValue({ id: teamId, name: 'Renamed' }) directory.updateManagedMemberRoleForUser.mockResolvedValue({ membership: { userId: targetUserId, teamId, role: 'member' }, }) @@ -250,4 +244,36 @@ describeRealPostgres('legacy V1 live-admin revocation on real external team rout expect(directory[mutation.handler]).toHaveBeenCalledOnce() }) } + + it('allows a demoted member to rename the team, as in dev', async () => { + await databasePool.query( + `UPDATE team_members SET role = 'member', updated_at = NOW() + WHERE team_id = $1 AND user_id = $2`, + [teamId, userId] + ) + + const response = await request(app()) + .put(`/external/teams/${teamId}/name`) + .send({ name: 'Renamed' }) + .set('x-user-session-token', staleAdminToken) + + expect(response.status).toBe(200) + expect(directory.renameTeam).toHaveBeenCalledWith(teamId, 'Renamed') + }) + + it("refuses a removed member's rename without reaching the writer", async () => { + await databasePool.query( + `UPDATE team_members SET status = 'deleted', updated_at = NOW() + WHERE team_id = $1 AND user_id = $2`, + [teamId, userId] + ) + + const response = await request(app()) + .put(`/external/teams/${teamId}/name`) + .send({ name: 'Renamed' }) + .set('x-user-session-token', staleAdminToken) + + expect(response.status).toBe(403) + expect(directory.renameTeam).not.toHaveBeenCalled() + }) }) diff --git a/control-api/test/routes.externalWorkflows.test.ts b/control-api/test/routes.externalWorkflows.test.ts index 8deb02ab14..7100a97c3b 100644 --- a/control-api/test/routes.externalWorkflows.test.ts +++ b/control-api/test/routes.externalWorkflows.test.ts @@ -263,7 +263,8 @@ describe('routes/external/workflows', () => { it('returns 401 when no credentials are supplied', async () => { const app = makeApp(gateway) - await request(app).get('/external/workflows').expect(401) + const response = await request(app).get('/external/workflows').expect(401) + expect(response.body).toEqual({ error: 'Unauthorized' }) }) }) @@ -326,6 +327,7 @@ describe('routes/external/workflows', () => { .send({}) expect(response.status).toBe(401) + expect(response.body).toEqual({ error: 'Unauthorized' }) // Witness: the session was evaluated and rejected after the limiter ran. expect(mockAuthenticateExternalUserSession).toHaveBeenCalledWith( 'rejected-session-token', diff --git a/control-api/test/routes.profile.test.ts b/control-api/test/routes.profile.test.ts index c640df5089..e60f0b35e5 100644 --- a/control-api/test/routes.profile.test.ts +++ b/control-api/test/routes.profile.test.ts @@ -474,7 +474,7 @@ describe('routes/profile', () => { ).expect(403) }) - it('does not allow an ordinary team member to rename a team', async () => { + it('lets a live team member rename a team, as in dev', async () => { svc.renameTeam.mockResolvedValue({ id: 't1', name: 'Renamed' }) const app = express() app.use(express.json()) @@ -482,8 +482,17 @@ describe('routes/profile', () => { await withInternalServiceAuthAndUserSession( request(app).put('/external/teams/t1/name').send({ name: 'Renamed' }) - ).expect(403) + ).expect(200, { id: 't1', name: 'Renamed' }) + + expect(svc.renameTeam).toHaveBeenCalledWith('t1', 'Renamed') + // Witness: a removed member is still refused by the live team matcher and + // the writer is never reached. + svc.renameTeam.mockClear() + liveTeamAuthorizationMock.getLiveTeamMembership.mockResolvedValue(null) + await withInternalServiceAuthAndUserSession( + request(app).put('/external/teams/t1/name').send({ name: 'Renamed' }) + ).expect(403) expect(svc.renameTeam).not.toHaveBeenCalled() }) @@ -1203,7 +1212,7 @@ describe('routes/profile', () => { await withInternalServiceAuth(request(app).post('/external/auth/google-login')) .send({ idToken: 'retired-google-token' }) - .expect(403, { error: 'membership_not_found' }) + .expect(401, { error: 'Unauthorized' }) await withInternalServiceAuth(request(app).post('/external/auth/password-login')) .send({ email: 'retired@example.com', password: 'synthetic-correct-password' }) diff --git a/control-api/test/services.directory.teams.test.ts b/control-api/test/services.directory.teams.test.ts index 821a7353b9..517c3a3165 100644 --- a/control-api/test/services.directory.teams.test.ts +++ b/control-api/test/services.directory.teams.test.ts @@ -8,7 +8,6 @@ import { getTeamById, listAllTeams, listMembers, - renameTeamForUser, softDeleteMember, updateMemberRole, } from '../src/services/directory/index.js' @@ -120,37 +119,6 @@ describe('services/directory team management unit tests', () => { ) }) - it('revalidates source authority before final team rename authorization', async () => { - dbMocks.txQuery - .mockResolvedValueOnce({ - rows: [ - { - id: 'user-1', - email: 'admin@example.test', - lifecycle_state: 'active', - lifecycle_version: 1, - }, - ], - rowCount: 1, - }) - .mockResolvedValueOnce({ rows: [{ valid_after: null, token_revoked: false }], rowCount: 1 }) - .mockResolvedValueOnce({ rows: [{ role: 'admin' }], rowCount: 1 }) - .mockResolvedValueOnce({ rows: [{ id: 'team-1', name: 'Renamed' }], rowCount: 1 }) - - const result = await renameTeamForUser('user-1', 'team-1', 'Renamed', { - contract: 'v1', - userId: 'user-1', - tokenHash: 'legacy-token-hash', - issuedAt: Math.floor(Date.now() / 1000), - authGeneration: 1, - }) - - expect(result).toEqual({ team: { id: 'team-1', name: 'Renamed' } }) - expect(String(dbMocks.txQuery.mock.calls[0]?.[0])).toContain('FROM users') - expect(String(dbMocks.txQuery.mock.calls[0]?.[0])).toContain('FOR UPDATE') - expect(String(dbMocks.txQuery.mock.calls[2]?.[0])).toContain('FROM team_members') - }) - it('addMemberToTeam upserts and returns membership row', async () => { dbMocks.txQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }).mockResolvedValueOnce({ rows: [{ team_id: 'team-1', user_id: 'user-1', role: 'member', status: 'active' }], From 6579d48e2a1fbe513cbc83c85454d7e46d0c6041 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Tue, 3 Nov 2026 16:54:33 +0100 Subject: [PATCH 447/528] fix(external-rest-api): stop sending the x-evenfire-client-ip alias E-4: the edge forwarded a second client-IP header on seven service calls while controlApiClient already adds the canonical x-external-client-ip from the request context. Drop the clientIp parameter and the alias from the auth, me, team and rpc services, and the req.ip argument from their routes. The services test now pins that no call passes an extra header, and controlApiClient's own test keeps the canonical header covered. --- external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- external-rest-api/src/releaseManifest.ts | 2 +- external-rest-api/src/routes/auth.ts | 17 +- external-rest-api/src/routes/me.ts | 2 +- external-rest-api/src/routes/rpc.ts | 7 +- external-rest-api/src/routes/team.ts | 2 +- external-rest-api/src/services/authService.ts | 22 +-- external-rest-api/src/services/meService.ts | 4 +- external-rest-api/src/services/rpcService.ts | 4 +- external-rest-api/src/services/teamService.ts | 4 +- external-rest-api/test/routes.auth.test.ts | 9 +- external-rest-api/test/routes.rpc.test.ts | 3 +- .../services.externalUserClientIp.test.ts | 147 +++++++++++------- 14 files changed, 116 insertions(+), 113 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index f8512f57eb..0e626ae731 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.163", + "version": "0.1.164", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.163", + "version": "0.1.164", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index cffaeba044..be66f8a111 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.163", + "version": "0.1.164", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index 4c7a281172..0cadb5a70e 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.12.0', - externalRestApiVersion: '0.1.163', + externalRestApiVersion: '0.1.164', rpcProxyVersion: '0.1.138', desktopVersion: '0.12.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/src/routes/auth.ts b/external-rest-api/src/routes/auth.ts index 0d79c2eba8..5a5812f68a 100644 --- a/external-rest-api/src/routes/auth.ts +++ b/external-rest-api/src/routes/auth.ts @@ -86,13 +86,10 @@ export function createAuthRouter(): Router { return } - const result = await loginWithGoogle( - { - idToken, - ...(requestedSessionContract(req) ? { sessionContract: 'v2' as const } : {}), - }, - req.ip - ) + const result = await loginWithGoogle({ + idToken, + ...(requestedSessionContract(req) ? { sessionContract: 'v2' as const } : {}), + }) sendLoginResponse(req, res, result) } catch (error) { if (isControlApiStatus(error, 404)) { @@ -123,7 +120,7 @@ export function createAuthRouter(): Router { res.status(400).json({ error: 'invalid_request' }) return } - const result = await loginWithPassword(email, password, requestedSessionContract(req), req.ip) + const result = await loginWithPassword(email, password, requestedSessionContract(req)) sendLoginResponse(req, res, result) } catch (error) { if (error instanceof ControlApiError && [401, 403, 409].includes(error.status)) { @@ -157,7 +154,7 @@ export function createAuthRouter(): Router { res.status(401).json({ error: 'Unauthorized' }) return } - const renewed = await renewUserSession(token, req.ip) + const renewed = await renewUserSession(token) setProfileSessionCookie(req, res, renewed.token) const body = shouldExposeBearerToken(req) ? { token: renewed.token, expiresInSeconds: renewed.expiresInSeconds } @@ -179,7 +176,7 @@ export function createAuthRouter(): Router { .replace(/^bearer\s+/i, '') .trim() const token = bearer || readCookie(req, PROFILE_SESSION_COOKIE) - if (token) await logoutUserSession(token, req.ip) + if (token) await logoutUserSession(token) clearProfileSessionCookie(req, res) res.status(200).json({ ok: true }) } catch (error) { diff --git a/external-rest-api/src/routes/me.ts b/external-rest-api/src/routes/me.ts index 3603b9ce7a..30f4e7a2c2 100644 --- a/external-rest-api/src/routes/me.ts +++ b/external-rest-api/src/routes/me.ts @@ -68,7 +68,7 @@ export function createMeRouter(): Router { return } - const result = await switchTeam(auth.userId, auth.email, nextTeamId, sessionToken, req.ip) + const result = await switchTeam(auth.userId, auth.email, nextTeamId, sessionToken) if (!result) { res.status(403).json({ error: 'You are not a member of this team' }) return diff --git a/external-rest-api/src/routes/rpc.ts b/external-rest-api/src/routes/rpc.ts index 3bf9598e64..4b2f30ed84 100644 --- a/external-rest-api/src/routes/rpc.ts +++ b/external-rest-api/src/routes/rpc.ts @@ -12,12 +12,7 @@ export function createRpcRouter(): Router { res.status(401).json({ error: 'Unauthorized' }) return } - const result = await issueRpcAccessToken( - sessionToken, - req.body?.scopes, - req.body?.hostRefs, - req.ip - ) + const result = await issueRpcAccessToken(sessionToken, req.body?.scopes, req.body?.hostRefs) if ('error' in result) { // Relay control-api's specific reason (e.g. desktop_requires_team) so the // desktop app can act on it instead of surfacing an opaque "no access". diff --git a/external-rest-api/src/routes/team.ts b/external-rest-api/src/routes/team.ts index 9e8c71a7b8..80447d55b9 100644 --- a/external-rest-api/src/routes/team.ts +++ b/external-rest-api/src/routes/team.ts @@ -41,7 +41,7 @@ export function createTeamRouter(): Router { res.status(400).json({ error: 'Team name is required' }) return } - const result = await createTeamForUser({ ...auth, sessionToken }, name, req.ip) + const result = await createTeamForUser({ ...auth, sessionToken }, name) const browserRequest = Boolean(req.header('origin') || req.header('sec-fetch-site')) if (browserRequest) setProfileSessionCookie(req, res, result.token) res.status(201).json(browserRequest ? { team: result.team } : result) diff --git a/external-rest-api/src/services/authService.ts b/external-rest-api/src/services/authService.ts index 51c524f257..39c537304b 100644 --- a/external-rest-api/src/services/authService.ts +++ b/external-rest-api/src/services/authService.ts @@ -20,13 +20,9 @@ type LoginResult = { isNewUser: boolean } -export async function loginWithGoogle( - google: GoogleLoginInput, - clientIp?: string -): Promise { +export async function loginWithGoogle(google: GoogleLoginInput): Promise { const payload = await controlApiRequest('POST', '/external/auth/google-login', { body: google, - ...(clientIp ? { extraHeaders: { 'x-evenfire-client-ip': clientIp } } : {}), }) return payload } @@ -34,15 +30,13 @@ export async function loginWithGoogle( export async function loginWithPassword( email: string, password: string, - sessionContract?: 'v2', - clientIp?: string + sessionContract?: 'v2' ): Promise> { const payload = await controlApiRequest>( 'POST', '/external/auth/password-login', { body: { email, password, ...(sessionContract ? { sessionContract } : {}) }, - ...(clientIp ? { extraHeaders: { 'x-evenfire-client-ip': clientIp } } : {}), } ) return payload @@ -54,26 +48,18 @@ export async function requestPasswordReset(email: string): Promise<{ requested: }) } -export async function renewUserSession( - sessionToken: string, - clientIp?: string -): Promise<{ +export async function renewUserSession(sessionToken: string): Promise<{ token: string expiresInSeconds: number absoluteExpiresAt: string }> { return controlApiRequest('POST', '/external/auth/session/renew', { userSessionToken: sessionToken, - ...(clientIp ? { extraHeaders: { 'x-evenfire-client-ip': clientIp } } : {}), }) } -export async function logoutUserSession( - sessionToken: string, - clientIp?: string -): Promise<{ revoked: boolean }> { +export async function logoutUserSession(sessionToken: string): Promise<{ revoked: boolean }> { return controlApiRequest('POST', '/external/auth/session/logout', { userSessionToken: sessionToken, - ...(clientIp ? { extraHeaders: { 'x-evenfire-client-ip': clientIp } } : {}), }) } diff --git a/external-rest-api/src/services/meService.ts b/external-rest-api/src/services/meService.ts index 91949a8ec8..9dec8d517b 100644 --- a/external-rest-api/src/services/meService.ts +++ b/external-rest-api/src/services/meService.ts @@ -73,8 +73,7 @@ export async function switchTeam( userId: string, email: string, nextTeamId: string, - sessionToken: string, - clientIp?: string + sessionToken: string ) { let membership: { team_id: string; role: TeamRole; team_name: string } try { @@ -100,7 +99,6 @@ export async function switchTeam( role, }, userSessionToken: sessionToken, - ...(clientIp ? { extraHeaders: { 'x-evenfire-client-ip': clientIp } } : {}), } ) return { diff --git a/external-rest-api/src/services/rpcService.ts b/external-rest-api/src/services/rpcService.ts index f127571963..62f467fbd8 100644 --- a/external-rest-api/src/services/rpcService.ts +++ b/external-rest-api/src/services/rpcService.ts @@ -16,8 +16,7 @@ export type RpcAccessTokenResult = IssuedRpcToken | { error: string } export async function issueRpcAccessToken( sessionToken: string, requestedScopesInput: unknown, - requestedHostRefsInput: unknown, - clientIp?: string + requestedHostRefsInput: unknown ): Promise { try { return await controlApiRequest('POST', '/external/rpc/token', { @@ -26,7 +25,6 @@ export async function issueRpcAccessToken( scopes: requestedScopesInput, hostRefs: requestedHostRefsInput, }, - ...(clientIp ? { extraHeaders: { 'x-evenfire-client-ip': clientIp } } : {}), }) } catch (error) { // Relay control-api's specific denial reason (e.g. `desktop_requires_team`) diff --git a/external-rest-api/src/services/teamService.ts b/external-rest-api/src/services/teamService.ts index 4751c5e3b5..cdda549211 100644 --- a/external-rest-api/src/services/teamService.ts +++ b/external-rest-api/src/services/teamService.ts @@ -36,8 +36,7 @@ export async function getCurrentTeam(auth: AuthContext) { export async function createTeamForUser( auth: Pick, - name: string, - clientIp?: string + name: string ) { const team = await controlApiRequest<{ id: string; name: string }>('POST', '/external/teams', { body: { @@ -58,7 +57,6 @@ export async function createTeamForUser( role: 'admin', }, userSessionToken: auth.sessionToken, - ...(clientIp ? { extraHeaders: { 'x-evenfire-client-ip': clientIp } } : {}), } ) diff --git a/external-rest-api/test/routes.auth.test.ts b/external-rest-api/test/routes.auth.test.ts index c9cf08f68a..6ee6e158c6 100644 --- a/external-rest-api/test/routes.auth.test.ts +++ b/external-rest-api/test/routes.auth.test.ts @@ -199,7 +199,7 @@ describe('routes/auth password-login', () => { ) }) - it('delegates Google verification with the trusted client IP', async () => { + it('delegates Google verification without an edge-supplied client IP', async () => { authServiceMock.loginWithGoogle.mockResolvedValueOnce({ token: 'google-session-test-token', me: { id: 'user-1', email: 'user@example.test' }, @@ -211,10 +211,7 @@ describe('routes/auth password-login', () => { .send({ idToken: 'opaque-test-token' }) expect(response.status).toBe(200) - expect(authServiceMock.loginWithGoogle).toHaveBeenCalledWith( - { idToken: 'opaque-test-token' }, - '198.51.100.52' - ) + expect(authServiceMock.loginWithGoogle).toHaveBeenCalledWith({ idToken: 'opaque-test-token' }) }) it('forwards more than ten session renewals without a local rate limit', async () => { @@ -235,7 +232,7 @@ describe('routes/auth password-login', () => { expect(response.headers['x-ratelimit-limit']).toBeUndefined() } expect(authServiceMock.renewUserSession).toHaveBeenCalledTimes(12) - expect(authServiceMock.renewUserSession).toHaveBeenCalledWith(session, '198.51.100.89') + expect(authServiceMock.renewUserSession).toHaveBeenCalledWith(session) }) it('documents the password-reset limiter headers and client-IP contract', async () => { diff --git a/external-rest-api/test/routes.rpc.test.ts b/external-rest-api/test/routes.rpc.test.ts index 9be6edad89..f80d4a3ec5 100644 --- a/external-rest-api/test/routes.rpc.test.ts +++ b/external-rest-api/test/routes.rpc.test.ts @@ -75,8 +75,7 @@ describe('routes/rpc /rpc/token', () => { expect(rpcServiceMock.issueRpcAccessToken).toHaveBeenCalledWith( 'session-xyz', ['host:message:invoke'], - ['pro-agent'], - '127.0.0.1' + ['pro-agent'] ) }) }) diff --git a/external-rest-api/test/services.externalUserClientIp.test.ts b/external-rest-api/test/services.externalUserClientIp.test.ts index c873f37b26..bc2bb51613 100644 --- a/external-rest-api/test/services.externalUserClientIp.test.ts +++ b/external-rest-api/test/services.externalUserClientIp.test.ts @@ -12,102 +12,137 @@ import { createTeamForUser } from '../src/services/teamService.js' const client = vi.hoisted(() => ({ controlApiRequest: vi.fn() })) vi.mock('../src/controlApiClient.js', () => client) -const clientIp = '198.51.100.52' -const headers = { 'x-evenfire-client-ip': clientIp } - -describe('external-user client IP forwarding', () => { +/** + * E-4: the edge no longer sends the `x-evenfire-client-ip` alias. The canonical + * `x-external-client-ip` header is added by `controlApiClient` from the request + * context for every call, so the services must pass no extra header at all. + * `test/controlApiClient.test.ts` pins that the canonical header still travels. + */ +describe('external-user client identity forwarding', () => { beforeEach(() => { client.controlApiRequest.mockReset() }) - it('forwards the trusted client IP for renewal and logout lifecycle calls', async () => { + it('sends no extra header from the four auth service calls', async () => { client.controlApiRequest - .mockResolvedValueOnce({ token: 'renewed', expiresInSeconds: 3600 }) + .mockResolvedValueOnce({ token: 'synthetic-google-session' }) + .mockResolvedValueOnce({ token: 'synthetic-password-session' }) + .mockResolvedValueOnce({ token: 'synthetic-renewed-session', expiresInSeconds: 3600 }) .mockResolvedValueOnce({ revoked: true }) - await renewUserSession('synthetic-session-token', clientIp) - await logoutUserSession('synthetic-session-token', clientIp) + await loginWithGoogle({ idToken: 'synthetic-google-id-token' }) + await loginWithPassword('user@example.invalid', 'synthetic-password', 'v2') + await renewUserSession('synthetic-session-token') + await logoutUserSession('synthetic-session-token') expect(client.controlApiRequest).toHaveBeenNthCalledWith( 1, 'POST', - '/external/auth/session/renew', - { - userSessionToken: 'synthetic-session-token', - extraHeaders: headers, - } + '/external/auth/google-login', + { body: { idToken: 'synthetic-google-id-token' } } ) expect(client.controlApiRequest).toHaveBeenNthCalledWith( 2, 'POST', - '/external/auth/session/logout', + '/external/auth/password-login', { - userSessionToken: 'synthetic-session-token', - extraHeaders: headers, + body: { + email: 'user@example.invalid', + password: 'synthetic-password', + sessionContract: 'v2', + }, } ) - }) - - it('forwards the trusted client IP for credential and RPC issuance boundaries', async () => { - client.controlApiRequest - .mockResolvedValueOnce({ token: 'synthetic-google-session' }) - .mockResolvedValueOnce({ token: 'synthetic-password-session' }) - .mockResolvedValueOnce({ token: 'synthetic-rpc-token' }) - - await loginWithGoogle({ idToken: 'google-token' }, clientIp) - await loginWithPassword('user@example.test', 'password', undefined, clientIp) - await issueRpcAccessToken( - 'synthetic-session-token', - ['host:message:invoke'], - ['agent-a'], - clientIp - ) - expect(client.controlApiRequest).toHaveBeenNthCalledWith( - 1, - 'POST', - '/external/auth/google-login', - expect.objectContaining({ extraHeaders: headers }) - ) - expect(client.controlApiRequest).toHaveBeenNthCalledWith( - 2, + 3, 'POST', - '/external/auth/password-login', - expect.objectContaining({ extraHeaders: headers }) + '/external/auth/session/renew', + { userSessionToken: 'synthetic-session-token' } ) expect(client.controlApiRequest).toHaveBeenNthCalledWith( - 3, + 4, 'POST', - '/external/rpc/token', - expect.objectContaining({ extraHeaders: headers }) + '/external/auth/session/logout', + { userSessionToken: 'synthetic-session-token' } ) }) - it('forwards the trusted client IP only on the session-token calls for team changes', async () => { + it('sends no extra header from team creation, team switch or RPC issuance', async () => { client.controlApiRequest - .mockResolvedValueOnce({ id: 'team-1', name: 'Team One' }) - .mockResolvedValueOnce({ token: 'synthetic-created-token' }) - .mockResolvedValueOnce({ team_id: 'team-2', team_name: 'Team Two', role: 'member' }) - .mockResolvedValueOnce({ token: 'synthetic-switched-token' }) + .mockResolvedValueOnce({ id: 'synthetic-team-id', name: 'Synthetic Team' }) + .mockResolvedValueOnce({ token: 'synthetic-created-session' }) + .mockResolvedValueOnce({ + team_id: 'synthetic-next-team-id', + team_name: 'Synthetic Next Team', + role: 'member', + }) + .mockResolvedValueOnce({ token: 'synthetic-switched-session' }) + .mockResolvedValueOnce({ token: 'synthetic-rpc-token' }) await createTeamForUser( - { userId: 'user-1', email: 'user@example.test', sessionToken: 'synthetic-session-token' }, - 'Team One', - clientIp + { + userId: 'synthetic-member-id', + email: 'member@example.invalid', + sessionToken: 'synthetic-session-token', + }, + 'Synthetic Team' + ) + await switchTeam( + 'synthetic-member-id', + 'member@example.invalid', + 'synthetic-next-team-id', + 'synthetic-session-token' + ) + await issueRpcAccessToken( + 'synthetic-session-token', + ['host:message:invoke'], + ['synthetic-agent'] ) - await switchTeam('user-1', 'user@example.test', 'team-2', 'synthetic-session-token', clientIp) + expect(client.controlApiRequest).toHaveBeenNthCalledWith(1, 'POST', '/external/teams', { + body: { userId: 'synthetic-member-id', name: 'Synthetic Team' }, + userSessionToken: 'synthetic-session-token', + }) expect(client.controlApiRequest).toHaveBeenNthCalledWith( 2, 'POST', '/external/auth/session-token', - expect.objectContaining({ extraHeaders: headers }) + { + body: { + userId: 'synthetic-member-id', + email: 'member@example.invalid', + teamId: 'synthetic-team-id', + role: 'admin', + }, + userSessionToken: 'synthetic-session-token', + } + ) + expect(client.controlApiRequest).toHaveBeenNthCalledWith( + 3, + 'GET', + '/external/users/synthetic-member-id/memberships/synthetic-next-team-id', + { userSessionToken: 'synthetic-session-token' } ) expect(client.controlApiRequest).toHaveBeenNthCalledWith( 4, 'POST', '/external/auth/session-token', - expect.objectContaining({ extraHeaders: headers }) + { + body: { + userId: 'synthetic-member-id', + email: 'member@example.invalid', + teamId: 'synthetic-next-team-id', + role: 'member', + }, + userSessionToken: 'synthetic-session-token', + } ) + expect(client.controlApiRequest).toHaveBeenNthCalledWith(5, 'POST', '/external/rpc/token', { + body: { + sessionToken: 'synthetic-session-token', + scopes: ['host:message:invoke'], + hostRefs: ['synthetic-agent'], + }, + }) }) }) From 5816c692dbe54d57ddf3a25f3428cfb39b449bc5 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Tue, 3 Nov 2026 16:56:17 +0100 Subject: [PATCH 448/528] fix(external-rest-api): keep the profile cookie on a refused renewal E-2: control-api renews V2 sessions only, so a 401 on POST /auth/session/renew can be a still-valid V1 cookie. Stop clearing the profile session cookie there; the route answers 401 and leaves the session alone, and PR4 decides how a V2 cookie is cleaned up. --- external-rest-api/package-lock.json | 4 ++-- external-rest-api/package.json | 2 +- external-rest-api/src/releaseManifest.ts | 2 +- external-rest-api/src/routes/auth.ts | 3 ++- external-rest-api/test/routes.auth.test.ts | 22 ++++++++++++++++++++++ 5 files changed, 28 insertions(+), 5 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 0e626ae731..1c9975e8ed 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.164", + "version": "0.1.165", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.164", + "version": "0.1.165", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index be66f8a111..d789d9eccd 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.164", + "version": "0.1.165", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index 0cadb5a70e..ee61ba6263 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.12.0', - externalRestApiVersion: '0.1.164', + externalRestApiVersion: '0.1.165', rpcProxyVersion: '0.1.138', desktopVersion: '0.12.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/src/routes/auth.ts b/external-rest-api/src/routes/auth.ts index 5a5812f68a..ad93b28f62 100644 --- a/external-rest-api/src/routes/auth.ts +++ b/external-rest-api/src/routes/auth.ts @@ -161,8 +161,9 @@ export function createAuthRouter(): Router { : { expiresInSeconds: renewed.expiresInSeconds } res.status(200).json(body) } catch (error) { + // control-api renews V2 sessions only, so a 401 can be a still-valid V1 + // cookie: answer it without clearing the caller's session. if (isControlApiStatus(error, 401)) { - clearProfileSessionCookie(req, res) res.status(401).json({ error: 'Unauthorized' }) return } diff --git a/external-rest-api/test/routes.auth.test.ts b/external-rest-api/test/routes.auth.test.ts index 6ee6e158c6..dd8ae8dd86 100644 --- a/external-rest-api/test/routes.auth.test.ts +++ b/external-rest-api/test/routes.auth.test.ts @@ -235,6 +235,28 @@ describe('routes/auth password-login', () => { expect(authServiceMock.renewUserSession).toHaveBeenCalledWith(session) }) + it('keeps a V1 profile cookie when renewal is refused with 401', async () => { + const v1Session = 'synthetic-v1-profile-session' + authServiceMock.renewUserSession.mockRejectedValueOnce( + new ControlApiError('Control API POST /external/auth/session/renew failed (401)', 401, { + error: 'Unauthorized', + }) + ) + + const response = await request(buildApp()) + .post('/api/v1/auth/session/renew') + .set('cookie', `profile_session=${v1Session}`) + .expect(401) + + expect(response.body).toEqual({ error: 'Unauthorized' }) + // E-2: control-api renews V2 sessions only, so a 401 on a still-valid V1 + // cookie must not delete it. + expect(String(response.headers['set-cookie'] ?? '')).not.toContain('profile_session') + // Witness: the route reached the renewal with the cookie's token. + expect(authServiceMock.renewUserSession).toHaveBeenCalledOnce() + expect(authServiceMock.renewUserSession).toHaveBeenCalledWith(v1Session) + }) + it('documents the password-reset limiter headers and client-IP contract', async () => { authServiceMock.requestPasswordReset.mockResolvedValue({ requested: true }) const app = buildApp() From bfc7ec8cd0c7d680496205ad759ccc82ebe48f8f Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Tue, 3 Nov 2026 17:14:53 +0100 Subject: [PATCH 449/528] refactor(control-api): drop the per-mutation session revalidation The session middleware already validates the caller on every protected route, so the managed-mutation services no longer revalidate it inside their transactions: `hasValidManagedMutationAuthority` and the `authority` parameter of the six managed writers leave, and the retired member is still refused by the live team matcher and the locked manager roles. The GFS user-token mint keeps one unlocked `users` read; GFSC re-reads `lifecycle_version` on every request and fails a stale generation closed. Route tests pin the exact argument arity and the service tests witness that the first statement is the membership lock, not a `users` re-lock. --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- control-api/src/gfs/externalAuthority.ts | 13 +- control-api/src/routes/external/members.ts | 39 ++---- control-api/src/routes/external/teams.ts | 9 +- .../src/services/directory/membership.ts | 75 ++--------- .../directoryAuthorizationPrivacy.test.ts | 50 +++---- .../test/gfs.externalAuthority.test.ts | 100 +++++++++++++- .../test/routes.externalMembers.test.ts | 21 +-- ...Authority.realPostgres.integration.test.ts | 124 ------------------ control-api/test/routes.profile.test.ts | 3 +- 11 files changed, 155 insertions(+), 285 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index c6e59849f9..ea1c70f0f6 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.889", + "version": "0.1.890", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.889", + "version": "0.1.890", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index b16a842fd6..8302ef30d1 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.889", + "version": "0.1.890", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/gfs/externalAuthority.ts b/control-api/src/gfs/externalAuthority.ts index 6835b7f179..126fc951ee 100644 --- a/control-api/src/gfs/externalAuthority.ts +++ b/control-api/src/gfs/externalAuthority.ts @@ -7,7 +7,6 @@ import { externalSessionDatabaseFailure, isExternalSessionBackendUnavailableError, } from '../services/auth/sessionDatabaseFailure.js' -import { validateExternalSessionAuthorityContext } from '../services/auth/userSessionService.js' import { type GfsDesktopOperatorLink, GfsDesktopOperatorLinkError, @@ -49,10 +48,9 @@ export type ExternalGfsUserTokenInput = Readonly<{ }> /** - * Sign a user-plane GFS token while the authenticated session and canonical - * user generation are current under the same principal lock. GFSC consumes - * users.lifecycle_version as authGeneration; V2's sessionVersion is a - * different fence and must never be projected into that claim. + * Sign a user-plane GFS token with the current canonical user generation. + * GFSC re-reads users.lifecycle_version on every request, so a generation that + * changes after signing fails closed there. * * The signed bytes remain private to this function until COMMIT succeeds, so a * lost/uncertain commit cannot be reported as a usable GFS credential. @@ -82,11 +80,6 @@ export async function signCurrentExternalGfsUserToken( try { return await withTransaction( async transaction => { - const current = await validateExternalSessionAuthorityContext(context, { - db: transaction, - }) - if (current.status !== 'valid' || current.identity.userId !== context.userId) return null - const result = await transaction.query( `SELECT lifecycle_state, lifecycle_version, clock_timestamp() AS db_now FROM users diff --git a/control-api/src/routes/external/members.ts b/control-api/src/routes/external/members.ts index 02863ee536..fca1ff9e24 100644 --- a/control-api/src/routes/external/members.ts +++ b/control-api/src/routes/external/members.ts @@ -112,8 +112,7 @@ export function createExternalMembersRouter(): Router { managerUserId, email, teamAssignments, - name, - req.externalSessionAuthority + name ) if ('error' in result) { if (result.error === 'forbidden') return res.status(403).json({ error: 'forbidden' }) @@ -133,11 +132,7 @@ export function createExternalMembersRouter(): Router { try { const managerUserId = currentUserId(req) if (!managerUserId) return res.status(403).json({ error: 'Forbidden' }) - const result = await resendManagedInvitationForUser( - managerUserId, - req.params.invitationId, - req.externalSessionAuthority - ) + const result = await resendManagedInvitationForUser(managerUserId, req.params.invitationId) if ('error' in result) { if (result.error === 'forbidden') return res.status(403).json({ error: 'forbidden' }) return res.status(404).json({ error: 'not_found' }) @@ -155,11 +150,7 @@ export function createExternalMembersRouter(): Router { try { const managerUserId = currentUserId(req) if (!managerUserId) return res.status(403).json({ error: 'Forbidden' }) - const result = await revokeManagedInvitationForUser( - managerUserId, - req.params.invitationId, - req.externalSessionAuthority - ) + const result = await revokeManagedInvitationForUser(managerUserId, req.params.invitationId) if ('error' in result) { if (result.error === 'forbidden') return res.status(403).json({ error: 'forbidden' }) return res.status(404).json({ error: 'not_found' }) @@ -186,8 +177,7 @@ export function createExternalMembersRouter(): Router { managerUserId, req.params.userId, req.params.teamId, - role, - req.externalSessionAuthority + role ) if ('error' in result) { if (result.error === 'forbidden') return res.status(403).json({ error: 'forbidden' }) @@ -209,20 +199,12 @@ export function createExternalMembersRouter(): Router { if (!reason) return res.status(400).json({ error: 'reason_required' }) const idempotencyKey = String(req.header('Idempotency-Key') || '').trim() if (!idempotencyKey) return res.status(400).json({ error: 'idempotency_key_required' }) - const result = await deleteManagedUserForUser( - managerUserId, - req.params.userId, - { - reason, - idempotencyKey, - requestId: req.correlationId ?? null, - }, - req.externalSessionAuthority - ) + const result = await deleteManagedUserForUser(managerUserId, req.params.userId, { + reason, + idempotencyKey, + requestId: req.correlationId ?? null, + }) if ('error' in result) { - if (result.error === 'forbidden') { - return res.status(403).json({ error: 'forbidden' }) - } if (result.error === 'forbidden_uncontrolled_teams') { return res.status(403).json({ error: 'forbidden_uncontrolled_teams' }) } @@ -255,8 +237,7 @@ export function createExternalMembersRouter(): Router { const result = await deleteManagedMemberForUser( managerUserId, req.params.userId, - req.params.teamId, - req.externalSessionAuthority + req.params.teamId ) if ('error' in result) { if (result.error === 'forbidden') return res.status(403).json({ error: 'forbidden' }) diff --git a/control-api/src/routes/external/teams.ts b/control-api/src/routes/external/teams.ts index 5781a2e5a5..43fdd82528 100644 --- a/control-api/src/routes/external/teams.ts +++ b/control-api/src/routes/external/teams.ts @@ -207,8 +207,7 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { (req as ExternalAuthedRequest).externalAuth!.userId, req.params.userId, req.params.teamId, - role, - (req as ExternalAuthedRequest).externalSessionAuthority + role ) if ('error' in result) { if (result.error === 'forbidden') return res.status(403).json({ error: 'forbidden' }) @@ -242,8 +241,7 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { (req as ExternalAuthedRequest).externalAuth!.userId, email, [{ teamId: req.params.teamId, role }], - name || fallbackName, - (req as ExternalAuthedRequest).externalSessionAuthority + name || fallbackName ) if ('error' in result) { return res.status(result.error === 'forbidden' ? 403 : 400).json({ error: result.error }) @@ -272,8 +270,7 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { const result = await deleteManagedMemberForUser( (req as ExternalAuthedRequest).externalAuth!.userId, req.params.userId, - req.params.teamId, - (req as ExternalAuthedRequest).externalSessionAuthority + req.params.teamId ) if ('error' in result) { if (result.error === 'forbidden') return res.status(403).json({ error: 'forbidden' }) diff --git a/control-api/src/services/directory/membership.ts b/control-api/src/services/directory/membership.ts index 05a3e30744..bd73d403d5 100644 --- a/control-api/src/services/directory/membership.ts +++ b/control-api/src/services/directory/membership.ts @@ -1,8 +1,6 @@ import { createHmac, timingSafeEqual } from 'node:crypto' import { config } from '../../config.js' import { type DbClient, pool, withTransaction } from '../../db.js' -import type { ExternalSessionAuthorityContext } from '../auth/externalSessionAuthentication.js' -import { validateExternalSessionAuthorityContext } from '../auth/userSessionService.js' import { registerAndSendInvitation } from '../invitationFlowRegistrationService.js' import { appendControlApiPermissionEventsInTransaction } from '../tracing/controlApiPermissionEvents.js' import { @@ -508,8 +506,7 @@ async function deliverAndActivateInvitation( async function deliverAndActivateManagedInvitation( inserted: InvitationWithTeams, commandId: string, - managerUserId: string, - authority?: ExternalSessionAuthorityContext + managerUserId: string ) { try { await registerAndSendInvitation( @@ -537,11 +534,6 @@ async function deliverAndActivateManagedInvitation( } return withTransaction(async db => { - const sourceAuthorityValid = await hasValidManagedMutationAuthority( - db, - managerUserId, - authority - ) const locked = await db.query( `SELECT i.id, i.team_id, i.invitee_name, i.email, i.role, i.token, i.status, i.purpose, i.created_at, i.expires_at, i.accepted_at, i.accepted_user_id, @@ -561,14 +553,6 @@ async function deliverAndActivateManagedInvitation( ) const invitation = (locked.rows[0] as InvitationRow | undefined) || null if (!invitation) return { error: 'not_found' as const } - if (!sourceAuthorityValid) { - await finishInvitationDeliveryCommand(db, commandId, 'cancelled', 'authority_changed') - await db.query( - `UPDATE invitations SET status = 'revoked' WHERE id = $1 AND status = 'draft'`, - [invitation.id] - ) - return { error: 'forbidden' as const } - } const teams = await loadInvitationTeams(db, invitation.id, invitation) if (!(await managerCanControlAllInvitationTeams(db, managerUserId, teams, true))) { await finishInvitationDeliveryCommand(db, commandId, 'cancelled', 'authority_changed') @@ -1378,16 +1362,6 @@ async function getManagerRolesForTeams( ) } -async function hasValidManagedMutationAuthority( - db: DbClient, - managerUserId: string, - authority?: ExternalSessionAuthorityContext -): Promise { - if (!authority) return true - const session = await validateExternalSessionAuthorityContext(authority, { db }) - return session.status === 'valid' && session.identity.userId === managerUserId.trim() -} - function canManagerInviteAssignment( managerRole: TeamRole | undefined, assignmentRole: InviteRole @@ -1477,8 +1451,7 @@ export async function createManagedInvitationForUser( managerUserId: string, email: string, teamAssignments: readonly InvitationTeamAssignment[], - inviteeName = '', - authority?: ExternalSessionAuthorityContext + inviteeName = '' ) { const normalizedEmail = email.trim().toLowerCase() const normalizedInviteeName = inviteeName.trim() @@ -1489,9 +1462,6 @@ export async function createManagedInvitationForUser( const fallbackName = normalizedEmail.split('@')[0] || normalizedEmail const authorized = await withTransaction(async db => { - if (!(await hasValidManagedMutationAuthority(db, managerUserId, authority))) { - return { error: 'forbidden' as const } - } const managerRoles = await getManagerRolesForTeams( db, managerUserId.trim(), @@ -1524,8 +1494,7 @@ export async function createManagedInvitationForUser( const delivered = await deliverAndActivateManagedInvitation( authorized.inserted, authorized.commandId, - managerUserId, - authority + managerUserId ) if ('error' in delivered) return { error: delivered.error } return { @@ -1537,17 +1506,13 @@ export async function updateManagedMemberRoleForUser( managerUserId: string, targetUserId: string, teamId: string, - role: TeamRole, - authority?: ExternalSessionAuthorityContext + role: TeamRole ) { const normalizedRole = normalizeTeamRoleInput(role) if (!normalizedRole) { return { error: 'invalid_role' as const } } return withTransaction(async db => { - if (!(await hasValidManagedMutationAuthority(db, managerUserId, authority))) { - return { error: 'forbidden' as const } - } const locked = await db.query( `SELECT user_id::text AS user_id, role FROM team_members @@ -1612,13 +1577,9 @@ export async function updateManagedMemberRoleForUser( export async function deleteManagedMemberForUser( managerUserId: string, targetUserId: string, - teamId: string, - authority?: ExternalSessionAuthorityContext + teamId: string ) { return withTransaction(async db => { - if (!(await hasValidManagedMutationAuthority(db, managerUserId, authority))) { - return { error: 'forbidden' as const } - } const locked = await db.query( `SELECT user_id::text AS user_id, role FROM team_members @@ -1673,8 +1634,7 @@ export async function deleteManagedMemberForUser( export async function deleteManagedUserForUser( managerUserId: string, targetUserId: string, - retirement: { reason: string; idempotencyKey: string; requestId: string | null }, - authority?: ExternalSessionAuthorityContext + retirement: { reason: string; idempotencyKey: string; requestId: string | null } ) { const normalizedManagerUserId = managerUserId.trim() const normalizedTargetUserId = targetUserId.trim() @@ -1695,7 +1655,7 @@ export async function deleteManagedUserForUser( return await withTransaction(async db => { // Reciprocal retirements must not each lock their own actor before the // other request's target. Lock the existing principal pair in the same - // database UUID order before revalidating authority or locking teams. + // database UUID order before locking teams. // Filter malformed ids before the UUID cast so the indexed lookup does // not change their existing later validation/error path. const principalIds = [...new Set([normalizedManagerUserId, normalizedTargetUserId])] @@ -1712,9 +1672,6 @@ export async function deleteManagedUserForUser( FOR UPDATE`, [principalIds] ) - if (!(await hasValidManagedMutationAuthority(db, normalizedManagerUserId, authority))) { - return { error: 'forbidden' as const } - } const outcome = await retireDesktopUser( { kind: 'platform_user', desktopUserId: normalizedManagerUserId }, normalizedTargetUserId, @@ -1765,15 +1722,8 @@ export async function deleteManagedUserForUser( } } -export async function resendManagedInvitationForUser( - managerUserId: string, - invitationId: string, - authority?: ExternalSessionAuthorityContext -) { +export async function resendManagedInvitationForUser(managerUserId: string, invitationId: string) { const authorized = await withTransaction(async db => { - if (!(await hasValidManagedMutationAuthority(db, managerUserId, authority))) { - return { error: 'forbidden' as const } - } const result = await db.query( `SELECT i.id, i.team_id, i.invitee_name, i.email, i.role, i.token, i.status, i.purpose, i.created_at, i.expires_at, i.accepted_at, i.accepted_user_id, @@ -1828,15 +1778,8 @@ export async function resendManagedInvitationForUser( return { resent: true as const, id: invitation.id, email: invitation.email } } -export async function revokeManagedInvitationForUser( - managerUserId: string, - invitationId: string, - authority?: ExternalSessionAuthorityContext -) { +export async function revokeManagedInvitationForUser(managerUserId: string, invitationId: string) { return withTransaction(async db => { - if (!(await hasValidManagedMutationAuthority(db, managerUserId, authority))) { - return { error: 'forbidden' as const } - } const result = await db.query( `SELECT i.id, i.team_id, i.invitee_name, i.email, i.role, i.token, i.status, i.purpose, i.created_at, i.expires_at, i.accepted_at, i.accepted_user_id, diff --git a/control-api/test/directoryAuthorizationPrivacy.test.ts b/control-api/test/directoryAuthorizationPrivacy.test.ts index 971036fcd1..3f06b03e35 100644 --- a/control-api/test/directoryAuthorizationPrivacy.test.ts +++ b/control-api/test/directoryAuthorizationPrivacy.test.ts @@ -218,20 +218,8 @@ describe('directory privacy and atomic authorization', () => { expect(mocks.withTransaction).toHaveBeenCalledOnce() }) - it('revalidates source authority before final role-mutation membership locks', async () => { + it('does not re-lock the caller before the membership locks', async () => { mocks.query - .mockResolvedValueOnce({ - rows: [ - { - id: MANAGER, - email: 'manager@example.test', - lifecycle_state: 'active', - lifecycle_version: 1, - }, - ], - rowCount: 1, - }) - .mockResolvedValueOnce({ rows: [{ valid_after: null, token_revoked: false }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [ { user_id: MANAGER, role: 'admin' }, @@ -255,9 +243,16 @@ describe('directory privacy and atomic authorization', () => { expect(result).toEqual({ membership: { team_id: TEAM_A, user_id: TARGET, role: 'inviter', status: 'active' }, }) - expect(String(mocks.query.mock.calls[0]?.[0])).toContain('FROM users') + expect(String(mocks.query.mock.calls[0]?.[0])).toContain('FROM team_members') expect(String(mocks.query.mock.calls[0]?.[0])).toContain('FOR UPDATE') - expect(String(mocks.query.mock.calls[2]?.[0])).toContain('FROM team_members') + expect( + mocks.query.mock.calls.some(call => + String(call[0]).includes('external_user_session_security_epochs') + ) + ).toBe(false) + // Witness: the mutation completed and emitted its UPDATE. + expect(String(mocks.query.mock.calls[1]?.[0])).toContain('UPDATE team_members') + expect(mocks.appendEvents).toHaveBeenCalledTimes(1) }) it('locks invitation authority and denies inviter-to-admin assignment before mutation', async () => { @@ -1031,20 +1026,8 @@ describe('directory privacy and atomic authorization', () => { expect(mocks.appendEvents).not.toHaveBeenCalled() }) - it('revalidates source authority before final member-removal membership locks', async () => { + it('does not re-lock the caller before the member-removal membership locks', async () => { mocks.query - .mockResolvedValueOnce({ - rows: [ - { - id: MANAGER, - email: 'manager@example.test', - lifecycle_state: 'active', - lifecycle_version: 1, - }, - ], - rowCount: 1, - }) - .mockResolvedValueOnce({ rows: [{ valid_after: null, token_revoked: false }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [ { user_id: MANAGER, role: 'admin' }, @@ -1068,9 +1051,16 @@ describe('directory privacy and atomic authorization', () => { expect(result).toEqual({ deleted: { team_id: TEAM_A, user_id: TARGET, role: 'member', status: 'deleted' }, }) - expect(String(mocks.query.mock.calls[0]?.[0])).toContain('FROM users') + expect(String(mocks.query.mock.calls[0]?.[0])).toContain('FROM team_members') expect(String(mocks.query.mock.calls[0]?.[0])).toContain('FOR UPDATE') - expect(String(mocks.query.mock.calls[2]?.[0])).toContain('FROM team_members') + expect( + mocks.query.mock.calls.some(call => + String(call[0]).includes('external_user_session_security_epochs') + ) + ).toBe(false) + // Witness: the removal completed and emitted its UPDATE. + expect(String(mocks.query.mock.calls[1]?.[0])).toContain('UPDATE team_members') + expect(mocks.appendEvents).toHaveBeenCalledTimes(1) }) it('changes a password and revokes every session in one conditional transaction', async () => { diff --git a/control-api/test/gfs.externalAuthority.test.ts b/control-api/test/gfs.externalAuthority.test.ts index 2702aff960..35753e5fd2 100644 --- a/control-api/test/gfs.externalAuthority.test.ts +++ b/control-api/test/gfs.externalAuthority.test.ts @@ -1,13 +1,19 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { ExternalGfsAuthorityError, resolveExternalGfsAuthority, + signCurrentExternalGfsUserToken, } from '../src/gfs/externalAuthority.js' import { GfsDesktopOperatorLinkError } from '../src/services/gfsDesktopOperatorLinkService.js' +const dbMock = vi.hoisted(() => ({ withTransaction: vi.fn() })) +const gfsTokenMock = vi.hoisted(() => ({ signGfsToken: vi.fn() })) + vi.mock('../src/config.js', () => ({ config: { desktopGfsOperatorLinkingEnabled: false }, })) +vi.mock('../src/db.js', () => ({ withTransaction: dbMock.withTransaction })) +vi.mock('../src/auth/gfsToken.js', () => ({ signGfsToken: gfsTokenMock.signGfsToken })) vi.mock('../src/services/gfsDesktopOperatorLinkService.js', () => { class GfsDesktopOperatorLinkError extends Error { constructor( @@ -145,3 +151,95 @@ describe('resolveExternalGfsAuthority', () => { ).rejects.toMatchObject({ status: 403, code: 'gfs_operator_link_invalid' }) }) }) + +describe('signCurrentExternalGfsUserToken', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const policy = { + policyVersion: '1' as const, + policyRevision: 'gfs-external-authority-test', + acceptV1: true, + issueV1: true, + acceptV2: false, + issueV2: false, + renewV2: false, + switchCompatibility: true, + computeCatalogShadow: false, + serveCatalog: false, + actionContextV2: false, + rpcDelegationV2: false, + desktopAllTeamMode: false, + profileV2Mode: false, + minimumClientVersion: null, + enforceMinimumClient: false, + advertisedCatalogFamilies: [], + } + const tokenClaims = { + userId: DESKTOP_USER_ID, + email: 'user@example.test', + teamId: null, + role: 'member' as const, + authGeneration: 1, + exp: Math.floor(Date.now() / 1000) + 600, + } + const authentication = { + status: 'authenticated' as const, + contract: 'v1' as const, + claims: tokenClaims, + authorityContext: { + contract: 'v1' as const, + userId: DESKTOP_USER_ID, + tokenHash: 'token-hash', + issuedAt: 1, + authGeneration: 1, + }, + tokenClaims, + policy, + } + const input = { subject: DESKTOP_USER_ID, drive: 'user', scopes: ['read' as const] } + + it('signs with a single unlocked users read and no session revalidation', async () => { + const query = vi.fn().mockResolvedValue({ + rows: [{ lifecycle_state: 'active', lifecycle_version: 3, db_now: new Date() }], + rowCount: 1, + }) + dbMock.withTransaction.mockImplementation(async work => work({ query })) + gfsTokenMock.signGfsToken.mockReturnValue({ token: 'signed-gfs-token', expiresInSeconds: 600 }) + + await expect(signCurrentExternalGfsUserToken(authentication, input)).resolves.toEqual({ + token: 'signed-gfs-token', + expiresInSeconds: 600, + }) + + expect(query).toHaveBeenCalledOnce() + expect(String(query.mock.calls[0]?.[0])).toContain('FROM users') + expect(String(query.mock.calls[0]?.[0])).not.toContain('FOR UPDATE') + expect( + query.mock.calls.some(call => + String(call[0]).includes('external_user_session_security_epochs') + ) + ).toBe(false) + // Witness: the canonical generation reaches the signed claims. + expect(gfsTokenMock.signGfsToken).toHaveBeenCalledWith( + expect.objectContaining({ + subject: DESKTOP_USER_ID, + authGeneration: 3, + principalType: 'user', + }) + ) + }) + + it('returns null for a retired user instead of signing', async () => { + const query = vi.fn().mockResolvedValue({ + rows: [{ lifecycle_state: 'retired', lifecycle_version: 4, db_now: new Date() }], + rowCount: 1, + }) + dbMock.withTransaction.mockImplementation(async work => work({ query })) + + await expect(signCurrentExternalGfsUserToken(authentication, input)).resolves.toBeNull() + expect(query).toHaveBeenCalledOnce() + expect(gfsTokenMock.signGfsToken).not.toHaveBeenCalled() + }) +}) diff --git a/control-api/test/routes.externalMembers.test.ts b/control-api/test/routes.externalMembers.test.ts index 92060fda6c..9d22b21457 100644 --- a/control-api/test/routes.externalMembers.test.ts +++ b/control-api/test/routes.externalMembers.test.ts @@ -99,8 +99,7 @@ describe('external members routes', () => { 'manager-1', 'invitee@example.com', [{ teamId: 'team-1', role: 'admin' }], - 'Full Name', - expect.objectContaining({ contract: 'v1', userId: 'manager-1' }) + 'Full Name' ) }) @@ -154,8 +153,7 @@ describe('external members routes', () => { 'manager-1', email, [{ teamId: 'team-1', role: 'member' }], - '', - expect.objectContaining({ contract: 'v1', userId: 'manager-1' }) + '' ) } ) @@ -242,16 +240,11 @@ describe('external members routes', () => { .send({ reason: 'manager retirement' }) .expect(200, { ok: true, id: 'target-1' }) - expect(directoryMock.deleteManagedUserForUser).toHaveBeenCalledWith( - 'manager-1', - 'target-1', - { - reason: 'manager retirement', - idempotencyKey: 'external-delete-1', - requestId: 'request-external-members', - }, - expect.objectContaining({ contract: 'v1', userId: 'manager-1' }) - ) + expect(directoryMock.deleteManagedUserForUser).toHaveBeenCalledWith('manager-1', 'target-1', { + reason: 'manager retirement', + idempotencyKey: 'external-delete-1', + requestId: 'request-external-members', + }) }) it.each([ diff --git a/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts b/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts index b1a71bbe26..dc02f2ff78 100644 --- a/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts +++ b/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts @@ -7,7 +7,6 @@ import { type DbClient, initDb } from '../src/db.js' import { createExternalMembersRouter } from '../src/routes/external/members.js' import { createExternalTeamsRouter } from '../src/routes/external/teams.js' import { issueExternalUserSession } from '../src/services/auth/externalSessionIssuance.js' -import { revokeAllUserSessions } from '../src/services/auth/userSessionService.js' import { acceptInvitationForEmail, createManagedInvitationForUser, @@ -254,46 +253,6 @@ describeRealPostgres('external team mutations final source authority on real Pos function mutations(): readonly AdminMutation[] { return [ - { - name: 'member role change', - invoke: server => - request(server) - .patch(`/external/teams/${teamId}/members/${targetUserId}/role`) - .send({ role: 'inviter' }), - assertMutation: async () => { - const result = await databasePool.query<{ role: string }>( - `SELECT role FROM team_members WHERE team_id = $1 AND user_id = $2`, - [teamId, targetUserId] - ) - expect(result.rows[0]?.role).toBe('inviter') - }, - assertNoMutation: async () => { - const result = await databasePool.query<{ role: string }>( - `SELECT role FROM team_members WHERE team_id = $1 AND user_id = $2`, - [teamId, targetUserId] - ) - expect(result.rows[0]?.role).toBe('member') - }, - }, - { - name: 'member removal', - invoke: server => - request(server).delete(`/external/teams/${teamId}/members/${targetUserId}`), - assertMutation: async () => { - const result = await databasePool.query<{ status: string }>( - `SELECT status FROM team_members WHERE team_id = $1 AND user_id = $2`, - [teamId, targetUserId] - ) - expect(result.rows[0]?.status).toBe('deleted') - }, - assertNoMutation: async () => { - const result = await databasePool.query<{ status: string }>( - `SELECT status FROM team_members WHERE team_id = $1 AND user_id = $2`, - [teamId, targetUserId] - ) - expect(result.rows[0]?.status).toBe('active') - }, - }, { name: 'managed invitation creation', expectedStatus: 201, @@ -507,59 +466,6 @@ describeRealPostgres('external team mutations final source authority on real Pos expect(result.rows[0]?.status).toBe('pending') }) - it('denies managed invitation activation when source-session invalidation wins during delivery', async () => { - await resetPrincipal('managed-invitation-post-delivery-revoke') - - let markDeliveryStarted!: () => void - const deliveryStarted = new Promise(resolve => { - markDeliveryStarted = resolve - }) - let releaseDelivery!: () => void - const deliveryGate = new Promise(resolve => { - releaseDelivery = resolve - }) - invitationDelivery.registerAndSendInvitation.mockImplementationOnce(async () => { - markDeliveryStarted() - await deliveryGate - }) - - const pending = request(app()) - .post('/external/members/invitations') - .set('x-user-session-token', token) - .send({ - email: invitationEmail, - name: 'Invitee', - teams: [{ teamId, role: 'member' }], - }) - .then(response => response) - - await deliveryStarted - const draft = await databasePool.query<{ id: string; status: string; token: string }>( - `SELECT id::text AS id, status, token - FROM invitations - WHERE email = $1`, - [invitationEmail] - ) - expect(draft.rows[0]?.status).toBe('draft') - - await revokeAllUserSessions(adminUserId, 'test') - releaseDelivery() - - const response = await pending - expect(response.status).toBe(403) - const finalState = await databasePool.query<{ status: string; command_status: string }>( - `SELECT i.status, c.status AS command_status - FROM invitations i - JOIN invitation_delivery_commands c ON c.invitation_id = i.id - WHERE i.id = $1`, - [draft.rows[0]!.id] - ) - expect(finalState.rows[0]).toEqual({ status: 'revoked', command_status: 'cancelled' }) - await expect( - acceptInvitationForEmail(invitationEmail, draft.rows[0]!.token, draft.rows[0]!.id) - ).resolves.toEqual({ error: 'not_pending' }) - }) - for (const mutation of mutations()) { it(`allows ${mutation.name} before later retirement or revoke-all`, async () => { await resetPrincipal(`wins-${mutation.name.replaceAll(' ', '-')}`) @@ -600,36 +506,6 @@ describeRealPostgres('external team mutations final source authority on real Pos gate.release() } }) - - it(`denies ${mutation.name} when source-session invalidation wins after admission`, async () => { - await resetPrincipal(`revoke-${mutation.name.replaceAll(' ', '-')}`) - await mutation.prepare?.() - const gate = await databasePool.connect() - try { - await gate.query('BEGIN') - await gate.query(`SELECT id FROM users WHERE id = $1 FOR UPDATE`, [adminUserId]) - const gatePid = await backendPid(gate) - const pending = mutation - .invoke(app()) - .set('x-user-session-token', token) - .then(response => response) - await waitForBlockedBy(databasePool, gatePid) - await gate.query( - `INSERT INTO external_user_session_security_epochs(user_id, valid_after, reason) - VALUES ($1, NOW() + INTERVAL '1 second', 'test') - ON CONFLICT (user_id) DO UPDATE - SET valid_after = EXCLUDED.valid_after, reason = EXCLUDED.reason`, - [adminUserId] - ) - await gate.query('COMMIT') - const response = await pending - expect(response.status).toBe(403) - await mutation.assertNoMutation() - } finally { - await gate.query('ROLLBACK').catch(() => undefined) - gate.release() - } - }) } it('serializes reciprocal managed-user retirements before related membership locks', async () => { diff --git a/control-api/test/routes.profile.test.ts b/control-api/test/routes.profile.test.ts index e60f0b35e5..55f582dd4b 100644 --- a/control-api/test/routes.profile.test.ts +++ b/control-api/test/routes.profile.test.ts @@ -540,8 +540,7 @@ describe('routes/profile', () => { 'u1', 'target@example.com', [{ teamId: 't1', role: 'admin' }], - 'target', - expect.objectContaining({ contract: 'v1', userId: 'u1' }) + 'target' ) }) From 8009406818f10fa1e3467e1eca7d17eda32c0760 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Tue, 3 Nov 2026 17:39:38 +0100 Subject: [PATCH 450/528] refactor(control-api): deliver managed invitations with dev's flow Managed invitation creation and resend no longer persist a delivery command or reopen a second transaction after the email: creation locks the manager roles, inserts the draft and then delivers and activates it; resend locks the invitation, checks the manager roles and sends, so a send failure leaves the invitation pending and answers 500 as in dev. `recordInvitationDeliveryCommand`, `finishInvitationDeliveryCommand` and `deliverAndActivateManagedInvitation` leave. INV-2, INV-6, INV-7 and INV-24 keep their tests, and a new real-PostgreSQL case pins resend and revoke to a pending member invitation only. --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../src/services/directory/membership.ts | 164 ++---------------- .../directoryAuthorizationPrivacy.test.ts | 120 ++++--------- ...empotency.realPostgres.integration.test.ts | 53 ++++++ ...Authority.realPostgres.integration.test.ts | 25 +-- 6 files changed, 113 insertions(+), 255 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index ea1c70f0f6..5066a8b047 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.890", + "version": "0.1.891", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.890", + "version": "0.1.891", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 8302ef30d1..04dd8cd7af 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.890", + "version": "0.1.891", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/directory/membership.ts b/control-api/src/services/directory/membership.ts index bd73d403d5..52300ba70d 100644 --- a/control-api/src/services/directory/membership.ts +++ b/control-api/src/services/directory/membership.ts @@ -428,43 +428,6 @@ export async function addMemberToTeam( }) } -async function recordInvitationDeliveryCommand( - db: Pick, - invitationId: string, - authorizedBy: string, - deliveryKind: 'create' | 'resend' -): Promise { - const result = await db.query( - `INSERT INTO invitation_delivery_commands(invitation_id, authorized_by, delivery_kind) - VALUES($1, $2, $3) - RETURNING id`, - [invitationId, authorizedBy.trim(), deliveryKind] - ) - const commandId = String((result.rows[0] as { id?: string } | undefined)?.id || '') - if (!commandId) throw new Error('Failed to persist invitation delivery command') - return commandId -} - -async function finishInvitationDeliveryCommand( - db: Pick, - commandId: string, - status: 'delivered' | 'failed' | 'cancelled', - failureCode?: string -): Promise { - const result = await db.query( - `UPDATE invitation_delivery_commands - SET status = $2, - completed_at = NOW(), - failure_code = $3 - WHERE id = $1 - AND status = 'authorized'`, - [commandId, status, failureCode || null] - ) - if ((result.rowCount ?? 0) === 0) { - throw new Error('Invitation delivery command is no longer authorized') - } -} - export function externalManagedInvitationResponse( invitation: Record ): Record { @@ -503,73 +466,6 @@ async function deliverAndActivateInvitation( ) } -async function deliverAndActivateManagedInvitation( - inserted: InvitationWithTeams, - commandId: string, - managerUserId: string -) { - try { - await registerAndSendInvitation( - inserted.invitation.email, - inserted.invitation.token, - inserted.teams.length > 0 - ? inserted.teams.map(team => team.team_name).join(', ') - : inserted.invitation.team_name, - inserted.invitation.created_at.toISOString(), - inserted.invitation.expires_at.toISOString(), - { - purpose: 'member_invitation', - teamNames: inserted.teams.map(team => team.team_name), - } - ) - } catch (error) { - await withTransaction(async db => { - await finishInvitationDeliveryCommand(db, commandId, 'failed', 'delivery_failed') - await db.query( - `UPDATE invitations SET status = 'revoked' WHERE id = $1 AND status = 'draft'`, - [inserted.invitation.id] - ) - }).catch(() => undefined) - throw error - } - - return withTransaction(async db => { - const locked = await db.query( - `SELECT i.id, i.team_id, i.invitee_name, i.email, i.role, i.token, i.status, i.purpose, - i.created_at, i.expires_at, i.accepted_at, i.accepted_user_id, - t.name AS team_name - FROM invitation_delivery_commands c - JOIN invitations i ON i.id = c.invitation_id - LEFT JOIN teams t ON t.id = i.team_id - WHERE c.id = $1 - AND c.invitation_id = $2 - AND c.authorized_by = $3 - AND c.delivery_kind = 'create' - AND c.status = 'authorized' - AND i.status = 'draft' - LIMIT 1 - FOR UPDATE OF c, i`, - [commandId, inserted.invitation.id, managerUserId.trim()] - ) - const invitation = (locked.rows[0] as InvitationRow | undefined) || null - if (!invitation) return { error: 'not_found' as const } - const teams = await loadInvitationTeams(db, invitation.id, invitation) - if (!(await managerCanControlAllInvitationTeams(db, managerUserId, teams, true))) { - await finishInvitationDeliveryCommand(db, commandId, 'cancelled', 'authority_changed') - await db.query( - `UPDATE invitations SET status = 'revoked' WHERE id = $1 AND status = 'draft'`, - [invitation.id] - ) - return { error: 'forbidden' as const } - } - - const pending = await activateDraftInvitation(db, invitation.id) - if (!pending) throw new Error('Failed to activate authorized invitation') - await finishInvitationDeliveryCommand(db, commandId, 'delivered') - return invitationForTeamsResponse({ ...pending, team_name: invitation.team_name }, teams) - }) -} - async function createInvitationForTeamsRecord( input: InvitationForTeamsInput, options: { sendEmail: boolean } @@ -1461,7 +1357,7 @@ export async function createManagedInvitationForUser( } const fallbackName = normalizedEmail.split('@')[0] || normalizedEmail - const authorized = await withTransaction(async db => { + const inserted = await withTransaction(async db => { const managerRoles = await getManagerRolesForTeams( db, managerUserId.trim(), @@ -1474,7 +1370,7 @@ export async function createManagedInvitationForUser( } } - const inserted = await insertInvitationForTeams(db, { + return insertInvitationForTeams(db, { inviteeName: normalizedInviteeName || fallbackName, email: normalizedEmail, purpose: 'member_invitation', @@ -1482,21 +1378,9 @@ export async function createManagedInvitationForUser( fallbackRole: 'member', status: 'draft', }) - const commandId = await recordInvitationDeliveryCommand( - db, - inserted.invitation.id, - managerUserId, - 'create' - ) - return { inserted, commandId } }) - if ('error' in authorized && authorized.error) return { error: authorized.error } - const delivered = await deliverAndActivateManagedInvitation( - authorized.inserted, - authorized.commandId, - managerUserId - ) - if ('error' in delivered) return { error: delivered.error } + if ('error' in inserted) return { error: inserted.error } + const delivered = await deliverAndActivateInvitation(inserted, 'member_invitation') return { invitation: externalManagedInvitationResponse(delivered), } @@ -1745,36 +1629,22 @@ export async function resendManagedInvitationForUser(managerUserId: string, invi return { error: 'forbidden' as const } } - const commandId = await recordInvitationDeliveryCommand( - db, - invitation.id, - managerUserId, - 'resend' - ) - return { invitation, teams, commandId } + return { invitation, teams } }) if ('error' in authorized) return authorized - const { invitation, teams, commandId } = authorized + const { invitation, teams } = authorized - try { - await registerAndSendInvitation( - invitation.email, - invitation.token, - teams.length > 0 ? teams.map(team => team.team_name).join(', ') : invitation.team_name, - new Date().toISOString(), - invitation.expires_at.toISOString(), - { - purpose: invitation.purpose, - teamNames: teams.map(team => team.team_name), - } - ) - } catch (error) { - await withTransaction(db => - finishInvitationDeliveryCommand(db, commandId, 'failed', 'delivery_failed') - ).catch(() => undefined) - throw error - } - await withTransaction(db => finishInvitationDeliveryCommand(db, commandId, 'delivered')) + await registerAndSendInvitation( + invitation.email, + invitation.token, + teams.length > 0 ? teams.map(team => team.team_name).join(', ') : invitation.team_name, + new Date().toISOString(), + invitation.expires_at.toISOString(), + { + purpose: invitation.purpose, + teamNames: teams.map(team => team.team_name), + } + ) return { resent: true as const, id: invitation.id, email: invitation.email } } diff --git a/control-api/test/directoryAuthorizationPrivacy.test.ts b/control-api/test/directoryAuthorizationPrivacy.test.ts index 3f06b03e35..d1b3180b2c 100644 --- a/control-api/test/directoryAuthorizationPrivacy.test.ts +++ b/control-api/test/directoryAuthorizationPrivacy.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import bcrypt from 'bcryptjs' +import { readFileSync } from 'node:fs' import { DirectorySearchCursorError, acceptInvitationForEmail, @@ -314,13 +315,6 @@ describe('directory privacy and atomic authorization', () => { if (sql.includes('INSERT INTO profiles')) return { rows: [], rowCount: 1 } if (sql.includes('WITH inserted AS')) return { rows: [invitation], rowCount: 1 } if (sql.includes('INSERT INTO invitation_teams')) return { rows: [], rowCount: 1 } - if (sql.includes('INSERT INTO invitation_delivery_commands')) { - return { rows: [{ id: '00000000-0000-4000-8000-000000000300' }], rowCount: 1 } - } - if (sql.includes('FROM invitation_delivery_commands c')) { - return { rows: [invitation], rowCount: 1 } - } - if (sql.includes('UPDATE invitation_delivery_commands')) return { rows: [], rowCount: 1 } if (sql.includes('FROM invitation_teams it') && sql.includes('JOIN teams')) { return { rows: [{ team_id: TEAM_A, team_name: 'Team A', role: 'member' }], @@ -385,76 +379,6 @@ describe('directory privacy and atomic authorization', () => { expect(mocks.registerInvitation).toHaveBeenCalledOnce() }) - it('leaves a delivered draft unusable when manager authority changes before activation', async () => { - const invitation = { - id: '00000000-0000-4000-8000-000000000200', - team_id: TEAM_A, - invitee_name: 'Invitee', - email: 'invitee@example.com', - role: 'member', - token: 'synthetic-inert-until-activation', - status: 'draft', - purpose: 'member_invitation', - ...activeInvitationWindow(), - accepted_at: null, - accepted_user_id: null, - team_name: 'Team A', - } - let transactionDepth = 0 - let managerChecks = 0 - mocks.withTransaction.mockImplementation(async work => { - transactionDepth += 1 - try { - return await work({ query: queryWithDatabaseClock }) - } finally { - transactionDepth -= 1 - } - }) - mocks.registerInvitation.mockImplementation(async () => { - expect(transactionDepth).toBe(0) - }) - mocks.query.mockImplementation(async (sql: string) => { - if (sql.includes("WHERE status = 'draft'")) return { rows: [], rowCount: 0 } - if (sql.includes('FROM team_members') && sql.includes('FOR UPDATE')) { - managerChecks += 1 - return managerChecks === 1 - ? { rows: [{ team_id: TEAM_A, role: 'admin' }], rowCount: 1 } - : { rows: [], rowCount: 0 } - } - if (sql.includes('WITH inserted AS')) return { rows: [invitation], rowCount: 1 } - if (sql.includes('INSERT INTO invitation_teams')) return { rows: [], rowCount: 1 } - if (sql.includes('INSERT INTO invitation_delivery_commands')) { - return { rows: [{ id: '00000000-0000-4000-8000-000000000300' }], rowCount: 1 } - } - if (sql.includes('FROM invitation_delivery_commands c')) { - return { rows: [invitation], rowCount: 1 } - } - if (sql.includes('FROM invitation_teams it') && sql.includes('JOIN teams')) { - return { - rows: [{ team_id: TEAM_A, team_name: 'Team A', role: 'member' }], - rowCount: 1, - } - } - if (sql.includes('UPDATE invitation_delivery_commands')) return { rows: [], rowCount: 1 } - if (sql.includes("SET status = 'revoked'")) return { rows: [], rowCount: 1 } - throw new Error(`unexpected query: ${sql.slice(0, 40)}`) - }) - - await expect( - createManagedInvitationForUser( - MANAGER, - invitation.email, - [{ teamId: TEAM_A, role: 'member' }], - '' - ) - ).resolves.toEqual({ error: 'forbidden' }) - - const sql = mocks.query.mock.calls.map(call => String(call[0])).join('\n') - expect(managerChecks).toBe(2) - expect(sql).toContain("SET status = 'revoked'") - expect(sql).not.toContain("SET status = 'pending'") - }) - it('does not apply inviter-controlled names to an existing user during acceptance', async () => { const invitation = { id: '00000000-0000-4000-8000-000000000200', @@ -924,7 +848,7 @@ describe('directory privacy and atomic authorization', () => { expect(sql).not.toContain('external_user_session_security_epochs') }) - it('sends a managed resend from a durable command without holding authority locks', async () => { + it('sends a managed resend from one locked transaction', async () => { const invitation = { id: '00000000-0000-4000-8000-000000000200', team_id: TEAM_A, @@ -958,21 +882,15 @@ describe('directory privacy and atomic authorization', () => { rowCount: 1, }) .mockResolvedValueOnce({ rows: [{ team_id: TEAM_A, role: 'admin' }], rowCount: 1 }) - .mockResolvedValueOnce({ - rows: [{ id: '00000000-0000-4000-8000-000000000300' }], - rowCount: 1, - }) - .mockResolvedValueOnce({ rows: [], rowCount: 1 }) await expect(resendManagedInvitationForUser(MANAGER, invitation.id)).resolves.toEqual({ resent: true, id: invitation.id, email: invitation.email, }) - expect(String(mocks.query.mock.calls[3]?.[0])).toContain( - 'INSERT INTO invitation_delivery_commands' - ) - expect(String(mocks.query.mock.calls[4]?.[0])).toContain('UPDATE invitation_delivery_commands') + expect(mocks.query).toHaveBeenCalledTimes(3) + expect(String(mocks.query.mock.calls[0]?.[0])).toContain('FOR UPDATE OF i') + expect(mocks.registerInvitation).toHaveBeenCalledOnce() }) it.each(['resend', 'revoke'] as const)( @@ -1128,4 +1046,32 @@ describe('directory privacy and atomic authorization', () => { searchDirectory(TEAM_A, '%_', `${result.nextCursor}tampered`) ).rejects.toBeInstanceOf(DirectorySearchCursorError) }) + + function functionBody(source: string, name: string): string | null { + const start = source.indexOf(`function ${name}(`) + if (start === -1) return null + const end = source.indexOf('\n}\n', start) + return end === -1 ? null : source.slice(start, end) + } + + // Text fix, not behaviour: the locked read already serializes writers, so no + // behaviour test can reach this predicate. The witness proves the extractor + // finds the same predicate in the acceptance path. + it('pins the conditional revoke predicate in the service body', () => { + const source = readFileSync( + new URL('../src/services/directory/membership.ts', import.meta.url), + 'utf8' + ) + const revokeBody = functionBody(source, 'revokeManagedInvitationForUser') + expect(revokeBody).toContain( + "UPDATE invitations SET status = 'revoked' WHERE id = $1 AND status = 'pending'" + ) + expect(functionBody(source, 'acceptPendingMembershipInvitation')).toContain( + "AND status = 'pending'" + ) + // Negative control: a copy without the predicate no longer satisfies it. + expect(revokeBody!.replace("AND status = 'pending'", '')).not.toContain( + "AND status = 'pending'" + ) + }) }) diff --git a/control-api/test/membershipInvitationIdempotency.realPostgres.integration.test.ts b/control-api/test/membershipInvitationIdempotency.realPostgres.integration.test.ts index 25706550b8..be5e3e8f1d 100644 --- a/control-api/test/membershipInvitationIdempotency.realPostgres.integration.test.ts +++ b/control-api/test/membershipInvitationIdempotency.realPostgres.integration.test.ts @@ -6,10 +6,15 @@ import { acceptInvitation, acceptInvitationById, acceptInvitationForEmail, + resendManagedInvitationForUser, + revokeManagedInvitationForUser, } from '../src/services/directory/membership.js' import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' const holder = vi.hoisted(() => ({ pool: null as unknown as Pool })) +const mailer = vi.hoisted(() => ({ registerAndSendInvitation: vi.fn() })) + +vi.mock('../src/services/invitationFlowRegistrationService.js', () => mailer) vi.mock('../src/db.js', async importOriginal => { const real = await importOriginal() @@ -497,6 +502,54 @@ describeRealPostgres('membership invitation acceptance idempotency on real Postg await expect(memberships(String(accepted.data.userId))).resolves.toHaveLength(1) }) + it('refuses resend and revoke unless the invitation is a pending member invitation', async () => { + const managerId = randomUUID() + await holder.pool.query( + `INSERT INTO users(id, email, name) VALUES ($1, $2, 'Invitation Manager')`, + [managerId, `manager-${managerId}@example.test`] + ) + await holder.pool.query( + `INSERT INTO team_members(team_id, user_id, role, status) + VALUES ($1, $2, 'admin', 'active')`, + [teamId, managerId] + ) + const accepted = await createInvitation({ label: 'resend-accepted', status: 'accepted' }) + const revoked = await createInvitation({ label: 'resend-revoked', status: 'revoked' }) + const expired = await createInvitation({ + label: 'resend-expired', + expiresAt: new Date(Date.now() - 60_000), + }) + const pending = await createInvitation({ label: 'resend-pending' }) + mailer.registerAndSendInvitation.mockClear() + + for (const [invitation, expectedStatus] of [ + [accepted, 'accepted'], + [revoked, 'revoked'], + [expired, 'pending'], + ] as const) { + await expect(resendManagedInvitationForUser(managerId, invitation.id)).resolves.toEqual({ + error: 'not_found', + }) + await expect(revokeManagedInvitationForUser(managerId, invitation.id)).resolves.toEqual({ + error: 'not_found', + }) + expect(await invitationState(invitation.id)).toMatchObject({ status: expectedStatus }) + } + expect(mailer.registerAndSendInvitation).not.toHaveBeenCalled() + + // Witness: the same calls succeed on a pending member invitation. + await expect(resendManagedInvitationForUser(managerId, pending.id)).resolves.toMatchObject({ + resent: true, + id: pending.id, + }) + expect(mailer.registerAndSendInvitation).toHaveBeenCalledOnce() + await expect(revokeManagedInvitationForUser(managerId, pending.id)).resolves.toMatchObject({ + revoked: true, + id: pending.id, + }) + expect(await invitationState(pending.id)).toMatchObject({ status: 'revoked' }) + }) + it('writes one membership when two acceptances overlap on the invitation row lock', async () => { const invitation = await createInvitation({ label: 'concurrent' }) await resetMembershipWriteProbe() diff --git a/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts b/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts index dc02f2ff78..79ca1446d4 100644 --- a/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts +++ b/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts @@ -302,29 +302,18 @@ describeRealPostgres('external team mutations final source authority on real Pos }, { name: 'managed invitation resend', - prepare: prepareInvitation, + prepare: async () => { + await prepareInvitation() + // Preparation already delivers one email; count only the resend. + invitationDelivery.registerAndSendInvitation.mockClear() + }, invoke: server => request(server).post(`/external/members/invitations/${invitationId}/resend`), assertMutation: async () => { - const result = await databasePool.query( - `SELECT 1 - FROM invitation_delivery_commands - WHERE invitation_id = $1 - AND delivery_kind = 'resend' - AND status = 'delivered'`, - [invitationId] - ) - expect(result.rowCount).toBe(1) + expect(invitationDelivery.registerAndSendInvitation).toHaveBeenCalledOnce() }, assertNoMutation: async () => { - const result = await databasePool.query( - `SELECT 1 - FROM invitation_delivery_commands - WHERE invitation_id = $1 - AND delivery_kind = 'resend'`, - [invitationId] - ) - expect(result.rowCount).toBe(0) + expect(invitationDelivery.registerAndSendInvitation).not.toHaveBeenCalled() }, }, { From c1b0df15f1132f9f62b548b759c8f1676d930dc3 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Tue, 3 Nov 2026 18:16:12 +0100 Subject: [PATCH 451/528] perf(control-api): keep the action budget out of V1 session reads The 2 s action budget no longer wraps session validation: the five routes that mounted it for live authorization (rpc token, directory, teams, shared filesystems and both workflow admission chains) drop it, and the session service, the external authentication boundary and the live team lookup lose their budget branches. Their no-budget paths already classify database outages, so a V1 request now reads the clock and the lifecycle row with two plain pool queries instead of opening a transaction with `statement_timeout` and `READ ONLY`. --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../src/middleware/externalSessionAuth.ts | 17 +++--- control-api/src/routes/external/auth.ts | 3 -- control-api/src/routes/external/directory.ts | 4 +- .../src/routes/external/sharedFilesystems.ts | 16 ++---- control-api/src/routes/external/teams.ts | 8 +-- .../routes/external/workflows/admission.ts | 3 -- .../services/access/liveTeamAuthorization.ts | 9 +--- .../auth/externalSessionAuthentication.ts | 17 ++---- .../src/services/auth/userSessionService.ts | 54 ++----------------- .../externalSessionTeamAuthorization.test.ts | 28 +--------- .../test/liveTeamAuthorizationBudget.test.ts | 35 ------------ ...ware.externalSessionAuth.lifecycle.test.ts | 46 ++++++++++------ .../test/routes.externalAccess.test.ts | 16 ++---- .../test/routes.externalDirectory.test.ts | 6 +-- .../routes.externalMemberVisibility.test.ts | 2 +- .../routes.externalSharedFilesystems.test.ts | 6 +-- control-api/test/routes.profile.test.ts | 2 +- 19 files changed, 67 insertions(+), 211 deletions(-) delete mode 100644 control-api/test/liveTeamAuthorizationBudget.test.ts diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 5066a8b047..77673e8ace 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.891", + "version": "0.1.892", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.891", + "version": "0.1.892", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 04dd8cd7af..af61114e87 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.891", + "version": "0.1.892", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/middleware/externalSessionAuth.ts b/control-api/src/middleware/externalSessionAuth.ts index 692074c119..0cc7fa04bf 100644 --- a/control-api/src/middleware/externalSessionAuth.ts +++ b/control-api/src/middleware/externalSessionAuth.ts @@ -56,11 +56,13 @@ export type ExternalAuthedRequest = Request & { ExternalSessionIdentityAuthentication, { status: 'authenticated' } > - accessExecutionBudget?: AccessExecutionBudget externalTeamAuth?: { teamId: string role: TeamRole } + // Retained until K9/K11 delete the /external/access routes and the budget + // module, which are its only remaining readers and writer. + accessExecutionBudget?: AccessExecutionBudget } function extractUserSessionToken(req: Request): string { @@ -156,7 +158,6 @@ async function validateExternalSessionToken( const authentication = await authenticateExternalUserSession(token, { purpose: 'protected', client: { version: req.header('x-evenfire-client-version') || undefined }, - budget: req.accessExecutionBudget, }) if (authentication.status === 'upgrade_required') { if (publicErrors) { @@ -199,9 +200,7 @@ export async function requireExternalSessionLimiterIdentityWithPublicErrors( return } try { - const identity = await authenticateExternalUserSessionIdentity(token, { - budget: req.accessExecutionBudget, - }) + const identity = await authenticateExternalUserSessionIdentity(token, {}) if (identity.status !== 'authenticated') { sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') return @@ -231,7 +230,6 @@ export async function requireCompletedExternalSessionAuthenticationWithPublicErr const authentication = await authenticateExternalUserSession(token, { purpose: 'protected', client: { version: req.header('x-evenfire-client-version') || undefined }, - budget: req.accessExecutionBudget, identity, }) if (authentication.status === 'upgrade_required') { @@ -289,7 +287,6 @@ function externalTeamParamMatcher(paramName: string) { } try { const membership = await getLiveTeamMembership(claims.userId, requestedTeamId, { - budget: req.accessExecutionBudget, onDatabaseFailure: externalSessionDatabaseFailure, }) if (!membership) { @@ -299,9 +296,9 @@ function externalTeamParamMatcher(paramName: string) { req.externalTeamAuth = membership next() } catch (error) { - // Only the database-outage provenance is an outage. Anything else (an - // exhausted access budget, a programmer or policy failure) keeps the - // global handler's sanitized 500 path, as the auth routes do. + // Only the database-outage provenance is an outage. Anything else (a + // programmer or policy failure) keeps the global handler's sanitized 500 + // path, as the auth routes do. if (!isExternalSessionBackendUnavailableError(error)) { next(error) return diff --git a/control-api/src/routes/external/auth.ts b/control-api/src/routes/external/auth.ts index b4c1b531f4..3deb48b511 100644 --- a/control-api/src/routes/external/auth.ts +++ b/control-api/src/routes/external/auth.ts @@ -3,7 +3,6 @@ import { createHash } from 'node:crypto' import { pool } from '../../db.js' import { sendPublicApiError } from '../../http/publicApiError.js' import type { K8sGateway } from '../../k8s.js' -import { attachAccessExecutionBudget } from '../../middleware/accessExecutionBudget.js' import { type ExternalAuthedRequest, handleExternalSessionBackendFailure, @@ -587,7 +586,6 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { router.post( '/external/rpc/token', - attachAccessExecutionBudget, requireLegacyRpcSessionRateLimitContext, requireAuthenticatedExternalUserRateLimitContext, async (req: ExternalAuthedRequest, res, next) => { @@ -598,7 +596,6 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { try { liveTeam = claims.teamId ? await getLiveTeamMembership(claims.userId, claims.teamId, { - budget: req.accessExecutionBudget, onDatabaseFailure: externalSessionDatabaseFailure, }) : null diff --git a/control-api/src/routes/external/directory.ts b/control-api/src/routes/external/directory.ts index 410232bf71..3c398c3d97 100644 --- a/control-api/src/routes/external/directory.ts +++ b/control-api/src/routes/external/directory.ts @@ -1,6 +1,5 @@ import { Router } from 'express' import { config } from '../../config.js' -import { attachAccessExecutionBudget } from '../../middleware/accessExecutionBudget.js' import { createExternalClientRateLimiters } from '../../middleware/externalClientIdentity.js' import { type ExternalAuthedRequest, @@ -23,8 +22,7 @@ export function createExternalDirectoryRouter(): Router { router.use( '/external/directory', ...externalDirectoryRateLimits, - requireValidExternalSessionToken, - attachAccessExecutionBudget + requireValidExternalSessionToken ) router.get( diff --git a/control-api/src/routes/external/sharedFilesystems.ts b/control-api/src/routes/external/sharedFilesystems.ts index 613ef2021e..56665d3f16 100644 --- a/control-api/src/routes/external/sharedFilesystems.ts +++ b/control-api/src/routes/external/sharedFilesystems.ts @@ -2,7 +2,6 @@ import { Router } from 'express' import { config } from '../../config.js' import { asyncHandler } from '../../http/asyncHandler.js' import type { K8sGateway } from '../../k8s.js' -import { attachAccessExecutionBudget } from '../../middleware/accessExecutionBudget.js' import { createExternalClientRateLimiters } from '../../middleware/externalClientIdentity.js' import { type ExternalAuthedRequest, @@ -72,8 +71,7 @@ class ContextAuthorizationUnavailableError extends Error { async function loadAccessibleContextIds( gateway: K8sGateway, userId: string, - teamId: string | null, - budget: NonNullable + teamId: string | null ): Promise> { const accessible = new Set() let activeContextIds: string[] @@ -92,7 +90,7 @@ async function loadAccessibleContextIds( const compatibilityTeamId = teamId.trim() let membership try { - membership = await getLiveTeamMembership(userId, compatibilityTeamId, { budget }) + membership = await getLiveTeamMembership(userId, compatibilityTeamId) } catch (err) { throw new ContextAuthorizationUnavailableError(err) } @@ -121,12 +119,7 @@ function loadAccessibleContextIdsForRequest( ): Promise> { const cache = requestCache(res) const claims = req.externalAuth! - cache.accessibleContextIds ??= loadAccessibleContextIds( - gateway, - claims.userId, - claims.teamId, - req.accessExecutionBudget! - ) + cache.accessibleContextIds ??= loadAccessibleContextIds(gateway, claims.userId, claims.teamId) return cache.accessibleContextIds } @@ -223,8 +216,7 @@ export function createExternalSharedFilesystemsRouter(gateway: K8sGateway): Rout router.use( '/external/contexts/:contextId/shared-filesystems', ...externalSharedFilesystemsRateLimits, - requireValidExternalSessionToken, - attachAccessExecutionBudget + requireValidExternalSessionToken ) // Reject anything but GET/HEAD up-front so the proxy below can never be diff --git a/control-api/src/routes/external/teams.ts b/control-api/src/routes/external/teams.ts index 43fdd82528..6aefdc99b1 100644 --- a/control-api/src/routes/external/teams.ts +++ b/control-api/src/routes/external/teams.ts @@ -1,7 +1,6 @@ import { Router } from 'express' import { config } from '../../config.js' import type { K8sGateway } from '../../k8s.js' -import { attachAccessExecutionBudget } from '../../middleware/accessExecutionBudget.js' import { createExternalClientRateLimiters } from '../../middleware/externalClientIdentity.js' import type { ExternalAuthedRequest } from '../../middleware/externalSessionAuth.js' import { @@ -51,12 +50,7 @@ export function createExternalTeamsRouter(gateway: K8sGateway): Router { config.approvalRlExternalClientIpPerMin, config.approvalRlExternalEdgePerMin ) - router.use( - '/external/teams', - ...externalTeamsRateLimits, - requireValidExternalSessionToken, - attachAccessExecutionBudget - ) + router.use('/external/teams', ...externalTeamsRateLimits, requireValidExternalSessionToken) router.get( '/external/teams/:teamId/users/:userId/current', diff --git a/control-api/src/routes/external/workflows/admission.ts b/control-api/src/routes/external/workflows/admission.ts index 6f4026eb44..d4ea99a400 100644 --- a/control-api/src/routes/external/workflows/admission.ts +++ b/control-api/src/routes/external/workflows/admission.ts @@ -1,4 +1,3 @@ -import { attachAccessExecutionBudget } from '../../../middleware/accessExecutionBudget.js' import { requireValidExternalSessionToken } from '../../../middleware/externalSessionAuth.js' import { bindCompletedExternalWorkflowCaller } from '../../workflows/shared/auth.js' import { workflowTriggerRateLimit } from '../../workflows/shared/rateLimit.js' @@ -6,7 +5,6 @@ import { workflowTriggerRateLimit } from '../../workflows/shared/rateLimit.js' // One session validation per request, as in dev and on every other protected // route: no limiter sits between identity and policy here. export const externalWorkflowReadAdmission = Object.freeze([ - attachAccessExecutionBudget, requireValidExternalSessionToken, bindCompletedExternalWorkflowCaller, ]) @@ -14,7 +12,6 @@ export const externalWorkflowReadAdmission = Object.freeze([ // The trigger limiter runs before any live session work, as in dev: it keys on // the signature-verified user (or the source IP), so rejected sessions still pay. export const externalWorkflowTriggerAdmission = Object.freeze([ - attachAccessExecutionBudget, workflowTriggerRateLimit(), requireValidExternalSessionToken, bindCompletedExternalWorkflowCaller, diff --git a/control-api/src/services/access/liveTeamAuthorization.ts b/control-api/src/services/access/liveTeamAuthorization.ts index c6509a15b0..4851bace8a 100644 --- a/control-api/src/services/access/liveTeamAuthorization.ts +++ b/control-api/src/services/access/liveTeamAuthorization.ts @@ -1,7 +1,5 @@ import { type DbClient, pool } from '../../db.js' import type { TeamRole } from '../../profileTypes.js' -import { runAccessDatabaseQuery } from './accessDatabaseQuery.js' -import type { AccessExecutionBudget } from './accessExecutionBudget.js' export type LiveTeamMembership = { teamId: string @@ -13,7 +11,6 @@ export async function getLiveTeamMembership( teamId: string, options: { db?: Pick - budget?: AccessExecutionBudget onDatabaseFailure?: (error: unknown) => unknown } = {} ): Promise { @@ -26,11 +23,7 @@ export async function getLiveTeamMembership( LIMIT 1` const values = [userId, teamId] let result - if (options.budget) { - result = await runAccessDatabaseQuery(db, options.budget, text, values, { - onDatabaseFailure: options.onDatabaseFailure, - }) - } else if (options.onDatabaseFailure) { + if (options.onDatabaseFailure) { try { result = await db.query(text, values) } catch (error) { diff --git a/control-api/src/services/auth/externalSessionAuthentication.ts b/control-api/src/services/auth/externalSessionAuthentication.ts index 42c8e4af7e..caeb4b509a 100644 --- a/control-api/src/services/auth/externalSessionAuthentication.ts +++ b/control-api/src/services/auth/externalSessionAuthentication.ts @@ -4,7 +4,6 @@ import { type UserSessionV2Claims, verifyUserSessionV2Token, } from '../../utils/auth/userSessionV2Token.js' -import type { AccessExecutionBudget } from '../access/accessExecutionBudget.js' import { type EffectiveUserAccessPolicy, compareSemanticVersions, @@ -58,7 +57,6 @@ type AuthenticationOptions = Readonly<{ purpose: ExternalSessionPurpose client?: ExternalSessionClient policy?: EffectiveUserAccessPolicy - budget?: AccessExecutionBudget now?: Date identity?: Extract }> @@ -157,12 +155,11 @@ function normalizedV2Claims( */ export async function authenticateExternalUserSessionIdentity( token: string, - options: Pick = {} + options: Pick = {} ): Promise { const v2Claims = verifyUserSessionV2Token(token) if (v2Claims) { const validation = await validateUserSessionClaims(v2Claims, { - budget: options.budget, touch: false, onDatabaseFailure: externalSessionDatabaseFailure, }) @@ -185,7 +182,6 @@ export async function authenticateExternalUserSessionIdentity( const v1Claims = verifyExternalSessionToken(token) if (!v1Claims) return { status: 'invalid', reason: 'invalid_representation' } const validation = await validateLegacyUserSession(token, v1Claims, { - budget: options.budget, onDatabaseFailure: externalSessionDatabaseFailure, }) if (validation.status !== 'valid') return validation @@ -219,13 +215,12 @@ export async function authenticateExternalUserSessionIdentity( async function revalidateExternalUserSessionIdentity( token: string, identity: Extract, - options: Pick + options: Pick ): Promise { if (identity.contract === 'v2') { const validation = await validateUserSessionClaims( identity.tokenClaims as UserSessionV2Claims, { - budget: options.budget, onDatabaseFailure: externalSessionDatabaseFailure, } ) @@ -239,7 +234,6 @@ async function revalidateExternalUserSessionIdentity( const claims = identity.tokenClaims as AuthClaims const validation = await validateLegacyUserSession(token, claims, { - budget: options.budget, onDatabaseFailure: externalSessionDatabaseFailure, }) if (validation.status !== 'valid') return validation @@ -257,13 +251,11 @@ export async function authenticateExternalUserSession( const policy = options.policy ?? (await resolveEffectiveUserAccessPolicy({ - budget: options.budget, databaseFailureMode: 'throw', onDatabaseFailure: externalSessionDatabaseFailure, })) const identity = options.identity ? await revalidateExternalUserSessionIdentity(token, options.identity, { - budget: options.budget, now: options.now, }) : null @@ -279,7 +271,6 @@ export async function authenticateExternalUserSession( const authenticatedIdentity = identity ?? (await authenticateExternalUserSessionIdentity(token, { - budget: options.budget, now: options.now, })) if (authenticatedIdentity.status !== 'authenticated') return authenticatedIdentity @@ -300,8 +291,7 @@ export async function renewExternalUserSession( onDatabaseFailure?: (error: unknown) => unknown } = {} ): Promise { - const policy = - options.policy ?? (await resolveEffectiveUserAccessPolicy({ budget: options.budget })) + const policy = options.policy ?? (await resolveEffectiveUserAccessPolicy()) if (!clientMeetsMinimum(options.client, policy)) { return { status: 'upgrade_required', reason: 'minimum_client_version' } } @@ -311,7 +301,6 @@ export async function renewExternalUserSession( const claims = verifyUserSessionV2Token(token) if (!claims) return { status: 'invalid', reason: 'invalid_representation' } const result = await renewUserSession(claims, { - budget: options.budget, onDatabaseFailure: options.onDatabaseFailure, }) if (!('token' in result)) { diff --git a/control-api/src/services/auth/userSessionService.ts b/control-api/src/services/auth/userSessionService.ts index 953bd8a6d8..ea6a43fecc 100644 --- a/control-api/src/services/auth/userSessionService.ts +++ b/control-api/src/services/auth/userSessionService.ts @@ -8,11 +8,6 @@ import { USER_SESSION_V2_VERSION, signUserSessionV2Token, } from '../../utils/auth/userSessionV2Token.js' -import { - runAccessDatabaseQuery, - withAccessDatabaseTransaction, -} from '../access/accessDatabaseQuery.js' -import type { AccessExecutionBudget } from '../access/accessExecutionBudget.js' import type { ExternalSessionAuthorityContext } from './externalSessionAuthentication.js' import { legacySecurityCutoffRejects } from './legacySecurityCutoff.js' import { legacyExternalSessionAuthGeneration } from './legacyV1Generation.js' @@ -64,16 +59,6 @@ export type IssuedUserSession = { identity: UserSessionIdentity } -function budgetedSessionDatabase( - db: SessionDatabase, - budget: AccessExecutionBudget, - onDatabaseFailure?: (error: unknown) => unknown -): SessionDatabase { - const query = (text: string, values?: unknown[]) => - runAccessDatabaseQuery(db, budget, text, values ?? [], { onDatabaseFailure }) - return Object.assign(Object.create(db), { query }) as SessionDatabase -} - function sessionDatabaseWithFailureProvenance( db: SessionDatabase, onDatabaseFailure?: (error: unknown) => unknown @@ -304,7 +289,6 @@ export async function validateUserSessionClaims( claims: UserSessionV2Claims, options: { db?: SessionDatabase - budget?: AccessExecutionBudget touch?: boolean onDatabaseFailure?: (error: unknown) => unknown } = {} @@ -315,25 +299,15 @@ export async function validateUserSessionClaims( return validateLoadedSession(db, row, claims, now, options.touch !== false) } if (options.db) { - return work( - options.budget - ? budgetedSessionDatabase(options.db, options.budget, options.onDatabaseFailure) - : sessionDatabaseWithFailureProvenance(options.db, options.onDatabaseFailure) - ) + return work(sessionDatabaseWithFailureProvenance(options.db, options.onDatabaseFailure)) } - return options.budget - ? withAccessDatabaseTransaction(options.budget, work, { - mode: 'read_write', - onDatabaseFailure: options.onDatabaseFailure, - }) - : withTransaction(work, pool, { onDatabaseFailure: options.onDatabaseFailure }) + return withTransaction(work, pool, { onDatabaseFailure: options.onDatabaseFailure }) } export async function renewUserSession( claims: UserSessionV2Claims, options: { db?: SessionDatabase - budget?: AccessExecutionBudget onDatabaseFailure?: (error: unknown) => unknown } = {} ): Promise { @@ -411,18 +385,9 @@ export async function renewUserSession( } } if (options.db) { - return work( - options.budget - ? budgetedSessionDatabase(options.db, options.budget, options.onDatabaseFailure) - : sessionDatabaseWithFailureProvenance(options.db, options.onDatabaseFailure) - ) + return work(sessionDatabaseWithFailureProvenance(options.db, options.onDatabaseFailure)) } - return options.budget - ? withAccessDatabaseTransaction(options.budget, work, { - mode: 'read_write', - onDatabaseFailure: options.onDatabaseFailure, - }) - : withTransaction(work, pool, { onDatabaseFailure: options.onDatabaseFailure }) + return withTransaction(work, pool, { onDatabaseFailure: options.onDatabaseFailure }) } export async function revokeUserSession( @@ -671,7 +636,6 @@ export async function validateLegacyUserSession( claims: AuthClaims, options: { db?: SessionDatabase - budget?: AccessExecutionBudget lockUser?: boolean onDatabaseFailure?: (error: unknown) => unknown } = {} @@ -738,15 +702,7 @@ export async function validateLegacyUserSession( } } const db = options.db ?? pool - if (!options.budget) - return work(sessionDatabaseWithFailureProvenance(db, options.onDatabaseFailure)) - if (options.db) { - return work(budgetedSessionDatabase(db, options.budget, options.onDatabaseFailure)) - } - return withAccessDatabaseTransaction(options.budget, work, { - mode: 'read_only', - onDatabaseFailure: options.onDatabaseFailure, - }) + return work(sessionDatabaseWithFailureProvenance(db, options.onDatabaseFailure)) } export async function revokeLegacyUserSession( diff --git a/control-api/test/externalSessionTeamAuthorization.test.ts b/control-api/test/externalSessionTeamAuthorization.test.ts index ec533f8ec0..c6e7f1080d 100644 --- a/control-api/test/externalSessionTeamAuthorization.test.ts +++ b/control-api/test/externalSessionTeamAuthorization.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import express, { type NextFunction, type Request, type Response } from 'express' +import express from 'express' import { DatabaseError } from 'pg' import request from 'supertest' import { clerumErrorHandler } from '../src/http/errorHandler.js' @@ -8,7 +8,6 @@ import { requireExternalTeamParamMatch, requireValidExternalSessionToken, } from '../src/middleware/externalSessionAuth.js' -import { AccessExecutionBudget } from '../src/services/access/accessExecutionBudget.js' const session = vi.hoisted(() => ({ authenticateExternalUserSession: vi.fn() })) const db = vi.hoisted(() => ({ query: vi.fn() })) @@ -23,15 +22,8 @@ function databaseError(sqlstate: string): DatabaseError { return error } -function app(budget?: AccessExecutionBudget) { +function app() { const value = express() - if (budget) { - value.use((req: Request, _res: Response, next: NextFunction) => { - ;(req as Request & { accessExecutionBudget?: AccessExecutionBudget }).accessExecutionBudget = - budget - next() - }) - } value.get( '/teams/:teamId', requireValidExternalSessionToken, @@ -132,20 +124,4 @@ describe('external team authorization', () => { 'team-1', ]) }) - - it('keeps an exhausted access budget on the sanitized 500 path', async () => { - const budget = AccessExecutionBudget.create('action', { limits: { producerCalls: 1 } }) - budget.charge({ kind: 'producerCalls' }) - - const response = await request(app(budget)) - .get('/teams/team-1') - .set('x-user-session-token', 'stale-admin-token') - budget.close() - - expect(response.status).toBe(500) - expect(response.body.error).toBe('Internal Server Error') - expect(response.headers['retry-after']).toBeUndefined() - // A budget refusal is not a database outage: the query never ran. - expect(db.query).not.toHaveBeenCalled() - }) }) diff --git a/control-api/test/liveTeamAuthorizationBudget.test.ts b/control-api/test/liveTeamAuthorizationBudget.test.ts deleted file mode 100644 index b33eaecdbc..0000000000 --- a/control-api/test/liveTeamAuthorizationBudget.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { AccessBudgetExceededError } from '../src/services/access/accessExecutionBudget.js' -import { AccessExecutionBudget } from '../src/services/access/accessExecutionBudget.js' -import { getLiveTeamMembership } from '../src/services/access/liveTeamAuthorization.js' - -describe('live team authorization request budget', () => { - it('charges the request database-statement budget for each live membership lookup', async () => { - const query = vi.fn().mockResolvedValue({ - rows: [{ team_id: 'team-1', role: 'member' }], - rowCount: 1, - }) - const budget = AccessExecutionBudget.create('action', { - limits: { databaseStatements: 1 }, - }) - - try { - await expect( - getLiveTeamMembership('user-1', 'team-1', { db: { query }, budget }) - ).resolves.toEqual({ teamId: 'team-1', role: 'member' }) - expect(budget.remaining('databaseStatements')).toBe(0) - - await expect( - getLiveTeamMembership('user-1', 'team-1', { db: { query }, budget }) - ).rejects.toEqual( - expect.objectContaining>({ - name: 'AccessBudgetExceededError', - limit: 'databaseStatements', - }) - ) - expect(query).toHaveBeenCalledTimes(1) - } finally { - budget.close() - } - }) -}) diff --git a/control-api/test/middleware.externalSessionAuth.lifecycle.test.ts b/control-api/test/middleware.externalSessionAuth.lifecycle.test.ts index 2f774143f4..807c1aa38b 100644 --- a/control-api/test/middleware.externalSessionAuth.lifecycle.test.ts +++ b/control-api/test/middleware.externalSessionAuth.lifecycle.test.ts @@ -3,7 +3,6 @@ import express from 'express' import { rateLimit } from 'express-rate-limit' import { DatabaseError } from 'pg' import request from 'supertest' -import { AccessExecutionBudget } from '../src/services/access/accessExecutionBudget.js' const poolQuery = vi.hoisted(() => vi.fn()) const poolConnect = vi.hoisted(() => vi.fn()) @@ -43,23 +42,22 @@ function databaseError(sqlstate: string): DatabaseError { return error } -function app(budget?: AccessExecutionBudget) { +function app() { const server = express() server.get( '/protected', rateLimit({ windowMs: 60_000, limit: 100, standardHeaders: 'draft-7', legacyHeaders: false }), async (req, res, next) => { - if (budget) { - const budgetedRequest = req as express.Request & { - accessExecutionBudget?: AccessExecutionBudget - } - budgetedRequest.accessExecutionBudget = budget - } const { requireValidExternalSessionToken } = await import('../src/middleware/externalSessionAuth.js') await requireValidExternalSessionToken(req as never, res, next) }, - (_req, res) => res.status(200).json({ ok: true }) + (req, res) => + res.status(200).json({ + ok: true, + userId: (req as express.Request & { externalAuth?: { userId: string } }).externalAuth + ?.userId, + }) ) server.use( (_err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { @@ -136,24 +134,40 @@ describe('external session lifecycle gate', () => { it('answers 503 for a no-SQLSTATE database connection-acquire failure', async () => { verifyToken.mockReturnValueOnce(claims) const acquireFailure = new Error('private pg-pool acquire timeout sentinel') - poolConnect.mockRejectedValueOnce(acquireFailure) - const budget = AccessExecutionBudget.create('action') + poolQuery.mockRejectedValueOnce(acquireFailure) - const response = await request(app(budget)) + const response = await request(app()) .get('/protected') - .set('x-user-session-token', 'session-with-budget') - budget.close() + .set('x-user-session-token', 'session-without-budget') expect(response.status).toBe(503) expect(response.body).toEqual({ error: 'session_backend_unavailable', retryAfterSeconds: 2 }) expect(response.headers['retry-after']).toBe('2') expect(JSON.stringify(response.body)).not.toContain(acquireFailure.message) expect(JSON.stringify(response.headers)).not.toContain(acquireFailure.message) - expect(poolConnect).toHaveBeenCalledTimes(1) - expect(verifyToken).toHaveBeenCalledWith('session-with-budget') + expect(poolConnect).not.toHaveBeenCalled() + expect(poolQuery).toHaveBeenCalledOnce() + expect(verifyToken).toHaveBeenCalledWith('session-without-budget') expect(loggerWarn).toHaveBeenCalledTimes(1) }) + it('validates a V1 session without opening a transaction when no budget is attached', async () => { + verifyToken.mockReturnValueOnce(claims) + + const response = await request(app()) + .get('/protected') + .set('x-user-session-token', 'session-without-budget') + + expect(response.status).toBe(200) + // Witness: next() ran and the middleware fixed req.externalAuth. + expect(response.body.userId).toBe(claims.userId) + expect(poolConnect).not.toHaveBeenCalled() + expect(poolQuery).toHaveBeenCalledTimes(2) + const statements = poolQuery.mock.calls.map(call => String(call[0])).join('\n') + expect(statements).not.toContain('statement_timeout') + expect(statements).not.toContain('READ ONLY') + }) + it('keeps a successful-query session clock invariant failure on the ordinary 500 path', async () => { verifyToken.mockReturnValueOnce(claims) poolQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }) diff --git a/control-api/test/routes.externalAccess.test.ts b/control-api/test/routes.externalAccess.test.ts index d5b27c7630..90287b3bb6 100644 --- a/control-api/test/routes.externalAccess.test.ts +++ b/control-api/test/routes.externalAccess.test.ts @@ -146,7 +146,7 @@ describe('external user-access contracts', () => { expect(mocks.validateV1).toHaveBeenCalledWith( 'v1-session', expect.any(Object), - expect.objectContaining({ budget: expect.any(Object) }) + expect.objectContaining({ onDatabaseFailure: expect.any(Function) }) ) }) @@ -230,11 +230,7 @@ describe('external user-access contracts', () => { auth_time: 1_900_000_000, amr: ['pwd'], }) - mocks.validateV2.mockImplementation(async (_claims, options) => { - options.budget.charge({ - kind: 'producerCalls', - amount: options.touch === false ? 2 : 3, - }) + mocks.validateV2.mockImplementation(async () => { return { status: 'valid', identity: { @@ -279,7 +275,7 @@ describe('external user-access contracts', () => { expect(mocks.validateV2).toHaveBeenCalledTimes(2) const catalogBudget = mocks.buildCatalog.mock.calls[0]?.[1].budget expect(catalogBudget?.limits.producerCalls).toBe(42) - expect(catalogBudget?.remaining('producerCalls')).toBe(8) + expect(catalogBudget?.remaining('producerCalls')).toBe(13) }) it('preserves the eleven-family catalog control and rejects revoked authority before catalog work', async () => { @@ -297,11 +293,7 @@ describe('external user-access contracts', () => { auth_time: 1_900_000_000, amr: ['pwd'], }) - mocks.validateV2.mockImplementation(async (_claims, options) => { - options.budget.charge({ - kind: 'producerCalls', - amount: options.touch === false ? 2 : 3, - }) + mocks.validateV2.mockImplementation(async () => { return { status: 'valid', identity: { diff --git a/control-api/test/routes.externalDirectory.test.ts b/control-api/test/routes.externalDirectory.test.ts index 2edf40af49..96dfe50c91 100644 --- a/control-api/test/routes.externalDirectory.test.ts +++ b/control-api/test/routes.externalDirectory.test.ts @@ -75,7 +75,7 @@ describe('external directory authorization', () => { expect(mocks.membership).toHaveBeenCalledWith( 'user-1', 'team-1', - expect.objectContaining({ budget: expect.anything() }) + expect.objectContaining({ onDatabaseFailure: expect.any(Function) }) ) expect(mocks.searchDirectory).toHaveBeenCalledWith('team-1', 'person', undefined) }) @@ -92,7 +92,7 @@ describe('external directory authorization', () => { expect(mocks.membership).toHaveBeenCalledWith( 'user-1', 'team-1', - expect.objectContaining({ budget: expect.anything() }) + expect.objectContaining({ onDatabaseFailure: expect.any(Function) }) ) expect(mocks.searchDirectory).not.toHaveBeenCalled() }) @@ -143,7 +143,7 @@ describe('external directory authorization', () => { expect(mocks.membership).toHaveBeenCalledWith( 'user-1', 'team-1', - expect.objectContaining({ budget: expect.anything() }) + expect.objectContaining({ onDatabaseFailure: expect.any(Function) }) ) expect(mocks.searchDirectory).toHaveBeenCalledWith('team-1', 'person', undefined) // dev has no per-user directory-search bucket on this route. diff --git a/control-api/test/routes.externalMemberVisibility.test.ts b/control-api/test/routes.externalMemberVisibility.test.ts index 0285f1f247..1fcf95712f 100644 --- a/control-api/test/routes.externalMemberVisibility.test.ts +++ b/control-api/test/routes.externalMemberVisibility.test.ts @@ -67,7 +67,7 @@ function expectLiveMembership(userId: string, teamId: string) { expect(authority.getLiveTeamMembership).toHaveBeenCalledWith( userId, teamId, - expect.objectContaining({ budget: expect.anything() }) + expect.objectContaining({ onDatabaseFailure: expect.any(Function) }) ) } diff --git a/control-api/test/routes.externalSharedFilesystems.test.ts b/control-api/test/routes.externalSharedFilesystems.test.ts index 2f19d013e4..7db8f87458 100644 --- a/control-api/test/routes.externalSharedFilesystems.test.ts +++ b/control-api/test/routes.externalSharedFilesystems.test.ts @@ -190,11 +190,7 @@ describe('GET /external/contexts/:contextId/shared-filesystems', () => { .set('x-user-session-token', 'dummy') expect(res.status).toBe(200) expect(res.body.items).toEqual([]) - expect(mockGetLiveTeamMembership).toHaveBeenCalledWith( - 'user-1', - 'team-1', - expect.objectContaining({ budget: expect.anything() }) - ) + expect(mockGetLiveTeamMembership).toHaveBeenCalledWith('user-1', 'team-1') }) it('does not trust a stale v1 team claim after membership revocation', async () => { diff --git a/control-api/test/routes.profile.test.ts b/control-api/test/routes.profile.test.ts index 55f582dd4b..c9363c1e9c 100644 --- a/control-api/test/routes.profile.test.ts +++ b/control-api/test/routes.profile.test.ts @@ -510,7 +510,7 @@ describe('routes/profile', () => { expect(liveTeamAuthorizationMock.getLiveTeamMembership).toHaveBeenCalledWith( 'u1', 't1', - expect.objectContaining({ budget: expect.anything() }) + expect.objectContaining({ onDatabaseFailure: expect.any(Function) }) ) }) From eb1f52f715a187c2b3163037c562d53be1a72c05 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 4 Nov 2026 12:40:23 +0100 Subject: [PATCH 452/528] refactor(access): remove the /external/access and /me/access routes M-37a (K9 of the PR #334 r4.2 fix plan). The aggregate access routes leave PR1: delete control-api/src/routes/external/access.ts and external-rest-api/src/routes/access.ts, unmount them, and delete the five test files that only exercised those routes. The mount files now match dev for these routers. The resolver group itself is removed in K11. Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- control-api/src/routes/external/access.ts | 365 --------------- control-api/src/routes/external/index.ts | 2 - ...s.externalAccess.rateLimitOrdering.test.ts | 315 ------------- .../test/routes.externalAccess.test.ts | 424 ------------------ external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- external-rest-api/src/app.ts | 2 - external-rest-api/src/releaseManifest.ts | 2 +- external-rest-api/src/routes/access.ts | 88 ---- external-rest-api/test/routes.access.test.ts | 187 -------- .../test/routes.accessQueryValidation.test.ts | 63 --- .../test/routes.accessResolveExposure.test.ts | 104 ----- 14 files changed, 7 insertions(+), 1557 deletions(-) delete mode 100644 control-api/src/routes/external/access.ts delete mode 100644 control-api/test/routes.externalAccess.rateLimitOrdering.test.ts delete mode 100644 control-api/test/routes.externalAccess.test.ts delete mode 100644 external-rest-api/src/routes/access.ts delete mode 100644 external-rest-api/test/routes.access.test.ts delete mode 100644 external-rest-api/test/routes.accessQueryValidation.test.ts delete mode 100644 external-rest-api/test/routes.accessResolveExposure.test.ts diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 77673e8ace..0c9142f4ad 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.892", + "version": "0.1.893", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.892", + "version": "0.1.893", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index af61114e87..5af88cd35d 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.892", + "version": "0.1.893", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/routes/external/access.ts b/control-api/src/routes/external/access.ts deleted file mode 100644 index 03d6fdebe7..0000000000 --- a/control-api/src/routes/external/access.ts +++ /dev/null @@ -1,365 +0,0 @@ -import { Router } from 'express' -import { config } from '../../config.js' -import { asyncHandler } from '../../http/asyncHandler.js' -import { sendPublicApiError } from '../../http/publicApiError.js' -import type { K8sGateway } from '../../k8s.js' -import { attachAccessExecutionBudget } from '../../middleware/accessExecutionBudget.js' -import { createExternalClientRateLimiters } from '../../middleware/externalClientIdentity.js' -import { - type ExternalAuthedRequest, - requireCompletedExternalSessionAuthenticationWithPublicErrors, - requireExternalSessionLimiterIdentityWithPublicErrors, - requireValidExternalSessionTokenWithPublicErrors, -} from '../../middleware/externalSessionAuth.js' -import { requireAuthenticatedExternalUserRateLimitContext } from '../../middleware/externalUserRateLimitPolicy.js' -import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' -import { - AccessCatalogRequestError, - buildAccessCatalog, -} from '../../services/access/accessCatalogCoordinator.js' -import { AccessCatalogCursorError } from '../../services/access/accessCatalogCursor.js' -import { - AccessBudgetExceededError, - AccessExecutionCancelledError, -} from '../../services/access/accessExecutionBudget.js' -import { isAccessCapability } from '../../services/access/capabilityRegistry.js' -import { CATALOG_FAMILIES, type CatalogFamily } from '../../services/access/catalogContracts.js' -import { resolveLiveAuthorization } from '../../services/access/liveAuthorizationResolver.js' -import { validateOperationTarget } from '../../services/access/operationTarget.js' -import { canonicalEnvironmentId } from '../../services/access/operationalAccessProjection.js' -import { canonicalResourceIdentity } from '../../services/access/resourceIdentity.js' -import { userAccessCapabilityManifest } from '../../services/access/userAccessPolicy.js' -import { resolveEffectiveUserAccessPolicy } from '../../services/access/userAccessRuntimePolicy.js' - -const CATALOG_RATE_LIMIT_PER_MINUTE = 10 -const ACCESS_PATH_PATTERN = /^ap1_[A-Za-z0-9_-]{43}$/ - -function catalogFamilies(value: unknown): CatalogFamily[] | null | undefined { - if (value === undefined) return undefined - if (typeof value !== 'string') return null - const values = [ - ...new Set( - value - .split(',') - .map(item => item.trim()) - .filter(Boolean) - ), - ] - if ( - values.length === 0 || - values.some(value => !CATALOG_FAMILIES.includes(value as CatalogFamily)) - ) { - return null - } - return CATALOG_FAMILIES.filter(family => values.includes(family)) -} - -function pageLimit(value: unknown): number | null | undefined { - if (value === undefined) return undefined - if (typeof value !== 'string' || !/^\d+$/.test(value)) return null - const parsed = Number(value) - return Number.isSafeInteger(parsed) && parsed >= 1 && parsed <= 100 ? parsed : null -} - -async function requireEffectiveV2Contract( - feature: 'catalog' | 'action', - req: ExternalAuthedRequest, - res: Parameters[1], - next: () => void -): Promise { - try { - const policy = await resolveEffectiveUserAccessPolicy({ - budget: req.accessExecutionBudget, - catalogReadiness: feature === 'catalog', - }) - const enabled = feature === 'catalog' ? policy.serveCatalog : policy.actionContextV2 - if (req.externalAuth?.sessionContract === 'v2' && enabled) { - next() - return - } - sendPublicApiError( - req, - res, - 409, - 'invalid_request', - 'This access contract is not enabled for the current session.' - ) - } catch { - sendPublicApiError( - req, - res, - 503, - 'authority_unavailable', - 'Access capability state is temporarily unavailable.', - true - ) - } -} - -function sendCatalogError( - req: ExternalAuthedRequest, - res: Parameters[1], - error: unknown -): void { - if (error instanceof AccessCatalogCursorError) { - sendPublicApiError( - req, - res, - error.code === 'invalid_cursor' ? 400 : 409, - error.code === 'invalid_cursor' ? 'invalid_request' : 'conflict', - error.code === 'invalid_cursor' - ? 'The catalog cursor is not valid.' - : 'Catalog access changed; refresh from the first page.' - ) - return - } - if (error instanceof AccessCatalogRequestError && error.code === 'session_not_live') { - sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') - return - } - if (error instanceof AccessCatalogRequestError && error.code === 'invalid_request') { - sendPublicApiError(req, res, 400, 'invalid_request', 'The catalog request is not valid.') - return - } - if ( - error instanceof AccessCatalogRequestError || - error instanceof AccessBudgetExceededError || - error instanceof AccessExecutionCancelledError - ) { - sendPublicApiError( - req, - res, - 503, - 'authority_unavailable', - 'Authorization is temporarily unavailable.', - true - ) - return - } - throw error -} - -export function createExternalAccessRouter(gateway: K8sGateway): Router { - const router = Router() - const externalAccessRateLimits = createExternalClientRateLimiters( - 'users', - config.approvalRlExternalClientIpPerMin, - config.approvalRlExternalEdgePerMin - ) - router.use('/external/access', ...externalAccessRateLimits, attachAccessExecutionBudget) - - router.get( - '/external/access/capabilities', - requireValidExternalSessionTokenWithPublicErrors, - asyncHandler(async (req: ExternalAuthedRequest, res) => { - try { - const policy = await resolveEffectiveUserAccessPolicy({ - budget: req.accessExecutionBudget, - catalogReadiness: true, - }) - res.status(200).json({ - contractVersion: '2', - currentSessionContract: req.externalAuth!.sessionContract ?? 'v1', - ...userAccessCapabilityManifest(policy), - }) - } catch { - sendPublicApiError( - req, - res, - 503, - 'authority_unavailable', - 'Access capability state is temporarily unavailable.', - true - ) - } - }) - ) - - router.get( - '/external/access/catalog', - requireExternalSessionLimiterIdentityWithPublicErrors, - requireAuthenticatedExternalUserRateLimitContext, - rateLimitMiddleware({ - bucketType: 'external_access_catalog', - maxPerMinute: CATALOG_RATE_LIMIT_PER_MINUTE, - onBackendUnavailable: 'process-memory', - getBucketKey: req => - `external_access_catalog:${(req as ExternalAuthedRequest).externalAuth!.userId}`, - onLimited: (req, res, retryAfterSeconds) => - sendPublicApiError( - req, - res, - 429, - 'rate_limited', - 'Too many access catalog requests; retry later.', - true, - { retryAfterSeconds } - ), - }), - requireCompletedExternalSessionAuthenticationWithPublicErrors, - asyncHandler((req, res, next) => requireEffectiveV2Contract('catalog', req, res, next)), - asyncHandler(async (req: ExternalAuthedRequest, res) => { - const families = catalogFamilies(req.query.families) - const limit = pageLimit(req.query.limit) - const cursor = typeof req.query.cursor === 'string' ? req.query.cursor : undefined - if (families === null || limit === null || (req.query.cursor !== undefined && !cursor)) { - sendPublicApiError(req, res, 400, 'invalid_request', 'The catalog query is not valid.') - return - } - try { - const catalog = await buildAccessCatalog( - { - session: req.externalSessionAuthority!, - ...(families ? { families } : {}), - ...(limit ? { limit } : {}), - ...(cursor ? { cursor } : {}), - }, - { budget: req.accessExecutionBudget! } - ) - res.status(200).json(catalog) - } catch (error) { - sendCatalogError(req, res, error) - } - }) - ) - - router.post( - '/external/access/resolve', - requireValidExternalSessionTokenWithPublicErrors, - asyncHandler((req, res, next) => requireEffectiveV2Contract('action', req, res, next)), - asyncHandler(async (req: ExternalAuthedRequest, res) => { - const body = req.body as Record | undefined - const rawResource = - body?.resource && typeof body.resource === 'object' && !Array.isArray(body.resource) - ? (body.resource as Record) - : null - const capability = typeof body?.requiredCapability === 'string' ? body.requiredCapability : '' - const accessPathId = typeof body?.accessPathId === 'string' ? body.accessPathId : undefined - let resource - try { - resource = canonicalResourceIdentity({ - environmentId: rawResource?.environmentId, - type: rawResource?.type, - logicalId: rawResource?.logicalId, - displayName: rawResource?.logicalId, - }) - } catch { - resource = null - } - if ( - !resource || - resource.environmentId !== canonicalEnvironmentId() || - !isAccessCapability(capability) || - (accessPathId !== undefined && !ACCESS_PATH_PATTERN.test(accessPathId)) - ) { - sendPublicApiError( - req, - res, - 400, - 'invalid_request', - 'The authorization request is invalid.' - ) - return - } - let target - try { - target = validateOperationTarget({ - capability, - resource, - operationTarget: body?.operationTarget, - }) - } catch { - sendPublicApiError( - req, - res, - 400, - 'invalid_request', - 'The authorization request is invalid.' - ) - return - } - const result = await resolveLiveAuthorization( - { - session: req.externalSessionAuthority!, - requiredCapability: capability, - resource, - ...(accessPathId ? { requestedAccessPathId: accessPathId } : {}), - ...(target ? { operationTarget: target } : {}), - }, - { - gateway, - correlationId: req.correlationId, - budget: req.accessExecutionBudget!, - } - ) - if (result.status === 'allowed') { - res.status(200).json({ - status: result.status, - effectiveCapabilities: result.effectiveCapabilities, - paths: result.paths.map(path => ({ - accessPathId: path.id, - kind: path.kind, - ...(path.teamId ? { teamId: path.teamId } : {}), - })), - selectedAccessPathId: result.selectedPath.id, - authorizationRevision: result.authorizationRevision, - }) - return - } - if (result.status === 'access_path_required') { - sendPublicApiError( - req, - res, - 409, - 'access_path_required', - 'Choose an access path for this operation.', - false, - { paths: result.safePathDescriptors } - ) - return - } - if (result.status === 'access_path_stale') { - sendPublicApiError( - req, - res, - 409, - 'access_path_stale', - 'The access path is stale; refresh access before retrying.' - ) - return - } - if (result.status === 'unavailable') { - sendPublicApiError( - req, - res, - 503, - 'authority_unavailable', - 'Authorization is temporarily unavailable.', - true - ) - return - } - if (result.status === 'not_found') { - sendPublicApiError(req, res, 404, 'not_found', 'The resource was not found.') - return - } - sendPublicApiError( - req, - res, - result.status === 'invalid' ? 400 : result.code === 'session_not_live' ? 401 : 403, - result.status === 'invalid' - ? 'invalid_request' - : result.code === 'session_not_live' - ? 'invalid_session' - : 'forbidden', - result.status === 'invalid' - ? 'The authorization request is invalid.' - : result.code === 'session_not_live' - ? 'The session is not valid.' - : 'The requested operation is not allowed.' - ) - }) - ) - - return router -} diff --git a/control-api/src/routes/external/index.ts b/control-api/src/routes/external/index.ts index c25b021258..76953f79cc 100644 --- a/control-api/src/routes/external/index.ts +++ b/control-api/src/routes/external/index.ts @@ -1,6 +1,5 @@ import { Router } from 'express' import type { K8sGateway } from '../../k8s.js' -import { createExternalAccessRouter } from './access.js' import { createExternalAuthRouter } from './auth.js' import { createExternalUserApprovalDecisionsRouter } from './decisions.routes.js' import { createExternalDirectoryRouter } from './directory.js' @@ -18,7 +17,6 @@ import { createExternalWorkflowsRouter } from './workflows/index.js' export function createExternalRouter(gateway: K8sGateway): Router { const router = Router() - router.use(createExternalAccessRouter(gateway)) router.use(createExternalAuthRouter(gateway)) router.use(createExternalUsersRouter(gateway)) router.use(createExternalTeamsRouter(gateway)) diff --git a/control-api/test/routes.externalAccess.rateLimitOrdering.test.ts b/control-api/test/routes.externalAccess.rateLimitOrdering.test.ts deleted file mode 100644 index a507c17a7d..0000000000 --- a/control-api/test/routes.externalAccess.rateLimitOrdering.test.ts +++ /dev/null @@ -1,315 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import express from 'express' -import request from 'supertest' -import { config } from '../src/config.js' -import { createExternalAccessRouter } from '../src/routes/external/access.js' -import { canonicalEnvironmentId } from '../src/services/access/operationalAccessProjection.js' -import { signUserSessionV2Token } from '../src/utils/auth/userSessionV2Token.js' - -const mocks = vi.hoisted(() => ({ - validateV1: vi.fn(), - validateV2: vi.fn(), - resolvePolicy: vi.fn(), - resolveAuthorization: vi.fn(), -})) - -const rateLimiter = vi.hoisted(() => ({ checkAndIncrement: vi.fn() })) - -vi.mock('../src/services/auth/userSessionService.js', async importOriginal => { - const actual = await importOriginal() - return { - ...actual, - validateLegacyUserSession: mocks.validateV1, - validateUserSessionClaims: mocks.validateV2, - } -}) -vi.mock('../src/services/access/userAccessRuntimePolicy.js', () => ({ - resolveEffectiveUserAccessPolicy: mocks.resolvePolicy, -})) -vi.mock('../src/services/access/liveAuthorizationResolver.js', () => ({ - resolveLiveAuthorization: mocks.resolveAuthorization, -})) -vi.mock('../src/services/rateLimiterService.js', () => rateLimiter) - -const userId = '10000000-0000-4000-8000-000000000001' -const sessionId = '20000000-0000-4000-8000-000000000002' -const sessionJti = '30000000-0000-4000-8000-000000000003' - -function sessionToken() { - return signUserSessionV2Token({ - sub: userId, - sid: sessionId, - jti: sessionJti, - sv: 1, - email: 'user@example.test', - auth_time: Math.floor(Date.now() / 1000), - amr: ['pwd'], - }) -} - -function effectivePolicy(overrides: Record = {}) { - return { - policyVersion: '1', - policyRevision: 'test-policy', - acceptV1: true, - issueV1: true, - acceptV2: true, - issueV2: false, - renewV2: false, - switchCompatibility: true, - computeCatalogShadow: false, - serveCatalog: true, - actionContextV2: true, - rpcDelegationV2: false, - desktopAllTeamMode: false, - profileV2Mode: false, - minimumClientVersion: null, - enforceMinimumClient: false, - advertisedCatalogFamilies: [], - ...overrides, - } -} - -function app() { - const value = express() - value.use(express.json()) - value.use(createExternalAccessRouter({} as never)) - return value -} - -describe('external access authenticated rate-limit ordering', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.validateV2.mockResolvedValue({ - status: 'valid', - identity: { - userId, - email: 'user@example.test', - sid: sessionId, - jti: sessionJti, - sessionVersion: 1, - }, - }) - mocks.resolvePolicy.mockResolvedValue(effectivePolicy()) - rateLimiter.checkAndIncrement.mockResolvedValue({ - allowed: false, - remaining: 0, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - backendAvailable: true, - count: 11, - }) - }) - - it('limits catalog before runtime policy work using the retained user bucket', async () => { - const response = await request(app()) - .get('/external/access/catalog') - .set('x-user-session-token', sessionToken()) - expect(response.status).toBe(429) - expect(response.body).toMatchObject({ error: { code: 'rate_limited' } }) - expect(response.headers['retry-after']).toBeDefined() - expect(rateLimiter.checkAndIncrement).toHaveBeenCalledWith( - `external_access_catalog:${userId}`, - 10 - ) - expect(mocks.resolvePolicy).not.toHaveBeenCalled() - expect(mocks.resolveAuthorization).not.toHaveBeenCalled() - }) - - it('shares the users edge family across capabilities and resolve without per-user PG charges', async () => { - const server = app() - const session = sessionToken() - const limit = config.approvalRlExternalEdgePerMin - expect(limit).toBe(120) - for (let index = 0; index < limit; index += 1) { - const response = - index % 2 === 0 - ? await request(server) - .get('/external/access/capabilities') - .set('x-user-session-token', session) - : await request(server) - .post('/external/access/resolve') - .set('x-user-session-token', session) - .send({}) - expect(response.status).toBe(index % 2 === 0 ? 200 : 400) - } - await request(server) - .get('/external/access/capabilities') - .set('x-user-session-token', session) - .expect(429) - expect(rateLimiter.checkAndIncrement).not.toHaveBeenCalled() - expect(mocks.resolvePolicy).toHaveBeenCalled() - }) - - it('rejects an invalid GET /external/access/catalog session before limiting or policy work', async () => { - const response = await request(app()) - .get('/external/access/catalog') - .set('x-user-session-token', 'invalid-session') - - expect(response.status).toBe(401) - expect(response.body).toMatchObject({ error: { code: 'invalid_session' } }) - expect(rateLimiter.checkAndIncrement).not.toHaveBeenCalled() - expect(mocks.resolvePolicy).not.toHaveBeenCalled() - }) - - // Capabilities and resolve use the single session boundary shared by every - // other protected route: policy and the minimum-client gate are read once, - // before identity, and no PG limiter is charged. - it.each([ - ['GET', '/external/access/capabilities'], - ['POST', '/external/access/resolve'], - ] as const)( - 'rejects an invalid %s %s session through the single session boundary', - async (method, path) => { - const response = await request(app()) - [method === 'GET' ? 'get' : 'post'](path) - .set('x-user-session-token', 'invalid-session') - .send(method === 'POST' ? {} : undefined) - - expect(response.status).toBe(401) - expect(response.body).toMatchObject({ error: { code: 'invalid_session' } }) - expect(rateLimiter.checkAndIncrement).not.toHaveBeenCalled() - expect(mocks.resolvePolicy).toHaveBeenCalledOnce() - } - ) - - it.each([ - ['GET', '/external/access/catalog'], - ['POST', '/external/access/resolve'], - ] as const)('preserves 409 when %s %s reaches final policy validation', async (method, path) => { - rateLimiter.checkAndIncrement.mockResolvedValue({ - allowed: true, - remaining: 9, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - count: 1, - backendAvailable: true, - }) - mocks.resolvePolicy.mockResolvedValue( - effectivePolicy({ serveCatalog: false, actionContextV2: false }) - ) - - const response = await request(app()) - [method === 'GET' ? 'get' : 'post'](path) - .set('x-user-session-token', sessionToken()) - .send(method === 'POST' ? {} : undefined) - - expect(response.status).toBe(409) - expect(response.body).toMatchObject({ error: { code: 'invalid_request' } }) - expect(mocks.resolvePolicy).toHaveBeenCalledTimes(2) - }) - - it.each([ - ['GET', '/external/access/catalog'], - ['POST', '/external/access/resolve'], - ] as const)('preserves 503 when %s %s reaches final policy validation', async (method, path) => { - rateLimiter.checkAndIncrement.mockResolvedValue({ - allowed: true, - remaining: 9, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - backendAvailable: true, - count: 1, - }) - mocks.resolvePolicy - .mockResolvedValueOnce(effectivePolicy()) - .mockRejectedValueOnce(new Error('readiness unavailable')) - - const response = await request(app()) - [method === 'GET' ? 'get' : 'post'](path) - .set('x-user-session-token', sessionToken()) - .send(method === 'POST' ? {} : undefined) - - expect(response.status).toBe(503) - expect(response.body).toMatchObject({ error: { code: 'authority_unavailable' } }) - expect(mocks.resolvePolicy).toHaveBeenCalledTimes(2) - }) - - it('continues an allowed resolve request after final policy validation', async () => { - rateLimiter.checkAndIncrement.mockResolvedValue({ - allowed: true, - remaining: 9, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - backendAvailable: true, - count: 1, - }) - const teamId = '40000000-0000-4000-8000-000000000004' - const accessPathId = `ap1_${'A'.repeat(43)}` - mocks.resolveAuthorization.mockResolvedValue({ - status: 'allowed', - effectiveCapabilities: ['team.manage'], - paths: [{ id: accessPathId, kind: 'team', teamId }], - selectedPath: { id: accessPathId, kind: 'team', teamId }, - authorizationRevision: 'revision-1', - }) - - const response = await request(app()) - .post('/external/access/resolve') - .set('x-user-session-token', sessionToken()) - .send({ - requiredCapability: 'team.manage', - resource: { - environmentId: canonicalEnvironmentId(), - type: 'team', - logicalId: teamId, - }, - operationTarget: { teamId, action: 'rename' }, - }) - - expect(response.status).toBe(200) - expect(mocks.resolvePolicy).toHaveBeenCalledTimes(2) - expect(mocks.resolveAuthorization).toHaveBeenCalledOnce() - }) - - it('rejects a session revoked after one validation for POST /external/access/resolve', async () => { - mocks.validateV2.mockResolvedValueOnce({ status: 'revoked', reason: 'logout' }) - - const response = await request(app()) - .post('/external/access/resolve') - .set('x-user-session-token', sessionToken()) - .send({}) - - expect(response.status).toBe(401) - expect(response.body).toMatchObject({ error: { code: 'invalid_session' } }) - // One live session validation per request (P-7). - expect(mocks.validateV2).toHaveBeenCalledOnce() - expect(mocks.resolveAuthorization).not.toHaveBeenCalled() - }) - - it.each([['GET', '/external/access/catalog']] as const)( - 'rechecks current session authority for %s %s', - async (method, path) => { - rateLimiter.checkAndIncrement.mockResolvedValue({ - allowed: true, - remaining: 9, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - backendAvailable: true, - count: 1, - }) - mocks.validateV2 - .mockResolvedValueOnce({ - status: 'valid', - identity: { - userId, - email: 'user@example.test', - sid: sessionId, - jti: sessionJti, - sessionVersion: 1, - }, - }) - .mockResolvedValueOnce({ status: 'revoked', reason: 'logout' }) - - const response = await request(app()) - [method === 'GET' ? 'get' : 'post'](path) - .set('x-user-session-token', sessionToken()) - .send(method === 'POST' ? {} : undefined) - - expect(response.status).toBe(401) - expect(response.body).toMatchObject({ error: { code: 'invalid_session' } }) - expect(mocks.validateV2).toHaveBeenCalledTimes(2) - expect(mocks.resolveAuthorization).not.toHaveBeenCalled() - } - ) -}) diff --git a/control-api/test/routes.externalAccess.test.ts b/control-api/test/routes.externalAccess.test.ts deleted file mode 100644 index 90287b3bb6..0000000000 --- a/control-api/test/routes.externalAccess.test.ts +++ /dev/null @@ -1,424 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import express from 'express' -import { EventEmitter } from 'node:events' -import request from 'supertest' -import { attachAccessExecutionBudget } from '../src/middleware/accessExecutionBudget.js' -import { createExternalAccessRouter } from '../src/routes/external/access.js' -import { canonicalEnvironmentId } from '../src/services/access/operationalAccessProjection.js' - -const mocks = vi.hoisted(() => ({ - verifyV1: vi.fn(), - verifyV2: vi.fn(), - validateV1: vi.fn(), - validateV2: vi.fn(), - resolvePolicy: vi.fn(), - resolveAuthorization: vi.fn(), - buildCatalog: vi.fn(), -})) - -const rateLimiter = vi.hoisted(() => ({ checkAndIncrement: vi.fn() })) - -vi.mock('../src/utils/auth/externalSessionAuthToken.js', () => ({ - verifyExternalSessionToken: mocks.verifyV1, -})) -vi.mock('../src/utils/auth/userSessionV2Token.js', () => ({ - verifyUserSessionV2Token: mocks.verifyV2, -})) -vi.mock('../src/services/auth/userSessionService.js', async importOriginal => { - const actual = await importOriginal() - return { - ...actual, - validateLegacyUserSession: mocks.validateV1, - validateUserSessionClaims: mocks.validateV2, - } -}) -vi.mock('../src/services/access/userAccessRuntimePolicy.js', () => ({ - resolveEffectiveUserAccessPolicy: mocks.resolvePolicy, -})) -vi.mock('../src/services/access/liveAuthorizationResolver.js', () => ({ - resolveLiveAuthorization: mocks.resolveAuthorization, -})) -vi.mock('../src/services/access/accessCatalogCoordinator.js', async importOriginal => { - const actual = - await importOriginal() - return { ...actual, buildAccessCatalog: mocks.buildCatalog } -}) -vi.mock('../src/services/rateLimiterService.js', () => rateLimiter) - -function effectivePolicy(overrides: Record = {}) { - return { - policyVersion: '1', - policyRevision: 'test-policy', - acceptV1: true, - issueV1: true, - acceptV2: true, - issueV2: false, - renewV2: false, - switchCompatibility: true, - computeCatalogShadow: false, - serveCatalog: false, - actionContextV2: false, - rpcDelegationV2: false, - desktopAllTeamMode: false, - profileV2Mode: false, - minimumClientVersion: null, - enforceMinimumClient: false, - advertisedCatalogFamilies: [], - ...overrides, - } -} - -function app() { - const value = express() - value.use(express.json()) - value.use(createExternalAccessRouter({} as never)) - return value -} - -describe('external user-access contracts', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.resolvePolicy.mockResolvedValue(effectivePolicy()) - mocks.buildCatalog.mockResolvedValue({ - complete: true, - items: [], - nextCursor: null, - partialErrors: [], - }) - mocks.verifyV2.mockReturnValue(null) - mocks.verifyV1.mockReturnValue({ - userId: '10000000-0000-4000-8000-000000000001', - email: 'user@example.test', - teamId: null, - role: 'member', - exp: 2_000_000_000, - iat: 1_900_000_000, - sessionContract: 'v1', - authGeneration: 1, - }) - mocks.validateV1.mockResolvedValue({ - status: 'valid', - identity: { jti: 'token-hash' }, - }) - mocks.validateV2.mockResolvedValue({ - status: 'valid', - identity: { - userId: '10000000-0000-4000-8000-000000000001', - email: 'user@example.test', - sid: '20000000-0000-4000-8000-000000000002', - jti: '30000000-0000-4000-8000-000000000003', - sessionVersion: 1, - }, - }) - rateLimiter.checkAndIncrement.mockResolvedValue({ - allowed: true, - remaining: 9, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - count: 1, - backendAvailable: true, - }) - }) - - it('advertises only the effective reconstruction defaults', async () => { - const response = await request(app()) - .get('/external/access/capabilities') - .set('x-user-session-token', 'v1-session') - - expect(response.status).toBe(200) - expect(response.body).toEqual( - expect.objectContaining({ - currentSessionContract: 'v1', - v1Accepted: true, - v1Issued: true, - v2Accepted: true, - v2Issued: false, - catalogShadow: false, - catalogServed: false, - actionContextV2: false, - rpcDelegationV2: false, - desktopAllTeamMode: false, - profileV2Mode: false, - minimumClientEnforced: false, - catalogFamilies: [], - }) - ) - expect(mocks.validateV1).toHaveBeenCalledWith( - 'v1-session', - expect.any(Object), - expect.objectContaining({ onDatabaseFailure: expect.any(Function) }) - ) - }) - - it('cancels and releases the shared request budget when the HTTP request aborts', () => { - const req = Object.assign(new EventEmitter(), { - method: 'GET', - path: '/external/access/catalog', - }) - const res = Object.assign(new EventEmitter(), { writableEnded: false }) - const next = vi.fn() - - attachAccessExecutionBudget(req as never, res as never, next) - - const budget = (req as { accessExecutionBudget?: { signal: AbortSignal } }) - .accessExecutionBudget - expect(next).toHaveBeenCalledOnce() - expect(budget?.signal.aborted).toBe(false) - req.emit('aborted') - expect(budget?.signal.aborted).toBe(true) - expect(budget?.signal.reason).toBe('cancelled') - res.emit('close') - expect(req.listenerCount('aborted')).toBe(0) - expect(res.listenerCount('finish')).toBe(0) - expect(res.listenerCount('close')).toBe(0) - }) - - it.each([ - ['GET', '/external/access/catalog'], - ['POST', '/external/access/resolve'], - ] as const)('keeps %s %s disabled before rate-limited work', async (method, path) => { - const response = await request(app()) - [method === 'GET' ? 'get' : 'post'](path) - .set('x-user-session-token', 'v1-session') - .send(method === 'POST' ? {} : undefined) - - expect(response.status).toBe(409) - expect(response.body.error.code).toBe('invalid_request') - }) - - it('requires an accepted live external session for the manifest', async () => { - mocks.verifyV1.mockReturnValue(null) - const response = await request(app()) - .get('/external/access/capabilities') - .set('x-user-session-token', 'invalid') - expect(response.status).toBe(401) - expect(response.body.error.code).toBe('invalid_session') - }) - - it('serves capabilities without a per-user PG budget (dev users-family parity)', async () => { - // A denying PG limiter would surface as 429 if the capability read still charged one. - rateLimiter.checkAndIncrement.mockResolvedValue({ - allowed: false, - remaining: 0, - resetMs: Date.now() + 60_000, - windowStartMs: Date.now(), - count: 11, - backendAvailable: true, - }) - - const response = await request(app()) - .get('/external/access/capabilities') - .set('x-user-session-token', 'v1-session') - - expect(response.status).toBe(200) - expect(response.body.currentSessionContract).toBe('v1') - expect(mocks.resolvePolicy).toHaveBeenCalled() - expect(rateLimiter.checkAndIncrement).not.toHaveBeenCalled() - }) - - it('admits the mounted default twelve-family catalog at its complete producer cost', async () => { - const familyCount = 12 - mocks.verifyV1.mockReturnValue(null) - mocks.verifyV2.mockReturnValue({ - sub: '10000000-0000-4000-8000-000000000001', - sid: '20000000-0000-4000-8000-000000000002', - jti: '30000000-0000-4000-8000-000000000003', - sv: 1, - ver: 2, - typ: 'user_session', - email: 'user@example.test', - auth_time: 1_900_000_000, - amr: ['pwd'], - }) - mocks.validateV2.mockImplementation(async () => { - return { - status: 'valid', - identity: { - userId: '10000000-0000-4000-8000-000000000001', - email: 'user@example.test', - sid: '20000000-0000-4000-8000-000000000002', - jti: '30000000-0000-4000-8000-000000000003', - sessionVersion: 1, - }, - } - }) - mocks.resolvePolicy.mockImplementation(async options => { - if (options.catalogReadiness) { - options.budget.charge({ kind: 'producerCalls' }) - } - return effectivePolicy({ serveCatalog: true }) - }) - mocks.buildCatalog.mockImplementation(async (input, options) => { - const requestedFamilies = input.families?.length ?? familyCount - options.budget.charge({ - kind: 'producerCalls', - amount: 4 + 2 * requestedFamilies, - }) - return { - complete: true, - items: [], - nextCursor: null, - partialErrors: [], - } - }) - - const response = await request(app()) - .get('/external/access/catalog') - .set('x-user-session-token', 'signed-v2-synthetic') - - expect(mocks.buildCatalog).toHaveBeenCalledOnce() - expect(response.status).toBe(200) - expect(mocks.buildCatalog).toHaveBeenCalledWith( - expect.not.objectContaining({ families: expect.anything() }), - expect.objectContaining({ budget: expect.any(Object) }) - ) - expect(mocks.validateV2).toHaveBeenCalledTimes(2) - const catalogBudget = mocks.buildCatalog.mock.calls[0]?.[1].budget - expect(catalogBudget?.limits.producerCalls).toBe(42) - expect(catalogBudget?.remaining('producerCalls')).toBe(13) - }) - - it('preserves the eleven-family catalog control and rejects revoked authority before catalog work', async () => { - const familyList = - 'user,team,host,context,mcp_server,workflow_recipe,workflow_run,workflow_approval,notification,gfs_resource,shared_filesystem' - mocks.verifyV1.mockReturnValue(null) - mocks.verifyV2.mockReturnValue({ - sub: '10000000-0000-4000-8000-000000000001', - sid: '20000000-0000-4000-8000-000000000002', - jti: '30000000-0000-4000-8000-000000000003', - sv: 1, - ver: 2, - typ: 'user_session', - email: 'user@example.test', - auth_time: 1_900_000_000, - amr: ['pwd'], - }) - mocks.validateV2.mockImplementation(async () => { - return { - status: 'valid', - identity: { - userId: '10000000-0000-4000-8000-000000000001', - email: 'user@example.test', - sid: '20000000-0000-4000-8000-000000000002', - jti: '30000000-0000-4000-8000-000000000003', - sessionVersion: 1, - }, - } - }) - mocks.resolvePolicy.mockImplementation(async options => { - if (options.catalogReadiness) { - options.budget.charge({ kind: 'producerCalls' }) - } - return effectivePolicy({ serveCatalog: true }) - }) - mocks.buildCatalog.mockImplementation(async (input, options) => { - const requestedFamilies = input.families?.length ?? 12 - options.budget.charge({ - kind: 'producerCalls', - amount: 4 + 2 * requestedFamilies, - }) - return { complete: true, items: [], nextCursor: null, partialErrors: [] } - }) - - const elevenFamilyResponse = await request(app()) - .get('/external/access/catalog') - .query({ families: familyList }) - .set('x-user-session-token', 'signed-v2-synthetic') - - expect(elevenFamilyResponse.status).toBe(200) - - mocks.validateV2.mockResolvedValue({ status: 'revoked', reason: 'revoked' }) - mocks.buildCatalog.mockClear() - const revokedResponse = await request(app()) - .get('/external/access/catalog') - .set('x-user-session-token', 'signed-v2-synthetic') - - expect(revokedResponse.status).toBe(401) - expect(mocks.buildCatalog).not.toHaveBeenCalled() - }) - - it('stops the mounted catalog before producer work when readiness is unavailable', async () => { - mocks.verifyV1.mockReturnValue(null) - mocks.verifyV2.mockReturnValue({ - sub: '10000000-0000-4000-8000-000000000001', - sid: '20000000-0000-4000-8000-000000000002', - jti: '30000000-0000-4000-8000-000000000003', - sv: 1, - ver: 2, - typ: 'user_session', - email: 'user@example.test', - auth_time: 1_900_000_000, - amr: ['pwd'], - }) - mocks.validateV2.mockResolvedValue({ - status: 'valid', - identity: { - userId: '10000000-0000-4000-8000-000000000001', - email: 'user@example.test', - sid: '20000000-0000-4000-8000-000000000002', - jti: '30000000-0000-4000-8000-000000000003', - sessionVersion: 1, - }, - }) - mocks.resolvePolicy.mockImplementation(async options => { - if (options.catalogReadiness) throw new Error('readiness unavailable') - return effectivePolicy({ serveCatalog: true }) - }) - - const response = await request(app()) - .get('/external/access/catalog') - .set('x-user-session-token', 'signed-v2-synthetic') - - expect(response.status).toBe(503) - expect(mocks.buildCatalog).not.toHaveBeenCalled() - }) - - it('uses the authoritative target schema for team management actions', async () => { - const teamId = '40000000-0000-4000-8000-000000000004' - const pathId = `ap1_${'A'.repeat(43)}` - mocks.verifyV1.mockReturnValue(null) - mocks.verifyV2.mockReturnValue({ - sub: '10000000-0000-4000-8000-000000000001', - sid: '20000000-0000-4000-8000-000000000002', - jti: '30000000-0000-4000-8000-000000000003', - sv: 1, - ver: 2, - typ: 'user_session', - email: 'user@example.test', - auth_time: 1_900_000_000, - amr: ['pwd'], - iat: 1_900_000_000, - exp: 2_000_000_000, - }) - mocks.resolvePolicy.mockResolvedValue(effectivePolicy({ actionContextV2: true })) - mocks.resolveAuthorization.mockResolvedValue({ - status: 'allowed', - effectiveCapabilities: ['team.manage'], - paths: [{ id: pathId, kind: 'team', teamId }], - selectedPath: { id: pathId, kind: 'team', teamId }, - authorizationRevision: 'revision-1', - }) - - const response = await request(app()) - .post('/external/access/resolve') - .set('x-user-session-token', 'v2-session') - .send({ - requiredCapability: 'team.manage', - resource: { - environmentId: canonicalEnvironmentId(), - type: 'team', - logicalId: teamId, - }, - operationTarget: { teamId, action: 'rename' }, - }) - - expect(response.status).toBe(200) - expect(mocks.resolveAuthorization).toHaveBeenCalledWith( - expect.objectContaining({ - requiredCapability: 'team.manage', - operationTarget: { teamId, action: 'rename' }, - }), - expect.any(Object) - ) - }) -}) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 1c9975e8ed..cedadb8215 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.165", + "version": "0.1.166", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.165", + "version": "0.1.166", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index d789d9eccd..b2f40c1a46 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.165", + "version": "0.1.166", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/app.ts b/external-rest-api/src/app.ts index c04de5cb82..13055750a4 100644 --- a/external-rest-api/src/app.ts +++ b/external-rest-api/src/app.ts @@ -7,7 +7,6 @@ import { invitationPasswordErrorHandler } from './http/passwordAdmissionError.js import { sendPublicApiError } from './http/publicApiError.js' import { requireTrustedBrowserMutation } from './middleware/browserMutationGuard.js' import { withExternalRequestContext } from './requestContext.js' -import { createAccessRouter } from './routes/access.js' import { createAuthRouter } from './routes/auth.js' import { createContextSharedFilesystemsRouter } from './routes/contextSharedFilesystems.js' import { createDesktopRouter } from './routes/desktop.js' @@ -69,7 +68,6 @@ export function createApp() { const api = express.Router() api.use(createAuthRouter()) - api.use(createAccessRouter()) api.use(createDesktopRouter()) api.use(createMeRouter()) api.use(createMembersRouter()) diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index ee61ba6263..c45bea732e 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.12.0', - externalRestApiVersion: '0.1.165', + externalRestApiVersion: '0.1.166', rpcProxyVersion: '0.1.138', desktopVersion: '0.12.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/src/routes/access.ts b/external-rest-api/src/routes/access.ts deleted file mode 100644 index 40a4544b39..0000000000 --- a/external-rest-api/src/routes/access.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { Router } from 'express' -import type { NextFunction, Response } from 'express' -import { controlApiRequest } from '../controlApiClient.js' -import { - publicCorrelationId, - sanitizeControlApiPublicError, - sendPublicApiError, - sendSanitizedControlApiPublicError, -} from '../http/publicApiError.js' -import { type AuthedRequest, extractAuthToken, requireAuth } from '../middleware/auth.js' -import { createRateLimiter } from '../middleware/rateLimit.js' - -const ACCESS_PUBLIC_STATUSES = new Set([400, 401, 403, 404, 409, 429, 503]) -const ACCESS_CATALOG_QUERY_FIELDS = ['families', 'limit', 'cursor'] as const - -function accessCatalogQuery(req: AuthedRequest): Record | null { - const query: Record = {} - for (const field of ACCESS_CATALOG_QUERY_FIELDS) { - const value = req.query[field] - if (value === undefined) { - query[field] = undefined - continue - } - if (typeof value !== 'string' || value.length === 0) return null - query[field] = value - } - return query -} -function forwardAccessError(error: unknown, res: Response, next: NextFunction): void { - const sanitized = sanitizeControlApiPublicError( - error, - ACCESS_PUBLIC_STATUSES, - publicCorrelationId(res.req) - ) - if (sanitized) { - sendSanitizedControlApiPublicError(res, sanitized) - return - } - next(error) -} - -export function createAccessRouter(): Router { - const router = Router() - router.use('/me/access', requireAuth) - const catalogRateLimit = createRateLimiter({ - windowMs: 60_000, - maxRequests: 10, - keyFn: req => { - const userId = (req as AuthedRequest).auth?.userId - return userId ? `access-catalog:${userId}` : 'access-catalog:unknown' - }, - }) - - router.get('/me/access/capabilities', async (req: AuthedRequest, res, next) => { - try { - const result = await controlApiRequest('GET', '/external/access/capabilities', { - userSessionToken: extractAuthToken(req), - }) - res.status(200).json(result) - } catch (error) { - forwardAccessError(error, res, next) - } - }) - - router.get('/me/access/catalog', catalogRateLimit, async (req: AuthedRequest, res, next) => { - const query = accessCatalogQuery(req) - if (!query) { - sendPublicApiError(req, res, 400, 'invalid_request', 'The catalog query is not valid.') - return - } - try { - const result = await controlApiRequest('GET', '/external/access/catalog', { - userSessionToken: extractAuthToken(req), - query, - }) - res.status(200).json(result) - } catch (error) { - forwardAccessError(error, res, next) - } - }) - - // There is deliberately no POST /me/access/resolve here. The control-api - // /external/access/resolve handler stays dormant until the access-resolution - // contract is enabled, so this edge must not forward to it. Re-adding a - // forwarding route re-exposes it; routes.accessResolveExposure.test.ts pins - // the absence. - return router -} diff --git a/external-rest-api/test/routes.access.test.ts b/external-rest-api/test/routes.access.test.ts deleted file mode 100644 index c8111eca68..0000000000 --- a/external-rest-api/test/routes.access.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import express from 'express' -import request from 'supertest' -import { ControlApiError } from '../src/controlApiClient.js' -import { createAccessRouter } from '../src/routes/access.js' - -const mocks = vi.hoisted(() => ({ - verifyToken: vi.fn(), - controlApiRequest: vi.fn(), -})) - -vi.mock('../src/authToken.js', () => ({ verifyToken: mocks.verifyToken })) -vi.mock('../src/controlApiClient.js', async importOriginal => { - const actual = await importOriginal() - return { ...actual, controlApiRequest: mocks.controlApiRequest } -}) - -function app() { - const value = express() - value.use(express.json()) - value.use(createAccessRouter()) - return value -} - -describe('public access-contract forwarding', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.verifyToken.mockReturnValue({ - userId: 'user-1', - email: 'user@example.test', - teamId: null, - role: 'member', - exp: 2_000_000_000, - }) - }) - - it('forwards capabilities and catalog query fields with the same user session', async () => { - mocks.controlApiRequest.mockResolvedValueOnce({ catalogServed: false }) - await request(app()) - .get('/me/access/capabilities') - .set('authorization', 'Bearer session-token') - .expect(200, { catalogServed: false }) - expect(mocks.controlApiRequest).toHaveBeenLastCalledWith( - 'GET', - '/external/access/capabilities', - { userSessionToken: 'session-token' } - ) - - mocks.controlApiRequest.mockResolvedValueOnce({ items: [] }) - await request(app()) - .get('/me/access/catalog?families=team%2Chost&limit=25&cursor=c3.value.signature') - .set('authorization', 'Bearer session-token') - .expect(200, { items: [] }) - expect(mocks.controlApiRequest).toHaveBeenLastCalledWith( - 'GET', - '/external/access/catalog', - expect.objectContaining({ - userSessionToken: 'session-token', - query: { - families: 'team,host', - limit: '25', - cursor: 'c3.value.signature', - }, - }) - ) - }) - - it('limits catalog per authenticated user while capabilities remain unrestricted', async () => { - const server = app() - mocks.controlApiRequest.mockResolvedValue({ items: [] }) - for (let index = 0; index < 10; index += 1) { - await request(server) - .get('/me/access/catalog') - .set('authorization', 'Bearer session-token') - .expect(200) - } - const rejected = await request(server) - .get('/me/access/catalog') - .set('authorization', 'Bearer session-token') - .expect(429) - expect(rejected.headers['x-ratelimit-limit']).toBe('10') - expect(mocks.controlApiRequest).toHaveBeenCalledTimes(10) - - for (let index = 0; index < 11; index += 1) { - await request(server) - .get('/me/access/capabilities') - .set('authorization', 'Bearer session-token') - .expect(200) - } - mocks.verifyToken.mockReturnValue({ - userId: 'user-2', - email: 'other@example.test', - role: 'member', - }) - await request(server) - .get('/me/access/catalog') - .set('authorization', 'Bearer other-session') - .expect(200) - expect(mocks.controlApiRequest).toHaveBeenCalledTimes(22) - }) - - it('rebuilds a bounded public error instead of forwarding upstream fields', async () => { - mocks.controlApiRequest.mockRejectedValue( - new ControlApiError('upstream', 503, { - error: { - code: 'authority_unavailable', - message: 'SQL /secret/path', - correlationId: 'safe-id', - retryable: true, - details: { internal: 'secret' }, - }, - }) - ) - const response = await request(app()) - .get('/me/access/capabilities') - .set('authorization', 'Bearer session-token') - .set('x-correlation-id', 'request-fallback') - expect(response.status).toBe(503) - expect(response.body).toEqual({ - error: { - code: 'authority_unavailable', - message: 'Authorization is temporarily unavailable.', - correlationId: 'safe-id', - retryable: true, - }, - }) - }) - - it('uses the request correlation when a sanitized upstream error has none', async () => { - mocks.controlApiRequest.mockRejectedValue( - new ControlApiError('upstream', 503, { - error: { - code: 'authority_unavailable', - message: 'SQL /secret/path', - retryable: true, - }, - }) - ) - - const response = await request(app()) - .get('/me/access/capabilities') - .set('authorization', 'Bearer session-token') - .set('x-correlation-id', 'access_ID-42') - .expect(503) - - expect(response.body.error.correlationId).toBe('access_ID-42') - }) - - it('preserves safe retry guidance for a locally sanitized access limit', async () => { - mocks.controlApiRequest.mockRejectedValue( - new ControlApiError( - 'rate limited', - 429, - { - error: { - code: 'rate_limited', - details: { retryAfterSeconds: 17, bucket: 'secret-bucket' }, - }, - }, - { - 'retry-after': '17', - 'x-ratelimit-limit': '10', - 'x-ratelimit-remaining': '0', - 'x-ratelimit-reset': '1900000000', - 'x-internal-bucket': 'secret-bucket', - } - ) - ) - - const response = await request(app()) - .get('/me/access/catalog') - .set('authorization', 'Bearer session-token') - - expect(response.status).toBe(429) - expect(response.body.error).toMatchObject({ - code: 'rate_limited', - retryable: true, - details: { retryAfterSeconds: 17 }, - }) - expect(response.headers['retry-after']).toBe('17') - expect(response.headers['x-ratelimit-limit']).toBe('10') - expect(response.headers['x-ratelimit-remaining']).toBe('0') - expect(response.headers['x-ratelimit-reset']).toBe('1900000000') - expect(response.headers['x-internal-bucket']).toBeUndefined() - expect(JSON.stringify(response.body)).not.toContain('secret-bucket') - }) -}) diff --git a/external-rest-api/test/routes.accessQueryValidation.test.ts b/external-rest-api/test/routes.accessQueryValidation.test.ts deleted file mode 100644 index 8c6e3e8c14..0000000000 --- a/external-rest-api/test/routes.accessQueryValidation.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import express from 'express' -import request from 'supertest' -import { createAccessRouter } from '../src/routes/access.js' - -const auth = vi.hoisted(() => ({ verifyToken: vi.fn() })) -vi.mock('../src/authToken.js', () => ({ verifyToken: auth.verifyToken })) - -function app() { - const value = express() - value.use(createAccessRouter()) - return value -} - -describe('access catalog query validation', () => { - beforeEach(() => { - vi.clearAllMocks() - auth.verifyToken.mockReturnValue({ userId: 'user-1', exp: 2_000_000_000 }) - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue( - new Response(JSON.stringify({ items: [] }), { - status: 200, - headers: { 'content-type': 'application/json' }, - }) - ) - ) - }) - - it.each([ - 'cursor=', - 'cursor=first&cursor=second', - 'families=', - 'families=team&families=host', - 'limit=', - 'limit=10&limit=20', - ])('rejects malformed query %s before contacting Control API', async query => { - const response = await request(app()) - .get(`/me/access/catalog?${query}`) - .set('authorization', 'Bearer session-token') - .expect(400) - - expect(response.body).toMatchObject({ error: { code: 'invalid_request' } }) - expect(fetch).not.toHaveBeenCalled() - }) - - it('preserves one value for every supported query field', async () => { - await request(app()) - .get('/me/access/catalog?families=team%2Chost&limit=25&cursor=c3.value.signature') - .set('authorization', 'Bearer session-token') - .expect(200) - - expect(fetch).toHaveBeenCalledTimes(1) - const requestUrl = String(vi.mocked(fetch).mock.calls[0]?.[0]) - expect(new URL(requestUrl).searchParams).toEqual( - new URLSearchParams({ - families: 'team,host', - limit: '25', - cursor: 'c3.value.signature', - }) - ) - }) -}) diff --git a/external-rest-api/test/routes.accessResolveExposure.test.ts b/external-rest-api/test/routes.accessResolveExposure.test.ts deleted file mode 100644 index 405240aee6..0000000000 --- a/external-rest-api/test/routes.accessResolveExposure.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import request from 'supertest' -import { createApp } from '../src/app.js' - -const mocks = vi.hoisted(() => ({ - verifyToken: vi.fn(), - controlApiFetch: vi.fn(), -})) - -vi.mock('../src/authToken.js', () => ({ verifyToken: mocks.verifyToken })) - -const PUBLIC_API_PREFIX = '/api/v1' -const RESOLVE_PATH = `${PUBLIC_API_PREFIX}/me/access/resolve` -const UNKNOWN_ACCESS_PATH = `${PUBLIC_API_PREFIX}/me/access/unknown-access-route` - -const claims = { - userId: '10000000-0000-4000-8000-000000000001', - email: 'user@example.test', - teamId: '', - role: 'member' as const, - exp: 2_000_000_000, -} - -// Shaped like the request control-api accepts, so a route that still forwards -// the resolve contract would reach the stubbed upstream instead of failing on -// this test's own input validation. -const resolveBody = { - requiredCapability: 'team.read', - resource: { - environmentId: 'environment', - type: 'team', - logicalId: 'team-1', - }, -} - -function controlApiJsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { 'content-type': 'application/json' }, - }) -} - -describe('public access resolve exposure', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.verifyToken.mockReturnValue(claims) - mocks.controlApiFetch.mockResolvedValue(controlApiJsonResponse({ catalogServed: true })) - vi.stubGlobal('fetch', mocks.controlApiFetch) - }) - - afterEach(() => { - vi.unstubAllGlobals() - }) - - it('answers the dormant resolve path exactly like an unknown access route while the sibling capabilities route stays live', async () => { - const app = createApp() - - const unknown = await request(app) - .post(UNKNOWN_ACCESS_PATH) - .set('authorization', 'Bearer session-token') - .send(resolveBody) - const resolve = await request(app) - .post(RESOLVE_PATH) - .set('authorization', 'Bearer session-token') - .send(resolveBody) - - // The edge's own not-found contract, read from the same app in the same - // authenticated context as the resolve request. - expect(unknown.status).toBe(404) - expect(unknown.body).toEqual({ error: 'Not Found' }) - expect(resolve.status).toBe(404) - expect(resolve.body).toEqual(unknown.body) - // Both requests carried a valid session, so neither was stopped by the - // /me/access auth gate; the resolve response is routing, not a 401. - expect(mocks.verifyToken.mock.calls.length).toBeGreaterThanOrEqual(2) - expect(mocks.verifyToken).toHaveBeenCalledWith('session-token') - // Nothing dialed control-api: the dormant /external/access/resolve handler - // is unreachable from this edge. - expect(mocks.controlApiFetch).not.toHaveBeenCalled() - - // Liveness witness: the same app, router and session still expose the - // sibling access route end to end, so the 404 above cannot come from a - // broken edge or a dead proxy. - mocks.controlApiFetch.mockResolvedValueOnce( - controlApiJsonResponse({ catalogServed: false, currentSessionContract: 'v1' }) - ) - const capabilities = await request(app) - .get(`${PUBLIC_API_PREFIX}/me/access/capabilities`) - .set('authorization', 'Bearer session-token') - - expect(capabilities.status).toBe(200) - expect(capabilities.body).toEqual({ - catalogServed: false, - currentSessionContract: 'v1', - }) - expect(mocks.controlApiFetch).toHaveBeenCalledTimes(1) - const [upstreamUrl, upstreamInit] = mocks.controlApiFetch.mock.calls[0] as [string, RequestInit] - expect(new URL(upstreamUrl).pathname).toBe('/api/v1/external/access/capabilities') - expect(upstreamInit.method).toBe('GET') - expect((upstreamInit.headers as Record)['x-user-session-token']).toBe( - 'session-token' - ) - }) -}) From f0e8a49adfd0e94f86d99a05f0b8ca0dad6dc4bc Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 4 Nov 2026 13:14:22 +0100 Subject: [PATCH 453/528] refactor(access): remove the aggregate access catalog from PR1 M-23/M-29 (K10 of the PR #334 r4.2 fix plan). The catalog has no client in PR1 and its reader lands in PR3. - Delete the 17 catalog-only modules (coordinator, cursor, merge, shadow, producers, hydration, team GFS top-K) and their 24 test files, including the real-PG suites that only import catalog code (Addendum V2-H3). - Remove the 12 catalog-shadow hooks from six legacy routes; users.ts and mcpInvocable.ts return to the dev form for those calls. - Remove the two shadow counters, the catalog parts of the user-access policy, the runtime readiness lookup (production always compiled with the reconstruction readiness because the catalog mode was forced off) and the two catalog config fields. - Drop the deleted suites from the pinned CI list and the password recovery suite manifest. The team GFS budget limit, the access budget readiness and the action, RPC, desktop and profile flags stay until K11. Until then the desktop mode gate cannot pass, because catalog serving no longer exists; a test pins that rejection. Known red before this commit, not caused by it: the real-PG lane fails passwordRecovery.http (edge getMe after 7fc7fda6a) and two archiveRevisionLockOrder cases (workflow-recipes writers after 5dae55d31). They are fixed in a follow-up commit. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci-public.yml | 9 - control-api/package-lock.json | 4 +- control-api/package.json | 2 +- control-api/src/config.ts | 22 - control-api/src/observability/metrics.ts | 12 - control-api/src/routes/external/gfs.ts | 13 - .../routes/external/notifications.routes.ts | 18 - .../src/routes/external/sharedFilesystems.ts | 14 - control-api/src/routes/external/users.ts | 53 +- .../routes/external/workflows/read.routes.ts | 36 - .../routes/external/workflows/runs.routes.ts | 14 - .../access/accessCatalogCoordinator.ts | 474 ----- .../services/access/accessCatalogCursor.ts | 199 -- .../src/services/access/accessCatalogMerge.ts | 208 -- .../services/access/accessCatalogShadow.ts | 192 -- .../access/catalogAuthorityContext.ts | 204 -- .../src/services/access/catalogContracts.ts | 155 -- .../services/access/catalogHydrationSql.ts | 711 ------- .../services/access/catalogProducerArms.ts | 74 - .../access/catalogProducerDatabase.ts | 14 - .../services/access/catalogProducerErrors.ts | 7 - .../services/access/catalogProducerHead.ts | 203 -- .../access/catalogProducerHydration.ts | 28 - .../access/catalogProducerReadiness.ts | 52 - .../src/services/access/catalogProducerSql.ts | 466 ----- .../services/access/catalogProducerSupport.ts | 15 - .../src/services/access/catalogProducers.ts | 691 ------- .../src/services/access/mcpInvocable.ts | 3 - .../src/services/access/teamGfsTopK.ts | 301 --- .../src/services/access/userAccessPolicy.ts | 155 +- .../access/userAccessRuntimePolicy.ts | 330 +--- .../auth/externalSessionAuthentication.ts | 7 +- .../accessCatalogConfiguredAdmission.test.ts | 130 -- ...ordinator.realPostgres.integration.test.ts | 444 ----- control-api/test/accessCatalogCursor.test.ts | 117 -- ...gFamilies.realPostgres.integration.test.ts | 1710 ----------------- ...rformance.realPostgres.integration.test.ts | 938 --------- .../test/accessCatalogProperties.test.ts | 584 ------ .../accessCatalogQueryObservation.test.ts | 31 - .../test/accessCatalogQueryObservation.ts | 43 - ...logShadow.realPostgres.integration.test.ts | 241 --- control-api/test/accessCatalogShadow.test.ts | 139 -- .../test/accessCatalogShadowCallsites.test.ts | 31 - .../test/accessCatalogShadowCapacity.test.ts | 114 -- ...cellation.realPostgres.integration.test.ts | 315 --- ...agination.realPostgres.integration.test.ts | 973 ---------- control-api/test/catalogOrdering.test.ts | 42 - ...Producers.realPostgres.integration.test.ts | 591 ------ control-api/test/catalogProducers.test.ts | 308 --- ...omponents.realPostgres.integration.test.ts | 929 --------- ...alogSandboxDuplicateClassification.test.ts | 11 - .../db.catalogUtf8OrderingMigration.test.ts | 40 - ...ptSession.realPostgres.integration.test.ts | 3 - ...dFirstSet.realPostgres.integration.test.ts | 3 - .../externalSessionAuthentication.test.ts | 3 - ...rrentness.realPostgres.integration.test.ts | 3 - ...ngleTouch.realPostgres.integration.test.ts | 6 +- ...evocation.realPostgres.integration.test.ts | 3 - ...Lifecycle.realPostgres.integration.test.ts | 348 ---- .../test/gfs.externalAuthority.test.ts | 3 - ...dIdentity.realPostgres.integration.test.ts | 3 - ...rrentness.realPostgres.integration.test.ts | 3 - ....externalNotifications.currentness.test.ts | 4 - ...Authority.realPostgres.integration.test.ts | 3 - ...evocation.realPostgres.integration.test.ts | 3 - control-api/test/teamGfsTopK.property.test.ts | 72 - .../test/userAccessCatalogActivation.test.ts | 126 -- ...serAccessCatalogReadinessIsolation.test.ts | 77 - control-api/test/userAccessPolicy.test.ts | 209 +- ...eContract.realPostgres.integration.test.ts | 238 --- tests/contracts/password-recovery-suites.json | 9 - 71 files changed, 52 insertions(+), 13484 deletions(-) delete mode 100644 control-api/src/services/access/accessCatalogCoordinator.ts delete mode 100644 control-api/src/services/access/accessCatalogCursor.ts delete mode 100644 control-api/src/services/access/accessCatalogMerge.ts delete mode 100644 control-api/src/services/access/accessCatalogShadow.ts delete mode 100644 control-api/src/services/access/catalogAuthorityContext.ts delete mode 100644 control-api/src/services/access/catalogContracts.ts delete mode 100644 control-api/src/services/access/catalogHydrationSql.ts delete mode 100644 control-api/src/services/access/catalogProducerArms.ts delete mode 100644 control-api/src/services/access/catalogProducerDatabase.ts delete mode 100644 control-api/src/services/access/catalogProducerErrors.ts delete mode 100644 control-api/src/services/access/catalogProducerHead.ts delete mode 100644 control-api/src/services/access/catalogProducerHydration.ts delete mode 100644 control-api/src/services/access/catalogProducerReadiness.ts delete mode 100644 control-api/src/services/access/catalogProducerSql.ts delete mode 100644 control-api/src/services/access/catalogProducerSupport.ts delete mode 100644 control-api/src/services/access/catalogProducers.ts delete mode 100644 control-api/src/services/access/teamGfsTopK.ts delete mode 100644 control-api/test/accessCatalogConfiguredAdmission.test.ts delete mode 100644 control-api/test/accessCatalogCoordinator.realPostgres.integration.test.ts delete mode 100644 control-api/test/accessCatalogCursor.test.ts delete mode 100644 control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts delete mode 100644 control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts delete mode 100644 control-api/test/accessCatalogProperties.test.ts delete mode 100644 control-api/test/accessCatalogQueryObservation.test.ts delete mode 100644 control-api/test/accessCatalogQueryObservation.ts delete mode 100644 control-api/test/accessCatalogShadow.realPostgres.integration.test.ts delete mode 100644 control-api/test/accessCatalogShadow.test.ts delete mode 100644 control-api/test/accessCatalogShadowCallsites.test.ts delete mode 100644 control-api/test/accessCatalogShadowCapacity.test.ts delete mode 100644 control-api/test/accessDatabaseCancellation.realPostgres.integration.test.ts delete mode 100644 control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts delete mode 100644 control-api/test/catalogOrdering.test.ts delete mode 100644 control-api/test/catalogProducers.realPostgres.integration.test.ts delete mode 100644 control-api/test/catalogProducers.test.ts delete mode 100644 control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts delete mode 100644 control-api/test/catalogSandboxDuplicateClassification.test.ts delete mode 100644 control-api/test/db.catalogUtf8OrderingMigration.test.ts delete mode 100644 control-api/test/finalAuthorityLifecycle.realPostgres.integration.test.ts delete mode 100644 control-api/test/teamGfsTopK.property.test.ts delete mode 100644 control-api/test/userAccessCatalogActivation.test.ts delete mode 100644 control-api/test/userAccessCatalogReadinessIsolation.test.ts diff --git a/.github/workflows/ci-public.yml b/.github/workflows/ci-public.yml index 07618476cd..41d7ea83ab 100644 --- a/.github/workflows/ci-public.yml +++ b/.github/workflows/ci-public.yml @@ -656,16 +656,7 @@ jobs: routes.mcpOauth.legacyGrantLaneFence.realPostgres.integration.test.ts \ oauth.dcrCredential.installIdentity.realPostgres.integration.test.ts \ db.realPostgresMigration.integration.test.ts \ - accessCatalogCoordinator.realPostgres.integration.test.ts \ - accessCatalogFamilies.realPostgres.integration.test.ts \ - accessCatalogPerformance.realPostgres.integration.test.ts \ - accessCatalogShadow.realPostgres.integration.test.ts \ - accessDatabaseCancellation.realPostgres.integration.test.ts \ - catalogDistinctPagination.realPostgres.integration.test.ts \ - catalogProducers.realPostgres.integration.test.ts \ - catalogRevisionComponents.realPostgres.integration.test.ts \ externalSessionSingleTouch.realPostgres.integration.test.ts \ - finalAuthorityLifecycle.realPostgres.integration.test.ts \ gfsRevisionLockOrder.realPostgres.integration.test.ts \ invitationAcceptedOnboarding.realPostgres.integration.test.ts \ legacyPasswordEpochBackfill.realPostgres.integration.test.ts \ diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 0c9142f4ad..8db624035c 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.893", + "version": "0.1.894", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.893", + "version": "0.1.894", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 5af88cd35d..34d35446ba 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.893", + "version": "0.1.894", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/config.ts b/control-api/src/config.ts index 6f592c22b0..7d310f1262 100644 --- a/control-api/src/config.ts +++ b/control-api/src/config.ts @@ -318,8 +318,6 @@ type Config = { sharedFilesystemsNamespace: string operationalAccessIndexerEnabled: boolean operationalAccessIndexerRetryMs: number - operationalAccessReadinessMaxAgeMs: number | null - userAccessCatalogActivationRecord: string // Audience claim used by browsing JWTs; must match WSF_JWT_AUDIENCE on // the per-SFS wfc Deployment. The signing key is reused from rpcJwtPrivateKey. wfcJwtAudience: string @@ -468,16 +466,6 @@ function positiveIntegerFromEnv(name: string, defaultValue: number): number { return value } -function optionalPositiveIntegerFromEnv(name: string): number | null { - const raw = process.env[name] - if (raw === undefined || raw.trim() === '') return null - const value = Number(raw) - if (!Number.isSafeInteger(value) || value < 1) { - throw new Error(`${name} must be a positive integer when configured`) - } - return value -} - /** * A non-negative integer env (>= 0). Unlike `positiveIntegerFromEnv`, 0 is a * valid, meaningful value here (the per-provider stale floor's default: guard @@ -1387,16 +1375,6 @@ export const config: Config = { 5_000, 100 ), - // No production freshness threshold is assumed. Catalog shadow/serve stays - // unavailable until an operator supplies a measured maximum source age. - operationalAccessReadinessMaxAgeMs: optionalPositiveIntegerFromEnv( - 'CONTROL_API_OPERATIONAL_ACCESS_READINESS_MAX_AGE_MS' - ), - // Versioned operator acceptance for aggregate catalog serving. The runtime - // validates the complete record against the active catalog configuration; - // absence or mismatch keeps serving fail-closed. - userAccessCatalogActivationRecord: - process.env.CONTROL_API_USER_ACCESS_CATALOG_ACTIVATION_RECORD ?? '', wfcJwtAudience: process.env.CONTROL_API_WFC_JWT_AUDIENCE || 'workspace-files-controller', wfcTokenTtlSeconds: Number(process.env.CONTROL_API_WFC_TOKEN_TTL_SECONDS || 300), gfsTokenAudience: process.env.CONTROL_API_GFS_JWT_AUDIENCE || 'gfs-controller', diff --git a/control-api/src/observability/metrics.ts b/control-api/src/observability/metrics.ts index 8f37e7ca87..0b6316143b 100644 --- a/control-api/src/observability/metrics.ts +++ b/control-api/src/observability/metrics.ts @@ -325,18 +325,6 @@ export const rateLimitHitsTotal = getOrCreateCounter({ labelNames: ['bucket_type', 'result'] as const as Array<'bucket_type' | 'result'>, }) -export const aggregateAccessShadowComparisonsTotal = getOrCreateCounter({ - name: 'aggregate_access_shadow_comparisons_total', - help: 'Count of bounded aggregate-access shadow comparisons by family and safe outcome.', - labelNames: ['family', 'outcome'] as const as Array<'family' | 'outcome'>, -}) - -export const aggregateAccessShadowDifferencesTotal = getOrCreateCounter({ - name: 'aggregate_access_shadow_differences_total', - help: 'Count of canonical resource differences observed by bounded access shadowing.', - labelNames: ['family', 'direction'] as const as Array<'family' | 'direction'>, -}) - // Every limiter query that failed, counted even when its warn line is // throttled. No label: the bucket key is unbounded (see below). export const rateLimitBackendErrorsTotal = getOrCreateCounter({ diff --git a/control-api/src/routes/external/gfs.ts b/control-api/src/routes/external/gfs.ts index 7e9218c417..f01f80356b 100644 --- a/control-api/src/routes/external/gfs.ts +++ b/control-api/src/routes/external/gfs.ts @@ -50,7 +50,6 @@ import { } from '../../middleware/gfsUploadAdmission.js' import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' import { rootLogger } from '../../observability/logger.js' -import { scheduleAccessCatalogShadow } from '../../services/access/accessCatalogShadow.js' import { getUserAgents } from '../../services/directory/index.js' import { GfsGrantError, @@ -960,18 +959,6 @@ export function createExternalGfsRouter(): Router { }> const page = rows.length > limit ? rows.slice(0, limit) : rows const last = page[page.length - 1] - scheduleAccessCatalogShadow({ - session: req.externalSessionAuthority, - family: 'gfs_resource', - legacyLogicalIds: page.map(row => String(row.resource_id)), - legacyComplete: rows.length <= limit && after === undefined, - scope: { - kind: 'behavior-json', - dimension: 'filesystemScope', - field: 'drive', - equals: drive, - }, - }) res.status(200).json({ ok: true, data: { diff --git a/control-api/src/routes/external/notifications.routes.ts b/control-api/src/routes/external/notifications.routes.ts index e6e2a3320e..0fe7f9e96d 100644 --- a/control-api/src/routes/external/notifications.routes.ts +++ b/control-api/src/routes/external/notifications.routes.ts @@ -17,7 +17,6 @@ import { notificationStreamEventsSentTotal, notificationStreamSnapshotSize, } from '../../observability/metrics.js' -import { scheduleAccessCatalogShadow } from '../../services/access/accessCatalogShadow.js' import { observeExternalSessionCurrentness } from '../../services/auth/externalSessionCurrentnessObserver.js' import { acknowledgeDesktopNotificationDelivery } from '../../services/notificationAckService.js' import { @@ -238,28 +237,11 @@ export function createExternalNotificationsRouter(): Router { flushInFlight = true try { if (!(await closeForAuthority())) return - const initialSnapshot = lastCursor === null const events = await listNotificationStreamEventsForUser(claims.userId, { limit: config.notificationStreamSnapshotLimit, after: lastCursor ? parseNotificationCursor(lastCursor) : null, }) if (!(await closeForAuthority())) return - const legacyComplete = - initialSnapshot && events.length < config.notificationStreamSnapshotLimit - scheduleAccessCatalogShadow({ - session: extReq.externalSessionAuthority, - family: 'notification', - legacyLogicalIds: events.map(event => event.id), - legacyComplete, - }) - scheduleAccessCatalogShadow({ - session: extReq.externalSessionAuthority, - family: 'workflow_approval', - legacyLogicalIds: events.flatMap(event => - event.eventType === 'approval.requested' ? [event.approval.id] : [] - ), - legacyComplete, - }) if (events.length === 0) { notificationStreamEventsFilteredTotal.inc({ reason: 'no_new_active_events' }, 1) return diff --git a/control-api/src/routes/external/sharedFilesystems.ts b/control-api/src/routes/external/sharedFilesystems.ts index 56665d3f16..b30b768783 100644 --- a/control-api/src/routes/external/sharedFilesystems.ts +++ b/control-api/src/routes/external/sharedFilesystems.ts @@ -7,7 +7,6 @@ import { type ExternalAuthedRequest, requireValidExternalSessionToken, } from '../../middleware/externalSessionAuth.js' -import { scheduleAccessCatalogShadow } from '../../services/access/accessCatalogShadow.js' import { getLiveTeamMembership } from '../../services/access/liveTeamAuthorization.js' import { listActiveContextIds, @@ -308,19 +307,6 @@ export function createExternalSharedFilesystemsRouter(gateway: K8sGateway): Rout } }) - scheduleAccessCatalogShadow({ - session: req.externalSessionAuthority, - family: 'shared_filesystem', - legacyLogicalIds: items.map(item => `${sfsNs}/${item.name}`), - legacyComplete: true, - scope: { - kind: 'behavior-json', - dimension: 'filesystemScope', - field: 'contextId', - equals: `${ctxNs}/${contextId}`, - }, - }) - res.status(200).json({ items }) }) ) diff --git a/control-api/src/routes/external/users.ts b/control-api/src/routes/external/users.ts index 955e53d460..7f7e8709c9 100644 --- a/control-api/src/routes/external/users.ts +++ b/control-api/src/routes/external/users.ts @@ -11,7 +11,6 @@ import { import { sendPasswordAdmissionError } from '../../middleware/passwordLoginAdmission.js' import { rateLimitMiddleware } from '../../middleware/rateLimitMiddleware.js' import { type Logger, rootLogger } from '../../observability/logger.js' -import { scheduleAccessCatalogShadow } from '../../services/access/accessCatalogShadow.js' import { resolveMcpServersForAgents } from '../../services/access/mcpInvocable.js' import { filterAccessValues, @@ -86,16 +85,9 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { requireExternalUserParamMatch(), async (req, res, next) => { try { - const result = await listTeams(req.params.userId, String(req.query.currentTeamId || '')) - scheduleAccessCatalogShadow({ - session: (req as ExternalAuthedRequest).externalSessionAuthority, - family: 'team', - legacyLogicalIds: Array.isArray(result.items) - ? result.items.map(item => String(item.id)) - : [], - legacyComplete: true, - }) - return res.status(200).json(result) + return res + .status(200) + .json(await listTeams(req.params.userId, String(req.query.currentTeamId || ''))) } catch (error) { return next(error) } @@ -215,12 +207,6 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { const teamId = String(req.query.teamId || '') const data = await getMe(req.params.userId, teamId) if (!data) return res.status(404).json({ error: 'not_found' }) - scheduleAccessCatalogShadow({ - session: (req as ExternalAuthedRequest).externalSessionAuthority, - family: 'user', - legacyLogicalIds: [req.params.userId], - legacyComplete: true, - }) return res.status(200).json(data) } catch (error) { return next(error) @@ -243,16 +229,9 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { 'external user context reconciliation failed' ) } - const contextIds = filterAccessValues(base.contextIds, active) - scheduleAccessCatalogShadow({ - session: (req as ExternalAuthedRequest).externalSessionAuthority, - family: 'context', - legacyLogicalIds: contextIds.map(id => `${config.contextsNamespace}/${id}`), - legacyComplete: active !== null, - }) return res.status(200).json({ ...base, - contextIds, + contextIds: filterAccessValues(base.contextIds, active), }) } catch (error) { return next(error) @@ -272,7 +251,6 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { const authorizedAgentNames = filterAccessValues(base.agentNames, null) let agents: Awaited> = [] let agentNames = authorizedAgentNames - let mcpEnrichmentComplete = true try { // Authorization model: authorizedAgentNames already reflects which agents // this user is allowed to use. resolveMcpServersForAgents treats agent @@ -282,9 +260,6 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { contextsNamespace: config.contextsNamespace, hostsNamespace: config.hostsNamespace, agentNames: authorizedAgentNames, - onPartial: () => { - mcpEnrichmentComplete = false - }, }) // A successful lookup can safely remove genuinely missing or invalid // Hosts. Only the DTOs resolved from the authorized input are used. @@ -298,27 +273,7 @@ export function createExternalUsersRouter(gateway: K8sGateway): Router { 'external user MCP server enrichment failed' ) agents = [] - mcpEnrichmentComplete = false } - const session = (req as ExternalAuthedRequest).externalSessionAuthority - scheduleAccessCatalogShadow({ - session, - family: 'host', - legacyLogicalIds: agentNames.map(name => `${config.hostsNamespace}/${name}`), - legacyComplete: agents.length === agentNames.length, - }) - scheduleAccessCatalogShadow({ - session, - family: 'mcp_server', - legacyLogicalIds: [ - ...new Set( - agents.flatMap(agent => - agent.mcpServers.map(server => `${config.mcpServersNamespace}/${server.name}`) - ) - ), - ], - legacyComplete: mcpEnrichmentComplete && agents.length === agentNames.length, - }) return res.status(200).json({ ...base, agentNames, agents }) } catch (error) { return next(error) diff --git a/control-api/src/routes/external/workflows/read.routes.ts b/control-api/src/routes/external/workflows/read.routes.ts index dee65a267d..768c0448d9 100644 --- a/control-api/src/routes/external/workflows/read.routes.ts +++ b/control-api/src/routes/external/workflows/read.routes.ts @@ -1,7 +1,6 @@ import { type Request, type Response, Router } from 'express' import { asyncHandler } from '../../../http/asyncHandler.js' import type { K8sGateway } from '../../../k8s.js' -import { scheduleAccessCatalogShadow } from '../../../services/access/accessCatalogShadow.js' import { K8sNotFoundError } from '../../../services/resourceService.js' import { WORKFLOW_RECIPE_PLURAL, @@ -27,41 +26,6 @@ export function createExternalWorkflowReadRoutes(gateway: K8sGateway): Router { if (!caller) return const recipes = await getAuthorizedRecipeResources(caller, gateway) - if (caller.kind === 'user-session') { - const recipeIds: string[] = [] - const appIds: string[] = [] - for (const recipe of recipes) { - const metadata = asRecord(recipe.metadata) - const spec = asRecord(recipe.spec) - const ui = asRecord(spec?.ui) - const namespace = typeof metadata?.namespace === 'string' ? metadata.namespace : '' - const name = typeof metadata?.name === 'string' ? metadata.name : '' - if (!namespace || !name) continue - const logicalId = `${namespace}/${name}` - recipeIds.push(logicalId) - if ( - typeof ui?.workloadRef === 'string' && - ui.workloadRef.trim() && - Number.isInteger(ui?.port) && - Number(ui.port) >= 1 && - Number(ui.port) <= 65_535 - ) { - appIds.push(logicalId) - } - } - scheduleAccessCatalogShadow({ - session: caller.session, - family: 'workflow_recipe', - legacyLogicalIds: recipeIds, - legacyComplete: true, - }) - scheduleAccessCatalogShadow({ - session: caller.session, - family: 'sandbox_app', - legacyLogicalIds: appIds, - legacyComplete: true, - }) - } res.json({ items: recipes, count: recipes.length }) }) ) diff --git a/control-api/src/routes/external/workflows/runs.routes.ts b/control-api/src/routes/external/workflows/runs.routes.ts index f7b938e523..ed2d80d978 100644 --- a/control-api/src/routes/external/workflows/runs.routes.ts +++ b/control-api/src/routes/external/workflows/runs.routes.ts @@ -1,7 +1,6 @@ import { type Request, type Response, Router } from 'express' import { asyncHandler } from '../../../http/asyncHandler.js' import type { K8sGateway } from '../../../k8s.js' -import { scheduleAccessCatalogShadow } from '../../../services/access/accessCatalogShadow.js' import { K8sNotFoundError } from '../../../services/resourceService.js' import { ensureRecipeAuthorized, @@ -66,19 +65,6 @@ export function createExternalWorkflowRunsRoutes(gateway: K8sGateway): Router { const limit = parseLimit(req.query?.limit) const items = await listCanonicalRuns(ns, name, limit, caller) - if (caller.kind === 'user-session') { - scheduleAccessCatalogShadow({ - session: caller.session, - family: 'workflow_run', - legacyLogicalIds: items.map(item => item.id), - legacyComplete: items.length < limit, - scope: { - kind: 'relationship', - type: 'recipe', - targetResourceId: `workflow_recipe:${ns}/${name}`, - }, - }) - } res.json({ items, count: items.length }) }) ) diff --git a/control-api/src/services/access/accessCatalogCoordinator.ts b/control-api/src/services/access/accessCatalogCoordinator.ts deleted file mode 100644 index 19a5df5bb2..0000000000 --- a/control-api/src/services/access/accessCatalogCoordinator.ts +++ /dev/null @@ -1,474 +0,0 @@ -import type { DbClient } from '../../db.js' -import type { ExternalSessionAuthorityContext } from '../auth/externalSessionAuthentication.js' -import { configureAccessAuthorityTransaction } from './accessAuthorityTransaction.js' -import { - ACCESS_CATALOG_CONTRACT_VERSION, - ACCESS_CATALOG_SORT, - type AccessCatalogCursorV3, - assertAccessCatalogCursorCurrent, - catalogFilterHash, - decodeAccessCatalogCursor, - encodeAccessCatalogCursor, -} from './accessCatalogCursor.js' -import { - AccessCatalogMergeError, - mergeCatalogCandidateStreams, - mergeHydratedCatalogResources, -} from './accessCatalogMerge.js' -import { withAccessDatabaseTransaction } from './accessDatabaseQuery.js' -import { AccessExecutionBudget, type AccessExecutionLimits } from './accessExecutionBudget.js' -import { buildAccessPath, compareCanonicalAccessPaths } from './accessPath.js' -import { - authorizationRevision, - canonicalAccessPathSeeds, - revisionOfValues, -} from './authorizationRevision.js' -import { normalizeAccessCapabilities } from './capabilityRegistry.js' -import { - CatalogAuthorityError, - catalogSourceStateRevision, - loadCatalogRequestContext, -} from './catalogAuthorityContext.js' -import { - CATALOG_FAMILIES, - type CatalogFamily, - type CatalogHydrationIncompleteRoot, - type CatalogHydrationResult, - type CatalogIdentityCandidate, - type CatalogKey, - type CatalogProducerPage, - type CatalogRelationship, - type CatalogRequestContext, - type HydratedCatalogResource, - type ProducerContinuation, - type SafeCatalogPartialError, -} from './catalogContracts.js' -import { CatalogProducerContractError } from './catalogProducerSupport.js' -import { requireCatalogProducer } from './catalogProducers.js' -import { canonicalEnvironmentId } from './operationalAccessProjection.js' -import type { CanonicalResourceIdentity } from './resourceIdentity.js' -import { configuredCatalogBudgetOptions } from './userAccessPolicy.js' - -export type PublicCatalogAccessPath = Readonly<{ - accessPathId: string - kind: 'direct' | 'team' - safeTeamDescriptor?: Readonly<{ teamId: string; currentRole: string }> - capabilities: readonly string[] - behaviorDescriptors: ReturnType['behavior'] -}> - -export type AccessCatalogItem = Readonly<{ - resource: CanonicalResourceIdentity & Readonly<{ resourceRevision: string }> - relationships: readonly CatalogRelationship[] - capabilities: readonly string[] - accessPaths: readonly PublicCatalogAccessPath[] -}> - -export type AccessCatalogResponse = Readonly<{ - contractVersion: typeof ACCESS_CATALOG_CONTRACT_VERSION - authorizationRevision: string - sourceStateRevision: string - complete: boolean - partialErrors: readonly SafeCatalogPartialError[] - items: readonly AccessCatalogItem[] - nextCursor: string | null -}> - -export class AccessCatalogRequestError extends Error { - constructor( - readonly code: - | 'invalid_request' - | 'authority_unavailable' - | 'session_not_live' - | 'catalog_invariant_failed' - ) { - super(`Access catalog request failed: ${code}`) - this.name = 'AccessCatalogRequestError' - } -} - -type CatalogTransaction = (work: (db: DbClient) => Promise) => Promise - -type ProducerRuntime = { - family: CatalogFamily - page: CatalogProducerPage -} - -function normalizeFamilies(value: readonly CatalogFamily[] | undefined): CatalogFamily[] { - if (value === undefined) return [...CATALOG_FAMILIES] - if (value.length === 0 || value.length > CATALOG_FAMILIES.length) { - throw new AccessCatalogRequestError('invalid_request') - } - const requested = new Set() - for (const family of value) { - if (!CATALOG_FAMILIES.includes(family)) { - throw new AccessCatalogRequestError('invalid_request') - } - requested.add(family) - } - return CATALOG_FAMILIES.filter(family => requested.has(family)) -} - -function emptyContinuations(): Record { - return Object.fromEntries( - CATALOG_FAMILIES.map(family => [family, Object.freeze({ afterKey: null, exhausted: false })]) - ) as Record -} - -function safePartialErrors( - runtimes: readonly ProducerRuntime[], - hasIncompleteHosts: boolean -): SafeCatalogPartialError[] { - const values = new Map() - for (const runtime of runtimes) { - for (const error of runtime.page.partialErrors) { - values.set(JSON.stringify([error.producer, error.code]), error) - } - } - if (hasIncompleteHosts) { - const error: SafeCatalogPartialError = Object.freeze({ - producer: 'host', - code: 'operational_source_unavailable', - retryable: true, - }) - values.set(JSON.stringify([error.producer, error.code]), error) - } - return [...values.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([, value]) => value) -} - -async function hydrateSelected( - context: CatalogRequestContext, - selected: readonly CatalogIdentityCandidate[] -): Promise<{ - resources: Map - incompleteRoots: ReadonlyMap -}> { - const byFamily = new Map() - for (const candidate of selected) { - const values = byFamily.get(candidate.key[1]) ?? [] - values.push(candidate.key) - byFamily.set(candidate.key[1], values) - } - const hydratedValues: HydratedCatalogResource[] = [] - const incompleteRoots: CatalogHydrationIncompleteRoot[] = [] - for (const family of CATALOG_FAMILIES) { - const keys = byFamily.get(family) - if (!keys?.length) continue - const values = await requireCatalogProducer(family).hydrateCanonicalKeys(context, keys) - if (Array.isArray(values)) { - hydratedValues.push(...values) - } else { - const result = values as CatalogHydrationResult - if (!Array.isArray(result.resources) || !Array.isArray(result.incompleteRoots)) { - throw new AccessCatalogRequestError('authority_unavailable') - } - hydratedValues.push(...result.resources) - incompleteRoots.push(...result.incompleteRoots) - } - } - const resources = mergeHydratedCatalogResources(hydratedValues) - const selectedByKey = new Map( - selected.map(candidate => [JSON.stringify(candidate.key), candidate]) - ) - const incompleteByKey = new Map() - for (const incomplete of incompleteRoots) { - const key = JSON.stringify(incomplete.key) - const candidate = selectedByKey.get(key) - if ( - !candidate || - candidate.key[1] !== 'host' || - (incomplete.reason !== 'configured_context_missing' && - incomplete.reason !== 'configured_context_ambiguous') || - incompleteByKey.has(key) || - resources.has(key) - ) { - throw new AccessCatalogRequestError('authority_unavailable') - } - incompleteByKey.set(key, incomplete.reason) - } - for (const candidate of selected) { - const key = JSON.stringify(candidate.key) - if (!resources.has(key) && !incompleteByKey.has(key)) { - throw new AccessCatalogRequestError('authority_unavailable') - } - } - return { resources, incompleteRoots: incompleteByKey } -} - -function catalogItem( - context: CatalogRequestContext, - hydrated: HydratedCatalogResource -): AccessCatalogItem { - const identitySeeds = canonicalAccessPathSeeds(hydrated.accessPaths) - const revision = authorizationRevision({ - principalUserId: context.principal.userId, - sessionContract: context.principal.sessionContract, - sessionRevision: context.principal.sessionRevision, - userRevision: context.principal.userRevision, - memberships: context.principal.memberships, - resource: hydrated.resource, - resourceRevision: hydrated.authorizationResourceRevision, - sourceStateRevision: hydrated.authorizationSourceRevision, - relationshipsRevision: hydrated.authorizationRelationshipsRevision, - candidates: identitySeeds, - }) - const paths = identitySeeds - .map(seed => - buildAccessPath({ - principalUserId: context.principal.userId, - resource: hydrated.resource, - seed, - authorizationRevision: revision, - }) - ) - .sort(compareCanonicalAccessPaths) - const publicPaths: PublicCatalogAccessPath[] = paths.map(path => - Object.freeze({ - accessPathId: path.id, - kind: path.kind, - ...(path.kind === 'team' - ? { - safeTeamDescriptor: Object.freeze({ - teamId: path.teamId!, - currentRole: path.currentRole!, - }), - } - : {}), - capabilities: path.behavior.capabilities, - behaviorDescriptors: path.behavior, - }) - ) - return Object.freeze({ - resource: Object.freeze({ - ...hydrated.resource, - resourceRevision: hydrated.authorizationResourceRevision, - }), - relationships: hydrated.relationships, - capabilities: Object.freeze( - normalizeAccessCapabilities(paths.flatMap(path => path.behavior.capabilities)) - ), - accessPaths: Object.freeze(publicPaths), - }) -} - -function finalContinuations(input: { - requestedFamilies: readonly CatalogFamily[] - runtimes: readonly ProducerRuntime[] - selected: readonly CatalogIdentityCandidate[] - initial: Readonly> -}): Record { - const requested = new Set(input.requestedFamilies) - const output = emptyContinuations() - for (const family of CATALOG_FAMILIES) { - if (!requested.has(family)) { - output[family] = Object.freeze({ afterKey: null, exhausted: true }) - continue - } - const runtime = input.runtimes.find(value => value.family === family)! - const emitted = input.selected.filter(candidate => candidate.key[1] === family) - const afterKey = emitted.at(-1)?.key ?? input.initial[family].afterKey - const hasUnconsumed = runtime.page.candidates.length > emitted.length - output[family] = Object.freeze({ - afterKey, - exhausted: - runtime.page.sourceCompleteness === 'complete' && !runtime.page.hasMore && !hasUnconsumed, - }) - } - return output -} - -function earliestValidUntil( - candidates: readonly CatalogIdentityCandidate[], - hydrated: ReadonlyMap -): string | null { - const values = [ - ...candidates.flatMap(candidate => (candidate.validUntil ? [candidate.validUntil] : [])), - ...[...hydrated.values()].flatMap(value => (value.validUntil ? [value.validUntil] : [])), - ] - return values.length > 0 ? values.sort()[0] : null -} - -function responseBytes(value: unknown): number { - try { - return Math.max(1, Buffer.byteLength(JSON.stringify(value), 'utf8')) - } catch { - throw new AccessCatalogRequestError('catalog_invariant_failed') - } -} - -async function buildInTransaction(input: { - db: DbClient - budget: AccessExecutionBudget - session: ExternalSessionAuthorityContext - environmentId: string - families: readonly CatalogFamily[] - limit: number - decodedCursor: AccessCatalogCursorV3 | null - filterHash: string - now: Date -}): Promise { - await configureAccessAuthorityTransaction(input.db, input.budget) - const context = await loadCatalogRequestContext({ - db: input.db, - budget: input.budget, - session: input.session, - environmentId: input.environmentId, - }) - const sourceStateRevision = catalogSourceStateRevision(context.sourceStates) - if (input.decodedCursor) { - assertAccessCatalogCursorCurrent(input.decodedCursor, { - authorizationRevision: context.principal.authorizationRevision, - sourceStateRevision, - filterHash: input.filterHash, - now: input.now, - }) - } - const initial = input.decodedCursor?.producers ?? emptyContinuations() - const runtimes: ProducerRuntime[] = [] - for (const family of input.families) { - const page = await requireCatalogProducer(family).listCanonicalKeys( - context, - initial[family], - input.limit - ) - runtimes.push({ - family, - page, - }) - } - const merged = mergeCatalogCandidateStreams( - runtimes.map(runtime => ({ streamId: runtime.family, candidates: runtime.page.candidates })), - input.limit + 1 - ) - const selected = merged.slice(0, input.limit) - const hydration = await hydrateSelected(context, selected) - const items = selected.flatMap(candidate => { - const key = JSON.stringify(candidate.key) - if (hydration.incompleteRoots.has(key)) return [] - const resource = hydration.resources.get(key) - if (!resource) throw new AccessCatalogRequestError('authority_unavailable') - return [catalogItem(context, resource)] - }) - const partialErrors = safePartialErrors(runtimes, hydration.incompleteRoots.size > 0) - const continuations = finalContinuations({ - requestedFamilies: input.families, - runtimes, - selected, - initial, - }) - const hasNext = - merged.length > input.limit || - runtimes.some(runtime => { - const continuation = continuations[runtime.family] - return runtime.page.sourceCompleteness === 'complete' && !continuation.exhausted - }) - const validUntil = earliestValidUntil(merged, hydration.resources) - const lastCanonicalKey = selected.at(-1)?.key - const nextCursor = - hasNext && lastCanonicalKey - ? encodeAccessCatalogCursor( - { - v: 3, - contractVersion: ACCESS_CATALOG_CONTRACT_VERSION, - authorizationRevision: context.principal.authorizationRevision, - sourceStateRevision, - filterHash: input.filterHash, - sort: ACCESS_CATALOG_SORT, - lastCanonicalKey, - producers: continuations, - validUntil, - }, - input.budget - ) - : null - const response: AccessCatalogResponse = Object.freeze({ - contractVersion: ACCESS_CATALOG_CONTRACT_VERSION, - authorizationRevision: context.principal.authorizationRevision, - sourceStateRevision, - complete: partialErrors.length === 0, - partialErrors: Object.freeze(partialErrors), - items: Object.freeze(items), - nextCursor, - }) - input.budget.charge({ kind: 'responseBytes', amount: responseBytes(response) }) - return response -} - -export async function buildAccessCatalog( - input: { - session: ExternalSessionAuthorityContext - environmentId?: string - families?: readonly CatalogFamily[] - limit?: number - cursor?: string | null - }, - options: { - transaction?: CatalogTransaction - budget?: AccessExecutionBudget - limits?: Partial - teamGfsMembershipAdmissionLimit?: number - now?: Date - } = {} -): Promise { - const environmentId = input.environmentId ?? canonicalEnvironmentId() - if (environmentId !== canonicalEnvironmentId()) { - throw new AccessCatalogRequestError('invalid_request') - } - const families = normalizeFamilies(input.families) - const limit = input.limit ?? 50 - const budget = - options.budget ?? - AccessExecutionBudget.create('catalog', { - limits: options.limits, - teamGfsMembershipAdmissionLimit: - options.teamGfsMembershipAdmissionLimit ?? - configuredCatalogBudgetOptions.teamGfsMembershipAdmissionLimit, - }) - const ownedBudget = options.budget ? null : budget - try { - budget.assertPageSize(limit) - const filterHash = catalogFilterHash(families) - const decodedCursor = input.cursor ? decodeAccessCatalogCursor(input.cursor, budget) : null - const work = (db: DbClient) => - buildInTransaction({ - db, - budget, - session: input.session, - environmentId, - families, - limit, - decodedCursor, - filterHash, - now: options.now ?? new Date(), - }) - return await (options.transaction - ? options.transaction(work) - : withAccessDatabaseTransaction(budget, work, { mode: 'caller_configured' })) - } catch (error) { - if (error instanceof CatalogAuthorityError) { - throw new AccessCatalogRequestError( - error.code === 'session_not_live' ? 'session_not_live' : 'authority_unavailable' - ) - } - if (error instanceof CatalogProducerContractError) { - throw new AccessCatalogRequestError('authority_unavailable') - } - if (error instanceof AccessCatalogMergeError) { - throw new AccessCatalogRequestError('catalog_invariant_failed') - } - throw error - } finally { - ownedBudget?.close() - } -} - -export function catalogCoordinatorContractRevision(): string { - return revisionOfValues([ - ACCESS_CATALOG_CONTRACT_VERSION, - ACCESS_CATALOG_SORT, - CATALOG_FAMILIES, - 'request-local-only', - ]) -} diff --git a/control-api/src/services/access/accessCatalogCursor.ts b/control-api/src/services/access/accessCatalogCursor.ts deleted file mode 100644 index 0c2e5140e8..0000000000 --- a/control-api/src/services/access/accessCatalogCursor.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { createHash, createHmac, timingSafeEqual } from 'node:crypto' -import { config } from '../../config.js' -import type { AccessExecutionBudget } from './accessExecutionBudget.js' -import { - CATALOG_FAMILIES, - type CatalogFamily, - type CatalogKey, - type ProducerContinuation, -} from './catalogContracts.js' - -export const ACCESS_CATALOG_CONTRACT_VERSION = '2' as const -export const ACCESS_CATALOG_SORT = 'environment-type-logical-v1' as const - -export type AccessCatalogCursorV3 = Readonly<{ - v: 3 - contractVersion: typeof ACCESS_CATALOG_CONTRACT_VERSION - authorizationRevision: string - sourceStateRevision: string - filterHash: string - sort: typeof ACCESS_CATALOG_SORT - lastCanonicalKey: CatalogKey - producers: Readonly> - validUntil: string | null -}> - -export class AccessCatalogCursorError extends Error { - constructor(readonly code: 'invalid_cursor' | 'stale_cursor') { - super(`Access catalog cursor rejected: ${code}`) - this.name = 'AccessCatalogCursorError' - } -} - -function boundedString(value: unknown, maximum: number): string | null { - return typeof value === 'string' && value.length > 0 && value.length <= maximum ? value : null -} - -function parseKey(value: unknown): CatalogKey { - if ( - !Array.isArray(value) || - value.length !== 3 || - !boundedString(value[0], 512) || - !CATALOG_FAMILIES.includes(value[1] as CatalogFamily) || - !boundedString(value[2], 1_024) - ) { - throw new AccessCatalogCursorError('invalid_cursor') - } - return Object.freeze([String(value[0]), value[1] as CatalogFamily, String(value[2])]) -} - -function parseContinuation(value: unknown): ProducerContinuation { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new AccessCatalogCursorError('invalid_cursor') - } - const candidate = value as Record - if (typeof candidate.exhausted !== 'boolean') { - throw new AccessCatalogCursorError('invalid_cursor') - } - if (candidate.opaqueState !== undefined && boundedString(candidate.opaqueState, 2_048) === null) { - throw new AccessCatalogCursorError('invalid_cursor') - } - return Object.freeze({ - afterKey: candidate.afterKey === null ? null : parseKey(candidate.afterKey), - exhausted: candidate.exhausted, - ...(candidate.opaqueState ? { opaqueState: String(candidate.opaqueState) } : {}), - }) -} - -function payloadFromUnknown(value: unknown): AccessCatalogCursorV3 { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new AccessCatalogCursorError('invalid_cursor') - } - const input = value as Record - if ( - input.v !== 3 || - input.contractVersion !== ACCESS_CATALOG_CONTRACT_VERSION || - input.sort !== ACCESS_CATALOG_SORT - ) { - throw new AccessCatalogCursorError('invalid_cursor') - } - const authorizationRevision = boundedString(input.authorizationRevision, 256) - const sourceStateRevision = boundedString(input.sourceStateRevision, 256) - const filterHash = boundedString(input.filterHash, 256) - if (!authorizationRevision || !sourceStateRevision || !filterHash) { - throw new AccessCatalogCursorError('invalid_cursor') - } - const lastCanonicalKey = parseKey(input.lastCanonicalKey) - if (!input.producers || typeof input.producers !== 'object' || Array.isArray(input.producers)) { - throw new AccessCatalogCursorError('invalid_cursor') - } - const rawProducers = input.producers as Record - if ( - Object.keys(rawProducers).length !== CATALOG_FAMILIES.length || - Object.keys(rawProducers).some(key => !CATALOG_FAMILIES.includes(key as CatalogFamily)) - ) { - throw new AccessCatalogCursorError('invalid_cursor') - } - const producers = Object.fromEntries( - CATALOG_FAMILIES.map(family => { - const continuation = parseContinuation(rawProducers[family]) - if ( - continuation.afterKey && - (continuation.afterKey[0] !== lastCanonicalKey[0] || continuation.afterKey[1] !== family) - ) { - throw new AccessCatalogCursorError('invalid_cursor') - } - return [family, continuation] - }) - ) as Record - let validUntil: string | null = null - if (input.validUntil !== null) { - const raw = boundedString(input.validUntil, 64) - if (!raw || Number.isNaN(new Date(raw).getTime())) { - throw new AccessCatalogCursorError('invalid_cursor') - } - validUntil = new Date(raw).toISOString() - } - return Object.freeze({ - v: 3, - contractVersion: ACCESS_CATALOG_CONTRACT_VERSION, - authorizationRevision, - sourceStateRevision, - filterHash, - sort: ACCESS_CATALOG_SORT, - lastCanonicalKey, - producers: Object.freeze(producers), - validUntil, - }) -} - -function signature(encodedPayload: string): Buffer { - return createHmac('sha256', config.sessionJwtPrivateKey).update(encodedPayload).digest() -} - -export function catalogFilterHash(families: readonly CatalogFamily[]): string { - return `cf1_${createHash('sha256') - .update(JSON.stringify({ families: [...families], sort: ACCESS_CATALOG_SORT })) - .digest('base64url')}` -} - -export function encodeAccessCatalogCursor( - payload: AccessCatalogCursorV3, - budget: AccessExecutionBudget -): string { - const encoded = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url') - const cursor = `c3.${encoded}.${signature(encoded).toString('base64url')}` - budget.assertCursorBytes(Buffer.byteLength(cursor, 'utf8')) - return cursor -} - -export function decodeAccessCatalogCursor( - cursor: string, - budget: AccessExecutionBudget -): AccessCatalogCursorV3 { - budget.assertCursorBytes(Buffer.byteLength(cursor, 'utf8')) - const parts = cursor.split('.') - if (parts.length !== 3 || parts[0] !== 'c3' || !parts[1] || !parts[2]) { - throw new AccessCatalogCursorError('invalid_cursor') - } - const expected = signature(parts[1]) - let supplied: Buffer - try { - supplied = Buffer.from(parts[2], 'base64url') - } catch { - throw new AccessCatalogCursorError('invalid_cursor') - } - if (expected.length !== supplied.length || !timingSafeEqual(expected, supplied)) { - throw new AccessCatalogCursorError('invalid_cursor') - } - try { - return payloadFromUnknown(JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'))) - } catch (error) { - if (error instanceof AccessCatalogCursorError) throw error - throw new AccessCatalogCursorError('invalid_cursor') - } -} - -export function assertAccessCatalogCursorCurrent( - cursor: AccessCatalogCursorV3, - expected: { - authorizationRevision: string - sourceStateRevision: string - filterHash: string - now?: Date - } -): void { - if ( - cursor.authorizationRevision !== expected.authorizationRevision || - cursor.sourceStateRevision !== expected.sourceStateRevision || - cursor.filterHash !== expected.filterHash - ) { - throw new AccessCatalogCursorError('stale_cursor') - } - if ( - cursor.validUntil && - new Date(cursor.validUntil).getTime() <= (expected.now ?? new Date()).getTime() - ) { - throw new AccessCatalogCursorError('stale_cursor') - } -} diff --git a/control-api/src/services/access/accessCatalogMerge.ts b/control-api/src/services/access/accessCatalogMerge.ts deleted file mode 100644 index 49afe8083d..0000000000 --- a/control-api/src/services/access/accessCatalogMerge.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { - canonicalAccessPathSeeds, - canonicalAuthorizationRelationships, -} from './authorizationRevision.js' -import { - type CatalogIdentityCandidate, - type CatalogKey, - type HydratedCatalogResource, - catalogKeyEquals, - compareCatalogKey, - compareCatalogText, -} from './catalogContracts.js' -import { resourceIdentityKey } from './resourceIdentity.js' - -export class AccessCatalogMergeError extends Error { - constructor(readonly code: string) { - super(`Access catalog merge invariant failed: ${code}`) - this.name = 'AccessCatalogMergeError' - } -} - -export type CatalogCandidateStream = Readonly<{ - streamId: string - candidates: readonly CatalogIdentityCandidate[] -}> - -type StreamCursor = { - stream: CatalogCandidateStream - index: number -} - -type HeapEntry = Readonly<{ - cursor: StreamCursor - candidate: CatalogIdentityCandidate -}> - -function entryCompare(left: HeapEntry, right: HeapEntry): number { - const keyOrder = compareCatalogKey(left.candidate.key, right.candidate.key) - return keyOrder || compareCatalogText(left.cursor.stream.streamId, right.cursor.stream.streamId) -} - -function heapPush(heap: HeapEntry[], entry: HeapEntry): void { - heap.push(entry) - let index = heap.length - 1 - while (index > 0) { - const parent = Math.floor((index - 1) / 2) - if (entryCompare(heap[parent], heap[index]) <= 0) break - ;[heap[parent], heap[index]] = [heap[index], heap[parent]] - index = parent - } -} - -function heapPop(heap: HeapEntry[]): HeapEntry | undefined { - const first = heap[0] - const last = heap.pop() - if (!first || !last || heap.length === 0) return first - heap[0] = last - let index = 0 - for (;;) { - const left = index * 2 + 1 - const right = left + 1 - let smallest = index - if (left < heap.length && entryCompare(heap[left], heap[smallest]) < 0) smallest = left - if (right < heap.length && entryCompare(heap[right], heap[smallest]) < 0) smallest = right - if (smallest === index) break - ;[heap[index], heap[smallest]] = [heap[smallest], heap[index]] - index = smallest - } - return first -} - -function validateCandidateStream(stream: CatalogCandidateStream): void { - if (!stream.streamId) throw new AccessCatalogMergeError('stream_id_missing') - for (let index = 1; index < stream.candidates.length; index += 1) { - if (compareCatalogKey(stream.candidates[index - 1].key, stream.candidates[index].key) >= 0) { - throw new AccessCatalogMergeError('stream_not_strictly_ordered') - } - } -} - -function advance(heap: HeapEntry[], cursor: StreamCursor): void { - cursor.index += 1 - const candidate = cursor.stream.candidates[cursor.index] - if (candidate) heapPush(heap, { cursor, candidate }) -} - -/** - * Deterministically merges already-keyset-paged producer streams. The bound is - * an output bound (normally pageSize + 1), not a hint: no later candidate is - * inspected after the bound has been satisfied. - */ -export function mergeCatalogCandidateStreams( - streams: readonly CatalogCandidateStream[], - maximumOutputs: number -): CatalogIdentityCandidate[] { - if (!Number.isSafeInteger(maximumOutputs) || maximumOutputs < 0) { - throw new AccessCatalogMergeError('output_bound_invalid') - } - const streamIds = new Set() - const heap: HeapEntry[] = [] - for (const stream of streams) { - validateCandidateStream(stream) - if (streamIds.has(stream.streamId)) { - throw new AccessCatalogMergeError('stream_id_duplicate') - } - streamIds.add(stream.streamId) - const cursor: StreamCursor = { stream, index: 0 } - const candidate = stream.candidates[0] - if (candidate) heapPush(heap, { cursor, candidate }) - } - - const values: CatalogIdentityCandidate[] = [] - while (heap.length > 0 && values.length < maximumOutputs) { - const first = heapPop(heap)! - const equal = [first] - while (heap[0] && catalogKeyEquals(heap[0].candidate.key, first.candidate.key)) { - equal.push(heapPop(heap)!) - } - if (equal.some(entry => entry.candidate.canonicalId !== first.candidate.canonicalId)) { - throw new AccessCatalogMergeError('canonical_id_conflict') - } - const validUntilValues = equal - .flatMap(entry => (entry.candidate.validUntil ? [entry.candidate.validUntil] : [])) - .sort() - for (const entry of equal) advance(heap, entry.cursor) - values.push( - Object.freeze({ - ...first.candidate, - validUntil: validUntilValues[0] ?? null, - }) - ) - } - return values -} - -function keyString(key: CatalogKey): string { - return JSON.stringify(key) -} - -function assertHydratedIdentity(value: HydratedCatalogResource): void { - if ( - value.key[0] !== value.resource.environmentId || - value.key[1] !== value.resource.type || - value.key[2] !== value.resource.logicalId || - value.resource.canonicalId !== `${value.resource.type}:${value.resource.logicalId}` - ) { - throw new AccessCatalogMergeError('hydrated_identity_mismatch') - } -} - -function canonicalHydratedResource(value: HydratedCatalogResource): HydratedCatalogResource { - assertHydratedIdentity(value) - return Object.freeze({ - ...value, - accessPaths: Object.freeze(canonicalAccessPathSeeds(value.accessPaths)), - relationships: Object.freeze(canonicalAuthorizationRelationships(value.relationships)), - }) -} - -/** - * Merges fragments only when they describe the exact same immutable resource - * and authoritative snapshot. Equal paths and relationships are deduplicated; - * every non-equal path and relationship is retained. - */ -export function mergeHydratedCatalogResources( - values: readonly HydratedCatalogResource[] -): Map { - const merged = new Map() - for (const raw of values) { - const value = canonicalHydratedResource(raw) - const key = keyString(value.key) - const prior = merged.get(key) - if (!prior) { - merged.set(key, value) - continue - } - if ( - resourceIdentityKey(prior.resource) !== resourceIdentityKey(value.resource) || - JSON.stringify(prior.resource) !== JSON.stringify(value.resource) - ) { - throw new AccessCatalogMergeError('resource_identity_conflict') - } - if ( - prior.authorizationResourceRevision !== value.authorizationResourceRevision || - prior.authorizationSourceRevision !== value.authorizationSourceRevision || - prior.authorizationRelationshipsRevision !== value.authorizationRelationshipsRevision - ) { - throw new AccessCatalogMergeError('authorization_revision_conflict') - } - merged.set( - key, - Object.freeze({ - ...prior, - accessPaths: Object.freeze( - canonicalAccessPathSeeds([...prior.accessPaths, ...value.accessPaths]) - ), - relationships: Object.freeze( - canonicalAuthorizationRelationships([...prior.relationships, ...value.relationships]) - ), - validUntil: - [prior.validUntil, value.validUntil] - .filter((item): item is string => Boolean(item)) - .sort()[0] ?? null, - }) - ) - } - return merged -} diff --git a/control-api/src/services/access/accessCatalogShadow.ts b/control-api/src/services/access/accessCatalogShadow.ts deleted file mode 100644 index 36a2342b37..0000000000 --- a/control-api/src/services/access/accessCatalogShadow.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { - aggregateAccessShadowComparisonsTotal, - aggregateAccessShadowDifferencesTotal, -} from '../../observability/metrics.js' -import type { ExternalSessionAuthorityContext } from '../auth/externalSessionAuthentication.js' -import { type AccessCatalogItem, buildAccessCatalog } from './accessCatalogCoordinator.js' -import { - ACCESS_EXECUTION_LIMIT_CLAMPS, - AccessBudgetExceededError, - AccessExecutionBudget, - AccessExecutionCancelledError, -} from './accessExecutionBudget.js' -import type { CatalogFamily } from './catalogContracts.js' -import { configuredCatalogBudgetOptions, configuredUserAccessIntent } from './userAccessPolicy.js' -import { resolveEffectiveUserAccessPolicy } from './userAccessRuntimePolicy.js' - -const SHADOW_MAX_IDENTITIES = 100 -const SHADOW_MAX_IN_FLIGHT = ACCESS_EXECUTION_LIMIT_CLAMPS.producerConcurrency -let shadowInFlight = 0 - -export type CatalogShadowScope = - | Readonly<{ - kind: 'relationship' - type?: string - targetResourceId: string - }> - | Readonly<{ - kind: 'behavior-json' - dimension: 'filesystemScope' | 'runtime' - field: string - equals: string - }> - -export type CatalogShadowOutcome = - | 'match' - | 'catalog_only' - | 'legacy_only' - | 'both_differ' - | 'partial' - | 'skipped_capacity' - | 'skipped_unavailable' - | 'skipped_legacy_incomplete' - -function jsonBehaviorMatches( - item: AccessCatalogItem, - scope: Extract -): boolean { - return item.accessPaths.some(path => { - const dimension = path.behaviorDescriptors[scope.dimension] - if (dimension.state !== 'known' || typeof dimension.value !== 'string') return false - try { - const value = JSON.parse(dimension.value) as Record - return String(value?.[scope.field] ?? '') === scope.equals - } catch { - return false - } - }) -} - -function inScope(item: AccessCatalogItem, scope: CatalogShadowScope | undefined): boolean { - if (!scope) return true - if (scope.kind === 'behavior-json') return jsonBehaviorMatches(item, scope) - return item.relationships.some( - relationship => - relationship.targetResourceId === scope.targetResourceId && - (!scope.type || relationship.type === scope.type) - ) -} - -function safeLegacyIds(values: readonly string[]): Set | null { - if (values.length > SHADOW_MAX_IDENTITIES) return null - const output = new Set() - for (const value of values) { - const normalized = value.trim() - if (!normalized || normalized.length > 1_024 || /[\u0000-\u001f\u007f]/u.test(normalized)) { - return null - } - output.add(normalized) - } - return output -} - -function record( - family: CatalogFamily, - outcome: CatalogShadowOutcome, - catalogOnly = 0, - legacyOnly = 0 -): CatalogShadowOutcome { - aggregateAccessShadowComparisonsTotal.inc({ family, outcome }, 1) - if (catalogOnly > 0) { - aggregateAccessShadowDifferencesTotal.inc({ family, direction: 'catalog_only' }, catalogOnly) - } - if (legacyOnly > 0) { - aggregateAccessShadowDifferencesTotal.inc({ family, direction: 'legacy_only' }, legacyOnly) - } - return outcome -} - -export async function compareAccessCatalogShadow( - input: { - session: ExternalSessionAuthorityContext - family: CatalogFamily - legacyLogicalIds: readonly string[] - legacyComplete: boolean - scope?: CatalogShadowScope - }, - options: { - enabled?: boolean - buildCatalog?: typeof buildAccessCatalog - budget?: AccessExecutionBudget - } = {} -): Promise { - if (!input.legacyComplete) return record(input.family, 'skipped_legacy_incomplete') - const legacy = safeLegacyIds(input.legacyLogicalIds) - if (!legacy) return record(input.family, 'skipped_legacy_incomplete') - if (options.enabled === undefined) { - try { - const policy = await resolveEffectiveUserAccessPolicy({ catalogReadiness: true }) - if (!policy.computeCatalogShadow) return 'skipped_unavailable' - } catch { - return record(input.family, 'skipped_unavailable') - } - } else if (!options.enabled) { - return 'skipped_unavailable' - } - - const parent = - options.budget ?? AccessExecutionBudget.create('catalog', configuredCatalogBudgetOptions) - let child: AccessExecutionBudget | undefined - try { - child = parent.child({ - producerCalls: 8, - databaseStatements: 109, - objects: 200, - decodedBytes: 2 * 1024 * 1024, - accessPaths: 512, - relationships: 1_024, - dbRowsReturned: 1_024, - responseBytes: 2 * 1024 * 1024, - }) - const catalog = await (options.buildCatalog ?? buildAccessCatalog)( - { session: input.session, families: [input.family], limit: SHADOW_MAX_IDENTITIES }, - { budget: child } - ) - if (!catalog.complete || catalog.nextCursor) return record(input.family, 'partial') - const aggregate = new Set( - catalog.items.filter(item => inScope(item, input.scope)).map(item => item.resource.logicalId) - ) - const catalogOnly = [...aggregate].filter(value => !legacy.has(value)).length - const legacyOnly = [...legacy].filter(value => !aggregate.has(value)).length - const outcome: CatalogShadowOutcome = - catalogOnly === 0 && legacyOnly === 0 - ? 'match' - : catalogOnly > 0 && legacyOnly > 0 - ? 'both_differ' - : catalogOnly > 0 - ? 'catalog_only' - : 'legacy_only' - return record(input.family, outcome, catalogOnly, legacyOnly) - } catch (error) { - if ( - error instanceof AccessBudgetExceededError || - error instanceof AccessExecutionCancelledError - ) { - return record(input.family, 'skipped_capacity') - } - return record(input.family, 'skipped_unavailable') - } finally { - child?.close() - if (!options.budget) parent.close() - } -} - -export function scheduleAccessCatalogShadow(input: { - session: ExternalSessionAuthorityContext | undefined - family: CatalogFamily - legacyLogicalIds: readonly string[] - legacyComplete: boolean - scope?: CatalogShadowScope -}): void { - if (!input.session || configuredUserAccessIntent.catalogMode === 'off') return - if (shadowInFlight >= SHADOW_MAX_IN_FLIGHT) { - record(input.family, 'skipped_capacity') - return - } - shadowInFlight += 1 - setImmediate(() => { - void compareAccessCatalogShadow({ ...input, session: input.session! }).finally(() => { - shadowInFlight = Math.max(0, shadowInFlight - 1) - }) - }) -} diff --git a/control-api/src/services/access/catalogAuthorityContext.ts b/control-api/src/services/access/catalogAuthorityContext.ts deleted file mode 100644 index 9453f79dc2..0000000000 --- a/control-api/src/services/access/catalogAuthorityContext.ts +++ /dev/null @@ -1,204 +0,0 @@ -import type { DbClient } from '../../db.js' -import type { ExternalSessionAuthorityContext } from '../auth/externalSessionAuthentication.js' -import { legacySecurityCutoffRejectsSql } from '../auth/legacySecurityCutoff.js' -import type { AccessExecutionBudget } from './accessExecutionBudget.js' -import { revisionOfValues } from './authorizationRevision.js' -import type { - CatalogOperationalSourceState, - CatalogPrincipalSnapshot, - CatalogRequestContext, -} from './catalogContracts.js' -import { catalogQuery } from './catalogProducerSupport.js' -import { - OPERATIONAL_SOURCE_FAMILIES, - type OperationalSourceFamily, -} from './operationalAccessProjection.js' -import { parseAuthorizationMemberships } from './principalAuthoritySnapshot.js' - -export class CatalogAuthorityError extends Error { - constructor(readonly code: 'principal_not_found' | 'session_not_live' | 'source_state_invalid') { - super(`Catalog authority snapshot failed: ${code}`) - this.name = 'CatalogAuthorityError' - } -} - -function sourceState(row: Record): CatalogOperationalSourceState { - const family = String(row.source_family) as OperationalSourceFamily - const status = String(row.status) - if ( - !OPERATIONAL_SOURCE_FAMILIES.includes(family) || - !['current', 'relisting', 'unavailable'].includes(status) - ) { - throw new CatalogAuthorityError('source_state_invalid') - } - return Object.freeze({ - family, - generation: String(row.generation), - resourceVersion: - row.resource_version === null || row.resource_version === undefined - ? null - : String(row.resource_version), - status: status as CatalogOperationalSourceState['status'], - }) -} - -function catalogAuthorizationRevision(principal: { - sessionContract: 'v1' | 'v2' - sessionRevision: string - userRevision: string - memberships: CatalogPrincipalSnapshot['memberships'] -}): string { - return `car2_${revisionOfValues([ - 'catalog-authority-v2', - principal.sessionContract, - principal.sessionRevision, - principal.userRevision, - ...principal.memberships.map(membership => [ - membership.teamId, - membership.role, - membership.membershipUpdatedAt, - membership.teamRevision, - ]), - ])}` -} - -export function catalogSourceStateRevision( - sourceStates: ReadonlyMap -): string { - return `csr1_${revisionOfValues( - OPERATIONAL_SOURCE_FAMILIES.map(family => { - const state = sourceStates.get(family) - return state - ? [family, state.generation, state.resourceVersion, state.status] - : [family, 'missing', null, 'unavailable'] - }) - )}` -} - -export async function loadCatalogRequestContext(input: { - db: Pick - budget: AccessExecutionBudget - session: ExternalSessionAuthorityContext - environmentId: string -}): Promise { - const principalResult = await catalogQuery( - input.db, - input.budget, - `WITH active_memberships AS ( - SELECT membership.team_id, membership.role, membership.updated_at, - COALESCE(team_revision.revision, 1) AS team_revision - FROM team_members membership - LEFT JOIN authorization_team_revisions team_revision - ON team_revision.team_id = membership.team_id - WHERE membership.user_id = $1 AND membership.status = 'active' - ) - SELECT users.id AS user_id, - COALESCE(user_revision.revision, 1)::text AS user_revision, - CASE - WHEN users.lifecycle_state <> 'active' THEN FALSE - WHEN $2::text = 'v2' THEN EXISTS ( - SELECT 1 - FROM external_user_sessions session - WHERE session.sid::text = $3 AND session.user_id = users.id - AND session.session_version = $5 AND session.revoked_at IS NULL - AND session.idle_expires_at > NOW() AND session.absolute_expires_at > NOW() - AND ( - session.current_jti::text = $4 - OR ( - session.prior_jti::text = $4 - AND session.prior_jti_expires_at >= NOW() - ) - ) - ) - ELSE ( - $7::bigint IS NOT NULL - AND $8::bigint IS NOT NULL - AND users.lifecycle_version = $8::bigint - AND NOT EXISTS ( - SELECT 1 FROM external_v1_session_revocations revoked - WHERE revoked.token_hash = $6 AND revoked.user_id = users.id - AND revoked.expires_at > NOW() - ) - AND NOT EXISTS ( - SELECT 1 FROM external_user_session_security_epochs epoch - WHERE epoch.user_id = users.id - AND ${legacySecurityCutoffRejectsSql('$7::bigint', 'epoch.valid_after')} - ) - ) - END AS session_live, - CASE - WHEN $2::text = 'v2' THEN COALESCE(( - SELECT session.session_version::text || ':' || session.current_jti::text || ':' || - COALESCE(session.revoked_at::text, '') || ':' || - session.absolute_expires_at::text - FROM external_user_sessions session - WHERE session.sid::text = $3 AND session.user_id = users.id - ), 'missing') - ELSE COALESCE(( - SELECT EXTRACT(EPOCH FROM epoch.valid_after)::text - FROM external_user_session_security_epochs epoch - WHERE epoch.user_id = users.id - ), '0') || ':' || $6 - END AS session_revision, - COALESCE( - jsonb_agg( - jsonb_build_object( - 'teamId', membership.team_id, - 'role', membership.role, - 'membershipUpdatedAt', membership.updated_at, - 'teamRevision', membership.team_revision - ) ORDER BY membership.team_id - ) FILTER (WHERE membership.team_id IS NOT NULL), - '[]'::jsonb - ) AS memberships - FROM users - LEFT JOIN authorization_user_revisions user_revision ON user_revision.user_id = users.id - LEFT JOIN active_memberships membership ON TRUE - WHERE users.id = $1 - GROUP BY users.id, user_revision.revision`, - [ - input.session.userId, - input.session.contract, - input.session.contract === 'v2' ? input.session.sid : null, - input.session.contract === 'v2' ? input.session.jti : null, - input.session.contract === 'v2' ? input.session.sessionVersion : null, - input.session.contract === 'v1' ? input.session.tokenHash : null, - input.session.contract === 'v1' ? input.session.issuedAt : null, - input.session.contract === 'v1' ? input.session.authGeneration : null, - ] - ) - const row = principalResult.rows[0] as Record | undefined - if (!row) throw new CatalogAuthorityError('principal_not_found') - if (row.session_live !== true) throw new CatalogAuthorityError('session_not_live') - const memberships = Object.freeze(parseAuthorizationMemberships(row.memberships)) - const partialPrincipal = { - userId: String(row.user_id), - sessionContract: input.session.contract, - sessionRevision: String(row.session_revision), - userRevision: String(row.user_revision ?? '1'), - memberships, - } as const - const principal: CatalogPrincipalSnapshot = Object.freeze({ - ...partialPrincipal, - authorizationRevision: catalogAuthorizationRevision(partialPrincipal), - }) - - const sourceResult = await catalogQuery( - input.db, - input.budget, - `SELECT source_family, generation, resource_version, status - FROM operational_catalog_source_state - WHERE environment_id = $1 - AND source_family = ANY($2::text[]) - ORDER BY source_family`, - [input.environmentId, OPERATIONAL_SOURCE_FAMILIES] - ) - const states = (sourceResult.rows as Record[]).map(sourceState) - return Object.freeze({ - db: input.db, - budget: input.budget, - principal, - environmentId: input.environmentId, - sourceStates: new Map(states.map(state => [state.family, state])), - }) -} diff --git a/control-api/src/services/access/catalogContracts.ts b/control-api/src/services/access/catalogContracts.ts deleted file mode 100644 index 049a3e97e9..0000000000 --- a/control-api/src/services/access/catalogContracts.ts +++ /dev/null @@ -1,155 +0,0 @@ -import type { DbClient } from '../../db.js' -import type { TeamRole } from '../../profileTypes.js' -import type { AccessExecutionBudget } from './accessExecutionBudget.js' -import type { AccessPathSeed } from './accessPath.js' -import { compareCanonicalUtf8Text } from './canonicalText.js' -import type { OperationalSourceFamily } from './operationalAccessProjection.js' -import type { CanonicalResourceIdentity } from './resourceIdentity.js' - -export const CATALOG_FAMILIES = [ - 'user', - 'team', - 'host', - 'context', - 'mcp_server', - 'workflow_recipe', - 'workflow_run', - 'workflow_approval', - 'notification', - 'gfs_resource', - 'shared_filesystem', - 'sandbox_app', -] as const - -export type CatalogFamily = (typeof CATALOG_FAMILIES)[number] -export type CatalogKey = readonly [ - environmentId: string, - resourceType: CatalogFamily, - logicalId: string, -] - -export type ProducerContinuation = Readonly<{ - afterKey: CatalogKey | null - exhausted: boolean - opaqueState?: string -}> - -export type CatalogIdentityCandidate = Readonly<{ - key: CatalogKey - canonicalId: string - validUntil: string | null -}> - -export type SafeCatalogPartialError = Readonly<{ - producer: CatalogFamily - code: 'operational_source_unavailable' | 'operational_source_relisting' - retryable: true -}> - -export type CatalogProducerPage = Readonly<{ - candidates: readonly CatalogIdentityCandidate[] - continuation: ProducerContinuation - hasMore: boolean - sourceRevision: string - sourceCompleteness: 'complete' | 'partial' - partialErrors: readonly SafeCatalogPartialError[] -}> - -export type CatalogRelationship = Readonly<{ - type: string - targetResourceId: string - instanceId?: string -}> - -export type HydratedCatalogResource = Readonly<{ - key: CatalogKey - resource: CanonicalResourceIdentity - accessPaths: readonly AccessPathSeed[] - relationships: readonly CatalogRelationship[] - authorizationResourceRevision: string - authorizationSourceRevision: string - authorizationRelationshipsRevision: string - validUntil: string | null -}> - -export type CatalogHydrationIncompleteRoot = Readonly<{ - key: CatalogKey - reason: 'configured_context_missing' | 'configured_context_ambiguous' -}> - -/** Internal producer signal for selected roots omitted due to configured-reference integrity. */ -export type CatalogHydrationResult = Readonly<{ - resources: readonly HydratedCatalogResource[] - incompleteRoots: readonly CatalogHydrationIncompleteRoot[] -}> - -/** Keep the unchanged fast path for complete pages; incomplete roots use the typed result. */ -export type CatalogHydrationOutcome = readonly HydratedCatalogResource[] | CatalogHydrationResult - -export type CatalogPrincipalSnapshot = Readonly<{ - userId: string - sessionContract: 'v1' | 'v2' - sessionRevision: string - userRevision: string - authorizationRevision: string - memberships: readonly Readonly<{ - teamId: string - role: TeamRole - membershipUpdatedAt: string - teamRevision: string - }>[] -}> - -export type CatalogOperationalSourceState = Readonly<{ - family: OperationalSourceFamily - generation: string - resourceVersion: string | null - status: 'current' | 'relisting' | 'unavailable' -}> - -export type CatalogRequestContext = Readonly<{ - db: Pick - budget: AccessExecutionBudget - principal: CatalogPrincipalSnapshot - environmentId: string - sourceStates: ReadonlyMap -}> - -export interface CatalogProducer { - readonly family: CatalogFamily - readonly requiredOperationalSources: readonly OperationalSourceFamily[] - listCanonicalKeys( - context: CatalogRequestContext, - continuation: ProducerContinuation, - take: number - ): Promise - hydrateCanonicalKeys( - context: CatalogRequestContext, - keys: readonly CatalogKey[] - ): Promise -} - -export function catalogKey( - environmentId: string, - family: CatalogFamily, - logicalId: string -): CatalogKey { - return Object.freeze([environmentId, family, logicalId]) -} - -/** Exact lexicographic ordering of the unsigned UTF-8 bytes for stored catalog text. */ -export function compareCatalogText(left: string, right: string): number { - return compareCanonicalUtf8Text(left, right) -} - -export function compareCatalogKey(left: CatalogKey, right: CatalogKey): number { - return ( - compareCatalogText(left[0], right[0]) || - compareCatalogText(left[1], right[1]) || - compareCatalogText(left[2], right[2]) - ) -} - -export function catalogKeyEquals(left: CatalogKey, right: CatalogKey): boolean { - return left[0] === right[0] && left[1] === right[1] && left[2] === right[2] -} diff --git a/control-api/src/services/access/catalogHydrationSql.ts b/control-api/src/services/access/catalogHydrationSql.ts deleted file mode 100644 index 5a0cc3ded9..0000000000 --- a/control-api/src/services/access/catalogHydrationSql.ts +++ /dev/null @@ -1,711 +0,0 @@ -import { canonicalContextLogicalIdSql } from './contextIdentitySql.js' -import { workflowTriggerGrantIdentitySql } from './workflowTriggerGrantIdentity.js' - -const directWorkflowTriggerGrantId = workflowTriggerGrantIdentitySql('direct', 'access_grant') -const teamWorkflowTriggerGrantId = workflowTriggerGrantIdentitySql('team', 'access_grant') - -export const USER_HYDRATION_SQL = ` - WITH requested AS (SELECT UNNEST($2::text[]) AS logical_id) - SELECT u.id::text AS logical_id, - COALESCE(NULLIF(u.name, ''), u.email, u.id::text) AS display_name, - NULL::text AS provider_uid, - COALESCE(arr.revision, 1)::text AS resource_revision, - jsonb_build_array(jsonb_build_object( - 'kind', 'direct', - 'grant_id', 'users:' || u.id::text - )) AS paths, - '[]'::jsonb AS relationships, - NULL::timestamptz AS valid_until - FROM requested requested - JOIN users u ON u.id::text = requested.logical_id AND u.id = $1 - LEFT JOIN authorization_resource_revisions arr - ON arr.environment_id = $3 AND arr.resource_type = 'user' - AND arr.resource_id = u.id::text - ORDER BY u.id::text` - -export const TEAM_HYDRATION_SQL = ` - WITH requested AS (SELECT UNNEST($2::text[]) AS logical_id) - SELECT team.id::text AS logical_id, - team.name AS display_name, - NULL::text AS provider_uid, - COALESCE(arr.revision, 1)::text AS resource_revision, - jsonb_build_array(jsonb_build_object( - 'kind', 'team', - 'grant_id', 'team_members:' || membership.team_id || ':' || membership.user_id, - 'team_id', membership.team_id, - 'current_role', membership.role - )) AS paths, - jsonb_build_array(jsonb_build_object( - 'type', 'membership', - 'targetResourceId', 'user:' || membership.user_id - )) AS relationships, - NULL::timestamptz AS valid_until - FROM requested requested - JOIN teams team ON team.id::text = requested.logical_id - JOIN team_members membership - ON membership.team_id = team.id AND membership.user_id = $1 - AND membership.status = 'active' - LEFT JOIN authorization_resource_revisions arr - ON arr.environment_id = $3 AND arr.resource_type = 'team' - AND arr.resource_id = team.id::text - ORDER BY team.id::text` - -export const SIMPLE_OPERATIONAL_HYDRATION_SQL = ` - WITH requested AS MATERIALIZED ( - SELECT UNNEST($2::text[]) AS logical_id - ), resources AS MATERIALIZED ( - SELECT resource.logical_id, resource.display_name, resource.provider_uid, - resource.resource_type, COALESCE(revision.revision, 1)::text AS resource_revision - FROM requested requested - JOIN operational_resource_index resource - ON resource.environment_id = $3 - AND resource.resource_type = $4 - AND resource.logical_id = requested.logical_id - AND resource.enabled = TRUE AND resource.deleted_at IS NULL - LEFT JOIN authorization_resource_revisions revision - ON revision.environment_id = resource.environment_id - AND revision.resource_type = resource.resource_type - AND revision.resource_id = resource.logical_id - ), paths AS MATERIALIZED ( - SELECT * FROM ( - SELECT resource.logical_id, 'direct'::text AS kind, - 'user_agents:' || access_grant.user_id || ':' || access_grant.agent_name AS grant_id, - NULL::uuid AS team_id, NULL::text AS current_role - FROM resources resource - JOIN user_agents access_grant - ON $4::text = 'host' - AND resource.logical_id = $5::text || '/' || access_grant.agent_name - WHERE access_grant.user_id = $1 - UNION ALL - SELECT resource.logical_id, 'team', - 'team_agents:' || access_grant.team_id || ':' || access_grant.agent_name, - access_grant.team_id, membership.role - FROM resources resource - JOIN team_agents access_grant - ON $4::text = 'host' - AND resource.logical_id = $5::text || '/' || access_grant.agent_name - JOIN team_members membership - ON membership.team_id = access_grant.team_id AND membership.user_id = $1 - AND membership.status = 'active' - UNION ALL - SELECT resource.logical_id, 'direct', - 'user_contexts:' || access_grant.user_id || ':' || access_grant.context_id, - NULL, NULL - FROM resources resource - JOIN user_contexts access_grant - ON $4::text = 'context' AND access_grant.user_id = $1 - CROSS JOIN LATERAL ( - SELECT ${canonicalContextLogicalIdSql('$3', "$6::text || '/' || access_grant.context_id")} - AS canonical_id - ) context_identity - WHERE resource.logical_id = context_identity.canonical_id - UNION ALL - SELECT resource.logical_id, 'team', - 'team_contexts:' || access_grant.team_id || ':' || access_grant.context_id, - access_grant.team_id, membership.role - FROM resources resource - JOIN team_contexts access_grant - ON $4::text = 'context' - JOIN team_members membership - ON membership.team_id = access_grant.team_id AND membership.user_id = $1 - AND membership.status = 'active' - CROSS JOIN LATERAL ( - SELECT ${canonicalContextLogicalIdSql('$3', "$6::text || '/' || access_grant.context_id")} - AS canonical_id - ) context_identity - WHERE resource.logical_id = context_identity.canonical_id - UNION ALL - SELECT resource.logical_id, 'direct', - ${directWorkflowTriggerGrantId}, - NULL, NULL - FROM resources resource - JOIN user_workflow_triggers access_grant - ON $4::text = 'workflow_recipe' - AND resource.logical_id = access_grant.recipe_namespace || '/' || access_grant.recipe_name - WHERE access_grant.user_id = $1 - UNION ALL - SELECT resource.logical_id, 'team', - ${teamWorkflowTriggerGrantId}, - access_grant.team_id, membership.role - FROM resources resource - JOIN team_workflow_triggers access_grant - ON $4::text = 'workflow_recipe' - AND resource.logical_id = access_grant.recipe_namespace || '/' || access_grant.recipe_name - JOIN team_members membership - ON membership.team_id = access_grant.team_id AND membership.user_id = $1 - AND membership.status = 'active' - UNION ALL - SELECT resource.logical_id, 'direct', - ${directWorkflowTriggerGrantId}, - NULL, NULL - FROM resources resource - JOIN operational_resource_relationships exposure - ON $4::text = 'sandbox_app' - AND exposure.environment_id = $3 - AND exposure.target_type = 'sandbox_app' - AND exposure.target_id = resource.logical_id - AND exposure.source_type = 'workflow_recipe' - AND exposure.relationship_type = 'exposes_sandbox_app' - JOIN user_workflow_triggers access_grant - ON exposure.source_id = access_grant.recipe_namespace || '/' || access_grant.recipe_name - WHERE access_grant.user_id = $1 - UNION ALL - SELECT resource.logical_id, 'team', - ${teamWorkflowTriggerGrantId}, - access_grant.team_id, membership.role - FROM resources resource - JOIN operational_resource_relationships exposure - ON $4::text = 'sandbox_app' - AND exposure.environment_id = $3 - AND exposure.target_type = 'sandbox_app' - AND exposure.target_id = resource.logical_id - AND exposure.source_type = 'workflow_recipe' - AND exposure.relationship_type = 'exposes_sandbox_app' - JOIN team_workflow_triggers access_grant - ON exposure.source_id = access_grant.recipe_namespace || '/' || access_grant.recipe_name - JOIN team_members membership - ON membership.team_id = access_grant.team_id AND membership.user_id = $1 - AND membership.status = 'active' - ) all_paths - ORDER BY logical_id, kind, team_id NULLS FIRST, grant_id - LIMIT $7 - ), outgoing_relationships AS MATERIALIZED ( - SELECT relationship.* - FROM operational_resource_relationships relationship - JOIN resources resource - ON relationship.environment_id = $3 - AND relationship.source_type = resource.resource_type - AND relationship.source_id = resource.logical_id - WHERE relationship.relationship_type <> 'context_identity_alias' - ), context_reference_ids AS MATERIALIZED ( - SELECT resource.logical_id AS reference_id - FROM resources resource - WHERE resource.resource_type = 'context' - UNION - SELECT alias_relationship.target_id AS reference_id - FROM resources resource - JOIN operational_resource_relationships alias_relationship - ON alias_relationship.environment_id = $3 - AND alias_relationship.source_type = 'context' - AND alias_relationship.source_id = resource.logical_id - AND alias_relationship.relationship_type = 'context_identity_alias' - AND alias_relationship.target_type = 'context' - JOIN operational_resource_index alias_source - ON alias_source.environment_id = alias_relationship.environment_id - AND alias_source.resource_type = 'context' - AND alias_source.logical_id = alias_relationship.source_id - AND alias_source.provider_uid = alias_relationship.source_provider_uid - WHERE resource.resource_type = 'context' - UNION - SELECT relationship.target_id AS reference_id - FROM outgoing_relationships relationship - WHERE relationship.relationship_type = 'uses_context' - ), context_identity_candidates AS MATERIALIZED ( - SELECT reference.reference_id, canonical_resource.logical_id AS canonical_id - FROM context_reference_ids reference - JOIN operational_resource_index canonical_resource - ON canonical_resource.environment_id = $3 - AND canonical_resource.resource_type = 'context' - AND canonical_resource.logical_id = reference.reference_id - UNION ALL - SELECT reference.reference_id, alias_relationship.source_id AS canonical_id - FROM context_reference_ids reference - JOIN operational_resource_relationships alias_relationship - ON alias_relationship.environment_id = $3 - AND alias_relationship.source_type = 'context' - AND alias_relationship.relationship_type = 'context_identity_alias' - AND alias_relationship.target_type = 'context' - AND alias_relationship.target_id = reference.reference_id - JOIN operational_resource_index alias_source - ON alias_source.environment_id = alias_relationship.environment_id - AND alias_source.resource_type = 'context' - AND alias_source.logical_id = alias_relationship.source_id - AND alias_source.provider_uid = alias_relationship.source_provider_uid - ), configured_context_candidate_counts AS MATERIALIZED ( - SELECT candidate.reference_id, COUNT(DISTINCT candidate.canonical_id)::int AS candidate_count - FROM context_identity_candidates candidate - GROUP BY candidate.reference_id - ), incomplete_configured_contexts AS MATERIALIZED ( - SELECT relationship.source_id AS logical_id, - CASE WHEN BOOL_OR(COALESCE(candidate_count.candidate_count, 0) > 1) - THEN 'configured_context_ambiguous' - ELSE 'configured_context_missing' - END AS incomplete_reason - FROM outgoing_relationships relationship - LEFT JOIN configured_context_candidate_counts candidate_count - ON candidate_count.reference_id = relationship.target_id - WHERE relationship.source_type = 'host' - AND relationship.relationship_type = 'uses_context' - AND relationship.target_type = 'context' - AND COALESCE(candidate_count.candidate_count, 0) <> 1 - GROUP BY relationship.source_id - ), context_identities AS MATERIALIZED ( - SELECT candidate.reference_id, MIN(candidate.canonical_id) AS canonical_id - FROM context_identity_candidates candidate - GROUP BY candidate.reference_id - HAVING COUNT(DISTINCT candidate.canonical_id) = 1 - ), relationships AS MATERIALIZED ( - SELECT DISTINCT relationship.logical_id, relationship.source_id, - relationship.relationship_type, relationship.target_type, - relationship.target_id, relationship.relationship_instance_id, - relationship.behavior_attributes, relationship.source_type, - relationship.source_provider_uid, relationship.source_resource_version - FROM ( - SELECT relationship.source_id AS logical_id, relationship.source_id, - relationship.relationship_type, relationship.target_type, - CASE WHEN relationship.relationship_type = 'uses_context' - THEN context_identity.canonical_id - ELSE relationship.target_id - END AS target_id, relationship.relationship_instance_id, - relationship.behavior_attributes, relationship.source_type, - relationship.source_provider_uid, relationship.source_resource_version - FROM outgoing_relationships relationship - LEFT JOIN context_identities context_identity - ON relationship.relationship_type = 'uses_context' - AND context_identity.reference_id = relationship.target_id - WHERE relationship.relationship_type <> 'uses_context' - OR context_identity.canonical_id IS NOT NULL - UNION ALL - SELECT relationship.source_id AS logical_id, relationship.source_id, - relationship.relationship_type, relationship.target_type, - relationship.target_id, relationship.relationship_instance_id, - relationship.behavior_attributes, relationship.source_type, - relationship.source_provider_uid, relationship.source_resource_version - FROM operational_resource_relationships relationship - JOIN resources resource - ON relationship.environment_id = $3 - AND relationship.target_type = resource.resource_type - AND relationship.target_id = resource.logical_id - WHERE relationship.relationship_type <> 'context_identity_alias' - AND relationship.relationship_type <> 'uses_context' - UNION ALL - SELECT relationship.source_id AS logical_id, relationship.source_id, - relationship.relationship_type, relationship.target_type, - context_identity.canonical_id AS target_id, - relationship.relationship_instance_id, - relationship.behavior_attributes, relationship.source_type, - relationship.source_provider_uid, relationship.source_resource_version - FROM context_identities context_identity - JOIN operational_resource_relationships relationship - ON relationship.environment_id = $3 - AND relationship.target_type = 'context' - AND relationship.relationship_type = 'uses_context' - AND relationship.target_id = context_identity.reference_id - JOIN resources resource - ON resource.resource_type = 'context' - AND resource.logical_id = context_identity.canonical_id - ) relationship - ORDER BY logical_id, relationship_type, target_type, target_id, - relationship_instance_id - LIMIT $8 - ) - SELECT resource.logical_id, resource.display_name, resource.provider_uid, - resource.resource_revision, - COALESCE(( - SELECT jsonb_agg(to_jsonb(path) ORDER BY path.kind, - path.team_id NULLS FIRST, path.grant_id) - FROM paths path WHERE path.logical_id = resource.logical_id - ), '[]'::jsonb) AS paths, - COALESCE(( - SELECT jsonb_agg(to_jsonb(relationship) ORDER BY relationship.relationship_type, - relationship.target_type, relationship.target_id, - relationship.relationship_instance_id) - FROM relationships relationship - WHERE relationship.logical_id = resource.logical_id - OR relationship.target_id = resource.logical_id - ), '[]'::jsonb) AS relationships, - (SELECT COUNT(*) FROM paths) AS total_path_rows, - (SELECT COUNT(*) FROM relationships) AS total_relationship_rows, - incomplete.incomplete_reason, - NULL::timestamptz AS valid_until - FROM resources resource - LEFT JOIN incomplete_configured_contexts incomplete - ON $4::text = 'host' AND incomplete.logical_id = resource.logical_id - ORDER BY resource.logical_id` - -export const DERIVED_OPERATIONAL_HYDRATION_SQL = ` - WITH requested AS MATERIALIZED ( - SELECT UNNEST($2::text[]) AS logical_id - ), resources AS MATERIALIZED ( - SELECT resource.logical_id, resource.display_name, resource.provider_uid, - resource.resource_type, COALESCE(revision.revision, 1)::text AS resource_revision - FROM requested requested - JOIN operational_resource_index resource - ON resource.environment_id = $3 - AND resource.resource_type = $4 - AND resource.logical_id = requested.logical_id - AND resource.enabled = TRUE AND resource.deleted_at IS NULL - LEFT JOIN authorization_resource_revisions revision - ON revision.environment_id = resource.environment_id - AND revision.resource_type = resource.resource_type - AND revision.resource_id = resource.logical_id - ), direct_edges AS MATERIALIZED ( - SELECT relationship.* - FROM operational_resource_relationships relationship - JOIN resources resource - ON relationship.environment_id = $3 - AND relationship.target_type = resource.resource_type - AND relationship.target_id = resource.logical_id - AND relationship.relationship_type = CASE - WHEN $4::text = 'mcp_server' THEN 'includes_mcp_server' - ELSE 'mounts_shared_filesystem' - END - JOIN operational_resource_index source_resource - ON source_resource.environment_id = relationship.environment_id - AND source_resource.resource_type = 'context' - AND source_resource.logical_id = relationship.source_id - AND source_resource.enabled = TRUE - AND source_resource.deleted_at IS NULL - WHERE relationship.source_type = 'context' - ), host_edges AS MATERIALIZED ( - SELECT relationship.environment_id, relationship.source_type, - relationship.source_id, relationship.relationship_type, - relationship.target_type, context_identity.canonical_id AS target_id, - relationship.relationship_instance_id, relationship.behavior_attributes, - relationship.source_family, relationship.source_provider_uid, - relationship.source_resource_version, relationship.source_generation, - relationship.observed_generation, relationship.content_bytes, - relationship.observed_at - FROM operational_resource_relationships relationship - CROSS JOIN LATERAL ( - SELECT ${canonicalContextLogicalIdSql('$3', 'relationship.target_id')} AS canonical_id - ) context_identity - JOIN operational_resource_index host_resource - ON host_resource.environment_id = relationship.environment_id - AND host_resource.resource_type = 'host' - AND host_resource.logical_id = relationship.source_id - AND host_resource.enabled = TRUE - AND host_resource.deleted_at IS NULL - JOIN operational_resource_index context_resource - ON context_resource.environment_id = relationship.environment_id - AND context_resource.resource_type = 'context' - AND context_resource.logical_id = context_identity.canonical_id - AND context_resource.enabled = TRUE - AND context_resource.deleted_at IS NULL - WHERE $4::text = 'mcp_server' - AND relationship.environment_id = $3 - AND relationship.source_type = 'host' - AND relationship.relationship_type = 'uses_context' - AND relationship.target_type = 'context' - AND context_identity.canonical_id IN (SELECT source_id FROM direct_edges) - ), paths AS MATERIALIZED ( - SELECT * FROM ( - SELECT edge.target_id AS logical_id, 'direct'::text AS kind, - 'user_contexts:' || access_grant.user_id || ':' || access_grant.context_id || ':' || - edge.relationship_instance_id AS grant_id, - NULL::uuid AS team_id, NULL::text AS current_role, - 'context'::text AS source_type, edge.source_id, - edge.source_provider_uid, edge.relationship_instance_id AS edge_instance, - edge.behavior_attributes AS edge_behavior, - NULL::text AS host_source_id, NULL::text AS host_provider_uid, - NULL::text AS host_edge_instance, NULL::jsonb AS host_edge_behavior - FROM direct_edges edge - JOIN user_contexts access_grant - ON edge.source_id = ${canonicalContextLogicalIdSql('$3', "$6::text || '/' || access_grant.context_id")} - WHERE access_grant.user_id = $1 - UNION ALL - SELECT edge.target_id, 'team', - 'team_contexts:' || access_grant.team_id || ':' || access_grant.context_id || ':' || - edge.relationship_instance_id, - access_grant.team_id, membership.role, 'context', edge.source_id, - edge.source_provider_uid, edge.relationship_instance_id, - edge.behavior_attributes, NULL, NULL, NULL, NULL - FROM direct_edges edge - JOIN team_contexts access_grant - ON edge.source_id = ${canonicalContextLogicalIdSql('$3', "$6::text || '/' || access_grant.context_id")} - JOIN team_members membership - ON membership.team_id = access_grant.team_id AND membership.user_id = $1 - AND membership.status = 'active' - UNION ALL - SELECT mcp_edge.target_id, 'direct', - 'user_agents:' || access_grant.user_id || ':' || access_grant.agent_name || ':' || - host_edge.relationship_instance_id || ':' || mcp_edge.relationship_instance_id, - NULL, NULL, 'host', mcp_edge.source_id, mcp_edge.source_provider_uid, - mcp_edge.relationship_instance_id, mcp_edge.behavior_attributes, - host_edge.source_id, host_edge.source_provider_uid, - host_edge.relationship_instance_id, host_edge.behavior_attributes - FROM direct_edges mcp_edge - JOIN host_edges host_edge ON host_edge.target_id = mcp_edge.source_id - JOIN user_agents access_grant - ON $4::text = 'mcp_server' - AND host_edge.source_id = $5::text || '/' || access_grant.agent_name - WHERE access_grant.user_id = $1 - UNION ALL - SELECT mcp_edge.target_id, 'team', - 'team_agents:' || access_grant.team_id || ':' || access_grant.agent_name || ':' || - host_edge.relationship_instance_id || ':' || mcp_edge.relationship_instance_id, - access_grant.team_id, membership.role, 'host', mcp_edge.source_id, - mcp_edge.source_provider_uid, mcp_edge.relationship_instance_id, - mcp_edge.behavior_attributes, host_edge.source_id, - host_edge.source_provider_uid, host_edge.relationship_instance_id, - host_edge.behavior_attributes - FROM direct_edges mcp_edge - JOIN host_edges host_edge ON host_edge.target_id = mcp_edge.source_id - JOIN team_agents access_grant - ON $4::text = 'mcp_server' - AND host_edge.source_id = $5::text || '/' || access_grant.agent_name - JOIN team_members membership - ON membership.team_id = access_grant.team_id AND membership.user_id = $1 - AND membership.status = 'active' - ) all_paths - ORDER BY logical_id, kind, team_id NULLS FIRST, grant_id - LIMIT $7 - ), relationships AS MATERIALIZED ( - SELECT * FROM direct_edges - UNION ALL - SELECT * FROM host_edges - ORDER BY source_type, source_id, relationship_type, target_type, target_id, - relationship_instance_id - LIMIT $8 - ) - SELECT resource.logical_id, resource.display_name, resource.provider_uid, - resource.resource_revision, - COALESCE(( - SELECT jsonb_agg(to_jsonb(path) ORDER BY path.kind, - path.team_id NULLS FIRST, path.grant_id) - FROM paths path WHERE path.logical_id = resource.logical_id - ), '[]'::jsonb) AS paths, - COALESCE(( - SELECT jsonb_agg(to_jsonb(relationship) ORDER BY relationship.source_type, - relationship.source_id, relationship.relationship_type, - relationship.target_type, relationship.target_id, - relationship.relationship_instance_id) - FROM relationships relationship - WHERE relationship.target_id = resource.logical_id - OR relationship.target_id IN ( - SELECT edge.source_id FROM direct_edges edge - WHERE edge.target_id = resource.logical_id - ) - ), '[]'::jsonb) AS relationships, - (SELECT COUNT(*) FROM paths) AS total_path_rows, - (SELECT COUNT(*) FROM relationships) AS total_relationship_rows, - NULL::timestamptz AS valid_until - FROM resources resource - ORDER BY resource.logical_id` - -export const WORKFLOW_RUN_HYDRATION_SQL = ` - WITH requested AS MATERIALIZED (SELECT UNNEST($2::uuid[]) AS id), - resources AS MATERIALIZED ( - SELECT run.*, COALESCE(revision.revision, 1)::text AS resource_revision - FROM requested requested - JOIN workflow_runs run ON run.run_id = requested.id - LEFT JOIN authorization_resource_revisions revision - ON revision.environment_id = $3 AND revision.resource_type = 'workflow_run' - AND revision.resource_id = run.run_id::text - ), paths AS MATERIALIZED ( - SELECT * FROM ( - SELECT run.run_id::text AS logical_id, 'direct'::text AS kind, - 'workflow_runs:user:' || run.run_id AS grant_id, - NULL::uuid AS team_id, NULL::text AS current_role - FROM resources run - JOIN user_workflow_triggers access_grant - ON access_grant.user_id = $1 - AND access_grant.recipe_namespace = run.recipe_namespace - AND access_grant.recipe_name = run.recipe_name - WHERE run.actor_type = 'user' AND run.actor_id = $1 - AND run.team_id IS NULL AND run.usage_team_id IS NULL - UNION ALL - SELECT run.run_id::text, 'team', - 'workflow_runs:team:' || membership.team_id || ':' || run.run_id, - membership.team_id, membership.role - FROM resources run - JOIN team_members membership - ON (membership.team_id = run.team_id OR membership.team_id::text = run.usage_team_id) - AND membership.user_id = $1::uuid AND membership.status = 'active' - JOIN team_workflow_triggers access_grant - ON access_grant.team_id = membership.team_id - AND access_grant.recipe_namespace = run.recipe_namespace - AND access_grant.recipe_name = run.recipe_name - WHERE run.actor_type IN ('scheduled', 'autonomous') - OR (run.actor_type = 'user' AND (run.actor_id = $1 OR run.actor_id IS NULL)) - ) all_paths - ORDER BY logical_id, kind, team_id NULLS FIRST, grant_id - LIMIT $4 - ) - SELECT run.run_id::text AS logical_id, - run.recipe_namespace || '/' || run.recipe_name || ' ' || run.run_id::text AS display_name, - NULL::text AS provider_uid, run.resource_revision, - COALESCE(( - SELECT jsonb_agg(to_jsonb(path) ORDER BY path.kind, - path.team_id NULLS FIRST, path.grant_id) - FROM paths path WHERE path.logical_id = run.run_id::text - ), '[]'::jsonb) AS paths, - jsonb_build_array(jsonb_build_object( - 'type', 'recipe', - 'targetResourceId', 'workflow_recipe:' || run.recipe_namespace || '/' || run.recipe_name - )) AS relationships, - (SELECT COUNT(*) FROM paths) AS total_path_rows, - 1::bigint AS total_relationship_rows, - NULL::timestamptz AS valid_until - FROM resources run - ORDER BY run.run_id::text` - -export const WORKFLOW_APPROVAL_HYDRATION_SQL = ` - WITH requested AS MATERIALIZED (SELECT UNNEST($2::uuid[]) AS id), - resources AS MATERIALIZED ( - SELECT approval.*, COALESCE(revision.revision, 1)::text AS resource_revision - FROM requested requested - JOIN workflow_approval_requests approval ON approval.id = requested.id - LEFT JOIN authorization_resource_revisions revision - ON revision.environment_id = $3 AND revision.resource_type = 'workflow_approval' - AND revision.resource_id = approval.id::text - WHERE approval.status = 'pending' AND approval.expires_at > NOW() - ), paths AS MATERIALIZED ( - SELECT * FROM ( - SELECT approval.id::text AS logical_id, 'direct'::text AS kind, - 'workflow_approvals:user:' || approval.id AS grant_id, - NULL::uuid AS team_id, NULL::text AS current_role - FROM resources approval - JOIN user_workflow_triggers access_grant - ON access_grant.user_id = $1 - AND access_grant.recipe_namespace = approval.recipe_namespace - AND access_grant.recipe_name = approval.recipe_name - WHERE approval.target_user_id = $1 - UNION ALL - SELECT approval.id::text, 'team', - 'workflow_approvals:team:' || membership.team_id || ':' || approval.id, - membership.team_id, membership.role - FROM resources approval - JOIN team_members membership - ON membership.team_id = approval.target_team_id - AND membership.user_id = $1::uuid AND membership.status = 'active' - JOIN workflow_recipe_allowed_teams access_grant - ON access_grant.team_id = membership.team_id - AND access_grant.recipe_namespace = approval.recipe_namespace - AND access_grant.recipe_name = approval.recipe_name - ) all_paths - ORDER BY logical_id, kind, team_id NULLS FIRST, grant_id - LIMIT $4 - ) - SELECT approval.id::text AS logical_id, - approval.recipe_namespace || '/' || approval.recipe_name || ' approval' AS display_name, - NULL::text AS provider_uid, approval.resource_revision, - COALESCE(( - SELECT jsonb_agg(to_jsonb(path) ORDER BY path.kind, - path.team_id NULLS FIRST, path.grant_id) - FROM paths path WHERE path.logical_id = approval.id::text - ), '[]'::jsonb) AS paths, - jsonb_build_array(jsonb_build_object( - 'type', 'recipe', - 'targetResourceId', 'workflow_recipe:' || approval.recipe_namespace || '/' || - approval.recipe_name - )) AS relationships, - (SELECT COUNT(*) FROM paths) AS total_path_rows, - 1::bigint AS total_relationship_rows, - approval.expires_at AS valid_until - FROM resources approval - ORDER BY approval.id::text` - -export const NOTIFICATION_HYDRATION_SQL = ` - WITH requested AS MATERIALIZED (SELECT UNNEST($2::uuid[]) AS id), - resources AS MATERIALIZED ( - SELECT notification.*, COALESCE(revision.revision, 1)::text AS resource_revision - FROM requested requested - JOIN notification_deliveries notification ON notification.id = requested.id - LEFT JOIN authorization_resource_revisions revision - ON revision.environment_id = $3 AND revision.resource_type = 'notification' - AND revision.resource_id = notification.id::text - WHERE notification.expires_at IS NULL OR notification.expires_at > NOW() - ), paths AS MATERIALIZED ( - SELECT * FROM ( - SELECT notification.id::text AS logical_id, 'direct'::text AS kind, - 'notifications:user:' || notification.id AS grant_id, - NULL::uuid AS team_id, NULL::text AS current_role - FROM resources notification - WHERE notification.audience->>'userId' = $1::text - UNION ALL - SELECT notification.id::text, 'team', - 'notifications:team:' || membership.team_id || ':' || notification.id, - membership.team_id, membership.role - FROM resources notification - JOIN team_members membership - ON membership.team_id::text = notification.audience->>'teamId' - AND membership.user_id = $1::uuid AND membership.status = 'active' - ) all_paths - ORDER BY logical_id, kind, team_id NULLS FIRST, grant_id - LIMIT $4 - ) - SELECT notification.id::text AS logical_id, - notification.event_type AS display_name, - NULL::text AS provider_uid, notification.resource_revision, - COALESCE(( - SELECT jsonb_agg(to_jsonb(path) ORDER BY path.kind, - path.team_id NULLS FIRST, path.grant_id) - FROM paths path WHERE path.logical_id = notification.id::text - ), '[]'::jsonb) AS paths, - CASE WHEN notification.audience ? 'teamId' - THEN jsonb_build_array(jsonb_build_object( - 'type', 'team', - 'targetResourceId', 'team:' || (notification.audience->>'teamId') - )) - ELSE '[]'::jsonb - END AS relationships, - (SELECT COUNT(*) FROM paths) AS total_path_rows, - CASE WHEN notification.audience ? 'teamId' THEN 1 ELSE 0 END::bigint - AS total_relationship_rows, - notification.expires_at AS valid_until - FROM resources notification - ORDER BY notification.id::text` - -export const GFS_HYDRATION_SQL = ` - WITH requested AS MATERIALIZED (SELECT UNNEST($2::uuid[]) AS id), - resources AS MATERIALIZED ( - SELECT resource.*, COALESCE(revision.revision, 1)::text AS resource_revision - FROM requested requested - JOIN gfs_resources resource - ON resource.resource_id = requested.id AND resource.deleted_at IS NULL - LEFT JOIN authorization_resource_revisions revision - ON revision.environment_id = $3 AND revision.resource_type = 'gfs_resource' - AND revision.resource_id = resource.resource_id::text - ), paths AS MATERIALIZED ( - SELECT * FROM ( - SELECT access_grant.resource_id::text AS logical_id, - CASE WHEN access_grant.subject_type = 'user' THEN 'direct' ELSE 'team' END AS kind, - 'gfs_grants:' || access_grant.id AS grant_id, - CASE WHEN access_grant.subject_type = 'team' THEN membership.team_id ELSE NULL END AS team_id, - membership.role AS current_role, access_grant.permissions, access_grant.drive - FROM resources resource - JOIN gfs_grants access_grant ON access_grant.resource_id = resource.resource_id - LEFT JOIN team_members membership - ON access_grant.subject_type = 'team' - AND membership.team_id::text = access_grant.subject_id - AND membership.user_id = $1::uuid AND membership.status = 'active' - WHERE (access_grant.subject_type = 'user' AND access_grant.subject_id = $1::text) - OR (access_grant.subject_type = 'team' AND membership.team_id IS NOT NULL) - UNION ALL - SELECT share.resource_id::text, - CASE WHEN share.subject_type = 'user' THEN 'direct' ELSE 'team' END, - 'gfs_shares:' || share.id, - CASE WHEN share.subject_type = 'team' THEN membership.team_id ELSE NULL END, - membership.role, share.permissions, share.drive - FROM resources resource - JOIN gfs_shares share ON share.resource_id = resource.resource_id - LEFT JOIN team_members membership - ON share.subject_type = 'team' AND membership.team_id::text = share.subject_id - AND membership.user_id = $1::uuid AND membership.status = 'active' - WHERE (share.subject_type = 'user' AND share.subject_id = $1::text) - OR (share.subject_type = 'team' AND membership.team_id IS NOT NULL) - ) all_paths - ORDER BY logical_id, kind, team_id NULLS FIRST, grant_id - LIMIT $4 - ) - SELECT resource.resource_id::text AS logical_id, resource.name AS display_name, - NULL::text AS provider_uid, resource.resource_revision, - COALESCE(( - SELECT jsonb_agg(to_jsonb(path) ORDER BY path.kind, - path.team_id NULLS FIRST, path.grant_id) - FROM paths path WHERE path.logical_id = resource.resource_id::text - ), '[]'::jsonb) AS paths, - CASE WHEN resource.parent_resource_id IS NOT NULL - THEN jsonb_build_array(jsonb_build_object( - 'type', 'parent', - 'targetResourceId', 'gfs_resource:' || resource.parent_resource_id - )) - ELSE '[]'::jsonb - END AS relationships, - (SELECT COUNT(*) FROM paths) AS total_path_rows, - CASE WHEN resource.parent_resource_id IS NULL THEN 0 ELSE 1 END::bigint - AS total_relationship_rows, - NULL::timestamptz AS valid_until - FROM resources resource - ORDER BY resource.resource_id::text` diff --git a/control-api/src/services/access/catalogProducerArms.ts b/control-api/src/services/access/catalogProducerArms.ts deleted file mode 100644 index 1724c9bc0c..0000000000 --- a/control-api/src/services/access/catalogProducerArms.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { CatalogProducerContractError } from './catalogProducerErrors.js' - -export type BoundedKeyArm = Readonly<{ - sql: string - orderBy?: string - duplicateCapable?: boolean - hasValidUntil?: boolean -}> - -/** PostgreSQL expression equivalent to the catalog's unsigned UTF-8 byte order. */ -export function catalogTextOrderSql(expression: string): string { - return `catalog_utf8_bytes((${expression})::text)` -} - -export function catalogTextAfterSql(left: string, right: string): string { - return `${catalogTextOrderSql(left)} > ${catalogTextOrderSql(right)}` -} - -/** Builds the static, bounded SQL envelope for producer source arms. */ -export function boundedKeyUnionSql(arms: readonly (string | BoundedKeyArm)[]): string { - if (arms.length === 0) throw new CatalogProducerContractError('key_arms_missing') - const names = arms.map((_, index) => `source_${index}`) - const sources = arms - .map((arm, index) => { - const definition = typeof arm === 'string' ? { sql: arm } : arm - const after = `arm_after_${index}` - const sourceSql = definition.sql.replaceAll('$2', `${after}.after_key`).replaceAll( - '$7', - `CASE WHEN POSITION('/' IN ${after}.after_key) > 0 - THEN SPLIT_PART(${after}.after_key, '/', 2) ELSE '' END` - ) - const orderBy = definition.orderBy ?? 'logical_id' - // `cursor_id` is the reserved alias for a native UUID column. PostgreSQL UUID ordering is - // byte-equivalent to UTF-8 ordering of canonical UUID text and keeps the bounded index scan. - const orderExpression = orderBy === 'cursor_id' ? orderBy : catalogTextOrderSql(orderBy) - return `${names[index]} AS MATERIALIZED ( - SELECT '${names[index]}'::text AS source_arm, bounded_arm.logical_id, - ${definition.hasValidUntil ? 'bounded_arm.valid_until' : 'NULL::timestamptz'} - AS valid_until - FROM ( - SELECT COALESCE(NULLIF($8::jsonb ->> '${names[index]}', ''), $2) AS after_key - WHERE $9::jsonb IS NULL OR $9::jsonb ? '${names[index]}' - ) ${after} - CROSS JOIN LATERAL (${sourceSql}) bounded_arm - ORDER BY ${orderExpression} - LIMIT $4 - )` - }) - .join(',\n') - const union = names - .map( - (name, index) => `SELECT '${name}'::text AS source_arm, - COALESCE(( - SELECT jsonb_agg(jsonb_build_object( - 'logical_id', logical_id, - 'valid_until', valid_until - ) ORDER BY ${catalogTextOrderSql('logical_id')}) - FROM ${name} - ), '[]'::jsonb) AS source_rows, - ${ - (typeof arms[index] === 'string' ? false : arms[index].duplicateCapable) - ? `(SELECT COUNT(*) FROM ${name}) >= $4` - : 'FALSE' - } AS source_saturated - WHERE $9::jsonb IS NULL OR $9::jsonb ? '${name}'` - ) - .join('\nUNION ALL\n') - return `WITH ${sources} - SELECT source_arm, source_rows, source_saturated - FROM (${union}) bounded_sources - WHERE $1::uuid IS NOT NULL AND $2::text IS NOT NULL AND $3::text IS NOT NULL - AND $5::text IS NOT NULL AND $6::text IS NOT NULL AND $7::text IS NOT NULL - ORDER BY ${catalogTextOrderSql('source_arm')}` -} diff --git a/control-api/src/services/access/catalogProducerDatabase.ts b/control-api/src/services/access/catalogProducerDatabase.ts deleted file mode 100644 index daf7f86e2b..0000000000 --- a/control-api/src/services/access/catalogProducerDatabase.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { DbClient } from '../../db.js' -import { runAccessDatabaseQuery } from './accessDatabaseQuery.js' -import type { AccessExecutionBudget } from './accessExecutionBudget.js' - -/** Shared request-scoped database execution boundary for catalog producers. */ -export async function catalogQuery( - db: Pick, - budget: AccessExecutionBudget, - text: string, - values: unknown[], - options: { chargeProducer?: boolean } = {} -) { - return runAccessDatabaseQuery(db, budget, text, values, options) -} diff --git a/control-api/src/services/access/catalogProducerErrors.ts b/control-api/src/services/access/catalogProducerErrors.ts deleted file mode 100644 index af05d595e8..0000000000 --- a/control-api/src/services/access/catalogProducerErrors.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** Typed contract failures shared by the catalog-producer boundaries. */ -export class CatalogProducerContractError extends Error { - constructor(readonly code: string) { - super(`Catalog producer contract violation: ${code}`) - this.name = 'CatalogProducerContractError' - } -} diff --git a/control-api/src/services/access/catalogProducerHead.ts b/control-api/src/services/access/catalogProducerHead.ts deleted file mode 100644 index 5029553a9d..0000000000 --- a/control-api/src/services/access/catalogProducerHead.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { - type CatalogFamily, - type CatalogIdentityCandidate, - type CatalogProducerPage, - type CatalogRequestContext, - type ProducerContinuation, - catalogKey, - compareCatalogText, -} from './catalogContracts.js' -import { catalogQuery } from './catalogProducerDatabase.js' -import { CatalogProducerContractError } from './catalogProducerErrors.js' -import { operationalReadiness } from './catalogProducerReadiness.js' -import type { OperationalSourceFamily } from './operationalAccessProjection.js' - -function validateContinuation( - family: CatalogFamily, - environmentId: string, - continuation: ProducerContinuation -): string { - if (continuation.exhausted) return '' - if (!continuation.afterKey) return '' - const [cursorEnvironment, cursorFamily, logicalId] = continuation.afterKey - if (cursorEnvironment !== environmentId || cursorFamily !== family || !logicalId) { - throw new CatalogProducerContractError('continuation_family_mismatch') - } - return logicalId -} - -/** Builds a bounded, private per-arm producer head for one catalog family. */ -export async function listBoundedProducerKeys(input: { - context: CatalogRequestContext - family: CatalogFamily - requiredOperationalSources: readonly OperationalSourceFamily[] - continuation: ProducerContinuation - take: number - sql: string - extraValues?: readonly unknown[] | ((after: string) => readonly unknown[]) -}): Promise { - const { context } = input - if ( - !Number.isSafeInteger(input.take) || - input.take < 1 || - input.take > context.budget.limits.publicPageSize || - input.take + 1 > context.budget.limits.keyCandidatesPerCall - ) { - throw new CatalogProducerContractError('take_invalid') - } - const readiness = operationalReadiness(context, input.family, input.requiredOperationalSources) - if (readiness.status === 'partial') { - return Object.freeze({ - candidates: Object.freeze([]), - continuation: Object.freeze({ afterKey: input.continuation.afterKey, exhausted: false }), - hasMore: false, - sourceRevision: readiness.sourceRevision, - sourceCompleteness: 'partial', - partialErrors: readiness.errors, - }) - } - if (input.continuation.exhausted) { - return Object.freeze({ - candidates: Object.freeze([]), - continuation: input.continuation, - hasMore: false, - sourceRevision: readiness.sourceRevision, - sourceCompleteness: 'complete', - partialErrors: Object.freeze([]), - }) - } - const after = validateContinuation(input.family, context.environmentId, input.continuation) - type ArmCandidate = Readonly<{ logicalId: string; validUntil: string | null }> - type ArmState = { after: string; exhausted: boolean; candidates: ArmCandidate[] } - const arms = new Map() - const candidates: CatalogIdentityCandidate[] = [] - let firstQuery = true - let loaded = false - const refill = async (armNames: readonly string[] | null) => { - const result = await catalogQuery( - context.db, - context.budget, - input.sql, - [ - context.principal.userId, - after, - context.environmentId, - input.take + 1, - ...(typeof input.extraValues === 'function' - ? input.extraValues(after) - : (input.extraValues ?? [])), - JSON.stringify(Object.fromEntries([...arms].map(([name, arm]) => [name, arm.after]))), - armNames === null - ? null - : JSON.stringify(Object.fromEntries(armNames.map(name => [name, true]))), - ], - { chargeProducer: firstQuery } - ) - firstQuery = false - const rawByArm = new Map() - const saturation = new Map() - for (const row of result.rows as Record[]) { - const armName = typeof row.source_arm === 'string' ? row.source_arm : '' - if (!armName) throw new CatalogProducerContractError('source_arm_invalid') - if (typeof row.source_saturated !== 'boolean') { - throw new CatalogProducerContractError('source_saturation_invalid') - } - saturation.set(armName, row.source_saturated) - if (!Array.isArray(row.source_rows)) { - throw new CatalogProducerContractError('source_rows_invalid') - } - const state = arms.get(armName) ?? { after, exhausted: false, candidates: [] } - const values: ArmCandidate[] = [] - for (const sourceRow of row.source_rows) { - if (!sourceRow || typeof sourceRow !== 'object' || Array.isArray(sourceRow)) { - throw new CatalogProducerContractError('source_rows_invalid') - } - const sourceValue = sourceRow as Record - const logicalId = typeof sourceValue.logical_id === 'string' ? sourceValue.logical_id : '' - if (!logicalId || compareCatalogText(logicalId, state.after) <= 0) { - throw new CatalogProducerContractError('keys_not_strictly_ordered') - } - let validUntil: string | null = null - if (sourceValue.valid_until !== null && sourceValue.valid_until !== undefined) { - const timestamp = new Date(String(sourceValue.valid_until)) - if (Number.isNaN(timestamp.getTime())) { - throw new CatalogProducerContractError('candidate_valid_until_invalid') - } - validUntil = timestamp.toISOString() - } - const previous = values.at(-1) - if (!previous || previous.logicalId !== logicalId) values.push({ logicalId, validUntil }) - else if ( - previous.validUntil === null || - (validUntil !== null && validUntil < previous.validUntil) - ) { - values[values.length - 1] = { logicalId, validUntil } - } - } - rawByArm.set(armName, values) - } - for (const [armName, saturated] of saturation) { - const state = arms.get(armName) ?? { after, exhausted: false, candidates: [] } - if (state.candidates.length === 0) state.candidates = rawByArm.get(armName) ?? [] - state.exhausted = !saturated - arms.set(armName, state) - } - loaded = true - } - await refill(null) - while (candidates.length < input.take + 1) { - const heads = [...arms.entries()] - .filter(([, state]) => state.candidates.length > 0) - .map(([name, state]) => ({ name, state, candidate: state.candidates[0] })) - if (heads.length === 0) { - const refillable = [...arms.values()].some(state => !state.exhausted) - if (!refillable) break - await refill( - [...arms.entries()] - .filter(([, state]) => !state.exhausted && state.candidates.length === 0) - .map(([name]) => name) - ) - continue - } - const logicalId = heads.map(head => head.candidate.logicalId).sort(compareCatalogText)[0] - const contributors = heads.filter(head => head.candidate.logicalId === logicalId) - const validUntil = - contributors - .map(head => head.candidate.validUntil) - .filter((value): value is string => value !== null) - .sort()[0] ?? null - candidates.push( - Object.freeze({ - key: catalogKey(context.environmentId, input.family, logicalId), - canonicalId: `${input.family}:${logicalId}`, - validUntil, - }) - ) - for (const { state } of contributors) { - const consumed = state.candidates.shift()! - state.after = consumed.logicalId - } - if (contributors.some(({ state }) => state.candidates.length === 0 && !state.exhausted)) { - await refill( - contributors - .filter(({ state }) => state.candidates.length === 0 && !state.exhausted) - .map(({ name }) => name) - ) - } - } - if (!loaded) throw new CatalogProducerContractError('source_arms_missing') - const hasMore = - candidates.length > input.take || - [...arms.values()].some(state => !state.exhausted || state.candidates.length > 0) - return Object.freeze({ - candidates: Object.freeze(candidates), - continuation: Object.freeze({ - afterKey: candidates.at(-1)?.key ?? input.continuation.afterKey, - exhausted: !hasMore, - }), - hasMore, - sourceRevision: readiness.sourceRevision, - sourceCompleteness: 'complete', - partialErrors: Object.freeze([]), - }) -} diff --git a/control-api/src/services/access/catalogProducerHydration.ts b/control-api/src/services/access/catalogProducerHydration.ts deleted file mode 100644 index cf3ee42c76..0000000000 --- a/control-api/src/services/access/catalogProducerHydration.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { - type CatalogFamily, - type CatalogRequestContext, - compareCatalogText, -} from './catalogContracts.js' -import { CatalogProducerContractError } from './catalogProducerErrors.js' - -/** Validates the ordered canonical keys passed to selected-ID hydration. */ -export function validateHydrationKeys(input: { - context: CatalogRequestContext - family: CatalogFamily - keys: readonly (readonly [string, CatalogFamily, string])[] -}): string[] { - if (input.keys.length > input.context.budget.limits.keyCandidatesPerCall) { - throw new CatalogProducerContractError('hydrate_key_count_exceeded') - } - const logicalIds: string[] = [] - for (const key of input.keys) { - if (key[0] !== input.context.environmentId || key[1] !== input.family || !key[2]) { - throw new CatalogProducerContractError('hydrate_key_mismatch') - } - if (logicalIds.at(-1) !== undefined && compareCatalogText(key[2], logicalIds.at(-1)!) <= 0) { - throw new CatalogProducerContractError('hydrate_keys_not_strictly_ordered') - } - logicalIds.push(key[2]) - } - return logicalIds -} diff --git a/control-api/src/services/access/catalogProducerReadiness.ts b/control-api/src/services/access/catalogProducerReadiness.ts deleted file mode 100644 index 6ce712c04e..0000000000 --- a/control-api/src/services/access/catalogProducerReadiness.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { revisionOfValues } from './authorizationRevision.js' -import type { - CatalogFamily, - CatalogOperationalSourceState, - CatalogRequestContext, - SafeCatalogPartialError, -} from './catalogContracts.js' -import type { OperationalSourceFamily } from './operationalAccessProjection.js' - -function sourceRevision(states: readonly CatalogOperationalSourceState[]): string { - return revisionOfValues( - states.map(state => [state.family, state.generation, state.resourceVersion, state.status]) - ) -} - -/** Reports whether required operational sources can authoritatively produce a family page. */ -export function operationalReadiness( - context: CatalogRequestContext, - family: CatalogFamily, - required: readonly OperationalSourceFamily[] -): - | Readonly<{ status: 'current'; sourceRevision: string }> - | Readonly<{ - status: 'partial' - sourceRevision: string - errors: readonly SafeCatalogPartialError[] - }> { - if (required.length === 0) return { status: 'current', sourceRevision: 'database' } - const states = required.flatMap(source => { - const value = context.sourceStates.get(source) - return value ? [value] : [] - }) - const errors: SafeCatalogPartialError[] = [] - for (const source of required) { - const state = context.sourceStates.get(source) - if (!state || state.status !== 'current') { - errors.push( - Object.freeze({ - producer: family, - code: - state?.status === 'relisting' - ? 'operational_source_relisting' - : 'operational_source_unavailable', - retryable: true, - }) - ) - } - } - return errors.length === 0 - ? { status: 'current', sourceRevision: sourceRevision(states) } - : { status: 'partial', sourceRevision: sourceRevision(states), errors: Object.freeze(errors) } -} diff --git a/control-api/src/services/access/catalogProducerSql.ts b/control-api/src/services/access/catalogProducerSql.ts deleted file mode 100644 index 9c92ff993e..0000000000 --- a/control-api/src/services/access/catalogProducerSql.ts +++ /dev/null @@ -1,466 +0,0 @@ -import type { CatalogFamily } from './catalogContracts.js' -import { boundedKeyUnionSql, catalogTextAfterSql } from './catalogProducerSupport.js' -import { canonicalContextLogicalIdSql } from './contextIdentitySql.js' - -export const CATALOG_KEY_SQL: Readonly> = Object.freeze({ - user: boundedKeyUnionSql([ - { - sql: `SELECT u.id::text AS logical_id, u.id AS cursor_id - FROM users u - WHERE u.id = $1 - AND u.id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000')`, - orderBy: 'cursor_id', - }, - ]), - team: boundedKeyUnionSql([ - { - sql: `SELECT tm.team_id::text AS logical_id, tm.team_id AS cursor_id - FROM team_members tm - WHERE tm.user_id = $1 AND tm.status = 'active' - AND tm.team_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000')`, - orderBy: 'cursor_id', - }, - ]), - host: boundedKeyUnionSql([ - { - sql: `SELECT $5::text || '/' || ua.agent_name AS logical_id, ua.agent_name AS source_key - FROM user_agents ua - JOIN operational_resource_index resource - ON resource.environment_id = $3 AND resource.resource_type = 'host' - AND resource.logical_id = $5::text || '/' || ua.agent_name - AND resource.enabled = TRUE AND resource.deleted_at IS NULL - WHERE ua.user_id = $1 AND ${catalogTextAfterSql('ua.agent_name', '$7')}`, - orderBy: 'source_key', - }, - { - sql: `SELECT $5::text || '/' || ta.agent_name AS logical_id, ta.agent_name AS source_key - FROM team_agents ta - JOIN team_members tm ON tm.team_id = ta.team_id - JOIN operational_resource_index resource - ON resource.environment_id = $3 AND resource.resource_type = 'host' - AND resource.logical_id = $5::text || '/' || ta.agent_name - AND resource.enabled = TRUE AND resource.deleted_at IS NULL - WHERE tm.user_id = $1 AND tm.status = 'active' - AND ${catalogTextAfterSql('ta.agent_name', '$7')}`, - orderBy: 'source_key', - duplicateCapable: true, - }, - ]), - context: boundedKeyUnionSql([ - { - sql: `SELECT context_identity.canonical_id AS logical_id, - context_identity.canonical_id AS source_key - FROM user_contexts uc - CROSS JOIN LATERAL ( - SELECT ${canonicalContextLogicalIdSql('$3', "$6::text || '/' || uc.context_id")} - AS canonical_id - ) context_identity - JOIN operational_resource_index resource - ON resource.environment_id = $3 AND resource.resource_type = 'context' - AND resource.logical_id = context_identity.canonical_id - AND resource.enabled = TRUE AND resource.deleted_at IS NULL - WHERE uc.user_id = $1 - AND ${catalogTextAfterSql("SPLIT_PART(context_identity.canonical_id, '/', 2)", '$7')}`, - orderBy: 'logical_id', - duplicateCapable: true, - }, - { - sql: `SELECT context_identity.canonical_id AS logical_id, - context_identity.canonical_id AS source_key - FROM team_contexts tc - CROSS JOIN LATERAL ( - SELECT ${canonicalContextLogicalIdSql('$3', "$6::text || '/' || tc.context_id")} - AS canonical_id - ) context_identity - JOIN team_members tm ON tm.team_id = tc.team_id - JOIN operational_resource_index resource - ON resource.environment_id = $3 AND resource.resource_type = 'context' - AND resource.logical_id = context_identity.canonical_id - AND resource.enabled = TRUE AND resource.deleted_at IS NULL - WHERE tm.user_id = $1 AND tm.status = 'active' - AND ${catalogTextAfterSql("SPLIT_PART(context_identity.canonical_id, '/', 2)", '$7')}`, - orderBy: 'logical_id', - duplicateCapable: true, - }, - ]), - mcp_server: boundedKeyUnionSql([ - { - sql: `SELECT edge.target_id AS logical_id - FROM user_contexts uc - CROSS JOIN LATERAL ( - SELECT ${canonicalContextLogicalIdSql('$3', "$6::text || '/' || uc.context_id")} - AS canonical_id - ) context_identity - JOIN operational_resource_relationships edge - ON edge.environment_id = $3 - AND edge.source_type = 'context' - AND edge.source_id = context_identity.canonical_id - AND edge.relationship_type = 'includes_mcp_server' - AND edge.target_type = 'mcp_server' - JOIN operational_resource_index source_resource - ON source_resource.environment_id = $3 AND source_resource.resource_type = 'context' - AND source_resource.logical_id = context_identity.canonical_id - AND source_resource.enabled = TRUE AND source_resource.deleted_at IS NULL - JOIN operational_resource_index target_resource - ON target_resource.environment_id = $3 AND target_resource.resource_type = 'mcp_server' - AND target_resource.logical_id = edge.target_id - AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL - WHERE uc.user_id = $1 AND ${catalogTextAfterSql('edge.target_id', '$2')}`, - duplicateCapable: true, - }, - { - sql: `SELECT edge.target_id AS logical_id - FROM team_contexts tc - CROSS JOIN LATERAL ( - SELECT ${canonicalContextLogicalIdSql('$3', "$6::text || '/' || tc.context_id")} - AS canonical_id - ) context_identity - JOIN team_members tm ON tm.team_id = tc.team_id - JOIN operational_resource_relationships edge - ON edge.environment_id = $3 - AND edge.source_type = 'context' - AND edge.source_id = context_identity.canonical_id - AND edge.relationship_type = 'includes_mcp_server' - AND edge.target_type = 'mcp_server' - JOIN operational_resource_index source_resource - ON source_resource.environment_id = $3 AND source_resource.resource_type = 'context' - AND source_resource.logical_id = context_identity.canonical_id - AND source_resource.enabled = TRUE AND source_resource.deleted_at IS NULL - JOIN operational_resource_index target_resource - ON target_resource.environment_id = $3 AND target_resource.resource_type = 'mcp_server' - AND target_resource.logical_id = edge.target_id - AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL - WHERE tm.user_id = $1 AND tm.status = 'active' - AND ${catalogTextAfterSql('edge.target_id', '$2')}`, - duplicateCapable: true, - }, - { - sql: `SELECT mcp_edge.target_id AS logical_id - FROM user_agents ua - JOIN operational_resource_relationships host_edge - ON host_edge.environment_id = $3 - AND host_edge.source_type = 'host' - AND host_edge.source_id = $5::text || '/' || ua.agent_name - AND host_edge.relationship_type = 'uses_context' - AND host_edge.target_type = 'context' - CROSS JOIN LATERAL ( - SELECT ${canonicalContextLogicalIdSql('$3', 'host_edge.target_id')} - AS canonical_id - ) context_identity - JOIN operational_resource_relationships mcp_edge - ON mcp_edge.environment_id = $3 - AND mcp_edge.source_type = 'context' - AND mcp_edge.source_id = context_identity.canonical_id - AND mcp_edge.relationship_type = 'includes_mcp_server' - AND mcp_edge.target_type = 'mcp_server' - JOIN operational_resource_index host_resource - ON host_resource.environment_id = $3 AND host_resource.resource_type = 'host' - AND host_resource.logical_id = host_edge.source_id - AND host_resource.enabled = TRUE AND host_resource.deleted_at IS NULL - JOIN operational_resource_index context_resource - ON context_resource.environment_id = $3 AND context_resource.resource_type = 'context' - AND context_resource.logical_id = context_identity.canonical_id - AND context_resource.enabled = TRUE AND context_resource.deleted_at IS NULL - JOIN operational_resource_index target_resource - ON target_resource.environment_id = $3 AND target_resource.resource_type = 'mcp_server' - AND target_resource.logical_id = mcp_edge.target_id - AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL - WHERE ua.user_id = $1 AND ${catalogTextAfterSql('mcp_edge.target_id', '$2')}`, - duplicateCapable: true, - }, - { - sql: `SELECT mcp_edge.target_id AS logical_id - FROM team_agents ta - JOIN team_members tm ON tm.team_id = ta.team_id - JOIN operational_resource_relationships host_edge - ON host_edge.environment_id = $3 - AND host_edge.source_type = 'host' - AND host_edge.source_id = $5::text || '/' || ta.agent_name - AND host_edge.relationship_type = 'uses_context' - AND host_edge.target_type = 'context' - CROSS JOIN LATERAL ( - SELECT ${canonicalContextLogicalIdSql('$3', 'host_edge.target_id')} - AS canonical_id - ) context_identity - JOIN operational_resource_relationships mcp_edge - ON mcp_edge.environment_id = $3 - AND mcp_edge.source_type = 'context' - AND mcp_edge.source_id = context_identity.canonical_id - AND mcp_edge.relationship_type = 'includes_mcp_server' - AND mcp_edge.target_type = 'mcp_server' - JOIN operational_resource_index host_resource - ON host_resource.environment_id = $3 AND host_resource.resource_type = 'host' - AND host_resource.logical_id = host_edge.source_id - AND host_resource.enabled = TRUE AND host_resource.deleted_at IS NULL - JOIN operational_resource_index context_resource - ON context_resource.environment_id = $3 AND context_resource.resource_type = 'context' - AND context_resource.logical_id = context_identity.canonical_id - AND context_resource.enabled = TRUE AND context_resource.deleted_at IS NULL - JOIN operational_resource_index target_resource - ON target_resource.environment_id = $3 AND target_resource.resource_type = 'mcp_server' - AND target_resource.logical_id = mcp_edge.target_id - AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL - WHERE tm.user_id = $1 AND tm.status = 'active' - AND ${catalogTextAfterSql('mcp_edge.target_id', '$2')}`, - duplicateCapable: true, - }, - ]), - workflow_recipe: boundedKeyUnionSql([ - { - sql: `SELECT uwt.recipe_namespace || '/' || uwt.recipe_name AS logical_id - FROM user_workflow_triggers uwt - JOIN operational_resource_index resource - ON resource.environment_id = $3 AND resource.resource_type = 'workflow_recipe' - AND resource.logical_id = uwt.recipe_namespace || '/' || uwt.recipe_name - AND resource.enabled = TRUE AND resource.deleted_at IS NULL - WHERE uwt.user_id = $1 - AND ${catalogTextAfterSql("uwt.recipe_namespace || '/' || uwt.recipe_name", '$2')}`, - orderBy: 'logical_id', - }, - { - sql: `SELECT twt.recipe_namespace || '/' || twt.recipe_name AS logical_id - FROM team_workflow_triggers twt - JOIN team_members tm ON tm.team_id = twt.team_id - JOIN operational_resource_index resource - ON resource.environment_id = $3 AND resource.resource_type = 'workflow_recipe' - AND resource.logical_id = twt.recipe_namespace || '/' || twt.recipe_name - AND resource.enabled = TRUE AND resource.deleted_at IS NULL - WHERE tm.user_id = $1 AND tm.status = 'active' - AND ${catalogTextAfterSql("twt.recipe_namespace || '/' || twt.recipe_name", '$2')}`, - orderBy: 'logical_id', - duplicateCapable: true, - }, - ]), - workflow_run: boundedKeyUnionSql([ - { - sql: `SELECT wr.run_id::text AS logical_id, wr.run_id AS cursor_id - FROM workflow_runs wr - JOIN user_workflow_triggers uwt - ON uwt.user_id = $1 - AND uwt.recipe_namespace = wr.recipe_namespace - AND uwt.recipe_name = wr.recipe_name - WHERE wr.actor_type = 'user' AND wr.actor_id = $1 - AND wr.team_id IS NULL AND wr.usage_team_id IS NULL - AND wr.run_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000')`, - orderBy: 'cursor_id', - }, - { - sql: `SELECT wr.run_id::text AS logical_id, wr.run_id AS cursor_id - FROM workflow_runs wr - JOIN team_members tm - ON tm.team_id = wr.team_id AND tm.user_id = $1 AND tm.status = 'active' - JOIN team_workflow_triggers twt - ON twt.team_id = tm.team_id - AND twt.recipe_namespace = wr.recipe_namespace - AND twt.recipe_name = wr.recipe_name - WHERE (wr.actor_type IN ('scheduled', 'autonomous') - OR (wr.actor_type = 'user' AND (wr.actor_id = $1 OR wr.actor_id IS NULL))) - AND wr.run_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000')`, - orderBy: 'cursor_id', - }, - { - sql: `SELECT wr.run_id::text AS logical_id, wr.run_id AS cursor_id - FROM workflow_runs wr - JOIN team_members tm - ON tm.team_id::text = wr.usage_team_id - AND tm.user_id = $1::uuid AND tm.status = 'active' - JOIN team_workflow_triggers twt - ON twt.team_id = tm.team_id - AND twt.recipe_namespace = wr.recipe_namespace - AND twt.recipe_name = wr.recipe_name - WHERE wr.usage_team_id IS NOT NULL - AND (wr.actor_type IN ('scheduled', 'autonomous') - OR (wr.actor_type = 'user' AND (wr.actor_id = $1 OR wr.actor_id IS NULL))) - AND wr.run_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000')`, - orderBy: 'cursor_id', - }, - ]), - workflow_approval: boundedKeyUnionSql([ - { - sql: `SELECT war.id::text AS logical_id, war.id AS cursor_id, - war.expires_at AS valid_until - FROM workflow_approval_requests war - JOIN user_workflow_triggers uwt - ON uwt.user_id = $1 - AND uwt.recipe_namespace = war.recipe_namespace - AND uwt.recipe_name = war.recipe_name - WHERE war.target_user_id = $1 AND war.status = 'pending' - AND war.expires_at > NOW() - AND war.id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000')`, - orderBy: 'cursor_id', - hasValidUntil: true, - }, - { - sql: `SELECT war.id::text AS logical_id, war.id AS cursor_id, - war.expires_at AS valid_until - FROM workflow_approval_requests war - WHERE war.status = 'pending' AND war.expires_at > NOW() - AND war.id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000') - AND EXISTS ( - SELECT 1 - FROM team_members tm - JOIN workflow_recipe_allowed_teams wat - ON wat.team_id = tm.team_id - AND wat.recipe_namespace = war.recipe_namespace - AND wat.recipe_name = war.recipe_name - WHERE tm.team_id = war.target_team_id - AND tm.user_id = $1 AND tm.status = 'active' - )`, - orderBy: 'cursor_id', - hasValidUntil: true, - }, - ]), - notification: boundedKeyUnionSql([ - { - sql: `SELECT nd.id::text AS logical_id, nd.id AS cursor_id, - nd.expires_at AS valid_until - FROM notification_deliveries nd - WHERE nd.audience->>'userId' = $1::text - AND (nd.expires_at IS NULL OR nd.expires_at > NOW()) - AND nd.id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000')`, - orderBy: 'cursor_id', - hasValidUntil: true, - }, - { - sql: `SELECT nd.id::text AS logical_id, nd.id AS cursor_id, - nd.expires_at AS valid_until - FROM notification_deliveries nd - JOIN team_members tm - ON tm.team_id::text = nd.audience->>'teamId' - AND tm.user_id = $1::uuid AND tm.status = 'active' - WHERE (nd.expires_at IS NULL OR nd.expires_at > NOW()) - AND nd.id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000')`, - orderBy: 'cursor_id', - hasValidUntil: true, - }, - ]), - gfs_resource: boundedKeyUnionSql([ - { - sql: `SELECT g.resource_id::text AS logical_id, g.resource_id AS cursor_id - FROM gfs_grants g - JOIN gfs_resources resource - ON resource.resource_id = g.resource_id AND resource.deleted_at IS NULL - WHERE g.subject_type = 'user' AND g.subject_id = $1::text - AND g.resource_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000')`, - orderBy: 'cursor_id', - duplicateCapable: true, - }, - { - sql: `SELECT share.resource_id::text AS logical_id, share.resource_id AS cursor_id - FROM gfs_shares share - JOIN gfs_resources resource - ON resource.resource_id = share.resource_id AND resource.deleted_at IS NULL - WHERE share.subject_type = 'user' AND share.subject_id = $1::text - AND share.resource_id > COALESCE(NULLIF($2, '')::uuid, '00000000-0000-0000-0000-000000000000')`, - orderBy: 'cursor_id', - duplicateCapable: true, - }, - ]), - shared_filesystem: boundedKeyUnionSql([ - { - sql: `SELECT edge.target_id AS logical_id - FROM user_contexts uc - CROSS JOIN LATERAL ( - SELECT ${canonicalContextLogicalIdSql('$3', "$6::text || '/' || uc.context_id")} - AS canonical_id - ) context_identity - JOIN operational_resource_relationships edge - ON edge.environment_id = $3 - AND edge.source_type = 'context' - AND edge.source_id = context_identity.canonical_id - AND edge.relationship_type = 'mounts_shared_filesystem' - AND edge.target_type = 'shared_filesystem' - JOIN operational_resource_index source_resource - ON source_resource.environment_id = $3 AND source_resource.resource_type = 'context' - AND source_resource.logical_id = context_identity.canonical_id - AND source_resource.enabled = TRUE AND source_resource.deleted_at IS NULL - JOIN operational_resource_index target_resource - ON target_resource.environment_id = $3 - AND target_resource.resource_type = 'shared_filesystem' - AND target_resource.logical_id = edge.target_id - AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL - WHERE uc.user_id = $1 AND ${catalogTextAfterSql('edge.target_id', '$2')}`, - duplicateCapable: true, - }, - { - sql: `SELECT edge.target_id AS logical_id - FROM team_contexts tc - CROSS JOIN LATERAL ( - SELECT ${canonicalContextLogicalIdSql('$3', "$6::text || '/' || tc.context_id")} - AS canonical_id - ) context_identity - JOIN team_members tm ON tm.team_id = tc.team_id - JOIN operational_resource_relationships edge - ON edge.environment_id = $3 - AND edge.source_type = 'context' - AND edge.source_id = context_identity.canonical_id - AND edge.relationship_type = 'mounts_shared_filesystem' - AND edge.target_type = 'shared_filesystem' - JOIN operational_resource_index source_resource - ON source_resource.environment_id = $3 AND source_resource.resource_type = 'context' - AND source_resource.logical_id = context_identity.canonical_id - AND source_resource.enabled = TRUE AND source_resource.deleted_at IS NULL - JOIN operational_resource_index target_resource - ON target_resource.environment_id = $3 - AND target_resource.resource_type = 'shared_filesystem' - AND target_resource.logical_id = edge.target_id - AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL - WHERE tm.user_id = $1 AND tm.status = 'active' - AND ${catalogTextAfterSql('edge.target_id', '$2')}`, - duplicateCapable: true, - }, - ]), - sandbox_app: boundedKeyUnionSql([ - { - sql: `SELECT edge.target_id AS logical_id - FROM operational_resource_relationships edge - JOIN operational_resource_index source_resource - ON source_resource.environment_id = $3 - AND source_resource.resource_type = 'workflow_recipe' - AND source_resource.logical_id = edge.source_id - AND source_resource.enabled = TRUE AND source_resource.deleted_at IS NULL - JOIN operational_resource_index target_resource - ON target_resource.environment_id = $3 AND target_resource.resource_type = 'sandbox_app' - AND target_resource.logical_id = edge.target_id - AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL - WHERE edge.environment_id = $3 - AND edge.source_type = 'workflow_recipe' - AND edge.relationship_type = 'exposes_sandbox_app' - AND edge.target_type = 'sandbox_app' - AND ${catalogTextAfterSql('edge.target_id', '$2')} - AND EXISTS ( - SELECT 1 - FROM user_workflow_triggers uwt - WHERE uwt.user_id = $1 - AND uwt.recipe_namespace || '/' || uwt.recipe_name = edge.source_id - )`, - duplicateCapable: true, - }, - { - sql: `SELECT edge.target_id AS logical_id - FROM operational_resource_relationships edge - JOIN operational_resource_index source_resource - ON source_resource.environment_id = $3 - AND source_resource.resource_type = 'workflow_recipe' - AND source_resource.logical_id = edge.source_id - AND source_resource.enabled = TRUE AND source_resource.deleted_at IS NULL - JOIN operational_resource_index target_resource - ON target_resource.environment_id = $3 AND target_resource.resource_type = 'sandbox_app' - AND target_resource.logical_id = edge.target_id - AND target_resource.enabled = TRUE AND target_resource.deleted_at IS NULL - WHERE edge.environment_id = $3 - AND edge.source_type = 'workflow_recipe' - AND edge.relationship_type = 'exposes_sandbox_app' - AND edge.target_type = 'sandbox_app' - AND ${catalogTextAfterSql('edge.target_id', '$2')} - AND EXISTS ( - SELECT 1 - FROM team_workflow_triggers twt - JOIN team_members tm ON tm.team_id = twt.team_id - WHERE tm.user_id = $1 AND tm.status = 'active' - AND twt.recipe_namespace || '/' || twt.recipe_name = edge.source_id - )`, - duplicateCapable: true, - }, - ]), -}) diff --git a/control-api/src/services/access/catalogProducerSupport.ts b/control-api/src/services/access/catalogProducerSupport.ts deleted file mode 100644 index 46b2a320a8..0000000000 --- a/control-api/src/services/access/catalogProducerSupport.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Compatibility facade for catalog-producer callers. Each responsibility has - * a focused owner; existing imports remain stable while callers migrate. - */ -export { - type BoundedKeyArm, - boundedKeyUnionSql, - catalogTextAfterSql, - catalogTextOrderSql, -} from './catalogProducerArms.js' -export { catalogQuery } from './catalogProducerDatabase.js' -export { CatalogProducerContractError } from './catalogProducerErrors.js' -export { listBoundedProducerKeys } from './catalogProducerHead.js' -export { validateHydrationKeys } from './catalogProducerHydration.js' -export { operationalReadiness } from './catalogProducerReadiness.js' diff --git a/control-api/src/services/access/catalogProducers.ts b/control-api/src/services/access/catalogProducers.ts deleted file mode 100644 index c69390d0c3..0000000000 --- a/control-api/src/services/access/catalogProducers.ts +++ /dev/null @@ -1,691 +0,0 @@ -import { config } from '../../config.js' -import type { TeamRole } from '../../profileTypes.js' -import { type AuthorityCandidate, authorityCandidate } from './authorityCandidates.js' -import { - canonicalAccessPathSeeds, - databaseRelationshipsRevision, - revisionOfValues, -} from './authorizationRevision.js' -import { compareCanonicalUtf8Text } from './canonicalText.js' -import type { AccessCapability } from './capabilityRegistry.js' -import { gfsPermissionsToCapabilities } from './capabilityRegistry.js' -import { - CATALOG_FAMILIES, - type CatalogFamily, - type CatalogHydrationIncompleteRoot, - type CatalogHydrationOutcome, - type CatalogHydrationResult, - type CatalogKey, - type CatalogProducer, - type CatalogProducerPage, - type CatalogRelationship, - type CatalogRequestContext, - type HydratedCatalogResource, - type ProducerContinuation, - catalogKey, -} from './catalogContracts.js' -import { - DERIVED_OPERATIONAL_HYDRATION_SQL, - GFS_HYDRATION_SQL, - NOTIFICATION_HYDRATION_SQL, - SIMPLE_OPERATIONAL_HYDRATION_SQL, - TEAM_HYDRATION_SQL, - USER_HYDRATION_SQL, - WORKFLOW_APPROVAL_HYDRATION_SQL, - WORKFLOW_RUN_HYDRATION_SQL, -} from './catalogHydrationSql.js' -import { CATALOG_KEY_SQL } from './catalogProducerSql.js' -import { - CatalogProducerContractError, - catalogQuery, - listBoundedProducerKeys, - operationalReadiness, - validateHydrationKeys, -} from './catalogProducerSupport.js' -import type { OperationalSourceFamily } from './operationalAccessProjection.js' -import { canonicalResourceIdentity } from './resourceIdentity.js' -import { listGfsProducerKeys } from './teamGfsTopK.js' - -const REQUIRED_SOURCES: Readonly> = - Object.freeze({ - user: [], - team: [], - host: ['host'], - context: ['context'], - mcp_server: ['host', 'context', 'mcp_server'], - workflow_recipe: ['workflow_recipe'], - workflow_run: [], - workflow_approval: [], - notification: [], - gfs_resource: [], - shared_filesystem: ['context', 'shared_filesystem'], - sandbox_app: ['workflow_recipe'], - }) - -type JsonRecord = Record - -function records(value: unknown): JsonRecord[] { - if (!Array.isArray(value)) throw new CatalogProducerContractError('json_array_invalid') - return value.map(item => { - if (!item || typeof item !== 'object' || Array.isArray(item)) { - throw new CatalogProducerContractError('json_record_invalid') - } - return item as JsonRecord - }) -} - -function boundedString(value: unknown, label: string, maximum = 1_024): string { - const result = typeof value === 'string' ? value : String(value ?? '') - if (!result || result.length > maximum || /[\u0000-\u001f\u007f]/u.test(result)) { - throw new CatalogProducerContractError(`${label}_invalid`) - } - return result -} - -function nullableString(value: unknown, maximum = 1_024): string | null { - if (value === null || value === undefined) return null - return boundedString(value, 'nullable_string', maximum) -} - -function nonNegativeInteger(value: unknown, label: string): number { - const result = Number(value) - if (!Number.isSafeInteger(result) || result < 0) { - throw new CatalogProducerContractError(`${label}_invalid`) - } - return result -} - -function teamRole(value: unknown): TeamRole | undefined { - return ['admin', 'inviter', 'member'].includes(String(value)) - ? (String(value) as TeamRole) - : undefined -} - -function kindOf(row: JsonRecord): 'direct' | 'team' { - if (row.kind === 'direct' || row.kind === 'team') return row.kind - throw new CatalogProducerContractError('path_kind_invalid') -} - -function relationValue(row: JsonRecord, camel: string, snake: string): unknown { - return row[camel] ?? row[snake] -} - -function catalogRelationship(row: JsonRecord): CatalogRelationship { - const type = boundedString( - relationValue(row, 'type', 'relationship_type'), - 'relationship_type', - 128 - ) - const explicitTarget = relationValue(row, 'targetResourceId', 'target_resource_id') - const targetResourceId = explicitTarget - ? boundedString(explicitTarget, 'relationship_target', 1_536) - : `${boundedString(row.target_type, 'relationship_target_type', 64)}:${boundedString( - row.target_id, - 'relationship_target_id' - )}` - const instance = relationValue(row, 'instanceId', 'relationship_instance_id') - return Object.freeze({ - type, - targetResourceId, - ...(instance ? { instanceId: boundedString(instance, 'relationship_instance', 256) } : {}), - }) -} - -function canonicalRelationships(rows: readonly JsonRecord[]): CatalogRelationship[] { - const values = new Map() - for (const row of rows) { - const relationship = catalogRelationship(row) - values.set( - JSON.stringify([ - relationship.type, - relationship.targetResourceId, - relationship.instanceId ?? null, - ]), - relationship - ) - } - return [...values.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([, value]) => value) -} - -function operationalRelationshipsRevision(rows: readonly JsonRecord[]): string { - return revisionOfValues( - rows.map(row => [ - row.source_type, - row.source_id, - row.relationship_type, - row.target_type, - row.target_id, - row.relationship_instance_id, - row.behavior_attributes, - row.source_provider_uid, - row.source_resource_version, - ]) - ) -} - -function commonCandidate(input: { - context: CatalogRequestContext - row: JsonRecord - capabilities: readonly AccessCapability[] - runtimeSensitive?: boolean - filesystemScope?: string | null - runtimeRef?: string | null - approvalRef?: string | null -}): AuthorityCandidate { - const kind = kindOf(input.row) - const teamId = kind === 'team' ? boundedString(input.row.team_id, 'path_team_id', 64) : undefined - const role = kind === 'team' ? teamRole(input.row.current_role) : undefined - if (kind === 'team' && !role) throw new CatalogProducerContractError('path_role_invalid') - return authorityCandidate({ - userId: input.context.principal.userId, - kind, - grantId: boundedString(input.row.grant_id, 'path_grant_id', 2_048), - ...(teamId ? { teamId } : {}), - ...(role ? { currentRole: role } : {}), - capabilities: input.capabilities, - runtimeSensitive: input.runtimeSensitive, - filesystemScope: input.filesystemScope, - runtimeRef: input.runtimeRef, - approvalRef: input.approvalRef, - }) -} - -function capabilitiesForFamily(family: CatalogFamily): readonly AccessCapability[] { - switch (family) { - case 'user': - return ['user.profile.read'] - case 'host': - return [ - 'host.read', - 'host.use', - 'host.activity.read', - 'remote_desktop.use', - 'chat.read', - 'chat.message.invoke', - 'task.read', - 'task.manage', - 'model.read', - 'model.select', - 'session.read', - 'session.manage', - ] - case 'context': - return ['context.read', 'context.use'] - case 'mcp_server': - return ['mcp_server.read', 'mcp_server.use'] - case 'workflow_recipe': - return ['workflow.read', 'workflow.trigger'] - case 'workflow_run': - return [ - 'workflow.read', - 'workflow.run.manage', - 'workflow.artifact.read', - 'workflow.artifact.delete', - ] - case 'workflow_approval': - return ['workflow.approval.decide'] - case 'notification': - return ['notification.read'] - case 'shared_filesystem': - return ['shared_filesystem.read'] - case 'sandbox_app': - return ['sandbox_app.read', 'sandbox_app.use', 'sandbox_oauth.vend'] - default: - return [] - } -} - -type SimpleOperationalFamily = 'host' | 'context' | 'workflow_recipe' | 'sandbox_app' - -/** - * Selects the relationship rows that are the simple operational item's own - * configuration. The published runtime descriptor and the serialized - * relationships share this predicate, so an item cannot expose an edge that is - * not on one of its own access paths. - */ -function ownsSimpleOperationalRelationship(input: { - family: SimpleOperationalFamily - logicalId: string - row: JsonRecord -}): boolean { - if (input.family === 'sandbox_app') { - return input.row.target_type === 'sandbox_app' && input.row.target_id === input.logicalId - } - if (input.family === 'workflow_recipe') return true - return ( - String(input.row.source_type ?? '') === input.family && - String(input.row.source_id ?? '') === input.logicalId - ) -} - -/** - * Selects the relationships a derived access path actually traverses: the - * Context edge that carries the item and, for an agent path, the Host edge that - * carries that Context. Edge instance ids are unique per source/target pair, so - * membership is exact without comparing serialized payloads. - */ -function ownsDerivedOperationalRelationship(input: { - pathRows: readonly JsonRecord[] - row: JsonRecord -}): boolean { - const instance = boundedString(input.row.relationship_instance_id, 'relationship_instance', 256) - return input.pathRows.some(pathRow => { - if (boundedString(pathRow.edge_instance, 'edge_instance', 256) === instance) return true - return nullableString(pathRow.host_edge_instance, 256) === instance - }) -} - -/** - * Only the rows the caller reaches through this item's own paths are published. - * The authorization relationships revision below still covers every hydrated - * row, because the live reader hashes the unfiltered graph. - */ -function serializableRelationshipRows(input: { - family: CatalogFamily - logicalId: string - pathRows: readonly JsonRecord[] - relationshipRows: readonly JsonRecord[] -}): readonly JsonRecord[] { - const family = input.family - if (family === 'mcp_server' || family === 'shared_filesystem') { - return input.relationshipRows.filter(row => - ownsDerivedOperationalRelationship({ pathRows: input.pathRows, row }) - ) - } - if ( - family === 'host' || - family === 'context' || - family === 'workflow_recipe' || - family === 'sandbox_app' - ) { - return input.relationshipRows.filter(row => - ownsSimpleOperationalRelationship({ family, logicalId: input.logicalId, row }) - ) - } - return input.relationshipRows -} - -function simpleOperationalCandidates(input: { - context: CatalogRequestContext - family: SimpleOperationalFamily - logicalId: string - pathRows: readonly JsonRecord[] - relationshipRows: readonly JsonRecord[] -}): AuthorityCandidate[] { - const relevantRelationships = input.relationshipRows.filter(row => - ownsSimpleOperationalRelationship({ - family: input.family, - logicalId: input.logicalId, - row, - }) - ) - const runtimeRef = JSON.stringify( - relevantRelationships - .map(row => boundedString(row.relationship_instance_id, 'relationship_instance', 256)) - .sort(compareCanonicalUtf8Text) - ) - return input.pathRows.map(row => - commonCandidate({ - context: input.context, - row, - capabilities: capabilitiesForFamily(input.family), - runtimeSensitive: true, - runtimeRef, - }) - ) -} - -function derivedCandidates(input: { - context: CatalogRequestContext - family: 'mcp_server' | 'shared_filesystem' - pathRows: readonly JsonRecord[] -}): AuthorityCandidate[] { - return input.pathRows.map(row => { - const edgeInstance = boundedString(row.edge_instance, 'edge_instance', 256) - const sourceId = boundedString(row.source_id, 'path_source_id') - if (input.family === 'shared_filesystem') { - const edgeBehavior = - row.edge_behavior && typeof row.edge_behavior === 'object' - ? (row.edge_behavior as JsonRecord) - : {} - return commonCandidate({ - context: input.context, - row, - capabilities: capabilitiesForFamily(input.family), - filesystemScope: JSON.stringify({ - contextId: sourceId, - relationshipInstanceId: edgeInstance, - mountPath: boundedString(edgeBehavior.mountPath, 'filesystem_mount_path'), - readOnly: true, - }), - runtimeRef: edgeInstance, - }) - } - const hostEdge = nullableString(row.host_edge_instance, 256) - return commonCandidate({ - context: input.context, - row, - capabilities: capabilitiesForFamily(input.family), - runtimeSensitive: true, - runtimeRef: hostEdge ? JSON.stringify([hostEdge, edgeInstance]) : edgeInstance, - }) - }) -} - -function databaseCandidates(input: { - context: CatalogRequestContext - family: CatalogFamily - logicalId: string - pathRows: readonly JsonRecord[] -}): AuthorityCandidate[] { - if (input.family === 'team') { - return input.pathRows.map(row => { - const role = teamRole(row.current_role) - if (!role) throw new CatalogProducerContractError('path_role_invalid') - const capabilities: AccessCapability[] = ['team.read'] - if (role === 'admin') { - capabilities.push( - 'team.manage', - 'team.member.read', - 'team.member.invite', - 'team.member.manage' - ) - } else if (role === 'inviter') { - capabilities.push('team.member.read', 'team.member.invite') - } - return commonCandidate({ context: input.context, row, capabilities }) - }) - } - if (input.family === 'gfs_resource') { - return input.pathRows.map(row => - commonCandidate({ - context: input.context, - row, - capabilities: gfsPermissionsToCapabilities(row.permissions), - filesystemScope: JSON.stringify({ - drive: boundedString(row.drive, 'gfs_drive', 512), - resourceId: input.logicalId, - }), - }) - ) - } - return input.pathRows.map(row => - commonCandidate({ - context: input.context, - row, - capabilities: capabilitiesForFamily(input.family), - runtimeSensitive: input.family === 'workflow_run', - approvalRef: input.family === 'workflow_approval' ? `approval:${input.logicalId}` : undefined, - }) - ) -} - -function hydrationSql(family: CatalogFamily): string { - if (family === 'user') return USER_HYDRATION_SQL - if (family === 'team') return TEAM_HYDRATION_SQL - if (['host', 'context', 'workflow_recipe', 'sandbox_app'].includes(family)) { - return SIMPLE_OPERATIONAL_HYDRATION_SQL - } - if (family === 'mcp_server' || family === 'shared_filesystem') { - return DERIVED_OPERATIONAL_HYDRATION_SQL - } - if (family === 'workflow_run') return WORKFLOW_RUN_HYDRATION_SQL - if (family === 'workflow_approval') return WORKFLOW_APPROVAL_HYDRATION_SQL - if (family === 'notification') return NOTIFICATION_HYDRATION_SQL - return GFS_HYDRATION_SQL -} - -function hydrationValues(input: { - context: CatalogRequestContext - family: CatalogFamily - logicalIds: readonly string[] -}): unknown[] { - const base = [input.context.principal.userId, input.logicalIds, input.context.environmentId] - if (input.family === 'user' || input.family === 'team') return base - if ( - ['workflow_run', 'workflow_approval', 'notification', 'gfs_resource'].includes(input.family) - ) { - return [...base, input.context.budget.remaining('accessPaths') + 1] - } - return [ - ...base, - input.family, - config.hostsNamespace, - config.contextsNamespace, - input.context.budget.remaining('accessPaths') + 1, - input.context.budget.remaining('relationships') + 1, - ] -} - -function decodedHydrationBytes(rows: readonly unknown[]): number { - try { - return Math.max(1, Buffer.byteLength(JSON.stringify(rows), 'utf8')) - } catch { - throw new CatalogProducerContractError('hydration_not_serializable') - } -} - -function hydrateRows(input: { - context: CatalogRequestContext - family: CatalogFamily - sourceRevision: string - rows: readonly JsonRecord[] -}): CatalogHydrationResult { - input.context.budget.charge({ - kind: 'decodedBytes', - amount: decodedHydrationBytes(input.rows), - authorityRequired: true, - }) - const hydrated: HydratedCatalogResource[] = [] - const incompleteRoots: CatalogHydrationIncompleteRoot[] = [] - let representedPaths = 0 - let representedRelationships = 0 - let rawPaths = 0 - let rawRelationships = 0 - let reportedPathRows = 0 - let reportedRelationshipRows = 0 - for (const row of input.rows) { - const logicalId = boundedString(row.logical_id, 'resource_logical_id') - const pathRows = records(row.paths) - const relationshipRows = records(row.relationships) - rawPaths += pathRows.length - rawRelationships += relationshipRows.length - reportedPathRows = Math.max( - reportedPathRows, - nonNegativeInteger(row.total_path_rows ?? 0, 'total_path_rows') - ) - reportedRelationshipRows = Math.max( - reportedRelationshipRows, - nonNegativeInteger(row.total_relationship_rows ?? 0, 'total_relationship_rows') - ) - if (row.incomplete_reason !== null && row.incomplete_reason !== undefined) { - if ( - input.family !== 'host' || - (row.incomplete_reason !== 'configured_context_missing' && - row.incomplete_reason !== 'configured_context_ambiguous') - ) { - throw new CatalogProducerContractError('incomplete_root_reason_invalid') - } - incompleteRoots.push({ - key: catalogKey(input.context.environmentId, input.family, logicalId), - reason: row.incomplete_reason, - }) - continue - } - if (pathRows.length === 0) continue - const accessPaths = - input.family === 'host' || - input.family === 'context' || - input.family === 'workflow_recipe' || - input.family === 'sandbox_app' - ? simpleOperationalCandidates({ - context: input.context, - family: input.family, - logicalId, - pathRows, - relationshipRows, - }) - : input.family === 'mcp_server' || input.family === 'shared_filesystem' - ? derivedCandidates({ context: input.context, family: input.family, pathRows }) - : databaseCandidates({ - context: input.context, - family: input.family, - logicalId, - pathRows, - }) - const paths = canonicalAccessPathSeeds(accessPaths) - const relationships = canonicalRelationships( - serializableRelationshipRows({ - family: input.family, - logicalId, - pathRows, - relationshipRows, - }) - ) - representedPaths += paths.length - representedRelationships += relationships.length - const validUntil = row.valid_until ? new Date(String(row.valid_until)).toISOString() : null - hydrated.push( - Object.freeze({ - key: catalogKey(input.context.environmentId, input.family, logicalId), - resource: canonicalResourceIdentity({ - environmentId: input.context.environmentId, - type: input.family, - logicalId, - displayName: boundedString(row.display_name, 'resource_display_name', 512), - providerUid: nullableString(row.provider_uid, 256) ?? undefined, - }), - accessPaths: Object.freeze(paths), - relationships: Object.freeze(relationships), - authorizationResourceRevision: boundedString( - row.resource_revision ?? '1', - 'resource_revision', - 128 - ), - authorizationSourceRevision: input.sourceRevision, - // The operational families hash every hydrated row, not the published - // subset: the live reader recomputes this revision over the resource's - // whole relationship set, so a filtered revision would make every - // catalog access path id stale when a foreign edge exists. - authorizationRelationshipsRevision: - input.family === 'host' || - input.family === 'context' || - input.family === 'mcp_server' || - input.family === 'workflow_recipe' || - input.family === 'shared_filesystem' || - input.family === 'sandbox_app' - ? operationalRelationshipsRevision(relationshipRows) - : databaseRelationshipsRevision(relationships), - validUntil, - }) - ) - } - const remainingPaths = input.context.budget.remaining('accessPaths') - const remainingRelationships = input.context.budget.remaining('relationships') - const requiredPathBudget = Math.max(rawPaths, reportedPathRows) - const requiredRelationshipBudget = Math.max(rawRelationships, reportedRelationshipRows) - if (requiredPathBudget > remainingPaths) { - input.context.budget.charge({ - kind: 'accessPaths', - amount: requiredPathBudget, - authorityRequired: true, - }) - } - if (requiredRelationshipBudget > remainingRelationships) { - input.context.budget.charge({ - kind: 'relationships', - amount: requiredRelationshipBudget, - authorityRequired: true, - }) - } - if (representedPaths > 0) { - input.context.budget.charge({ - kind: 'accessPaths', - amount: representedPaths, - authorityRequired: true, - }) - } - if (representedRelationships > 0) { - input.context.budget.charge({ - kind: 'relationships', - amount: representedRelationships, - authorityRequired: true, - }) - } - return Object.freeze({ - resources: Object.freeze(hydrated), - incompleteRoots: Object.freeze(incompleteRoots), - }) -} - -class SqlCatalogProducer implements CatalogProducer { - readonly requiredOperationalSources: readonly OperationalSourceFamily[] - - constructor(readonly family: CatalogFamily) { - this.requiredOperationalSources = REQUIRED_SOURCES[family] - } - - listCanonicalKeys( - context: CatalogRequestContext, - continuation: ProducerContinuation, - take: number - ): Promise { - if (this.family === 'gfs_resource') { - return listGfsProducerKeys({ context, continuation, take }) - } - const prefix = `${this.family === 'host' ? config.hostsNamespace : config.contextsNamespace}/` - return listBoundedProducerKeys({ - context, - family: this.family, - requiredOperationalSources: this.requiredOperationalSources, - continuation, - take, - sql: CATALOG_KEY_SQL[this.family], - extraValues: after => [ - config.hostsNamespace, - config.contextsNamespace, - after.startsWith(prefix) ? after.slice(prefix.length) : '', - ], - }) - } - - async hydrateCanonicalKeys( - context: CatalogRequestContext, - keys: readonly CatalogKey[] - ): Promise { - const logicalIds = validateHydrationKeys({ context, family: this.family, keys }) - if (logicalIds.length === 0) return Object.freeze([]) - const readiness = operationalReadiness(context, this.family, this.requiredOperationalSources) - if (readiness.status !== 'current') { - throw new CatalogProducerContractError('operational_source_changed_before_hydration') - } - const result = await catalogQuery( - context.db, - context.budget, - hydrationSql(this.family), - hydrationValues({ context, family: this.family, logicalIds }) - ) - const hydrated = hydrateRows({ - context, - family: this.family, - sourceRevision: - this.requiredOperationalSources.length === 0 - ? 'database-resource' - : readiness.sourceRevision, - rows: result.rows as JsonRecord[], - }) - return hydrated.incompleteRoots.length > 0 ? hydrated : hydrated.resources - } -} - -export const catalogProducers: ReadonlyMap = new Map( - CATALOG_FAMILIES.map(family => [family, new SqlCatalogProducer(family)]) -) - -export function requireCatalogProducer(family: CatalogFamily): CatalogProducer { - const producer = catalogProducers.get(family) - if (!producer) throw new CatalogProducerContractError('producer_missing') - return producer -} diff --git a/control-api/src/services/access/mcpInvocable.ts b/control-api/src/services/access/mcpInvocable.ts index 57bd7716b7..b6f7612efc 100644 --- a/control-api/src/services/access/mcpInvocable.ts +++ b/control-api/src/services/access/mcpInvocable.ts @@ -625,7 +625,6 @@ export async function resolveMcpServersForAgents( contextsNamespace: string hostsNamespace: string agentNames: readonly string[] - onPartial?: (source: 'mcp_servers' | 'contexts') => void } ): Promise { const { mcpServersNamespace, contextsNamespace, hostsNamespace, agentNames } = opts @@ -639,7 +638,6 @@ export async function resolveMcpServersForAgents( try { serverList = asArray(await gateway.listResource('mcpservers', mcpServersNamespace)) } catch (err) { - opts.onPartial?.('mcp_servers') // Directory identity remains useful when optional MCP catalog enrichment // is temporarily unavailable — but the degradation must be observable, or // a broken mcpservers list path reads as "this agent has no tools". @@ -664,7 +662,6 @@ export async function resolveMcpServersForAgents( scopedContextIds ) } catch (err) { - opts.onPartial?.('contexts') // Preserve the authorized directory DTOs with an empty tool catalog — // loudly, so a Context-read outage is distinguishable from "no tools". log.error( diff --git a/control-api/src/services/access/teamGfsTopK.ts b/control-api/src/services/access/teamGfsTopK.ts deleted file mode 100644 index 4a169b95b8..0000000000 --- a/control-api/src/services/access/teamGfsTopK.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { AccessBudgetExceededError } from './accessExecutionBudget.js' -import { - type CatalogIdentityCandidate, - type CatalogProducerPage, - type CatalogRequestContext, - type ProducerContinuation, - catalogKey, - compareCatalogText, -} from './catalogContracts.js' -import { CATALOG_KEY_SQL } from './catalogProducerSql.js' -import { - CatalogProducerContractError, - catalogQuery, - catalogTextOrderSql, - listBoundedProducerKeys, -} from './catalogProducerSupport.js' - -export type TeamGfsStreamKind = 'grant' | 'share' -export type TeamGfsStreamRequest = Readonly<{ - kind: TeamGfsStreamKind - subjectId: string - afterId: string - take: number -}> -export type TeamGfsStreamHead = Readonly<{ - kind: TeamGfsStreamKind - subjectId: string - logicalId: string - batchLast: boolean -}> - -const TEAM_GFS_ADVANCE_CHUNK = 8 - -const TEAM_GFS_STREAM_HEAD_SQL = `WITH requested AS ( - SELECT stream.kind, stream.subject_id, stream.after_id, stream.take - FROM jsonb_to_recordset($1::jsonb) - AS stream(kind text, subject_id text, after_id uuid, take integer) -), heads AS ( - SELECT requested.kind, requested.subject_id, candidate.resource_id - FROM (SELECT * FROM requested WHERE kind = 'grant') requested - CROSS JOIN LATERAL ( - SELECT grant_row.resource_id - FROM gfs_grants grant_row - CROSS JOIN LATERAL ( - SELECT 1 - FROM gfs_resources resource - WHERE resource.resource_id = grant_row.resource_id - AND resource.drive = grant_row.drive AND resource.deleted_at IS NULL - OFFSET 0 - ) live_resource - WHERE grant_row.subject_type = 'team' AND grant_row.subject_id = requested.subject_id - AND grant_row.resource_id > requested.after_id - ORDER BY grant_row.resource_id - LIMIT requested.take - ) candidate - UNION ALL - SELECT requested.kind, requested.subject_id, candidate.resource_id - FROM (SELECT * FROM requested WHERE kind = 'share') requested - CROSS JOIN LATERAL ( - SELECT share.resource_id - FROM gfs_shares share - CROSS JOIN LATERAL ( - SELECT 1 - FROM gfs_resources resource - WHERE resource.resource_id = share.resource_id - AND resource.drive = share.drive AND resource.deleted_at IS NULL - OFFSET 0 - ) live_resource - WHERE share.subject_type = 'team' AND share.subject_id = requested.subject_id - AND share.resource_id > requested.after_id - ORDER BY share.resource_id - LIMIT requested.take - ) candidate -) -SELECT ordered.kind, ordered.subject_id, ordered.logical_id - FROM ( - SELECT kind, subject_id, resource_id::text AS logical_id - FROM heads - ) ordered - ORDER BY ${catalogTextOrderSql('ordered.logical_id')}, - ${catalogTextOrderSql('ordered.kind')}, - ${catalogTextOrderSql('ordered.subject_id')}` - -function compareHead(left: TeamGfsStreamHead, right: TeamGfsStreamHead): number { - return ( - compareCatalogText(left.logicalId, right.logicalId) || - compareCatalogText(left.kind, right.kind) || - compareCatalogText(left.subjectId, right.subjectId) - ) -} - -class HeadHeap { - private readonly values: TeamGfsStreamHead[] = [] - - get size(): number { - return this.values.length - } - - peek(): TeamGfsStreamHead | undefined { - return this.values[0] - } - - push(value: TeamGfsStreamHead): void { - this.values.push(value) - let index = this.values.length - 1 - while (index > 0) { - const parent = Math.floor((index - 1) / 2) - if (compareHead(this.values[parent]!, value) <= 0) break - this.values[index] = this.values[parent]! - index = parent - } - this.values[index] = value - } - - pop(): TeamGfsStreamHead | undefined { - const first = this.values[0] - const last = this.values.pop() - if (!first || !last || this.values.length === 0) return first - let index = 0 - while (true) { - const left = index * 2 + 1 - const right = left + 1 - if (left >= this.values.length) break - const child = - right < this.values.length && compareHead(this.values[right]!, this.values[left]!) < 0 - ? right - : left - if (compareHead(last, this.values[child]!) <= 0) break - this.values[index] = this.values[child]! - index = child - } - this.values[index] = last - return first - } -} - -function parseHeads(rows: readonly Record[]): TeamGfsStreamHead[] { - const parsed = rows.map(row => { - const kind = row.kind - const subjectId = row.subject_id - const logicalId = row.logical_id - if ( - (kind !== 'grant' && kind !== 'share') || - typeof subjectId !== 'string' || - !subjectId || - typeof logicalId !== 'string' || - !logicalId - ) { - throw new CatalogProducerContractError('team_gfs_stream_head_invalid') - } - return { kind: kind as TeamGfsStreamKind, subjectId, logicalId } - }) - const lastByStream = new Map() - for (const head of parsed) lastByStream.set(`${head.kind}:${head.subjectId}`, head.logicalId) - return parsed.map(head => - Object.freeze({ - ...head, - batchLast: lastByStream.get(`${head.kind}:${head.subjectId}`) === head.logicalId, - }) - ) -} - -async function readHeads( - context: CatalogRequestContext, - streams: readonly TeamGfsStreamRequest[] -): Promise { - if (streams.length === 0) return [] - const result = await catalogQuery( - context.db, - context.budget, - TEAM_GFS_STREAM_HEAD_SQL, - [ - JSON.stringify( - streams.map(stream => ({ - kind: stream.kind, - subject_id: stream.subjectId, - after_id: stream.afterId, - take: stream.take, - })) - ), - ], - { chargeProducer: false } - ) - return parseHeads(result.rows as Record[]) -} - -export async function collectTeamGfsTopK(input: { - streams: readonly Omit[] - take: number - read: (streams: readonly TeamGfsStreamRequest[]) => Promise -}): Promise> { - if (input.streams.length === 0) return { logicalIds: Object.freeze([]), hasMore: false } - const streamCount = input.streams.length - const initialTake = Math.max( - 1, - Math.min(TEAM_GFS_ADVANCE_CHUNK, Math.ceil((input.take + 1) / streamCount) + 1) - ) - const initialStreams = input.streams.map(stream => ({ ...stream, take: initialTake })) - const heap = new HeadHeap() - for (const head of await input.read(initialStreams)) heap.push(head) - const logicalIds: string[] = [] - while (heap.size > 0 && logicalIds.length < input.take + 1) { - const logicalId = heap.peek()!.logicalId - const consumed: TeamGfsStreamHead[] = [] - while (heap.peek()?.logicalId === logicalId) consumed.push(heap.pop()!) - logicalIds.push(logicalId) - if (logicalIds.length < input.take + 1) { - const advanced = await input.read( - consumed - .filter(head => head.batchLast) - .map(head => ({ - kind: head.kind, - subjectId: head.subjectId, - afterId: logicalId, - take: TEAM_GFS_ADVANCE_CHUNK, - })) - ) - for (const head of advanced) heap.push(head) - } - } - return Object.freeze({ logicalIds: Object.freeze(logicalIds), hasMore: heap.size > 0 }) -} - -async function teamCandidates(input: { - context: CatalogRequestContext - after: string - take: number -}): Promise> { - const memberships = input.context.principal.memberships - if (memberships.length === 0) return { candidates: Object.freeze([]), hasMore: false } - const admission = input.context.budget.teamGfsMembershipAdmissionLimit - if (admission === null || memberships.length > admission) { - throw new AccessBudgetExceededError('teamGfsMembershipAdmission', true) - } - const streams = memberships.flatMap(membership => - (['grant', 'share'] as const).map(kind => ({ - kind, - subjectId: membership.teamId, - afterId: input.after || '00000000-0000-0000-0000-000000000000', - })) - ) - const merged = await collectTeamGfsTopK({ - streams, - take: input.take, - read: requested => readHeads(input.context, requested), - }) - const candidates = merged.logicalIds.map(logicalId => - Object.freeze({ - key: catalogKey(input.context.environmentId, 'gfs_resource', logicalId), - canonicalId: `gfs_resource:${logicalId}`, - validUntil: null, - }) - ) - return Object.freeze({ candidates: Object.freeze(candidates), hasMore: merged.hasMore }) -} - -function mergeCandidates( - direct: readonly CatalogIdentityCandidate[], - team: readonly CatalogIdentityCandidate[], - take: number -): CatalogIdentityCandidate[] { - const byId = new Map() - for (const candidate of [...direct, ...team]) byId.set(candidate.key[2], candidate) - return [...byId.values()] - .sort((left, right) => compareCatalogText(left.key[2], right.key[2])) - .slice(0, take + 1) -} - -export async function listGfsProducerKeys(input: { - context: CatalogRequestContext - continuation: ProducerContinuation - take: number -}): Promise { - const direct = await listBoundedProducerKeys({ - context: input.context, - family: 'gfs_resource', - requiredOperationalSources: [], - continuation: input.continuation, - take: input.take, - sql: CATALOG_KEY_SQL.gfs_resource, - extraValues: ['', '', ''], - }) - if (direct.sourceCompleteness !== 'complete' || input.continuation.exhausted) return direct - const after = input.continuation.afterKey?.[2] ?? '' - const team = await teamCandidates({ context: input.context, after, take: input.take }) - const candidates = mergeCandidates(direct.candidates, team.candidates, input.take) - const hasMore = direct.hasMore || team.hasMore || candidates.length > input.take - return Object.freeze({ - candidates: Object.freeze(candidates), - continuation: Object.freeze({ - afterKey: candidates.at(-1)?.key ?? input.continuation.afterKey, - exhausted: !hasMore, - }), - hasMore, - sourceRevision: direct.sourceRevision, - sourceCompleteness: direct.sourceCompleteness, - partialErrors: direct.partialErrors, - }) -} - -export const teamGfsTopKSql = Object.freeze({ streamHead: TEAM_GFS_STREAM_HEAD_SQL }) diff --git a/control-api/src/services/access/userAccessPolicy.ts b/control-api/src/services/access/userAccessPolicy.ts index 02aa0641e6..c8eb4604c4 100644 --- a/control-api/src/services/access/userAccessPolicy.ts +++ b/control-api/src/services/access/userAccessPolicy.ts @@ -2,31 +2,13 @@ import { createHash } from 'node:crypto' export const USER_ACCESS_POLICY_VERSION = '1' as const -export const CATALOG_FAMILIES = [ - 'user', - 'team', - 'host', - 'context', - 'mcp_server', - 'workflow_recipe', - 'workflow_run', - 'workflow_approval', - 'notification', - 'gfs_resource', - 'shared_filesystem', - 'sandbox_app', -] as const - -export type CatalogFamily = (typeof CATALOG_FAMILIES)[number] export type LegacyLifecycle = 'issue_and_accept' | 'accept_only' | 'disabled' -export type CatalogMode = 'off' | 'shadow' | 'serve_and_shadow' | 'serve' export type Readiness = 'ready' | 'unavailable' export type ConfiguredUserAccessIntent = Readonly<{ legacyLifecycle: LegacyLifecycle sessionV2Acceptance: boolean sessionV2Issuance: boolean - catalogMode: CatalogMode teamGfsMembershipAdmissionLimit: number | null actionContextV2: boolean rpcDelegationV2: boolean @@ -46,13 +28,6 @@ export type DeploymentReadiness = Readonly<{ sessionV2Acceptance: Readiness sessionV2Issuance: Readiness accessBudget: Readiness - catalogCoordinator: Readiness - catalogRegisteredFamilies: ReadonlySet - catalogHarnessFamilies: ReadonlySet - catalogOperationalFamilies: ReadonlySet - catalogShadowComparison: Readiness - catalogParityAccepted: boolean - actionSafeRevisions: Readiness actionContext: Readiness rpcDelegationAllHops: Readiness desktop: Readiness @@ -70,15 +45,12 @@ export type EffectiveUserAccessPolicy = Readonly<{ issueV2: boolean renewV2: boolean switchCompatibility: boolean - computeCatalogShadow: boolean - serveCatalog: boolean actionContextV2: boolean rpcDelegationV2: boolean desktopAllTeamMode: boolean profileV2Mode: boolean minimumClientVersion: string | null enforceMinimumClient: boolean - advertisedCatalogFamilies: readonly CatalogFamily[] }> export class UserAccessPolicyConfigurationError extends Error { @@ -94,18 +66,6 @@ function requireCondition(condition: boolean, code: string): void { if (!condition) throw new UserAccessPolicyConfigurationError(code) } -function sortedFamilies(values: ReadonlySet): CatalogFamily[] { - return CATALOG_FAMILIES.filter(family => values.has(family)) -} - -function intersection(...sets: ReadonlySet[]): CatalogFamily[] { - return CATALOG_FAMILIES.filter(family => sets.every(set => set.has(family))) -} - -function hasEveryCatalogFamily(families: readonly CatalogFamily[]): boolean { - return families.length === CATALOG_FAMILIES.length -} - function policyRevision( readinessRevision: string, effective: Omit @@ -116,7 +76,6 @@ function policyRevision( v: USER_ACCESS_POLICY_VERSION, readinessRevision, ...effective, - advertisedCatalogFamilies: [...effective.advertisedCatalogFamilies], }) ) .digest('hex') @@ -164,52 +123,6 @@ export function compileUserAccessPolicy( ) } - const wantsShadow = intent.catalogMode === 'shadow' || intent.catalogMode === 'serve_and_shadow' - const wantsServe = intent.catalogMode === 'serve' || intent.catalogMode === 'serve_and_shadow' - if (wantsShadow || wantsServe) { - requireCondition( - intent.teamGfsMembershipAdmissionLimit !== null, - 'catalog_team_gfs_membership_admission_missing' - ) - } - const advertisedCatalogFamilies = - wantsShadow || wantsServe - ? intersection( - readiness.catalogRegisteredFamilies, - readiness.catalogHarnessFamilies, - readiness.catalogOperationalFamilies - ) - : [] - const everyCatalogFamilyReady = hasEveryCatalogFamily(advertisedCatalogFamilies) - - if (wantsShadow) { - requireCondition(acceptV2, 'catalog_shadow_requires_v2_acceptance') - requireCondition(readiness.accessBudget === 'ready', 'catalog_shadow_budget_unavailable') - requireCondition( - readiness.catalogCoordinator === 'ready', - 'catalog_shadow_coordinator_unavailable' - ) - requireCondition(everyCatalogFamilyReady, 'catalog_shadow_families_incomplete') - requireCondition( - readiness.catalogShadowComparison === 'ready', - 'catalog_shadow_comparison_unavailable' - ) - } - if (wantsServe) { - requireCondition(acceptV2, 'catalog_serving_requires_v2_acceptance') - requireCondition(readiness.accessBudget === 'ready', 'catalog_serving_budget_unavailable') - requireCondition( - readiness.catalogCoordinator === 'ready', - 'catalog_serving_coordinator_unavailable' - ) - requireCondition(everyCatalogFamilyReady, 'catalog_serving_families_incomplete') - requireCondition( - readiness.actionSafeRevisions === 'ready', - 'catalog_serving_revisions_unavailable' - ) - requireCondition(readiness.catalogParityAccepted, 'catalog_serving_parity_not_accepted') - } - if (intent.actionContextV2) { requireCondition(acceptV2, 'action_context_requires_v2_acceptance') requireCondition(readiness.accessBudget === 'ready', 'action_context_budget_unavailable') @@ -221,7 +134,7 @@ export function compileUserAccessPolicy( } if (intent.desktopAllTeamMode) { requireCondition(issueV2, 'desktop_mode_requires_v2_issuance') - requireCondition(wantsServe, 'desktop_mode_requires_catalog_serving') + requireCondition(false, 'desktop_mode_requires_catalog_serving') requireCondition(intent.actionContextV2, 'desktop_mode_requires_action_context') requireCondition(intent.rpcDelegationV2, 'desktop_mode_requires_rpc_v2') requireCondition(readiness.desktop === 'ready', 'desktop_mode_unavailable') @@ -258,15 +171,12 @@ export function compileUserAccessPolicy( issueV2, renewV2: acceptV2 && issueV2, switchCompatibility: intent.legacySwitchEndpoint, - computeCatalogShadow: wantsShadow, - serveCatalog: wantsServe, actionContextV2: intent.actionContextV2, rpcDelegationV2: intent.rpcDelegationV2, desktopAllTeamMode: intent.desktopAllTeamMode, profileV2Mode: intent.profileV2Mode, minimumClientVersion: intent.minimumClientEnforcement ? minimumVersion : null, enforceMinimumClient: intent.minimumClientEnforcement, - advertisedCatalogFamilies: Object.freeze(advertisedCatalogFamilies), }) return Object.freeze({ @@ -319,21 +229,10 @@ function parseOptionalPositiveSafeInteger(env: NodeJS.ProcessEnv, name: string): } export function loadConfiguredUserAccessIntent(env: NodeJS.ProcessEnv): ConfiguredUserAccessIntent { - const catalogMode = parseEnum( - env, - 'CONTROL_API_USER_ACCESS_CATALOG_MODE', - ['off', 'shadow', 'serve_and_shadow', 'serve'] as const, - 'off' - ) const teamGfsMembershipAdmissionLimit = parseOptionalPositiveSafeInteger( env, 'CONTROL_API_USER_ACCESS_TEAM_GFS_MEMBERSHIP_ADMISSION_LIMIT' ) - if (catalogMode !== 'off' && teamGfsMembershipAdmissionLimit === null) { - throw new UserAccessPolicyConfigurationError( - 'control_api_user_access_team_gfs_membership_admission_limit_missing' - ) - } return Object.freeze({ legacyLifecycle: parseEnum( env, @@ -343,7 +242,6 @@ export function loadConfiguredUserAccessIntent(env: NodeJS.ProcessEnv): Configur ), sessionV2Acceptance: parseBoolean(env, 'CONTROL_API_USER_ACCESS_SESSION_V2_ACCEPTANCE', true), sessionV2Issuance: parseBoolean(env, 'CONTROL_API_USER_ACCESS_SESSION_V2_ISSUANCE', false), - catalogMode, teamGfsMembershipAdmissionLimit, actionContextV2: parseBoolean(env, 'CONTROL_API_USER_ACCESS_ACTION_CONTEXT_V2', false), rpcDelegationV2: parseBoolean(env, 'CONTROL_API_USER_ACCESS_RPC_DELEGATION_V2', false), @@ -368,13 +266,6 @@ export const reconstructionReadiness: DeploymentReadiness = Object.freeze({ sessionV2Acceptance: 'ready', sessionV2Issuance: 'ready', accessBudget: 'ready', - catalogCoordinator: 'ready', - catalogRegisteredFamilies: new Set(CATALOG_FAMILIES), - catalogHarnessFamilies: new Set(CATALOG_FAMILIES), - catalogOperationalFamilies: new Set(), - catalogShadowComparison: 'ready', - catalogParityAccepted: false, - actionSafeRevisions: 'ready', // The resolver exists for foundation validation, but its public rollout is // owned by the later runtime unit and remains unavailable in PR 1. actionContext: 'unavailable', @@ -402,46 +293,6 @@ export const configuredCatalogBudgetOptions = catalogBudgetOptionsForIntent( configuredUserAccessIntent ) -export function userAccessCapabilityManifest(policy: EffectiveUserAccessPolicy): Readonly<{ - policyVersion: string - policyRevision: string - v1Accepted: boolean - v1Issued: boolean - v2Accepted: boolean - v2Issued: boolean - v2Renewal: boolean - legacySwitch: boolean - catalogShadow: boolean - catalogServed: boolean - actionContextV2: boolean - rpcDelegationV2: boolean - desktopAllTeamMode: boolean - profileV2Mode: boolean - minimumClientVersion: string | null - minimumClientEnforced: boolean - catalogFamilies: readonly CatalogFamily[] -}> { - return Object.freeze({ - policyVersion: policy.policyVersion, - policyRevision: policy.policyRevision, - v1Accepted: policy.acceptV1, - v1Issued: policy.issueV1, - v2Accepted: policy.acceptV2, - v2Issued: policy.issueV2, - v2Renewal: policy.renewV2, - legacySwitch: policy.switchCompatibility, - catalogShadow: policy.computeCatalogShadow, - catalogServed: policy.serveCatalog, - actionContextV2: policy.actionContextV2, - rpcDelegationV2: policy.rpcDelegationV2, - desktopAllTeamMode: policy.desktopAllTeamMode, - profileV2Mode: policy.profileV2Mode, - minimumClientVersion: policy.minimumClientVersion, - minimumClientEnforced: policy.enforceMinimumClient, - catalogFamilies: Object.freeze([...policy.advertisedCatalogFamilies]), - }) -} - export function compareSemanticVersions(left: string, right: string): number | null { const leftMatch = SEMANTIC_VERSION_PATTERN.exec(left) const rightMatch = SEMANTIC_VERSION_PATTERN.exec(right) @@ -452,7 +303,3 @@ export function compareSemanticVersions(left: string, right: string): number | n } return 0 } - -export function registeredCatalogFamilies(readiness: DeploymentReadiness): CatalogFamily[] { - return sortedFamilies(readiness.catalogRegisteredFamilies) -} diff --git a/control-api/src/services/access/userAccessRuntimePolicy.ts b/control-api/src/services/access/userAccessRuntimePolicy.ts index b63cf73de5..0fd7b26568 100644 --- a/control-api/src/services/access/userAccessRuntimePolicy.ts +++ b/control-api/src/services/access/userAccessRuntimePolicy.ts @@ -1,342 +1,20 @@ -import { config } from '../../config.js' -import type { DbClient } from '../../db.js' -import { runAccessDatabaseQuery, withAccessDatabaseTransaction } from './accessDatabaseQuery.js' -import { AccessExecutionBudget } from './accessExecutionBudget.js' -import { revisionOfValues } from './authorizationRevision.js' import { - OPERATIONAL_SOURCE_FAMILIES, - type OperationalSourceFamily, - canonicalEnvironmentId, -} from './operationalAccessProjection.js' -import { - CATALOG_FAMILIES, - type CatalogFamily, type ConfiguredUserAccessIntent, - type DeploymentReadiness, type EffectiveUserAccessPolicy, - USER_ACCESS_POLICY_VERSION, - UserAccessPolicyConfigurationError, compileUserAccessPolicy, configuredUserAccessIntent, reconstructionReadiness, } from './userAccessPolicy.js' -type RuntimeSourceState = Readonly<{ - family: OperationalSourceFamily - generation: number - resourceVersion: string - status: 'current' | 'relisting' | 'unavailable' - lastSuccessAt: Date -}> - type RuntimePolicyOptions = Readonly<{ intent?: ConfiguredUserAccessIntent - db?: Pick - budget?: AccessExecutionBudget - indexerEnabled?: boolean - readinessMaxAgeMs?: number | null - now?: Date - catalogActivationRecord?: string - catalogReadiness?: boolean - databaseFailureMode?: 'fallback' | 'throw' - onDatabaseFailure?: (error: unknown) => unknown -}> - -type CatalogComparisonEvidence = Readonly<{ - family: CatalogFamily - attempted: number - completed: number - reference: string -}> - -type CatalogActivationRecordV1 = Readonly<{ - version: 1 - active: boolean - revision: string - acceptedBy: string - acceptedAt: string - catalogConfigurationRevision: string - requiredFamilies: readonly CatalogFamily[] - comparisonEvidence: readonly CatalogComparisonEvidence[] }> -const DATABASE_ONLY_FAMILIES: readonly CatalogFamily[] = Object.freeze([ - 'user', - 'team', - 'workflow_run', - 'workflow_approval', - 'notification', - 'gfs_resource', -]) - -const REQUIRED_SOURCES: Readonly< - Partial> -> = Object.freeze({ - host: ['host'], - context: ['context'], - mcp_server: ['host', 'context', 'mcp_server'], - workflow_recipe: ['workflow_recipe'], - shared_filesystem: ['context', 'shared_filesystem'], - sandbox_app: ['workflow_recipe'], -}) - -function needsOperationalReadiness(intent: ConfiguredUserAccessIntent): boolean { - return intent.catalogMode !== 'off' -} - -function unavailableReadiness(): DeploymentReadiness { - return Object.freeze({ - ...reconstructionReadiness, - revision: 'runtime-readiness-unavailable', - snapshot: 'unavailable', - catalogOperationalFamilies: new Set(), - }) -} - -export function catalogConfigurationRevision(intent: ConfiguredUserAccessIntent): string { - return revisionOfValues([ - 'catalog-activation-configuration-v1', - USER_ACCESS_POLICY_VERSION, - intent.catalogMode, - intent.teamGfsMembershipAdmissionLimit, - CATALOG_FAMILIES, - ]) -} - -function boundedText(value: unknown): value is string { - return typeof value === 'string' && value.trim().length > 0 && value.length <= 1_024 -} - -function parseCatalogActivationRecord(value: string): CatalogActivationRecordV1 | null { - if (!value || value.length > 64 * 1_024) return null - try { - const parsed = JSON.parse(value) as Record - if ( - parsed.version !== 1 || - typeof parsed.active !== 'boolean' || - !boundedText(parsed.revision) || - !boundedText(parsed.acceptedBy) || - !boundedText(parsed.acceptedAt) || - !Number.isFinite(Date.parse(parsed.acceptedAt)) || - !boundedText(parsed.catalogConfigurationRevision) || - !Array.isArray(parsed.requiredFamilies) || - !Array.isArray(parsed.comparisonEvidence) - ) { - return null - } - const requiredFamilies = parsed.requiredFamilies as unknown[] - const uniqueRequired = new Set(requiredFamilies) - if ( - uniqueRequired.size !== CATALOG_FAMILIES.length || - CATALOG_FAMILIES.some(family => !uniqueRequired.has(family)) - ) { - return null - } - const evidence = parsed.comparisonEvidence as Record[] - const evidenceByFamily = new Map() - for (const item of evidence) { - const family = item?.family as CatalogFamily - if ( - !CATALOG_FAMILIES.includes(family) || - evidenceByFamily.has(family) || - !Number.isSafeInteger(item.attempted) || - Number(item.attempted) < 1 || - !Number.isSafeInteger(item.completed) || - Number(item.completed) < 1 || - Number(item.completed) > Number(item.attempted) || - !boundedText(item.reference) - ) { - return null - } - evidenceByFamily.set( - family, - Object.freeze({ - family, - attempted: Number(item.attempted), - completed: Number(item.completed), - reference: item.reference, - }) - ) - } - if (evidenceByFamily.size !== CATALOG_FAMILIES.length) return null - return Object.freeze({ - version: 1, - active: parsed.active, - revision: parsed.revision, - acceptedBy: parsed.acceptedBy, - acceptedAt: parsed.acceptedAt, - catalogConfigurationRevision: parsed.catalogConfigurationRevision, - requiredFamilies: Object.freeze([...requiredFamilies] as CatalogFamily[]), - comparisonEvidence: Object.freeze( - CATALOG_FAMILIES.map(family => evidenceByFamily.get(family)!) - ), - }) - } catch { - return null - } -} - -function catalogParityAccepted( - intent: ConfiguredUserAccessIntent, - activationValue: string -): boolean { - const record = parseCatalogActivationRecord(activationValue) - return ( - record?.active === true && - record.catalogConfigurationRevision === catalogConfigurationRevision(intent) - ) -} - -function parseSourceStates( - rows: readonly Record[] -): readonly RuntimeSourceState[] { - const seen = new Set() - return Object.freeze( - rows.map(row => { - const family = String(row.source_family) as OperationalSourceFamily - const generation = Number(row.generation) - const resourceVersion = typeof row.resource_version === 'string' ? row.resource_version : '' - const status = String(row.status) - const lastSuccessAt = row.last_success_at ? new Date(String(row.last_success_at)) : null - if ( - !OPERATIONAL_SOURCE_FAMILIES.includes(family) || - seen.has(family) || - !Number.isSafeInteger(generation) || - generation < 0 || - !resourceVersion || - !['current', 'relisting', 'unavailable'].includes(status) || - !lastSuccessAt || - !Number.isFinite(lastSuccessAt.getTime()) - ) { - throw new UserAccessPolicyConfigurationError('runtime_readiness_invalid') - } - seen.add(family) - return Object.freeze({ - family, - generation, - resourceVersion, - status: status as RuntimeSourceState['status'], - lastSuccessAt, - }) - }) - ) -} - -function runtimeReadiness( - states: readonly RuntimeSourceState[], - now: Date, - maxAgeMs: number, - intent: ConfiguredUserAccessIntent, - activationValue: string -): DeploymentReadiness { - const currentSources = new Set() - for (const state of states) { - const age = now.getTime() - state.lastSuccessAt.getTime() - if (state.status === 'current' && age >= 0 && age <= maxAgeMs) { - currentSources.add(state.family) - } - } - const operationalFamilies = new Set(DATABASE_ONLY_FAMILIES) - for (const family of CATALOG_FAMILIES) { - const required = REQUIRED_SOURCES[family] - if (required?.every(source => currentSources.has(source))) operationalFamilies.add(family) - } - const parityAccepted = catalogParityAccepted(intent, activationValue) - return Object.freeze({ - ...reconstructionReadiness, - revision: revisionOfValues([ - reconstructionReadiness.revision, - maxAgeMs, - states.map(state => [ - state.family, - state.generation, - state.resourceVersion, - state.status, - state.lastSuccessAt.toISOString(), - ]), - catalogConfigurationRevision(intent), - activationValue, - ]), - snapshot: 'current', - catalogOperationalFamilies: operationalFamilies, - catalogParityAccepted: parityAccepted, - }) -} - export async function resolveEffectiveUserAccessPolicy( options: RuntimePolicyOptions = {} ): Promise { - const configuredIntent = options.intent ?? configuredUserAccessIntent - const intent = options.catalogReadiness - ? configuredIntent - : Object.freeze({ ...configuredIntent, catalogMode: 'off' as const }) - if (!needsOperationalReadiness(intent)) { - return compileUserAccessPolicy(intent, reconstructionReadiness) - } - - const indexerEnabled = options.indexerEnabled ?? config.operationalAccessIndexerEnabled - const maxAgeMs = - options.readinessMaxAgeMs === undefined - ? config.operationalAccessReadinessMaxAgeMs - : options.readinessMaxAgeMs - if (!indexerEnabled || !Number.isSafeInteger(maxAgeMs) || Number(maxAgeMs) < 1) { - return compileUserAccessPolicy(intent, unavailableReadiness()) - } - const validatedMaxAgeMs = Number(maxAgeMs) - - const ownedBudget = options.budget ? null : AccessExecutionBudget.create('action') - const budget = options.budget ?? ownedBudget! - try { - const query = (db: Pick) => - runAccessDatabaseQuery( - db, - budget, - `SELECT source_family, generation, resource_version, status, last_success_at - FROM operational_catalog_source_state - WHERE environment_id = $1 - AND source_family = ANY($2::text[]) - ORDER BY source_family`, - [canonicalEnvironmentId(), OPERATIONAL_SOURCE_FAMILIES], - { onDatabaseFailure: options.onDatabaseFailure } - ) - const result = options.db - ? await query(options.db) - : await withAccessDatabaseTransaction( - budget, - db => - db.query( - `SELECT source_family, generation, resource_version, status, last_success_at - FROM operational_catalog_source_state - WHERE environment_id = $1 - AND source_family = ANY($2::text[]) - ORDER BY source_family`, - [canonicalEnvironmentId(), OPERATIONAL_SOURCE_FAMILIES] - ), - { - mode: 'read_only', - onDatabaseFailure: options.onDatabaseFailure, - } - ) - const states = parseSourceStates(result.rows as Record[]) - return compileUserAccessPolicy( - intent, - runtimeReadiness( - states, - options.now ?? new Date(), - validatedMaxAgeMs, - intent, - options.catalogActivationRecord ?? config.userAccessCatalogActivationRecord - ) - ) - } catch (error) { - if ( - error instanceof UserAccessPolicyConfigurationError || - options.databaseFailureMode === 'throw' - ) { - throw error - } - return compileUserAccessPolicy(intent, unavailableReadiness()) - } finally { - ownedBudget?.close() - } + return compileUserAccessPolicy( + options.intent ?? configuredUserAccessIntent, + reconstructionReadiness + ) } diff --git a/control-api/src/services/auth/externalSessionAuthentication.ts b/control-api/src/services/auth/externalSessionAuthentication.ts index caeb4b509a..d4535d6e59 100644 --- a/control-api/src/services/auth/externalSessionAuthentication.ts +++ b/control-api/src/services/auth/externalSessionAuthentication.ts @@ -248,12 +248,7 @@ export async function authenticateExternalUserSession( token: string, options: AuthenticationOptions ): Promise { - const policy = - options.policy ?? - (await resolveEffectiveUserAccessPolicy({ - databaseFailureMode: 'throw', - onDatabaseFailure: externalSessionDatabaseFailure, - })) + const policy = options.policy ?? (await resolveEffectiveUserAccessPolicy()) const identity = options.identity ? await revalidateExternalUserSessionIdentity(token, options.identity, { now: options.now, diff --git a/control-api/test/accessCatalogConfiguredAdmission.test.ts b/control-api/test/accessCatalogConfiguredAdmission.test.ts deleted file mode 100644 index 0a69b254c8..0000000000 --- a/control-api/test/accessCatalogConfiguredAdmission.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { EventEmitter } from 'node:events' - -const MODE = 'CONTROL_API_USER_ACCESS_CATALOG_MODE' -const ADMISSION = 'CONTROL_API_USER_ACCESS_TEAM_GFS_MEMBERSHIP_ADMISSION_LIMIT' -const originalMode = process.env[MODE] -const originalAdmission = process.env[ADMISSION] - -function restore(name: string, value: string | undefined): void { - if (value === undefined) delete process.env[name] - else process.env[name] = value -} - -afterEach(() => { - restore(MODE, originalMode) - restore(ADMISSION, originalAdmission) - vi.resetModules() -}) - -describe('configured Team-GFS catalog admission', () => { - it('allows rollout off without an admission input', async () => { - delete process.env[MODE] - delete process.env[ADMISSION] - vi.resetModules() - const { loadConfiguredUserAccessIntent } = - await import('../src/services/access/userAccessPolicy.js') - - expect(loadConfiguredUserAccessIntent(process.env)).toMatchObject({ - catalogMode: 'off', - teamGfsMembershipAdmissionLimit: null, - }) - }) - - it.each(['shadow', 'serve_and_shadow', 'serve'] as const)( - 'rejects missing Team-GFS admission before %s activation', - async catalogMode => { - const { loadConfiguredUserAccessIntent, UserAccessPolicyConfigurationError } = - await import('../src/services/access/userAccessPolicy.js') - - expect(() => - loadConfiguredUserAccessIntent({ CONTROL_API_USER_ACCESS_CATALOG_MODE: catalogMode }) - ).toThrowError( - new UserAccessPolicyConfigurationError( - 'control_api_user_access_team_gfs_membership_admission_limit_missing' - ) - ) - } - ) - - it.each(['0', '-1', '1.5', 'not-a-number'])( - 'rejects invalid operator admission value %s', - async value => { - const { loadConfiguredUserAccessIntent, UserAccessPolicyConfigurationError } = - await import('../src/services/access/userAccessPolicy.js') - - expect(() => - loadConfiguredUserAccessIntent({ - CONTROL_API_USER_ACCESS_CATALOG_MODE: 'shadow', - CONTROL_API_USER_ACCESS_TEAM_GFS_MEMBERSHIP_ADMISSION_LIMIT: value, - }) - ).toThrowError( - new UserAccessPolicyConfigurationError( - 'control_api_user_access_team_gfs_membership_admission_limit_invalid' - ) - ) - } - ) - - it('passes the exact operator value through the real route budget construction', async () => { - process.env[MODE] = 'serve' - process.env[ADMISSION] = '3' - vi.resetModules() - const { attachAccessExecutionBudget } = - await import('../src/middleware/accessExecutionBudget.js') - const req = Object.assign(new EventEmitter(), { - method: 'GET', - path: '/external/access/catalog', - }) - const res = Object.assign(new EventEmitter(), { writableEnded: false }) - const next = vi.fn() - - attachAccessExecutionBudget(req as never, res as never, next) - - const budget = ( - req as { accessExecutionBudget?: { teamGfsMembershipAdmissionLimit: number | null } } - ).accessExecutionBudget - expect(next).toHaveBeenCalledOnce() - expect(budget?.teamGfsMembershipAdmissionLimit).toBe(3) - res.emit('finish') - }) - - it('passes the exact operator value into the production shadow comparison budget', async () => { - process.env[MODE] = 'shadow' - process.env[ADMISSION] = '2' - vi.resetModules() - const { compareAccessCatalogShadow } = - await import('../src/services/access/accessCatalogShadow.js') - const buildCatalog = vi.fn(async (_input, options) => { - expect(options.budget?.teamGfsMembershipAdmissionLimit).toBe(2) - return { - contractVersion: '2' as const, - authorizationRevision: 'revision', - sourceStateRevision: 'source', - complete: true, - partialErrors: [], - items: [], - nextCursor: null, - } - }) - - await expect( - compareAccessCatalogShadow( - { - session: { - contract: 'v1', - userId: '10000000-0000-4000-8000-000000000001', - tokenHash: 'token-hash', - issuedAt: 1_900_000_000, - authGeneration: 1, - }, - family: 'gfs_resource', - legacyLogicalIds: [], - legacyComplete: true, - }, - { enabled: true, buildCatalog } - ) - ).resolves.toBe('match') - expect(buildCatalog).toHaveBeenCalledOnce() - }) -}) diff --git a/control-api/test/accessCatalogCoordinator.realPostgres.integration.test.ts b/control-api/test/accessCatalogCoordinator.realPostgres.integration.test.ts deleted file mode 100644 index 2d12ff2b42..0000000000 --- a/control-api/test/accessCatalogCoordinator.realPostgres.integration.test.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { randomBytes, randomUUID } from 'node:crypto' -import { Pool, type PoolClient } from 'pg' -import { config } from '../src/config.js' -import { type DbClient, initDb } from '../src/db.js' -import { - AccessCatalogRequestError, - buildAccessCatalog, -} from '../src/services/access/accessCatalogCoordinator.js' -import { - AccessCatalogCursorError, - decodeAccessCatalogCursor, -} from '../src/services/access/accessCatalogCursor.js' -import { - AccessBudgetExceededError, - AccessExecutionBudget, -} from '../src/services/access/accessExecutionBudget.js' -import { type CatalogRequestContext, catalogKey } from '../src/services/access/catalogContracts.js' -import { requireCatalogProducer } from '../src/services/access/catalogProducers.js' -import { resolveLiveAuthorization } from '../src/services/access/liveAuthorizationResolver.js' -import { OperationalAccessIndex } from '../src/services/access/operationalAccessIndex.js' -import { - canonicalEnvironmentId, - projectOperationalObject, -} from '../src/services/access/operationalAccessProjection.js' -import { canonicalResourceIdentity } from '../src/services/access/resourceIdentity.js' -import type { ExternalSessionAuthorityContext } from '../src/services/auth/externalSessionAuthentication.js' -import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' - -const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL -const describeRealPostgres = adminUrl ? describe : describe.skip -const runtimeRoles = [ - 'control_api_runtime', - 'trace_maintenance_runtime', - 'workflow_recipes_runtime', -] as const - -function databaseUrl(baseUrl: string, database: string): string { - const value = new URL(baseUrl) - value.pathname = `/${database}` - return value.toString() -} - -function quoteIdentifier(value: string): string { - return `"${value.replace(/"/g, '""')}"` -} - -function transaction(pool: Pool) { - return async (work: (db: DbClient) => Promise): Promise => { - const client = (await pool.connect()) as PoolClient - try { - await client.query('BEGIN') - const result = await work(client) - await client.query('COMMIT') - return result - } catch (error) { - await client.query('ROLLBACK') - throw error - } finally { - client.release() - } - } -} - -describeRealPostgres('aggregate catalog coordinator on real PostgreSQL', () => { - const database = `control_api_catalog_coordinator_${randomBytes(6).toString('hex')}` - const connectionString = databaseUrl( - adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', - database - ) - const environmentId = canonicalEnvironmentId() - const userId = randomUUID() - const teamIds = [randomUUID(), randomUUID()].sort() - const session: ExternalSessionAuthorityContext = { - contract: 'v1', - userId, - tokenHash: randomBytes(32).toString('hex'), - issuedAt: Math.floor(Date.now() / 1_000), - authGeneration: 1, - } - let adminPool: Pool - let databasePool: Pool - - beforeAll(async () => { - adminPool = new Pool({ connectionString: adminUrl }) - await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) - await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) - databasePool = new Pool({ connectionString }) - await initDb({ connect: () => databasePool.connect() }) - await databasePool.query(`INSERT INTO users(id, email, name) VALUES ($1, $2, 'Catalog User')`, [ - userId, - `${userId}@example.test`, - ]) - for (const [index, teamId] of teamIds.entries()) { - await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, $2)`, [ - teamId, - `Team ${index + 1}`, - ]) - await databasePool.query( - `INSERT INTO team_members(team_id, user_id, role, status) - VALUES ($1, $2, $3, 'active')`, - [teamId, userId, index === 0 ? 'admin' : 'member'] - ) - } - }) - - afterAll(async () => { - try { - await endPoolAndWaitForClients(databasePool) - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) - FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) - await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) - } - } finally { - await adminPool?.end() - } - }) - - it('composes keyset pages and emits access paths that re-resolve live', async () => { - const first = await buildAccessCatalog( - { session, families: ['user', 'team'], limit: 2 }, - { transaction: transaction(databasePool) } - ) - - expect(first.complete).toBe(true) - expect(first.items.map(item => item.resource.logicalId)).toEqual(teamIds) - expect(first.nextCursor).toEqual(expect.stringMatching(/^c3\./)) - const firstPath = first.items[0].accessPaths[0] - const resolved = await resolveLiveAuthorization( - { - session, - requiredCapability: 'team.read', - resource: canonicalResourceIdentity({ - environmentId, - type: 'team', - logicalId: teamIds[0], - }), - requestedAccessPathId: firstPath.accessPathId, - }, - { transaction: transaction(databasePool) } - ) - expect(resolved).toEqual( - expect.objectContaining({ - status: 'allowed', - selectedPath: expect.objectContaining({ id: firstPath.accessPathId }), - }) - ) - - const second = await buildAccessCatalog( - { session, families: ['user', 'team'], limit: 2, cursor: first.nextCursor }, - { transaction: transaction(databasePool) } - ) - expect(second.items.map(item => item.resource.logicalId)).toEqual([userId]) - expect(second.nextCursor).toBeNull() - }) - - it('rejects a cursor after a live membership revision changes', async () => { - const page = await buildAccessCatalog( - { session, families: ['user', 'team'], limit: 1 }, - { transaction: transaction(databasePool) } - ) - expect(page.nextCursor).not.toBeNull() - await databasePool.query( - `UPDATE team_members SET role = 'inviter', updated_at = NOW() - WHERE team_id = $1 AND user_id = $2`, - [teamIds[0], userId] - ) - - await expect( - buildAccessCatalog( - { session, families: ['user', 'team'], limit: 1, cursor: page.nextCursor }, - { transaction: transaction(databasePool) } - ) - ).rejects.toBeInstanceOf(AccessCatalogCursorError) - }) - - it('signs a complete per-producer continuation vector', async () => { - const page = await buildAccessCatalog( - { session, families: ['team'], limit: 1 }, - { transaction: transaction(databasePool) } - ) - const budget = AccessExecutionBudget.create('catalog') - try { - const cursor = decodeAccessCatalogCursor(page.nextCursor!, budget) - expect(Object.keys(cursor.producers)).toHaveLength(12) - expect(cursor.producers.team.afterKey?.[1]).toBe('team') - expect(cursor.producers.host).toEqual({ afterKey: null, exhausted: true }) - } finally { - budget.close() - } - }) - - it('keeps missing and ambiguous configured Context references root-local and advances cursors', async () => { - const suffix = randomBytes(5).toString('hex') - const names = { - missing: `a-missing-${suffix}`, - ambiguous: `b-ambiguous-${suffix}`, - canonical: `c-canonical-${suffix}`, - alias: `d-alias-${suffix}`, - contextFree: `e-context-free-${suffix}`, - } - const hostNamespace = config.hostsNamespace - const contextNamespace = config.contextsNamespace - const aliasName = `wire-${suffix}` - const ambiguousAlias = `collision-${suffix}` - const projection = ( - kind: 'hosts' | 'contexts', - name: string, - spec: Record - ) => { - const namespace = kind === 'hosts' ? hostNamespace : contextNamespace - return projectOperationalObject({ - environmentId, - plural: kind, - namespace, - behaviorFingerprintKey: 'catalog-integrity-test-key', - relationshipNamespaces: { - context: contextNamespace, - mcpServer: config.mcpServersNamespace, - sharedFilesystem: config.sharedFilesystemsNamespace, - }, - object: { - apiVersion: 'clerum.io/v1alpha1', - kind: kind === 'hosts' ? 'Host' : 'Context', - metadata: { - name, - namespace, - uid: randomUUID(), - resourceVersion: '1', - generation: 1, - }, - spec: { enabled: true, ...spec }, - }, - }) - } - const contextProjections = [ - projection('contexts', `canonical-${suffix}`, { mcpServers: [], sharedFileSystems: [] }), - projection('contexts', `alias-source-${suffix}`, { - contextId: aliasName, - mcpServers: [], - sharedFileSystems: [], - }), - projection('contexts', `ambiguous-one-${suffix}`, { - contextId: ambiguousAlias, - mcpServers: [], - sharedFileSystems: [], - }), - projection('contexts', `ambiguous-two-${suffix}`, { - contextId: ambiguousAlias, - mcpServers: [], - sharedFileSystems: [], - }), - ] - const hostProjections = [ - projection('hosts', names.missing, { contextRef: `missing-${suffix}` }), - projection('hosts', names.ambiguous, { contextRef: ambiguousAlias }), - projection('hosts', names.canonical, { contextRef: `canonical-${suffix}` }), - projection('hosts', names.alias, { contextRef: aliasName }), - projection('hosts', names.contextFree, {}), - ] - await databasePool.query( - `INSERT INTO user_agents(user_id, agent_name) - SELECT $1, agent_name FROM UNNEST($2::text[]) AS requested(agent_name)`, - [userId, Object.values(names)] - ) - - const index = new OperationalAccessIndex(databasePool, transaction(databasePool)) - const indexBudget = AccessExecutionBudget.create('indexer') - try { - for (const [sourceFamily, projections] of [ - ['context', contextProjections], - ['host', hostProjections], - ] as const) { - const stagingGeneration = await index.beginRelist({ - environmentId, - sourceFamily, - budget: indexBudget, - }) - await index.stageRelistPage({ - environmentId, - sourceFamily, - stagingGeneration, - projections, - budget: indexBudget, - }) - await index.promoteRelist({ - environmentId, - sourceFamily, - stagingGeneration, - resourceVersion: '1', - budget: indexBudget, - }) - } - } finally { - indexBudget.close() - } - - let queryCount = 0 - const measuredTransaction = (work: (db: DbClient) => Promise) => - transaction(databasePool)(db => - work({ - query: (text, values) => { - queryCount += 1 - return db.query(text, values) - }, - }) - ) - const completePage = await buildAccessCatalog( - { session, families: ['user', 'host'], limit: 100 }, - { transaction: measuredTransaction } - ) - - expect(completePage.complete).toBe(false) - expect(completePage.partialErrors).toEqual([ - { producer: 'host', code: 'operational_source_unavailable', retryable: true }, - ]) - const itemIds = completePage.items.map(item => item.resource.logicalId) - expect(itemIds).toContain(userId) - expect(itemIds).toContain(`${hostNamespace}/${names.canonical}`) - expect(itemIds).toContain(`${hostNamespace}/${names.alias}`) - expect(itemIds).toContain(`${hostNamespace}/${names.contextFree}`) - expect(itemIds).not.toContain(`${hostNamespace}/${names.missing}`) - expect(itemIds).not.toContain(`${hostNamespace}/${names.ambiguous}`) - expect(JSON.stringify(completePage)).not.toMatch(/configured_context_(missing|ambiguous)/u) - - const producerBudget = AccessExecutionBudget.create('catalog') - try { - const producerContext: CatalogRequestContext = { - db: databasePool, - budget: producerBudget, - principal: { - userId, - sessionContract: 'v1', - sessionRevision: '1', - userRevision: '1', - authorizationRevision: 'producer-context-integrity', - memberships: [], - }, - environmentId, - sourceStates: new Map([ - [ - 'host', - { - family: 'host', - generation: '1', - resourceVersion: '1', - status: 'current', - }, - ], - ]), - } - const producerResult = await requireCatalogProducer('host').hydrateCanonicalKeys( - producerContext, - [ - catalogKey(environmentId, 'host', `${hostNamespace}/${names.missing}`), - catalogKey(environmentId, 'host', `${hostNamespace}/${names.ambiguous}`), - ] - ) - expect(Array.isArray(producerResult)).toBe(false) - const integrity = producerResult as unknown as { - resources: readonly unknown[] - incompleteRoots: readonly Readonly<{ key: readonly string[]; reason: string }>[] - } - expect(integrity.resources).toEqual([]) - expect(integrity.incompleteRoots).toEqual([ - { - key: catalogKey(environmentId, 'host', `${hostNamespace}/${names.missing}`), - reason: 'configured_context_missing', - }, - { - key: catalogKey(environmentId, 'host', `${hostNamespace}/${names.ambiguous}`), - reason: 'configured_context_ambiguous', - }, - ]) - } finally { - producerBudget.close() - } - - queryCount = 0 - await buildAccessCatalog( - { session, families: ['host'], limit: 100 }, - { transaction: measuredTransaction } - ) - const multiRootQueryCount = queryCount - - queryCount = 0 - const firstHostPage = await buildAccessCatalog( - { session, families: ['host'], limit: 1 }, - { transaction: measuredTransaction } - ) - expect(firstHostPage.items).toEqual([]) - expect(firstHostPage.complete).toBe(false) - expect(firstHostPage.nextCursor).not.toBeNull() - const cursorBudget = AccessExecutionBudget.create('catalog') - try { - const cursor = decodeAccessCatalogCursor(firstHostPage.nextCursor!, cursorBudget) - expect(cursor.lastCanonicalKey).toEqual([ - environmentId, - 'host', - `${hostNamespace}/${names.missing}`, - ]) - } finally { - cursorBudget.close() - } - const firstPageQueryCount = queryCount - - queryCount = 0 - const oneHostPage = await buildAccessCatalog( - { session, families: ['host'], limit: 1, cursor: firstHostPage.nextCursor }, - { transaction: measuredTransaction } - ) - expect(oneHostPage.items).toEqual([]) - expect(oneHostPage.nextCursor).not.toBe(firstHostPage.nextCursor) - expect(queryCount).toBe(firstPageQueryCount) - expect(multiRootQueryCount).toBe(firstPageQueryCount) - - await expect( - buildAccessCatalog( - { session, families: ['host'], limit: 100 }, - { - transaction: transaction(databasePool), - limits: { decodedBytes: 1 }, - } - ) - ).rejects.toBeInstanceOf(AccessBudgetExceededError) - - await expect( - buildAccessCatalog( - { session: { ...session, userId: randomUUID() }, families: ['user', 'host'], limit: 100 }, - { transaction: transaction(databasePool) } - ) - ).rejects.toEqual( - expect.objectContaining>({ - code: 'authority_unavailable', - }) - ) - }) -}) diff --git a/control-api/test/accessCatalogCursor.test.ts b/control-api/test/accessCatalogCursor.test.ts deleted file mode 100644 index edbef5fe17..0000000000 --- a/control-api/test/accessCatalogCursor.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { - ACCESS_CATALOG_CONTRACT_VERSION, - ACCESS_CATALOG_SORT, - AccessCatalogCursorError, - assertAccessCatalogCursorCurrent, - catalogFilterHash, - decodeAccessCatalogCursor, - encodeAccessCatalogCursor, -} from '../src/services/access/accessCatalogCursor.js' -import { AccessExecutionBudget } from '../src/services/access/accessExecutionBudget.js' -import { - CATALOG_FAMILIES, - type CatalogFamily, - type ProducerContinuation, - catalogKey, -} from '../src/services/access/catalogContracts.js' - -const budgets: AccessExecutionBudget[] = [] - -function budget(): AccessExecutionBudget { - const value = AccessExecutionBudget.create('catalog') - budgets.push(value) - return value -} - -function producers(environmentId: string): Record { - return Object.fromEntries( - CATALOG_FAMILIES.map(family => [ - family, - { - afterKey: family === 'team' ? catalogKey(environmentId, family, 'team-0001') : null, - exhausted: family !== 'team', - }, - ]) - ) as Record -} - -afterEach(() => { - for (const value of budgets.splice(0)) value.close() -}) - -describe('access catalog cursor v3', () => { - it('round-trips the signed complete producer state', () => { - const environmentId = 'cluster.local/evenfire' - const filterHash = catalogFilterHash(['team']) - const encoded = encodeAccessCatalogCursor( - { - v: 3, - contractVersion: ACCESS_CATALOG_CONTRACT_VERSION, - authorizationRevision: 'authorization-1', - sourceStateRevision: 'source-1', - filterHash, - sort: ACCESS_CATALOG_SORT, - lastCanonicalKey: catalogKey(environmentId, 'team', 'team-0001'), - producers: producers(environmentId), - validUntil: '2030-01-01T00:00:00.000Z', - }, - budget() - ) - const decoded = decodeAccessCatalogCursor(encoded, budget()) - expect(decoded.producers.team.afterKey).toEqual([environmentId, 'team', 'team-0001']) - expect(Object.keys(decoded.producers)).toEqual(CATALOG_FAMILIES) - }) - - it('rejects tampering and every stale cursor binding', () => { - const environmentId = 'cluster.local/evenfire' - const filterHash = catalogFilterHash(['team']) - const encoded = encodeAccessCatalogCursor( - { - v: 3, - contractVersion: ACCESS_CATALOG_CONTRACT_VERSION, - authorizationRevision: 'authorization-1', - sourceStateRevision: 'source-1', - filterHash, - sort: ACCESS_CATALOG_SORT, - lastCanonicalKey: catalogKey(environmentId, 'team', 'team-0001'), - producers: producers(environmentId), - validUntil: '2030-01-01T00:00:00.000Z', - }, - budget() - ) - expect(() => decodeAccessCatalogCursor(`${encoded}x`, budget())).toThrow( - AccessCatalogCursorError - ) - const decoded = decodeAccessCatalogCursor(encoded, budget()) - expect(() => - assertAccessCatalogCursorCurrent(decoded, { - authorizationRevision: 'authorization-2', - sourceStateRevision: 'source-1', - filterHash, - }) - ).toThrow(AccessCatalogCursorError) - expect(() => - assertAccessCatalogCursorCurrent(decoded, { - authorizationRevision: 'authorization-1', - sourceStateRevision: 'source-2', - filterHash, - }) - ).toThrow(AccessCatalogCursorError) - expect(() => - assertAccessCatalogCursorCurrent(decoded, { - authorizationRevision: 'authorization-1', - sourceStateRevision: 'source-1', - filterHash: catalogFilterHash(['user']), - }) - ).toThrow(AccessCatalogCursorError) - expect(() => - assertAccessCatalogCursorCurrent(decoded, { - authorizationRevision: 'authorization-1', - sourceStateRevision: 'source-1', - filterHash, - now: new Date('2030-01-01T00:00:00.000Z'), - }) - ).toThrow(AccessCatalogCursorError) - }) -}) diff --git a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts b/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts deleted file mode 100644 index 82177ca792..0000000000 --- a/control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts +++ /dev/null @@ -1,1710 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' -import { randomBytes, randomUUID } from 'node:crypto' -import { Pool, type PoolClient } from 'pg' -import { config } from '../src/config.js' -import { type DbClient, initDb } from '../src/db.js' -import { K8sGateway } from '../src/k8s.js' -import { buildAccessCatalog } from '../src/services/access/accessCatalogCoordinator.js' -import { - AccessBudgetExceededError, - AccessExecutionBudget, -} from '../src/services/access/accessExecutionBudget.js' -import type { AccessCapability } from '../src/services/access/capabilityRegistry.js' -import { CATALOG_FAMILIES, type CatalogFamily } from '../src/services/access/catalogContracts.js' -import { resolveLiveAuthorization } from '../src/services/access/liveAuthorizationResolver.js' -import { OperationalAccessIndex } from '../src/services/access/operationalAccessIndex.js' -import { - OperationalAccessIndexer, - operationalSourceSpecs, -} from '../src/services/access/operationalAccessIndexer.js' -import { canonicalEnvironmentId } from '../src/services/access/operationalAccessProjection.js' -import { canonicalResourceIdentity } from '../src/services/access/resourceIdentity.js' -import { - catalogBudgetOptionsForIntent, - loadConfiguredUserAccessIntent, -} from '../src/services/access/userAccessPolicy.js' -import type { ExternalSessionAuthorityContext } from '../src/services/auth/externalSessionAuthentication.js' -import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' -import { TemporaryKubernetesApi } from './helpers/temporaryKubernetesApi.js' - -vi.mock('../src/services/access/operationTarget.js', async importOriginal => { - const actual = await importOriginal() - return { - ...actual, - validateOperationTarget: (input: Parameters[0]) => - input.capability === 'gfs.write' || input.capability === 'gfs.delete' - ? null - : actual.validateOperationTarget(input), - } -}) - -const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL -const describeRealPostgres = adminUrl ? describe : describe.skip -const runtimeRoles = [ - 'control_api_runtime', - 'trace_maintenance_runtime', - 'workflow_recipes_runtime', -] as const - -function databaseUrl(baseUrl: string, database: string): string { - const value = new URL(baseUrl) - value.pathname = `/${database}` - return value.toString() -} - -function quoteIdentifier(value: string): string { - return `"${value.replace(/"/g, '""')}"` -} - -function transaction(pool: Pool) { - return async (work: (db: DbClient) => Promise): Promise => { - const client = (await pool.connect()) as PoolClient - try { - await client.query('BEGIN') - const result = await work(client) - await client.query('COMMIT') - return result - } catch (error) { - await client.query('ROLLBACK') - throw error - } finally { - client.release() - } - } -} - -async function waitFor(predicate: () => boolean | Promise): Promise { - const deadline = Date.now() + 2_000 - while (Date.now() < deadline) { - if (await predicate()) return - await new Promise(resolve => setTimeout(resolve, 10)) - } - throw new Error('producer_boundary_wait_timeout') -} - -type OperationalFixture = Readonly<{ - plural: 'hosts' | 'contexts' | 'mcpservers' | 'workflowrecipes' | 'sharedfilesystems' - namespace: string - object: Readonly> -}> - -function fixture(input: { - plural: OperationalFixture['plural'] - namespace: string - name: string - uid: string - spec?: Readonly> -}): OperationalFixture { - const kind = { - hosts: 'Host', - contexts: 'Context', - mcpservers: 'McpServer', - workflowrecipes: 'WorkflowRecipe', - sharedfilesystems: 'SharedFileSystem', - }[input.plural] - return Object.freeze({ - plural: input.plural, - namespace: input.namespace, - object: Object.freeze({ - apiVersion: 'clerum.io/v1alpha1', - kind, - metadata: Object.freeze({ - name: input.name, - namespace: input.namespace, - uid: input.uid, - resourceVersion: '1', - generation: 1, - }), - spec: Object.freeze({ enabled: true, ...(input.spec ?? {}) }), - }), - }) -} - -describeRealPostgres('all aggregate catalog families on real producers', () => { - const database = `control_api_catalog_families_${randomBytes(6).toString('hex')}` - const connectionString = databaseUrl( - adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', - database - ) - const environmentId = canonicalEnvironmentId() - const userId = randomUUID() - const teamId = randomUUID() - const directRunId = randomUUID() - const teamRunId = randomUUID() - const directApprovalId = randomUUID() - const teamApprovalId = randomUUID() - const directNotificationId = randomUUID() - const teamNotificationId = randomUUID() - const gfsResourceId = randomUUID() - const session: ExternalSessionAuthorityContext = { - contract: 'v1', - userId, - tokenHash: randomBytes(32).toString('hex'), - issuedAt: Math.floor(Date.now() / 1_000), - authGeneration: 1, - } - const operationalFixtures: readonly OperationalFixture[] = [ - fixture({ - plural: 'hosts', - namespace: config.hostsNamespace, - name: 'catalog-host', - uid: 'catalog-host-uid', - spec: { - contextRef: 'catalog-context', - model: { provider: 'openai', name: 'test-model' }, - }, - }), - fixture({ - plural: 'contexts', - namespace: config.contextsNamespace, - name: 'catalog-context', - uid: 'catalog-context-uid', - spec: { - mcpServers: ['catalog-mcp'], - sharedFileSystems: [ - { name: 'catalog-files', mountPath: '/workspace/a' }, - { name: 'catalog-files', mountPath: '/workspace/b' }, - ], - }, - }), - fixture({ - plural: 'mcpservers', - namespace: config.mcpServersNamespace, - name: 'catalog-mcp', - uid: 'catalog-mcp-uid', - }), - fixture({ - plural: 'workflowrecipes', - namespace: config.sandboxNamespace, - name: 'catalog-recipe', - uid: 'catalog-recipe-uid', - spec: { - contextRef: 'catalog-context', - runtimeEgress: ['example.test'], - ui: { - workloadRef: 'catalog-app', - port: 8080, - title: 'Catalog App', - defaultPath: '/', - }, - }, - }), - fixture({ - plural: 'sharedfilesystems', - namespace: config.sharedFilesystemsNamespace, - name: 'catalog-files', - uid: 'catalog-files-uid', - }), - ] - const kubernetesApi = new TemporaryKubernetesApi() - let adminPool: Pool - let databasePool: Pool - let gateway: K8sGateway - let indexer: OperationalAccessIndexer - let initialWireRequestCount = 0 - - beforeAll(async () => { - adminPool = new Pool({ connectionString: adminUrl }) - await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) - await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) - databasePool = new Pool({ connectionString }) - await initDb({ connect: () => databasePool.connect() }) - await kubernetesApi.start() - for (const value of operationalFixtures) { - kubernetesApi.put(value.plural, value.namespace, value.object) - } - const priorKubeconfig = process.env.KUBECONFIG - process.env.KUBECONFIG = kubernetesApi.kubeconfig() - try { - gateway = new K8sGateway() - } finally { - if (priorKubeconfig === undefined) delete process.env.KUBECONFIG - else process.env.KUBECONFIG = priorKubeconfig - } - - await databasePool.query( - `INSERT INTO users(id, email, name) VALUES ($1, $2, 'Catalog Harness User')`, - [userId, `${userId}@example.test`] - ) - await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, 'Catalog Harness Team')`, [ - teamId, - ]) - await databasePool.query( - `INSERT INTO team_members(team_id, user_id, role, status) - VALUES ($1, $2, 'admin', 'active')`, - [teamId, userId] - ) - await databasePool.query( - `INSERT INTO user_agents(user_id, agent_name) VALUES ($1, 'catalog-host')`, - [userId] - ) - await databasePool.query( - `INSERT INTO team_agents(team_id, agent_name) VALUES ($1, 'catalog-host')`, - [teamId] - ) - await databasePool.query( - `INSERT INTO user_contexts(user_id, context_id) VALUES ($1, 'catalog-context')`, - [userId] - ) - await databasePool.query( - `INSERT INTO team_contexts(team_id, context_id) VALUES ($1, 'catalog-context')`, - [teamId] - ) - await databasePool.query( - `INSERT INTO user_workflow_triggers(user_id, recipe_namespace, recipe_name) - VALUES ($1, $2, 'catalog-recipe')`, - [userId, config.sandboxNamespace] - ) - await databasePool.query( - `INSERT INTO team_workflow_triggers(team_id, recipe_namespace, recipe_name) - VALUES ($1, $2, 'catalog-recipe')`, - [teamId, config.sandboxNamespace] - ) - await databasePool.query( - `INSERT INTO workflow_recipe_allowed_teams(team_id, recipe_namespace, recipe_name) - VALUES ($1, $2, 'catalog-recipe')`, - [teamId, config.sandboxNamespace] - ) - await databasePool.query( - `INSERT INTO workflow_runs( - run_id, recipe_namespace, recipe_name, phase, actor_type, actor_id, - team_id, trigger_source - ) VALUES - ($1, $5, 'catalog-recipe', 'Succeeded', 'user', $3, NULL, 'onDemand'), - ($2, $5, 'catalog-recipe', 'Succeeded', 'user', $3, $4, 'onDemand')`, - [directRunId, teamRunId, userId, teamId, config.sandboxNamespace] - ) - await databasePool.query( - `INSERT INTO workflow_approval_requests( - id, recipe_namespace, recipe_name, expires_at, status, target_user_id, - target_team_id, payload, idempotency_key, payload_hash - ) VALUES - ($1, $5, 'catalog-recipe', NOW() + INTERVAL '1 hour', 'pending', $3, NULL, - '{}'::jsonb, $6, 'hash'), - ($2, $5, 'catalog-recipe', NOW() + INTERVAL '1 hour', 'pending', NULL, $4, - '{}'::jsonb, $7, 'hash')`, - [ - directApprovalId, - teamApprovalId, - userId, - teamId, - config.sandboxNamespace, - `direct-${directApprovalId}`, - `team-${teamApprovalId}`, - ] - ) - await databasePool.query( - `INSERT INTO notification_deliveries( - id, event_type, dedupe_key, audience, payload, status, expires_at - ) VALUES - ($1, 'catalog.direct', $3, jsonb_build_object('userId', $5::text), - '{}'::jsonb, 'queued', NOW() + INTERVAL '1 hour'), - ($2, 'catalog.team', $4, jsonb_build_object('teamId', $6::text), - '{}'::jsonb, 'queued', NOW() + INTERVAL '1 hour')`, - [ - directNotificationId, - teamNotificationId, - `direct-${directNotificationId}`, - `team-${teamNotificationId}`, - userId, - teamId, - ] - ) - await databasePool.query( - `INSERT INTO gfs_resources(resource_id, drive, name, kind) - VALUES ($1, 'catalog-drive', '/', 'directory')`, - [gfsResourceId] - ) - await databasePool.query( - `INSERT INTO gfs_grants(drive, resource_id, subject_type, subject_id, permissions) - VALUES - ('catalog-drive', $1, 'user', $2::text, ARRAY['read', 'write']::text[]), - ('catalog-drive', $1, 'team', $3::text, ARRAY['read']::text[])`, - [gfsResourceId, userId, teamId] - ) - - const index = new OperationalAccessIndex(databasePool, transaction(databasePool)) - indexer = new OperationalAccessIndexer(gateway, index, { - environmentId, - behaviorFingerprintKey: config.sessionJwtPrivateKey, - }) - for (const source of operationalSourceSpecs) { - await indexer.reconcileSource(source) - } - initialWireRequestCount = kubernetesApi.requests.length - }) - - afterAll(async () => { - try { - await kubernetesApi.close() - await endPoolAndWaitForClients(databasePool) - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) - FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) - await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) - } - } finally { - await adminPool?.end() - } - }) - - const expectedItemCounts: Readonly> = Object.freeze({ - user: 1, - team: 1, - host: 1, - context: 1, - mcp_server: 1, - workflow_recipe: 1, - workflow_run: 2, - workflow_approval: 2, - notification: 2, - gfs_resource: 1, - shared_filesystem: 1, - sandbox_app: 1, - }) - const capability: Readonly> = Object.freeze({ - user: 'user.profile.read', - team: 'team.read', - host: 'host.read', - context: 'context.read', - mcp_server: 'mcp_server.read', - workflow_recipe: 'workflow.read', - workflow_run: 'workflow.read', - workflow_approval: 'workflow.approval.decide', - notification: 'notification.read', - gfs_resource: 'gfs.read', - shared_filesystem: 'shared_filesystem.read', - sandbox_app: 'sandbox_app.read', - }) - const expectedKinds: Readonly> = - Object.freeze({ - user: ['direct'], - team: ['team'], - host: ['direct', 'team'], - context: ['direct', 'team'], - mcp_server: ['direct', 'team'], - workflow_recipe: ['direct', 'team'], - workflow_run: ['direct', 'team'], - workflow_approval: ['direct', 'team'], - notification: ['direct', 'team'], - gfs_resource: ['direct', 'team'], - shared_filesystem: ['direct', 'team'], - sandbox_app: ['direct', 'team'], - }) - - for (const family of CATALOG_FAMILIES) { - it(`${family} producer paths round-trip through catalog and live resolution`, async () => { - const catalog = await buildAccessCatalog( - { session, families: [family], limit: 100 }, - { - transaction: transaction(databasePool), - ...(family === 'gfs_resource' ? { teamGfsMembershipAdmissionLimit: 1 } : {}), - } - ) - expect(catalog.complete).toBe(true) - expect(catalog.partialErrors).toEqual([]) - expect(catalog.nextCursor).toBeNull() - expect(catalog.items).toHaveLength(expectedItemCounts[family]) - expect( - [...new Set(catalog.items.flatMap(item => item.accessPaths.map(path => path.kind)))].sort() - ).toEqual([...expectedKinds[family]].sort()) - - for (const item of catalog.items) { - expect(item.resource.type).toBe(family) - expect(item.accessPaths.length).toBeGreaterThan(0) - for (const path of item.accessPaths) { - const resolved = await resolveLiveAuthorization( - { - session, - requiredCapability: capability[family], - resource: canonicalResourceIdentity(item.resource), - requestedAccessPathId: path.accessPathId, - ...(family === 'workflow_approval' - ? { - operationTarget: { - approvalId: item.resource.logicalId, - decision: 'approve', - }, - } - : {}), - }, - { - transaction: transaction(databasePool), - gateway, - } - ) - expect(resolved).toEqual( - expect.objectContaining({ - status: 'allowed', - selectedPath: expect.objectContaining({ id: path.accessPathId }), - }) - ) - } - } - }) - } - - it('resolves producer Context aliases to one canonical catalog and authority identity', async () => { - const suffix = randomBytes(5).toString('hex') - const contextName = `r55-m7-context-${suffix}` - const contextAlias = `r55-m7-wire-${suffix}` - const hostName = `r55-m7-host-${suffix}` - const mcpName = `r55-m7-mcp-${suffix}` - const sharedFilesystemName = `r55-m7-files-${suffix}` - const duplicateAliasName = `r55-m7-duplicate-${suffix}` - const nextContextAlias = `r55-m7-next-wire-${suffix}` - const contextUid = randomUUID() - const contextSource = operationalSourceSpecs.find(value => value.family === 'context')! - const hostSource = operationalSourceSpecs.find(value => value.family === 'host')! - const mcpSource = operationalSourceSpecs.find(value => value.family === 'mcp_server')! - const sharedFilesystemSource = operationalSourceSpecs.find( - value => value.family === 'shared_filesystem' - )! - let contextObject = fixture({ - plural: 'contexts', - namespace: config.contextsNamespace, - name: contextName, - uid: contextUid, - spec: { - contextId: contextAlias, - mcpServers: [mcpName], - sharedFileSystems: [{ name: sharedFilesystemName, mountPath: '/workspace/m7' }], - }, - }) - let hostObject = fixture({ - plural: 'hosts', - namespace: config.hostsNamespace, - name: hostName, - uid: randomUUID(), - spec: { contextRef: contextAlias }, - }) - const mcpObject = fixture({ - plural: 'mcpservers', - namespace: config.mcpServersNamespace, - name: mcpName, - uid: randomUUID(), - }) - const sharedFilesystemObject = fixture({ - plural: 'sharedfilesystems', - namespace: config.sharedFilesystemsNamespace, - name: sharedFilesystemName, - uid: randomUUID(), - }) - kubernetesApi.put(contextObject.plural, contextObject.namespace, contextObject.object) - kubernetesApi.put(hostObject.plural, hostObject.namespace, hostObject.object) - kubernetesApi.put(mcpObject.plural, mcpObject.namespace, mcpObject.object) - kubernetesApi.put( - sharedFilesystemObject.plural, - sharedFilesystemObject.namespace, - sharedFilesystemObject.object - ) - await indexer.reconcileSource(contextSource) - await indexer.reconcileSource(hostSource) - await indexer.reconcileSource(mcpSource) - await indexer.reconcileSource(sharedFilesystemSource) - await databasePool.query(`INSERT INTO user_contexts(user_id, context_id) VALUES ($1, $2)`, [ - userId, - contextAlias, - ]) - await databasePool.query(`INSERT INTO user_agents(user_id, agent_name) VALUES ($1, $2)`, [ - userId, - hostName, - ]) - - try { - const canonicalContextId = `${config.contextsNamespace}/${contextName}` - const contextCatalog = await buildAccessCatalog( - { session, families: ['context'], limit: 100 }, - { transaction: transaction(databasePool) } - ) - const contextItem = contextCatalog.items.find( - item => item.resource.logicalId === canonicalContextId - ) - expect(contextCatalog.complete).toBe(true) - expect(contextItem?.resource.logicalId).toBe(canonicalContextId) - expect(contextItem?.resource.providerUid).toBe(contextUid) - expect(contextItem?.accessPaths).toHaveLength(1) - expect(contextItem?.relationships).not.toContainEqual( - expect.objectContaining({ type: 'context_identity_alias' }) - ) - - const mcpCatalog = await buildAccessCatalog( - { session, families: ['mcp_server'], limit: 100 }, - { transaction: transaction(databasePool) } - ) - const mcpItem = mcpCatalog.items.find( - item => item.resource.logicalId === `${config.mcpServersNamespace}/${mcpName}` - ) - expect(mcpCatalog.complete).toBe(true) - expect(mcpItem?.accessPaths).toHaveLength(2) - for (const path of mcpItem!.accessPaths) { - await expect( - resolveLiveAuthorization( - { - session, - requiredCapability: 'mcp_server.read', - resource: canonicalResourceIdentity(mcpItem!.resource), - requestedAccessPathId: path.accessPathId, - }, - { transaction: transaction(databasePool), gateway } - ) - ).resolves.toEqual( - expect.objectContaining({ - status: 'allowed', - selectedPath: expect.objectContaining({ id: path.accessPathId }), - }) - ) - } - - const sharedFilesystemCatalog = await buildAccessCatalog( - { session, families: ['shared_filesystem'], limit: 100 }, - { transaction: transaction(databasePool) } - ) - const sharedFilesystemItem = sharedFilesystemCatalog.items.find( - item => - item.resource.logicalId === `${config.sharedFilesystemsNamespace}/${sharedFilesystemName}` - ) - expect(sharedFilesystemCatalog.complete).toBe(true) - expect(sharedFilesystemItem?.accessPaths).toHaveLength(1) - await expect( - resolveLiveAuthorization( - { - session, - requiredCapability: 'shared_filesystem.read', - resource: canonicalResourceIdentity(sharedFilesystemItem!.resource), - requestedAccessPathId: sharedFilesystemItem!.accessPaths[0].accessPathId, - }, - { transaction: transaction(databasePool), gateway } - ) - ).resolves.toEqual( - expect.objectContaining({ - status: 'allowed', - selectedPath: expect.objectContaining({ - id: sharedFilesystemItem!.accessPaths[0].accessPathId, - }), - }) - ) - - const duplicateAliasObject = fixture({ - plural: 'contexts', - namespace: config.contextsNamespace, - name: duplicateAliasName, - uid: randomUUID(), - spec: { contextId: contextAlias, mcpServers: [], sharedFileSystems: [] }, - }) - kubernetesApi.put( - duplicateAliasObject.plural, - duplicateAliasObject.namespace, - duplicateAliasObject.object - ) - await indexer.reconcileSource(contextSource) - const duplicateAliasCatalog = await buildAccessCatalog( - { session, families: ['context'], limit: 100 }, - { transaction: transaction(databasePool) } - ) - expect( - duplicateAliasCatalog.items.some(item => item.resource.logicalId === canonicalContextId) - ).toBe(false) - const duplicateAliasMcpCatalog = await buildAccessCatalog( - { session, families: ['mcp_server'], limit: 100 }, - { transaction: transaction(databasePool) } - ) - expect( - duplicateAliasMcpCatalog.items.find( - item => item.resource.logicalId === `${config.mcpServersNamespace}/${mcpName}` - )?.accessPaths ?? [] - ).toHaveLength(0) - kubernetesApi.delete( - duplicateAliasObject.plural, - duplicateAliasObject.namespace, - duplicateAliasName - ) - await indexer.reconcileSource(contextSource) - - const nameCollisionObject = fixture({ - plural: 'contexts', - namespace: config.contextsNamespace, - name: contextAlias, - uid: randomUUID(), - spec: { mcpServers: [], sharedFileSystems: [] }, - }) - kubernetesApi.put( - nameCollisionObject.plural, - nameCollisionObject.namespace, - nameCollisionObject.object - ) - await indexer.reconcileSource(contextSource) - const nameCollisionCatalog = await buildAccessCatalog( - { session, families: ['context'], limit: 100 }, - { transaction: transaction(databasePool) } - ) - expect( - nameCollisionCatalog.items.some(item => item.resource.logicalId === canonicalContextId) - ).toBe(false) - kubernetesApi.delete(nameCollisionObject.plural, nameCollisionObject.namespace, contextAlias) - await indexer.reconcileSource(contextSource) - - contextObject = fixture({ - plural: 'contexts', - namespace: config.contextsNamespace, - name: contextName, - uid: contextUid, - spec: { - contextId: nextContextAlias, - mcpServers: [mcpName], - sharedFileSystems: [{ name: sharedFilesystemName, mountPath: '/workspace/m7' }], - }, - }) - kubernetesApi.put(contextObject.plural, contextObject.namespace, contextObject.object) - await indexer.reconcileSource(contextSource) - const staleAliasCatalog = await buildAccessCatalog( - { session, families: ['context'], limit: 100 }, - { transaction: transaction(databasePool) } - ) - expect( - staleAliasCatalog.items.some(item => item.resource.logicalId === canonicalContextId) - ).toBe(false) - - await databasePool.query( - `UPDATE user_contexts SET context_id = $3 WHERE user_id = $1 AND context_id = $2`, - [userId, contextAlias, nextContextAlias] - ) - hostObject = fixture({ - plural: 'hosts', - namespace: config.hostsNamespace, - name: hostName, - uid: String((hostObject.object.metadata as Record).uid), - spec: { contextRef: nextContextAlias }, - }) - kubernetesApi.put(hostObject.plural, hostObject.namespace, hostObject.object) - await indexer.reconcileSource(hostSource) - const refreshedCatalog = await buildAccessCatalog( - { session, families: ['context'], limit: 100 }, - { transaction: transaction(databasePool) } - ) - const refreshedContext = refreshedCatalog.items.find( - item => item.resource.logicalId === canonicalContextId - ) - expect(refreshedContext?.accessPaths).toHaveLength(1) - - const oldContextPathId = refreshedContext!.accessPaths[0].accessPathId - const recreatedUid = randomUUID() - contextObject = fixture({ - plural: 'contexts', - namespace: config.contextsNamespace, - name: contextName, - uid: recreatedUid, - spec: { - contextId: nextContextAlias, - mcpServers: [mcpName], - sharedFileSystems: [{ name: sharedFilesystemName, mountPath: '/workspace/m7' }], - }, - }) - kubernetesApi.put(contextObject.plural, contextObject.namespace, contextObject.object) - await indexer.reconcileSource(contextSource) - const recreatedCatalog = await buildAccessCatalog( - { session, families: ['context'], limit: 100 }, - { transaction: transaction(databasePool) } - ) - const recreatedContext = recreatedCatalog.items.find( - item => item.resource.logicalId === canonicalContextId - ) - expect(recreatedContext?.resource.providerUid).toBe(recreatedUid) - expect(recreatedContext?.accessPaths.map(path => path.accessPathId)).not.toContain( - oldContextPathId - ) - await expect( - resolveLiveAuthorization( - { - session, - requiredCapability: 'context.read', - resource: canonicalResourceIdentity(refreshedContext!.resource), - requestedAccessPathId: oldContextPathId, - }, - { transaction: transaction(databasePool), gateway } - ) - ).resolves.toEqual(expect.objectContaining({ status: 'access_path_stale' })) - } finally { - await databasePool.query( - `DELETE FROM user_contexts WHERE user_id = $1 AND context_id = ANY($2::text[])`, - [userId, [contextAlias, nextContextAlias]] - ) - await databasePool.query(`DELETE FROM user_agents WHERE user_id = $1 AND agent_name = $2`, [ - userId, - hostName, - ]) - kubernetesApi.delete(hostObject.plural, hostObject.namespace, hostName) - kubernetesApi.delete(contextObject.plural, contextObject.namespace, contextName) - kubernetesApi.delete('contexts', config.contextsNamespace, duplicateAliasName) - kubernetesApi.delete('contexts', config.contextsNamespace, contextAlias) - kubernetesApi.delete(mcpObject.plural, mcpObject.namespace, mcpName) - kubernetesApi.delete( - sharedFilesystemObject.plural, - sharedFilesystemObject.namespace, - sharedFilesystemName - ) - await indexer.reconcileSource(hostSource) - await indexer.reconcileSource(contextSource) - await indexer.reconcileSource(mcpSource) - await indexer.reconcileSource(sharedFilesystemSource) - } - }) - - it('excludes an ineligible sibling Host from selected MCP hydration', async () => { - const hostSource = operationalSourceSpecs.find(value => value.family === 'host')! - const disabledHost = fixture({ - plural: 'hosts', - namespace: config.hostsNamespace, - name: 'disabled-catalog-host', - uid: 'disabled-catalog-host-uid', - spec: { enabled: false, contextRef: 'catalog-context' }, - }) - kubernetesApi.put(disabledHost.plural, disabledHost.namespace, disabledHost.object) - await indexer.reconcileSource(hostSource) - await databasePool.query( - `INSERT INTO user_agents(user_id, agent_name) VALUES ($1, 'disabled-catalog-host')`, - [userId] - ) - - try { - const disabledEdge = await databasePool.query<{ relationship_instance_id: string }>( - `SELECT relationship_instance_id - FROM operational_resource_relationships - WHERE environment_id = $1 AND source_type = 'host' - AND source_id = $2 AND relationship_type = 'uses_context' - AND target_id = $3`, - [ - environmentId, - `${config.hostsNamespace}/disabled-catalog-host`, - `${config.contextsNamespace}/catalog-context`, - ] - ) - const enabledEdge = await databasePool.query<{ relationship_instance_id: string }>( - `SELECT relationship_instance_id - FROM operational_resource_relationships - WHERE environment_id = $1 AND source_type = 'host' - AND source_id = $2 AND relationship_type = 'uses_context' - AND target_id = $3`, - [ - environmentId, - `${config.hostsNamespace}/catalog-host`, - `${config.contextsNamespace}/catalog-context`, - ] - ) - const directContextEdge = await databasePool.query<{ relationship_instance_id: string }>( - `SELECT relationship_instance_id - FROM operational_resource_relationships - WHERE environment_id = $1 AND source_type = 'context' - AND source_id = $2 AND relationship_type = 'includes_mcp_server' - AND target_id = $3`, - [ - environmentId, - `${config.contextsNamespace}/catalog-context`, - `${config.mcpServersNamespace}/catalog-mcp`, - ] - ) - expect(disabledEdge.rows).toHaveLength(1) - expect(enabledEdge.rows).toHaveLength(1) - expect(directContextEdge.rows).toHaveLength(1) - - const catalog = await buildAccessCatalog( - { session, families: ['mcp_server'], limit: 100 }, - { transaction: transaction(databasePool) } - ) - const item = catalog.items.find( - value => value.resource.logicalId === `${config.mcpServersNamespace}/catalog-mcp` - ) - expect(catalog.complete).toBe(true) - expect(catalog.partialErrors).toEqual([]) - expect(item).toBeDefined() - const runtimeRefs = item!.accessPaths.flatMap(path => { - const runtime = path.behaviorDescriptors.runtime - return runtime.state === 'known' && runtime.value ? [runtime.value] : [] - }) - expect( - runtimeRefs.some(value => value.includes(enabledEdge.rows[0]!.relationship_instance_id)) - ).toBe(true) - expect( - runtimeRefs.some(value => value === directContextEdge.rows[0]!.relationship_instance_id) - ).toBe(true) - expect( - runtimeRefs.some(value => value.includes(disabledEdge.rows[0]!.relationship_instance_id)) - ).toBe(false) - } finally { - await databasePool.query( - `DELETE FROM user_agents WHERE user_id = $1 AND agent_name = 'disabled-catalog-host'`, - [userId] - ) - kubernetesApi.delete(disabledHost.plural, disabledHost.namespace, 'disabled-catalog-host') - await indexer.reconcileSource(hostSource) - } - }) - - it('publishes only the relationships the caller reaches through its own paths', async () => { - const suffix = randomBytes(5).toString('hex') - const foreignTeamId = randomUUID() - const foreignContextName = `i7-foreign-context-${suffix}` - const foreignHostName = `i7-foreign-host-${suffix}` - const unownedHostName = `i7-unowned-host-${suffix}` - const foreignContextId = `${config.contextsNamespace}/${foreignContextName}` - const mcpLogicalId = `${config.mcpServersNamespace}/catalog-mcp` - const contextLogicalId = `${config.contextsNamespace}/catalog-context` - const sharedFilesystemLogicalId = `${config.sharedFilesystemsNamespace}/catalog-files` - const contextSource = operationalSourceSpecs.find(value => value.family === 'context')! - const hostSource = operationalSourceSpecs.find(value => value.family === 'host')! - const foreignContext = fixture({ - plural: 'contexts', - namespace: config.contextsNamespace, - name: foreignContextName, - uid: randomUUID(), - spec: { - mcpServers: ['catalog-mcp'], - sharedFileSystems: [{ name: 'catalog-files', mountPath: '/workspace/foreign' }], - }, - }) - const foreignHost = fixture({ - plural: 'hosts', - namespace: config.hostsNamespace, - name: foreignHostName, - uid: randomUUID(), - spec: { contextRef: foreignContextName }, - }) - const unownedHost = fixture({ - plural: 'hosts', - namespace: config.hostsNamespace, - name: unownedHostName, - uid: randomUUID(), - spec: { contextRef: 'catalog-context' }, - }) - - const relationshipInstanceId = async (input: { - sourceType: string - sourceId: string - relationshipType: string - targetType: string - targetId: string - mountPath?: string - }): Promise => { - const result = await databasePool.query<{ - relationship_instance_id: string - behavior_attributes: Record - }>( - `SELECT relationship_instance_id, behavior_attributes - FROM operational_resource_relationships - WHERE environment_id = $1 AND source_type = $2 AND source_id = $3 - AND relationship_type = $4 AND target_type = $5 AND target_id = $6 - ORDER BY relationship_instance_id`, - [ - environmentId, - input.sourceType, - input.sourceId, - input.relationshipType, - input.targetType, - input.targetId, - ] - ) - const matches = input.mountPath - ? result.rows.filter(row => row.behavior_attributes.mountPath === input.mountPath) - : result.rows - expect(matches).toHaveLength(1) - return matches[0]!.relationship_instance_id - } - const catalogItem = async (family: CatalogFamily, logicalId: string) => { - const catalog = await buildAccessCatalog( - { session, families: [family], limit: 100 }, - { transaction: transaction(databasePool) } - ) - expect(catalog.complete).toBe(true) - const item = catalog.items.find(value => value.resource.logicalId === logicalId) - expect(item).toBeDefined() - return item! - } - const instanceIds = (relationships: readonly { instanceId?: string }[]) => - relationships.flatMap(relationship => - relationship.instanceId ? [relationship.instanceId] : [] - ) - - kubernetesApi.put(foreignContext.plural, foreignContext.namespace, foreignContext.object) - kubernetesApi.put(foreignHost.plural, foreignHost.namespace, foreignHost.object) - kubernetesApi.put(unownedHost.plural, unownedHost.namespace, unownedHost.object) - await indexer.reconcileSource(contextSource) - await indexer.reconcileSource(hostSource) - await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, 'I7 Foreign Team')`, [ - foreignTeamId, - ]) - await databasePool.query(`INSERT INTO team_contexts(team_id, context_id) VALUES ($1, $2)`, [ - foreignTeamId, - foreignContextName, - ]) - await databasePool.query(`INSERT INTO team_agents(team_id, agent_name) VALUES ($1, $2)`, [ - foreignTeamId, - foreignHostName, - ]) - - try { - const ownMcpEdge = await relationshipInstanceId({ - sourceType: 'context', - sourceId: contextLogicalId, - relationshipType: 'includes_mcp_server', - targetType: 'mcp_server', - targetId: mcpLogicalId, - }) - const ownHostEdge = await relationshipInstanceId({ - sourceType: 'host', - sourceId: `${config.hostsNamespace}/catalog-host`, - relationshipType: 'uses_context', - targetType: 'context', - targetId: contextLogicalId, - }) - const ownMountA = await relationshipInstanceId({ - sourceType: 'context', - sourceId: contextLogicalId, - relationshipType: 'mounts_shared_filesystem', - targetType: 'shared_filesystem', - targetId: sharedFilesystemLogicalId, - mountPath: '/workspace/a', - }) - const ownMountB = await relationshipInstanceId({ - sourceType: 'context', - sourceId: contextLogicalId, - relationshipType: 'mounts_shared_filesystem', - targetType: 'shared_filesystem', - targetId: sharedFilesystemLogicalId, - mountPath: '/workspace/b', - }) - const foreignMcpEdge = await relationshipInstanceId({ - sourceType: 'context', - sourceId: foreignContextId, - relationshipType: 'includes_mcp_server', - targetType: 'mcp_server', - targetId: mcpLogicalId, - }) - const foreignHostEdge = await relationshipInstanceId({ - sourceType: 'host', - sourceId: `${config.hostsNamespace}/${foreignHostName}`, - relationshipType: 'uses_context', - targetType: 'context', - targetId: foreignContextId, - }) - const foreignMountEdge = await relationshipInstanceId({ - sourceType: 'context', - sourceId: foreignContextId, - relationshipType: 'mounts_shared_filesystem', - targetType: 'shared_filesystem', - targetId: sharedFilesystemLogicalId, - mountPath: '/workspace/foreign', - }) - const unownedHostEdge = await relationshipInstanceId({ - sourceType: 'host', - sourceId: `${config.hostsNamespace}/${unownedHostName}`, - relationshipType: 'uses_context', - targetType: 'context', - targetId: contextLogicalId, - }) - expect( - new Set([ownMcpEdge, ownHostEdge, ownMountA, ownMountB, foreignMcpEdge, foreignHostEdge]) - .size - ).toBe(6) - - const mcpItem = await catalogItem('mcp_server', mcpLogicalId) - const mcpInstances = instanceIds(mcpItem.relationships) - expect(mcpInstances).toContain(ownMcpEdge) - expect(mcpInstances).toContain(ownHostEdge) - expect(mcpInstances).not.toContain(foreignMcpEdge) - expect(mcpInstances).not.toContain(foreignHostEdge) - expect(mcpInstances).not.toContain(unownedHostEdge) - expect(mcpItem.relationships.map(value => value.targetResourceId)).not.toContain( - `context:${foreignContextId}` - ) - - const sharedFilesystemItem = await catalogItem('shared_filesystem', sharedFilesystemLogicalId) - const sharedFilesystemInstances = instanceIds(sharedFilesystemItem.relationships) - expect(sharedFilesystemInstances).toContain(ownMountA) - expect(sharedFilesystemInstances).toContain(ownMountB) - expect(sharedFilesystemInstances).not.toContain(foreignMountEdge) - - const contextItem = await catalogItem('context', contextLogicalId) - const contextInstances = instanceIds(contextItem.relationships) - expect(contextInstances).toContain(ownMcpEdge) - expect(contextInstances).toContain(ownMountA) - expect(contextInstances).not.toContain(unownedHostEdge) - - for (const [item, capability] of [ - [mcpItem, 'mcp_server.read'], - [sharedFilesystemItem, 'shared_filesystem.read'], - ] as const) { - expect(item.accessPaths.length).toBeGreaterThan(0) - for (const path of item.accessPaths) { - await expect( - resolveLiveAuthorization( - { - session, - requiredCapability: capability, - resource: canonicalResourceIdentity(item.resource), - requestedAccessPathId: path.accessPathId, - }, - { transaction: transaction(databasePool), gateway } - ) - ).resolves.toEqual( - expect.objectContaining({ - status: 'allowed', - selectedPath: expect.objectContaining({ id: path.accessPathId }), - }) - ) - } - } - - const unexplainedRelationships: string[] = [] - let checkedRelationships = 0 - for (const family of [ - 'host', - 'context', - 'mcp_server', - 'workflow_recipe', - 'shared_filesystem', - 'sandbox_app', - ] as const) { - const catalog = await buildAccessCatalog( - { session, families: [family], limit: 100 }, - { transaction: transaction(databasePool) } - ) - expect(catalog.complete).toBe(true) - for (const item of catalog.items) { - const pathInstances = new Set() - for (const path of item.accessPaths) { - for (const dimension of [ - path.behaviorDescriptors.runtime, - path.behaviorDescriptors.filesystemScope, - ]) { - if (dimension.state !== 'known' || typeof dimension.value !== 'string') continue - for (const match of dimension.value.matchAll(/rel1_[A-Za-z0-9_-]{43}/gu)) { - pathInstances.add(match[0]) - } - } - } - for (const relationship of item.relationships) { - if (!relationship.instanceId) continue - checkedRelationships += 1 - if (!pathInstances.has(relationship.instanceId)) { - unexplainedRelationships.push( - `${family}:${item.resource.logicalId}:${relationship.instanceId}` - ) - } - } - } - } - expect(checkedRelationships).toBeGreaterThan(0) - expect(unexplainedRelationships).toEqual([]) - - await databasePool.query( - `INSERT INTO team_members(team_id, user_id, role, status) - VALUES ($1, $2, 'member', 'active')`, - [foreignTeamId, userId] - ) - try { - const joinedMcpItem = await catalogItem('mcp_server', mcpLogicalId) - const joinedInstances = instanceIds(joinedMcpItem.relationships) - expect(joinedInstances).toContain(foreignMcpEdge) - expect(joinedInstances).toContain(foreignHostEdge) - expect(joinedMcpItem.relationships.map(value => value.targetResourceId)).toContain( - `context:${foreignContextId}` - ) - } finally { - await databasePool.query(`DELETE FROM team_members WHERE team_id = $1 AND user_id = $2`, [ - foreignTeamId, - userId, - ]) - } - } finally { - await databasePool.query(`DELETE FROM team_members WHERE team_id = $1 AND user_id = $2`, [ - foreignTeamId, - userId, - ]) - await databasePool.query(`DELETE FROM team_contexts WHERE team_id = $1`, [foreignTeamId]) - await databasePool.query(`DELETE FROM team_agents WHERE team_id = $1`, [foreignTeamId]) - await databasePool.query(`DELETE FROM teams WHERE id = $1`, [foreignTeamId]) - kubernetesApi.delete(unownedHost.plural, unownedHost.namespace, unownedHostName) - kubernetesApi.delete(foreignHost.plural, foreignHost.namespace, foreignHostName) - kubernetesApi.delete(foreignContext.plural, foreignContext.namespace, foreignContextName) - await indexer.reconcileSource(contextSource) - await indexer.reconcileSource(hostSource) - } - }) - - it('fails a Host closed when its configured Context does not resolve', async () => { - const suffix = randomBytes(5).toString('hex') - const unresolvedHostName = `i7-unresolved-host-${suffix}` - const missingContextRef = `i7-missing-context-${suffix}` - const ambiguousContextName = `i7-amb-context-${suffix}` - const ambiguousAliasName = `i7-amb-alias-${suffix}` - const ambiguousHostName = `i7-amb-host-${suffix}` - const unresolvedHostId = `${config.hostsNamespace}/${unresolvedHostName}` - const ambiguousHostId = `${config.hostsNamespace}/${ambiguousHostName}` - const contextSource = operationalSourceSpecs.find(value => value.family === 'context')! - const hostSource = operationalSourceSpecs.find(value => value.family === 'host')! - const unresolvedHost = fixture({ - plural: 'hosts', - namespace: config.hostsNamespace, - name: unresolvedHostName, - uid: randomUUID(), - spec: { contextRef: missingContextRef }, - }) - const ambiguousContext = fixture({ - plural: 'contexts', - namespace: config.contextsNamespace, - name: ambiguousContextName, - uid: randomUUID(), - spec: { mcpServers: [], sharedFileSystems: [] }, - }) - const ambiguousAlias = fixture({ - plural: 'contexts', - namespace: config.contextsNamespace, - name: ambiguousAliasName, - uid: randomUUID(), - spec: { contextId: ambiguousContextName, mcpServers: [], sharedFileSystems: [] }, - }) - const ambiguousHost = fixture({ - plural: 'hosts', - namespace: config.hostsNamespace, - name: ambiguousHostName, - uid: randomUUID(), - spec: { contextRef: ambiguousContextName }, - }) - - kubernetesApi.put(ambiguousContext.plural, ambiguousContext.namespace, ambiguousContext.object) - kubernetesApi.put(ambiguousAlias.plural, ambiguousAlias.namespace, ambiguousAlias.object) - kubernetesApi.put(unresolvedHost.plural, unresolvedHost.namespace, unresolvedHost.object) - kubernetesApi.put(ambiguousHost.plural, ambiguousHost.namespace, ambiguousHost.object) - await indexer.reconcileSource(contextSource) - await indexer.reconcileSource(hostSource) - await databasePool.query( - `INSERT INTO user_agents(user_id, agent_name) VALUES ($1, $2), ($1, $3)`, - [userId, unresolvedHostName, ambiguousHostName] - ) - - try { - for (const logicalId of [unresolvedHostId, ambiguousHostId]) { - await expect( - resolveLiveAuthorization( - { - session, - requiredCapability: 'host.read', - resource: canonicalResourceIdentity({ - environmentId, - type: 'host', - logicalId, - }), - }, - { transaction: transaction(databasePool), gateway } - ) - ).resolves.toEqual({ - status: 'unavailable', - dependencyClass: 'operational_resource_store', - retryable: true, - }) - } - - const hostCatalog = await buildAccessCatalog( - { session, families: ['host'], limit: 100 }, - { transaction: transaction(databasePool) } - ) - expect(hostCatalog.complete).toBe(false) - expect(hostCatalog.partialErrors).toEqual([ - { producer: 'host', code: 'operational_source_unavailable', retryable: true }, - ]) - expect(hostCatalog.items.map(item => item.resource.logicalId)).not.toContain(unresolvedHostId) - expect(hostCatalog.items.map(item => item.resource.logicalId)).not.toContain(ambiguousHostId) - const resolvableHost = hostCatalog.items.find( - item => item.resource.logicalId === `${config.hostsNamespace}/catalog-host` - ) - expect(resolvableHost).toBeDefined() - for (const path of resolvableHost!.accessPaths) { - await expect( - resolveLiveAuthorization( - { - session, - requiredCapability: 'host.read', - resource: canonicalResourceIdentity(resolvableHost!.resource), - requestedAccessPathId: path.accessPathId, - }, - { transaction: transaction(databasePool), gateway } - ) - ).resolves.toEqual( - expect.objectContaining({ - status: 'allowed', - selectedPath: expect.objectContaining({ id: path.accessPathId }), - }) - ) - } - } finally { - await databasePool.query( - `DELETE FROM user_agents WHERE user_id = $1 AND agent_name = ANY($2::text[])`, - [userId, [unresolvedHostName, ambiguousHostName]] - ) - kubernetesApi.delete(unresolvedHost.plural, unresolvedHost.namespace, unresolvedHostName) - kubernetesApi.delete(ambiguousHost.plural, ambiguousHost.namespace, ambiguousHostName) - kubernetesApi.delete(ambiguousAlias.plural, ambiguousAlias.namespace, ambiguousAliasName) - kubernetesApi.delete( - ambiguousContext.plural, - ambiguousContext.namespace, - ambiguousContextName - ) - await indexer.reconcileSource(contextSource) - await indexer.reconcileSource(hostSource) - } - }) - - it('round-trips a catalog write path when capability selection excludes a read-only sibling', async () => { - const catalog = await buildAccessCatalog( - { session, families: ['gfs_resource'], limit: 10 }, - { - transaction: transaction(databasePool), - teamGfsMembershipAdmissionLimit: 1, - } - ) - const item = catalog.items[0]! - const directWritePath = item.accessPaths.find( - path => path.kind === 'direct' && path.capabilities.includes('gfs.write') - ) - const teamReadPath = item.accessPaths.find( - path => path.kind === 'team' && path.capabilities.includes('gfs.read') - ) - expect(directWritePath).toBeDefined() - expect(teamReadPath).toBeDefined() - - const resolved = await resolveLiveAuthorization( - { - session, - requiredCapability: 'gfs.write', - resource: canonicalResourceIdentity(item.resource), - requestedAccessPathId: directWritePath!.accessPathId, - }, - { transaction: transaction(databasePool), gateway } - ) - - expect(resolved).toEqual( - expect.objectContaining({ - status: 'allowed', - selectedPath: expect.objectContaining({ id: directWritePath!.accessPathId }), - }) - ) - - await expect( - resolveLiveAuthorization( - { - session, - requiredCapability: 'gfs.write', - resource: canonicalResourceIdentity(item.resource), - requestedAccessPathId: teamReadPath!.accessPathId, - }, - { transaction: transaction(databasePool), gateway } - ) - ).resolves.toEqual( - expect.objectContaining({ status: 'access_path_stale', code: 'access_path_stale' }) - ) - - await expect( - resolveLiveAuthorization( - { - session, - requiredCapability: 'gfs.delete', - resource: canonicalResourceIdentity(item.resource), - }, - { transaction: transaction(databasePool), gateway } - ) - ).resolves.toEqual({ status: 'denied', code: 'forbidden' }) - }) - - it('enforces the operator-configured Team-GFS admission through the production budget contract', async () => { - const intent = loadConfiguredUserAccessIntent({ - CONTROL_API_USER_ACCESS_CATALOG_MODE: 'serve', - CONTROL_API_USER_ACCESS_TEAM_GFS_MEMBERSHIP_ADMISSION_LIMIT: '1', - }) - const budget = AccessExecutionBudget.create('catalog', catalogBudgetOptionsForIntent(intent)) - try { - await expect( - buildAccessCatalog( - { session, families: ['gfs_resource'], limit: 100 }, - { transaction: transaction(databasePool), budget } - ) - ).resolves.toMatchObject({ complete: true, items: [{ resource: { type: 'gfs_resource' } }] }) - } finally { - budget.close() - } - - const secondTeamId = randomUUID() - await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, 'Admission Overflow Team')`, [ - secondTeamId, - ]) - await databasePool.query( - `INSERT INTO team_members(team_id, user_id, role, status) - VALUES ($1, $2, 'member', 'active')`, - [secondTeamId, userId] - ) - const exhausted = AccessExecutionBudget.create('catalog', catalogBudgetOptionsForIntent(intent)) - try { - await expect( - buildAccessCatalog( - { session, families: ['gfs_resource'], limit: 100 }, - { transaction: transaction(databasePool), budget: exhausted } - ) - ).rejects.toBeInstanceOf(AccessBudgetExceededError) - } finally { - exhausted.close() - await databasePool.query(`DELETE FROM teams WHERE id = $1`, [secondTeamId]) - } - }) - - it('preserves both direct/team provenance across duplicate filesystem mounts', async () => { - const catalog = await buildAccessCatalog( - { session, families: ['shared_filesystem'], limit: 100 }, - { transaction: transaction(databasePool) } - ) - const item = catalog.items[0] - expect( - item.relationships.filter(value => value.type === 'mounts_shared_filesystem') - ).toHaveLength(2) - expect(item.accessPaths).toHaveLength(4) - expect(new Set(item.accessPaths.map(path => path.accessPathId)).size).toBe(4) - expect( - new Set( - item.accessPaths.map(path => JSON.stringify(path.behaviorDescriptors.filesystemScope)) - ).size - ).toBe(2) - }) - - it('discovers a Host through a direct user grant without a team grant', async () => { - const directOnlyUserId = randomUUID() - const directOnlySession: ExternalSessionAuthorityContext = { - contract: 'v1', - userId: directOnlyUserId, - tokenHash: randomBytes(32).toString('hex'), - issuedAt: Math.floor(Date.now() / 1_000), - authGeneration: 1, - } - await databasePool.query( - `INSERT INTO users(id, email, name) VALUES ($1, $2, 'Direct-only Catalog User')`, - [directOnlyUserId, `${directOnlyUserId}@example.test`] - ) - await databasePool.query( - `INSERT INTO user_agents(user_id, agent_name) VALUES ($1, 'catalog-host')`, - [directOnlyUserId] - ) - - const catalog = await buildAccessCatalog( - { session: directOnlySession, families: ['host'], limit: 10 }, - { transaction: transaction(databasePool) } - ) - expect(catalog.items.map(item => item.resource.logicalId)).toEqual([ - `${config.hostsNamespace}/catalog-host`, - ]) - expect(catalog.items[0]?.accessPaths).toHaveLength(1) - expect(catalog.items[0]?.accessPaths[0]).toEqual(expect.objectContaining({ kind: 'direct' })) - expect(catalog.items[0]?.accessPaths[0]).not.toHaveProperty('teamId') - }) - - it('discovers a team-only Host and revokes it live without replacing the user session', async () => { - const teamOnlyUserId = randomUUID() - const teamOnlyTeamId = randomUUID() - const teamOnlySession: ExternalSessionAuthorityContext = { - contract: 'v1', - userId: teamOnlyUserId, - tokenHash: randomBytes(32).toString('hex'), - issuedAt: Math.floor(Date.now() / 1_000), - authGeneration: 1, - } - await databasePool.query( - `INSERT INTO users(id, email, name) VALUES ($1, $2, 'Team-only Catalog User')`, - [teamOnlyUserId, `${teamOnlyUserId}@example.test`] - ) - await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, 'Team-only Catalog Team')`, [ - teamOnlyTeamId, - ]) - await databasePool.query( - `INSERT INTO team_members(team_id, user_id, role, status) - VALUES ($1, $2, 'member', 'active')`, - [teamOnlyTeamId, teamOnlyUserId] - ) - await databasePool.query( - `INSERT INTO team_agents(team_id, agent_name) VALUES ($1, 'catalog-host')`, - [teamOnlyTeamId] - ) - - const firstCatalog = await buildAccessCatalog( - { session: teamOnlySession, families: ['host'], limit: 10 }, - { transaction: transaction(databasePool) } - ) - expect(firstCatalog.items.map(item => item.resource.logicalId)).toEqual([ - `${config.hostsNamespace}/catalog-host`, - ]) - expect(firstCatalog.items[0]?.accessPaths).toEqual([ - expect.objectContaining({ - kind: 'team', - safeTeamDescriptor: expect.objectContaining({ teamId: teamOnlyTeamId }), - }), - ]) - - const teamPath = firstCatalog.items[0]!.accessPaths[0]! - await expect( - resolveLiveAuthorization( - { - session: teamOnlySession, - requiredCapability: 'host.read', - resource: canonicalResourceIdentity(firstCatalog.items[0]!.resource), - requestedAccessPathId: teamPath.accessPathId, - }, - { transaction: transaction(databasePool), gateway } - ) - ).resolves.toEqual( - expect.objectContaining({ - status: 'allowed', - selectedPath: expect.objectContaining({ kind: 'team', teamId: teamOnlyTeamId }), - }) - ) - - await databasePool.query( - `UPDATE team_members SET status = 'deleted' WHERE team_id = $1 AND user_id = $2`, - [teamOnlyTeamId, teamOnlyUserId] - ) - - const afterRevocation = await buildAccessCatalog( - { session: teamOnlySession, families: ['host'], limit: 10 }, - { transaction: transaction(databasePool) } - ) - expect(afterRevocation.items).toEqual([]) - await expect( - resolveLiveAuthorization( - { - session: teamOnlySession, - requiredCapability: 'host.read', - resource: canonicalResourceIdentity(firstCatalog.items[0]!.resource), - requestedAccessPathId: teamPath.accessPathId, - }, - { transaction: transaction(databasePool), gateway } - ) - ).resolves.toEqual(expect.objectContaining({ status: 'not_found', code: 'not_found' })) - }) - - it('uses real Kubernetes list and exact-read wire boundaries', async () => { - const initialWireRequests = kubernetesApi.requests.slice(0, initialWireRequestCount) - const listRequests = initialWireRequests.filter(request => !request.watch && !request.name) - expect(listRequests).toHaveLength(operationalSourceSpecs.length) - expect(listRequests.map(request => `${request.namespace}/${request.plural}`).sort()).toEqual( - operationalSourceSpecs.map(source => `${source.namespace}/${source.plural}`).sort() - ) - expect(listRequests.every(request => request.limit !== null)).toBe(true) - const exactRequests = kubernetesApi.requests.filter(request => request.name) - expect(exactRequests.length).toBeGreaterThan(0) - expect( - new Set(exactRequests.map(request => request.namespace)).has(config.hostsNamespace) - ).toBe(true) - const sourceStates = await databasePool.query( - `SELECT source_family, resource_version, status - FROM operational_catalog_source_state - WHERE environment_id = $1 - ORDER BY source_family`, - [environmentId] - ) - expect(sourceStates.rows).toHaveLength(operationalSourceSpecs.length) - expect(sourceStates.rows.every(row => row.resource_version === '1')).toBe(true) - expect(sourceStates.rows.every(row => row.status === 'current')).toBe(true) - }) - - it('ingests deletion and recreation through the real Kubernetes watch boundary', async () => { - const source = operationalSourceSpecs.find(value => value.family === 'host')! - const original = operationalFixtures.find(value => value.plural === 'hosts')! - const controller = new AbortController() - const watch = gateway.watchResource( - source.plural, - source.namespace, - '1', - controller.signal, - (phase, object) => indexer.applyWatchEvent(source, phase, object, controller.signal) - ) - await waitFor(() => kubernetesApi.requests.some(request => request.watch)) - - const deleted = { - ...original.object, - metadata: { - ...(original.object.metadata as Record), - resourceVersion: '2', - }, - } - kubernetesApi.delete(source.plural, source.namespace, 'catalog-host') - kubernetesApi.emitWatch('DELETED', deleted) - await waitFor(async () => { - const result = await databasePool.query( - `SELECT COUNT(*)::int AS count - FROM operational_resource_index - WHERE environment_id = $1 AND resource_type = 'host' - AND logical_id = $2`, - [environmentId, `${config.hostsNamespace}/catalog-host`] - ) - return result.rows[0]?.count === 0 - }) - - const recreated = fixture({ - plural: 'hosts', - namespace: config.hostsNamespace, - name: 'catalog-host', - uid: 'catalog-host-uid-recreated', - spec: { - contextRef: 'catalog-context', - model: { provider: 'openai', name: 'test-model' }, - }, - }).object - const recreatedWithVersion = { - ...recreated, - metadata: { - ...(recreated.metadata as Record), - resourceVersion: '3', - }, - } - kubernetesApi.put(source.plural, source.namespace, recreatedWithVersion) - kubernetesApi.emitWatch('ADDED', recreatedWithVersion) - await waitFor(async () => { - const result = await databasePool.query( - `SELECT provider_uid, provider_resource_version, deleted_at - FROM operational_resource_index - WHERE environment_id = $1 AND resource_type = 'host' - AND logical_id = $2`, - [environmentId, `${config.hostsNamespace}/catalog-host`] - ) - return ( - result.rows[0]?.provider_uid === 'catalog-host-uid-recreated' && - result.rows[0]?.provider_resource_version === '3' && - result.rows[0]?.deleted_at === null - ) - }) - - const catalog = await buildAccessCatalog( - { session, families: ['host'], limit: 10 }, - { transaction: transaction(databasePool) } - ) - expect(catalog.items).toHaveLength(1) - const item = catalog.items[0] - const resolved = await resolveLiveAuthorization( - { - session, - requiredCapability: 'host.read', - resource: canonicalResourceIdentity(item.resource), - requestedAccessPathId: item.accessPaths[0].accessPathId, - }, - { transaction: transaction(databasePool), gateway } - ) - expect(resolved.status).toBe('allowed') - - controller.abort('watch_complete') - await expect(watch).resolves.toBeUndefined() - const watchRequest = kubernetesApi.requests.find(request => request.watch) - expect(watchRequest).toEqual( - expect.objectContaining({ - namespace: config.hostsNamespace, - plural: 'hosts', - resourceVersion: '1', - }) - ) - }) - - it('cancels a real indexer relist without staging or promoting late results', async () => { - const source = operationalSourceSpecs.find(value => value.family === 'host')! - const cancellationHost = fixture({ - plural: 'hosts', - namespace: config.hostsNamespace, - name: 'catalog-host', - uid: 'catalog-host-uid-cancellation', - spec: { - contextRef: 'catalog-context', - model: { provider: 'openai', name: 'test-model' }, - }, - }).object - const cancellationHostWithVersion = { - ...cancellationHost, - metadata: { - ...(cancellationHost.metadata as Record), - resourceVersion: '4', - }, - } - kubernetesApi.put(source.plural, source.namespace, cancellationHostWithVersion) - await expect(indexer.reconcileSource(source)).resolves.toBe('4') - const before = await databasePool.query( - `SELECT generation, staging_generation, resource_version, status - FROM operational_catalog_source_state - WHERE environment_id = $1 AND source_family = $2`, - [environmentId, source.family] - ) - expect(before.rows[0]).toEqual( - expect.objectContaining({ - staging_generation: null, - resource_version: '4', - status: 'current', - }) - ) - const held = kubernetesApi.holdNextList() - const controller = new AbortController() - const pending = indexer.reconcileSource(source, controller.signal) - try { - await held.requested - controller.abort(new Error('test_cancelled')) - await expect(pending).rejects.toThrow() - await held.closed - - const afterAbort = await databasePool.query( - `SELECT generation, staging_generation, resource_version, status - FROM operational_catalog_source_state - WHERE environment_id = $1 AND source_family = $2`, - [environmentId, source.family] - ) - expect(afterAbort.rows[0]).toEqual( - expect.objectContaining({ - generation: String(Number(before.rows[0].generation) + 1), - resource_version: '4', - status: 'relisting', - }) - ) - expect(afterAbort.rows[0].staging_generation).not.toBeNull() - const staged = await databasePool.query( - `SELECT - (SELECT COUNT(*)::int FROM operational_resource_index_staging - WHERE environment_id = $1 AND source_family = $2) AS resources, - (SELECT COUNT(*)::int FROM operational_relationships_staging - WHERE environment_id = $1 AND source_family = $2) AS relationships`, - [environmentId, source.family] - ) - expect(staged.rows[0]).toEqual({ resources: 0, relationships: 0 }) - const live = await databasePool.query( - `SELECT provider_uid, provider_resource_version - FROM operational_resource_index - WHERE environment_id = $1 AND resource_type = 'host' - AND logical_id = $2`, - [environmentId, `${config.hostsNamespace}/catalog-host`] - ) - expect(live.rows).toEqual([ - { - provider_uid: 'catalog-host-uid-cancellation', - provider_resource_version: '4', - }, - ]) - - await expect(indexer.reconcileSource(source)).resolves.toBe('4') - const recovered = await databasePool.query( - `SELECT staging_generation, resource_version, status - FROM operational_catalog_source_state - WHERE environment_id = $1 AND source_family = $2`, - [environmentId, source.family] - ) - expect(recovered.rows[0]).toEqual({ - staging_generation: null, - resource_version: '4', - status: 'current', - }) - } finally { - held.release() - } - }) -}) diff --git a/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts b/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts deleted file mode 100644 index 202be172e9..0000000000 --- a/control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts +++ /dev/null @@ -1,938 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { randomBytes, randomUUID } from 'node:crypto' -import { Pool, type PoolClient } from 'pg' -import { config } from '../src/config.js' -import { type DbClient, initDb } from '../src/db.js' -import { buildAccessCatalog } from '../src/services/access/accessCatalogCoordinator.js' -import { AccessCatalogCursorError } from '../src/services/access/accessCatalogCursor.js' -import { CATALOG_FAMILIES, type CatalogFamily } from '../src/services/access/catalogContracts.js' -import { CATALOG_KEY_SQL } from '../src/services/access/catalogProducerSql.js' -import { - OPERATIONAL_SOURCE_FAMILIES, - canonicalEnvironmentId, -} from '../src/services/access/operationalAccessProjection.js' -import { - type TeamGfsStreamRequest, - collectTeamGfsTopK, - teamGfsTopKSql, -} from '../src/services/access/teamGfsTopK.js' -import type { ExternalSessionAuthorityContext } from '../src/services/auth/externalSessionAuthentication.js' -import { CatalogQueryObservation } from './accessCatalogQueryObservation.js' -import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' - -const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL -const describeRealPostgres = adminUrl ? describe : describe.skip -const runtimeRoles = [ - 'control_api_runtime', - 'trace_maintenance_runtime', - 'workflow_recipes_runtime', -] as const -const DIRECT_ONLY_RESOURCES = 20 -const TYPICAL_TEAMS = 3 -const TYPICAL_RESOURCES_PER_TEAM = 10 -const HIGH_DIRECT_RESOURCES = 1_500 -const HIGH_TEAMS = 250 -const HIGH_RESOURCES_PER_TEAM = 6 - -const armCounts: Readonly> = Object.freeze({ - user: 1, - team: 1, - host: 2, - context: 2, - mcp_server: 4, - workflow_recipe: 2, - workflow_run: 3, - workflow_approval: 2, - notification: 2, - gfs_resource: 2, - shared_filesystem: 2, - sandbox_app: 2, -}) - -function databaseUrl(baseUrl: string, database: string): string { - const value = new URL(baseUrl) - value.pathname = `/${database}` - return value.toString() -} - -function quoteIdentifier(value: string): string { - return `"${value.replace(/"/g, '""')}"` -} - -type JsonPlanNode = Record & { Plans?: JsonPlanNode[] } - -function planNodes(root: JsonPlanNode): JsonPlanNode[] { - const values: JsonPlanNode[] = [] - const pending = [root] - while (pending.length > 0) { - const current = pending.pop()! - values.push(current) - pending.push(...(current.Plans ?? [])) - } - return values -} - -function numberField(value: JsonPlanNode, key: string): number { - const number = Number(value[key] ?? 0) - return Number.isFinite(number) ? number : 0 -} - -function queryPlan(row: unknown): Record & { Plan: JsonPlanNode } { - const value = (row as Record)['QUERY PLAN'] - if (!Array.isArray(value) || !value[0] || typeof value[0] !== 'object') { - throw new Error('explain_plan_invalid') - } - return value[0] as Record & { Plan: JsonPlanNode } -} - -describeRealPostgres('aggregate catalog plans on real PostgreSQL', () => { - const database = `control_api_catalog_performance_${randomBytes(6).toString('hex')}` - const connectionString = databaseUrl( - adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', - database - ) - const environmentId = canonicalEnvironmentId() - const userId = randomUUID() - const allTeamIds = Array.from({ length: HIGH_TEAMS }, () => randomUUID()) - const session: ExternalSessionAuthorityContext = { - contract: 'v1', - userId, - tokenHash: randomBytes(32).toString('hex'), - issuedAt: Math.floor(Date.now() / 1_000), - authGeneration: 1, - } - let adminPool: Pool - let databasePool: Pool - - beforeAll(async () => { - adminPool = new Pool({ connectionString: adminUrl }) - await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) - await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) - databasePool = new Pool({ connectionString }) - await initDb({ connect: () => databasePool.connect() }) - await databasePool.query( - `INSERT INTO users(id, email, name) VALUES ($1, $2, 'Catalog Plan User')`, - [userId, `${userId}@example.test`] - ) - await databasePool.query( - `INSERT INTO operational_catalog_source_state( - environment_id, source_family, generation, resource_version, status, last_success_at - ) - SELECT $1, source_family, 1, '1', 'current', NOW() - FROM UNNEST($2::text[]) source_family`, - [environmentId, OPERATIONAL_SOURCE_FAMILIES] - ) - }) - - afterAll(async () => { - try { - await endPoolAndWaitForClients(databasePool) - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) - FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) - await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) - } - } finally { - await adminPool?.end() - } - }) - - async function seedDirect(first: number, last: number): Promise { - if (first > last) return - const seriesValues = [userId, first, last] - await databasePool.query( - `WITH requester AS (SELECT $1::uuid AS user_id), ids AS ( - SELECT value, LPAD(value::text, 5, '0') AS suffix - FROM generate_series($2::int, $3::int) value - ) - INSERT INTO user_agents(user_id, agent_name) - SELECT $1, 'direct-host-' || suffix FROM ids`, - seriesValues - ) - await databasePool.query( - `WITH requester AS (SELECT $1::uuid AS user_id), ids AS ( - SELECT value, LPAD(value::text, 5, '0') AS suffix - FROM generate_series($2::int, $3::int) value - ) - INSERT INTO user_contexts(user_id, context_id) - SELECT $1, 'direct-context-' || suffix FROM ids`, - seriesValues - ) - await databasePool.query( - `WITH ids AS ( - SELECT value, LPAD(value::text, 5, '0') AS suffix - FROM generate_series($2::int, $3::int) value - ) - INSERT INTO user_workflow_triggers(user_id, recipe_namespace, recipe_name) - SELECT $1, $4, 'direct-recipe-' || suffix FROM ids`, - [...seriesValues, config.sandboxNamespace] - ) - await databasePool.query( - `WITH requester AS (SELECT $1::uuid AS user_id), ids AS ( - SELECT value, LPAD(value::text, 5, '0') AS suffix - FROM generate_series($2::int, $3::int) value - ), resources(resource_type, logical_id, source_family, provider_uid, display_name) AS ( - SELECT 'host', $5 || '/direct-host-' || suffix, 'host', - 'direct-host-uid-' || suffix, 'Direct Host ' || suffix FROM ids - UNION ALL - SELECT 'context', $6 || '/direct-context-' || suffix, 'context', - 'direct-context-uid-' || suffix, 'Direct Context ' || suffix FROM ids - UNION ALL - SELECT 'mcp_server', $7 || '/direct-mcp-' || suffix, 'mcp_server', - 'direct-mcp-uid-' || suffix, 'Direct MCP ' || suffix FROM ids - UNION ALL - SELECT 'shared_filesystem', $8 || '/direct-files-' || suffix, 'shared_filesystem', - 'direct-files-uid-' || suffix, 'Direct Files ' || suffix FROM ids - UNION ALL - SELECT 'workflow_recipe', $9 || '/direct-recipe-' || suffix, 'workflow_recipe', - 'direct-recipe-uid-' || suffix, 'Direct Recipe ' || suffix FROM ids - UNION ALL - SELECT 'sandbox_app', $9 || '/direct-recipe-' || suffix, 'workflow_recipe', - 'direct-recipe-uid-' || suffix || ':ui', 'Direct App ' || suffix FROM ids - ) - INSERT INTO operational_resource_index( - environment_id, resource_type, logical_id, source_family, source_generation, - provider_uid, provider_resource_version, display_name, enabled, content_bytes - ) - SELECT $4, resource_type, logical_id, source_family, 1, - provider_uid, '1', display_name, TRUE, 128 - FROM resources`, - [ - userId, - first, - last, - environmentId, - config.hostsNamespace, - config.contextsNamespace, - config.mcpServersNamespace, - config.sharedFilesystemsNamespace, - config.sandboxNamespace, - ] - ) - await databasePool.query( - `WITH requester AS (SELECT $1::uuid AS user_id), ids AS ( - SELECT value, LPAD(value::text, 5, '0') AS suffix - FROM generate_series($2::int, $3::int) value - ), relationships( - source_type, source_id, relationship_type, target_type, target_id, - relationship_instance_id, behavior_attributes, source_family, - source_provider_uid - ) AS ( - SELECT 'host', $5 || '/direct-host-' || suffix, 'uses_context', 'context', - $6 || '/direct-context-' || suffix, 'direct-host-context-' || suffix, - '{}'::jsonb, 'host', 'direct-host-uid-' || suffix FROM ids - UNION ALL - SELECT 'context', $6 || '/direct-context-' || suffix, 'includes_mcp_server', - 'mcp_server', $7 || '/direct-mcp-' || suffix, 'direct-context-mcp-' || suffix, - '{}'::jsonb, 'context', 'direct-context-uid-' || suffix FROM ids - UNION ALL - SELECT 'context', $6 || '/direct-context-' || suffix, 'mounts_shared_filesystem', - 'shared_filesystem', $8 || '/direct-files-' || suffix, - 'direct-context-files-' || suffix, - jsonb_build_object('mountPath', '/workspace/' || suffix, 'readOnly', TRUE), - 'context', 'direct-context-uid-' || suffix FROM ids - UNION ALL - SELECT 'workflow_recipe', $9 || '/direct-recipe-' || suffix, - 'exposes_sandbox_app', 'sandbox_app', $9 || '/direct-recipe-' || suffix, - 'direct-recipe-app-' || suffix, - jsonb_build_object('workloadRef', 'app', 'port', 8080, 'defaultPath', '/'), - 'workflow_recipe', 'direct-recipe-uid-' || suffix FROM ids - ) - INSERT INTO operational_resource_relationships( - environment_id, source_type, source_id, relationship_type, target_type, target_id, - relationship_instance_id, behavior_attributes, source_family, source_provider_uid, - source_resource_version, source_generation, content_bytes - ) - SELECT $4, source_type, source_id, relationship_type, target_type, target_id, - relationship_instance_id, behavior_attributes, source_family, - source_provider_uid, '1', 1, 64 - FROM relationships`, - [ - userId, - first, - last, - environmentId, - config.hostsNamespace, - config.contextsNamespace, - config.mcpServersNamespace, - config.sharedFilesystemsNamespace, - config.sandboxNamespace, - ] - ) - await databasePool.query( - `WITH ids AS ( - SELECT value, LPAD(value::text, 5, '0') AS suffix - FROM generate_series($2::int, $3::int) value - ) - INSERT INTO workflow_runs( - run_id, recipe_namespace, recipe_name, phase, actor_type, actor_id, trigger_source - ) - SELECT gen_random_uuid(), $4, 'direct-recipe-' || suffix, - 'Succeeded', 'user', $1, 'onDemand' - FROM ids`, - [userId, first, last, config.sandboxNamespace] - ) - await databasePool.query( - `WITH ids AS ( - SELECT value, LPAD(value::text, 5, '0') AS suffix - FROM generate_series($2::int, $3::int) value - ) - INSERT INTO workflow_approval_requests( - id, recipe_namespace, recipe_name, expires_at, status, target_user_id, - payload, idempotency_key, payload_hash - ) - SELECT gen_random_uuid(), $4, 'direct-recipe-' || suffix, - NOW() + INTERVAL '1 hour', 'pending', $1, '{}'::jsonb, - 'direct-load-' || gen_random_uuid()::text, 'hash' - FROM ids`, - [userId, first, last, config.sandboxNamespace] - ) - await databasePool.query( - `WITH ids AS ( - SELECT value FROM generate_series($2::int, $3::int) value - ) - INSERT INTO notification_deliveries( - id, event_type, dedupe_key, audience, payload, status, expires_at - ) - SELECT gen_random_uuid(), 'catalog.direct', 'direct-load-' || gen_random_uuid()::text, - jsonb_build_object('userId', $1::text), '{}'::jsonb, 'queued', - NOW() + INTERVAL '1 hour' - FROM ids`, - [userId, first, last] - ) - await databasePool.query( - `WITH ids AS ( - SELECT value, LPAD(value::text, 5, '0') AS suffix - FROM generate_series($2::int, $3::int) value - ), inserted AS ( - INSERT INTO gfs_resources(resource_id, drive, name, kind) - SELECT gen_random_uuid(), 'direct-drive-' || suffix, '/', 'directory' FROM ids - RETURNING resource_id, drive - ) - INSERT INTO gfs_grants(drive, resource_id, subject_type, subject_id, permissions) - SELECT drive, resource_id, 'user', $1::text, ARRAY['read']::text[] FROM inserted`, - [userId, first, last] - ) - } - - async function seedTeams(teamIds: readonly string[], resourcesPerTeam: number): Promise { - if (teamIds.length === 0) return - const seriesValues = [teamIds, userId, resourcesPerTeam] - await databasePool.query( - `WITH requester AS (SELECT $2::uuid AS user_id), requested AS ( - SELECT team_id, ordinal FROM UNNEST($1::uuid[]) WITH ORDINALITY value(team_id, ordinal) - ), inserted_teams AS ( - INSERT INTO teams(id, name) - SELECT team_id, 'Load Team ' || team_id::text FROM requested - ON CONFLICT (id) DO NOTHING RETURNING id - ) - INSERT INTO team_members(team_id, user_id, role, status) - SELECT team_id, $2, 'member', 'active' FROM requested - ON CONFLICT (team_id, user_id) DO NOTHING`, - [teamIds, userId] - ) - await databasePool.query( - `WITH requester AS (SELECT $2::uuid AS user_id), requested AS ( - SELECT team_id, ordinal FROM UNNEST($1::uuid[]) WITH ORDINALITY value(team_id, ordinal) - ), ids AS ( - SELECT team_id, - REPLACE(team_id::text, '-', '') || '-' || LPAD(value::text, 3, '0') AS suffix - FROM requested CROSS JOIN generate_series(1, $3::int) value - ) - INSERT INTO team_agents(team_id, agent_name) - SELECT team_id, 'team-host-' || suffix FROM ids`, - seriesValues - ) - await databasePool.query( - `WITH requester AS (SELECT $2::uuid AS user_id), requested AS ( - SELECT team_id, ordinal FROM UNNEST($1::uuid[]) WITH ORDINALITY value(team_id, ordinal) - ), ids AS ( - SELECT team_id, - REPLACE(team_id::text, '-', '') || '-' || LPAD(value::text, 3, '0') AS suffix - FROM requested CROSS JOIN generate_series(1, $3::int) value - ) - INSERT INTO team_contexts(team_id, context_id) - SELECT team_id, 'team-context-' || suffix FROM ids`, - seriesValues - ) - await databasePool.query( - `WITH requester AS (SELECT $2::uuid AS user_id), requested AS ( - SELECT team_id, ordinal FROM UNNEST($1::uuid[]) WITH ORDINALITY value(team_id, ordinal) - ), ids AS ( - SELECT team_id, - REPLACE(team_id::text, '-', '') || '-' || LPAD(value::text, 3, '0') AS suffix - FROM requested CROSS JOIN generate_series(1, $3::int) value - ) - INSERT INTO team_workflow_triggers(team_id, recipe_namespace, recipe_name) - SELECT team_id, $4, 'team-recipe-' || suffix FROM ids`, - [...seriesValues, config.sandboxNamespace] - ) - await databasePool.query( - `WITH requester AS (SELECT $2::uuid AS user_id), requested AS ( - SELECT team_id, ordinal FROM UNNEST($1::uuid[]) WITH ORDINALITY value(team_id, ordinal) - ), ids AS ( - SELECT team_id, - REPLACE(team_id::text, '-', '') || '-' || LPAD(value::text, 3, '0') AS suffix - FROM requested CROSS JOIN generate_series(1, $3::int) value - ), resources(resource_type, logical_id, source_family, provider_uid, display_name) AS ( - SELECT 'host', $5 || '/team-host-' || suffix, 'host', - 'team-host-uid-' || suffix, 'Team Host ' || suffix FROM ids - UNION ALL - SELECT 'context', $6 || '/team-context-' || suffix, 'context', - 'team-context-uid-' || suffix, 'Team Context ' || suffix FROM ids - UNION ALL - SELECT 'mcp_server', $7 || '/team-mcp-' || suffix, 'mcp_server', - 'team-mcp-uid-' || suffix, 'Team MCP ' || suffix FROM ids - UNION ALL - SELECT 'shared_filesystem', $8 || '/team-files-' || suffix, 'shared_filesystem', - 'team-files-uid-' || suffix, 'Team Files ' || suffix FROM ids - UNION ALL - SELECT 'workflow_recipe', $9 || '/team-recipe-' || suffix, 'workflow_recipe', - 'team-recipe-uid-' || suffix, 'Team Recipe ' || suffix FROM ids - UNION ALL - SELECT 'sandbox_app', $9 || '/team-recipe-' || suffix, 'workflow_recipe', - 'team-recipe-uid-' || suffix || ':ui', 'Team App ' || suffix FROM ids - ) - INSERT INTO operational_resource_index( - environment_id, resource_type, logical_id, source_family, source_generation, - provider_uid, provider_resource_version, display_name, enabled, content_bytes - ) - SELECT $4, resource_type, logical_id, source_family, 1, - provider_uid, '1', display_name, TRUE, 128 FROM resources`, - [ - teamIds, - userId, - resourcesPerTeam, - environmentId, - config.hostsNamespace, - config.contextsNamespace, - config.mcpServersNamespace, - config.sharedFilesystemsNamespace, - config.sandboxNamespace, - ] - ) - await databasePool.query( - `WITH requester AS (SELECT $2::uuid AS user_id), requested AS ( - SELECT team_id, ordinal FROM UNNEST($1::uuid[]) WITH ORDINALITY value(team_id, ordinal) - ), ids AS ( - SELECT team_id, - REPLACE(team_id::text, '-', '') || '-' || LPAD(value::text, 3, '0') AS suffix - FROM requested CROSS JOIN generate_series(1, $3::int) value - ), relationships( - source_type, source_id, relationship_type, target_type, target_id, - relationship_instance_id, behavior_attributes, source_family, - source_provider_uid - ) AS ( - SELECT 'host', $5 || '/team-host-' || suffix, 'uses_context', 'context', - $6 || '/team-context-' || suffix, 'team-host-context-' || suffix, - '{}'::jsonb, 'host', 'team-host-uid-' || suffix FROM ids - UNION ALL - SELECT 'context', $6 || '/team-context-' || suffix, 'includes_mcp_server', - 'mcp_server', $7 || '/team-mcp-' || suffix, 'team-context-mcp-' || suffix, - '{}'::jsonb, 'context', 'team-context-uid-' || suffix FROM ids - UNION ALL - SELECT 'context', $6 || '/team-context-' || suffix, 'mounts_shared_filesystem', - 'shared_filesystem', $8 || '/team-files-' || suffix, - 'team-context-files-' || suffix, - jsonb_build_object('mountPath', '/workspace/' || suffix, 'readOnly', TRUE), - 'context', 'team-context-uid-' || suffix FROM ids - UNION ALL - SELECT 'workflow_recipe', $9 || '/team-recipe-' || suffix, - 'exposes_sandbox_app', 'sandbox_app', $9 || '/team-recipe-' || suffix, - 'team-recipe-app-' || suffix, - jsonb_build_object('workloadRef', 'app', 'port', 8080, 'defaultPath', '/'), - 'workflow_recipe', 'team-recipe-uid-' || suffix FROM ids - ) - INSERT INTO operational_resource_relationships( - environment_id, source_type, source_id, relationship_type, target_type, target_id, - relationship_instance_id, behavior_attributes, source_family, source_provider_uid, - source_resource_version, source_generation, content_bytes - ) - SELECT $4, source_type, source_id, relationship_type, target_type, target_id, - relationship_instance_id, behavior_attributes, source_family, - source_provider_uid, '1', 1, 64 FROM relationships`, - [ - teamIds, - userId, - resourcesPerTeam, - environmentId, - config.hostsNamespace, - config.contextsNamespace, - config.mcpServersNamespace, - config.sharedFilesystemsNamespace, - config.sandboxNamespace, - ] - ) - await databasePool.query( - `WITH requester AS (SELECT $2::uuid AS user_id), requested AS ( - SELECT team_id, ordinal FROM UNNEST($1::uuid[]) WITH ORDINALITY value(team_id, ordinal) - ), ids AS ( - SELECT team_id, - REPLACE(team_id::text, '-', '') || '-' || LPAD(value::text, 3, '0') AS suffix - FROM requested CROSS JOIN generate_series(1, $3::int) value - ) - INSERT INTO workflow_runs( - run_id, recipe_namespace, recipe_name, phase, actor_type, actor_id, - team_id, trigger_source - ) - SELECT gen_random_uuid(), $4, 'team-recipe-' || suffix, - 'Succeeded', 'user', $2, team_id, 'onDemand' FROM ids`, - [teamIds, userId, resourcesPerTeam, config.sandboxNamespace] - ) - await databasePool.query( - `WITH requester AS (SELECT $2::uuid AS user_id), requested AS ( - SELECT team_id, ordinal FROM UNNEST($1::uuid[]) WITH ORDINALITY value(team_id, ordinal) - ), ids AS ( - SELECT team_id, - REPLACE(team_id::text, '-', '') || '-' || LPAD(value::text, 3, '0') AS suffix - FROM requested CROSS JOIN generate_series(1, $3::int) value - ) - INSERT INTO workflow_approval_requests( - id, recipe_namespace, recipe_name, expires_at, status, target_team_id, - payload, idempotency_key, payload_hash - ) - SELECT gen_random_uuid(), $4, 'team-recipe-' || suffix, - NOW() + INTERVAL '1 hour', 'pending', team_id, '{}'::jsonb, - 'team-load-' || gen_random_uuid()::text, 'hash' FROM ids`, - [teamIds, userId, resourcesPerTeam, config.sandboxNamespace] - ) - await databasePool.query( - `WITH requester AS (SELECT $2::uuid AS user_id), requested AS ( - SELECT team_id, ordinal FROM UNNEST($1::uuid[]) WITH ORDINALITY value(team_id, ordinal) - ), ids AS ( - SELECT team_id, - REPLACE(team_id::text, '-', '') || '-' || LPAD(value::text, 3, '0') AS suffix - FROM requested CROSS JOIN generate_series(1, $3::int) value - ) - INSERT INTO notification_deliveries( - id, event_type, dedupe_key, audience, payload, status, expires_at - ) - SELECT gen_random_uuid(), 'catalog.team', 'team-load-' || gen_random_uuid()::text, - jsonb_build_object('teamId', team_id::text), '{}'::jsonb, 'queued', - NOW() + INTERVAL '1 hour' FROM ids`, - [teamIds, userId, resourcesPerTeam] - ) - await databasePool.query( - `WITH requester AS (SELECT $2::uuid AS user_id), requested AS ( - SELECT team_id, ordinal FROM UNNEST($1::uuid[]) WITH ORDINALITY value(team_id, ordinal) - ), ids AS ( - SELECT team_id, - REPLACE(team_id::text, '-', '') || '-' || LPAD(value::text, 3, '0') AS suffix - FROM requested CROSS JOIN generate_series(1, $3::int) value - ), inserted AS ( - INSERT INTO gfs_resources(resource_id, drive, name, kind) - SELECT gen_random_uuid(), 'team-drive-' || suffix, '/', 'directory' FROM ids - RETURNING resource_id, drive - ) - INSERT INTO gfs_grants(drive, resource_id, subject_type, subject_id, permissions) - SELECT resource.drive, resource.resource_id, 'team', requested.team_id::text, - ARRAY['read']::text[] - FROM inserted resource - JOIN requested ON resource.drive LIKE '%' || REPLACE(requested.team_id::text, '-', '') || '%'`, - [teamIds, userId, resourcesPerTeam] - ) - } - - async function seedDuplicateTeamGfs(teamIds: readonly string[]): Promise { - if (teamIds.length === 0) return - await databasePool.query( - `INSERT INTO gfs_resources(resource_id, drive, name, kind) - VALUES - ('00000000-0000-4000-8000-000000000001', 'team-plan-duplicate-1', '/', 'directory'), - ('00000000-0000-4000-8000-000000000002', 'team-plan-duplicate-2', '/', 'directory') - ON CONFLICT (resource_id) DO NOTHING` - ) - await databasePool.query( - `WITH requested AS (SELECT UNNEST($1::uuid[])::text AS subject_id), - resources AS ( - SELECT resource_id, drive - FROM gfs_resources - WHERE resource_id IN ( - '00000000-0000-4000-8000-000000000001', - '00000000-0000-4000-8000-000000000002' - ) - ) - INSERT INTO gfs_grants(drive, resource_id, subject_type, subject_id, permissions) - SELECT resource.drive, resource.resource_id, 'team', requested.subject_id, - ARRAY['read']::text[] - FROM requested CROSS JOIN resources resource - ON CONFLICT (drive, resource_id, subject_type, subject_id) DO NOTHING`, - [teamIds] - ) - await databasePool.query( - `WITH requested AS (SELECT UNNEST($1::uuid[])::text AS subject_id), - resources AS ( - SELECT resource_id, drive - FROM gfs_resources - WHERE resource_id IN ( - '00000000-0000-4000-8000-000000000001', - '00000000-0000-4000-8000-000000000002' - ) - ) - INSERT INTO gfs_shares(drive, resource_id, subject_type, subject_id, permissions) - SELECT resource.drive, resource.resource_id, 'team', requested.subject_id, - ARRAY['read']::text[] - FROM requested CROSS JOIN resources resource - ON CONFLICT (drive, resource_id, subject_type, subject_id) DO NOTHING`, - [teamIds] - ) - } - - function teamGfsRequests(teamIds: readonly string[], take: number): TeamGfsStreamRequest[] { - return teamIds.flatMap(subjectId => - (['grant', 'share'] as const).map(kind => ({ - kind, - subjectId, - afterId: '00000000-0000-0000-0000-000000000000', - take, - })) - ) - } - - async function explainTeamGfs( - profile: string, - requests: readonly TeamGfsStreamRequest[] - ): Promise { - const result = await databasePool.query( - `EXPLAIN (ANALYZE, BUFFERS, WAL, FORMAT JSON) ${teamGfsTopKSql.streamHead}`, - [ - JSON.stringify( - requests.map(request => ({ - kind: request.kind, - subject_id: request.subjectId, - after_id: request.afterId, - take: request.take, - })) - ), - ] - ) - const explained = queryPlan(result.rows[0]) - const nodes = planNodes(explained.Plan) - const indexNames = new Set( - nodes.flatMap(node => (typeof node['Index Name'] === 'string' ? [node['Index Name']] : [])) - ) - if (profile.startsWith('high-')) { - expect(indexNames, `${profile}:grant-index`).toContain( - 'gfs_grants_subject_resource_catalog_idx' - ) - expect(indexNames, `${profile}:share-index`).toContain( - 'gfs_shares_subject_resource_catalog_idx' - ) - } - const perStreamTake = Math.max(...requests.map(request => request.take), 0) - const rowEnvelope = requests.length * perStreamTake - const relationWork = (relation: string) => - nodes - .filter(node => node['Relation Name'] === relation) - .reduce( - (total, node) => - total + - numberField(node, 'Actual Loops') * - (numberField(node, 'Actual Rows') + numberField(node, 'Rows Removed by Filter')), - 0 - ) - expect(relationWork('gfs_grants'), `${profile}:grant-work`).toBeLessThanOrEqual(rowEnvelope) - expect(relationWork('gfs_shares'), `${profile}:share-work`).toBeLessThanOrEqual(rowEnvelope) - if (profile.startsWith('high-')) { - expect(relationWork('gfs_resources'), `${profile}:resource-work`).toBeLessThanOrEqual( - rowEnvelope - ) - } - const sharedBlocks = - numberField(explained.Plan, 'Shared Hit Blocks') + - numberField(explained.Plan, 'Shared Read Blocks') - expect(sharedBlocks, `${profile}:shared-blocks`).toBeLessThanOrEqual(rowEnvelope * 8 + 256) - for (const node of nodes) { - expect(String(node['Sort Method'] ?? ''), `${profile}:sort`).not.toContain('external') - expect(numberField(node, 'Temp Read Blocks'), `${profile}:temp-read`).toBe(0) - expect(numberField(node, 'Temp Written Blocks'), `${profile}:temp-write`).toBe(0) - } - console.info( - `[access-catalog-team-gfs-plan:${profile}] ${JSON.stringify({ - streams: requests.length, - perStreamTake, - rows: numberField(explained.Plan, 'Actual Rows'), - grantWork: relationWork('gfs_grants'), - shareWork: relationWork('gfs_shares'), - resourceWork: relationWork('gfs_resources'), - sharedBlocks, - tempReadBlocks: nodes.reduce( - (total, node) => total + numberField(node, 'Temp Read Blocks'), - 0 - ), - tempWrittenBlocks: nodes.reduce( - (total, node) => total + numberField(node, 'Temp Written Blocks'), - 0 - ), - planningMs: Number(explained['Planning Time'] ?? 0), - executionMs: Number(explained['Execution Time'] ?? 0), - indexes: [...indexNames].sort(), - })}` - ) - } - - async function measureTeamGfsTopK( - profile: string, - teamIds: readonly string[], - take: number - ): Promise { - let statementCount = 0 - const merged = await collectTeamGfsTopK({ - streams: teamGfsRequests(teamIds, 1).map(({ kind, subjectId, afterId }) => ({ - kind, - subjectId, - afterId, - })), - take, - read: async requests => { - statementCount += 1 - const result = await databasePool.query(teamGfsTopKSql.streamHead, [ - JSON.stringify( - requests.map(request => ({ - kind: request.kind, - subject_id: request.subjectId, - after_id: request.afterId, - take: request.take, - })) - ), - ]) - const lastByStream = new Map() - for (const row of result.rows as Array>) { - lastByStream.set(`${row.kind}:${row.subject_id}`, row.logical_id) - } - return (result.rows as Array>).map(row => ({ - kind: row.kind as 'grant' | 'share', - subjectId: row.subject_id, - logicalId: row.logical_id, - batchLast: lastByStream.get(`${row.kind}:${row.subject_id}`) === row.logical_id, - })) - }, - }) - const expected = await databasePool.query<{ logical_id: string }>( - `SELECT resource_id::text AS logical_id - FROM ( - SELECT grant_row.resource_id - FROM gfs_grants grant_row - WHERE grant_row.subject_type = 'team' - AND grant_row.subject_id = ANY($1::text[]) - UNION - SELECT share.resource_id - FROM gfs_shares share - WHERE share.subject_type = 'team' - AND share.subject_id = ANY($1::text[]) - ) authorized - JOIN gfs_resources resource USING (resource_id) - WHERE resource.deleted_at IS NULL - ORDER BY resource_id - LIMIT $2`, - [teamIds, take + 1] - ) - expect(merged.logicalIds, `${profile}:composition`).toEqual( - expected.rows.map(row => row.logical_id) - ) - expect(statementCount, `${profile}:statement-envelope`).toBeLessThanOrEqual(take + 2) - console.info( - `[access-catalog-team-gfs-merge:${profile}] ${JSON.stringify({ - memberships: teamIds.length, - take, - statements: statementCount, - candidates: merged.logicalIds.length, - })}` - ) - } - - async function analyzeAndExplain(profile: string): Promise { - await databasePool.query(`ANALYZE`) - const metrics: Array> = [] - const profileIndexes = new Set() - for (const family of CATALOG_FAMILIES) { - const result = await databasePool.query( - `EXPLAIN (ANALYZE, BUFFERS, WAL, FORMAT JSON) ${CATALOG_KEY_SQL[family]}`, - [ - userId, - '', - environmentId, - 101, - config.hostsNamespace, - config.contextsNamespace, - '', - '{}', - null, - ] - ) - const explained = queryPlan(result.rows[0]) - const nodes = planNodes(explained.Plan) - const boundedEnvelope = armCounts[family] * 101 - const boundedNodes = nodes.filter(node => - /Sort|Aggregate|Unique/.test(String(node['Node Type'] ?? '')) - ) - expect( - numberField(explained.Plan, 'Actual Rows'), - `${profile}:${family}:rows` - ).toBeLessThanOrEqual(101) - for (const node of boundedNodes) { - expect( - numberField(node, 'Actual Rows'), - `${profile}:${family}:${node['Node Type']}` - ).toBeLessThanOrEqual(boundedEnvelope) - expect(String(node['Sort Method'] ?? '')).not.toContain('external') - } - const indexNames = nodes - .flatMap(node => (typeof node['Index Name'] === 'string' ? [node['Index Name']] : [])) - .sort() - indexNames.forEach(indexName => profileIndexes.add(indexName)) - metrics.push({ - family, - rows: numberField(explained.Plan, 'Actual Rows'), - planningMs: Number(explained['Planning Time'] ?? 0), - executionMs: Number(explained['Execution Time'] ?? 0), - sharedHitBlocks: nodes.reduce( - (total, node) => total + numberField(node, 'Shared Hit Blocks'), - 0 - ), - sharedReadBlocks: nodes.reduce( - (total, node) => total + numberField(node, 'Shared Read Blocks'), - 0 - ), - tempReadBlocks: nodes.reduce( - (total, node) => total + numberField(node, 'Temp Read Blocks'), - 0 - ), - tempWrittenBlocks: nodes.reduce( - (total, node) => total + numberField(node, 'Temp Written Blocks'), - 0 - ), - walRecords: nodes.reduce((total, node) => total + numberField(node, 'WAL Records'), 0), - indexes: indexNames, - }) - } - if (profile === 'high-cardinality') { - // The relationship primary key covers source lookups; P-12 removed the - // duplicate source index. - for (const requiredIndex of ['operational_resource_index_pkey']) { - expect(profileIndexes.has(requiredIndex), requiredIndex).toBe(true) - } - } - console.info(`[access-catalog-plan:${profile}] ${JSON.stringify(metrics)}`) - } - - it('measures every bounded producer on direct, typical, and high-cardinality data', async () => { - await seedDirect(1, DIRECT_ONLY_RESOURCES) - await analyzeAndExplain('direct-only') - - await seedTeams(allTeamIds.slice(0, TYPICAL_TEAMS), TYPICAL_RESOURCES_PER_TEAM) - await seedDuplicateTeamGfs(allTeamIds.slice(0, TYPICAL_TEAMS)) - await analyzeAndExplain('typical-multi-team') - await explainTeamGfs( - 'typical-multi-team', - teamGfsRequests(allTeamIds.slice(0, TYPICAL_TEAMS), 8) - ) - await measureTeamGfsTopK('typical-multi-team', allTeamIds.slice(0, TYPICAL_TEAMS), 2) - - await seedDirect(DIRECT_ONLY_RESOURCES + 1, HIGH_DIRECT_RESOURCES) - await seedTeams(allTeamIds.slice(TYPICAL_TEAMS), HIGH_RESOURCES_PER_TEAM) - await seedDuplicateTeamGfs(allTeamIds) - await analyzeAndExplain('high-cardinality') - await explainTeamGfs('high-dense-and-duplicate', teamGfsRequests(allTeamIds, 8)) - await explainTeamGfs('high-duplicate-saturated', teamGfsRequests(allTeamIds, 2)) - await explainTeamGfs( - 'high-sparse', - teamGfsRequests( - [allTeamIds[0]!, ...Array.from({ length: HIGH_TEAMS - 1 }, () => randomUUID())], - 2 - ) - ) - await measureTeamGfsTopK('high-duplicate-saturated', allTeamIds, 2) - await measureTeamGfsTopK('high-maximum-page', allTeamIds, 100) - }, 30_000) - - it('bounds coordinator query count and composes keyset pages', async () => { - const observation = new CatalogQueryObservation() - const measuredTransaction = async (work: (db: DbClient) => Promise): Promise => { - const client = (await databasePool.connect()) as PoolClient - const measured: DbClient = { - query: async (text, values) => { - observation.observe(text) - return client.query(text, values) - }, - } - try { - await client.query('BEGIN') - const value = await work(measured) - await client.query('COMMIT') - return value - } catch (error) { - await client.query('ROLLBACK') - throw error - } finally { - client.release() - } - } - - const first = await buildAccessCatalog( - { session, families: CATALOG_FAMILIES, limit: 100 }, - { - transaction: measuredTransaction, - teamGfsMembershipAdmissionLimit: HIGH_TEAMS, - } - ) - expect(first.items).toHaveLength(100) - expect(first.nextCursor).not.toBeNull() - expect(observation.workCount).toBeLessThanOrEqual(40) - expect(observation.unexpected).toEqual([]) - - const nextObservation = new CatalogQueryObservation() - const second = await buildAccessCatalog( - { session, families: CATALOG_FAMILIES, limit: 100, cursor: first.nextCursor }, - { - transaction: async work => { - const client = (await databasePool.connect()) as PoolClient - const measured: DbClient = { - query: async (text, values) => { - nextObservation.observe(text) - return client.query(text, values) - }, - } - try { - await client.query('BEGIN') - const value = await work(measured) - await client.query('COMMIT') - return value - } catch (error) { - await client.query('ROLLBACK') - throw error - } finally { - client.release() - } - }, - teamGfsMembershipAdmissionLimit: HIGH_TEAMS, - } - ) - expect(second.items).toHaveLength(100) - expect(nextObservation.workCount).toBeLessThanOrEqual(40) - expect(nextObservation.unexpected).toEqual([]) - const firstIds = new Set(first.items.map(item => item.resource.canonicalId)) - expect(second.items.every(item => !firstIds.has(item.resource.canonicalId))).toBe(true) - - await databasePool.query( - `UPDATE operational_catalog_source_state - SET generation = generation + 1, resource_version = '2' - WHERE environment_id = $1 AND source_family = 'context'`, - [environmentId] - ) - await expect( - buildAccessCatalog( - { session, families: CATALOG_FAMILIES, limit: 100, cursor: first.nextCursor }, - { - transaction: measuredTransaction, - teamGfsMembershipAdmissionLimit: HIGH_TEAMS, - } - ) - ).rejects.toBeInstanceOf(AccessCatalogCursorError) - }) -}) diff --git a/control-api/test/accessCatalogProperties.test.ts b/control-api/test/accessCatalogProperties.test.ts deleted file mode 100644 index 93d24bc542..0000000000 --- a/control-api/test/accessCatalogProperties.test.ts +++ /dev/null @@ -1,584 +0,0 @@ -import { describe, expect, it } from 'vitest' -import fc from 'fast-check' -import { createHash } from 'node:crypto' -import { - AccessCatalogMergeError, - mergeCatalogCandidateStreams, - mergeHydratedCatalogResources, -} from '../src/services/access/accessCatalogMerge.js' -import { - AccessBudgetExceededError, - AccessExecutionBudget, - AccessExecutionCancelledError, -} from '../src/services/access/accessExecutionBudget.js' -import type { - AccessPath, - AccessPathBehavior, - AccessPathSeed, - BehaviorDimension, -} from '../src/services/access/accessPath.js' -import { selectEquivalentAccessPath } from '../src/services/access/accessPath.js' -import { - canonicalAccessPathSeeds, - canonicalAuthorizationRelationships, - revisionOfValues, -} from '../src/services/access/authorizationRevision.js' -import { - type AccessCapability, - normalizeAccessCapabilities, -} from '../src/services/access/capabilityRegistry.js' -import { - CATALOG_FAMILIES, - type CatalogFamily, - type CatalogIdentityCandidate, - type CatalogKey, - type CatalogRelationship, - type HydratedCatalogResource, - catalogKey, - catalogKeyEquals, - compareCatalogKey, -} from '../src/services/access/catalogContracts.js' -import { canonicalResourceIdentity } from '../src/services/access/resourceIdentity.js' - -const environmentId = 'cluster.local/evenfire' -const capabilities: readonly AccessCapability[] = [ - 'user.profile.read', - 'team.read', - 'host.read', - 'context.read', - 'workflow.read', - 'notification.read', -] - -function identifier(value: number): string { - return `id-${String(value).padStart(5, '0')}` -} - -function compareTextOracle(left: string, right: string): number { - const encoder = new TextEncoder() - const leftBytes = encoder.encode(left) - const rightBytes = encoder.encode(right) - for (let index = 0; index < Math.min(leftBytes.length, rightBytes.length); index += 1) { - if (leftBytes[index] !== rightBytes[index]) return leftBytes[index]! - rightBytes[index]! - } - return leftBytes.length - rightBytes.length -} - -function compareKeyOracle(left: CatalogKey, right: CatalogKey): number { - return ( - compareTextOracle(left[0], right[0]) || - compareTextOracle(left[1], right[1]) || - compareTextOracle(left[2], right[2]) - ) -} - -function canonicalValueOracle(value: unknown): string { - if (value === undefined) return 'null' - if (value === null || typeof value !== 'object') return JSON.stringify(value) - if (Array.isArray(value)) return `[${value.map(canonicalValueOracle).join(',')}]` - return `{${Object.entries(value as Record) - .sort(([left], [right]) => compareTextOracle(left, right)) - .map(([key, item]) => `${JSON.stringify(key)}:${canonicalValueOracle(item)}`) - .join(',')}}` -} - -const canonicalBehavior: AccessPathBehavior = Object.freeze({ - capabilities: Object.freeze(['team.read', 'user.profile.read']), - budget: Object.freeze({ state: 'known', value: null }), - credentialPolicy: Object.freeze({ state: 'known', value: null }), - approvalPolicy: Object.freeze({ state: 'known', value: null }), - filesystemScope: Object.freeze({ state: 'known', value: null }), - runtime: Object.freeze({ state: 'known', value: null }), - providerModelPolicy: Object.freeze({ state: 'known', value: null }), - audit: Object.freeze({ state: 'known', value: null }), -}) - -const catalogTextArbitrary = fc - .array( - fc.constantFrom('a', 'z', 'A', 'Z', '0', '9', '-', '_', '.', '/', 'é', 'e\u0301', 'Ω', '😀'), - { - minLength: 1, - maxLength: 16, - } - ) - .map(parts => parts.join('')) - -function uuid(value: number): string { - return `00000000-0000-4000-8000-${String(value).padStart(12, '0')}` -} - -const candidateArbitrary = fc - .record({ - family: fc.constantFrom(...CATALOG_FAMILIES), - logicalId: catalogTextArbitrary, - validUntil: fc.option( - fc - .integer({ min: 1, max: 20 }) - .map(day => `2030-01-${String(day).padStart(2, '0')}T00:00:00.000Z`), - { nil: null } - ), - }) - .map(({ family, logicalId, validUntil }): CatalogIdentityCandidate => { - return Object.freeze({ - key: catalogKey(environmentId, family, logicalId), - canonicalId: `${family}:${logicalId}`, - validUntil, - }) - }) - -const candidateStreamsArbitrary = fc - .array( - fc.uniqueArray(candidateArbitrary, { - maxLength: 30, - selector: candidate => JSON.stringify(candidate.key), - }), - { maxLength: 8 } - ) - .map(candidateLists => - candidateLists.map((candidates, index) => ({ - streamId: `stream-${index}`, - candidates: [...candidates].sort((left, right) => compareKeyOracle(left.key, right.key)), - })) - ) - -function expectedCandidateUnion( - streams: readonly Readonly<{ candidates: readonly CatalogIdentityCandidate[] }>[], - maximumOutputs = Number.MAX_SAFE_INTEGER -): CatalogIdentityCandidate[] { - const byKey = new Map() - for (const candidate of streams.flatMap(stream => stream.candidates)) { - const key = JSON.stringify(candidate.key) - const prior = byKey.get(key) - const validUntil = [prior?.validUntil, candidate.validUntil] - .filter((value): value is string => Boolean(value)) - .sort()[0] - byKey.set(key, { ...candidate, validUntil: validUntil ?? null }) - } - return [...byKey.values()] - .sort((left, right) => compareKeyOracle(left.key, right.key)) - .slice(0, maximumOutputs) -} - -const dimensionArbitrary: fc.Arbitrary = fc.oneof( - fc.constant({ state: 'unknown' } as const), - fc.option(fc.string({ maxLength: 24 }), { nil: null }).map(value => ({ - state: 'known' as const, - value, - })) -) - -const behaviorArbitrary: fc.Arbitrary = fc.record({ - capabilities: fc.uniqueArray(fc.constantFrom(...capabilities), { - maxLength: capabilities.length, - }), - budget: dimensionArbitrary, - credentialPolicy: dimensionArbitrary, - approvalPolicy: dimensionArbitrary, - filesystemScope: dimensionArbitrary, - runtime: dimensionArbitrary, - providerModelPolicy: dimensionArbitrary, - audit: dimensionArbitrary, -}) - -const pathArbitrary: fc.Arbitrary = fc - .record({ - kind: fc.constantFrom<'direct' | 'team'>('direct', 'team'), - grant: fc.integer({ min: 0, max: 100 }), - team: fc.integer({ min: 1, max: 20 }), - role: fc.constantFrom<'admin' | 'inviter' | 'member'>('admin', 'inviter', 'member'), - behavior: behaviorArbitrary, - }) - .map(({ kind, grant, team, role, behavior }) => - kind === 'direct' - ? { kind, grantId: `direct-${grant}`, behavior } - : { kind, grantId: `team-${grant}`, teamId: uuid(team), currentRole: role, behavior } - ) - -const relationshipArbitrary: fc.Arbitrary = fc - .record({ - type: fc.constantFrom('mounted_by', 'served_by', 'created_from'), - target: fc.integer({ min: 0, max: 100 }), - instance: fc.option(fc.integer({ min: 0, max: 30 }), { nil: undefined }), - }) - .map(({ type, target, instance }) => ({ - type, - targetResourceId: `target:${identifier(target)}`, - ...(instance === undefined ? {} : { instanceId: `edge-${instance}` }), - })) - -function hydrated(input: { - id: number - displayName?: string - paths: readonly AccessPathSeed[] - relationships: readonly CatalogRelationship[] - validUntil?: string | null -}): HydratedCatalogResource { - const logicalId = uuid(input.id) - return { - key: catalogKey(environmentId, 'user', logicalId), - resource: canonicalResourceIdentity({ - environmentId, - type: 'user', - logicalId, - displayName: input.displayName ?? `User ${input.id}`, - }), - accessPaths: input.paths, - relationships: input.relationships, - authorizationResourceRevision: 'resource-1', - authorizationSourceRevision: 'source-1', - authorizationRelationshipsRevision: 'relationships-1', - validUntil: input.validUntil ?? null, - } -} - -describe('aggregate access catalog properties', () => { - it('uses one unsigned UTF-8 order for every public authorization identity canonicalizer', () => { - const directZ: AccessPathSeed = { - kind: 'direct', - grantId: 'Z', - behavior: canonicalBehavior, - } - const directUmlaut: AccessPathSeed = { - kind: 'direct', - grantId: 'ä', - behavior: canonicalBehavior, - } - expect(canonicalAccessPathSeeds([directUmlaut, directZ]).map(seed => seed.grantId)).toEqual([ - 'Z', - 'ä', - ]) - - expect( - canonicalAuthorizationRelationships([ - { type: 'ä', targetResourceId: 'resource' }, - { type: 'Z', targetResourceId: 'resource' }, - ]).map(relationship => relationship.type) - ).toEqual(['Z', 'ä']) - - const values = [{ ä: '\uE000', Z: '😀' }, '\uE000', '😀'] - const expectedRevision = createHash('sha256') - .update(`[${values.map(canonicalValueOracle).sort(compareTextOracle).join(',')}]`) - .digest('base64url') - expect(revisionOfValues(values)).toBe(expectedRevision) - - fc.assert( - fc.property( - fc.uniqueArray(fc.constantFrom(...capabilities), { maxLength: capabilities.length }), - values => { - const expected = [...values].sort(compareTextOracle) - expect(normalizeAccessCapabilities(values)).toEqual(expected) - expect(normalizeAccessCapabilities([...values].reverse())).toEqual(expected) - } - ), - { numRuns: 200 } - ) - - const equivalentPaths: AccessPath[] = [ - { - id: 'ap1-umlaut', - kind: 'direct', - grantId: 'ä', - authorizationRevision: 'ar1', - behavior: canonicalBehavior, - }, - { - id: 'ap1-z', - kind: 'direct', - grantId: 'Z', - authorizationRevision: 'ar1', - behavior: canonicalBehavior, - }, - ] - expect(selectEquivalentAccessPath(equivalentPaths)?.grantId).toBe('Z') - }) - - it('matches an independent UTF-8 byte oracle with exact identity', () => { - fc.assert( - fc.property(candidateArbitrary, candidateArbitrary, (left, right) => { - expect(Math.sign(compareCatalogKey(left.key, right.key))).toBe( - Math.sign(compareKeyOracle(left.key, right.key)) - ) - expect(catalogKeyEquals(left.key, right.key)).toBe( - left.key[0] === right.key[0] && - left.key[1] === right.key[1] && - left.key[2] === right.key[2] - ) - }), - { numRuns: 1_000 } - ) - }) - - it('k-way merge is ordered, bounded, deduplicating, and producer-order independent', () => { - fc.assert( - fc.property( - candidateStreamsArbitrary, - fc.integer({ min: 0, max: 50 }), - (streams, maximumOutputs) => { - const expected = expectedCandidateUnion(streams, maximumOutputs) - expect(mergeCatalogCandidateStreams(streams, maximumOutputs)).toEqual(expected) - expect(mergeCatalogCandidateStreams([...streams].reverse(), maximumOutputs)).toEqual( - expected - ) - } - ), - { numRuns: 300 } - ) - }) - - it('composes bounded pages without identity loss or duplication', () => { - const perFamilyStreams = fc.subarray([...CATALOG_FAMILIES], { minLength: 1 }).chain(families => - fc - .tuple( - ...families.map(family => - fc.uniqueArray(catalogTextArbitrary, { maxLength: 25 }).map(values => ({ - streamId: family, - candidates: values - .map(logicalId => { - return { - key: catalogKey(environmentId, family, logicalId), - canonicalId: `${family}:${logicalId}`, - validUntil: null, - } - }) - .sort((left, right) => compareKeyOracle(left.key, right.key)), - })) - ) - ) - .map(streams => streams) - ) - - fc.assert( - fc.property(perFamilyStreams, fc.integer({ min: 1, max: 20 }), (streams, pageSize) => { - const expected = expectedCandidateUnion(streams) - const remaining = streams.map(stream => ({ - streamId: stream.streamId, - candidates: [...stream.candidates], - })) - const composed: CatalogIdentityCandidate[] = [] - while (remaining.some(stream => stream.candidates.length > 0)) { - const page = mergeCatalogCandidateStreams(remaining, pageSize + 1).slice(0, pageSize) - expect(page.length).toBeGreaterThan(0) - composed.push(...page) - for (const stream of remaining) { - const emitted = page.filter(candidate => candidate.key[1] === stream.streamId) - const afterKey = emitted.at(-1)?.key - if (afterKey) { - stream.candidates = stream.candidates.filter( - candidate => compareKeyOracle(candidate.key, afterKey) > 0 - ) - } - } - } - expect(composed).toEqual(expected) - }), - { numRuns: 200 } - ) - }) - - it('same-ID fragments preserve every distinct path and relationship exactly once', () => { - fc.assert( - fc.property( - fc.array(pathArbitrary, { maxLength: 40 }), - fc.array(relationshipArbitrary, { maxLength: 60 }), - fc.nat(), - fc.boolean(), - (paths, relationships, splitSeed, reverse) => { - const duplicatedPaths = [...paths, ...paths.slice(0, paths.length % 5)] - const duplicatedRelationships = [ - ...relationships, - ...relationships.slice(0, relationships.length % 7), - ] - const pathSplit = duplicatedPaths.length === 0 ? 0 : splitSeed % duplicatedPaths.length - const relationshipSplit = - duplicatedRelationships.length === 0 ? 0 : splitSeed % duplicatedRelationships.length - const fragments = [ - hydrated({ - id: 1, - paths: duplicatedPaths.slice(0, pathSplit), - relationships: duplicatedRelationships.slice(0, relationshipSplit), - }), - hydrated({ - id: 1, - paths: duplicatedPaths.slice(pathSplit), - relationships: duplicatedRelationships.slice(relationshipSplit), - }), - ] - const result = mergeHydratedCatalogResources(reverse ? fragments.reverse() : fragments) - const value = [...result.values()][0] - expect(value.accessPaths).toEqual(canonicalAccessPathSeeds(duplicatedPaths)) - expect(value.relationships).toEqual( - canonicalAuthorizationRelationships(duplicatedRelationships) - ) - expect(mergeHydratedCatalogResources([...result.values()])).toEqual(result) - } - ), - { numRuns: 300 } - ) - }) - - it('preserves equivalent provenance and every behavior-distinct path', () => { - fc.assert( - fc.property( - behaviorArbitrary, - fc.string({ minLength: 1, maxLength: 24 }), - (behavior, scope) => { - const direct: AccessPathSeed = { kind: 'direct', grantId: 'direct', behavior } - const team: AccessPathSeed = { - kind: 'team', - grantId: 'team', - teamId: uuid(2), - currentRole: 'member', - behavior, - } - const distinctScope: AccessPathSeed = { - kind: 'direct', - grantId: 'direct', - behavior: { - ...behavior, - filesystemScope: { state: 'known', value: scope }, - }, - } - const value = [ - ...mergeHydratedCatalogResources([ - hydrated({ id: 1, paths: [direct, team, distinctScope], relationships: [] }), - ]).values(), - ][0] - expect(value.accessPaths).toHaveLength( - canonicalAccessPathSeeds([direct, team, distinctScope]).length - ) - expect(value.accessPaths).toEqual(canonicalAccessPathSeeds([direct, team, distinctScope])) - } - ), - { numRuns: 200 } - ) - }) - - it('never merges same-label resources with different immutable IDs', () => { - fc.assert( - fc.property( - fc.integer({ min: 1, max: 1_000 }), - fc.integer({ min: 1_001, max: 2_000 }), - (left, right) => { - const result = mergeHydratedCatalogResources([ - hydrated({ id: left, displayName: 'Same name', paths: [], relationships: [] }), - hydrated({ id: right, displayName: 'Same name', paths: [], relationships: [] }), - ]) - expect(result).toHaveLength(2) - } - ), - { numRuns: 100 } - ) - }) - - it('rejects conflicting producer identity or authority revisions', () => { - const value = hydrated({ id: 1, paths: [], relationships: [] }) - expect(() => - mergeHydratedCatalogResources([ - value, - { ...value, authorizationResourceRevision: 'resource-2' }, - ]) - ).toThrow(AccessCatalogMergeError) - expect(() => - mergeCatalogCandidateStreams( - [ - { streamId: 'a', candidates: [{ ...candidate('user', 1), canonicalId: 'user:a' }] }, - { streamId: 'b', candidates: [{ ...candidate('user', 1), canonicalId: 'user:b' }] }, - ], - 2 - ) - ).toThrow(AccessCatalogMergeError) - }) - - it('recovers a complete deterministic union when a partial producer returns', () => { - fc.assert( - fc.property(candidateStreamsArbitrary, streams => { - const missing = streams.slice(1) - const partial = mergeCatalogCandidateStreams(missing, 10_000) - const recovered = mergeCatalogCandidateStreams(streams, 10_000) - expect( - partial.every(value => recovered.some(item => item.canonicalId === value.canonicalId)) - ).toBe(true) - expect(recovered).toEqual(expectedCandidateUnion(streams)) - }), - { numRuns: 200 } - ) - }) -}) - -function candidate(family: CatalogFamily, value: number): CatalogIdentityCandidate { - const logicalId = identifier(value) - return { - key: catalogKey(environmentId, family, logicalId), - canonicalId: `${family}:${logicalId}`, - validUntil: null, - } -} - -describe('execution-budget properties', () => { - it('rejects exactly when an arbitrary cumulative charge crosses its limit', () => { - fc.assert( - fc.property( - fc.integer({ min: 1, max: 100 }), - fc.array(fc.integer({ min: 1, max: 20 }), { minLength: 1, maxLength: 30 }), - (capacity, charges) => { - const budget = AccessExecutionBudget.create('catalog', { - limits: { accessPaths: capacity }, - }) - let consumed = 0 - let rejected = false - for (const amount of charges) { - if (rejected) break - if (consumed + amount > capacity) { - expect(() => budget.charge({ kind: 'accessPaths', amount })).toThrow( - AccessBudgetExceededError - ) - rejected = true - } else { - budget.charge({ kind: 'accessPaths', amount }) - consumed += amount - } - } - expect(budget.remaining('accessPaths')).toBe(capacity - consumed) - budget.close() - } - ), - { numRuns: 200 } - ) - }) - - it('never publishes late producer results after arbitrary cancellation', async () => { - await fc.assert( - fc.asyncProperty(fc.integer({ min: 1, max: 8 }), async producerCount => { - const budget = AccessExecutionBudget.create('catalog', { - limits: { producerCalls: producerCount, producerConcurrency: Math.min(4, producerCount) }, - }) - const completions: Array<() => void> = [] - const published: number[] = [] - const work = Array.from({ length: producerCount }, (_, index) => - budget - .runProducer( - () => - new Promise(resolve => { - completions.push(() => resolve(index)) - }) - ) - .then(value => published.push(value)) - ) - await Promise.resolve() - budget.cancel() - for (const complete of completions) complete() - const results = await Promise.allSettled(work) - expect(results.every(result => result.status === 'rejected')).toBe(true) - expect( - results.every( - result => - result.status === 'rejected' && result.reason instanceof AccessExecutionCancelledError - ) - ).toBe(true) - expect(published).toEqual([]) - budget.close() - }), - { numRuns: 30 } - ) - }) -}) diff --git a/control-api/test/accessCatalogQueryObservation.test.ts b/control-api/test/accessCatalogQueryObservation.test.ts deleted file mode 100644 index c5108ee49f..0000000000 --- a/control-api/test/accessCatalogQueryObservation.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - CatalogQueryObservation, - classifyMeasuredCatalogStatement, -} from './accessCatalogQueryObservation.js' - -describe('catalog query observation', () => { - it('excludes only exact access transaction setup statements', () => { - expect( - classifyMeasuredCatalogStatement( - ' SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY ' - ).classification - ).toBe('transaction_setup') - expect( - classifyMeasuredCatalogStatement("SELECT set_config('statement_timeout', $1, true)") - .classification - ).toBe('transaction_setup') - }) - - it('counts work containing a former regex token and surfaces unknown statements', () => { - const observation = new CatalogQueryObservation() - expect(observation.observe("SELECT set_config('statement_timeout', $1, true); SELECT 1")).toBe( - true - ) - expect(observation.observe('SET TRANSACTION READ ONLY')).toBe(true) - expect(observation.workCount).toBe(2) - expect(observation.unexpected.map(statement => statement.text)).toEqual([ - 'SET TRANSACTION READ ONLY', - ]) - }) -}) diff --git a/control-api/test/accessCatalogQueryObservation.ts b/control-api/test/accessCatalogQueryObservation.ts deleted file mode 100644 index 64ff43600d..0000000000 --- a/control-api/test/accessCatalogQueryObservation.ts +++ /dev/null @@ -1,43 +0,0 @@ -const TRANSACTION_SETUP = new Set([ - 'SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY', - "SELECT set_config('statement_timeout', $1, true)", -]) - -export type MeasuredCatalogStatement = Readonly<{ - text: string - classification: 'transaction_setup' | 'catalog_work' | 'unexpected_work' -}> - -function normalize(text: string): string { - return text.trim().replaceAll(/\s+/g, ' ') -} - -export function classifyMeasuredCatalogStatement(text: string): MeasuredCatalogStatement { - const normalized = normalize(text) - if (TRANSACTION_SETUP.has(normalized)) { - return { text: normalized, classification: 'transaction_setup' } - } - if (/^(?:WITH|SELECT|EXPLAIN)\b/i.test(normalized)) { - return { text: normalized, classification: 'catalog_work' } - } - return { text: normalized, classification: 'unexpected_work' } -} - -export class CatalogQueryObservation { - private readonly measured: MeasuredCatalogStatement[] = [] - - observe(text: string): boolean { - const statement = classifyMeasuredCatalogStatement(text) - this.measured.push(statement) - return statement.classification !== 'transaction_setup' - } - - get workCount(): number { - return this.measured.filter(statement => statement.classification !== 'transaction_setup') - .length - } - - get unexpected(): readonly MeasuredCatalogStatement[] { - return this.measured.filter(statement => statement.classification === 'unexpected_work') - } -} diff --git a/control-api/test/accessCatalogShadow.realPostgres.integration.test.ts b/control-api/test/accessCatalogShadow.realPostgres.integration.test.ts deleted file mode 100644 index bd9d781480..0000000000 --- a/control-api/test/accessCatalogShadow.realPostgres.integration.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { randomBytes, randomUUID } from 'node:crypto' -import { Pool } from 'pg' -import { type DbClient, initDb } from '../src/db.js' -import { buildAccessCatalog } from '../src/services/access/accessCatalogCoordinator.js' -import { compareAccessCatalogShadow } from '../src/services/access/accessCatalogShadow.js' -import { - runAccessDatabaseQuery, - withAccessDatabaseTransaction, -} from '../src/services/access/accessDatabaseQuery.js' -import { AccessExecutionBudget } from '../src/services/access/accessExecutionBudget.js' -import type { ExternalSessionAuthorityContext } from '../src/services/auth/externalSessionAuthentication.js' -import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' - -const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL -const describeRealPostgres = adminUrl ? describe : describe.skip - -function databaseUrl(baseUrl: string, database: string): string { - const value = new URL(baseUrl) - value.pathname = `/${database}` - return value.toString() -} - -function quoteIdentifier(value: string): string { - return `"${value.replace(/"/g, '""')}"` -} - -describeRealPostgres('aggregate shadow physical statement budget on real PostgreSQL', () => { - const database = `control_api_shadow_budget_${randomBytes(6).toString('hex')}` - const memberUserId = randomUUID() - const noMembershipUserId = randomUUID() - const memberTeamIds = Array.from({ length: 100 }, () => randomUUID()) - const resourceIds = Array.from({ length: 100 }, () => randomUUID()) - const memberSession: ExternalSessionAuthorityContext = { - contract: 'v1', - userId: memberUserId, - tokenHash: 'shadow-member-token', - issuedAt: Math.floor(Date.now() / 1_000), - authGeneration: 1, - } - const noMembershipSession: ExternalSessionAuthorityContext = { - contract: 'v1', - userId: noMembershipUserId, - tokenHash: 'shadow-no-membership-token', - issuedAt: Math.floor(Date.now() / 1_000), - authGeneration: 1, - } - let adminPool: Pool - let databasePool: Pool - - beforeAll(async () => { - adminPool = new Pool({ connectionString: adminUrl }) - await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) - databasePool = new Pool({ connectionString: databaseUrl(adminUrl!, database) }) - await initDb({ connect: () => databasePool.connect() }) - await databasePool.query( - `INSERT INTO users(id, email, name) VALUES - ($1, $2, 'Shadow Member'), ($3, $4, 'Shadow Direct User')`, - [ - memberUserId, - `${memberUserId}@example.test`, - noMembershipUserId, - `${noMembershipUserId}@example.test`, - ] - ) - for (let index = 0; index < memberTeamIds.length; index += 1) { - const teamId = memberTeamIds[index]! - const resourceId = resourceIds[index]! - await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, $2)`, [ - teamId, - `Shadow Team ${index}`, - ]) - await databasePool.query( - `INSERT INTO team_members(team_id, user_id, role, status) - VALUES ($1, $2, 'member', 'active')`, - [teamId, memberUserId] - ) - await databasePool.query( - `INSERT INTO gfs_resources(resource_id, drive, parent_resource_id, name, kind) - VALUES ($1, $2, CASE WHEN $4::integer = 0 THEN NULL ELSE $5::uuid END, $3, 'directory')`, - [ - resourceId, - `shadow-${database}`, - index === 0 ? '/' : `member-${index}`, - index, - resourceIds[0], - ] - ) - await databasePool.query( - `INSERT INTO gfs_grants(drive, resource_id, subject_type, subject_id, permissions) - VALUES ($1, $2, 'team', $3::text, ARRAY['read']::text[])`, - [`shadow-${database}`, resourceId, teamId] - ) - } - await databasePool.query( - `INSERT INTO gfs_grants(drive, resource_id, subject_type, subject_id, permissions) - VALUES ($1, $2, 'user', $3::text, ARRAY['read']::text[]), - ($1, $2, 'user', $4::text, ARRAY['read']::text[])`, - [`shadow-${database}`, resourceIds[0], memberUserId, noMembershipUserId] - ) - }) - - afterAll(async () => { - try { - await endPoolAndWaitForClients(databasePool) - if (!adminPool) return - await adminPool.query( - `SELECT pg_terminate_backend(pid) - FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) - } finally { - await adminPool?.end() - } - }) - - it('executes and charges a real shadow SQL statement only when capacity is reserved', async () => { - const budget = AccessExecutionBudget.create('catalog', { - limits: { databaseStatements: 109 }, - }) - let statements = 0 - try { - await expect( - compareAccessCatalogShadow( - { - session: { - contract: 'v1', - userId: '10000000-0000-4000-8000-000000000001', - tokenHash: 'shadow-test', - issuedAt: 1, - authGeneration: 1, - }, - family: 'team', - legacyLogicalIds: [], - legacyComplete: true, - }, - { - enabled: true, - budget, - buildCatalog: async (_input, options) => { - const query = await withAccessDatabaseTransaction( - options.budget!, - async db => { - statements += 1 - return runAccessDatabaseQuery( - db, - options.budget!, - 'SELECT 1 WHERE $1::int = 1', - [1], - { - chargeProducer: false, - } - ) - }, - { connectionPool: databasePool, mode: 'caller_configured' } - ) - expect(query.rows).toHaveLength(1) - return { - contractVersion: '2', - authorizationRevision: 'shadow', - sourceStateRevision: 'shadow', - complete: true, - partialErrors: [], - items: [], - nextCursor: null, - } - }, - } - ) - ).resolves.toBe('match') - expect(statements).toBe(1) - expect(budget.remaining('databaseStatements')).toBe(106) - } finally { - budget.close() - } - }) - - async function buildRealCatalog( - input: Parameters[0], - options: NonNullable[1]> - ) { - const budget = options.budget - if (!budget) throw new Error('shadow_budget_missing') - const transaction = (work: (db: DbClient) => Promise) => - withAccessDatabaseTransaction(budget, work, { - connectionPool: databasePool, - mode: 'caller_configured', - }) - return buildAccessCatalog(input, { ...options, transaction }) - } - - it('completes the 100-membership direct-GFS shadow and charges COMMIT', async () => { - const budget = AccessExecutionBudget.create('catalog', { - limits: { databaseStatements: 109 }, - teamGfsMembershipAdmissionLimit: 100, - }) - try { - await expect( - compareAccessCatalogShadow( - { - session: memberSession, - family: 'gfs_resource', - legacyLogicalIds: resourceIds, - legacyComplete: true, - }, - { enabled: true, budget, buildCatalog: buildRealCatalog } - ) - ).resolves.toBe('match') - expect(budget.remaining('databaseStatements')).toBe(0) - expect(42 - budget.remaining('producerCalls')).toBeLessThanOrEqual(6) - } finally { - budget.close() - } - }) - - it('completes a direct-GFS shadow for a user with no team memberships', async () => { - const budget = AccessExecutionBudget.create('catalog', { - limits: { databaseStatements: 109 }, - teamGfsMembershipAdmissionLimit: 100, - }) - try { - await expect( - compareAccessCatalogShadow( - { - session: noMembershipSession, - family: 'gfs_resource', - legacyLogicalIds: [resourceIds[0]!], - legacyComplete: true, - }, - { enabled: true, budget, buildCatalog: buildRealCatalog } - ) - ).resolves.toBe('match') - expect(budget.remaining('databaseStatements')).toBeGreaterThan(0) - expect(budget.remaining('databaseStatements')).toBeLessThan(109) - } finally { - budget.close() - } - }) -}) diff --git a/control-api/test/accessCatalogShadow.test.ts b/control-api/test/accessCatalogShadow.test.ts deleted file mode 100644 index d1dd554e15..0000000000 --- a/control-api/test/accessCatalogShadow.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { registry } from '../src/observability/metrics.js' -import { - type CatalogShadowOutcome, - compareAccessCatalogShadow, -} from '../src/services/access/accessCatalogShadow.js' -import { runAccessDatabaseQuery } from '../src/services/access/accessDatabaseQuery.js' -import { AccessExecutionBudget } from '../src/services/access/accessExecutionBudget.js' - -const session = { - contract: 'v1' as const, - userId: '10000000-0000-4000-8000-000000000001', - tokenHash: 'token-hash', - issuedAt: 1_900_000_000, - authGeneration: 1, -} - -function catalog(logicalIds: string[], complete = true, nextCursor: string | null = null) { - return { - contractVersion: '2' as const, - authorizationRevision: 'authorization-1', - sourceStateRevision: 'source-1', - complete, - partialErrors: [], - items: logicalIds.map(logicalId => ({ - resource: { - environmentId: 'cluster.local/evenfire', - type: 'team' as const, - canonicalId: `team:${logicalId}`, - logicalId, - displayName: logicalId, - resourceRevision: '1', - }, - relationships: [], - capabilities: ['team.read'], - accessPaths: [], - })), - nextCursor, - } -} - -async function outcome(legacy: string[], aggregate: string[]): Promise { - return compareAccessCatalogShadow( - { session, family: 'team', legacyLogicalIds: legacy, legacyComplete: true }, - { enabled: true, buildCatalog: vi.fn().mockResolvedValue(catalog(aggregate)) } - ) -} - -afterEach(() => { - vi.restoreAllMocks() -}) - -describe('aggregate access shadow comparison', () => { - it.each([ - [[], [], 'match'], - [['a'], ['a'], 'match'], - [['a'], ['a', 'b'], 'catalog_only'], - [['a', 'b'], ['a'], 'legacy_only'], - [['a'], ['b'], 'both_differ'], - ] as const)('classifies bounded canonical sets %#', async (legacy, aggregate, expected) => { - await expect(outcome([...legacy], [...aggregate])).resolves.toBe(expected) - }) - - it('does not compute parity for incomplete legacy or aggregate results', async () => { - const buildCatalog = vi.fn() - await expect( - compareAccessCatalogShadow( - { session, family: 'team', legacyLogicalIds: [], legacyComplete: false }, - { enabled: true, buildCatalog } - ) - ).resolves.toBe('skipped_legacy_incomplete') - expect(buildCatalog).not.toHaveBeenCalled() - - await expect( - compareAccessCatalogShadow( - { session, family: 'team', legacyLogicalIds: [], legacyComplete: true }, - { - enabled: true, - buildCatalog: vi.fn().mockResolvedValue(catalog([], false, 'c3.cursor.signature')), - } - ) - ).resolves.toBe('partial') - }) - - it('records only low-cardinality family, outcome, and direction labels', async () => { - await outcome(['legacy-secret-id'], ['catalog-secret-id']) - const metrics = await registry.metrics() - expect(metrics).toContain('aggregate_access_shadow_comparisons_total') - expect(metrics).toContain('family="team"') - expect(metrics).toContain('outcome="both_differ"') - expect(metrics).not.toContain('legacy-secret-id') - expect(metrics).not.toContain('catalog-secret-id') - }) - - it('reserves physical statement capacity for a database-consuming child build', async () => { - const budget = AccessExecutionBudget.create('catalog') - const query = vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }) - try { - await expect( - compareAccessCatalogShadow( - { session, family: 'team', legacyLogicalIds: [], legacyComplete: true }, - { - enabled: true, - budget, - buildCatalog: async (_input, options) => { - await runAccessDatabaseQuery({ query }, options.budget!, 'SELECT 1', [], { - chargeRows: false, - }) - return catalog([]) - }, - } - ) - ).resolves.toBe('match') - expect(query).toHaveBeenCalledTimes(1) - expect(budget.remaining('databaseStatements')).toBe(1_492) - } finally { - budget.close() - } - }) - - it('truthfully skips the shadow when the parent cannot reserve statements', async () => { - const budget = AccessExecutionBudget.create('catalog', { - limits: { databaseStatements: 7 }, - }) - const buildCatalog = vi.fn() - try { - await expect( - compareAccessCatalogShadow( - { session, family: 'team', legacyLogicalIds: [], legacyComplete: true }, - { enabled: true, budget, buildCatalog } - ) - ).resolves.toBe('skipped_capacity') - expect(buildCatalog).not.toHaveBeenCalled() - expect(budget.remaining('databaseStatements')).toBe(7) - } finally { - budget.close() - } - }) -}) diff --git a/control-api/test/accessCatalogShadowCallsites.test.ts b/control-api/test/accessCatalogShadowCallsites.test.ts deleted file mode 100644 index 8579dd2081..0000000000 --- a/control-api/test/accessCatalogShadowCallsites.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { readFileSync } from 'node:fs' -import { CATALOG_FAMILIES, type CatalogFamily } from '../src/services/access/catalogContracts.js' - -const SOURCES = [ - 'src/routes/external/users.ts', - 'src/routes/external/workflows/read.routes.ts', - 'src/routes/external/workflows/runs.routes.ts', - 'src/routes/external/notifications.routes.ts', - 'src/routes/external/gfs.ts', - 'src/routes/external/sharedFilesystems.ts', -] as const - -describe('aggregate shadow production call sites', () => { - it('maps every frozen PR 1 family from an actual legacy discovery root', () => { - const source = SOURCES.map(path => readFileSync(path, 'utf8')).join('\n') - const observed = new Set() - for (const family of CATALOG_FAMILIES) { - if (source.includes(`family: '${family}'`)) observed.add(family) - } - expect([...observed]).toEqual(CATALOG_FAMILIES) - }) - - it('keeps shadow scheduling out of authorization and response expressions', () => { - for (const path of SOURCES) { - const source = readFileSync(path, 'utf8') - expect(source).not.toMatch(/await\s+scheduleAccessCatalogShadow/u) - expect(source).not.toMatch(/res\.[a-z]+\([^)]*scheduleAccessCatalogShadow/u) - } - }) -}) diff --git a/control-api/test/accessCatalogShadowCapacity.test.ts b/control-api/test/accessCatalogShadowCapacity.test.ts deleted file mode 100644 index a574db5beb..0000000000 --- a/control-api/test/accessCatalogShadowCapacity.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { ACCESS_EXECUTION_LIMIT_CLAMPS } from '../src/services/access/accessExecutionBudget.js' - -const ORIGINAL_MODE = process.env.CONTROL_API_USER_ACCESS_CATALOG_MODE -const ORIGINAL_TEAM_GFS_ADMISSION = - process.env.CONTROL_API_USER_ACCESS_TEAM_GFS_MEMBERSHIP_ADMISSION_LIMIT - -afterEach(() => { - vi.unstubAllGlobals() - vi.resetModules() - if (ORIGINAL_MODE === undefined) { - delete process.env.CONTROL_API_USER_ACCESS_CATALOG_MODE - } else { - process.env.CONTROL_API_USER_ACCESS_CATALOG_MODE = ORIGINAL_MODE - } - if (ORIGINAL_TEAM_GFS_ADMISSION === undefined) { - delete process.env.CONTROL_API_USER_ACCESS_TEAM_GFS_MEMBERSHIP_ADMISSION_LIMIT - } else { - process.env.CONTROL_API_USER_ACCESS_TEAM_GFS_MEMBERSHIP_ADMISSION_LIMIT = - ORIGINAL_TEAM_GFS_ADMISSION - } -}) - -describe('aggregate access shadow scheduling capacity', () => { - it('reserves bounded capacity before deferring comparison work', async () => { - process.env.CONTROL_API_USER_ACCESS_CATALOG_MODE = 'shadow' - process.env.CONTROL_API_USER_ACCESS_TEAM_GFS_MEMBERSHIP_ADMISSION_LIMIT = '4' - vi.resetModules() - const deferred: Array<() => void> = [] - vi.stubGlobal('setImmediate', (callback: () => void) => { - deferred.push(callback) - return {} as NodeJS.Immediate - }) - const { scheduleAccessCatalogShadow } = - await import('../src/services/access/accessCatalogShadow.js') - const session = { - contract: 'v1' as const, - userId: '10000000-0000-4000-8000-000000000001', - tokenHash: 'token-hash', - issuedAt: 1_900_000_000, - authGeneration: 1, - } - - for (let index = 0; index < 1_000; index += 1) { - scheduleAccessCatalogShadow({ - session, - family: 'team', - legacyLogicalIds: [], - legacyComplete: true, - }) - } - - expect(deferred).toHaveLength(ACCESS_EXECUTION_LIMIT_CLAMPS.producerConcurrency) - }) - - it('reserves all 109 shadow statements before starting comparison work', async () => { - const [{ AccessExecutionBudget }, { compareAccessCatalogShadow }] = await Promise.all([ - import('../src/services/access/accessExecutionBudget.js'), - import('../src/services/access/accessCatalogShadow.js'), - ]) - const session = { - contract: 'v1' as const, - userId: '10000000-0000-4000-8000-000000000001', - tokenHash: 'token-hash', - issuedAt: 1_900_000_000, - authGeneration: 1, - } - const tooSmall = AccessExecutionBudget.create('catalog', { - limits: { databaseStatements: 108 }, - }) - const unusedBuild = vi.fn(async () => ({ - complete: true, - items: [], - nextCursor: null, - partialErrors: [], - })) - try { - await expect( - compareAccessCatalogShadow( - { session, family: 'gfs_resource', legacyLogicalIds: [], legacyComplete: true }, - { enabled: true, budget: tooSmall, buildCatalog: unusedBuild } - ) - ).resolves.toBe('skipped_capacity') - expect(unusedBuild).not.toHaveBeenCalled() - expect(tooSmall.remaining('databaseStatements')).toBe(108) - } finally { - tooSmall.close() - } - - const exact = AccessExecutionBudget.create('catalog', { - limits: { databaseStatements: 109 }, - }) - const observedChildLimits: number[] = [] - try { - await expect( - compareAccessCatalogShadow( - { session, family: 'gfs_resource', legacyLogicalIds: [], legacyComplete: true }, - { - enabled: true, - budget: exact, - buildCatalog: vi.fn(async (_input, options) => { - observedChildLimits.push(options.budget!.limits.databaseStatements) - return { complete: true, items: [], nextCursor: null, partialErrors: [] } - }), - } - ) - ).resolves.toBe('match') - expect(observedChildLimits).toEqual([109]) - expect(exact.remaining('databaseStatements')).toBe(109) - } finally { - exact.close() - } - }) -}) diff --git a/control-api/test/accessDatabaseCancellation.realPostgres.integration.test.ts b/control-api/test/accessDatabaseCancellation.realPostgres.integration.test.ts deleted file mode 100644 index adad69c91b..0000000000 --- a/control-api/test/accessDatabaseCancellation.realPostgres.integration.test.ts +++ /dev/null @@ -1,315 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' -import { randomBytes } from 'node:crypto' -import { Pool, type PoolClient } from 'pg' -import { - runAccessDatabaseQuery, - withAccessDatabaseTransaction, -} from '../src/services/access/accessDatabaseQuery.js' -import { - AccessBudgetExceededError, - AccessExecutionBudget, - AccessExecutionCancelledError, -} from '../src/services/access/accessExecutionBudget.js' -import { catalogQuery } from '../src/services/access/catalogProducerSupport.js' -import { OperationalAccessIndex } from '../src/services/access/operationalAccessIndex.js' -import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' -import { waitForDatabaseConnectionsToClose } from './realPostgresCleanup.ts' - -describe('physical access database statement accounting', () => { - it('charges before execution and rejects the statement after the configured limit', async () => { - const query = vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }) - const budget = AccessExecutionBudget.create('catalog', { - limits: { databaseStatements: 2 }, - }) - try { - await catalogQuery({ query }, budget, 'SELECT 1', []) - await catalogQuery({ query }, budget, 'SELECT 2', []) - await expect(catalogQuery({ query }, budget, 'SELECT 3', [])).rejects.toMatchObject({ - name: 'AccessBudgetExceededError', - limit: 'databaseStatements', - authorityRequired: true, - }) - expect(query).toHaveBeenCalledTimes(2) - expect(budget.remaining('databaseStatements')).toBe(0) - } finally { - budget.close() - } - }) - - it('accounts bounded internal statements without consuming additional logical producer calls', async () => { - const query = vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }) - const budget = AccessExecutionBudget.create('catalog', { - limits: { producerCalls: 1, databaseStatements: 2 }, - }) - try { - await catalogQuery({ query }, budget, 'SELECT 1', []) - await catalogQuery({ query }, budget, 'SELECT 2', [], { chargeProducer: false }) - expect(budget.remaining('producerCalls')).toBe(0) - expect(budget.remaining('databaseStatements')).toBe(0) - await expect( - catalogQuery({ query }, budget, 'SELECT 3', [], { chargeProducer: false }) - ).rejects.toMatchObject({ limit: 'databaseStatements' }) - expect(query).toHaveBeenCalledTimes(2) - } finally { - budget.close() - } - }) - - it('uses owned timeout provenance for a localized cancellation message', async () => { - const query = vi - .fn() - .mockResolvedValueOnce({ rows: [], rowCount: 0 }) - .mockImplementationOnce( - () => - new Promise((_, reject) => - setTimeout( - () => - reject(Object.assign(new Error('cancelación de declaración'), { code: '57014' })), - 75 - ) - ) - ) - const budget = AccessExecutionBudget.create('catalog', { - limits: { catalogDeadlineMs: 1_000, statementTimeoutMs: 50 }, - }) - const db = budgetedQueryClient(query) - try { - await runAccessDatabaseQuery(db, budget, 'SET statement_timeout', [], { - chargeRows: false, - chargeProducer: false, - statementTimeoutMs: 50, - }) - await expect(runAccessDatabaseQuery(db, budget, 'SELECT pg_sleep(2)')).rejects.toMatchObject({ - name: 'AccessBudgetExceededError', - limit: 'deadline', - authorityRequired: true, - }) - } finally { - budget.close() - } - }) -}) - -function budgetedQueryClient(query: ReturnType) { - return { query } -} - -const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL -const describeRealPostgres = adminUrl ? describe : describe.skip - -describeRealPostgres('access database cancellation on real PostgreSQL', () => { - const database = `access_cancel_${randomBytes(6).toString('hex')}` - let adminPool: Pool - let testPool: Pool - - function databaseUrl(baseUrl: string, databaseName: string): string { - const url = new URL(baseUrl) - url.pathname = `/${databaseName}` - return url.toString() - } - - function quoteIdentifier(value: string): string { - return `"${value.replace(/"/g, '""')}"` - } - - beforeAll(async () => { - adminPool = new Pool({ connectionString: adminUrl }) - await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) - testPool = new Pool({ - connectionString: databaseUrl( - adminUrl ?? 'postgresql://postgres@db.example.com/postgres', - database - ), - }) - }) - - afterAll(async () => { - try { - await endPoolAndWaitForClients(testPool) - if (!adminPool) return - await waitForDatabaseConnectionsToClose(adminPool, database) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) - } finally { - await adminPool?.end() - } - }) - - it('cancels an active statement when its request budget is cancelled', async () => { - const client = (await testPool.connect()) as PoolClient - const budget = AccessExecutionBudget.create('catalog') - const startedAt = performance.now() - try { - const query = catalogQuery(client, budget, 'SELECT pg_sleep(2)', []) - setTimeout(() => budget.cancel(), 50) - await expect(query).rejects.toBeInstanceOf(AccessExecutionCancelledError) - expect(performance.now() - startedAt).toBeLessThan(1_000) - } finally { - budget.close() - client.release() - } - }) - - it('maps a real statement timeout to authoritative budget exhaustion', async () => { - const client = (await testPool.connect()) as PoolClient - const budget = AccessExecutionBudget.create('catalog') - const startedAt = performance.now() - try { - await runAccessDatabaseQuery(client, budget, `SET statement_timeout = '50ms'`, [], { - chargeRows: false, - chargeProducer: false, - statementTimeoutMs: 50, - }) - await expect(catalogQuery(client, budget, 'SELECT pg_sleep(2)', [])).rejects.toMatchObject({ - name: 'AccessBudgetExceededError', - limit: 'deadline', - authorityRequired: true, - }) - expect(performance.now() - startedAt).toBeLessThan(1_000) - } finally { - await client.query(`SET statement_timeout = 0`) - budget.close() - client.release() - } - }) - - it('does not classify an operator cancellation with the same SQLSTATE as a timeout', async () => { - const client = (await testPool.connect()) as PoolClient - const budget = AccessExecutionBudget.create('catalog') - try { - await runAccessDatabaseQuery(client, budget, `SET statement_timeout = '500ms'`, [], { - chargeRows: false, - chargeProducer: false, - statementTimeoutMs: 500, - }) - const query = catalogQuery(client, budget, 'SELECT pg_sleep(2)', []) - setTimeout(() => { - void testPool.query('SELECT pg_cancel_backend($1)', [client.processID]) - }, 10) - const error = await query.catch(value => value) - expect(error).toMatchObject({ code: '57014' }) - expect(error).not.toBeInstanceOf(AccessBudgetExceededError) - } finally { - budget.close() - client.release() - } - }) - - it('requires rollback and preserves no transactional writes after timeout', async () => { - const client = (await testPool.connect()) as PoolClient - const budget = AccessExecutionBudget.create('catalog') - try { - await client.query(`CREATE TEMP TABLE access_timeout_rollback(value integer)`) - await client.query('BEGIN') - await runAccessDatabaseQuery(client, budget, `SET LOCAL statement_timeout = '50ms'`, [], { - chargeRows: false, - chargeProducer: false, - statementTimeoutMs: 50, - }) - await client.query(`INSERT INTO access_timeout_rollback(value) VALUES (1)`) - await expect(catalogQuery(client, budget, 'SELECT pg_sleep(2)', [])).rejects.toBeInstanceOf( - AccessBudgetExceededError - ) - await client.query('ROLLBACK') - const result = await client.query<{ count: string }>( - `SELECT count(*)::text AS count FROM access_timeout_rollback` - ) - expect(result.rows).toEqual([{ count: '0' }]) - } finally { - if (client) await client.query('ROLLBACK').catch(() => undefined) - budget.close() - client.release() - } - }) - - it('owns commit and cancellation rollback under one request budget', async () => { - const table = `access_budget_transaction_${Date.now()}` - await testPool.query(`CREATE TABLE ${table}(value integer NOT NULL)`) - const committed = AccessExecutionBudget.create('catalog', { limits: { producerCalls: 1 } }) - try { - await withAccessDatabaseTransaction( - committed, - async db => { - await db.query(`INSERT INTO ${table}(value) VALUES(1)`) - }, - { mode: 'read_write', connectionPool: testPool } - ) - } finally { - committed.close() - } - - const cancelled = AccessExecutionBudget.create('catalog', { limits: { producerCalls: 2 } }) - try { - const work = withAccessDatabaseTransaction( - cancelled, - async db => { - await db.query(`INSERT INTO ${table}(value) VALUES(2)`) - await db.query('SELECT pg_sleep(2)') - }, - { mode: 'read_write', connectionPool: testPool } - ) - setTimeout(() => cancelled.cancel(), 50) - await expect(work).rejects.toBeInstanceOf(AccessExecutionCancelledError) - const values = await testPool.query<{ value: number }>( - `SELECT value FROM ${table} ORDER BY value` - ) - expect(values.rows).toEqual([{ value: 1 }]) - } finally { - cancelled.close() - await testPool.query(`DROP TABLE IF EXISTS ${table}`) - } - }) - - it('cancels a queued pool acquisition without leaking the later client', async () => { - const constrainedPool = new Pool({ connectionString: adminUrl, max: 1 }) - const heldClient = await constrainedPool.connect() - let heldReleased = false - const budget = AccessExecutionBudget.create('action') - try { - const startedAt = performance.now() - const work = withAccessDatabaseTransaction(budget, async db => db.query('SELECT 1'), { - mode: 'read_only', - connectionPool: constrainedPool, - }) - setTimeout(() => budget.cancel(), 50) - await expect(work).rejects.toBeInstanceOf(AccessExecutionCancelledError) - expect(performance.now() - startedAt).toBeLessThan(1_000) - - heldClient.release() - heldReleased = true - const replacement = await constrainedPool.connect() - replacement.release() - } finally { - if (!heldReleased) heldClient.release() - budget.close() - await constrainedPool.end() - } - }) - - it('bounds the operational index default transaction acquisition', async () => { - const constrainedPool = new Pool({ connectionString: adminUrl, max: 1 }) - const heldClient = await constrainedPool.connect() - let heldReleased = false - const budget = AccessExecutionBudget.create('action') - const index = new OperationalAccessIndex(constrainedPool) - try { - const startedAt = performance.now() - const work = index.loadSourceStates({ - environmentId: 'test:cluster', - sourceFamilies: ['host'], - budget, - }) - setTimeout(() => budget.cancel(), 50) - await expect(work).rejects.toBeInstanceOf(AccessExecutionCancelledError) - expect(performance.now() - startedAt).toBeLessThan(1_000) - - heldClient.release() - heldReleased = true - const replacement = await constrainedPool.connect() - replacement.release() - } finally { - if (!heldReleased) heldClient.release() - budget.close() - await constrainedPool.end() - } - }) -}) diff --git a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts b/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts deleted file mode 100644 index 939b74a495..0000000000 --- a/control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts +++ /dev/null @@ -1,973 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { randomBytes, randomUUID } from 'node:crypto' -import { Pool } from 'pg' -import { config } from '../src/config.js' -import { type DbClient, initDb } from '../src/db.js' -import { buildAccessCatalog } from '../src/services/access/accessCatalogCoordinator.js' -import { - AccessBudgetExceededError, - AccessExecutionBudget, -} from '../src/services/access/accessExecutionBudget.js' -import type { - CatalogOperationalSourceState, - CatalogRequestContext, -} from '../src/services/access/catalogContracts.js' -import { CATALOG_KEY_SQL } from '../src/services/access/catalogProducerSql.js' -import { requireCatalogProducer } from '../src/services/access/catalogProducers.js' -import { - OPERATIONAL_SOURCE_FAMILIES, - canonicalEnvironmentId, -} from '../src/services/access/operationalAccessProjection.js' -import { teamGfsTopKSql } from '../src/services/access/teamGfsTopK.js' -import type { ExternalSessionAuthorityContext } from '../src/services/auth/externalSessionAuthentication.js' -import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' - -const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL -const describeRealPostgres = adminUrl ? describe : describe.skip -const runtimeRoles = [ - 'control_api_runtime', - 'trace_maintenance_runtime', - 'workflow_recipes_runtime', -] as const - -function databaseUrl(baseUrl: string, database: string): string { - const value = new URL(baseUrl) - value.pathname = `/${database}` - return value.toString() -} - -function quoteIdentifier(value: string): string { - return `"${value.replace(/"/g, '""')}"` -} - -function transaction(pool: Pool) { - return async (work: (db: DbClient) => Promise): Promise => { - const client = await pool.connect() - try { - await client.query('BEGIN') - const result = await work(client) - await client.query('COMMIT') - return result - } catch (error) { - await client.query('ROLLBACK') - throw error - } finally { - client.release() - } - } -} - -type ExplainNode = Record & { Plans?: ExplainNode[] } - -function explainNodes(root: ExplainNode): ExplainNode[] { - const values: ExplainNode[] = [] - const pending = [root] - while (pending.length > 0) { - const node = pending.pop()! - values.push(node) - pending.push(...(node.Plans ?? [])) - } - return values -} - -function actualWork(node: ExplainNode): number { - return Number(node['Actual Rows'] ?? 0) * Math.max(1, Number(node['Actual Loops'] ?? 1)) -} - -describeRealPostgres('distinct catalog key pagination on real PostgreSQL', () => { - const database = `control_api_catalog_distinct_${randomBytes(6).toString('hex')}` - const connectionString = databaseUrl( - adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', - database - ) - const environmentId = canonicalEnvironmentId() - const userId = randomUUID() - const teamIds = [randomUUID(), randomUUID(), randomUUID()] - let adminPool: Pool - let databasePool: Pool - - async function principal(label: string, teamCount = 0) { - const principalUserId = randomUUID() - await databasePool.query(`INSERT INTO users(id, email, name) VALUES ($1, $2, $3)`, [ - principalUserId, - `${label}-${principalUserId}@example.test`, - label, - ]) - const principalTeamIds: string[] = [] - for (let index = 0; index < teamCount; index += 1) { - const teamId = randomUUID() - principalTeamIds.push(teamId) - await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, $2)`, [ - teamId, - `${label} Team ${index + 1}`, - ]) - await databasePool.query( - `INSERT INTO team_members(team_id, user_id, role, status) - VALUES ($1, $2, 'member', 'active')`, - [teamId, principalUserId] - ) - } - return { userId: principalUserId, teamIds: principalTeamIds } - } - - async function operationalResource( - type: string, - logicalId: string, - sourceFamily = type - ): Promise { - await databasePool.query( - `INSERT INTO operational_resource_index( - environment_id, resource_type, logical_id, source_family, source_generation, - provider_uid, provider_resource_version, display_name, enabled, content_bytes - ) VALUES ($1, $2, $3, $4, 1, $5, '1', $3, TRUE, 64) - ON CONFLICT (environment_id, resource_type, logical_id) DO NOTHING`, - [environmentId, type, logicalId, sourceFamily, `${type}:${logicalId}`] - ) - } - - async function relationship(input: { - sourceType: string - sourceId: string - relationshipType: string - targetType: string - targetId: string - instanceId: string - behavior?: Record - }): Promise { - await databasePool.query( - `INSERT INTO operational_resource_relationships( - environment_id, source_type, source_id, relationship_type, target_type, target_id, - relationship_instance_id, behavior_attributes, source_family, source_provider_uid, - source_resource_version, source_generation, content_bytes - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $2, $9, '1', 1, 64)`, - [ - environmentId, - input.sourceType, - input.sourceId, - input.relationshipType, - input.targetType, - input.targetId, - input.instanceId, - input.behavior ?? {}, - `${input.sourceType}:${input.sourceId}`, - ] - ) - } - - async function consumeFamily(input: { - family: - | 'host' - | 'context' - | 'mcp_server' - | 'workflow_recipe' - | 'gfs_resource' - | 'shared_filesystem' - | 'sandbox_app' - userId: string - expected: readonly string[] - minimumPathsForFirst?: number - minimumRelationshipsForFirst?: number - }): Promise { - const session: ExternalSessionAuthorityContext = { - contract: 'v1', - userId: input.userId, - tokenHash: randomBytes(32).toString('hex'), - issuedAt: Math.floor(Date.now() / 1_000), - authGeneration: 1, - } - const seen: string[] = [] - let cursor: string | null = null - let firstItem: Awaited>['items'][number] | undefined - try { - do { - const page = await buildAccessCatalog( - { - session, - families: [input.family], - limit: 2, - ...(cursor ? { cursor } : {}), - }, - { - transaction: transaction(databasePool), - teamGfsMembershipAdmissionLimit: 8, - } - ) - expect(page.complete).toBe(true) - firstItem ??= page.items[0] - seen.push(...page.items.map(item => item.resource.logicalId)) - cursor = page.nextCursor - } while (cursor) - } catch (error) { - throw new Error(`Failed to consume ${input.family}`, { cause: error }) - } - expect(seen).toEqual(input.expected) - expect(new Set(seen).size).toBe(input.expected.length) - if (input.minimumPathsForFirst) { - expect(firstItem?.accessPaths.length).toBeGreaterThanOrEqual(input.minimumPathsForFirst) - } - if (input.minimumRelationshipsForFirst) { - expect(firstItem?.relationships.length).toBeGreaterThanOrEqual( - input.minimumRelationshipsForFirst - ) - } - const explained = await databasePool.query( - `EXPLAIN (ANALYZE, BUFFERS, WAL, FORMAT JSON) ${CATALOG_KEY_SQL[input.family]}`, - [ - input.userId, - '', - environmentId, - 3, - config.hostsNamespace, - config.contextsNamespace, - '', - '{}', - null, - ] - ) - const envelope = (explained.rows[0] as Record)['QUERY PLAN'] - expect(Array.isArray(envelope)).toBe(true) - const root = (envelope as Array>)[0]?.Plan as - | (Record & { Plans?: unknown[] }) - | undefined - const armCount = (CATALOG_KEY_SQL[input.family].match(/AS MATERIALIZED/g) ?? []).length - // Each requested arm contributes at most take + 1 raw rows plus its marker. - expect(Number(root?.['Actual Rows'] ?? 0)).toBeLessThanOrEqual(armCount * 4) - const pending: Array & { Plans?: unknown[] }> = root ? [root] : [] - let sawLimit = false - while (pending.length > 0) { - const node = pending.pop()! - if (node['Node Type'] === 'Limit') sawLimit = true - expect(String(node['Sort Method'] ?? '')).not.toContain('external') - expect(Number(node['Temp Read Blocks'] ?? 0)).toBe(0) - expect(Number(node['Temp Written Blocks'] ?? 0)).toBe(0) - pending.push( - ...((node.Plans ?? []) as Array & { Plans?: unknown[] }>) - ) - } - expect(sawLimit).toBe(true) - } - - beforeAll(async () => { - adminPool = new Pool({ connectionString: adminUrl }) - await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) - await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) - databasePool = new Pool({ connectionString }) - await initDb({ connect: () => databasePool.connect() }) - await databasePool.query( - `INSERT INTO users(id, email, name) VALUES ($1, $2, 'Distinct Catalog User')`, - [userId, `${userId}@example.test`] - ) - await databasePool.query( - `INSERT INTO operational_catalog_source_state( - environment_id, source_family, generation, resource_version, status, last_success_at - ) - SELECT $1, source_family, 1, '1', 'current', NOW() - FROM UNNEST($2::text[]) source_family`, - [environmentId, OPERATIONAL_SOURCE_FAMILIES] - ) - for (const [index, teamId] of teamIds.entries()) { - await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, $2)`, [ - teamId, - `Distinct Team ${index + 1}`, - ]) - await databasePool.query( - `INSERT INTO team_members(team_id, user_id, role, status) - VALUES ($1, $2, 'member', 'active')`, - [teamId, userId] - ) - await databasePool.query(`INSERT INTO team_agents(team_id, agent_name) VALUES ($1, 'a')`, [ - teamId, - ]) - } - await databasePool.query( - `INSERT INTO team_agents(team_id, agent_name) VALUES ($1, 'b'), ($1, 'c')`, - [teamIds[0]] - ) - await databasePool.query( - `INSERT INTO operational_resource_index( - environment_id, resource_type, logical_id, source_family, source_generation, - provider_uid, provider_resource_version, display_name, enabled, content_bytes - ) VALUES - ($1, 'host', $2 || '/a', 'host', 1, 'host-a', '1', 'Host A', TRUE, 64), - ($1, 'host', $2 || '/b', 'host', 1, 'host-b', '1', 'Host B', TRUE, 64), - ($1, 'host', $2 || '/c', 'host', 1, 'host-c', '1', 'Host C', TRUE, 64)`, - [environmentId, config.hostsNamespace] - ) - }) - - afterAll(async () => { - try { - await endPoolAndWaitForClients(databasePool) - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) - FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) - await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) - } - } finally { - await adminPool?.end() - } - }) - - it('S3 C12 refills duplicate direct GFS grants and shares before ending a page', async () => { - for (const mode of ['grant', 'share'] as const) { - const actor = await principal(`s3-gfs-${mode}`) - const outsider = await principal(`s3-gfs-outsider-${mode}`) - const first = - mode === 'grant' - ? '30000000-0000-4000-8000-000000000001' - : '40000000-0000-4000-8000-000000000001' - const second = - mode === 'grant' - ? '30000000-0000-4000-8000-000000000002' - : '40000000-0000-4000-8000-000000000002' - const drive = `s3-${mode}-drive` - await databasePool.query( - `INSERT INTO gfs_resources(resource_id, drive, name, kind) - VALUES ($1, $3, '/', 'directory'), ($2, $4, '/', 'directory')`, - [first, second, drive, `${drive}-second`] - ) - const table = mode === 'grant' ? 'gfs_grants' : 'gfs_shares' - await databasePool.query( - `INSERT INTO ${table}(drive, resource_id, subject_type, subject_id, permissions) - VALUES ($3, $1, 'user', $4, ARRAY['read']::text[]), - ($5, $1, 'user', $4, ARRAY['read']::text[]), - ($3, $2, 'user', $4, ARRAY['read']::text[])`, - [first, second, drive, actor.userId, `${drive}-other`] - ) - const session = (userId: string): ExternalSessionAuthorityContext => ({ - contract: 'v1', - userId, - tokenHash: randomBytes(32).toString('hex'), - issuedAt: Math.floor(Date.now() / 1_000), - authGeneration: 1, - }) - const currentSession = session(actor.userId) - const page1 = await buildAccessCatalog( - { session: currentSession, families: ['gfs_resource'], limit: 1 }, - { transaction: transaction(databasePool) } - ) - expect(page1.complete).toBe(true) - expect(page1.items.map(item => item.resource.logicalId)).toEqual([first]) - expect(page1.nextCursor).toBeTruthy() - const page2 = await buildAccessCatalog( - { - session: currentSession, - families: ['gfs_resource'], - limit: 1, - cursor: page1.nextCursor!, - }, - { transaction: transaction(databasePool) } - ) - expect(page2.complete).toBe(true) - expect(page2.items.map(item => item.resource.logicalId)).toEqual([second]) - const otherPage = await buildAccessCatalog( - { session: session(outsider.userId), families: ['gfs_resource'], limit: 1 }, - { transaction: transaction(databasePool) } - ) - expect(otherPage.complete).toBe(true) - expect(otherPage.items).toEqual([]) - } - }) - - it('refills bounded duplicate team host paths without hiding later canonical IDs', async () => { - await databasePool.query( - `INSERT INTO user_agents(user_id, agent_name) VALUES ($1, 'x'), ($1, 'y'), ($1, 'z')`, - [userId] - ) - await databasePool.query( - `INSERT INTO operational_resource_index( - environment_id, resource_type, logical_id, source_family, source_generation, - provider_uid, provider_resource_version, display_name, enabled, content_bytes - ) VALUES - ($1, 'host', $2 || '/x', 'host', 1, 'host-x', '1', 'Host X', TRUE, 64), - ($1, 'host', $2 || '/y', 'host', 1, 'host-y', '1', 'Host Y', TRUE, 64), - ($1, 'host', $2 || '/z', 'host', 1, 'host-z', '1', 'Host Z', TRUE, 64)`, - [environmentId, config.hostsNamespace] - ) - const budget = AccessExecutionBudget.create('catalog') - let hostKeyStatements = 0 - const sourceStates: CatalogOperationalSourceState[] = OPERATIONAL_SOURCE_FAMILIES.map( - family => ({ family, generation: '1', resourceVersion: '1', status: 'current' }) - ) - const context: CatalogRequestContext = { - db: { - query: (text, values) => { - if (text === CATALOG_KEY_SQL.host) hostKeyStatements += 1 - return databasePool.query(text, values) - }, - }, - budget, - principal: { - userId, - sessionContract: 'v2', - sessionRevision: '1', - userRevision: '1', - authorizationRevision: '1', - memberships: teamIds.map(teamId => ({ teamId, role: 'member' })), - }, - environmentId, - sourceStates: new Map(sourceStates.map(state => [state.family, state])), - } - try { - const page = await requireCatalogProducer('host').listCanonicalKeys( - context, - { afterKey: null, exhausted: false }, - 2 - ) - expect(page.candidates.map(candidate => candidate.key[2])).toEqual([ - `${config.hostsNamespace}/a`, - `${config.hostsNamespace}/b`, - `${config.hostsNamespace}/c`, - ]) - expect(page.hasMore).toBe(true) - expect(page.continuation.exhausted).toBe(false) - expect(hostKeyStatements).toBe(2) - await consumeFamily({ - family: 'host', - userId, - expected: ['a', 'b', 'c', 'x', 'y', 'z'].map(name => `${config.hostsNamespace}/${name}`), - minimumPathsForFirst: 3, - }) - } finally { - budget.close() - } - }) - - it('fails before an unbudgeted duplicate-refill statement can execute', async () => { - const budget = AccessExecutionBudget.create('catalog', { - limits: { databaseStatements: 1 }, - }) - let hostKeyStatements = 0 - const sourceStates: CatalogOperationalSourceState[] = OPERATIONAL_SOURCE_FAMILIES.map( - family => ({ family, generation: '1', resourceVersion: '1', status: 'current' }) - ) - const context: CatalogRequestContext = { - db: { - query: (text, values) => { - if (text === CATALOG_KEY_SQL.host) hostKeyStatements += 1 - return databasePool.query(text, values) - }, - }, - budget, - principal: { - userId, - sessionContract: 'v2', - sessionRevision: '1', - userRevision: '1', - authorizationRevision: '1', - memberships: teamIds.map(teamId => ({ teamId, role: 'member' })), - }, - environmentId, - sourceStates: new Map(sourceStates.map(state => [state.family, state])), - } - try { - await expect( - requireCatalogProducer('host').listCanonicalKeys( - context, - { afterKey: null, exhausted: false }, - 2 - ) - ).rejects.toMatchObject({ - limit: 'databaseStatements', - authorityRequired: true, - } satisfies Partial) - expect(hostKeyStatements).toBe(1) - } finally { - budget.close() - } - }) - - it('preserves distinct keys for every duplicate-capable producer arm class', async () => { - const teamHost = await principal('team-host', 3) - const hostNames = ['team-host-a', 'team-host-b', 'team-host-c'] - for (const name of hostNames) - await operationalResource('host', `${config.hostsNamespace}/${name}`) - for (const teamId of teamHost.teamIds) { - await databasePool.query(`INSERT INTO team_agents(team_id, agent_name) VALUES ($1, $2)`, [ - teamId, - hostNames[0], - ]) - } - await databasePool.query( - `INSERT INTO team_agents(team_id, agent_name) VALUES ($1, $2), ($1, $3)`, - [teamHost.teamIds[0], hostNames[1], hostNames[2]] - ) - await consumeFamily({ - family: 'host', - userId: teamHost.userId, - expected: hostNames.map(name => `${config.hostsNamespace}/${name}`), - minimumPathsForFirst: 3, - }) - - const teamContext = await principal('team-context', 3) - const contextNames = ['team-context-a', 'team-context-b', 'team-context-c'] - for (const name of contextNames) { - await operationalResource('context', `${config.contextsNamespace}/${name}`) - } - for (const teamId of teamContext.teamIds) { - await databasePool.query(`INSERT INTO team_contexts(team_id, context_id) VALUES ($1, $2)`, [ - teamId, - contextNames[0], - ]) - } - await databasePool.query( - `INSERT INTO team_contexts(team_id, context_id) VALUES ($1, $2), ($1, $3)`, - [teamContext.teamIds[0], contextNames[1], contextNames[2]] - ) - await consumeFamily({ - family: 'context', - userId: teamContext.userId, - expected: contextNames.map(name => `${config.contextsNamespace}/${name}`), - minimumPathsForFirst: 3, - }) - - const teamRecipe = await principal('team-recipe', 3) - const recipeNames = ['team-recipe-a', 'team-recipe-b', 'team-recipe-c'] - for (const name of recipeNames) { - await operationalResource( - 'workflow_recipe', - `${config.sandboxNamespace}/${name}`, - 'workflow_recipe' - ) - } - for (const teamId of teamRecipe.teamIds) { - await databasePool.query( - `INSERT INTO team_workflow_triggers(team_id, recipe_namespace, recipe_name) - VALUES ($1, $2, $3)`, - [teamId, config.sandboxNamespace, recipeNames[0]] - ) - } - await databasePool.query( - `INSERT INTO team_workflow_triggers(team_id, recipe_namespace, recipe_name) - VALUES ($1, $2, $3), ($1, $2, $4)`, - [teamRecipe.teamIds[0], config.sandboxNamespace, recipeNames[1], recipeNames[2]] - ) - await consumeFamily({ - family: 'workflow_recipe', - userId: teamRecipe.userId, - expected: recipeNames.map(name => `${config.sandboxNamespace}/${name}`), - minimumPathsForFirst: 3, - }) - - for (const mode of ['direct-context', 'team-context', 'direct-host', 'team-host'] as const) { - const usingTeam = mode.startsWith('team') - const usingHost = mode.endsWith('host') - const actor = await principal(`mcp-${mode}`, usingTeam ? 3 : 0) - const mcpIds = ['a', 'b', 'c'].map( - suffix => `${config.mcpServersNamespace}/mcp-${mode}-${suffix}` - ) - for (const id of mcpIds) await operationalResource('mcp_server', id) - const contextId = `${config.contextsNamespace}/mcp-${mode}-context` - await operationalResource('context', contextId) - for (const [index, targetId] of mcpIds.entries()) { - await relationship({ - sourceType: 'context', - sourceId: contextId, - relationshipType: 'includes_mcp_server', - targetType: 'mcp_server', - targetId, - instanceId: `mcp-${mode}-${index}`, - }) - } - if (usingHost) { - const hostName = `mcp-${mode}-host` - const hostId = `${config.hostsNamespace}/${hostName}` - await operationalResource('host', hostId) - await relationship({ - sourceType: 'host', - sourceId: hostId, - relationshipType: 'uses_context', - targetType: 'context', - targetId: contextId, - instanceId: `mcp-${mode}-host-context`, - }) - if (usingTeam) { - for (const teamId of actor.teamIds) { - await databasePool.query( - `INSERT INTO team_agents(team_id, agent_name) VALUES ($1, $2)`, - [teamId, hostName] - ) - } - } else { - await databasePool.query(`INSERT INTO user_agents(user_id, agent_name) VALUES ($1, $2)`, [ - actor.userId, - hostName, - ]) - for (let copy = 1; copy < 3; copy += 1) { - const duplicateHostName = `${hostName}-${copy}` - const duplicateHostId = `${config.hostsNamespace}/${duplicateHostName}` - await operationalResource('host', duplicateHostId) - await databasePool.query( - `INSERT INTO user_agents(user_id, agent_name) VALUES ($1, $2)`, - [actor.userId, duplicateHostName] - ) - await relationship({ - sourceType: 'host', - sourceId: duplicateHostId, - relationshipType: 'uses_context', - targetType: 'context', - targetId: contextId, - instanceId: `mcp-${mode}-host-context-${copy}`, - }) - } - } - } else if (usingTeam) { - const contextName = contextId.slice(config.contextsNamespace.length + 1) - for (const teamId of actor.teamIds) { - await databasePool.query( - `INSERT INTO team_contexts(team_id, context_id) VALUES ($1, $2)`, - [teamId, contextName] - ) - } - } else { - const sourceIds = [contextId] - for (let copy = 1; copy < 3; copy += 1) { - const duplicateId = `${config.contextsNamespace}/mcp-${mode}-context-${copy}` - sourceIds.push(duplicateId) - await operationalResource('context', duplicateId) - await relationship({ - sourceType: 'context', - sourceId: duplicateId, - relationshipType: 'includes_mcp_server', - targetType: 'mcp_server', - targetId: mcpIds[0], - instanceId: `mcp-${mode}-duplicate-${copy}`, - }) - } - for (const sourceId of sourceIds) { - await databasePool.query( - `INSERT INTO user_contexts(user_id, context_id) VALUES ($1, $2)`, - [actor.userId, sourceId.slice(config.contextsNamespace.length + 1)] - ) - } - } - await consumeFamily({ - family: 'mcp_server', - userId: actor.userId, - expected: mcpIds, - minimumPathsForFirst: 3, - }) - } - - for (const mode of ['direct', 'team'] as const) { - const actor = await principal(`sfs-${mode}`, mode === 'team' ? 3 : 0) - const contextName = `sfs-${mode}-context` - const contextId = `${config.contextsNamespace}/${contextName}` - const filesystemIds = ['a', 'b', 'c'].map( - suffix => `${config.sharedFilesystemsNamespace}/sfs-${mode}-${suffix}` - ) - await operationalResource('context', contextId) - for (const id of filesystemIds) await operationalResource('shared_filesystem', id) - for (let copy = 0; copy < 3; copy += 1) { - await relationship({ - sourceType: 'context', - sourceId: contextId, - relationshipType: 'mounts_shared_filesystem', - targetType: 'shared_filesystem', - targetId: filesystemIds[0], - instanceId: `sfs-${mode}-a-${copy}`, - behavior: { mountPath: `/workspace/${mode}/a-${copy}`, readOnly: true }, - }) - } - for (const [index, id] of filesystemIds.slice(1).entries()) { - await relationship({ - sourceType: 'context', - sourceId: contextId, - relationshipType: 'mounts_shared_filesystem', - targetType: 'shared_filesystem', - targetId: id, - instanceId: `sfs-${mode}-${index + 1}`, - behavior: { mountPath: `/workspace/${mode}/${index + 1}`, readOnly: true }, - }) - } - if (mode === 'direct') { - await databasePool.query(`INSERT INTO user_contexts(user_id, context_id) VALUES ($1, $2)`, [ - actor.userId, - contextName, - ]) - } else { - for (const teamId of actor.teamIds) { - await databasePool.query( - `INSERT INTO team_contexts(team_id, context_id) VALUES ($1, $2)`, - [teamId, contextName] - ) - } - } - await consumeFamily({ - family: 'shared_filesystem', - userId: actor.userId, - expected: filesystemIds, - minimumPathsForFirst: 3, - minimumRelationshipsForFirst: 3, - }) - } - - for (const mode of ['grant', 'share'] as const) { - const actor = await principal(`gfs-${mode}`, 3) - const prefix = mode === 'grant' ? '10000000' : '20000000' - const resourceIds = ['a', 'b', 'c'].map( - suffix => `${prefix}-0000-4000-8000-00000000000${suffix}` - ) - for (const [index, resourceId] of resourceIds.entries()) { - await databasePool.query( - `INSERT INTO gfs_resources(resource_id, drive, name, kind) - VALUES ($1, $2, '/', 'directory')`, - [resourceId, `gfs-${mode}-${index}`] - ) - } - const table = mode === 'grant' ? 'gfs_grants' : 'gfs_shares' - for (const teamId of actor.teamIds) { - await databasePool.query( - `INSERT INTO ${table}(drive, resource_id, subject_type, subject_id, permissions) - VALUES ($1, $2, 'team', $3, ARRAY['read']::text[])`, - [`gfs-${mode}-0`, resourceIds[0], teamId] - ) - } - for (let index = 1; index < resourceIds.length; index += 1) { - await databasePool.query( - `INSERT INTO ${table}(drive, resource_id, subject_type, subject_id, permissions) - VALUES ($1, $2, 'team', $3, ARRAY['read']::text[])`, - [`gfs-${mode}-${index}`, resourceIds[index], actor.teamIds[0]] - ) - } - await consumeFamily({ - family: 'gfs_resource', - userId: actor.userId, - expected: resourceIds, - minimumPathsForFirst: 3, - }) - } - - for (const mode of ['direct', 'team'] as const) { - const actor = await principal(`sandbox-${mode}`, mode === 'team' ? 3 : 0) - const recipes = ['a', 'b', 'c'].map( - name => `${config.sandboxNamespace}/sandbox-${mode}-${name}` - ) - const apps = ['a', 'b', 'c'].map( - name => `${config.sandboxNamespace}/sandbox-app-${mode}-${name}` - ) - for (const recipe of recipes) - await operationalResource('workflow_recipe', recipe, 'workflow_recipe') - for (const app of apps) await operationalResource('sandbox_app', app, 'workflow_recipe') - for (let copy = 0; copy < 3; copy += 1) { - await relationship({ - sourceType: 'workflow_recipe', - sourceId: recipes[0], - relationshipType: 'exposes_sandbox_app', - targetType: 'sandbox_app', - targetId: apps[0], - instanceId: `sandbox-${mode}-a-${copy}`, - }) - } - for (const [index, app] of apps.slice(1).entries()) { - await relationship({ - sourceType: 'workflow_recipe', - sourceId: recipes[index + 1], - relationshipType: 'exposes_sandbox_app', - targetType: 'sandbox_app', - targetId: app, - instanceId: `sandbox-${mode}-${index + 1}`, - }) - } - if (mode === 'direct') { - for (const recipe of recipes) { - const [recipeNamespace, recipeName] = recipe.split('/') - await databasePool.query( - `INSERT INTO user_workflow_triggers(user_id, recipe_namespace, recipe_name) - VALUES ($1, $2, $3)`, - [actor.userId, recipeNamespace, recipeName] - ) - } - } else { - for (const teamId of actor.teamIds) { - const [recipeNamespace, recipeName] = recipes[0].split('/') - await databasePool.query( - `INSERT INTO team_workflow_triggers(team_id, recipe_namespace, recipe_name) - VALUES ($1, $2, $3)`, - [teamId, recipeNamespace, recipeName] - ) - } - for (const recipe of recipes.slice(1)) { - const [recipeNamespace, recipeName] = recipe.split('/') - await databasePool.query( - `INSERT INTO team_workflow_triggers(team_id, recipe_namespace, recipe_name) - VALUES ($1, $2, $3)`, - [actor.teamIds[0], recipeNamespace, recipeName] - ) - } - } - await consumeFamily({ - family: 'sandbox_app', - userId: actor.userId, - expected: apps, - // Repeated physical relationships prove refill/no-loss behavior, but - // they do not mint additional authorization paths. Direct access has - // one user path; the team fixture intentionally has three team paths. - minimumPathsForFirst: mode === 'team' ? 3 : 1, - minimumRelationshipsForFirst: 3, - }) - } - }, 30_000) - - it('keeps sparse GFS access subject-indexed instead of scanning unrelated resources', async () => { - const actor = await principal('sparse-gfs', 1) - const authorizedResourceId = 'f0000000-0000-4000-8000-000000000001' - const authorizedDrive = 'sparse-gfs-authorized' - await databasePool.query( - `INSERT INTO gfs_resources(resource_id, drive, name, kind) - SELECT ('01000000-0000-4000-8000-' || LPAD(value::text, 12, '0'))::uuid, - 'sparse-unrelated-' || value, '/', 'directory' - FROM generate_series(1, 2000) value` - ) - await databasePool.query( - `INSERT INTO gfs_resources(resource_id, drive, name, kind) - VALUES ($1, $2, '/', 'directory')`, - [authorizedResourceId, authorizedDrive] - ) - for (const table of ['gfs_grants', 'gfs_shares'] as const) { - await databasePool.query( - `INSERT INTO ${table}(drive, resource_id, subject_type, subject_id, permissions) - SELECT resource.drive, resource.resource_id, subject.subject_type, subject.subject_id, - ARRAY['read']::text[] - FROM gfs_resources resource - CROSS JOIN (VALUES - ('user', 'ffffffff-ffff-4fff-8fff-ffffffffffff'), - ('team', 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee')) - subject(subject_type, subject_id) - WHERE resource.drive LIKE 'sparse-unrelated-%'` - ) - } - for (const table of ['gfs_grants', 'gfs_shares'] as const) { - await databasePool.query( - `INSERT INTO ${table}(drive, resource_id, subject_type, subject_id, permissions) - VALUES ($1, $2, 'user', $3, ARRAY['read']::text[]), - ($1, $2, 'team', $4, ARRAY['read']::text[])`, - [authorizedDrive, authorizedResourceId, actor.userId, actor.teamIds[0]] - ) - } - await databasePool.query('ANALYZE gfs_resources') - await databasePool.query('ANALYZE gfs_grants') - await databasePool.query('ANALYZE gfs_shares') - - const explained = await databasePool.query( - `EXPLAIN (ANALYZE, BUFFERS, WAL, FORMAT JSON) ${CATALOG_KEY_SQL.gfs_resource}`, - [ - actor.userId, - '', - environmentId, - 3, - config.hostsNamespace, - config.contextsNamespace, - '', - '{}', - null, - ] - ) - const envelope = (explained.rows[0] as Record)['QUERY PLAN'] as Array<{ - Plan: ExplainNode - }> - const nodes = explainNodes(envelope[0].Plan) - const indexNames = new Set( - nodes.flatMap(node => (typeof node['Index Name'] === 'string' ? [node['Index Name']] : [])) - ) - expect(indexNames).toContain('gfs_grants_subject_resource_catalog_idx') - expect(indexNames).toContain('gfs_shares_subject_resource_catalog_idx') - const resourceWork = nodes - .filter(node => node['Relation Name'] === 'gfs_resources') - .reduce((total, node) => total + actualWork(node), 0) - expect(resourceWork).toBeLessThanOrEqual(16) - for (const node of nodes) { - expect(String(node['Sort Method'] ?? '')).not.toContain('external') - expect(Number(node['Temp Read Blocks'] ?? 0)).toBe(0) - expect(Number(node['Temp Written Blocks'] ?? 0)).toBe(0) - } - - const teamExplained = await databasePool.query( - `EXPLAIN (ANALYZE, BUFFERS, WAL, FORMAT JSON) ${teamGfsTopKSql.streamHead}`, - [ - JSON.stringify([ - { - kind: 'grant', - subject_id: actor.teamIds[0], - after_id: '00000000-0000-0000-0000-000000000000', - take: 3, - }, - { - kind: 'share', - subject_id: actor.teamIds[0], - after_id: '00000000-0000-0000-0000-000000000000', - take: 3, - }, - ]), - ] - ) - const teamEnvelope = (teamExplained.rows[0] as Record)['QUERY PLAN'] as Array<{ - Plan: ExplainNode - }> - const teamNodes = explainNodes(teamEnvelope[0].Plan) - const teamIndexes = new Set( - teamNodes.flatMap(node => - typeof node['Index Name'] === 'string' ? [node['Index Name']] : [] - ) - ) - expect(teamIndexes).toContain('gfs_grants_subject_resource_catalog_idx') - expect(teamIndexes).toContain('gfs_shares_subject_resource_catalog_idx') - expect( - teamNodes - .filter(node => node['Relation Name'] === 'gfs_resources') - .reduce((total, node) => total + actualWork(node), 0) - ).toBeLessThanOrEqual(8) - expect(teamNodes.every(node => Number(node['Temp Written Blocks'] ?? 0) === 0)).toBe(true) - - await consumeFamily({ - family: 'gfs_resource', - userId: actor.userId, - expected: [authorizedResourceId], - minimumPathsForFirst: 4, - }) - }, 30_000) - - it('fails closed above the configured local team GFS membership admission bound', async () => { - const actor = await principal('gfs-admission', 3) - const resourceId = 'e0000000-0000-4000-8000-000000000001' - await databasePool.query( - `INSERT INTO gfs_resources(resource_id, drive, name, kind) - VALUES ($1, 'gfs-admission', '/', 'directory')`, - [resourceId] - ) - for (const teamId of actor.teamIds) { - await databasePool.query( - `INSERT INTO gfs_grants(drive, resource_id, subject_type, subject_id, permissions) - VALUES ('gfs-admission', $1, 'team', $2, ARRAY['read']::text[])`, - [resourceId, teamId] - ) - } - const session: ExternalSessionAuthorityContext = { - contract: 'v1', - userId: actor.userId, - tokenHash: randomBytes(32).toString('hex'), - issuedAt: Math.floor(Date.now() / 1_000), - authGeneration: 1, - } - const build = (admission: number) => - buildAccessCatalog( - { session, families: ['gfs_resource'], limit: 2 }, - { - transaction: transaction(databasePool), - teamGfsMembershipAdmissionLimit: admission, - } - ) - - await expect(build(4)).resolves.toMatchObject({ complete: true }) - await expect(build(3)).resolves.toMatchObject({ complete: true }) - await expect(build(2)).rejects.toMatchObject({ - limit: 'teamGfsMembershipAdmission', - authorityRequired: true, - } satisfies Partial) - }) -}) diff --git a/control-api/test/catalogOrdering.test.ts b/control-api/test/catalogOrdering.test.ts deleted file mode 100644 index fe094101d2..0000000000 --- a/control-api/test/catalogOrdering.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { mergeCatalogCandidateStreams } from '../src/services/access/accessCatalogMerge.js' -import { - catalogKey, - catalogKeyEquals, - compareCatalogKey, -} from '../src/services/access/catalogContracts.js' - -const environmentId = 'cluster.local/evenfire' - -function byteOrder(left: string, right: string): number { - const encoder = new TextEncoder() - const leftBytes = encoder.encode(left) - const rightBytes = encoder.encode(right) - for (let index = 0; index < Math.min(leftBytes.length, rightBytes.length); index += 1) { - if (leftBytes[index] !== rightBytes[index]) return leftBytes[index]! - rightBytes[index]! - } - return leftBytes.length - rightBytes.length -} - -describe('catalog UTF-8 byte ordering contract', () => { - it('uses exact identity and byte order for mixed-case and canonically equivalent keys', () => { - const zeta = catalogKey(environmentId, 'host', 'ns/Zeta') - const alpha = catalogKey(environmentId, 'host', 'ns/alpha') - const composed = catalogKey(environmentId, 'host', 'ns/e\u0301') - const precomposed = catalogKey(environmentId, 'host', 'ns/é') - - expect(Math.sign(compareCatalogKey(zeta, alpha))).toBe( - Math.sign(byteOrder('ns/Zeta', 'ns/alpha')) - ) - expect(catalogKeyEquals(composed, precomposed)).toBe(false) - - const stream = [zeta, alpha].map(key => ({ - key, - canonicalId: `host:${key[2]}`, - validUntil: null, - })) - expect(mergeCatalogCandidateStreams([{ streamId: 'host', candidates: stream }], 2)).toEqual( - stream - ) - }) -}) diff --git a/control-api/test/catalogProducers.realPostgres.integration.test.ts b/control-api/test/catalogProducers.realPostgres.integration.test.ts deleted file mode 100644 index 9c1c5d9787..0000000000 --- a/control-api/test/catalogProducers.realPostgres.integration.test.ts +++ /dev/null @@ -1,591 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import fc from 'fast-check' -import { randomBytes, randomUUID } from 'node:crypto' -import { Pool } from 'pg' -import { config } from '../src/config.js' -import { initDb } from '../src/db.js' -import { AccessExecutionBudget } from '../src/services/access/accessExecutionBudget.js' -import { - CATALOG_FAMILIES, - type CatalogFamily, - type CatalogOperationalSourceState, - type CatalogRequestContext, - catalogKey, -} from '../src/services/access/catalogContracts.js' -import { requireCatalogProducer } from '../src/services/access/catalogProducers.js' -import { loadDatabaseCandidates } from '../src/services/access/databaseAuthorityCandidates.js' -import { OperationalAccessIndex } from '../src/services/access/operationalAccessIndex.js' -import { - OPERATIONAL_SOURCE_FAMILIES, - canonicalEnvironmentId, - projectOperationalObject, -} from '../src/services/access/operationalAccessProjection.js' -import { canonicalResourceIdentity } from '../src/services/access/resourceIdentity.js' -import { parseWorkflowTriggerIntent } from '../src/services/userApprovalRequestService.js' -import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' - -const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL -const describeRealPostgres = adminUrl ? describe : describe.skip -const runtimeRoles = [ - 'control_api_runtime', - 'trace_maintenance_runtime', - 'workflow_recipes_runtime', -] as const - -function databaseUrl(baseUrl: string, database: string): string { - const value = new URL(baseUrl) - value.pathname = `/${database}` - return value.toString() -} - -function quoteIdentifier(value: string): string { - return `"${value.replace(/"/g, '""')}"` -} - -describeRealPostgres('catalog producer SQL on real PostgreSQL', () => { - const database = `control_api_catalog_producer_${randomBytes(6).toString('hex')}` - const connectionString = databaseUrl( - adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', - database - ) - const environmentId = canonicalEnvironmentId() - const userId = randomUUID() - let adminPool: Pool - let databasePool: Pool - - async function insertUser(): Promise { - const id = randomUUID() - await databasePool.query(`INSERT INTO users(id, email, name) VALUES ($1, $2, 'Catalog User')`, [ - id, - `${id}@example.test`, - ]) - return id - } - - async function observe(user: string, family: CatalogFamily, ids: readonly string[]) { - const budget = AccessExecutionBudget.create('catalog') - const principal = { - userId: user, - sessionContract: 'v1' as const, - sessionRevision: '1', - userRevision: '1', - authorizationRevision: '1', - memberships: [], - } - const context: CatalogRequestContext = { - db: databasePool, - budget, - principal, - environmentId, - sourceStates: new Map(), - } - try { - const producer = requireCatalogProducer(family) - const page = await producer.listCanonicalKeys( - context, - { afterKey: null, exhausted: false }, - 100 - ) - const hydrated = await producer.hydrateCanonicalKeys( - context, - [...ids].sort().map(id => catalogKey(environmentId, family, id)) - ) - const resources = Array.isArray(hydrated) ? hydrated : hydrated.resources - const authority: string[] = [] - for (const id of ids) { - const result = await loadDatabaseCandidates({ - db: databasePool, - budget, - snapshot: { ...principal, sessionLive: true, resourceRevision: '1' }, - resource: canonicalResourceIdentity({ environmentId, type: family, logicalId: id }), - }) - if (result.exists) authority.push(id) - } - return { - keys: page.candidates.map(candidate => candidate.key[2]).filter(id => ids.includes(id)), - hydrated: resources.map(resource => resource.key[2]), - authority, - } - } finally { - budget.close() - } - } - - function expectVisible(result: Awaited>, expected: readonly string[]) { - expect(result.keys.sort()).toEqual([...expected].sort()) - expect(result.hydrated.sort()).toEqual([...expected].sort()) - expect(result.authority.sort()).toEqual([...expected].sort()) - } - - beforeAll(async () => { - adminPool = new Pool({ connectionString: adminUrl }) - await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) - await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) - databasePool = new Pool({ connectionString }) - await initDb({ connect: () => databasePool.connect() }) - }) - - afterAll(async () => { - try { - await endPoolAndWaitForClients(databasePool) - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) - FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) - await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) - } - } finally { - await adminPool?.end() - } - }) - - it('S3 C7 restricts team run paths to the actor or team-scoped runs', async () => { - const alice = await insertUser() - const bob = await insertUser() - const team = randomUUID() - const ids = Array.from({ length: 7 }, () => randomUUID()) - await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, 'Run Team')`, [team]) - await databasePool.query( - `INSERT INTO team_members(team_id, user_id, role, status) - VALUES ($1, $2, 'member', 'active'), ($1, $3, 'member', 'active')`, - [team, alice, bob] - ) - await databasePool.query( - `INSERT INTO team_workflow_triggers(team_id, recipe_namespace, recipe_name) - VALUES ($1, 's3', 'team-run')`, - [team] - ) - await databasePool.query( - `INSERT INTO workflow_runs( - run_id, recipe_namespace, recipe_name, phase, actor_type, actor_id, - team_id, usage_team_id, trigger_source - ) VALUES - ($1, 's3', 'team-run', 'Succeeded', 'user', $8, $10, NULL, 'onDemand'), - ($2, 's3', 'team-run', 'Succeeded', 'user', $8, NULL, $10::text, 'onDemand'), - ($3, 's3', 'team-run', 'Succeeded', 'user', $9, $10, NULL, 'onDemand'), - ($4, 's3', 'team-run', 'Succeeded', 'scheduled', NULL, $10, NULL, 'schedule'), - ($5, 's3', 'team-run', 'Succeeded', 'autonomous', NULL, NULL, $10::text, 'autonomous'), - ($6, 's3', 'team-run', 'Succeeded', 'user', NULL, $10, NULL, 'onDemand'), - ($7, 's3', 'team-run', 'Succeeded', 'admin', NULL, $10, NULL, 'onDemand')`, - [...ids, alice, bob, team] - ) - expectVisible(await observe(bob, 'workflow_run', ids), [ids[2]!, ids[3]!, ids[4]!, ids[5]!]) - expectVisible(await observe(alice, 'workflow_run', ids), [ - ids[0]!, - ids[1]!, - ids[3]!, - ids[4]!, - ids[5]!, - ]) - }) - - it('S3 TERRA1 uses the approval allowlist instead of team trigger grants', async () => { - const member = await insertUser() - const outsider = await insertUser() - const team = randomUUID() - const allowed = randomUUID() - const denied = randomUUID() - await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, 'Approval Team')`, [team]) - await databasePool.query( - `INSERT INTO team_members(team_id, user_id, role, status) VALUES ($1, $2, 'member', 'active')`, - [team, member] - ) - await databasePool.query( - `INSERT INTO workflow_recipe_allowed_teams(recipe_namespace, recipe_name, team_id) - VALUES ('s3', 'allowed', $1)`, - [team] - ) - await databasePool.query( - `INSERT INTO team_workflow_triggers(team_id, recipe_namespace, recipe_name) - VALUES ($1, 's3', 'trigger-only')`, - [team] - ) - await databasePool.query( - `INSERT INTO workflow_approval_requests(id, recipe_namespace, recipe_name, expires_at, - status, target_team_id, payload, idempotency_key, payload_hash) - VALUES ($1, 's3', 'allowed', NOW() + INTERVAL '1 hour', 'pending', $3, '{}', $1::uuid::text, 'hash'), - ($2, 's3', 'trigger-only', NOW() + INTERVAL '1 hour', 'pending', $3, '{}', $2::uuid::text, 'hash')`, - [allowed, denied, team] - ) - expectVisible(await observe(member, 'workflow_approval', [allowed, denied]), [allowed]) - expectVisible(await observe(outsider, 'workflow_approval', [allowed, denied]), []) - await databasePool.query(`DELETE FROM workflow_recipe_allowed_teams WHERE team_id = $1`, [team]) - expectVisible(await observe(member, 'workflow_approval', [allowed, denied]), []) - }) - - it('S3 I7 binds the team approval candidate to the trigger requester', async () => { - const requester = await insertUser() - const teammate = await insertUser() - const team = randomUUID() - await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, 'Requester Team')`, [team]) - await databasePool.query( - `INSERT INTO team_members(team_id, user_id, role, status) - VALUES ($1, $2, 'member', 'active'), ($1, $3, 'member', 'active')`, - [team, requester, teammate] - ) - await databasePool.query( - `INSERT INTO workflow_recipe_allowed_teams(recipe_namespace, recipe_name, team_id) - VALUES ('s3', 'requester', $1)`, - [team] - ) - const trigger = (requesterUserId?: unknown) => ({ - namespace: 's3', - name: 'requester-trigger', - caller: `user:${requester}`, - ...(requesterUserId === undefined ? {} : { requesterUserId }), - }) - const variants: ReadonlyArray<{ - name: string - payload: Record - requesterVisible: boolean - teammateVisible: boolean - }> = [ - { - name: 'requester B', - payload: { metadata: { workflowTrigger: trigger(requester) } }, - requesterVisible: true, - teammateVisible: false, - }, - { - name: 'no workflowTrigger', - payload: { metadata: {} }, - requesterVisible: true, - teammateVisible: true, - }, - { - name: "requester ' B '", - payload: { metadata: { workflowTrigger: trigger(` ${requester} `) } }, - requesterVisible: true, - teammateVisible: false, - }, - { - name: 'requester U+00A0/U+3000 padded', - payload: { metadata: { workflowTrigger: trigger(`\u00a0${requester}\u3000`) } }, - requesterVisible: true, - teammateVisible: false, - }, - { - name: 'whitespace-only requester', - payload: { metadata: { workflowTrigger: trigger(' \u00a0\u3000 ') } }, - requesterVisible: true, - teammateVisible: true, - }, - { - name: "requester ''", - payload: { metadata: { workflowTrigger: trigger('') } }, - requesterVisible: true, - teammateVisible: true, - }, - { - name: 'requester 123', - payload: { metadata: { workflowTrigger: trigger(123) } }, - requesterVisible: true, - teammateVisible: true, - }, - ] - const authorityFor = async (user: string, approvalId: string) => - (await observe(user, 'workflow_approval', [approvalId])).authority - - try { - for (const variant of variants) { - const approvalId = randomUUID() - await databasePool.query( - `INSERT INTO workflow_approval_requests( - id, recipe_namespace, recipe_name, expires_at, status, target_team_id, - payload, idempotency_key, payload_hash - ) VALUES ($1, 's3', 'requester', NOW() + INTERVAL '1 hour', 'pending', $2, - $3::jsonb, $4, 'hash')`, - [approvalId, team, JSON.stringify(variant.payload), `i7-${approvalId}`] - ) - // The candidate SQL must agree with the parser the decide guard uses. - const parsedRequester = parseWorkflowTriggerIntent(variant.payload)?.requesterUserId ?? null - expect(parsedRequester === null || parsedRequester === requester).toBe( - variant.requesterVisible - ) - expect(parsedRequester === null || parsedRequester === teammate).toBe( - variant.teammateVisible - ) - expect(await authorityFor(requester, approvalId)).toEqual( - variant.requesterVisible ? [approvalId] : [] - ) - expect(await authorityFor(teammate, approvalId)).toEqual( - variant.teammateVisible ? [approvalId] : [] - ) - } - } finally { - await databasePool.query(`DELETE FROM workflow_approval_requests WHERE target_team_id = $1`, [ - team, - ]) - await databasePool.query(`DELETE FROM workflow_recipe_allowed_teams WHERE team_id = $1`, [ - team, - ]) - await databasePool.query(`DELETE FROM team_members WHERE team_id = $1`, [team]) - await databasePool.query(`DELETE FROM teams WHERE id = $1`, [team]) - } - }) - - it('orders and resumes arbitrary PostgreSQL text by exact UTF-8 bytes', async () => { - const generated = fc.sample( - fc - .array( - fc.constantFrom( - 'a', - 'z', - 'A', - 'Z', - '0', - '9', - '-', - '_', - '.', - '/', - 'é', - 'e\u0301', - 'Ω', - '😀' - ), - { minLength: 1, maxLength: 12 } - ) - .map(parts => parts.join('')), - { seed: 106_026, numRuns: 200 } - ) - const values = [ - ...new Set(['alpha', 'Zeta', 'a_b', 'a-b', 'é', 'e\u0301', 'Ω', '😀', ...generated]), - ] - const expected = [...values].sort((left, right) => - Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8')) - ) - const ordered = await databasePool.query( - `SELECT value - FROM unnest($1::text[]) value - ORDER BY catalog_utf8_bytes(value)`, - [values] - ) - expect(ordered.rows.map(row => row.value)).toEqual(expected) - - for (const after of expected) { - const resumed = await databasePool.query( - `SELECT value - FROM unnest($1::text[]) value - WHERE catalog_utf8_bytes(value) > catalog_utf8_bytes($2) - ORDER BY catalog_utf8_bytes(value)`, - [values, after] - ) - expect(resumed.rows.map(row => row.value)).toEqual( - expected.filter(value => Buffer.from(value, 'utf8').compare(Buffer.from(after, 'utf8')) > 0) - ) - } - }) - - it('proves native UUID order matches UTF-8 byte order for canonical UUID text', async () => { - const values = [ - 'ffffffff-ffff-4fff-bfff-ffffffffffff', - '00000000-0000-4000-8000-000000000000', - '7fffffff-ffff-4fff-bfff-ffffffffffff', - '80000000-0000-4000-8000-000000000000', - '0fffffff-ffff-4fff-bfff-ffffffffffff', - ] - const expected = [...values].sort((left, right) => - Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8')) - ) - const ordered = await databasePool.query<{ value: string }>( - `SELECT value::text AS value - FROM unnest($1::uuid[]) AS candidate(value) - ORDER BY value`, - [values] - ) - - expect(ordered.rows.map(row => row.value)).toEqual(expected) - }) - - it('parses and executes every bounded key and selected-ID hydration query', async () => { - const budget = AccessExecutionBudget.create('catalog') - const sourceStates: CatalogOperationalSourceState[] = OPERATIONAL_SOURCE_FAMILIES.map( - family => ({ - family, - generation: '1', - resourceVersion: '1', - status: 'current', - }) - ) - const context: CatalogRequestContext = { - db: databasePool, - budget, - principal: { - userId, - sessionContract: 'v2', - sessionRevision: '1', - userRevision: '1', - authorizationRevision: 'catalog-authorization-1', - memberships: [], - }, - environmentId, - sourceStates: new Map(sourceStates.map(state => [state.family, state])), - } - const logicalId: Record = { - user: userId, - team: randomUUID(), - host: `${config.hostsNamespace}/host-a`, - context: `${config.contextsNamespace}/context-a`, - mcp_server: `${config.mcpServersNamespace}/server-a`, - workflow_recipe: `${config.sandboxNamespace}/recipe-a`, - workflow_run: randomUUID(), - workflow_approval: randomUUID(), - notification: randomUUID(), - gfs_resource: randomUUID(), - shared_filesystem: `${config.sharedFilesystemsNamespace}/filesystem-a`, - sandbox_app: `${config.sandboxNamespace}/recipe-a`, - } - - try { - for (const family of CATALOG_FAMILIES) { - const producer = requireCatalogProducer(family) - try { - const page = await producer.listCanonicalKeys( - context, - { afterKey: null, exhausted: false }, - 2 - ) - expect(page.candidates).toEqual([]) - await expect( - producer.hydrateCanonicalKeys(context, [ - catalogKey(environmentId, family, logicalId[family]), - ]) - ).resolves.toEqual([]) - } catch (error) { - throw new Error(`Producer ${family} failed`, { cause: error }) - } - } - } finally { - budget.close() - } - }) - - it('stages a producer-shaped Context with duplicate allowlist entries exactly once', async () => { - const index = new OperationalAccessIndex(databasePool) - const budget = AccessExecutionBudget.create('catalog') - const contextName = `r55-m8-${randomBytes(6).toString('hex')}` - const sourceId = `${config.contextsNamespace}/${contextName}` - const projection = projectOperationalObject({ - environmentId, - plural: 'contexts', - namespace: config.contextsNamespace, - object: { - metadata: { - name: contextName, - namespace: config.contextsNamespace, - uid: randomUUID(), - resourceVersion: '1', - generation: 1, - }, - spec: { - contextId: contextName, - mcpServers: ['server-a', 'server-a', 'server-b'], - sharedFileSystems: [ - { name: 'filesystem-a', mountPath: '/workspace/a' }, - { name: 'filesystem-a', mountPath: '/workspace/b' }, - ], - }, - }, - behaviorFingerprintKey: 'test-context-fingerprint-key', - relationshipNamespaces: { - context: config.contextsNamespace, - mcpServer: config.mcpServersNamespace, - sharedFilesystem: config.sharedFilesystemsNamespace, - }, - }) - - try { - const stagingGeneration = await index.beginRelist({ - environmentId, - sourceFamily: 'context', - budget, - }) - await index.stageRelistPage({ - environmentId, - sourceFamily: 'context', - stagingGeneration, - projections: [projection], - budget, - }) - - const staged = await databasePool.query<{ - relationship_type: string - target_id: string - behavior_attributes: Record - }>( - `SELECT relationship_type, target_id, behavior_attributes - FROM operational_relationships_staging - WHERE environment_id = $1 - AND source_family = 'context' - AND source_id = $2 - AND source_generation = $3 - ORDER BY relationship_type, target_id, behavior_attributes->>'mountPath'`, - [environmentId, sourceId, stagingGeneration] - ) - expect(staged.rows).toEqual([ - { - relationship_type: 'includes_mcp_server', - target_id: `${config.mcpServersNamespace}/server-a`, - behavior_attributes: {}, - }, - { - relationship_type: 'includes_mcp_server', - target_id: `${config.mcpServersNamespace}/server-b`, - behavior_attributes: {}, - }, - { - relationship_type: 'mounts_shared_filesystem', - target_id: `${config.sharedFilesystemsNamespace}/filesystem-a`, - behavior_attributes: { mountPath: '/workspace/a', readOnly: true }, - }, - { - relationship_type: 'mounts_shared_filesystem', - target_id: `${config.sharedFilesystemsNamespace}/filesystem-a`, - behavior_attributes: { mountPath: '/workspace/b', readOnly: true }, - }, - ]) - await index.promoteRelist({ - environmentId, - sourceFamily: 'context', - stagingGeneration, - resourceVersion: '1', - budget, - }) - - const promoted = await databasePool.query<{ relationship_type: string; target_id: string }>( - `SELECT relationship_type, target_id - FROM operational_resource_relationships - WHERE environment_id = $1 - AND source_family = 'context' - AND source_id = $2 - ORDER BY relationship_type, target_id`, - [environmentId, sourceId] - ) - expect(promoted.rows).toEqual([ - { - relationship_type: 'includes_mcp_server', - target_id: `${config.mcpServersNamespace}/server-a`, - }, - { - relationship_type: 'includes_mcp_server', - target_id: `${config.mcpServersNamespace}/server-b`, - }, - { - relationship_type: 'mounts_shared_filesystem', - target_id: `${config.sharedFilesystemsNamespace}/filesystem-a`, - }, - { - relationship_type: 'mounts_shared_filesystem', - target_id: `${config.sharedFilesystemsNamespace}/filesystem-a`, - }, - ]) - } finally { - budget.close() - } - }) -}) diff --git a/control-api/test/catalogProducers.test.ts b/control-api/test/catalogProducers.test.ts deleted file mode 100644 index 406a908168..0000000000 --- a/control-api/test/catalogProducers.test.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import fc from 'fast-check' -import type { DbClient } from '../src/db.js' -import { AccessExecutionBudget } from '../src/services/access/accessExecutionBudget.js' -import { - CATALOG_FAMILIES, - type CatalogOperationalSourceState, - type CatalogRequestContext, - catalogKey, -} from '../src/services/access/catalogContracts.js' -import { CATALOG_KEY_SQL } from '../src/services/access/catalogProducerSql.js' -import { listBoundedProducerKeys } from '../src/services/access/catalogProducerSupport.js' -import { - catalogProducers, - requireCatalogProducer, -} from '../src/services/access/catalogProducers.js' -import { canonicalEnvironmentId } from '../src/services/access/operationalAccessProjection.js' - -const environmentId = canonicalEnvironmentId() -const userId = '10000000-0000-4000-8000-000000000001' -const budgets: AccessExecutionBudget[] = [] - -function context( - query: ReturnType, - sourceStates: CatalogOperationalSourceState[] = [] -): CatalogRequestContext { - const budget = AccessExecutionBudget.create('catalog') - budgets.push(budget) - return { - db: { query } as unknown as Pick, - budget, - principal: { - userId, - sessionContract: 'v2', - sessionRevision: 'session-1', - userRevision: 'user-1', - authorizationRevision: 'catalog-authorization-1', - memberships: [], - }, - environmentId, - sourceStates: new Map(sourceStates.map(state => [state.family, state])), - } -} - -afterEach(() => { - for (const budget of budgets.splice(0)) budget.close() -}) - -describe('catalog producer registry', () => { - it('registers exactly the twelve frozen PR 1 families', () => { - expect([...catalogProducers.keys()]).toEqual(CATALOG_FAMILIES) - }) - - it('keyset-bounds every raw source arm before its final union and never uses offsets', () => { - const expectedArmCounts: Record<(typeof CATALOG_FAMILIES)[number], number> = { - user: 1, - team: 1, - host: 2, - context: 2, - mcp_server: 4, - workflow_recipe: 2, - workflow_run: 3, - workflow_approval: 2, - notification: 2, - gfs_resource: 2, - shared_filesystem: 2, - sandbox_app: 2, - } - for (const family of CATALOG_FAMILIES) { - const sql = CATALOG_KEY_SQL[family] - expect(sql).not.toMatch(/\bOFFSET\b/i) - expect(sql.match(/AS MATERIALIZED/g)).toHaveLength(expectedArmCounts[family]) - expect(sql.match(/LIMIT \$4/g)).toHaveLength(expectedArmCounts[family]) - expect(sql).not.toMatch(/SELECT DISTINCT ON/i) - } - }) -}) - -describe('catalog producer protocol', () => { - it('returns a stable keyset page with one bounded lookahead candidate', async () => { - const query = vi.fn().mockResolvedValue({ - rows: [ - { - source_arm: 'source_0', - source_rows: [ - { logical_id: '20000000-0000-4000-8000-000000000002', valid_until: null }, - { logical_id: '30000000-0000-4000-8000-000000000003', valid_until: null }, - ], - source_saturated: false, - }, - ], - rowCount: 2, - }) - const result = await requireCatalogProducer('team').listCanonicalKeys( - context(query), - { afterKey: null, exhausted: false }, - 1 - ) - - expect(result.candidates.map(candidate => candidate.key[2])).toEqual([ - '20000000-0000-4000-8000-000000000002', - '30000000-0000-4000-8000-000000000003', - ]) - expect(result.hasMore).toBe(true) - expect(query).toHaveBeenCalledWith(expect.any(String), [ - userId, - '', - environmentId, - 2, - expect.any(String), - expect.any(String), - '', - '{}', - null, - ]) - }) - - it('rejects duplicate or non-increasing producer keys', async () => { - const query = vi.fn().mockResolvedValue({ - rows: [ - { - source_arm: 'source_0', - source_rows: [{ logical_id: '', valid_until: null }], - source_saturated: false, - }, - ], - rowCount: 2, - }) - await expect( - requireCatalogProducer('notification').listCanonicalKeys( - context(query), - { afterKey: null, exhausted: false }, - 2 - ) - ).rejects.toMatchObject({ code: 'keys_not_strictly_ordered' }) - }) - - it('keeps raw continuation private to its contributing source arm', async () => { - const query = vi - .fn() - .mockResolvedValueOnce({ - rows: [ - { - source_arm: 'source_0', - source_rows: [ - { logical_id: 'x', valid_until: null }, - { logical_id: 'y', valid_until: null }, - { logical_id: 'z', valid_until: null }, - ], - source_saturated: false, - }, - { - source_arm: 'source_1', - source_rows: [ - { logical_id: 'a', valid_until: null }, - { logical_id: 'a', valid_until: null }, - { logical_id: 'a', valid_until: null }, - ], - source_saturated: true, - }, - ], - rowCount: 2, - }) - .mockResolvedValueOnce({ - rows: [ - { - source_arm: 'source_1', - source_rows: [ - { logical_id: 'b', valid_until: null }, - { logical_id: 'c', valid_until: null }, - ], - source_saturated: false, - }, - ], - rowCount: 1, - }) - - const result = await listBoundedProducerKeys({ - context: context(query), - family: 'host', - requiredOperationalSources: [], - continuation: { afterKey: null, exhausted: false }, - take: 2, - sql: 'SELECT 1', - }) - - expect(result.candidates.map(candidate => candidate.key[2])).toEqual(['a', 'b', 'c']) - expect(result.continuation.afterKey?.[2]).toBe('c') - expect(query).toHaveBeenCalledTimes(2) - expect(query.mock.calls[1]?.[1]?.[4]).toBe(JSON.stringify({ source_0: '', source_1: 'a' })) - expect(query.mock.calls[1]?.[1]?.[5]).toBe(JSON.stringify({ source_1: true })) - }) - - it('is invariant to arm order, raw batch boundaries, and duplicate cluster size', async () => { - await fc.assert( - fc.asyncProperty( - fc.array(fc.integer({ min: 0, max: 20 }), { minLength: 1, maxLength: 24 }), - fc.array(fc.integer({ min: 0, max: 20 }), { minLength: 1, maxLength: 24 }), - fc.integer({ min: 1, max: 4 }), - fc.boolean(), - async (leftValues, rightValues, take, reverseArms) => { - const streams = [ - [...leftValues].sort((left, right) => left - right), - [...rightValues].sort((left, right) => left - right), - ].map(values => values.map(value => `id-${String(value).padStart(3, '0')}`)) - const query = vi.fn(async (_text: string, values: unknown[]) => { - const afterByArm = JSON.parse(String(values[4])) as Record - const active = - values[5] === null ? null : (JSON.parse(String(values[5])) as Record) - const rows = streams.flatMap((stream, index) => { - const arm = `source_${index}` - if (active !== null && !active[arm]) return [] - const batch = stream - .filter(logicalId => logicalId > (afterByArm[arm] ?? '')) - .slice(0, take + 1) - return [ - { - source_arm: arm, - source_rows: batch.map(logical_id => ({ logical_id, valid_until: null })), - source_saturated: batch.length >= take + 1, - }, - ] - }) - return { rows: reverseArms ? rows.reverse() : rows, rowCount: rows.length } - }) - const budget = AccessExecutionBudget.create('catalog') - try { - const result = await listBoundedProducerKeys({ - context: { - ...context(query), - budget, - }, - family: 'host', - requiredOperationalSources: [], - continuation: { afterKey: null, exhausted: false }, - take, - sql: 'SELECT 1', - }) - const expected = [...new Set(streams.flat())].sort().slice(0, take + 1) - expect(result.candidates.map(candidate => candidate.key[2])).toEqual(expected) - expect(new Set(result.candidates.map(candidate => candidate.key[2])).size).toBe( - result.candidates.length - ) - } finally { - budget.close() - } - } - ), - { numRuns: 100 } - ) - }) - - it('reports an operational source gap as partial without querying authority', async () => { - const query = vi.fn() - const result = await requireCatalogProducer('host').listCanonicalKeys( - context(query, [ - { - family: 'host', - generation: '2', - resourceVersion: null, - status: 'relisting', - }, - ]), - { afterKey: null, exhausted: false }, - 10 - ) - - expect(result).toEqual( - expect.objectContaining({ - sourceCompleteness: 'partial', - candidates: [], - partialErrors: [expect.objectContaining({ code: 'operational_source_relisting' })], - }) - ) - expect(query).not.toHaveBeenCalled() - }) - - it('hydrates a real user path shape only for selected canonical IDs', async () => { - const query = vi.fn().mockResolvedValue({ - rows: [ - { - logical_id: userId, - display_name: 'Ada', - provider_uid: null, - resource_revision: '4', - paths: [{ kind: 'direct', grant_id: `users:${userId}` }], - relationships: [], - valid_until: null, - }, - ], - rowCount: 1, - }) - const result = await requireCatalogProducer('user').hydrateCanonicalKeys(context(query), [ - catalogKey(environmentId, 'user', userId), - ]) - - expect(result).toEqual([ - expect.objectContaining({ - key: [environmentId, 'user', userId], - authorizationResourceRevision: '4', - authorizationSourceRevision: 'database-resource', - authorizationRelationshipsRevision: '[]', - accessPaths: [expect.objectContaining({ kind: 'direct', grantId: `users:${userId}` })], - }), - ]) - expect(query).toHaveBeenCalledWith(expect.any(String), [userId, [userId], environmentId]) - }) -}) diff --git a/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts b/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts deleted file mode 100644 index 59d3cb9b42..0000000000 --- a/control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts +++ /dev/null @@ -1,929 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { randomBytes, randomUUID } from 'node:crypto' -import { Pool, type PoolClient } from 'pg' -import { type DbClient, initDb } from '../src/db.js' -import { buildAccessCatalog } from '../src/services/access/accessCatalogCoordinator.js' -import { AccessCatalogCursorError } from '../src/services/access/accessCatalogCursor.js' -import { GFS_HYDRATION_SQL } from '../src/services/access/catalogHydrationSql.js' -import { canonicalEnvironmentId } from '../src/services/access/operationalAccessProjection.js' -import { applyComposableCatalogRevisionSchema } from '../src/services/access/userAccessFoundationSchema.js' -import type { ExternalSessionAuthorityContext } from '../src/services/auth/externalSessionAuthentication.js' -import { acknowledgeDesktopNotificationDelivery } from '../src/services/notificationAckService.js' -import { - markUserBoundNotificationDeliveryFailed, - markUserBoundNotificationDeliverySent, - markUserBoundNotificationDeliverySkippedNoBot, -} from '../src/services/notificationDeliveryQueueService.js' -import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' - -const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL -const describeRealPostgres = adminUrl ? describe : describe.skip -const runtimeRoles = [ - 'control_api_runtime', - 'trace_maintenance_runtime', - 'workflow_recipes_runtime', -] as const - -// Catalog writer tables and the revision component each one advances. This -// was a production table with no reader (P-13); it is now test data. -const CATALOG_WRITER_COMPONENTS = Object.freeze([ - ['users', 'user'], - ['teams', 'team'], - ['team_members', 'user+team'], - ['user_contexts', 'user'], - ['team_contexts', 'team'], - ['user_agents', 'user'], - ['team_agents', 'team'], - ['user_workflow_triggers', 'user'], - ['team_workflow_triggers', 'team'], - ['workflow_runs', 'user+team'], - ['workflow_approval_requests', 'user+team'], - ['notification_deliveries', 'user+team'], - ['gfs_resources', 'resource+gfs-subjects'], - ['gfs_grants', 'resource+user+team'], - ['gfs_shares', 'resource+user+team'], - ['operational_resource_index', 'resource+source-state'], - ['operational_resource_relationships', 'resource+source-state'], - ['operational_catalog_source_state', 'source-state'], -] as const) - -function databaseUrl(baseUrl: string, database: string): string { - const value = new URL(baseUrl) - value.pathname = `/${database}` - return value.toString() -} - -function quoteIdentifier(value: string): string { - return `"${value.replace(/"/g, '""')}"` -} - -function transaction(pool: Pool) { - return async (work: (db: DbClient) => Promise): Promise => { - const client = await pool.connect() - try { - await client.query('BEGIN') - const result = await work(client) - await client.query('COMMIT') - return result - } catch (error) { - await client.query('ROLLBACK') - throw error - } finally { - client.release() - } - } -} - -describeRealPostgres('composable catalog revisions on real PostgreSQL', () => { - const database = `control_api_catalog_revision_${randomBytes(6).toString('hex')}` - const connectionString = databaseUrl( - adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', - database - ) - const userIds = [randomUUID(), randomUUID()] - const teamIds = [randomUUID(), randomUUID()] - let adminPool: Pool - let databasePool: Pool - - async function userRevision(userId: string): Promise { - const result = await databasePool.query<{ revision: string }>( - `SELECT revision::text FROM authorization_user_revisions WHERE user_id = $1`, - [userId] - ) - return Number(result.rows[0]?.revision ?? 1) - } - - async function teamRevision(teamId: string): Promise { - const result = await databasePool.query<{ revision: string }>( - `SELECT revision::text FROM authorization_team_revisions WHERE team_id = $1`, - [teamId] - ) - return Number(result.rows[0]?.revision ?? 1) - } - - async function resourceRevision(resourceId: string): Promise { - const result = await databasePool.query<{ revision: string }>( - `SELECT revision::text - FROM authorization_resource_revisions - WHERE environment_id = $1 - AND resource_type = 'gfs_resource' - AND resource_id = $2`, - [canonicalEnvironmentId(), resourceId] - ) - return Number(result.rows[0]?.revision ?? 0) - } - - async function backendPid(client: PoolClient): Promise { - const result = await client.query<{ pid: number }>('SELECT pg_backend_pid() AS pid') - return result.rows[0]!.pid - } - - async function waitForBlock(blockedPid: number, blockerPid: number): Promise { - for (let attempt = 0; attempt < 100; attempt += 1) { - const result = await databasePool.query<{ blocked: boolean }>( - `SELECT $2::int = ANY(pg_blocking_pids($1::int)) AS blocked`, - [blockedPid, blockerPid] - ) - if (result.rows[0]?.blocked) return - await new Promise(resolve => setTimeout(resolve, 10)) - } - throw new Error('expected revision writer to block on the same component') - } - - beforeAll(async () => { - adminPool = new Pool({ connectionString: adminUrl }) - await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) - await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) - databasePool = new Pool({ connectionString }) - await initDb({ connect: () => databasePool.connect() }) - for (const [index, userId] of userIds.entries()) { - await databasePool.query(`INSERT INTO users(id, email, name) VALUES ($1, $2, $3)`, [ - userId, - `revision-${index}-${userId}@example.test`, - `Revision ${index}`, - ]) - } - for (const [index, teamId] of teamIds.entries()) { - await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, $2)`, [ - teamId, - `Revision Team ${index}`, - ]) - await databasePool.query( - `INSERT INTO team_members(team_id, user_id, role, status) - VALUES ($1, $2, 'member', 'active')`, - [teamId, userIds[0]] - ) - } - }) - - afterAll(async () => { - try { - await endPoolAndWaitForClients(databasePool) - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) - FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) - await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) - } - } finally { - await adminPool?.end() - } - }) - - it('maps the complete writer class without a singleton revision trigger', async () => { - await applyComposableCatalogRevisionSchema(databasePool) - await applyComposableCatalogRevisionSchema(databasePool) - expect(CATALOG_WRITER_COMPONENTS).toHaveLength(18) - const registry = await databasePool.query<{ relation: string | null }>( - `SELECT to_regclass('public.authorization_catalog_writer_components')::text AS relation` - ) - expect(registry.rows).toEqual([{ relation: null }]) - - const globalTriggers = await databasePool.query<{ count: string }>( - `SELECT count(*)::text AS count - FROM pg_trigger - WHERE NOT tgisinternal AND tgname LIKE '%_catalog_revision'` - ) - expect(globalTriggers.rows).toEqual([{ count: '0' }]) - - const triggerTables = await databasePool.query<{ table_name: string }>( - `SELECT DISTINCT event_object_table AS table_name - FROM information_schema.triggers - WHERE trigger_name LIKE '%_authorization_revision'` - ) - const directlyVersioned = new Set(['operational_catalog_source_state']) - const triggered = new Set(triggerTables.rows.map(row => row.table_name)) - for (const [writerTable] of CATALOG_WRITER_COMPONENTS) { - expect(triggered.has(writerTable) || directlyVersioned.has(writerTable), writerTable).toBe( - true - ) - } - }) - - it('commits and rolls back only the relevant principal component', async () => { - const initial = await userRevision(userIds[0]) - const unrelatedInitial = await userRevision(userIds[1]) - - const rollback = await databasePool.connect() - try { - await rollback.query('BEGIN') - await rollback.query(`UPDATE users SET name = 'rolled back' WHERE id = $1`, [userIds[0]]) - await rollback.query('ROLLBACK') - } finally { - rollback.release() - } - expect(await userRevision(userIds[0])).toBe(initial) - - await databasePool.query(`UPDATE users SET name = 'committed' WHERE id = $1`, [userIds[0]]) - expect(await userRevision(userIds[0])).toBeGreaterThan(initial) - expect(await userRevision(userIds[1])).toBe(unrelatedInitial) - }) - - it('bumps the mapped principal for dynamic catalog writer classes', async () => { - let revision = await userRevision(userIds[0]) - const workflowRun = await databasePool.query<{ run_id: string }>( - `INSERT INTO workflow_runs( - recipe_namespace, recipe_name, phase, actor_type, actor_id, trigger_source - ) VALUES ('sandbox-recipes', 'revision-test', 'Pending', 'user', $1, 'onDemand') - RETURNING run_id::text`, - [userIds[0]] - ) - expect(await userRevision(userIds[0])).toBeGreaterThan(revision) - - revision = await userRevision(userIds[0]) - await databasePool.query( - `INSERT INTO workflow_approval_requests( - recipe_namespace, recipe_name, expires_at, status, target_user_id, - payload, idempotency_key - ) VALUES ('sandbox-recipes', 'revision-test', NOW() + INTERVAL '1 hour', - 'pending', $1, '{}'::jsonb, $2)`, - [userIds[0], `revision-${randomUUID()}`] - ) - expect(await userRevision(userIds[0])).toBeGreaterThan(revision) - - revision = await userRevision(userIds[0]) - await databasePool.query( - `INSERT INTO notification_deliveries( - event_type, dedupe_key, audience, payload, status - ) VALUES ('revision.test', $1, jsonb_build_object('userId', $2::text), '{}'::jsonb, 'queued')`, - [`revision-${randomUUID()}`, userIds[0]] - ) - expect(await userRevision(userIds[0])).toBeGreaterThan(revision) - - const resourceId = randomUUID() - await databasePool.query( - `INSERT INTO gfs_resources(resource_id, drive, name, kind) - VALUES ($1, '3rd', 'revision-test', 'file')`, - [resourceId] - ) - revision = await userRevision(userIds[0]) - await databasePool.query( - `INSERT INTO gfs_grants(drive, resource_id, subject_type, subject_id, permissions) - VALUES ('3rd', $1, 'user', $2, ARRAY['read']::text[])`, - [resourceId, userIds[0]] - ) - expect(await userRevision(userIds[0])).toBeGreaterThan(revision) - - revision = await userRevision(userIds[0]) - await databasePool.query( - `UPDATE gfs_resources SET name = 'revision-test-2' WHERE resource_id = $1`, - [resourceId] - ) - expect(await userRevision(userIds[0])).toBeGreaterThan(revision) - - revision = await userRevision(userIds[0]) - await databasePool.query(`DELETE FROM workflow_runs WHERE run_id = $1`, [ - workflowRun.rows[0]!.run_id, - ]) - expect(await userRevision(userIds[0])).toBeGreaterThan(revision) - }) - - it('bumps the team revision for allowed-team changes', async () => { - const recipeName = `p10-${randomUUID()}` - let revision = await teamRevision(teamIds[0]) - await databasePool.query( - `INSERT INTO workflow_recipe_allowed_teams(recipe_namespace, recipe_name, team_id) - VALUES ('sandbox-recipes', $1, $2)`, - [recipeName, teamIds[0]] - ) - expect(await teamRevision(teamIds[0])).toBeGreaterThan(revision) - - revision = await teamRevision(teamIds[0]) - await databasePool.query( - `DELETE FROM workflow_recipe_allowed_teams - WHERE recipe_namespace = 'sandbox-recipes' AND recipe_name = $1 AND team_id = $2`, - [recipeName, teamIds[0]] - ) - expect(await teamRevision(teamIds[0])).toBeGreaterThan(revision) - }) - - it('bumps the user revision for approval medium-account changes', async () => { - let revision = await userRevision(userIds[1]) - const account = await databasePool.query<{ id: string }>( - `INSERT INTO workflow_approval_medium_accounts( - user_id, medium, provider_user_id, verified_at - ) VALUES ($1, 'slack', $2, NOW()) - RETURNING id::text`, - [userIds[1], `provider-${randomUUID()}`] - ) - expect(await userRevision(userIds[1])).toBeGreaterThan(revision) - - revision = await userRevision(userIds[1]) - await databasePool.query(`DELETE FROM workflow_approval_medium_accounts WHERE id = $1`, [ - account.rows[0]!.id, - ]) - expect(await userRevision(userIds[1])).toBeGreaterThan(revision) - }) - - it('leaves run subject revisions unchanged on heartbeats and bumps them on a subject change', async () => { - const team = teamIds[0] - const nextTeam = teamIds[1] - const user = userIds[0] - const run = randomUUID() - await databasePool.query( - `INSERT INTO workflow_runs(run_id, recipe_namespace, recipe_name, phase, actor_type, - actor_id, team_id, usage_team_id, trigger_source) - VALUES ($1, 'sandbox-recipes', 'revision-heartbeat', 'Pending', 'user', $2, $3, $4, - 'onDemand')`, - [run, user, team, team] - ) - const beforeUser = await userRevision(user) - const beforeTeam = await teamRevision(team) - const beforeNextTeam = await teamRevision(nextTeam) - - // The workflow-recipes reconciler SQL, run as its runtime role. None of - // these statements changes a column a catalog consumer reads. - const client = await databasePool.connect() - try { - await client.query('BEGIN') - await client.query('SET LOCAL ROLE workflow_recipes_runtime') - const progress = await client.query( - `UPDATE workflow_runs - SET last_reconciled_at = now(), - updated_at = now() - WHERE run_id = $1 - AND phase IN ('Pending', 'Running')`, - [run] - ) - expect(progress.rowCount).toBe(1) - const claim = await client.query( - `UPDATE workflow_runs - SET phase = 'Running', - owner_instance_id = $1, - child_recipe_name = $2, - child_recipe_namespace = $3, - started_at = now(), - last_reconciled_at = now(), - updated_at = now() - WHERE run_id = $4 - AND phase = 'Pending' - AND (owner_instance_id IS NULL OR owner_instance_id = $1)`, - ['owner-heartbeat', null, null, run] - ) - expect(claim.rowCount).toBe(1) - const terminal = await client.query( - `UPDATE workflow_runs - SET phase = $1, - completed_at = COALESCE($2::timestamptz, now()), - last_reconciled_at = now(), - updated_at = now() - WHERE run_id = $3 - AND ( - phase IN ('Pending', 'Running') - OR (phase = 'Failed' AND $1 = 'Succeeded') - )`, - ['Succeeded', null, run] - ) - expect(terminal.rowCount).toBe(1) - await client.query('COMMIT') - } finally { - await client.query('ROLLBACK').catch(() => undefined) - client.release() - } - - // Write witness: the heartbeat rows really changed. - expect( - ( - await databasePool.query( - `SELECT phase, last_reconciled_at IS NOT NULL AS reconciled, - started_at IS NOT NULL AS started, completed_at IS NOT NULL AS completed, - owner_instance_id - FROM workflow_runs WHERE run_id = $1`, - [run] - ) - ).rows - ).toEqual([ - { - phase: 'Succeeded', - reconciled: true, - started: true, - completed: true, - owner_instance_id: 'owner-heartbeat', - }, - ]) - // Negative witness: no subject revision moved. - expect(await userRevision(user)).toBe(beforeUser) - expect(await teamRevision(team)).toBe(beforeTeam) - expect(await teamRevision(nextTeam)).toBe(beforeNextTeam) - - // Liveness witness in the same test: a subject change still bumps. The - // team moves to nextTeam while the usage team stays on the old team, so the - // NEW image bumps nextTeam and the old usage team, and the OLD image bumps - // the old team and the old usage team: the old team moves three times. - await databasePool.query('UPDATE workflow_runs SET team_id = $2 WHERE run_id = $1', [ - run, - nextTeam, - ]) - expect(await userRevision(user)).toBe(beforeUser + 2) - expect(await teamRevision(team)).toBe(beforeTeam + 3) - expect(await teamRevision(nextTeam)).toBe(beforeNextTeam + 1) - }) - - it('bumps ordered audit-run subjects and permits the Admin UI sentinel', async () => { - const runId = randomUUID() - let userBefore = await userRevision(userIds[0]) - let teamBefore = await teamRevision(teamIds[1]) - await databasePool.query( - `INSERT INTO workflow_runs_audit( - run_id, run_namespace, run_name, recipe_namespace, recipe_name, - triggerer_team_id, usage_team_id, triggerer_user_id, triggerer_actor_type, - trigger_source, idempotency_key, triggered_at, completed_at, - final_phase, step_count, snapshot_sha - ) VALUES ( - $1, 'revision', $2, 'sandbox-recipes', $2, - $3, NULL, $4, 'user', 'onDemand', $5, NOW(), NOW(), - 'Succeeded', 0, 'p15-audit-witness' - )`, - [runId, `audit-${randomUUID()}`, teamIds[1], userIds[0], `audit-${randomUUID()}`] - ) - expect(await userRevision(userIds[0])).toBeGreaterThan(userBefore) - expect(await teamRevision(teamIds[1])).toBeGreaterThan(teamBefore) - - userBefore = await userRevision(userIds[0]) - teamBefore = await teamRevision(teamIds[1]) - await databasePool.query(`DELETE FROM workflow_runs_audit WHERE run_id = $1`, [runId]) - expect(await userRevision(userIds[0])).toBeGreaterThan(userBefore) - expect(await teamRevision(teamIds[1])).toBeGreaterThan(teamBefore) - - const sentinelRunId = randomUUID() - userBefore = await userRevision(userIds[1]) - teamBefore = await teamRevision(teamIds[0]) - await databasePool.query( - `INSERT INTO workflow_runs_audit( - run_id, run_namespace, run_name, recipe_namespace, recipe_name, - triggerer_team_id, usage_team_id, triggerer_user_id, triggerer_actor_type, - trigger_source, idempotency_key, triggered_at, completed_at, - final_phase, step_count, snapshot_sha - ) VALUES ( - $1, 'revision', $2, 'sandbox-recipes', $2, - NULL, 'control-plane-admin-ui', NULL, 'scheduled', 'schedule', $3, - NOW(), NOW(), 'Succeeded', 0, 'p15-sentinel-witness' - )`, - [sentinelRunId, `audit-${randomUUID()}`, `audit-${randomUUID()}`] - ) - expect(await userRevision(userIds[1])).toBe(userBefore) - expect(await teamRevision(teamIds[0])).toBe(teamBefore) - - await expect( - databasePool.query( - `INSERT INTO workflow_runs_audit( - run_id, run_namespace, run_name, recipe_namespace, recipe_name, - triggerer_team_id, usage_team_id, triggerer_user_id, triggerer_actor_type, - trigger_source, idempotency_key, triggered_at, completed_at, - final_phase, step_count, snapshot_sha - ) VALUES ( - $1, 'revision', $2, 'sandbox-recipes', $2, - NULL, 'not-a-uuid', NULL, 'scheduled', 'schedule', $3, - NOW(), NOW(), 'Succeeded', 0, 'p15-invalid-witness' - )`, - [randomUUID(), `audit-${randomUUID()}`, `audit-${randomUUID()}`] - ) - ).rejects.toMatchObject({ code: 'P0001' }) - }) - - it('moves GFS resource and subject components for the complete writer class', async () => { - const firstResourceId = randomUUID() - const secondResourceId = randomUUID() - const firstDrive = `r6-${randomBytes(4).toString('hex')}` - const secondDrive = `r6-${randomBytes(4).toString('hex')}` - await databasePool.query( - `INSERT INTO gfs_resources(resource_id, drive, name, kind) - VALUES ($1, $3, 'resource-one', 'file'), - ($2, $4, 'resource-two', 'file')`, - [firstResourceId, secondResourceId, firstDrive, secondDrive] - ) - const firstInsertedRevision = await resourceRevision(firstResourceId) - const secondInsertedRevision = await resourceRevision(secondResourceId) - expect(firstInsertedRevision).toBeGreaterThan(0) - expect(secondInsertedRevision).toBeGreaterThan(0) - - const grant = await databasePool.query<{ id: string }>( - `INSERT INTO gfs_grants(drive, resource_id, subject_type, subject_id, permissions) - VALUES ($3, $1, 'user', $2, ARRAY['read']::text[]) - RETURNING id::text`, - [firstResourceId, userIds[0], firstDrive] - ) - expect(await resourceRevision(firstResourceId)).toBeGreaterThan(firstInsertedRevision) - expect(await resourceRevision(secondResourceId)).toBe(secondInsertedRevision) - - const beforeMoveFirst = await resourceRevision(firstResourceId) - const beforeMoveSecond = await resourceRevision(secondResourceId) - await databasePool.query(`UPDATE gfs_grants SET resource_id = $2, drive = $3 WHERE id = $1`, [ - grant.rows[0]!.id, - secondResourceId, - secondDrive, - ]) - expect(await resourceRevision(firstResourceId)).toBeGreaterThan(beforeMoveFirst) - expect(await resourceRevision(secondResourceId)).toBeGreaterThan(beforeMoveSecond) - - const share = await databasePool.query<{ id: string }>( - `INSERT INTO gfs_shares(drive, resource_id, subject_type, subject_id, permissions) - VALUES ($3, $1, 'team', $2, ARRAY['read']::text[]) - RETURNING id::text`, - [firstResourceId, teamIds[0], firstDrive] - ) - const beforeShareMoveFirst = await resourceRevision(firstResourceId) - const beforeShareMoveSecond = await resourceRevision(secondResourceId) - await databasePool.query(`UPDATE gfs_shares SET resource_id = $2, drive = $3 WHERE id = $1`, [ - share.rows[0]!.id, - secondResourceId, - secondDrive, - ]) - expect(await resourceRevision(firstResourceId)).toBeGreaterThan(beforeShareMoveFirst) - expect(await resourceRevision(secondResourceId)).toBeGreaterThan(beforeShareMoveSecond) - - const beforeGrantDelete = await resourceRevision(secondResourceId) - await databasePool.query(`DELETE FROM gfs_grants WHERE id = $1`, [grant.rows[0]!.id]) - expect(await resourceRevision(secondResourceId)).toBeGreaterThan(beforeGrantDelete) - - const beforeShareDelete = await resourceRevision(secondResourceId) - await databasePool.query(`DELETE FROM gfs_shares WHERE id = $1`, [share.rows[0]!.id]) - expect(await resourceRevision(secondResourceId)).toBeGreaterThan(beforeShareDelete) - - const beforeRollback = await resourceRevision(firstResourceId) - const rollback = await databasePool.connect() - try { - await rollback.query('BEGIN') - await rollback.query(`UPDATE gfs_resources SET name = 'rolled-back' WHERE resource_id = $1`, [ - firstResourceId, - ]) - await rollback.query('ROLLBACK') - } finally { - rollback.release() - } - expect(await resourceRevision(firstResourceId)).toBe(beforeRollback) - - const userRevisionBefore = await userRevision(userIds[0]) - await databasePool.query( - `INSERT INTO gfs_grants(drive, resource_id, subject_type, subject_id, permissions) - VALUES ($3, $1, 'user', $2, ARRAY['read']::text[])`, - [firstResourceId, userIds[0], firstDrive] - ) - expect(await userRevision(userIds[0])).toBeGreaterThan(userRevisionBefore) - - const hydratedBefore = await databasePool.query<{ resource_revision: string }>( - GFS_HYDRATION_SQL, - [userIds[0], [firstResourceId], canonicalEnvironmentId(), 8] - ) - const beforeResourceUpdate = await resourceRevision(firstResourceId) - await databasePool.query( - `UPDATE gfs_resources SET name = name || '-changed' WHERE resource_id = $1`, - [firstResourceId] - ) - const hydratedAfter = await databasePool.query<{ resource_revision: string }>( - GFS_HYDRATION_SQL, - [userIds[0], [firstResourceId], canonicalEnvironmentId(), 8] - ) - expect(await resourceRevision(firstResourceId)).toBeGreaterThan(beforeResourceUpdate) - expect(Number(hydratedAfter.rows[0]?.resource_revision)).toBeGreaterThan( - Number(hydratedBefore.rows[0]?.resource_revision) - ) - }) - - it('leaves GFS resource revisions unchanged on content writes and bumps them on a rename', async () => { - const resourceId = randomUUID() - const drive = `r6-${randomBytes(4).toString('hex')}` - await databasePool.query( - `INSERT INTO gfs_resources(resource_id, drive, name, kind) - VALUES ($1, $2, 'content-resource', 'file')`, - [resourceId, drive] - ) - await databasePool.query( - `INSERT INTO gfs_grants(drive, resource_id, subject_type, subject_id, permissions) - VALUES ($3, $1, 'user', $2, ARRAY['read']::text[])`, - [resourceId, userIds[0], drive] - ) - const beforeResource = await resourceRevision(resourceId) - const beforeUser = await userRevision(userIds[0]) - - const blobKey = `${resourceId.replaceAll('-', '')}/${randomUUID()}` - const contentSha = 'a'.repeat(64) - // The gfs-controller content save: version, bytes, blob_key, content_sha256. - await databasePool.query( - `UPDATE gfs_resources - SET version = version + 1, - bytes = 4096, - blob_key = $2, - content_sha256 = $3, - updated_at = clock_timestamp() - WHERE resource_id = $1`, - [resourceId, blobKey, contentSha] - ) - // Write witness: the content save really changed the row. - expect( - ( - await databasePool.query( - `SELECT version, bytes::text AS bytes, blob_key, content_sha256 - FROM gfs_resources WHERE resource_id = $1`, - [resourceId] - ) - ).rows - ).toEqual([ - { - version: 1, - bytes: '4096', - blob_key: blobKey, - content_sha256: contentSha, - }, - ]) - // Negative witness: a content save is not a catalog change, neither for the - // resource component nor for its direct grant subjects. - expect(await resourceRevision(resourceId)).toBe(beforeResource) - expect(await userRevision(userIds[0])).toBe(beforeUser) - - // Liveness witness in the same test: a rename still bumps both. - await databasePool.query( - `UPDATE gfs_resources SET name = 'content-resource-renamed' WHERE resource_id = $1`, - [resourceId] - ) - expect(await resourceRevision(resourceId)).toBe(beforeResource + 1) - expect(await userRevision(userIds[0])).toBe(beforeUser + 1) - }) - - it('leaves notification subject revisions unchanged on delivery state writes and bumps them when a catalog column changes', async () => { - const notificationUser = userIds[0] - const notificationTeam = teamIds[0] - async function delivery( - eventType: string, - audience: Record, - payload: Record - ): Promise { - const result = await databasePool.query<{ id: string }>( - `INSERT INTO notification_deliveries( - event_type, dedupe_key, audience, payload, status, next_attempt_at - ) VALUES ($1, $2, $3::jsonb, $4::jsonb, 'queued', NOW() - INTERVAL '1 minute') - RETURNING id::text`, - [ - eventType, - `notification-revision-${randomUUID()}`, - JSON.stringify(audience), - JSON.stringify(payload), - ] - ) - return result.rows[0]!.id - } - - // One row per writer (N6): every mark* only writes while its row is still - // queued/retrying, so a row reused after `sent` would make the next call a - // silent no-op and its negative assertion vacuous. - const sentRow = await delivery('approval.requested', { userId: notificationUser }, {}) - const failedRow = await delivery('approval.requested', { userId: notificationUser }, {}) - // At least one state write targets a team audience, so the team negative - // has a write witness of its own. - const skippedRow = await delivery('approval.requested', { teamId: notificationTeam }, {}) - const claimRow = await delivery('approval.requested', { userId: notificationUser }, {}) - // No `notificationId` in the payload: the ack then skips - // markSdkNotificationDelivered, which writes through the module pool - // instead of the test client (notificationAckService.ts:36-39). - const desktopRow = await delivery( - 'plugin_workload_sdk.notification', - { userId: notificationUser }, - { origin: 'plugin_workload_sdk', title: 'no invocation' } - ) - const beforeUser = await userRevision(notificationUser) - const beforeTeam = await teamRevision(notificationTeam) - - // The real exported writers, each against the test pool as their `db`. - expect(await markUserBoundNotificationDeliverySent(sentRow, databasePool)).toBe(true) - expect(await markUserBoundNotificationDeliveryFailed(failedRow, databasePool)).toBe(true) - expect(await markUserBoundNotificationDeliverySkippedNoBot(skippedRow, databasePool)).toBe(true) - expect( - await acknowledgeDesktopNotificationDelivery(notificationUser, desktopRow, databasePool) - ).toEqual({ status: 'acked' }) - // The claims' literal SET clause (notificationDeliveryQueueService.ts:315-321, - // :698-704); both claims change only these three columns. - await databasePool.query( - `UPDATE notification_deliveries nd - SET status = 'retrying', - attempts = nd.attempts + 1, - next_attempt_at = NOW() + INTERVAL '30 seconds' - WHERE nd.id = $1`, - [claimRow] - ) - - // Write witness: the state writes really changed the rows. - const written = await databasePool.query<{ - id: string - status: string - attempts: number - delivered_medium: string | null - }>( - `SELECT id::text, status, attempts, delivered_medium - FROM notification_deliveries - WHERE id = ANY($1::uuid[])`, - [[sentRow, failedRow, skippedRow, claimRow, desktopRow]] - ) - expect(Object.fromEntries(written.rows.map(row => [row.id, row]))).toEqual({ - [sentRow]: { id: sentRow, status: 'sent', attempts: 0, delivered_medium: null }, - [failedRow]: { id: failedRow, status: 'retrying', attempts: 0, delivered_medium: null }, - [skippedRow]: { - id: skippedRow, - status: 'skipped_no_bot', - attempts: 0, - delivered_medium: null, - }, - [claimRow]: { id: claimRow, status: 'retrying', attempts: 1, delivered_medium: null }, - [desktopRow]: { id: desktopRow, status: 'sent', attempts: 0, delivered_medium: 'desktop' }, - }) - // Negative witness: none of the delivery state writes is a catalog change. - expect(await userRevision(notificationUser)).toBe(beforeUser) - expect(await teamRevision(notificationTeam)).toBe(beforeTeam) - - // Liveness in the same test. Each step starts from a fresh row, and each - // UPDATE bumps the NEW and the OLD subject of its own row. - const expiresRow = await delivery('approval.requested', { userId: notificationUser }, {}) - let userBefore = await userRevision(notificationUser) - await databasePool.query( - `UPDATE notification_deliveries - SET expires_at = NOW() + INTERVAL '1 hour' - WHERE id = $1`, - [expiresRow] - ) - // One user audience: NEW user +1 and OLD user +1. - expect(await userRevision(notificationUser)).toBe(userBefore + 2) - - const eventTypeRow = await delivery('approval.requested', { userId: notificationUser }, {}) - userBefore = await userRevision(notificationUser) - await databasePool.query( - `UPDATE notification_deliveries SET event_type = 'approval.updated' WHERE id = $1`, - [eventTypeRow] - ) - // event_type is the hydration display name (catalogHydrationSql.ts:629). - expect(await userRevision(notificationUser)).toBe(userBefore + 2) - - const audienceRow = await delivery('approval.requested', { userId: notificationUser }, {}) - userBefore = await userRevision(notificationUser) - let teamBefore = await teamRevision(notificationTeam) - await databasePool.query( - `UPDATE notification_deliveries SET audience = $2::jsonb WHERE id = $1`, - [audienceRow, JSON.stringify({ teamId: notificationTeam })] - ) - // user -> team: OLD user +1, NEW team +1. - expect(await userRevision(notificationUser)).toBe(userBefore + 1) - expect(await teamRevision(notificationTeam)).toBe(teamBefore + 1) - userBefore = await userRevision(notificationUser) - teamBefore = await teamRevision(notificationTeam) - await databasePool.query( - `UPDATE notification_deliveries SET audience = $2::jsonb WHERE id = $1`, - [audienceRow, JSON.stringify({ userId: notificationUser })] - ) - // team -> user: OLD team +1, NEW user +1; the round trip is user +2, team +2. - expect(await userRevision(notificationUser)).toBe(userBefore + 1) - expect(await teamRevision(notificationTeam)).toBe(teamBefore + 1) - - const deleteRow = await delivery('approval.requested', { teamId: notificationTeam }, {}) - teamBefore = await teamRevision(notificationTeam) - await databasePool.query('DELETE FROM notification_deliveries WHERE id = $1', [deleteRow]) - // DELETE bumps the removed row's subject once. - expect(await teamRevision(notificationTeam)).toBe(teamBefore + 1) - - userBefore = await userRevision(notificationUser) - await delivery('approval.requested', { userId: notificationUser }, {}) - // A new delivery has a random id that can sort before a client cursor, so - // INSERT keeps bumping once. - expect(await userRevision(notificationUser)).toBe(userBefore + 1) - }) - - it('preserves concurrent GFS invalidation increments on one resource component', async () => { - const resourceId = randomUUID() - const drive = `r6-${randomBytes(4).toString('hex')}` - await databasePool.query( - `INSERT INTO gfs_resources(resource_id, drive, name, kind) - VALUES ($1, $2, 'concurrent-resource', 'file')`, - [resourceId, drive] - ) - const before = await resourceRevision(resourceId) - const blocker = await databasePool.connect() - const blocked = await databasePool.connect() - try { - await blocker.query('BEGIN') - await blocked.query('BEGIN') - const blockerPid = await backendPid(blocker) - const blockedPid = await backendPid(blocked) - await blocker.query('SELECT authorization_bump_gfs_resource_component($1)', [resourceId]) - const pending = blocked.query('SELECT authorization_bump_gfs_resource_component($1)', [ - resourceId, - ]) - await waitForBlock(blockedPid, blockerPid) - await blocker.query('COMMIT') - await pending - await blocked.query('COMMIT') - } finally { - await blocker.query('ROLLBACK').catch(() => undefined) - await blocked.query('ROLLBACK').catch(() => undefined) - blocker.release() - blocked.release() - } - expect(await resourceRevision(resourceId)).toBe(before + 2) - }) - - it('does not serialize independent components and preserves same-component increments', async () => { - const first = await databasePool.connect() - const second = await databasePool.connect() - try { - await first.query('BEGIN') - await second.query('BEGIN') - await first.query('SELECT authorization_bump_user_revision($1)', [userIds[0]]) - - await expect( - second.query('SELECT authorization_bump_user_revision($1)', [userIds[1]]) - ).resolves.toBeDefined() - await second.query('COMMIT') - await first.query('COMMIT') - } finally { - await first.query('ROLLBACK').catch(() => undefined) - await second.query('ROLLBACK').catch(() => undefined) - first.release() - second.release() - } - - const before = await userRevision(userIds[0]) - const blocker = await databasePool.connect() - const blocked = await databasePool.connect() - try { - await blocker.query('BEGIN') - await blocked.query('BEGIN') - const blockerPid = await backendPid(blocker) - const blockedPid = await backendPid(blocked) - await blocker.query('SELECT authorization_bump_user_revision($1)', [userIds[0]]) - const pending = blocked.query('SELECT authorization_bump_user_revision($1)', [userIds[0]]) - await waitForBlock(blockedPid, blockerPid) - await blocker.query('COMMIT') - await pending - await blocked.query('COMMIT') - } finally { - blocker.release() - blocked.release() - } - expect(await userRevision(userIds[0])).toBe(before + 2) - }) - - it('rejects a cursor after a committed relevant component change', async () => { - const session: ExternalSessionAuthorityContext = { - contract: 'v1', - userId: userIds[0], - tokenHash: randomBytes(32).toString('hex'), - issuedAt: Math.floor(Date.now() / 1_000), - authGeneration: 1, - } - const first = await buildAccessCatalog( - { session, families: ['team'], limit: 1 }, - { transaction: transaction(databasePool) } - ) - expect(first.nextCursor).not.toBeNull() - - await databasePool.query(`UPDATE teams SET name = name || ' changed' WHERE id = $1`, [ - teamIds[0], - ]) - - await expect( - buildAccessCatalog( - { session, families: ['team'], limit: 1, cursor: first.nextCursor }, - { transaction: transaction(databasePool) } - ) - ).rejects.toEqual(expect.objectContaining({ code: 'stale_cursor' })) - }) - - it('reads authority data and its component from one repeatable-read snapshot', async () => { - const snapshot = await databasePool.connect() - try { - await snapshot.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY') - const before = await snapshot.query<{ name: string; revision: string }>( - `SELECT teams.name, revisions.revision::text - FROM teams - JOIN authorization_team_revisions revisions ON revisions.team_id = teams.id - WHERE teams.id = $1`, - [teamIds[1]] - ) - await databasePool.query(`UPDATE teams SET name = name || ' committed' WHERE id = $1`, [ - teamIds[1], - ]) - const during = await snapshot.query<{ name: string; revision: string }>( - `SELECT teams.name, revisions.revision::text - FROM teams - JOIN authorization_team_revisions revisions ON revisions.team_id = teams.id - WHERE teams.id = $1`, - [teamIds[1]] - ) - expect(during.rows).toEqual(before.rows) - await snapshot.query('COMMIT') - - const after = await databasePool.query<{ name: string; revision: string }>( - `SELECT teams.name, revisions.revision::text - FROM teams - JOIN authorization_team_revisions revisions ON revisions.team_id = teams.id - WHERE teams.id = $1`, - [teamIds[1]] - ) - expect(after.rows[0]?.name).not.toBe(before.rows[0]?.name) - expect(Number(after.rows[0]?.revision)).toBeGreaterThan(Number(before.rows[0]?.revision)) - } finally { - await snapshot.query('ROLLBACK').catch(() => undefined) - snapshot.release() - } - }) -}) diff --git a/control-api/test/catalogSandboxDuplicateClassification.test.ts b/control-api/test/catalogSandboxDuplicateClassification.test.ts deleted file mode 100644 index c43ee77a5c..0000000000 --- a/control-api/test/catalogSandboxDuplicateClassification.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { CATALOG_KEY_SQL } from '../src/services/access/catalogProducerSql.js' - -describe('Sandbox catalog duplicate classification', () => { - it('keeps both relationship arms refillable after a duplicate-saturated batch', () => { - const sandboxSql = CATALOG_KEY_SQL.sandbox_app - - expect(sandboxSql.match(/\(SELECT COUNT\(\*\) FROM source_[01]\) >= \$4/g)).toHaveLength(2) - expect(sandboxSql).not.toContain('FALSE AS source_saturated') - }) -}) diff --git a/control-api/test/db.catalogUtf8OrderingMigration.test.ts b/control-api/test/db.catalogUtf8OrderingMigration.test.ts deleted file mode 100644 index 161da5010f..0000000000 --- a/control-api/test/db.catalogUtf8OrderingMigration.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -vi.mock('../src/config.js', () => ({ - config: { databaseUrl: 'postgresql://test' }, -})) - -describe('0133 catalog UTF-8 ordering migration', () => { - it('installs the immutable byte function and plans its index class online', async () => { - const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') - const { PR1_ONLINE_INDEX_PLAN } = await import('../src/migrations/pr1OnlineIndexPlan.js') - const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0133_catalog_utf8_ordering' - ) - expect(migration).toBeDefined() - - const query = vi.fn(async () => ({ rows: [], rowCount: 0 })) - await migration!.apply({ query }) - const sql = String(query.mock.calls[0]?.[0]) - - expect(sql).toContain('CREATE OR REPLACE FUNCTION catalog_utf8_bytes(value TEXT)') - expect(sql).toContain("SELECT convert_to(value, 'UTF8')") - expect(sql.match(/catalog_utf8_bytes\(/g)).toHaveLength(1) - expect(sql).not.toMatch(/CREATE\s+(UNIQUE\s+)?INDEX/i) - - const expectedIndexes = [ - 'user_agents_catalog_utf8_idx', - 'team_agents_catalog_utf8_idx', - 'user_contexts_catalog_utf8_idx', - 'team_contexts_catalog_utf8_idx', - 'user_workflow_triggers_catalog_utf8_idx', - 'team_workflow_triggers_catalog_utf8_idx', - 'operational_relationship_catalog_utf8_target_idx', - ] - const planned = PR1_ONLINE_INDEX_PLAN.filter( - entry => entry.migrationVersion === '0133_catalog_utf8_ordering' - ) - expect(planned.map(entry => entry.name).sort()).toEqual([...expectedIndexes].sort()) - for (const entry of planned) expect(entry.createSql).toContain('catalog_utf8_bytes(') - }) -}) diff --git a/control-api/test/externalInvitationAcceptSession.realPostgres.integration.test.ts b/control-api/test/externalInvitationAcceptSession.realPostgres.integration.test.ts index 49b5f1e9a4..bf9a7d1ccc 100644 --- a/control-api/test/externalInvitationAcceptSession.realPostgres.integration.test.ts +++ b/control-api/test/externalInvitationAcceptSession.realPostgres.integration.test.ts @@ -50,15 +50,12 @@ const policy = { issueV2: true, renewV2: true, switchCompatibility: false, - computeCatalogShadow: false, - serveCatalog: false, actionContextV2: false, rpcDelegationV2: false, desktopAllTeamMode: false, profileV2Mode: false, minimumClientVersion: null, enforceMinimumClient: false, - advertisedCatalogFamilies: [], } as EffectiveUserAccessPolicy type InvitationFixture = { diff --git a/control-api/test/externalInvitationPasswordFirstSet.realPostgres.integration.test.ts b/control-api/test/externalInvitationPasswordFirstSet.realPostgres.integration.test.ts index c7ab67bf38..8376f4b01b 100644 --- a/control-api/test/externalInvitationPasswordFirstSet.realPostgres.integration.test.ts +++ b/control-api/test/externalInvitationPasswordFirstSet.realPostgres.integration.test.ts @@ -69,15 +69,12 @@ const policy = { issueV2: true, renewV2: true, switchCompatibility: false, - computeCatalogShadow: false, - serveCatalog: false, actionContextV2: false, rpcDelegationV2: false, desktopAllTeamMode: false, profileV2Mode: false, minimumClientVersion: null, enforceMinimumClient: false, - advertisedCatalogFamilies: [], } as EffectiveUserAccessPolicy type StoredUser = { diff --git a/control-api/test/externalSessionAuthentication.test.ts b/control-api/test/externalSessionAuthentication.test.ts index d0b021f39c..6939c34b75 100644 --- a/control-api/test/externalSessionAuthentication.test.ts +++ b/control-api/test/externalSessionAuthentication.test.ts @@ -35,15 +35,12 @@ function policy(overrides: Partial = {}): EffectiveUs issueV2: false, renewV2: false, switchCompatibility: true, - computeCatalogShadow: false, - serveCatalog: false, actionContextV2: false, rpcDelegationV2: false, desktopAllTeamMode: false, profileV2Mode: false, minimumClientVersion: null, enforceMinimumClient: false, - advertisedCatalogFamilies: [], ...overrides, } } diff --git a/control-api/test/externalSessionCurrentness.realPostgres.integration.test.ts b/control-api/test/externalSessionCurrentness.realPostgres.integration.test.ts index 2f809144ff..690afdc5a8 100644 --- a/control-api/test/externalSessionCurrentness.realPostgres.integration.test.ts +++ b/control-api/test/externalSessionCurrentness.realPostgres.integration.test.ts @@ -48,15 +48,12 @@ const policy = { issueV2: true, renewV2: true, switchCompatibility: true, - computeCatalogShadow: false, - serveCatalog: false, actionContextV2: false, rpcDelegationV2: false, desktopAllTeamMode: false, profileV2Mode: false, minimumClientVersion: null, enforceMinimumClient: false, - advertisedCatalogFamilies: [], } as EffectiveUserAccessPolicy function databaseUrl(baseUrl: string, database: string): string { diff --git a/control-api/test/externalSessionSingleTouch.realPostgres.integration.test.ts b/control-api/test/externalSessionSingleTouch.realPostgres.integration.test.ts index f06643abcd..6d3b58dd83 100644 --- a/control-api/test/externalSessionSingleTouch.realPostgres.integration.test.ts +++ b/control-api/test/externalSessionSingleTouch.realPostgres.integration.test.ts @@ -125,10 +125,10 @@ describeRealPostgres('external-session staged touch ownership on real PostgreSQL switchCompatibility: true, minimumClientVersion: null, enforceMinimumClient: false, - catalogMode: 'off', - serveCatalog: false, - shadowCatalog: false, actionContextV2: true, + rpcDelegationV2: false, + desktopAllTeamMode: false, + profileV2Mode: false, } await expect( authenticateExternalUserSession(issued.token, { diff --git a/control-api/test/externalSessionV1CanonicalRevocation.realPostgres.integration.test.ts b/control-api/test/externalSessionV1CanonicalRevocation.realPostgres.integration.test.ts index e2ff04207a..1b5f011c86 100644 --- a/control-api/test/externalSessionV1CanonicalRevocation.realPostgres.integration.test.ts +++ b/control-api/test/externalSessionV1CanonicalRevocation.realPostgres.integration.test.ts @@ -44,15 +44,12 @@ const policy = { issueV2: true, renewV2: true, switchCompatibility: true, - computeCatalogShadow: false, - serveCatalog: false, actionContextV2: false, rpcDelegationV2: false, desktopAllTeamMode: false, profileV2Mode: false, minimumClientVersion: null, enforceMinimumClient: false, - advertisedCatalogFamilies: [], } as EffectiveUserAccessPolicy function databaseUrl(baseUrl: string, database: string): string { diff --git a/control-api/test/finalAuthorityLifecycle.realPostgres.integration.test.ts b/control-api/test/finalAuthorityLifecycle.realPostgres.integration.test.ts deleted file mode 100644 index beb9104a40..0000000000 --- a/control-api/test/finalAuthorityLifecycle.realPostgres.integration.test.ts +++ /dev/null @@ -1,348 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' -import { randomBytes, randomUUID } from 'node:crypto' -import { Pool } from 'pg' -import { type DbClient, initDb, pool } from '../src/db.js' -import { - AccessCatalogRequestError, - buildAccessCatalog, -} from '../src/services/access/accessCatalogCoordinator.js' -import { resolveLiveAuthorization } from '../src/services/access/liveAuthorizationResolver.js' -import { canonicalResourceIdentity } from '../src/services/access/resourceIdentity.js' -import type { ExternalSessionAuthorityContext } from '../src/services/auth/externalSessionAuthentication.js' -import { createUserSession } from '../src/services/auth/userSessionService.js' -import { retireDesktopUser } from '../src/services/directory/users.js' -import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' - -const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL -const describeRealPostgres = adminUrl ? describe : describe.skip -const runtimeRoles = [ - 'control_api_runtime', - 'trace_maintenance_runtime', - 'workflow_recipes_runtime', -] as const - -function databaseUrl(baseUrl: string, database: string): string { - const value = new URL(baseUrl) - value.pathname = `/${database}` - return value.toString() -} - -function quoteIdentifier(value: string): string { - return `"${value.replace(/"/g, '""')}"` -} - -function deferred() { - let resolve!: (value: T | PromiseLike) => void - const promise = new Promise(value => { - resolve = value - }) - return { promise, resolve } -} - -describeRealPostgres('final catalog and resolve lifecycle authority on real PostgreSQL', () => { - const database = `control_api_final_authority_${randomBytes(6).toString('hex')}` - const connectionString = databaseUrl( - adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', - database - ) - let adminPool: Pool - let databasePool: Pool - let corePoolConnectSpy: ReturnType - - async function createUser( - label: string, - lifecycleVersion = 1 - ): Promise<{ - userId: string - email: string - session: ExternalSessionAuthorityContext - retirementActorId: string - }> { - const userId = randomUUID() - const retirementActorId = randomUUID() - const email = `${label}-${userId}@example.test` - await databasePool.query( - `INSERT INTO users(id, email, name, lifecycle_state, lifecycle_version) - VALUES ($1, $2, $3, 'active', $4)`, - [userId, email, label, lifecycleVersion] - ) - await databasePool.query( - `INSERT INTO control_admin_users(id, username, password_hash, role, status) - VALUES ($1, $2, 'r6-final-authority-proof', 'admin', 'active')`, - [retirementActorId, `r6-final-authority-${retirementActorId}`] - ) - await databasePool.query( - `INSERT INTO gfs_desktop_operator_links( - id, lineage_id, generation, user_id, control_admin_id, state, source, created_by, row_version - ) VALUES ( - gen_random_uuid(), gen_random_uuid(), 1, $1::uuid, $2::uuid, - 'active', 'initial_setup', $2::uuid, 1 - )`, - [userId, retirementActorId] - ) - return { - userId, - email, - retirementActorId, - session: { - contract: 'v1', - userId, - tokenHash: randomBytes(32).toString('hex'), - issuedAt: Math.floor(Date.now() / 1_000), - authGeneration: lifecycleVersion, - }, - } - } - - function transaction() { - return async (work: (db: DbClient) => Promise): Promise => { - const client = await databasePool.connect() - try { - await client.query('BEGIN') - const result = await work(client as unknown as DbClient) - await client.query('COMMIT') - return result - } catch (error) { - await client.query('ROLLBACK') - throw error - } finally { - client.release() - } - } - } - - function transactionPausedBeforeAuthoritySnapshot( - started: ReturnType, - release: ReturnType - ) { - return async (work: (db: DbClient) => Promise): Promise => { - const client = await databasePool.connect() - try { - await client.query('BEGIN') - started.resolve() - await release.promise - const result = await work(client as unknown as DbClient) - await client.query('COMMIT') - return result - } catch (error) { - await client.query('ROLLBACK') - throw error - } finally { - client.release() - } - } - } - - async function retire(userId: string, retirementActorId: string): Promise { - await retireDesktopUser( - { kind: 'control_admin', controlAdminId: retirementActorId }, - userId, - 'final authority lifecycle race proof', - `r6-final-authority-${randomUUID()}`, - `r6-final-authority-request-${randomUUID()}` - ) - } - - beforeAll(async () => { - adminPool = new Pool({ connectionString: adminUrl }) - await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) - await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) - databasePool = new Pool({ connectionString }) - await initDb({ connect: () => databasePool.connect() }) - corePoolConnectSpy = vi - .spyOn(pool, 'connect') - .mockImplementation((() => databasePool.connect()) as typeof pool.connect) - }) - - afterAll(async () => { - try { - corePoolConnectSpy?.mockRestore() - await endPoolAndWaitForClients(databasePool) - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) - FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) - await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) - } - } finally { - await adminPool?.end() - } - }) - - it('accepts only the current canonical V1 lifecycle generation', async () => { - const current = await createUser('current-generation') - await expect( - buildAccessCatalog( - { session: current.session, families: ['user'], limit: 10 }, - { transaction: transaction() } - ) - ).resolves.toMatchObject({ complete: true }) - - const mismatch = await createUser('generation-mismatch', 2) - const generationOneRepresentation: ExternalSessionAuthorityContext = { - ...mismatch.session, - authGeneration: 1, - } - await expect( - buildAccessCatalog( - { session: generationOneRepresentation, families: ['user'], limit: 10 }, - { transaction: transaction() } - ) - ).rejects.toEqual( - expect.objectContaining({ code: 'session_not_live' }) - ) - - const originalIncarnation = await createUser('generation-one-current') - await expect( - buildAccessCatalog( - { - session: { ...originalIncarnation.session, authGeneration: 1 }, - families: ['user'], - limit: 10, - }, - { transaction: transaction() } - ) - ).resolves.toMatchObject({ complete: true }) - }) - - it('denies inactive V2 and missing-user authority in the final snapshot', async () => { - const inactive = await createUser('inactive-v2') - const issued = await createUserSession( - { - userId: inactive.userId, - email: inactive.email, - authenticationMethods: ['pwd'], - }, - { db: databasePool } - ) - await retire(inactive.userId, inactive.retirementActorId) - const v2: ExternalSessionAuthorityContext = { - contract: 'v2', - userId: inactive.userId, - sid: issued.identity.sid, - jti: issued.identity.jti, - sessionVersion: issued.identity.sessionVersion, - } - await expect( - buildAccessCatalog( - { session: v2, families: ['user'], limit: 10 }, - { transaction: transaction() } - ) - ).rejects.toEqual( - expect.objectContaining({ code: 'session_not_live' }) - ) - - const missing: ExternalSessionAuthorityContext = { - contract: 'v1', - userId: randomUUID(), - tokenHash: randomBytes(32).toString('hex'), - issuedAt: Math.floor(Date.now() / 1_000), - authGeneration: 1, - } - await expect( - buildAccessCatalog( - { session: missing, families: ['user'], limit: 10 }, - { transaction: transaction() } - ) - ).rejects.toEqual( - expect.objectContaining({ code: 'authority_unavailable' }) - ) - }) - - it('denies catalog when retirement commits after admission but before final authority', async () => { - const principal = await createUser('catalog-retirement-race') - const started = deferred() - const release = deferred() - const pending = buildAccessCatalog( - { session: principal.session, families: ['user'], limit: 10 }, - { transaction: transactionPausedBeforeAuthoritySnapshot(started, release) } - ) - await started.promise - await retire(principal.userId, principal.retirementActorId) - release.resolve() - await expect(pending).rejects.toEqual( - expect.objectContaining({ code: 'session_not_live' }) - ) - }) - - it('denies resolve when retirement commits after admission but before final authority', async () => { - const principal = await createUser('resolve-retirement-race') - const catalog = await buildAccessCatalog( - { session: principal.session, families: ['user'], limit: 10 }, - { transaction: transaction() } - ) - const item = catalog.items[0] - expect(item).toBeDefined() - const started = deferred() - const release = deferred() - const pending = resolveLiveAuthorization( - { - session: principal.session, - requiredCapability: 'user.profile.read', - resource: canonicalResourceIdentity(item!.resource), - requestedAccessPathId: item!.accessPaths[0]!.accessPathId, - }, - { transaction: transactionPausedBeforeAuthoritySnapshot(started, release) } - ) - await started.promise - await retire(principal.userId, principal.retirementActorId) - release.resolve() - await expect(pending).resolves.toEqual({ status: 'denied', code: 'session_not_live' }) - }) - - it('applies the V1 security cutoff to the catalog and the authority snapshot', async () => { - const principal = await createUser('security-cutoff') - const secondResult = await databasePool.query( - `SELECT EXTRACT(EPOCH FROM date_trunc('second', clock_timestamp()))::bigint - 60 AS second` - ) - const cutoff = Number((secondResult.rows[0] as { second: string }).second) - await databasePool.query( - `INSERT INTO external_user_session_security_epochs(user_id, valid_after, reason, updated_at) - VALUES ($1, to_timestamp($2), 'cutoff_site_test', to_timestamp($2))`, - [principal.userId, cutoff] - ) - // The generation stays current and the token unrevoked, so only the - // cutoff can make these sessions not live. - const atCutoff: ExternalSessionAuthorityContext = { ...principal.session, issuedAt: cutoff } - const afterCutoff: ExternalSessionAuthorityContext = { - ...principal.session, - issuedAt: cutoff + 1, - } - - await expect( - buildAccessCatalog( - { session: atCutoff, families: ['user'], limit: 10 }, - { transaction: transaction() } - ) - ).rejects.toEqual( - expect.objectContaining({ code: 'session_not_live' }) - ) - const catalog = await buildAccessCatalog( - { session: afterCutoff, families: ['user'], limit: 10 }, - { transaction: transaction() } - ) - expect(catalog).toMatchObject({ complete: true }) - const item = catalog.items[0] - expect(item).toBeDefined() - - const resolve = (session: ExternalSessionAuthorityContext) => - resolveLiveAuthorization( - { - session, - requiredCapability: 'user.profile.read', - resource: canonicalResourceIdentity(item!.resource), - requestedAccessPathId: item!.accessPaths[0]!.accessPathId, - }, - { transaction: transaction() } - ) - await expect(resolve(atCutoff)).resolves.toEqual({ - status: 'denied', - code: 'session_not_live', - }) - await expect(resolve(afterCutoff)).resolves.toMatchObject({ status: 'allowed' }) - }) -}) diff --git a/control-api/test/gfs.externalAuthority.test.ts b/control-api/test/gfs.externalAuthority.test.ts index 35753e5fd2..fec28d9431 100644 --- a/control-api/test/gfs.externalAuthority.test.ts +++ b/control-api/test/gfs.externalAuthority.test.ts @@ -166,15 +166,12 @@ describe('signCurrentExternalGfsUserToken', () => { issueV2: false, renewV2: false, switchCompatibility: true, - computeCatalogShadow: false, - serveCatalog: false, actionContextV2: false, rpcDelegationV2: false, desktopAllTeamMode: false, profileV2Mode: false, minimumClientVersion: null, enforceMinimumClient: false, - advertisedCatalogFamilies: [], } const tokenClaims = { userId: DESKTOP_USER_ID, diff --git a/control-api/test/invitationFirstPasswordIdentity.realPostgres.integration.test.ts b/control-api/test/invitationFirstPasswordIdentity.realPostgres.integration.test.ts index 7b9cda352b..8c915197e9 100644 --- a/control-api/test/invitationFirstPasswordIdentity.realPostgres.integration.test.ts +++ b/control-api/test/invitationFirstPasswordIdentity.realPostgres.integration.test.ts @@ -52,15 +52,12 @@ const policy = { issueV2: true, renewV2: true, switchCompatibility: false, - computeCatalogShadow: false, - serveCatalog: false, actionContextV2: false, rpcDelegationV2: false, desktopAllTeamMode: false, profileV2Mode: false, minimumClientVersion: null, enforceMinimumClient: false, - advertisedCatalogFamilies: [], } as EffectiveUserAccessPolicy type IdentityState = { diff --git a/control-api/test/routes.externalNotifications.currentness.realPostgres.integration.test.ts b/control-api/test/routes.externalNotifications.currentness.realPostgres.integration.test.ts index 8d3f8fcde2..3b25a7f70b 100644 --- a/control-api/test/routes.externalNotifications.currentness.realPostgres.integration.test.ts +++ b/control-api/test/routes.externalNotifications.currentness.realPostgres.integration.test.ts @@ -58,9 +58,6 @@ vi.mock('../src/observability/metrics.js', () => { notificationStreamSnapshotSize: metric, } }) -vi.mock('../src/services/access/accessCatalogShadow.js', () => ({ - scheduleAccessCatalogShadow: vi.fn(), -})) vi.mock('../src/services/notificationStreamService.js', () => ({ listActiveApprovalNotificationsForUser: producerDatabase.listActive, listNotificationStreamEventsForUser: producerDatabase.listEvents, diff --git a/control-api/test/routes.externalNotifications.currentness.test.ts b/control-api/test/routes.externalNotifications.currentness.test.ts index 8dc8b88654..911b9c54b5 100644 --- a/control-api/test/routes.externalNotifications.currentness.test.ts +++ b/control-api/test/routes.externalNotifications.currentness.test.ts @@ -10,7 +10,6 @@ const streamState = vi.hoisted(() => ({ listEvents: vi.fn(), newestCursor: vi.fn(), openListener: vi.fn(), - scheduleShadow: vi.fn(), backendFailure: vi.fn(), })) @@ -81,9 +80,6 @@ vi.mock('../src/observability/metrics.js', () => { notificationStreamSnapshotSize: metric, } }) -vi.mock('../src/services/access/accessCatalogShadow.js', () => ({ - scheduleAccessCatalogShadow: streamState.scheduleShadow, -})) vi.mock('../src/services/auth/externalSessionCurrentnessObserver.js', () => ({ observeExternalSessionCurrentness: streamState.observer, })) diff --git a/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts b/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts index 79ca1446d4..b53d08c2ac 100644 --- a/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts +++ b/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts @@ -27,15 +27,12 @@ const runtimePolicy = vi.hoisted(() => ({ issueV2: false, renewV2: false, switchCompatibility: true, - computeCatalogShadow: false, - serveCatalog: false, actionContextV2: false, rpcDelegationV2: false, desktopAllTeamMode: false, profileV2Mode: false, minimumClientVersion: null, enforceMinimumClient: false, - advertisedCatalogFamilies: [], })) vi.mock('../src/db.js', async importOriginal => { diff --git a/control-api/test/routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts b/control-api/test/routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts index da2681cdea..52ab32b16c 100644 --- a/control-api/test/routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts +++ b/control-api/test/routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts @@ -27,15 +27,12 @@ const runtimePolicy = vi.hoisted(() => ({ issueV2: false, renewV2: false, switchCompatibility: true, - computeCatalogShadow: false, - serveCatalog: false, actionContextV2: false, rpcDelegationV2: false, desktopAllTeamMode: false, profileV2Mode: false, minimumClientVersion: null, enforceMinimumClient: false, - advertisedCatalogFamilies: [], })) const directory = vi.hoisted(() => ({ createManagedInvitationForUser: vi.fn(), diff --git a/control-api/test/teamGfsTopK.property.test.ts b/control-api/test/teamGfsTopK.property.test.ts deleted file mode 100644 index 9cb2334cf5..0000000000 --- a/control-api/test/teamGfsTopK.property.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it } from 'vitest' -import fc from 'fast-check' -import { - type TeamGfsStreamHead, - type TeamGfsStreamRequest, - collectTeamGfsTopK, -} from '../src/services/access/teamGfsTopK.js' - -function logicalId(value: number): string { - return `00000000-0000-4000-8000-${String(value).padStart(12, '0')}` -} - -describe('team GFS top-k properties', () => { - it('matches the exact union across arbitrary stream order and duplicate distributions', async () => { - await fc.assert( - fc.asyncProperty( - fc.array(fc.uniqueArray(fc.integer({ min: 1, max: 80 }), { maxLength: 30 }), { - minLength: 1, - maxLength: 16, - }), - fc.integer({ min: 0, max: 40 }), - fc.oneof(fc.constant(1), fc.constant(2), fc.integer({ min: 3, max: 20 })), - fc.boolean(), - async (generated, afterValue, take, reverseResults) => { - const valuesBySubject = new Map( - generated.map((values, index) => [ - `team-${index}`, - [...values].sort((left, right) => left - right).map(logicalId), - ]) - ) - const streams = [...valuesBySubject.keys()].map(subjectId => ({ - kind: 'grant' as const, - subjectId, - afterId: logicalId(afterValue), - })) - const read = async ( - requests: readonly TeamGfsStreamRequest[] - ): Promise => { - const heads = requests.flatMap(request => { - const selected = (valuesBySubject.get(request.subjectId) ?? []) - .filter(value => value > request.afterId) - .slice(0, request.take) - return selected.map((value, index) => ({ - kind: request.kind, - subjectId: request.subjectId, - logicalId: value, - batchLast: index === selected.length - 1, - })) - }) - return reverseResults ? heads.reverse() : heads - } - const expected = [...new Set(generated.flat())] - .filter(value => value > afterValue) - .sort((left, right) => left - right) - .map(logicalId) - const result = await collectTeamGfsTopK({ streams, take, read }) - const repeated = await collectTeamGfsTopK({ - streams: [...streams].reverse(), - take, - read, - }) - - expect(result.logicalIds).toEqual(expected.slice(0, take + 1)) - expect(new Set(result.logicalIds).size).toBe(result.logicalIds.length) - expect(repeated.logicalIds).toEqual(result.logicalIds) - expect(result.hasMore || result.logicalIds.length > take).toBe(expected.length > take) - } - ), - { numRuns: 250 } - ) - }) -}) diff --git a/control-api/test/userAccessCatalogActivation.test.ts b/control-api/test/userAccessCatalogActivation.test.ts deleted file mode 100644 index f3dc008699..0000000000 --- a/control-api/test/userAccessCatalogActivation.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { OPERATIONAL_SOURCE_FAMILIES } from '../src/services/access/operationalAccessProjection.js' -import { - CATALOG_FAMILIES, - type ConfiguredUserAccessIntent, - UserAccessPolicyConfigurationError, -} from '../src/services/access/userAccessPolicy.js' -import * as runtimePolicy from '../src/services/access/userAccessRuntimePolicy.js' - -const configured: ConfiguredUserAccessIntent = Object.freeze({ - legacyLifecycle: 'issue_and_accept', - sessionV2Acceptance: true, - sessionV2Issuance: false, - catalogMode: 'serve', - teamGfsMembershipAdmissionLimit: 4, - actionContextV2: false, - rpcDelegationV2: false, - desktopAllTeamMode: false, - profileV2Mode: false, - legacySwitchEndpoint: true, - minimumClientVersion: null, - minimumClientEnforcement: false, -}) - -const now = new Date('2026-09-01T10:00:00.000Z') - -function sourceQuery() { - return vi.fn().mockResolvedValue({ - rows: OPERATIONAL_SOURCE_FAMILIES.map((source_family, index) => ({ - source_family, - generation: index + 1, - resource_version: String(index + 10), - status: 'current', - last_success_at: new Date(now.getTime() - 1_000), - })), - }) -} - -function activationRecord( - overrides: Partial<{ - active: boolean - catalogConfigurationRevision: string - comparisonEvidence: Array<{ - family: (typeof CATALOG_FAMILIES)[number] - attempted: number - completed: number - reference: string - }> - }> = {} -): string { - const revisionProducer = Reflect.get(runtimePolicy, 'catalogConfigurationRevision') as - | ((intent: ConfiguredUserAccessIntent) => string) - | undefined - return JSON.stringify({ - version: 1, - active: true, - revision: 'catalog-acceptance-2026-09-01', - acceptedBy: 'release-operator@example.test', - acceptedAt: '2026-09-01T10:00:00.000Z', - catalogConfigurationRevision: revisionProducer?.(configured) ?? 'pre-correction-placeholder', - requiredFamilies: CATALOG_FAMILIES, - comparisonEvidence: CATALOG_FAMILIES.map(family => ({ - family, - attempted: 10, - completed: 10, - reference: `shadow-run-2026-09-01/${family}`, - })), - ...overrides, - }) -} - -async function resolve(catalogActivationRecord: string) { - return runtimePolicy.resolveEffectiveUserAccessPolicy({ - intent: configured, - db: { query: sourceQuery() } as never, - indexerEnabled: true, - readinessMaxAgeMs: 5_000, - now, - catalogActivationRecord, - catalogReadiness: true, - }) -} - -describe('operator-owned catalog activation', () => { - it('serves only from an active acceptance record bound to the current configuration', async () => { - await expect(resolve(activationRecord())).resolves.toMatchObject({ - serveCatalog: true, - advertisedCatalogFamilies: CATALOG_FAMILIES, - }) - - await expect(resolve(activationRecord({ active: false }))).rejects.toThrowError( - new UserAccessPolicyConfigurationError('catalog_serving_parity_not_accepted') - ) - await expect( - resolve(activationRecord({ catalogConfigurationRevision: 'another-configuration' })) - ).rejects.toThrowError( - new UserAccessPolicyConfigurationError('catalog_serving_parity_not_accepted') - ) - }) - - it('rejects incomplete or skipped-only comparison evidence', async () => { - const incompleteEvidence = CATALOG_FAMILIES.slice(1).map(family => ({ - family, - attempted: 10, - completed: 10, - reference: `shadow-run-2026-09-01/${family}`, - })) - const skippedEvidence = CATALOG_FAMILIES.map(family => ({ - family, - attempted: 10, - completed: family === CATALOG_FAMILIES[0] ? 0 : 10, - reference: `shadow-run-2026-09-01/${family}`, - })) - - await expect( - resolve(activationRecord({ comparisonEvidence: incompleteEvidence })) - ).rejects.toThrowError( - new UserAccessPolicyConfigurationError('catalog_serving_parity_not_accepted') - ) - await expect( - resolve(activationRecord({ comparisonEvidence: skippedEvidence })) - ).rejects.toThrowError( - new UserAccessPolicyConfigurationError('catalog_serving_parity_not_accepted') - ) - }) -}) diff --git a/control-api/test/userAccessCatalogReadinessIsolation.test.ts b/control-api/test/userAccessCatalogReadinessIsolation.test.ts deleted file mode 100644 index 5fade2ea64..0000000000 --- a/control-api/test/userAccessCatalogReadinessIsolation.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { ConfiguredUserAccessIntent } from '../src/services/access/userAccessPolicy.js' -import { resolveEffectiveUserAccessPolicy } from '../src/services/access/userAccessRuntimePolicy.js' -import { externalSessionDatabaseFailure } from '../src/services/auth/sessionDatabaseFailure.js' - -const shadowIntent: ConfiguredUserAccessIntent = Object.freeze({ - legacyLifecycle: 'issue_and_accept', - sessionV2Acceptance: true, - sessionV2Issuance: false, - catalogMode: 'shadow', - teamGfsMembershipAdmissionLimit: 4, - actionContextV2: false, - rpcDelegationV2: false, - desktopAllTeamMode: false, - profileV2Mode: false, - legacySwitchEndpoint: true, - minimumClientVersion: null, - minimumClientEnforcement: false, -}) - -describe('catalog readiness isolation', () => { - it('keeps ordinary policy resolution independent from catalog readiness', async () => { - const query = vi.fn().mockRejectedValue(new Error('catalog source unavailable')) - - await expect( - resolveEffectiveUserAccessPolicy({ - intent: shadowIntent, - db: { query } as never, - indexerEnabled: true, - readinessMaxAgeMs: 5_000, - }) - ).resolves.toMatchObject({ - acceptV1: true, - acceptV2: true, - computeCatalogShadow: false, - serveCatalog: false, - advertisedCatalogFamilies: [], - }) - expect(query).not.toHaveBeenCalled() - }) - - it('keeps catalog-specific readiness fail-closed', async () => { - const query = vi.fn().mockRejectedValue(new Error('catalog source unavailable')) - - await expect( - resolveEffectiveUserAccessPolicy({ - intent: shadowIntent, - db: { query } as never, - indexerEnabled: true, - readinessMaxAgeMs: 5_000, - catalogReadiness: true, - }) - ).rejects.toMatchObject({ code: 'readiness_snapshot_unavailable' }) - expect(query).toHaveBeenCalledOnce() - }) - - it('preserves database failure provenance for session authentication policy reads', async () => { - const acquireFailure = new Error('pg-pool query transport failure') - const query = vi.fn().mockRejectedValue(acquireFailure) - - await expect( - resolveEffectiveUserAccessPolicy({ - intent: shadowIntent, - db: { query } as never, - indexerEnabled: true, - readinessMaxAgeMs: 5_000, - catalogReadiness: true, - databaseFailureMode: 'throw', - onDatabaseFailure: externalSessionDatabaseFailure, - }) - ).rejects.toMatchObject({ - name: 'ExternalSessionBackendUnavailableError', - cause: acquireFailure, - }) - expect(query).toHaveBeenCalledOnce() - }) -}) diff --git a/control-api/test/userAccessPolicy.test.ts b/control-api/test/userAccessPolicy.test.ts index 5d8c1b01fe..36ae5561b8 100644 --- a/control-api/test/userAccessPolicy.test.ts +++ b/control-api/test/userAccessPolicy.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from 'vitest' -import { vi } from 'vitest' -import { OPERATIONAL_SOURCE_FAMILIES } from '../src/services/access/operationalAccessProjection.js' import { - CATALOG_FAMILIES, type ConfiguredUserAccessIntent, type DeploymentReadiness, UserAccessPolicyConfigurationError, @@ -13,14 +10,11 @@ import { } from '../src/services/access/userAccessPolicy.js' import { resolveEffectiveUserAccessPolicy } from '../src/services/access/userAccessRuntimePolicy.js' -const allFamilies = new Set(CATALOG_FAMILIES) - function intent(overrides: Partial = {}): ConfiguredUserAccessIntent { const configured: ConfiguredUserAccessIntent = { legacyLifecycle: 'issue_and_accept', sessionV2Acceptance: true, sessionV2Issuance: false, - catalogMode: 'off', teamGfsMembershipAdmissionLimit: null, actionContextV2: false, rpcDelegationV2: false, @@ -31,9 +25,7 @@ function intent(overrides: Partial = {}): Configured minimumClientEnforcement: false, ...overrides, } - return configured.catalogMode !== 'off' && overrides.teamGfsMembershipAdmissionLimit === undefined - ? { ...configured, teamGfsMembershipAdmissionLimit: 4 } - : configured + return configured } function readiness(overrides: Partial = {}): DeploymentReadiness { @@ -46,13 +38,6 @@ function readiness(overrides: Partial = {}): DeploymentRead sessionV2Acceptance: 'ready', sessionV2Issuance: 'ready', accessBudget: 'ready', - catalogCoordinator: 'ready', - catalogRegisteredFamilies: allFamilies, - catalogHarnessFamilies: allFamilies, - catalogOperationalFamilies: allFamilies, - catalogShadowComparison: 'ready', - catalogParityAccepted: true, - actionSafeRevisions: 'ready', actionContext: 'ready', rpcDelegationAllHops: 'ready', desktop: 'ready', @@ -75,14 +60,11 @@ describe('central user-access rollout compiler', () => { issueV2: false, renewV2: false, switchCompatibility: true, - computeCatalogShadow: false, - serveCatalog: false, actionContextV2: false, rpcDelegationV2: false, desktopAllTeamMode: false, profileV2Mode: false, enforceMinimumClient: false, - advertisedCatalogFamilies: [], }) }) @@ -112,18 +94,17 @@ describe('central user-access rollout compiler', () => { 'legacy_switch_requires_v1_issue_and_accept', intent({ legacyLifecycle: 'accept_only', sessionV2Issuance: true }), ], - [ - 'catalog_shadow_families_incomplete', - intent({ catalogMode: 'shadow' }), - readiness({ catalogHarnessFamilies: new Set(['user']) }), - ], - [ - 'catalog_serving_parity_not_accepted', - intent({ catalogMode: 'serve' }), - readiness({ catalogParityAccepted: false }), - ], ['rpc_v2_requires_action_context', intent({ rpcDelegationV2: true })], ['desktop_mode_requires_v2_issuance', intent({ desktopAllTeamMode: true })], + [ + 'desktop_mode_requires_catalog_serving', + intent({ + sessionV2Issuance: true, + actionContextV2: true, + rpcDelegationV2: true, + desktopAllTeamMode: true, + }), + ], [ 'profile_explicit_team_adapters_unavailable', intent({ sessionV2Issuance: true, profileV2Mode: true }), @@ -140,14 +121,12 @@ describe('central user-access rollout compiler', () => { ) }) - it('derives shadow, serving, renewal, and advertisement only from ready dependencies', () => { + it('derives renewal and the gated flags only from ready dependencies', () => { const policy = compileUserAccessPolicy( intent({ sessionV2Issuance: true, - catalogMode: 'serve_and_shadow', actionContextV2: true, rpcDelegationV2: true, - desktopAllTeamMode: true, profileV2Mode: true, minimumClientVersion: '2.3.0', minimumClientEnforcement: true, @@ -157,20 +136,16 @@ describe('central user-access rollout compiler', () => { expect(policy).toMatchObject({ renewV2: true, - computeCatalogShadow: true, - serveCatalog: true, actionContextV2: true, rpcDelegationV2: true, - desktopAllTeamMode: true, profileV2Mode: true, minimumClientVersion: '2.3.0', enforceMinimumClient: true, - advertisedCatalogFamilies: CATALOG_FAMILIES, }) expect(policy.policyRevision).toMatch(/^[0-9a-f]{64}$/) }) - it('enumerates every configured Boolean/lifecycle/catalog combination deterministically', () => { + it('enumerates every configured Boolean/lifecycle combination deterministically', () => { const booleanKeys = [ 'sessionV2Acceptance', 'sessionV2Issuance', @@ -182,40 +157,36 @@ describe('central user-access rollout compiler', () => { 'minimumClientEnforcement', ] as const const lifecycles = ['issue_and_accept', 'accept_only', 'disabled'] as const - const catalogModes = ['off', 'shadow', 'serve_and_shadow', 'serve'] as const let outcomes = 0 for (const legacyLifecycle of lifecycles) { - for (const catalogMode of catalogModes) { - for (let mask = 0; mask < 1 << booleanKeys.length; mask += 1) { - const configured = intent({ - legacyLifecycle, - catalogMode, - minimumClientVersion: mask & (1 << 7) ? '2.0.0' : null, - }) as Record - booleanKeys.forEach((key, index) => { - configured[key] = Boolean(mask & (1 << index)) - }) - try { - const first = compileUserAccessPolicy( - configured as ConfiguredUserAccessIntent, - readiness() - ) - const second = compileUserAccessPolicy( - configured as ConfiguredUserAccessIntent, - readiness() - ) - expect(first).toEqual(second) - expect(first.issueV2 && !first.acceptV2).toBe(false) - expect(first.renewV2).toBe(first.issueV2 && first.acceptV2) - } catch (error) { - expect(error).toBeInstanceOf(UserAccessPolicyConfigurationError) - } - outcomes += 1 + for (let mask = 0; mask < 1 << booleanKeys.length; mask += 1) { + const configured = intent({ + legacyLifecycle, + minimumClientVersion: mask & (1 << 7) ? '2.0.0' : null, + }) as Record + booleanKeys.forEach((key, index) => { + configured[key] = Boolean(mask & (1 << index)) + }) + try { + const first = compileUserAccessPolicy( + configured as ConfiguredUserAccessIntent, + readiness() + ) + const second = compileUserAccessPolicy( + configured as ConfiguredUserAccessIntent, + readiness() + ) + expect(first).toEqual(second) + expect(first.issueV2 && !first.acceptV2).toBe(false) + expect(first.renewV2).toBe(first.issueV2 && first.acceptV2) + } catch (error) { + expect(error).toBeInstanceOf(UserAccessPolicyConfigurationError) } + outcomes += 1 } } - expect(outcomes).toBe(3 * 4 * 2 ** booleanKeys.length) + expect(outcomes).toBe(3 * 2 ** booleanKeys.length) }) it('strictly parses controls and compares bounded semantic versions', () => { @@ -231,114 +202,6 @@ describe('central user-access rollout compiler', () => { expect(compareSemanticVersions('unbounded', '2.3.0')).toBeNull() }) - it('advertises shadow only from a fresh complete runtime readiness snapshot', async () => { - const now = new Date('2026-08-11T10:00:00.000Z') - const query = vi.fn().mockResolvedValue({ - rows: OPERATIONAL_SOURCE_FAMILIES.map((source_family, index) => ({ - source_family, - generation: index + 1, - resource_version: String(index + 10), - status: 'current', - last_success_at: new Date(now.getTime() - 1_000), - })), - }) - - const policy = await resolveEffectiveUserAccessPolicy({ - intent: intent({ catalogMode: 'shadow' }), - db: { query } as never, - indexerEnabled: true, - readinessMaxAgeMs: 5_000, - now, - catalogReadiness: true, - }) - - expect(policy.computeCatalogShadow).toBe(true) - expect(policy.advertisedCatalogFamilies).toEqual(CATALOG_FAMILIES) - expect(query).toHaveBeenCalledOnce() - }) - - it.each([ - ['missing', OPERATIONAL_SOURCE_FAMILIES.slice(1), 'catalog_shadow_families_incomplete'], - ['mixed', OPERATIONAL_SOURCE_FAMILIES, 'catalog_shadow_families_incomplete'], - ['expired', OPERATIONAL_SOURCE_FAMILIES, 'catalog_shadow_families_incomplete'], - ])( - 'fails shadow readiness closed for a %s operational snapshot', - async (kind, families, code) => { - const now = new Date('2026-08-11T10:00:00.000Z') - const query = vi.fn().mockResolvedValue({ - rows: families.map((source_family, index) => ({ - source_family, - generation: index + 1, - resource_version: String(index + 10), - status: kind === 'mixed' && index === 0 ? 'relisting' : 'current', - last_success_at: new Date(now.getTime() - (kind === 'expired' ? 5_001 : 1_000)), - })), - }) - - await expect( - resolveEffectiveUserAccessPolicy({ - intent: intent({ catalogMode: 'shadow' }), - db: { query } as never, - indexerEnabled: true, - readinessMaxAgeMs: 5_000, - now, - catalogReadiness: true, - }) - ).rejects.toThrowError(new UserAccessPolicyConfigurationError(code)) - } - ) - - it('does not query operational readiness or advertise families while catalog rollout is off', async () => { - const query = vi.fn() - const policy = await resolveEffectiveUserAccessPolicy({ - intent: intent(), - db: { query } as never, - indexerEnabled: true, - readinessMaxAgeMs: null, - }) - - expect(query).not.toHaveBeenCalled() - expect(policy.advertisedCatalogFamilies).toEqual([]) - expect(policy.computeCatalogShadow).toBe(false) - }) - - it('fails configured shadow closed when freshness evidence or its authority is unavailable', async () => { - await expect( - resolveEffectiveUserAccessPolicy({ - intent: intent({ catalogMode: 'shadow' }), - indexerEnabled: true, - readinessMaxAgeMs: null, - catalogReadiness: true, - }) - ).rejects.toThrowError(new UserAccessPolicyConfigurationError('readiness_snapshot_unavailable')) - - await expect( - resolveEffectiveUserAccessPolicy({ - intent: intent({ catalogMode: 'shadow' }), - db: { query: vi.fn().mockRejectedValue(new Error('authority unavailable')) } as never, - indexerEnabled: true, - readinessMaxAgeMs: 5_000, - catalogReadiness: true, - }) - ).rejects.toThrowError(new UserAccessPolicyConfigurationError('readiness_snapshot_unavailable')) - }) - - it('rejects an unparseable runtime readiness row', async () => { - await expect( - resolveEffectiveUserAccessPolicy({ - intent: intent({ catalogMode: 'shadow' }), - db: { - query: vi.fn().mockResolvedValue({ - rows: [{ source_family: 'host', generation: 'invalid' }], - }), - } as never, - indexerEnabled: true, - readinessMaxAgeMs: 5_000, - catalogReadiness: true, - }) - ).rejects.toThrowError(new UserAccessPolicyConfigurationError('runtime_readiness_invalid')) - }) - it('keeps PR 1 action context unavailable even when configured', async () => { await expect( resolveEffectiveUserAccessPolicy({ intent: intent({ actionContextV2: true }) }) diff --git a/control-api/test/userAccessRuntimeContract.realPostgres.integration.test.ts b/control-api/test/userAccessRuntimeContract.realPostgres.integration.test.ts index 362c469d15..52245fb445 100644 --- a/control-api/test/userAccessRuntimeContract.realPostgres.integration.test.ts +++ b/control-api/test/userAccessRuntimeContract.realPostgres.integration.test.ts @@ -1,18 +1,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { randomBytes, randomUUID } from 'node:crypto' import { Pool, type PoolClient } from 'pg' -import { config } from '../src/config.js' import { type DbClient, initDb } from '../src/db.js' -import { buildAccessCatalog } from '../src/services/access/accessCatalogCoordinator.js' -import { AccessExecutionBudget } from '../src/services/access/accessExecutionBudget.js' -import { resolveLiveAuthorization } from '../src/services/access/liveAuthorizationResolver.js' -import { OperationalAccessIndex } from '../src/services/access/operationalAccessIndex.js' -import { - canonicalEnvironmentId, - projectOperationalObject, -} from '../src/services/access/operationalAccessProjection.js' -import { canonicalResourceIdentity } from '../src/services/access/resourceIdentity.js' -import type { ExternalSessionAuthorityContext } from '../src/services/auth/externalSessionAuthentication.js' import { createUserSession, renewUserSession, @@ -46,14 +35,6 @@ describeRealPostgres('user-access runtime role contract on real PostgreSQL', () database ) const userId = randomUUID() - const environmentId = canonicalEnvironmentId() - const authority: ExternalSessionAuthorityContext = { - contract: 'v1', - userId, - tokenHash: randomBytes(32).toString('hex'), - issuedAt: Math.floor(Date.now() / 1_000), - authGeneration: 1, - } let adminPool: Pool let databasePool: Pool @@ -141,225 +122,6 @@ describeRealPostgres('user-access runtime role contract on real PostgreSQL', () expect(row.rows).toEqual([{ revoked: true }]) }) - it('projects operational state and bumps authorization revisions as the runtime role', async () => { - const projection = projectOperationalObject({ - environmentId, - plural: 'hosts', - namespace: config.hostsNamespace, - object: { - metadata: { - name: 'runtime-host', - namespace: config.hostsNamespace, - uid: 'runtime-host-uid', - resourceVersion: '1', - generation: 1, - }, - spec: { enabled: true, contextRef: 'runtime-context' }, - }, - behaviorFingerprintKey: config.sessionJwtPrivateKey, - relationshipNamespaces: { - context: config.contextsNamespace, - mcpServer: config.mcpServersNamespace, - sharedFilesystem: config.sharedFilesystemsNamespace, - }, - }) - const budget = AccessExecutionBudget.create('catalog') - const index = new OperationalAccessIndex(databasePool, runAsControlApiRuntime) - try { - const generation = await index.beginRelist({ - environmentId, - sourceFamily: 'host', - budget, - }) - await index.stageRelistPage({ - environmentId, - sourceFamily: 'host', - stagingGeneration: generation, - projections: [projection], - budget, - }) - await index.promoteRelist({ - environmentId, - sourceFamily: 'host', - stagingGeneration: generation, - resourceVersion: '1', - budget, - }) - } finally { - budget.close() - } - - const stored = await databasePool.query<{ resources: string; revisions: string }>( - `SELECT - (SELECT count(*)::text FROM operational_resource_index - WHERE environment_id = $1 AND resource_type = 'host') AS resources, - (SELECT count(*)::text FROM authorization_resource_revisions - WHERE environment_id = $1 AND resource_type = 'host') AS revisions`, - [environmentId] - ) - expect(stored.rows).toEqual([{ resources: '1', revisions: '1' }]) - }) - - it('prelocks archive subjects through the scoped runtime function', async () => { - await runAsControlApiRuntime(async db => { - const teamId = randomUUID() - const runId = randomUUID() - await db.query(`INSERT INTO teams(id, name) VALUES ($1, 'Archive runtime team')`, [teamId]) - await db.query( - `INSERT INTO workflow_runs(run_id, recipe_namespace, recipe_name, phase, - actor_type, actor_id, team_id, usage_team_id, trigger_source) - VALUES ($1, 'revision', 'archive-runtime', 'Pending', 'user', $2, $3::uuid, $3::uuid::text, 'onDemand')`, - [runId, userId, teamId] - ) - const readRevisions = () => - db.query<{ user_revision: string; team_revision: string }>( - `SELECT - (SELECT revision::text FROM authorization_user_revisions WHERE user_id = $1) - AS user_revision, - (SELECT revision::text FROM authorization_team_revisions WHERE team_id = $2) - AS team_revision`, - [userId, teamId] - ) - const before = (await readRevisions()).rows[0]! - await db.query('SELECT authorization_prelock_workflow_run_revisions($1::uuid[])', [ - [runId, runId], - ]) - const after = (await readRevisions()).rows[0]! - expect(Number(after.user_revision)).toBe(Number(before.user_revision) + 1) - expect(Number(after.team_revision)).toBe(Number(before.team_revision) + 1) - }) - }) - - it('reads catalog and resolver authority as the runtime role', async () => { - const catalog = await buildAccessCatalog( - { session: authority, families: ['user'], limit: 10 }, - { transaction: runAsControlApiRuntime } - ) - expect(catalog.complete).toBe(true) - expect(catalog.items).toHaveLength(1) - - const resolved = await resolveLiveAuthorization( - { - session: authority, - requiredCapability: 'user.profile.read', - resource: canonicalResourceIdentity(catalog.items[0]!.resource), - requestedAccessPathId: catalog.items[0]!.accessPaths[0]!.accessPathId, - }, - { transaction: runAsControlApiRuntime } - ) - expect(resolved.status).toBe('allowed') - }) - - it('keeps catalog continuation and resolver authority stable across normal session activity', async () => { - const teamIds = [randomUUID(), randomUUID()] - for (const [index, teamId] of teamIds.entries()) { - await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, $2)`, [ - teamId, - `Activity Revision ${index}`, - ]) - await databasePool.query( - `INSERT INTO team_members(team_id, user_id, role, status) - VALUES ($1, $2, 'member', 'active')`, - [teamId, userId] - ) - } - const issued = await createUserSession( - { userId, email: `${userId}@example.test`, authenticationMethods: ['pwd'] }, - { db: databasePool } - ) - const session: ExternalSessionAuthorityContext = { - contract: 'v2', - userId, - sid: issued.identity.sid, - jti: issued.identity.jti, - sessionVersion: issued.identity.sessionVersion, - } - const firstPage = await buildAccessCatalog( - { session, families: ['team'], limit: 1 }, - { transaction: runAsControlApiRuntime } - ) - expect(firstPage.items).toHaveLength(1) - expect(firstPage.nextCursor).toBeTruthy() - - const item = firstPage.items[0]! - const selectedPath = item.accessPaths.find(path => path.capabilities.includes('team.read')) - expect(selectedPath).toBeDefined() - const firstResolution = await resolveLiveAuthorization( - { - session, - requiredCapability: 'team.read', - resource: canonicalResourceIdentity(item.resource), - requestedAccessPathId: selectedPath!.accessPathId, - }, - { transaction: runAsControlApiRuntime } - ) - expect(firstResolution.status).toBe('allowed') - - const idleBefore = await databasePool.query<{ idle_expires_at: Date }>( - `SELECT idle_expires_at FROM external_user_sessions WHERE sid = $1`, - [session.sid] - ) - await databasePool.query( - `UPDATE external_user_sessions - SET last_used_at = clock_timestamp(), - idle_expires_at = LEAST(absolute_expires_at, clock_timestamp() + interval '14 days') - WHERE sid = $1`, - [session.sid] - ) - const idleAfter = await databasePool.query<{ idle_expires_at: Date }>( - `SELECT idle_expires_at FROM external_user_sessions WHERE sid = $1`, - [session.sid] - ) - expect(idleAfter.rows[0]!.idle_expires_at.getTime()).toBeGreaterThan( - idleBefore.rows[0]!.idle_expires_at.getTime() - ) - - const continued = await buildAccessCatalog( - { session, families: ['team'], limit: 1, cursor: firstPage.nextCursor }, - { transaction: runAsControlApiRuntime } - ) - expect(continued.complete).toBe(true) - expect(continued.items).toHaveLength(1) - const continuedResolution = await resolveLiveAuthorization( - { - session, - requiredCapability: 'team.read', - resource: canonicalResourceIdentity(item.resource), - requestedAccessPathId: selectedPath!.accessPathId, - }, - { transaction: runAsControlApiRuntime } - ) - expect(continuedResolution.status).toBe('allowed') - if (firstResolution.status !== 'allowed' || continuedResolution.status !== 'allowed') { - throw new Error('expected both activity-bound resolutions to be allowed') - } - expect(continuedResolution.authorizationRevision).toBe(firstResolution.authorizationRevision) - - await databasePool.query( - `UPDATE external_user_sessions - SET idle_expires_at = clock_timestamp() - interval '1 second' - WHERE sid = $1`, - [session.sid] - ) - await expect( - buildAccessCatalog( - { session, families: ['team'], limit: 1 }, - { transaction: runAsControlApiRuntime } - ) - ).rejects.toMatchObject({ code: 'session_not_live' }) - await expect( - resolveLiveAuthorization( - { - session, - requiredCapability: 'team.read', - resource: canonicalResourceIdentity(item.resource), - requestedAccessPathId: selectedPath!.accessPathId, - }, - { transaction: runAsControlApiRuntime } - ) - ).resolves.toMatchObject({ status: 'denied', code: 'session_not_live' }) - }) - it('denies the session tables to unrelated roles and PUBLIC', async () => { const publicPrivilege = await databasePool.query<{ allowed: boolean }>( `SELECT EXISTS ( diff --git a/tests/contracts/password-recovery-suites.json b/tests/contracts/password-recovery-suites.json index 3be543f824..72120c0914 100644 --- a/tests/contracts/password-recovery-suites.json +++ b/tests/contracts/password-recovery-suites.json @@ -97,22 +97,13 @@ "control-api/test/services.rateLimiterPoolSaturation.realPostgres.integration.test.ts", "control-api/test/services.subscriptionCatalogSyncCron.realPostgres.integration.test.ts", "control-api/test/services.workflowScheduleForbid.realPostgres.integration.test.ts", - "control-api/test/accessCatalogCoordinator.realPostgres.integration.test.ts", - "control-api/test/accessCatalogFamilies.realPostgres.integration.test.ts", - "control-api/test/accessCatalogPerformance.realPostgres.integration.test.ts", - "control-api/test/accessCatalogShadow.realPostgres.integration.test.ts", - "control-api/test/accessDatabaseCancellation.realPostgres.integration.test.ts", "control-api/test/adminDesktopReplayPassword.realPostgres.integration.test.ts", - "control-api/test/catalogDistinctPagination.realPostgres.integration.test.ts", - "control-api/test/catalogProducers.realPostgres.integration.test.ts", - "control-api/test/catalogRevisionComponents.realPostgres.integration.test.ts", "control-api/test/externalInvitationAcceptSession.realPostgres.integration.test.ts", "control-api/test/externalInvitationPasswordFirstSet.realPostgres.integration.test.ts", "control-api/test/invitationFirstPasswordIdentity.realPostgres.integration.test.ts", "control-api/test/externalSessionCurrentness.realPostgres.integration.test.ts", "control-api/test/externalSessionSingleTouch.realPostgres.integration.test.ts", "control-api/test/externalSessionV1CanonicalRevocation.realPostgres.integration.test.ts", - "control-api/test/finalAuthorityLifecycle.realPostgres.integration.test.ts", "control-api/test/gfsRevisionLockOrder.realPostgres.integration.test.ts", "control-api/test/invitationAcceptedOnboarding.realPostgres.integration.test.ts", "control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts", From fcdfa5a4487cfe36dfe5ba1680334e1370ae7982 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 4 Nov 2026 13:34:54 +0100 Subject: [PATCH 454/528] test(control-api): drop the archive revision lock-order suite after K1 K1b, Addendum V4 of the PR #334 r4.2 fix plan. K1 (5dae55d31) returned the workflow-recipes run and recipe writers to dev, so the two cases of archiveRevisionLockOrder that read that source can no longer find the removed prelock statements, and its other cases exercise SQL that no production code runs any more. The plan already deletes the suite with the revision writers in K13; it goes now so the real-PG lane is not red between K1 and K13. Also drop it from the pinned real-PG list and the password recovery suite manifest (124 public suites). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci-public.yml | 1 - control-api/package-lock.json | 4 +- control-api/package.json | 2 +- ...LockOrder.realPostgres.integration.test.ts | 2206 ----------------- tests/contracts/password-recovery-suites.json | 1 - 5 files changed, 3 insertions(+), 2211 deletions(-) delete mode 100644 control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts diff --git a/.github/workflows/ci-public.yml b/.github/workflows/ci-public.yml index 41d7ea83ab..e626e5ca0d 100644 --- a/.github/workflows/ci-public.yml +++ b/.github/workflows/ci-public.yml @@ -670,7 +670,6 @@ jobs: routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts \ userAccessRuntimeContract.realPostgres.integration.test.ts \ userSessionDatabaseClock.realPostgres.integration.test.ts \ - archiveRevisionLockOrder.realPostgres.integration.test.ts \ revisionWritersLockOrder.realPostgres.integration.test.ts \ userRetirementRevisionLockOrder.realPostgres.integration.test.ts \ workflowRunReadAndApprovalV1Compatibility.realPostgres.integration.test.ts diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 8db624035c..9f82483158 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.894", + "version": "0.1.895", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.894", + "version": "0.1.895", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 34d35446ba..33a71416c3 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.894", + "version": "0.1.895", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts b/control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts deleted file mode 100644 index 97e84fa0ec..0000000000 --- a/control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts +++ /dev/null @@ -1,2206 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' -import { randomBytes, randomUUID } from 'node:crypto' -import { readFileSync } from 'node:fs' -import { Pool, type PoolClient } from 'pg' -import { initDb, pool } from '../src/db.js' -import type { K8sGateway } from '../src/k8s.js' -import { archiveTerminalApprovals } from '../src/services/userApprovalRequestArchiveService.js' -import { - expirePendingRequests, - recordDecision, -} from '../src/services/userApprovalRequestService.js' -import { createApprovedRun, createRun } from '../src/services/workflowRunService.js' -import { archiveTerminalRuns } from '../src/services/workflowRunsArchiveService.js' -import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' - -const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL -const describeRealPostgres = adminUrl ? describe : describe.skip -const barrierClass = 33_406 -const runtimeRoles = [ - 'control_api_runtime', - 'trace_maintenance_runtime', - 'workflow_recipes_runtime', -] -const gateway = { deleteResource: vi.fn() } as unknown as K8sGateway - -type Outcome = { status: 'fulfilled'; value: unknown } | { status: 'rejected'; code: string } - -function observe(work: Promise): Promise { - return work.then( - value => ({ status: 'fulfilled', value }), - error => ({ status: 'rejected', code: String(error.code) }) - ) -} - -describeRealPostgres('archive batches and phase updates share revision lock order', () => { - const database = `control_api_archive_order_${randomBytes(6).toString('hex')}` - let adminPool: Pool - let databasePool: Pool - - beforeAll(async () => { - adminPool = new Pool({ connectionString: adminUrl }) - await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) - await adminPool.query(`CREATE DATABASE "${database}"`) - const url = new URL(adminUrl!) - url.pathname = `/${database}` - databasePool = new Pool({ connectionString: url.toString(), max: 12 }) - await initDb({ connect: () => databasePool.connect() }) - // Only a test AFTER trigger pauses after a real revision upsert has - // acquired its lock. Production SQL and bump functions run unchanged. - await databasePool.query(` - CREATE TABLE archive_order_barriers ( - application_name TEXT NOT NULL, - revision_table TEXT NOT NULL, - subject_id TEXT NOT NULL, - gate INTEGER NOT NULL - ); - CREATE TABLE archive_delete_revision_observations ( - position BIGSERIAL, application_name TEXT, revision_table TEXT, - subject_id TEXT, revision BIGINT - ); - CREATE FUNCTION archive_order_barrier() RETURNS TRIGGER LANGUAGE plpgsql AS $$ - DECLARE barrier RECORD; - BEGIN - IF current_setting('application_name') LIKE 'archive-delete-%' - OR current_setting('application_name') LIKE 'delete-principal-%' - OR current_setting('application_name') LIKE 'approval-archive-%' - OR current_setting('application_name') LIKE 'approval-expiry-%' - OR current_setting('application_name') LIKE 'approval-decision-%' - OR current_setting('application_name') LIKE 'approval-approved-run-%' - OR current_setting('application_name') LIKE 'recipe-delete-%' - OR current_setting('application_name') LIKE 'stuck-run-%' THEN - INSERT INTO archive_delete_revision_observations - (application_name, revision_table, subject_id, revision) - VALUES (current_setting('application_name'), TG_TABLE_NAME, - COALESCE(to_jsonb(NEW)->>'user_id', to_jsonb(NEW)->>'team_id'), NEW.revision); - END IF; - FOR barrier IN - SELECT gate FROM archive_order_barriers - WHERE application_name = current_setting('application_name') - AND revision_table = TG_TABLE_NAME - AND (subject_id = '' OR subject_id = COALESCE( - to_jsonb(NEW)->>'user_id', to_jsonb(NEW)->>'team_id')) - LOOP - PERFORM pg_advisory_xact_lock(${barrierClass}, barrier.gate); - END LOOP; - RETURN NEW; - END; - $$; - CREATE TRIGGER archive_order_barrier AFTER INSERT OR UPDATE - ON authorization_user_revisions FOR EACH ROW EXECUTE FUNCTION archive_order_barrier(); - CREATE TRIGGER archive_order_barrier AFTER INSERT OR UPDATE - ON authorization_team_revisions FOR EACH ROW EXECUTE FUNCTION archive_order_barrier(); - `) - }) - - afterAll(async () => { - try { - await endPoolAndWaitForClients(databasePool) - if (adminPool) { - await adminPool.query( - 'SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1', - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS "${database}"`) - await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) - } - } finally { - await adminPool?.end() - } - }) - - async function fixture() { - const users = [randomUUID(), randomUUID()] - const teams = [randomUUID(), randomUUID()] - const archived = [randomUUID(), randomUUID()] - const live = [randomUUID(), randomUUID()] - for (const user of users) { - await databasePool.query( - "INSERT INTO users(id, email, name) VALUES ($1, $2, 'Archive order')", - [user, `archive-${user}@example.test`] - ) - } - for (const team of teams) { - await databasePool.query('INSERT INTO teams(id, name) VALUES ($1, $2)', [ - team, - `Archive team ${team}`, - ]) - } - for (const index of [0, 1]) { - await databasePool.query( - ` - INSERT INTO workflow_runs(run_id, recipe_namespace, recipe_name, phase, actor_type, actor_id, - team_id, trigger_source, completed_at, ttl_seconds_after_finished) - VALUES ($1, 'revision', 'archive-order', 'Succeeded', 'user', $2, $3, 'onDemand', - NOW() - interval '2 hours', 0) - `, - [archived[index], users[index], teams[index]] - ) - await databasePool.query( - ` - INSERT INTO workflow_run_steps(run_id, step_id, phase) - VALUES ($1, 'archive-step', 'Succeeded') - `, - [archived[index]] - ) - // Whichever old row is visited first, a live run shares its user and - // the other old row's team. The test observes, rather than assumes, order. - await databasePool.query( - ` - INSERT INTO workflow_runs(run_id, recipe_namespace, recipe_name, phase, actor_type, actor_id, - team_id, trigger_source) - VALUES ($1, 'revision', 'live-order', 'Pending', 'user', $2, $3, 'onDemand') - `, - [live[index], users[index], teams[1 - index]] - ) - } - return { users, teams, archived, live } - } - - async function revisions(ids: { users: string[]; teams: string[] }) { - const user = await databasePool.query( - 'SELECT user_id::text AS id, revision::int FROM authorization_user_revisions WHERE user_id = ANY($1::uuid[]) ORDER BY user_id', - [ids.users] - ) - const team = await databasePool.query( - 'SELECT team_id::text AS id, revision::int FROM authorization_team_revisions WHERE team_id = ANY($1::uuid[]) ORDER BY team_id', - [ids.teams] - ) - return { user: user.rows, team: team.rows } - } - - async function pid(client: PoolClient): Promise { - return (await client.query('SELECT pg_backend_pid() AS pid')).rows[0].pid - } - - function byId(rows: Array<{ id: string; revision: number }>): Record { - return Object.fromEntries(rows.map(row => [row.id, row.revision])) - } - - // Bind the real archive/expiry service to one configured session for its - // first transaction; later drain batches get ordinary pooled connections. - function bindFirstSession(session: PoolClient) { - let connects = 0 - return vi.spyOn(pool, 'connect').mockImplementation(() => { - connects += 1 - return connects === 1 ? Promise.resolve(session) : databasePool.connect() - }) - } - - async function waitForBlock(blocked: number, blockers: number[]) { - const deadline = Date.now() + 5_000 - while (Date.now() < deadline) { - const result = await databasePool.query('SELECT pg_blocking_pids($1::int) AS pids', [blocked]) - const blocker = blockers.find(candidate => result.rows[0].pids.includes(candidate)) - if (blocker !== undefined) { - console.info('ARCHIVE_ORDER_BLOCK', { blocked, blocker, observed: result.rows[0].pids }) - return blocker - } - await new Promise(resolve => setTimeout(resolve, 10)) - } - throw new Error(`writer ${blocked} did not block on one of ${blockers.join(', ')}`) - } - - async function configure(client: PoolClient, application: string) { - await client.query("SELECT set_config('application_name', $1, false)", [application]) - await client.query("SET deadlock_timeout = '100ms'") - await client.query("SET statement_timeout = '8s'") - } - - async function archivedWitness(ids: { archived: string[] }) { - expect( - ( - await databasePool.query( - 'SELECT run_id FROM workflow_runs WHERE run_id = ANY($1::uuid[])', - [ids.archived] - ) - ).rows - ).toHaveLength(0) - expect( - ( - await databasePool.query( - 'SELECT run_id FROM workflow_run_steps WHERE run_id = ANY($1::uuid[])', - [ids.archived] - ) - ).rows - ).toHaveLength(0) - expect( - ( - await databasePool.query( - 'SELECT run_id FROM workflow_runs_audit WHERE run_id = ANY($1::uuid[])', - [ids.archived] - ) - ).rows - ).toHaveLength(2) - expect( - ( - await databasePool.query( - 'SELECT run_id FROM workflow_run_step_audit WHERE run_id = ANY($1::uuid[])', - [ids.archived] - ) - ).rows - ).toHaveLength(2) - } - - // One archive batch holding a user-targeted approval and a team-targeted one, - // drained by the real archive sweep. - async function approvalFixture() { - const [userApprovalId, teamApprovalId] = [randomUUID(), randomUUID()].sort() - const user = randomUUID() - const team = randomUUID() - const live = randomUUID() - await databasePool.query('INSERT INTO users(id, email) VALUES ($1, $2)', [ - user, - `approval-order-${user}@example.test`, - ]) - await databasePool.query("INSERT INTO teams(id, name) VALUES ($1, 'Approval order')", [team]) - // The user-targeted approval is first in heap order and lower in id order, - // so both a sequential and an index scan reach its subject first. - const targets = [ - [userApprovalId, 'target_user_id', user], - [teamApprovalId, 'target_team_id', team], - ] as const - for (const [id, column, target] of targets) { - await databasePool.query( - `INSERT INTO workflow_approval_requests( - id, recipe_namespace, recipe_name, expires_at, status, ${column}, - payload, idempotency_key, requested_at, decided_at - ) VALUES ($1, 'revision', 'approval-order', NOW() - INTERVAL '200 days', - 'approved', $2, '{}'::jsonb, $3, NOW() - INTERVAL '200 days', - NOW() - INTERVAL '200 days')`, - [id, target, `approval-order-${id}`] - ) - } - // A live run shares the team and the user, so a phase write takes T then U. - await databasePool.query( - `INSERT INTO workflow_runs(run_id, recipe_namespace, recipe_name, phase, actor_type, - actor_id, team_id, trigger_source) - VALUES ($1, 'revision', 'approval-order', 'Pending', 'user', $2, $3, 'onDemand')`, - [live, user, team] - ) - return { userApprovalId, teamApprovalId, user, team, live } - } - - // Two pending approvals that the expiry sweep already considers expired: the - // user-targeted row is first in heap order and lower in id order. - async function expiryFixture() { - const [userApprovalId, teamApprovalId] = [randomUUID(), randomUUID()].sort() - const user = randomUUID() - const team = randomUUID() - const live = randomUUID() - await databasePool.query('INSERT INTO users(id, email) VALUES ($1, $2)', [ - user, - `approval-expiry-${user}@example.test`, - ]) - await databasePool.query("INSERT INTO teams(id, name) VALUES ($1, 'Approval expiry')", [team]) - const targets = [ - [userApprovalId, 'target_user_id', user], - [teamApprovalId, 'target_team_id', team], - ] as const - for (const [id, column, target] of targets) { - await databasePool.query( - `INSERT INTO workflow_approval_requests( - id, recipe_namespace, recipe_name, expires_at, status, ${column}, - payload, idempotency_key, requested_at - ) VALUES ($1, 'revision', 'approval-expiry', NOW() - INTERVAL '1 hour', - 'pending', $2, '{}'::jsonb, $3, NOW() - INTERVAL '2 hours')`, - [id, target, `approval-expiry-${id}`] - ) - } - await databasePool.query( - `INSERT INTO workflow_runs(run_id, recipe_namespace, recipe_name, phase, actor_type, - actor_id, team_id, trigger_source) - VALUES ($1, 'revision', 'approval-expiry', 'Pending', 'user', $2, $3, 'onDemand')`, - [live, user, team] - ) - return { userApprovalId, teamApprovalId, user, team, live } - } - - it('archives two rows and advances revisions with a run-insert liveness witness', async () => { - const ids = await fixture() - const before = await revisions(ids) - const connect = vi.spyOn(pool, 'connect').mockImplementation(() => databasePool.connect()) - try { - expect(await archiveTerminalRuns(gateway, { batchSize: 500, maxBatches: 1 })).toBe(2) - await archivedWitness(ids) - // A phase-only UPDATE no longer bumps revisions, so the witness is a run - // INSERT of the same user and the other live run's team. - const created = await createRun( - { - recipe_namespace: 'revision', - recipe_name: 'archive-order-witness', - actor_type: 'user', - team_id: ids.teams[1], - usage_team_id: ids.teams[1], - actor_id: ids.users[0], - idempotency_key: null, - trigger_source: 'onDemand', - ttl_seconds_after_finished: 2_592_000, - }, - databasePool - ) - expect(created.created).toBe(true) - const after = await revisions(ids) - // Archive: prelock + live DELETE + audit INSERT, one bump each per - // subject. The INSERT adds one bump for the user and, because team and - // usage team are the same subject, two for that team. - expect(byId(after.user)).toEqual({ - [ids.users[0]!]: byId(before.user)[ids.users[0]!]! + 4, - [ids.users[1]!]: byId(before.user)[ids.users[1]!]! + 3, - }) - expect(byId(after.team)).toEqual({ - [ids.teams[0]!]: byId(before.team)[ids.teams[0]!]! + 3, - [ids.teams[1]!]: byId(before.team)[ids.teams[1]!]! + 5, - }) - } finally { - connect.mockRestore() - } - }) - - it('commits an archive batch beside a run INSERT without reversing subject locks', async () => { - const ids = await fixture() - const before = await revisions(ids) - const archiveGate = await databasePool.connect() - const competitorGate = await databasePool.connect() - const archive = await databasePool.connect() - const competitor = await databasePool.connect() - const archivePid = await pid(archive) - const competitorPid = await pid(competitor) - const archiveGatePid = await pid(archiveGate) - const competitorGatePid = await pid(competitorGate) - let archiveOutcome: Promise | undefined - let competitorOutcome: Promise | undefined - let archiveStarted = false - let firstIndex = -1 - let outcomes: Outcome[] = [] - // The connection boundary binds the actual archive service to this real - // database. Neither archiveOneBatch nor the INSERT SQL is replaced. - const connect = vi.spyOn(pool, 'connect').mockResolvedValue(archive) - try { - await configure(archive, 'archive-order-archive') - await configure(competitor, 'archive-order-insert') - await archiveGate.query('SELECT pg_advisory_lock($1, 1), pg_advisory_lock($1, 2)', [ - barrierClass, - ]) - await competitorGate.query('SELECT pg_advisory_lock($1, 3)', [barrierClass]) - await databasePool.query( - ` - INSERT INTO archive_order_barriers VALUES - ('archive-order-archive', 'authorization_user_revisions', $1, 1), - ('archive-order-archive', 'authorization_user_revisions', $2, 2), - ('archive-order-insert', 'authorization_team_revisions', '', 3) - `, - ids.users - ) - archiveStarted = true - archiveOutcome = observe(archiveTerminalRuns(gateway, { batchSize: 500, maxBatches: 1 })) - await waitForBlock(archivePid, [archiveGatePid]) - const waiting = await databasePool.query( - ` - SELECT objid::int AS gate FROM pg_locks - WHERE pid = $1 AND locktype = 'advisory' AND NOT granted AND classid = $2::oid - `, - [archivePid, barrierClass] - ) - expect(waiting.rows).toHaveLength(1) - expect([1, 2]).toContain(waiting.rows[0].gate) - firstIndex = waiting.rows[0].gate - 1 - console.info('ARCHIVE_ORDER_FIRST_USER', { - index: firstIndex, - user: ids.users[firstIndex], - team: ids.teams[firstIndex], - }) - await competitor.query('BEGIN') - // A phase-only UPDATE no longer bumps revisions, so the competitor is a - // run INSERT of the user the archive holds and the other live team. - competitorOutcome = observe( - createRun( - { - recipe_namespace: 'revision', - recipe_name: 'archive-order-competitor', - actor_type: 'user', - team_id: ids.teams[1 - firstIndex], - usage_team_id: ids.teams[1 - firstIndex], - actor_id: ids.users[firstIndex], - idempotency_key: null, - trigger_source: 'onDemand', - ttl_seconds_after_finished: 2_592_000, - }, - competitor - ) - ) - const competitorBlocker = await waitForBlock(competitorPid, [competitorGatePid, archivePid]) - if (competitorBlocker === competitorGatePid) { - // Without the prelock, the INSERT holds the other team's revision - // before waiting on the archive's first user: a cycle. - await competitorGate.query('SELECT pg_advisory_unlock_all()') - await waitForBlock(competitorPid, [archivePid]) - await archiveGate.query('SELECT pg_advisory_unlock_all()') - } else { - // With the prelock, the archive already holds both teams before - // reaching its first user. The INSERT waits at its first team. - await archiveGate.query('SELECT pg_advisory_unlock_all()') - await waitForBlock(competitorPid, [competitorGatePid]) - await competitorGate.query('SELECT pg_advisory_unlock_all()') - } - const competitorResult = await competitorOutcome - await competitor.query(competitorResult.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') - outcomes = [await archiveOutcome, competitorResult] - console.info( - 'ARCHIVE_ORDER_OUTCOMES', - outcomes.map(result => ({ - status: result.status, - code: result.status === 'rejected' ? result.code : undefined, - })) - ) - } finally { - await archiveGate.query('SELECT pg_advisory_unlock_all()') - await competitorGate.query('SELECT pg_advisory_unlock_all()') - await competitorOutcome - await competitor.query('ROLLBACK') - await archiveOutcome - connect.mockRestore() - for (const client of [archiveGate, competitorGate, competitor]) client.release() - if (!archiveStarted) archive.release() - await databasePool.query('TRUNCATE archive_order_barriers') - } - expect(outcomes.map(result => result.status)).toEqual(['fulfilled', 'fulfilled']) - expect(outcomes[0]).toEqual({ status: 'fulfilled', value: 2 }) - await archivedWitness(ids) - const after = await revisions(ids) - // Archive: prelock + live DELETE + audit INSERT, one bump each per subject. - // The INSERT adds one bump for the user it names and, because team and - // usage team are one subject, two for that team. - expect(byId(after.user)).toEqual({ - [ids.users[firstIndex]!]: byId(before.user)[ids.users[firstIndex]!]! + 4, - [ids.users[1 - firstIndex]!]: byId(before.user)[ids.users[1 - firstIndex]!]! + 3, - }) - expect(byId(after.team)).toEqual({ - [ids.teams[1 - firstIndex]!]: byId(before.team)[ids.teams[1 - firstIndex]!]! + 5, - [ids.teams[firstIndex]!]: byId(before.team)[ids.teams[firstIndex]!]! + 3, - }) - expect( - ( - await databasePool.query( - `SELECT phase FROM workflow_runs WHERE actor_id = $1 AND team_id = $2 - AND recipe_name = 'archive-order-competitor'`, - [ids.users[firstIndex], ids.teams[1 - firstIndex]] - ) - ).rows - ).toEqual([{ phase: 'Pending' }]) - }) - - it.each(['user', 'team'] as const)( - 'commits an archive batch beside %s deletion with parent-before-revision locks', - async kind => { - const user = randomUUID() - const team = randomUUID() - const run = randomUUID() - await databasePool.query('INSERT INTO users(id, email) VALUES ($1, $2)', [ - user, - `${user}@example.test`, - ]) - await databasePool.query("INSERT INTO teams(id, name) VALUES ($1, 'Archive delete')", [team]) - await databasePool.query( - `INSERT INTO workflow_runs(run_id, recipe_namespace, recipe_name, phase, actor_type, - actor_id, team_id, trigger_source, completed_at, ttl_seconds_after_finished) - VALUES ($1, 'revision', 'archive-delete', 'Succeeded', 'user', $2, $3, - 'onDemand', NOW() - interval '3 hours', 0)`, - [run, user, team] - ) - await databasePool.query( - "INSERT INTO workflow_run_steps(run_id, step_id, phase) VALUES ($1, 'delete-step', 'Succeeded')", - [run] - ) - const ids = { users: [user], teams: [team] } - const before = await revisions(ids) - const archiveGate = await databasePool.connect() - const archive = await databasePool.connect() - const deletion = await databasePool.connect() - const archivePid = await pid(archive) - const deletionPid = await pid(deletion) - const gatePid = await pid(archiveGate) - const subject = kind === 'user' ? user : team - const revisionTable = `authorization_${kind}_revisions` - let archiveOutcome: Promise | undefined - let deletionOutcome: Promise | undefined - const connect = vi.spyOn(pool, 'connect').mockResolvedValue(archive) - try { - await configure(archive, `archive-delete-${kind}`) - await configure(deletion, `delete-principal-${kind}`) - await archiveGate.query('SELECT pg_advisory_lock($1, 10)', [barrierClass]) - await databasePool.query('INSERT INTO archive_order_barriers VALUES ($1, $2, $3, 10)', [ - `archive-delete-${kind}`, - revisionTable, - subject, - ]) - archiveOutcome = observe(archiveTerminalRuns(gateway, { batchSize: 1, maxBatches: 1 })) - // The real prelock has bumped the subject and holds its revision row. - await waitForBlock(archivePid, [gatePid]) - deletionOutcome = observe( - deletion.query(`DELETE FROM ${kind === 'user' ? 'users' : 'teams'} WHERE id = $1`, [ - subject, - ]) - ) - await waitForBlock(deletionPid, [archivePid]) - await archiveGate.query('SELECT pg_advisory_unlock_all()') - const outcomes = await Promise.all([archiveOutcome, deletionOutcome]) - console.info('ARCHIVE_DELETE_OUTCOMES', { kind, outcomes }) - expect(outcomes.map(result => result.status)).toEqual(['fulfilled', 'fulfilled']) - expect(outcomes[0]).toEqual({ status: 'fulfilled', value: 1 }) - expect(outcomes[1]).toMatchObject({ status: 'fulfilled', value: { rowCount: 1 } }) - expect( - ( - await databasePool.query( - `SELECT id FROM ${kind === 'user' ? 'users' : 'teams'} WHERE id = $1`, - [subject] - ) - ).rows - ).toHaveLength(0) - expect( - (await databasePool.query('SELECT run_id FROM workflow_runs WHERE run_id = $1', [run])) - .rows - ).toHaveLength(0) - expect( - ( - await databasePool.query('SELECT run_id FROM workflow_run_steps WHERE run_id = $1', [ - run, - ]) - ).rows - ).toHaveLength(0) - expect( - ( - await databasePool.query( - 'SELECT run_id, triggerer_user_id, triggerer_team_id FROM workflow_runs_audit WHERE run_id = $1', - [run] - ) - ).rows - ).toEqual([ - { - run_id: run, - triggerer_user_id: kind === 'user' ? null : user, - triggerer_team_id: kind === 'team' ? null : team, - }, - ]) - expect( - ( - await databasePool.query( - 'SELECT step_id FROM workflow_run_step_audit WHERE run_id = $1', - [run] - ) - ).rows - ).toEqual([{ step_id: 'delete-step' }]) - const after = await revisions(ids) - const deleted = kind === 'user' ? 'user' : 'team' - const surviving = kind === 'user' ? 'team' : 'user' - expect(after[deleted]).toEqual([]) - // Archive: prelock + audit INSERT + live DELETE. Principal deletion - // nulls audit attribution, bumping its surviving subject twice. - expect(after[surviving]).toEqual([ - { - id: before[surviving][0].id, - revision: before[surviving][0].revision + 5, - }, - ]) - for (const revisionKind of ['user', 'team'] as const) { - const recorded = await databasePool.query( - `SELECT revision::int FROM archive_delete_revision_observations - WHERE application_name = $1 AND revision_table = $2 AND subject_id = $3 - ORDER BY position`, - [ - `archive-delete-${kind}`, - `authorization_${revisionKind}_revisions`, - revisionKind === 'user' ? user : team, - ] - ) - expect(recorded.rows).toEqual( - [1, 2, 3].map(delta => ({ revision: before[revisionKind][0].revision + delta })) - ) - } - console.info( - 'ARCHIVE_DELETE_WITNESSES', - JSON.stringify({ kind, before, after, archived: run, deleted: subject }) - ) - } finally { - await archiveGate.query('SELECT pg_advisory_unlock_all()') - await Promise.all([archiveOutcome, deletionOutcome]) - connect.mockRestore() - if (!archiveOutcome) archive.release() - archiveGate.release() - deletion.release() - await databasePool.query('TRUNCATE archive_order_barriers') - await databasePool.query('DELETE FROM workflow_runs WHERE run_id = $1', [run]) - await databasePool.query('DELETE FROM workflow_runs_audit WHERE run_id = $1', [run]) - await databasePool.query('DELETE FROM users WHERE id = $1', [user]) - await databasePool.query('DELETE FROM teams WHERE id = $1', [team]) - } - } - ) - - it('creates missing revisions once per subject in team-then-user order', async () => { - const ids = await fixture() - const usageTeam = randomUUID() - const adminActor = randomUUID() - await databasePool.query("INSERT INTO teams(id, name) VALUES ($1, 'Usage team')", [usageTeam]) - await databasePool.query('INSERT INTO users(id, email) VALUES ($1, $2)', [ - adminActor, - `${adminActor}@example.test`, - ]) - await databasePool.query('UPDATE workflow_runs SET usage_team_id = $2 WHERE run_id = $1', [ - ids.archived[0], - ids.teams[1], - ]) - await databasePool.query('UPDATE workflow_runs SET usage_team_id = $2 WHERE run_id = $1', [ - ids.archived[1], - usageTeam, - ]) - await databasePool.query( - `UPDATE workflow_runs SET actor_type = 'admin', actor_id = $2, - usage_team_id = 'control-plane-admin-ui' WHERE run_id = $1`, - [ids.live[1], adminActor] - ) - const subjects = { users: ids.users, teams: [...ids.teams, usageTeam] } - const adminBefore = await revisions({ users: [adminActor], teams: [] }) - await databasePool.query( - 'DELETE FROM authorization_user_revisions WHERE user_id = ANY($1::uuid[])', - [subjects.users] - ) - await databasePool.query( - 'DELETE FROM authorization_team_revisions WHERE team_id = ANY($1::uuid[])', - [subjects.teams] - ) - await databasePool.query(` - CREATE TABLE archive_prelock_observations ( - position BIGSERIAL, subject_type TEXT, subject_id TEXT - ); - CREATE FUNCTION observe_archive_prelock() RETURNS TRIGGER LANGUAGE plpgsql AS $$ - BEGIN - INSERT INTO archive_prelock_observations(subject_type, subject_id) - VALUES ( - CASE WHEN TG_TABLE_NAME = 'authorization_team_revisions' THEN 'team' ELSE 'user' END, - COALESCE(to_jsonb(NEW)->>'team_id', to_jsonb(NEW)->>'user_id') - ); - RETURN NEW; - END; - $$; - CREATE TRIGGER observe_archive_prelock AFTER INSERT OR UPDATE - ON authorization_team_revisions FOR EACH ROW EXECUTE FUNCTION observe_archive_prelock(); - CREATE TRIGGER observe_archive_prelock AFTER INSERT OR UPDATE - ON authorization_user_revisions FOR EACH ROW EXECUTE FUNCTION observe_archive_prelock(); - `) - const client = await databasePool.connect() - try { - await client.query('BEGIN') - await client.query('SET LOCAL ROLE control_api_runtime') - for (const expectedRevision of [1, 2]) { - await client.query('SELECT authorization_prelock_workflow_run_revisions($1::uuid[])', [ - [...ids.archived, ...ids.live, ids.archived[0], randomUUID()], - ]) - const after = await client.query<{ revision: number }>( - `SELECT revision::int FROM authorization_user_revisions WHERE user_id = ANY($1::uuid[]) - UNION ALL - SELECT revision::int FROM authorization_team_revisions WHERE team_id = ANY($2::uuid[])`, - [subjects.users, subjects.teams] - ) - expect(after.rows).toEqual( - Array.from({ length: 5 }, () => ({ revision: expectedRevision })) - ) - } - await client.query('COMMIT') - const expectedOrder = [ - ...subjects.teams.sort().map(subject_id => ({ subject_type: 'team', subject_id })), - ...subjects.users.sort().map(subject_id => ({ subject_type: 'user', subject_id })), - ] - const observed = await databasePool.query( - 'SELECT subject_type, subject_id FROM archive_prelock_observations ORDER BY position' - ) - expect(observed.rows).toEqual([...expectedOrder, ...expectedOrder]) - expect(await revisions({ users: [adminActor], teams: [] })).toEqual(adminBefore) - } finally { - await client.query('ROLLBACK') - client.release() - await databasePool.query(` - DROP TRIGGER observe_archive_prelock ON authorization_team_revisions; - DROP TRIGGER observe_archive_prelock ON authorization_user_revisions; - DROP FUNCTION observe_archive_prelock(); - DROP TABLE archive_prelock_observations; - `) - } - }) - - it('creates missing revisions once per approval-batch subject in team-then-user order', async () => { - const targetUser = randomUUID() - const runActor = randomUUID() - const targetTeam = randomUUID() - const runTeam = randomUUID() - const usageTeam = randomUUID() - const userApproval = randomUUID() - const teamApproval = randomUUID() - const run = randomUUID() - for (const user of [targetUser, runActor]) { - await databasePool.query('INSERT INTO users(id, email) VALUES ($1, $2)', [ - user, - `approval-prelock-${user}@example.test`, - ]) - } - for (const team of [targetTeam, runTeam, usageTeam]) { - await databasePool.query("INSERT INTO teams(id, name) VALUES ($1, 'Approval prelock')", [ - team, - ]) - } - await databasePool.query( - `INSERT INTO workflow_approval_requests( - id, recipe_namespace, recipe_name, expires_at, status, target_user_id, - payload, idempotency_key - ) VALUES ($1, 'revision', 'approval-prelock', NOW() + INTERVAL '1 hour', 'pending', - $2, '{}'::jsonb, $3)`, - [userApproval, targetUser, `approval-prelock-user-${userApproval}`] - ) - await databasePool.query( - `INSERT INTO workflow_approval_requests( - id, recipe_namespace, recipe_name, expires_at, status, target_team_id, - payload, idempotency_key - ) VALUES ($1, 'revision', 'approval-prelock', NOW() + INTERVAL '1 hour', 'pending', - $2, '{}'::jsonb, $3)`, - [teamApproval, targetTeam, `approval-prelock-team-${teamApproval}`] - ) - // The referencing run's attribution joins the approval targets. - await databasePool.query( - `INSERT INTO workflow_runs(run_id, recipe_namespace, recipe_name, phase, actor_type, - actor_id, team_id, usage_team_id, trigger_source, approval_request_id) - VALUES ($1, 'revision', 'approval-prelock', 'Pending', 'user', $2, $3, $4, 'onDemand', $5)`, - [run, runActor, runTeam, usageTeam, userApproval] - ) - const subjects = { - users: [targetUser, runActor].sort(), - teams: [targetTeam, runTeam, usageTeam].sort(), - } - await databasePool.query( - 'DELETE FROM authorization_user_revisions WHERE user_id = ANY($1::uuid[])', - [subjects.users] - ) - await databasePool.query( - 'DELETE FROM authorization_team_revisions WHERE team_id = ANY($1::uuid[])', - [subjects.teams] - ) - await databasePool.query(` - CREATE TABLE approval_prelock_observations ( - position BIGSERIAL, subject_type TEXT, subject_id TEXT - ); - CREATE FUNCTION observe_approval_prelock() RETURNS TRIGGER LANGUAGE plpgsql AS $$ - BEGIN - INSERT INTO approval_prelock_observations(subject_type, subject_id) - VALUES ( - CASE WHEN TG_TABLE_NAME = 'authorization_team_revisions' THEN 'team' ELSE 'user' END, - COALESCE(to_jsonb(NEW)->>'team_id', to_jsonb(NEW)->>'user_id') - ); - RETURN NEW; - END; - $$; - CREATE TRIGGER observe_approval_prelock AFTER INSERT OR UPDATE - ON authorization_team_revisions FOR EACH ROW EXECUTE FUNCTION observe_approval_prelock(); - CREATE TRIGGER observe_approval_prelock AFTER INSERT OR UPDATE - ON authorization_user_revisions FOR EACH ROW EXECUTE FUNCTION observe_approval_prelock(); - `) - const client = await databasePool.connect() - try { - await client.query('BEGIN') - await client.query('SET LOCAL ROLE control_api_runtime') - const expectedOrder = [ - ...subjects.teams.map(subject_id => ({ subject_type: 'team', subject_id })), - ...subjects.users.map(subject_id => ({ subject_type: 'user', subject_id })), - ] - for (const expectedRevision of [1, 2]) { - // The duplicate first id proves the batch is deduplicated. - await client.query('SELECT authorization_prelock_workflow_approval_revisions($1::uuid[])', [ - [userApproval, teamApproval, userApproval, randomUUID()], - ]) - const after = await client.query<{ revision: number }>( - `SELECT revision::int FROM authorization_user_revisions WHERE user_id = ANY($1::uuid[]) - UNION ALL - SELECT revision::int FROM authorization_team_revisions WHERE team_id = ANY($2::uuid[])`, - [subjects.users, subjects.teams] - ) - expect(after.rows).toEqual( - Array.from({ length: 5 }, () => ({ revision: expectedRevision })) - ) - } - await client.query('COMMIT') - // Each call bumps every subject once, teams before users. - const observed = await databasePool.query( - 'SELECT subject_type, subject_id FROM approval_prelock_observations ORDER BY position' - ) - expect(observed.rows).toEqual([...expectedOrder, ...expectedOrder]) - } finally { - await client.query('ROLLBACK') - client.release() - await databasePool.query(` - DROP TRIGGER observe_approval_prelock ON authorization_team_revisions; - DROP TRIGGER observe_approval_prelock ON authorization_user_revisions; - DROP FUNCTION observe_approval_prelock(); - DROP TABLE approval_prelock_observations; - `) - await databasePool.query('DELETE FROM workflow_runs WHERE run_id = $1', [run]) - await databasePool.query( - 'DELETE FROM workflow_approval_requests WHERE id = ANY($1::uuid[])', - [[userApproval, teamApproval]] - ) - await databasePool.query('DELETE FROM users WHERE id = ANY($1::uuid[])', [subjects.users]) - await databasePool.query('DELETE FROM teams WHERE id = ANY($1::uuid[])', [subjects.teams]) - } - }) - - it('commits an approval archive batch beside a run INSERT without reversing subject locks', async () => { - const ids = await approvalFixture() - const before = await revisions({ users: [ids.user], teams: [ids.team] }) - const archiveGate = await databasePool.connect() - const archive = await databasePool.connect() - const competitor = await databasePool.connect() - const archivePid = await pid(archive) - const competitorPid = await pid(competitor) - const gatePid = await pid(archiveGate) - let archiveOutcome: Promise | undefined - let competitorOutcome: Promise | undefined - const connect = bindFirstSession(archive) - try { - await configure(archive, 'approval-archive-order') - await configure(competitor, 'approval-insert-order') - await archiveGate.query('SELECT pg_advisory_lock($1, 21)', [barrierClass]) - await databasePool.query('INSERT INTO archive_order_barriers VALUES ($1, $2, $3, 21)', [ - 'approval-archive-order', - 'authorization_user_revisions', - ids.user, - ]) - archiveOutcome = observe(archiveTerminalApprovals(180, 500)) - // The prelock pauses on the user only after taking the batch's team. - await waitForBlock(archivePid, [gatePid]) - await competitor.query('BEGIN') - // A phase-only UPDATE no longer bumps revisions, so the competitor is a - // run INSERT of the same user and team. - competitorOutcome = observe( - createRun( - { - recipe_namespace: 'revision', - recipe_name: 'approval-insert-competitor', - actor_type: 'user', - team_id: ids.team, - usage_team_id: ids.team, - actor_id: ids.user, - idempotency_key: null, - trigger_source: 'onDemand', - ttl_seconds_after_finished: 2_592_000, - }, - competitor - ) - ) - // The INSERT takes its team first and waits there, so it can never hold - // the team this batch already prelocked while asking for its user. - await waitForBlock(competitorPid, [archivePid]) - await archiveGate.query('SELECT pg_advisory_unlock_all()') - const competitorResult = await competitorOutcome - await competitor.query(competitorResult.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') - const archiveResult = await archiveOutcome - console.info('APPROVAL_ORDER_OUTCOMES', { - archive: archiveResult, - competitor: competitorResult, - }) - expect([archiveResult.status, competitorResult.status]).toEqual(['fulfilled', 'fulfilled']) - expect(archiveResult).toEqual({ status: 'fulfilled', value: 2 }) - expect( - ( - await databasePool.query( - `SELECT phase FROM workflow_runs WHERE actor_id = $1 AND team_id = $2 - AND recipe_name = 'approval-insert-competitor'`, - [ids.user, ids.team] - ) - ).rows - ).toEqual([{ phase: 'Pending' }]) - expect( - ( - await databasePool.query( - 'SELECT id FROM workflow_approval_requests WHERE id = ANY($1::uuid[])', - [[ids.userApprovalId, ids.teamApprovalId]] - ) - ).rows - ).toHaveLength(0) - // The prelock and the DELETE's row trigger bump each subject once; the - // INSERT adds one user bump and two team bumps (team and usage team are - // the same subject). - const after = await revisions({ users: [ids.user], teams: [ids.team] }) - expect(after.user).toEqual([{ id: ids.user, revision: before.user[0]!.revision + 3 }]) - expect(after.team).toEqual([{ id: ids.team, revision: before.team[0]!.revision + 4 }]) - // The prelock takes the team first; the DELETE then meets the - // user-targeted row first, as the fixture intends. - expect( - ( - await databasePool.query( - `SELECT revision_table, subject_id FROM archive_delete_revision_observations - WHERE application_name = $1 ORDER BY position`, - ['approval-archive-order'] - ) - ).rows - ).toEqual([ - { revision_table: 'authorization_team_revisions', subject_id: ids.team }, - { revision_table: 'authorization_user_revisions', subject_id: ids.user }, - { revision_table: 'authorization_user_revisions', subject_id: ids.user }, - { revision_table: 'authorization_team_revisions', subject_id: ids.team }, - ]) - } finally { - await archiveGate.query('SELECT pg_advisory_unlock_all()') - await Promise.all([archiveOutcome, competitorOutcome]) - await competitor.query('ROLLBACK') - connect.mockRestore() - for (const client of [archiveGate, competitor]) client.release() - if (!archiveOutcome) archive.release() - await databasePool.query('TRUNCATE archive_order_barriers') - await databasePool.query( - 'DELETE FROM workflow_approval_requests WHERE id = ANY($1::uuid[])', - [[ids.userApprovalId, ids.teamApprovalId]] - ) - await databasePool.query( - 'DELETE FROM workflow_runs WHERE recipe_name = $1 AND actor_id = $2', - ['approval-insert-competitor', ids.user] - ) - await databasePool.query('DELETE FROM workflow_runs WHERE run_id = $1', [ids.live]) - await databasePool.query('DELETE FROM users WHERE id = $1', [ids.user]) - await databasePool.query('DELETE FROM teams WHERE id = $1', [ids.team]) - } - }) - - it('expires pending approvals beside a run INSERT without reversing subject locks', async () => { - const ids = await expiryFixture() - const before = await revisions({ users: [ids.user], teams: [ids.team] }) - const sweepGate = await databasePool.connect() - const sweep = await databasePool.connect() - const competitor = await databasePool.connect() - const sweepPid = await pid(sweep) - const competitorPid = await pid(competitor) - const gatePid = await pid(sweepGate) - let sweepOutcome: Promise | undefined - let competitorOutcome: Promise | undefined - const connect = bindFirstSession(sweep) - try { - await configure(sweep, 'approval-expiry-sweep') - await configure(competitor, 'approval-insert-order') - // The sweep pauses on the user revision, after taking its team. - await sweepGate.query('SELECT pg_advisory_lock($1, 22)', [barrierClass]) - await databasePool.query('INSERT INTO archive_order_barriers VALUES ($1, $2, $3, 22)', [ - 'approval-expiry-sweep', - 'authorization_user_revisions', - ids.user, - ]) - sweepOutcome = observe(expirePendingRequests()) - await waitForBlock(sweepPid, [gatePid]) - await competitor.query('BEGIN') - // A phase-only UPDATE no longer bumps revisions, so the competitor is a - // run INSERT of the same user and team. - competitorOutcome = observe( - createRun( - { - recipe_namespace: 'revision', - recipe_name: 'approval-expiry-competitor', - actor_type: 'user', - team_id: ids.team, - usage_team_id: ids.team, - actor_id: ids.user, - idempotency_key: null, - trigger_source: 'onDemand', - ttl_seconds_after_finished: 2_592_000, - }, - competitor - ) - ) - // The INSERT takes its team first and waits there, so the sweep never has - // to wait for the user it already holds. - await waitForBlock(competitorPid, [sweepPid]) - await sweepGate.query('SELECT pg_advisory_unlock_all()') - const competitorResult = await competitorOutcome - await competitor.query(competitorResult.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') - const sweepResult = await sweepOutcome - console.info('APPROVAL_EXPIRY_OUTCOMES', { sweep: sweepResult, competitor: competitorResult }) - expect([sweepResult.status, competitorResult.status]).toEqual(['fulfilled', 'fulfilled']) - expect(sweepResult).toEqual({ status: 'fulfilled', value: 2 }) - expect( - ( - await databasePool.query( - `SELECT id::text AS id, status FROM workflow_approval_requests - WHERE id = ANY($1::uuid[]) ORDER BY id`, - [[ids.userApprovalId, ids.teamApprovalId]] - ) - ).rows - ).toEqual([ - { id: ids.userApprovalId, status: 'expired' }, - { id: ids.teamApprovalId, status: 'expired' }, - ]) - expect( - ( - await databasePool.query( - `SELECT phase FROM workflow_runs WHERE actor_id = $1 AND team_id = $2 - AND recipe_name = 'approval-expiry-competitor'`, - [ids.user, ids.team] - ) - ).rows - ).toEqual([{ phase: 'Pending' }]) - // The prelock takes the team first; the UPDATE then meets the - // user-targeted row first and bumps both sides of its attribution. - expect( - ( - await databasePool.query( - `SELECT revision_table, subject_id FROM archive_delete_revision_observations - WHERE application_name = $1 ORDER BY position`, - ['approval-expiry-sweep'] - ) - ).rows - ).toEqual([ - { revision_table: 'authorization_team_revisions', subject_id: ids.team }, - { revision_table: 'authorization_user_revisions', subject_id: ids.user }, - { revision_table: 'authorization_user_revisions', subject_id: ids.user }, - { revision_table: 'authorization_user_revisions', subject_id: ids.user }, - { revision_table: 'authorization_team_revisions', subject_id: ids.team }, - { revision_table: 'authorization_team_revisions', subject_id: ids.team }, - ]) - // Prelock plus the UPDATE's NEW and OLD attribution pairs, plus the - // INSERT's one user bump and two team bumps (team and usage team are the - // same subject). - const after = await revisions({ users: [ids.user], teams: [ids.team] }) - expect(after.user).toEqual([{ id: ids.user, revision: before.user[0]!.revision + 4 }]) - expect(after.team).toEqual([{ id: ids.team, revision: before.team[0]!.revision + 5 }]) - } finally { - await sweepGate.query('SELECT pg_advisory_unlock_all()') - await Promise.all([sweepOutcome, competitorOutcome]) - await competitor.query('ROLLBACK') - connect.mockRestore() - for (const client of [sweepGate, competitor]) client.release() - if (!sweepOutcome) sweep.release() - await databasePool.query('TRUNCATE archive_order_barriers') - await databasePool.query( - 'DELETE FROM workflow_approval_requests WHERE id = ANY($1::uuid[])', - [[ids.userApprovalId, ids.teamApprovalId]] - ) - await databasePool.query('DELETE FROM workflow_runs WHERE run_id = $1', [ids.live]) - await databasePool.query( - 'DELETE FROM workflow_runs WHERE recipe_name = $1 AND actor_id = $2', - ['approval-expiry-competitor', ids.user] - ) - await databasePool.query('DELETE FROM users WHERE id = $1', [ids.user]) - await databasePool.query('DELETE FROM teams WHERE id = $1', [ids.team]) - } - }) - - // A user-targeted approval whose stored run intent is team T, usage team T - // and actor U, plus the trigger grant the approved-trigger paths require. - async function triggerApprovalFixture(decision: 'pending' | 'approved') { - const approvalId = randomUUID() - const user = randomUUID() - const team = randomUUID() - const recipeName = `approval-trigger-${randomBytes(4).toString('hex')}` - const idempotencyKey = `approval-trigger-${randomUUID()}` - await databasePool.query('INSERT INTO users(id, email) VALUES ($1, $2)', [ - user, - `approval-trigger-${user}@example.test`, - ]) - await databasePool.query("INSERT INTO teams(id, name) VALUES ($1, 'Approval trigger')", [team]) - await databasePool.query( - `INSERT INTO user_workflow_triggers(user_id, recipe_namespace, recipe_name) - VALUES ($1, 'revision', $2)`, - [user, recipeName] - ) - await databasePool.query( - `INSERT INTO workflow_approval_requests( - id, recipe_namespace, recipe_name, expires_at, status, target_user_id, - payload, idempotency_key, requested_at, decided_at, decided_by_user_id - ) VALUES ($1, 'revision', $2, NOW() + INTERVAL '1 hour', $3, $4::uuid, '{}'::jsonb, $5, - NOW(), CASE WHEN $3 = 'approved' THEN NOW() END, - CASE WHEN $3 = 'approved' THEN $4::uuid::text END)`, - [approvalId, recipeName, decision, user, idempotencyKey] - ) - await databasePool.query( - `INSERT INTO workflow_approval_trigger_intents( - approval_request_id, trigger_namespace, trigger_name, trigger_caller_key - ) VALUES ($1, 'revision', $2, 'revision/' || $2)`, - [approvalId, recipeName] - ) - await databasePool.query( - `INSERT INTO workflow_approval_trigger_run_intents( - approval_request_id, actor_type, actor_id, team_id, usage_team_id, - trigger_source, idempotency_key, idempotency_payload_hash, - ttl_seconds_after_finished - ) VALUES ($1, 'user', $2, $3, $4, 'onDemand', $5, 'hash-approval-trigger', 2592000)`, - [approvalId, user, team, team, idempotencyKey] - ) - return { approvalId, user, team, recipeName, idempotencyKey } - } - - async function observedRevisions(application: string) { - return ( - await databasePool.query( - `SELECT revision_table, subject_id FROM archive_delete_revision_observations - WHERE application_name = $1 ORDER BY position`, - [application] - ) - ).rows - } - - async function triggerApprovalCleanup(ids: { approvalId: string; user: string; team: string }) { - await databasePool.query('DELETE FROM workflow_runs WHERE approval_request_id = $1', [ - ids.approvalId, - ]) - await databasePool.query('DELETE FROM workflow_approval_requests WHERE id = $1', [ - ids.approvalId, - ]) - await databasePool.query('DELETE FROM user_workflow_triggers WHERE user_id = $1', [ids.user]) - await databasePool.query('DELETE FROM users WHERE id = $1', [ids.user]) - await databasePool.query('DELETE FROM teams WHERE id = $1', [ids.team]) - } - - // Returns the witness rows so the caller can assert them before cleanup. - async function triggerApprovalWitness(ids: { approvalId: string; user: string; team: string }) { - const runs = ( - await databasePool.query( - `SELECT run_id, phase, approval_request_id, team_id, usage_team_id, actor_id - FROM workflow_runs WHERE approval_request_id = $1`, - [ids.approvalId] - ) - ).rows - const approval = ( - await databasePool.query('SELECT status FROM workflow_approval_requests WHERE id = $1', [ - ids.approvalId, - ]) - ).rows - return { runs, approval } - } - - // A phase-only UPDATE no longer bumps the run's subjects, so the competing - // writer is a run INSERT of the same team, usage team and actor: it takes the - // team before the user, exactly as the approved-trigger paths do. - function competingRunInsert( - ids: { user: string; team: string }, - recipeName: string - ): Parameters[0] { - return { - recipe_namespace: 'revision', - recipe_name: recipeName, - actor_type: 'user', - team_id: ids.team, - usage_team_id: ids.team, - actor_id: ids.user, - idempotency_key: null, - trigger_source: 'onDemand', - ttl_seconds_after_finished: 2_592_000, - } - } - - it('decides a user-targeted approval beside a run INSERT without reversing subject locks', async () => { - const ids = await triggerApprovalFixture('pending') - const decisionGate = await databasePool.connect() - const competitorGate = await databasePool.connect() - const decision = await databasePool.connect() - const competitor = await databasePool.connect() - const decisionPid = await pid(decision) - const competitorPid = await pid(competitor) - const decisionGatePid = await pid(decisionGate) - const competitorGatePid = await pid(competitorGate) - let decisionOutcome: Promise | undefined - let competitorOutcome: Promise | undefined - let decisionSettled = false - let outcomes: Outcome[] = [] - let witness: Awaited> | undefined - try { - await configure(decision, 'approval-decision-order') - await configure(competitor, 'approval-create-run') - await decisionGate.query('SELECT pg_advisory_lock($1, 31)', [barrierClass]) - await competitorGate.query('SELECT pg_advisory_lock($1, 32)', [barrierClass]) - await databasePool.query( - `INSERT INTO archive_order_barriers VALUES - ('approval-decision-order', 'authorization_user_revisions', $1, 31), - ('approval-create-run', 'authorization_team_revisions', '', 32)`, - [ids.user] - ) - await decision.query('BEGIN') - decisionOutcome = observe( - recordDecision( - ids.approvalId, - 'approve', - { userId: ids.user }, - undefined, - undefined, - decision - ) - ) - // The decision locks the approval row and then its subjects; it pauses on - // the user only after the team bump. - await waitForBlock(decisionPid, [decisionGatePid]) - await competitor.query('BEGIN') - competitorOutcome = observe( - createRun(competingRunInsert(ids, 'approval-decision-competitor'), competitor) - ) - const competitorBlocker = await waitForBlock(competitorPid, [decisionPid, competitorGatePid]) - if (competitorBlocker === competitorGatePid) { - // Without the prelock the decision holds only the user, so the INSERT - // takes the team and waits behind the barrier before asking for it. - await competitorGate.query('SELECT pg_advisory_unlock_all()') - await waitForBlock(competitorPid, [decisionPid]) - await decisionGate.query('SELECT pg_advisory_unlock_all()') - } else { - // With the prelock the decision already holds the team, so the INSERT - // waits on the decision itself. - await decisionGate.query('SELECT pg_advisory_unlock_all()') - const decisionResult = await decisionOutcome - await decision.query(decisionResult.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') - decisionSettled = true - await waitForBlock(competitorPid, [competitorGatePid]) - await competitorGate.query('SELECT pg_advisory_unlock_all()') - } - const decisionResult = await decisionOutcome - if (!decisionSettled) { - await decision.query(decisionResult.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') - decisionSettled = true - } - const competitorResult = await competitorOutcome - await competitor.query(competitorResult.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') - outcomes = [decisionResult, competitorResult] - console.info( - 'APPROVAL_TRIGGER_DECISION_OUTCOMES', - outcomes.map(result => ({ - status: result.status, - code: result.status === 'rejected' ? result.code : undefined, - })) - ) - witness = await triggerApprovalWitness(ids) - } finally { - await decisionGate.query('SELECT pg_advisory_unlock_all()') - await competitorGate.query('SELECT pg_advisory_unlock_all()') - await Promise.all([decisionOutcome, competitorOutcome]) - if (!decisionSettled) await decision.query('ROLLBACK') - await competitor.query('ROLLBACK') - for (const client of [decisionGate, competitorGate, decision, competitor]) client.release() - await databasePool.query('TRUNCATE archive_order_barriers') - await triggerApprovalCleanup(ids) - } - expect(outcomes.map(result => result.status)).toEqual(['fulfilled', 'fulfilled']) - expect(witness).toEqual({ - runs: [ - { - run_id: expect.any(String), - phase: 'Pending', - approval_request_id: ids.approvalId, - team_id: ids.team, - usage_team_id: ids.team, - actor_id: ids.user, - }, - ], - approval: [{ status: 'consumed' }], - }) - // The prelock holds the team before the user it pauses on; without it the - // first subject is the approval target and this list starts with the user. - const observed = await observedRevisions('approval-decision-order') - expect(observed.slice(0, 2)).toEqual([ - { revision_table: 'authorization_team_revisions', subject_id: ids.team }, - { revision_table: 'authorization_user_revisions', subject_id: ids.user }, - ]) - }) - - it('creates an approved run beside a run INSERT without reversing subject locks', async () => { - const ids = await triggerApprovalFixture('approved') - const approvedGate = await databasePool.connect() - const competitorGate = await databasePool.connect() - const approved = await databasePool.connect() - const competitor = await databasePool.connect() - const approvedPid = await pid(approved) - const competitorPid = await pid(competitor) - const approvedGatePid = await pid(approvedGate) - const competitorGatePid = await pid(competitorGate) - let approvedOutcome: Promise | undefined - let competitorOutcome: Promise | undefined - let outcomes: Outcome[] = [] - let witness: Awaited> | undefined - const connect = vi.spyOn(pool, 'connect').mockResolvedValue(approved) - try { - await configure(approved, 'approval-approved-run-order') - await configure(competitor, 'approval-create-run') - await approvedGate.query('SELECT pg_advisory_lock($1, 33)', [barrierClass]) - await competitorGate.query('SELECT pg_advisory_lock($1, 34)', [barrierClass]) - await databasePool.query( - `INSERT INTO archive_order_barriers VALUES - ('approval-approved-run-order', 'authorization_user_revisions', $1, 33), - ('approval-create-run', 'authorization_team_revisions', '', 34)`, - [ids.user] - ) - approvedOutcome = observe( - createApprovedRun({ - ...competingRunInsert(ids, ids.recipeName), - idempotency_key: ids.idempotencyKey, - approval_request_id: ids.approvalId, - idempotency_payload_hash: 'hash-approval-trigger', - approval_caller_key: `revision/${ids.recipeName}`, - correlation_id: 'approval-approved-run-order', - }) - ) - // The consume prelock takes the team before the user it pauses on. - await waitForBlock(approvedPid, [approvedGatePid]) - await competitor.query('BEGIN') - competitorOutcome = observe( - createRun(competingRunInsert(ids, 'approval-approved-run-competitor'), competitor) - ) - const competitorBlocker = await waitForBlock(competitorPid, [approvedPid, competitorGatePid]) - if (competitorBlocker === competitorGatePid) { - // Without the prelock the consumer holds only the user, so the INSERT - // takes the team and waits behind the barrier before asking for it. - await competitorGate.query('SELECT pg_advisory_unlock_all()') - await waitForBlock(competitorPid, [approvedPid]) - await approvedGate.query('SELECT pg_advisory_unlock_all()') - } else { - await approvedGate.query('SELECT pg_advisory_unlock_all()') - await approvedOutcome - await waitForBlock(competitorPid, [competitorGatePid]) - await competitorGate.query('SELECT pg_advisory_unlock_all()') - } - const approvedResult = await approvedOutcome - const competitorResult = await competitorOutcome - await competitor.query(competitorResult.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') - outcomes = [approvedResult, competitorResult] - console.info( - 'APPROVAL_TRIGGER_APPROVED_RUN_OUTCOMES', - outcomes.map(result => ({ - status: result.status, - code: result.status === 'rejected' ? result.code : undefined, - })) - ) - witness = await triggerApprovalWitness(ids) - } finally { - await approvedGate.query('SELECT pg_advisory_unlock_all()') - await competitorGate.query('SELECT pg_advisory_unlock_all()') - await Promise.all([approvedOutcome, competitorOutcome]) - await competitor.query('ROLLBACK') - connect.mockRestore() - // The pool spy hands this client to createApprovedRun, which releases it - // in its own finally; release it only when the call never started. - if (!approvedOutcome) approved.release() - for (const client of [approvedGate, competitorGate, competitor]) client.release() - await databasePool.query('TRUNCATE archive_order_barriers') - await triggerApprovalCleanup(ids) - } - expect(outcomes.map(result => result.status)).toEqual(['fulfilled', 'fulfilled']) - expect(witness).toEqual({ - runs: [ - { - run_id: expect.any(String), - phase: 'Pending', - approval_request_id: ids.approvalId, - team_id: ids.team, - usage_team_id: ids.team, - actor_id: ids.user, - }, - ], - approval: [{ status: 'consumed' }], - }) - const observed = await observedRevisions('approval-approved-run-order') - expect(observed.slice(0, 2)).toEqual([ - { revision_table: 'authorization_team_revisions', subject_id: ids.team }, - { revision_table: 'authorization_user_revisions', subject_id: ids.user }, - ]) - }) - - // The cleanup statements of WorkflowReconciler.reconcileDelete's transaction, - // copied verbatim. The first new case below binds this copy to the production - // file in the same order, so these tests exercise the real cleanup, not a - // divergent local variant. - const recipeDeleteStatements = { - approvalLock: `SELECT id FROM workflow_approval_requests - WHERE recipe_namespace = $1 AND recipe_name = $2 - AND status IN ('pending', 'approved') - ORDER BY id - FOR UPDATE`, - prelock: 'SELECT authorization_prelock_workflow_recipe_policy_revisions($1, $2)', - deleteUserGrants: `DELETE FROM user_workflow_triggers - WHERE recipe_namespace = $1 AND recipe_name = $2`, - deleteTeamGrants: `DELETE FROM team_workflow_triggers - WHERE recipe_namespace = $1 AND recipe_name = $2`, - deleteAllowedTeams: `DELETE FROM workflow_recipe_allowed_teams - WHERE recipe_namespace = $1 AND recipe_name = $2`, - legacyLookup: `SELECT to_regclass('public.workflow_recipe_allowed_users')::text AS "regclass"`, - deleteLegacyGrants: `DELETE FROM workflow_recipe_allowed_users - WHERE recipe_namespace = $1 AND recipe_name = $2`, - cancelApprovals: `UPDATE workflow_approval_requests - SET status = 'cancelled', - cancelled_at = COALESCE(cancelled_at, NOW()), - cancelled_by = COALESCE(cancelled_by, 'workflow-recipe-delete') - WHERE recipe_namespace = $1 - AND recipe_name = $2 - AND status IN ('pending', 'approved')`, - } as const - - async function runRecipeDeleteCleanup( - client: PoolClient, - recipe: { namespace: string; name: string } - ): Promise { - await client.query(recipeDeleteStatements.approvalLock, [recipe.namespace, recipe.name]) - await client.query(recipeDeleteStatements.prelock, [recipe.namespace, recipe.name]) - for (const statement of [ - recipeDeleteStatements.deleteUserGrants, - recipeDeleteStatements.deleteTeamGrants, - recipeDeleteStatements.deleteAllowedTeams, - ]) { - await client.query(statement, [recipe.namespace, recipe.name]) - } - const legacy = await client.query<{ regclass?: string | null }>( - recipeDeleteStatements.legacyLookup - ) - if (legacy.rows[0]?.regclass) { - await client.query(recipeDeleteStatements.deleteLegacyGrants, [recipe.namespace, recipe.name]) - } - await client.query(recipeDeleteStatements.cancelApprovals, [recipe.namespace, recipe.name]) - } - - async function recipePolicyWitness(recipe: { namespace: string; name: string }) { - const grants = await databasePool.query<{ table_name: string; rows: number }>( - `SELECT 'user_workflow_triggers' AS table_name, - count(*)::int AS rows - FROM user_workflow_triggers - WHERE recipe_namespace = $1 AND recipe_name = $2 - UNION ALL - SELECT 'team_workflow_triggers', count(*)::int - FROM team_workflow_triggers - WHERE recipe_namespace = $1 AND recipe_name = $2 - UNION ALL - SELECT 'workflow_recipe_allowed_teams', count(*)::int - FROM workflow_recipe_allowed_teams - WHERE recipe_namespace = $1 AND recipe_name = $2 - ORDER BY table_name`, - [recipe.namespace, recipe.name] - ) - const approvals = await databasePool.query<{ status: string; cancelled_by: string | null }>( - `SELECT status, cancelled_by FROM workflow_approval_requests - WHERE recipe_namespace = $1 AND recipe_name = $2 - ORDER BY id`, - [recipe.namespace, recipe.name] - ) - return { grants: grants.rows, approvals: approvals.rows } - } - - async function removeRecipePolicyFixture( - recipe: { namespace: string; name: string }, - fixture: { runRecipes?: string[]; approvals?: string[]; users?: string[]; teams?: string[] } - ) { - await databasePool.query('TRUNCATE archive_order_barriers') - await databasePool.query('DELETE FROM workflow_runs WHERE recipe_name = ANY($1::text[])', [ - fixture.runRecipes ?? [], - ]) - await databasePool.query('DELETE FROM workflow_approval_requests WHERE id = ANY($1::uuid[])', [ - fixture.approvals ?? [], - ]) - for (const table of [ - 'user_workflow_triggers', - 'team_workflow_triggers', - 'workflow_recipe_allowed_teams', - ]) { - await databasePool.query( - `DELETE FROM ${table} WHERE recipe_namespace = $1 AND recipe_name = $2`, - [recipe.namespace, recipe.name] - ) - } - await databasePool.query('DELETE FROM users WHERE id = ANY($1::uuid[])', [fixture.users ?? []]) - await databasePool.query('DELETE FROM teams WHERE id = ANY($1::uuid[])', [fixture.teams ?? []]) - } - - it('runs the cleanup statements the reconciler executes, in the same order', () => { - const source = readFileSync( - new URL('../../workflow-recipes/src/workflow/workflowReconciler.ts', import.meta.url), - 'utf8' - ) - const normalize = (value: string) => value.replace(/\s+/g, ' ').trim() - const normalizedSource = normalize(source) - let position = -1 - for (const statement of [ - recipeDeleteStatements.approvalLock, - recipeDeleteStatements.prelock, - recipeDeleteStatements.deleteUserGrants, - recipeDeleteStatements.deleteTeamGrants, - recipeDeleteStatements.deleteAllowedTeams, - recipeDeleteStatements.legacyLookup, - recipeDeleteStatements.deleteLegacyGrants, - recipeDeleteStatements.cancelApprovals, - ]) { - const index = normalizedSource.indexOf(normalize(statement), position + 1) - expect(index, `missing after the previous statement: ${statement}`).toBeGreaterThan(position) - position = index - } - }) - - it('deletes grant rows beside a run INSERT without reversing subject locks', async () => { - const recipe = { - namespace: 'sandbox-recipes', - name: `recipe-delete-grants-${randomBytes(4).toString('hex')}`, - } - const runRecipe = `recipe-delete-grants-run-${randomBytes(4).toString('hex')}` - const user = randomUUID() - const team = randomUUID() - await databasePool.query('INSERT INTO users(id, email) VALUES ($1, $2)', [ - user, - `recipe-delete-${user}@example.test`, - ]) - await databasePool.query("INSERT INTO teams(id, name) VALUES ($1, 'Recipe delete grants')", [ - team, - ]) - await databasePool.query( - `INSERT INTO user_workflow_triggers(user_id, recipe_namespace, recipe_name) - VALUES ($1, $2, $3)`, - [user, recipe.namespace, recipe.name] - ) - await databasePool.query( - `INSERT INTO team_workflow_triggers(team_id, recipe_namespace, recipe_name) - VALUES ($1, $2, $3)`, - [team, recipe.namespace, recipe.name] - ) - await databasePool.query( - `INSERT INTO workflow_recipe_allowed_teams(team_id, recipe_namespace, recipe_name) - VALUES ($1, $2, $3)`, - [team, recipe.namespace, recipe.name] - ) - const cleanupGate = await databasePool.connect() - const competitorGate = await databasePool.connect() - const cleanup = await databasePool.connect() - const competitor = await databasePool.connect() - const cleanupPid = await pid(cleanup) - const competitorPid = await pid(competitor) - const cleanupGatePid = await pid(cleanupGate) - const competitorGatePid = await pid(competitorGate) - let cleanupOutcome: Promise | undefined - let competitorOutcome: Promise | undefined - let cleanupSettled = false - let outcomes: Outcome[] = [] - let witness: - | { - policy: Awaited> - runs: Array<{ run_id: string }> - observed: Array<{ revision_table: string; subject_id: string }> - } - | undefined - try { - await configure(cleanup, 'recipe-delete-grants') - await configure(competitor, 'recipe-delete-run-insert') - await cleanupGate.query('SELECT pg_advisory_lock($1, 41)', [barrierClass]) - await competitorGate.query('SELECT pg_advisory_lock($1, 42)', [barrierClass]) - await databasePool.query( - `INSERT INTO archive_order_barriers VALUES - ('recipe-delete-grants', 'authorization_user_revisions', $1, 41), - ('recipe-delete-run-insert', 'authorization_team_revisions', '', 42)`, - [user] - ) - await cleanup.query('BEGIN') - await cleanup.query('SET LOCAL ROLE workflow_recipes_runtime') - cleanupOutcome = observe(runRecipeDeleteCleanup(cleanup, recipe)) - // The prelock holds the team before the user it pauses on. - await waitForBlock(cleanupPid, [cleanupGatePid]) - await competitor.query('BEGIN') - competitorOutcome = observe( - createRun(competingRunInsert({ user, team }, runRecipe), competitor) - ) - const competitorBlocker = await waitForBlock(competitorPid, [cleanupPid, competitorGatePid]) - if (competitorBlocker === competitorGatePid) { - // Without the prelock the cleanup holds only the user grant row and the - // user revision, so the INSERT takes the team and waits behind the - // barrier before asking for that user: a cycle. - await competitorGate.query('SELECT pg_advisory_unlock_all()') - await waitForBlock(competitorPid, [cleanupPid]) - await cleanupGate.query('SELECT pg_advisory_unlock_all()') - } else { - // With the prelock the cleanup already holds the team, so the INSERT - // waits on the cleanup itself. - await cleanupGate.query('SELECT pg_advisory_unlock_all()') - const settled = await cleanupOutcome - await cleanup.query(settled.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') - cleanupSettled = true - await waitForBlock(competitorPid, [competitorGatePid]) - await competitorGate.query('SELECT pg_advisory_unlock_all()') - } - const cleanupResult = await cleanupOutcome - if (!cleanupSettled) { - await cleanup.query(cleanupResult.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') - cleanupSettled = true - } - const competitorResult = await competitorOutcome - await competitor.query(competitorResult.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') - outcomes = [cleanupResult, competitorResult] - console.info( - 'RECIPE_DELETE_GRANTS_OUTCOMES', - outcomes.map(result => ({ - status: result.status, - code: result.status === 'rejected' ? result.code : undefined, - })) - ) - witness = { - policy: await recipePolicyWitness(recipe), - runs: ( - await databasePool.query( - `SELECT run_id FROM workflow_runs - WHERE recipe_name = $1 AND team_id = $2 AND actor_id = $3`, - [runRecipe, team, user] - ) - ).rows, - observed: (await observedRevisions('recipe-delete-grants')).slice(0, 2), - } - } finally { - await cleanupGate.query('SELECT pg_advisory_unlock_all()') - await competitorGate.query('SELECT pg_advisory_unlock_all()') - await Promise.all([cleanupOutcome, competitorOutcome]) - if (!cleanupSettled) await cleanup.query('ROLLBACK') - await competitor.query('ROLLBACK') - for (const client of [cleanupGate, competitorGate, cleanup, competitor]) client.release() - await removeRecipePolicyFixture(recipe, { - runRecipes: [runRecipe], - users: [user], - teams: [team], - }) - } - expect(outcomes.map(result => result.status)).toEqual(['fulfilled', 'fulfilled']) - expect(witness.policy).toEqual({ - grants: [ - { table_name: 'team_workflow_triggers', rows: 0 }, - { table_name: 'user_workflow_triggers', rows: 0 }, - { table_name: 'workflow_recipe_allowed_teams', rows: 0 }, - ], - approvals: [], - }) - expect(witness.runs).toHaveLength(1) - // The prelock holds the team first; without it the cleanup's first user - // DELETE bumps the user revision first. - expect(witness.observed).toEqual([ - { revision_table: 'authorization_team_revisions', subject_id: team }, - { revision_table: 'authorization_user_revisions', subject_id: user }, - ]) - }) - - it('cancels approvals beside a run INSERT without reversing subject locks', async () => { - const recipe = { - namespace: 'sandbox-recipes', - name: `recipe-delete-approvals-${randomBytes(4).toString('hex')}`, - } - const runRecipe = `recipe-delete-approvals-run-${randomBytes(4).toString('hex')}` - const [userApprovalId, teamApprovalId] = [randomUUID(), randomUUID()].sort() as [string, string] - const user = randomUUID() - const team = randomUUID() - await databasePool.query('INSERT INTO users(id, email) VALUES ($1, $2)', [ - user, - `recipe-delete-approval-${user}@example.test`, - ]) - await databasePool.query("INSERT INTO teams(id, name) VALUES ($1, 'Recipe delete approvals')", [ - team, - ]) - // The user-targeted approval is first in heap order and lower in id order, - // so both a sequential and an index scan reach its subject first. - const targets = [ - [userApprovalId, 'target_user_id', user], - [teamApprovalId, 'target_team_id', team], - ] as const - for (const [id, column, target] of targets) { - await databasePool.query( - `INSERT INTO workflow_approval_requests( - id, recipe_namespace, recipe_name, expires_at, status, ${column}, - payload, idempotency_key, requested_at - ) VALUES ($1, $2, $3, NOW() + INTERVAL '1 hour', 'pending', $4, - '{}'::jsonb, $5, NOW())`, - [id, recipe.namespace, recipe.name, target, `recipe-delete-approval-${id}`] - ) - } - const cleanupGate = await databasePool.connect() - const competitorGate = await databasePool.connect() - const cleanup = await databasePool.connect() - const competitor = await databasePool.connect() - const cleanupPid = await pid(cleanup) - const competitorPid = await pid(competitor) - const cleanupGatePid = await pid(cleanupGate) - const competitorGatePid = await pid(competitorGate) - let cleanupOutcome: Promise | undefined - let competitorOutcome: Promise | undefined - let cleanupSettled = false - let outcomes: Outcome[] = [] - let witness: - | { - policy: Awaited> - runs: Array<{ run_id: string }> - observed: Array<{ revision_table: string; subject_id: string }> - } - | undefined - try { - await configure(cleanup, 'recipe-delete-approvals') - await configure(competitor, 'recipe-delete-approvals-run') - await cleanupGate.query('SELECT pg_advisory_lock($1, 43)', [barrierClass]) - await competitorGate.query('SELECT pg_advisory_lock($1, 44)', [barrierClass]) - await databasePool.query( - `INSERT INTO archive_order_barriers VALUES - ('recipe-delete-approvals', 'authorization_user_revisions', $1, 43), - ('recipe-delete-approvals-run', 'authorization_team_revisions', '', 44)`, - [user] - ) - await cleanup.query('BEGIN') - await cleanup.query('SET LOCAL ROLE workflow_recipes_runtime') - cleanupOutcome = observe(runRecipeDeleteCleanup(cleanup, recipe)) - await waitForBlock(cleanupPid, [cleanupGatePid]) - await competitor.query('BEGIN') - competitorOutcome = observe( - createRun(competingRunInsert({ user, team }, runRecipe), competitor) - ) - const competitorBlocker = await waitForBlock(competitorPid, [cleanupPid, competitorGatePid]) - if (competitorBlocker === competitorGatePid) { - // Without the prelock the first cancelled approval is the user-targeted - // row, so the cleanup holds the user and the INSERT takes the team - // before asking for it: a cycle. - await competitorGate.query('SELECT pg_advisory_unlock_all()') - await waitForBlock(competitorPid, [cleanupPid]) - await cleanupGate.query('SELECT pg_advisory_unlock_all()') - } else { - await cleanupGate.query('SELECT pg_advisory_unlock_all()') - const settled = await cleanupOutcome - await cleanup.query(settled.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') - cleanupSettled = true - await waitForBlock(competitorPid, [competitorGatePid]) - await competitorGate.query('SELECT pg_advisory_unlock_all()') - } - const cleanupResult = await cleanupOutcome - if (!cleanupSettled) { - await cleanup.query(cleanupResult.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') - cleanupSettled = true - } - const competitorResult = await competitorOutcome - await competitor.query(competitorResult.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') - outcomes = [cleanupResult, competitorResult] - console.info( - 'RECIPE_DELETE_APPROVALS_OUTCOMES', - outcomes.map(result => ({ - status: result.status, - code: result.status === 'rejected' ? result.code : undefined, - })) - ) - witness = { - policy: await recipePolicyWitness(recipe), - runs: ( - await databasePool.query( - `SELECT run_id FROM workflow_runs - WHERE recipe_name = $1 AND team_id = $2 AND actor_id = $3`, - [runRecipe, team, user] - ) - ).rows, - observed: (await observedRevisions('recipe-delete-approvals')).slice(0, 2), - } - } finally { - await cleanupGate.query('SELECT pg_advisory_unlock_all()') - await competitorGate.query('SELECT pg_advisory_unlock_all()') - await Promise.all([cleanupOutcome, competitorOutcome]) - if (!cleanupSettled) await cleanup.query('ROLLBACK') - await competitor.query('ROLLBACK') - for (const client of [cleanupGate, competitorGate, cleanup, competitor]) client.release() - await removeRecipePolicyFixture(recipe, { - runRecipes: [runRecipe], - approvals: [userApprovalId, teamApprovalId], - users: [user], - teams: [team], - }) - } - expect(outcomes.map(result => result.status)).toEqual(['fulfilled', 'fulfilled']) - expect(witness.policy).toEqual({ - grants: [ - { table_name: 'team_workflow_triggers', rows: 0 }, - { table_name: 'user_workflow_triggers', rows: 0 }, - { table_name: 'workflow_recipe_allowed_teams', rows: 0 }, - ], - approvals: [ - { status: 'cancelled', cancelled_by: 'workflow-recipe-delete' }, - { status: 'cancelled', cancelled_by: 'workflow-recipe-delete' }, - ], - }) - expect(witness.runs).toHaveLength(1) - expect(witness.observed).toEqual([ - { revision_table: 'authorization_team_revisions', subject_id: team }, - { revision_table: 'authorization_user_revisions', subject_id: user }, - ]) - }) - - it('keeps its grant rows beside an approval-target revoke without reversing locks', async () => { - const recipe = { - namespace: 'sandbox-recipes', - name: `recipe-delete-revoke-${randomBytes(4).toString('hex')}`, - } - const team = randomUUID() - await databasePool.query("INSERT INTO teams(id, name) VALUES ($1, 'Recipe delete revoke')", [ - team, - ]) - await databasePool.query( - `INSERT INTO team_workflow_triggers(team_id, recipe_namespace, recipe_name) - VALUES ($1, $2, $3)`, - [team, recipe.namespace, recipe.name] - ) - await databasePool.query( - `INSERT INTO workflow_recipe_allowed_teams(team_id, recipe_namespace, recipe_name) - VALUES ($1, $2, $3)`, - [team, recipe.namespace, recipe.name] - ) - const cleanupGate = await databasePool.connect() - const cleanup = await databasePool.connect() - const revoke = await databasePool.connect() - const cleanupPid = await pid(cleanup) - const revokePid = await pid(revoke) - const cleanupGatePid = await pid(cleanupGate) - let cleanupOutcome: Promise | undefined - let revokeOutcome: Promise | undefined - let cleanupSettled = false - let outcomes: Outcome[] = [] - let witness: Awaited> | undefined - try { - await configure(cleanup, 'recipe-delete-revoke') - await configure(revoke, 'recipe-delete-revoke-grant') - await cleanupGate.query('SELECT pg_advisory_lock($1, 45)', [barrierClass]) - await databasePool.query( - `INSERT INTO archive_order_barriers VALUES - ('recipe-delete-revoke', 'authorization_team_revisions', $1, 45)`, - [team] - ) - await cleanup.query('BEGIN') - await cleanup.query('SET LOCAL ROLE workflow_recipes_runtime') - cleanupOutcome = observe(runRecipeDeleteCleanup(cleanup, recipe)) - // The prelock holds the grant row before it bumps the paused team. - await waitForBlock(cleanupPid, [cleanupGatePid]) - await revoke.query('BEGIN') - // The production approval-target revoke (workflowGrantManagementService - // revokeWorkflowRecipeApprovalTeam) without its audit CTE. - revokeOutcome = observe( - revoke.query( - `DELETE FROM workflow_recipe_allowed_teams - WHERE recipe_namespace = $1 AND recipe_name = $2 AND team_id = $3::uuid - RETURNING team_id`, - [recipe.namespace, recipe.name, team] - ) - ) - // With the grant-row phase the revoke waits on the cleanup's row lock. - // Without it the revoke holds the row and waits on the team revision the - // cleanup holds: a cycle. - await waitForBlock(revokePid, [cleanupPid]) - await cleanupGate.query('SELECT pg_advisory_unlock_all()') - const cleanupResult = await cleanupOutcome - await cleanup.query(cleanupResult.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') - cleanupSettled = true - const revokeResult = await revokeOutcome - await revoke.query(revokeResult.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') - outcomes = [cleanupResult, revokeResult] - console.info( - 'RECIPE_DELETE_REVOKE_OUTCOMES', - outcomes.map(result => ({ - status: result.status, - code: result.status === 'rejected' ? result.code : undefined, - })) - ) - expect(revokeResult).toMatchObject({ status: 'fulfilled', value: { rowCount: 0 } }) - witness = await recipePolicyWitness(recipe) - } finally { - await cleanupGate.query('SELECT pg_advisory_unlock_all()') - await Promise.all([cleanupOutcome, revokeOutcome]) - if (!cleanupSettled) await cleanup.query('ROLLBACK') - await revoke.query('ROLLBACK') - for (const client of [cleanupGate, cleanup, revoke]) client.release() - await removeRecipePolicyFixture(recipe, { teams: [team] }) - } - expect(outcomes.map(result => result.status)).toEqual(['fulfilled', 'fulfilled']) - expect(witness).toEqual({ - grants: [ - { table_name: 'team_workflow_triggers', rows: 0 }, - { table_name: 'user_workflow_triggers', rows: 0 }, - { table_name: 'workflow_recipe_allowed_teams', rows: 0 }, - ], - approvals: [], - }) - }) - - it.each(['not-a-uuid', 'CONTROL-PLANE-ADMIN-UI'])( - 'keeps P0001 validation for non-sentinel usage team %s', - async invalidTeam => { - const ids = await fixture() - // Seed a legacy malformed value without invoking the live-row trigger; - // the scoped function must still use the production subject validator. - await databasePool.query( - 'ALTER TABLE workflow_runs DISABLE TRIGGER workflow_runs_authorization_revision' - ) - try { - await databasePool.query('UPDATE workflow_runs SET usage_team_id = $2 WHERE run_id = $1', [ - ids.archived[0], - invalidTeam, - ]) - } finally { - await databasePool.query( - 'ALTER TABLE workflow_runs ENABLE TRIGGER workflow_runs_authorization_revision' - ) - } - const before = await revisions(ids) - await expect( - databasePool.query('SELECT authorization_prelock_workflow_run_revisions($1::uuid[])', [ - ids.archived, - ]) - ).rejects.toMatchObject({ code: 'P0001' }) - expect(await revisions(ids)).toEqual(before) - } - ) - - // The stuck-run sweep's statements, copied from - // workflow-recipes/src/reconciler/dbRunProcessor.ts:153-162 (FIND_STUCK_RUNS, - // now also returning approval_request_id) and :164-172 (FAIL_STUCK_RUN). The - // runner below executes them in the same order as :566-597. - const stuckRunStatements = { - find: `SELECT run_id, started_at, max_duration_seconds, approval_request_id - FROM workflow_runs - WHERE phase = 'Running' - AND owner_instance_id = $1 - AND max_duration_seconds IS NOT NULL - AND started_at IS NOT NULL - AND started_at < now() - (max_duration_seconds || ' seconds')::interval - FOR UPDATE SKIP LOCKED - LIMIT 50`, - prelock: 'SELECT authorization_prelock_workflow_approval_revisions($1::uuid[])', - fail: `UPDATE workflow_runs - SET phase = 'Failed', - completed_at = now(), - last_reconciled_at = now(), - updated_at = now() - WHERE run_id = $1 - AND phase = 'Running' - AND owner_instance_id = $2`, - } as const - - // T3 executes a copy of the sweep, so bind that copy to the processor source: - // removing the prelock call from checkStuckRuns must redden this test as well - // as the unit test (mutation M-B1a), not only the real-PostgreSQL case. - it('runs the stuck-run sweep statements the processor executes, in the same order', () => { - const source = readFileSync( - new URL('../../workflow-recipes/src/reconciler/dbRunProcessor.ts', import.meta.url), - 'utf8' - ) - const normalize = (value: string) => value.replace(/\s+/g, ' ').trim() - const normalizedSource = normalize(source) - for (const statement of [stuckRunStatements.find, stuckRunStatements.fail]) { - expect(normalizedSource, `missing statement: ${statement}`).toContain(normalize(statement)) - } - expect(normalizedSource).toContain(stuckRunStatements.prelock) - // FIND must return the column the prelock feeds on. - expect(normalize(stuckRunStatements.find)).toContain('approval_request_id') - const sweepStart = normalizedSource.indexOf('async function checkStuckRuns()') - expect(sweepStart).toBeGreaterThan(-1) - const sweepBody = normalizedSource.slice( - sweepStart, - normalizedSource.indexOf('return {', sweepStart) - ) - let position = -1 - for (const call of [ - 'FIND_STUCK_RUNS', - 'authorization_prelock_workflow_approval_revisions($1::uuid[])', - 'FAIL_STUCK_RUN', - ]) { - const index = sweepBody.indexOf(call, position + 1) - expect(index, `missing after the previous statement: ${call}`).toBeGreaterThan(position) - position = index - } - // The prelock is conditional: a batch with no approval ids makes no call. - expect(sweepBody).toContain('if (approvalRequestIds.length > 0)') - }) - - it('fails stuck provider-bound runs beside a run INSERT without reversing subject locks', async () => { - const instanceId = `stuck-sweep-${randomBytes(4).toString('hex')}` - const competitorRecipe = `stuck-run-competitor-${randomBytes(4).toString('hex')}` - const recipeName = `stuck-run-${randomBytes(4).toString('hex')}` - const user = randomUUID() - const team = randomUUID() - // N3: the user-targeted run carries the smaller explicit run_id and is - // inserted first, so the heap scan FIND_STUCK_RUNS (no ORDER BY) uses - // reaches it first. - const userRunId = '00000000-0000-4000-8000-000000000001' - const teamRunId = '00000000-0000-4000-8000-000000000002' - await databasePool.query('INSERT INTO users(id, email) VALUES ($1, $2)', [ - user, - `stuck-run-${user}@example.test`, - ]) - await databasePool.query("INSERT INTO teams(id, name) VALUES ($1, 'Stuck run sweep')", [team]) - const approvalIds: string[] = [] - for (const [runId, column, target] of [ - [userRunId, 'target_user_id', user], - [teamRunId, 'target_team_id', team], - ] as const) { - const approvalId = randomUUID() - approvalIds.push(approvalId) - await databasePool.query( - `INSERT INTO workflow_approval_requests( - id, recipe_namespace, recipe_name, expires_at, status, ${column}, - payload, idempotency_key, requested_at, decided_at - ) VALUES ($1, 'sandbox-recipes', $2, NOW() + INTERVAL '1 hour', 'consumed', $3, - $4::jsonb, $5, NOW() - INTERVAL '2 hours', NOW() - INTERVAL '2 hours')`, - [ - approvalId, - recipeName, - target, - JSON.stringify({ - metadata: { - workflowTrigger: { - providerBinding: { - medium: 'telegram', - providerChannelId: `channel-${randomUUID()}`, - }, - }, - }, - }), - `stuck-run-${approvalId}`, - ] - ) - await databasePool.query( - `INSERT INTO workflow_approval_trigger_intents( - approval_request_id, trigger_namespace, trigger_name, trigger_caller_key - ) VALUES ($1, 'sandbox-recipes', $2, 'sandbox-recipes/' || $2)`, - [approvalId, recipeName] - ) - await databasePool.query( - `INSERT INTO workflow_runs( - run_id, recipe_namespace, recipe_name, phase, actor_type, actor_id, team_id, - trigger_source, approval_request_id, owner_instance_id, max_duration_seconds, started_at - ) VALUES ($1, 'sandbox-recipes', $2, 'Running', 'user', $3, $4, 'onDemand', $5, $6, 1, - NOW() - INTERVAL '1 hour')`, - [runId, recipeName, user, team, approvalId, instanceId] - ) - } - const before = await revisions({ users: [user], teams: [team] }) - - const sweep = await databasePool.connect() - const competitor = await databasePool.connect() - const sweepPid = await pid(sweep) - const competitorPid = await pid(competitor) - let sweepFailure: unknown - let competitorWork: Promise | undefined - let competitorOutcome: Outcome | undefined - let phases: { - foundFirst?: string - firstFailed?: boolean - competitorBlocker?: number - committed?: boolean - } = {} - let witness: - | { - runs: Array<{ run_id: string; phase: string }> - deliveries: Array> - observed: Array<{ revision_table: string; subject_id: string }> - after: Awaited> - } - | undefined - try { - await configure(sweep, 'stuck-run-sweep') - await configure(competitor, 'stuck-run-insert') - await sweep.query('BEGIN') - // The sweep runs on the Workflow Recipes pool (dbRunProcessor.ts:566). - await sweep.query('SET LOCAL ROLE workflow_recipes_runtime') - const found = await sweep.query<{ run_id: string; approval_request_id: string | null }>( - stuckRunStatements.find, - [instanceId] - ) - phases.foundFirst = found.rows[0]?.run_id - const foundApprovalIds = [ - ...new Set( - found.rows - .map(row => row.approval_request_id) - .filter((id): id is string => typeof id === 'string' && id.length > 0) - ), - ] - await sweep.query(stuckRunStatements.prelock, [foundApprovalIds]) - const firstFailure = await sweep.query(stuckRunStatements.fail, [ - found.rows[0]!.run_id, - instanceId, - ]) - phases.firstFailed = firstFailure.rowCount === 1 - - // B: the competing run INSERT of (team, user), started while A holds the - // prelocked subjects; it must block on A. - await competitor.query('BEGIN') - competitorWork = observe( - createRun(competingRunInsert({ user, team }, competitorRecipe), competitor) - ) - phases.competitorBlocker = await waitForBlock(competitorPid, [sweepPid]) - - // A: the remaining stuck run, then COMMIT. - for (const row of found.rows.slice(1)) { - const update = await sweep.query(stuckRunStatements.fail, [row.run_id, instanceId]) - expect(update.rowCount).toBe(1) - } - await sweep.query('COMMIT') - phases.committed = true - competitorOutcome = await competitorWork - await competitor.query(competitorOutcome.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') - - witness = { - runs: ( - await databasePool.query<{ run_id: string; phase: string }>( - `SELECT run_id, phase FROM workflow_runs WHERE run_id = ANY($1::uuid[]) ORDER BY run_id`, - [[userRunId, teamRunId]] - ) - ).rows, - deliveries: ( - await databasePool.query<{ audience: Record }>( - `SELECT audience FROM notification_deliveries - WHERE dedupe_key = ANY($1::text[]) ORDER BY audience::text`, - [ - [ - `workflow.run.completed:${userRunId}:Failed`, - `workflow.run.completed:${teamRunId}:Failed`, - ], - ] - ) - ).rows.map(row => row.audience), - observed: (await observedRevisions('stuck-run-sweep')).slice(0, 2), - after: await revisions({ users: [user], teams: [team] }), - } - } catch (error) { - sweepFailure = error - } finally { - if (!phases.committed) await sweep.query('ROLLBACK').catch(() => undefined) - await competitor.query('ROLLBACK').catch(() => undefined) - if (competitorWork) await competitorWork - sweep.release() - competitor.release() - await databasePool.query( - `DELETE FROM notification_deliveries - WHERE dedupe_key = ANY($1::text[])`, - [ - [ - `workflow.run.completed:${userRunId}:Failed`, - `workflow.run.completed:${teamRunId}:Failed`, - ], - ] - ) - await databasePool.query('DELETE FROM workflow_runs WHERE recipe_name = ANY($1::text[])', [ - [recipeName, competitorRecipe], - ]) - await databasePool.query( - 'DELETE FROM workflow_approval_requests WHERE id = ANY($1::uuid[])', - [approvalIds] - ) - await databasePool.query('DELETE FROM users WHERE id = $1', [user]) - await databasePool.query('DELETE FROM teams WHERE id = $1', [team]) - } - expect(sweepFailure).toBeUndefined() - expect(phases).toMatchObject({ - foundFirst: userRunId, - firstFailed: true, - competitorBlocker: sweepPid, - committed: true, - }) - expect(competitorOutcome?.status).toBe('fulfilled') - expect(witness!.runs).toEqual([ - { run_id: userRunId, phase: 'Failed' }, - { run_id: teamRunId, phase: 'Failed' }, - ]) - // Liveness of the nested INSERT: one delivery per failed provider-bound run, - // with the audience the trigger's approval target names. - expect(witness!.deliveries).toEqual([{ teamId: team }, { userId: user }]) - // The prelock takes the batch's subjects in the global order: the team - // revision first, then the user revision. - expect(witness!.observed).toEqual([ - { revision_table: 'authorization_team_revisions', subject_id: team }, - { revision_table: 'authorization_user_revisions', subject_id: user }, - ]) - // Both subjects advanced. B's own INSERT also bumps them, so this is a - // liveness check, not the ordering witness (N3). - expect(byId(witness!.after.user)[user]!).toBeGreaterThan(byId(before.user)[user]!) - expect(byId(witness!.after.team)[team]!).toBeGreaterThan(byId(before.team)[team]!) - }) -}) diff --git a/tests/contracts/password-recovery-suites.json b/tests/contracts/password-recovery-suites.json index 72120c0914..67657f483a 100644 --- a/tests/contracts/password-recovery-suites.json +++ b/tests/contracts/password-recovery-suites.json @@ -123,7 +123,6 @@ "control-api/test/userAccessRuntimeContract.realPostgres.integration.test.ts", "control-api/test/userRetirementRevisionLockOrder.realPostgres.integration.test.ts", "control-api/test/userSessionDatabaseClock.realPostgres.integration.test.ts", - "control-api/test/archiveRevisionLockOrder.realPostgres.integration.test.ts", "control-api/test/revisionWritersLockOrder.realPostgres.integration.test.ts", "control-api/test/workflowRunReadAndApprovalV1Compatibility.realPostgres.integration.test.ts" ], From b53951ad030e352fc290008230e227bcbef77851 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 4 Nov 2026 14:04:57 +0100 Subject: [PATCH 455/528] fix(external-rest-api): answer a revoked session with 401 on /me K4b, Addendum V4 of the PR #334 r4.2 fix plan; owner decision M-61. PR1 revokes server sessions on password change and reset, logout and security events, so a revoked session is now a normal case. getMe kept dev's mapping of every non-503 Control API error to null, which made GET /api/v1/me answer 404 "User not found" for a revoked session and left clients unable to tell they must sign in again. getMe now rethrows the Control API 401, as the master plan's error contract asks ("revoked session -> stable 401"); switchTeam keeps the dev mapping. Tests pin the 401 at the route and in the service, with the Control API call as liveness witness; removing the 401 from the condition turns both red. The real-PG lane is green again (124 files, 1034 tests), including passwordRecovery.http. Co-Authored-By: Claude Opus 5.5 --- external-rest-api/package-lock.json | 4 ++-- external-rest-api/package.json | 2 +- external-rest-api/src/releaseManifest.ts | 2 +- external-rest-api/src/services/meService.ts | 7 ++++++- external-rest-api/test/meUnavailable.contract.test.ts | 8 ++++---- .../test/services.legacyAuthorityErrors.test.ts | 10 ++++++++-- 6 files changed, 22 insertions(+), 11 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index cedadb8215..232c256053 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.166", + "version": "0.1.167", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.166", + "version": "0.1.167", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index b2f40c1a46..af8cabdcc6 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.166", + "version": "0.1.167", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index c45bea732e..250bfbaa73 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.12.0', - externalRestApiVersion: '0.1.166', + externalRestApiVersion: '0.1.167', rpcProxyVersion: '0.1.138', desktopVersion: '0.12.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/src/services/meService.ts b/external-rest-api/src/services/meService.ts index 9dec8d517b..096f86d555 100644 --- a/external-rest-api/src/services/meService.ts +++ b/external-rest-api/src/services/meService.ts @@ -131,7 +131,12 @@ export async function getMe(userId: string, teamId: string, sessionToken: string }, } } catch (error) { - if (error instanceof ControlApiError && error.status === 503) throw error + // Exception to the dev error mapping: Control API revokes server sessions + // (password change, logout, revocation), and a revoked session must reach + // the client as 401 so it re-authenticates instead of reading "not found". + if (error instanceof ControlApiError && (error.status === 401 || error.status === 503)) { + throw error + } return null } } diff --git a/external-rest-api/test/meUnavailable.contract.test.ts b/external-rest-api/test/meUnavailable.contract.test.ts index 888e941fb5..e0b0a7543f 100644 --- a/external-rest-api/test/meUnavailable.contract.test.ts +++ b/external-rest-api/test/meUnavailable.contract.test.ts @@ -62,7 +62,7 @@ describe('External REST /me unavailable producer contract', () => { ) }) - it('answers a revoked upstream session with the dev not-found response', async () => { + it('answers a revoked upstream session with 401 so the client re-authenticates', async () => { mocks.controlApiRequest.mockRejectedValue( new ControlApiError('private upstream detail', 401, { error: 'Unauthorized' }) ) @@ -74,9 +74,9 @@ describe('External REST /me unavailable producer contract', () => { .get('/me') .set('authorization', 'Bearer synthetic-session-token') - expect(response.status).toBe(404) - expect(response.body).toEqual({ error: 'User not found' }) - expect(JSON.stringify(response.body)).not.toMatch(/private upstream detail|Unauthorized/) + expect(response.status).toBe(401) + expect(response.body).toEqual({ error: 'Unauthorized' }) + expect(JSON.stringify(response.body)).not.toMatch(/private upstream detail/) expect(mocks.controlApiRequest).toHaveBeenCalledWith( 'GET', '/external/users/synthetic-member-id/me', diff --git a/external-rest-api/test/services.legacyAuthorityErrors.test.ts b/external-rest-api/test/services.legacyAuthorityErrors.test.ts index 5e533e9325..d43bc4171a 100644 --- a/external-rest-api/test/services.legacyAuthorityErrors.test.ts +++ b/external-rest-api/test/services.legacyAuthorityErrors.test.ts @@ -34,10 +34,16 @@ describe('legacy authority adapters', () => { clientMock.controlApiRequest.mockReset() }) - it('maps every non-outage /me and switch-team failure to absence', async () => { + it('maps every other /me and switch-team failure to absence, keeping outages and /me revocation', async () => { for (const status of [404, 401, 500]) { clientMock.controlApiRequest.mockRejectedValueOnce(upstream(status)) - await expect(getMe(auth.userId, auth.teamId, auth.sessionToken)).resolves.toBeNull() + if (status === 401) { + await expect(getMe(auth.userId, auth.teamId, auth.sessionToken)).rejects.toMatchObject({ + status: 401, + }) + } else { + await expect(getMe(auth.userId, auth.teamId, auth.sessionToken)).resolves.toBeNull() + } clientMock.controlApiRequest.mockRejectedValueOnce(upstream(status)) await expect( From ddf8cf4d9e371d476fa675ff1460ded155a5f36e Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 4 Nov 2026 14:42:51 +0100 Subject: [PATCH 456/528] fix(external-rest-api): confirm logout only for an invalid session K4c, Addendum V4 of the PR #334 r4.2 fix plan. The PR1 edge logout answered 200 {ok:true} to any 401 from Control API. A bare 401 on that route comes from the edge's own service credential being rejected, which does not prove the session is gone, yet Profile UI then treated the revocation as confirmed. Control API answers an invalid, expired or revoked session with its public invalid_session envelope; only that envelope now counts as an already-revoked logout. Any other 401 still expires the cookie and is forwarded as an error. Tests: the idempotency fixture uses the real producer envelope; a new case pins the service-credential 401, both with the remote logout call as witness; a real-PG case checks that a repeated logout returns invalid_session. Accepting any 401 again fails one test; dropping the remote call fails four. Real-PG lane: 124 files, 1034 tests. Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 +-- control-api/package.json | 2 +- ...re.parity.realPostgres.integration.test.ts | 8 ++++++ external-rest-api/package-lock.json | 4 +-- external-rest-api/package.json | 2 +- external-rest-api/src/releaseManifest.ts | 2 +- external-rest-api/src/routes/auth.ts | 19 ++++++++++++- external-rest-api/test/routes.auth.test.ts | 28 ++++++++++++++++++- 8 files changed, 60 insertions(+), 9 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 9f82483158..1396ef8ac2 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.895", + "version": "0.1.896", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.895", + "version": "0.1.896", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 33a71416c3..fe2b77d5e7 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.895", + "version": "0.1.896", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/profileUiRecoveryFixture.parity.realPostgres.integration.test.ts b/control-api/test/profileUiRecoveryFixture.parity.realPostgres.integration.test.ts index ea99e723c6..b6078989cd 100644 --- a/control-api/test/profileUiRecoveryFixture.parity.realPostgres.integration.test.ts +++ b/control-api/test/profileUiRecoveryFixture.parity.realPostgres.integration.test.ts @@ -573,6 +573,14 @@ realPg('Profile UI recovery lane fixture parity on PostgreSQL 16', () => { expect(bodyShape(fixtureRevoked.body)).toEqual(bodyShape(realRevoked.body)) expect(realRevoked.status).toBe(401) + // External REST treats only this envelope as an already-revoked logout; a bare 401 is + // its own service credential being rejected. + const realRepeatedLogout = await requestReal('POST', '/external/auth/session/logout', { + sessionToken: realToken, + }) + expect(realRepeatedLogout.status).toBe(401) + expect(realRepeatedLogout.body).toMatchObject({ error: { code: 'invalid_session' } }) + await expectParity( 'GET', `/external/users/${fixtureModule.RECOVERY_USERS.logout.id}/me?teamId=` diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 232c256053..161d1a5b6c 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.167", + "version": "0.1.168", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.167", + "version": "0.1.168", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index af8cabdcc6..29fe9f6c0e 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.167", + "version": "0.1.168", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index 250bfbaa73..3d6a9b027b 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.12.0', - externalRestApiVersion: '0.1.167', + externalRestApiVersion: '0.1.168', rpcProxyVersion: '0.1.138', desktopVersion: '0.12.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/src/routes/auth.ts b/external-rest-api/src/routes/auth.ts index ad93b28f62..683af04e46 100644 --- a/external-rest-api/src/routes/auth.ts +++ b/external-rest-api/src/routes/auth.ts @@ -47,6 +47,23 @@ function isRejectedGoogleIdentity(error: unknown): error is ControlApiError { return 'error' in error.body && error.body.error === 'Unauthorized' } +// control-api answers an invalid, expired or revoked session on logout with its public +// `invalid_session` envelope. A bare 401 on that route comes from the edge's own service +// credential, so it does not prove that the session is already gone. +function isInvalidSessionLogoutDenial(error: unknown): error is ControlApiError { + if (!isControlApiStatus(error, 401) || !error.body || typeof error.body !== 'object') { + return false + } + if (!('error' in error.body)) return false + const envelope = error.body.error + return ( + typeof envelope === 'object' && + envelope !== null && + 'code' in envelope && + envelope.code === 'invalid_session' + ) +} + type LoginResponse = { token: string me: unknown @@ -181,7 +198,7 @@ export function createAuthRouter(): Router { clearProfileSessionCookie(req, res) res.status(200).json({ ok: true }) } catch (error) { - if (isControlApiStatus(error, 401)) { + if (isInvalidSessionLogoutDenial(error)) { clearProfileSessionCookie(req, res) res.status(200).json({ ok: true }) return diff --git a/external-rest-api/test/routes.auth.test.ts b/external-rest-api/test/routes.auth.test.ts index dd8ae8dd86..f597b5fe65 100644 --- a/external-rest-api/test/routes.auth.test.ts +++ b/external-rest-api/test/routes.auth.test.ts @@ -331,7 +331,14 @@ describe('routes/auth password-login', () => { it('keeps already-invalid logout idempotent and expires its cookie', async () => { authServiceMock.logoutUserSession.mockRejectedValueOnce( - new ControlApiError('private upstream detail', 401, { error: 'Unauthorized' }) + new ControlApiError('private upstream detail', 401, { + error: { + code: 'invalid_session', + message: 'The session is not valid.', + correlationId: 'synthetic-correlation', + retryable: false, + }, + }) ) const response = await request(buildApp()) @@ -344,6 +351,25 @@ describe('routes/auth password-login', () => { expect(String(response.headers['set-cookie'])).toContain( 'Expires=Thu, 01 Jan 1970 00:00:00 GMT' ) + expect(authServiceMock.logoutUserSession).toHaveBeenCalledWith('opaque-session') + }) + + it('does not confirm logout when control-api rejects the edge service credential', async () => { + authServiceMock.logoutUserSession.mockRejectedValueOnce( + new ControlApiError('private upstream detail', 401, { error: 'Unauthorized' }) + ) + + const response = await request(buildApp()) + .post('/api/v1/auth/logout') + .set('Cookie', 'profile_session=opaque-session') + .set('x-forwarded-proto', 'https') + .expect(401) + + expect(response.body.ok).toBeUndefined() + expect(String(response.headers['set-cookie'])).toContain( + 'Expires=Thu, 01 Jan 1970 00:00:00 GMT' + ) + expect(authServiceMock.logoutUserSession).toHaveBeenCalledWith('opaque-session') }) it('does not call remote revocation or set a cookie when no session is present', async () => { From 4e30c600e09902134b4a8daeca4d2f9743eebd64 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 4 Nov 2026 17:09:48 +0100 Subject: [PATCH 457/528] fix(desktop-app): end the session when /me rejects it and tell the window K4d, Addendum V4 of the PR #334 r4.2 fix plan. PR1 revokes server sessions, so Desktop can hold a committed session that Control API no longer accepts. Main kept such a session in memory and on disk, and the renderer stayed signed in while every call failed. Only a rejection from /me (401, 403, 410) ends the session now. Main fences the session's GFS uploads, clears it, removes the saved token and sends auth:sessionCleared; the renderer reloads the session, which runs the same signed-out transition as a failed restore. /me is asked when a team lookup needs it, when a team call in the workflow listing answers 401, and when the live-update stream expires. The existing closes after a team-switch token was issued now also notify the window. A /me rejection that arrives while a login is in progress does not cancel that login; it is applied when the last login settles, unless the login replaced the session. The workflow listing is bound to the session that started it: if that session ends or is replaced, including in the no-team fallback, no list or team map is published. Accepted residuals, tracked elsewhere: a silently failed token deletion can still be restored (token store, dev); stale listing work under a replacement session (PR3). Tests cover each trigger, a reserved Google or password login, replaced sessions during the listing, fence order and identity, the IPC channel and the renderer effects. Each negative assertion has a liveness witness; 21 mutants are killed. Desktop gates on Node 24: 452 test files, 5458 tests, build and UI typecheck green. Co-Authored-By: Claude Opus 5.5 --- .../appService.entityChanges.test.ts | 120 +++++ .../appService.sessionRejection.test.ts | 433 ++++++++++++++++++ ...appService.sessionRejectionClosing.test.ts | 299 ++++++++++++ .../appService.sessionRejectionLogin.test.ts | 130 ++++++ .../__tests__/mainGfsNavigationIpc.test.ts | 21 +- desktop-app/src/appService.ts | 235 +++++++--- desktop-app/src/main.ts | 5 + desktop-app/src/preload.ts | 5 + desktop-app/src/renderer.d.ts | 1 + .../useAppController.sessionCleared.test.tsx | 50 ++ .../__fixtures__/appControllerHarness.tsx | 1 + ...AuthController.desktopEnvironment.test.tsx | 3 + .../useAuthController.sessionCleared.test.tsx | 90 ++++ .../ui/src/hooks/domain/useAuthController.ts | 16 + 14 files changed, 1345 insertions(+), 64 deletions(-) create mode 100644 desktop-app/src/__tests__/appService.sessionRejection.test.ts create mode 100644 desktop-app/src/__tests__/appService.sessionRejectionClosing.test.ts create mode 100644 desktop-app/src/__tests__/appService.sessionRejectionLogin.test.ts create mode 100644 desktop-app/ui/src/hooks/__tests__/useAppController.sessionCleared.test.tsx create mode 100644 desktop-app/ui/src/hooks/domain/__tests__/useAuthController.sessionCleared.test.tsx diff --git a/desktop-app/src/__tests__/appService.entityChanges.test.ts b/desktop-app/src/__tests__/appService.entityChanges.test.ts index 5b4f2f57b4..d922890ac0 100644 --- a/desktop-app/src/__tests__/appService.entityChanges.test.ts +++ b/desktop-app/src/__tests__/appService.entityChanges.test.ts @@ -66,6 +66,7 @@ describe('AppService entity-change fan-out', () => { token: 'synthetic-new-committed-token', me: { id: 'user-1', teamId: 'team-1' }, }), + getMe: vi.fn().mockResolvedValue({ id: 'user-1', teamId: 'team-1' }), openEntityChangeStream: vi.fn((token, cursor, onEvent, signal) => { opened.push({ token, cursor, onEvent, signal }) return new Promise(resolve => { @@ -282,6 +283,7 @@ describe('AppService entity-change reconnect behavior', () => { openEntityChangeStream: vi .fn() .mockRejectedValue(new ApiError('Entity change stream failed (401)', 401, '')), + getMe: vi.fn().mockResolvedValue({ id: 'user-1', teamId: 'team-1' }), } const events: EntityChangeStreamEvent[] = [] @@ -346,6 +348,7 @@ describe('AppService entity-change reconnect behavior', () => { reason: 'session_expired', }) }), + getMe: vi.fn().mockResolvedValue({ id: 'user-1', teamId: 'team-1' }), } const events: EntityChangeStreamEvent[] = [] @@ -404,6 +407,7 @@ describe('AppService entity-change reconnect behavior', () => { setSyntheticSessionToken(service, 'committed-session-token') let publish!: (event: EntityChangeStreamEvent) => void service.authClient = { + getMe: vi.fn().mockResolvedValue({ id: 'user-1', teamId: 'team-1' }), openEntityChangeStream: vi.fn((_token, _cursor, onEvent, signal) => { publish = onEvent return new Promise(resolve => { @@ -435,7 +439,123 @@ describe('AppService entity-change reconnect behavior', () => { }) }) +describe('AppService entity-change expiry asks /me about the session', () => { + function expiringService(getMe: ReturnType) { + const tokenStore = { + clearSessionToken: vi.fn().mockResolvedValue(undefined), + setSessionToken: vi.fn().mockResolvedValue(undefined), + getSessionToken: vi.fn().mockResolvedValue(null), + } + const onSessionCleared = vi.fn() + const service = new AppService({ tokenStore: tokenStore as never, onSessionCleared }) as any + setSyntheticSessionToken(service, 'committed-session-token') + service.me = { id: 'user-1', teamId: 'team-1' } + service.suspendDesktopGfsUploadsForAuthBoundary = vi.fn().mockResolvedValue(undefined) + let publish!: (event: EntityChangeStreamEvent) => void + service.authClient = { + getMe, + openEntityChangeStream: vi.fn((_token, _cursor, onEvent, signal) => { + publish = onEvent + return new Promise(resolve => { + signal.addEventListener('abort', () => resolve(), { once: true }) + }) + }), + } + const events: EntityChangeStreamEvent[] = [] + service.startEntityChangeStream('stream-1', 7, (event: EntityChangeStreamEvent) => { + events.push(event) + }) + const expire = () => + publish({ + type: 'stream.closing', + schemaVersion: 1, + cursor: '00000000-0000-0000-0000-000000000000', + reason: 'session_expired', + }) + return { service, tokenStore, onSessionCleared, events, expire } + } + + it('ends the session when /me rejects it after the stream expired', async () => { + const getMe = vi.fn().mockRejectedValue(new ApiError('401 Unauthorized', 401, '')) + const { service, tokenStore, onSessionCleared, events, expire } = expiringService(getMe) + await flushAsyncWork() + + expire() + await flushAsyncWork(20) + + expect(events.map(event => event.type)).toEqual(['stream.closing']) + expect(events[0]).toMatchObject({ reason: 'session_expired' }) + expect(getMe).toHaveBeenCalledTimes(1) + expect(getMe).toHaveBeenCalledWith('committed-session-token') + expect(service.sessionToken).toBeNull() + expect(service.me).toBeNull() + expect(tokenStore.clearSessionToken).toHaveBeenCalledTimes(1) + expect(onSessionCleared).toHaveBeenCalledTimes(1) + }) + + it('keeps the session when /me accepts it after the stream expired', async () => { + const getMe = vi.fn().mockResolvedValue({ id: 'user-1', teamId: 'team-1' }) + const { service, tokenStore, onSessionCleared, expire } = expiringService(getMe) + await flushAsyncWork() + + expire() + await flushAsyncWork(20) + + expect(getMe).toHaveBeenCalledTimes(1) + expect(service.sessionToken).toBe('committed-session-token') + expect(tokenStore.clearSessionToken).not.toHaveBeenCalled() + expect(onSessionCleared).not.toHaveBeenCalled() + service.stopEntityChangeStream('stream-1', 7) + }) + + it('does not ask /me when a newer committed session replaces the expired stream', async () => { + const getMe = vi.fn() + const { service, onSessionCleared, expire } = expiringService(getMe) + await flushAsyncWork() + setSyntheticEntityChangeSession(service, 'new-committed-session-token', 1) + + expire() + await flushAsyncWork(20) + + const openedTokens = service.authClient.openEntityChangeStream.mock.calls.map( + (call: unknown[]) => call[0] + ) + expect(openedTokens).toEqual(['committed-session-token', 'new-committed-session-token']) + expect(getMe).not.toHaveBeenCalled() + expect(onSessionCleared).not.toHaveBeenCalled() + service.stopEntityChangeStream('stream-1', 7) + }) +}) + describe('AppService entity-change stream team-context lifecycle', () => { + it('asks /me about the session when a team hop releases a deferred stream expiry', async () => { + const tokenStore = { + clearSessionToken: vi.fn().mockResolvedValue(undefined), + setSessionToken: vi.fn().mockResolvedValue(undefined), + getSessionToken: vi.fn().mockResolvedValue(null), + } + const onSessionCleared = vi.fn() + const service = new AppService({ tokenStore: tokenStore as never, onSessionCleared }) as any + setSyntheticSessionToken(service, 'committed-session-token') + service.me = { id: 'user-1', teamId: 'team-1' } + service.suspendDesktopGfsUploadsForAuthBoundary = vi.fn().mockResolvedValue(undefined) + service.authClient = { + getMe: vi.fn().mockRejectedValue(new ApiError('401 Unauthorized', 401, '')), + } + const release = service.enterGfsTransientTeamHop() + + service.handleEntityChangeSessionExpiry(service.entityChangeSessionGeneration) + + // The expiry was deferred during the hop, not dropped and not checked yet. + expect(service.entityChangeSessionExpiryDeferred).toBe(true) + expect(service.authClient.getMe).not.toHaveBeenCalled() + release() + await vi.waitFor(() => expect(onSessionCleared).toHaveBeenCalledTimes(1)) + expect(service.authClient.getMe).toHaveBeenCalledTimes(1) + expect(service.authClient.getMe).toHaveBeenCalledWith('committed-session-token') + expect(service.sessionToken).toBeNull() + }) + it('never reconnects with a transient team token and rebinds after the hop', async () => { vi.useFakeTimers() vi.spyOn(Math, 'random').mockReturnValue(0) diff --git a/desktop-app/src/__tests__/appService.sessionRejection.test.ts b/desktop-app/src/__tests__/appService.sessionRejection.test.ts new file mode 100644 index 0000000000..91aa6bea02 --- /dev/null +++ b/desktop-app/src/__tests__/appService.sessionRejection.test.ts @@ -0,0 +1,433 @@ +import { describe, expect, it, vi } from 'vitest' +import { AppService } from '../appService.js' +import { getActiveEnvKey } from '../config.js' +import { ApiError } from '../httpClient.js' +import type { SessionMe } from '../types.js' + +vi.mock('../chatStoreBinding.js', () => ({ + bindChatStoreForUser: vi.fn(), + getChatStore: vi.fn(), + unbindChatStore: vi.fn(), +})) + +type Mock = ReturnType + +type TestService = { + sessionToken: string | null + me: SessionMe | null + workflowTeamByKey: Map + authClient: Record + suspendDesktopGfsUploadsForAuthBoundary: Mock + runWithTeamContext( + teamId: string | null, + operation: (sessionToken: string) => Promise + ): Promise + listWorkflows(): Promise + switchTeam(teamId: string): Promise +} + +function sessionMe(overrides: Partial = {}): SessionMe { + return { + id: 'user-a', + email: 'user-a@example.test', + name: 'User A', + picture: null, + teamId: 'team-a', + teamName: 'Team A', + role: 'member', + ...overrides, + } +} + +function unauthorized(): ApiError { + return new ApiError('401 Unauthorized', 401, '{"error":"Unauthorized"}') +} + +function createService(me: SessionMe) { + const tokenStore = { + clearSessionToken: vi.fn().mockResolvedValue(undefined), + getSessionToken: vi.fn().mockResolvedValue(null), + setSessionToken: vi.fn().mockResolvedValue(undefined), + } + const onSessionCleared = vi.fn() + const service = new AppService({ + tokenStore: tokenStore as never, + onSessionCleared, + }) as unknown as TestService + service.sessionToken = 'session-token' + service.me = me + service.suspendDesktopGfsUploadsForAuthBoundary = vi.fn().mockResolvedValue(undefined) + return { service, tokenStore, onSessionCleared } +} + +function expectSessionEnded( + service: TestService, + tokenStore: { clearSessionToken: Mock }, + onSessionCleared: Mock +) { + expect(service.sessionToken).toBeNull() + expect(service.me).toBeNull() + expect(tokenStore.clearSessionToken).toHaveBeenCalledTimes(1) + expect(tokenStore.clearSessionToken).toHaveBeenCalledWith(getActiveEnvKey(), { + legacyEnvKeys: expect.any(Array), + }) + expect(onSessionCleared).toHaveBeenCalledTimes(1) +} + +describe('AppService session rejected by /me', () => { + it('ends the session when /me rejects it during a team-context lookup', async () => { + const { service, tokenStore, onSessionCleared } = createService(sessionMe({ teamId: null })) + const rejection = unauthorized() + const fenced: Array<{ token: string | null; ownerId: unknown }> = [] + service.suspendDesktopGfsUploadsForAuthBoundary = vi.fn( + async (identity?: { ownerId?: string }) => { + fenced.push({ token: service.sessionToken, ownerId: identity?.ownerId }) + } + ) + service.authClient = { getMe: vi.fn().mockRejectedValue(rejection) } + const operation = vi.fn() + + await expect(service.runWithTeamContext('team-b', operation)).rejects.toBe(rejection) + + expect(service.authClient.getMe).toHaveBeenCalledTimes(1) + expect(service.authClient.getMe).toHaveBeenCalledWith('session-token') + expect(operation).not.toHaveBeenCalled() + // The uploads of the rejected session are fenced before it is cleared, with its identity. + expect(fenced).toEqual([{ token: 'session-token', ownerId: 'user-a' }]) + expectSessionEnded(service, tokenStore, onSessionCleared) + }) + + it('keeps the session when /me fails without rejecting it', async () => { + const { service, tokenStore, onSessionCleared } = createService(sessionMe({ teamId: null })) + const unavailable = new ApiError('503 Service Unavailable', 503, '') + service.authClient = { getMe: vi.fn().mockRejectedValue(unavailable) } + + await expect(service.runWithTeamContext('team-b', vi.fn())).rejects.toBe(unavailable) + + expect(service.authClient.getMe).toHaveBeenCalledTimes(1) + expect(service.sessionToken).toBe('session-token') + expect(tokenStore.clearSessionToken).not.toHaveBeenCalled() + expect(onSessionCleared).not.toHaveBeenCalled() + }) + + it('leaves a newer committed session alone when /me rejects the old token', async () => { + const { service, tokenStore, onSessionCleared } = createService(sessionMe({ teamId: null })) + const rejection = unauthorized() + const newerMe = sessionMe({ id: 'user-a', teamId: 'team-c' }) + service.authClient = { + getMe: vi.fn(async () => { + service.sessionToken = 'newer' + service.me = newerMe + throw rejection + }), + } + + await expect(service.runWithTeamContext('team-b', vi.fn())).rejects.toBe(rejection) + + expect(service.authClient.getMe).toHaveBeenCalledTimes(1) + expect(service.sessionToken).toBe('newer') + expect(service.me).toBe(newerMe) + expect(tokenStore.clearSessionToken).not.toHaveBeenCalled() + expect(onSessionCleared).not.toHaveBeenCalled() + }) + + it('stops a workflow listing and ends the session when /me rejects it', async () => { + const { service, tokenStore, onSessionCleared } = createService(sessionMe({ teamId: null })) + const rejection = unauthorized() + service.workflowTeamByKey.set('sandbox-recipes/stale', 'team-old') + service.authClient = { + listTeams: vi.fn().mockResolvedValue({ + currentTeamId: null, + items: [ + { id: 'team-a', name: 'Team A', role: 'member' }, + { id: 'team-b', name: 'Team B', role: 'member' }, + ], + }), + getMe: vi.fn().mockRejectedValue(rejection), + listWorkflows: vi.fn(), + } + + await expect(service.listWorkflows()).rejects.toBe(rejection) + + expect(service.authClient.listTeams).toHaveBeenCalledTimes(1) + expect(service.authClient.getMe).toHaveBeenCalledTimes(1) + expect(service.authClient.listWorkflows).not.toHaveBeenCalled() + expect(service.workflowTeamByKey.size).toBe(0) + expectSessionEnded(service, tokenStore, onSessionCleared) + }) + + it('stops a workflow listing when a team hop ended the session with another error', async () => { + const { service, tokenStore, onSessionCleared } = createService(sessionMe()) + // A failure after the hop token was issued ends the session by the existing rule in + // switchSessionToTeam. The trigger is a token-store write, not /me: only a rejection + // from /me is a reason of its own to end the session. + const unavailable = new Error('keychain write failed') + tokenStore.setSessionToken.mockRejectedValue(unavailable) + service.authClient = { + listTeams: vi.fn().mockResolvedValue({ + currentTeamId: 'team-a', + items: [ + { id: 'team-b', name: 'Team B', role: 'member' }, + { id: 'team-c', name: 'Team C', role: 'member' }, + ], + }), + switchTeam: vi.fn().mockResolvedValue({ + token: 'session-team-b', + team: { id: 'team-b', name: 'Team B', role: 'member' }, + }), + getMe: vi.fn().mockResolvedValue(sessionMe({ teamId: 'team-b' })), + listWorkflows: vi.fn(), + } + + await expect(service.listWorkflows()).rejects.toBe(unavailable) + + // The hop issued a replacement token and then failed, so main ended the + // session; the listing must not continue to the next team. + expect(service.authClient.switchTeam).toHaveBeenCalledTimes(1) + expect(service.authClient.listWorkflows).not.toHaveBeenCalled() + expect(service.sessionToken).toBeNull() + expect(tokenStore.clearSessionToken).toHaveBeenCalledTimes(1) + expect(onSessionCleared).toHaveBeenCalledTimes(1) + }) + + it('ends the session when a team call answers 401 and /me rejects the session', async () => { + const { service, tokenStore, onSessionCleared } = createService(sessionMe()) + const rejection = unauthorized() + service.authClient = { + listTeams: vi.fn().mockResolvedValue({ + currentTeamId: 'team-a', + items: [ + { id: 'team-a', name: 'Team A', role: 'member' }, + { id: 'team-b', name: 'Team B', role: 'member' }, + ], + }), + listWorkflows: vi.fn().mockRejectedValue(rejection), + switchTeam: vi.fn(), + getMe: vi.fn().mockRejectedValue(unauthorized()), + } + + await expect(service.listWorkflows()).rejects.toBe(rejection) + + expect(service.authClient.listWorkflows).toHaveBeenCalledTimes(1) + expect(service.authClient.switchTeam).not.toHaveBeenCalled() + expect(service.authClient.getMe).toHaveBeenCalledTimes(1) + expect(service.authClient.getMe).toHaveBeenCalledWith('session-token') + expectSessionEnded(service, tokenStore, onSessionCleared) + }) + + it('fails a workflow listing with a 401 team call even when /me accepts the session', async () => { + const { service, tokenStore, onSessionCleared } = createService(sessionMe()) + const rejection = unauthorized() + service.workflowTeamByKey.set('sandbox-recipes/kept', 'team-a') + service.authClient = { + listTeams: vi.fn().mockResolvedValue({ + currentTeamId: 'team-a', + items: [ + { id: 'team-a', name: 'Team A', role: 'member' }, + { id: 'team-b', name: 'Team B', role: 'member' }, + ], + }), + listWorkflows: vi.fn().mockRejectedValue(rejection), + switchTeam: vi.fn(), + getMe: vi.fn().mockResolvedValue(sessionMe()), + } + + await expect(service.listWorkflows()).rejects.toBe(rejection) + + expect(service.authClient.getMe).toHaveBeenCalledTimes(1) + expect(service.sessionToken).toBe('session-token') + expect(tokenStore.clearSessionToken).not.toHaveBeenCalled() + expect(onSessionCleared).not.toHaveBeenCalled() + expect([...service.workflowTeamByKey]).toEqual([['sandbox-recipes/kept', 'team-a']]) + }) + + it('keeps listing other teams when a team switch is refused with 403', async () => { + const { service, tokenStore, onSessionCleared } = createService(sessionMe()) + const recipe = { metadata: { namespace: 'sandbox-recipes', name: 'recipe-a' } } + service.authClient = { + listTeams: vi.fn().mockResolvedValue({ + currentTeamId: 'team-a', + items: [ + { id: 'team-a', name: 'Team A', role: 'member' }, + { id: 'team-b', name: 'Team B', role: 'member' }, + ], + }), + listWorkflows: vi.fn().mockResolvedValue({ items: [recipe], count: 1 }), + switchTeam: vi + .fn() + .mockRejectedValue(new ApiError('403 Forbidden', 403, '{"error":"forbidden"}')), + getMe: vi.fn(), + } + + await expect(service.listWorkflows()).resolves.toEqual({ items: [recipe], count: 1 }) + + expect(service.authClient.switchTeam).toHaveBeenCalledTimes(1) + expect(service.authClient.getMe).not.toHaveBeenCalled() + expect(service.sessionToken).toBe('session-token') + expect(tokenStore.clearSessionToken).not.toHaveBeenCalled() + expect(onSessionCleared).not.toHaveBeenCalled() + }) + + it('tells the window when a deliberate team switch ends the session', async () => { + const { service, tokenStore, onSessionCleared } = createService(sessionMe()) + const rejection = unauthorized() + service.authClient = { + switchTeam: vi.fn().mockResolvedValue({ + token: 'session-team-b', + team: { id: 'team-b', name: 'Team B', role: 'member' }, + }), + getMe: vi.fn().mockRejectedValue(rejection), + } + + await expect(service.switchTeam('team-b')).rejects.toBe(rejection) + + expect(service.authClient.switchTeam).toHaveBeenCalledTimes(1) + expect(service.authClient.getMe).toHaveBeenCalledWith('session-team-b') + expectSessionEnded(service, tokenStore, onSessionCleared) + }) + + it('tells the window when the GFS fence of a deliberate team switch fails', async () => { + const { service, tokenStore, onSessionCleared } = createService(sessionMe()) + const fenceFailure = new Error('gfs state write failed') + service.suspendDesktopGfsUploadsForAuthBoundary = vi.fn().mockRejectedValue(fenceFailure) + service.authClient = { + switchTeam: vi.fn().mockResolvedValue({ + token: 'session-team-b', + team: { id: 'team-b', name: 'Team B', role: 'member' }, + }), + getMe: vi.fn().mockResolvedValue(sessionMe({ teamId: 'team-b', teamName: 'Team B' })), + } + + await expect(service.switchTeam('team-b')).rejects.toBe(fenceFailure) + + expect(service.suspendDesktopGfsUploadsForAuthBoundary).toHaveBeenCalledTimes(1) + expectSessionEnded(service, tokenStore, onSessionCleared) + }) + + it('tells the window even when the saved token cannot be removed', async () => { + const { service, tokenStore, onSessionCleared } = createService(sessionMe({ teamId: null })) + tokenStore.clearSessionToken.mockRejectedValue(new Error('keychain unavailable')) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const rejection = unauthorized() + service.authClient = { getMe: vi.fn().mockRejectedValue(rejection) } + + try { + await expect(service.runWithTeamContext('team-b', vi.fn())).rejects.toBe(rejection) + } finally { + warn.mockRestore() + } + + expect(tokenStore.clearSessionToken).toHaveBeenCalledTimes(1) + expect(service.sessionToken).toBeNull() + expect(onSessionCleared).toHaveBeenCalledTimes(1) + }) +}) + +describe('AppService workflow listing when the session is replaced', () => { + const recipe = (name: string) => ({ metadata: { namespace: 'sandbox-recipes', name } }) + + function listingService() { + const built = createService(sessionMe()) + const service = built.service as unknown as TestService & Record + service.bindCurrentChatStore = vi.fn().mockResolvedValue(undefined) + service.authClient = { + listTeams: vi.fn().mockResolvedValue({ + currentTeamId: 'team-a', + items: [ + { id: 'team-a', name: 'Team A', role: 'member' }, + { id: 'team-b', name: 'Team B', role: 'member' }, + ], + }), + listWorkflows: vi.fn(), + switchTeam: vi.fn(async (token: string, teamId: string) => ({ + token: `${token}@${teamId}`, + team: { id: teamId, name: teamId, role: 'member' }, + })), + getMe: vi.fn(async () => sessionMe({ id: 'user-b', teamId: 'team-b' })), + } + // Another session (a new login) replaces the one that started the listing. + const replaceSession = () => + service.withNativeAuthEnvironmentCommit(async () => { + service.clearAuthenticatedSessionState() + service.sessionGeneration += 1 + service.sessionToken = 'token-b' + service.me = sessionMe({ id: 'user-b', teamId: 'team-c' }) + service.workflowTeamByKey.set('sandbox-recipes/b-own', 'team-c') + }) + return { ...built, service, replaceSession } + } + + it('rejects a listing whose session was replaced while a team answered', async () => { + const { service, replaceSession } = listingService() + let releaseTeamA!: () => void + service.authClient.listWorkflows.mockImplementation(async (token: string) => { + if (token !== 'session-token') return { items: [recipe(`via-${token}`)], count: 1 } + await new Promise(resolve => (releaseTeamA = resolve)) + return { items: [recipe('from-a')], count: 1 } + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + try { + const outcome = service.listWorkflows().then( + () => 'resolved', + () => 'rejected' + ) + await vi.waitFor(() => expect(service.authClient.listWorkflows).toHaveBeenCalledTimes(1)) + expect(service.authClient.listWorkflows).toHaveBeenCalledWith('session-token') + await replaceSession() + releaseTeamA() + + expect(await outcome).toBe('rejected') + } finally { + warn.mockRestore() + } + expect(service.authClient.switchTeam).not.toHaveBeenCalled() + expect([...service.workflowTeamByKey]).toEqual([['sandbox-recipes/b-own', 'team-c']]) + }) + + it('rejects a listing whose session was replaced inside a team request', async () => { + const { service, replaceSession } = listingService() + service.authClient.listWorkflows.mockImplementation(async (token: string) => { + if (token !== 'session-token') return { items: [recipe(`via-${token}`)], count: 1 } + await replaceSession() + return { items: [recipe('from-a')], count: 1 } + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + let outcome: string + try { + outcome = await service.listWorkflows().then( + () => 'resolved', + () => 'rejected' + ) + } finally { + warn.mockRestore() + } + + expect(service.authClient.listWorkflows).toHaveBeenCalledTimes(1) + expect(outcome).toBe('rejected') + expect(service.authClient.switchTeam).not.toHaveBeenCalled() + expect([...service.workflowTeamByKey]).toEqual([['sandbox-recipes/b-own', 'team-c']]) + }) + + it('rejects a listing whose next team ran under the replacement session', async () => { + const { service, replaceSession } = listingService() + service.authClient.listWorkflows.mockImplementation(async (token: string) => ({ + items: [recipe(`via-${token}`)], + count: 1, + })) + const runWithTeamContext = service.runWithTeamContext.bind(service) + service.runWithTeamContext = async (teamId: string | null, operation: any) => { + if (teamId === 'team-b') await replaceSession() + return runWithTeamContext(teamId, operation) + } + + const outcome = await service.listWorkflows().then( + () => 'resolved', + () => 'rejected' + ) + + // The current team was listed with the session that started the listing. + expect(service.authClient.listWorkflows).toHaveBeenCalledWith('session-token') + expect(outcome).toBe('rejected') + expect([...service.workflowTeamByKey]).toEqual([['sandbox-recipes/b-own', 'team-c']]) + }) +}) diff --git a/desktop-app/src/__tests__/appService.sessionRejectionClosing.test.ts b/desktop-app/src/__tests__/appService.sessionRejectionClosing.test.ts new file mode 100644 index 0000000000..35ae264d68 --- /dev/null +++ b/desktop-app/src/__tests__/appService.sessionRejectionClosing.test.ts @@ -0,0 +1,299 @@ +import { describe, expect, it, vi } from 'vitest' +import { AppService } from '../appService.js' +import { getActiveEnvKey } from '../config.js' +import { ApiError } from '../httpClient.js' + +// passwordLogin refuses to run until the desktop runtime is configured. +vi.hoisted(() => { + process.env.EXTERNAL_REST_API_BASE_URL = 'https://api.example.test' + process.env.RPC_PROXY_BASE_URL = 'https://rpc.example.test' +}) + +vi.mock('electron', () => ({ + app: { isReady: vi.fn(() => false), getPath: vi.fn(() => '/tmp/clerum-desktop-test') }, + safeStorage: { + isEncryptionAvailable: vi.fn(() => false), + encryptString: vi.fn(), + decryptString: vi.fn(), + }, + shell: { openExternal: vi.fn() }, +})) +vi.mock('../chatStoreBinding.js', () => ({ + bindChatStoreForUser: vi.fn(), + getChatStore: vi.fn(), + unbindChatStore: vi.fn(), +})) + +type LoginKind = 'google' | 'password' + +const unauthorized = () => new ApiError('401 Unauthorized', 401, '{"error":"Unauthorized"}') +const sessionMe = (id: string, teamId: string | null) => ({ + id, + email: `${id}@example.test`, + name: id, + picture: null, + teamId, + teamName: teamId, + role: 'member', +}) + +function createService() { + const tokenStore = { + clearSessionToken: vi.fn().mockResolvedValue(undefined), + getSessionToken: vi.fn().mockResolvedValue(null), + setSessionToken: vi.fn().mockResolvedValue(undefined), + } + const onSessionCleared = vi.fn() + const service = new AppService({ tokenStore: tokenStore as never, onSessionCleared }) as any + service.sessionToken = 'token-a' + service.me = sessionMe('user-a', 'team-a') + service.beginPrewarmAuthTransition = () => () => undefined + service.bindCurrentChatStore = vi.fn().mockResolvedValue(undefined) + service.activateGfsAuthScope = vi.fn() + service.suspendDesktopGfsUploadsForAuthBoundary = vi.fn().mockResolvedValue(undefined) + return { service, tokenStore, onSessionCleared } +} + +/** + * Session A is committed. Each login in `kinds` is reserved and its request is + * dispatched; then `/me` rejects A. Returns the pending logins and their + * settle functions, after checking that the rejection reached the close + * decision and was deferred (A still committed, no notice yet). + */ +async function rejectSessionDuringLogins(kinds: LoginKind[]) { + const built = createService() + const { service, onSessionCleared } = built + const endRejected = vi.spyOn(service, 'endSessionRejectedByMe') + let rejectMe!: (error: unknown) => void + const settlers: Array<{ resolve: (value: unknown) => void; reject: (error: unknown) => void }> = + [] + const pendingLogin = () => + new Promise((resolve, reject) => { + settlers.push({ resolve, reject }) + }) + service.authClient = { + getMe: vi.fn(() => new Promise((_, reject) => (rejectMe = reject))), + googleLogin: vi.fn(pendingLogin), + passwordLogin: vi.fn(pendingLogin), + } + const check = service.confirmCommittedSessionWithMe() + const logins: Array> = [] + for (const kind of kinds) { + const login = + kind === 'google' + ? service.googleLogin('id-token') + : service.passwordLogin('user-b@example.test', 'password') + // Observe the outcome so an early rejection is not reported as unhandled. + login.catch(() => undefined) + logins.push(login) + await vi.waitFor(() => expect(settlers).toHaveLength(logins.length)) + } + + rejectMe(unauthorized()) + await check + + // Liveness: the rejection of A reached the close decision. + expect(service.authClient.getMe).toHaveBeenCalledWith('token-a') + expect(endRejected).toHaveBeenCalledTimes(1) + expect(endRejected).toHaveBeenCalledWith('token-a') + // Deferred: A is still committed while a login is reserved. + expect(service.sessionToken).toBe('token-a') + expect(onSessionCleared).not.toHaveBeenCalled() + return { ...built, endRejected, logins, settlers } +} + +function expectSessionAEnded( + service: any, + tokenStore: { clearSessionToken: ReturnType }, + onSessionCleared: ReturnType +) { + expect(service.sessionToken).toBeNull() + expect(service.me).toBeNull() + expect(service.suspendDesktopGfsUploadsForAuthBoundary).toHaveBeenCalledTimes(1) + expect(tokenStore.clearSessionToken).toHaveBeenCalledTimes(1) + expect(tokenStore.clearSessionToken).toHaveBeenCalledWith(getActiveEnvKey(), { + legacyEnvKeys: expect.any(Array), + }) + expect(onSessionCleared).toHaveBeenCalledTimes(1) +} + +describe('AppService session rejected by /me while a login is reserved (K4d-C01)', () => { + it.each(['google', 'password'])( + 'ends the rejected session once a failed %s login settles', + async kind => { + const { service, tokenStore, onSessionCleared, endRejected, logins, settlers } = + await rejectSessionDuringLogins([kind]) + const loginFailure = new Error(`${kind} login failed`) + + settlers[0].reject(loginFailure) + await expect(logins[0]).rejects.toBe(loginFailure) + + expectSessionAEnded(service, tokenStore, onSessionCleared) + // It was the kept rejection, evaluated again when the login settled. + expect(endRejected).toHaveBeenCalledTimes(2) + expect(endRejected).toHaveBeenLastCalledWith('token-a') + expect(service.interactiveLoginAttempts).toBe(0) + } + ) + + it.each(['google', 'password'])( + 'leaves the session installed by a successful %s login', + async kind => { + const { service, tokenStore, onSessionCleared, endRejected, logins, settlers } = + await rejectSessionDuringLogins([kind]) + + settlers[0].resolve({ token: 'token-b', me: sessionMe('user-b', 'team-b') }) + await expect(logins[0]).resolves.toMatchObject({ authenticated: true, me: { id: 'user-b' } }) + + // Liveness: the kept rejection was evaluated and found A replaced. + expect(endRejected).toHaveBeenCalledTimes(2) + expect(endRejected).toHaveBeenLastCalledWith('token-a') + expect(service.sessionToken).toBe('token-b') + expect(service.me).toMatchObject({ id: 'user-b' }) + expect(tokenStore.setSessionToken).toHaveBeenCalledWith('token-b', expect.any(String)) + expect(tokenStore.clearSessionToken).not.toHaveBeenCalled() + expect(onSessionCleared).not.toHaveBeenCalled() + } + ) + + it('leaves a logout made during the login as the only sign-out', async () => { + const { service, tokenStore, onSessionCleared, endRejected, logins, settlers } = + await rejectSessionDuringLogins(['google']) + + await service.logout() + expect(service.sessionToken).toBeNull() + expect(tokenStore.clearSessionToken).toHaveBeenCalledTimes(1) + expect(service.suspendDesktopGfsUploadsForAuthBoundary).toHaveBeenCalledTimes(1) + + // The logout advanced the generation, so the login can no longer install. + settlers[0].resolve({ token: 'token-b', me: sessionMe('user-b', 'team-b') }) + await expect(logins[0]).rejects.toThrow('stale_session_generation') + + // Liveness: the kept rejection was evaluated after the logout. + expect(endRejected).toHaveBeenCalledTimes(2) + expect(endRejected).toHaveBeenLastCalledWith('token-a') + // Nothing was ended, cleared, fenced or announced a second time. + expect(tokenStore.clearSessionToken).toHaveBeenCalledTimes(1) + expect(service.suspendDesktopGfsUploadsForAuthBoundary).toHaveBeenCalledTimes(1) + expect(tokenStore.setSessionToken).not.toHaveBeenCalled() + expect(onSessionCleared).not.toHaveBeenCalled() + expect(service.sessionToken).toBeNull() + }) + + it('keeps the rejection until the last of two failed logins settles', async () => { + const { service, tokenStore, onSessionCleared, endRejected, logins, settlers } = + await rejectSessionDuringLogins(['google', 'password']) + + settlers[0].reject(new Error('google login failed')) + await expect(logins[0]).rejects.toThrow('google login failed') + // Liveness: the first login settled; the second still holds a reservation. + expect(service.interactiveLoginAttempts).toBe(1) + expect(service.sessionToken).toBe('token-a') + expect(onSessionCleared).not.toHaveBeenCalled() + + settlers[1].reject(new Error('password login failed')) + await expect(logins[1]).rejects.toThrow('password login failed') + expectSessionAEnded(service, tokenStore, onSessionCleared) + expect(endRejected).toHaveBeenLastCalledWith('token-a') + }) +}) + +describe('AppService workflow listing fallback when the session is replaced (K4d-C04)', () => { + const recipe = (name: string) => ({ metadata: { namespace: 'sandbox-recipes', name } }) + type TeamOutcome = 'error' | 'empty' + const settleTeams = ( + outcome: TeamOutcome, + settle: { resolve: (value: unknown) => void; reject: (error: unknown) => void } + ) => + outcome === 'error' + ? settle.reject(new Error('teams unavailable')) + : settle.resolve({ currentTeamId: 'team-a', items: [] }) + + function listingService() { + const built = createService() + const { service } = built + service.workflowTeamByKey.set('sandbox-recipes/a-old', 'team-a') + service.authClient = { + listTeams: vi.fn(), + listWorkflows: vi.fn(), + googleLogin: vi + .fn() + .mockResolvedValue({ token: 'token-b', me: sessionMe('user-b', 'team-c') }), + } + // A login replaces A, and B's own listing publishes B's map. + const replaceWithSessionB = async () => { + await service.googleLogin('id-token') + expect(service.sessionToken).toBe('token-b') + service.workflowTeamByKey.set('sandbox-recipes/b-own', 'team-c') + } + return { ...built, replaceWithSessionB } + } + + it.each(['error', 'empty'])( + 'rejects the fallback when B is installed before teams answer (%s)', + async outcome => { + const { service, replaceWithSessionB } = listingService() + let teams!: { resolve: (value: unknown) => void; reject: (error: unknown) => void } + service.authClient.listTeams.mockImplementation( + () => new Promise((resolve, reject) => (teams = { resolve, reject })) + ) + const listing = service.listWorkflows() + listing.catch(() => undefined) + await vi.waitFor(() => expect(service.authClient.listTeams).toHaveBeenCalledTimes(1)) + expect(service.authClient.listTeams).toHaveBeenCalledWith('token-a') + + await replaceWithSessionB() + settleTeams(outcome, teams) + + // Liveness: the listing ran to the session check and rejected there. + await expect(listing).rejects.toThrow('stale_auth_epoch') + expect(service.authClient.listWorkflows).not.toHaveBeenCalled() + expect([...service.workflowTeamByKey]).toEqual([['sandbox-recipes/b-own', 'team-c']]) + } + ) + + it.each(['error', 'empty'])( + 'rejects the fallback result when B is installed while it is in flight (%s)', + async outcome => { + const { service, replaceWithSessionB } = listingService() + service.authClient.listTeams.mockImplementation( + () => new Promise((resolve, reject) => settleTeams(outcome, { resolve, reject })) + ) + let releaseA!: () => void + service.authClient.listWorkflows.mockImplementation(async () => { + await new Promise(resolve => (releaseA = resolve)) + return { items: [recipe('from-a')], count: 1 } + }) + const listing = service.listWorkflows() + listing.catch(() => undefined) + // Liveness: the fallback dispatched A's request. + await vi.waitFor(() => expect(service.authClient.listWorkflows).toHaveBeenCalledTimes(1)) + expect(service.authClient.listWorkflows).toHaveBeenCalledWith('token-a') + + await replaceWithSessionB() + releaseA() + + await expect(listing).rejects.toThrow('stale_auth_epoch') + expect(service.authClient.listWorkflows).toHaveBeenCalledTimes(1) + expect([...service.workflowTeamByKey]).toEqual([['sandbox-recipes/b-own', 'team-c']]) + } + ) + + it.each(['error', 'empty'])( + 'still returns the fallback list for the same session (%s)', + async outcome => { + const { service } = listingService() + service.authClient.listTeams.mockImplementation( + () => new Promise((resolve, reject) => settleTeams(outcome, { resolve, reject })) + ) + service.authClient.listWorkflows.mockResolvedValue({ items: [recipe('from-a')], count: 1 }) + + await expect(service.listWorkflows()).resolves.toEqual({ + items: [recipe('from-a')], + count: 1, + }) + expect(service.authClient.listWorkflows).toHaveBeenCalledWith('token-a') + expect(service.workflowTeamByKey.size).toBe(0) + } + ) +}) diff --git a/desktop-app/src/__tests__/appService.sessionRejectionLogin.test.ts b/desktop-app/src/__tests__/appService.sessionRejectionLogin.test.ts new file mode 100644 index 0000000000..0e7440a5ba --- /dev/null +++ b/desktop-app/src/__tests__/appService.sessionRejectionLogin.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi } from 'vitest' +import { AppService } from '../appService.js' +import { ApiError } from '../httpClient.js' + +// passwordLogin refuses to run until the desktop runtime is configured. +vi.hoisted(() => { + process.env.EXTERNAL_REST_API_BASE_URL = 'https://api.example.test' + process.env.RPC_PROXY_BASE_URL = 'https://rpc.example.test' +}) + +vi.mock('electron', () => ({ + app: { isReady: vi.fn(() => false), getPath: vi.fn(() => '/tmp/clerum-desktop-test') }, + safeStorage: { + isEncryptionAvailable: vi.fn(() => false), + encryptString: vi.fn(), + decryptString: vi.fn(), + }, + shell: { openExternal: vi.fn() }, +})) +vi.mock('../chatStoreBinding.js', () => ({ + bindChatStoreForUser: vi.fn(), + getChatStore: vi.fn(), + unbindChatStore: vi.fn(), +})) + +const unauthorized = () => new ApiError('401 Unauthorized', 401, '{"error":"Unauthorized"}') +const sessionMe = (id: string, teamId: string | null) => ({ + id, + email: `${id}@example.test`, + name: id, + picture: null, + teamId, + teamName: teamId, + role: 'member', +}) + +function createService() { + const tokenStore = { + clearSessionToken: vi.fn().mockResolvedValue(undefined), + getSessionToken: vi.fn().mockResolvedValue(null), + setSessionToken: vi.fn().mockResolvedValue(undefined), + } + const onSessionCleared = vi.fn() + const service = new AppService({ tokenStore: tokenStore as never, onSessionCleared }) as any + service.sessionToken = 'token-a' + service.me = sessionMe('user-a', 'team-a') + service.beginPrewarmAuthTransition = () => () => undefined + service.bindCurrentChatStore = vi.fn().mockResolvedValue(undefined) + service.activateGfsAuthScope = vi.fn() + service.suspendDesktopGfsUploadsForAuthBoundary = vi.fn().mockResolvedValue(undefined) + return { service, tokenStore, onSessionCleared } +} + +async function rejectOldSessionDuringReservedLogin(kind: 'google' | 'password') { + const { service, tokenStore, onSessionCleared } = createService() + const endRejected = vi.spyOn(service, 'endSessionRejectedByMe') + let rejectMe!: (error: unknown) => void + let resolveLogin!: (value: unknown) => void + const pendingLogin = () => new Promise(resolve => (resolveLogin = resolve)) + service.authClient = { + getMe: vi.fn(() => new Promise((_, reject) => (rejectMe = reject))), + googleLogin: vi.fn(pendingLogin), + passwordLogin: vi.fn(pendingLogin), + } + const dispatched = + kind === 'google' ? service.authClient.googleLogin : service.authClient.passwordLogin + const check = service.confirmCommittedSessionWithMe() + const login = + kind === 'google' + ? service.googleLogin('id-token') + : service.passwordLogin('user-b@example.test', 'password') + await vi.waitFor(() => expect(dispatched).toHaveBeenCalledTimes(1)) + + rejectMe(unauthorized()) + await check + + // The rejection of the old token reached the close decision. + expect(service.authClient.getMe).toHaveBeenCalledWith('token-a') + expect(endRejected).toHaveBeenCalledTimes(1) + expect(endRejected).toHaveBeenCalledWith('token-a') + + resolveLogin({ token: 'token-b', me: sessionMe('user-b', 'team-b') }) + await expect(login).resolves.toMatchObject({ authenticated: true, me: { id: 'user-b' } }) + expect(service.sessionToken).toBe('token-b') + expect(tokenStore.setSessionToken).toHaveBeenCalledWith('token-b', expect.any(String)) + expect(tokenStore.clearSessionToken).not.toHaveBeenCalled() + expect(onSessionCleared).not.toHaveBeenCalled() +} + +describe('AppService session rejected by /me while a login is in progress', () => { + it('keeps a reserved Google login when /me rejects the session it replaces', async () => { + await rejectOldSessionDuringReservedLogin('google') + }) + + it('keeps a reserved password login when /me rejects the session it replaces', async () => { + await rejectOldSessionDuringReservedLogin('password') + }) + + it('leaves a login committed before the old rejection arrives', async () => { + const { service, tokenStore, onSessionCleared } = createService() + let rejectMe!: (error: unknown) => void + service.authClient = { + getMe: vi.fn(() => new Promise((_, reject) => (rejectMe = reject))), + googleLogin: vi + .fn() + .mockResolvedValue({ token: 'token-b', me: sessionMe('user-b', 'team-b') }), + } + const check = service.confirmCommittedSessionWithMe() + await service.googleLogin('id-token') + + rejectMe(unauthorized()) + await check + + expect(service.authClient.getMe).toHaveBeenCalledWith('token-a') + expect(service.sessionToken).toBe('token-b') + expect(tokenStore.clearSessionToken).not.toHaveBeenCalled() + expect(onSessionCleared).not.toHaveBeenCalled() + }) + + it('ends the session when /me rejects it and no login is in progress', async () => { + const { service, tokenStore, onSessionCleared } = createService() + service.authClient = { getMe: vi.fn().mockRejectedValue(unauthorized()) } + + await service.confirmCommittedSessionWithMe() + + expect(service.sessionToken).toBeNull() + expect(tokenStore.clearSessionToken).toHaveBeenCalledTimes(1) + expect(onSessionCleared).toHaveBeenCalledTimes(1) + }) +}) diff --git a/desktop-app/src/__tests__/mainGfsNavigationIpc.test.ts b/desktop-app/src/__tests__/mainGfsNavigationIpc.test.ts index 24c069014d..d60714d3c7 100644 --- a/desktop-app/src/__tests__/mainGfsNavigationIpc.test.ts +++ b/desktop-app/src/__tests__/mainGfsNavigationIpc.test.ts @@ -20,6 +20,7 @@ const harness = vi.hoisted(() => { logout: vi.fn(async () => 7), sessionGeneration: 0, invalidateSandboxUiSession: null as null | (() => void), + sessionCleared: null as null | (() => void), parseSandboxUiDeepLink: vi.fn(() => null), registeredLogout: null as null | (() => Promise), createWindow: null as null | (() => Record), @@ -106,8 +107,12 @@ vi.mock('electron', () => ({ vi.mock('../appService.js', () => ({ AppService: class { - constructor(options?: { onSandboxUiSessionInvalidated?: () => void }) { + constructor(options?: { + onSandboxUiSessionInvalidated?: () => void + onSessionCleared?: () => void + }) { harness.invalidateSandboxUiSession = options?.onSandboxUiSessionInvalidated ?? null + harness.sessionCleared = options?.onSessionCleared ?? null } initialize = harness.initialize logout = harness.logout @@ -259,6 +264,20 @@ describe('main.ts GFS navigation-generation composition', () => { expect(listPending(ipcEvent(ownContents)).links).toEqual([]) }) + it('tells the main window when main ends the committed session', () => { + const ownContents = harness.windows[0]?.webContents + if (!ownContents) throw new Error('main.ts did not create the owning BrowserWindow') + if (!harness.sessionCleared) throw new Error('main.ts did not pass onSessionCleared') + ownContents.send.mockClear() + + harness.sessionCleared() + + const notices = ownContents.send.mock.calls.filter( + ([channel]) => channel === 'auth:sessionCleared' + ) + expect(notices).toEqual([['auth:sessionCleared']]) + }) + it('keeps post-invalidation App and resource links above the main queue reset floors', () => { const mainWindow = harness.windows[0] const ownContents = mainWindow?.webContents diff --git a/desktop-app/src/appService.ts b/desktop-app/src/appService.ts index 2d04fd4080..5307dcdde3 100644 --- a/desktop-app/src/appService.ts +++ b/desktop-app/src/appService.ts @@ -879,6 +879,7 @@ export function migrateDesktopGfsUploadState(value: unknown): { export interface AppServiceOptions { tokenStore?: TokenStore onSandboxUiSessionInvalidated?: () => void + onSessionCleared?: () => void } export class AppService { @@ -896,6 +897,7 @@ export class AppService { }) private readonly tokenStore: TokenStore private readonly onSandboxUiSessionInvalidated?: () => void + private readonly onSessionCleared?: () => void private readonly rpcTokenManager = new RpcTokenManager(this.authClient) private sessionToken: string | null = null private me: SessionMe | null = null @@ -911,6 +913,9 @@ export class AppService { private savedSessionRestoreAttemptedEnvKey: string | null = null private savedSessionRestoreAttemptedAtMs = 0 private interactiveLoginAttempts = 0 + // A session that /me rejected while a login was in progress. It is ended + // when the last login settles, unless that login replaced it. + private sessionRejectedDuringLogin: string | null = null private nativeAuthEnvironmentCommitQueue: Promise = Promise.resolve() private gfsAuthEpoch = 0 private gfsDispatchBlocked = true @@ -999,6 +1004,7 @@ export class AppService { constructor(options: AppServiceOptions = {}) { this.tokenStore = options.tokenStore ?? new TokenStore() this.onSandboxUiSessionInvalidated = options.onSandboxUiSessionInvalidated + this.onSessionCleared = options.onSessionCleared } private static dedupe(values: string[]): string[] { @@ -1275,17 +1281,7 @@ export class AppService { fenceError ) } - this.clearAuthenticatedSessionState() - try { - await this.tokenStore.clearSessionToken(getActiveEnvKey(), { - legacyEnvKeys: getActiveLegacyEnvKeys(), - }) - } catch (clearError) { - console.warn( - '[AppService] Failed to clear a partially switched team session:', - clearError - ) - } + await this.endCommittedSession() } throw error } @@ -1363,7 +1359,17 @@ export class AppService { let context = session if (!context.teamId) { - const me = await this.authClient.getMe(session.sessionToken) + let me: SessionMe + try { + me = await this.authClient.getMe(session.sessionToken) + } catch (error) { + if (AppService.isRejectedStoredSessionError(error)) { + await this.withNativeAuthEnvironmentCommit(() => + this.endSessionRejectedByMe(session.sessionToken) + ) + } + throw error + } context = await this.withNativeAuthEnvironmentCommit(async () => { this.assertSessionGeneration(session.sessionGeneration) if (this.sessionToken !== session.sessionToken) { @@ -1538,6 +1544,7 @@ export class AppService { * first and drops its effects if it moved. `sessionGeneration` cannot serve: * every token commit bumps it, including the two of each transient team hop * that unrelated concurrent work performs while a mint is in flight. + * `listWorkflows` captures it too, so a listing never mixes two sessions. */ private sandboxUiSessionEpoch = 0 @@ -1589,6 +1596,79 @@ export class AppService { this.invalidateSandboxUiSession() } + /** + * Ends the committed session: clears in-memory state, removes the saved token + * and tells the window so it returns to sign-in. The window is told even when + * the saved token could not be removed, because the in-memory session is gone. + */ + private async endCommittedSession(): Promise { + this.clearAuthenticatedSessionState() + try { + await this.tokenStore.clearSessionToken(getActiveEnvKey(), { + legacyEnvKeys: getActiveLegacyEnvKeys(), + }) + } catch (clearError) { + console.warn('[AppService] Failed to clear the saved session token:', clearError) + } finally { + this.onSessionCleared?.() + } + } + + /** + * Ends a session that control-api rejected on `/me`. A token that is no longer + * the committed one belongs to a session that was already replaced, so it is + * left alone. + */ + private async endSessionRejectedByMe(rejectedToken: string): Promise { + if (this.sessionToken !== rejectedToken) return + // A login in progress has reserved the next session generation and replaces + // this session itself (installAuthenticatedLogin fences its GFS uploads). + // Ending it here would advance the generation and cancel that login, so the + // rejection is kept until the last login settles. + if (this.interactiveLoginAttempts > 0) { + this.sessionRejectedDuringLogin = rejectedToken + return + } + const identity = + this.gfsScopeIdentity ?? (this.me ? desktopGfsUploadIdentity(this.me) : undefined) + try { + await this.suspendDesktopGfsUploadsForAuthBoundary(identity) + } catch (fenceError) { + console.warn('[AppService] Failed to fence GFS uploads for a rejected session:', fenceError) + } + await this.endCommittedSession() + } + + /** + * Called inside the commit owner when the last interactive login settles. + * Ends a session that `/me` rejected during the login if it is still the + * committed one; a login that installed a new session leaves nothing to end. + */ + private async endSessionRejectedDuringLogin(): Promise { + const rejectedToken = this.sessionRejectedDuringLogin + this.sessionRejectedDuringLogin = null + if (rejectedToken) await this.endSessionRejectedByMe(rejectedToken) + } + + /** + * Asks `/me` whether the committed session is still valid after another call + * answered 401. Only a rejection from `/me` ends the session; any other + * failure gives no verdict and leaves it in place. + */ + private async confirmCommittedSessionWithMe(): Promise { + const token = this.sessionToken + if (!token) return + try { + await this.authClient.getMe(token) + } catch (error) { + if (!AppService.isRejectedStoredSessionError(error)) { + console.warn('[AppService] Session check returned no verdict:', error) + return + } + await this.withNativeAuthEnvironmentCommit(() => this.endSessionRejectedByMe(token)) + } + } + /** Retires the sandbox-ui work of a session that is being cleared or replaced. */ private invalidateSandboxUiSession(): void { this.sandboxUiSessionEpoch += 1 @@ -2230,6 +2310,7 @@ export class AppService { if (this.interactiveLoginAttempts === 0 && !this.sessionToken && !this.me) { await this.commitPendingRuntimeConfigDiscoveryIfSafe().catch(() => undefined) } + if (this.interactiveLoginAttempts === 0) await this.endSessionRejectedDuringLogin() }) } } @@ -2424,6 +2505,7 @@ export class AppService { if (this.interactiveLoginAttempts === 0 && !this.sessionToken && !this.me) { await this.commitPendingRuntimeConfigDiscoveryIfSafe().catch(() => undefined) } + if (this.interactiveLoginAttempts === 0) await this.endSessionRejectedDuringLogin() }) } } @@ -3694,6 +3776,16 @@ export class AppService { // can rebind the stream without requiring a renderer remount. The expired // connection itself is already stopped; logout and renderer teardown remove // these owners through stopAllStreams/stopEntityChangeStreamsForOwner. + // + // The stream ending says the token expired; /me decides whether the session + // really ended. Not awaited: when this runs inside the commit owner (a hop + // release), the check waits in the queue instead of blocking it. + void this.confirmCommittedSessionWithMe().catch(error => { + console.warn( + '[AppService] Could not check the session after the live-update stream expired:', + error + ) + }) } private ensureEntityChangeConnection(): void { @@ -4020,54 +4112,77 @@ export class AppService { async listWorkflows(): Promise { const token = this.requireSessionToken() + // The listing belongs to the signed-in session that started it. If that + // session ends (or a login replaces it) while teams are being listed, no + // list is returned and no team map is published. + const sessionEpoch = this.sandboxUiSessionEpoch + const sessionChanged = () => this.sandboxUiSessionEpoch !== sessionEpoch + const assertSameSession = () => { + if (sessionChanged()) + throw new Error('stale_auth_epoch: the session changed during the workflow listing') + } + // Without a team list, only the session's current team is listed. + const listWithoutTeams = async () => { + assertSameSession() + this.workflowTeamByKey.clear() + const result = await this.authClient.listWorkflows(token) + assertSameSession() + return result + } + let listed: { currentTeamId: string; items: TeamSummary[] } try { - const listed = await this.authClient.listTeams(token) - const teams = Array.isArray(listed.items) ? listed.items : [] - if (!teams.length) { - this.workflowTeamByKey.clear() - return this.authClient.listWorkflows(token) - } - - const orderedTeams = [ - ...teams.filter(team => team.id === listed.currentTeamId), - ...teams.filter(team => team.id !== listed.currentTeamId), - ] - const items: WorkflowRecipeListResult['items'] = [] - const seen = new Set() - const nextWorkflowTeamByKey = new Map() - - for (const team of orderedTeams) { - const teamId = String(team.id || '').trim() - if (!teamId) continue - try { - const result = await this.runWithTeamContext(teamId, teamToken => - this.authClient.listWorkflows(teamToken) - ) - const teamItems = Array.isArray(result.items) ? result.items : [] - for (const item of teamItems) { - const namespace = String(item.metadata?.namespace || '').trim() - const name = String(item.metadata?.name || '').trim() - if (!namespace || !name) continue - const key = this.workflowKey(namespace, name) - if (seen.has(key)) continue - seen.add(key) - nextWorkflowTeamByKey.set(key, teamId) - items.push(item) - } - } catch (error) { - console.warn(`[AppService] Failed loading workflows for team ${teamId}:`, error) + listed = await this.authClient.listTeams(token) + } catch { + return listWithoutTeams() + } + const teams = Array.isArray(listed.items) ? listed.items : [] + if (!teams.length) return listWithoutTeams() + + const orderedTeams = [ + ...teams.filter(team => team.id === listed.currentTeamId), + ...teams.filter(team => team.id !== listed.currentTeamId), + ] + const items: WorkflowRecipeListResult['items'] = [] + const seen = new Set() + const nextWorkflowTeamByKey = new Map() + + for (const team of orderedTeams) { + const teamId = String(team.id || '').trim() + if (!teamId) continue + try { + const result = await this.runWithTeamContext(teamId, teamToken => + this.authClient.listWorkflows(teamToken) + ) + const teamItems = Array.isArray(result.items) ? result.items : [] + for (const item of teamItems) { + const namespace = String(item.metadata?.namespace || '').trim() + const name = String(item.metadata?.name || '').trim() + if (!namespace || !name) continue + const key = this.workflowKey(namespace, name) + if (seen.has(key)) continue + seen.add(key) + nextWorkflowTeamByKey.set(key, teamId) + items.push(item) + } + } catch (error) { + // The session ended while this listing ran, so no list is returned. + if (sessionChanged()) throw error + if (error instanceof ApiError && error.status === 401) { + // A rejected team call leaves the list incomplete. Let /me decide + // whether the session ended; the list is not returned either way. + await this.confirmCommittedSessionWithMe() + throw error } + console.warn(`[AppService] Failed loading workflows for team ${teamId}:`, error) } + } + assertSameSession() - this.workflowTeamByKey.clear() - for (const [key, teamId] of nextWorkflowTeamByKey) { - this.workflowTeamByKey.set(key, teamId) - } - return { items, count: items.length } - } catch { - this.workflowTeamByKey.clear() - return this.authClient.listWorkflows(token) + this.workflowTeamByKey.clear() + for (const [key, teamId] of nextWorkflowTeamByKey) { + this.workflowTeamByKey.set(key, teamId) } + return { items, count: items.length } } async readWorkflow(ns: string, name: string): Promise { @@ -4178,14 +4293,8 @@ export class AppService { // auth-boundary state write fails, do not leave that token/me paired // with a blocked or stale GFS scope. Cleanup remains inside the outer // transient-hop gate, and the original persistence error is preserved. - this.clearAuthenticatedSessionState() - try { - await this.tokenStore.clearSessionToken(getActiveEnvKey(), { - legacyEnvKeys: getActiveLegacyEnvKeys(), - }) - } catch { - // Preserve the original team-transition error when cleanup also fails. - } + // The window is told the session ended so it does not keep showing it. + await this.endCommittedSession() throw error } this.activateGfsAuthScope() diff --git a/desktop-app/src/main.ts b/desktop-app/src/main.ts index b87a2d6f21..dc5a6f2f40 100644 --- a/desktop-app/src/main.ts +++ b/desktop-app/src/main.ts @@ -91,6 +91,11 @@ const appService = new AppService({ }) } }, + onSessionCleared: () => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('auth:sessionCleared') + } + }, }) const gfsResourceDeepLinkQueue = new GfsResourceDeepLinkQueue() const gfsUserNavigationGeneration = new GfsUserNavigationGeneration() diff --git a/desktop-app/src/preload.ts b/desktop-app/src/preload.ts index a04581ba7c..9c613f7305 100644 --- a/desktop-app/src/preload.ts +++ b/desktop-app/src/preload.ts @@ -197,6 +197,11 @@ const clerum = Object.freeze({ ipcRenderer.on('auth:externalLogout', listener) return () => ipcRenderer.off('auth:externalLogout', listener) }, + onSessionCleared: (callback: () => void) => { + const listener = () => callback() + ipcRenderer.on('auth:sessionCleared', listener) + return () => ipcRenderer.off('auth:sessionCleared', listener) + }, logout: () => ipcRenderer.invoke('auth:logout'), }, team: { diff --git a/desktop-app/src/renderer.d.ts b/desktop-app/src/renderer.d.ts index c040eb51fd..90684574bd 100644 --- a/desktop-app/src/renderer.d.ts +++ b/desktop-app/src/renderer.d.ts @@ -114,6 +114,7 @@ declare global { getDesktopAppInfo: () => Promise openDesktopRelease: (releaseUrl: string) => Promise<{ opened: true }> onExternalLogout: (callback: () => void) => () => void + onSessionCleared: (callback: () => void) => () => void logout: () => Promise<{ ok: true; sessionGeneration: number }> } team: { diff --git a/desktop-app/ui/src/hooks/__tests__/useAppController.sessionCleared.test.tsx b/desktop-app/ui/src/hooks/__tests__/useAppController.sessionCleared.test.tsx new file mode 100644 index 0000000000..3429e38210 --- /dev/null +++ b/desktop-app/ui/src/hooks/__tests__/useAppController.sessionCleared.test.tsx @@ -0,0 +1,50 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, waitFor } from '@testing-library/react' +import { + installAppControllerClerum, + renderAppController, +} from '../domain/__tests__/__fixtures__/appControllerHarness' +import { uninstallMockClerum } from '../domain/__tests__/__fixtures__/mockClerum' +import { desktopQueryKeys } from '../domain/queryKeys' + +afterEach(() => { + cleanup() + uninstallMockClerum() +}) + +describe('useAppController session-cleared notice', () => { + it('runs the signed-out transition when main ends the session', async () => { + const { clerum, handle } = installAppControllerClerum() + let notifySessionCleared: (() => void) | null = null + const auth = (clerum as unknown as { auth: { onSessionCleared: ReturnType } }) + .auth + auth.onSessionCleared = vi.fn((listener: () => void) => { + notifySessionCleared = listener + return () => undefined + }) + + const app = renderAppController() + await waitFor(() => expect(app.result.current.isAuthenticated).toBe(true)) + const gfsRootKey = [...desktopQueryKeys.gfsRoot, 'session-cleared-probe'] + app.queryClient.setQueryData(gfsRootKey, { held: true }) + const sessionStateCallsBefore = handle.getSessionState.mock.calls.length + if (!notifySessionCleared) throw new Error('the controller did not subscribe to the notice') + + handle.getSessionState.mockImplementation(async () => ({ + authenticated: false, + me: null, + sessionGeneration: 2, + })) + act(() => { + notifySessionCleared?.() + }) + + await waitFor(() => expect(app.result.current.isAuthenticated).toBe(false)) + expect(handle.getSessionState.mock.calls.length).toBeGreaterThan(sessionStateCallsBefore) + expect(app.result.current.me).toBeNull() + // The signed-out branch of loadSession drops every GFS query. + expect(app.queryClient.getQueryData(gfsRootKey)).toBeUndefined() + app.unmount() + }) +}) diff --git a/desktop-app/ui/src/hooks/domain/__tests__/__fixtures__/appControllerHarness.tsx b/desktop-app/ui/src/hooks/domain/__tests__/__fixtures__/appControllerHarness.tsx index 561b165938..4bd3798ba4 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/__fixtures__/appControllerHarness.tsx +++ b/desktop-app/ui/src/hooks/domain/__tests__/__fixtures__/appControllerHarness.tsx @@ -334,6 +334,7 @@ export function extendMockClerumForAppController( onDesktopSetupToken: vi.fn(() => () => undefined), onDesktopEnvironmentSetup, onExternalLogout: vi.fn(() => () => undefined), + onSessionCleared: vi.fn(() => () => undefined), }, team: { list: vi.fn(async () => teamDirectoryPayload), diff --git a/desktop-app/ui/src/hooks/domain/__tests__/useAuthController.desktopEnvironment.test.tsx b/desktop-app/ui/src/hooks/domain/__tests__/useAuthController.desktopEnvironment.test.tsx index 7126486a86..0bd39fb399 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/useAuthController.desktopEnvironment.test.tsx +++ b/desktop-app/ui/src/hooks/domain/__tests__/useAuthController.desktopEnvironment.test.tsx @@ -19,6 +19,7 @@ const mocks = vi.hoisted(() => ({ onDesktopEnvironmentSetup: vi.fn(), onDesktopSetupToken: vi.fn(), onExternalLogout: vi.fn(), + onSessionCleared: vi.fn(), onSessionEnded: vi.fn(), saveRuntimeConfig: vi.fn(), selectRuntimeConfig: vi.fn(), @@ -207,6 +208,7 @@ beforeEach(async () => { return () => undefined }) mocks.onExternalLogout.mockReturnValue(() => undefined) + mocks.onSessionCleared.mockReturnValue(() => undefined) mocks.onDesktopEnvironmentSetup.mockImplementation( (listener: (payload: DesktopEnvironmentSetupPayload) => void | Promise) => { desktopEnvironmentSetupListener = listener @@ -226,6 +228,7 @@ beforeEach(async () => { onDesktopSetupToken: mocks.onDesktopSetupToken, completeDesktopSetup: mocks.completeDesktopSetup, onExternalLogout: mocks.onExternalLogout, + onSessionCleared: mocks.onSessionCleared, }, } }) diff --git a/desktop-app/ui/src/hooks/domain/__tests__/useAuthController.sessionCleared.test.tsx b/desktop-app/ui/src/hooks/domain/__tests__/useAuthController.sessionCleared.test.tsx new file mode 100644 index 0000000000..587516e3ec --- /dev/null +++ b/desktop-app/ui/src/hooks/domain/__tests__/useAuthController.sessionCleared.test.tsx @@ -0,0 +1,90 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { useAuthController } from '../useAuthController' + +type Listener = () => void + +const auth = { + getRuntimeConfigState: vi.fn(), + getSessionGeneration: vi.fn(), + onDesktopSetupToken: vi.fn(), + onDesktopEnvironmentSetup: vi.fn(), + onExternalLogout: vi.fn(), + onSessionCleared: vi.fn(), +} + +let sessionClearedListener: Listener | null = null +const unsubscribeSessionCleared = vi.fn() + +beforeEach(() => { + sessionClearedListener = null + unsubscribeSessionCleared.mockReset() + auth.getRuntimeConfigState.mockResolvedValue({ options: [], selectedOptionId: null }) + auth.getSessionGeneration.mockResolvedValue(1) + auth.onDesktopSetupToken.mockReturnValue(() => undefined) + auth.onDesktopEnvironmentSetup.mockReturnValue(() => undefined) + auth.onExternalLogout.mockReturnValue(() => undefined) + auth.onSessionCleared.mockReset() + auth.onSessionCleared.mockImplementation((listener: Listener) => { + sessionClearedListener = listener + return unsubscribeSessionCleared + }) + window.clerum = { ...window.clerum, auth: { ...window.clerum?.auth, ...auth } } as never +}) + +afterEach(() => { + cleanup() +}) + +function mountAuthController() { + const setStatus = vi.fn() + const onSessionNeedsLoad = vi.fn().mockResolvedValue(undefined) + const hook = renderHook(() => + useAuthController({ + setStatus, + onSessionNeedsLoad, + onSessionEnded: vi.fn(), + logoutForEnvironmentMismatch: vi.fn().mockResolvedValue(null), + }) + ) + return { hook, setStatus, onSessionNeedsLoad } +} + +describe('useAuthController session-cleared notice', () => { + it('reloads the session when main reports that it ended the session', async () => { + const { hook, setStatus, onSessionNeedsLoad } = mountAuthController() + expect(auth.onSessionCleared).toHaveBeenCalledTimes(1) + // The boot effect loads the session once on mount. + await waitFor(() => expect(onSessionNeedsLoad).toHaveBeenCalledTimes(1)) + if (!sessionClearedListener) throw new Error('the hook did not subscribe to the notice') + + act(() => { + sessionClearedListener?.() + }) + + await waitFor(() => expect(onSessionNeedsLoad).toHaveBeenCalledTimes(2)) + expect(setStatus).toHaveBeenCalledWith('Your session ended. Sign in again.', 'info') + + hook.unmount() + expect(unsubscribeSessionCleared).toHaveBeenCalledTimes(1) + }) + + it('reports a failed reload after the session ended', async () => { + const { setStatus, onSessionNeedsLoad } = mountAuthController() + await waitFor(() => expect(onSessionNeedsLoad).toHaveBeenCalledTimes(1)) + onSessionNeedsLoad.mockRejectedValueOnce(new Error('restore unavailable')) + + act(() => { + sessionClearedListener?.() + }) + + await waitFor(() => + expect(setStatus).toHaveBeenCalledWith( + 'Your session ended, but the desktop could not refresh it: restore unavailable', + 'warn' + ) + ) + expect(onSessionNeedsLoad).toHaveBeenCalledTimes(2) + }) +}) diff --git a/desktop-app/ui/src/hooks/domain/useAuthController.ts b/desktop-app/ui/src/hooks/domain/useAuthController.ts index 1b8ad5f4a9..5c4132f8dc 100644 --- a/desktop-app/ui/src/hooks/domain/useAuthController.ts +++ b/desktop-app/ui/src/hooks/domain/useAuthController.ts @@ -227,6 +227,22 @@ export function useAuthController({ }) }, [onSessionEnded, setStatus]) + // Main ended the committed session (control-api rejected it). Loading the + // session again runs the same signed-out transition as a failed restore. + useEffect(() => { + return window.clerum.auth.onSessionCleared(() => { + setStatus('Your session ended. Sign in again.', 'info') + onSessionNeedsLoad().catch(error => { + setStatus( + `Your session ended, but the desktop could not refresh it: ${ + error instanceof Error ? error.message : String(error) + }`, + 'warn' + ) + }) + }) + }, [onSessionNeedsLoad, setStatus]) + const handlePasswordLogin = async () => { try { setBusy(true) From 75e24828b138d1f761e41f39121c2e418753506f Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 4 Nov 2026 18:31:29 +0100 Subject: [PATCH 458/528] test(profile-ui): answer a repeated logout with invalid_session in the fixture K4C-03, follow-up of K4c in the PR #334 r4.2 fix plan. The Profile UI recovery Playwright fixture answered a logout of an unknown or revoked session with a bare 401 {error:'Unauthorized'}. The real Control API producer answers it with the public invalid_session envelope, and since K4c External REST treats only that envelope as an already-revoked logout. A journey that logged out twice against the fixture would have seen a forwarded error instead of the real outcome. The fixture now returns the producer's envelope, and the real-PG parity suite compares the repeated logout of both implementations. Returning the bare 401 again fails that parity check. Real-PG lane: 124 files, 1034 tests. Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- ...Fixture.parity.realPostgres.integration.test.ts | 6 ++++++ tests/e2e/playwright/package-lock.json | 4 ++-- tests/e2e/playwright/package.json | 2 +- .../playwright/profile-ui-recovery-control-api.mjs | 14 +++++++++++++- 6 files changed, 25 insertions(+), 7 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 1396ef8ac2..c3425baee5 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.896", + "version": "0.1.897", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.896", + "version": "0.1.897", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index fe2b77d5e7..adda98c971 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.896", + "version": "0.1.897", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/profileUiRecoveryFixture.parity.realPostgres.integration.test.ts b/control-api/test/profileUiRecoveryFixture.parity.realPostgres.integration.test.ts index b6078989cd..fd9e51cb20 100644 --- a/control-api/test/profileUiRecoveryFixture.parity.realPostgres.integration.test.ts +++ b/control-api/test/profileUiRecoveryFixture.parity.realPostgres.integration.test.ts @@ -580,6 +580,12 @@ realPg('Profile UI recovery lane fixture parity on PostgreSQL 16', () => { }) expect(realRepeatedLogout.status).toBe(401) expect(realRepeatedLogout.body).toMatchObject({ error: { code: 'invalid_session' } }) + const fixtureRepeatedLogout = await requestFixture('POST', '/external/auth/session/logout', { + sessionToken: fixtureToken, + }) + expect(fixtureRepeatedLogout.status).toBe(realRepeatedLogout.status) + expect(bodyShape(fixtureRepeatedLogout.body)).toEqual(bodyShape(realRepeatedLogout.body)) + expect(fixtureRepeatedLogout.body).toMatchObject({ error: { code: 'invalid_session' } }) await expectParity( 'GET', diff --git a/tests/e2e/playwright/package-lock.json b/tests/e2e/playwright/package-lock.json index e569fa7ce6..3b8d2cadb6 100644 --- a/tests/e2e/playwright/package-lock.json +++ b/tests/e2e/playwright/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-playwright-tests", - "version": "0.1.91", + "version": "0.1.92", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-playwright-tests", - "version": "0.1.91", + "version": "0.1.92", "devDependencies": { "@playwright/test": "^1.50.0", "@types/node": "^22.0.0", diff --git a/tests/e2e/playwright/package.json b/tests/e2e/playwright/package.json index 81276b51a8..f6095ab83b 100644 --- a/tests/e2e/playwright/package.json +++ b/tests/e2e/playwright/package.json @@ -1,6 +1,6 @@ { "name": "clerum-playwright-tests", - "version": "0.1.91", + "version": "0.1.92", "private": true, "description": "Playwright E2E tests for Control UI and Desktop App", "scripts": { diff --git a/tests/e2e/playwright/profile-ui-recovery-control-api.mjs b/tests/e2e/playwright/profile-ui-recovery-control-api.mjs index e12b3d4e4a..845ca8d7d3 100644 --- a/tests/e2e/playwright/profile-ui-recovery-control-api.mjs +++ b/tests/e2e/playwright/profile-ui-recovery-control-api.mjs @@ -509,7 +509,19 @@ export async function createRecoveryControlApi({ metrics.logoutRequests.push({ hasToken: Boolean(token) }) const session = sessions.get(token) if (!session || session.revoked) { - return { status: 401, body: { error: 'Unauthorized' } } + // Same public envelope as the real producer (sendPublicApiError): External REST + // treats only invalid_session as an already-revoked logout. + return { + status: 401, + body: { + error: { + code: 'invalid_session', + message: 'The session is not valid.', + correlationId: `fixture-logout-${metrics.logoutRequests.length}`, + retryable: false, + }, + }, + } } session.revoked = true return { status: 200, body: { revoked: true } } From f7a1a22275a7dbed002dc49ec8f73340e38ff774 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 4 Nov 2026 19:37:42 +0100 Subject: [PATCH 459/528] fix(profile-ui): confirm logout only for a presented session K4e, follow-up of K4c in the PR #334 r4.2 fix plan. The edge logout answered 200 {ok:true} both when it revoked the session it received and when the request carried no session at all. A failed logout already expires the Profile cookie, so a retry arrived without a cookie and Profile took that 200 as a confirmed revocation, releasing its fence although nothing was revoked. The edge now reports sessionPresented in the 200 body: whether the request carried a session token. Profile confirms revocation only for sessionPresented === true and treats a missing field, an empty body or any other value as unconfirmed. When the retry reached no session, the retry banner gives way to the existing "could not confirm" notice. Only one sign-out request is in flight at a time: Retry is disabled while it waits, and a late answer no longer replaces the current state, including after a new login. Known limits, left for the Profile logout work in PR4: a retry after a login in another tab revokes that new session; a late logout answer can expire a newer cookie; Profile with an edge that lacks the field and blocked storage keeps offering Retry. Tests pin the field in every edge branch, a two-request logout through a cookie jar, the strict comparison in Profile, the retry outcomes and the single in-flight request. Each negative assertion has a liveness witness; the mutants of the edge field, the Profile comparison, the banner rule and the in-flight guard all fail. Gates on Node 24: External REST 583 tests and build, Profile UI 194 tests and next build. Co-Authored-By: Claude Opus 5.5 --- external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- external-rest-api/src/releaseManifest.ts | 2 +- external-rest-api/src/routes/auth.ts | 6 +- external-rest-api/test/routes.auth.test.ts | 56 ++++++- .../app/components/AuthContext/index.tsx | 43 ++++- .../app/components/AuthContext/types.ts | 1 + .../app/components/LoginPanel/index.tsx | 4 +- .../__tests__/AuthContext.logout.test.tsx | 152 +++++++++++++++++- .../components/__tests__/AuthGate.test.tsx | 1 + .../LoginPanel.signOutIncomplete.test.tsx | 18 ++- .../components/__tests__/api.logout.test.ts | 106 ++++++++++++ profile-ui/lib/api.ts | 13 +- profile-ui/package-lock.json | 4 +- profile-ui/package.json | 2 +- 15 files changed, 383 insertions(+), 31 deletions(-) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 161d1a5b6c..d7313ad900 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.168", + "version": "0.1.169", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.168", + "version": "0.1.169", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index 29fe9f6c0e..0f34c4c5eb 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.168", + "version": "0.1.169", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index 3d6a9b027b..c7f99a968b 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.12.0', - externalRestApiVersion: '0.1.168', + externalRestApiVersion: '0.1.169', rpcProxyVersion: '0.1.138', desktopVersion: '0.12.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/src/routes/auth.ts b/external-rest-api/src/routes/auth.ts index 683af04e46..3abb0b9f5c 100644 --- a/external-rest-api/src/routes/auth.ts +++ b/external-rest-api/src/routes/auth.ts @@ -196,11 +196,13 @@ export function createAuthRouter(): Router { const token = bearer || readCookie(req, PROFILE_SESSION_COOKIE) if (token) await logoutUserSession(token) clearProfileSessionCookie(req, res) - res.status(200).json({ ok: true }) + // sessionPresented says whether this request carried a session, so a client can + // tell a revocation from a request that arrived after its cookie had expired. + res.status(200).json({ ok: true, sessionPresented: Boolean(token) }) } catch (error) { if (isInvalidSessionLogoutDenial(error)) { clearProfileSessionCookie(req, res) - res.status(200).json({ ok: true }) + res.status(200).json({ ok: true, sessionPresented: true }) return } clearProfileSessionCookie(req, res) diff --git a/external-rest-api/test/routes.auth.test.ts b/external-rest-api/test/routes.auth.test.ts index f597b5fe65..d84852c506 100644 --- a/external-rest-api/test/routes.auth.test.ts +++ b/external-rest-api/test/routes.auth.test.ts @@ -322,7 +322,8 @@ describe('routes/auth password-login', () => { .set('x-forwarded-proto', 'https') .expect(200) - expect(response.body).toEqual({ ok: true }) + expect(response.body).toEqual({ ok: true, sessionPresented: true }) + expect(authServiceMock.logoutUserSession).toHaveBeenCalledWith('opaque-session') expect(String(response.headers['set-cookie'])).toContain( 'Expires=Thu, 01 Jan 1970 00:00:00 GMT' ) @@ -347,7 +348,7 @@ describe('routes/auth password-login', () => { .set('x-forwarded-proto', 'https') .expect(200) - expect(response.body).toEqual({ ok: true }) + expect(response.body).toEqual({ ok: true, sessionPresented: true }) expect(String(response.headers['set-cookie'])).toContain( 'Expires=Thu, 01 Jan 1970 00:00:00 GMT' ) @@ -372,10 +373,59 @@ describe('routes/auth password-login', () => { expect(authServiceMock.logoutUserSession).toHaveBeenCalledWith('opaque-session') }) + it.each([ + [401, { error: 'Unauthorized' }], + [429, { error: { code: 'rate_limited' } }], + [503, { error: { code: 'authority_unavailable' } }], + ] as const)( + 'reports no presented session when a logout retry follows a failed logout (%s)', + async (status, body) => { + authServiceMock.loginWithPassword.mockResolvedValueOnce({ + token: 'composed-session-token', + me: { + id: 'user-1', + email: 'user@example.invalid', + name: null, + picture: null, + teamId: null, + teamName: null, + role: 'member', + }, + }) + authServiceMock.logoutUserSession.mockRejectedValueOnce( + new ControlApiError('private upstream detail', status, body) + ) + // No x-forwarded-proto: a Secure cookie would not travel over plain http in the agent's jar. + const agent = request.agent(buildApp()) + + const login = await agent + .post('/api/v1/auth/password-login') + .set('origin', 'http://localhost:3001') + .send({ email: 'user@example.invalid', password: 'synthetic-correct-password' }) + .expect(200) + expect(String(login.headers['set-cookie'])).toContain( + 'profile_session=composed-session-token' + ) + + const failed = await agent.post('/api/v1/auth/logout').expect(status) + // Send the retry before checking the failed answer's headers, so a kept cookie shows up + // as a retry that still presents a session rather than only as a missing header. + const retried = await agent.post('/api/v1/auth/logout').expect(200) + + // The first logout carried the login cookie; the retry reached no session at all. + expect(authServiceMock.logoutUserSession.mock.calls).toEqual([['composed-session-token']]) + expect(retried.body).toEqual({ ok: true, sessionPresented: false }) + expect(failed.body.ok).toBeUndefined() + expect(String(failed.headers['set-cookie'])).toContain( + 'Expires=Thu, 01 Jan 1970 00:00:00 GMT' + ) + } + ) + it('does not call remote revocation or set a cookie when no session is present', async () => { const response = await request(buildApp()).post('/api/v1/auth/logout').expect(200) - expect(response.body).toEqual({ ok: true }) + expect(response.body).toEqual({ ok: true, sessionPresented: false }) expect(authServiceMock.logoutUserSession).not.toHaveBeenCalled() expect(String(response.headers['set-cookie'])).toContain( 'Expires=Thu, 01 Jan 1970 00:00:00 GMT' diff --git a/profile-ui/app/components/AuthContext/index.tsx b/profile-ui/app/components/AuthContext/index.tsx index c684b14c31..443ee2d458 100644 --- a/profile-ui/app/components/AuthContext/index.tsx +++ b/profile-ui/app/components/AuthContext/index.tsx @@ -61,6 +61,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { me: null, }) const [signOutIncomplete, setSignOutIncomplete] = useState(false) + const [signOutPending, setSignOutPending] = useState(false) + // The sign-out request whose answer may still change this screen. A newer sign-out or a + // login replaces it, so an answer that arrives late for an older request is ignored. + const pendingRevocationRef = useRef(null) const checkAuth = useCallback(async (): Promise => { const sequence = ++authCheckSequenceRef.current @@ -97,6 +101,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const login = useCallback(async (email: string, password: string) => { authCheckSequenceRef.current += 1 + pendingRevocationRef.current = null + setSignOutPending(false) try { const result = await loginWithPassword(email, password) resetProfileAccessCache() @@ -119,26 +125,46 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }, []) const requestRevocation = useCallback(() => { - void logoutProfileUI() - .then(({ revocationConfirmed, fencePersisted }) => { + const attempt = {} + pendingRevocationRef.current = attempt + setSignOutPending(true) + const settle = () => { + if (pendingRevocationRef.current !== attempt) return false + pendingRevocationRef.current = null + setSignOutPending(false) + return true + } + void logoutProfileUI().then( + ({ revocationConfirmed, fencePersisted, sessionAbsent }) => { + if (!settle()) return if (revocationConfirmed) { setSignOutIncomplete(false) return } - if (!fencePersisted) { + if (!fencePersisted && !sessionAbsent) { // Browser storage could not record the logout fence, so the local sign-out // outlives only this page. The login screen says so and offers a retry. + // When the edge reports that no session reached it, this browser holds no + // Profile cookie any more, so the unconfirmed-revocation notice below applies. setSignOutIncomplete(true) return } setSignOutIncomplete(false) showToast(UNCONFIRMED_REVOCATION_MESSAGE, { tone: 'error', durationMs: 7000 }) - }) - .catch(() => { + }, + () => { + if (!settle()) return showToast(UNCONFIRMED_REVOCATION_MESSAGE, { tone: 'error', durationMs: 7000 }) - }) + } + ) }, [showToast]) + const retrySignOut = useCallback(() => { + // One sign-out request at a time: two requests could answer in either order. + if (pendingRevocationRef.current) return + requestRevocation() + }, [requestRevocation]) + const logout = useCallback(() => { authCheckSequenceRef.current += 1 sessionExpiredHandledRef.current = false @@ -156,9 +182,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { logout, checkAuth, signOutIncomplete, - retrySignOut: requestRevocation, + signOutPending, + retrySignOut, }), - [authState, checkAuth, login, logout, signOutIncomplete, requestRevocation] + [authState, checkAuth, login, logout, signOutIncomplete, signOutPending, retrySignOut] ) return {children} diff --git a/profile-ui/app/components/AuthContext/types.ts b/profile-ui/app/components/AuthContext/types.ts index 4099e93f64..0a293249c9 100644 --- a/profile-ui/app/components/AuthContext/types.ts +++ b/profile-ui/app/components/AuthContext/types.ts @@ -19,5 +19,6 @@ export type AuthContextValue = { logout: () => void checkAuth: () => Promise signOutIncomplete: boolean + signOutPending: boolean retrySignOut: () => void } diff --git a/profile-ui/app/components/LoginPanel/index.tsx b/profile-ui/app/components/LoginPanel/index.tsx index 7b9973cace..28097e19cd 100644 --- a/profile-ui/app/components/LoginPanel/index.tsx +++ b/profile-ui/app/components/LoginPanel/index.tsx @@ -13,7 +13,7 @@ import type { LoginPanelProps } from './types' export function LoginPanel({ description = 'Sign in with your Evenfire account credentials.', }: LoginPanelProps) { - const { login, signOutIncomplete, retrySignOut } = useAuth() + const { login, signOutIncomplete, signOutPending, retrySignOut } = useAuth() const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [error, setError] = useState('') @@ -104,7 +104,7 @@ export function LoginPanel({ save the sign-out, so this session may come back after a reload or stay active in other Profile tabs.

- diff --git a/profile-ui/app/components/__tests__/AuthContext.logout.test.tsx b/profile-ui/app/components/__tests__/AuthContext.logout.test.tsx index 66d2e2c915..6dfa8dcb6b 100644 --- a/profile-ui/app/components/__tests__/AuthContext.logout.test.tsx +++ b/profile-ui/app/components/__tests__/AuthContext.logout.test.tsx @@ -24,11 +24,38 @@ vi.mock('@lib/api', () => ({ vi.mock('@lib/profileAccess', () => ({ resetProfileAccessCache: mocks.resetProfileAccessCache })) +type LogoutResult = { + revocationConfirmed: boolean + fencePersisted: boolean + sessionAbsent: boolean +} + +const unsavedFailure: LogoutResult = { + revocationConfirmed: false, + fencePersisted: false, + sessionAbsent: false, +} + +const noSessionReached: LogoutResult = { + revocationConfirmed: false, + fencePersisted: false, + sessionAbsent: true, +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise(next => { + resolve = next + }) + return { promise, resolve } +} + function AuthProbe() { - const { authState, login, logout, signOutIncomplete, retrySignOut } = useAuth() + const { authState, login, logout, signOutIncomplete, signOutPending, retrySignOut } = useAuth() return (
{authState.isLoggedIn ? 'signed-in' : 'signed-out'} + {signOutPending ? 'sign-out-pending' : 'sign-out-idle'} @@ -57,6 +84,7 @@ describe('Profile logout consumer', () => { mocks.logoutProfileUI.mockResolvedValueOnce({ revocationConfirmed: false, fencePersisted: true, + sessionAbsent: false, }) render( @@ -98,10 +126,7 @@ describe('Profile logout consumer', () => { ) }) - it('keeps the local sign-out and offers a retry when the fence cannot be saved', async () => { - mocks.logoutProfileUI - .mockResolvedValueOnce({ revocationConfirmed: false, fencePersisted: false }) - .mockResolvedValueOnce({ revocationConfirmed: true, fencePersisted: false }) + function renderSignedIn() { render( @@ -109,7 +134,9 @@ describe('Profile logout consumer', () => { ) + } + async function logOutWithUnsavedFence() { await screen.findByText('signed-in') fireEvent.click(screen.getByRole('button', { name: 'Log out' })) @@ -120,16 +147,128 @@ describe('Profile logout consumer', () => { expect(screen.queryByText(/signed out here/i)).toBeNull() fireEvent.click(screen.getByRole('button', { name: 'Retry sign-out' })) - expect(mocks.logoutProfileUI).toHaveBeenCalledTimes(2) + } + + it('keeps the local sign-out and confirms a retry that revoked the session', async () => { + mocks.logoutProfileUI + .mockResolvedValueOnce({ + revocationConfirmed: false, + fencePersisted: false, + sessionAbsent: false, + }) + .mockResolvedValueOnce({ + revocationConfirmed: true, + fencePersisted: false, + sessionAbsent: false, + }) + renderSignedIn() + + await logOutWithUnsavedFence() + + await waitFor(() => expect(screen.queryByRole('alert')).toBeNull()) + expect(screen.getByText('signed-out')).toBeInTheDocument() + expect(screen.queryByRole('status')).toBeNull() + }) + + it('reports an unconfirmed revocation when the retry reached no session', async () => { + mocks.logoutProfileUI + .mockResolvedValueOnce({ + revocationConfirmed: false, + fencePersisted: false, + sessionAbsent: false, + }) + .mockResolvedValueOnce({ + revocationConfirmed: false, + fencePersisted: false, + sessionAbsent: true, + }) + renderSignedIn() + + await logOutWithUnsavedFence() + await waitFor(() => expect(screen.queryByRole('alert')).toBeNull()) + await waitFor(() => + expect(screen.getByRole('status')).toHaveTextContent( + /server could not confirm that your session was revoked/i + ) + ) expect(screen.getByText('signed-out')).toBeInTheDocument() }) + it('keeps offering the retry when the retry fails while the cookie may remain', async () => { + const retry = deferred() + mocks.logoutProfileUI.mockResolvedValueOnce(unsavedFailure).mockReturnValueOnce(retry.promise) + renderSignedIn() + + await logOutWithUnsavedFence() + expect(screen.getByText('sign-out-pending')).toBeInTheDocument() + + await act(async () => retry.resolve(unsavedFailure)) + + // Liveness: the retry's answer was processed, not only requested. + await screen.findByText('sign-out-idle') + expect(screen.getByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('status')).toBeNull() + expect(screen.getByText('signed-out')).toBeInTheDocument() + }) + + it('sends one retry at a time, so overlapping answers cannot show the banner and the notice together', async () => { + const retry = deferred() + const secondRetry = deferred() + mocks.logoutProfileUI + .mockResolvedValueOnce(unsavedFailure) + .mockReturnValueOnce(retry.promise) + .mockReturnValueOnce(secondRetry.promise) + renderSignedIn() + + await logOutWithUnsavedFence() + fireEvent.click(screen.getByRole('button', { name: 'Retry sign-out' })) + + // The answer without a session arrives first; a failure for a second request would follow. + await act(async () => retry.resolve(noSessionReached)) + await act(async () => secondRetry.resolve(unsavedFailure)) + + // Liveness: the first retry's answer reached the screen. + await waitFor(() => + expect(screen.getByRole('status')).toHaveTextContent( + /server could not confirm that your session was revoked/i + ) + ) + expect(screen.queryByRole('alert')).toBeNull() + expect(mocks.logoutProfileUI).toHaveBeenCalledTimes(2) + }) + + it('ignores a sign-out answer that arrives after a new login', async () => { + const retry = deferred() + mocks.logoutProfileUI.mockResolvedValueOnce(unsavedFailure).mockReturnValueOnce(retry.promise) + mocks.loginWithPassword.mockResolvedValueOnce({ + token: 'test-token', + me: { id: 'user-1', email: 'user@example.invalid' }, + }) + renderSignedIn() + + await logOutWithUnsavedFence() + fireEvent.click(screen.getByRole('button', { name: 'Log in' })) + await screen.findByText('signed-in') + expect(mocks.loginWithPassword).toHaveBeenCalledOnce() + + await act(async () => retry.resolve(noSessionReached)) + + // Liveness: the late answer was delivered to the provider before the checks below. + await expect(mocks.logoutProfileUI.mock.results[1]?.value).resolves.toEqual(noSessionReached) + await act(async () => {}) + expect(screen.getByText('signed-in')).toBeInTheDocument() + expect(screen.queryByText(/signed out here/i)).toBeNull() + expect(screen.queryByRole('alert')).toBeNull() + expect(screen.getByText('sign-out-idle')).toBeInTheDocument() + }) + it('a new login clears the incomplete sign-out', async () => { mocks.logoutProfileUI.mockResolvedValueOnce({ revocationConfirmed: false, fencePersisted: false, + sessionAbsent: false, }) mocks.loginWithPassword.mockResolvedValueOnce({ token: 'test-token', @@ -165,6 +304,7 @@ describe('Profile logout consumer', () => { mocks.logoutProfileUI.mockResolvedValueOnce({ revocationConfirmed: true, fencePersisted: true, + sessionAbsent: false, }) render( diff --git a/profile-ui/app/components/__tests__/AuthGate.test.tsx b/profile-ui/app/components/__tests__/AuthGate.test.tsx index c6b6753fbf..52a049d6b7 100644 --- a/profile-ui/app/components/__tests__/AuthGate.test.tsx +++ b/profile-ui/app/components/__tests__/AuthGate.test.tsx @@ -16,6 +16,7 @@ describe('AuthGate', () => { login: vi.fn(), logout: vi.fn(), signOutIncomplete: false, + signOutPending: false, retrySignOut: vi.fn(), }) const view = render( diff --git a/profile-ui/app/components/__tests__/LoginPanel.signOutIncomplete.test.tsx b/profile-ui/app/components/__tests__/LoginPanel.signOutIncomplete.test.tsx index 6b8a4619b7..9057bb32f3 100644 --- a/profile-ui/app/components/__tests__/LoginPanel.signOutIncomplete.test.tsx +++ b/profile-ui/app/components/__tests__/LoginPanel.signOutIncomplete.test.tsx @@ -4,13 +4,14 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { LoginPanel } from '@components/LoginPanel' const mocks = vi.hoisted(() => ({ - authState: { signOutIncomplete: false, retrySignOut: vi.fn() }, + authState: { signOutIncomplete: false, signOutPending: false, retrySignOut: vi.fn() }, })) vi.mock('@components/AuthContext', () => ({ useAuth: () => ({ login: vi.fn(), signOutIncomplete: mocks.authState.signOutIncomplete, + signOutPending: mocks.authState.signOutPending, retrySignOut: mocks.authState.retrySignOut, }), })) @@ -35,6 +36,7 @@ vi.mock('next/link', () => ({ beforeEach(() => { mocks.authState.signOutIncomplete = false + mocks.authState.signOutPending = false mocks.authState.retrySignOut.mockReset() }) @@ -55,6 +57,20 @@ describe('LoginPanel incomplete sign-out banner', () => { expect(mocks.authState.retrySignOut).toHaveBeenCalledOnce() }) + it('disables the retry while a sign-out request is pending', () => { + mocks.authState.signOutIncomplete = true + mocks.authState.signOutPending = true + + render() + + // Liveness: the banner and its retry button are on screen. + expect(screen.getByRole('alert')).toHaveTextContent(/sign-out is not complete/i) + const retry = screen.getByRole('button', { name: 'Retry sign-out' }) + expect(retry).toBeDisabled() + fireEvent.click(retry) + expect(mocks.authState.retrySignOut).not.toHaveBeenCalled() + }) + it('renders the ordinary login form when the sign-out completed', () => { render() diff --git a/profile-ui/app/components/__tests__/api.logout.test.ts b/profile-ui/app/components/__tests__/api.logout.test.ts index 6362b798b3..3abce10b5c 100644 --- a/profile-ui/app/components/__tests__/api.logout.test.ts +++ b/profile-ui/app/components/__tests__/api.logout.test.ts @@ -31,6 +31,7 @@ describe('Profile logout client boundary', () => { await expect(logoutProfileUI()).resolves.toEqual({ revocationConfirmed: false, fencePersisted: true, + sessionAbsent: false, }) expect(window.localStorage.getItem('profile_logout_unconfirmed')).toBe('true') @@ -61,6 +62,7 @@ describe('Profile logout client boundary', () => { await expect(logoutProfileUI()).resolves.toEqual({ revocationConfirmed: false, fencePersisted: false, + sessionAbsent: false, }) // Liveness: the API boundary wrote through the stubbed browser storage. @@ -77,6 +79,109 @@ describe('Profile logout client boundary', () => { } ) + const jsonResponse = (body: string) => + new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }) + + it('confirms revocation only when the edge answered for a presented session', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce(jsonResponse('{"ok":true,"sessionPresented":true}')) + .mockResolvedValueOnce(jsonResponse('{"id":"user-1"}')) + + await expect(logoutProfileUI()).resolves.toEqual({ + revocationConfirmed: true, + fencePersisted: true, + sessionAbsent: false, + }) + + expect(window.localStorage.getItem('profile_logout_unconfirmed')).toBeNull() + // Liveness: the released fence lets the next session request reach the network. + await expect(apiGet('/api/v1/me')).resolves.toEqual({ id: 'user-1' }) + expect(fetch).toHaveBeenCalledTimes(2) + }) + + it.each([ + [ + 'no presented session', + '{"ok":true,"sessionPresented":false}', + { revocationConfirmed: false, fencePersisted: true, sessionAbsent: true }, + ], + [ + 'an edge without the field', + '{"ok":true}', + { revocationConfirmed: false, fencePersisted: true, sessionAbsent: false }, + ], + [ + 'an empty body', + '', + { revocationConfirmed: false, fencePersisted: true, sessionAbsent: false }, + ], + [ + 'the field as the string "true"', + '{"ok":true,"sessionPresented":"true"}', + { revocationConfirmed: false, fencePersisted: true, sessionAbsent: false }, + ], + [ + 'the field as the number 1', + '{"ok":true,"sessionPresented":1}', + { revocationConfirmed: false, fencePersisted: true, sessionAbsent: false }, + ], + ])('does not confirm revocation for a 200 with %s', async (_caseName, body, expected) => { + vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(body)) + + await expect(logoutProfileUI()).resolves.toEqual(expected) + + expect(window.localStorage.getItem('profile_logout_unconfirmed')).toBe('true') + // Liveness: the logout request was sent; the fence then blocks session requests. + expect(String(vi.mocked(fetch).mock.calls[0]?.[0])).toContain('/api/v1/auth/logout') + expect(vi.mocked(fetch).mock.calls[0]?.[1]?.method).toBe('POST') + await expect(apiGet('/api/v1/me')).rejects.toBeInstanceOf(AuthExpiredError) + expect(fetch).toHaveBeenCalledOnce() + }) + + it('does not confirm a retry that reached the edge after the first failure expired the cookie', async () => { + const storage = { + length: 0, + getItem: vi.fn(() => null), + setItem: vi.fn(() => { + throw new DOMException('Quota exceeded', 'QuotaExceededError') + }), + removeItem: vi.fn(), + clear: vi.fn(), + key: vi.fn(() => null), + } + vi.stubGlobal('localStorage', storage) + vi.mocked(fetch) + .mockResolvedValueOnce( + new Response('{"error":"Unauthorized"}', { + status: 401, + headers: { 'content-type': 'application/json' }, + }) + ) + .mockResolvedValueOnce(jsonResponse('{"ok":true,"sessionPresented":false}')) + + await expect(logoutProfileUI()).resolves.toEqual({ + revocationConfirmed: false, + fencePersisted: false, + sessionAbsent: false, + }) + await expect(logoutProfileUI()).resolves.toEqual({ + revocationConfirmed: false, + fencePersisted: false, + sessionAbsent: true, + }) + + // Liveness: both logout requests were sent with the browser's credentials. + expect(fetch).toHaveBeenCalledTimes(2) + for (const call of vi.mocked(fetch).mock.calls) { + expect(String(call[0])).toContain('/api/v1/auth/logout') + expect(call[1]?.method).toBe('POST') + expect(call[1]?.credentials).toBe('include') + } + // The in-memory fence still holds after the unconfirmed retry. + await expect(apiGet('/api/v1/me')).rejects.toBeInstanceOf(AuthExpiredError) + expect(fetch).toHaveBeenCalledTimes(2) + }) + it('restores session requests only after a successful new login', async () => { vi.mocked(fetch) .mockResolvedValueOnce(new Response('', { status: 503 })) @@ -113,6 +218,7 @@ describe('Profile logout client boundary', () => { await expect(logoutProfileUI()).resolves.toEqual({ revocationConfirmed: false, fencePersisted: true, + sessionAbsent: false, }) expect(window.localStorage.getItem('profile_logout_unconfirmed')).toBe('true') await expect(apiGet('/api/v1/me')).rejects.toBeInstanceOf(AuthExpiredError) diff --git a/profile-ui/lib/api.ts b/profile-ui/lib/api.ts index dc7b694eaa..5234e844f8 100644 --- a/profile-ui/lib/api.ts +++ b/profile-ui/lib/api.ts @@ -291,15 +291,24 @@ export async function loginWithPassword( export async function logoutProfileUI(): Promise<{ revocationConfirmed: boolean fencePersisted: boolean + sessionAbsent: boolean }> { const fencePersisted = markLogoutUnconfirmed() let revocationConfirmed = false + let sessionAbsent = false try { const response = await fetch(`${EXTERNAL_REST_API_BASE_URL}/api/v1/auth/logout`, { method: 'POST', credentials: 'include', }) - revocationConfirmed = response.ok + if (response.ok) { + // The edge also answers 200 when the request carried no session, for example after + // an earlier failed logout expired the cookie. Only an answer for a presented + // session confirms revocation; a missing field confirms nothing. + const body = (await response.json()) as { sessionPresented?: unknown } + revocationConfirmed = body.sessionPresented === true + sessionAbsent = body.sessionPresented === false + } } catch { // The browser cannot expire an HttpOnly cookie without a server response. // The local marker prevents that cookie from restoring or authorizing Profile state. @@ -307,7 +316,7 @@ export async function logoutProfileUI(): Promise<{ clearToken() } if (revocationConfirmed) clearUnconfirmedLogout() - return { revocationConfirmed, fencePersisted } + return { revocationConfirmed, fencePersisted, sessionAbsent } } export async function requestPasswordReset(email: string): Promise<{ requested: true }> { diff --git a/profile-ui/package-lock.json b/profile-ui/package-lock.json index 0b09563066..9833d83f71 100644 --- a/profile-ui/package-lock.json +++ b/profile-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-profile-ui", - "version": "0.1.195", + "version": "0.1.196", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-profile-ui", - "version": "0.1.195", + "version": "0.1.196", "dependencies": { "@clerum/desktop-app-links": "file:../packages/desktop-app-links", "@clerum/frontend-components": "file:../packages/frontend-components", diff --git a/profile-ui/package.json b/profile-ui/package.json index 8af41238b8..f182b8a771 100644 --- a/profile-ui/package.json +++ b/profile-ui/package.json @@ -1,6 +1,6 @@ { "name": "clerum-profile-ui", - "version": "0.1.195", + "version": "0.1.196", "private": true, "scripts": { "dev": "next dev", From 64e27bfacec229869550036aff6b4d371d97b0f5 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 4 Nov 2026 20:03:05 +0100 Subject: [PATCH 460/528] refactor(control-api): remove the live resolver, operational index and access budget K11 of the PR #334 r4.2 fix plan (M-37, M-38, M-46 D-6). The live authorization resolver, its candidates, reader and target helpers, the operational access index and its indexer, and the access execution budget only served the /external/access routes removed in K9. PR #594 already re-reads each resource live, so the index decided nothing. This deletes them, delete-only, keeping operationalAccessProjection.ts and canonicalText.ts until K13 because the pinned foundation schema still imports the projection. - userAccessPolicy.ts drops the actionContextV2, rpcDelegationV2, desktopAllTeamMode and profileV2Mode flags, their readiness and the catalog budget options. The policy compiled at startup stays, so a contradictory configuration still stops the process. - main.ts and shutdown.ts no longer start or stop the indexer; config.ts drops its two settings. - k8s.ts, resourceService.ts and types.ts return to their dev content (watch, paged list and exact read had no other consumer), as do the indexer flag in the control-plane ConfigMap and the watch verbs it needed in three RBAC manifests. - Tests that only exercised the removed code are deleted, together with their Kubernetes API helper; the remaining suites drop the four policy flags; the External REST path guard accepts a member invitations path instead of the removed capabilities route. Gates on Node 24: control-api tsc, 8588 tests and build; recovery suite contract 32/32; External REST 583 tests and build. Real-PG runs in the global review before T2. Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- control-api/src/config.ts | 12 - control-api/src/k8s.ts | 169 +---- control-api/src/main.ts | 26 - .../src/middleware/accessExecutionBudget.ts | 45 -- .../src/middleware/externalSessionAuth.ts | 4 - .../services/access/accessAuthorityQuery.ts | 12 - .../services/access/accessAuthorityStore.ts | 121 ---- .../access/accessAuthorityTransaction.ts | 24 - .../services/access/accessDatabaseQuery.ts | 296 --------- .../services/access/accessExecutionBudget.ts | 463 -------------- control-api/src/services/access/accessPath.ts | 150 ----- .../services/access/authorityCandidates.ts | 124 ---- .../services/access/authorizationRevision.ts | 122 ---- .../src/services/access/capabilityRegistry.ts | 86 --- .../src/services/access/contextIdentitySql.ts | 31 - .../access/databaseAuthorityCandidates.ts | 282 -------- .../access/exactOperationalAuthorization.ts | 120 ---- .../access/liveAuthorizationResolver.ts | 415 ------------ .../src/services/access/operationTarget.ts | 168 ----- .../services/access/operationalAccessIndex.ts | 600 ------------------ .../access/operationalAccessIndexer.ts | 308 --------- .../access/operationalAccessReader.ts | 308 --------- .../access/operationalAuthorityCandidates.ts | 387 ----------- .../access/principalAuthoritySnapshot.ts | 155 ----- .../src/services/access/resourceIdentity.ts | 115 ---- .../src/services/access/userAccessPolicy.ts | 89 --- .../access/workflowTriggerGrantIdentity.ts | 15 - control-api/src/services/resourceService.ts | 122 ---- control-api/src/shutdown.ts | 1 - control-api/src/types.ts | 4 - ...atabaseTransactionAsyncClientError.test.ts | 88 --- .../test/accessExecutionBudget.test.ts | 202 ------ .../test/accessLiveAuthorization.test.ts | 594 ----------------- ...ptSession.realPostgres.integration.test.ts | 4 - ...dFirstSet.realPostgres.integration.test.ts | 4 - .../externalSessionAuthentication.test.ts | 4 - ...rrentness.realPostgres.integration.test.ts | 4 - ...ngleTouch.realPostgres.integration.test.ts | 4 - ...evocation.realPostgres.integration.test.ts | 4 - .../test/gfs.externalAuthority.test.ts | 4 - .../test/helpers/temporaryKubernetesApi.ts | 234 ------- ...dIdentity.realPostgres.integration.test.ts | 4 - control-api/test/k8s.watchResource.test.ts | 128 ---- .../test/network.gateway.intent.test.ts | 57 -- .../test/operationalAccessIndexer.test.ts | 298 --------- ...Authority.realPostgres.integration.test.ts | 4 - ...evocation.realPostgres.integration.test.ts | 4 - .../test/services.resourceService.test.ts | 108 ---- control-api/test/shutdown.test.ts | 4 +- control-api/test/userAccessPolicy.test.ts | 66 +- deploy/base/cluster-wide/clusterroles.yaml | 11 - deploy/base/control-plane/configmaps.yaml | 4 - deploy/base/mcp-host/rbac.yaml | 2 +- deploy/base/sandbox-recipes/rbac.yaml | 2 +- external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- external-rest-api/src/releaseManifest.ts | 2 +- .../test/controlApiClient.pathGuard.test.ts | 8 +- 60 files changed, 36 insertions(+), 6598 deletions(-) delete mode 100644 control-api/src/middleware/accessExecutionBudget.ts delete mode 100644 control-api/src/services/access/accessAuthorityQuery.ts delete mode 100644 control-api/src/services/access/accessAuthorityStore.ts delete mode 100644 control-api/src/services/access/accessAuthorityTransaction.ts delete mode 100644 control-api/src/services/access/accessDatabaseQuery.ts delete mode 100644 control-api/src/services/access/accessExecutionBudget.ts delete mode 100644 control-api/src/services/access/accessPath.ts delete mode 100644 control-api/src/services/access/authorityCandidates.ts delete mode 100644 control-api/src/services/access/authorizationRevision.ts delete mode 100644 control-api/src/services/access/capabilityRegistry.ts delete mode 100644 control-api/src/services/access/contextIdentitySql.ts delete mode 100644 control-api/src/services/access/databaseAuthorityCandidates.ts delete mode 100644 control-api/src/services/access/exactOperationalAuthorization.ts delete mode 100644 control-api/src/services/access/liveAuthorizationResolver.ts delete mode 100644 control-api/src/services/access/operationTarget.ts delete mode 100644 control-api/src/services/access/operationalAccessIndex.ts delete mode 100644 control-api/src/services/access/operationalAccessIndexer.ts delete mode 100644 control-api/src/services/access/operationalAccessReader.ts delete mode 100644 control-api/src/services/access/operationalAuthorityCandidates.ts delete mode 100644 control-api/src/services/access/principalAuthoritySnapshot.ts delete mode 100644 control-api/src/services/access/resourceIdentity.ts delete mode 100644 control-api/src/services/access/workflowTriggerGrantIdentity.ts delete mode 100644 control-api/test/accessDatabaseTransactionAsyncClientError.test.ts delete mode 100644 control-api/test/accessExecutionBudget.test.ts delete mode 100644 control-api/test/accessLiveAuthorization.test.ts delete mode 100644 control-api/test/helpers/temporaryKubernetesApi.ts delete mode 100644 control-api/test/k8s.watchResource.test.ts delete mode 100644 control-api/test/operationalAccessIndexer.test.ts diff --git a/control-api/package-lock.json b/control-api/package-lock.json index c3425baee5..d493f22e5f 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.897", + "version": "0.1.898", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.897", + "version": "0.1.898", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index adda98c971..9c8664e6f7 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.897", + "version": "0.1.898", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/config.ts b/control-api/src/config.ts index 7d310f1262..6c98b73c9e 100644 --- a/control-api/src/config.ts +++ b/control-api/src/config.ts @@ -316,8 +316,6 @@ type Config = { // SharedFileSystem — namespace housing both the CRD and the per-SFS // workspace-files-controller Service. sharedFilesystemsNamespace: string - operationalAccessIndexerEnabled: boolean - operationalAccessIndexerRetryMs: number // Audience claim used by browsing JWTs; must match WSF_JWT_AUDIENCE on // the per-SFS wfc Deployment. The signing key is reused from rpcJwtPrivateKey. wfcJwtAudience: string @@ -1365,16 +1363,6 @@ export const config: Config = { // CONTROL_API_SHARED_FILESYSTEMS_NAMESPACE override still wins when set. sharedFilesystemsNamespace: process.env.CONTROL_API_SHARED_FILESYSTEMS_NAMESPACE || HOSTS_NAMESPACE, - // The operational resource index stores provider lifecycle/relationships only. It is not an - // effective-access cache. Keep the worker opt-in until the controlled list/watch measurements - // in Task 106 are accepted; catalog/action rollout remains independently disabled. - operationalAccessIndexerEnabled: - (process.env.CONTROL_API_OPERATIONAL_ACCESS_INDEXER_ENABLED ?? 'false') === 'true', - operationalAccessIndexerRetryMs: intervalMsFromEnv( - 'CONTROL_API_OPERATIONAL_ACCESS_INDEXER_RETRY_MS', - 5_000, - 100 - ), wfcJwtAudience: process.env.CONTROL_API_WFC_JWT_AUDIENCE || 'workspace-files-controller', wfcTokenTtlSeconds: Number(process.env.CONTROL_API_WFC_TOKEN_TTL_SECONDS || 300), gfsTokenAudience: process.env.CONTROL_API_GFS_JWT_AUDIENCE || 'gfs-controller', diff --git a/control-api/src/k8s.ts b/control-api/src/k8s.ts index 3795da7966..6d3fd6c21e 100644 --- a/control-api/src/k8s.ts +++ b/control-api/src/k8s.ts @@ -7,17 +7,11 @@ import { type AllowedModelsConfigMapMaterializer, LlmAllowedModelsConfigMapWriter, } from './services/llmAllowedModelsConfigMap.js' -import { - type ResourceListPage, - ResourceService, - mergeAnnotationsForReplace, -} from './services/resourceService.js' +import { ResourceService, mergeAnnotationsForReplace } from './services/resourceService.js' import type { SecretConstraintOptions } from './services/secretConstraints.js' import type { SecretSnapshot } from './services/secretRepository.js' import { type DeleteSecretSummary, SecretService } from './services/secretService.js' import { - CLERUM_GROUP, - CLERUM_VERSION, ClerumResourceType, HostOverview, ResourcePreconditions, @@ -50,42 +44,6 @@ export const ALLOWED_READ_DIRS: readonly string[] = Object.freeze([ export const MAX_ARTIFACT_BYTES = 50 * 1024 * 1024 export const MAX_ARTIFACT_LISTING_BYTES = 1 * 1024 * 1024 -export const K8S_WATCH_QUEUE_LIMIT_CLAMPS = Object.freeze({ - maxPendingEvents: 1_000, - maxPendingBytes: 8 * 1024 * 1024, - maxObjectBytes: 512 * 1024, -}) - -export type K8sWatchQueueLimits = Readonly<{ - maxPendingEvents: number - maxPendingBytes: number - maxObjectBytes: number -}> - -export class K8sWatchQueueLimitError extends Error { - constructor(readonly limit: keyof K8sWatchQueueLimits) { - super(`Kubernetes watch event budget exceeded: ${limit}`) - this.name = 'K8sWatchQueueLimitError' - } -} - -function resolvedWatchQueueLimits( - requested: Partial = {} -): K8sWatchQueueLimits { - const resolved: Record = { - ...K8S_WATCH_QUEUE_LIMIT_CLAMPS, - } - for (const limit of Object.keys(resolved) as Array) { - const supplied = requested[limit] - if (supplied === undefined) continue - if (!Number.isSafeInteger(supplied) || supplied < 1 || supplied > resolved[limit]) { - throw new Error(`Invalid Kubernetes watch queue limit: ${limit}`) - } - resolved[limit] = supplied - } - return Object.freeze(resolved) -} - export class ExecOutputLimitError extends Error { constructor( readonly maxBytes: number, @@ -273,131 +231,6 @@ export class K8sGateway { return this.resources.listResource(plural, namespace) } - async listResourcePage( - plural: ClerumResourceType, - namespace: string, - options: { - limit: number - continueToken?: string - resourceVersion?: string - timeoutSeconds: number - signal: AbortSignal - } - ): Promise { - return this.resources.listResourcePage(plural, namespace, options) - } - - async getResourceExact( - plural: ClerumResourceType, - name: string, - namespace: string, - options: { timeoutSeconds: number; signal: AbortSignal } - ): Promise { - return this.resources.getResourceExact(plural, name, namespace, options) - } - - async watchResource( - plural: ClerumResourceType, - namespace: string, - resourceVersion: string, - signal: AbortSignal, - onEvent: (phase: string, object: unknown) => void | Promise, - requestedQueueLimits: Partial = {} - ): Promise { - this.resources.assertNamespaceAllowed(namespace) - const queueLimits = resolvedWatchQueueLimits(requestedQueueLimits) - const watch = new k8s.Watch(this.kc) - const path = `/apis/${CLERUM_GROUP}/${CLERUM_VERSION}/namespaces/${encodeURIComponent( - namespace - )}/${plural}` - await new Promise((resolve, reject) => { - let controller: AbortController | null = null - let settled = false - let eventQueue = Promise.resolve() - let handlerError: unknown - let pendingEvents = 0 - let pendingBytes = 0 - const finish = (error?: unknown) => { - if (settled) return - settled = true - signal.removeEventListener('abort', onAbort) - void eventQueue.then( - () => { - const finalError = handlerError ?? error - if (finalError && !signal.aborted) reject(finalError) - else resolve() - }, - queueError => { - if (!signal.aborted) reject(queueError) - else resolve() - } - ) - } - const onAbort = () => { - controller?.abort() - finish() - } - signal.addEventListener('abort', onAbort, { once: true }) - if (signal.aborted) { - onAbort() - return - } - void watch - .watch( - path, - { resourceVersion, allowWatchBookmarks: true }, - (phase, object) => { - if (signal.aborted || handlerError) return - let objectBytes: number - try { - objectBytes = Buffer.byteLength(JSON.stringify(object) ?? 'null', 'utf8') - } catch { - handlerError = new K8sWatchQueueLimitError('maxObjectBytes') - controller?.abort() - finish(handlerError) - return - } - const exceededLimit = - objectBytes > queueLimits.maxObjectBytes - ? 'maxObjectBytes' - : pendingEvents + 1 > queueLimits.maxPendingEvents - ? 'maxPendingEvents' - : pendingBytes + objectBytes > queueLimits.maxPendingBytes - ? 'maxPendingBytes' - : null - if (exceededLimit) { - handlerError = new K8sWatchQueueLimitError(exceededLimit) - controller?.abort() - finish(handlerError) - return - } - pendingEvents += 1 - pendingBytes += objectBytes - eventQueue = eventQueue - .then(() => { - if (signal.aborted || handlerError) return - return onEvent(phase, object) - }) - .catch(error => { - handlerError = error - controller?.abort() - finish(error) - }) - .finally(() => { - pendingEvents -= 1 - pendingBytes -= objectBytes - }) - }, - error => finish(error) - ) - .then(value => { - controller = value - if (settled || signal.aborted || handlerError) controller.abort() - }) - .catch(finish) - }) - } - async getResource( plural: ClerumResourceType, name: string, diff --git a/control-api/src/main.ts b/control-api/src/main.ts index 6b01f5087c..6dece8a1a8 100644 --- a/control-api/src/main.ts +++ b/control-api/src/main.ts @@ -5,7 +5,6 @@ import { reconcileAllowedModelsConfigMapOnBoot } from './llmAllowedModelsBootRec import { rootLogger } from './observability/logger.js' import { logRegistryConnectionState } from './registryBootGuard.js' import { ControlApiServer } from './server.js' -import { OperationalAccessIndexer } from './services/access/operationalAccessIndexer.js' import { resolveEffectiveUserAccessPolicy } from './services/access/userAccessRuntimePolicy.js' import { startAdminRevokedTokenCleanup, @@ -80,7 +79,6 @@ const logger = rootLogger.child({ module: 'control-api-shutdown' }) let activeServer: ControlApiServer | null = null let requestedExitCode = 0 let shutdownRequested = false -let stopOperationalAccessIndexer: (() => void) | null = null const shutdown = createShutdownHandler(async () => { const result = await runShutdownSteps( @@ -104,7 +102,6 @@ const shutdown = createShutdownHandler(async () => { 'oauth-shared-grant-sweep': stopOauthSharedGrantSweepCron, 'workflow-approval-trace-projector': stopWorkflowApprovalTraceProjector, 'entity-change-dispatcher': stopEntityChangeDispatcher, - 'operational-access-indexer': () => stopOperationalAccessIndexer?.(), 'core-database-pool': () => pool.end(), 'rate-limit-database-pool': () => rateLimitPool.end(), 'trace-database-pools': closeTracingPools, @@ -202,29 +199,6 @@ async function main(): Promise { const gateway = new K8sGateway(config.namespace) - if (config.operationalAccessIndexerEnabled) { - const operationalIndexer = new OperationalAccessIndexer(gateway, undefined, { - retryDelayMs: config.operationalAccessIndexerRetryMs, - }) - const runningIndexer = operationalIndexer.start() - stopOperationalAccessIndexer = runningIndexer.stop - void runningIndexer.completion.catch(error => { - rootLogger.error( - { err: error, event: 'operational_access_indexer_stopped' }, - 'operational access index stopped unexpectedly' - ) - }) - logger.info( - { event: 'operational_access_indexer_enabled' }, - 'Operational access indexer enabled' - ) - } else { - logger.info( - { event: 'operational_access_indexer_disabled' }, - 'Operational access indexer disabled' - ) - } - // LLM catalog discovery sync cron (Fase 4). Code default off; the base deploy // sets LLM_CATALOG_SYNC_CRON_ENABLED=true. When on, the first sync runs a few // seconds after start (not awaited — boot never waits on models.dev), then diff --git a/control-api/src/middleware/accessExecutionBudget.ts b/control-api/src/middleware/accessExecutionBudget.ts deleted file mode 100644 index 9b9e99feca..0000000000 --- a/control-api/src/middleware/accessExecutionBudget.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { Response } from 'express' -import { - AccessExecutionBudget, - type AccessExecutionKind, -} from '../services/access/accessExecutionBudget.js' -import { configuredCatalogBudgetOptions } from '../services/access/userAccessPolicy.js' -import { type ExternalAuthedRequest } from './externalSessionAuth.js' - -export function attachAccessExecutionBudget( - req: ExternalAuthedRequest, - res: Response, - next: () => void -): void { - const kind: AccessExecutionKind = - req.method === 'GET' && req.path.endsWith('/catalog') ? 'catalog' : 'action' - const budget = AccessExecutionBudget.create( - kind, - kind === 'catalog' ? configuredCatalogBudgetOptions : undefined - ) - req.accessExecutionBudget = budget - let settled = false - const detach = () => { - req.removeListener('aborted', onAborted) - res.removeListener('finish', onFinished) - res.removeListener('close', onClosed) - } - const onAborted = () => budget.cancel() - const onFinished = () => { - if (settled) return - settled = true - detach() - budget.close() - } - const onClosed = () => { - if (settled) return - settled = true - if (!res.writableEnded) budget.cancel() - detach() - budget.close() - } - req.once('aborted', onAborted) - res.once('finish', onFinished) - res.once('close', onClosed) - next() -} diff --git a/control-api/src/middleware/externalSessionAuth.ts b/control-api/src/middleware/externalSessionAuth.ts index 0cc7fa04bf..288aa3a8c9 100644 --- a/control-api/src/middleware/externalSessionAuth.ts +++ b/control-api/src/middleware/externalSessionAuth.ts @@ -2,7 +2,6 @@ import { NextFunction, Request, Response } from 'express' import { sendPublicApiError } from '../http/publicApiError.js' import { rootLogger } from '../observability/logger.js' import { AuthClaims, TeamRole } from '../profileTypes.js' -import type { AccessExecutionBudget } from '../services/access/accessExecutionBudget.js' import { getLiveTeamMembership } from '../services/access/liveTeamAuthorization.js' import { authenticateExternalUserSession, @@ -60,9 +59,6 @@ export type ExternalAuthedRequest = Request & { teamId: string role: TeamRole } - // Retained until K9/K11 delete the /external/access routes and the budget - // module, which are its only remaining readers and writer. - accessExecutionBudget?: AccessExecutionBudget } function extractUserSessionToken(req: Request): string { diff --git a/control-api/src/services/access/accessAuthorityQuery.ts b/control-api/src/services/access/accessAuthorityQuery.ts deleted file mode 100644 index c562271f29..0000000000 --- a/control-api/src/services/access/accessAuthorityQuery.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { DbClient } from '../../db.js' -import { runAccessDatabaseQuery } from './accessDatabaseQuery.js' -import type { AccessExecutionBudget } from './accessExecutionBudget.js' - -export async function query( - db: Pick, - budget: AccessExecutionBudget, - text: string, - values: unknown[] -) { - return runAccessDatabaseQuery(db, budget, text, values) -} diff --git a/control-api/src/services/access/accessAuthorityStore.ts b/control-api/src/services/access/accessAuthorityStore.ts deleted file mode 100644 index 8d7a5c20e1..0000000000 --- a/control-api/src/services/access/accessAuthorityStore.ts +++ /dev/null @@ -1,121 +0,0 @@ -import type { DbClient } from '../../db.js' -import { query } from './accessAuthorityQuery.js' -import type { AccessExecutionBudget } from './accessExecutionBudget.js' -import type { AuthorityCandidate } from './authorityCandidates.js' -import type { AccessCapability } from './capabilityRegistry.js' -import { - type ResourceAuthorityResult, - loadDatabaseCandidates, -} from './databaseAuthorityCandidates.js' -import type { ValidatedOperationTarget } from './operationTarget.js' -import type { OperationalResourceGraphResult } from './operationalAccessReader.js' -import { - loadDerivedOperationalCandidates, - loadSimpleOperationalGrantCandidates, -} from './operationalAuthorityCandidates.js' -import type { PrincipalAuthoritySnapshot } from './principalAuthoritySnapshot.js' -import type { CanonicalResourceIdentity } from './resourceIdentity.js' - -function publicRelationships(graph: OperationalResourceGraphResult | null) { - if (!graph || graph.status !== 'current') return [] - return graph.relationships.map(relationship => - Object.freeze({ - type: relationship.relationshipType, - targetResourceId: `${relationship.targetType}:${relationship.targetId}`, - instanceId: relationship.relationshipInstanceId, - }) - ) -} - -export async function loadResourceAuthority(input: { - db: Pick - budget: AccessExecutionBudget - snapshot: PrincipalAuthoritySnapshot - resource: CanonicalResourceIdentity - operationTarget: ValidatedOperationTarget - operationalGraph: OperationalResourceGraphResult | null -}): Promise { - if (input.resource.type === 'user' || input.resource.type === 'team') { - return loadDatabaseCandidates(input) - } - if (input.operationalGraph?.status === 'current') { - const candidates = ['mcp_server', 'shared_filesystem'].includes(input.resource.type) - ? await loadDerivedOperationalCandidates({ - ...input, - userId: input.snapshot.userId, - graph: input.operationalGraph, - }) - : await loadSimpleOperationalGrantCandidates({ - ...input, - userId: input.snapshot.userId, - graph: input.operationalGraph, - }) - return Object.freeze({ - exists: candidates.length > 0, - candidates: Object.freeze(candidates), - relationships: Object.freeze(publicRelationships(input.operationalGraph)), - validUntil: null, - }) - } - return loadDatabaseCandidates(input) -} - -export async function operationTargetIsCurrentlyValid(input: { - db: Pick - budget: AccessExecutionBudget - resource: CanonicalResourceIdentity - capability: AccessCapability - operationTarget: ValidatedOperationTarget -}): Promise { - const target = input.operationTarget - if (!target) return true - if (target.teamId && target.teamId !== input.resource.logicalId) return false - if (target.userId && ['team.member.read', 'team.member.manage'].includes(input.capability)) { - const result = await query( - input.db, - input.budget, - `SELECT 1 FROM team_members - WHERE team_id = $1 AND user_id = $2 AND status = 'active' - LIMIT 1`, - [target.teamId, target.userId] - ) - return (result.rowCount ?? 0) === 1 - } - if (input.capability === 'team.member.invite' && target.action !== 'create') { - const result = await query( - input.db, - input.budget, - `SELECT 1 - FROM invitations invitation - JOIN invitation_teams assignment ON assignment.invitation_id = invitation.id - WHERE invitation.id = $1 - AND invitation.status = 'pending' - AND assignment.team_id = $2 - AND assignment.role = $3 - LIMIT 1`, - [target.invitationId, target.teamId, target.role] - ) - return (result.rowCount ?? 0) === 1 - } - return true -} - -export function filterCandidatesForOperationTarget(input: { - candidates: readonly AuthorityCandidate[] - capability: AccessCapability - operationTarget: ValidatedOperationTarget -}): AuthorityCandidate[] { - const target = input.operationTarget - if (!target) return [...input.candidates] - let candidates = [...input.candidates] - if (target.teamId) { - candidates = candidates.filter(candidate => candidate.teamId === target.teamId) - } - if ( - target.role === 'admin' && - ['team.member.invite', 'team.member.manage'].includes(input.capability) - ) { - candidates = candidates.filter(candidate => candidate.currentRole === 'admin') - } - return candidates -} diff --git a/control-api/src/services/access/accessAuthorityTransaction.ts b/control-api/src/services/access/accessAuthorityTransaction.ts deleted file mode 100644 index 13865a06da..0000000000 --- a/control-api/src/services/access/accessAuthorityTransaction.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { DbClient } from '../../db.js' -import { runAccessDatabaseQuery } from './accessDatabaseQuery.js' -import type { AccessExecutionBudget } from './accessExecutionBudget.js' - -export async function configureAccessAuthorityTransaction( - db: Pick, - budget: AccessExecutionBudget -): Promise { - const statementTimeoutMs = budget.statementTimeoutMs() - await runAccessDatabaseQuery( - db, - budget, - 'SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY', - [], - { chargeRows: false } - ) - await runAccessDatabaseQuery( - db, - budget, - `SELECT set_config('statement_timeout', $1, true)`, - [`${statementTimeoutMs}ms`], - { chargeRows: false, statementTimeoutMs } - ) -} diff --git a/control-api/src/services/access/accessDatabaseQuery.ts b/control-api/src/services/access/accessDatabaseQuery.ts deleted file mode 100644 index cffcb77fee..0000000000 --- a/control-api/src/services/access/accessDatabaseQuery.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { Client, type ClientConfig, type PoolClient } from 'pg' -import { type DbClient, pool } from '../../db.js' -import { AccessBudgetExceededError, type AccessExecutionBudget } from './accessExecutionBudget.js' - -type PostgresConnectionParameters = Readonly<{ - user?: unknown - database?: unknown - port?: unknown - host?: unknown - password?: unknown - ssl?: unknown -}> - -type PostgresCancellationTarget = Pick & - Readonly<{ - processID?: unknown - connectionParameters?: PostgresConnectionParameters - }> - -type StatementTimeoutProvenance = Readonly<{ - timeoutMs: number -}> - -const statementTimeoutProvenance = new WeakMap() - -function boundedPort(value: unknown): number | undefined { - const port = Number(value) - return Number.isSafeInteger(port) && port > 0 && port <= 65_535 ? port : undefined -} - -function connectionText(value: unknown): string | undefined { - return typeof value === 'string' && value ? value : undefined -} - -async function cancelPostgresBackend(db: PostgresCancellationTarget): Promise { - const processId = Number(db.processID) - const parameters = db.connectionParameters - if (!Number.isSafeInteger(processId) || processId <= 0 || !parameters) return - const client = new Client({ - user: connectionText(parameters.user), - database: connectionText(parameters.database), - port: boundedPort(parameters.port), - host: connectionText(parameters.host), - password: parameters.password as ClientConfig['password'], - ssl: parameters.ssl as ClientConfig['ssl'], - connectionTimeoutMillis: 1_000, - }) - try { - await client.connect() - await client.query('SELECT pg_cancel_backend($1)', [processId]) - } finally { - await client.end().catch(() => undefined) - } -} - -function hasStatementTimeoutCode(error: unknown): boolean { - if (!error || typeof error !== 'object') return false - return (error as { code?: unknown }).code === '57014' -} - -function rememberStatementTimeout(db: object, timeoutMs: number): void { - statementTimeoutProvenance.set(db, { timeoutMs }) -} - -function forgetStatementTimeout(db: object): void { - statementTimeoutProvenance.delete(db) -} - -function statementTimeoutStarted(db: object): StatementTimeoutProvenance | undefined { - return statementTimeoutProvenance.get(db) -} - -function isOwnedStatementTimeout( - error: unknown, - provenance: StatementTimeoutProvenance | undefined, - startedAt: number -): boolean { - if (!provenance || !hasStatementTimeoutCode(error)) return false - return performance.now() - startedAt >= provenance.timeoutMs -} - -/** - * Runs one PostgreSQL statement under the request budget. Real PoolClients are - * cancelled through a separate connection when the shared AbortSignal fires; - * test doubles without backend identity still receive late-result rejection. - */ -export async function runAccessDatabaseQuery( - db: Pick, - budget: AccessExecutionBudget, - text: string, - values: unknown[] = [], - options: { - chargeRows?: boolean - chargeProducer?: boolean - statementTimeoutMs?: number - onDatabaseFailure?: (error: unknown) => unknown - } = {} -) { - const run = - options.chargeProducer === false - ? budget.runBoundedWork.bind(budget) - : budget.runProducer.bind(budget) - return run(async signal => { - budget.assertActive() - budget.charge({ kind: 'databaseStatements' }) - const queryStartedAt = performance.now() - const provenance = - typeof db === 'object' && db !== null ? statementTimeoutStarted(db) : undefined - let cancellation: Promise | undefined - const onAbort = () => { - cancellation ??= cancelPostgresBackend(db as PostgresCancellationTarget).catch( - () => undefined - ) - } - signal.addEventListener('abort', onAbort, { once: true }) - try { - let result - try { - result = await db.query(text, values) - } catch (error) { - if (isOwnedStatementTimeout(error, provenance, queryStartedAt)) { - throw new AccessBudgetExceededError('deadline', true) - } - throw options.onDatabaseFailure ? options.onDatabaseFailure(error) : error - } - if (options.statementTimeoutMs !== undefined && typeof db === 'object' && db !== null) { - rememberStatementTimeout(db, options.statementTimeoutMs) - } - budget.assertActive() - if (options.chargeRows !== false && result.rows.length > 0) { - budget.charge({ kind: 'dbRowsReturned', amount: result.rows.length }) - } - return result - } catch (error) { - if (signal.aborted) budget.assertActive() - if (isOwnedStatementTimeout(error, provenance, queryStartedAt)) { - throw new AccessBudgetExceededError('deadline', true) - } - throw error - } finally { - signal.removeEventListener('abort', onAbort) - if (cancellation) await cancellation - } - }) -} - -async function acquireAccessDatabaseClient( - budget: AccessExecutionBudget, - connectionPool: Pick, - onDatabaseFailure?: (error: unknown) => unknown -): Promise { - let acquired: PoolClient | undefined - let acquiredReleased = false - const releaseAcquired = (error?: Error | boolean) => { - if (!acquired || acquiredReleased) return - acquiredReleased = true - acquired.release(error) - } - try { - return await budget.runBoundedWork( - signal => - new Promise((resolve, reject) => { - let settled = false - const rejectForAbort = () => { - if (settled) return - settled = true - try { - budget.assertActive() - } catch (error) { - reject(error) - } - } - signal.addEventListener('abort', rejectForAbort, { once: true }) - if (signal.aborted) rejectForAbort() - Promise.resolve(connectionPool.connect()).then( - client => { - acquired = client as PoolClient - if (settled) { - releaseAcquired(true) - return - } - settled = true - signal.removeEventListener('abort', rejectForAbort) - resolve(acquired) - }, - error => { - if (settled) return - settled = true - signal.removeEventListener('abort', rejectForAbort) - reject(onDatabaseFailure ? onDatabaseFailure(error) : error) - } - ) - }) - ) - } catch (error) { - releaseAcquired(error instanceof Error ? error : true) - throw error - } -} - -export async function withAccessDatabaseTransaction( - budget: AccessExecutionBudget, - work: (db: DbClient) => Promise, - options: { - mode?: 'read_only' | 'read_write' | 'caller_configured' - connectionPool?: Pick - onDatabaseFailure?: (error: unknown) => unknown - } = {} -): Promise { - budget.assertActive() - const client = await acquireAccessDatabaseClient( - budget, - options.connectionPool ?? pool, - options.onDatabaseFailure - ) - let transactionStarted = false - let commitSent = false - let releaseError: Error | boolean | undefined - // pg-pool removes its idle 'error' listener while a client is checked out. - // Hold one for the whole loan so a backend failure during non-SQL work cannot - // become an uncaught EventEmitter error, and discard the client on release. - let asyncClientError: Error | undefined - const onClientError = (err: unknown): void => { - asyncClientError ??= err instanceof Error ? err : new Error(String(err)) - } - client.on('error', onClientError) - const query = (text: string, values?: unknown[]) => - runAccessDatabaseQuery(client, budget, text, values ?? [], { - onDatabaseFailure: options.onDatabaseFailure, - }) - const budgetedDb = Object.assign(Object.create(client), { query }) as DbClient - try { - budget.assertActive() - await runAccessDatabaseQuery(client, budget, 'BEGIN', [], { - chargeRows: false, - chargeProducer: false, - onDatabaseFailure: options.onDatabaseFailure, - }) - transactionStarted = true - if (options.mode !== 'caller_configured') { - const statementTimeoutMs = budget.statementTimeoutMs() - if (options.mode === 'read_only') { - await runAccessDatabaseQuery(client, budget, 'SET TRANSACTION READ ONLY', [], { - chargeRows: false, - chargeProducer: false, - onDatabaseFailure: options.onDatabaseFailure, - }) - } - await runAccessDatabaseQuery( - client, - budget, - `SELECT set_config('statement_timeout', $1, true)`, - [`${statementTimeoutMs}ms`], - { - chargeRows: false, - chargeProducer: false, - statementTimeoutMs, - onDatabaseFailure: options.onDatabaseFailure, - } - ) - } - const result = await work( - options.mode === 'caller_configured' ? (client as DbClient) : budgetedDb - ) - budget.assertActive() - commitSent = true - await runAccessDatabaseQuery(client, budget, 'COMMIT', [], { - chargeRows: false, - chargeProducer: false, - onDatabaseFailure: options.onDatabaseFailure, - }) - return result - } catch (error) { - if (commitSent || !transactionStarted) { - releaseError = error instanceof Error ? error : true - } else { - try { - await runAccessDatabaseQuery(client, budget, 'ROLLBACK', [], { - chargeRows: false, - chargeProducer: false, - onDatabaseFailure: options.onDatabaseFailure, - }) - } catch (rollbackError) { - releaseError = rollbackError instanceof Error ? rollbackError : true - } - } - throw error - } finally { - forgetStatementTimeout(client) - client.removeListener('error', onClientError) - // Keep the primary failure; adopt the async one only when there is none, so - // a poisoned client always reaches release() with a truthy value. - if (asyncClientError && !releaseError) releaseError = asyncClientError - client.release(releaseError) - } -} diff --git a/control-api/src/services/access/accessExecutionBudget.ts b/control-api/src/services/access/accessExecutionBudget.ts deleted file mode 100644 index 46df1bec56..0000000000 --- a/control-api/src/services/access/accessExecutionBudget.ts +++ /dev/null @@ -1,463 +0,0 @@ -export const ACCESS_EXECUTION_LIMIT_CLAMPS = Object.freeze({ - publicPageSize: 100, - catalogDeadlineMs: 5_000, - actionDeadlineMs: 2_000, - statementTimeoutMs: 2_500, - producerCalls: 42, - databaseStatements: 1_493, - producerConcurrency: 4, - keyCandidatesPerCall: 101, - objects: 1_000, - decodedBytes: 8 * 1024 * 1024, - objectBytes: 512 * 1024, - accessPaths: 2_048, - relationships: 4_096, - relationshipDepth: 1, - dbRowsReturned: 4_096, - kubernetesPageSize: 100, - exactKubernetesGets: 4, - memoEntries: 512, - memoBytes: 4 * 1024 * 1024, - cursorBytes: 16 * 1024, - responseBytes: 4 * 1024 * 1024, -}) - -export type AccessExecutionKind = 'catalog' | 'action' | 'indexer' - -const ACCESS_EXECUTION_CLASS_LIMITS: Readonly< - Record> -> = Object.freeze({ - catalog: Object.freeze({ producerCalls: 42, databaseStatements: 1_493 }), - action: Object.freeze({ producerCalls: 32, databaseStatements: 128 }), - indexer: Object.freeze({ producerCalls: 41, databaseStatements: 55 }), -}) - -export type AccessExecutionLimitName = keyof typeof ACCESS_EXECUTION_LIMIT_CLAMPS -export type AccessExecutionLimits = Readonly> - -export type AccessBudgetCounterKind = - | 'producerCalls' - | 'databaseStatements' - | 'objects' - | 'decodedBytes' - | 'accessPaths' - | 'relationships' - | 'dbRowsReturned' - | 'exactKubernetesGets' - | 'memoEntries' - | 'memoBytes' - | 'responseBytes' - -export type AccessBudgetCharge = Readonly<{ - kind: AccessBudgetCounterKind - amount?: number - authorityRequired?: boolean -}> - -export type AccessBudgetReservation = Partial>> - -export class AccessBudgetConfigurationError extends Error { - constructor( - readonly limit: keyof AccessExecutionLimits, - readonly reason: string - ) { - super(`Invalid access execution limit ${limit}: ${reason}`) - this.name = 'AccessBudgetConfigurationError' - } -} - -export class AccessBudgetExceededError extends Error { - constructor( - readonly limit: - | AccessBudgetCounterKind - | 'deadline' - | 'objectBytes' - | 'relationshipDepth' - | 'teamGfsMembershipAdmission', - readonly authorityRequired: boolean - ) { - super(`Access execution budget exhausted: ${limit}`) - this.name = 'AccessBudgetExceededError' - } -} - -export class AccessExecutionCancelledError extends Error { - constructor(readonly reason: 'deadline' | 'cancelled' | 'closed') { - super(`Access execution cancelled: ${reason}`) - this.name = 'AccessExecutionCancelledError' - } -} - -class BudgetCounter { - constructor( - readonly capacity: number, - private available = capacity - ) {} - - remaining(): number { - return this.available - } - - take(amount: number): boolean { - if (amount > this.available) return false - this.available -= amount - return true - } - - giveBack(amount: number): void { - this.available = Math.min(this.capacity, this.available + amount) - } -} - -class AccessSemaphore { - private inUse = 0 - private readonly waiters: Array<{ - resolve: (release: () => void) => void - reject: (error: Error) => void - signal: AbortSignal - onAbort: () => void - }> = [] - - constructor(private readonly capacity: number) {} - - async acquire(signal: AbortSignal): Promise<() => void> { - if (signal.aborted) throw new AccessExecutionCancelledError('cancelled') - if (this.inUse < this.capacity) { - this.inUse += 1 - return this.releaseOnce() - } - - return new Promise<() => void>((resolve, reject) => { - const waiter = { - resolve, - reject, - signal, - onAbort: () => undefined, - } - waiter.onAbort = () => { - const index = this.waiters.indexOf(waiter) - if (index >= 0) this.waiters.splice(index, 1) - reject(new AccessExecutionCancelledError('cancelled')) - } - signal.addEventListener('abort', waiter.onAbort, { once: true }) - this.waiters.push(waiter) - }) - } - - private releaseOnce(): () => void { - let released = false - return () => { - if (released) return - released = true - const waiter = this.waiters.shift() - if (waiter) { - waiter.signal.removeEventListener('abort', waiter.onAbort) - waiter.resolve(this.releaseOnce()) - return - } - this.inUse = Math.max(0, this.inUse - 1) - } - } -} - -const COUNTER_KINDS: readonly AccessBudgetCounterKind[] = [ - 'producerCalls', - 'databaseStatements', - 'objects', - 'decodedBytes', - 'accessPaths', - 'relationships', - 'dbRowsReturned', - 'exactKubernetesGets', - 'memoEntries', - 'memoBytes', - 'responseBytes', -] - -function positiveSafeInteger(value: unknown): value is number { - return Number.isSafeInteger(value) && Number(value) > 0 -} - -export function resolveAccessExecutionLimits( - requested: Partial = {}, - kind: AccessExecutionKind = 'action' -): AccessExecutionLimits { - const classLimits = ACCESS_EXECUTION_CLASS_LIMITS[kind] - const values: Record = { - ...ACCESS_EXECUTION_LIMIT_CLAMPS, - ...classLimits, - } - const maximums = { ...ACCESS_EXECUTION_LIMIT_CLAMPS, ...classLimits } - for (const key of Object.keys(ACCESS_EXECUTION_LIMIT_CLAMPS) as Array< - keyof AccessExecutionLimits - >) { - const supplied = requested[key] - if (supplied === undefined) continue - if (!positiveSafeInteger(supplied)) { - throw new AccessBudgetConfigurationError(key, 'must be a positive safe integer') - } - if (supplied > maximums[key]) { - throw new AccessBudgetConfigurationError(key, 'exceeds the hard code clamp') - } - values[key] = supplied - } - return Object.freeze(values) -} - -function createCounters( - limits: AccessExecutionLimits -): Map { - return new Map(COUNTER_KINDS.map(kind => [kind, new BudgetCounter(limits[kind])])) -} - -type SharedAccessBudgetState = { - counters: Map - semaphore: AccessSemaphore - controller: AbortController - deadline: number -} - -export class AccessExecutionBudget { - readonly deadline: number - readonly signal: AbortSignal - readonly limits: AccessExecutionLimits - readonly teamGfsMembershipAdmissionLimit: number | null - private closed = false - private readonly localCounters: Map | null - private readonly reservedFromParent: AccessBudgetReservation - private readonly reservationParentCounters: Map | null - private timer?: ReturnType - private parentSignal?: AbortSignal - private parentAbortListener?: () => void - - private constructor( - private readonly shared: SharedAccessBudgetState, - limits: AccessExecutionLimits, - localCounters: Map | null, - reservedFromParent: AccessBudgetReservation, - reservationParentCounters: Map | null, - teamGfsMembershipAdmissionLimit: number | null - ) { - this.deadline = shared.deadline - this.signal = shared.controller.signal - this.limits = limits - this.localCounters = localCounters - this.reservedFromParent = reservedFromParent - this.reservationParentCounters = reservationParentCounters - this.teamGfsMembershipAdmissionLimit = teamGfsMembershipAdmissionLimit - } - - static create( - kind: AccessExecutionKind, - options: { - limits?: Partial - teamGfsMembershipAdmissionLimit?: number - now?: number - parentSignal?: AbortSignal - } = {} - ): AccessExecutionBudget { - const limits = resolveAccessExecutionLimits(options.limits, kind) - const teamGfsMembershipAdmissionLimit = - options.teamGfsMembershipAdmissionLimit === undefined - ? null - : options.teamGfsMembershipAdmissionLimit - if ( - teamGfsMembershipAdmissionLimit !== null && - (!Number.isSafeInteger(teamGfsMembershipAdmissionLimit) || - teamGfsMembershipAdmissionLimit < 1) - ) { - throw new AccessBudgetConfigurationError( - 'objects', - 'team GFS membership admission must be a positive safe integer' - ) - } - const controller = new AbortController() - const now = options.now ?? Date.now() - const deadlineMs = kind === 'action' ? limits.actionDeadlineMs : limits.catalogDeadlineMs - const shared: SharedAccessBudgetState = { - counters: createCounters(limits), - semaphore: new AccessSemaphore(limits.producerConcurrency), - controller, - deadline: now + deadlineMs, - } - const budget = new AccessExecutionBudget( - shared, - limits, - null, - {}, - null, - teamGfsMembershipAdmissionLimit - ) - budget.timer = setTimeout(() => controller.abort('deadline'), deadlineMs) - budget.timer.unref?.() - if (options.parentSignal) { - if (options.parentSignal.aborted) controller.abort('cancelled') - else { - const onParentAbort = () => controller.abort('cancelled') - budget.parentSignal = options.parentSignal - budget.parentAbortListener = onParentAbort - options.parentSignal.addEventListener('abort', onParentAbort, { once: true }) - } - } - return budget - } - - remainingMs(now = Date.now()): number { - return Math.max(0, this.deadline - now) - } - - statementTimeoutMs(now = Date.now()): number { - this.assertActive(now) - const timeout = Math.min(this.limits.statementTimeoutMs, this.remainingMs(now) - 250) - if (timeout < 1) throw new AccessBudgetExceededError('deadline', true) - return Math.floor(timeout) - } - - assertActive(now = Date.now()): void { - if (this.closed) throw new AccessExecutionCancelledError('closed') - if (this.signal.aborted) { - throw new AccessExecutionCancelledError( - this.signal.reason === 'deadline' || now >= this.deadline - ? 'deadline' - : this.signal.reason === 'closed' - ? 'closed' - : 'cancelled' - ) - } - if (now >= this.deadline) { - this.shared.controller.abort('deadline') - throw new AccessExecutionCancelledError('deadline') - } - } - - charge(event: AccessBudgetCharge): void { - this.assertActive() - const amount = event.amount ?? 1 - if (!positiveSafeInteger(amount)) { - throw new AccessBudgetConfigurationError(event.kind, 'charge must be a positive safe integer') - } - const counter = this.counter(event.kind) - if (!counter.take(amount)) { - throw new AccessBudgetExceededError(event.kind, event.authorityRequired ?? true) - } - } - - chargeOperationalObject(bytes: number, authorityRequired = false): void { - this.assertActive() - if (!positiveSafeInteger(bytes)) { - throw new AccessBudgetConfigurationError('objectBytes', 'must be a positive safe integer') - } - if (bytes > this.limits.objectBytes) { - throw new AccessBudgetExceededError('objectBytes', authorityRequired) - } - this.charge({ kind: 'objects', authorityRequired }) - this.charge({ kind: 'decodedBytes', amount: bytes, authorityRequired }) - } - - assertRelationshipDepth(depth: number, authorityRequired = false): void { - if (!Number.isSafeInteger(depth) || depth < 0 || depth > this.limits.relationshipDepth) { - throw new AccessBudgetExceededError('relationshipDepth', authorityRequired) - } - } - - assertPageSize(pageSize: number): void { - if (!positiveSafeInteger(pageSize) || pageSize > this.limits.publicPageSize) { - throw new AccessBudgetConfigurationError('publicPageSize', 'request exceeds the page clamp') - } - } - - assertCursorBytes(bytes: number): void { - if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > this.limits.cursorBytes) { - throw new AccessBudgetConfigurationError('cursorBytes', 'request exceeds the cursor clamp') - } - } - - child(reservation: AccessBudgetReservation): AccessExecutionBudget { - this.assertActive() - const local = new Map() - const reserved: Partial> = {} - try { - for (const kind of COUNTER_KINDS) { - const amount = reservation[kind] ?? 0 - if (!Number.isSafeInteger(amount) || amount < 0) { - throw new AccessBudgetConfigurationError(kind, 'reservation must be a safe integer') - } - if (amount === 0) { - local.set(kind, new BudgetCounter(0)) - continue - } - const parentCounter = this.counter(kind) - if (!parentCounter.take(amount)) throw new AccessBudgetExceededError(kind, true) - reserved[kind] = amount - local.set(kind, new BudgetCounter(amount)) - } - } catch (error) { - for (const [kind, amount] of Object.entries(reserved) as Array< - [AccessBudgetCounterKind, number] - >) { - this.counter(kind).giveBack(amount) - } - throw error - } - return new AccessExecutionBudget( - this.shared, - this.limits, - local, - reserved, - this.localCounters ?? this.shared.counters, - this.teamGfsMembershipAdmissionLimit - ) - } - - async runProducer(work: (signal: AbortSignal) => Promise): Promise { - this.charge({ kind: 'producerCalls' }) - return this.runBoundedWork(work) - } - - async runBoundedWork(work: (signal: AbortSignal) => Promise): Promise { - const release = await this.shared.semaphore.acquire(this.signal) - try { - this.assertActive() - const result = await work(this.signal) - this.assertActive() - return result - } finally { - release() - } - } - - cancel(): void { - this.shared.controller.abort('cancelled') - } - - close(): void { - if (this.closed) return - this.closed = true - if (!this.localCounters) { - if (this.timer) clearTimeout(this.timer) - if (this.parentSignal && this.parentAbortListener) { - this.parentSignal.removeEventListener('abort', this.parentAbortListener) - this.parentSignal = undefined - this.parentAbortListener = undefined - } - this.shared.controller.abort('closed') - return - } - for (const kind of COUNTER_KINDS) { - const unused = this.localCounters.get(kind)?.remaining() ?? 0 - if (unused > 0 && (this.reservedFromParent[kind] ?? 0) > 0) { - this.reservationParentCounters?.get(kind)?.giveBack(unused) - } - } - } - - remaining(kind: AccessBudgetCounterKind): number { - return this.counter(kind).remaining() - } - - private counter(kind: AccessBudgetCounterKind): BudgetCounter { - const value = (this.localCounters ?? this.shared.counters).get(kind) - if (!value) throw new AccessBudgetConfigurationError(kind, 'counter is not configured') - return value - } -} diff --git a/control-api/src/services/access/accessPath.ts b/control-api/src/services/access/accessPath.ts deleted file mode 100644 index 1830775559..0000000000 --- a/control-api/src/services/access/accessPath.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { createHmac, timingSafeEqual } from 'node:crypto' -import { config } from '../../config.js' -import type { TeamRole } from '../../profileTypes.js' -import { compareCanonicalUtf8Text } from './canonicalText.js' -import { type AccessCapability, normalizeAccessCapabilities } from './capabilityRegistry.js' -import { type CanonicalResourceIdentity, resourceIdentityKey } from './resourceIdentity.js' - -export type AccessPathKind = 'direct' | 'team' -export type BehaviorDimension = - | Readonly<{ state: 'known'; value: string | null }> - | Readonly<{ state: 'unknown' }> - -export type AccessPathBehavior = Readonly<{ - capabilities: readonly AccessCapability[] - budget: BehaviorDimension - credentialPolicy: BehaviorDimension - approvalPolicy: BehaviorDimension - filesystemScope: BehaviorDimension - runtime: BehaviorDimension - providerModelPolicy: BehaviorDimension - audit: BehaviorDimension -}> - -export type AccessPath = Readonly<{ - id: string - kind: AccessPathKind - grantId: string - teamId?: string - currentRole?: TeamRole - authorizationRevision: string - behavior: AccessPathBehavior -}> - -export type AccessPathSeed = Readonly<{ - kind: AccessPathKind - grantId: string - teamId?: string - currentRole?: TeamRole - behavior: AccessPathBehavior -}> - -export const knownBehavior = (value: string | null): BehaviorDimension => - Object.freeze({ state: 'known', value }) -export const unknownBehavior = (): BehaviorDimension => Object.freeze({ state: 'unknown' }) - -function normalizedBehavior(behavior: AccessPathBehavior): AccessPathBehavior { - return Object.freeze({ - ...behavior, - capabilities: Object.freeze(normalizeAccessCapabilities(behavior.capabilities)), - }) -} - -function signingValue(input: { - principalUserId: string - resource: CanonicalResourceIdentity - seed: AccessPathSeed - authorizationRevision: string -}): string { - return JSON.stringify([ - 'access_path_v1', - input.principalUserId, - resourceIdentityKey(input.resource), - input.seed.kind, - input.seed.grantId, - input.seed.teamId ?? null, - input.authorizationRevision, - normalizedBehavior(input.seed.behavior), - ]) -} - -export function buildAccessPath(input: { - principalUserId: string - resource: CanonicalResourceIdentity - seed: AccessPathSeed - authorizationRevision: string -}): AccessPath { - if (!input.principalUserId.trim() || !input.seed.grantId.trim() || !input.authorizationRevision) { - throw new Error('access_path_identity_incomplete') - } - if (input.seed.kind === 'team' && !input.seed.teamId?.trim()) { - throw new Error('access_path_team_missing') - } - if (input.seed.kind === 'direct' && input.seed.teamId) { - throw new Error('access_path_direct_team_invalid') - } - const behavior = normalizedBehavior(input.seed.behavior) - const digest = createHmac('sha256', config.sessionJwtPrivateKey) - .update(signingValue({ ...input, seed: { ...input.seed, behavior } }), 'utf8') - .digest('base64url') - return Object.freeze({ - id: `ap1_${digest}`, - kind: input.seed.kind, - grantId: input.seed.grantId, - ...(input.seed.teamId ? { teamId: input.seed.teamId } : {}), - ...(input.seed.currentRole ? { currentRole: input.seed.currentRole } : {}), - authorizationRevision: input.authorizationRevision, - behavior, - }) -} - -function dimensionIsEquivalent(left: BehaviorDimension, right: BehaviorDimension): boolean { - return left.state === 'known' && right.state === 'known' && left.value === right.value -} - -export function accessPathsAreEquivalent(left: AccessPath, right: AccessPath): boolean { - if (left.id === right.id) return true - if (JSON.stringify(left.behavior.capabilities) !== JSON.stringify(right.behavior.capabilities)) { - return false - } - return ( - dimensionIsEquivalent(left.behavior.budget, right.behavior.budget) && - dimensionIsEquivalent(left.behavior.credentialPolicy, right.behavior.credentialPolicy) && - dimensionIsEquivalent(left.behavior.approvalPolicy, right.behavior.approvalPolicy) && - dimensionIsEquivalent(left.behavior.filesystemScope, right.behavior.filesystemScope) && - dimensionIsEquivalent(left.behavior.runtime, right.behavior.runtime) && - dimensionIsEquivalent(left.behavior.providerModelPolicy, right.behavior.providerModelPolicy) && - dimensionIsEquivalent(left.behavior.audit, right.behavior.audit) - ) -} - -export function canonicalAccessPathTuple( - path: Pick -): string { - return JSON.stringify([ - path.kind === 'direct' ? 0 : 1, - path.kind, - path.teamId ?? '', - path.grantId, - ]) -} - -export function compareCanonicalAccessPaths( - left: Pick, - right: Pick -): number { - return compareCanonicalUtf8Text(canonicalAccessPathTuple(left), canonicalAccessPathTuple(right)) -} - -export function selectEquivalentAccessPath(paths: readonly AccessPath[]): AccessPath | null { - if (paths.length === 0) return null - if (paths.length === 1) return paths[0] - if (!paths.every(path => accessPathsAreEquivalent(paths[0], path))) return null - return [...paths].sort(compareCanonicalAccessPaths)[0] -} - -export function accessPathHandleEquals(left: string, right: string): boolean { - const leftBytes = Buffer.from(left) - const rightBytes = Buffer.from(right) - return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes) -} diff --git a/control-api/src/services/access/authorityCandidates.ts b/control-api/src/services/access/authorityCandidates.ts deleted file mode 100644 index 820f366d77..0000000000 --- a/control-api/src/services/access/authorityCandidates.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { TeamRole } from '../../profileTypes.js' -import { - type AccessPathBehavior, - type AccessPathSeed, - knownBehavior, - unknownBehavior, -} from './accessPath.js' -import { type AccessCapability, normalizeAccessCapabilities } from './capabilityRegistry.js' - -export type OperationalPathBinding = Readonly<{ - resourceType: 'host' | 'context' | 'mcp_server' | 'workflow_recipe' | 'shared_filesystem' - logicalId: string - providerUid: string - relationships: readonly Readonly<{ - instanceId: string - behaviorAttributes: Readonly> - }>[] -}> - -export type AuthorityCandidate = AccessPathSeed & - Readonly<{ operationalBindings: readonly OperationalPathBinding[] }> - -function behavior(input: { - userId: string - kind: 'direct' | 'team' - teamId?: string - runtimeSensitive?: boolean - filesystemScope?: string | null - runtimeRef?: string | null - providerModelRef?: string | null - approvalRef?: string | null -}): AccessPathBehavior { - const runtimeSensitive = input.runtimeSensitive === true - return Object.freeze({ - capabilities: Object.freeze([]), - budget: runtimeSensitive ? unknownBehavior() : knownBehavior(null), - credentialPolicy: runtimeSensitive ? unknownBehavior() : knownBehavior(null), - approvalPolicy: - input.approvalRef !== undefined - ? knownBehavior(input.approvalRef) - : runtimeSensitive - ? unknownBehavior() - : knownBehavior(null), - filesystemScope: - input.filesystemScope !== undefined - ? knownBehavior(input.filesystemScope) - : knownBehavior(null), - runtime: - input.runtimeRef !== undefined - ? knownBehavior(input.runtimeRef) - : runtimeSensitive - ? unknownBehavior() - : knownBehavior(null), - providerModelPolicy: - input.providerModelRef !== undefined - ? knownBehavior(input.providerModelRef) - : runtimeSensitive - ? unknownBehavior() - : knownBehavior(null), - audit: knownBehavior(input.kind === 'team' ? `team:${input.teamId}` : `user:${input.userId}`), - }) -} - -export function authorityCandidate(input: { - userId: string - kind: 'direct' | 'team' - grantId: string - teamId?: string - currentRole?: TeamRole - capabilities: readonly AccessCapability[] - runtimeSensitive?: boolean - filesystemScope?: string | null - runtimeRef?: string | null - providerModelRef?: string | null - approvalRef?: string | null - operationalBindings?: readonly OperationalPathBinding[] -}): AuthorityCandidate { - return Object.freeze({ - kind: input.kind, - grantId: input.grantId, - ...(input.teamId ? { teamId: input.teamId } : {}), - ...(input.currentRole ? { currentRole: input.currentRole } : {}), - behavior: Object.freeze({ - ...behavior(input), - capabilities: Object.freeze(normalizeAccessCapabilities(input.capabilities)), - }), - operationalBindings: Object.freeze([...(input.operationalBindings ?? [])]), - }) -} - -export function authorityCandidateFromGrantRow(input: { - row: Record - userId: string - capabilities: readonly AccessCapability[] - runtimeSensitive?: boolean - operationalBindings?: readonly OperationalPathBinding[] - filesystemScope?: string | null - runtimeRef?: string | null - providerModelRef?: string | null - approvalRef?: string | null -}): AuthorityCandidate | null { - const kind = input.row.kind === 'direct' ? 'direct' : input.row.kind === 'team' ? 'team' : null - const grantId = String(input.row.grant_id ?? '') - if (!kind || !grantId) return null - const teamId = kind === 'team' ? String(input.row.team_id ?? '') : undefined - const currentRole = kind === 'team' ? (String(input.row.current_role) as TeamRole) : undefined - if (kind === 'team' && (!teamId || !['admin', 'inviter', 'member'].includes(currentRole!))) { - return null - } - return authorityCandidate({ - userId: input.userId, - kind, - grantId, - ...(teamId ? { teamId } : {}), - ...(currentRole ? { currentRole } : {}), - capabilities: input.capabilities, - runtimeSensitive: input.runtimeSensitive, - operationalBindings: input.operationalBindings, - filesystemScope: input.filesystemScope, - runtimeRef: input.runtimeRef, - providerModelRef: input.providerModelRef, - approvalRef: input.approvalRef, - }) -} diff --git a/control-api/src/services/access/authorizationRevision.ts b/control-api/src/services/access/authorizationRevision.ts deleted file mode 100644 index 940cc3ae16..0000000000 --- a/control-api/src/services/access/authorizationRevision.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { createHash } from 'node:crypto' -import type { TeamRole } from '../../profileTypes.js' -import type { AccessPathSeed } from './accessPath.js' -import { compareCanonicalUtf8Text } from './canonicalText.js' -import { type CanonicalResourceIdentity, resourceIdentityKey } from './resourceIdentity.js' - -export type AuthorizationMembershipRevision = Readonly<{ - teamId: string - role: TeamRole - membershipUpdatedAt: string - teamRevision: string -}> - -export type AuthorizationRelationship = Readonly<{ - type: string - targetResourceId: string - instanceId?: string -}> - -export type AuthorizationRevisionInput = Readonly<{ - principalUserId: string - sessionContract: 'v1' | 'v2' - sessionRevision: string - userRevision: string - memberships: readonly AuthorizationMembershipRevision[] - resource: CanonicalResourceIdentity - resourceRevision: string - sourceStateRevision: string - relationshipsRevision: string - candidates: readonly AccessPathSeed[] -}> - -export function canonicalAccessPathSeeds(candidates: readonly T[]): T[] { - const values = new Map() - for (const candidate of candidates) { - values.set( - JSON.stringify([ - candidate.kind, - candidate.teamId ?? null, - candidate.grantId, - candidate.behavior, - ]), - candidate - ) - } - return [...values.entries()] - .sort(([left], [right]) => compareCanonicalUtf8Text(left, right)) - .map(([, value]) => value) -} - -function canonicalCandidates(candidates: readonly AccessPathSeed[]) { - return canonicalAccessPathSeeds(candidates).map(candidate => ({ - kind: candidate.kind, - grantId: candidate.grantId, - teamId: candidate.teamId ?? null, - currentRole: candidate.currentRole ?? null, - behavior: candidate.behavior, - })) -} - -export function authorizationRevision(input: AuthorizationRevisionInput): string { - return `ar1_${createHash('sha256') - .update( - JSON.stringify({ - policy: 'user-access-authority-v1', - principalUserId: input.principalUserId, - sessionContract: input.sessionContract, - sessionRevision: input.sessionRevision, - userRevision: input.userRevision, - memberships: [...input.memberships].sort((left, right) => - compareCanonicalUtf8Text(left.teamId, right.teamId) - ), - resource: resourceIdentityKey(input.resource), - resourceRevision: input.resourceRevision, - sourceStateRevision: input.sourceStateRevision, - relationshipsRevision: input.relationshipsRevision, - candidates: canonicalCandidates(input.candidates), - }) - ) - .digest('base64url')}` -} - -export function canonicalAuthorizationRelationships( - relationships: readonly AuthorizationRelationship[] -): AuthorizationRelationship[] { - const values = new Map() - for (const relationship of relationships) { - values.set( - JSON.stringify([ - relationship.type, - relationship.targetResourceId, - relationship.instanceId ?? null, - ]), - Object.freeze({ ...relationship }) - ) - } - return [...values.entries()] - .sort(([left], [right]) => compareCanonicalUtf8Text(left, right)) - .map(([, value]) => value) -} - -export function databaseRelationshipsRevision( - relationships: readonly AuthorizationRelationship[] -): string { - return JSON.stringify(canonicalAuthorizationRelationships(relationships)) -} - -function canonicalValue(value: unknown): string { - if (value === undefined) return 'null' - if (value === null || typeof value !== 'object') return JSON.stringify(value) - if (Array.isArray(value)) return `[${value.map(canonicalValue).join(',')}]` - return `{${Object.entries(value as Record) - .sort(([left], [right]) => compareCanonicalUtf8Text(left, right)) - .map(([key, item]) => `${JSON.stringify(key)}:${canonicalValue(item)}`) - .join(',')}}` -} - -export function revisionOfValues(values: readonly unknown[]): string { - return createHash('sha256') - .update(`[${values.map(canonicalValue).sort(compareCanonicalUtf8Text).join(',')}]`) - .digest('base64url') -} diff --git a/control-api/src/services/access/capabilityRegistry.ts b/control-api/src/services/access/capabilityRegistry.ts deleted file mode 100644 index 886d34460e..0000000000 --- a/control-api/src/services/access/capabilityRegistry.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { TeamRole } from '../../profileTypes.js' -import { compareCanonicalUtf8Text } from './canonicalText.js' - -export const ACCESS_CAPABILITIES = [ - 'user.profile.read', - 'team.read', - 'team.manage', - 'team.member.read', - 'team.member.invite', - 'team.member.manage', - 'host.read', - 'host.use', - 'host.manage', - 'host.activity.read', - 'host.activity.read_all', - 'context.read', - 'context.use', - 'context.manage', - 'mcp_server.read', - 'mcp_server.use', - 'workflow.read', - 'workflow.trigger', - 'workflow.run.manage', - 'workflow.artifact.read', - 'workflow.artifact.delete', - 'workflow.approval.decide', - 'gfs.read', - 'gfs.write', - 'gfs.delete', - 'gfs.manage_acl', - 'gfs.share', - 'shared_filesystem.read', - 'shared_filesystem.write', - 'sandbox_app.read', - 'sandbox_app.use', - 'sandbox_oauth.vend', - 'remote_desktop.use', - 'chat.read', - 'chat.message.invoke', - 'task.read', - 'task.manage', - 'model.read', - 'model.select', - 'session.read', - 'session.manage', - 'notification.read', -] as const - -export type AccessCapability = (typeof ACCESS_CAPABILITIES)[number] - -const capabilities = new Set(ACCESS_CAPABILITIES) - -export function isAccessCapability(value: unknown): value is AccessCapability { - return typeof value === 'string' && capabilities.has(value) -} - -export function requireAccessCapability(value: unknown): AccessCapability { - if (!isAccessCapability(value)) throw new Error('capability_unknown') - return value -} - -export function normalizeAccessCapabilities(values: readonly unknown[]): AccessCapability[] { - const normalized = new Set() - for (const value of values) normalized.add(requireAccessCapability(value)) - return [...normalized].sort(compareCanonicalUtf8Text) -} - -export function capabilitiesForTeamRole(role: TeamRole): AccessCapability[] { - const values: AccessCapability[] = ['team.read'] - if (role === 'admin') { - values.push('team.manage', 'team.member.read', 'team.member.invite', 'team.member.manage') - } else if (role === 'inviter') { - values.push('team.member.read', 'team.member.invite') - } - return normalizeAccessCapabilities(values) -} - -export function gfsPermissionsToCapabilities(value: unknown): AccessCapability[] { - if (!Array.isArray(value)) return [] - return normalizeAccessCapabilities( - value.flatMap(permission => { - const capability = `gfs.${String(permission)}` - return isAccessCapability(capability) ? [capability] : [] - }) - ) -} diff --git a/control-api/src/services/access/contextIdentitySql.ts b/control-api/src/services/access/contextIdentitySql.ts deleted file mode 100644 index d21c987318..0000000000 --- a/control-api/src/services/access/contextIdentitySql.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Resolve one namespace-qualified Context reference to its canonical metadata.name identity. - * Both arguments are static SQL fragments owned by the caller; request data remains in binds. - */ -export function canonicalContextLogicalIdSql( - environmentExpression: string, - referenceExpression: string -): string { - return `(SELECT MIN(identity_candidate.canonical_id) - FROM ( - SELECT canonical_resource.logical_id AS canonical_id - FROM operational_resource_index canonical_resource - WHERE canonical_resource.environment_id = ${environmentExpression} - AND canonical_resource.resource_type = 'context' - AND canonical_resource.logical_id = ${referenceExpression} - UNION ALL - SELECT alias_relationship.source_id AS canonical_id - FROM operational_resource_relationships alias_relationship - JOIN operational_resource_index alias_source - ON alias_source.environment_id = alias_relationship.environment_id - AND alias_source.resource_type = 'context' - AND alias_source.logical_id = alias_relationship.source_id - AND alias_source.provider_uid = alias_relationship.source_provider_uid - WHERE alias_relationship.environment_id = ${environmentExpression} - AND alias_relationship.source_type = 'context' - AND alias_relationship.relationship_type = 'context_identity_alias' - AND alias_relationship.target_type = 'context' - AND alias_relationship.target_id = ${referenceExpression} - ) identity_candidate - HAVING COUNT(DISTINCT identity_candidate.canonical_id) = 1)` -} diff --git a/control-api/src/services/access/databaseAuthorityCandidates.ts b/control-api/src/services/access/databaseAuthorityCandidates.ts deleted file mode 100644 index 427e50c759..0000000000 --- a/control-api/src/services/access/databaseAuthorityCandidates.ts +++ /dev/null @@ -1,282 +0,0 @@ -import type { DbClient } from '../../db.js' -import { query } from './accessAuthorityQuery.js' -import type { AccessExecutionBudget } from './accessExecutionBudget.js' -import { - type AuthorityCandidate, - authorityCandidate, - authorityCandidateFromGrantRow, -} from './authorityCandidates.js' -import { - type AccessCapability, - capabilitiesForTeamRole, - gfsPermissionsToCapabilities, -} from './capabilityRegistry.js' -import type { PrincipalAuthoritySnapshot } from './principalAuthoritySnapshot.js' -import type { CanonicalResourceIdentity } from './resourceIdentity.js' - -export type ResourceAuthorityResult = Readonly<{ - exists: boolean - candidates: readonly AuthorityCandidate[] - relationships: readonly Readonly<{ - type: string - targetResourceId: string - instanceId?: string - }>[] - validUntil: Date | null -}> - -/** - * The exact code points `String.prototype.trim` removes (ECMA-262 WhiteSpace - * and LineTerminator), so `btrim(value, ...)` and `parseWorkflowTriggerIntent` - * agree on what an absent or present approval requester is. `btrim`'s default - * set only removes U+0020. - */ -const WORKFLOW_TRIGGER_TRIM_CODE_POINTS = - '\u0009\u000a\u000b\u000c\u000d\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005' + - '\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff' - -export async function loadDatabaseCandidates(input: { - db: Pick - budget: AccessExecutionBudget - snapshot: PrincipalAuthoritySnapshot - resource: CanonicalResourceIdentity -}): Promise { - const { resource, snapshot } = input - if (resource.type === 'user') { - const owns = resource.logicalId === snapshot.userId - return { - exists: owns, - candidates: owns - ? [ - authorityCandidate({ - userId: snapshot.userId, - kind: 'direct', - grantId: `users:${snapshot.userId}`, - capabilities: ['user.profile.read'], - }), - ] - : [], - relationships: [], - validUntil: null, - } - } - if (resource.type === 'team') { - const membership = snapshot.memberships.find(value => value.teamId === resource.logicalId) - return { - exists: Boolean(membership), - candidates: membership - ? [ - authorityCandidate({ - userId: snapshot.userId, - kind: 'team', - grantId: `team_members:${membership.teamId}:${snapshot.userId}`, - teamId: membership.teamId, - currentRole: membership.role, - capabilities: capabilitiesForTeamRole(membership.role), - }), - ] - : [], - relationships: membership - ? [ - Object.freeze({ - type: 'membership', - targetResourceId: `user:${snapshot.userId}`, - }), - ] - : [], - validUntil: null, - } - } - - const result = await query( - input.db, - input.budget, - `WITH candidates AS ( - SELECT 'direct'::text AS kind, 'workflow_runs:user:' || wr.run_id AS grant_id, - NULL::uuid AS team_id, NULL::text AS current_role, - NULL::text[] AS permissions, wr.recipe_namespace, wr.recipe_name, - NULL::timestamptz AS valid_until, wr.team_id::text AS related_team, - wr.usage_team_id AS usage_team, NULL::text AS drive, - NULL::text AS parent_resource - FROM workflow_runs wr - JOIN user_workflow_triggers uwt - ON uwt.user_id = $1 - AND uwt.recipe_namespace = wr.recipe_namespace - AND uwt.recipe_name = wr.recipe_name - WHERE $2::text = 'workflow_run' AND wr.run_id::text = $3 - AND wr.actor_type = 'user' AND wr.actor_id = $1 - AND wr.team_id IS NULL AND wr.usage_team_id IS NULL - UNION ALL - SELECT 'team', 'workflow_runs:team:' || tm.team_id || ':' || wr.run_id, - tm.team_id, tm.role, NULL, wr.recipe_namespace, wr.recipe_name, - NULL, wr.team_id::text, wr.usage_team_id, NULL, NULL - FROM workflow_runs wr - JOIN team_members tm - ON (tm.team_id = wr.team_id OR tm.team_id::text = wr.usage_team_id) - AND tm.user_id = $1 AND tm.status = 'active' - JOIN team_workflow_triggers twt - ON twt.team_id = tm.team_id - AND twt.recipe_namespace = wr.recipe_namespace - AND twt.recipe_name = wr.recipe_name - WHERE $2::text = 'workflow_run' AND wr.run_id::text = $3 - AND (wr.actor_type IN ('scheduled', 'autonomous') - OR (wr.actor_type = 'user' AND (wr.actor_id = $1 OR wr.actor_id IS NULL))) - UNION ALL - SELECT 'direct', 'workflow_approvals:user:' || war.id, NULL, NULL, NULL, - war.recipe_namespace, war.recipe_name, war.expires_at, NULL, NULL, NULL, NULL - FROM workflow_approval_requests war - JOIN user_workflow_triggers uwt - ON uwt.user_id = $1 - AND uwt.recipe_namespace = war.recipe_namespace - AND uwt.recipe_name = war.recipe_name - WHERE $2::text = 'workflow_approval' AND war.id::text = $3 - AND war.target_user_id = $1 AND war.status = 'pending' AND war.expires_at > NOW() - UNION ALL - SELECT 'team', 'workflow_approvals:team:' || tm.team_id || ':' || war.id, - tm.team_id, tm.role, NULL, war.recipe_namespace, war.recipe_name, - war.expires_at, war.target_team_id::text, NULL, NULL, NULL - FROM workflow_approval_requests war - JOIN team_members tm - ON tm.team_id = war.target_team_id AND tm.user_id = $1 AND tm.status = 'active' - JOIN workflow_recipe_allowed_teams wat - ON wat.team_id = tm.team_id - AND wat.recipe_namespace = war.recipe_namespace - AND wat.recipe_name = war.recipe_name - WHERE $2::text = 'workflow_approval' AND war.id::text = $3 - AND war.status = 'pending' AND war.expires_at > NOW() - AND ( - jsonb_typeof(war.payload #> '{metadata,workflowTrigger,requesterUserId}') - IS DISTINCT FROM 'string' - OR btrim( - war.payload #>> '{metadata,workflowTrigger,requesterUserId}', $5::text - ) IN ('', $1::text) - ) - UNION ALL - SELECT CASE WHEN g.subject_type = 'user' THEN 'direct' ELSE 'team' END, - 'gfs_grants:' || g.id, - CASE WHEN g.subject_type = 'team' THEN tm.team_id ELSE NULL END, - tm.role, g.permissions, NULL, NULL, NULL, NULL, NULL, g.drive, - gr.parent_resource_id::text - FROM gfs_grants g - JOIN gfs_resources gr ON gr.resource_id = g.resource_id AND gr.deleted_at IS NULL - LEFT JOIN team_members tm - ON g.subject_type = 'team' AND tm.team_id::text = g.subject_id - AND tm.user_id = $1 AND tm.status = 'active' - WHERE $2::text = 'gfs_resource' AND g.resource_id::text = $3 - AND ((g.subject_type = 'user' AND g.subject_id = $1::text) - OR (g.subject_type = 'team' AND tm.team_id IS NOT NULL)) - UNION ALL - SELECT CASE WHEN s.subject_type = 'user' THEN 'direct' ELSE 'team' END, - 'gfs_shares:' || s.id, - CASE WHEN s.subject_type = 'team' THEN tm.team_id ELSE NULL END, - tm.role, s.permissions, NULL, NULL, NULL, NULL, NULL, s.drive, - gr.parent_resource_id::text - FROM gfs_shares s - JOIN gfs_resources gr ON gr.resource_id = s.resource_id AND gr.deleted_at IS NULL - LEFT JOIN team_members tm - ON s.subject_type = 'team' AND tm.team_id::text = s.subject_id - AND tm.user_id = $1 AND tm.status = 'active' - WHERE $2::text = 'gfs_resource' AND s.resource_id::text = $3 - AND ((s.subject_type = 'user' AND s.subject_id = $1::text) - OR (s.subject_type = 'team' AND tm.team_id IS NOT NULL)) - UNION ALL - SELECT 'direct', 'notifications:user:' || nd.id, NULL, NULL, NULL, - NULL, NULL, nd.expires_at, NULL, NULL, NULL, NULL - FROM notification_deliveries nd - WHERE $2::text = 'notification' AND nd.id::text = $3 - AND nd.audience->>'userId' = $1::text - AND (nd.expires_at IS NULL OR nd.expires_at > NOW()) - UNION ALL - SELECT 'team', 'notifications:team:' || tm.team_id || ':' || nd.id, - tm.team_id, tm.role, NULL, NULL, NULL, nd.expires_at, - tm.team_id::text, NULL, NULL, NULL - FROM notification_deliveries nd - JOIN team_members tm - ON tm.team_id::text = nd.audience->>'teamId' - AND tm.user_id = $1 AND tm.status = 'active' - WHERE $2::text = 'notification' AND nd.id::text = $3 - AND (nd.expires_at IS NULL OR nd.expires_at > NOW()) - ) - SELECT * FROM candidates - ORDER BY kind, team_id NULLS FIRST, grant_id - LIMIT $4`, - [ - snapshot.userId, - resource.type, - resource.logicalId, - input.budget.limits.accessPaths + 1, - WORKFLOW_TRIGGER_TRIM_CODE_POINTS, - ] - ) - const rows = result.rows as Record[] - const candidates = rows.flatMap(row => { - const type = resource.type - const capabilities: AccessCapability[] = - type === 'workflow_run' - ? [ - 'workflow.read', - 'workflow.run.manage', - 'workflow.artifact.read', - 'workflow.artifact.delete', - ] - : type === 'workflow_approval' - ? ['workflow.approval.decide'] - : type === 'gfs_resource' - ? gfsPermissionsToCapabilities(row.permissions) - : ['notification.read'] - const value = authorityCandidateFromGrantRow({ - row, - userId: snapshot.userId, - capabilities, - runtimeSensitive: type === 'workflow_run', - approvalRef: type === 'workflow_approval' ? `approval:${resource.logicalId}` : undefined, - filesystemScope: - type === 'gfs_resource' - ? JSON.stringify({ drive: row.drive, resourceId: resource.logicalId }) - : undefined, - }) - return value ? [value] : [] - }) - const relationships: Array<{ - type: string - targetResourceId: string - instanceId?: string - }> = [] - for (const row of rows) { - if (resource.type === 'workflow_run' || resource.type === 'workflow_approval') { - relationships.push( - Object.freeze({ - type: 'recipe', - targetResourceId: `workflow_recipe:${String(row.recipe_namespace)}/${String(row.recipe_name)}`, - }) - ) - continue - } - if (resource.type === 'gfs_resource' && row.parent_resource) { - relationships.push( - Object.freeze({ - type: 'parent', - targetResourceId: `gfs_resource:${String(row.parent_resource)}`, - }) - ) - continue - } - if (resource.type === 'notification' && row.related_team) { - relationships.push( - Object.freeze({ type: 'team', targetResourceId: `team:${String(row.related_team)}` }) - ) - } - } - const validTimes = rows - .map(row => (row.valid_until ? new Date(String(row.valid_until)) : null)) - .filter((value): value is Date => value !== null) - return Object.freeze({ - exists: candidates.length > 0, - candidates: Object.freeze(candidates), - relationships: Object.freeze(relationships), - validUntil: - validTimes.length > 0 - ? new Date(Math.min(...validTimes.map(value => value.getTime()))) - : null, - }) -} diff --git a/control-api/src/services/access/exactOperationalAuthorization.ts b/control-api/src/services/access/exactOperationalAuthorization.ts deleted file mode 100644 index 122b70ea78..0000000000 --- a/control-api/src/services/access/exactOperationalAuthorization.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { config } from '../../config.js' -import type { K8sGateway } from '../../k8s.js' -import type { ClerumResourceType } from '../../types.js' -import type { AccessExecutionBudget } from './accessExecutionBudget.js' -import type { OperationalPathBinding } from './authorityCandidates.js' -import { projectOperationalObject } from './operationalAccessProjection.js' - -type ExactGateway = Pick - -type ExactBindingSpec = Readonly<{ - plural: Extract< - ClerumResourceType, - 'hosts' | 'contexts' | 'mcpservers' | 'workflowrecipes' | 'sharedfilesystems' - > - namespace: string -}> - -const BINDING_SPECS: Readonly> = - Object.freeze({ - host: { plural: 'hosts', namespace: config.hostsNamespace }, - context: { plural: 'contexts', namespace: config.contextsNamespace }, - mcp_server: { plural: 'mcpservers', namespace: config.mcpServersNamespace }, - workflow_recipe: { plural: 'workflowrecipes', namespace: config.sandboxNamespace }, - shared_filesystem: { - plural: 'sharedfilesystems', - namespace: config.sharedFilesystemsNamespace, - }, - }) - -function scopedName(logicalId: string): { namespace: string; name: string } | null { - const separator = logicalId.indexOf('/') - if (separator < 1 || separator === logicalId.length - 1) return null - return { namespace: logicalId.slice(0, separator), name: logicalId.slice(separator + 1) } -} - -function canonicalBehavior(value: Readonly>): string { - return JSON.stringify( - Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right))) - ) -} - -function isNotFound(error: unknown): boolean { - if (!error || typeof error !== 'object') return false - const value = error as Record - return value.httpStatus === 404 || value.statusCode === 404 || value.code === 404 -} - -export type ExactOperationalAuthorizationResult = - | Readonly<{ status: 'current' }> - | Readonly<{ status: 'stale' | 'not_found' }> - | Readonly<{ status: 'unavailable' }> - -export async function validateExactOperationalBindings(input: { - gateway: ExactGateway | undefined - budget: AccessExecutionBudget - bindings: readonly OperationalPathBinding[] -}): Promise { - if (input.bindings.length === 0) return { status: 'current' } - if (!input.gateway) return { status: 'unavailable' } - const unique = new Map() - for (const binding of input.bindings) { - unique.set(JSON.stringify([binding.resourceType, binding.logicalId]), binding) - } - if (unique.size > input.budget.limits.exactKubernetesGets) { - return { status: 'unavailable' } - } - for (const binding of unique.values()) { - const identity = scopedName(binding.logicalId) - const spec = BINDING_SPECS[binding.resourceType] - if (!identity || identity.namespace !== spec.namespace) return { status: 'stale' } - input.budget.charge({ kind: 'exactKubernetesGets' }) - try { - const timeoutSeconds = Math.max( - 1, - Math.min(60, Math.ceil(input.budget.remainingMs() / 1_000)) - ) - const object = await input.budget.runProducer(signal => - input.gateway!.getResourceExact(spec.plural, identity.name, identity.namespace, { - timeoutSeconds, - signal, - }) - ) - const projection = projectOperationalObject({ - environmentId: 'exact-validation', - plural: spec.plural, - namespace: identity.namespace, - object, - behaviorFingerprintKey: config.sessionJwtPrivateKey, - maxObjectBytes: input.budget.limits.objectBytes, - relationshipNamespaces: { - context: config.contextsNamespace, - mcpServer: config.mcpServersNamespace, - sharedFilesystem: config.sharedFilesystemsNamespace, - }, - }) - input.budget.chargeOperationalObject(projection.contentBytes, true) - const root = projection.resources.find( - resource => - resource.resourceType === binding.resourceType && resource.logicalId === binding.logicalId - ) - if (!root || !root.enabled || root.deletedAt) return { status: 'not_found' } - if (root.providerUid !== binding.providerUid) return { status: 'stale' } - for (const expected of binding.relationships) { - const current = projection.relationships.find( - relationship => relationship.relationshipInstanceId === expected.instanceId - ) - if ( - !current || - canonicalBehavior(current.behaviorAttributes) !== - canonicalBehavior(expected.behaviorAttributes) - ) { - return { status: 'stale' } - } - } - } catch (error) { - return isNotFound(error) ? { status: 'not_found' } : { status: 'unavailable' } - } - } - return { status: 'current' } -} diff --git a/control-api/src/services/access/liveAuthorizationResolver.ts b/control-api/src/services/access/liveAuthorizationResolver.ts deleted file mode 100644 index 9c329fa6c5..0000000000 --- a/control-api/src/services/access/liveAuthorizationResolver.ts +++ /dev/null @@ -1,415 +0,0 @@ -import type { DbClient } from '../../db.js' -import type { K8sGateway } from '../../k8s.js' -import type { ExternalSessionAuthorityContext } from '../auth/externalSessionAuthentication.js' -import { - filterCandidatesForOperationTarget, - loadResourceAuthority, - operationTargetIsCurrentlyValid, -} from './accessAuthorityStore.js' -import { configureAccessAuthorityTransaction } from './accessAuthorityTransaction.js' -import { withAccessDatabaseTransaction } from './accessDatabaseQuery.js' -import { - AccessBudgetExceededError, - AccessExecutionBudget, - AccessExecutionCancelledError, -} from './accessExecutionBudget.js' -import { - type AccessPath, - accessPathHandleEquals, - buildAccessPath, - selectEquivalentAccessPath, -} from './accessPath.js' -import type { AuthorityCandidate } from './authorityCandidates.js' -import { - authorizationRevision, - canonicalAccessPathSeeds, - databaseRelationshipsRevision, -} from './authorizationRevision.js' -import { type AccessCapability, isAccessCapability } from './capabilityRegistry.js' -import { validateExactOperationalBindings } from './exactOperationalAuthorization.js' -import { - OperationTargetValidationError, - stableOperationTarget, - validateOperationTarget, -} from './operationTarget.js' -import { canonicalEnvironmentId } from './operationalAccessProjection.js' -import { - type OperationalResourceGraphResult, - isOperationalAccessResourceType, - loadOperationalResourceGraph, -} from './operationalAccessReader.js' -import { loadPrincipalAuthoritySnapshot } from './principalAuthoritySnapshot.js' -import { - type CanonicalResourceIdentity, - canonicalResourceIdentity, - resourceIdentityKey, -} from './resourceIdentity.js' - -export type LiveAuthorizationInput = Readonly<{ - session: ExternalSessionAuthorityContext - requiredCapability: unknown - resource: CanonicalResourceIdentity - operationTarget?: unknown - requestedAccessPathId?: string -}> - -export type LiveAuthorizationResult = - | Readonly<{ - status: 'allowed' - effectiveCapabilities: readonly AccessCapability[] - paths: readonly AccessPath[] - selectedPath: AccessPath - authorizationRevision: string - validUntil: string | null - }> - | Readonly<{ - status: 'denied' - code: 'forbidden' | 'unknown_capability' | 'session_not_live' - }> - | Readonly<{ status: 'invalid'; code: 'invalid_resource' | 'invalid_operation_target' }> - | Readonly<{ status: 'not_found'; code: 'not_found' }> - | Readonly<{ - status: 'access_path_required' - code: 'access_path_required' - authorizationRevision: string - safePathDescriptors: readonly Readonly<{ - id: string - kind: 'direct' | 'team' - teamId?: string - }>[] - }> - | Readonly<{ - status: 'access_path_stale' - code: 'access_path_stale' - currentAuthorizationRevision: string - }> - | Readonly<{ - status: 'unavailable' - dependencyClass: 'authorization_store' | 'operational_resource_store' | 'capacity' - retryable: true - correlationId?: string - }> - -type ResolverTransaction = (work: (db: DbClient) => Promise) => Promise - -function validPathHandle(value: string | undefined): value is string { - return typeof value === 'string' && /^ap1_[A-Za-z0-9_-]{43}$/.test(value) -} - -function dependencyForError( - error: unknown -): 'authorization_store' | 'operational_resource_store' | 'capacity' { - if ( - error instanceof AccessBudgetExceededError || - error instanceof AccessExecutionCancelledError - ) { - return 'capacity' - } - return 'authorization_store' -} - -function candidateForPath( - path: AccessPath, - candidates: readonly AuthorityCandidate[] -): AuthorityCandidate | undefined { - return candidates.find( - candidate => - candidate.kind === path.kind && - candidate.grantId === path.grantId && - candidate.teamId === path.teamId - ) -} - -export class AuthorizationRequestMemo { - private readonly values = new Map>() - - constructor(private readonly budget: AccessExecutionBudget) {} - - getOrCreate( - input: LiveAuthorizationInput, - targetKey: string, - factory: () => Promise - ): Promise { - const key = JSON.stringify([ - input.session, - input.requiredCapability, - resourceIdentityKey(input.resource), - targetKey, - input.requestedAccessPathId ?? null, - ]) - const existing = this.values.get(key) - if (existing) return existing - this.budget.charge({ kind: 'memoEntries' }) - this.budget.charge({ kind: 'memoBytes', amount: Buffer.byteLength(key, 'utf8') }) - const created = factory() - this.values.set(key, created) - void created.catch(() => this.values.delete(key)) - return created - } -} - -async function resolveInTransaction(input: { - request: LiveAuthorizationInput - capability: AccessCapability - operationTarget: ReturnType - db: DbClient - gateway?: Pick - budget: AccessExecutionBudget -}): Promise { - await configureAccessAuthorityTransaction(input.db, input.budget) - const snapshot = await loadPrincipalAuthoritySnapshot({ - db: input.db, - budget: input.budget, - session: input.request.session, - resource: input.request.resource, - }) - if (!snapshot) return { status: 'not_found', code: 'not_found' } - if (!snapshot.sessionLive) return { status: 'denied', code: 'session_not_live' } - - let graph: OperationalResourceGraphResult | null = null - if (isOperationalAccessResourceType(input.request.resource.type)) { - try { - graph = await loadOperationalResourceGraph({ - db: input.db, - budget: input.budget, - environmentId: input.request.resource.environmentId, - resourceType: input.request.resource.type, - logicalId: input.request.resource.logicalId, - }) - } catch (error) { - if ( - error instanceof AccessBudgetExceededError || - error instanceof AccessExecutionCancelledError - ) { - throw error - } - return { - status: 'unavailable', - dependencyClass: 'operational_resource_store', - retryable: true, - } - } - if (graph.status === 'unavailable') { - return { - status: 'unavailable', - dependencyClass: 'operational_resource_store', - retryable: true, - } - } - if (graph.status === 'not_found') return { status: 'not_found', code: 'not_found' } - } - if ( - !(await operationTargetIsCurrentlyValid({ - db: input.db, - budget: input.budget, - resource: input.request.resource, - capability: input.capability, - operationTarget: input.operationTarget, - })) - ) { - return { status: 'denied', code: 'forbidden' } - } - - const authority = await loadResourceAuthority({ - db: input.db, - budget: input.budget, - snapshot, - resource: input.request.resource, - operationTarget: input.operationTarget, - operationalGraph: graph, - }) - if (!authority.exists) return { status: 'not_found', code: 'not_found' } - const targeted = filterCandidatesForOperationTarget({ - candidates: authority.candidates, - capability: input.capability, - operationTarget: input.operationTarget, - }) - const identitySeeds = canonicalAccessPathSeeds(targeted) - if (identitySeeds.length === 0) return { status: 'denied', code: 'forbidden' } - input.budget.charge({ kind: 'accessPaths', amount: identitySeeds.length }) - const capable = Object.freeze( - identitySeeds.filter(candidate => candidate.behavior.capabilities.includes(input.capability)) - ) - if (capable.length === 0) return { status: 'denied', code: 'forbidden' } - - const revision = authorizationRevision({ - principalUserId: snapshot.userId, - sessionContract: snapshot.sessionContract, - sessionRevision: snapshot.sessionRevision, - userRevision: snapshot.userRevision, - memberships: snapshot.memberships, - resource: input.request.resource, - resourceRevision: snapshot.resourceRevision, - sourceStateRevision: - graph?.status === 'current' ? graph.sourceStateRevision : 'database-resource', - relationshipsRevision: - graph?.status === 'current' - ? graph.relationshipsRevision - : databaseRelationshipsRevision(authority.relationships), - candidates: identitySeeds, - }) - const paths = Object.freeze( - capable.map(seed => - buildAccessPath({ - principalUserId: snapshot.userId, - resource: input.request.resource, - seed, - authorizationRevision: revision, - }) - ) - ) - - let selectedPath: AccessPath | undefined - if (input.request.requestedAccessPathId) { - selectedPath = paths.find(path => - accessPathHandleEquals(path.id, input.request.requestedAccessPathId!) - ) - if (!selectedPath) { - return { - status: 'access_path_stale', - code: 'access_path_stale', - currentAuthorizationRevision: revision, - } - } - } else { - selectedPath = selectEquivalentAccessPath(paths) ?? undefined - if (!selectedPath) { - return { - status: 'access_path_required', - code: 'access_path_required', - authorizationRevision: revision, - safePathDescriptors: Object.freeze( - paths.map(path => - Object.freeze({ - id: path.id, - kind: path.kind, - ...(path.teamId ? { teamId: path.teamId } : {}), - }) - ) - ), - } - } - } - - const selectedCandidate = candidateForPath(selectedPath, capable) - if (!selectedCandidate) { - return { - status: 'access_path_stale', - code: 'access_path_stale', - currentAuthorizationRevision: revision, - } - } - const exact = await validateExactOperationalBindings({ - gateway: input.gateway, - budget: input.budget, - bindings: selectedCandidate.operationalBindings, - }) - if (exact.status === 'not_found') return { status: 'not_found', code: 'not_found' } - if (exact.status === 'stale') { - return input.request.requestedAccessPathId - ? { - status: 'access_path_stale', - code: 'access_path_stale', - currentAuthorizationRevision: revision, - } - : { - status: 'unavailable', - dependencyClass: 'operational_resource_store', - retryable: true, - } - } - if (exact.status === 'unavailable') { - return { - status: 'unavailable', - dependencyClass: 'operational_resource_store', - retryable: true, - } - } - return Object.freeze({ - status: 'allowed', - effectiveCapabilities: Object.freeze([...selectedPath.behavior.capabilities]), - paths, - selectedPath, - authorizationRevision: revision, - validUntil: authority.validUntil?.toISOString() ?? null, - }) -} - -export async function resolveLiveAuthorization( - input: LiveAuthorizationInput, - options: { - gateway?: Pick - budget?: AccessExecutionBudget - memo?: AuthorizationRequestMemo - transaction?: ResolverTransaction - correlationId?: string - } = {} -): Promise { - const ownedBudget = options.budget ? null : AccessExecutionBudget.create('action') - const budget = options.budget ?? ownedBudget! - try { - if (!isAccessCapability(input.requiredCapability)) { - return { status: 'denied', code: 'unknown_capability' } - } - if ( - input.session.userId.trim().length === 0 || - input.session.userId !== input.session.userId.trim() || - input.resource.environmentId !== canonicalEnvironmentId() - ) { - return { status: 'invalid', code: 'invalid_resource' } - } - let resource: CanonicalResourceIdentity - try { - resource = canonicalResourceIdentity(input.resource) - } catch { - return { status: 'invalid', code: 'invalid_resource' } - } - if (resourceIdentityKey(resource) !== resourceIdentityKey(input.resource)) { - return { status: 'invalid', code: 'invalid_resource' } - } - if ( - input.requestedAccessPathId !== undefined && - !validPathHandle(input.requestedAccessPathId) - ) { - return { status: 'invalid', code: 'invalid_operation_target' } - } - let operationTarget: ReturnType - try { - operationTarget = validateOperationTarget({ - capability: input.requiredCapability, - resource, - operationTarget: input.operationTarget, - }) - } catch (error) { - if (error instanceof OperationTargetValidationError) { - return { status: 'invalid', code: 'invalid_operation_target' } - } - throw error - } - const work = (db: DbClient) => - resolveInTransaction({ - request: { ...input, resource }, - capability: input.requiredCapability as AccessCapability, - operationTarget, - db, - gateway: options.gateway, - budget, - }) - const factory = () => - options.transaction - ? options.transaction(work) - : withAccessDatabaseTransaction(budget, work, { mode: 'caller_configured' }) - return options.memo - ? await options.memo.getOrCreate(input, stableOperationTarget(operationTarget), factory) - : await factory() - } catch (error) { - return { - status: 'unavailable', - dependencyClass: dependencyForError(error), - retryable: true, - ...(options.correlationId ? { correlationId: options.correlationId } : {}), - } - } finally { - ownedBudget?.close() - } -} diff --git a/control-api/src/services/access/operationTarget.ts b/control-api/src/services/access/operationTarget.ts deleted file mode 100644 index 00c592bb7c..0000000000 --- a/control-api/src/services/access/operationTarget.ts +++ /dev/null @@ -1,168 +0,0 @@ -import type { TeamRole } from '../../profileTypes.js' -import type { AccessCapability } from './capabilityRegistry.js' -import type { CanonicalResourceIdentity } from './resourceIdentity.js' - -export type ValidatedOperationTarget = Readonly> | null - -export class OperationTargetValidationError extends Error { - constructor(readonly code: 'missing' | 'invalid' | 'unsupported' | 'resource_mismatch') { - super(`Operation target ${code}`) - this.name = 'OperationTargetValidationError' - } -} - -const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i -const TEAM_ROLES = new Set(['admin', 'inviter', 'member']) -const READ_CAPABILITIES = new Set([ - 'user.profile.read', - 'team.read', - 'host.read', - 'context.read', - 'mcp_server.read', - 'workflow.read', - 'gfs.read', - 'shared_filesystem.read', - 'sandbox_app.read', - 'chat.read', - 'task.read', - 'model.read', - 'session.read', - 'notification.read', -]) - -function flatStringRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new OperationTargetValidationError('invalid') - } - const entries = Object.entries(value as Record) - if (entries.length === 0 || entries.length > 8) { - throw new OperationTargetValidationError('invalid') - } - const result: Record = {} - for (const [key, item] of entries) { - if (!/^[a-zA-Z][a-zA-Z0-9]{0,63}$/.test(key) || typeof item !== 'string') { - throw new OperationTargetValidationError('invalid') - } - const normalized = item.trim() - if (!normalized || normalized.length > 1_024 || /[\u0000-\u001f\u007f]/u.test(normalized)) { - throw new OperationTargetValidationError('invalid') - } - result[key] = normalized - } - return result -} - -function exactKeys( - value: Record, - required: readonly string[], - optional: readonly string[] = [] -) { - const allowed = new Set([...required, ...optional]) - if (required.some(key => !(key in value)) || Object.keys(value).some(key => !allowed.has(key))) { - throw new OperationTargetValidationError('invalid') - } -} - -function requireUuid(value: string | undefined): void { - if (!value || !UUID_PATTERN.test(value)) throw new OperationTargetValidationError('invalid') -} - -function requireRole(value: string | undefined): asserts value is TeamRole { - if (!value || !TEAM_ROLES.has(value as TeamRole)) { - throw new OperationTargetValidationError('invalid') - } -} - -function requireTargetTeam(resource: CanonicalResourceIdentity, teamId: string | undefined): void { - requireUuid(teamId) - if (resource.type !== 'team' || resource.logicalId !== teamId) { - throw new OperationTargetValidationError('resource_mismatch') - } -} - -export function validateOperationTarget(input: { - capability: AccessCapability - resource: CanonicalResourceIdentity - operationTarget?: unknown -}): ValidatedOperationTarget { - if (READ_CAPABILITIES.has(input.capability)) { - if (input.operationTarget !== undefined && input.operationTarget !== null) { - throw new OperationTargetValidationError('unsupported') - } - return null - } - if (input.operationTarget === undefined || input.operationTarget === null) { - throw new OperationTargetValidationError('missing') - } - const target = flatStringRecord(input.operationTarget) - - if (input.capability === 'team.manage') { - exactKeys(target, ['teamId', 'action']) - requireTargetTeam(input.resource, target.teamId) - if (!['rename', 'delete'].includes(target.action)) { - throw new OperationTargetValidationError('invalid') - } - return Object.freeze(target) - } - - if (input.capability === 'team.member.read') { - exactKeys(target, ['teamId'], ['userId']) - requireTargetTeam(input.resource, target.teamId) - if (target.userId) requireUuid(target.userId) - return Object.freeze(target) - } - - if (input.capability === 'team.member.invite') { - exactKeys(target, ['teamId', 'action', 'role'], ['invitationId']) - requireTargetTeam(input.resource, target.teamId) - requireRole(target.role) - if (!['create', 'resend', 'revoke'].includes(target.action)) { - throw new OperationTargetValidationError('invalid') - } - if (target.action === 'create' && target.invitationId) { - throw new OperationTargetValidationError('invalid') - } - if (target.action !== 'create') requireUuid(target.invitationId) - return Object.freeze(target) - } - - if (input.capability === 'team.member.manage') { - exactKeys(target, ['teamId', 'action', 'userId', 'role']) - requireTargetTeam(input.resource, target.teamId) - requireUuid(target.userId) - requireRole(target.role) - if (!['set_role', 'remove'].includes(target.action)) { - throw new OperationTargetValidationError('invalid') - } - return Object.freeze(target) - } - - if (input.capability === 'workflow.approval.decide') { - exactKeys(target, ['approvalId', 'decision']) - requireUuid(target.approvalId) - if ( - input.resource.type !== 'workflow_approval' || - input.resource.logicalId !== target.approvalId - ) { - throw new OperationTargetValidationError('resource_mismatch') - } - if (!['approve', 'deny'].includes(target.decision)) { - throw new OperationTargetValidationError('invalid') - } - return Object.freeze(target) - } - - // PR 2 owns the exact domain target schemas for runtime/file/app mutation capabilities. - // Failing closed here prevents PR 1 from advertising a generic unbound action decision. - throw new OperationTargetValidationError('unsupported') -} - -export function stableOperationTarget(target: ValidatedOperationTarget): string { - return JSON.stringify( - target - ? Object.fromEntries( - Object.entries(target).sort(([left], [right]) => left.localeCompare(right)) - ) - : null - ) -} diff --git a/control-api/src/services/access/operationalAccessIndex.ts b/control-api/src/services/access/operationalAccessIndex.ts deleted file mode 100644 index 38490f7bd6..0000000000 --- a/control-api/src/services/access/operationalAccessIndex.ts +++ /dev/null @@ -1,600 +0,0 @@ -import { type DbClient, pool } from '../../db.js' -import { runAccessDatabaseQuery, withAccessDatabaseTransaction } from './accessDatabaseQuery.js' -import type { AccessExecutionBudget } from './accessExecutionBudget.js' -import type { - OperationalObjectProjection, - OperationalRelationshipRecord, - OperationalResourceRecord, - OperationalSourceFamily, -} from './operationalAccessProjection.js' - -export type OperationalSourceStatus = 'current' | 'relisting' | 'unavailable' - -export type OperationalSourceState = Readonly<{ - environmentId: string - sourceFamily: OperationalSourceFamily - generation: number - stagingGeneration: number | null - resourceVersion: string | null - status: OperationalSourceStatus - safeErrorCode: string | null -}> - -export class OperationalSourceStateError extends Error { - constructor(readonly code: 'stale_generation' | 'source_not_current' | 'source_unavailable') { - super(`Operational access source state error: ${code}`) - this.name = 'OperationalSourceStateError' - } -} - -type TransactionRunner = (work: (db: DbClient) => Promise) => Promise -type ConnectionPool = Pick - -function connectionPoolFor(db: DbClient): ConnectionPool | null { - const candidate = db as DbClient & Partial - if (typeof candidate.connect !== 'function') return null - return { connect: candidate.connect.bind(candidate) } -} - -function numberValue(value: unknown): number { - const result = Number(value) - if (!Number.isSafeInteger(result) || result < 0) { - throw new OperationalSourceStateError('source_unavailable') - } - return result -} - -function sourceState(row: unknown): OperationalSourceState { - const value = row as Record - const status = String(value.status) - if (!['current', 'relisting', 'unavailable'].includes(status)) { - throw new OperationalSourceStateError('source_unavailable') - } - return Object.freeze({ - environmentId: String(value.environment_id), - sourceFamily: String(value.source_family) as OperationalSourceFamily, - generation: numberValue(value.generation), - stagingGeneration: - value.staging_generation === null || value.staging_generation === undefined - ? null - : numberValue(value.staging_generation), - resourceVersion: typeof value.resource_version === 'string' ? value.resource_version : null, - status: status as OperationalSourceStatus, - safeErrorCode: typeof value.safe_error_code === 'string' ? value.safe_error_code : null, - }) -} - -function resourcePayload(value: OperationalResourceRecord, generation: number) { - return { - environment_id: value.environmentId, - resource_type: value.resourceType, - logical_id: value.logicalId, - source_family: value.sourceFamily, - source_generation: generation, - provider_uid: value.providerUid, - provider_resource_version: value.providerResourceVersion, - display_name: value.displayName, - enabled: value.enabled, - deleted_at: value.deletedAt, - observed_generation: value.observedGeneration, - content_bytes: value.contentBytes, - } -} - -function relationshipPayload(value: OperationalRelationshipRecord, generation: number) { - return { - environment_id: value.environmentId, - source_type: value.sourceType, - source_id: value.sourceId, - relationship_type: value.relationshipType, - target_type: value.targetType, - target_id: value.targetId, - relationship_instance_id: value.relationshipInstanceId, - behavior_attributes: value.behaviorAttributes, - source_family: value.sourceFamily, - source_provider_uid: value.sourceProviderUid, - source_resource_version: value.sourceResourceVersion, - source_generation: generation, - observed_generation: value.observedGeneration, - content_bytes: value.contentBytes, - } -} - -export class OperationalAccessIndex { - constructor( - private readonly readDb: DbClient = pool, - private readonly transaction?: TransactionRunner - ) {} - - private async runTransaction( - budget: AccessExecutionBudget, - work: (db: DbClient) => Promise - ): Promise { - if (this.transaction) return this.transaction(work) - const connectionPool = connectionPoolFor(this.readDb) - if (!connectionPool) throw new OperationalSourceStateError('source_unavailable') - return withAccessDatabaseTransaction(budget, work, { - mode: 'caller_configured', - connectionPool, - }) - } - - private async query( - db: DbClient, - budget: AccessExecutionBudget, - text: string, - values: unknown[] = [], - options: { statementTimeoutMs?: number } = {} - ) { - return runAccessDatabaseQuery(db, budget, text, values, options) - } - - private async configureTransaction(db: DbClient, budget: AccessExecutionBudget): Promise { - const statementTimeoutMs = budget.statementTimeoutMs() - await this.query( - db, - budget, - `SELECT set_config('statement_timeout', $1, true)`, - [`${statementTimeoutMs}ms`], - { statementTimeoutMs } - ) - } - - async beginRelist(input: { - environmentId: string - sourceFamily: OperationalSourceFamily - budget: AccessExecutionBudget - }): Promise { - return this.runTransaction(input.budget, async db => { - await this.configureTransaction(db, input.budget) - const result = await this.query( - db, - input.budget, - `WITH source_state AS ( - INSERT INTO operational_catalog_source_state( - environment_id, source_family, generation, staging_generation, - status, safe_error_code, updated_at - ) - VALUES($1, $2, 1, 2, 'relisting', NULL, NOW()) - ON CONFLICT (environment_id, source_family) DO UPDATE - SET generation = operational_catalog_source_state.generation + 1, - staging_generation = operational_catalog_source_state.generation + 2, - status = 'relisting', - safe_error_code = NULL, - updated_at = NOW() - RETURNING staging_generation - ), cleared_relationships AS ( - DELETE FROM operational_relationships_staging - WHERE environment_id = $1 - AND source_family = $2 - RETURNING 1 - ), cleared_resources AS ( - DELETE FROM operational_resource_index_staging - WHERE environment_id = $1 - AND source_family = $2 - RETURNING 1 - ) - SELECT staging_generation FROM source_state`, - [input.environmentId, input.sourceFamily] - ) - return numberValue((result.rows[0] as Record).staging_generation) - }) - } - - async stageRelistPage(input: { - environmentId: string - sourceFamily: OperationalSourceFamily - stagingGeneration: number - projections: readonly OperationalObjectProjection[] - budget: AccessExecutionBudget - }): Promise { - const resources = input.projections.flatMap(projection => - projection.resources.map(value => resourcePayload(value, input.stagingGeneration)) - ) - const relationships = input.projections.flatMap(projection => - projection.relationships.map(value => relationshipPayload(value, input.stagingGeneration)) - ) - await this.runTransaction(input.budget, async db => { - await this.configureTransaction(db, input.budget) - const result = await this.query( - db, - input.budget, - `WITH valid_generation AS ( - SELECT 1 - FROM operational_catalog_source_state - WHERE environment_id = $1 - AND source_family = $2 - AND status = 'relisting' - AND staging_generation = $3 - FOR UPDATE - ), resource_rows AS ( - SELECT value - FROM jsonb_array_elements($4::jsonb) AS value - WHERE EXISTS (SELECT 1 FROM valid_generation) - ), inserted_resources AS ( - INSERT INTO operational_resource_index_staging( - environment_id, resource_type, logical_id, source_family, source_generation, - provider_uid, provider_resource_version, display_name, enabled, deleted_at, - observed_generation, content_bytes, observed_at - ) - SELECT value->>'environment_id', value->>'resource_type', value->>'logical_id', - value->>'source_family', (value->>'source_generation')::bigint, - value->>'provider_uid', value->>'provider_resource_version', - value->>'display_name', (value->>'enabled')::boolean, - (value->>'deleted_at')::timestamptz, - (value->>'observed_generation')::bigint, - (value->>'content_bytes')::bigint, NOW() - FROM resource_rows - ON CONFLICT ( - environment_id, source_family, source_generation, resource_type, logical_id - ) DO UPDATE - SET source_family = EXCLUDED.source_family, - source_generation = EXCLUDED.source_generation, - provider_uid = EXCLUDED.provider_uid, - provider_resource_version = EXCLUDED.provider_resource_version, - display_name = EXCLUDED.display_name, - enabled = EXCLUDED.enabled, - deleted_at = EXCLUDED.deleted_at, - observed_generation = EXCLUDED.observed_generation, - content_bytes = EXCLUDED.content_bytes, - observed_at = NOW() - RETURNING 1 - ), relationship_rows AS ( - SELECT value - FROM jsonb_array_elements($5::jsonb) AS value - WHERE EXISTS (SELECT 1 FROM valid_generation) - ), inserted_relationships AS ( - INSERT INTO operational_relationships_staging( - environment_id, source_type, source_id, relationship_type, target_type, target_id, - relationship_instance_id, behavior_attributes, source_family, source_provider_uid, - source_resource_version, source_generation, observed_generation, content_bytes, - observed_at - ) - SELECT value->>'environment_id', value->>'source_type', value->>'source_id', - value->>'relationship_type', value->>'target_type', value->>'target_id', - value->>'relationship_instance_id', value->'behavior_attributes', - value->>'source_family', value->>'source_provider_uid', - value->>'source_resource_version', (value->>'source_generation')::bigint, - (value->>'observed_generation')::bigint, - (value->>'content_bytes')::bigint, NOW() - FROM relationship_rows - ON CONFLICT ( - environment_id, source_family, source_generation, - source_type, source_id, relationship_type, - target_type, target_id, relationship_instance_id - ) DO UPDATE - SET behavior_attributes = EXCLUDED.behavior_attributes, - source_family = EXCLUDED.source_family, - source_provider_uid = EXCLUDED.source_provider_uid, - source_resource_version = EXCLUDED.source_resource_version, - source_generation = EXCLUDED.source_generation, - observed_generation = EXCLUDED.observed_generation, - content_bytes = EXCLUDED.content_bytes, - observed_at = NOW() - RETURNING 1 - ) - SELECT EXISTS (SELECT 1 FROM valid_generation) AS valid`, - [ - input.environmentId, - input.sourceFamily, - input.stagingGeneration, - JSON.stringify(resources), - JSON.stringify(relationships), - ] - ) - if (!(result.rows[0] as { valid?: boolean } | undefined)?.valid) { - throw new OperationalSourceStateError('stale_generation') - } - }) - } - - async promoteRelist(input: { - environmentId: string - sourceFamily: OperationalSourceFamily - stagingGeneration: number - resourceVersion: string - budget: AccessExecutionBudget - }): Promise { - await this.runTransaction(input.budget, async db => { - await this.configureTransaction(db, input.budget) - const locked = await this.query( - db, - input.budget, - `SELECT staging_generation - FROM operational_catalog_source_state - WHERE environment_id = $1 AND source_family = $2 - AND status = 'relisting' - FOR UPDATE`, - [input.environmentId, input.sourceFamily] - ) - if ( - numberValue((locked.rows[0] as Record | undefined)?.staging_generation) !== - input.stagingGeneration - ) { - throw new OperationalSourceStateError('stale_generation') - } - await this.query( - db, - input.budget, - `DELETE FROM operational_resource_relationships - WHERE environment_id = $1 AND source_family = $2`, - [input.environmentId, input.sourceFamily] - ) - await this.query( - db, - input.budget, - `DELETE FROM operational_resource_index - WHERE environment_id = $1 AND source_family = $2`, - [input.environmentId, input.sourceFamily] - ) - await this.query( - db, - input.budget, - `INSERT INTO operational_resource_index( - environment_id, resource_type, logical_id, source_family, source_generation, - provider_uid, provider_resource_version, display_name, enabled, deleted_at, - observed_generation, content_bytes, observed_at - ) - SELECT environment_id, resource_type, logical_id, source_family, source_generation, - provider_uid, provider_resource_version, display_name, enabled, deleted_at, - observed_generation, content_bytes, observed_at - FROM operational_resource_index_staging - WHERE environment_id = $1 AND source_family = $2 - AND source_generation = $3`, - [input.environmentId, input.sourceFamily, input.stagingGeneration] - ) - await this.query( - db, - input.budget, - `INSERT INTO operational_resource_relationships( - environment_id, source_type, source_id, relationship_type, target_type, target_id, - relationship_instance_id, behavior_attributes, source_family, source_provider_uid, - source_resource_version, source_generation, observed_generation, content_bytes, - observed_at - ) - SELECT environment_id, source_type, source_id, relationship_type, target_type, target_id, - relationship_instance_id, behavior_attributes, source_family, source_provider_uid, - source_resource_version, source_generation, observed_generation, content_bytes, - observed_at - FROM operational_relationships_staging - WHERE environment_id = $1 AND source_family = $2 - AND source_generation = $3`, - [input.environmentId, input.sourceFamily, input.stagingGeneration] - ) - await this.query( - db, - input.budget, - `DELETE FROM operational_relationships_staging - WHERE environment_id = $1 AND source_family = $2`, - [input.environmentId, input.sourceFamily] - ) - await this.query( - db, - input.budget, - `DELETE FROM operational_resource_index_staging - WHERE environment_id = $1 AND source_family = $2`, - [input.environmentId, input.sourceFamily] - ) - await this.query( - db, - input.budget, - `UPDATE operational_catalog_source_state - SET generation = $3, - staging_generation = NULL, - resource_version = $4, - status = 'current', - last_success_at = NOW(), - safe_error_code = NULL, - updated_at = NOW() - WHERE environment_id = $1 AND source_family = $2`, - [input.environmentId, input.sourceFamily, input.stagingGeneration, input.resourceVersion] - ) - }) - } - - async applyWatchProjection(input: { - environmentId: string - sourceFamily: OperationalSourceFamily - resourceVersion: string - projection: OperationalObjectProjection - deleted: boolean - budget: AccessExecutionBudget - }): Promise { - return this.runTransaction(input.budget, async db => { - await this.configureTransaction(db, input.budget) - const locked = await this.query( - db, - input.budget, - `SELECT generation, status - FROM operational_catalog_source_state - WHERE environment_id = $1 AND source_family = $2 - FOR UPDATE`, - [input.environmentId, input.sourceFamily] - ) - const state = locked.rows[0] as Record | undefined - if (!state || state.status !== 'current') { - throw new OperationalSourceStateError('source_not_current') - } - const generation = numberValue(state.generation) + 1 - await this.query( - db, - input.budget, - `DELETE FROM operational_resource_relationships - WHERE environment_id = $1 AND source_family = $2 - AND ( - source_provider_uid = $3 - OR (source_type = $4 AND source_id = $5) - )`, - [ - input.environmentId, - input.sourceFamily, - input.projection.providerUid, - input.projection.rootType, - input.projection.rootId, - ] - ) - await this.query( - db, - input.budget, - `DELETE FROM operational_resource_index - WHERE environment_id = $1 AND source_family = $2 - AND ( - provider_uid = $3 - OR provider_uid LIKE $3 || ':%' - OR logical_id = $4 - )`, - [ - input.environmentId, - input.sourceFamily, - input.projection.providerUid, - input.projection.rootId, - ] - ) - if (!input.deleted) { - await this.insertCurrentProjection(db, input.budget, input.projection, generation) - } - await this.query( - db, - input.budget, - `UPDATE operational_catalog_source_state - SET generation = $3, - resource_version = $4, - last_success_at = NOW(), - safe_error_code = NULL, - updated_at = NOW() - WHERE environment_id = $1 AND source_family = $2`, - [input.environmentId, input.sourceFamily, generation, input.resourceVersion] - ) - return generation - }) - } - - private async insertCurrentProjection( - db: DbClient, - budget: AccessExecutionBudget, - projection: OperationalObjectProjection, - generation: number - ): Promise { - const resources = projection.resources.map(value => resourcePayload(value, generation)) - const relationships = projection.relationships.map(value => - relationshipPayload(value, generation) - ) - await this.query( - db, - budget, - `WITH resource_rows AS ( - SELECT value FROM jsonb_array_elements($1::jsonb) AS value - ), inserted_resources AS ( - INSERT INTO operational_resource_index( - environment_id, resource_type, logical_id, source_family, source_generation, - provider_uid, provider_resource_version, display_name, enabled, deleted_at, - observed_generation, content_bytes, observed_at - ) - SELECT value->>'environment_id', value->>'resource_type', value->>'logical_id', - value->>'source_family', (value->>'source_generation')::bigint, - value->>'provider_uid', value->>'provider_resource_version', - value->>'display_name', (value->>'enabled')::boolean, - (value->>'deleted_at')::timestamptz, - (value->>'observed_generation')::bigint, - (value->>'content_bytes')::bigint, NOW() - FROM resource_rows - RETURNING 1 - ), relationship_rows AS ( - SELECT value FROM jsonb_array_elements($2::jsonb) AS value - ) - INSERT INTO operational_resource_relationships( - environment_id, source_type, source_id, relationship_type, target_type, target_id, - relationship_instance_id, behavior_attributes, source_family, source_provider_uid, - source_resource_version, source_generation, observed_generation, content_bytes, - observed_at - ) - SELECT value->>'environment_id', value->>'source_type', value->>'source_id', - value->>'relationship_type', value->>'target_type', value->>'target_id', - value->>'relationship_instance_id', value->'behavior_attributes', - value->>'source_family', value->>'source_provider_uid', - value->>'source_resource_version', (value->>'source_generation')::bigint, - (value->>'observed_generation')::bigint, - (value->>'content_bytes')::bigint, NOW() - FROM relationship_rows`, - [JSON.stringify(resources), JSON.stringify(relationships)] - ) - } - - async markSourceState(input: { - environmentId: string - sourceFamily: OperationalSourceFamily - status: Extract - safeErrorCode: string - budget: AccessExecutionBudget - }): Promise { - await this.runTransaction(input.budget, async db => { - await this.configureTransaction(db, input.budget) - await this.query( - db, - input.budget, - `INSERT INTO operational_catalog_source_state( - environment_id, source_family, generation, status, - last_error_at, safe_error_code, updated_at - ) - VALUES($1, $2, 1, $3, NOW(), $4, NOW()) - ON CONFLICT (environment_id, source_family) DO UPDATE - SET generation = operational_catalog_source_state.generation + 1, - staging_generation = NULL, - status = EXCLUDED.status, - last_error_at = NOW(), - safe_error_code = EXCLUDED.safe_error_code, - updated_at = NOW()`, - [input.environmentId, input.sourceFamily, input.status, input.safeErrorCode] - ) - }) - } - - async recordWatchBookmark(input: { - environmentId: string - sourceFamily: OperationalSourceFamily - resourceVersion: string - budget: AccessExecutionBudget - }): Promise { - await this.runTransaction(input.budget, async db => { - await this.configureTransaction(db, input.budget) - const result = await this.query( - db, - input.budget, - `UPDATE operational_catalog_source_state - SET resource_version = $3, - last_success_at = NOW(), - updated_at = NOW() - WHERE environment_id = $1 - AND source_family = $2 - AND status = 'current'`, - [input.environmentId, input.sourceFamily, input.resourceVersion] - ) - if ((result.rowCount ?? 0) !== 1) { - throw new OperationalSourceStateError('source_not_current') - } - }) - } - - async loadSourceStates(input: { - environmentId: string - sourceFamilies: readonly OperationalSourceFamily[] - budget: AccessExecutionBudget - }): Promise { - if (input.sourceFamilies.length === 0) return [] - return this.runTransaction(input.budget, async db => { - await this.configureTransaction(db, input.budget) - const result = await this.query( - db, - input.budget, - `SELECT environment_id, source_family, generation, staging_generation, - resource_version, status, safe_error_code - FROM operational_catalog_source_state - WHERE environment_id = $1 - AND source_family = ANY($2::text[]) - ORDER BY source_family`, - [input.environmentId, input.sourceFamilies] - ) - return Object.freeze(result.rows.map(sourceState)) - }) - } -} diff --git a/control-api/src/services/access/operationalAccessIndexer.ts b/control-api/src/services/access/operationalAccessIndexer.ts deleted file mode 100644 index fc467e18fe..0000000000 --- a/control-api/src/services/access/operationalAccessIndexer.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { config } from '../../config.js' -import type { K8sGateway } from '../../k8s.js' -import type { ClerumResourceType } from '../../types.js' -import { AccessExecutionBudget } from './accessExecutionBudget.js' -import { OperationalAccessIndex } from './operationalAccessIndex.js' -import { - OPERATIONAL_SOURCE_FAMILIES, - type OperationalObjectProjection, - type OperationalSourceFamily, - canonicalEnvironmentId, - projectOperationalObject, -} from './operationalAccessProjection.js' - -type IndexedPlural = Extract< - ClerumResourceType, - 'hosts' | 'contexts' | 'mcpservers' | 'workflowrecipes' | 'sharedfilesystems' -> - -export type OperationalSourceSpec = Readonly<{ - family: OperationalSourceFamily - plural: IndexedPlural - namespace: string -}> - -export const operationalSourceSpecs: readonly OperationalSourceSpec[] = Object.freeze([ - { family: 'host', plural: 'hosts', namespace: config.hostsNamespace }, - { family: 'context', plural: 'contexts', namespace: config.contextsNamespace }, - { family: 'mcp_server', plural: 'mcpservers', namespace: config.mcpServersNamespace }, - { - family: 'workflow_recipe', - plural: 'workflowrecipes', - namespace: config.sandboxNamespace, - }, - { - family: 'shared_filesystem', - plural: 'sharedfilesystems', - namespace: config.sharedFilesystemsNamespace, - }, -]) - -export type OperationalIndexGateway = Pick< - K8sGateway, - 'listResourcePage' | 'watchResource' | 'getResourceExact' -> - -export class OperationalWatchExpiredError extends Error { - constructor() { - super('Operational Kubernetes watch expired') - this.name = 'OperationalWatchExpiredError' - } -} - -function objectRecord(value: unknown): Record { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : {} -} - -function metadataResourceVersion(value: unknown): string { - const metadata = objectRecord(objectRecord(value).metadata) - const resourceVersion = - typeof metadata.resourceVersion === 'string' ? metadata.resourceVersion.trim() : '' - if (!resourceVersion || resourceVersion.length > 256 || resourceVersion.includes('\0')) { - throw new Error('operational_object_resource_version_invalid') - } - return resourceVersion -} - -function watchStatusCode(value: unknown): number | null { - const record = objectRecord(value) - const code = Number(record.code) - return Number.isSafeInteger(code) ? code : null -} - -function safeSourceError(error: unknown): string { - if (error instanceof OperationalWatchExpiredError) return 'watch_expired' - if (error instanceof Error && error.name === 'AbortError') return 'request_cancelled' - return 'operational_source_unavailable' -} - -function abortableDelay(milliseconds: number, signal: AbortSignal): Promise { - if (signal.aborted) return Promise.resolve() - return new Promise(resolve => { - const timer = setTimeout(done, milliseconds) - timer.unref?.() - function done() { - signal.removeEventListener('abort', done) - clearTimeout(timer) - resolve() - } - signal.addEventListener('abort', done, { once: true }) - }) -} - -export class OperationalAccessIndexer { - private readonly environmentId: string - private readonly relationshipNamespaces: Readonly<{ - context: string - mcpServer: string - sharedFilesystem: string - }> - - constructor( - private readonly gateway: OperationalIndexGateway, - private readonly index: OperationalAccessIndex = new OperationalAccessIndex(), - options: { - environmentId?: string - behaviorFingerprintKey?: string - retryDelayMs?: number - } = {} - ) { - this.environmentId = options.environmentId ?? canonicalEnvironmentId() - this.behaviorFingerprintKey = options.behaviorFingerprintKey ?? config.sessionJwtPrivateKey - this.retryDelayMs = options.retryDelayMs ?? 5_000 - if (!Number.isSafeInteger(this.retryDelayMs) || this.retryDelayMs < 100) { - throw new Error('operational index retry delay must be at least 100ms') - } - this.relationshipNamespaces = Object.freeze({ - context: config.contextsNamespace, - mcpServer: config.mcpServersNamespace, - sharedFilesystem: config.sharedFilesystemsNamespace, - }) - } - - private readonly behaviorFingerprintKey: string - private readonly retryDelayMs: number - - private projection( - spec: OperationalSourceSpec, - object: unknown, - maxObjectBytes: number - ): OperationalObjectProjection { - return projectOperationalObject({ - environmentId: this.environmentId, - plural: spec.plural, - namespace: spec.namespace, - object, - behaviorFingerprintKey: this.behaviorFingerprintKey, - maxObjectBytes, - relationshipNamespaces: this.relationshipNamespaces, - }) - } - - async reconcileSource(spec: OperationalSourceSpec, parentSignal?: AbortSignal): Promise { - const budget = AccessExecutionBudget.create('indexer', { parentSignal }) - try { - const stagingGeneration = await this.index.beginRelist({ - environmentId: this.environmentId, - sourceFamily: spec.family, - budget, - }) - let continueToken: string | undefined - let snapshotResourceVersion: string | null = null - do { - const timeoutSeconds = Math.max(1, Math.min(60, Math.ceil(budget.remainingMs() / 1_000))) - const page = await budget.runProducer(signal => - this.gateway.listResourcePage(spec.plural, spec.namespace, { - limit: budget.limits.kubernetesPageSize, - ...(continueToken ? { continueToken } : {}), - timeoutSeconds, - signal, - }) - ) - if (snapshotResourceVersion && page.resourceVersion !== snapshotResourceVersion) { - throw new Error('operational_relist_snapshot_changed') - } - snapshotResourceVersion ??= page.resourceVersion - const projections = page.items.map(item => { - const projection = this.projection(spec, item, budget.limits.objectBytes) - budget.chargeOperationalObject(projection.contentBytes, false) - if (projection.relationships.length > 0) { - budget.charge({ - kind: 'relationships', - amount: projection.relationships.length, - authorityRequired: false, - }) - } - return projection - }) - await this.index.stageRelistPage({ - environmentId: this.environmentId, - sourceFamily: spec.family, - stagingGeneration, - projections, - budget, - }) - continueToken = page.continueToken ?? undefined - } while (continueToken) - - if (!snapshotResourceVersion) { - throw new Error('operational_relist_resource_version_missing') - } - await this.index.promoteRelist({ - environmentId: this.environmentId, - sourceFamily: spec.family, - stagingGeneration, - resourceVersion: snapshotResourceVersion, - budget, - }) - return snapshotResourceVersion - } finally { - budget.close() - } - } - - async applyWatchEvent( - spec: OperationalSourceSpec, - phase: string, - object: unknown, - parentSignal?: AbortSignal - ): Promise { - if (phase === 'ERROR') { - if (watchStatusCode(object) === 410) throw new OperationalWatchExpiredError() - throw new Error('operational_watch_error') - } - if (!['ADDED', 'MODIFIED', 'DELETED', 'BOOKMARK'].includes(phase)) { - throw new Error('operational_watch_phase_invalid') - } - const resourceVersion = metadataResourceVersion(object) - const budget = AccessExecutionBudget.create('action', { parentSignal }) - try { - if (phase === 'BOOKMARK') { - await this.index.recordWatchBookmark({ - environmentId: this.environmentId, - sourceFamily: spec.family, - resourceVersion, - budget, - }) - return - } - const projection = this.projection(spec, object, budget.limits.objectBytes) - budget.chargeOperationalObject(projection.contentBytes, false) - if (projection.relationships.length > 0) { - budget.charge({ - kind: 'relationships', - amount: projection.relationships.length, - authorityRequired: false, - }) - } - await this.index.applyWatchProjection({ - environmentId: this.environmentId, - sourceFamily: spec.family, - resourceVersion, - projection, - deleted: phase === 'DELETED', - budget, - }) - } finally { - budget.close() - } - } - - async runSourceOnce(spec: OperationalSourceSpec, signal: AbortSignal): Promise { - const resourceVersion = await this.reconcileSource(spec, signal) - await this.gateway.watchResource( - spec.plural, - spec.namespace, - resourceVersion, - signal, - (phase, object) => this.applyWatchEvent(spec, phase, object, signal) - ) - if (!signal.aborted) throw new Error('operational_watch_closed') - } - - start(specs: readonly OperationalSourceSpec[] = operationalSourceSpecs): { - stop: () => void - completion: Promise - } { - if ( - specs.length !== OPERATIONAL_SOURCE_FAMILIES.length || - new Set(specs.map(spec => spec.family)).size !== OPERATIONAL_SOURCE_FAMILIES.length - ) { - throw new Error('operational index requires every source family exactly once') - } - const controller = new AbortController() - const loops = specs.map(spec => this.runSourceLoop(spec, controller.signal)) - return { - stop: () => controller.abort('shutdown'), - completion: Promise.all(loops).then(() => undefined), - } - } - - private async runSourceLoop(spec: OperationalSourceSpec, signal: AbortSignal): Promise { - while (!signal.aborted) { - try { - await this.runSourceOnce(spec, signal) - } catch (error) { - if (signal.aborted) break - const status = error instanceof OperationalWatchExpiredError ? 'relisting' : 'unavailable' - const budget = AccessExecutionBudget.create('action', { parentSignal: signal }) - try { - await this.index.markSourceState({ - environmentId: this.environmentId, - sourceFamily: spec.family, - status, - safeErrorCode: safeSourceError(error), - budget, - }) - } catch { - // The source remains unavailable if even its authoritative state cannot be recorded. - } finally { - budget.close() - } - await abortableDelay(this.retryDelayMs, signal) - } - } - } -} diff --git a/control-api/src/services/access/operationalAccessReader.ts b/control-api/src/services/access/operationalAccessReader.ts deleted file mode 100644 index d59a59ae8d..0000000000 --- a/control-api/src/services/access/operationalAccessReader.ts +++ /dev/null @@ -1,308 +0,0 @@ -import type { DbClient } from '../../db.js' -import { runAccessDatabaseQuery } from './accessDatabaseQuery.js' -import type { AccessExecutionBudget } from './accessExecutionBudget.js' -import { revisionOfValues } from './authorizationRevision.js' -import { canonicalContextLogicalIdSql } from './contextIdentitySql.js' -import type { - OperationalRelationshipRecord, - OperationalResourceType, - OperationalSourceFamily, -} from './operationalAccessProjection.js' -import type { AccessResourceType } from './resourceIdentity.js' - -export type OperationalIndexedResource = Readonly<{ - environmentId: string - resourceType: OperationalResourceType - logicalId: string - sourceFamily: OperationalSourceFamily - providerUid: string - providerResourceVersion: string - displayName: string - enabled: boolean - deletedAt: string | null - observedGeneration: number | null - contentBytes: number -}> - -export type OperationalIndexedRelationship = OperationalRelationshipRecord - -export type OperationalResourceGraphResult = - | Readonly<{ - status: 'current' - resource: OperationalIndexedResource - relationships: readonly OperationalIndexedRelationship[] - sourceStateRevision: string - relationshipsRevision: string - }> - | Readonly<{ status: 'not_found'; sourceStateRevision: string }> - | Readonly<{ status: 'unavailable'; safeCode: string }> - -const SOURCE_FAMILIES_BY_TYPE: Readonly< - Partial> -> = Object.freeze({ - host: ['host'], - context: ['context'], - mcp_server: ['mcp_server', 'context', 'host'], - workflow_recipe: ['workflow_recipe'], - shared_filesystem: ['shared_filesystem', 'context'], - sandbox_app: ['workflow_recipe'], -}) - -export function isOperationalAccessResourceType(type: AccessResourceType): boolean { - return Boolean(SOURCE_FAMILIES_BY_TYPE[type]) -} - -function sourceFamilyForType(type: AccessResourceType): OperationalSourceFamily | null { - if (type === 'sandbox_app') return 'workflow_recipe' - if (['host', 'context', 'mcp_server', 'workflow_recipe', 'shared_filesystem'].includes(type)) { - return type as OperationalSourceFamily - } - return null -} - -function integer(value: unknown, label: string): number { - const result = Number(value) - if (!Number.isSafeInteger(result) || result < 0) throw new Error(`${label}_invalid`) - return result -} - -function nullableInteger(value: unknown): number | null { - return value === null || value === undefined ? null : integer(value, 'operational_generation') -} - -function behaviorAttributes(value: unknown): Readonly> { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new Error('operational_relationship_behavior_invalid') - } - const entries = Object.entries(value as Record) - if (entries.length > 16) throw new Error('operational_relationship_behavior_invalid') - const normalized: Record = {} - for (const [key, item] of entries) { - if ( - !/^[a-zA-Z][a-zA-Z0-9]{0,63}$/.test(key) || - !['string', 'number', 'boolean'].includes(typeof item) || - (typeof item === 'string' && item.length > 1_024) || - (typeof item === 'number' && !Number.isFinite(item)) - ) { - throw new Error('operational_relationship_behavior_invalid') - } - normalized[key] = item as string | number | boolean - } - return Object.freeze(normalized) -} - -function parseResource(row: Record): OperationalIndexedResource { - return Object.freeze({ - environmentId: String(row.environment_id), - resourceType: String(row.resource_type) as OperationalResourceType, - logicalId: String(row.logical_id), - sourceFamily: String(row.source_family) as OperationalSourceFamily, - providerUid: String(row.provider_uid), - providerResourceVersion: String(row.provider_resource_version), - displayName: typeof row.display_name === 'string' ? row.display_name : String(row.logical_id), - enabled: row.enabled === true, - deletedAt: row.deleted_at ? new Date(String(row.deleted_at)).toISOString() : null, - observedGeneration: nullableInteger(row.observed_generation), - contentBytes: integer(row.content_bytes, 'operational_content_bytes'), - }) -} - -function parseRelationship(row: Record): OperationalIndexedRelationship { - return Object.freeze({ - environmentId: String(row.environment_id), - sourceType: String(row.source_type) as OperationalResourceType, - sourceId: String(row.source_id), - relationshipType: String( - row.relationship_type - ) as OperationalIndexedRelationship['relationshipType'], - targetType: String(row.target_type) as OperationalResourceType, - targetId: String(row.target_id), - relationshipInstanceId: String(row.relationship_instance_id), - behaviorAttributes: behaviorAttributes(row.behavior_attributes), - sourceFamily: String(row.source_family) as OperationalSourceFamily, - sourceProviderUid: String(row.source_provider_uid), - sourceResourceVersion: String(row.source_resource_version), - observedGeneration: nullableInteger(row.observed_generation), - contentBytes: integer(row.content_bytes, 'operational_relationship_content_bytes'), - }) -} - -async function budgetedQuery( - db: Pick, - budget: AccessExecutionBudget, - text: string, - values: unknown[] -) { - return runAccessDatabaseQuery(db, budget, text, values) -} - -export async function loadOperationalResourceGraph(input: { - db: Pick - budget: AccessExecutionBudget - environmentId: string - resourceType: AccessResourceType - logicalId: string -}): Promise { - const requiredFamilies = SOURCE_FAMILIES_BY_TYPE[input.resourceType] - const targetFamily = sourceFamilyForType(input.resourceType) - if (!requiredFamilies || !targetFamily) { - throw new Error('operational_resource_type_unsupported') - } - - const states = await budgetedQuery( - input.db, - input.budget, - `SELECT source_family, generation, resource_version, status, safe_error_code - FROM operational_catalog_source_state - WHERE environment_id = $1 - AND source_family = ANY($2::text[]) - ORDER BY source_family`, - [input.environmentId, requiredFamilies] - ) - const stateRows = states.rows as Record[] - const sourceStateRevision = revisionOfValues( - stateRows.map(row => [row.source_family, row.generation, row.resource_version, row.status]) - ) - if ( - stateRows.length !== requiredFamilies.length || - stateRows.some(row => row.status !== 'current') - ) { - return { status: 'unavailable', safeCode: 'operational_source_not_current' } - } - - const resourceResult = await budgetedQuery( - input.db, - input.budget, - `SELECT environment_id, resource_type, logical_id, source_family, - provider_uid, provider_resource_version, display_name, enabled, - deleted_at, observed_generation, content_bytes - FROM operational_resource_index - WHERE environment_id = $1 - AND resource_type = $2 - AND logical_id = $3 - AND source_family = $4 - LIMIT 1`, - [input.environmentId, input.resourceType, input.logicalId, targetFamily] - ) - const rawResource = resourceResult.rows[0] as Record | undefined - if (!rawResource) return { status: 'not_found', sourceStateRevision } - const resource = parseResource(rawResource) - input.budget.chargeOperationalObject(Math.max(1, resource.contentBytes), true) - if (!resource.enabled || resource.deletedAt) { - return { status: 'not_found', sourceStateRevision } - } - - // A Host whose configured Context reference does not resolve to exactly one - // canonical Context is incomplete in the catalog, so it must fail closed here - // as well instead of validating only the edges that still resolve. - if (input.resourceType === 'host') { - const unresolvedContexts = await budgetedQuery( - input.db, - input.budget, - `SELECT 1 AS unresolved_host_context - FROM operational_resource_relationships relationship - WHERE relationship.environment_id = $1 - AND relationship.source_type = 'host' - AND relationship.source_id = $2 - AND relationship.relationship_type = 'uses_context' - AND relationship.target_type = 'context' - AND ${canonicalContextLogicalIdSql('$1', 'relationship.target_id')} IS NULL - LIMIT 1`, - [input.environmentId, input.logicalId] - ) - if (unresolvedContexts.rows.length > 0) { - return { status: 'unavailable', safeCode: 'operational_source_unavailable' } - } - } - - const relationshipResult = await budgetedQuery( - input.db, - input.budget, - `SELECT relationship.environment_id, relationship.source_type, - relationship.source_id, relationship.relationship_type, - relationship.target_type, - CASE WHEN relationship.relationship_type = 'uses_context' - THEN ${canonicalContextLogicalIdSql('$1', 'relationship.target_id')} - ELSE relationship.target_id - END AS target_id, - relationship.relationship_instance_id, relationship.behavior_attributes, - source_family, source_provider_uid, source_resource_version, - observed_generation, content_bytes - FROM operational_resource_relationships relationship - WHERE relationship.environment_id = $1 - AND relationship.relationship_type <> 'context_identity_alias' - AND (relationship.relationship_type <> 'uses_context' - OR ${canonicalContextLogicalIdSql('$1', 'relationship.target_id')} IS NOT NULL) - AND ( - (relationship.source_type = $2 AND relationship.source_id = $3) - OR (relationship.target_type = $2 AND - (CASE WHEN relationship.relationship_type = 'uses_context' - THEN ${canonicalContextLogicalIdSql('$1', 'relationship.target_id')} - ELSE relationship.target_id - END) = $3) - OR ( - $2 = 'mcp_server' - AND relationship.source_type = 'host' - AND relationship.relationship_type = 'uses_context' - AND relationship.target_type = 'context' - AND ${canonicalContextLogicalIdSql('$1', 'relationship.target_id')} IN ( - SELECT mcp_edge.source_id - FROM operational_resource_relationships mcp_edge - WHERE mcp_edge.environment_id = $1 - AND mcp_edge.relationship_type = 'includes_mcp_server' - AND mcp_edge.target_type = 'mcp_server' - AND mcp_edge.target_id = $3 - ) - ) - ) - ORDER BY relationship.source_type, relationship.source_id, - relationship.relationship_type, relationship.target_type, - target_id, relationship.relationship_instance_id - LIMIT $4`, - [ - input.environmentId, - input.resourceType, - input.logicalId, - input.budget.limits.relationships + 1, - ] - ) - if (relationshipResult.rows.length > input.budget.limits.relationships) { - input.budget.charge({ - kind: 'relationships', - amount: relationshipResult.rows.length, - authorityRequired: true, - }) - } - const relationships = Object.freeze( - (relationshipResult.rows as Record[]).map(parseRelationship) - ) - if (relationships.length > 0) { - input.budget.charge({ kind: 'relationships', amount: relationships.length }) - const relationshipBytes = relationships.reduce( - (total, relationship) => total + relationship.contentBytes, - 0 - ) - if (relationshipBytes > 0) { - input.budget.charge({ kind: 'decodedBytes', amount: relationshipBytes }) - } - } - return Object.freeze({ - status: 'current', - resource, - relationships, - sourceStateRevision, - relationshipsRevision: revisionOfValues( - relationships.map(relationship => [ - relationship.sourceType, - relationship.sourceId, - relationship.relationshipType, - relationship.targetType, - relationship.targetId, - relationship.relationshipInstanceId, - relationship.behaviorAttributes, - relationship.sourceProviderUid, - relationship.sourceResourceVersion, - ]) - ), - }) -} diff --git a/control-api/src/services/access/operationalAuthorityCandidates.ts b/control-api/src/services/access/operationalAuthorityCandidates.ts deleted file mode 100644 index bff996fc70..0000000000 --- a/control-api/src/services/access/operationalAuthorityCandidates.ts +++ /dev/null @@ -1,387 +0,0 @@ -import type { DbClient } from '../../db.js' -import { query } from './accessAuthorityQuery.js' -import type { AccessExecutionBudget } from './accessExecutionBudget.js' -import { - type AuthorityCandidate, - type OperationalPathBinding, - authorityCandidateFromGrantRow, -} from './authorityCandidates.js' -import { compareCanonicalUtf8Text } from './canonicalText.js' -import type { AccessCapability } from './capabilityRegistry.js' -import { canonicalContextLogicalIdSql } from './contextIdentitySql.js' -import type { OperationalResourceGraphResult } from './operationalAccessReader.js' -import type { CanonicalResourceIdentity } from './resourceIdentity.js' -import { workflowTriggerGrantIdentitySql } from './workflowTriggerGrantIdentity.js' - -function scopedName(logicalId: string): { namespace: string; name: string } | null { - const separator = logicalId.indexOf('/') - if (separator < 1 || separator === logicalId.length - 1) return null - return { namespace: logicalId.slice(0, separator), name: logicalId.slice(separator + 1) } -} - -export async function loadSimpleOperationalGrantCandidates(input: { - db: Pick - budget: AccessExecutionBudget - userId: string - resource: CanonicalResourceIdentity - graph: Extract -}): Promise { - const identity = scopedName(input.resource.logicalId) - if (!identity) return [] - const isHost = input.resource.type === 'host' - const isContext = input.resource.type === 'context' - const isRecipe = ['workflow_recipe', 'sandbox_app'].includes(input.resource.type) - if (!isHost && !isContext && !isRecipe) return [] - const directWorkflowTriggerGrantId = workflowTriggerGrantIdentitySql('direct', 'uwt') - const teamWorkflowTriggerGrantId = workflowTriggerGrantIdentitySql('team', 'twt') - const result = await query( - input.db, - input.budget, - `WITH requested_context_references AS ( - SELECT $5::text AS logical_id, split_part($5::text, '/', 2) AS reference_name - UNION - SELECT alias_relationship.source_id, - split_part(alias_relationship.target_id, '/', 2) - FROM operational_resource_relationships alias_relationship - JOIN operational_resource_index alias_source - ON alias_source.environment_id = alias_relationship.environment_id - AND alias_source.resource_type = 'context' - AND alias_source.logical_id = alias_relationship.source_id - AND alias_source.provider_uid = alias_relationship.source_provider_uid - WHERE alias_relationship.environment_id = $6 - AND alias_relationship.source_type = 'context' - AND alias_relationship.source_id = $5 - AND alias_relationship.relationship_type = 'context_identity_alias' - AND alias_relationship.target_type = 'context' - ), candidates AS ( - SELECT 'direct'::text AS kind, - 'user_agents:' || ua.user_id || ':' || ua.agent_name AS grant_id, - NULL::uuid AS team_id, NULL::text AS current_role - FROM user_agents ua - WHERE $2::text = 'host' AND ua.user_id = $1 AND ua.agent_name = $4 - UNION ALL - SELECT 'team', 'team_agents:' || ta.team_id || ':' || ta.agent_name, - ta.team_id, tm.role - FROM team_agents ta - JOIN team_members tm ON tm.team_id = ta.team_id - WHERE $2::text = 'host' AND tm.user_id = $1 AND tm.status = 'active' - AND ta.agent_name = $4 - UNION ALL - SELECT 'direct', 'user_contexts:' || uc.user_id || ':' || uc.context_id, - NULL, NULL - FROM user_contexts uc - JOIN requested_context_references requested - ON requested.reference_name = uc.context_id - WHERE $2::text = 'context' AND uc.user_id = $1 - AND ${canonicalContextLogicalIdSql('$6', "split_part(requested.logical_id, '/', 1) || '/' || uc.context_id")} = requested.logical_id - UNION ALL - SELECT 'team', 'team_contexts:' || tc.team_id || ':' || tc.context_id, - tc.team_id, tm.role - FROM team_contexts tc - JOIN requested_context_references requested - ON requested.reference_name = tc.context_id - JOIN team_members tm ON tm.team_id = tc.team_id - WHERE $2::text = 'context' AND tm.user_id = $1 AND tm.status = 'active' - AND ${canonicalContextLogicalIdSql('$6', "split_part(requested.logical_id, '/', 1) || '/' || tc.context_id")} = requested.logical_id - UNION ALL - SELECT 'direct', ${directWorkflowTriggerGrantId}, - NULL, NULL - FROM user_workflow_triggers uwt - WHERE $2::text IN ('workflow_recipe','sandbox_app') AND uwt.user_id = $1 - AND uwt.recipe_namespace = $3 AND uwt.recipe_name = $4 - UNION ALL - SELECT 'team', ${teamWorkflowTriggerGrantId}, - twt.team_id, tm.role - FROM team_workflow_triggers twt - JOIN team_members tm ON tm.team_id = twt.team_id - WHERE $2::text IN ('workflow_recipe','sandbox_app') - AND tm.user_id = $1 AND tm.status = 'active' - AND twt.recipe_namespace = $3 AND twt.recipe_name = $4 - ) - SELECT * FROM candidates - ORDER BY kind, team_id NULLS FIRST, grant_id - LIMIT $7`, - [ - input.userId, - input.resource.type, - identity.namespace, - identity.name, - input.resource.logicalId, - input.graph.resource.environmentId, - input.budget.limits.accessPaths + 1, - ] - ) - const capabilities: AccessCapability[] = isHost - ? [ - 'host.read', - 'host.use', - 'host.activity.read', - 'remote_desktop.use', - 'chat.read', - 'chat.message.invoke', - 'task.read', - 'task.manage', - 'model.read', - 'model.select', - 'session.read', - 'session.manage', - ] - : isContext - ? ['context.read', 'context.use'] - : input.resource.type === 'sandbox_app' - ? ['sandbox_app.read', 'sandbox_app.use', 'sandbox_oauth.vend'] - : ['workflow.read', 'workflow.trigger'] - const relevantRelationships = input.graph.relationships.filter( - relationship => relationship.sourceType === input.resource.type || isRecipe - ) - const runtimeRef = JSON.stringify( - relevantRelationships - .map(relationship => relationship.relationshipInstanceId) - .sort(compareCanonicalUtf8Text) - ) - const bindingType = - input.resource.type === 'sandbox_app' ? 'workflow_recipe' : input.resource.type - const bindingProviderUid = - input.resource.type === 'sandbox_app' - ? relevantRelationships.find(relationship => relationship.sourceType === 'workflow_recipe') - ?.sourceProviderUid - : input.graph.resource.providerUid - if (!bindingProviderUid) return [] - return (result.rows as Record[]).flatMap(row => { - const value = authorityCandidateFromGrantRow({ - row, - userId: input.userId, - capabilities, - runtimeSensitive: true, - runtimeRef, - operationalBindings: [ - { - resourceType: bindingType as OperationalPathBinding['resourceType'], - logicalId: input.resource.logicalId, - providerUid: bindingProviderUid, - relationships: relevantRelationships.map(relationship => ({ - instanceId: relationship.relationshipInstanceId, - behaviorAttributes: relationship.behaviorAttributes, - })), - }, - ], - }) - return value ? [value] : [] - }) -} - -export async function loadDerivedOperationalCandidates(input: { - db: Pick - budget: AccessExecutionBudget - userId: string - resource: CanonicalResourceIdentity - graph: Extract -}): Promise { - if (input.resource.type !== 'mcp_server' && input.resource.type !== 'shared_filesystem') { - return [] - } - const derivedResourceType = input.resource.type - const directEdges = input.graph.relationships.filter(relationship => { - if (input.resource.type === 'mcp_server') { - return ( - relationship.relationshipType === 'includes_mcp_server' && - relationship.targetId === input.resource.logicalId - ) - } - return ( - relationship.relationshipType === 'mounts_shared_filesystem' && - relationship.targetId === input.resource.logicalId - ) - }) - const contextIds = [...new Set(directEdges.map(edge => edge.sourceId))] - const hostEdges = - input.resource.type === 'mcp_server' - ? input.graph.relationships.filter( - relationship => - relationship.relationshipType === 'uses_context' && - relationship.targetType === 'context' && - contextIds.includes(relationship.targetId) - ) - : [] - const hostIds = [...new Set(hostEdges.map(edge => edge.sourceId))] - const hostNames = hostIds.map(value => scopedName(value)?.name ?? '').filter(Boolean) - if (contextIds.length === 0 && hostNames.length === 0) return [] - const result = await query( - input.db, - input.budget, - `WITH context_names AS (SELECT UNNEST($2::text[]) AS logical_id), - context_references AS MATERIALIZED ( - SELECT requested.logical_id, - split_part(requested.logical_id, '/', 2) AS reference_name - FROM context_names requested - UNION - SELECT requested.logical_id, - split_part(alias_relationship.target_id, '/', 2) - FROM context_names requested - JOIN operational_resource_relationships alias_relationship - ON alias_relationship.environment_id = $4 - AND alias_relationship.source_type = 'context' - AND alias_relationship.source_id = requested.logical_id - AND alias_relationship.relationship_type = 'context_identity_alias' - AND alias_relationship.target_type = 'context' - JOIN operational_resource_index alias_source - ON alias_source.environment_id = alias_relationship.environment_id - AND alias_source.resource_type = 'context' - AND alias_source.logical_id = alias_relationship.source_id - AND alias_source.provider_uid = alias_relationship.source_provider_uid - ), - host_names AS (SELECT UNNEST($3::text[]) AS name), - candidates AS ( - SELECT 'direct'::text AS kind, - 'user_contexts:' || uc.user_id || ':' || uc.context_id AS grant_id, - NULL::uuid AS team_id, NULL::text AS current_role, - 'context'::text AS source_type, context_identity.canonical_id AS source_name - FROM user_contexts uc - JOIN context_references requested ON requested.reference_name = uc.context_id - CROSS JOIN LATERAL ( - SELECT ${canonicalContextLogicalIdSql('$4', "split_part(requested.logical_id, '/', 1) || '/' || uc.context_id")} - AS canonical_id - ) context_identity - WHERE uc.user_id = $1 AND context_identity.canonical_id = requested.logical_id - UNION ALL - SELECT 'team', 'team_contexts:' || tc.team_id || ':' || tc.context_id, - tc.team_id, tm.role, 'context', context_identity.canonical_id - FROM team_contexts tc - JOIN context_references requested ON requested.reference_name = tc.context_id - CROSS JOIN LATERAL ( - SELECT ${canonicalContextLogicalIdSql('$4', "split_part(requested.logical_id, '/', 1) || '/' || tc.context_id")} - AS canonical_id - ) context_identity - JOIN team_members tm ON tm.team_id = tc.team_id - WHERE tm.user_id = $1 AND tm.status = 'active' - AND context_identity.canonical_id = requested.logical_id - UNION ALL - SELECT 'direct', 'user_agents:' || ua.user_id || ':' || ua.agent_name, - NULL, NULL, 'host', ua.agent_name - FROM user_agents ua JOIN host_names requested ON requested.name = ua.agent_name - WHERE ua.user_id = $1 - UNION ALL - SELECT 'team', 'team_agents:' || ta.team_id || ':' || ta.agent_name, - ta.team_id, tm.role, 'host', ta.agent_name - FROM team_agents ta JOIN host_names requested ON requested.name = ta.agent_name - JOIN team_members tm ON tm.team_id = ta.team_id - WHERE tm.user_id = $1 AND tm.status = 'active' - ) - SELECT * FROM candidates - ORDER BY kind, team_id NULLS FIRST, grant_id, source_type, source_name - LIMIT $5`, - [ - input.userId, - contextIds, - hostNames, - input.graph.resource.environmentId, - input.budget.limits.accessPaths + 1, - ] - ) - const output: AuthorityCandidate[] = [] - for (const row of result.rows as Record[]) { - const sourceType = String(row.source_type) - const sourceName = String(row.source_name) - if (sourceType === 'context') { - const contextId = contextIds.find(value => value === sourceName) - if (!contextId) continue - for (const edge of directEdges.filter(value => value.sourceId === contextId)) { - const filesystemScope = - input.resource.type === 'shared_filesystem' - ? JSON.stringify({ - contextId, - relationshipInstanceId: edge.relationshipInstanceId, - mountPath: edge.behaviorAttributes.mountPath, - readOnly: true, - }) - : undefined - const value = authorityCandidateFromGrantRow({ - row: { - ...row, - grant_id: `${String(row.grant_id)}:${edge.relationshipInstanceId}`, - }, - userId: input.userId, - capabilities: - input.resource.type === 'mcp_server' - ? ['mcp_server.read', 'mcp_server.use'] - : ['shared_filesystem.read'], - runtimeSensitive: input.resource.type === 'mcp_server', - runtimeRef: edge.relationshipInstanceId, - filesystemScope, - operationalBindings: [ - { - resourceType: 'context', - logicalId: contextId, - providerUid: edge.sourceProviderUid, - relationships: [ - { - instanceId: edge.relationshipInstanceId, - behaviorAttributes: edge.behaviorAttributes, - }, - ], - }, - { - resourceType: derivedResourceType, - logicalId: input.resource.logicalId, - providerUid: input.graph.resource.providerUid, - relationships: [], - }, - ], - }) - if (value) output.push(value) - } - continue - } - const hostId = hostIds.find(value => scopedName(value)?.name === sourceName) - if (!hostId) continue - for (const hostEdge of hostEdges.filter(value => value.sourceId === hostId)) { - const contextEdge = directEdges.find(value => value.sourceId === hostEdge.targetId) - if (!contextEdge) continue - const value = authorityCandidateFromGrantRow({ - row: { - ...row, - grant_id: `${String(row.grant_id)}:${hostEdge.relationshipInstanceId}:${contextEdge.relationshipInstanceId}`, - }, - userId: input.userId, - capabilities: ['mcp_server.read', 'mcp_server.use'], - runtimeSensitive: true, - runtimeRef: JSON.stringify([ - hostEdge.relationshipInstanceId, - contextEdge.relationshipInstanceId, - ]), - operationalBindings: [ - { - resourceType: 'host', - logicalId: hostId, - providerUid: hostEdge.sourceProviderUid, - relationships: [ - { - instanceId: hostEdge.relationshipInstanceId, - behaviorAttributes: hostEdge.behaviorAttributes, - }, - ], - }, - { - resourceType: 'context', - logicalId: hostEdge.targetId, - providerUid: contextEdge.sourceProviderUid, - relationships: [ - { - instanceId: contextEdge.relationshipInstanceId, - behaviorAttributes: contextEdge.behaviorAttributes, - }, - ], - }, - { - resourceType: 'mcp_server', - logicalId: input.resource.logicalId, - providerUid: input.graph.resource.providerUid, - relationships: [], - }, - ], - }) - if (value) output.push(value) - } - } - return output -} diff --git a/control-api/src/services/access/principalAuthoritySnapshot.ts b/control-api/src/services/access/principalAuthoritySnapshot.ts deleted file mode 100644 index 9acc7c6098..0000000000 --- a/control-api/src/services/access/principalAuthoritySnapshot.ts +++ /dev/null @@ -1,155 +0,0 @@ -import type { DbClient } from '../../db.js' -import type { TeamRole } from '../../profileTypes.js' -import type { ExternalSessionAuthorityContext } from '../auth/externalSessionAuthentication.js' -import { legacySecurityCutoffRejectsSql } from '../auth/legacySecurityCutoff.js' -import { query } from './accessAuthorityQuery.js' -import type { AccessExecutionBudget } from './accessExecutionBudget.js' -import type { AuthorizationMembershipRevision } from './authorizationRevision.js' -import type { CanonicalResourceIdentity } from './resourceIdentity.js' - -export type PrincipalAuthoritySnapshot = Readonly<{ - userId: string - sessionContract: 'v1' | 'v2' - sessionLive: boolean - sessionRevision: string - userRevision: string - resourceRevision: string - memberships: readonly AuthorizationMembershipRevision[] -}> - -export function parseAuthorizationMemberships(value: unknown): AuthorizationMembershipRevision[] { - if (!Array.isArray(value)) return [] - return value.flatMap(item => { - if (!item || typeof item !== 'object' || Array.isArray(item)) return [] - const row = item as Record - const teamId = String(row.teamId ?? '') - const role = String(row.role ?? '') as TeamRole - if (!teamId || !['admin', 'inviter', 'member'].includes(role)) return [] - return [ - Object.freeze({ - teamId, - role, - membershipUpdatedAt: new Date(String(row.membershipUpdatedAt)).toISOString(), - teamRevision: String(row.teamRevision ?? '1'), - }), - ] - }) -} - -export async function loadPrincipalAuthoritySnapshot(input: { - db: Pick - budget: AccessExecutionBudget - session: ExternalSessionAuthorityContext - resource: CanonicalResourceIdentity -}): Promise { - const result = await query( - input.db, - input.budget, - `WITH active_memberships AS ( - SELECT tm.team_id, tm.role, tm.updated_at, - COALESCE(atr.revision, 1) AS team_revision - FROM team_members tm - LEFT JOIN authorization_team_revisions atr ON atr.team_id = tm.team_id - WHERE tm.user_id = $1 - AND tm.status = 'active' - ) - SELECT u.id AS user_id, - COALESCE(aur.revision, 1) AS user_revision, - COALESCE(arr.revision, 1) AS resource_revision, - CASE - WHEN u.lifecycle_state <> 'active' THEN FALSE - WHEN $2::text = 'v2' THEN EXISTS ( - SELECT 1 - FROM external_user_sessions s - WHERE s.sid::text = $3 - AND s.user_id = u.id - AND s.session_version = $5 - AND s.revoked_at IS NULL - AND s.idle_expires_at > NOW() - AND s.absolute_expires_at > NOW() - AND ( - s.current_jti::text = $4 - OR ( - s.prior_jti::text = $4 - AND s.prior_jti_expires_at >= NOW() - ) - ) - ) - ELSE ( - $7::bigint IS NOT NULL - AND $8::bigint IS NOT NULL - AND u.lifecycle_version = $8::bigint - AND NOT EXISTS ( - SELECT 1 - FROM external_v1_session_revocations r - WHERE r.token_hash = $6 - AND r.user_id = u.id - AND r.expires_at > NOW() - ) - AND NOT EXISTS ( - SELECT 1 - FROM external_user_session_security_epochs e - WHERE e.user_id = u.id - AND ${legacySecurityCutoffRejectsSql('$7::bigint', 'e.valid_after')} - ) - ) - END AS session_live, - CASE - WHEN $2::text = 'v2' THEN COALESCE(( - SELECT s.session_version::text || ':' || s.current_jti::text || ':' || - COALESCE(s.revoked_at::text, '') || ':' || s.absolute_expires_at::text - FROM external_user_sessions s - WHERE s.sid::text = $3 AND s.user_id = u.id - ), 'missing') - ELSE COALESCE(( - SELECT EXTRACT(EPOCH FROM e.valid_after)::text - FROM external_user_session_security_epochs e - WHERE e.user_id = u.id - ), '0') || ':' || $6 - END AS session_revision, - COALESCE( - jsonb_agg( - jsonb_build_object( - 'teamId', am.team_id, - 'role', am.role, - 'membershipUpdatedAt', am.updated_at, - 'teamRevision', am.team_revision - ) ORDER BY am.team_id - ) FILTER (WHERE am.team_id IS NOT NULL), - '[]'::jsonb - ) AS memberships - FROM users u - LEFT JOIN authorization_user_revisions aur ON aur.user_id = u.id - LEFT JOIN authorization_resource_revisions arr - ON arr.environment_id = $9 - AND arr.resource_type = $10 - AND arr.resource_id = $11 - LEFT JOIN active_memberships am ON TRUE - WHERE u.id = $1 - GROUP BY u.id, aur.revision, arr.revision`, - [ - input.session.userId, - input.session.contract, - input.session.contract === 'v2' ? input.session.sid : null, - input.session.contract === 'v2' ? input.session.jti : null, - input.session.contract === 'v2' ? input.session.sessionVersion : null, - input.session.contract === 'v1' ? input.session.tokenHash : null, - input.session.contract === 'v1' ? input.session.issuedAt : null, - input.session.contract === 'v1' ? input.session.authGeneration : null, - input.resource.environmentId, - input.resource.type, - input.resource.logicalId, - ] - ) - const row = result.rows[0] as Record | undefined - if (!row) return null - return Object.freeze({ - userId: String(row.user_id), - sessionContract: input.session.contract, - sessionLive: row.session_live === true, - sessionRevision: String(row.session_revision), - userRevision: String(row.user_revision ?? '1'), - resourceRevision: String(row.resource_revision ?? '1'), - memberships: Object.freeze(parseAuthorizationMemberships(row.memberships)), - }) -} diff --git a/control-api/src/services/access/resourceIdentity.ts b/control-api/src/services/access/resourceIdentity.ts deleted file mode 100644 index 88d3924667..0000000000 --- a/control-api/src/services/access/resourceIdentity.ts +++ /dev/null @@ -1,115 +0,0 @@ -export const ACCESS_RESOURCE_TYPES = [ - 'user', - 'team', - 'host', - 'context', - 'mcp_server', - 'workflow_recipe', - 'workflow_run', - 'workflow_artifact', - 'workflow_approval', - 'gfs_resource', - 'shared_filesystem', - 'sandbox_app', - 'chat', - 'runtime_session', - 'notification', -] as const - -export type AccessResourceType = (typeof ACCESS_RESOURCE_TYPES)[number] - -export type CanonicalResourceIdentity = Readonly<{ - environmentId: string - type: AccessResourceType - canonicalId: string - logicalId: string - displayName: string - providerUid?: string -}> - -const resourceTypes = new Set(ACCESS_RESOURCE_TYPES) -const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i -const DNS_LABEL_PATTERN = - /^[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?\/[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?$/ - -function boundedText(value: unknown, label: string, maximum: number): string { - const normalized = typeof value === 'string' ? value.trim() : '' - if (!normalized || normalized.length > maximum || /[\u0000-\u001f\u007f]/u.test(normalized)) { - throw new Error(`${label}_invalid`) - } - return normalized -} - -export function isAccessResourceType(value: unknown): value is AccessResourceType { - return typeof value === 'string' && resourceTypes.has(value) -} - -export function requireAccessResourceType(value: unknown): AccessResourceType { - if (!isAccessResourceType(value)) throw new Error('resource_type_invalid') - return value -} - -export function validateLogicalResourceId(type: AccessResourceType, logicalId: string): void { - if ( - ['user', 'team', 'workflow_run', 'workflow_approval', 'gfs_resource', 'notification'].includes( - type - ) - ) { - if (!UUID_PATTERN.test(logicalId)) throw new Error('resource_logical_id_invalid') - return - } - if ( - [ - 'host', - 'context', - 'mcp_server', - 'workflow_recipe', - 'shared_filesystem', - 'sandbox_app', - ].includes(type) - ) { - if (!DNS_LABEL_PATTERN.test(logicalId) || logicalId.length > 507) { - throw new Error('resource_logical_id_invalid') - } - } -} - -export function canonicalResourceIdentity(input: { - environmentId: unknown - type: unknown - logicalId: unknown - displayName?: unknown - providerUid?: unknown -}): CanonicalResourceIdentity { - const environmentId = boundedText(input.environmentId, 'environment_id', 512) - const type = requireAccessResourceType(input.type) - const logicalId = boundedText(input.logicalId, 'resource_logical_id', 1_024) - validateLogicalResourceId(type, logicalId) - const displayName = - input.displayName === undefined - ? logicalId - : boundedText(input.displayName, 'resource_display_name', 512) - const providerUid = - input.providerUid === undefined || input.providerUid === null - ? undefined - : boundedText(input.providerUid, 'resource_provider_uid', 256) - return Object.freeze({ - environmentId, - type, - canonicalId: `${type}:${logicalId}`, - logicalId, - displayName, - ...(providerUid ? { providerUid } : {}), - }) -} - -export function resourceIdentityKey(resource: CanonicalResourceIdentity): string { - return JSON.stringify([resource.environmentId, resource.type, resource.logicalId]) -} - -export function compareResourceIdentity( - left: CanonicalResourceIdentity, - right: CanonicalResourceIdentity -): number { - return resourceIdentityKey(left).localeCompare(resourceIdentityKey(right)) -} diff --git a/control-api/src/services/access/userAccessPolicy.ts b/control-api/src/services/access/userAccessPolicy.ts index c8eb4604c4..d4e209aba4 100644 --- a/control-api/src/services/access/userAccessPolicy.ts +++ b/control-api/src/services/access/userAccessPolicy.ts @@ -9,11 +9,6 @@ export type ConfiguredUserAccessIntent = Readonly<{ legacyLifecycle: LegacyLifecycle sessionV2Acceptance: boolean sessionV2Issuance: boolean - teamGfsMembershipAdmissionLimit: number | null - actionContextV2: boolean - rpcDelegationV2: boolean - desktopAllTeamMode: boolean - profileV2Mode: boolean legacySwitchEndpoint: boolean minimumClientVersion: string | null minimumClientEnforcement: boolean @@ -27,12 +22,6 @@ export type DeploymentReadiness = Readonly<{ legacyDrainAccepted: boolean sessionV2Acceptance: Readiness sessionV2Issuance: Readiness - accessBudget: Readiness - actionContext: Readiness - rpcDelegationAllHops: Readiness - desktop: Readiness - explicitTeamAdapters: Readiness - profile: Readiness minimumClientEnforcement: Readiness }> @@ -45,10 +34,6 @@ export type EffectiveUserAccessPolicy = Readonly<{ issueV2: boolean renewV2: boolean switchCompatibility: boolean - actionContextV2: boolean - rpcDelegationV2: boolean - desktopAllTeamMode: boolean - profileV2Mode: boolean minimumClientVersion: string | null enforceMinimumClient: boolean }> @@ -123,31 +108,6 @@ export function compileUserAccessPolicy( ) } - if (intent.actionContextV2) { - requireCondition(acceptV2, 'action_context_requires_v2_acceptance') - requireCondition(readiness.accessBudget === 'ready', 'action_context_budget_unavailable') - requireCondition(readiness.actionContext === 'ready', 'action_context_unavailable') - } - if (intent.rpcDelegationV2) { - requireCondition(intent.actionContextV2, 'rpc_v2_requires_action_context') - requireCondition(readiness.rpcDelegationAllHops === 'ready', 'rpc_v2_all_hops_unavailable') - } - if (intent.desktopAllTeamMode) { - requireCondition(issueV2, 'desktop_mode_requires_v2_issuance') - requireCondition(false, 'desktop_mode_requires_catalog_serving') - requireCondition(intent.actionContextV2, 'desktop_mode_requires_action_context') - requireCondition(intent.rpcDelegationV2, 'desktop_mode_requires_rpc_v2') - requireCondition(readiness.desktop === 'ready', 'desktop_mode_unavailable') - } - if (intent.profileV2Mode) { - requireCondition(issueV2 && acceptV2, 'profile_mode_requires_v2_issue_and_accept') - requireCondition( - readiness.explicitTeamAdapters === 'ready', - 'profile_explicit_team_adapters_unavailable' - ) - requireCondition(readiness.profile === 'ready', 'profile_mode_unavailable') - } - const minimumVersion = intent.minimumClientVersion?.trim() || null if (minimumVersion !== null) { requireCondition( @@ -171,10 +131,6 @@ export function compileUserAccessPolicy( issueV2, renewV2: acceptV2 && issueV2, switchCompatibility: intent.legacySwitchEndpoint, - actionContextV2: intent.actionContextV2, - rpcDelegationV2: intent.rpcDelegationV2, - desktopAllTeamMode: intent.desktopAllTeamMode, - profileV2Mode: intent.profileV2Mode, minimumClientVersion: intent.minimumClientEnforcement ? minimumVersion : null, enforceMinimumClient: intent.minimumClientEnforcement, }) @@ -215,24 +171,7 @@ function parseVersion(env: NodeJS.ProcessEnv, name: string): string | null { return raw } -function parseOptionalPositiveSafeInteger(env: NodeJS.ProcessEnv, name: string): number | null { - const raw = env[name]?.trim() - if (!raw) return null - if (!/^[1-9][0-9]*$/.test(raw)) { - throw new UserAccessPolicyConfigurationError(`${name.toLowerCase()}_invalid`) - } - const value = Number(raw) - if (!Number.isSafeInteger(value) || value < 1) { - throw new UserAccessPolicyConfigurationError(`${name.toLowerCase()}_invalid`) - } - return value -} - export function loadConfiguredUserAccessIntent(env: NodeJS.ProcessEnv): ConfiguredUserAccessIntent { - const teamGfsMembershipAdmissionLimit = parseOptionalPositiveSafeInteger( - env, - 'CONTROL_API_USER_ACCESS_TEAM_GFS_MEMBERSHIP_ADMISSION_LIMIT' - ) return Object.freeze({ legacyLifecycle: parseEnum( env, @@ -242,11 +181,6 @@ export function loadConfiguredUserAccessIntent(env: NodeJS.ProcessEnv): Configur ), sessionV2Acceptance: parseBoolean(env, 'CONTROL_API_USER_ACCESS_SESSION_V2_ACCEPTANCE', true), sessionV2Issuance: parseBoolean(env, 'CONTROL_API_USER_ACCESS_SESSION_V2_ISSUANCE', false), - teamGfsMembershipAdmissionLimit, - actionContextV2: parseBoolean(env, 'CONTROL_API_USER_ACCESS_ACTION_CONTEXT_V2', false), - rpcDelegationV2: parseBoolean(env, 'CONTROL_API_USER_ACCESS_RPC_DELEGATION_V2', false), - desktopAllTeamMode: parseBoolean(env, 'CONTROL_API_USER_ACCESS_DESKTOP_ALL_TEAM_MODE', false), - profileV2Mode: parseBoolean(env, 'CONTROL_API_USER_ACCESS_PROFILE_V2_MODE', false), legacySwitchEndpoint: parseBoolean(env, 'CONTROL_API_USER_ACCESS_LEGACY_SWITCH_ENDPOINT', true), minimumClientVersion: parseVersion(env, 'CONTROL_API_USER_ACCESS_MINIMUM_CLIENT_VERSION'), minimumClientEnforcement: parseBoolean( @@ -265,34 +199,11 @@ export const reconstructionReadiness: DeploymentReadiness = Object.freeze({ legacyDrainAccepted: false, sessionV2Acceptance: 'ready', sessionV2Issuance: 'ready', - accessBudget: 'ready', - // The resolver exists for foundation validation, but its public rollout is - // owned by the later runtime unit and remains unavailable in PR 1. - actionContext: 'unavailable', - // PR 2 owns the rpc-proxy and mcp-host hops. This stays unavailable in PR 1. - rpcDelegationAllHops: 'unavailable', - desktop: 'unavailable', - explicitTeamAdapters: 'ready', - profile: 'unavailable', minimumClientEnforcement: 'unavailable', }) export const configuredUserAccessIntent = loadConfiguredUserAccessIntent(process.env) -export function catalogBudgetOptionsForIntent( - intent: ConfiguredUserAccessIntent -): Readonly<{ teamGfsMembershipAdmissionLimit?: number }> { - return intent.teamGfsMembershipAdmissionLimit === null - ? Object.freeze({}) - : Object.freeze({ - teamGfsMembershipAdmissionLimit: intent.teamGfsMembershipAdmissionLimit, - }) -} - -export const configuredCatalogBudgetOptions = catalogBudgetOptionsForIntent( - configuredUserAccessIntent -) - export function compareSemanticVersions(left: string, right: string): number | null { const leftMatch = SEMANTIC_VERSION_PATTERN.exec(left) const rightMatch = SEMANTIC_VERSION_PATTERN.exec(right) diff --git a/control-api/src/services/access/workflowTriggerGrantIdentity.ts b/control-api/src/services/access/workflowTriggerGrantIdentity.ts deleted file mode 100644 index 17d802c1e6..0000000000 --- a/control-api/src/services/access/workflowTriggerGrantIdentity.ts +++ /dev/null @@ -1,15 +0,0 @@ -export type WorkflowTriggerGrantKind = 'direct' | 'team' - -type WorkflowTriggerGrantAlias = 'access_grant' | 'uwt' | 'twt' - -/** Catalog paths and live authorization must bind the same trigger grant. */ -export function workflowTriggerGrantIdentitySql( - kind: WorkflowTriggerGrantKind, - alias: WorkflowTriggerGrantAlias -): string { - return kind === 'direct' - ? `'user_workflow_triggers:' || ${alias}.user_id || ':' || - ${alias}.recipe_namespace || '/' || ${alias}.recipe_name` - : `'team_workflow_triggers:' || ${alias}.team_id || ':' || - ${alias}.recipe_namespace || '/' || ${alias}.recipe_name` -} diff --git a/control-api/src/services/resourceService.ts b/control-api/src/services/resourceService.ts index 79be578482..882907f1dd 100644 --- a/control-api/src/services/resourceService.ts +++ b/control-api/src/services/resourceService.ts @@ -58,43 +58,6 @@ export class K8sConflictError extends Error { } } -export type ResourceListPage = Readonly<{ - items: readonly unknown[] - continueToken: string | null - resourceVersion: string -}> - -type BoundedKubernetesRequest = Readonly<{ - signal: AbortSignal - timeoutSeconds: number -}> - -type KubernetesCallOptions = k8s.ConfigurationOptions - -function kubernetesAbortOptions(signal: AbortSignal): KubernetesCallOptions { - const middleware: k8s.ObservableMiddleware = { - pre: context => { - context.setSignal(signal) - return new k8s.Observable(Promise.resolve(context)) - }, - post: context => new k8s.Observable(Promise.resolve(context)), - } - return { - middleware: [middleware], - middlewareMergeStrategy: 'append', - } -} - -async function runKubernetesTransportCall( - signal: AbortSignal, - invoke: (options: KubernetesCallOptions) => Promise -): Promise { - if (signal.aborted) throw signal.reason ?? new Error('request aborted') - const result = await invoke(kubernetesAbortOptions(signal)) - if (signal.aborted) throw signal.reason ?? new Error('request aborted') - return result -} - export type MutableResourceSnapshot = { metadata?: { annotations?: Record @@ -226,12 +189,6 @@ export class ResourceService { return primary || this.defaultNamespace } - assertNamespaceAllowed(namespace: string): void { - if (!this.allowedNamespaces.has(namespace)) { - throw new Error(`Namespace not allowed: ${namespace}`) - } - } - private listNamespacesForPlural(plural: ClerumResourceType): string[] { const namespaces = new Set() addNonEmpty(namespaces, this.defaultNamespaces[plural]) @@ -282,85 +239,6 @@ export class ResourceService { return res.items || [] } - async listResourcePage( - plural: ClerumResourceType, - namespace: string, - options: BoundedKubernetesRequest & { - limit: number - continueToken?: string - resourceVersion?: string - } - ): Promise { - if (!Number.isSafeInteger(options.limit) || options.limit < 1 || options.limit > 100) { - throw new Error('Kubernetes list limit must be between 1 and 100') - } - if ( - !Number.isSafeInteger(options.timeoutSeconds) || - options.timeoutSeconds < 1 || - options.timeoutSeconds > 60 - ) { - throw new Error('Kubernetes request timeout must be between 1 and 60 seconds') - } - const resolvedNamespace = this.resolveNamespace(plural, namespace) - const response = (await runKubernetesTransportCall(options.signal, callOptions => - this.customApi.listNamespacedCustomObject( - { - group: CLERUM_GROUP, - version: CLERUM_VERSION, - namespace: resolvedNamespace, - plural, - limit: options.limit, - _continue: options.continueToken, - resourceVersion: options.resourceVersion, - timeoutSeconds: options.timeoutSeconds, - }, - callOptions - ) - )) as ResourceListResponse - const resourceVersion = String(response.metadata?.resourceVersion || '').trim() - if (!resourceVersion) throw new Error('Kubernetes list response omitted resourceVersion') - return Object.freeze({ - items: Object.freeze([...(response.items ?? [])]), - continueToken: String(response.metadata?.continue || '').trim() || null, - resourceVersion, - }) - } - - async getResourceExact( - plural: ClerumResourceType, - name: string, - namespace: string, - options: BoundedKubernetesRequest - ): Promise { - if ( - !Number.isSafeInteger(options.timeoutSeconds) || - options.timeoutSeconds < 1 || - options.timeoutSeconds > 60 - ) { - throw new Error('Kubernetes request timeout must be between 1 and 60 seconds') - } - const resolvedNamespace = this.resolveNamespace(plural, namespace) - try { - return await runKubernetesTransportCall(options.signal, callOptions => - this.customApi.getNamespacedCustomObject( - { - group: CLERUM_GROUP, - version: CLERUM_VERSION, - namespace: resolvedNamespace, - plural, - name, - }, - callOptions - ) - ) - } catch (error) { - if (extractK8sStatus(error) === 404) { - throw new K8sNotFoundError(`${plural}/${name} not found in namespace ${resolvedNamespace}`) - } - throw error - } - } - async getResource( plural: ClerumResourceType, name: string, diff --git a/control-api/src/shutdown.ts b/control-api/src/shutdown.ts index aa7589204a..58030beec4 100644 --- a/control-api/src/shutdown.ts +++ b/control-api/src/shutdown.ts @@ -28,7 +28,6 @@ export const CONTROL_API_SHUTDOWN_STEP_NAMES = [ 'oauth-shared-grant-sweep', 'workflow-approval-trace-projector', 'entity-change-dispatcher', - 'operational-access-indexer', 'core-database-pool', 'rate-limit-database-pool', 'trace-database-pools', diff --git a/control-api/src/types.ts b/control-api/src/types.ts index 3c39f261c6..173e98f92a 100644 --- a/control-api/src/types.ts +++ b/control-api/src/types.ts @@ -31,10 +31,6 @@ export interface ClerumResource> { export interface ResourceListResponse { items: unknown[] - metadata?: { - continue?: string - resourceVersion?: string - } } export interface HostOverview { diff --git a/control-api/test/accessDatabaseTransactionAsyncClientError.test.ts b/control-api/test/accessDatabaseTransactionAsyncClientError.test.ts deleted file mode 100644 index 45d8090a82..0000000000 --- a/control-api/test/accessDatabaseTransactionAsyncClientError.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { EventEmitter } from 'node:events' -import { withAccessDatabaseTransaction } from '../src/services/access/accessDatabaseQuery.js' -import { AccessExecutionBudget } from '../src/services/access/accessExecutionBudget.js' - -// pg-pool removes its idle 'error' listener while a client is checked out. An -// access transaction can wait on non-SQL work (for example a Kubernetes read) -// while PostgreSQL terminates the backend; node-postgres then emits 'error' on -// the borrowed client in another tick. Without a listener owned by the -// transaction, Node's EventEmitter throws and Control API exits. The client is -// a real EventEmitter so the emit has exactly that semantics. - -type FakeClient = EventEmitter & { - query: ReturnType - release: ReturnType -} - -function createFakeClient(): FakeClient { - const client = new EventEmitter() as FakeClient - client.query = vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }) - client.release = vi.fn() - return client -} - -describe('withAccessDatabaseTransaction — async out-of-band client error', () => { - const uncaught: unknown[] = [] - // Without the fix the emit surfaces here; recording it turns the crash into - // an assertion failure instead of a worker abort. - const onUncaught = (err: unknown): void => { - uncaught.push(err) - } - - beforeEach(() => { - uncaught.length = 0 - process.on('uncaughtException', onUncaught) - }) - - afterEach(() => { - process.removeListener('uncaughtException', onUncaught) - vi.clearAllMocks() - }) - - it('contains the client error during non-SQL work and discards the client once', async () => { - const client = createFakeClient() - const connectionPool = { connect: vi.fn().mockResolvedValue(client) } - const budget = AccessExecutionBudget.create('action') - const terminated = Object.assign( - new Error('terminating connection due to administrator command'), - { - code: '57P01', - } - ) - let releaseExternalWait!: () => void - const externalWait = new Promise(resolve => { - releaseExternalWait = resolve - }) - let workEntered = false - - try { - const resultPromise = withAccessDatabaseTransaction( - budget, - async () => { - workEntered = true - setTimeout(() => client.emit('error', terminated), 0) - await externalWait - return 'done' - }, - { connectionPool } - ) - - await new Promise(resolve => setTimeout(resolve, 10)) - releaseExternalWait() - const result = await resultPromise - await new Promise(resolve => setTimeout(resolve, 0)) - - // Liveness witness: the transaction opened and the work ran. - expect(workEntered).toBe(true) - expect(client.query).toHaveBeenCalledWith('BEGIN', []) - expect(result).toBe('done') - expect(uncaught).toEqual([]) - expect(client.listenerCount('error')).toBe(0) - expect(client.release).toHaveBeenCalledTimes(1) - expect(client.release).toHaveBeenCalledWith(terminated) - } finally { - budget.close() - } - }) -}) diff --git a/control-api/test/accessExecutionBudget.test.ts b/control-api/test/accessExecutionBudget.test.ts deleted file mode 100644 index d8fbfa8f86..0000000000 --- a/control-api/test/accessExecutionBudget.test.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - AccessBudgetConfigurationError, - AccessBudgetExceededError, - AccessExecutionBudget, - AccessExecutionCancelledError, - resolveAccessExecutionLimits, -} from '../src/services/access/accessExecutionBudget.js' - -describe('AccessExecutionBudget', () => { - it('applies the accepted local clamps and rejects attempts to raise them', () => { - expect(resolveAccessExecutionLimits().publicPageSize).toBe(100) - expect(resolveAccessExecutionLimits({ producerCalls: 2 }).producerCalls).toBe(2) - expect(resolveAccessExecutionLimits({ databaseStatements: 2 }).databaseStatements).toBe(2) - expect(() => resolveAccessExecutionLimits({ producerCalls: 33 })).toThrow( - AccessBudgetConfigurationError - ) - expect(() => resolveAccessExecutionLimits({ producerCalls: 0 })).toThrow( - AccessBudgetConfigurationError - ) - expect(() => resolveAccessExecutionLimits({ databaseStatements: 129 })).toThrow( - AccessBudgetConfigurationError - ) - }) - - it('rejects invalid public limits and cursor sizes before producer work', () => { - const budget = AccessExecutionBudget.create('catalog') - expect(() => budget.assertPageSize(101)).toThrow(AccessBudgetConfigurationError) - expect(() => budget.assertCursorBytes(16 * 1024 + 1)).toThrow(AccessBudgetConfigurationError) - expect(budget.remaining('producerCalls')).toBe(42) - expect(budget.remaining('databaseStatements')).toBe(1_493) - budget.close() - - const actionBudget = AccessExecutionBudget.create('action') - expect(actionBudget.remaining('producerCalls')).toBe(32) - expect(actionBudget.remaining('databaseStatements')).toBe(128) - actionBudget.close() - }) - - it('keeps each execution class within its own authorized producer and SQL ceiling', () => { - expect(resolveAccessExecutionLimits({ producerCalls: 42 }, 'catalog').producerCalls).toBe(42) - expect( - resolveAccessExecutionLimits({ databaseStatements: 1_493 }, 'catalog').databaseStatements - ).toBe(1_493) - expect(resolveAccessExecutionLimits({ producerCalls: 41 }, 'indexer').producerCalls).toBe(41) - expect( - resolveAccessExecutionLimits({ databaseStatements: 55 }, 'indexer').databaseStatements - ).toBe(55) - expect(() => - AccessExecutionBudget.create('catalog', { limits: { producerCalls: 43 } }) - ).toThrow(AccessBudgetConfigurationError) - expect(() => - AccessExecutionBudget.create('catalog', { limits: { databaseStatements: 1_494 } }) - ).toThrow(AccessBudgetConfigurationError) - expect(() => AccessExecutionBudget.create('action', { limits: { producerCalls: 33 } })).toThrow( - AccessBudgetConfigurationError - ) - expect(() => - AccessExecutionBudget.create('action', { limits: { databaseStatements: 129 } }) - ).toThrow(AccessBudgetConfigurationError) - expect(() => resolveAccessExecutionLimits({ producerCalls: 42 }, 'indexer')).toThrow( - AccessBudgetConfigurationError - ) - expect(() => resolveAccessExecutionLimits({ databaseStatements: 56 }, 'indexer')).toThrow( - AccessBudgetConfigurationError - ) - }) - - it('isolates the bounded indexer ceiling from ordinary action requests', () => { - const indexer = AccessExecutionBudget.create('indexer') - expect(indexer.remaining('producerCalls')).toBe(41) - expect(indexer.remaining('databaseStatements')).toBe(55) - expect(() => indexer.charge({ kind: 'producerCalls', amount: 42 })).toThrow( - AccessBudgetExceededError - ) - expect(() => indexer.charge({ kind: 'databaseStatements', amount: 56 })).toThrow( - AccessBudgetExceededError - ) - indexer.close() - - const action = AccessExecutionBudget.create('action') - expect(action.remaining('producerCalls')).toBe(32) - expect(action.remaining('databaseStatements')).toBe(128) - expect(() => action.charge({ kind: 'producerCalls', amount: 33 })).toThrow( - AccessBudgetExceededError - ) - expect(() => action.charge({ kind: 'databaseStatements', amount: 129 })).toThrow( - AccessBudgetExceededError - ) - action.close() - }) - - it('charges objects, bytes, paths, relationships, rows, memo, and response limits', () => { - const budget = AccessExecutionBudget.create('catalog', { - limits: { - objects: 1, - decodedBytes: 10, - objectBytes: 8, - accessPaths: 1, - relationships: 1, - dbRowsReturned: 1, - databaseStatements: 1, - memoEntries: 1, - memoBytes: 8, - responseBytes: 8, - }, - }) - budget.chargeOperationalObject(8) - budget.charge({ kind: 'accessPaths' }) - budget.charge({ kind: 'relationships' }) - budget.charge({ kind: 'dbRowsReturned' }) - budget.charge({ kind: 'databaseStatements' }) - budget.charge({ kind: 'memoEntries' }) - budget.charge({ kind: 'memoBytes', amount: 8 }) - budget.charge({ kind: 'responseBytes', amount: 8 }) - expect(() => budget.chargeOperationalObject(1)).toThrow(AccessBudgetExceededError) - expect(() => budget.assertRelationshipDepth(2)).toThrow(AccessBudgetExceededError) - budget.close() - }) - - it('reserves child capacity and returns only unused capacity on close', () => { - const parent = AccessExecutionBudget.create('catalog', { - limits: { producerCalls: 4, objects: 4 }, - teamGfsMembershipAdmissionLimit: 3, - }) - const child = parent.child({ producerCalls: 2, objects: 3 }) - expect(child.teamGfsMembershipAdmissionLimit).toBe(3) - expect(parent.remaining('producerCalls')).toBe(2) - expect(parent.remaining('objects')).toBe(1) - child.charge({ kind: 'producerCalls' }) - child.charge({ kind: 'objects', amount: 2 }) - child.close() - expect(parent.remaining('producerCalls')).toBe(3) - expect(parent.remaining('objects')).toBe(2) - parent.close() - }) - - it('requires an explicit positive Team-GFS membership admission when supplied', () => { - expect(() => - AccessExecutionBudget.create('catalog', { teamGfsMembershipAdmissionLimit: 0 }) - ).toThrow(AccessBudgetConfigurationError) - const budget = AccessExecutionBudget.create('catalog', { - teamGfsMembershipAdmissionLimit: 1, - }) - expect(budget.teamGfsMembershipAdmissionLimit).toBe(1) - budget.close() - }) - - it('bounds producer concurrency and releases slots after completion', async () => { - const budget = AccessExecutionBudget.create('catalog', { - limits: { producerCalls: 3, producerConcurrency: 1 }, - }) - let releaseFirst: (() => void) | undefined - const first = budget.runProducer(() => new Promise(resolve => (releaseFirst = resolve))) - await vi.waitFor(() => expect(releaseFirst).toBeTypeOf('function')) - const secondWork = vi.fn(async () => 'second') - const second = budget.runProducer(secondWork) - await Promise.resolve() - expect(secondWork).not.toHaveBeenCalled() - releaseFirst!() - await expect(first).resolves.toBeUndefined() - await expect(second).resolves.toBe('second') - budget.close() - }) - - it('cancels queued and late producer completion without returning a result', async () => { - const budget = AccessExecutionBudget.create('catalog', { - limits: { producerCalls: 2, producerConcurrency: 1 }, - }) - let finish: (() => void) | undefined - const running = budget.runProducer(() => new Promise(resolve => (finish = resolve))) - await vi.waitFor(() => expect(finish).toBeTypeOf('function')) - const queued = budget.runProducer(async () => 'late') - budget.cancel() - finish!() - await expect(running).rejects.toBeInstanceOf(AccessExecutionCancelledError) - await expect(queued).rejects.toBeInstanceOf(AccessExecutionCancelledError) - budget.close() - }) - - it('derives the statement timeout from the same absolute deadline', () => { - const now = Date.now() - const budget = AccessExecutionBudget.create('action', { now }) - expect(budget.statementTimeoutMs(now)).toBe(1_750) - expect(() => budget.statementTimeoutMs(now + 1_751)).toThrow(AccessBudgetExceededError) - budget.close() - }) - - it('releases a long-lived parent abort listener when a root budget closes', () => { - const parent = new AbortController() - const add = vi.spyOn(parent.signal, 'addEventListener') - const remove = vi.spyOn(parent.signal, 'removeEventListener') - const budget = AccessExecutionBudget.create('action', { parentSignal: parent.signal }) - - const listener = add.mock.calls.find(([event]) => event === 'abort')?.[1] - expect(listener).toBeTypeOf('function') - - budget.close() - - expect(remove).toHaveBeenCalledWith('abort', listener) - }) -}) diff --git a/control-api/test/accessLiveAuthorization.test.ts b/control-api/test/accessLiveAuthorization.test.ts deleted file mode 100644 index 25444b9d47..0000000000 --- a/control-api/test/accessLiveAuthorization.test.ts +++ /dev/null @@ -1,594 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { DbClient } from '../src/db.js' -import { AccessExecutionBudget } from '../src/services/access/accessExecutionBudget.js' -import { - type AccessPathBehavior, - type AccessPathSeed, - accessPathsAreEquivalent, - buildAccessPath, - knownBehavior, - selectEquivalentAccessPath, - unknownBehavior, -} from '../src/services/access/accessPath.js' -import { - type LiveAuthorizationInput, - resolveLiveAuthorization, -} from '../src/services/access/liveAuthorizationResolver.js' -import { canonicalEnvironmentId } from '../src/services/access/operationalAccessProjection.js' -import { canonicalResourceIdentity } from '../src/services/access/resourceIdentity.js' - -vi.mock('../src/services/access/operationTarget.js', async importOriginal => { - const actual = await importOriginal() - return { - ...actual, - validateOperationTarget: (input: Parameters[0]) => - input.capability === 'gfs.write' || input.capability === 'gfs.delete' - ? null - : actual.validateOperationTarget(input), - } -}) - -const environmentId = canonicalEnvironmentId() -const userId = '10000000-0000-4000-8000-000000000001' -const teamId = '20000000-0000-4000-8000-000000000002' -const sid = '30000000-0000-4000-8000-000000000003' -const jti = '40000000-0000-4000-8000-000000000004' - -const session = Object.freeze({ - contract: 'v2' as const, - userId, - sid, - jti, - sessionVersion: 1, -}) - -function behavior(overrides: Partial = {}): AccessPathBehavior { - return Object.freeze({ - capabilities: Object.freeze(['host.read'] as const), - budget: knownBehavior(null), - credentialPolicy: knownBehavior(null), - approvalPolicy: knownBehavior(null), - filesystemScope: knownBehavior(null), - runtime: knownBehavior(null), - providerModelPolicy: knownBehavior(null), - audit: knownBehavior(`user:${userId}`), - ...overrides, - }) -} - -function path(seed: AccessPathSeed) { - return buildAccessPath({ - principalUserId: userId, - resource: canonicalResourceIdentity({ - environmentId, - type: 'host', - logicalId: 'mcp-host/host-a', - }), - seed, - authorizationRevision: 'ar1_test', - }) -} - -type FakeDbOptions = Readonly<{ - memberships?: unknown[] - sessionLive?: boolean - hostGrantRows?: Record[] - providerUid?: string - unresolvedContextReference?: boolean -}> - -function fakeTransaction(options: FakeDbOptions = {}) { - const query = vi.fn(async (text: string) => { - if (text.startsWith('SET TRANSACTION') || text.includes("set_config('statement_timeout'")) { - return { rows: [], rowCount: 0 } - } - if (text.includes('AS session_live')) { - return { - rows: [ - { - user_id: userId, - user_revision: '1', - resource_revision: '1', - session_live: options.sessionLive ?? true, - session_revision: '1:current', - memberships: options.memberships ?? [], - }, - ], - rowCount: 1, - } - } - if (text.includes('FROM operational_catalog_source_state')) { - return { - rows: [ - { - source_family: 'host', - generation: '7', - resource_version: '91', - status: 'current', - safe_error_code: null, - }, - ], - rowCount: 1, - } - } - if (text.includes('AS unresolved_host_context')) { - const rows = options.unresolvedContextReference ? [{ unresolved_host_context: 1 }] : [] - return { rows, rowCount: rows.length } - } - if (text.includes('FROM operational_resource_relationships relationship')) { - return { rows: [], rowCount: 0 } - } - if (text.includes('SELECT * FROM candidates')) { - const rows = options.hostGrantRows ?? [ - { - kind: 'direct', - grant_id: `user_agents:${userId}:host-a`, - team_id: null, - current_role: null, - }, - ] - return { rows, rowCount: rows.length } - } - if (text.includes('FROM operational_resource_index')) { - return { - rows: [ - { - environment_id: environmentId, - resource_type: 'host', - logical_id: 'mcp-host/host-a', - source_family: 'host', - provider_uid: options.providerUid ?? 'uid-host-a', - provider_resource_version: '91', - display_name: 'host-a', - enabled: true, - deleted_at: null, - observed_generation: 1, - content_bytes: 512, - }, - ], - rowCount: 1, - } - } - if (text.includes('FROM operational_resource_relationships')) { - return { rows: [], rowCount: 0 } - } - throw new Error(`Unexpected query: ${text.slice(0, 80)}`) - }) - const transaction = async (work: (db: DbClient) => Promise) => - work({ query } as unknown as DbClient) - return { query, transaction } -} - -function hostRequest(overrides: Partial = {}): LiveAuthorizationInput { - return { - session, - requiredCapability: 'host.read', - resource: canonicalResourceIdentity({ - environmentId, - type: 'host', - logicalId: 'mcp-host/host-a', - }), - ...overrides, - } -} - -function gfsRequest(overrides: Partial = {}): LiveAuthorizationInput { - return { - session, - requiredCapability: 'gfs.read', - resource: canonicalResourceIdentity({ - environmentId, - type: 'gfs_resource', - logicalId: '50000000-0000-4000-8000-000000000005', - }), - ...overrides, - } -} - -describe('access-path behavior', () => { - it('selects direct first only when every behavior dimension is known and equal', () => { - const direct = path({ kind: 'direct', grantId: 'direct-a', behavior: behavior() }) - const team = path({ - kind: 'team', - grantId: 'team-a', - teamId, - currentRole: 'member', - behavior: behavior(), - }) - - expect(accessPathsAreEquivalent(direct, team)).toBe(true) - expect(selectEquivalentAccessPath([team, direct])).toBe(direct) - }) - - it('treats equal unknown behavior dimensions as non-equivalent', () => { - const direct = path({ - kind: 'direct', - grantId: 'direct-a', - behavior: behavior({ budget: unknownBehavior() }), - }) - const team = path({ - kind: 'team', - grantId: 'team-a', - teamId, - currentRole: 'member', - behavior: behavior({ budget: unknownBehavior() }), - }) - - expect(accessPathsAreEquivalent(direct, team)).toBe(false) - expect(selectEquivalentAccessPath([direct, team])).toBeNull() - }) -}) - -describe('live user-access resolution', () => { - beforeEach(() => vi.restoreAllMocks()) - - it('fails a non-live session before reading the operational resource index', async () => { - const db = fakeTransaction({ sessionLive: false }) - - await expect( - resolveLiveAuthorization(hostRequest(), { transaction: db.transaction }) - ).resolves.toEqual({ status: 'denied', code: 'session_not_live' }) - expect( - db.query.mock.calls.some(([sql]) => - String(sql).includes('FROM operational_catalog_source_state') - ) - ).toBe(false) - }) - - it('fails a Host closed when its configured Context does not resolve', async () => { - const unresolved = fakeTransaction({ unresolvedContextReference: true }) - - await expect( - resolveLiveAuthorization(hostRequest(), { transaction: unresolved.transaction }) - ).resolves.toEqual({ - status: 'unavailable', - dependencyClass: 'operational_resource_store', - retryable: true, - }) - expect( - unresolved.query.mock.calls.some(([sql]) => - String(sql).includes('AS unresolved_host_context') - ) - ).toBe(true) - expect( - unresolved.query.mock.calls.some(([sql]) => - String(sql).includes('relationship.relationship_instance_id') - ) - ).toBe(false) - - const resolved = fakeTransaction() - const gateway = { - getResourceExact: vi.fn().mockResolvedValue({ - metadata: { - name: 'host-a', - namespace: 'mcp-host', - uid: 'uid-host-a', - resourceVersion: '91', - generation: 1, - }, - spec: { enabled: true }, - }), - } - await expect( - resolveLiveAuthorization(hostRequest(), { - transaction: resolved.transaction, - gateway, - }) - ).resolves.toEqual(expect.objectContaining({ status: 'allowed' })) - expect( - resolved.query.mock.calls.some(([sql]) => String(sql).includes('AS unresolved_host_context')) - ).toBe(true) - }) - - it('requires an explicit path when direct and team runtime behavior is unknown', async () => { - const db = fakeTransaction({ - memberships: [ - { - teamId, - role: 'member', - membershipUpdatedAt: '2026-08-10T00:00:00.000Z', - teamRevision: '1', - }, - ], - hostGrantRows: [ - { - kind: 'direct', - grant_id: `user_agents:${userId}:host-a`, - team_id: null, - current_role: null, - }, - { - kind: 'team', - grant_id: `team_agents:${teamId}:host-a`, - team_id: teamId, - current_role: 'member', - }, - ], - }) - - const result = await resolveLiveAuthorization(hostRequest(), { - transaction: db.transaction, - }) - - expect(result.status).toBe('access_path_required') - if (result.status === 'access_path_required') { - expect(result.safePathDescriptors).toEqual([ - expect.objectContaining({ kind: 'direct' }), - expect.objectContaining({ kind: 'team', teamId }), - ]) - } - }) - - it('reports only the capabilities of the explicitly selected path', async () => { - const db = fakeTransaction({ - memberships: [ - { - teamId, - role: 'member', - membershipUpdatedAt: '2026-08-10T00:00:00.000Z', - teamRevision: '1', - }, - ], - hostGrantRows: [ - { - kind: 'direct', - grant_id: 'gfs_grants:direct', - team_id: null, - current_role: null, - permissions: ['read', 'write'], - drive: '3rd', - }, - { - kind: 'team', - grant_id: 'gfs_grants:team', - team_id: teamId, - current_role: 'member', - permissions: ['read'], - drive: '3rd', - }, - ], - }) - - const choice = await resolveLiveAuthorization(gfsRequest(), { - transaction: db.transaction, - }) - expect(choice.status).toBe('access_path_required') - if (choice.status !== 'access_path_required') return - const selectedTeamPath = choice.safePathDescriptors.find(path => path.kind === 'team') - expect(selectedTeamPath).toBeDefined() - - const result = await resolveLiveAuthorization( - gfsRequest({ requestedAccessPathId: selectedTeamPath!.id }), - { transaction: db.transaction } - ) - - expect(result).toEqual( - expect.objectContaining({ - status: 'allowed', - effectiveCapabilities: ['gfs.read'], - selectedPath: expect.objectContaining({ kind: 'team', teamId }), - }) - ) - }) - - it('preserves a path identity when capability selection filters a sibling path', async () => { - const options = { - memberships: [ - { - teamId, - role: 'member', - membershipUpdatedAt: '2026-08-10T00:00:00.000Z', - teamRevision: '1', - }, - ], - hostGrantRows: [ - { - kind: 'direct', - grant_id: 'gfs_grants:direct', - team_id: null, - current_role: null, - permissions: ['read', 'write'], - drive: '3rd', - }, - { - kind: 'team', - grant_id: 'gfs_grants:team', - team_id: teamId, - current_role: 'member', - permissions: ['read'], - drive: '3rd', - }, - ], - } - const catalogEquivalent = await resolveLiveAuthorization(gfsRequest(), { - transaction: fakeTransaction(options).transaction, - }) - expect(catalogEquivalent.status).toBe('access_path_required') - if (catalogEquivalent.status !== 'access_path_required') return - const directPath = catalogEquivalent.safePathDescriptors.find(path => path.kind === 'direct') - const teamPath = catalogEquivalent.safePathDescriptors.find(path => path.kind === 'team') - expect(directPath).toBeDefined() - expect(teamPath).toBeDefined() - - const write = await resolveLiveAuthorization( - gfsRequest({ - requiredCapability: 'gfs.write', - requestedAccessPathId: directPath!.id, - }), - { transaction: fakeTransaction(options).transaction } - ) - - expect(write).toEqual( - expect.objectContaining({ - status: 'allowed', - selectedPath: expect.objectContaining({ id: directPath!.id, kind: 'direct' }), - }) - ) - - await expect( - resolveLiveAuthorization( - gfsRequest({ - requiredCapability: 'gfs.write', - requestedAccessPathId: teamPath!.id, - }), - { transaction: fakeTransaction(options).transaction } - ) - ).resolves.toEqual( - expect.objectContaining({ status: 'access_path_stale', code: 'access_path_stale' }) - ) - - const unheldDb = fakeTransaction(options) - await expect( - resolveLiveAuthorization(gfsRequest({ requiredCapability: 'gfs.delete' }), { - transaction: unheldDb.transaction, - }) - ).resolves.toEqual({ status: 'denied', code: 'forbidden' }) - expect(unheldDb.query).toHaveBeenCalled() - - const unsupportedDb = fakeTransaction(options) - await expect( - resolveLiveAuthorization(gfsRequest({ requiredCapability: 'gfs.future.unsupported' }), { - transaction: unsupportedDb.transaction, - }) - ).resolves.toEqual({ status: 'denied', code: 'unknown_capability' }) - expect(unsupportedDb.query).not.toHaveBeenCalled() - }) - - it('charges the complete identity seed set before capability selection', async () => { - const db = fakeTransaction({ - memberships: [ - { - teamId, - role: 'member', - membershipUpdatedAt: '2026-08-10T00:00:00.000Z', - teamRevision: '1', - }, - ], - hostGrantRows: [ - { - kind: 'direct', - grant_id: 'gfs_grants:direct', - team_id: null, - current_role: null, - permissions: ['read', 'write'], - drive: '3rd', - }, - { - kind: 'team', - grant_id: 'gfs_grants:team', - team_id: teamId, - current_role: 'member', - permissions: ['read'], - drive: '3rd', - }, - ], - }) - const budget = AccessExecutionBudget.create('action', { limits: { accessPaths: 1 } }) - try { - await expect( - resolveLiveAuthorization(gfsRequest({ requiredCapability: 'gfs.write' }), { - transaction: db.transaction, - budget, - }) - ).resolves.toEqual({ - status: 'unavailable', - dependencyClass: 'capacity', - retryable: true, - }) - } finally { - budget.close() - } - }) - - it('revalidates the exact provider incarnation for a selected operational path', async () => { - const currentObject = { - metadata: { - name: 'host-a', - namespace: 'mcp-host', - uid: 'uid-host-a', - resourceVersion: '91', - generation: 1, - }, - spec: { enabled: true }, - } - const firstDb = fakeTransaction() - const gateway = { getResourceExact: vi.fn().mockResolvedValue(currentObject) } - const first = await resolveLiveAuthorization(hostRequest(), { - transaction: firstDb.transaction, - gateway, - }) - expect(first.status).toBe('allowed') - if (first.status !== 'allowed') return - - gateway.getResourceExact.mockResolvedValue({ - ...currentObject, - metadata: { ...currentObject.metadata, uid: 'uid-host-recreated', resourceVersion: '1' }, - }) - const secondDb = fakeTransaction() - const second = await resolveLiveAuthorization( - hostRequest({ requestedAccessPathId: first.selectedPath.id }), - { transaction: secondDb.transaction, gateway } - ) - - expect(second).toEqual( - expect.objectContaining({ status: 'access_path_stale', code: 'access_path_stale' }) - ) - }) - - it('rejects incomplete and role-escalating team invitation targets', async () => { - const base = { - session, - requiredCapability: 'team.member.invite', - resource: canonicalResourceIdentity({ - environmentId, - type: 'team', - logicalId: teamId, - }), - } satisfies LiveAuthorizationInput - const db = fakeTransaction({ - memberships: [ - { - teamId, - role: 'inviter', - membershipUpdatedAt: '2026-08-10T00:00:00.000Z', - teamRevision: '1', - }, - ], - }) - - await expect(resolveLiveAuthorization(base, { transaction: db.transaction })).resolves.toEqual({ - status: 'invalid', - code: 'invalid_operation_target', - }) - - await expect( - resolveLiveAuthorization( - { - ...base, - operationTarget: { teamId, action: 'create', role: 'admin' }, - }, - { transaction: db.transaction } - ) - ).resolves.toEqual({ status: 'denied', code: 'forbidden' }) - }) - - it('rejects a caller-selected environment before authority or gateway work', async () => { - const db = fakeTransaction() - const gateway = { getResourceExact: vi.fn() } - - await expect( - resolveLiveAuthorization( - hostRequest({ - resource: canonicalResourceIdentity({ - environmentId: 'attacker:selected-environment', - type: 'host', - logicalId: 'mcp-host/host-a', - }), - }), - { transaction: db.transaction, gateway } - ) - ).resolves.toEqual({ status: 'invalid', code: 'invalid_resource' }) - expect(db.query).not.toHaveBeenCalled() - expect(gateway.getResourceExact).not.toHaveBeenCalled() - }) -}) diff --git a/control-api/test/externalInvitationAcceptSession.realPostgres.integration.test.ts b/control-api/test/externalInvitationAcceptSession.realPostgres.integration.test.ts index bf9a7d1ccc..c478a1badf 100644 --- a/control-api/test/externalInvitationAcceptSession.realPostgres.integration.test.ts +++ b/control-api/test/externalInvitationAcceptSession.realPostgres.integration.test.ts @@ -50,10 +50,6 @@ const policy = { issueV2: true, renewV2: true, switchCompatibility: false, - actionContextV2: false, - rpcDelegationV2: false, - desktopAllTeamMode: false, - profileV2Mode: false, minimumClientVersion: null, enforceMinimumClient: false, } as EffectiveUserAccessPolicy diff --git a/control-api/test/externalInvitationPasswordFirstSet.realPostgres.integration.test.ts b/control-api/test/externalInvitationPasswordFirstSet.realPostgres.integration.test.ts index 8376f4b01b..6220f75d15 100644 --- a/control-api/test/externalInvitationPasswordFirstSet.realPostgres.integration.test.ts +++ b/control-api/test/externalInvitationPasswordFirstSet.realPostgres.integration.test.ts @@ -69,10 +69,6 @@ const policy = { issueV2: true, renewV2: true, switchCompatibility: false, - actionContextV2: false, - rpcDelegationV2: false, - desktopAllTeamMode: false, - profileV2Mode: false, minimumClientVersion: null, enforceMinimumClient: false, } as EffectiveUserAccessPolicy diff --git a/control-api/test/externalSessionAuthentication.test.ts b/control-api/test/externalSessionAuthentication.test.ts index 6939c34b75..7fc71eccdb 100644 --- a/control-api/test/externalSessionAuthentication.test.ts +++ b/control-api/test/externalSessionAuthentication.test.ts @@ -35,10 +35,6 @@ function policy(overrides: Partial = {}): EffectiveUs issueV2: false, renewV2: false, switchCompatibility: true, - actionContextV2: false, - rpcDelegationV2: false, - desktopAllTeamMode: false, - profileV2Mode: false, minimumClientVersion: null, enforceMinimumClient: false, ...overrides, diff --git a/control-api/test/externalSessionCurrentness.realPostgres.integration.test.ts b/control-api/test/externalSessionCurrentness.realPostgres.integration.test.ts index 690afdc5a8..cc5e6dd216 100644 --- a/control-api/test/externalSessionCurrentness.realPostgres.integration.test.ts +++ b/control-api/test/externalSessionCurrentness.realPostgres.integration.test.ts @@ -48,10 +48,6 @@ const policy = { issueV2: true, renewV2: true, switchCompatibility: true, - actionContextV2: false, - rpcDelegationV2: false, - desktopAllTeamMode: false, - profileV2Mode: false, minimumClientVersion: null, enforceMinimumClient: false, } as EffectiveUserAccessPolicy diff --git a/control-api/test/externalSessionSingleTouch.realPostgres.integration.test.ts b/control-api/test/externalSessionSingleTouch.realPostgres.integration.test.ts index 6d3b58dd83..3930e7080a 100644 --- a/control-api/test/externalSessionSingleTouch.realPostgres.integration.test.ts +++ b/control-api/test/externalSessionSingleTouch.realPostgres.integration.test.ts @@ -125,10 +125,6 @@ describeRealPostgres('external-session staged touch ownership on real PostgreSQL switchCompatibility: true, minimumClientVersion: null, enforceMinimumClient: false, - actionContextV2: true, - rpcDelegationV2: false, - desktopAllTeamMode: false, - profileV2Mode: false, } await expect( authenticateExternalUserSession(issued.token, { diff --git a/control-api/test/externalSessionV1CanonicalRevocation.realPostgres.integration.test.ts b/control-api/test/externalSessionV1CanonicalRevocation.realPostgres.integration.test.ts index 1b5f011c86..ea5a457a7f 100644 --- a/control-api/test/externalSessionV1CanonicalRevocation.realPostgres.integration.test.ts +++ b/control-api/test/externalSessionV1CanonicalRevocation.realPostgres.integration.test.ts @@ -44,10 +44,6 @@ const policy = { issueV2: true, renewV2: true, switchCompatibility: true, - actionContextV2: false, - rpcDelegationV2: false, - desktopAllTeamMode: false, - profileV2Mode: false, minimumClientVersion: null, enforceMinimumClient: false, } as EffectiveUserAccessPolicy diff --git a/control-api/test/gfs.externalAuthority.test.ts b/control-api/test/gfs.externalAuthority.test.ts index fec28d9431..d85f88cd1b 100644 --- a/control-api/test/gfs.externalAuthority.test.ts +++ b/control-api/test/gfs.externalAuthority.test.ts @@ -166,10 +166,6 @@ describe('signCurrentExternalGfsUserToken', () => { issueV2: false, renewV2: false, switchCompatibility: true, - actionContextV2: false, - rpcDelegationV2: false, - desktopAllTeamMode: false, - profileV2Mode: false, minimumClientVersion: null, enforceMinimumClient: false, } diff --git a/control-api/test/helpers/temporaryKubernetesApi.ts b/control-api/test/helpers/temporaryKubernetesApi.ts deleted file mode 100644 index 7cd331964f..0000000000 --- a/control-api/test/helpers/temporaryKubernetesApi.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { type Server, type ServerResponse, createServer } from 'node:http' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -type KubernetesObject = Readonly> - -type RecordedRequest = Readonly<{ - method: string - namespace: string - plural: string - name: string | null - watch: boolean - resourceVersion: string | null - limit: string | null - continueToken: string | null -}> - -function metadata(value: KubernetesObject): Record { - const candidate = value.metadata - return candidate && typeof candidate === 'object' && !Array.isArray(candidate) - ? (candidate as Record) - : {} -} - -function json(response: ServerResponse, status: number, body: unknown): void { - const serialized = JSON.stringify(body) - response.writeHead(status, { - 'content-type': 'application/json', - 'content-length': Buffer.byteLength(serialized), - }) - response.end(serialized) -} - -export class TemporaryKubernetesApi { - private readonly objects = new Map() - private readonly watchResponses = new Set() - private readonly server: Server - private scratchDirectory: string | null = null - private kubeconfigPath: string | null = null - private origin: string | null = null - private heldList: { - response: ServerResponse | null - requested: () => void - closed: () => void - } | null = null - readonly requests: RecordedRequest[] = [] - - constructor() { - this.server = createServer((request, response) => { - void this.handle(request.method ?? 'GET', request.url ?? '/', response) - }) - } - - async start(): Promise { - await new Promise((resolve, reject) => { - this.server.once('error', reject) - this.server.listen(0, '127.0.0.1', () => { - this.server.off('error', reject) - resolve() - }) - }) - const address = this.server.address() - if (!address || typeof address === 'string') throw new Error('temporary_kubernetes_address') - this.origin = `http://127.0.0.1:${address.port}` - const scratchRoot = process.env.CODEX_SCRATCH_ROOT || tmpdir() - this.scratchDirectory = await mkdtemp(join(scratchRoot, 'evenfire-k8s-api-')) - this.kubeconfigPath = join(this.scratchDirectory, 'kubeconfig.yaml') - await writeFile( - this.kubeconfigPath, - `apiVersion: v1 -kind: Config -clusters: - - name: temporary - cluster: - server: ${this.origin} - insecure-skip-tls-verify: true -users: - - name: temporary - user: {} -contexts: - - name: temporary - context: - cluster: temporary - user: temporary -current-context: temporary -`, - 'utf8' - ) - } - - kubeconfig(): string { - if (!this.kubeconfigPath) throw new Error('temporary_kubernetes_not_started') - return this.kubeconfigPath - } - - put(plural: string, namespace: string, object: KubernetesObject): void { - const name = String(metadata(object).name ?? '') - if (!name) throw new Error('temporary_kubernetes_name_missing') - this.objects.set(`${namespace}/${plural}/${name}`, object) - } - - delete(plural: string, namespace: string, name: string): void { - this.objects.delete(`${namespace}/${plural}/${name}`) - } - - emitWatch(phase: string, object: KubernetesObject): void { - const event = `${JSON.stringify({ type: phase, object })}\n` - for (const response of this.watchResponses) response.write(event) - } - - holdNextList(): Readonly<{ - requested: Promise - closed: Promise - release: () => void - }> { - if (this.heldList) throw new Error('temporary_kubernetes_list_already_held') - let requested!: () => void - let closed!: () => void - const requestedPromise = new Promise(resolve => { - requested = resolve - }) - const closedPromise = new Promise(resolve => { - closed = resolve - }) - this.heldList = { response: null, requested, closed } - return Object.freeze({ - requested: requestedPromise, - closed: closedPromise, - release: () => { - const held = this.heldList - if (!held?.response) return - json(held.response, 200, { - apiVersion: 'clerum.io/v1alpha1', - kind: 'List', - metadata: { resourceVersion: '1', continue: '' }, - items: [], - }) - this.heldList = null - }, - }) - } - - async close(): Promise { - const held = this.heldList - this.heldList = null - if (held?.response) held.response.destroy() - held?.closed() - for (const response of this.watchResponses) response.destroy() - this.watchResponses.clear() - this.server.closeAllConnections?.() - if (this.server.listening) { - await new Promise(resolve => this.server.close(() => resolve())) - } - if (this.scratchDirectory) await rm(this.scratchDirectory, { recursive: true, force: true }) - this.scratchDirectory = null - this.kubeconfigPath = null - } - - private async handle(method: string, rawUrl: string, response: ServerResponse): Promise { - const requestUrl = new URL(rawUrl, this.origin ?? 'http://127.0.0.1') - const match = requestUrl.pathname.match( - /^\/apis\/clerum\.io\/v1alpha1\/namespaces\/([^/]+)\/([^/]+)(?:\/([^/]+))?$/ - ) - if (method !== 'GET' || !match) { - json(response, 404, { kind: 'Status', code: 404, reason: 'NotFound' }) - return - } - const namespace = decodeURIComponent(match[1]) - const plural = decodeURIComponent(match[2]) - const name = match[3] ? decodeURIComponent(match[3]) : null - const watch = requestUrl.searchParams.get('watch') === 'true' - this.requests.push( - Object.freeze({ - method, - namespace, - plural, - name, - watch, - resourceVersion: requestUrl.searchParams.get('resourceVersion'), - limit: requestUrl.searchParams.get('limit'), - continueToken: requestUrl.searchParams.get('continue'), - }) - ) - if (watch) { - response.writeHead(200, { - 'content-type': 'application/json', - 'transfer-encoding': 'chunked', - }) - this.watchResponses.add(response) - response.on('close', () => this.watchResponses.delete(response)) - return - } - if (name) { - const object = this.objects.get(`${namespace}/${plural}/${name}`) - if (!object) { - json(response, 404, { kind: 'Status', code: 404, reason: 'NotFound' }) - return - } - json(response, 200, object) - return - } - if (this.heldList && !this.heldList.response) { - this.heldList.response = response - this.heldList.requested() - response.on('close', () => { - this.heldList?.closed() - this.heldList = null - }) - return - } - const all = [...this.objects.entries()] - .filter(([key]) => key.startsWith(`${namespace}/${plural}/`)) - .map(([, object]) => object) - .sort((left, right) => - String(metadata(left).name).localeCompare(String(metadata(right).name)) - ) - const start = Number(requestUrl.searchParams.get('continue') ?? 0) - const requestedLimit = Number(requestUrl.searchParams.get('limit') ?? 100) - const limit = Number.isSafeInteger(requestedLimit) && requestedLimit > 0 ? requestedLimit : 100 - const items = all.slice(start, start + limit) - const next = start + items.length < all.length ? String(start + items.length) : '' - const resourceVersion = all.reduce( - (maximum, object) => Math.max(maximum, Number(metadata(object).resourceVersion ?? 0)), - 1 - ) - json(response, 200, { - apiVersion: 'clerum.io/v1alpha1', - kind: 'List', - metadata: { resourceVersion: String(resourceVersion), continue: next }, - items, - }) - } -} diff --git a/control-api/test/invitationFirstPasswordIdentity.realPostgres.integration.test.ts b/control-api/test/invitationFirstPasswordIdentity.realPostgres.integration.test.ts index 8c915197e9..3271ff8468 100644 --- a/control-api/test/invitationFirstPasswordIdentity.realPostgres.integration.test.ts +++ b/control-api/test/invitationFirstPasswordIdentity.realPostgres.integration.test.ts @@ -52,10 +52,6 @@ const policy = { issueV2: true, renewV2: true, switchCompatibility: false, - actionContextV2: false, - rpcDelegationV2: false, - desktopAllTeamMode: false, - profileV2Mode: false, minimumClientVersion: null, enforceMinimumClient: false, } as EffectiveUserAccessPolicy diff --git a/control-api/test/k8s.watchResource.test.ts b/control-api/test/k8s.watchResource.test.ts deleted file mode 100644 index c62ba4098b..0000000000 --- a/control-api/test/k8s.watchResource.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { K8sGateway } from '../src/k8s.js' - -const mocks = vi.hoisted(() => ({ - watch: vi.fn(), -})) - -vi.mock('@kubernetes/client-node', async importOriginal => { - const actual = await importOriginal() - return { - ...actual, - Watch: class { - watch = mocks.watch - }, - } -}) - -describe('K8sGateway.watchResource', () => { - it('does not start a watch for an already-aborted request', async () => { - mocks.watch.mockImplementation(() => new Promise(() => undefined)) - const gateway = Object.create(K8sGateway.prototype) as K8sGateway - Object.defineProperties(gateway, { - resources: { - value: { assertNamespaceAllowed: vi.fn() }, - }, - kc: { value: {} }, - }) - const controller = new AbortController() - controller.abort() - - await expect( - Promise.race([ - gateway.watchResource('hosts', 'mcp-host', '42', controller.signal, vi.fn()), - new Promise((_, reject) => - setTimeout(() => reject(new Error('watch did not settle')), 250) - ), - ]) - ).resolves.toBeUndefined() - expect(mocks.watch).not.toHaveBeenCalled() - }) - - it('rejects and aborts when an event handler fails before the controller resolves', async () => { - const controller = { abort: vi.fn() } - const handlerError = new Error('handler failed') - mocks.watch.mockImplementation( - async ( - _path: string, - _options: unknown, - onEvent: (phase: string, object: unknown) => void - ) => { - onEvent('ADDED', { metadata: { name: 'host-a' } }) - await Promise.resolve() - return controller - } - ) - - const gateway = Object.create(K8sGateway.prototype) as K8sGateway - Object.defineProperties(gateway, { - resources: { - value: { assertNamespaceAllowed: vi.fn() }, - }, - kc: { value: {} }, - }) - - const work = gateway.watchResource( - 'hosts', - 'mcp-host', - '42', - new AbortController().signal, - async () => { - throw handlerError - } - ) - - await expect( - Promise.race([ - work, - new Promise((_, reject) => - setTimeout(() => reject(new Error('watch did not settle')), 250) - ), - ]) - ).rejects.toBe(handlerError) - expect(controller.abort).toHaveBeenCalledOnce() - }) - - it('aborts instead of accumulating an unbounded watch-event backlog', async () => { - const controller = { abort: vi.fn() } - let releaseFirstEvent: (() => void) | undefined - const firstEventBlocked = new Promise(resolve => { - releaseFirstEvent = resolve - }) - mocks.watch.mockImplementation( - async ( - _path: string, - _options: unknown, - onEvent: (phase: string, object: unknown) => void - ) => { - onEvent('ADDED', { metadata: { name: 'host-0' } }) - await Promise.resolve() - for (let index = 1; index <= 3; index += 1) { - onEvent('ADDED', { metadata: { name: `host-${index}` } }) - } - return controller - } - ) - - const gateway = Object.create(K8sGateway.prototype) as K8sGateway - Object.defineProperties(gateway, { - resources: { - value: { assertNamespaceAllowed: vi.fn() }, - }, - kc: { value: {} }, - }) - - const work = gateway.watchResource( - 'hosts', - 'mcp-host', - '42', - new AbortController().signal, - async () => firstEventBlocked, - { maxPendingEvents: 3, maxPendingBytes: 1_024, maxObjectBytes: 512 } - ) - - await vi.waitFor(() => expect(controller.abort).toHaveBeenCalledOnce(), { timeout: 250 }) - releaseFirstEvent?.() - await expect(work).rejects.toThrow('watch event budget exceeded') - }) -}) diff --git a/control-api/test/network.gateway.intent.test.ts b/control-api/test/network.gateway.intent.test.ts index f50aa079bb..6e398cf234 100644 --- a/control-api/test/network.gateway.intent.test.ts +++ b/control-api/test/network.gateway.intent.test.ts @@ -150,30 +150,6 @@ function roleRuleForResource(doc: string, resource: string): string { return lines.slice(start, end).join('\n') } -function roleRulesForResource(doc: string, resource: string): string[] { - const lines = doc.split('\n') - const rules: string[] = [] - for (let index = 0; index < lines.length; index += 1) { - if ( - lines[index] !== ` - ${resource}` && - !lines[index].includes(`['${resource}']`) && - !lines[index].includes(`["${resource}"]`) - ) { - continue - } - let start = index - while (start > 0 && lines[start] !== '- apiGroups:' && lines[start] !== ' - apiGroups:') { - start -= 1 - } - let end = index + 1 - while (end < lines.length && lines[end] !== '- apiGroups:' && lines[end] !== ' - apiGroups:') { - end += 1 - } - rules.push(lines.slice(start, end).join('\n')) - } - return rules -} - function fromPeerBlock(doc: string, marker: string): string { const lines = doc.split('\n') const markerIndex = lines.findIndex(line => line.includes(marker)) @@ -1104,39 +1080,6 @@ describe('network/gateway intent (manifest-level)', () => { } }) - it('grants control-api watch access only for operational access index families', () => { - const clusterRoles = read(`${BASE}/cluster-wide/clusterroles.yaml`) - const controlApiClusterRole = docContaining(yamlDocs(clusterRoles), 'name: control-api') - const hostRbac = read(`${BASE}/mcp-host/rbac.yaml`) - const controlApiHostRole = docContaining( - yamlDocs(hostRbac), - 'name: control-api-hosts-and-secrets' - ) - const sandboxRbac = read(`${BASE}/sandbox-recipes/rbac.yaml`) - const controlApiWorkflowRole = docContaining( - yamlDocs(sandboxRbac), - 'name: control-api-workflow-recipes-sandbox' - ) - - for (const resource of ['hosts', 'contexts', 'mcpservers']) { - expect(roleRulesForResource(controlApiClusterRole, resource)).toSatisfy(rules => - rules.some(rule => rule.includes(' - watch')) - ) - } - expect(roleRulesForResource(controlApiClusterRole, 'communicationchannels')).toSatisfy(rules => - rules.every(rule => !rule.includes(' - watch')) - ) - expect(roleRulesForResource(controlApiHostRole, 'sharedfilesystems')).toSatisfy(rules => - rules.some(rule => rule.includes('watch')) - ) - expect(roleRulesForResource(controlApiWorkflowRole, 'workflowrecipes')).toSatisfy(rules => - rules.some(rule => rule.includes('watch')) - ) - - const controlApiConfig = read(`${BASE}/control-plane/configmaps.yaml`) - expect(controlApiConfig).toContain("CONTROL_API_OPERATIONAL_ACCESS_INDEXER_ENABLED: 'false'") - }) - it('routes every rpc-proxy internal-service endpoint through the control-api-rpc-gateway allowlist', () => { const configmaps = read(`${BASE}/control-plane/configmaps.yaml`) const gatewayConf = docContaining(yamlDocs(configmaps), 'name: control-api-rpc-gateway') diff --git a/control-api/test/operationalAccessIndexer.test.ts b/control-api/test/operationalAccessIndexer.test.ts deleted file mode 100644 index e0ab63c7ae..0000000000 --- a/control-api/test/operationalAccessIndexer.test.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { K8sGateway } from '../src/k8s.js' -import { - type AccessExecutionBudget, - type AccessExecutionLimits, -} from '../src/services/access/accessExecutionBudget.js' -import type { OperationalAccessIndex } from '../src/services/access/operationalAccessIndex.js' -import { OperationalAccessIndexer } from '../src/services/access/operationalAccessIndexer.js' -import { TemporaryKubernetesApi } from './helpers/temporaryKubernetesApi.js' - -function hostObject(name: string, resourceVersion: string) { - return { - metadata: { - name, - namespace: 'mcp-host', - uid: `uid-${name}`, - resourceVersion, - }, - spec: { host: name, contextRef: 'ctx-a', secretRef: 'secret-a' }, - } -} - -function fakeIndex() { - return { - beginRelist: vi.fn().mockResolvedValue(2), - stageRelistPage: vi.fn().mockResolvedValue(undefined), - promoteRelist: vi.fn().mockResolvedValue(undefined), - applyWatchProjection: vi.fn().mockResolvedValue(3), - recordWatchBookmark: vi.fn().mockResolvedValue(undefined), - markSourceState: vi.fn().mockResolvedValue(undefined), - } -} - -function meteredIndex() { - const observed: { - limits?: AccessExecutionLimits - beforePromotion?: { producerCalls: number; databaseStatements: number } - afterPromotion?: { producerCalls: number; databaseStatements: number } - } = {} - const charge = ( - budget: AccessExecutionBudget, - producerCalls: number, - databaseStatements: number - ) => { - budget.charge({ kind: 'producerCalls', amount: producerCalls }) - budget.charge({ kind: 'databaseStatements', amount: databaseStatements }) - } - const index = { - beginRelist: vi.fn(async ({ budget }: { budget: AccessExecutionBudget }) => { - observed.limits = budget.limits - charge(budget, 2, 4) - return 2 - }), - stageRelistPage: vi.fn(async ({ budget }: { budget: AccessExecutionBudget }) => - charge(budget, 2, 4) - ), - promoteRelist: vi.fn(async ({ budget }: { budget: AccessExecutionBudget }) => { - observed.beforePromotion = { - producerCalls: budget.remaining('producerCalls'), - databaseStatements: budget.remaining('databaseStatements'), - } - charge(budget, 9, 11) - observed.afterPromotion = { - producerCalls: budget.remaining('producerCalls'), - databaseStatements: budget.remaining('databaseStatements'), - } - }), - applyWatchProjection: vi.fn().mockResolvedValue(3), - recordWatchBookmark: vi.fn().mockResolvedValue(undefined), - markSourceState: vi.fn().mockResolvedValue(undefined), - } - return { index, observed } -} - -async function temporaryHostGateway(objectCount: number) { - const api = new TemporaryKubernetesApi() - await api.start() - const previousKubeconfig = process.env.KUBECONFIG - process.env.KUBECONFIG = api.kubeconfig() - const gateway = new K8sGateway() - for (let index = 0; index < objectCount; index += 1) { - api.put('hosts', 'mcp-host', hostObject(`host-${index}`, '10')) - } - return { - api, - gateway, - async close() { - if (previousKubeconfig === undefined) delete process.env.KUBECONFIG - else process.env.KUBECONFIG = previousKubeconfig - await api.close() - }, - } -} - -describe('operational access indexer', () => { - it('reconciles bounded Kubernetes pages into one atomic source generation', async () => { - const index = fakeIndex() - const listResourcePage = vi - .fn() - .mockResolvedValueOnce({ - items: [hostObject('host-a', '10')], - continueToken: 'next', - resourceVersion: '10', - }) - .mockResolvedValueOnce({ - items: [hostObject('host-b', '10')], - continueToken: null, - resourceVersion: '10', - }) - const indexer = new OperationalAccessIndexer( - { - listResourcePage, - watchResource: vi.fn(), - getResourceExact: vi.fn(), - }, - index as unknown as OperationalAccessIndex, - { environmentId: 'test:cluster', behaviorFingerprintKey: 'test-key' } - ) - - await expect( - indexer.reconcileSource({ family: 'host', plural: 'hosts', namespace: 'mcp-host' }) - ).resolves.toBe('10') - - expect(listResourcePage).toHaveBeenCalledTimes(2) - expect(listResourcePage.mock.calls[0]?.[2]).toMatchObject({ limit: 100 }) - expect(listResourcePage.mock.calls[1]?.[2]).toMatchObject({ - limit: 100, - continueToken: 'next', - }) - expect(index.stageRelistPage).toHaveBeenCalledTimes(2) - expect(index.promoteRelist).toHaveBeenCalledOnce() - }) - - it.each([ - [700, 7], - [701, 8], - [1_000, 10], - ])( - 'reconstructs %i objects across %i pages before one atomic promotion', - async (count, pages) => { - const { index, observed } = meteredIndex() - const producer = await temporaryHostGateway(count) - try { - const indexer = new OperationalAccessIndexer( - producer.gateway, - index as unknown as OperationalAccessIndex, - { environmentId: 'test:cluster', behaviorFingerprintKey: 'test-key' } - ) - - await expect( - indexer.reconcileSource({ family: 'host', plural: 'hosts', namespace: 'mcp-host' }) - ).resolves.toBe('10') - - const pageRequests = producer.api.requests.filter( - value => value.plural === 'hosts' && !value.watch - ) - expect(pageRequests).toHaveLength(pages) - expect(pageRequests.every(value => value.limit === '100')).toBe(true) - expect(index.stageRelistPage).toHaveBeenCalledTimes(pages) - expect(index.stageRelistPage.mock.calls.map(([input]) => input.stagingGeneration)).toEqual( - Array(pages).fill(2) - ) - expect(index.promoteRelist).toHaveBeenCalledOnce() - expect(index.promoteRelist).toHaveBeenCalledWith( - expect.objectContaining({ stagingGeneration: 2, resourceVersion: '10' }) - ) - if (count === 1_000) { - expect(observed.limits).toMatchObject({ producerCalls: 41, databaseStatements: 55 }) - expect(observed.beforePromotion).toEqual({ producerCalls: 9, databaseStatements: 11 }) - expect(observed.afterPromotion).toEqual({ producerCalls: 0, databaseStatements: 0 }) - } - } finally { - await producer.close() - } - } - ) - - it('does not promote an over-capacity or cancelled source reconstruction', async () => { - const overCapacity = meteredIndex() - const overCapacityProducer = await temporaryHostGateway(1_001) - try { - const overCapacityIndexer = new OperationalAccessIndexer( - overCapacityProducer.gateway, - overCapacity.index as unknown as OperationalAccessIndex, - { environmentId: 'test:cluster', behaviorFingerprintKey: 'test-key' } - ) - await expect( - overCapacityIndexer.reconcileSource({ - family: 'host', - plural: 'hosts', - namespace: 'mcp-host', - }) - ).rejects.toMatchObject({ name: 'AccessBudgetExceededError', limit: 'objects' }) - expect(overCapacity.index.promoteRelist).not.toHaveBeenCalled() - } finally { - await overCapacityProducer.close() - } - - const controller = new AbortController() - const cancelled = meteredIndex() - const cancelledProducer = await temporaryHostGateway(100) - try { - const cancelledGateway = { - listResourcePage: async (...args: Parameters) => { - const page = await cancelledProducer.gateway.listResourcePage(...args) - controller.abort() - return page - }, - watchResource: cancelledProducer.gateway.watchResource.bind(cancelledProducer.gateway), - getResourceExact: cancelledProducer.gateway.getResourceExact.bind( - cancelledProducer.gateway - ), - } - const cancelledIndexer = new OperationalAccessIndexer( - cancelledGateway, - cancelled.index as unknown as OperationalAccessIndex, - { environmentId: 'test:cluster', behaviorFingerprintKey: 'test-key' } - ) - - await expect( - cancelledIndexer.reconcileSource( - { family: 'host', plural: 'hosts', namespace: 'mcp-host' }, - controller.signal - ) - ).rejects.toMatchObject({ name: 'AccessExecutionCancelledError' }) - expect(cancelled.index.stageRelistPage).not.toHaveBeenCalled() - expect(cancelled.index.promoteRelist).not.toHaveBeenCalled() - } finally { - await cancelledProducer.close() - } - }) - - it('does not promote pages from inconsistent snapshot resource versions', async () => { - const index = fakeIndex() - const indexer = new OperationalAccessIndexer( - { - listResourcePage: vi - .fn() - .mockResolvedValueOnce({ items: [], continueToken: 'next', resourceVersion: '10' }) - .mockResolvedValueOnce({ items: [], continueToken: null, resourceVersion: '11' }), - watchResource: vi.fn(), - getResourceExact: vi.fn(), - }, - index as unknown as OperationalAccessIndex, - { environmentId: 'test:cluster', behaviorFingerprintKey: 'test-key' } - ) - - await expect( - indexer.reconcileSource({ family: 'host', plural: 'hosts', namespace: 'mcp-host' }) - ).rejects.toThrow('operational_relist_snapshot_changed') - expect(index.promoteRelist).not.toHaveBeenCalled() - }) - - it('applies watch deletion and rejects expired watches for relist', async () => { - const index = fakeIndex() - const indexer = new OperationalAccessIndexer( - { - listResourcePage: vi.fn(), - watchResource: vi.fn(), - getResourceExact: vi.fn(), - }, - index as unknown as OperationalAccessIndex, - { environmentId: 'test:cluster', behaviorFingerprintKey: 'test-key' } - ) - const source = { family: 'host', plural: 'hosts', namespace: 'mcp-host' } as const - - await indexer.applyWatchEvent(source, 'DELETED', hostObject('host-a', '12')) - expect(index.applyWatchProjection).toHaveBeenCalledWith( - expect.objectContaining({ deleted: true, resourceVersion: '12' }) - ) - await expect(indexer.applyWatchEvent(source, 'ERROR', { code: 410 })).rejects.toThrow( - 'watch expired' - ) - }) - - it('records bookmarks without rewriting resource projections', async () => { - const index = fakeIndex() - const indexer = new OperationalAccessIndexer( - { - listResourcePage: vi.fn(), - watchResource: vi.fn(), - getResourceExact: vi.fn(), - }, - index as unknown as OperationalAccessIndex, - { environmentId: 'test:cluster', behaviorFingerprintKey: 'test-key' } - ) - - await indexer.applyWatchEvent( - { family: 'host', plural: 'hosts', namespace: 'mcp-host' }, - 'BOOKMARK', - { metadata: { resourceVersion: '15' } } - ) - expect(index.recordWatchBookmark).toHaveBeenCalledWith( - expect.objectContaining({ resourceVersion: '15' }) - ) - expect(index.applyWatchProjection).not.toHaveBeenCalled() - }) -}) diff --git a/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts b/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts index b53d08c2ac..aa3e39e60c 100644 --- a/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts +++ b/control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts @@ -27,10 +27,6 @@ const runtimePolicy = vi.hoisted(() => ({ issueV2: false, renewV2: false, switchCompatibility: true, - actionContextV2: false, - rpcDelegationV2: false, - desktopAllTeamMode: false, - profileV2Mode: false, minimumClientVersion: null, enforceMinimumClient: false, })) diff --git a/control-api/test/routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts b/control-api/test/routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts index 52ab32b16c..accac3fcd9 100644 --- a/control-api/test/routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts +++ b/control-api/test/routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts @@ -27,10 +27,6 @@ const runtimePolicy = vi.hoisted(() => ({ issueV2: false, renewV2: false, switchCompatibility: true, - actionContextV2: false, - rpcDelegationV2: false, - desktopAllTeamMode: false, - profileV2Mode: false, minimumClientVersion: null, enforceMinimumClient: false, })) diff --git a/control-api/test/services.resourceService.test.ts b/control-api/test/services.resourceService.test.ts index 7da2418bb5..ac78ce8167 100644 --- a/control-api/test/services.resourceService.test.ts +++ b/control-api/test/services.resourceService.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it, vi } from 'vitest' -import type * as k8s from '@kubernetes/client-node' import { governedTraceAdministrativeIntentReconcileFailedTotal } from '../src/observability/metrics.js' import { K8sConflictError, @@ -91,113 +90,6 @@ describe('ResourceService.getResource', () => { }) }) -describe('ResourceService bounded operational reads', () => { - it('passes a bounded keyset page request and preserves continuation metadata', async () => { - const listNamespacedCustomObject = vi.fn().mockResolvedValue({ - items: [{ metadata: { name: 'host-a' } }], - metadata: { continue: 'next-page', resourceVersion: '42' }, - }) - const service = new ResourceService({ listNamespacedCustomObject } as never, 'control-plane', { - hosts: 'mcp-host', - }) - const controller = new AbortController() - - await expect( - service.listResourcePage('hosts', 'mcp-host', { - limit: 100, - continueToken: 'prior-page', - timeoutSeconds: 5, - signal: controller.signal, - }) - ).resolves.toEqual({ - items: [{ metadata: { name: 'host-a' } }], - continueToken: 'next-page', - resourceVersion: '42', - }) - expect(listNamespacedCustomObject).toHaveBeenCalledWith( - expect.objectContaining({ - namespace: 'mcp-host', - plural: 'hosts', - limit: 100, - _continue: 'prior-page', - timeoutSeconds: 5, - }), - expect.objectContaining({ middlewareMergeStrategy: 'append' }) - ) - }) - - it('rejects invalid limits before calling Kubernetes', async () => { - const listNamespacedCustomObject = vi.fn() - const service = new ResourceService({ listNamespacedCustomObject } as never, 'control-plane', { - hosts: 'mcp-host', - }) - - await expect( - service.listResourcePage('hosts', 'mcp-host', { - limit: 101, - timeoutSeconds: 5, - signal: new AbortController().signal, - }) - ).rejects.toThrow('between 1 and 100') - expect(listNamespacedCustomObject).not.toHaveBeenCalled() - }) - - it('propagates cancellation into the Kubernetes transport and waits for it to stop', async () => { - let transportSignal: AbortSignal | undefined - let transportStopped = false - const listNamespacedCustomObject = vi.fn( - async (_request: unknown, options: { middleware: k8s.ObservableMiddleware[] }) => { - const context = { - setSignal: (signal: AbortSignal) => { - transportSignal = signal - }, - } - await options.middleware[0]!.pre(context as never).toPromise() - return new Promise((_resolve, reject) => { - const stop = () => { - transportStopped = true - reject(new Error('transport aborted')) - } - if (transportSignal!.aborted) stop() - else transportSignal!.addEventListener('abort', stop, { once: true }) - }) - } - ) - const service = new ResourceService({ listNamespacedCustomObject } as never, 'control-plane', { - hosts: 'mcp-host', - }) - const controller = new AbortController() - const pending = service.listResourcePage('hosts', 'mcp-host', { - limit: 1, - timeoutSeconds: 5, - signal: controller.signal, - }) - - controller.abort('test-cancel') - await expect(pending).rejects.toThrow('transport aborted') - expect(transportSignal).toBe(controller.signal) - expect(transportStopped).toBe(true) - }) - - it('uses exact namespace/name lookup and maps a Kubernetes 404', async () => { - const getNamespacedCustomObject = vi.fn().mockRejectedValue(makeNotFoundError()) - const service = new ResourceService({ getNamespacedCustomObject } as never, 'control-plane', { - hosts: 'mcp-host', - }) - - await expect( - service.getResourceExact('hosts', 'host-a', 'mcp-host', { - timeoutSeconds: 2, - signal: new AbortController().signal, - }) - ).rejects.toBeInstanceOf(K8sNotFoundError) - expect(getNamespacedCustomObject).toHaveBeenCalledWith( - expect.objectContaining({ namespace: 'mcp-host', plural: 'hosts', name: 'host-a' }), - expect.objectContaining({ middlewareMergeStrategy: 'append' }) - ) - }) -}) - describe('ResourceService.updateResource', () => { it('forwards UID together with resourceVersion for an identity-bound replacement', async () => { const customApi = { diff --git a/control-api/test/shutdown.test.ts b/control-api/test/shutdown.test.ts index fb6910ceae..f5f960d18d 100644 --- a/control-api/test/shutdown.test.ts +++ b/control-api/test/shutdown.test.ts @@ -17,12 +17,12 @@ describe('Control API shutdown', () => { expect(poolIndex).toBeGreaterThan(cronIndex) }) - it('stops both database-backed dispatchers and the access indexer before pools close', () => { + it('stops the entity-change dispatcher before pools close', () => { const names = [...CONTROL_API_SHUTDOWN_STEP_NAMES] as string[] const corePoolIndex = names.indexOf('core-database-pool') + expect(names.indexOf('entity-change-dispatcher')).toBeGreaterThanOrEqual(0) expect(corePoolIndex).toBeGreaterThan(names.indexOf('entity-change-dispatcher')) - expect(corePoolIndex).toBeGreaterThan(names.indexOf('operational-access-indexer')) }) it('stops the shared OAuth grant sweep before database pools close', () => { diff --git a/control-api/test/userAccessPolicy.test.ts b/control-api/test/userAccessPolicy.test.ts index 36ae5561b8..92d62462a8 100644 --- a/control-api/test/userAccessPolicy.test.ts +++ b/control-api/test/userAccessPolicy.test.ts @@ -15,11 +15,6 @@ function intent(overrides: Partial = {}): Configured legacyLifecycle: 'issue_and_accept', sessionV2Acceptance: true, sessionV2Issuance: false, - teamGfsMembershipAdmissionLimit: null, - actionContextV2: false, - rpcDelegationV2: false, - desktopAllTeamMode: false, - profileV2Mode: false, legacySwitchEndpoint: true, minimumClientVersion: null, minimumClientEnforcement: false, @@ -37,12 +32,6 @@ function readiness(overrides: Partial = {}): DeploymentRead legacyDrainAccepted: true, sessionV2Acceptance: 'ready', sessionV2Issuance: 'ready', - accessBudget: 'ready', - actionContext: 'ready', - rpcDelegationAllHops: 'ready', - desktop: 'ready', - explicitTeamAdapters: 'ready', - profile: 'ready', minimumClientEnforcement: 'ready', ...overrides, } @@ -60,10 +49,6 @@ describe('central user-access rollout compiler', () => { issueV2: false, renewV2: false, switchCompatibility: true, - actionContextV2: false, - rpcDelegationV2: false, - desktopAllTeamMode: false, - profileV2Mode: false, enforceMinimumClient: false, }) }) @@ -94,22 +79,6 @@ describe('central user-access rollout compiler', () => { 'legacy_switch_requires_v1_issue_and_accept', intent({ legacyLifecycle: 'accept_only', sessionV2Issuance: true }), ], - ['rpc_v2_requires_action_context', intent({ rpcDelegationV2: true })], - ['desktop_mode_requires_v2_issuance', intent({ desktopAllTeamMode: true })], - [ - 'desktop_mode_requires_catalog_serving', - intent({ - sessionV2Issuance: true, - actionContextV2: true, - rpcDelegationV2: true, - desktopAllTeamMode: true, - }), - ], - [ - 'profile_explicit_team_adapters_unavailable', - intent({ sessionV2Issuance: true, profileV2Mode: true }), - readiness({ explicitTeamAdapters: 'unavailable' }), - ], [ 'minimum_client_version_missing', intent({ sessionV2Issuance: true, minimumClientEnforcement: true }), @@ -121,13 +90,10 @@ describe('central user-access rollout compiler', () => { ) }) - it('derives renewal and the gated flags only from ready dependencies', () => { + it('derives renewal and the minimum-client gate only from ready dependencies', () => { const policy = compileUserAccessPolicy( intent({ sessionV2Issuance: true, - actionContextV2: true, - rpcDelegationV2: true, - profileV2Mode: true, minimumClientVersion: '2.3.0', minimumClientEnforcement: true, }), @@ -136,9 +102,6 @@ describe('central user-access rollout compiler', () => { expect(policy).toMatchObject({ renewV2: true, - actionContextV2: true, - rpcDelegationV2: true, - profileV2Mode: true, minimumClientVersion: '2.3.0', enforceMinimumClient: true, }) @@ -149,10 +112,6 @@ describe('central user-access rollout compiler', () => { const booleanKeys = [ 'sessionV2Acceptance', 'sessionV2Issuance', - 'actionContextV2', - 'rpcDelegationV2', - 'desktopAllTeamMode', - 'profileV2Mode', 'legacySwitchEndpoint', 'minimumClientEnforcement', ] as const @@ -163,7 +122,8 @@ describe('central user-access rollout compiler', () => { for (let mask = 0; mask < 1 << booleanKeys.length; mask += 1) { const configured = intent({ legacyLifecycle, - minimumClientVersion: mask & (1 << 7) ? '2.0.0' : null, + minimumClientVersion: + mask & (1 << booleanKeys.indexOf('minimumClientEnforcement')) ? '2.0.0' : null, }) as Record booleanKeys.forEach((key, index) => { configured[key] = Boolean(mask & (1 << index)) @@ -202,9 +162,23 @@ describe('central user-access rollout compiler', () => { expect(compareSemanticVersions('unbounded', '2.3.0')).toBeNull() }) - it('keeps PR 1 action context unavailable even when configured', async () => { + it('compiles the runtime policy against the PR 1 deployment readiness', async () => { + await expect(resolveEffectiveUserAccessPolicy({ intent: intent() })).resolves.toMatchObject({ + acceptV1: true, + acceptV2: true, + issueV2: false, + }) + // Minimum-client enforcement is not deployed in PR 1, so configuring it stops startup. await expect( - resolveEffectiveUserAccessPolicy({ intent: intent({ actionContextV2: true }) }) - ).rejects.toThrowError(new UserAccessPolicyConfigurationError('action_context_unavailable')) + resolveEffectiveUserAccessPolicy({ + intent: intent({ + sessionV2Issuance: true, + minimumClientVersion: '2.0.0', + minimumClientEnforcement: true, + }), + }) + ).rejects.toThrowError( + new UserAccessPolicyConfigurationError('minimum_client_enforcement_unavailable') + ) }) }) diff --git a/deploy/base/cluster-wide/clusterroles.yaml b/deploy/base/cluster-wide/clusterroles.yaml index 25550ba084..bc823321af 100644 --- a/deploy/base/cluster-wide/clusterroles.yaml +++ b/deploy/base/cluster-wide/clusterroles.yaml @@ -52,17 +52,6 @@ rules: - create - update - delete -# The user-access operational index keeps an authoritative list/watch projection -# for these three cluster-readable families. Keep watch separate so the existing -# CommunicationChannel CRUD rule does not gain an unrelated watch capability. -- apiGroups: - - clerum.io - resources: - - hosts - - contexts - - mcpservers - verbs: - - watch # workflowrecipepolicies: READ-ONLY access so the /admin/recipes/validate # handler can check the agenticWorkflowContextRefBlocked invariant (spec # §270-271 / §1441). control-api never mutates policies — those are authored diff --git a/deploy/base/control-plane/configmaps.yaml b/deploy/base/control-plane/configmaps.yaml index 6f80b3653b..46cd5ad805 100644 --- a/deploy/base/control-plane/configmaps.yaml +++ b/deploy/base/control-plane/configmaps.yaml @@ -15,10 +15,6 @@ data: CONTROL_API_MCP_SERVERS_NAMESPACE: mcp-server CONTROL_API_COMMUNICATION_CHANNELS_NAMESPACE: channels CONTROL_API_SECRETS_NAMESPACE: mcp-host - # Catalog projection is operator-activated only. Base remains off so an - # environment cannot start a writer without selecting an explicit rollout - # configuration, single-writer ownership, and rollback plan. - CONTROL_API_OPERATIONAL_ACCESS_INDEXER_ENABLED: 'false' CONTROL_API_JWT_ISSUER: control-api CONTROL_API_JWT_AUDIENCE: dev-profile-ui CONTROL_API_RPC_JWT_ISSUER: control-api diff --git a/deploy/base/mcp-host/rbac.yaml b/deploy/base/mcp-host/rbac.yaml index 60b7d694eb..4acc543278 100644 --- a/deploy/base/mcp-host/rbac.yaml +++ b/deploy/base/mcp-host/rbac.yaml @@ -27,7 +27,7 @@ rules: # proxy endpoint reads the CRD before forwarding to the per-SFS wfc. - apiGroups: ['clerum.io'] resources: ['sharedfilesystems'] - verbs: ['get', 'list', 'watch', 'create', 'update', 'delete'] + verbs: ['get', 'list', 'create', 'update', 'delete'] - apiGroups: [''] resources: ['secrets'] verbs: ['get', 'list', 'create', 'update', 'delete'] diff --git a/deploy/base/sandbox-recipes/rbac.yaml b/deploy/base/sandbox-recipes/rbac.yaml index 82ac660593..a14073e6ec 100644 --- a/deploy/base/sandbox-recipes/rbac.yaml +++ b/deploy/base/sandbox-recipes/rbac.yaml @@ -7,7 +7,7 @@ metadata: rules: - apiGroups: ["clerum.io"] resources: ["workflowrecipes"] - verbs: ["get", "list", "watch", "create", "update", "delete"] + verbs: ["get", "list", "create", "update", "delete"] - apiGroups: [""] resources: ["secrets"] verbs: ["get", "list", "create", "update", "patch", "delete"] diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index d7313ad900..b55801ad1e 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.169", + "version": "0.1.170", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.169", + "version": "0.1.170", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index 0f34c4c5eb..7149eeb68a 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.169", + "version": "0.1.170", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index c7f99a968b..2d04463906 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.12.0', - externalRestApiVersion: '0.1.169', + externalRestApiVersion: '0.1.170', rpcProxyVersion: '0.1.138', desktopVersion: '0.12.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/test/controlApiClient.pathGuard.test.ts b/external-rest-api/test/controlApiClient.pathGuard.test.ts index 3ebb433c9a..8c67236670 100644 --- a/external-rest-api/test/controlApiClient.pathGuard.test.ts +++ b/external-rest-api/test/controlApiClient.pathGuard.test.ts @@ -40,7 +40,7 @@ const REJECTED_PATHS = [ ] as const const ACCEPTED_PATHS = [ - ['capabilities', '/external/access/capabilities'], + ['member invitations', '/external/members/invitations'], [ 'invitation resend', '/external/members/invitations/7f9c2bce-1a2b-4c3d-8e4f-0123456789ab/resend', @@ -63,7 +63,7 @@ const ACCEPTED_RAW_QUERIES = [ ] as const const REJECTED_PATH = '/external/members/invitations/../../access/resolve?/resend' -const ACCEPTED_PATH = '/external/access/capabilities' +const ACCEPTED_PATH = '/external/members/invitations' function jsonResponse(body: unknown): Response { return new Response(JSON.stringify(body), { @@ -151,7 +151,7 @@ describe('control-api path guard', () => { await expect(controlApiRequest('GET', ACCEPTED_PATH)).resolves.toEqual({ served: true }) expect(fetchMock).toHaveBeenCalledTimes(2) for (const callIndex of [0, 1]) { - expect(fetchedUrl(fetchMock, callIndex).pathname).toBe('/api/v1/external/access/capabilities') + expect(fetchedUrl(fetchMock, callIndex).pathname).toBe('/api/v1/external/members/invitations') } }) @@ -172,7 +172,7 @@ describe('control-api path guard', () => { const result = await controlApiBinaryRequestWithStatus('GET', ACCEPTED_PATH) expect(result.body.equals(Buffer.from('binary-body'))).toBe(true) expect(fetchMock).toHaveBeenCalledTimes(1) - expect(fetchedUrl(fetchMock, 0).pathname).toBe('/api/v1/external/access/capabilities') + expect(fetchedUrl(fetchMock, 0).pathname).toBe('/api/v1/external/members/invitations') }) it('fails closed in the stream client before fetch, then forwards a raw query unchanged', async () => { From 3695a207c2908c3f47480b93c5c058d6ecc507aa Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 4 Nov 2026 20:04:34 +0100 Subject: [PATCH 461/528] test(control-api): cover every user-access policy rejection Addendum V5 of the PR #334 r4.2 fix plan (K11-L2). The fix plan relied on the impossible-state table of userAccessPolicy.test.ts to cover the contradictions that the startup compilation rejects, but 9 of its 16 rejection codes had no row: removing those guards left the suite green. The table now has one row per code, each reaching its own guard. Neutralising any one of the 16 guards turns the suite red. Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 +-- control-api/package.json | 2 +- control-api/test/userAccessPolicy.test.ts | 35 +++++++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index d493f22e5f..02d17c82d2 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.898", + "version": "0.1.899", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.898", + "version": "0.1.899", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 9c8664e6f7..7c541e9338 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.898", + "version": "0.1.899", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/userAccessPolicy.test.ts b/control-api/test/userAccessPolicy.test.ts index 92d62462a8..3dcd9af064 100644 --- a/control-api/test/userAccessPolicy.test.ts +++ b/control-api/test/userAccessPolicy.test.ts @@ -84,6 +84,41 @@ describe('central user-access rollout compiler', () => { intent({ sessionV2Issuance: true, minimumClientEnforcement: true }), ], ['readiness_snapshot_unavailable', intent(), readiness({ snapshot: 'unavailable' })], + ['readiness_revision_missing', intent(), readiness({ revision: ' ' })], + ['legacy_session_unavailable', intent(), readiness({ legacySession: 'unavailable' })], + [ + 'session_v2_acceptance_unavailable', + intent(), + readiness({ sessionV2Acceptance: 'unavailable' }), + ], + [ + 'session_v2_issuance_unavailable', + intent({ sessionV2Issuance: true }), + readiness({ sessionV2Issuance: 'unavailable' }), + ], + [ + 'legacy_disabled_requires_v2_issue_and_accept', + intent({ legacyLifecycle: 'disabled', legacySwitchEndpoint: false }), + ], + [ + 'legacy_switch_authorization_unavailable', + intent(), + readiness({ legacySwitchAuthorization: 'unavailable' }), + ], + ['minimum_client_version_invalid', intent({ minimumClientVersion: '2.0' })], + [ + 'minimum_client_enforcement_requires_v2_issuance', + intent({ minimumClientVersion: '2.0.0', minimumClientEnforcement: true }), + ], + [ + 'minimum_client_enforcement_unavailable', + intent({ + sessionV2Issuance: true, + minimumClientVersion: '2.0.0', + minimumClientEnforcement: true, + }), + readiness({ minimumClientEnforcement: 'unavailable' }), + ], ])('rejects impossible state %s', (code, configured, deployed = readiness()) => { expect(() => compileUserAccessPolicy(configured, deployed)).toThrowError( new UserAccessPolicyConfigurationError(code) From cd79955446575d84b5350bd305f4ada8b6943120 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 4 Nov 2026 20:38:49 +0100 Subject: [PATCH 462/528] refactor: remove session and edge error helpers left without callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit K12 of the PR #334 r4.2 fix plan (§2.3 C.5/C.6, §2.4 point 5, §4.1), with Addendum V6. After the /external/access routes and the staged limiter were removed, these had no production caller: - control-api: the *WithPublicErrors middleware variants, the publicErrors parameter, the externalSessionAuthority and externalSessionLimiterIdentity request fields, the staged identity option and revalidateExternalUserSessionIdentity, and the team-param wrapper; - External REST: the control-api error sanitizer and its tables, and ControlApiError.responseHeaders (headers carries the same object); - Desktop: the httpClient test case that pinned the sanitizer envelope. Addendum V6: PR1 validated V2 sessions with touch disabled in the identity stage so that only the staged revalidation recorded use; the ordinary path never did, and removing the staged path removed the only touch. The identity stage now records use once per authenticated request, except for renewal, which records it itself when it rotates the session. A real-PG suite pins one touch for a protected request and one for a renewal; dropping the touch or touching on renewal fails it. Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../src/middleware/externalSessionAuth.ts | 133 +-------- control-api/src/routes/external/auth.ts | 1 - .../auth/externalSessionAuthentication.ts | 66 +---- ...externalSessionAuth.nonStringToken.test.ts | 1 - ...ngleTouch.realPostgres.integration.test.ts | 61 +++- ....externalAuth.authorityUnavailable.test.ts | 1 - .../test/routes.externalMembers.test.ts | 14 - ....externalNotifications.currentness.test.ts | 1 - .../test/routes.externalWorkflows.test.ts | 2 - desktop-app/test/httpClient.test.ts | 24 -- external-rest-api/package-lock.json | 4 +- external-rest-api/package.json | 2 +- .../controlApiClient.passthrough.test.ts | 2 +- .../src/__tests__/publicErrorContract.test.ts | 122 +------- ...licErrorMappingMutationSensitivity.test.ts | 44 --- external-rest-api/src/controlApiClient.ts | 7 +- external-rest-api/src/http/publicApiError.ts | 272 ------------------ external-rest-api/src/releaseManifest.ts | 2 +- external-rest-api/test/routes.gfs.test.ts | 2 - 21 files changed, 77 insertions(+), 690 deletions(-) delete mode 100644 external-rest-api/src/__tests__/publicErrorMappingMutationSensitivity.test.ts diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 02d17c82d2..0b8037dc78 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.899", + "version": "0.1.900", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.899", + "version": "0.1.900", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 7c541e9338..624da4ddef 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.899", + "version": "0.1.900", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/middleware/externalSessionAuth.ts b/control-api/src/middleware/externalSessionAuth.ts index 288aa3a8c9..0338186d96 100644 --- a/control-api/src/middleware/externalSessionAuth.ts +++ b/control-api/src/middleware/externalSessionAuth.ts @@ -3,15 +3,10 @@ import { sendPublicApiError } from '../http/publicApiError.js' import { rootLogger } from '../observability/logger.js' import { AuthClaims, TeamRole } from '../profileTypes.js' import { getLiveTeamMembership } from '../services/access/liveTeamAuthorization.js' -import { - authenticateExternalUserSession, - authenticateExternalUserSessionIdentity, -} from '../services/auth/externalSessionAuthentication.js' +import { authenticateExternalUserSession } from '../services/auth/externalSessionAuthentication.js' import type { ExternalSessionAuthentication, - ExternalSessionAuthorityContext, ExternalSessionClient, - ExternalSessionIdentityAuthentication, ExternalSessionPurpose, } from '../services/auth/externalSessionAuthentication.js' import { @@ -46,15 +41,10 @@ export function handleExternalSessionBackendFailure( export type ExternalAuthedRequest = Request & { externalAuth?: AuthClaims - externalSessionAuthority?: ExternalSessionAuthorityContext externalSessionAuthentication?: Extract< ExternalSessionAuthentication, { status: 'authenticated' } > - externalSessionLimiterIdentity?: Extract< - ExternalSessionIdentityAuthentication, - { status: 'authenticated' } - > externalTeamAuth?: { teamId: string role: TeamRole @@ -125,7 +115,6 @@ export function requireExternalSessionRateLimitContext(options: { return } req.externalAuth = authentication.claims - req.externalSessionAuthority = authentication.authorityContext req.externalSessionAuthentication = authentication next() } catch (error) { @@ -134,110 +123,31 @@ export function requireExternalSessionRateLimitContext(options: { } } -async function validateExternalSessionToken( - req: ExternalAuthedRequest, - res: Response, - next: NextFunction, - publicErrors: boolean -): Promise { - const token = extractUserSessionToken(req) - if (!token || token.length > 4096) { - if (publicErrors) { - sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') - } else { - res.status(401).json({ error: 'Unauthorized' }) - } - return - } - - try { - const authentication = await authenticateExternalUserSession(token, { - purpose: 'protected', - client: { version: req.header('x-evenfire-client-version') || undefined }, - }) - if (authentication.status === 'upgrade_required') { - if (publicErrors) { - sendPublicApiError(req, res, 426, 'upgrade_required', 'A newer client is required.') - } else { - res.status(426).json({ error: 'upgrade_required' }) - } - return - } - if (authentication.status !== 'authenticated') { - if (publicErrors) { - sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') - } else { - res.status(401).json({ error: 'Unauthorized' }) - } - return - } - req.externalAuth = authentication.claims - req.externalSessionAuthority = authentication.authorityContext - req.externalSessionAuthentication = authentication - next() - } catch (error) { - handleExternalSessionBackendFailure(error, req, res, next) - } -} - -/** - * Establishes the policy-I/O-free live identity needed only for the dedicated - * catalog/resolve authenticated limiter. Final policy and contract validation - * must run separately after that limiter allows the request. - */ -export async function requireExternalSessionLimiterIdentityWithPublicErrors( +export async function requireValidExternalSessionToken( req: ExternalAuthedRequest, res: Response, next: NextFunction ): Promise { const token = extractUserSessionToken(req) if (!token || token.length > 4096) { - sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') + res.status(401).json({ error: 'Unauthorized' }) return } - try { - const identity = await authenticateExternalUserSessionIdentity(token, {}) - if (identity.status !== 'authenticated') { - sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') - return - } - req.externalAuth = identity.claims - req.externalSessionAuthority = identity.authorityContext - req.externalSessionLimiterIdentity = identity - next() - } catch (error) { - handleExternalSessionBackendFailure(error, req, res, next) - } -} -/** Completes canonical current policy/contract authentication after limiter allowance. */ -export async function requireCompletedExternalSessionAuthenticationWithPublicErrors( - req: ExternalAuthedRequest, - res: Response, - next: NextFunction -): Promise { - const token = extractUserSessionToken(req) - const identity = req.externalSessionLimiterIdentity - if (!token || token.length > 4096 || !identity) { - sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') - return - } try { const authentication = await authenticateExternalUserSession(token, { purpose: 'protected', client: { version: req.header('x-evenfire-client-version') || undefined }, - identity, }) if (authentication.status === 'upgrade_required') { - sendPublicApiError(req, res, 426, 'upgrade_required', 'A newer client is required.') + res.status(426).json({ error: 'upgrade_required' }) return } if (authentication.status !== 'authenticated') { - sendPublicApiError(req, res, 401, 'invalid_session', 'The session is not valid.') + res.status(401).json({ error: 'Unauthorized' }) return } req.externalAuth = authentication.claims - req.externalSessionAuthority = authentication.authorityContext req.externalSessionAuthentication = authentication next() } catch (error) { @@ -245,22 +155,6 @@ export async function requireCompletedExternalSessionAuthenticationWithPublicErr } } -export async function requireValidExternalSessionToken( - req: ExternalAuthedRequest, - res: Response, - next: NextFunction -): Promise { - return validateExternalSessionToken(req, res, next, false) -} - -export async function requireValidExternalSessionTokenWithPublicErrors( - req: ExternalAuthedRequest, - res: Response, - next: NextFunction -): Promise { - return validateExternalSessionToken(req, res, next, true) -} - export function requireExternalUserParamMatch(paramName = 'userId') { return (req: ExternalAuthedRequest, res: Response, next: NextFunction): void => { const claims = req.externalAuth @@ -273,7 +167,7 @@ export function requireExternalUserParamMatch(paramName = 'userId') { } } -function externalTeamParamMatcher(paramName: string) { +export function requireExternalTeamParamMatch(paramName = 'teamId') { return async (req: ExternalAuthedRequest, res: Response, next: NextFunction): Promise => { const claims = req.externalAuth const requestedTeamId = String(req.params?.[paramName] || req.query?.[paramName] || '').trim() @@ -304,10 +198,6 @@ function externalTeamParamMatcher(paramName: string) { } } -export function requireExternalTeamParamMatch(paramName = 'teamId') { - return externalTeamParamMatcher(paramName) -} - export function rejectBodyUserTeamMismatch( req: ExternalAuthedRequest, res: Response, @@ -342,14 +232,3 @@ export function requireExternalRole(allowedRoles: TeamRole[]) { next() } } - -export function requireExternalRoleWithPublicErrors(allowedRoles: TeamRole[]) { - return (req: ExternalAuthedRequest, res: Response, next: NextFunction): void => { - const role = req.externalTeamAuth?.role - if (!role || !allowedRoles.includes(role)) { - sendPublicApiError(req, res, 403, 'forbidden', 'The requested operation is not allowed.') - return - } - next() - } -} diff --git a/control-api/src/routes/external/auth.ts b/control-api/src/routes/external/auth.ts index 3deb48b511..e296e0894e 100644 --- a/control-api/src/routes/external/auth.ts +++ b/control-api/src/routes/external/auth.ts @@ -159,7 +159,6 @@ async function requireLegacyRpcSessionRateLimitContext( return } req.externalAuth = authentication.claims - req.externalSessionAuthority = authentication.authorityContext req.externalSessionAuthentication = authentication next() } catch (error) { diff --git a/control-api/src/services/auth/externalSessionAuthentication.ts b/control-api/src/services/auth/externalSessionAuthentication.ts index d4535d6e59..3be7c77f4c 100644 --- a/control-api/src/services/auth/externalSessionAuthentication.ts +++ b/control-api/src/services/auth/externalSessionAuthentication.ts @@ -58,7 +58,6 @@ type AuthenticationOptions = Readonly<{ client?: ExternalSessionClient policy?: EffectiveUserAccessPolicy now?: Date - identity?: Extract }> export type ExternalSessionAuthorityContext = Readonly< @@ -149,18 +148,20 @@ function normalizedV2Claims( } /** - * The identity-only stage of the sole external-user authentication boundary. - * It is sufficient to derive a trusted authenticated rate-limit subject, but - * intentionally performs no effective-policy, rollout, or readiness lookup. + * The identity stage of the sole external-user authentication boundary. It + * validates the presented session and, for a V2 session, records its use (the + * idle-lifetime touch) unless the caller's own operation records it, as session + * renewal does when it rotates the session. It performs no effective-policy, + * rollout, or readiness lookup; authenticateExternalUserSession adds those. */ -export async function authenticateExternalUserSessionIdentity( +async function authenticateExternalUserSessionIdentity( token: string, - options: Pick = {} + options: Pick & { touch?: boolean } = {} ): Promise { const v2Claims = verifyUserSessionV2Token(token) if (v2Claims) { const validation = await validateUserSessionClaims(v2Claims, { - touch: false, + touch: options.touch !== false, onDatabaseFailure: externalSessionDatabaseFailure, }) if (validation.status !== 'valid') return validation @@ -207,39 +208,6 @@ export async function authenticateExternalUserSessionIdentity( } } -/** - * Rechecks mutable session authority after the authenticated limiter permits - * the request. It reuses the Stage-A verified claims and does not re-verify - * the bearer representation. - */ -async function revalidateExternalUserSessionIdentity( - token: string, - identity: Extract, - options: Pick -): Promise { - if (identity.contract === 'v2') { - const validation = await validateUserSessionClaims( - identity.tokenClaims as UserSessionV2Claims, - { - onDatabaseFailure: externalSessionDatabaseFailure, - } - ) - if (validation.status !== 'valid') return validation - const claims = identity.tokenClaims as UserSessionV2Claims - return { - ...identity, - claims: normalizedV2Claims(claims, validation.identity), - } - } - - const claims = identity.tokenClaims as AuthClaims - const validation = await validateLegacyUserSession(token, claims, { - onDatabaseFailure: externalSessionDatabaseFailure, - }) - if (validation.status !== 'valid') return validation - return identity -} - /** * Sole external-user authentication boundary. No route or middleware may call * a low-level external-user token verifier directly. @@ -249,13 +217,6 @@ export async function authenticateExternalUserSession( options: AuthenticationOptions ): Promise { const policy = options.policy ?? (await resolveEffectiveUserAccessPolicy()) - const identity = options.identity - ? await revalidateExternalUserSessionIdentity(token, options.identity, { - now: options.now, - }) - : null - if (identity && identity.status !== 'authenticated') return identity - if ( purposeRequiresMinimumClient(options.purpose) && !clientMeetsMinimum(options.client, policy) @@ -263,11 +224,12 @@ export async function authenticateExternalUserSession( return { status: 'upgrade_required', reason: 'minimum_client_version' } } - const authenticatedIdentity = - identity ?? - (await authenticateExternalUserSessionIdentity(token, { - now: options.now, - })) + // Renewal records the session's use itself when it rotates the session, so + // one accepted request is one touch. + const authenticatedIdentity = await authenticateExternalUserSessionIdentity(token, { + now: options.now, + touch: options.purpose !== 'renew', + }) if (authenticatedIdentity.status !== 'authenticated') return authenticatedIdentity if ( diff --git a/control-api/test/externalSessionAuth.nonStringToken.test.ts b/control-api/test/externalSessionAuth.nonStringToken.test.ts index 2e0b2093ae..4560cd3bcc 100644 --- a/control-api/test/externalSessionAuth.nonStringToken.test.ts +++ b/control-api/test/externalSessionAuth.nonStringToken.test.ts @@ -7,7 +7,6 @@ import { createExternalAuthRouter } from '../src/routes/external/auth.js' const sessions = vi.hoisted(() => ({ authenticateExternalUserSession: vi.fn(), - authenticateExternalUserSessionIdentity: vi.fn(), renewExternalUserSession: vi.fn(), })) const revocations = vi.hoisted(() => ({ diff --git a/control-api/test/externalSessionSingleTouch.realPostgres.integration.test.ts b/control-api/test/externalSessionSingleTouch.realPostgres.integration.test.ts index 3930e7080a..2872f240fd 100644 --- a/control-api/test/externalSessionSingleTouch.realPostgres.integration.test.ts +++ b/control-api/test/externalSessionSingleTouch.realPostgres.integration.test.ts @@ -7,7 +7,7 @@ import { workflowTriggerRateLimitCredential } from '../src/routes/workflows/shar import type { EffectiveUserAccessPolicy } from '../src/services/access/userAccessPolicy.js' import { authenticateExternalUserSession, - authenticateExternalUserSessionIdentity, + renewExternalUserSession, } from '../src/services/auth/externalSessionAuthentication.js' import { createUserSession } from '../src/services/auth/userSessionService.js' import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' @@ -30,7 +30,7 @@ function quoteIdentifier(value: string): string { return `"${value.replace(/"/g, '""')}"` } -describeRealPostgres('external-session staged touch ownership on real PostgreSQL', () => { +describeRealPostgres('external-session touch ownership on real PostgreSQL', () => { const database = `control_api_session_touch_${randomBytes(6).toString('hex')}` const connectionString = databaseUrl( adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', @@ -92,19 +92,35 @@ describeRealPostgres('external-session staged touch ownership on real PostgreSQL } }) - it('touches a V2 session once after authenticated limiter identity is established', async () => { + it('touches a V2 session once per authenticated request', async () => { const issued = await createUserSession({ userId, email: `${userId}@example.test`, authenticationMethods: ['password'], }) + const policy: EffectiveUserAccessPolicy = { + policyVersion: '1', + policyRevision: 'r24-single-touch', + acceptV1: true, + issueV1: true, + acceptV2: true, + issueV2: true, + renewV2: true, + switchCompatibility: true, + minimumClientVersion: null, + enforceMinimumClient: false, + } - const identity = await authenticateExternalUserSessionIdentity(issued.token) - expect(identity.status).toBe('authenticated') - if (identity.status !== 'authenticated') throw new Error('Stage A rejected the issued session') + const authentication = await authenticateExternalUserSession(issued.token, { + purpose: 'protected', + policy, + }) + expect(authentication).toMatchObject({ status: 'authenticated', contract: 'v2' }) + if (authentication.status !== 'authenticated') + throw new Error('the issued session was rejected') expect( workflowTriggerRateLimitCredential({ - externalAuth: identity.claims, + externalAuth: authentication.claims, ip: '203.0.113.10', header(name: string) { if (name.toLowerCase() === 'authorization') return 'Bearer workflow-service-token' @@ -114,9 +130,22 @@ describeRealPostgres('external-session staged touch ownership on real PostgreSQL } as Request) ).toBe(`user:${userId}`) + const touches = await databasePool.query<{ count: string }>( + `SELECT count(*)::text AS count FROM session_touch_audit WHERE sid = $1`, + [issued.identity.sid] + ) + expect(touches.rows).toEqual([{ count: '1' }]) + }) + + it('touches a V2 session once when the request renews it', async () => { + const issued = await createUserSession({ + userId, + email: `${userId}@example.test`, + authenticationMethods: ['password'], + }) const policy: EffectiveUserAccessPolicy = { policyVersion: '1', - policyRevision: 'r24-single-touch', + policyRevision: 'r24-single-touch-renew', acceptV1: true, issueV1: true, acceptV2: true, @@ -126,13 +155,15 @@ describeRealPostgres('external-session staged touch ownership on real PostgreSQL minimumClientVersion: null, enforceMinimumClient: false, } - await expect( - authenticateExternalUserSession(issued.token, { - purpose: 'protected', - policy, - identity, - }) - ).resolves.toMatchObject({ status: 'authenticated' }) + + // The renewal route authenticates with purpose 'renew' and then renews. + const authentication = await authenticateExternalUserSession(issued.token, { + purpose: 'renew', + policy, + }) + expect(authentication).toMatchObject({ status: 'authenticated', contract: 'v2' }) + const renewal = await renewExternalUserSession(issued.token, { policy }) + expect(renewal.status).toBe('renewed') const touches = await databasePool.query<{ count: string }>( `SELECT count(*)::text AS count FROM session_touch_audit WHERE sid = $1`, diff --git a/control-api/test/routes.externalAuth.authorityUnavailable.test.ts b/control-api/test/routes.externalAuth.authorityUnavailable.test.ts index 90c999b235..b8484adba7 100644 --- a/control-api/test/routes.externalAuth.authorityUnavailable.test.ts +++ b/control-api/test/routes.externalAuth.authorityUnavailable.test.ts @@ -7,7 +7,6 @@ import { ExternalSessionBackendUnavailableError } from '../src/services/auth/ses const sessions = vi.hoisted(() => ({ authenticateExternalUserSession: vi.fn(), - authenticateExternalUserSessionIdentity: vi.fn(), renewExternalUserSession: vi.fn(), })) const revocations = vi.hoisted(() => ({ diff --git a/control-api/test/routes.externalMembers.test.ts b/control-api/test/routes.externalMembers.test.ts index 9d22b21457..5f3f1fa2b4 100644 --- a/control-api/test/routes.externalMembers.test.ts +++ b/control-api/test/routes.externalMembers.test.ts @@ -27,24 +27,10 @@ vi.mock('../src/middleware/externalSessionAuth.js', () => ({ ) => { const externalReq = req as express.Request & { externalAuth?: { userId: string } - externalSessionAuthority?: { - contract: 'v1' - userId: string - authGeneration: number - issuedAt: number - tokenHash: string - } } externalReq.externalAuth = { userId: 'manager-1', } - externalReq.externalSessionAuthority = { - contract: 'v1', - userId: 'manager-1', - authGeneration: 1, - issuedAt: 1_787_931_018, - tokenHash: 'external-members-test-token', - } next() }, })) diff --git a/control-api/test/routes.externalNotifications.currentness.test.ts b/control-api/test/routes.externalNotifications.currentness.test.ts index 911b9c54b5..1c90431fd0 100644 --- a/control-api/test/routes.externalNotifications.currentness.test.ts +++ b/control-api/test/routes.externalNotifications.currentness.test.ts @@ -26,7 +26,6 @@ vi.mock('../src/middleware/externalSessionAuth.js', () => ({ ) => { const externalReq = req as express.Request & Record externalReq.externalAuth = { userId: 'user-1' } - externalReq.externalSessionAuthority = { contract: 'v2', userId: 'user-1' } externalReq.externalSessionAuthentication = { status: 'authenticated', contract: 'v2', diff --git a/control-api/test/routes.externalWorkflows.test.ts b/control-api/test/routes.externalWorkflows.test.ts index 7100a97c3b..adf5458fe9 100644 --- a/control-api/test/routes.externalWorkflows.test.ts +++ b/control-api/test/routes.externalWorkflows.test.ts @@ -43,8 +43,6 @@ vi.mock('../src/utils/auth/adminAuthToken.js', () => ({ vi.mock('../src/services/auth/externalSessionAuthentication.js', () => ({ authenticateExternalUserSession: (...args: unknown[]) => mockAuthenticateExternalUserSession(...args), - authenticateExternalUserSessionIdentity: (...args: unknown[]) => - mockAuthenticateExternalUserSession(...args), externalSessionUserIdForRateLimit: (token: string) => token === 'user-session-token' ? 'user-123' diff --git a/desktop-app/test/httpClient.test.ts b/desktop-app/test/httpClient.test.ts index a8433e47d1..20de8f92f7 100644 --- a/desktop-app/test/httpClient.test.ts +++ b/desktop-app/test/httpClient.test.ts @@ -1,6 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { ControlApiError } from '../../external-rest-api/src/controlApiClient.js' -import { sanitizeControlApiPublicError } from '../../external-rest-api/src/http/publicApiError.js' import { ApiError, requestJson } from '../src/httpClient.js' vi.mock('../src/config.js', () => ({ @@ -9,10 +7,6 @@ vi.mock('../src/config.js', () => ({ }, })) -// The test exercises the shared public-error contract, not External REST's -// process configuration. Its production config requires a deployment JWT key. -vi.mock('../../external-rest-api/src/config.js', () => ({ config: {} })) - function okResponse(body?: unknown): Response { return { ok: true, @@ -139,24 +133,6 @@ describe('requestJson — transient retry', () => { expect(fetchSpy).toHaveBeenCalledTimes(1) }) - it('formats the real External REST typed envelope without object stringification', async () => { - const produced = sanitizeControlApiPublicError( - new ControlApiError('raw', 403, { error: 'escalation_rejected' }), - new Set([403]) - ) - expect(produced).not.toBeNull() - vi.mocked(global.fetch).mockResolvedValueOnce( - errorResponse(403, JSON.stringify(produced!.body)) - ) - - await expect(requestJson('PUT', 'http://localhost/test')).rejects.toThrow( - /403 Error: forbidden: The requested operation is not allowed\./ - ) - await expect(Promise.resolve(JSON.stringify(produced!.body))).resolves.not.toContain( - '[object Object]' - ) - }) - it('preserves Retry-After on HTTP errors for bounded upload lifecycle retries', async () => { const fetchSpy = vi.mocked(global.fetch) fetchSpy.mockResolvedValueOnce( diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index b55801ad1e..8441226a57 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.170", + "version": "0.1.171", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.170", + "version": "0.1.171", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index 7149eeb68a..75f4fe3a71 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.170", + "version": "0.1.171", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/__tests__/controlApiClient.passthrough.test.ts b/external-rest-api/src/__tests__/controlApiClient.passthrough.test.ts index 333301ca73..47b1356bd3 100644 --- a/external-rest-api/src/__tests__/controlApiClient.passthrough.test.ts +++ b/external-rest-api/src/__tests__/controlApiClient.passthrough.test.ts @@ -85,7 +85,7 @@ describe('controlApiPassthroughGet', () => { ) expect(error).toBeInstanceOf(ControlApiError) - expect((error as ControlApiError).responseHeaders).toEqual({ + expect((error as ControlApiError).headers).toEqual({ 'retry-after': '17', 'x-ratelimit-limit': '30', 'x-ratelimit-remaining': '0', diff --git a/external-rest-api/src/__tests__/publicErrorContract.test.ts b/external-rest-api/src/__tests__/publicErrorContract.test.ts index 820139f1df..4662fd40aa 100644 --- a/external-rest-api/src/__tests__/publicErrorContract.test.ts +++ b/external-rest-api/src/__tests__/publicErrorContract.test.ts @@ -3,7 +3,7 @@ import express from 'express' import request from 'supertest' import { externalRestPublicErrorHandler } from '../app.js' import { ControlApiError, controlApiRequest } from '../controlApiClient.js' -import { sanitizeControlApiPublicError, selectPublicCorrelationId } from '../http/publicApiError.js' +import { selectPublicCorrelationId } from '../http/publicApiError.js' function appThrowing(error: Error) { const app = express() @@ -151,124 +151,4 @@ describe('External REST public error contract', () => { expect(response.headers['retry-after']).toBeUndefined() expect(response.headers['x-internal-bucket']).toBeUndefined() }) - - it('preserves only a bounded retry delay from legacy rate-limit bodies', () => { - const sanitized = sanitizeControlApiPublicError( - new ControlApiError('raw', 429, { - error: 'Too Many Requests', - retryAfterSeconds: 17, - bucket: 'secret-internal-bucket', - }), - new Set([429]) - ) - - expect(sanitized?.body).toMatchObject({ - error: { - code: 'rate_limited', - details: { retryAfterSeconds: 17 }, - }, - }) - expect(JSON.stringify(sanitized?.body)).not.toContain('secret-internal-bucket') - }) - - it('preserves only allowlisted GFS denial details from legacy bodies', () => { - const sanitized = sanitizeControlApiPublicError( - new ControlApiError('raw', 400, { - error: 'subjects_invalid', - invalidIndexes: [0, 2], - sql: 'SELECT secret', - }), - new Set([400]) - ) - - expect(sanitized?.body).toMatchObject({ - error: { - code: 'invalid_request', - details: { reason: 'subjects_invalid', invalidIndexes: [0, 2] }, - }, - }) - expect(JSON.stringify(sanitized?.body)).not.toContain('SELECT secret') - }) - - it('rebuilds every forwarded route class from a bounded typed envelope', () => { - const sentinel = privatePostgresSentinel('/var/run/service.sock') - for (const status of [ - 400, 401, 403, 404, 408, 409, 410, 411, 412, 413, 422, 425, 429, 500, 502, 503, 504, 507, - ]) { - const sanitized = sanitizeControlApiPublicError( - new ControlApiError(sentinel, status, { - error: { - code: status === 409 ? 'access_path_required' : 'made_up_internal_code', - message: sentinel, - correlationId: `bad/${sentinel}`, - details: { sql: sentinel, path: sentinel, secret: sentinel }, - }, - }), - new Set([status]) - ) - - expect(sanitized?.status).toBe(status) - expect(JSON.stringify(sanitized?.body)).not.toContain(sentinel) - expect(JSON.stringify(sanitized?.body)).not.toContain('made_up_internal_code') - } - }) - - it.each([ - [408, 'request_timeout', true, { 'retry-after': '7' }], - [411, 'length_required', false, {}], - [413, 'payload_too_large', false, { 'upload-length': '209715200' }], - [425, 'too_early', true, { 'retry-after': '7' }], - [507, 'insufficient_storage', false, {}], - ] as const)( - 'preserves the safe typed public GFS contract for %s', - (status, expectedCode, retryable, expectedHeaders) => { - const sentinel = `private upstream detail at ${privatePostgresSentinel()}` - const sanitized = sanitizeControlApiPublicError( - new ControlApiError( - sentinel, - status, - { error: { code: expectedCode, message: sentinel, details: { secret: sentinel } } }, - { - 'retry-after': '7', - 'upload-length': '209715200', - 'x-ratelimit-limit': '16', - 'x-internal-secret': sentinel, - } - ), - new Set([status]) - ) - - expect(sanitized).toMatchObject({ - status, - headers: expectedHeaders, - body: { error: { code: expectedCode, retryable } }, - }) - expect(JSON.stringify(sanitized)).not.toContain(sentinel) - } - ) - - it('preserves only bounded access-path descriptors for an ambiguity response', () => { - const pathId = `ap1_${'a'.repeat(43)}` - const sanitized = sanitizeControlApiPublicError( - new ControlApiError('raw', 409, { - error: { - code: 'access_path_required', - message: 'raw', - details: { - paths: [{ id: pathId, kind: 'team', teamId: 'team-safe' }], - sql: 'SELECT secret', - }, - }, - }), - new Set([409]) - ) - - expect(sanitized?.body).toMatchObject({ - error: { - code: 'access_path_required', - details: { paths: [{ id: pathId, kind: 'team', teamId: 'team-safe' }] }, - }, - }) - expect(JSON.stringify(sanitized?.body)).not.toContain('SELECT secret') - }) }) diff --git a/external-rest-api/src/__tests__/publicErrorMappingMutationSensitivity.test.ts b/external-rest-api/src/__tests__/publicErrorMappingMutationSensitivity.test.ts deleted file mode 100644 index 703f0ba72f..0000000000 --- a/external-rest-api/src/__tests__/publicErrorMappingMutationSensitivity.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { ControlApiError } from '../controlApiClient.js' -import { sanitizeControlApiPublicError } from '../http/publicApiError.js' - -const STATUS_CODES = [ - [400, 'invalid_request'], - [401, 'invalid_session'], - [403, 'forbidden'], - [404, 'not_found'], - [408, 'request_timeout'], - [409, 'conflict'], - [410, 'gone'], - [411, 'length_required'], - [412, 'precondition_failed'], - [413, 'payload_too_large'], - [422, 'invalid_request'], - [425, 'too_early'], - [429, 'rate_limited'], - [500, 'internal_error'], - [502, 'upstream_unavailable'], - [503, 'authority_unavailable'], - [504, 'upstream_timeout'], - [507, 'insufficient_storage'], -] as const - -describe('public error mapping mutation sensitivity', () => { - it.each(STATUS_CODES)('accepts the canonical code for status %s', (status, expectedCode) => { - const result = sanitizeControlApiPublicError( - new ControlApiError('private', status, { error: { code: expectedCode } }), - new Set([status]) - ) - - expect(result?.body).toMatchObject({ error: { code: expectedCode } }) - }) - - it.each(STATUS_CODES)('selects the canonical fallback for status %s', (status, expectedCode) => { - const result = sanitizeControlApiPublicError( - new ControlApiError('private', status, { error: { code: 'not_allowed' } }), - new Set([status]) - ) - - expect(result?.body).toMatchObject({ error: { code: expectedCode } }) - }) -}) diff --git a/external-rest-api/src/controlApiClient.ts b/external-rest-api/src/controlApiClient.ts index da1ac941eb..945cce92bf 100644 --- a/external-rest-api/src/controlApiClient.ts +++ b/external-rest-api/src/controlApiClient.ts @@ -14,7 +14,6 @@ function addExternalClientIdentity(headers: Record): void { export class ControlApiError extends Error { status: number body: unknown - responseHeaders: Record headers: Record constructor( message: string, @@ -26,7 +25,6 @@ export class ControlApiError extends Error { this.name = 'ControlApiError' this.status = status this.body = body - this.responseHeaders = responseHeaders this.headers = responseHeaders } @@ -67,9 +65,8 @@ export class ControlApiUnexpectedResponseError extends Error { /** * A caller-influenced control-api path would have selected a route other than * the one the edge intended. It is a client error, not an upstream failure: - * extend ControlApiError so the existing public invalid-request mapping - * (sanitizeControlApiPublicError and the app's 4xx handler) handles it without - * a new status scheme. + * extend ControlApiError so the app's existing 4xx handler maps it to a public + * invalid-request error without a new status scheme. */ export class ControlApiPathError extends ControlApiError { constructor() { diff --git a/external-rest-api/src/http/publicApiError.ts b/external-rest-api/src/http/publicApiError.ts index 895595dfee..9989aa75bb 100644 --- a/external-rest-api/src/http/publicApiError.ts +++ b/external-rest-api/src/http/publicApiError.ts @@ -1,6 +1,5 @@ import type { Request, Response } from 'express' import { randomUUID } from 'node:crypto' -import { ControlApiError } from '../controlApiClient.js' const SAFE_PUBLIC_CORRELATION_ID = /^[A-Za-z0-9_-]{1,128}$/ @@ -12,277 +11,6 @@ export function selectPublicCorrelationId(...candidates: readonly unknown[]): st return randomUUID() } -const PUBLIC_CODE_BY_STATUS: Readonly> = { - 400: 'invalid_request', - 401: 'invalid_session', - 403: 'forbidden', - 404: 'not_found', - 408: 'request_timeout', - 409: 'conflict', - 410: 'gone', - 411: 'length_required', - 412: 'precondition_failed', - 413: 'payload_too_large', - 422: 'invalid_request', - 425: 'too_early', - 429: 'rate_limited', - 500: 'internal_error', - 502: 'upstream_unavailable', - 503: 'authority_unavailable', - 504: 'upstream_timeout', - 507: 'insufficient_storage', -} - -const PUBLIC_MESSAGE_BY_CODE: Readonly> = { - invalid_request: 'The request is not valid.', - invalid_session: 'The session is not valid.', - session_revoked: 'The session is not valid.', - forbidden: 'The requested operation is not allowed.', - not_found: 'The resource was not found.', - request_timeout: 'The request timed out.', - conflict: 'The request conflicts with current state.', - access_path_required: 'Choose an access path for this operation.', - access_path_stale: 'The access path is stale; refresh access before retrying.', - gone: 'The resource is no longer available.', - length_required: 'A content length is required.', - precondition_failed: 'The request precondition did not match current state.', - payload_too_large: 'The request payload is too large.', - too_early: 'The request is not ready to be processed.', - rate_limited: 'Too many requests; retry later.', - internal_error: 'The request could not be completed.', - upstream_unavailable: 'The upstream service is temporarily unavailable.', - authority_unavailable: 'Authorization is temporarily unavailable.', - session_issuance_temporarily_unavailable: - 'A session could not be issued right now. Try again in two seconds.', - member_registration_unavailable: 'Member registration is temporarily unavailable.', - member_registration_misconfigured: 'Member registration is not configured correctly.', - upstream_timeout: 'The upstream service timed out.', - insufficient_storage: 'The requested operation cannot be completed because storage is full.', -} - -const ALLOWED_CODES_BY_STATUS: Readonly>> = { - 400: new Set(['invalid_request']), - 401: new Set(['invalid_session', 'session_revoked']), - 403: new Set(['forbidden']), - 404: new Set(['not_found']), - 408: new Set(['request_timeout']), - 409: new Set(['conflict', 'access_path_required', 'access_path_stale']), - 410: new Set(['gone']), - 411: new Set(['length_required']), - 412: new Set(['precondition_failed']), - 413: new Set(['payload_too_large']), - 422: new Set(['invalid_request']), - 425: new Set(['too_early']), - 429: new Set(['rate_limited']), - 500: new Set(['internal_error']), - 502: new Set(['upstream_unavailable']), - 503: new Set([ - 'authority_unavailable', - 'session_issuance_temporarily_unavailable', - 'member_registration_unavailable', - 'member_registration_misconfigured', - ]), - 504: new Set(['upstream_timeout']), - 507: new Set(['insufficient_storage']), -} - -type PublicPathDescriptor = { id: string; kind: 'direct' | 'team'; teamId?: string } -export type SanitizedControlApiPublicError = { - status: number - body: Record - headers: Record -} - -const SAFE_DOMAIN_REASONS = new Set([ - 'agent_manager_forbidden', - 'managed_agent_permission_forbidden', - 'foreign_agent_forbidden', - 'subjects_invalid', - 'escalation_rejected', - 'manage_acl_required', - 'desktop_requires_team', - 'no_permitted_scopes', -]) - -const PUBLIC_RATE_LIMIT_HEADERS = [ - 'retry-after', - 'x-ratelimit-limit', - 'x-ratelimit-remaining', - 'x-ratelimit-reset', -] as const - -function safePublicRateLimitHeaders(headers: Record): Record { - const result: Record = {} - for (const name of PUBLIC_RATE_LIMIT_HEADERS) { - const raw = headers[name] - if (!raw || !/^\d{1,10}$/.test(raw)) continue - const value = Number(raw) - if (!Number.isSafeInteger(value) || value < 0) continue - if (name === 'retry-after' && (value < 1 || value > 86_400)) continue - result[name] = String(value) - } - return result -} - -/** - * Caching guidance is an upstream contract for outages, not free-form metadata. - * Control API sends `no-store` on a session-backend 503 so no intermediary - * caches a transient authority failure; the sanitizer keeps only that literal - * value and never relays any other cache directive. - */ -function safeNoStoreHeader(headers: Record): Record { - const raw = headers['cache-control'] - if (typeof raw !== 'string' || raw.trim().toLowerCase() !== 'no-store') return {} - return { 'cache-control': 'no-store' } -} - -function safeUnsignedIntegerHeader( - headers: Record, - name: string -): Record { - const raw = headers[name] - if (!raw || !/^\d{1,16}$/.test(raw)) return {} - const value = Number(raw) - return Number.isSafeInteger(value) && value >= 0 ? { [name]: String(value) } : {} -} - -function safeInvalidIndexes(value: unknown): number[] | undefined { - if (!Array.isArray(value) || value.length === 0 || value.length > 100) return undefined - const indexes = value.filter( - (item): item is number => Number.isSafeInteger(item) && item >= 0 && item <= 10_000 - ) - return indexes.length === value.length ? indexes : undefined -} - -function safePathDescriptors(value: unknown): PublicPathDescriptor[] | undefined { - if (!Array.isArray(value) || value.length === 0 || value.length > 100) return undefined - const paths: PublicPathDescriptor[] = [] - for (const item of value) { - if (!item || typeof item !== 'object') return undefined - const row = item as Record - const id = typeof row.id === 'string' ? row.id : '' - const kind = row.kind === 'direct' || row.kind === 'team' ? row.kind : null - const teamId = typeof row.teamId === 'string' ? row.teamId : undefined - if (!/^ap1_[A-Za-z0-9_-]{43}$/.test(id) || !kind) return undefined - if (kind === 'team' && !teamId) return undefined - paths.push({ id, kind, ...(teamId ? { teamId } : {}) }) - } - return paths -} - -export function sanitizeControlApiPublicError( - error: unknown, - propagatedStatuses: ReadonlySet, - fallbackCorrelationId = '' -): SanitizedControlApiPublicError | null { - if (!(error instanceof ControlApiError) || !propagatedStatuses.has(error.status)) return null - const rawBody = - error.body && typeof error.body === 'object' - ? (error.body as Record) - : undefined - const rawError = - rawBody && Object.prototype.hasOwnProperty.call(rawBody, 'error') ? rawBody.error : undefined - const rawEnvelope = rawError && typeof rawError === 'object' ? rawError : undefined - const suppliedCode = - rawEnvelope && typeof (rawEnvelope as { code?: unknown }).code === 'string' - ? String((rawEnvelope as { code: string }).code) - : error.status === 503 && typeof rawError === 'string' - ? rawError - : '' - const fallbackCode = PUBLIC_CODE_BY_STATUS[error.status] || 'internal_error' - const code = ALLOWED_CODES_BY_STATUS[error.status]?.has(suppliedCode) - ? suppliedCode - : fallbackCode - const rawCorrelationId = - rawEnvelope && typeof (rawEnvelope as { correlationId?: unknown }).correlationId === 'string' - ? String((rawEnvelope as { correlationId: string }).correlationId) - : '' - const correlationId = selectPublicCorrelationId(rawCorrelationId, fallbackCorrelationId) - const retryable = [408, 425, 429, 502, 503, 504].includes(error.status) - const rawDetails = - rawEnvelope && typeof (rawEnvelope as { details?: unknown }).details === 'object' - ? ((rawEnvelope as { details: Record }).details ?? undefined) - : undefined - const paths = code === 'access_path_required' ? safePathDescriptors(rawDetails?.paths) : undefined - const rawReason = - typeof rawError === 'string' - ? rawError - : typeof rawDetails?.reason === 'string' - ? rawDetails.reason - : '' - const reason = SAFE_DOMAIN_REASONS.has(rawReason) ? rawReason : undefined - const invalidIndexes = - reason === 'subjects_invalid' - ? safeInvalidIndexes(rawDetails?.invalidIndexes ?? rawBody?.invalidIndexes) - : undefined - const responseHeaders = error.responseHeaders ?? {} - const headerRetryAfterSeconds = Number(responseHeaders['retry-after']) - const rawRetryAfterSeconds = - rawDetails?.retryAfterSeconds ?? - rawBody?.retryAfterSeconds ?? - (Number.isSafeInteger(headerRetryAfterSeconds) ? headerRetryAfterSeconds : undefined) - const retryAfterSeconds = - code === 'rate_limited' && - typeof rawRetryAfterSeconds === 'number' && - Number.isSafeInteger(rawRetryAfterSeconds) && - rawRetryAfterSeconds >= 1 && - rawRetryAfterSeconds <= 86_400 - ? rawRetryAfterSeconds - : undefined - const details = paths - ? { paths } - : reason || invalidIndexes || retryAfterSeconds - ? { - ...(reason ? { reason } : {}), - ...(invalidIndexes ? { invalidIndexes } : {}), - ...(retryAfterSeconds ? { retryAfterSeconds } : {}), - } - : undefined - const safeHeaders = safePublicRateLimitHeaders(responseHeaders) - const publicHeaders = - code === 'session_issuance_temporarily_unavailable' - ? { - 'cache-control': 'no-store', - ...(safeHeaders['retry-after'] ? { 'retry-after': safeHeaders['retry-after'] } : {}), - } - : code === 'rate_limited' - ? safeHeaders - : error.status === 413 - ? safeUnsignedIntegerHeader(responseHeaders, 'upload-length') - : retryable - ? { - ...(safeHeaders['retry-after'] - ? { 'retry-after': safeHeaders['retry-after'] } - : {}), - ...(error.status === 503 ? safeNoStoreHeader(responseHeaders) : {}), - } - : {} - - return { - status: error.status, - headers: publicHeaders, - body: { - error: { - code, - message: PUBLIC_MESSAGE_BY_CODE[code] || PUBLIC_MESSAGE_BY_CODE[fallbackCode], - correlationId, - retryable, - ...(details ? { details } : {}), - }, - }, - } -} - -export function sendSanitizedControlApiPublicError( - res: Response, - sanitized: SanitizedControlApiPublicError -): void { - for (const [name, value] of Object.entries(sanitized.headers)) { - res.setHeader(name, value) - } - res.status(sanitized.status).json(sanitized.body) -} - export function publicCorrelationId(req: Request): string { return selectPublicCorrelationId(req.header('x-correlation-id')) } diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index 2d04463906..dc126c3918 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.12.0', - externalRestApiVersion: '0.1.170', + externalRestApiVersion: '0.1.171', rpcProxyVersion: '0.1.138', desktopVersion: '0.12.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/test/routes.gfs.test.ts b/external-rest-api/test/routes.gfs.test.ts index 1e36c88cf1..834a818c83 100644 --- a/external-rest-api/test/routes.gfs.test.ts +++ b/external-rest-api/test/routes.gfs.test.ts @@ -13,10 +13,8 @@ const { ControlApiError, clientMock } = vi.hoisted(() => { ) { super(message) this.headers = headers instanceof Headers ? Object.fromEntries(headers.entries()) : headers - this.responseHeaders = this.headers } public headers: Record - public responseHeaders: Record } return { ControlApiError, From 522049e07333b93f1c2d0c1b02ac3fd3205b692f Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 4 Nov 2026 21:09:36 +0100 Subject: [PATCH 463/528] fix(control-api): trim PR1 access schema to session tables (K13) Writers and schema change together so no intermediate commit keeps the prelocks without their triggers: - restore the four approval/archive/admin writers to base and drop the authorization prelocks from membership and user retirement; - reduce userAccessFoundationSchema to the three session tables, their four indexes, the epoch backfill and session-only grants; - 0131 keeps five online indexes; 0132-0134 are removed and the legacy password epoch backfill is renumbered from 0135 to 0132; - delete the operational access projection, canonical text and invitation delivery schema modules with their tests; - drop ten relations from each runtime access TSV and update the migration pins, shell contract checks, CI suite list and manifest. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci-public.yml | 5 - control-api/package-lock.json | 4 +- control-api/package.json | 2 +- control-api/src/db.ts | 17 +- control-api/src/migrations/migrationRunner.ts | 5 +- .../src/migrations/pr1OnlineIndexPlan.ts | 195 +- .../src/services/access/canonicalText.ts | 4 - .../access/operationalAccessProjection.ts | 478 ----- .../access/userAccessFoundationSchema.ts | 1377 +------------ control-api/src/services/adminAuthService.ts | 116 +- .../directory/invitationDeliverySchema.ts | 32 - .../src/services/directory/membership.ts | 3 - control-api/src/services/directory/users.ts | 79 +- .../userApprovalRequestArchiveService.ts | 6 - .../services/userApprovalRequestService.ts | 55 +- .../services/workflowRunsArchiveService.ts | 2 - ...composableCatalogRevisionMigration.test.ts | 124 -- control-api/test/db.initDb.test.ts | 7 +- ...invitationDeliveryCommandMigration.test.ts | 44 - control-api/test/db.migrationsOrder.test.ts | 10 +- ....realPostgresMigration.integration.test.ts | 246 +-- .../db.userAccessFoundationMigration.test.ts | 76 +- ...LockOrder.realPostgres.integration.test.ts | 356 ---- ...hBackfill.realPostgres.integration.test.ts | 4 +- ...ionPolicy.realPostgres.integration.test.ts | 72 +- .../test/migrationExecutionPolicy.test.ts | 89 +- ...fillBound.realPostgres.integration.test.ts | 231 --- ...atibility.realPostgres.integration.test.ts | 36 - ...Revisions.realPostgres.integration.test.ts | 233 --- .../test/operationalAccessProjection.test.ts | 209 -- ...tionLocks.realPostgres.integration.test.ts | 158 +- ...LockOrder.realPostgres.integration.test.ts | 826 -------- .../test/routes.adminWorkflows.test.ts | 9 - .../test/services.directory.teams.test.ts | 21 +- .../services.directory.userRetirement.test.ts | 41 +- ...ervices.userApprovalRequestArchive.test.ts | 35 - ...ervices.userApprovalRequestService.test.ts | 106 +- .../test/services.workflowRunsArchive.test.ts | 11 +- ...LockOrder.realPostgres.integration.test.ts | 1774 ----------------- .../control-api-runtime-access-profiles.tsv | 10 - ...rkflow-recipes-runtime-access-profiles.tsv | 10 - .../test-control-api-migration-bounds.sh | 4 +- ...est-control-api-runtime-access-contract.sh | 1 - tests/contracts/password-recovery-suites.json | 5 - 44 files changed, 244 insertions(+), 6884 deletions(-) delete mode 100644 control-api/src/services/access/canonicalText.ts delete mode 100644 control-api/src/services/access/operationalAccessProjection.ts delete mode 100644 control-api/src/services/directory/invitationDeliverySchema.ts delete mode 100644 control-api/test/db.composableCatalogRevisionMigration.test.ts delete mode 100644 control-api/test/db.invitationDeliveryCommandMigration.test.ts delete mode 100644 control-api/test/gfsRevisionLockOrder.realPostgres.integration.test.ts delete mode 100644 control-api/test/migrationGfsBackfillBound.realPostgres.integration.test.ts delete mode 100644 control-api/test/migrationUserRetirementRevisions.realPostgres.integration.test.ts delete mode 100644 control-api/test/operationalAccessProjection.test.ts delete mode 100644 control-api/test/revisionWritersLockOrder.realPostgres.integration.test.ts delete mode 100644 control-api/test/userRetirementRevisionLockOrder.realPostgres.integration.test.ts diff --git a/.github/workflows/ci-public.yml b/.github/workflows/ci-public.yml index e626e5ca0d..42256cad89 100644 --- a/.github/workflows/ci-public.yml +++ b/.github/workflows/ci-public.yml @@ -657,21 +657,16 @@ jobs: oauth.dcrCredential.installIdentity.realPostgres.integration.test.ts \ db.realPostgresMigration.integration.test.ts \ externalSessionSingleTouch.realPostgres.integration.test.ts \ - gfsRevisionLockOrder.realPostgres.integration.test.ts \ invitationAcceptedOnboarding.realPostgres.integration.test.ts \ legacyPasswordEpochBackfill.realPostgres.integration.test.ts \ legacySessionExchangeRace.realPostgres.integration.test.ts \ migrationExecutionPolicy.realPostgres.integration.test.ts \ - migrationGfsBackfillBound.realPostgres.integration.test.ts \ migrationPreviousImageCompatibility.realPostgres.integration.test.ts \ - migrationUserRetirementRevisions.realPostgres.integration.test.ts \ pr1MigrationLocks.realPostgres.integration.test.ts \ routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts \ routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts \ userAccessRuntimeContract.realPostgres.integration.test.ts \ userSessionDatabaseClock.realPostgres.integration.test.ts \ - revisionWritersLockOrder.realPostgres.integration.test.ts \ - userRetirementRevisionLockOrder.realPostgres.integration.test.ts \ workflowRunReadAndApprovalV1Compatibility.realPostgres.integration.test.ts - name: Install gfs-controller dependencies diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 0b8037dc78..b27f356800 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.900", + "version": "0.1.901", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.900", + "version": "0.1.901", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 624da4ddef..5b93989f69 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.900", + "version": "0.1.901", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/db.ts b/control-api/src/db.ts index 76decd49b6..abba5170d7 100644 --- a/control-api/src/db.ts +++ b/control-api/src/db.ts @@ -9,8 +9,6 @@ import { migrationSessionBoundsSql } from './migrations/migrationExecutionPolicy import { applyPendingPr1Migrations } from './migrations/migrationRunner.js' import { rootLogger } from './observability/logger.js' import { - applyCatalogUtf8OrderingSchema, - applyComposableCatalogRevisionSchema, applyUserAccessFoundationSchema, backfillLegacyPasswordSecurityEpochs, } from './services/access/userAccessFoundationSchema.js' @@ -33,7 +31,6 @@ import { applyCodexSubscriptionConnectionSchema, } from './services/codexSubscriptionConnection.js' import { applyCodexSubscriptionOAuthStateSchema } from './services/codexSubscriptionOAuthState.js' -import { applyInvitationDeliveryCommandFoundation } from './services/directory/invitationDeliverySchema.js' import { applyEntityChangeCheckpointSchema, applyEntityChangeDefinerSearchPathSchema, @@ -6424,19 +6421,7 @@ export const CONTROL_API_MIGRATIONS: DbMigration[] = [ apply: applyUserAccessFoundationSchema, }, { - version: '0132_invitation_delivery_commands', - apply: applyInvitationDeliveryCommandFoundation, - }, - { - version: '0133_catalog_utf8_ordering', - apply: applyCatalogUtf8OrderingSchema, - }, - { - version: '0134_composable_catalog_revisions', - apply: applyComposableCatalogRevisionSchema, - }, - { - version: '0135_legacy_password_security_epoch_backfill', + version: '0132_legacy_password_security_epoch_backfill', apply: backfillLegacyPasswordSecurityEpochs, }, ] diff --git a/control-api/src/migrations/migrationRunner.ts b/control-api/src/migrations/migrationRunner.ts index a3a57346a3..afb6287600 100644 --- a/control-api/src/migrations/migrationRunner.ts +++ b/control-api/src/migrations/migrationRunner.ts @@ -4,10 +4,7 @@ import { preparePr1Migration } from './pr1OnlineIndexPlan.js' export const PR1_MIGRATION_VERSIONS = Object.freeze([ '0131_user_access_foundation', - '0132_invitation_delivery_commands', - '0133_catalog_utf8_ordering', - '0134_composable_catalog_revisions', - '0135_legacy_password_security_epoch_backfill', + '0132_legacy_password_security_epoch_backfill', ] as const) // Every migration up to and including dev's last migration runs in initDb's diff --git a/control-api/src/migrations/pr1OnlineIndexPlan.ts b/control-api/src/migrations/pr1OnlineIndexPlan.ts index 6833b11997..6e98acb9f7 100644 --- a/control-api/src/migrations/pr1OnlineIndexPlan.ts +++ b/control-api/src/migrations/pr1OnlineIndexPlan.ts @@ -1,11 +1,8 @@ import type { DbClient } from '../db.js' -import { - MIGRATION_EXECUTION_POLICY, - migrationSessionBoundsSql, -} from './migrationExecutionPolicy.js' +import { MIGRATION_EXECUTION_POLICY } from './migrationExecutionPolicy.js' export type OnlineIndexDefinition = Readonly<{ - migrationVersion: '0131_user_access_foundation' | '0133_catalog_utf8_ordering' + migrationVersion: '0131_user_access_foundation' name: string table: string unique?: boolean @@ -48,163 +45,6 @@ export const PR1_ONLINE_INDEX_PLAN: readonly OnlineIndexDefinition[] = Object.fr createSql: `CREATE INDEX CONCURRENTLY team_agents_agent_team_idx ON team_agents (agent_name, team_id)`, }, - { - migrationVersion: '0131_user_access_foundation', - name: 'user_workflow_triggers_recipe_user_idx', - table: 'user_workflow_triggers', - createSql: `CREATE INDEX CONCURRENTLY user_workflow_triggers_recipe_user_idx - ON user_workflow_triggers (recipe_namespace, recipe_name, user_id)`, - }, - { - migrationVersion: '0131_user_access_foundation', - name: 'team_workflow_triggers_recipe_team_idx', - table: 'team_workflow_triggers', - createSql: `CREATE INDEX CONCURRENTLY team_workflow_triggers_recipe_team_idx - ON team_workflow_triggers (recipe_namespace, recipe_name, team_id)`, - }, - { - migrationVersion: '0131_user_access_foundation', - name: 'user_workflow_triggers_catalog_key_idx', - table: 'user_workflow_triggers', - createSql: `CREATE INDEX CONCURRENTLY user_workflow_triggers_catalog_key_idx - ON user_workflow_triggers (user_id, ((recipe_namespace || '/'::text) || recipe_name))`, - }, - { - migrationVersion: '0131_user_access_foundation', - name: 'team_workflow_triggers_catalog_key_idx', - table: 'team_workflow_triggers', - createSql: `CREATE INDEX CONCURRENTLY team_workflow_triggers_catalog_key_idx - ON team_workflow_triggers (((recipe_namespace || '/'::text) || recipe_name), team_id)`, - }, - { - migrationVersion: '0131_user_access_foundation', - name: 'workflow_runs_actor_catalog_idx', - table: 'workflow_runs', - createSql: `CREATE INDEX CONCURRENTLY workflow_runs_actor_catalog_idx - ON workflow_runs (actor_id, run_id) - INCLUDE (recipe_namespace, recipe_name, phase, team_id, usage_team_id) - WHERE actor_type = 'user' AND actor_id IS NOT NULL`, - }, - { - migrationVersion: '0131_user_access_foundation', - name: 'workflow_runs_team_catalog_idx', - table: 'workflow_runs', - createSql: `CREATE INDEX CONCURRENTLY workflow_runs_team_catalog_idx - ON workflow_runs (team_id, run_id) - INCLUDE (recipe_namespace, recipe_name, phase, actor_type, actor_id, usage_team_id) - WHERE team_id IS NOT NULL`, - }, - { - migrationVersion: '0131_user_access_foundation', - name: 'workflow_runs_usage_team_catalog_idx', - table: 'workflow_runs', - createSql: `CREATE INDEX CONCURRENTLY workflow_runs_usage_team_catalog_idx - ON workflow_runs (usage_team_id, run_id) - INCLUDE (recipe_namespace, recipe_name, phase, actor_type, actor_id, team_id) - WHERE usage_team_id IS NOT NULL`, - }, - { - migrationVersion: '0131_user_access_foundation', - name: 'workflow_approval_user_catalog_idx', - table: 'workflow_approval_requests', - createSql: `CREATE INDEX CONCURRENTLY workflow_approval_user_catalog_idx - ON workflow_approval_requests (target_user_id, id) - INCLUDE (status, expires_at, recipe_namespace, recipe_name) - WHERE target_user_id IS NOT NULL`, - }, - { - migrationVersion: '0131_user_access_foundation', - name: 'workflow_approval_team_catalog_idx', - table: 'workflow_approval_requests', - createSql: `CREATE INDEX CONCURRENTLY workflow_approval_team_catalog_idx - ON workflow_approval_requests (target_team_id, id) - INCLUDE (status, expires_at, recipe_namespace, recipe_name) - WHERE target_team_id IS NOT NULL`, - }, - { - migrationVersion: '0131_user_access_foundation', - name: 'notification_user_catalog_idx', - table: 'notification_deliveries', - createSql: `CREATE INDEX CONCURRENTLY notification_user_catalog_idx - ON notification_deliveries ((audience->>'userId'), id) - INCLUDE (expires_at, status, event_type) WHERE audience ? 'userId'`, - }, - { - migrationVersion: '0131_user_access_foundation', - name: 'notification_team_catalog_idx', - table: 'notification_deliveries', - createSql: `CREATE INDEX CONCURRENTLY notification_team_catalog_idx - ON notification_deliveries ((audience->>'teamId'), id) - INCLUDE (expires_at, status, event_type) WHERE audience ? 'teamId'`, - }, - { - migrationVersion: '0131_user_access_foundation', - name: 'gfs_grants_subject_resource_catalog_idx', - table: 'gfs_grants', - createSql: `CREATE INDEX CONCURRENTLY gfs_grants_subject_resource_catalog_idx - ON gfs_grants (subject_type, subject_id, resource_id) - INCLUDE (id, drive, permissions, inherit)`, - }, - { - migrationVersion: '0131_user_access_foundation', - name: 'gfs_shares_subject_resource_catalog_idx', - table: 'gfs_shares', - createSql: `CREATE INDEX CONCURRENTLY gfs_shares_subject_resource_catalog_idx - ON gfs_shares (subject_type, subject_id, resource_id) - INCLUDE (id, drive, permissions, include_descendants)`, - }, - { - migrationVersion: '0133_catalog_utf8_ordering', - name: 'user_agents_catalog_utf8_idx', - table: 'user_agents', - createSql: `CREATE INDEX CONCURRENTLY user_agents_catalog_utf8_idx - ON user_agents (user_id, catalog_utf8_bytes(agent_name))`, - }, - { - migrationVersion: '0133_catalog_utf8_ordering', - name: 'team_agents_catalog_utf8_idx', - table: 'team_agents', - createSql: `CREATE INDEX CONCURRENTLY team_agents_catalog_utf8_idx - ON team_agents (catalog_utf8_bytes(agent_name), team_id)`, - }, - { - migrationVersion: '0133_catalog_utf8_ordering', - name: 'user_contexts_catalog_utf8_idx', - table: 'user_contexts', - createSql: `CREATE INDEX CONCURRENTLY user_contexts_catalog_utf8_idx - ON user_contexts (user_id, catalog_utf8_bytes(context_id))`, - }, - { - migrationVersion: '0133_catalog_utf8_ordering', - name: 'team_contexts_catalog_utf8_idx', - table: 'team_contexts', - createSql: `CREATE INDEX CONCURRENTLY team_contexts_catalog_utf8_idx - ON team_contexts (catalog_utf8_bytes(context_id), team_id)`, - }, - { - migrationVersion: '0133_catalog_utf8_ordering', - name: 'user_workflow_triggers_catalog_utf8_idx', - table: 'user_workflow_triggers', - createSql: `CREATE INDEX CONCURRENTLY user_workflow_triggers_catalog_utf8_idx - ON user_workflow_triggers - (user_id, catalog_utf8_bytes(recipe_namespace || '/' || recipe_name))`, - }, - { - migrationVersion: '0133_catalog_utf8_ordering', - name: 'team_workflow_triggers_catalog_utf8_idx', - table: 'team_workflow_triggers', - createSql: `CREATE INDEX CONCURRENTLY team_workflow_triggers_catalog_utf8_idx - ON team_workflow_triggers - (catalog_utf8_bytes(recipe_namespace || '/' || recipe_name), team_id)`, - }, - { - migrationVersion: '0133_catalog_utf8_ordering', - name: 'operational_relationship_catalog_utf8_target_idx', - table: 'operational_resource_relationships', - createSql: `CREATE INDEX CONCURRENTLY operational_relationship_catalog_utf8_target_idx - ON operational_resource_relationships - (environment_id, target_type, relationship_type, catalog_utf8_bytes(target_id))`, - }, ]) type IndexSqlToken = Readonly<{ @@ -621,39 +461,8 @@ export async function ensureOnlineIndex(db: DbClient, entry: OnlineIndexDefiniti if (!created.indisvalid) throw new Error(`Online index is invalid after creation: ${entry.name}`) } -async function prepareCatalogUtf8Function(db: DbClient): Promise { - let started = false - try { - await db.query('BEGIN') - started = true - for (const sql of migrationSessionBoundsSql(true)) await db.query(sql) - await db.query(` - CREATE OR REPLACE FUNCTION catalog_utf8_bytes(value TEXT) - RETURNS BYTEA - LANGUAGE SQL - IMMUTABLE - STRICT - PARALLEL SAFE - AS $$ - SELECT convert_to(value, 'UTF8'); - $$ - `) - await db.query('COMMIT') - } catch (error) { - if (started) { - try { - await db.query('ROLLBACK') - } catch { - // The migration owner destroys the session on failure. - } - } - throw error - } -} - export async function preparePr1Migration(db: DbClient, version: string): Promise { const indexes = PR1_ONLINE_INDEX_PLAN.filter(entry => entry.migrationVersion === version) if (indexes.length === 0) return - if (version === '0133_catalog_utf8_ordering') await prepareCatalogUtf8Function(db) for (const index of indexes) await ensureOnlineIndex(db, index) } diff --git a/control-api/src/services/access/canonicalText.ts b/control-api/src/services/access/canonicalText.ts deleted file mode 100644 index f8711fdcb9..0000000000 --- a/control-api/src/services/access/canonicalText.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** Exact lexicographic ordering of unsigned UTF-8 bytes used by access identities. */ -export function compareCanonicalUtf8Text(left: string, right: string): number { - return Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8')) -} diff --git a/control-api/src/services/access/operationalAccessProjection.ts b/control-api/src/services/access/operationalAccessProjection.ts deleted file mode 100644 index 520929dd0d..0000000000 --- a/control-api/src/services/access/operationalAccessProjection.ts +++ /dev/null @@ -1,478 +0,0 @@ -import { createHash, createHmac } from 'node:crypto' -import type { ClerumResourceType } from '../../types.js' -import { compareCanonicalUtf8Text } from './canonicalText.js' - -export const OPERATIONAL_SOURCE_FAMILIES = [ - 'host', - 'context', - 'mcp_server', - 'workflow_recipe', - 'shared_filesystem', -] as const - -export type OperationalSourceFamily = (typeof OPERATIONAL_SOURCE_FAMILIES)[number] -export type OperationalResourceType = OperationalSourceFamily | 'sandbox_app' - -export type OperationalResourceRecord = Readonly<{ - environmentId: string - resourceType: OperationalResourceType - logicalId: string - sourceFamily: OperationalSourceFamily - providerUid: string - providerResourceVersion: string - displayName: string | null - enabled: boolean - deletedAt: string | null - observedGeneration: number | null - contentBytes: number -}> - -export type OperationalRelationshipRecord = Readonly<{ - environmentId: string - sourceType: OperationalResourceType - sourceId: string - relationshipType: - | 'uses_context' - | 'context_identity_alias' - | 'includes_mcp_server' - | 'mounts_shared_filesystem' - | 'exposes_sandbox_app' - targetType: OperationalResourceType - targetId: string - relationshipInstanceId: string - behaviorAttributes: Readonly> - sourceFamily: OperationalSourceFamily - sourceProviderUid: string - sourceResourceVersion: string - observedGeneration: number | null - contentBytes: number -}> - -export type OperationalObjectProjection = Readonly<{ - family: OperationalSourceFamily - namespace: string - rootType: OperationalResourceType - rootId: string - providerUid: string - providerResourceVersion: string - contentBytes: number - resources: readonly OperationalResourceRecord[] - relationships: readonly OperationalRelationshipRecord[] -}> - -export class OperationalProjectionError extends Error { - constructor(readonly code: string) { - super(`Invalid operational resource projection: ${code}`) - this.name = 'OperationalProjectionError' - } -} - -type ResourceObject = { - metadata?: { - name?: unknown - namespace?: unknown - uid?: unknown - resourceVersion?: unknown - generation?: unknown - deletionTimestamp?: unknown - } - spec?: unknown - status?: unknown -} - -type ProjectionInput = Readonly<{ - environmentId: string - plural: Extract< - ClerumResourceType, - 'hosts' | 'contexts' | 'mcpservers' | 'workflowrecipes' | 'sharedfilesystems' - > - namespace: string - object: unknown - behaviorFingerprintKey: string - maxObjectBytes?: number - relationshipNamespaces: Readonly<{ - context: string - mcpServer: string - sharedFilesystem: string - }> -}> - -const FAMILY_BY_PLURAL: Readonly> = { - hosts: 'host', - contexts: 'context', - mcpservers: 'mcp_server', - workflowrecipes: 'workflow_recipe', - sharedfilesystems: 'shared_filesystem', -} - -function requiredBoundedString(value: unknown, code: string, max = 512): string { - const result = typeof value === 'string' ? value.trim() : '' - if (!result || result.length > max || result.includes('\0')) { - throw new OperationalProjectionError(code) - } - return result -} - -function optionalBoundedString(value: unknown, max: number): string | null { - if (typeof value !== 'string') return null - const result = value.trim() - return result && result.length <= max && !result.includes('\0') ? result : null -} - -function objectRecord(value: unknown): Record { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : {} -} - -function boundedInteger(value: unknown, minimum: number, maximum: number): number | null { - return Number.isSafeInteger(value) && Number(value) >= minimum && Number(value) <= maximum - ? Number(value) - : null -} - -function canonicalJson(value: unknown): string { - if (value === undefined) return 'null' - if (value === null || typeof value !== 'object') return JSON.stringify(value) - if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]` - return `{${Object.entries(value as Record) - .sort(([left], [right]) => compareCanonicalUtf8Text(left, right)) - .map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`) - .join(',')}}` -} - -function behaviorFingerprint(key: string, value: unknown): string { - return createHmac('sha256', key).update(canonicalJson(value)).digest('base64url') -} - -function relationshipInstanceId(parts: readonly string[]): string { - return `rel1_${createHash('sha256').update(parts.join('\0')).digest('base64url')}` -} - -function logicalId(family: OperationalSourceFamily, namespace: string, name: string): string { - return `${namespace}/${name}` -} - -function assertBoundedObjectShape(value: unknown): void { - const pending: Array<{ value: unknown; depth: number }> = [{ value, depth: 0 }] - const visited = new Set() - let nodes = 0 - while (pending.length > 0) { - const current = pending.pop()! - nodes += 1 - if (nodes > 10_000 || current.depth > 32) { - throw new OperationalProjectionError('object_shape_exceeded') - } - if (!current.value || typeof current.value !== 'object') continue - if (visited.has(current.value)) throw new OperationalProjectionError('object_not_serializable') - visited.add(current.value) - const entries = Array.isArray(current.value) - ? current.value - : Object.values(current.value as Record) - if (entries.length > 2_048) throw new OperationalProjectionError('object_shape_exceeded') - for (const child of entries) pending.push({ value: child, depth: current.depth + 1 }) - } -} - -function contentBytes(value: unknown, maximum = Number.MAX_SAFE_INTEGER): number { - try { - assertBoundedObjectShape(value) - const bytes = Buffer.byteLength(JSON.stringify(value), 'utf8') - if (bytes > maximum) throw new OperationalProjectionError('object_bytes_exceeded') - return bytes - } catch (error) { - if (error instanceof OperationalProjectionError) throw error - throw new OperationalProjectionError('object_not_serializable') - } -} - -function displayName(family: OperationalSourceFamily, name: string, spec: Record) { - if (family === 'host') return optionalBoundedString(spec.host, 200) ?? name - return optionalBoundedString(spec.displayName, 200) ?? name -} - -function relationship(input: { - environmentId: string - sourceType: OperationalResourceType - sourceId: string - relationshipType: OperationalRelationshipRecord['relationshipType'] - targetType: OperationalResourceType - targetId: string - behaviorAttributes?: Record - instanceParts: readonly string[] - family: OperationalSourceFamily - providerUid: string - providerResourceVersion: string - observedGeneration: number | null -}): OperationalRelationshipRecord { - const behaviorAttributes = Object.freeze({ ...(input.behaviorAttributes ?? {}) }) - return Object.freeze({ - environmentId: input.environmentId, - sourceType: input.sourceType, - sourceId: input.sourceId, - relationshipType: input.relationshipType, - targetType: input.targetType, - targetId: input.targetId, - relationshipInstanceId: relationshipInstanceId(input.instanceParts), - behaviorAttributes, - sourceFamily: input.family, - sourceProviderUid: input.providerUid, - sourceResourceVersion: input.providerResourceVersion, - observedGeneration: input.observedGeneration, - contentBytes: contentBytes(behaviorAttributes), - }) -} - -function stringArray(value: unknown, maxItems = 256): string[] { - if (!Array.isArray(value) || value.length > maxItems) { - if (Array.isArray(value)) throw new OperationalProjectionError('relationship_fanout_exceeded') - return [] - } - return value.map((item, index) => requiredBoundedString(item, `relationship_${index}_invalid`)) -} - -function contextRelationships(params: { - environmentId: string - rootId: string - spec: Record - family: OperationalSourceFamily - providerUid: string - providerResourceVersion: string - observedGeneration: number | null - relationshipNamespaces: ProjectionInput['relationshipNamespaces'] -}): OperationalRelationshipRecord[] { - const relationships = [...new Set(stringArray(params.spec.mcpServers))].map(server => - relationship({ - ...params, - sourceType: 'context', - sourceId: params.rootId, - relationshipType: 'includes_mcp_server', - targetType: 'mcp_server', - targetId: `${params.relationshipNamespaces.mcpServer}/${server}`, - instanceParts: [ - 'context', - params.rootId, - 'mcp', - `${params.relationshipNamespaces.mcpServer}/${server}`, - ], - }) - ) - const mounts = Array.isArray(params.spec.sharedFileSystems) ? params.spec.sharedFileSystems : [] - if (mounts.length > 256) throw new OperationalProjectionError('relationship_fanout_exceeded') - for (const [index, value] of mounts.entries()) { - const mount = objectRecord(value) - const name = requiredBoundedString(mount.name, `shared_filesystem_${index}_name_invalid`) - const mountPath = requiredBoundedString( - mount.mountPath, - `shared_filesystem_${index}_mount_path_invalid`, - 1_024 - ) - relationships.push( - relationship({ - ...params, - sourceType: 'context', - sourceId: params.rootId, - relationshipType: 'mounts_shared_filesystem', - targetType: 'shared_filesystem', - targetId: `${params.relationshipNamespaces.sharedFilesystem}/${name}`, - behaviorAttributes: { mountPath, readOnly: true }, - instanceParts: [ - 'context', - params.rootId, - 'sfs', - `${params.relationshipNamespaces.sharedFilesystem}/${name}`, - mountPath, - ], - }) - ) - } - return relationships -} - -export function projectOperationalObject(input: ProjectionInput): OperationalObjectProjection { - const raw = objectRecord(input.object) as ResourceObject - const metadata = objectRecord(raw.metadata) - const spec = objectRecord(raw.spec) - const name = requiredBoundedString(metadata.name, 'metadata_name_invalid', 253) - const namespace = - optionalBoundedString(metadata.namespace, 253) ?? - requiredBoundedString(input.namespace, 'namespace_invalid', 253) - if (namespace !== input.namespace) throw new OperationalProjectionError('namespace_mismatch') - const providerUid = requiredBoundedString(metadata.uid, 'metadata_uid_invalid', 256) - const providerResourceVersion = requiredBoundedString( - metadata.resourceVersion, - 'metadata_resource_version_invalid', - 256 - ) - const family = FAMILY_BY_PLURAL[input.plural] - const rootId = logicalId(family, namespace, name) - const observedGeneration = boundedInteger(metadata.generation, 0, Number.MAX_SAFE_INTEGER) - const deletedAt = optionalBoundedString(metadata.deletionTimestamp, 128) - const encodedBytes = contentBytes(input.object, input.maxObjectBytes ?? 512 * 1024) - const enabled = !deletedAt && spec.enabled !== false - const root: OperationalResourceRecord = Object.freeze({ - environmentId: requiredBoundedString(input.environmentId, 'environment_id_invalid', 512), - resourceType: family, - logicalId: rootId, - sourceFamily: family, - providerUid, - providerResourceVersion, - displayName: displayName(family, name, spec), - enabled, - deletedAt, - observedGeneration, - contentBytes: encodedBytes, - }) - const common = { - environmentId: root.environmentId, - rootId, - family, - providerUid, - providerResourceVersion, - observedGeneration, - } - const relationships: OperationalRelationshipRecord[] = [] - const resources: OperationalResourceRecord[] = [root] - - if (family === 'host' || family === 'mcp_server') { - const contextRef = optionalBoundedString(spec.contextRef, 253) - if (contextRef) { - const model = objectRecord(spec.model) - const behavior: Record = - family === 'host' - ? { - modelProvider: optionalBoundedString(model.provider, 100) ?? '', - modelName: optionalBoundedString(model.name, 200) ?? '', - executionPolicyFingerprint: behaviorFingerprint(input.behaviorFingerprintKey, { - secretRef: optionalBoundedString(spec.secretRef, 253), - allowedModels: spec.allowedModels ?? null, - }), - } - : {} - relationships.push( - relationship({ - ...common, - sourceType: family, - sourceId: rootId, - relationshipType: 'uses_context', - targetType: 'context', - targetId: `${input.relationshipNamespaces.context}/${contextRef}`, - behaviorAttributes: behavior, - instanceParts: [ - family, - rootId, - 'context', - `${input.relationshipNamespaces.context}/${contextRef}`, - ], - }) - ) - } - } else if (family === 'context') { - const contextId = optionalBoundedString(spec.contextId, 253) - if (contextId && contextId !== name) { - relationships.push( - relationship({ - ...common, - sourceType: 'context', - sourceId: rootId, - relationshipType: 'context_identity_alias', - targetType: 'context', - targetId: logicalId('context', namespace, contextId), - instanceParts: ['context', rootId, 'identity-alias', contextId], - }) - ) - } - relationships.push( - ...contextRelationships({ - ...common, - spec, - relationshipNamespaces: input.relationshipNamespaces, - }) - ) - } else if (family === 'workflow_recipe') { - const contextRef = optionalBoundedString(spec.contextRef, 253) - if (contextRef) { - relationships.push( - relationship({ - ...common, - sourceType: 'workflow_recipe', - sourceId: rootId, - relationshipType: 'uses_context', - targetType: 'context', - targetId: `${input.relationshipNamespaces.context}/${contextRef}`, - behaviorAttributes: { - runtimePolicyFingerprint: behaviorFingerprint(input.behaviorFingerprintKey, { - runtimeEgress: spec.runtimeEgress ?? null, - }), - }, - instanceParts: [ - 'workflow_recipe', - rootId, - 'context', - `${input.relationshipNamespaces.context}/${contextRef}`, - ], - }) - ) - } - const ui = objectRecord(spec.ui) - const workloadRef = optionalBoundedString(ui.workloadRef, 63) - const port = boundedInteger(ui.port, 1, 65_535) - if (workloadRef && port !== null) { - const appId = rootId - resources.push( - Object.freeze({ - ...root, - resourceType: 'sandbox_app', - logicalId: appId, - providerUid: `${providerUid}:ui`, - displayName: optionalBoundedString(ui.title, 100) ?? name, - }) - ) - relationships.push( - relationship({ - ...common, - sourceType: 'workflow_recipe', - sourceId: rootId, - relationshipType: 'exposes_sandbox_app', - targetType: 'sandbox_app', - targetId: appId, - behaviorAttributes: { - workloadRef, - port, - defaultPath: optionalBoundedString(ui.defaultPath, 1_024) ?? '/', - runtimePolicyFingerprint: behaviorFingerprint(input.behaviorFingerprintKey, { - workloadRef, - port, - runtimeEgress: spec.runtimeEgress ?? null, - oauthClients: spec.oauthClients ?? null, - }), - }, - instanceParts: ['workflow_recipe', rootId, 'sandbox_app', appId, workloadRef], - }) - ) - } - } - - return Object.freeze({ - family, - namespace, - rootType: family, - rootId, - providerUid, - providerResourceVersion, - contentBytes: encodedBytes, - resources: Object.freeze(resources), - relationships: Object.freeze(relationships), - }) -} - -export function canonicalEnvironmentId(env: NodeJS.ProcessEnv = process.env): string { - const environment = env.TRACING_ENVIRONMENT?.trim() || 'development' - const cluster = - env.TRACING_CLUSTER_NAME?.trim() || env.KUBERNETES_CLUSTER_NAME?.trim() || 'local-cluster' - return `${requiredBoundedString(environment, 'environment_name_invalid', 253)}:${requiredBoundedString( - cluster, - 'cluster_name_invalid', - 253 - )}` -} diff --git a/control-api/src/services/access/userAccessFoundationSchema.ts b/control-api/src/services/access/userAccessFoundationSchema.ts index f3bd743698..cf337d2d7e 100644 --- a/control-api/src/services/access/userAccessFoundationSchema.ts +++ b/control-api/src/services/access/userAccessFoundationSchema.ts @@ -1,13 +1,9 @@ import type { DbClient } from '../../db.js' -import { canonicalEnvironmentId } from './operationalAccessProjection.js' /** - * Additive persistence foundation for user-session v2 and user-centric access. - * - * The operational tables intentionally contain provider state only. They do - * not materialize a user's effective access and are never final action - * authority. Relist staging is separate so an indexer can promote a complete - * generation in one transaction without exposing a half-reconciled list. + * Additive persistence foundation for user-session v2: the session rows, the + * per-user security epochs that invalidate sessions issued before a password + * event, and the append-only ledger of revoked v1 session tokens. */ export async function applyUserAccessFoundationSchema(db: DbClient): Promise { await db.query(` @@ -59,1357 +55,14 @@ export async function applyUserAccessFoundationSchema(db: DbClient): Promise 0), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - CREATE TABLE IF NOT EXISTS authorization_team_revisions ( - team_id UUID PRIMARY KEY REFERENCES teams(id) ON DELETE CASCADE, - revision BIGINT NOT NULL DEFAULT 1 CHECK (revision > 0), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - CREATE TABLE IF NOT EXISTS authorization_resource_revisions ( - environment_id TEXT NOT NULL, - resource_type TEXT NOT NULL, - resource_id TEXT NOT NULL, - revision BIGINT NOT NULL DEFAULT 1 CHECK (revision > 0), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - PRIMARY KEY (environment_id, resource_type, resource_id), - CHECK (environment_id <> '' AND resource_type <> '' AND resource_id <> '') - ); - CREATE INDEX IF NOT EXISTS authorization_resource_revisions_updated_idx - ON authorization_resource_revisions (environment_id, updated_at DESC); - - CREATE TABLE IF NOT EXISTS operational_catalog_source_state ( - environment_id TEXT NOT NULL, - source_family TEXT NOT NULL, - generation BIGINT NOT NULL DEFAULT 0 CHECK (generation >= 0), - staging_generation BIGINT CHECK (staging_generation > generation), - resource_version TEXT, - status TEXT NOT NULL DEFAULT 'relisting' - CHECK (status IN ('current', 'relisting', 'unavailable')), - last_success_at TIMESTAMPTZ, - last_error_at TIMESTAMPTZ, - safe_error_code TEXT, - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - PRIMARY KEY (environment_id, source_family), - CHECK (environment_id <> '' AND source_family <> '') - ); - - CREATE TABLE IF NOT EXISTS operational_resource_index ( - environment_id TEXT NOT NULL, - resource_type TEXT NOT NULL, - logical_id TEXT NOT NULL, - source_family TEXT NOT NULL, - source_generation BIGINT NOT NULL CHECK (source_generation > 0), - provider_uid TEXT NOT NULL, - provider_resource_version TEXT NOT NULL, - display_name TEXT, - enabled BOOLEAN NOT NULL DEFAULT TRUE, - deleted_at TIMESTAMPTZ, - observed_generation BIGINT, - content_bytes BIGINT NOT NULL CHECK (content_bytes >= 0), - observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - PRIMARY KEY (environment_id, resource_type, logical_id), - CHECK ( - environment_id <> '' AND resource_type <> '' AND logical_id <> '' - AND source_family <> '' AND provider_uid <> '' - AND provider_resource_version <> '' - ) - ); - CREATE INDEX IF NOT EXISTS operational_resource_source_idx - ON operational_resource_index ( - environment_id, source_family, source_generation, resource_type, logical_id - ); - - CREATE TABLE IF NOT EXISTS operational_resource_relationships ( - environment_id TEXT NOT NULL, - source_type TEXT NOT NULL, - source_id TEXT NOT NULL, - relationship_type TEXT NOT NULL, - target_type TEXT NOT NULL, - target_id TEXT NOT NULL, - relationship_instance_id TEXT NOT NULL, - behavior_attributes JSONB NOT NULL DEFAULT '{}'::JSONB, - source_family TEXT NOT NULL, - source_provider_uid TEXT NOT NULL, - source_resource_version TEXT NOT NULL, - source_generation BIGINT NOT NULL CHECK (source_generation > 0), - observed_generation BIGINT, - content_bytes BIGINT NOT NULL CHECK (content_bytes >= 0), - observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - PRIMARY KEY ( - environment_id, source_type, source_id, relationship_type, - target_type, target_id, relationship_instance_id - ), - CHECK (jsonb_typeof(behavior_attributes) = 'object'), - CHECK ( - environment_id <> '' AND source_type <> '' AND source_id <> '' - AND relationship_type <> '' AND target_type <> '' AND target_id <> '' - AND relationship_instance_id <> '' AND source_family <> '' - AND source_provider_uid <> '' AND source_resource_version <> '' - ) - ); - CREATE INDEX IF NOT EXISTS operational_relationship_target_idx - ON operational_resource_relationships ( - environment_id, target_type, target_id, source_type, - source_id, relationship_instance_id - ); - CREATE INDEX IF NOT EXISTS operational_relationship_catalog_target_idx - ON operational_resource_relationships ( - environment_id, target_type, relationship_type, target_id, - source_type, source_id, relationship_instance_id - ); - CREATE INDEX IF NOT EXISTS operational_relationship_generation_idx - ON operational_resource_relationships ( - environment_id, source_family, source_generation, source_type, source_id - ); - - CREATE TABLE IF NOT EXISTS operational_resource_index_staging - (LIKE operational_resource_index INCLUDING DEFAULTS INCLUDING CONSTRAINTS); - CREATE UNIQUE INDEX IF NOT EXISTS operational_resource_staging_identity_idx - ON operational_resource_index_staging ( - environment_id, source_family, source_generation, resource_type, logical_id - ); - - CREATE TABLE IF NOT EXISTS operational_relationships_staging - (LIKE operational_resource_relationships INCLUDING DEFAULTS INCLUDING CONSTRAINTS); - CREATE UNIQUE INDEX IF NOT EXISTS operational_relationship_staging_identity_idx - ON operational_relationships_staging ( - environment_id, source_family, source_generation, source_type, source_id, - relationship_type, target_type, target_id, relationship_instance_id - ); `) // Indexes on tables that exist before PR1 are built online by // PR1_ONLINE_INDEX_PLAN before this transaction opens, so this transaction // takes no ShareLock on those tables. - await applyAuthorizationRevisionFunctions(db) - await applyAuthorizationRevisionTriggers(db) await applyUserAccessRuntimePrivileges(db) } -/** - * Forward migration for D26A's exact UTF-8 byte ordering. Its supporting - * indexes are built online by PR1_ONLINE_INDEX_PLAN before this transaction. - */ -export async function applyCatalogUtf8OrderingSchema(db: DbClient): Promise { - await db.query(` - CREATE OR REPLACE FUNCTION catalog_utf8_bytes(value TEXT) - RETURNS BYTEA - LANGUAGE SQL - IMMUTABLE - STRICT - PARALLEL SAFE - AS $$ - SELECT convert_to(value, 'UTF8'); - $$; - `) -} - -/** - * Install the transactional user, team, resource, and operational-source - * revision writers consumed by catalog reads. - */ -export async function applyComposableCatalogRevisionSchema(db: DbClient): Promise { - // Bound the backfill before trigger DDL takes writer-blocking locks. Larger - // datasets need a separately planned batched backfill, not a longer timeout. - await db.query(` - DO $$ - DECLARE - resource_count BIGINT; - resource_limit CONSTANT BIGINT := 100000; - BEGIN - SELECT count(*) INTO resource_count FROM gfs_resources; - IF resource_count > resource_limit THEN - RAISE EXCEPTION '0134 gfs_resources backfill refused: count % exceeds supported limit %; a batched backfill is required', - resource_count, resource_limit; - END IF; - END; - $$; - `) - await db.query( - `INSERT INTO authorization_resource_revisions( - environment_id, resource_type, resource_id, revision, updated_at - ) - SELECT $1, 'gfs_resource', resource_id::text, 1, clock_timestamp() - FROM gfs_resources - ON CONFLICT (environment_id, resource_type, resource_id) DO NOTHING`, - [canonicalEnvironmentId()] - ) - await db.query(` - CREATE TABLE IF NOT EXISTS authorization_catalog_environment ( - singleton BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (singleton), - environment_id TEXT NOT NULL CHECK (environment_id <> '') - ); - - CREATE OR REPLACE FUNCTION authorization_bump_subject_revision( - target_subject_type TEXT, - target_subject_id TEXT - ) - RETURNS VOID - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = pg_catalog, public, pg_temp - AS $$ - BEGIN - IF target_subject_type NOT IN ('user', 'team', 'operator', 'host', 'context') THEN - RAISE EXCEPTION 'unmapped catalog subject type: %', target_subject_type; - END IF; - IF target_subject_type IN ('operator', 'host', 'context') THEN - RETURN; - END IF; - IF target_subject_id IS NULL OR target_subject_id = '' THEN - RETURN; - END IF; - IF target_subject_id !~* - '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' - THEN - RAISE EXCEPTION 'invalid catalog % subject identifier', target_subject_type; - END IF; - IF target_subject_type = 'user' THEN - PERFORM authorization_bump_user_revision(target_subject_id::UUID); - ELSE - PERFORM authorization_bump_team_revision(target_subject_id::UUID); - END IF; - END; - $$; - - CREATE OR REPLACE FUNCTION public.authorization_prelock_user_retirement_revisions(target_user_ids UUID[]) - RETURNS VOID - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = pg_catalog, public, pg_temp - AS $$ - DECLARE - team_keys TEXT[]; - team_key TEXT; - BEGIN - -- Lock the complete principal batch before discovering its affected teams. - -- Callers must not have locked a member of this batch out of UUID order. - PERFORM principal.id FROM public.users AS principal - WHERE principal.id = ANY(target_user_ids) - ORDER BY principal.id FOR UPDATE; - - SELECT array_agg(subject_id ORDER BY subject_id) INTO team_keys - FROM ( - SELECT member.team_id::TEXT AS subject_id - FROM public.team_members AS member - WHERE member.user_id = ANY(target_user_ids) - UNION - SELECT subjects.subject_id - FROM public.workflow_runs_audit AS audit - CROSS JOIN LATERAL (VALUES - (audit.triggerer_team_id::TEXT), - (CASE WHEN audit.usage_team_id = 'control-plane-admin-ui' - THEN NULL ELSE audit.usage_team_id END) - ) AS subjects(subject_id) - WHERE audit.triggerer_user_id = ANY(target_user_ids) - AND subjects.subject_id IS NOT NULL AND subjects.subject_id <> '' - ) AS affected; - - -- Match authorization_bump_subject_revision's validation and raw TEXT order. - -- Do not lowercase before sorting: usage_team_id is TEXT, not a UUID FK. - FOREACH team_key IN ARRAY COALESCE(team_keys, ARRAY[]::TEXT[]) LOOP - IF team_key !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' THEN - RAISE EXCEPTION 'invalid catalog % subject identifier', 'team'; - END IF; - END LOOP; - - -- Parent locks before revision locks, as in the workflow prelock idiom. - PERFORM team.id FROM public.teams AS team - WHERE team.id IN (SELECT key::UUID FROM unnest(team_keys) AS keys(key)) - ORDER BY team.id FOR KEY SHARE; - - -- Child-row writers take their row before the AFTER ROW revision trigger. - -- Lock every status, including deleted, before taking any team revision. - PERFORM member.user_id FROM public.team_members AS member - WHERE member.user_id = ANY(target_user_ids) - ORDER BY member.user_id, member.team_id FOR UPDATE; - - -- Residual pre-existing createRun risk: case variants of one UUID retain raw - -- TEXT keys; collation can invert physical team order. Inventory before rollout. - FOREACH team_key IN ARRAY COALESCE(team_keys, ARRAY[]::TEXT[]) LOOP - PERFORM revision.team_id FROM public.authorization_team_revisions AS revision - WHERE revision.team_id = team_key::UUID FOR UPDATE; - END LOOP; - -- No user-revision prelock: medium/row triggers acquire it after child rows. - END; - $$; - REVOKE ALL ON FUNCTION public.authorization_prelock_user_retirement_revisions(UUID[]) FROM PUBLIC; - GRANT EXECUTE ON FUNCTION public.authorization_prelock_user_retirement_revisions(UUID[]) TO control_api_runtime; - - CREATE OR REPLACE FUNCTION authorization_prelock_workflow_run_revisions(target_run_ids UUID[]) - RETURNS VOID - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = pg_catalog, public, pg_temp - AS $$ - DECLARE revision RECORD; - BEGIN - -- Parent locks must precede revisions and the audit INSERT's FK checks, - -- matching principal deletion's parent-before-cascade order. - PERFORM team.id - FROM teams AS team - WHERE team.id::TEXT IN ( - SELECT run.team_id::TEXT FROM workflow_runs AS run - WHERE run.run_id = ANY(target_run_ids) - UNION - -- Keep malformed usage IDs for the existing subject validator below. - SELECT lower(run.usage_team_id) FROM workflow_runs AS run - WHERE run.run_id = ANY(target_run_ids) - AND run.usage_team_id <> 'control-plane-admin-ui' - ) - ORDER BY team.id - FOR KEY SHARE; - - PERFORM principal.id - FROM users AS principal - WHERE principal.id IN ( - SELECT run.actor_id FROM workflow_runs AS run - WHERE run.run_id = ANY(target_run_ids) AND run.actor_type = 'user' - ) - ORDER BY principal.id - FOR KEY SHARE; - - -- A batch must hold every team before any user, across both archive - -- INSERT and live DELETE triggers. Bumping also creates missing rows. - FOR revision IN - SELECT DISTINCT subject_type, subject_id - FROM workflow_runs AS run - CROSS JOIN LATERAL (VALUES - ('team', run.team_id::TEXT), - ('team', CASE - WHEN run.usage_team_id = 'control-plane-admin-ui' THEN NULL - ELSE run.usage_team_id - END), - ('user', CASE WHEN run.actor_type = 'user' THEN run.actor_id::TEXT END) - ) AS subjects(subject_type, subject_id) - WHERE run.run_id = ANY(target_run_ids) - AND subject_id IS NOT NULL AND subject_id <> '' - ORDER BY subject_type, subject_id - LOOP - PERFORM authorization_bump_subject_revision(revision.subject_type, revision.subject_id); - END LOOP; - END; - $$; - - -- Approval batches delete rows whose FK cascade also rewrites every live run - -- that references them. Those run rows take their subjects in this - -- transaction too, so the subject set covers both the approval targets and - -- the referencing runs' attribution, which keeps one global order against - -- run writers. The cascade's attribution change no longer bumps a revision - -- on its own; the batch holds these subjects anyway. Both phases mirror the - -- run prelock. - CREATE OR REPLACE FUNCTION authorization_prelock_workflow_approval_revisions( - target_request_ids UUID[] - ) - RETURNS VOID - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = pg_catalog, public, pg_temp - AS $$ - DECLARE revision RECORD; - BEGIN - -- Parent locks must precede revisions and the FK checks of the bumped - -- rows, matching principal deletion's parent-before-cascade order. - PERFORM team.id - FROM teams AS team - WHERE team.id::TEXT IN ( - SELECT approval.target_team_id::TEXT - FROM workflow_approval_requests AS approval - WHERE approval.id = ANY(target_request_ids) - UNION - SELECT run.team_id::TEXT FROM workflow_runs AS run - WHERE run.approval_request_id = ANY(target_request_ids) - UNION - -- Keep malformed usage IDs for the existing subject validator below. - SELECT lower(run.usage_team_id) FROM workflow_runs AS run - WHERE run.approval_request_id = ANY(target_request_ids) - AND run.usage_team_id <> 'control-plane-admin-ui' - ) - ORDER BY team.id - FOR KEY SHARE; - - PERFORM principal.id - FROM users AS principal - WHERE principal.id IN ( - SELECT approval.target_user_id - FROM workflow_approval_requests AS approval - WHERE approval.id = ANY(target_request_ids) - UNION - SELECT run.actor_id FROM workflow_runs AS run - WHERE run.approval_request_id = ANY(target_request_ids) AND run.actor_type = 'user' - ) - ORDER BY principal.id - FOR KEY SHARE; - - -- A batch must hold every team before any user, across the approval - -- target, the referencing runs, and both the archive INSERT and live - -- DELETE triggers. Bumping also creates missing rows. - FOR revision IN - SELECT DISTINCT batch.subject_type, batch.subject_id - FROM ( - SELECT 'team'::TEXT AS subject_type, approval.target_team_id::TEXT AS subject_id - FROM workflow_approval_requests AS approval - WHERE approval.id = ANY(target_request_ids) - UNION ALL - SELECT 'user'::TEXT, approval.target_user_id::TEXT - FROM workflow_approval_requests AS approval - WHERE approval.id = ANY(target_request_ids) - UNION ALL - SELECT subjects.subject_type, subjects.subject_id - FROM workflow_runs AS run - CROSS JOIN LATERAL (VALUES - ('team', run.team_id::TEXT), - ('team', CASE - WHEN run.usage_team_id = 'control-plane-admin-ui' THEN NULL - ELSE run.usage_team_id - END), - ('user', CASE WHEN run.actor_type = 'user' THEN run.actor_id::TEXT END) - ) AS subjects(subject_type, subject_id) - WHERE run.approval_request_id = ANY(target_request_ids) - ) AS batch(subject_type, subject_id) - WHERE batch.subject_id IS NOT NULL AND batch.subject_id <> '' - ORDER BY batch.subject_type, batch.subject_id - LOOP - PERFORM authorization_bump_subject_revision(revision.subject_type, revision.subject_id); - END LOOP; - END; - $$; - - -- Deciding an approval takes the target's revision before the run INSERT - -- takes the run's team, so both call sites hold this subject set first. - -- The set mirrors the run INSERT expressions exactly - -- (userApprovalRequestService.ts:723,727,1255 and - -- workflowRunService.ts:299,300). - -- - -- Premise 1: at decision time the decision_maker column is still NULL - -- when this function reads it. Both deciders either pass the approval's own - -- target team or omit the team (decisions.routes.ts:130, - -- workflowApprovalProviderDecisionService.ts:177,192), so the fallback is - -- the same value at decision and INSERT time. - -- Premise 2: an approval without a run intent reaches an INSERT only - -- through createApprovedRun, which is always mcp-host-control with a null - -- team, null usage team and 'autonomous' actor - -- (workflowTriggerService.ts:84-104,297-305); its subjects then come from - -- the approval alone. Approvals without run intents are still created when - -- the request carries no workflowTriggerRunIntent - -- (user-approval-requests.routes.ts:651 versus :715-729). - CREATE OR REPLACE FUNCTION authorization_prelock_workflow_approval_trigger_revisions( - target_request_id UUID - ) - RETURNS VOID - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = pg_catalog, public, pg_temp - AS $$ - DECLARE revision RECORD; - BEGIN - -- Parent locks must precede revisions and the bumped rows' FK checks, - -- matching principal deletion's parent-before-cascade order. - PERFORM team.id - FROM teams AS team - WHERE team.id::TEXT IN ( - SELECT lower(subject.subject_id) - FROM workflow_approval_requests AS war - LEFT JOIN workflow_approval_trigger_run_intents AS watri - ON watri.approval_request_id = war.id - CROSS JOIN LATERAL (VALUES - ('team', war.target_team_id::TEXT), - ('team', COALESCE( - watri.team_id::TEXT, war.target_team_id::TEXT, war.decision_maker->>'teamId' - )), - ('team', NULLIF(COALESCE( - watri.usage_team_id, watri.team_id::TEXT, - war.target_team_id::TEXT, war.decision_maker->>'teamId' - ), 'control-plane-admin-ui')) - ) AS subject(subject_type, subject_id) - WHERE war.id = target_request_id - AND subject.subject_type = 'team' - AND subject.subject_id IS NOT NULL AND subject.subject_id <> '' - ) - ORDER BY team.id - FOR KEY SHARE; - - PERFORM principal.id - FROM users AS principal - WHERE principal.id IN ( - SELECT war.target_user_id - FROM workflow_approval_requests AS war - WHERE war.id = target_request_id - UNION - SELECT watri.actor_id - FROM workflow_approval_trigger_run_intents AS watri - WHERE watri.approval_request_id = target_request_id - AND watri.actor_type = 'user' - ) - ORDER BY principal.id - FOR KEY SHARE; - - -- Hold every team before any user. Bumping also creates missing rows. - FOR revision IN - SELECT DISTINCT subject.subject_type, subject.subject_id - FROM workflow_approval_requests AS war - LEFT JOIN workflow_approval_trigger_run_intents AS watri - ON watri.approval_request_id = war.id - CROSS JOIN LATERAL (VALUES - ('team', war.target_team_id::TEXT), - ('team', COALESCE( - watri.team_id::TEXT, war.target_team_id::TEXT, war.decision_maker->>'teamId' - )), - ('team', NULLIF(COALESCE( - watri.usage_team_id, watri.team_id::TEXT, - war.target_team_id::TEXT, war.decision_maker->>'teamId' - ), 'control-plane-admin-ui')), - ('user', war.target_user_id::TEXT), - ('user', CASE WHEN watri.actor_type = 'user' THEN watri.actor_id::TEXT END) - ) AS subject(subject_type, subject_id) - WHERE war.id = target_request_id - AND subject.subject_id IS NOT NULL AND subject.subject_id <> '' - ORDER BY subject.subject_type, subject.subject_id - LOOP - PERFORM authorization_bump_subject_revision(revision.subject_type, revision.subject_id); - END LOOP; - END; - $$; - - -- Recipe deletion (workflow-recipes workflowReconciler.ts reconcileDelete) - -- deletes the recipe's trigger grants and cancels its live approvals in one - -- transaction, after its caller locks those approval rows. Its user-grant - -- DELETE would take users before the team-grant DELETEs take teams, and its - -- multi-row approval UPDATE takes targets in scan order; run INSERTs and the - -- other prelocks take teams first. Grant writers lock the grant row before - -- its revision (workflowGrants.ts:106-111,238-242; - -- workflowGrantManagementService.ts:258-262), so these grant rows are - -- locked after their parents and before any revision. A row committed after - -- this function's reads is still deleted or cancelled, but its subject is - -- taken after the prelocked set (one trailing subject per late row). - CREATE OR REPLACE FUNCTION authorization_prelock_workflow_recipe_policy_revisions( - target_recipe_namespace TEXT, - target_recipe_name TEXT - ) - RETURNS VOID - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = pg_catalog, public, pg_temp - AS $$ - DECLARE revision RECORD; - BEGIN - -- Parent locks must precede grant rows and revisions, matching principal - -- deletion's parent-before-cascade order. - PERFORM team.id - FROM teams AS team - WHERE team.id::TEXT IN ( - SELECT trigger_grant.team_id::TEXT - FROM team_workflow_triggers AS trigger_grant - WHERE trigger_grant.recipe_namespace = target_recipe_namespace - AND trigger_grant.recipe_name = target_recipe_name - UNION - SELECT approval_target.team_id::TEXT - FROM workflow_recipe_allowed_teams AS approval_target - WHERE approval_target.recipe_namespace = target_recipe_namespace - AND approval_target.recipe_name = target_recipe_name - UNION - SELECT approval.target_team_id::TEXT - FROM workflow_approval_requests AS approval - WHERE approval.recipe_namespace = target_recipe_namespace - AND approval.recipe_name = target_recipe_name - AND approval.status IN ('pending', 'approved') - ) - ORDER BY team.id - FOR KEY SHARE; - - PERFORM principal.id - FROM users AS principal - WHERE principal.id IN ( - SELECT trigger_grant.user_id - FROM user_workflow_triggers AS trigger_grant - WHERE trigger_grant.recipe_namespace = target_recipe_namespace - AND trigger_grant.recipe_name = target_recipe_name - UNION - SELECT approval.target_user_id - FROM workflow_approval_requests AS approval - WHERE approval.recipe_namespace = target_recipe_namespace - AND approval.recipe_name = target_recipe_name - AND approval.status IN ('pending', 'approved') - ) - ORDER BY principal.id - FOR KEY SHARE; - - -- Grant writers take the grant row before its revision, so these rows are - -- held now, after their parents. The runtime role cannot lock or read the - -- subject columns, so this phase runs as the function owner. - PERFORM 1 - FROM user_workflow_triggers AS trigger_grant - WHERE trigger_grant.recipe_namespace = target_recipe_namespace - AND trigger_grant.recipe_name = target_recipe_name - ORDER BY trigger_grant.user_id - FOR UPDATE; - PERFORM 1 - FROM team_workflow_triggers AS trigger_grant - WHERE trigger_grant.recipe_namespace = target_recipe_namespace - AND trigger_grant.recipe_name = target_recipe_name - ORDER BY trigger_grant.team_id - FOR UPDATE; - PERFORM 1 - FROM workflow_recipe_allowed_teams AS approval_target - WHERE approval_target.recipe_namespace = target_recipe_namespace - AND approval_target.recipe_name = target_recipe_name - ORDER BY approval_target.team_id - FOR UPDATE; - - -- Hold every team before any user, as the other prelocks do. Bumping also - -- creates missing rows. - FOR revision IN - SELECT DISTINCT subject.subject_type, subject.subject_id - FROM ( - SELECT 'team'::TEXT AS subject_type, trigger_grant.team_id::TEXT AS subject_id - FROM team_workflow_triggers AS trigger_grant - WHERE trigger_grant.recipe_namespace = target_recipe_namespace - AND trigger_grant.recipe_name = target_recipe_name - UNION ALL - SELECT 'team', approval_target.team_id::TEXT - FROM workflow_recipe_allowed_teams AS approval_target - WHERE approval_target.recipe_namespace = target_recipe_namespace - AND approval_target.recipe_name = target_recipe_name - UNION ALL - SELECT 'user', trigger_grant.user_id::TEXT - FROM user_workflow_triggers AS trigger_grant - WHERE trigger_grant.recipe_namespace = target_recipe_namespace - AND trigger_grant.recipe_name = target_recipe_name - UNION ALL - SELECT 'team', approval.target_team_id::TEXT - FROM workflow_approval_requests AS approval - WHERE approval.recipe_namespace = target_recipe_namespace - AND approval.recipe_name = target_recipe_name - AND approval.status IN ('pending', 'approved') - UNION ALL - SELECT 'user', approval.target_user_id::TEXT - FROM workflow_approval_requests AS approval - WHERE approval.recipe_namespace = target_recipe_namespace - AND approval.recipe_name = target_recipe_name - AND approval.status IN ('pending', 'approved') - ) AS subject(subject_type, subject_id) - WHERE subject.subject_id IS NOT NULL AND subject.subject_id <> '' - ORDER BY subject.subject_type, subject.subject_id - LOOP - PERFORM authorization_bump_subject_revision(revision.subject_type, revision.subject_id); - END LOOP; - END; - $$; - - CREATE OR REPLACE FUNCTION authorization_bump_user_row_revision() - RETURNS TRIGGER - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = pg_catalog, public, pg_temp - AS $$ - BEGIN - IF TG_OP <> 'DELETE' THEN - PERFORM authorization_bump_user_revision(NEW.id); - END IF; - IF TG_OP = 'DELETE' OR (TG_OP = 'UPDATE' AND OLD.id IS DISTINCT FROM NEW.id) THEN - PERFORM authorization_bump_user_revision(OLD.id); - END IF; - RETURN COALESCE(NEW, OLD); - END; - $$; - - CREATE OR REPLACE FUNCTION authorization_bump_team_row_revision() - RETURNS TRIGGER - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = pg_catalog, public, pg_temp - AS $$ - BEGIN - IF TG_OP <> 'DELETE' THEN - PERFORM authorization_bump_team_revision(NEW.id); - END IF; - IF TG_OP = 'DELETE' OR (TG_OP = 'UPDATE' AND OLD.id IS DISTINCT FROM NEW.id) THEN - PERFORM authorization_bump_team_revision(OLD.id); - END IF; - RETURN COALESCE(NEW, OLD); - END; - $$; - - CREATE OR REPLACE FUNCTION authorization_bump_workflow_run_revision() - RETURNS TRIGGER - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = pg_catalog, public, pg_temp - AS $$ - DECLARE revision RECORD; - BEGIN - -- Catalog producers, hydration and the live resolver read only these - -- columns (catalogProducerSql.ts:236-274, catalogHydrationSql.ts:491-535, - -- databaseAuthorityCandidates.ts:85-113); heartbeats and phase writes - -- change none of them. This is the same idiom as the guards below, an - -- in-function TG_OP test, because this trigger handles INSERT and DELETE - -- too and a WHEN clause cannot compare OLD and NEW there. - IF TG_OP = 'UPDATE' AND ( - OLD.run_id, OLD.recipe_namespace, OLD.recipe_name, OLD.actor_type, - OLD.actor_id, OLD.team_id, OLD.usage_team_id - ) IS NOT DISTINCT FROM ( - NEW.run_id, NEW.recipe_namespace, NEW.recipe_name, NEW.actor_type, - NEW.actor_id, NEW.team_id, NEW.usage_team_id - ) THEN - RETURN NEW; - END IF; - -- Order all affected subjects together, including prior attribution. - FOR revision IN - SELECT subject_type, subject_id - FROM ( - SELECT actor_type, actor_id::TEXT, team_id::TEXT, usage_team_id - FROM (VALUES (NEW.actor_type, NEW.actor_id, NEW.team_id, NEW.usage_team_id)) - AS current_row(actor_type, actor_id, team_id, usage_team_id) - WHERE TG_OP <> 'DELETE' - UNION ALL - SELECT actor_type, actor_id::TEXT, team_id::TEXT, usage_team_id - FROM (VALUES (OLD.actor_type, OLD.actor_id, OLD.team_id, OLD.usage_team_id)) - AS prior_row(actor_type, actor_id, team_id, usage_team_id) - WHERE TG_OP <> 'INSERT' - ) AS row_value - CROSS JOIN LATERAL (VALUES - ('team', row_value.team_id), - ('team', CASE - WHEN row_value.usage_team_id = 'control-plane-admin-ui' THEN NULL - ELSE row_value.usage_team_id - END), - ('user', CASE WHEN row_value.actor_type = 'user' THEN row_value.actor_id END) - ) AS subjects(subject_type, subject_id) - WHERE subject_id IS NOT NULL AND subject_id <> '' - ORDER BY subject_type, subject_id - LOOP - PERFORM authorization_bump_subject_revision(revision.subject_type, revision.subject_id); - END LOOP; - RETURN COALESCE(NEW, OLD); - END; - $$; - - CREATE OR REPLACE FUNCTION authorization_bump_workflow_run_audit_revision() - RETURNS TRIGGER - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = pg_catalog, public, pg_temp - AS $$ - DECLARE revision RECORD; - BEGIN - -- Order current and prior attribution together, as for live runs. - FOR revision IN - SELECT subject_type, subject_id - FROM ( - SELECT triggerer_actor_type, triggerer_user_id::TEXT, - triggerer_team_id::TEXT, usage_team_id - FROM (VALUES ( - NEW.triggerer_actor_type, NEW.triggerer_user_id, - NEW.triggerer_team_id, NEW.usage_team_id - )) AS current_row( - triggerer_actor_type, triggerer_user_id, - triggerer_team_id, usage_team_id - ) - WHERE TG_OP <> 'DELETE' - UNION ALL - SELECT triggerer_actor_type, triggerer_user_id::TEXT, - triggerer_team_id::TEXT, usage_team_id - FROM (VALUES ( - OLD.triggerer_actor_type, OLD.triggerer_user_id, - OLD.triggerer_team_id, OLD.usage_team_id - )) AS prior_row( - triggerer_actor_type, triggerer_user_id, - triggerer_team_id, usage_team_id - ) - WHERE TG_OP <> 'INSERT' - ) AS row_value - CROSS JOIN LATERAL (VALUES - ('team', row_value.triggerer_team_id), - ('team', CASE - WHEN row_value.usage_team_id = 'control-plane-admin-ui' THEN NULL - ELSE row_value.usage_team_id - END), - ('user', CASE - WHEN row_value.triggerer_actor_type = 'user' - THEN row_value.triggerer_user_id - END) - ) AS revision_subjects(subject_type, subject_id) - WHERE subject_id IS NOT NULL AND subject_id <> '' - ORDER BY subject_type, subject_id - LOOP - PERFORM authorization_bump_subject_revision( - revision.subject_type, revision.subject_id - ); - END LOOP; - RETURN COALESCE(NEW, OLD); - END; - $$; - - CREATE OR REPLACE FUNCTION authorization_bump_workflow_approval_revision() - RETURNS TRIGGER - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = pg_catalog, public, pg_temp - AS $$ - BEGIN - IF TG_OP <> 'DELETE' THEN - PERFORM authorization_bump_subject_revision('user', NEW.target_user_id::TEXT); - PERFORM authorization_bump_subject_revision('team', NEW.target_team_id::TEXT); - END IF; - IF TG_OP <> 'INSERT' THEN - PERFORM authorization_bump_subject_revision('user', OLD.target_user_id::TEXT); - PERFORM authorization_bump_subject_revision('team', OLD.target_team_id::TEXT); - END IF; - RETURN COALESCE(NEW, OLD); - END; - $$; - - CREATE OR REPLACE FUNCTION authorization_bump_notification_revision() - RETURNS TRIGGER - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = pg_catalog, public, pg_temp - AS $$ - BEGIN - -- Catalog producers, hydration and the live resolver read only these - -- columns (catalogProducerSql.ts:313-336, catalogHydrationSql.ts:599-648, - -- databaseAuthorityCandidates.ts:182-198). Delivery claims, acks and - -- terminal writes change only status, attempts, next_attempt_at and - -- delivered_medium (notificationDeliveryQueueService.ts:315-321,698-704, - -- 725-776, notificationDeliveryTerminalService.ts:47-50,74-78, - -- notificationAckService.ts:21-30), so they need no revision bump. Same - -- in-function TG_OP idiom as the run guard above. - IF TG_OP = 'UPDATE' AND ( - OLD.id, OLD.event_type, OLD.audience, OLD.expires_at - ) IS NOT DISTINCT FROM ( - NEW.id, NEW.event_type, NEW.audience, NEW.expires_at - ) THEN - RETURN NEW; - END IF; - IF TG_OP <> 'DELETE' THEN - PERFORM authorization_bump_subject_revision('user', NEW.audience->>'userId'); - PERFORM authorization_bump_subject_revision('team', NEW.audience->>'teamId'); - END IF; - IF TG_OP <> 'INSERT' THEN - PERFORM authorization_bump_subject_revision('user', OLD.audience->>'userId'); - PERFORM authorization_bump_subject_revision('team', OLD.audience->>'teamId'); - END IF; - RETURN COALESCE(NEW, OLD); - END; - $$; - - CREATE OR REPLACE FUNCTION authorization_bump_gfs_resource_component( - target_resource_id UUID - ) - RETURNS VOID - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = pg_catalog, public, pg_temp - AS $$ - DECLARE target_environment_id TEXT; - BEGIN - SELECT environment_id - INTO target_environment_id - FROM authorization_catalog_environment - WHERE singleton = TRUE; - IF target_environment_id IS NULL THEN - RAISE EXCEPTION 'catalog environment is not configured'; - END IF; - IF target_resource_id IS NULL THEN - RETURN; - END IF; - INSERT INTO authorization_resource_revisions( - environment_id, resource_type, resource_id, revision, updated_at - ) VALUES ( - target_environment_id, 'gfs_resource', target_resource_id::TEXT, 1, clock_timestamp() - ) - ON CONFLICT (environment_id, resource_type, resource_id) DO UPDATE - SET revision = authorization_resource_revisions.revision + 1, - updated_at = clock_timestamp(); - END; - $$; - - CREATE OR REPLACE FUNCTION authorization_bump_gfs_authority_revision() - RETURNS TRIGGER - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = pg_catalog, public, pg_temp - AS $$ - BEGIN - IF TG_OP <> 'DELETE' THEN - PERFORM authorization_bump_gfs_resource_component(NEW.resource_id); - PERFORM authorization_bump_subject_revision(NEW.subject_type, NEW.subject_id); - END IF; - IF TG_OP = 'DELETE' OR ( - TG_OP = 'UPDATE' AND ( - OLD.subject_type IS DISTINCT FROM NEW.subject_type - OR OLD.subject_id IS DISTINCT FROM NEW.subject_id - OR OLD.resource_id IS DISTINCT FROM NEW.resource_id - ) - ) THEN - PERFORM authorization_bump_gfs_resource_component(OLD.resource_id); - PERFORM authorization_bump_subject_revision(OLD.subject_type, OLD.subject_id); - END IF; - RETURN COALESCE(NEW, OLD); - END; - $$; - - CREATE OR REPLACE FUNCTION authorization_bump_gfs_resource_subjects(target_resource_id UUID) - RETURNS VOID - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = pg_catalog, public, pg_temp - AS $$ - DECLARE subject RECORD; - BEGIN - FOR subject IN - SELECT grant_row.subject_type, grant_row.subject_id - FROM gfs_grants grant_row - WHERE grant_row.resource_id = target_resource_id - UNION - SELECT share_row.subject_type, share_row.subject_id - FROM gfs_shares share_row - WHERE share_row.resource_id = target_resource_id - ORDER BY subject_type, subject_id - LOOP - PERFORM authorization_bump_subject_revision(subject.subject_type, subject.subject_id); - END LOOP; - END; - $$; - - CREATE OR REPLACE FUNCTION authorization_bump_gfs_resource_revision() - RETURNS TRIGGER - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = pg_catalog, public, pg_temp - AS $$ - BEGIN - -- Catalog producers, hydration and the live resolver read only this - -- resource's identity, name, parent and tombstone - -- (catalogProducerSql.ts:340-352, catalogHydrationSql.ts:654-655,692,697-701, - -- databaseAuthorityCandidates.ts:142-158). A content save rewrites only - -- version, bytes, blob_key, content_sha256 and updated_at - -- (gfs-controller/src/db/writeStore.ts:400-405) and a descendant rename - -- rewrites path_cache, so neither needs a revision bump. Same in-function - -- TG_OP idiom as the run guard above. - IF TG_OP = 'UPDATE' AND ( - OLD.resource_id, OLD.name, OLD.parent_resource_id, OLD.deleted_at - ) IS NOT DISTINCT FROM ( - NEW.resource_id, NEW.name, NEW.parent_resource_id, NEW.deleted_at - ) THEN - RETURN NEW; - END IF; - IF TG_OP <> 'DELETE' THEN - PERFORM authorization_bump_gfs_resource_component(NEW.resource_id); - PERFORM authorization_bump_gfs_resource_subjects(NEW.resource_id); - END IF; - IF TG_OP <> 'INSERT' AND (TG_OP = 'DELETE' OR OLD.resource_id IS DISTINCT FROM NEW.resource_id) - THEN - PERFORM authorization_bump_gfs_resource_component(OLD.resource_id); - PERFORM authorization_bump_gfs_resource_subjects(OLD.resource_id); - END IF; - RETURN COALESCE(NEW, OLD); - END; - $$; - - -- CREATE OR REPLACE takes ShareRowExclusiveLock (readers continue); a - -- DROP of an existing trigger would take AccessExclusiveLock. - CREATE OR REPLACE TRIGGER users_authorization_revision - AFTER INSERT OR UPDATE OR DELETE ON users - FOR EACH ROW EXECUTE FUNCTION authorization_bump_user_row_revision(); - CREATE OR REPLACE TRIGGER teams_authorization_revision - AFTER INSERT OR UPDATE OR DELETE ON teams - FOR EACH ROW EXECUTE FUNCTION authorization_bump_team_row_revision(); - CREATE OR REPLACE TRIGGER workflow_runs_authorization_revision - AFTER INSERT OR UPDATE OR DELETE ON workflow_runs - FOR EACH ROW EXECUTE FUNCTION authorization_bump_workflow_run_revision(); - CREATE OR REPLACE TRIGGER workflow_approval_requests_authorization_revision - AFTER INSERT OR UPDATE OR DELETE ON workflow_approval_requests - FOR EACH ROW EXECUTE FUNCTION authorization_bump_workflow_approval_revision(); - CREATE OR REPLACE TRIGGER workflow_recipe_allowed_teams_authorization_revision - AFTER INSERT OR UPDATE OR DELETE ON workflow_recipe_allowed_teams - FOR EACH ROW EXECUTE FUNCTION authorization_bump_team_grant_revision(); - CREATE OR REPLACE TRIGGER workflow_approval_medium_accounts_authorization_revision - AFTER INSERT OR UPDATE OR DELETE ON workflow_approval_medium_accounts - FOR EACH ROW EXECUTE FUNCTION authorization_bump_user_grant_revision(); - CREATE OR REPLACE TRIGGER workflow_runs_audit_authorization_revision - AFTER INSERT OR UPDATE OR DELETE ON workflow_runs_audit - FOR EACH ROW EXECUTE FUNCTION authorization_bump_workflow_run_audit_revision(); - CREATE OR REPLACE TRIGGER notification_deliveries_authorization_revision - AFTER INSERT OR UPDATE OR DELETE ON notification_deliveries - FOR EACH ROW EXECUTE FUNCTION authorization_bump_notification_revision(); - CREATE OR REPLACE TRIGGER gfs_grants_authorization_revision - AFTER INSERT OR UPDATE OR DELETE ON gfs_grants - FOR EACH ROW EXECUTE FUNCTION authorization_bump_gfs_authority_revision(); - CREATE OR REPLACE TRIGGER gfs_shares_authorization_revision - AFTER INSERT OR UPDATE OR DELETE ON gfs_shares - FOR EACH ROW EXECUTE FUNCTION authorization_bump_gfs_authority_revision(); - CREATE OR REPLACE TRIGGER gfs_resources_authorization_revision - AFTER INSERT OR UPDATE OR DELETE ON gfs_resources - FOR EACH ROW EXECUTE FUNCTION authorization_bump_gfs_resource_revision(); - - REVOKE ALL ON TABLE authorization_catalog_environment FROM PUBLIC; - GRANT SELECT ON TABLE authorization_catalog_environment TO control_api_runtime; - REVOKE ALL ON FUNCTION authorization_bump_subject_revision(TEXT, TEXT) FROM PUBLIC; - REVOKE ALL ON FUNCTION authorization_prelock_workflow_run_revisions(UUID[]) FROM PUBLIC; - GRANT EXECUTE ON FUNCTION authorization_prelock_workflow_run_revisions(UUID[]) - TO control_api_runtime; - REVOKE ALL ON FUNCTION authorization_prelock_workflow_approval_revisions(UUID[]) FROM PUBLIC; - GRANT EXECUTE ON FUNCTION authorization_prelock_workflow_approval_revisions(UUID[]) - TO control_api_runtime; - -- The stuck-run sweep runs on the Workflow Recipes pool, so that runtime - -- role may hold the batch's approval subjects before failing the runs. - GRANT EXECUTE ON FUNCTION authorization_prelock_workflow_approval_revisions(UUID[]) - TO workflow_recipes_runtime; - REVOKE ALL ON FUNCTION authorization_prelock_workflow_approval_trigger_revisions(UUID) - FROM PUBLIC; - GRANT EXECUTE ON FUNCTION authorization_prelock_workflow_approval_trigger_revisions(UUID) - TO control_api_runtime; - -- Recipe deletion runs on the Workflow Recipes pool, so only that runtime - -- role may lock its grant rows and bump its subjects first. - REVOKE ALL ON FUNCTION authorization_prelock_workflow_recipe_policy_revisions(TEXT, TEXT) - FROM PUBLIC; - GRANT EXECUTE ON FUNCTION authorization_prelock_workflow_recipe_policy_revisions(TEXT, TEXT) - TO workflow_recipes_runtime; - REVOKE ALL ON FUNCTION authorization_bump_user_row_revision() FROM PUBLIC; - REVOKE ALL ON FUNCTION authorization_bump_team_row_revision() FROM PUBLIC; - REVOKE ALL ON FUNCTION authorization_bump_workflow_run_revision() FROM PUBLIC; - REVOKE ALL ON FUNCTION authorization_bump_workflow_run_audit_revision() FROM PUBLIC; - REVOKE ALL ON FUNCTION authorization_bump_workflow_approval_revision() FROM PUBLIC; - REVOKE ALL ON FUNCTION authorization_bump_notification_revision() FROM PUBLIC; - REVOKE ALL ON FUNCTION authorization_bump_gfs_resource_component(UUID) FROM PUBLIC; - REVOKE ALL ON FUNCTION authorization_bump_gfs_authority_revision() FROM PUBLIC; - REVOKE ALL ON FUNCTION authorization_bump_gfs_resource_subjects(UUID) FROM PUBLIC; - REVOKE ALL ON FUNCTION authorization_bump_gfs_resource_revision() FROM PUBLIC; - `) - await db.query( - `INSERT INTO authorization_catalog_environment(singleton, environment_id) - VALUES(TRUE, $1) - ON CONFLICT (singleton) DO UPDATE SET environment_id = EXCLUDED.environment_id`, - [canonicalEnvironmentId()] - ) - // Ship coverage and the definer atomically with the 0134 receipt. The DDL - // already fences writers; a bounded wait aborts the entire unit on contention. - // Rerunning initDb after rollback preserves existing revisions and fills gaps. - await db.query(` - DO $$ - DECLARE - previous_lock_timeout TEXT := current_setting('lock_timeout'); - lock_wait_ms INTEGER := floor( - extract(epoch FROM current_setting('deadlock_timeout')::interval) * 500 - ); - BEGIN - IF lock_wait_ms < 1 THEN - RAISE EXCEPTION '0134 revision backfill requires deadlock_timeout >= 2ms'; - END IF; - PERFORM set_config('lock_timeout', LEAST(lock_wait_ms, 500)::TEXT, true); - - -- Teams before users, all lifecycle states. Never reset a bumped revision. - INSERT INTO public.authorization_team_revisions(team_id, revision, updated_at) - SELECT id, 1, clock_timestamp() FROM public.teams ORDER BY id - ON CONFLICT (team_id) DO NOTHING; - INSERT INTO public.authorization_user_revisions(user_id, revision, updated_at) - SELECT id, 1, clock_timestamp() FROM public.users ORDER BY id - ON CONFLICT (user_id) DO NOTHING; - - -- Catch gfs_resources inserted between the early backfill and trigger creation. - INSERT INTO public.authorization_resource_revisions( - environment_id, resource_type, resource_id, revision, updated_at - ) - SELECT env.environment_id, 'gfs_resource', g.resource_id::text, 1, clock_timestamp() - FROM public.gfs_resources g - CROSS JOIN public.authorization_catalog_environment env - WHERE env.singleton = TRUE - ON CONFLICT (environment_id, resource_type, resource_id) DO NOTHING; - - IF EXISTS (SELECT 1 FROM public.users u LEFT JOIN public.authorization_user_revisions r - ON r.user_id = u.id WHERE r.user_id IS NULL) - OR EXISTS (SELECT 1 FROM public.teams t LEFT JOIN public.authorization_team_revisions r - ON r.team_id = t.id WHERE r.team_id IS NULL) - OR EXISTS (SELECT 1 FROM public.gfs_resources g - LEFT JOIN public.authorization_resource_revisions r - ON r.environment_id = (SELECT environment_id FROM public.authorization_catalog_environment WHERE singleton) - AND r.resource_type = 'gfs_resource' - AND r.resource_id = g.resource_id::text - WHERE r.resource_id IS NULL) THEN - RAISE EXCEPTION '0134 revision backfill left uncovered users, teams, or gfs resources'; - END IF; - IF NOT EXISTS ( - SELECT 1 FROM pg_proc p - WHERE p.oid = 'public.authorization_prelock_user_retirement_revisions(uuid[])'::regprocedure - AND p.prosecdef - AND p.proowner = (SELECT relowner FROM pg_class - WHERE oid = 'public.authorization_user_revisions'::regclass) - AND p.proowner = (SELECT relowner FROM pg_class - WHERE oid = 'public.authorization_team_revisions'::regclass) - AND p.proconfig = ARRAY['search_path=pg_catalog, public, pg_temp'] - AND NOT EXISTS (SELECT 1 FROM aclexplode(COALESCE(p.proacl, acldefault('f', p.proowner))) - WHERE grantee = 0) - AND has_function_privilege('control_api_runtime', p.oid, 'EXECUTE') - ) OR NOT has_table_privilege('control_api_runtime', 'public.authorization_user_revisions', 'SELECT') - OR NOT has_table_privilege('control_api_runtime', 'public.authorization_team_revisions', 'SELECT') - OR has_table_privilege('control_api_runtime', 'public.authorization_user_revisions', 'INSERT, UPDATE, DELETE, TRUNCATE') - OR has_table_privilege('control_api_runtime', 'public.authorization_team_revisions', 'INSERT, UPDATE, DELETE, TRUNCATE') THEN - RAISE EXCEPTION '0134 user retirement prelock owner or runtime privileges are invalid'; - END IF; - PERFORM set_config('lock_timeout', previous_lock_timeout, true); - END; - $$; - `) -} - -async function applyAuthorizationRevisionFunctions(db: DbClient): Promise { - await db.query(` - CREATE OR REPLACE FUNCTION authorization_bump_user_revision(target_user_id UUID) - RETURNS VOID - LANGUAGE SQL - SECURITY DEFINER - SET search_path = pg_catalog, public, pg_temp - AS $$ - INSERT INTO authorization_user_revisions(user_id, revision, updated_at) - SELECT users.id, 1, clock_timestamp() - FROM users - WHERE users.id = target_user_id - ON CONFLICT (user_id) DO UPDATE - SET revision = authorization_user_revisions.revision + 1, - updated_at = clock_timestamp(); - $$; - - CREATE OR REPLACE FUNCTION authorization_bump_team_revision(target_team_id UUID) - RETURNS VOID - LANGUAGE SQL - SECURITY DEFINER - SET search_path = pg_catalog, public, pg_temp - AS $$ - INSERT INTO authorization_team_revisions(team_id, revision, updated_at) - SELECT teams.id, 1, clock_timestamp() - FROM teams - WHERE teams.id = target_team_id - ON CONFLICT (team_id) DO UPDATE - SET revision = authorization_team_revisions.revision + 1, - updated_at = clock_timestamp(); - $$; - - CREATE OR REPLACE FUNCTION authorization_bump_resource_revision( - target_environment_id TEXT, - target_resource_type TEXT, - target_resource_id TEXT - ) - RETURNS VOID - LANGUAGE SQL - SECURITY DEFINER - SET search_path = pg_catalog, public, pg_temp - AS $$ - INSERT INTO authorization_resource_revisions( - environment_id, resource_type, resource_id, revision, updated_at - ) - VALUES(target_environment_id, target_resource_type, target_resource_id, 1, clock_timestamp()) - ON CONFLICT (environment_id, resource_type, resource_id) DO UPDATE - SET revision = authorization_resource_revisions.revision + 1, - updated_at = clock_timestamp(); - $$; - - REVOKE ALL ON FUNCTION authorization_bump_user_revision(UUID) FROM PUBLIC; - REVOKE ALL ON FUNCTION authorization_bump_team_revision(UUID) FROM PUBLIC; - REVOKE ALL ON FUNCTION authorization_bump_resource_revision(TEXT, TEXT, TEXT) FROM PUBLIC; - -- Triggers and scoped definer functions call these; runtime calls stay denied. - -- Explicit revocation also repairs grants retained by CREATE OR REPLACE. - REVOKE ALL ON FUNCTION authorization_bump_user_revision(UUID) FROM control_api_runtime; - REVOKE ALL ON FUNCTION authorization_bump_team_revision(UUID) FROM control_api_runtime; - REVOKE ALL ON FUNCTION authorization_bump_resource_revision(TEXT, TEXT, TEXT) - FROM control_api_runtime; - `) -} - -async function applyAuthorizationRevisionTriggers(db: DbClient): Promise { - await db.query(` - CREATE OR REPLACE FUNCTION authorization_bump_team_membership_revision() - RETURNS TRIGGER - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = pg_catalog, public, pg_temp - AS $$ - DECLARE revision RECORD; - BEGIN - FOR revision IN - SELECT subject_type, subject_id - FROM ( - SELECT NEW.team_id AS team_id, NEW.user_id AS user_id - WHERE TG_OP <> 'DELETE' - UNION ALL - SELECT OLD.team_id, OLD.user_id - WHERE TG_OP <> 'INSERT' AND ( - TG_OP = 'DELETE' OR OLD.user_id IS DISTINCT FROM NEW.user_id - OR OLD.team_id IS DISTINCT FROM NEW.team_id - ) - ) AS row_value - CROSS JOIN LATERAL (VALUES - ('team', row_value.team_id), ('user', row_value.user_id) - ) AS subjects(subject_type, subject_id) - ORDER BY subject_type, subject_id - LOOP - IF revision.subject_type = 'team' THEN - PERFORM authorization_bump_team_revision(revision.subject_id); - ELSE - PERFORM authorization_bump_user_revision(revision.subject_id); - END IF; - END LOOP; - RETURN COALESCE(NEW, OLD); - END; - $$; - - CREATE OR REPLACE FUNCTION authorization_bump_user_grant_revision() - RETURNS TRIGGER - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = pg_catalog, public, pg_temp - AS $$ - BEGIN - IF TG_OP <> 'DELETE' THEN - PERFORM authorization_bump_user_revision(NEW.user_id); - END IF; - IF TG_OP <> 'INSERT' AND ( - TG_OP = 'DELETE' OR OLD.user_id IS DISTINCT FROM NEW.user_id - ) THEN - PERFORM authorization_bump_user_revision(OLD.user_id); - END IF; - RETURN COALESCE(NEW, OLD); - END; - $$; - - CREATE OR REPLACE FUNCTION authorization_bump_team_grant_revision() - RETURNS TRIGGER - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = pg_catalog, public, pg_temp - AS $$ - BEGIN - IF TG_OP <> 'DELETE' THEN - PERFORM authorization_bump_team_revision(NEW.team_id); - END IF; - IF TG_OP <> 'INSERT' AND ( - TG_OP = 'DELETE' OR OLD.team_id IS DISTINCT FROM NEW.team_id - ) THEN - PERFORM authorization_bump_team_revision(OLD.team_id); - END IF; - RETURN COALESCE(NEW, OLD); - END; - $$; - - CREATE OR REPLACE FUNCTION authorization_bump_operational_resource_revision() - RETURNS TRIGGER - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = pg_catalog, public, pg_temp - AS $$ - BEGIN - IF TG_OP <> 'DELETE' THEN - PERFORM authorization_bump_resource_revision( - NEW.environment_id, NEW.resource_type, NEW.logical_id - ); - END IF; - IF TG_OP <> 'INSERT' AND ( - TG_OP = 'DELETE' - OR OLD.environment_id IS DISTINCT FROM NEW.environment_id - OR OLD.resource_type IS DISTINCT FROM NEW.resource_type - OR OLD.logical_id IS DISTINCT FROM NEW.logical_id - ) THEN - PERFORM authorization_bump_resource_revision( - OLD.environment_id, OLD.resource_type, OLD.logical_id - ); - END IF; - RETURN COALESCE(NEW, OLD); - END; - $$; - - CREATE OR REPLACE FUNCTION authorization_bump_operational_relationship_revision() - RETURNS TRIGGER - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = pg_catalog, public, pg_temp - AS $$ - BEGIN - IF TG_OP <> 'DELETE' THEN - PERFORM authorization_bump_resource_revision( - NEW.environment_id, NEW.source_type, NEW.source_id - ); - PERFORM authorization_bump_resource_revision( - NEW.environment_id, NEW.target_type, NEW.target_id - ); - END IF; - IF TG_OP <> 'INSERT' THEN - PERFORM authorization_bump_resource_revision( - OLD.environment_id, OLD.source_type, OLD.source_id - ); - PERFORM authorization_bump_resource_revision( - OLD.environment_id, OLD.target_type, OLD.target_id - ); - END IF; - RETURN COALESCE(NEW, OLD); - END; - $$; - - DO $$ - DECLARE trigger_spec RECORD; - BEGIN - FOR trigger_spec IN - -- The audit writer is installed by 0134 after this 0131 bootstrap. - -- Keep its repair spec here, but skip it until that function exists. - SELECT specs.* FROM (VALUES - ('team_members', 'team_members_authorization_revision', - 'authorization_bump_team_membership_revision'), - ('user_contexts', 'user_contexts_authorization_revision', - 'authorization_bump_user_grant_revision'), - ('user_agents', 'user_agents_authorization_revision', - 'authorization_bump_user_grant_revision'), - ('user_workflow_triggers', 'user_workflow_authorization_revision', - 'authorization_bump_user_grant_revision'), - ('team_contexts', 'team_contexts_authorization_revision', - 'authorization_bump_team_grant_revision'), - ('team_agents', 'team_agents_authorization_revision', - 'authorization_bump_team_grant_revision'), - ('team_workflow_triggers', 'team_workflow_authorization_revision', - 'authorization_bump_team_grant_revision'), - ('workflow_recipe_allowed_teams', 'workflow_recipe_allowed_teams_authorization_revision', - 'authorization_bump_team_grant_revision'), - ('workflow_approval_medium_accounts', - 'workflow_approval_medium_accounts_authorization_revision', - 'authorization_bump_user_grant_revision'), - ('workflow_runs_audit', 'workflow_runs_audit_authorization_revision', - 'authorization_bump_workflow_run_audit_revision'), - ('operational_resource_index', 'operational_resource_authorization_revision', - 'authorization_bump_operational_resource_revision'), - ('operational_resource_relationships', - 'operational_relationship_authorization_revision', - 'authorization_bump_operational_relationship_revision') - ) AS specs(table_name, trigger_name, function_name) - WHERE to_regprocedure(format('public.%I()', specs.function_name)) IS NOT NULL - LOOP - IF NOT EXISTS ( - SELECT 1 FROM pg_trigger WHERE tgname = trigger_spec.trigger_name - ) THEN - EXECUTE format( - 'CREATE TRIGGER %I AFTER INSERT OR UPDATE OR DELETE ON %I ' - || 'FOR EACH ROW EXECUTE FUNCTION %I()', - trigger_spec.trigger_name, - trigger_spec.table_name, - trigger_spec.function_name - ); - END IF; - END LOOP; - END; - $$; - - REVOKE ALL ON FUNCTION authorization_bump_team_membership_revision() FROM PUBLIC; - REVOKE ALL ON FUNCTION authorization_bump_user_grant_revision() FROM PUBLIC; - REVOKE ALL ON FUNCTION authorization_bump_team_grant_revision() FROM PUBLIC; - REVOKE ALL ON FUNCTION authorization_bump_operational_resource_revision() FROM PUBLIC; - REVOKE ALL ON FUNCTION authorization_bump_operational_relationship_revision() FROM PUBLIC; - `) -} - export async function backfillLegacyPasswordSecurityEpochs(db: DbClient): Promise { await db.query(` INSERT INTO external_user_session_security_epochs(user_id, valid_after, reason, updated_at) @@ -1439,36 +92,16 @@ async function applyUserAccessRuntimePrivileges(db: DbClient): Promise { REVOKE ALL PRIVILEGES ON TABLE external_user_sessions, external_user_session_security_epochs, - external_v1_session_revocations, - authorization_user_revisions, - authorization_team_revisions, - authorization_resource_revisions, - operational_catalog_source_state, - operational_resource_index, - operational_resource_relationships, - operational_resource_index_staging, - operational_relationships_staging + external_v1_session_revocations FROM PUBLIC, control_api_runtime, trace_maintenance_runtime, workflow_recipes_runtime; GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE external_user_sessions, - external_user_session_security_epochs, - operational_catalog_source_state, - operational_resource_index, - operational_resource_relationships, - operational_resource_index_staging, - operational_relationships_staging + external_user_session_security_epochs TO control_api_runtime; -- Append-only ledger: the runtime records a revocation but never rewrites or -- erases one. The user-deletion cascade runs as the table owner. GRANT SELECT, INSERT ON TABLE external_v1_session_revocations TO control_api_runtime; - - -- Revision rows are written only by the SECURITY DEFINER bump functions. - GRANT SELECT ON TABLE - authorization_user_revisions, - authorization_team_revisions, - authorization_resource_revisions - TO control_api_runtime; `) } diff --git a/control-api/src/services/adminAuthService.ts b/control-api/src/services/adminAuthService.ts index c1319cb113..4593882690 100644 --- a/control-api/src/services/adminAuthService.ts +++ b/control-api/src/services/adminAuthService.ts @@ -1168,68 +1168,6 @@ export const CONTROL_ADMIN_REPLACED_REASON = 'control_admin_replaced' const TEAM_ROLE_RANK = (column: string) => `(CASE ${column} WHEN 'admin' THEN 3 WHEN 'inviter' THEN 2 ELSE 1 END)` -export class HandoverIdentityChangedError extends Error { - readonly code = 'handover_identity_changed' - - constructor(phase: 'after-prelock' | 'pinned-sender-lookup' | 'before-membership-copy') { - super(`handover identity changed: ${phase}`) - this.name = 'HandoverIdentityChangedError' - } -} - -type HandoverDesktopIdentity = { id: string; email: string | null; lifecycle_state: string } -type HandoverLinkIdentity = HandoverDesktopIdentity & { - link_id: string - generation: number - state: string - row_version: string -} - -// Discovery never locks desktop users. UNIQUE(email) is case-sensitive, so -// every active case-insensitive receiver belongs to the batch, not just one. -// Keep the complete latest-link mapping: even a same-user version change must -// invalidate the snapshot that defined the ordered prelock. -async function discoverHandover(db: DbClient, inviterId: string, receiverEmail: string) { - const inviter = ( - await db.query('SELECT id::text AS id, email, status FROM control_admin_users WHERE id=$1', [ - inviterId, - ]) - ).rows[0] as { id: string; email: string | null; status: string } | undefined - const links = ( - await db.query( - `SELECT link.id::text AS link_id, link.generation, link.state, link.row_version, - u.id::text AS id, u.email, u.lifecycle_state - FROM gfs_desktop_operator_links link JOIN users u ON u.id=link.user_id - WHERE link.control_admin_id=$1::uuid - ORDER BY link.generation DESC, link.created_at DESC LIMIT 1`, - [inviterId] - ) - ).rows as HandoverLinkIdentity[] - let sender: HandoverDesktopIdentity | undefined = - links[0]?.lifecycle_state === 'active' ? links[0] : undefined - if (!links.length && inviter?.email) { - sender = ( - await db.query( - `SELECT u.id::text AS id, u.email, u.lifecycle_state - FROM users u WHERE lower(u.email)=lower($1) AND u.lifecycle_state='active' - AND NOT EXISTS (SELECT 1 FROM gfs_desktop_operator_links l - WHERE l.user_id=u.id AND l.control_admin_id<>$2::uuid) - ORDER BY u.id LIMIT 1`, - [inviter.email, inviterId] - ) - ).rows[0] as HandoverDesktopIdentity | undefined - } - const receivers = ( - await db.query( - `SELECT id::text AS id, email, lifecycle_state FROM users - WHERE lower(email)=lower($1) AND lifecycle_state='active' - AND id IS DISTINCT FROM $2::uuid ORDER BY id`, - [receiverEmail, sender?.id ?? null] - ) - ).rows as HandoverDesktopIdentity[] - return { inviter, links, sender: sender ?? null, receivers } -} - /** * Hand the inviter's access to the invitee and retire the inviter, inside the * transaction that created the invitee's control admin. @@ -1267,26 +1205,6 @@ async function replaceInviterInTransaction( return null } - // Lock the complete sender/receiver union before any membership copy or - // desktop-user lock. Repeated retirement prelocks then consume a held subset. - const newAdminEmail = input.newAdmin.email ?? '' - const handover = await discoverHandover(db, inviter.id, newAdminEmail) - const principalIds = [ - ...new Set([ - ...(handover.sender ? [handover.sender.id] : []), - ...handover.receivers.map(receiver => receiver.id), - ]), - ] - await db.query('SELECT public.authorization_prelock_user_retirement_revisions($1::uuid[])', [ - principalIds, - ]) - if ( - JSON.stringify(handover) !== - JSON.stringify(await discoverHandover(db, inviter.id, newAdminEmail)) - ) { - throw new HandoverIdentityChangedError('after-prelock') - } - // The most recent operator link names this admin's desktop user, revoked or // not. An admin email change never updates users.email, and a revoked link // must not fall through to that email. Email is only the mapping for an @@ -1297,10 +1215,10 @@ async function replaceInviterInTransaction( FROM gfs_desktop_operator_links link JOIN users u ON u.id = link.user_id WHERE link.control_admin_id = $1::uuid - AND u.id = $2::uuid ORDER BY link.generation DESC, link.created_at DESC - LIMIT 1`, - [inviter.id, handover.links[0]?.id ?? null] + LIMIT 1 + FOR UPDATE OF u`, + [inviter.id] ) const linked = linkHistory.rows[0] as | { id: string; email: string | null; lifecycle_state: string } @@ -1320,18 +1238,17 @@ async function replaceInviterInTransaction( WHERE l.user_id = u.id AND l.control_admin_id <> $2::uuid ) - AND u.id = $3::uuid ORDER BY u.id - LIMIT 1`, - [inviter.email, inviter.id, handover.sender?.id ?? null] + LIMIT 1 + FOR UPDATE OF u`, + [inviter.email, inviter.id] ) inviterDesktop = emailMatch.rows[0] as { id: string; email: string | null } | undefined } const inviterDesktopUserId = inviterDesktop?.id ?? null const inviterDesktopEmail = inviterDesktop?.email ?? null - if (inviterDesktopUserId !== (handover.sender?.id ?? null)) { - throw new HandoverIdentityChangedError('pinned-sender-lookup') - } + // completeControlAdminInvitation always inserts the normalized invitation email. + const newAdminEmail = input.newAdmin.email ?? '' if (inviterDesktopUserId) { // The invitee is the inviter's own desktop user. Copying roles onto that @@ -1380,22 +1297,13 @@ async function replaceInviterInTransaction( } if (hasRoles && !hasReceiver) return 'replace_inviter_receiver_missing' - // Close identity drift after the initial revalidation, including link row - // versions. No copy may run on a changed batch; the caller rolls back admin - // creation and invitation acceptance as well. Never retry or enlarge it. - const verified = await discoverHandover(db, inviter.id, newAdminEmail) - if (JSON.stringify(handover) !== JSON.stringify(verified)) { - throw new HandoverIdentityChangedError('before-membership-copy') - } - const receiverIds = verified.receivers.map(receiver => receiver.id) - // (1a) The invitee already has a desktop user: raise its memberships. await db.query( `INSERT INTO team_members(team_id, user_id, role, status) SELECT source.team_id, target.id, source.role, 'active' FROM team_members source JOIN users target - ON target.id = ANY($2::uuid[]) + ON lower(target.email) = lower($2) AND target.lifecycle_state = 'active' AND target.id <> $1::uuid WHERE source.user_id = $1::uuid @@ -1409,7 +1317,7 @@ async function replaceInviterInTransaction( END, status = 'active', updated_at = NOW()`, - [inviterDesktopUserId, receiverIds] + [inviterDesktopUserId, newAdminEmail] ) // (1b) The desktop invitation is accepted after this transaction commits // (POST /admin/auth/control-admin-invitations/complete) and upserts @@ -1444,7 +1352,7 @@ async function replaceInviterInTransaction( ON source.user_id = $1::uuid AND source.status = 'active' LEFT JOIN users target - ON target.id = ANY($3::uuid[]) + ON lower(target.email) = lower(i.email) AND target.lifecycle_state = 'active' LEFT JOIN team_members held ON held.user_id = target.id @@ -1459,7 +1367,7 @@ async function replaceInviterInTransaction( THEN EXCLUDED.role ELSE invitation_teams.role END`, - [inviterDesktopUserId, newAdminEmail, receiverIds] + [inviterDesktopUserId, newAdminEmail] ) // (2) Retire, never hard-delete, the inviter's desktop identity. diff --git a/control-api/src/services/directory/invitationDeliverySchema.ts b/control-api/src/services/directory/invitationDeliverySchema.ts deleted file mode 100644 index 4caedf329f..0000000000 --- a/control-api/src/services/directory/invitationDeliverySchema.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { DbClient } from '../../db.js' - -export async function applyInvitationDeliveryCommandFoundation(db: DbClient): Promise { - await db.query(` - CREATE TABLE IF NOT EXISTS invitation_delivery_commands ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - invitation_id UUID NOT NULL REFERENCES invitations(id) ON DELETE CASCADE, - authorized_by UUID REFERENCES users(id) ON DELETE SET NULL, - delivery_kind TEXT NOT NULL CHECK (delivery_kind IN ('create', 'resend')), - status TEXT NOT NULL DEFAULT 'authorized' - CHECK (status IN ('authorized', 'delivered', 'failed', 'cancelled')), - authorized_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - completed_at TIMESTAMPTZ, - failure_code TEXT, - CHECK ( - (status = 'authorized' AND completed_at IS NULL) - OR (status <> 'authorized' AND completed_at IS NOT NULL) - ) - ); - - CREATE INDEX IF NOT EXISTS invitation_delivery_commands_authorized_idx - ON invitation_delivery_commands (authorized_at, id) - WHERE status = 'authorized'; - CREATE INDEX IF NOT EXISTS invitation_delivery_commands_invitation_idx - ON invitation_delivery_commands (invitation_id, authorized_at DESC); - - REVOKE ALL PRIVILEGES ON TABLE invitation_delivery_commands - FROM PUBLIC, control_api_runtime, trace_maintenance_runtime, workflow_recipes_runtime; - GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE invitation_delivery_commands - TO control_api_runtime; - `) -} diff --git a/control-api/src/services/directory/membership.ts b/control-api/src/services/directory/membership.ts index 52300ba70d..69f1ceadf9 100644 --- a/control-api/src/services/directory/membership.ts +++ b/control-api/src/services/directory/membership.ts @@ -1545,9 +1545,6 @@ export async function deleteManagedUserForUser( const principalIds = [...new Set([normalizedManagerUserId, normalizedTargetUserId])] .filter(id => USER_LOCK_UUID_PATTERN.test(id)) .map(id => id.toLowerCase()) - await db.query('SELECT public.authorization_prelock_user_retirement_revisions($1::uuid[])', [ - principalIds, - ]) await db.query( `SELECT id FROM users diff --git a/control-api/src/services/directory/users.ts b/control-api/src/services/directory/users.ts index 25c8ee206b..496a5bf62c 100644 --- a/control-api/src/services/directory/users.ts +++ b/control-api/src/services/directory/users.ts @@ -495,9 +495,6 @@ export async function retireDesktopUser( ) } const lifecycleVersion = requireLifecycleVersion(user.lifecycle_version) - await db.query('SELECT public.authorization_prelock_user_retirement_revisions($1::uuid[])', [ - [targetUserId], - ]) await options.authorize?.(db) const history = await db.query( @@ -677,58 +674,50 @@ export async function retireDesktopUserInTransaction( * Teams are retained even when this leaves them with zero active members. * Accounts with retained operator-link history are intentionally not purged. */ -export async function adminDeleteUserInTransaction( - db: DbClient, - userId: string -): Promise { - await db.query('SELECT public.authorization_prelock_user_retirement_revisions($1::uuid[])', [ - [userId], - ]) - const exists = await db.query(`SELECT 1 FROM users WHERE id = $1 LIMIT 1`, [userId]) - if ((exists.rowCount ?? 0) === 0) { - return { error: 'not_found' } - } +export async function adminDeleteUser(userId: string): Promise { + return withTransaction(async db => { + const exists = await db.query(`SELECT 1 FROM users WHERE id = $1 LIMIT 1`, [userId]) + if ((exists.rowCount ?? 0) === 0) { + return { error: 'not_found' } + } - // Operator-link generations are retained for audit and are protected by - // ON DELETE RESTRICT. Refuse the legacy hard-delete operation explicitly - // rather than relying on a late FK error or silently erasing lifecycle history. - const operatorLinkHistory = await db.query( - `SELECT 1 - FROM gfs_desktop_operator_links - WHERE user_id = $1 - LIMIT 1`, - [userId] - ) - if ((operatorLinkHistory.rowCount ?? 0) > 0) { - return { error: 'gfs_operator_link_history_retained' } - } + // Operator-link generations are retained for audit and are protected by + // ON DELETE RESTRICT. Refuse the legacy hard-delete operation explicitly + // rather than relying on a late FK error or silently erasing the lifecycle + // history. A future actor-aware account-retirement flow must revoke the + // active generation and use the governed retention process before purge. + const operatorLinkHistory = await db.query( + `SELECT 1 + FROM gfs_desktop_operator_links + WHERE user_id = $1 + LIMIT 1`, + [userId] + ) + if ((operatorLinkHistory.rowCount ?? 0) > 0) { + return { error: 'gfs_operator_link_history_retained' } + } - await db.query( - `UPDATE workflow_approval_medium_accounts + await db.query( + `UPDATE workflow_approval_medium_accounts SET disabled_at = COALESCE(disabled_at, NOW()), updated_at = NOW() WHERE user_id = $1 AND disabled_at IS NULL`, - [userId] - ) - await db.query( - `UPDATE workflow_approval_medium_challenges + [userId] + ) + await db.query( + `UPDATE workflow_approval_medium_challenges SET consumed_at = COALESCE(consumed_at, NOW()), expires_at = LEAST(expires_at, NOW()) WHERE user_id = $1 AND consumed_at IS NULL`, - [userId] - ) - - const del = await db.query(`DELETE FROM users WHERE id = $1 RETURNING id`, [userId]) - if ((del.rowCount ?? 0) === 0) { - return { error: 'not_found' } - } - return { ok: true, id: userId } -} + [userId] + ) -export async function adminDeleteUser(userId: string): Promise { - return withTransaction(async db => { - return adminDeleteUserInTransaction(db, userId) + const del = await db.query(`DELETE FROM users WHERE id = $1 RETURNING id`, [userId]) + if ((del.rowCount ?? 0) === 0) { + return { error: 'not_found' } + } + return { ok: true, id: userId } }) } diff --git a/control-api/src/services/userApprovalRequestArchiveService.ts b/control-api/src/services/userApprovalRequestArchiveService.ts index 660c30c94b..ec441c0ff1 100644 --- a/control-api/src/services/userApprovalRequestArchiveService.ts +++ b/control-api/src/services/userApprovalRequestArchiveService.ts @@ -87,12 +87,6 @@ async function archiveOneBatch(olderThanDays: number, batchSize: number): Promis const ids = selected.rows.map(r => (r as { id: string }).id) - // Deadlock prevention: the DELETE below cascades to every live run that - // references an archived approval, and each trigger takes its subjects in - // table order. Hold the batch's whole subject set (teams before users) - // before the first write so a concurrent run writer cannot invert it. - await db.query('SELECT authorization_prelock_workflow_approval_revisions($1::uuid[])', [ids]) - // Copy into archive. Schema columns mirror workflow_approval_requests // plus archived_at (defaulted to NOW() in DDL). Use ON CONFLICT DO NOTHING // so a retry after partial failure is idempotent on PK collisions. diff --git a/control-api/src/services/userApprovalRequestService.ts b/control-api/src/services/userApprovalRequestService.ts index c4f8257372..3bd421eaec 100644 --- a/control-api/src/services/userApprovalRequestService.ts +++ b/control-api/src/services/userApprovalRequestService.ts @@ -825,16 +825,6 @@ export async function recordDecision( return { ok: false, error: 'approval_requester_mismatch' } } - if (decision === 'approve') { - // The `approved` UPDATE below bumps the approval target's revision, and - // the run INSERT it can trigger takes the run's team before its user, so - // deciding first would hold the user while asking for the team. Take the - // same subject set the INSERT will bump, teams before users, first. - await db.query('SELECT authorization_prelock_workflow_approval_trigger_revisions($1::uuid)', [ - id, - ]) - } - const decidedAt = new Date() const decidedAtIso = decidedAt.toISOString() await db.query( @@ -1199,14 +1189,6 @@ export async function consumeApprovalForTrigger( ) } - // The approval row is locked and every check passed. Take the revision - // subjects, teams before users, before the consume UPDATE bumps the target: - // the run INSERT that follows takes the run's team first, so consuming first - // would hold the target user while asking for that team. - await dbTx.query('SELECT authorization_prelock_workflow_approval_trigger_revisions($1::uuid)', [ - params.approvalRequestId, - ]) - const consumed = await dbTx.query( `UPDATE workflow_approval_requests war SET status = 'consumed' @@ -1348,35 +1330,14 @@ export async function assertApprovalTriggerBinding(params: { } export async function expirePendingRequests(): Promise { - const expired = await withTransaction(async db => { - // Select the sweep's rows first: the UPDATE then has a fixed id set, so it - // cannot pick up rows committed after the prelock by re-evaluating the - // predicate under a newer snapshot. SKIP LOCKED lets a second replica, or a - // concurrent decision, keep its rows for the next tick. - const selected = await db.query( - `SELECT id FROM workflow_approval_requests - WHERE status = 'pending' AND expires_at < NOW() - FOR UPDATE SKIP LOCKED` - ) - if ((selected.rowCount ?? 0) === 0) return 0 - - const ids = selected.rows.map(row => (row as { id: string }).id) - - // Same ordered prelock as the approval archive: the UPDATE fires the - // approval revision trigger per row in scan order, so hold the whole batch - // (teams before users) before the first write. - await db.query('SELECT authorization_prelock_workflow_approval_revisions($1::uuid[])', [ids]) - - const updated = await db.query( - `UPDATE workflow_approval_requests - SET status = 'expired', - decided_at = COALESCE(decided_at, NOW()) - WHERE id = ANY($1::uuid[]) - RETURNING id`, - [ids] - ) - return updated.rowCount ?? 0 - }) + const result = await pool.query( + `UPDATE workflow_approval_requests + SET status = 'expired', + decided_at = COALESCE(decided_at, NOW()) + WHERE status = 'pending' AND expires_at < NOW() + RETURNING id` + ) + const expired = result.rowCount ?? 0 if (expired > 0) { approvalsExpiredTotal.inc(expired) rootLogger.info( diff --git a/control-api/src/services/workflowRunsArchiveService.ts b/control-api/src/services/workflowRunsArchiveService.ts index 9675f26a88..82a12cbc85 100644 --- a/control-api/src/services/workflowRunsArchiveService.ts +++ b/control-api/src/services/workflowRunsArchiveService.ts @@ -192,8 +192,6 @@ async function archiveOneBatch( const children = selected.rows as ArchivedChild[] const ids = children.map(r => r.run_id) - await client.query('SELECT authorization_prelock_workflow_run_revisions($1::uuid[])', [ids]) - // Copy runs → workflow_runs_audit. Field mapping documented at module top. await client.query( `INSERT INTO workflow_runs_audit ( diff --git a/control-api/test/db.composableCatalogRevisionMigration.test.ts b/control-api/test/db.composableCatalogRevisionMigration.test.ts deleted file mode 100644 index 43fadc0b26..0000000000 --- a/control-api/test/db.composableCatalogRevisionMigration.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -vi.mock('../src/config.js', () => ({ - config: { databaseUrl: 'postgresql://test' }, -})) - -describe('0134 composable catalog revisions migration', () => { - it('installs scoped transactional writers for every catalog writer table', async () => { - const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') - const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0134_composable_catalog_revisions' - ) - expect(migration).toBeDefined() - - const query = vi.fn(async () => ({ rows: [], rowCount: 0 })) - await migration!.apply({ query }) - const sql = query.mock.calls.map(call => String(call[0])).join('\n') - - // The writer-class map is test data in the catalogRevisionComponents - // real-PostgreSQL suite, not a production table. - expect(sql).not.toContain('authorization_catalog_writer_components') - expect(sql).toContain('authorization_bump_workflow_run_revision') - expect(sql).toContain('authorization_bump_workflow_run_audit_revision') - expect(sql).toContain('authorization_prelock_workflow_run_revisions(target_run_ids UUID[])') - expect(sql).toContain( - 'REVOKE ALL ON FUNCTION authorization_prelock_workflow_run_revisions(UUID[]) FROM PUBLIC' - ) - expect(sql).toMatch( - /GRANT EXECUTE ON FUNCTION authorization_prelock_workflow_run_revisions\(UUID\[\]\)\s+TO control_api_runtime/ - ) - // The approval prelock also covers the runs its DELETE cascade rewrites. - expect(sql).toContain( - 'authorization_prelock_workflow_approval_revisions(\n target_request_ids UUID[]\n )' - ) - expect(sql).toContain('run.approval_request_id = ANY(target_request_ids)') - expect(sql).toContain( - 'REVOKE ALL ON FUNCTION authorization_prelock_workflow_approval_revisions(UUID[]) FROM PUBLIC' - ) - expect(sql).toMatch( - /GRANT EXECUTE ON FUNCTION authorization_prelock_workflow_approval_revisions\(UUID\[\]\)\s+TO control_api_runtime/ - ) - // The stuck-run sweep runs on the Workflow Recipes pool and holds its - // batch's approval subjects there before the first run phase change. - expect(sql).toMatch( - /GRANT EXECUTE ON FUNCTION authorization_prelock_workflow_approval_revisions\(UUID\[\]\)\s+TO workflow_recipes_runtime/ - ) - // The approval-trigger prelock covers the run INSERT that a decision or a - // consume performs, so its body reads the run intent and the decision team. - expect(sql).toContain( - 'authorization_prelock_workflow_approval_trigger_revisions(\n target_request_id UUID\n )' - ) - expect(sql).toContain('workflow_approval_trigger_run_intents') - expect(sql).toContain("war.decision_maker->>'teamId'") - expect(sql).toContain( - 'REVOKE ALL ON FUNCTION authorization_prelock_workflow_approval_trigger_revisions(UUID)\n FROM PUBLIC' - ) - expect(sql).toMatch( - /GRANT EXECUTE ON FUNCTION authorization_prelock_workflow_approval_trigger_revisions\(UUID\)\s+TO control_api_runtime/ - ) - // Recipe deletion runs on the Workflow Recipes pool: its prelock derives the - // recipe's grant and approval subjects, and only that runtime role executes - // it. The grant-row phase must sit in the function, because the runtime role - // cannot lock or read the subject columns. - expect(sql).toContain( - 'authorization_prelock_workflow_recipe_policy_revisions(\n target_recipe_namespace TEXT,\n target_recipe_name TEXT\n )' - ) - expect(sql).toContain( - 'REVOKE ALL ON FUNCTION authorization_prelock_workflow_recipe_policy_revisions(TEXT, TEXT)\n FROM PUBLIC' - ) - expect(sql).toMatch( - /GRANT EXECUTE ON FUNCTION authorization_prelock_workflow_recipe_policy_revisions\(TEXT, TEXT\)\s+TO workflow_recipes_runtime/ - ) - expect(sql).not.toMatch( - /GRANT EXECUTE ON FUNCTION authorization_prelock_workflow_recipe_policy_revisions\(TEXT, TEXT\)\s+TO control_api_runtime/ - ) - const recipePolicyFunction = sql.slice( - sql.indexOf('FUNCTION authorization_prelock_workflow_recipe_policy_revisions'), - sql.indexOf('CREATE OR REPLACE FUNCTION authorization_bump_user_row_revision') - ) - expect(recipePolicyFunction).toContain('ORDER BY trigger_grant.user_id\n FOR UPDATE') - expect(recipePolicyFunction).toContain('ORDER BY trigger_grant.team_id\n FOR UPDATE') - expect(recipePolicyFunction).toContain('ORDER BY approval_target.team_id\n FOR UPDATE') - expect(recipePolicyFunction).not.toContain('workflow_recipe_allowed_users') - expect(sql).toContain('authorization_bump_workflow_approval_revision') - expect(sql).toContain('authorization_bump_notification_revision') - expect(sql).toContain('authorization_bump_gfs_resource_revision') - expect(sql).not.toContain('authorization_catalog_revision') - expect(sql).not.toMatch(/\bDROP\s+(?:TRIGGER|TABLE|FUNCTION)\b/i) - }) - - it('replaces row-level revision triggers without dropping them', async () => { - const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') - const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0134_composable_catalog_revisions' - ) - expect(migration).toBeDefined() - - const query = vi.fn(async () => ({ rows: [], rowCount: 0 })) - await migration!.apply({ query }) - const sql = query.mock.calls - .map(call => String(call[0])) - .join('\n') - .replace(/\s+/g, ' ') - - const replaced = [...sql.matchAll(/CREATE OR REPLACE TRIGGER (\w+) /g)].map(match => match[1]) - expect(replaced.sort()).toEqual( - [ - 'users_authorization_revision', - 'teams_authorization_revision', - 'workflow_runs_authorization_revision', - 'workflow_approval_requests_authorization_revision', - 'workflow_recipe_allowed_teams_authorization_revision', - 'workflow_approval_medium_accounts_authorization_revision', - 'workflow_runs_audit_authorization_revision', - 'notification_deliveries_authorization_revision', - 'gfs_grants_authorization_revision', - 'gfs_shares_authorization_revision', - 'gfs_resources_authorization_revision', - ].sort() - ) - expect(sql).not.toMatch(/DROP TRIGGER IF EXISTS \w+_authorization_revision/) - expect(sql).not.toMatch(/CREATE TRIGGER /) - }) -}) diff --git a/control-api/test/db.initDb.test.ts b/control-api/test/db.initDb.test.ts index bab1cd432b..7c1123924c 100644 --- a/control-api/test/db.initDb.test.ts +++ b/control-api/test/db.initDb.test.ts @@ -67,10 +67,9 @@ describe('db.initDb', () => { expect(sqls.some(sql => sql.includes('DROP TABLE IF EXISTS workflow_run_outputs'))).toBe(false) expect(sqls).not.toContain('ROLLBACK') // The committed password-admission rollout fence, one dev batch through - // 0130_password_login_success_admission (as in dev), then five PR1 versions - // plus the 0133 function transaction. - expect(sqls.filter(sql => sql === 'BEGIN')).toHaveLength(8) - expect(sqls.filter(sql => sql === 'COMMIT')).toHaveLength(8) + // 0130_password_login_success_admission (as in dev), then two PR1 versions. + expect(sqls.filter(sql => sql === 'BEGIN')).toHaveLength(4) + expect(sqls.filter(sql => sql === 'COMMIT')).toHaveLength(4) expect(sqls[sqls.length - 2]).toBe('COMMIT') expect(sqls[sqls.length - 1]).toContain('SELECT pg_advisory_unlock') expect(clientRelease).toHaveBeenCalledTimes(1) diff --git a/control-api/test/db.invitationDeliveryCommandMigration.test.ts b/control-api/test/db.invitationDeliveryCommandMigration.test.ts deleted file mode 100644 index 7d26416881..0000000000 --- a/control-api/test/db.invitationDeliveryCommandMigration.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { applyInvitationDeliveryCommandFoundation } from '../src/services/directory/invitationDeliverySchema.js' - -describe('invitation delivery command migration', () => { - it('persists non-secret authorized delivery commands with bounded runtime access', async () => { - const query = vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }) - - await applyInvitationDeliveryCommandFoundation({ query }) - - const sql = String(query.mock.calls[0]?.[0]) - expect(sql).toContain('CREATE TABLE IF NOT EXISTS invitation_delivery_commands') - expect(sql).toContain("delivery_kind IN ('create', 'resend')") - expect(sql).toContain("status IN ('authorized', 'delivered', 'failed', 'cancelled')") - expect(sql).toContain('REFERENCES invitations(id) ON DELETE CASCADE') - expect(sql).toContain("WHERE status = 'authorized'") - expect(sql).toContain('REVOKE ALL PRIVILEGES ON TABLE invitation_delivery_commands') - expect(sql).toContain( - 'FROM PUBLIC, control_api_runtime, trace_maintenance_runtime, workflow_recipes_runtime' - ) - expect(sql).toContain( - 'GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE invitation_delivery_commands' - ) - expect(sql).toContain('TO control_api_runtime') - expect(sql).not.toContain('token') - }) - - it('declares the delivery table in the deploy-time runtime contract', () => { - const contractPath = join( - dirname(fileURLToPath(import.meta.url)), - '../../deploy/scripts/control-api-runtime-access-profiles.tsv' - ) - const entries = new Map( - readFileSync(contractPath, 'utf8') - .split('\n') - .filter(line => line && !line.startsWith('#')) - .map(line => line.split('\t') as [string, string]) - ) - - expect(entries.get('invitation_delivery_commands')).toBe('legacy_dml') - }) -}) diff --git a/control-api/test/db.migrationsOrder.test.ts b/control-api/test/db.migrationsOrder.test.ts index ebbfa2361a..89162c2c04 100644 --- a/control-api/test/db.migrationsOrder.test.ts +++ b/control-api/test/db.migrationsOrder.test.ts @@ -66,13 +66,7 @@ describe('CONTROL_API_MIGRATIONS ordering invariant', () => { ] const incomingDev = '0129_workflow_schedules_concurrency_policy' const lastDev = '0130_password_login_success_admission' - const task106 = [ - '0131_user_access_foundation', - '0132_invitation_delivery_commands', - '0133_catalog_utf8_ordering', - '0134_composable_catalog_revisions', - '0135_legacy_password_security_epoch_backfill', - ] + const task106 = ['0131_user_access_foundation', '0132_legacy_password_security_epoch_backfill'] expect( versions.slice(versions.indexOf(parentDev[0]!), versions.indexOf(parentDev[0]!) + 3) @@ -81,7 +75,7 @@ describe('CONTROL_API_MIGRATIONS ordering invariant', () => { expect(versions[versions.indexOf(incomingDev) + 1]).toBe(lastDev) expect(versions[versions.indexOf(lastDev) + 1]).toBe(task106[0]) expect( - versions.slice(versions.indexOf(task106[0]!), versions.indexOf(task106[0]!) + 5) + versions.slice(versions.indexOf(task106[0]!), versions.indexOf(task106[0]!) + 2) ).toEqual(task106) expect(versions.indexOf(parentDev.at(-1)!)).toBeLessThan(versions.indexOf(task106[0]!)) expect(versions.at(-1)).toBe(task106.at(-1)) diff --git a/control-api/test/db.realPostgresMigration.integration.test.ts b/control-api/test/db.realPostgresMigration.integration.test.ts index 21a3e70f71..42dacb1b7c 100644 --- a/control-api/test/db.realPostgresMigration.integration.test.ts +++ b/control-api/test/db.realPostgresMigration.integration.test.ts @@ -42,57 +42,6 @@ type PrivilegeExpectation = Record> const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip -const accessFoundationDefinerSignatures = [ - 'authorization_bump_user_revision(pg_catalog.uuid)', - 'authorization_bump_team_revision(pg_catalog.uuid)', - 'authorization_bump_subject_revision(pg_catalog.text, pg_catalog.text)', - 'authorization_prelock_user_retirement_revisions(pg_catalog.uuid[])', - 'authorization_prelock_workflow_run_revisions(pg_catalog.uuid[])', - 'authorization_prelock_workflow_approval_revisions(pg_catalog.uuid[])', - 'authorization_prelock_workflow_approval_trigger_revisions(pg_catalog.uuid)', - 'authorization_prelock_workflow_recipe_policy_revisions(pg_catalog.text, pg_catalog.text)', - 'authorization_bump_user_row_revision()', - 'authorization_bump_team_row_revision()', - 'authorization_bump_workflow_run_revision()', - 'authorization_bump_workflow_run_audit_revision()', - 'authorization_bump_workflow_approval_revision()', - 'authorization_bump_notification_revision()', - 'authorization_bump_gfs_resource_component(pg_catalog.uuid)', - 'authorization_bump_gfs_authority_revision()', - 'authorization_bump_gfs_resource_subjects(pg_catalog.uuid)', - 'authorization_bump_gfs_resource_revision()', - 'authorization_bump_resource_revision(pg_catalog.text, pg_catalog.text, pg_catalog.text)', - 'authorization_bump_team_membership_revision()', - 'authorization_bump_user_grant_revision()', - 'authorization_bump_team_grant_revision()', - 'authorization_bump_operational_resource_revision()', - 'authorization_bump_operational_relationship_revision()', -] as const -const accessFoundationDefinerNames = accessFoundationDefinerSignatures.map(signature => - signature.slice(0, signature.indexOf('(')) -) - -type AccessFoundationDefinerState = { - function_name: string - config: string[] | null - owner: string - acl: string[] | null -} - -async function readAccessFoundationDefinerState(pool: Pool) { - return pool.query( - `SELECT procedure.proname AS function_name, - procedure.proconfig AS config, - pg_get_userbyid(procedure.proowner) AS owner, - procedure.proacl AS acl - FROM pg_proc procedure - JOIN pg_namespace namespace ON namespace.oid = procedure.pronamespace - WHERE namespace.nspname = 'public' - AND procedure.prosecdef - AND procedure.proname LIKE 'authorization_%' - ORDER BY procedure.proname` - ) -} function databaseUrl(baseUrl: string, database: string): string { const url = new URL(baseUrl) @@ -843,127 +792,8 @@ describeRealPostgres('control-api real Postgres migrations', () => { AS function_name` ) expect(obsoleteGfsSubjectRevision.rows).toEqual([{ function_name: null }]) - // Revision rows are written only by SECURITY DEFINER bump functions. - for (const relation of [ - 'authorization_user_revisions', - 'authorization_team_revisions', - 'authorization_resource_revisions', - ]) { - expectPrivileges([...privileges[relation]], ['SELECT']) - } // RP-12: the session-revocation ledger is append-only for the runtime. expectPrivileges([...privileges.external_v1_session_revocations], ['SELECT', 'INSERT']) - const runtimeClient = await dbPool.connect() - try { - await runtimeClient.query('BEGIN') - await runtimeClient.query('SET LOCAL ROLE control_api_runtime') - const team = await runtimeClient.query<{ id: string }>( - `INSERT INTO teams(name) VALUES ('Synthetic revision grant team') RETURNING id` - ) - const teamId = team.rows[0].id - // Liveness witness: the runtime's own team write advances the revision - // through the trigger. - const bumped = await runtimeClient.query<{ revision: string }>( - 'SELECT revision::text FROM authorization_team_revisions WHERE team_id = $1', - [teamId] - ) - expect(bumped.rows).toHaveLength(1) - for (const statement of [ - 'INSERT INTO authorization_team_revisions(team_id) VALUES ($1)', - 'UPDATE authorization_team_revisions SET revision = revision + 1 WHERE team_id = $1', - ]) { - await runtimeClient.query('SAVEPOINT direct_revision_write') - await expect(runtimeClient.query(statement, [teamId])).rejects.toMatchObject({ - code: '42501', - }) - await runtimeClient.query('ROLLBACK TO SAVEPOINT direct_revision_write') - } - const directBumpResults: Array = [] - for (const statement of [ - 'SELECT authorization_bump_user_revision($1::uuid)', - 'SELECT authorization_bump_team_revision($1::uuid)', - "SELECT authorization_bump_resource_revision('synthetic-od6', 'gfs_resource', $1::text)", - ]) { - await runtimeClient.query('SAVEPOINT direct_revision_bump') - try { - await runtimeClient.query(statement, [teamId]) - directBumpResults.push('allowed') - } catch (error) { - directBumpResults.push((error as { code?: string }).code) - } - await runtimeClient.query('ROLLBACK TO SAVEPOINT direct_revision_bump') - } - expect(directBumpResults).toEqual(['42501', '42501', '42501']) - const revisionExecute = await runtimeClient.query<{ function_name: string }>(` - SELECT routine.proname AS function_name - FROM pg_proc routine - JOIN pg_namespace namespace ON namespace.oid = routine.pronamespace - WHERE namespace.nspname = 'public' - AND routine.prosecdef - AND routine.proname LIKE 'authorization_%' - AND has_function_privilege('control_api_runtime', routine.oid, 'EXECUTE') - ORDER BY routine.proname - `) - expect(revisionExecute.rows).toEqual([ - { function_name: 'authorization_prelock_user_retirement_revisions' }, - { function_name: 'authorization_prelock_workflow_approval_revisions' }, - { function_name: 'authorization_prelock_workflow_approval_trigger_revisions' }, - { function_name: 'authorization_prelock_workflow_run_revisions' }, - ]) - const prelockGrants = await runtimeClient.query<{ grantee: string }>(` - SELECT routine.proname AS function_name, pg_get_userbyid(privilege.grantee) AS grantee - FROM pg_proc routine - CROSS JOIN LATERAL aclexplode(routine.proacl) privilege - WHERE routine.oid IN ( - 'public.authorization_prelock_user_retirement_revisions(uuid[])'::regprocedure, - 'public.authorization_prelock_workflow_run_revisions(uuid[])'::regprocedure, - 'public.authorization_prelock_workflow_approval_revisions(uuid[])'::regprocedure, - 'public.authorization_prelock_workflow_approval_trigger_revisions(uuid)'::regprocedure, - 'public.authorization_prelock_workflow_recipe_policy_revisions(text, text)'::regprocedure - ) - AND privilege.grantee <> routine.proowner - AND privilege.privilege_type = 'EXECUTE' - ORDER BY grantee, function_name - `) - expect(prelockGrants.rows).toEqual([ - { - function_name: 'authorization_prelock_user_retirement_revisions', - grantee: 'control_api_runtime', - }, - { - function_name: 'authorization_prelock_workflow_approval_revisions', - grantee: 'control_api_runtime', - }, - { - function_name: 'authorization_prelock_workflow_approval_trigger_revisions', - grantee: 'control_api_runtime', - }, - { - function_name: 'authorization_prelock_workflow_run_revisions', - grantee: 'control_api_runtime', - }, - { - function_name: 'authorization_prelock_workflow_approval_revisions', - grantee: 'workflow_recipes_runtime', - }, - { - function_name: 'authorization_prelock_workflow_recipe_policy_revisions', - grantee: 'workflow_recipes_runtime', - }, - ]) - expect( - ( - await runtimeClient.query( - 'SELECT revision::text FROM authorization_team_revisions WHERE team_id = $1', - [teamId] - ) - ).rows - ).toEqual(bumped.rows) - } finally { - await runtimeClient.query('ROLLBACK') - runtimeClient.release() - } - try { await dbPool.query('GRANT UPDATE ON password_verification_work TO control_api_runtime') expect(await violations(relationQuery)).toBe(1) @@ -1877,13 +1707,6 @@ describeRealPostgres('control-api real Postgres migrations', () => { AS function_name` ) expect(obsoleteCatalogRevision.rows).toEqual([{ function_name: null }]) - const freshAccessFoundationFunctions = await readAccessFoundationDefinerState(dbPool) - expect(freshAccessFoundationFunctions.rows.map(row => row.function_name)).toEqual( - [...accessFoundationDefinerNames].sort() - ) - for (const { config } of freshAccessFoundationFunctions.rows) { - expect(config).toEqual(['search_path=pg_catalog, public, pg_temp']) - } const firstVersions = await dbPool.query<{ version: string }>( 'SELECT version FROM schema_migrations ORDER BY version' @@ -2169,7 +1992,6 @@ describeRealPostgres('control-api real Postgres migrations', () => { const exactControlApiRelations = [ 'administrative_events', 'agent_run_events', - 'authorization_catalog_environment', 'governed_event_read_v1', 'governed_event_stream', 'governed_approval_prompt_history', @@ -2193,7 +2015,6 @@ describeRealPostgres('control-api real Postgres migrations', () => { const expectedControlApiRelations: Record = { administrative_events: ['INSERT', 'SELECT'], agent_run_events: ['INSERT', 'SELECT'], - authorization_catalog_environment: ['SELECT'], governed_event_read_v1: ['SELECT'], governed_event_stream: ['INSERT', 'SELECT'], governed_approval_prompt_history: ['INSERT', 'SELECT'], @@ -3027,37 +2848,11 @@ describeRealPostgres('control-api real Postgres migrations', () => { VALUES ($1, 'sandbox-recipes', 'runtime-cleanup-test')`, [cleanupTeamId] ) - const cleanupApprovalId = randomUUID() - await dbPool.query( - `INSERT INTO workflow_approval_requests( - id, recipe_namespace, recipe_name, expires_at, status, target_user_id, - payload, idempotency_key, requested_at - ) VALUES ($1, 'sandbox-recipes', 'runtime-cleanup-test', NOW() + INTERVAL '1 hour', - 'pending', $2, '{}'::jsonb, $3, NOW())`, - [cleanupApprovalId, cleanupUserId, `runtime-cleanup-${cleanupApprovalId}`] - ) const cleanupClient = await dbPool.connect() try { await cleanupClient.query('BEGIN') await cleanupClient.query('SET LOCAL ROLE workflow_recipes_runtime') - // The real cleanup transaction first locks the approval rows it cancels, - // then runs the prelock. Both statements need the runtime grants the - // deployment pins here; a missing EXECUTE or UPDATE would roll back the - // recipe deletion in production and be swallowed there. - const approvalLock = await cleanupClient.query( - `SELECT id FROM workflow_approval_requests - WHERE recipe_namespace = $1 AND recipe_name = $2 - AND status IN ('pending', 'approved') - ORDER BY id - FOR UPDATE`, - ['sandbox-recipes', 'runtime-cleanup-test'] - ) - expect(approvalLock.rowCount).toBe(1) - await cleanupClient.query( - 'SELECT authorization_prelock_workflow_recipe_policy_revisions($1, $2)', - ['sandbox-recipes', 'runtime-cleanup-test'] - ) for (const table of [ 'user_workflow_triggers', 'team_workflow_triggers', @@ -3102,23 +2897,6 @@ describeRealPostgres('control-api real Postgres migrations', () => { `) expect(functionAccess.rows[0]?.violations).toBe('') - // The Workflow Recipes runtime may execute exactly two authorization - // functions: the stuck-run sweep's approval prelock and the recipe-delete - // prelock. Every other one stays owner-only. - const recipePolicyFunctionAccess = await dbPool.query<{ function_name: string }>(` - SELECT routine.proname AS function_name - FROM pg_proc routine - JOIN pg_namespace namespace ON namespace.oid = routine.pronamespace - WHERE namespace.nspname = 'public' - AND routine.proname LIKE 'authorization_%' - AND has_function_privilege('workflow_recipes_runtime', routine.oid, 'EXECUTE') - ORDER BY routine.proname - `) - expect(recipePolicyFunctionAccess.rows).toEqual([ - { function_name: 'authorization_prelock_workflow_approval_revisions' }, - { function_name: 'authorization_prelock_workflow_recipe_policy_revisions' }, - ]) - const traceService = new AgentRunEventService({ transaction: async () => { throw new Error('integration fixture supplies the transaction explicitly') @@ -3891,14 +3669,9 @@ describeRealPostgres('control-api real Postgres migrations', () => { } }) - it('prevents temp-type shadowing in access-foundation SECURITY DEFINER triggers', async () => { - const definerFunctions = await readAccessFoundationDefinerState(dbPool) - expect(definerFunctions.rows.map(row => row.function_name)).toEqual( - [...accessFoundationDefinerNames].sort() - ) - for (const { config } of definerFunctions.rows) { - expect(config).toEqual(['search_path=pg_catalog, public, pg_temp']) - } + it('prevents temp-type shadowing in entity-change SECURITY DEFINER triggers', async () => { + const { initDb } = await import('../src/db.js') + await initDb({ connect: () => dbPool.connect() }) const attackerRole = `entity_change_shadow_${randomBytes(5).toString('hex')}` const attackerPassword = randomBytes(24).toString('hex') @@ -3944,19 +3717,6 @@ describeRealPostgres('control-api real Postgres migrations', () => { resourceId, ]) expect(stored.rowCount).toBe(1) - const revision = await dbPool.query<{ revision: string }>( - `SELECT revision::text - FROM authorization_resource_revisions - WHERE environment_id = ( - SELECT environment_id - FROM authorization_catalog_environment - WHERE singleton = TRUE - ) - AND resource_type = 'gfs_resource' - AND resource_id = $1`, - [resourceId] - ) - expect(revision.rows).toEqual([{ revision: '1' }]) } finally { attacker.release() } diff --git a/control-api/test/db.userAccessFoundationMigration.test.ts b/control-api/test/db.userAccessFoundationMigration.test.ts index c06a3f515b..5807f918fd 100644 --- a/control-api/test/db.userAccessFoundationMigration.test.ts +++ b/control-api/test/db.userAccessFoundationMigration.test.ts @@ -22,7 +22,7 @@ async function migrationSql(): Promise { async function legacyEpochBackfillSql(): Promise { const { CONTROL_API_MIGRATIONS } = await import('../src/db.js') const migration = CONTROL_API_MIGRATIONS.find( - candidate => candidate.version === '0135_legacy_password_security_epoch_backfill' + candidate => candidate.version === '0132_legacy_password_security_epoch_backfill' ) expect(migration).toBeDefined() @@ -32,7 +32,7 @@ async function legacyEpochBackfillSql(): Promise { } describe('user-access foundation migration', () => { - it('adds the stateful user session and revision foundations additively', async () => { + it('adds the stateful user session foundation additively', async () => { const sql = await migrationSql() expect(sql).toContain('CREATE TABLE IF NOT EXISTS external_user_sessions') @@ -42,41 +42,9 @@ describe('user-access foundation migration', () => { expect(sql).toContain('absolute_expires_at TIMESTAMPTZ NOT NULL') expect(sql).toContain('CREATE TABLE IF NOT EXISTS external_user_session_security_epochs') expect(sql).toContain('CREATE TABLE IF NOT EXISTS external_v1_session_revocations') - expect(sql).toContain('CREATE TABLE IF NOT EXISTS authorization_user_revisions') - expect(sql).toContain('CREATE TABLE IF NOT EXISTS authorization_team_revisions') - expect(sql).toContain('CREATE TABLE IF NOT EXISTS authorization_resource_revisions') - expect(sql).not.toContain('authorization_catalog_revision') - expect(sql).toContain('SECURITY DEFINER') - expect(sql).toContain('authorization_bump_team_membership_revision') expect(sql).not.toMatch(/DROP\s+(?:TABLE|COLUMN)/i) }) - it('stores operational facts and staging generations without effective access', async () => { - const sql = await migrationSql() - - expect(sql).toContain('CREATE TABLE IF NOT EXISTS operational_catalog_source_state') - expect(sql).toContain('CREATE TABLE IF NOT EXISTS operational_resource_index') - expect(sql).toContain('CREATE TABLE IF NOT EXISTS operational_resource_relationships') - expect(sql).toContain('CREATE TABLE IF NOT EXISTS operational_resource_index_staging') - expect(sql).toContain('CREATE TABLE IF NOT EXISTS operational_relationships_staging') - expect(sql).toContain('staging_generation BIGINT') - expect(sql).toContain('operational_relationship_target_idx') - expect(sql).toContain('relationship_instance_id TEXT NOT NULL') - - const currentResourceTable = sql.match( - /CREATE TABLE IF NOT EXISTS operational_resource_index \(([\s\S]*?)\n \);/ - )?.[1] - const currentRelationshipTable = sql.match( - /CREATE TABLE IF NOT EXISTS operational_resource_relationships \(([\s\S]*?)\n \);/ - )?.[1] - expect(currentResourceTable).toBeDefined() - expect(currentRelationshipTable).toBeDefined() - expect(`${currentResourceTable}\n${currentRelationshipTable}`).not.toMatch( - /\b(?:user_id|team_id|role|grant|capabilit(?:y|ies))\b/ - ) - expect(sql).not.toMatch(/CREATE\s+MATERIALIZED\s+VIEW/i) - }) - it('builds keyset and reverse indexes online, outside the 0131 transaction', async () => { const sql = await migrationSql() const { PR1_ONLINE_INDEX_PLAN } = await import('../src/migrations/pr1OnlineIndexPlan.js') @@ -89,42 +57,12 @@ describe('user-access foundation migration', () => { 'team_contexts_context_team_idx', 'user_agents_agent_user_idx', 'team_agents_agent_team_idx', - 'user_workflow_triggers_recipe_user_idx', - 'team_workflow_triggers_recipe_team_idx', - 'user_workflow_triggers_catalog_key_idx', - 'team_workflow_triggers_catalog_key_idx', - 'workflow_runs_actor_catalog_idx', - 'workflow_runs_team_catalog_idx', - 'workflow_runs_usage_team_catalog_idx', - 'workflow_approval_user_catalog_idx', - 'workflow_approval_team_catalog_idx', - 'notification_user_catalog_idx', - 'notification_team_catalog_idx', - 'gfs_grants_subject_resource_catalog_idx', - 'gfs_shares_subject_resource_catalog_idx', ] expect(planned.map(entry => entry.name).sort()).toEqual([...expectedIndexes].sort()) // Witness: the transaction still creates the PR1-owned tables and their indexes. expect(sql).toContain('CREATE TABLE IF NOT EXISTS external_user_sessions') - expect(sql).toContain('CREATE INDEX IF NOT EXISTS operational_relationship_target_idx') + expect(sql).toContain('CREATE INDEX IF NOT EXISTS external_user_sessions_user_live_idx') for (const expectedIndex of expectedIndexes) expect(sql).not.toContain(expectedIndex) - - const plannedSql = planned.map(entry => entry.createSql).join('\n') - expect(plannedSql).toContain('ON workflow_runs (actor_id, run_id)') - expect(plannedSql).toContain("ON notification_deliveries ((audience->>'userId'), id)") - }) - - it('installs no statement-level catalog revision trigger', async () => { - const sql = await migrationSql() - - // Witness: the trigger block ran and still installs the row-level triggers. - expect(sql).toContain("('team_members', 'team_members_authorization_revision',") - expect(sql).toContain( - 'CREATE OR REPLACE FUNCTION authorization_bump_team_membership_revision()' - ) - expect(sql).not.toContain('authorization_bump_catalog_revision') - expect(sql).not.toMatch(/FOR EACH STATEMENT/i) - expect(sql).not.toMatch(/\|\|\s*'_catalog_revision'/) }) it('declares exact least-privilege profiles for every new relation', () => { @@ -140,17 +78,9 @@ describe('user-access foundation migration', () => { ) const expected = new Map([ - ['authorization_resource_revisions', 'read'], - ['authorization_team_revisions', 'read'], - ['authorization_user_revisions', 'read'], ['external_user_session_security_epochs', 'legacy_dml'], ['external_user_sessions', 'legacy_dml'], ['external_v1_session_revocations', 'append'], - ['operational_catalog_source_state', 'legacy_dml'], - ['operational_relationships_staging', 'legacy_dml'], - ['operational_resource_index', 'legacy_dml'], - ['operational_resource_index_staging', 'legacy_dml'], - ['operational_resource_relationships', 'legacy_dml'], ]) for (const [relation, profile] of expected) { diff --git a/control-api/test/gfsRevisionLockOrder.realPostgres.integration.test.ts b/control-api/test/gfsRevisionLockOrder.realPostgres.integration.test.ts deleted file mode 100644 index 8a54c9c9d0..0000000000 --- a/control-api/test/gfsRevisionLockOrder.realPostgres.integration.test.ts +++ /dev/null @@ -1,356 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { randomBytes, randomUUID } from 'node:crypto' -import { Pool, type PoolClient } from 'pg' -import { initDb } from '../src/db.js' -import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' - -const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL -const describeRealPostgres = adminUrl ? describe : describe.skip -const runtimeRoles = [ - 'control_api_runtime', - 'trace_maintenance_runtime', - 'workflow_recipes_runtime', -] as const -const barrierClass = 35_005 - -function databaseUrl(baseUrl: string, database: string): string { - const value = new URL(baseUrl) - value.pathname = `/${database}` - return value.toString() -} - -function quoteIdentifier(value: string): string { - return `"${value.replace(/"/g, '""')}"` -} - -async function backendPid(client: PoolClient): Promise { - const result = await client.query<{ pid: number }>('SELECT pg_backend_pid() AS pid') - return result.rows[0]!.pid -} - -async function waitForAdvisoryBlock( - pool: Pool, - blockedPid: number, - blockerPid: number, - label: string -): Promise { - const deadline = Date.now() + 5_000 - while (Date.now() < deadline) { - const result = await pool.query<{ blocked: boolean; wait_event: string | null }>( - `SELECT $2::integer = ANY(pg_blocking_pids($1::integer)) AS blocked, - wait_event - FROM pg_stat_activity - WHERE pid = $1::integer`, - [blockedPid, blockerPid] - ) - if (result.rows[0]?.blocked && result.rows[0].wait_event === 'advisory') return - await new Promise(resolve => setTimeout(resolve, 10)) - } - throw new Error(`${label}: expected advisory barrier was not observed`) -} - -async function waitForSecondWriter( - pool: Pool, - blockedPid: number, - firstWriterPid: number, - secondBarrierPid: number -): Promise<'same-first-row' | 'opposite-first-row'> { - const deadline = Date.now() + 5_000 - while (Date.now() < deadline) { - const result = await pool.query<{ - blocked_by_first: boolean - blocked_by_barrier: boolean - wait_event: string | null - }>( - `SELECT $2::integer = ANY(pg_blocking_pids($1::integer)) AS blocked_by_first, - $3::integer = ANY(pg_blocking_pids($1::integer)) AS blocked_by_barrier, - wait_event - FROM pg_stat_activity - WHERE pid = $1::integer`, - [blockedPid, firstWriterPid, secondBarrierPid] - ) - const row = result.rows[0] - if (row?.blocked_by_first) return 'same-first-row' - if (row?.blocked_by_barrier && row.wait_event === 'advisory') return 'opposite-first-row' - await new Promise(resolve => setTimeout(resolve, 10)) - } - throw new Error('expected the second GFS writer to reach a revision-row lock or barrier') -} - -type WriteOutcome = { status: 'fulfilled' } | { status: 'rejected'; code: string; error: unknown } - -function observeWrite(write: Promise): Promise { - return write.then( - () => ({ status: 'fulfilled' as const }), - error => ({ - status: 'rejected' as const, - code: - typeof error === 'object' && error !== null && 'code' in error - ? String((error as { code?: unknown }).code ?? 'unknown') - : 'unknown', - error, - }) - ) -} - -async function rollbackQuietly(client: PoolClient): Promise { - await client.query('ROLLBACK').catch(() => undefined) -} - -describeRealPostgres('GFS subject revision lock order on real PostgreSQL', () => { - const database = `control_api_gfs_revision_order_${randomBytes(6).toString('hex')}` - const connectionString = databaseUrl( - adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', - database - ) - let adminPool: Pool - let databasePool: Pool - - beforeAll(async () => { - adminPool = new Pool({ connectionString: adminUrl }) - await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) - await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) - databasePool = new Pool({ connectionString, max: 12 }) - await initDb({ connect: () => databasePool.connect() }) - }) - - afterAll(async () => { - try { - await endPoolAndWaitForClients(databasePool) - if (adminPool) { - await adminPool.query( - `SELECT pg_terminate_backend(pid) - FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) - await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) - } - } finally { - await adminPool?.end() - } - }) - - it('serializes overlapping resource writes in one deduplicated subject order', async () => { - const subjectIds = Array.from({ length: 96 }, (_, index) => { - const suffix = String(index + 1).padStart(12, '0') - return `00000000-0000-4000-8000-${suffix}` - }) - for (const [index, subjectId] of subjectIds.entries()) { - await databasePool.query(`INSERT INTO users(id, email, name) VALUES ($1, $2, $3)`, [ - subjectId, - `rp005a-${index}-${subjectId}@example.test`, - `RP005A ${index}`, - ]) - } - - const resourceA = randomUUID() - const resourceB = randomUUID() - const driveA = `rp005a-a-${randomBytes(4).toString('hex')}` - const driveB = `rp005a-b-${randomBytes(4).toString('hex')}` - await databasePool.query( - `INSERT INTO gfs_resources(resource_id, drive, name, kind) - VALUES ($1, $3, 'rp005a-a', 'file'), ($2, $4, 'rp005a-b', 'file')`, - [resourceA, resourceB, driveA, driveB] - ) - - for (const [resourceId, drive, ids] of [ - [resourceA, driveA, subjectIds], - [resourceB, driveB, [...subjectIds].reverse()], - ] as const) { - for (const subjectId of ids) { - await databasePool.query( - `INSERT INTO gfs_grants(drive, resource_id, subject_type, subject_id, permissions) - VALUES ($1, $2, 'user', $3, ARRAY['read']::text[])`, - [drive, resourceId, subjectId] - ) - await databasePool.query( - `INSERT INTO gfs_shares(drive, resource_id, subject_type, subject_id, permissions) - VALUES ($1, $2, 'user', $3, ARRAY['read']::text[])`, - [drive, resourceId, subjectId] - ) - } - } - - const subjectOrder = async ( - resourceId: string, - strategy: 'sorted' | 'hashed' - ): Promise => { - const client = await databasePool.connect() - try { - await client.query('BEGIN') - if (strategy === 'sorted') { - await client.query('SET LOCAL enable_hashagg = off') - await client.query('SET LOCAL enable_sort = on') - } else { - await client.query('SET LOCAL enable_hashagg = on') - await client.query('SET LOCAL enable_sort = off') - } - const result = await client.query<{ subject_id: string }>( - `SELECT grant_row.subject_type, grant_row.subject_id - FROM gfs_grants grant_row - WHERE grant_row.resource_id = $1 - UNION - SELECT share_row.subject_type, share_row.subject_id - FROM gfs_shares share_row - WHERE share_row.resource_id = $1`, - [resourceId] - ) - return result.rows.map(row => row.subject_id) - } finally { - await client.query('ROLLBACK') - client.release() - } - } - - const sortedOrder = await subjectOrder(resourceA, 'sorted') - const hashedOrder = await subjectOrder(resourceB, 'hashed') - const firstSorted = sortedOrder[0] - const firstHashed = hashedOrder[0] - expect(firstSorted).toBeDefined() - expect(firstHashed).toBeDefined() - expect(firstSorted).not.toBe(firstHashed) - - const before = await databasePool.query<{ user_id: string; revision: string }>( - `SELECT user_id::text, revision::text - FROM authorization_user_revisions - WHERE user_id = ANY($1::uuid[]) - ORDER BY user_id::text`, - [subjectIds] - ) - const revisionsBefore = new Map(before.rows.map(row => [row.user_id, BigInt(row.revision)])) - - await databasePool.query(` - CREATE TABLE rp005a_barrier_config( - application_name TEXT PRIMARY KEY, - user_id UUID NOT NULL, - gate INTEGER NOT NULL - ); - CREATE OR REPLACE FUNCTION rp005a_revision_barrier() - RETURNS TRIGGER - LANGUAGE plpgsql - AS $$ - DECLARE selected_gate INTEGER; - BEGIN - SELECT gate INTO selected_gate - FROM rp005a_barrier_config - WHERE application_name = current_setting('application_name') - AND user_id = NEW.user_id; - IF FOUND THEN - PERFORM pg_advisory_lock(35005, selected_gate); - PERFORM pg_advisory_unlock(35005, selected_gate); - END IF; - RETURN NEW; - END; - $$; - CREATE TRIGGER rp005a_revision_barrier - BEFORE UPDATE ON authorization_user_revisions - FOR EACH ROW EXECUTE FUNCTION rp005a_revision_barrier(); - `) - await databasePool.query( - `INSERT INTO rp005a_barrier_config(application_name, user_id, gate) - VALUES ('rp005a-sorted', $1, 1), ('rp005a-hashed', $2, 2)`, - [firstSorted, firstHashed] - ) - - const gateOne = await databasePool.connect() - const gateTwo = await databasePool.connect() - const txOne = await databasePool.connect() - const txTwo = await databasePool.connect() - const outcomes: WriteOutcome[] = [] - try { - const gateOnePid = await backendPid(gateOne) - const gateTwoPid = await backendPid(gateTwo) - await gateOne.query('SELECT pg_advisory_lock($1, 1)', [barrierClass]) - await gateTwo.query('SELECT pg_advisory_lock($1, 2)', [barrierClass]) - - await txOne.query('BEGIN') - await txOne.query("SELECT set_config('application_name', 'rp005a-sorted', true)") - await txOne.query('SET LOCAL enable_hashagg = off') - await txOne.query('SET LOCAL enable_sort = on') - await txOne.query("SET LOCAL deadlock_timeout = '100ms'") - await txOne.query("SET LOCAL statement_timeout = '8s'") - const txOnePid = await backendPid(txOne) - const writeOne = observeWrite( - txOne.query(`UPDATE gfs_resources SET name = name || '-one' WHERE resource_id = $1`, [ - resourceA, - ]) - ) - await waitForAdvisoryBlock(databasePool, txOnePid, gateOnePid, 'first-writer-first-row') - - await txTwo.query('BEGIN') - await txTwo.query("SELECT set_config('application_name', 'rp005a-hashed', true)") - await txTwo.query('SET LOCAL enable_hashagg = on') - await txTwo.query('SET LOCAL enable_sort = off') - await txTwo.query("SET LOCAL deadlock_timeout = '100ms'") - await txTwo.query("SET LOCAL statement_timeout = '8s'") - const txTwoPid = await backendPid(txTwo) - const writeTwo = observeWrite( - txTwo.query(`UPDATE gfs_resources SET name = name || '-two' WHERE resource_id = $1`, [ - resourceB, - ]) - ) - const secondWriterState = await waitForSecondWriter( - databasePool, - txTwoPid, - txOnePid, - gateTwoPid - ) - - if (secondWriterState === 'same-first-row') { - await gateOne.query('SELECT pg_advisory_unlock($1, 1)', [barrierClass]) - const firstOutcome = await writeOne - outcomes.push(firstOutcome) - if (firstOutcome.status === 'fulfilled') await txOne.query('COMMIT') - else await rollbackQuietly(txOne) - await waitForAdvisoryBlock(databasePool, txTwoPid, gateTwoPid, 'second-writer-later-row') - await gateTwo.query('SELECT pg_advisory_unlock($1, 2)', [barrierClass]) - const secondOutcome = await writeTwo - outcomes.push(secondOutcome) - if (secondOutcome.status === 'fulfilled') await txTwo.query('COMMIT') - else await rollbackQuietly(txTwo) - } else { - await gateOne.query('SELECT pg_advisory_unlock($1, 1)', [barrierClass]) - await gateTwo.query('SELECT pg_advisory_unlock($1, 2)', [barrierClass]) - outcomes.push(await writeOne, await writeTwo) - await rollbackQuietly(txOne) - await rollbackQuietly(txTwo) - } - } finally { - await gateOne.query('SELECT pg_advisory_unlock_all()').catch(() => undefined) - await gateTwo.query('SELECT pg_advisory_unlock_all()').catch(() => undefined) - await rollbackQuietly(txOne) - await rollbackQuietly(txTwo) - gateOne.release() - gateTwo.release() - txOne.release() - txTwo.release() - } - - expect(outcomes).toEqual([{ status: 'fulfilled' }, { status: 'fulfilled' }]) - const after = await databasePool.query<{ user_id: string; revision: string }>( - `SELECT user_id::text, revision::text - FROM authorization_user_revisions - WHERE user_id = ANY($1::uuid[]) - ORDER BY user_id::text`, - [subjectIds] - ) - expect(after.rows).toHaveLength(subjectIds.length) - for (const row of after.rows) { - expect(BigInt(row.revision) - (revisionsBefore.get(row.user_id) ?? 0n)).toBe(2n) - } - }) - - it('preserves the subject validation failures owned by the revision dispatcher', async () => { - await expect( - databasePool.query(`SELECT authorization_bump_subject_revision('invalid', 'subject')`) - ).rejects.toMatchObject({ message: expect.stringContaining('unmapped catalog subject type') }) - await expect( - databasePool.query(`SELECT authorization_bump_subject_revision('user', 'not-a-uuid')`) - ).rejects.toMatchObject({ - code: 'P0001', - message: expect.stringContaining('invalid catalog user subject identifier'), - }) - }) -}) diff --git a/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts b/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts index e7fe9dc5bb..7d7835cdaf 100644 --- a/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts +++ b/control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts @@ -25,7 +25,7 @@ function quoteIdentifier(value: string): string { async function removeLegacyEpochMigrationReceipts(databasePool: Pool): Promise { const removed = await databasePool.query( `DELETE FROM schema_migrations - WHERE version = '0135_legacy_password_security_epoch_backfill'` + WHERE version = '0132_legacy_password_security_epoch_backfill'` ) if (removed.rowCount !== 1) throw new Error('legacy epoch backfill receipt was not recorded') } @@ -133,7 +133,7 @@ describeRealPostgres('legacy password epoch backfill on real PostgreSQL', () => const recorded = await databasePool.query<{ count: string }>( `SELECT COUNT(*)::text AS count FROM schema_migrations - WHERE version = '0135_legacy_password_security_epoch_backfill'` + WHERE version = '0132_legacy_password_security_epoch_backfill'` ) expect(recorded.rows[0]?.count).toBe('1') }) diff --git a/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts index 382f16285d..43537374f7 100644 --- a/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts +++ b/control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts @@ -110,15 +110,6 @@ const FRESH_TABLE_INDEXES = Object.freeze([ 'external_user_sessions_idle_idx', 'external_v1_session_revocations_user_idx', 'external_v1_session_revocations_expiry_idx', - 'authorization_resource_revisions_updated_idx', - 'operational_resource_source_idx', - 'operational_relationship_target_idx', - 'operational_relationship_catalog_target_idx', - 'operational_relationship_generation_idx', - 'operational_resource_staging_identity_idx', - 'operational_relationship_staging_identity_idx', - 'invitation_delivery_commands_authorized_idx', - 'invitation_delivery_commands_invitation_idx', ]) function databaseUrl(baseUrl: string, database: string): string { @@ -312,11 +303,11 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { it('classifies, creates, and reruns all PR1 indexes without replay', async () => { const firstVersions = await versions(databasePool) - expect(PR1_ONLINE_INDEX_PLAN).toHaveLength(25) - expect(FRESH_TABLE_INDEXES).toHaveLength(13) + expect(PR1_ONLINE_INDEX_PLAN).toHaveLength(5) + expect(FRESH_TABLE_INDEXES).toHaveLength(4) const allNames = [...PR1_ONLINE_INDEX_PLAN.map(index => index.name), ...FRESH_TABLE_INDEXES] - expect(new Set(allNames)).toHaveLength(38) + expect(new Set(allNames)).toHaveLength(9) const indexes = await databasePool.query<{ relname: string; indisvalid: boolean }>( `SELECT relation.relname, index.indisvalid FROM pg_class relation @@ -324,7 +315,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { WHERE relation.relname = ANY($1::text[])`, [allNames] ) - expect(indexes.rows).toHaveLength(38) + expect(indexes.rows).toHaveLength(9) expect(indexes.rows.every(row => row.indisvalid)).toBe(true) await initDb({ connect: () => databasePool.connect() }) @@ -349,9 +340,9 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { expect(await versions(databasePool)).toContain(entry.migrationVersion) }) - it('reuses a deparsed partial index without rebuilding its physical index', async () => { + it('reuses a deparsed index without rebuilding its physical index', async () => { const entry = PR1_ONLINE_INDEX_PLAN.find( - index => index.name === 'workflow_runs_actor_catalog_idx' + index => index.name === 'team_members_user_active_idx' )! const before = await databasePool.query<{ oid: string }>( 'SELECT $1::regclass::oid::text AS oid', @@ -602,10 +593,13 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { it('bounds ordinary DDL lock acquisition and never runs the later version', async () => { const locker = await databasePool.connect() - await removeMigrationReceiptForReplay(databasePool, '0134_composable_catalog_revisions') + // 0131 replays without touching users (its CREATE TABLE IF NOT EXISTS + // skips existing tables); 0132's SELECT FROM users is the first statement + // that waits on the lock. + await removeMigrationReceiptForReplay(databasePool, '0131_user_access_foundation') await removeMigrationReceiptForReplay( databasePool, - '0135_legacy_password_security_epoch_backfill' + '0132_legacy_password_security_epoch_backfill' ) await locker.query('BEGIN') await locker.query('LOCK TABLE users IN ACCESS EXCLUSIVE MODE') @@ -621,21 +615,21 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { expect(Date.now() - started).toBeGreaterThanOrEqual(9_000) expect(Date.now() - started).toBeLessThan(15_000) const failedVersions = await versions(databasePool) - expect(failedVersions).not.toContain('0134_composable_catalog_revisions') - expect(failedVersions).not.toContain('0135_legacy_password_security_epoch_backfill') + expect(failedVersions).toContain('0131_user_access_foundation') + expect(failedVersions).not.toContain('0132_legacy_password_security_epoch_backfill') await initDb({ connect: () => databasePool.connect() }) }, 20_000) it('cancels and rolls back one ordinary migration statement within 15 seconds', async () => { const client = await databasePool.connect() - const appliedVersions = new Set(PR1_MIGRATION_VERSIONS.slice(0, 3)) + const appliedVersions = new Set(PR1_MIGRATION_VERSIONS.slice(0, 1)) const recordTable = `d34_record_${randomBytes(4).toString('hex')}` await client.query(`CREATE TEMP TABLE ${recordTable}(version text PRIMARY KEY)`) const migrations = [ ...PR1_MIGRATION_VERSIONS.map(version => ({ version, apply: async (db: DbClient) => { - if (version === '0134_composable_catalog_revisions') { + if (version === '0132_legacy_password_security_epoch_backfill') { await db.query('SELECT pg_sleep(20)') } }, @@ -659,7 +653,7 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { `SELECT version FROM ${recordTable} ORDER BY version` ) expect(recorded.rows).toEqual([]) - expect(appliedVersions).not.toContain('0135_legacy_password_security_epoch_backfill') + expect(appliedVersions).not.toContain('0132_legacy_password_security_epoch_backfill') } finally { client.release(true) } @@ -705,8 +699,8 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { expect( ( await isolatedPool.query( - // Keep the Admin UI usage-team sentinel while changing a guarded - // column, so the UPDATE still runs the revision trigger. + // The pre-PR1 Admin UI row keeps its usage-team sentinel and stays + // writable after the upgrade. "UPDATE workflow_runs SET phase = 'Running', actor_id = $2 WHERE run_id = $1 RETURNING run_id", [legacyAdminRun.rows[0]!.run_id, randomUUID()] ) @@ -742,7 +736,9 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { ) expect(passwordAdmissionIndex).toBeGreaterThanOrEqual(0) expect(passwordAdmissionIndex).toBeLessThan(foundationIndex) - expect(upgrade.applyOrder[foundationIndex + 1]).toBe('0132_invitation_delivery_commands') + expect(upgrade.applyOrder[foundationIndex + 1]).toBe( + '0132_legacy_password_security_epoch_backfill' + ) await assertDbReady(isolatedPool) // Rollback: the DEV image's migrator runs against the upgraded database. @@ -836,7 +832,9 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { expect(scheduleIndex).toBeGreaterThanOrEqual(0) expect(scheduleIndex).toBeLessThan(passwordAdmissionIndex) expect(passwordAdmissionIndex).toBeLessThan(foundationIndex) - expect(firstRun.applyOrder[foundationIndex + 1]).toBe('0132_invitation_delivery_commands') + expect(firstRun.applyOrder[foundationIndex + 1]).toBe( + '0132_legacy_password_security_epoch_backfill' + ) await assertDbReady(isolatedPool) const afterUpgrade = await versions(isolatedPool) @@ -851,26 +849,4 @@ describeRealPostgres('D34 migration execution on real PostgreSQL', () => { await endPoolAndWaitForClients(isolatedPool) } }) - - it('keeps legacy team-member payloads compatible with revision triggers', async () => { - const userId = randomUUID() - const teamId = randomUUID() - await databasePool.query( - `INSERT INTO users(id, email, name) VALUES ($1, $2, 'D34 old writer')`, - [userId, `d34-${userId}@example.test`] - ) - await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, 'D34 old team')`, [teamId]) - await databasePool.query( - `INSERT INTO team_members(team_id, user_id, role, status) - VALUES ($1, $2, 'member', 'active')`, - [teamId, userId] - ) - const revision = await databasePool.query<{ count: string }>( - `SELECT count(*)::text AS count - FROM authorization_team_revisions - WHERE team_id = $1`, - [teamId] - ) - expect(Number(revision.rows[0]?.count ?? 0)).toBeGreaterThan(0) - }) }) diff --git a/control-api/test/migrationExecutionPolicy.test.ts b/control-api/test/migrationExecutionPolicy.test.ts index 85d1efa235..f0713e2e16 100644 --- a/control-api/test/migrationExecutionPolicy.test.ts +++ b/control-api/test/migrationExecutionPolicy.test.ts @@ -17,27 +17,15 @@ const FRESH_TABLE_INDEXES = Object.freeze([ 'external_user_sessions_idle_idx', 'external_v1_session_revocations_user_idx', 'external_v1_session_revocations_expiry_idx', - 'authorization_resource_revisions_updated_idx', - 'operational_resource_source_idx', - 'operational_relationship_target_idx', - 'operational_relationship_catalog_target_idx', - 'operational_relationship_generation_idx', - 'operational_resource_staging_identity_idx', - 'operational_relationship_staging_identity_idx', - 'invitation_delivery_commands_authorized_idx', - 'invitation_delivery_commands_invitation_idx', ]) const USER_ACCESS_FOUNDATION_VERSION = '0131_user_access_foundation' describe('D34 migration execution policy', () => { - it('classifies the five PR1 migrations that follow the dev batch', () => { + it('classifies the two PR1 migrations that follow the dev batch', () => { expect(PR1_MIGRATION_VERSIONS).toEqual([ '0131_user_access_foundation', - '0132_invitation_delivery_commands', - '0133_catalog_utf8_ordering', - '0134_composable_catalog_revisions', - '0135_legacy_password_security_epoch_backfill', + '0132_legacy_password_security_epoch_backfill', ]) expect(PR1_MIGRATION_VERSIONS).not.toContain('0117_control_admin_invitation_replace_inviter') expect(PR1_MIGRATION_VERSIONS).not.toContain('0118_control_admin_replace_inviter_accept_guard') @@ -71,15 +59,12 @@ describe('D34 migration execution policy', () => { expect(deployPolicy.equals(runtimePolicy)).toBe(true) }) - it('classifies exactly 25 existing-table indexes and no fresh-table index', () => { - expect(PR1_ONLINE_INDEX_PLAN).toHaveLength(25) - expect(new Set(PR1_ONLINE_INDEX_PLAN.map(index => index.name))).toHaveLength(25) + it('classifies exactly 5 existing-table indexes and no fresh-table index', () => { + expect(PR1_ONLINE_INDEX_PLAN).toHaveLength(5) + expect(new Set(PR1_ONLINE_INDEX_PLAN.map(index => index.name))).toHaveLength(5) expect( PR1_ONLINE_INDEX_PLAN.filter(index => index.migrationVersion.startsWith('0131')) - ).toHaveLength(18) - expect( - PR1_ONLINE_INDEX_PLAN.filter(index => index.migrationVersion.startsWith('0133')) - ).toHaveLength(7) + ).toHaveLength(5) expect( PR1_ONLINE_INDEX_PLAN.some(index => index.name.startsWith('external_user_sessions_')) ).toBe(false) @@ -89,43 +74,16 @@ describe('D34 migration execution policy', () => { }) it('pins the reviewed PR1 migration bodies byte-for-byte', async () => { - // S1d changed the PR1 bodies before any shared environment applied them: - // statement-level catalog triggers are no longer installed by the foundation - // migration, the composable-revision migration replaces row triggers in place, - // and planned indexes left the transactions. S1e folded three later PR1 - // migrations into those two and dropped the authorization_catalog_revision - // table and function. PR1 now occupies slots 0131-0135, after dev's 0130. - // The 2026-10-28 review (still unapplied anywhere) dropped unused objects, - // made revision tables read-only for the runtime, ordered revision writers, - // kept the Admin UI attribution sentinel, added three revision triggers and - // a pre-lock for archive batches, now locking parents before revisions. - // RP-3 (PR #334 review r2) added the approval-batch pre-lock, which also - // covers the runs its DELETE cascade rewrites; RP-12 narrowed the - // session-revocation ledger to SELECT and INSERT. O1 added the - // approval-trigger pre-lock that both decision call sites take before they - // bump the approval target. CR-1 (PR #334 review r2) added the recipe-delete - // pre-lock, which the Workflow Recipes runtime executes and which locks the - // recipe's grant rows inside the function before any revision. The run - // revision trigger now returns early on - // an UPDATE that changes none of the columns catalog consumers read, so - // reconciler heartbeats stop bumping team and user revisions; the GFS - // resource trigger does the same for content saves that keep its identity, - // name, parent and tombstone, and the notification trigger does the same - // for delivery-state writes that keep its id, event type, audience and - // expiry. B4.1 (PR #334 review r2) then granted the approval prelock to the - // Workflow Recipes runtime, which holds the stuck-run sweep's approval - // subjects before its first run phase change. DB-2 B0 now guards the GFS - // backfill at 100000 resources and runs it before trigger DDL. DB-1 - // (D-D1: no shared 0134 receipts) adds the retirement definer and atomic - // user/team coverage with fail-closed receipt postconditions in 0134. + // PR1 occupies slots 0131-0132, after dev's 0130, and no shared + // environment holds a receipt for either. 0131 creates the user-session + // tables (sessions, security epochs, the v1 revocation ledger) and their + // runtime grants; the indexes it needs on pre-existing tables are built + // online by PR1_ONLINE_INDEX_PLAN. 0132 backfills the security epochs from + // historical password events. PR1 installs no trigger and no function. const files = [ [ 'src/services/access/userAccessFoundationSchema.ts', - '07fd931ec209d101282501d3fe148183cc9377b1e8f70ed9e2b039628e0a71f3', - ], - [ - 'src/services/directory/invitationDeliverySchema.ts', - 'eafb103ae84541176c03486035503dda90eab91e55d881b643c2b5099493ee31', + '538c188790dda1eeca15b715a851c4d32148d4eae3986e563dfb50dcfe95994f', ], ] as const for (const [path, expected] of files) { @@ -140,10 +98,6 @@ describe('D34 migration execution policy', () => { new URL('../src/services/access/userAccessFoundationSchema.ts', import.meta.url), 'utf8' ), - readFile( - new URL('../src/services/directory/invitationDeliverySchema.ts', import.meta.url), - 'utf8' - ), ]) const inTransactionNames = historicalSql .flatMap(sql => [...sql.matchAll(/CREATE (?:UNIQUE )?INDEX IF NOT EXISTS (\w+)/g)]) @@ -154,9 +108,9 @@ describe('D34 migration execution policy', () => { // Only indexes on tables PR1 itself creates are built inside a transaction; // every index on a pre-existing table is built online, and only online. expect(inTransactionNames).toEqual([...FRESH_TABLE_INDEXES].sort()) - expect(plannedNames).toHaveLength(25) - // 25 online plus 13 fresh-table indexes (P-12 removed the duplicate source index). - expect(new Set([...plannedNames, ...inTransactionNames])).toHaveLength(38) + expect(plannedNames).toHaveLength(5) + // 5 online plus 4 fresh-table indexes. + expect(new Set([...plannedNames, ...inTransactionNames])).toHaveLength(9) // The planned definitions equal the in-transaction definitions S1d removed // (canonical form at 579ed2986), so an index built by either path is equivalent. @@ -172,7 +126,7 @@ describe('D34 migration execution policy', () => { .map(index => `${index.name}\t${canonical(index.createSql)}`) .join('\n') expect(createHash('sha256').update(plannedDefinitions).digest('hex')).toBe( - 'bc94e081bba8daa3708eb783a2c9abaf64e72fdfdf11d1b74ac6ba1926e834d2' + 'ecb09f793b6d95cc9473a91270536e7e17184db23e808b8271625e9ed04a3e8f' ) }) @@ -336,9 +290,8 @@ describe('D34 PR1 migration runner', () => { }) expect(applied).toEqual([...PR1_MIGRATION_VERSIONS]) - // One transaction per version, plus the catalog_utf8_bytes function that - // 0133 creates and commits before building its online indexes. - const transactions = PR1_MIGRATION_VERSIONS.length + 1 + // One transaction per version. + const transactions = PR1_MIGRATION_VERSIONS.length expect(queries.filter(({ sql }) => sql === 'BEGIN')).toHaveLength(transactions) expect(queries.filter(({ sql }) => sql === 'COMMIT')).toHaveLength(transactions) expect(queries.filter(({ sql }) => sql === 'ROLLBACK')).toHaveLength(0) @@ -353,7 +306,7 @@ describe('D34 PR1 migration runner', () => { legacyVersions: [`legacy_${version}`], apply: vi.fn(async () => { applyOrder.push(version) - if (version === PR1_MIGRATION_VERSIONS[3]) throw new Error('boom') + if (version === PR1_MIGRATION_VERSIONS[1]) throw new Error('boom') }), })), ] @@ -367,7 +320,7 @@ describe('D34 PR1 migration runner', () => { recordMigration: async () => undefined, }) ).rejects.toThrow('boom') - expect(applyOrder).not.toContain(PR1_MIGRATION_VERSIONS[4]) + expect(applyOrder).not.toContain(PR1_MIGRATION_VERSIONS[0]) expect(query).toHaveBeenCalledWith('ROLLBACK') }) diff --git a/control-api/test/migrationGfsBackfillBound.realPostgres.integration.test.ts b/control-api/test/migrationGfsBackfillBound.realPostgres.integration.test.ts deleted file mode 100644 index e50a8a1032..0000000000 --- a/control-api/test/migrationGfsBackfillBound.realPostgres.integration.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' -import { randomBytes } from 'node:crypto' -import { Pool } from 'pg' -import { CONTROL_API_MIGRATIONS, assertDbReady, initDb } from '../src/db.js' -import { canonicalEnvironmentId } from '../src/services/access/operationalAccessProjection.js' -import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' - -const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL -const describeRealPostgres = adminUrl ? describe : describe.skip -const VERSION = '0134_composable_catalog_revisions' -const NEXT_VERSION = '0135_legacy_password_security_epoch_backfill' - -function databaseUrl(baseUrl: string, database: string): string { - const value = new URL(baseUrl) - value.pathname = `/${database}` - return value.toString() -} - -describeRealPostgres('0134 GFS backfill bound on real PostgreSQL', () => { - let database: string - let adminPool: Pool - let databasePool: Pool - - beforeAll(() => { - adminPool = new Pool({ connectionString: adminUrl }) - }) - - beforeEach(async () => { - database = `control_api_gfs_bound_${randomBytes(6).toString('hex')}` - await adminPool.query(`CREATE DATABASE "${database}"`) - databasePool = new Pool({ connectionString: databaseUrl(adminUrl!, database) }) - - // Build the actual pre-0134 schema and receipts through initDb. Abort at - // entry to 0134 rather than running it and faking an older database state. - const migration = CONTROL_API_MIGRATIONS.find(item => item.version === VERSION)! - const original = migration.apply - const stop = new Error('fixture stops before 0134') - migration.apply = async () => { - throw stop - } - try { - await expect(initDb({ connect: () => databasePool.connect() })).rejects.toBe(stop) - } finally { - migration.apply = original - } - const receipts = await databasePool.query<{ version: string }>( - 'SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1' - ) - expect(receipts.rows).toEqual([{ version: '0133_catalog_utf8_ordering' }]) - expect((await databasePool.query('SELECT count(*) FROM gfs_resources')).rows).toEqual([ - { count: '0' }, - ]) - }) - - afterEach(async () => { - await endPoolAndWaitForClients(databasePool) - if (adminPool && database) { - await adminPool.query(`DROP DATABASE IF EXISTS "${database}"`) - database = '' - } - }) - - afterAll(async () => { - try { - // Retry the last database cleanup if a per-test teardown failed. - if (adminPool && database) { - await adminPool.query(`DROP DATABASE IF EXISTS "${database}"`) - } - } finally { - await adminPool?.end() - } - }) - - async function seedResources(count: number): Promise { - // Separate drives give each root a valid, unique structural identity. - const inserted = await databasePool.query( - `INSERT INTO gfs_resources(drive, name, kind) - SELECT 'bound-' || n, 'root', 'directory' FROM generate_series(1, $1::int) AS n`, - [count] - ) - expect(inserted.rowCount).toBe(count) - const resources = await databasePool.query('SELECT count(*) FROM gfs_resources') - expect(resources.rows).toEqual([{ count: String(count) }]) - } - - async function shareRowExclusiveLocks(pid: number): Promise { - const result = await databasePool.query( - `SELECT relation::regclass::text AS relation, granted - FROM pg_locks - WHERE pid = $1 AND mode = 'ShareRowExclusiveLock'`, - [pid] - ) - return result.rows - } - - async function migrate() { - const triggerDdl: string[] = [] - let locksBeforeBackfill: unknown[] | undefined - let locksAfterRollback: unknown[] | undefined - let statementTimeout: string | undefined - let error: unknown - const started = Date.now() - try { - await initDb({ - connect: async () => { - const client = await databasePool.connect() - const { rows } = await client.query<{ pid: number }>('SELECT pg_backend_pid() AS pid') - const pid = rows[0]!.pid - return { - release: (destroy?: boolean | Error) => client.release(destroy), - query: async (sql: string, values?: unknown[]) => { - if (/\bCREATE\s+(?:OR\s+REPLACE\s+)?TRIGGER\b/i.test(sql)) triggerDdl.push(sql) - if (sql.trimStart().startsWith('INSERT INTO authorization_resource_revisions(')) { - locksBeforeBackfill = await shareRowExclusiveLocks(pid) - const setting = await client.query('SHOW statement_timeout') - statementTimeout = setting.rows[0].statement_timeout - } - const result = await client.query(sql, values) - if (sql.trim() === 'ROLLBACK') { - locksAfterRollback = await shareRowExclusiveLocks(pid) - } - return result - }, - } - }, - }) - } catch (caught) { - error = caught - } - return { - error, - triggerDdl, - locksBeforeBackfill, - locksAfterRollback, - statementTimeout, - elapsedMs: Date.now() - started, - } - } - - it.each([0, 662, 100000])('backfills %i resources and commits its receipt', async count => { - await seedResources(count) - // Existing non-default revisions must survive ON CONFLICT unchanged. - await databasePool.query( - `INSERT INTO authorization_resource_revisions(environment_id, resource_type, resource_id, revision) - SELECT $1, 'gfs_resource', resource_id::text, 7 FROM gfs_resources LIMIT 1`, - [canonicalEnvironmentId()] - ) - - const run = await migrate() - - expect(run.error).toBeUndefined() - expect(run.locksBeforeBackfill).toEqual([]) - expect(run.statementTimeout).toBe('15s') - expect(run.triggerDdl).toHaveLength(1) - const coverage = await databasePool.query( - `SELECT count(*)::int AS resources, count(revision.resource_id)::int AS covered, - count(*) FILTER (WHERE revision.revision = 7)::int AS preserved - FROM gfs_resources AS resource - LEFT JOIN authorization_resource_revisions AS revision - ON revision.environment_id = $1 AND revision.resource_type = 'gfs_resource' - AND revision.resource_id = resource.resource_id::text`, - [canonicalEnvironmentId()] - ) - expect(coverage.rows).toEqual([{ resources: count, covered: count, preserved: count ? 1 : 0 }]) - const receipts = await databasePool.query( - 'SELECT version FROM schema_migrations WHERE version = ANY($1::text[]) ORDER BY version', - [[VERSION, NEXT_VERSION]] - ) - expect(receipts.rows).toEqual([{ version: VERSION }, { version: NEXT_VERSION }]) - await expect(assertDbReady(databasePool)).resolves.toBeUndefined() - console.info('B0 backfill', { - count, - ...coverage.rows[0], - elapsedMs: run.elapsedMs, - statementTimeout: run.statementTimeout, - }) - }) - - it('refuses 100001 resources before trigger DDL and rolls back without a receipt or locks', async () => { - await seedResources(100001) - await databasePool.query( - `INSERT INTO authorization_resource_revisions(environment_id, resource_type, resource_id, revision) - SELECT $1, 'gfs_resource', resource_id::text, 7 FROM gfs_resources LIMIT 1`, - [canonicalEnvironmentId()] - ) - const before = await databasePool.query('SELECT * FROM authorization_resource_revisions') - // Make the unguarded backfill deterministically exceed the real 15s bound. - // This test-only trigger turns guard removal into 57014, independent of - // host speed; it must never run when the count guard is present. - await databasePool.query(` - CREATE FUNCTION test_slow_resource_backfill() RETURNS trigger LANGUAGE plpgsql AS $$ - BEGIN - PERFORM pg_sleep(16); - RETURN NULL; - END; - $$; - CREATE TRIGGER test_slow_resource_backfill BEFORE INSERT ON authorization_resource_revisions - FOR EACH STATEMENT EXECUTE FUNCTION test_slow_resource_backfill(); - `) - - const run = await migrate() - const error = run.error as { code?: string; message?: string } | undefined - console.info('B0 refusal', { - code: error?.code, - message: error?.message, - elapsedMs: run.elapsedMs, - triggerDdl: run.triggerDdl.length, - locksAfterRollback: run.locksAfterRollback, - }) - - expect(error).toMatchObject({ code: 'P0001' }) - expect(error?.message).toContain('0134 gfs_resources backfill refused') - expect(error?.message).toContain('count 100001') - expect(error?.message).toContain('limit 100000') - expect(run.triggerDdl).toEqual([]) - expect(run.locksBeforeBackfill).toBeUndefined() - expect(run.locksAfterRollback).toEqual([]) - const after = await databasePool.query('SELECT * FROM authorization_resource_revisions') - expect(after.rows).toEqual(before.rows) - const receipts = await databasePool.query( - 'SELECT version FROM schema_migrations WHERE version = ANY($1::text[])', - [[VERSION, NEXT_VERSION]] - ) - expect(receipts.rows).toEqual([]) - await expect(assertDbReady(databasePool)).rejects.toThrow(VERSION) - const environment = await databasePool.query( - "SELECT to_regclass('authorization_catalog_environment') AS relation" - ) - expect(environment.rows).toEqual([{ relation: null }]) - }) -}) diff --git a/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts b/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts index ad311aa529..525c17b14a 100644 --- a/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts +++ b/control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts @@ -103,42 +103,6 @@ describeRealPostgres('D34 previous-image writer compatibility on real PostgreSQL [team.id, userId] ) expect(membership.rows).toEqual([{ role: 'admin', status: 'active' }]) - const revision = await databasePool.query<{ count: string }>( - `SELECT count(*)::text AS count - FROM authorization_team_revisions - WHERE team_id = $1`, - [team.id] - ) - expect(Number(revision.rows[0]?.count ?? 0)).toBeGreaterThan(0) - }) - - it('installs deletion-compatible revision functions on fresh initialization', async () => { - const functionState = await databasePool.query<{ - name: string - config: string[] | null - definition: string - }>(`SELECT proname AS name, proconfig AS config, pg_get_functiondef(oid) AS definition - FROM pg_proc - WHERE oid = ANY(ARRAY[ - 'public.authorization_bump_team_revision(uuid)'::regprocedure, - 'public.authorization_bump_user_revision(uuid)'::regprocedure - ]) - ORDER BY proname`) - expect(functionState.rows.map(row => row.name)).toEqual([ - 'authorization_bump_team_revision', - 'authorization_bump_user_revision', - ]) - for (const [name, parentTable, targetId] of [ - ['authorization_bump_team_revision', 'teams', 'target_team_id'], - ['authorization_bump_user_revision', 'users', 'target_user_id'], - ]) { - const state = functionState.rows.find(row => row.name === name) - expect(state?.config).toContain('search_path=pg_catalog, public, pg_temp') - expect(state?.definition).toMatch( - new RegExp(`SELECT ${parentTable}\\.id[\\s\\S]*FROM ${parentTable}`, 'i') - ) - expect(state?.definition).not.toMatch(new RegExp(`VALUES\\s*\\(${targetId}`, 'i')) - } }) it('lets the runtime role delete a user and a team with a deleted membership', async () => { diff --git a/control-api/test/migrationUserRetirementRevisions.realPostgres.integration.test.ts b/control-api/test/migrationUserRetirementRevisions.realPostgres.integration.test.ts deleted file mode 100644 index d8133ca88f..0000000000 --- a/control-api/test/migrationUserRetirementRevisions.realPostgres.integration.test.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' -import { randomBytes, randomUUID } from 'node:crypto' -import { Pool } from 'pg' -import { CONTROL_API_MIGRATIONS, assertDbReady, initDb } from '../src/db.js' -import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' - -const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL -const describeRealPostgres = adminUrl ? describe : describe.skip -const VERSION = '0134_composable_catalog_revisions' -const isBackfill = (sql: string) => sql.includes('previous_lock_timeout TEXT') - -describeRealPostgres('0134 user retirement revision coverage on PostgreSQL 16', () => { - let database: string - let admin: Pool - let db: Pool - const user = randomUUID(), - retired = randomUUID(), - team = randomUUID() - - beforeAll(() => { - admin = new Pool({ connectionString: adminUrl }) - }) - - beforeEach(async () => { - expect(process.version).toMatch(/^v24\./) - database = `retirement_migration_${randomBytes(6).toString('hex')}` - const version = Number( - (await admin.query('SHOW server_version_num')).rows[0].server_version_num - ) - expect(version).toBeGreaterThanOrEqual(160000) - expect(version).toBeLessThan(170000) - await admin.query(`CREATE DATABASE "${database}"`) - const url = new URL(adminUrl!) - url.pathname = `/${database}` - db = new Pool({ connectionString: url.toString() }) - const migration = CONTROL_API_MIGRATIONS.find(m => m.version === VERSION)! - const apply = migration.apply - const stop = new Error('stop before 0134') - migration.apply = async () => { - throw stop - } - try { - await expect(initDb({ connect: () => db.connect() })).rejects.toBe(stop) - } finally { - migration.apply = apply - } - await db.query( - "INSERT INTO users(id,email) VALUES($1,'active@example.test'),($2,'retired@example.test')", - [user, retired] - ) - await db.query("INSERT INTO teams(id,name) VALUES($1,'team without memberships')", [team]) - // A valid retained lifecycle from before 0134, without relying on the new caller. - const actor = randomUUID(), - operation = randomUUID() - await db.query( - `INSERT INTO control_admin_users(id,username,password_hash,role,status) - VALUES($1,$2,'test-unusable-hash','admin','disabled')`, - [actor, actor] - ) - await db.query( - `INSERT INTO desktop_user_retirement_operations(id,operation,actor_type, - actor_control_admin_id,target_user_id,idempotency_key_hash,request_fingerprint,reason) - VALUES($1,'retire_desktop_user','control_admin',$2,$3,repeat('a',64),repeat('b',64),'legacy retirement')`, - [operation, actor, retired] - ) - await db.query( - `UPDATE users SET lifecycle_state='retired',retired_at=NOW(), - retirement_reason='legacy retirement',retired_by_type='control_admin', - retired_by_control_admin_id=$2,retirement_operation_id=$3 WHERE id=$1`, - [retired, actor, operation] - ) - await db.query('DELETE FROM authorization_user_revisions') - await db.query('DELETE FROM authorization_team_revisions') - }) - afterEach(async () => { - await endPoolAndWaitForClients(db) - if (admin && database) { - await admin.query(`DROP DATABASE IF EXISTS "${database}"`) - database = '' - } - }) - afterAll(async () => { - try { - // Retry the last database cleanup if a per-test teardown failed. - if (admin && database) await admin.query(`DROP DATABASE IF EXISTS "${database}"`) - } finally { - await admin?.end() - } - }) - async function coverage() { - return ( - await db.query(`SELECT - (SELECT count(*)::int FROM users u LEFT JOIN authorization_user_revisions r ON r.user_id=u.id WHERE r.user_id IS NULL) AS users, - (SELECT count(*)::int FROM teams t LEFT JOIN authorization_team_revisions r ON r.team_id=t.id WHERE r.team_id IS NULL) AS teams`) - ).rows[0] - } - async function assertNoReceipt() { - expect( - (await db.query('SELECT version FROM schema_migrations WHERE version >= $1', [VERSION])).rows - ).toEqual([]) - await expect(assertDbReady(db)).rejects.toThrow(VERSION) - expect( - ( - await db.query( - "SELECT to_regprocedure('public.authorization_prelock_user_retirement_revisions(uuid[])')::text AS function" - ) - ).rows - ).toEqual([{ function: null }]) - } - it('covers every lifecycle and teams without memberships, preserves revisions and commits one receipt', async () => { - await db.query('INSERT INTO authorization_user_revisions(user_id,revision) VALUES($1,7)', [ - user, - ]) - await initDb({ connect: () => db.connect() }) - expect(await coverage()).toEqual({ users: 0, teams: 0 }) - expect( - (await db.query('SELECT user_id,revision FROM authorization_user_revisions ORDER BY user_id')) - .rows - ).toEqual( - [ - { user_id: user, revision: '7' }, - { user_id: retired, revision: '1' }, - ].sort((a, b) => a.user_id.localeCompare(b.user_id)) - ) - expect( - (await db.query('SELECT revision FROM authorization_team_revisions WHERE team_id=$1', [team])) - .rows - ).toEqual([{ revision: '1' }]) - const before = (await db.query('SELECT * FROM authorization_user_revisions ORDER BY user_id')) - .rows - await initDb({ connect: () => db.connect() }) - expect( - (await db.query('SELECT * FROM authorization_user_revisions ORDER BY user_id')).rows - ).toEqual(before) - expect( - ( - await db.query('SELECT count(*)::int AS count FROM schema_migrations WHERE version=$1', [ - VERSION, - ]) - ).rows - ).toEqual([{ count: 1 }]) - await expect(assertDbReady(db)).resolves.toBeUndefined() - }) - it('rolls back interrupted coverage and function DDL without a receipt, then resumes', async () => { - const interruption = new Error('interrupted after coverage') - await expect( - initDb({ - connect: async () => { - const c = await db.connect() - return { - release: () => c.release(), - query: async (sql: string, values?: unknown[]) => { - const result = await c.query(sql, values) - if (isBackfill(sql)) throw interruption - return result - }, - } - }, - }) - ).rejects.toBe(interruption) - await assertNoReceipt() - expect(await coverage()).toEqual({ users: 2, teams: 1 }) - await initDb({ connect: () => db.connect() }) - expect(await coverage()).toEqual({ users: 0, teams: 0 }) - }) - it('refuses a receipt when runtime EXECUTE is missing and restores the whole unit on rerun', async () => { - await expect( - initDb({ - connect: async () => { - const c = await db.connect() - return { - release: () => c.release(), - query: async (sql: string, values?: unknown[]) => { - if (isBackfill(sql)) - await c.query( - 'REVOKE EXECUTE ON FUNCTION public.authorization_prelock_user_retirement_revisions(uuid[]) FROM control_api_runtime' - ) - return c.query(sql, values) - }, - } - }, - }) - ).rejects.toThrow('prelock owner or runtime privileges are invalid') - await assertNoReceipt() - expect(await coverage()).toEqual({ users: 2, teams: 1 }) - await initDb({ connect: () => db.connect() }) - expect(await coverage()).toEqual({ users: 0, teams: 0 }) - }) - it('bounds the real migration backfill lock wait below deadlock detection and resumes after rollback', async () => { - const blocker = await db.connect() - let migrationPid: number | undefined - const blockerPid = (await blocker.query('SELECT pg_backend_pid() AS pid')).rows[0].pid - let observed: unknown - let migration: Promise | undefined - try { - await blocker.query('BEGIN') - await blocker.query('SELECT id FROM users WHERE id=$1 FOR UPDATE', [user]) - migration = initDb({ - connect: async () => { - const c = await db.connect() - await c.query("SET deadlock_timeout='2s'") - migrationPid = (await c.query('SELECT pg_backend_pid() AS pid')).rows[0].pid - return c - }, - }).then( - () => null, - error => error - ) - const deadline = Date.now() + 8000 - while (Date.now() < deadline) { - if (migrationPid) { - const row = (await db.query('SELECT pg_blocking_pids($1) AS blockers', [migrationPid])) - .rows[0] - if (row.blockers.includes(blockerPid)) { - observed = row - break - } - } - await new Promise(resolve => setTimeout(resolve, 5)) - } - expect(observed).toBeDefined() - expect(await migration).toMatchObject({ code: '55P03' }) - await assertNoReceipt() - expect(await coverage()).toEqual({ users: 2, teams: 1 }) - } finally { - await blocker.query('ROLLBACK') - await migration - blocker.release() - } - await initDb({ connect: () => db.connect() }) - expect(await coverage()).toEqual({ users: 0, teams: 0 }) - }) -}) diff --git a/control-api/test/operationalAccessProjection.test.ts b/control-api/test/operationalAccessProjection.test.ts deleted file mode 100644 index 88d2eab54b..0000000000 --- a/control-api/test/operationalAccessProjection.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - OperationalProjectionError, - projectOperationalObject, -} from '../src/services/access/operationalAccessProjection.js' - -const namespaces = { - context: 'contexts', - mcpServer: 'mcp-server', - sharedFilesystem: 'mcp-host', -} - -function contextObject(sharedFileSystems: unknown[]) { - return { - metadata: { - name: 'ctx-a', - namespace: 'contexts', - uid: 'uid-context-a', - resourceVersion: '41', - generation: 3, - }, - spec: { - contextId: 'ctx-a', - mcpServers: ['server-a'], - sharedFileSystems, - }, - } -} - -describe('operational access projection', () => { - it('uses namespace-qualified canonical identities and relationship targets', () => { - const projection = projectOperationalObject({ - environmentId: 'test:cluster', - plural: 'contexts', - namespace: 'contexts', - object: contextObject([{ name: 'files', mountPath: '/workspace' }]), - behaviorFingerprintKey: 'test-key', - relationshipNamespaces: namespaces, - }) - - expect(projection.rootId).toBe('contexts/ctx-a') - expect(projection.resources[0]).toMatchObject({ - resourceType: 'context', - logicalId: 'contexts/ctx-a', - providerUid: 'uid-context-a', - }) - expect(projection.relationships).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - relationshipType: 'includes_mcp_server', - targetId: 'mcp-server/server-a', - }), - expect.objectContaining({ - relationshipType: 'mounts_shared_filesystem', - targetId: 'mcp-host/files', - behaviorAttributes: { mountPath: '/workspace', readOnly: true }, - }), - ]) - ) - }) - - it('projects a supported Context alias without changing canonical identity', () => { - const projection = projectOperationalObject({ - environmentId: 'test:cluster', - plural: 'contexts', - namespace: 'contexts', - object: { - ...contextObject([]), - metadata: { - ...contextObject([]).metadata, - name: 'ctx-resource', - }, - spec: { - contextId: 'ctx-wire', - mcpServers: [], - sharedFileSystems: [], - }, - }, - behaviorFingerprintKey: 'test-key', - relationshipNamespaces: namespaces, - }) - - expect(projection.rootId).toBe('contexts/ctx-resource') - expect(projection.relationships).toContainEqual( - expect.objectContaining({ - sourceType: 'context', - sourceId: 'contexts/ctx-resource', - relationshipType: 'context_identity_alias', - targetType: 'context', - targetId: 'contexts/ctx-wire', - }) - ) - }) - - it('preserves repeated filesystem relationship instances with different mount scopes', () => { - const projection = projectOperationalObject({ - environmentId: 'test:cluster', - plural: 'contexts', - namespace: 'contexts', - object: contextObject([ - { name: 'files', mountPath: '/workspace/a' }, - { name: 'files', mountPath: '/workspace/b' }, - ]), - behaviorFingerprintKey: 'test-key', - relationshipNamespaces: namespaces, - }) - const mounts = projection.relationships.filter( - relationship => relationship.relationshipType === 'mounts_shared_filesystem' - ) - - expect(mounts).toHaveLength(2) - expect(new Set(mounts.map(mount => mount.relationshipInstanceId)).size).toBe(2) - expect(mounts.map(mount => mount.behaviorAttributes.mountPath).sort()).toEqual([ - '/workspace/a', - '/workspace/b', - ]) - }) - - it('deduplicates repeated MCP allowlist entries but preserves distinct targets', () => { - const projection = projectOperationalObject({ - environmentId: 'test:cluster', - plural: 'contexts', - namespace: 'contexts', - object: { - ...contextObject([]), - spec: { - contextId: 'ctx-a', - mcpServers: ['server-a', 'server-a', 'server-b'], - sharedFileSystems: [], - }, - }, - behaviorFingerprintKey: 'test-key', - relationshipNamespaces: namespaces, - }) - const relationships = projection.relationships.filter( - relationship => relationship.relationshipType === 'includes_mcp_server' - ) - - expect(relationships.map(relationship => relationship.targetId).sort()).toEqual([ - 'mcp-server/server-a', - 'mcp-server/server-b', - ]) - expect( - new Set(relationships.map(relationship => relationship.relationshipInstanceId)).size - ).toBe(2) - }) - - it('rejects over-budget relationship fan-out before projection', () => { - expect(() => - projectOperationalObject({ - environmentId: 'test:cluster', - plural: 'contexts', - namespace: 'contexts', - object: contextObject( - Array.from({ length: 257 }, (_, index) => ({ - name: `files-${index}`, - mountPath: `/workspace/${index}`, - })) - ), - behaviorFingerprintKey: 'test-key', - relationshipNamespaces: namespaces, - }) - ).toThrowError(OperationalProjectionError) - }) - - it('derives sandbox app identity without exposing raw runtime policy', () => { - const projection = projectOperationalObject({ - environmentId: 'test:cluster', - plural: 'workflowrecipes', - namespace: 'sandbox-recipes', - object: { - metadata: { - name: 'recipe-a', - namespace: 'sandbox-recipes', - uid: 'uid-recipe-a', - resourceVersion: '9', - }, - spec: { - contextRef: 'ctx-a', - runtimeEgress: { allow: ['private-service'] }, - oauthClients: [{ secretRef: 'oauth-secret' }], - ui: { workloadRef: 'web', port: 8080, defaultPath: '/home' }, - }, - }, - behaviorFingerprintKey: 'test-key', - relationshipNamespaces: namespaces, - }) - - expect( - projection.resources.map(resource => [resource.resourceType, resource.logicalId]) - ).toEqual([ - ['workflow_recipe', 'sandbox-recipes/recipe-a'], - ['sandbox_app', 'sandbox-recipes/recipe-a'], - ]) - const sandboxExposures = projection.relationships.filter( - relationship => relationship.relationshipType === 'exposes_sandbox_app' - ) - expect(sandboxExposures).toHaveLength(1) - expect(sandboxExposures[0]).toEqual( - expect.objectContaining({ - sourceId: 'sandbox-recipes/recipe-a', - targetId: 'sandbox-recipes/recipe-a', - }) - ) - const encoded = JSON.stringify(projection.relationships) - expect(encoded).not.toContain('private-service') - expect(encoded).not.toContain('oauth-secret') - }) -}) diff --git a/control-api/test/pr1MigrationLocks.realPostgres.integration.test.ts b/control-api/test/pr1MigrationLocks.realPostgres.integration.test.ts index 4760667817..3247a327e4 100644 --- a/control-api/test/pr1MigrationLocks.realPostgres.integration.test.ts +++ b/control-api/test/pr1MigrationLocks.realPostgres.integration.test.ts @@ -18,7 +18,6 @@ import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' type DbConnector = NonNullable[0]> type RelationLock = { relname: string; mode: string } type CommitSnapshot = { versions: string[]; locks: RelationLock[] } -type Probe = { ok: boolean; sqlstate: string | null } const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL const describeRealPostgres = adminUrl ? describe : describe.skip @@ -30,20 +29,36 @@ const runtimeRoles = [ const PR1_VERSIONS = new Set(PR1_MIGRATION_VERSIONS) const ACCESS_FOUNDATION_MARKER = 'CREATE TABLE IF NOT EXISTS external_user_sessions' -const COMPOSABLE_CATALOG_VERSION = '0134_composable_catalog_revisions' -const ROW_REVISION_TRIGGER_TABLES = [ - 'users', - 'teams', - 'workflow_runs', - 'workflow_approval_requests', - 'workflow_recipe_allowed_teams', - 'workflow_approval_medium_accounts', - 'workflow_runs_audit', - 'notification_deliveries', - 'gfs_grants', - 'gfs_shares', - 'gfs_resources', +// Online indexes the removed catalog migration (0133) and the removed +// catalog rows of 0131 used to build; none of them may exist. +const REMOVED_CATALOG_INDEXES = [ + 'user_workflow_triggers_recipe_user_idx', + 'team_workflow_triggers_recipe_team_idx', + 'user_workflow_triggers_catalog_key_idx', + 'team_workflow_triggers_catalog_key_idx', + 'workflow_runs_actor_catalog_idx', + 'workflow_runs_team_catalog_idx', + 'workflow_runs_usage_team_catalog_idx', + 'workflow_approval_user_catalog_idx', + 'workflow_approval_team_catalog_idx', + 'notification_user_catalog_idx', + 'notification_team_catalog_idx', + 'gfs_grants_subject_resource_catalog_idx', + 'gfs_shares_subject_resource_catalog_idx', + 'user_agents_catalog_utf8_idx', + 'team_agents_catalog_utf8_idx', + 'user_contexts_catalog_utf8_idx', + 'team_contexts_catalog_utf8_idx', + 'user_workflow_triggers_catalog_utf8_idx', + 'team_workflow_triggers_catalog_utf8_idx', + 'operational_relationship_catalog_utf8_target_idx', ] as const +const SESSION_TABLES = [ + 'external_user_session_security_epochs', + 'external_user_sessions', + 'external_v1_session_revocations', +] as const + function databaseUrl(baseUrl: string, database: string): string { const value = new URL(baseUrl) value.pathname = `/${database}` @@ -80,27 +95,6 @@ async function committedTables(observer: Pool): Promise> { return new Set(result.rows.map(row => row.relname)) } -// Runs one statement in its own transaction with a short lock_timeout and -// reports whether it got through or waited for a lock. -async function probe(observer: Pool, sql: string): Promise { - const client = await observer.connect() - try { - await client.query('BEGIN') - await client.query(`SET LOCAL lock_timeout = '1000ms'`) - try { - await client.query(sql) - return { ok: true, sqlstate: null } - } catch (error) { - const sqlstate = (error as { code?: string }).code ?? null - if (sqlstate !== '55P03') throw error - return { ok: false, sqlstate } - } - } finally { - await client.query('ROLLBACK') - client.release() - } -} - async function triggerDefinitions(pool: Pool): Promise { const result = await pool.query<{ definition: string }>( `SELECT pg_get_triggerdef(t.oid) AS definition @@ -116,14 +110,12 @@ async function triggerDefinitions(pool: Pool): Promise { type MigrationRun = { preExistingTables: Set commits: CommitSnapshot[] - composableCatalogProbes: Record | null } async function migrate(pool: Pool, observer: Pool): Promise { const run: MigrationRun = { preExistingTables: new Set(), commits: [], - composableCatalogProbes: null, } const pendingVersions: string[] = [] let accessFoundationSeen = false @@ -147,13 +139,6 @@ async function migrate(pool: Pool, observer: Pool): Promise { if (text.trim() === 'COMMIT') { const versions = pendingVersions.splice(0) if (versions.some(version => PR1_VERSIONS.has(version))) { - if (versions.includes(COMPOSABLE_CATALOG_VERSION)) { - run.composableCatalogProbes = { - readUsers: await probe(observer, 'SELECT count(*) FROM users'), - readWorkflowRuns: await probe(observer, 'SELECT count(*) FROM workflow_runs'), - writeUsers: await probe(observer, 'LOCK TABLE users IN ROW EXCLUSIVE MODE'), - } - } run.commits.push({ versions, locks: await relationLocks(observer, pid) }) } } @@ -218,8 +203,8 @@ describeRealPostgres('PR1 migration locks on real PostgreSQL', () => { }) it('holds no AccessExclusive or Share lock on a pre-existing table in any PR1 transaction', () => { - // Liveness: every PR1 version committed under observation, the pre-PR1 - // table set was captured, and the snapshots see the trigger locks 0134 takes. + // Liveness: every PR1 version committed under observation and the pre-PR1 + // table set was captured. for (const version of PR1_MIGRATION_VERSIONS) { expect( fresh.commits.some(commit => commit.versions.includes(version)), @@ -228,23 +213,10 @@ describeRealPostgres('PR1 migration locks on real PostgreSQL', () => { } expect(fresh.preExistingTables.has('users')).toBe(true) expect(fresh.preExistingTables.has('external_user_sessions')).toBe(false) - const composable = fresh.commits.find(commit => - commit.versions.includes(COMPOSABLE_CATALOG_VERSION) - ) - expect(composable?.locks).toContainEqual({ relname: 'users', mode: 'ShareRowExclusiveLock' }) expect(blockingLocks(fresh)).toEqual([]) }) - it('lets readers through and holds writers while 0134 is open', () => { - expect(fresh.composableCatalogProbes).toEqual({ - readUsers: { ok: true, sqlstate: null }, - readWorkflowRuns: { ok: true, sqlstate: null }, - // Witness that the probe ran inside the open 0134 transaction. - writeUsers: { ok: false, sqlstate: '55P03' }, - }) - }) - it('builds every planned index online and leaves it valid', async () => { const result = await freshPool.query<{ name: string; valid: boolean }>( `SELECT c.relname AS name, i.indisvalid AS valid @@ -261,18 +233,64 @@ describeRealPostgres('PR1 migration locks on real PostgreSQL', () => { ) }) - it('installs the row revision triggers and no statement-level catalog trigger', async () => { + it('installs no revision, catalog, operational or invitation-delivery object', async () => { + // Witness: the fresh database holds the PR1 session tables and every + // planned online index, so the absence queries below ran after 0131. + const sessionTables = await freshPool.query<{ name: string }>( + `SELECT c.relname AS name + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = current_schema() + AND c.relkind IN ('r', 'p') + AND c.relname = ANY($1::text[]) + ORDER BY 1`, + [[...SESSION_TABLES]] + ) + expect(sessionTables.rows.map(row => row.name)).toEqual([...SESSION_TABLES]) + const plannedIndexes = await freshPool.query<{ name: string }>( + `SELECT c.relname AS name + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = current_schema() + AND c.relkind = 'i' + AND c.relname = ANY($1::text[]) + ORDER BY 1`, + [PR1_ONLINE_INDEX_PLAN.map(entry => entry.name)] + ) + expect(plannedIndexes.rows).toHaveLength(5) + + const functions = await freshPool.query<{ name: string }>( + `SELECT p.proname AS name + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = current_schema() + AND (p.proname LIKE 'authorization\\_%' OR p.proname = 'catalog_utf8_bytes') + ORDER BY 1` + ) + expect(functions.rows).toEqual([]) + const definitions = await triggerDefinitions(freshPool) - expect(definitions.filter(definition => definition.includes('_catalog_revision '))).toEqual([]) - for (const table of ROW_REVISION_TRIGGER_TABLES) { - expect( - definitions.some(definition => - definition.startsWith( - `CREATE TRIGGER ${table}_authorization_revision AFTER INSERT OR DELETE OR UPDATE ON public.${table} FOR EACH ROW` + expect( + definitions.filter( + definition => + definition.includes('_authorization_revision') || definition.includes('_catalog_revision') + ) + ).toEqual([]) + + const relations = await freshPool.query<{ name: string }>( + `SELECT c.relname AS name + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = current_schema() + AND ( + c.relname LIKE 'authorization\\_%' + OR c.relname LIKE 'operational\\_%' + OR c.relname = 'invitation_delivery_commands' + OR c.relname = ANY($1::text[]) ) - ), - table - ).toBe(true) - } + ORDER BY 1`, + [[...REMOVED_CATALOG_INDEXES]] + ) + expect(relations.rows).toEqual([]) }) }) diff --git a/control-api/test/revisionWritersLockOrder.realPostgres.integration.test.ts b/control-api/test/revisionWritersLockOrder.realPostgres.integration.test.ts deleted file mode 100644 index d1950e8325..0000000000 --- a/control-api/test/revisionWritersLockOrder.realPostgres.integration.test.ts +++ /dev/null @@ -1,826 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' -import type { Request } from 'express' -import { randomBytes, randomUUID } from 'node:crypto' -import { Pool, type PoolClient } from 'pg' -import { config } from '../src/config.js' -import { initDb, pool } from '../src/db.js' -import type { K8sGateway } from '../src/k8s.js' -import { type GfsSubject, writeGfsGrantBatchInTransaction } from '../src/routes/gfs/grants.js' -import { writeGfsShareBatchInTransaction } from '../src/routes/gfs/shares.js' -import { createRun } from '../src/services/workflowRunService.js' -import { triggerWorkflow } from '../src/services/workflows/workflowTriggerService.js' -import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' - -const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL -const describeRealPostgres = adminUrl ? describe : describe.skip -const barrierClass = 33_405 -const runtimeRoles = [ - 'control_api_runtime', - 'trace_maintenance_runtime', - 'workflow_recipes_runtime', -] - -type Outcome = { status: 'fulfilled' } | { status: 'rejected'; code: string } - -function observe(write: Promise): Promise { - return write.then( - () => ({ status: 'fulfilled' }), - error => ({ status: 'rejected', code: String(error.code) }) - ) -} - -describeRealPostgres('revision writers share one lock order on real PostgreSQL', () => { - const database = `control_api_revision_writers_${randomBytes(6).toString('hex')}` - let adminPool: Pool - let databasePool: Pool - - beforeAll(async () => { - adminPool = new Pool({ connectionString: adminUrl }) - await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) - await adminPool.query(`CREATE DATABASE "${database}"`) - const url = new URL(adminUrl!) - url.pathname = `/${database}` - databasePool = new Pool({ connectionString: url.toString(), max: 12 }) - await initDb({ connect: () => databasePool.connect() }) - // Test-only AFTER triggers pause a writer only after the real revision - // upsert has acquired its row lock. No production bump function is replaced. - await databasePool.query(` - CREATE TABLE revision_writer_barriers ( - application_name TEXT NOT NULL, - revision_table TEXT NOT NULL, - subject_id TEXT NOT NULL, - gate INTEGER NOT NULL - ); - CREATE FUNCTION revision_writer_barrier() RETURNS TRIGGER LANGUAGE plpgsql AS $$ - DECLARE barrier RECORD; - BEGIN - FOR barrier IN - SELECT gate FROM revision_writer_barriers - WHERE application_name = current_setting('application_name') - AND revision_table = TG_TABLE_NAME - AND (subject_id = '' OR subject_id = COALESCE( - to_jsonb(NEW)->>'user_id', to_jsonb(NEW)->>'team_id', - to_jsonb(NEW)->>'resource_id')) - LOOP - PERFORM pg_advisory_xact_lock(${barrierClass}, barrier.gate); - END LOOP; - RETURN NEW; - END; - $$; - CREATE TRIGGER revision_writer_barrier AFTER INSERT OR UPDATE - ON authorization_user_revisions FOR EACH ROW EXECUTE FUNCTION revision_writer_barrier(); - CREATE TRIGGER revision_writer_barrier AFTER INSERT OR UPDATE - ON authorization_team_revisions FOR EACH ROW EXECUTE FUNCTION revision_writer_barrier(); - CREATE TRIGGER revision_writer_barrier AFTER INSERT OR UPDATE - ON authorization_resource_revisions FOR EACH ROW EXECUTE FUNCTION revision_writer_barrier(); - `) - }) - - afterAll(async () => { - try { - await endPoolAndWaitForClients(databasePool) - if (adminPool) { - await adminPool.query( - 'SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1', - [database] - ) - await adminPool.query(`DROP DATABASE IF EXISTS "${database}"`) - await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) - } - } finally { - await adminPool?.end() - } - }) - - async function fixture() { - const users = [randomUUID(), randomUUID()].sort() - const teams = [randomUUID(), randomUUID()].sort() - const resources = [randomUUID(), randomUUID()] - const drive = `revision-writers-${randomBytes(6).toString('hex')}` - for (const id of users) { - await databasePool.query( - "INSERT INTO users(id, email, name) VALUES ($1, $2, 'Revision writer')", - [id, `revision-${id}@example.test`] - ) - } - for (const id of teams) { - await databasePool.query('INSERT INTO teams(id, name) VALUES ($1, $2)', [id, `Team ${id}`]) - } - for (const [index, id] of resources.entries()) { - await databasePool.query( - "INSERT INTO gfs_resources(resource_id, drive, name, kind) VALUES ($1, $2, 'order', 'file')", - [id, index === 0 ? drive : `${drive}-b`] - ) - } - return { users, teams, resources, drive } - } - - async function revision(type: 'user' | 'team' | 'resource', id: string): Promise { - const result = await databasePool.query<{ revision: string }>( - `SELECT revision::text FROM authorization_${type}_revisions WHERE ${type}_id::text = $1`, - [id] - ) - expect(result.rows.length).toBeLessThanOrEqual(1) - return Number(result.rows[0]?.revision ?? 0) - } - - async function pid(client: PoolClient): Promise { - return (await client.query<{ pid: number }>('SELECT pg_backend_pid() AS pid')).rows[0]!.pid - } - - async function waitForBlock(blocked: number, blockers: number[]): Promise { - const deadline = Date.now() + 5_000 - while (Date.now() < deadline) { - const result = await databasePool.query<{ pids: number[] }>( - 'SELECT pg_blocking_pids($1::int) AS pids', - [blocked] - ) - const blocker = blockers.find(candidate => result.rows[0]!.pids.includes(candidate)) - if (blocker !== undefined) return blocker - await new Promise(resolve => setTimeout(resolve, 10)) - } - throw new Error(`writer ${blocked} did not block on one of ${blockers.join(', ')}`) - } - - async function begin(client: PoolClient, application: string) { - await client.query('BEGIN') - await client.query("SELECT set_config('application_name', $1, true)", [application]) - await client.query("SET LOCAL deadlock_timeout = '100ms'") - await client.query("SET LOCAL statement_timeout = '8s'") - } - - async function finish(client: PoolClient, outcome: Outcome) { - await client.query(outcome.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') - } - - async function barrier(application: string, table: string, id: string, gate: number) { - await databasePool.query('INSERT INTO revision_writer_barriers VALUES ($1, $2, $3, $4)', [ - application, - `authorization_${table}_revisions`, - id, - gate, - ]) - } - - async function stagedWriters( - firstWrite: (client: PoolClient) => Promise, - secondWrite: (client: PoolClient) => Promise, - secondGate = false - ) { - const gateOne = await databasePool.connect() - const gateTwo = await databasePool.connect() - const first = await databasePool.connect() - const second = await databasePool.connect() - const outcomes: Outcome[] = [] - try { - await gateOne.query('SELECT pg_advisory_lock($1, 1)', [barrierClass]) - await gateTwo.query('SELECT pg_advisory_lock($1, 2)', [barrierClass]) - const firstPid = await pid(first) - const secondPid = await pid(second) - const gateOnePid = await pid(gateOne) - const gateTwoPid = await pid(gateTwo) - await begin(first, 'revision-first') - await begin(second, 'revision-second') - const firstOutcome = observe(firstWrite(first)) - await waitForBlock(firstPid, [gateOnePid]) - const secondOutcome = observe(secondWrite(second)) - const secondBlocker = await waitForBlock( - secondPid, - secondGate ? [firstPid, gateTwoPid] : [firstPid] - ) - // Opposite first rows are directly observed through pg_blocking_pids; - // releasing both barriers then lets PostgreSQL detect the real 40P01. - await gateOne.query('SELECT pg_advisory_unlock($1, 1)', [barrierClass]) - if (secondBlocker === gateTwoPid) { - await gateTwo.query('SELECT pg_advisory_unlock($1, 2)', [barrierClass]) - } - const one = await firstOutcome - outcomes.push(one) - await finish(first, one) - if (secondGate && secondBlocker === firstPid) { - await waitForBlock(secondPid, [gateTwoPid]) - await gateTwo.query('SELECT pg_advisory_unlock($1, 2)', [barrierClass]) - } - const two = await secondOutcome - outcomes.push(two) - await finish(second, two) - } finally { - await gateOne.query('SELECT pg_advisory_unlock_all()').catch(() => undefined) - await gateTwo.query('SELECT pg_advisory_unlock_all()').catch(() => undefined) - await first.query('ROLLBACK').catch(() => undefined) - await second.query('ROLLBACK').catch(() => undefined) - for (const client of [gateOne, gateTwo, first, second]) client.release() - await databasePool.query('TRUNCATE revision_writer_barriers') - } - expect(outcomes).toEqual([{ status: 'fulfilled' }, { status: 'fulfilled' }]) - } - - async function grant(drive: string, resource: string, type: string, id: string) { - await databasePool.query( - `INSERT INTO gfs_grants(drive, resource_id, subject_type, subject_id, permissions) - VALUES ($1, $2, $3, $4, ARRAY['read'])`, - [drive, resource, type, id] - ) - } - - it.each( - ['gfs_grants', 'gfs_shares'].flatMap(table => - ['INSERT', 'UPDATE', 'DELETE'].map(operation => [table, operation] as const) - ) - )('takes the resource before the subject for %s %s', async (table, operation) => { - const { - users: [user], - resources: [resource], - drive, - } = await fixture() - // Keep a committed authority visible to the resource writer, including - // when the competing authority row is an uncommitted INSERT or DELETE. - await grant(drive, resource!, 'user', user!) - const authorityId = randomUUID() - if (operation !== 'INSERT') { - await databasePool.query( - `INSERT INTO ${table}(id, drive, resource_id, subject_type, subject_id, permissions) - VALUES ($1, $2, $3, 'user', $4, ARRAY['read']) - ON CONFLICT (drive, resource_id, subject_type, subject_id) DO UPDATE - SET id = EXCLUDED.id`, - [authorityId, drive, resource, user] - ) - } - // INSERT uses the same subject but another authority table to avoid an - // unrelated unique-key conflict with the committed witness above. - if (operation === 'INSERT' && table === 'gfs_grants') { - await databasePool.query( - `INSERT INTO gfs_shares(drive, resource_id, subject_type, subject_id, permissions) - VALUES ($1, $2, 'user', $3, ARRAY['read'])`, - [drive, resource, user] - ) - await databasePool.query('DELETE FROM gfs_grants WHERE resource_id = $1', [resource]) - } - const beforeUser = await revision('user', user!) - const beforeResource = await revision('resource', resource!) - await barrier('revision-first', 'resource', resource!, 1) - await stagedWriters( - client => - client.query('UPDATE gfs_resources SET name = name || $2 WHERE resource_id = $1', [ - resource, - '-resource', - ]), - client => - operation === 'INSERT' - ? client.query( - `INSERT INTO ${table}(drive, resource_id, subject_type, subject_id, permissions) - VALUES ($1, $2, 'user', $3, ARRAY['read'])`, - [drive, resource, user] - ) - : client.query( - operation === 'UPDATE' - ? `UPDATE ${table} SET permissions = ARRAY['read', 'write'] WHERE id = $1` - : `DELETE FROM ${table} WHERE id = $1`, - [authorityId] - ) - ) - expect(await revision('resource', resource!)).toBe(beforeResource + 2) - expect(await revision('user', user!)).toBe(beforeUser + 2) - }) - - it.each(['workflow', 'membership'] as const)( - 'takes teams before users for a %s write competing with a resource UPDATE', - async writer => { - const { - users: [user], - teams: [team], - resources: [resource], - drive, - } = await fixture() - await grant(drive, resource!, 'team', team!) - await grant(drive, resource!, 'user', user!) - if (writer === 'membership') { - await databasePool.query( - "INSERT INTO team_members(team_id, user_id, role, status) VALUES ($1, $2, 'member', 'active')", - [team, user] - ) - } - const beforeUser = await revision('user', user!) - const beforeTeam = await revision('team', team!) - const beforeResource = await revision('resource', resource!) - await barrier('revision-first', 'team', team!, 1) - await stagedWriters( - client => - client.query('UPDATE gfs_resources SET name = name || $2 WHERE resource_id = $1', [ - resource, - '-resource', - ]), - client => - writer === 'workflow' - ? // A phase-only UPDATE bumps no revision, so the workflow writer is - // a run INSERT of the same team and user: it takes the team first. - createRun( - { - recipe_namespace: 'test', - recipe_name: 'revision-order', - actor_type: 'user', - team_id: team, - usage_team_id: team, - actor_id: user, - idempotency_key: null, - trigger_source: 'onDemand', - ttl_seconds_after_finished: 2_592_000, - }, - client - ) - : client.query( - "UPDATE team_members SET role = 'admin' WHERE team_id = $1 AND user_id = $2", - [team, user] - ) - ) - // The resource UPDATE bumps the resource and its two subjects once each; - // the run INSERT adds one user bump and two team bumps. - expect(await revision('user', user!)).toBe(beforeUser + 2) - expect(await revision('team', team!)).toBe(beforeTeam + (writer === 'workflow' ? 3 : 2)) - expect(await revision('resource', resource!)).toBe(beforeResource + 1) - } - ) - - it.each(['workflow usage team', 'workflow attribution change', 'membership team change'])( - 'orders every affected team for a %s', - async writer => { - const { - users: [user], - teams, - resources: [resource], - drive, - } = await fixture() - for (const team of teams) await grant(drive, resource!, 'team', team) - await grant(drive, resource!, 'user', user!) - let runId: string | undefined - if (writer === 'workflow attribution change') { - runId = ( - await databasePool.query<{ run_id: string }>( - `INSERT INTO workflow_runs(recipe_namespace, recipe_name, phase, actor_type, actor_id, - trigger_source, team_id, usage_team_id) - VALUES ('test', 'team-order', 'Pending', 'user', $1, 'onDemand', $2, $3) - RETURNING run_id::text`, - [user, teams[0], null] - ) - ).rows[0]!.run_id - } else if (writer === 'membership team change') { - await databasePool.query( - "INSERT INTO team_members(team_id, user_id, role, status) VALUES ($1, $2, 'member', 'active')", - [teams[0], user] - ) - } - const beforeTeams = await Promise.all(teams.map(team => revision('team', team))) - const beforeUser = await revision('user', user!) - const beforeResource = await revision('resource', resource!) - await barrier('revision-first', 'team', teams[0]!, 1) - await stagedWriters( - client => - client.query('UPDATE gfs_resources SET name = name || $2 WHERE resource_id = $1', [ - resource, - '-team-order', - ]), - client => - writer === 'workflow usage team' - ? // A phase-only UPDATE bumps no revision, so the two-team workflow - // writer is a run INSERT whose team and usage team are reversed - // against the sorted order the trigger must still follow. - createRun( - { - recipe_namespace: 'test', - recipe_name: 'team-order', - actor_type: 'user', - team_id: teams[1], - usage_team_id: teams[0], - actor_id: user, - idempotency_key: null, - trigger_source: 'onDemand', - ttl_seconds_after_finished: 2_592_000, - }, - client - ) - : writer === 'workflow attribution change' - ? client.query( - "UPDATE workflow_runs SET team_id = $2, phase = 'Running' WHERE run_id = $1", - [runId, teams[1]] - ) - : client.query( - 'UPDATE team_members SET team_id = $3 WHERE team_id = $1 AND user_id = $2', - [teams[0], user, teams[1]] - ) - ) - // The resource UPDATE bumps both teams and the user once; the run INSERT - // adds one bump per team and one for the user. The two UPDATE writers - // bump a NEW and an OLD attribution pair. - for (const [index, team] of teams.entries()) { - expect(await revision('team', team)).toBe(beforeTeams[index]! + 2) - } - expect(await revision('user', user!)).toBe( - beforeUser + (writer === 'workflow usage team' ? 2 : 3) - ) - expect(await revision('resource', resource!)).toBe(beforeResource + 1) - } - ) - - it.each(['removal', 'change'] as const)( - 'orders OLD and NEW audit subjects together for a team %s', - async operation => { - const { - users: [user], - teams, - resources: [resource], - drive, - } = await fixture() - const affectedTeams = operation === 'removal' ? teams.slice(0, 1) : teams - for (const team of affectedTeams) await grant(drive, resource!, 'team', team) - await grant(drive, resource!, 'user', user!) - const runId = randomUUID() - await databasePool.query( - `INSERT INTO workflow_runs_audit( - run_id, run_namespace, run_name, recipe_namespace, recipe_name, - triggerer_team_id, triggerer_user_id, triggerer_actor_type, - trigger_source, idempotency_key, triggered_at, completed_at, - final_phase, step_count, snapshot_sha - ) VALUES ( - $1, 'revision', $1::uuid::text, 'revision', 'audit-order', - $2, $3, 'user', 'onDemand', $1::uuid::text, NOW(), NOW(), - 'Succeeded', 0, 'audit-order-witness' - )`, - [runId, teams[0], user] - ) - const beforeTeams = await Promise.all(affectedTeams.map(team => revision('team', team))) - const beforeUser = await revision('user', user!) - const beforeResource = await revision('resource', resource!) - const nextTeam = operation === 'removal' ? null : teams[1] - await barrier('revision-first', 'team', teams[0]!, 1) - await stagedWriters( - client => - client.query('UPDATE gfs_resources SET name = name || $2 WHERE resource_id = $1', [ - resource, - '-audit-order', - ]), - // NULL is also the row image produced by the audit team's FK - // ON DELETE SET NULL action. Both images must share one sort. - client => - client.query('UPDATE workflow_runs_audit SET triggerer_team_id = $2 WHERE run_id = $1', [ - runId, - nextTeam, - ]) - ) - for (const [index, team] of affectedTeams.entries()) { - expect(await revision('team', team)).toBe(beforeTeams[index]! + 2) - } - expect(await revision('user', user!)).toBe(beforeUser + 3) - expect(await revision('resource', resource!)).toBe(beforeResource + 1) - expect( - ( - await databasePool.query( - 'SELECT triggerer_team_id FROM workflow_runs_audit WHERE run_id = $1', - [runId] - ) - ).rows - ).toEqual([{ triggerer_team_id: nextTeam }]) - } - ) - - for (const kind of ['grant', 'share'] as const) { - it.each(['user IDs', 'subject types'] as const)( - `orders ${kind} batch %s with opposite overlapping request orders`, - async subjectCase => { - const { - users, - teams: [team], - resources, - drive, - } = await fixture() - const subjects: GfsSubject[] = - subjectCase === 'user IDs' - ? users.map(id => ({ type: 'user', id })) - : [ - { type: 'team', id: team }, - { type: 'user', id: users[0] }, - ] - const before = await Promise.all( - subjects.map(subject => revision(subject.type as 'team' | 'user', subject.id!)) - ) - const beforeResources = await Promise.all(resources.map(id => revision('resource', id))) - for (const table of ['user', 'team']) { - await barrier('revision-first', table, '', 1) - await barrier('revision-second', table, '', 2) - } - const write = async (client: PoolClient, resource: string, targets: GfsSubject[]) => { - const params = { - req: { - ip: '127.0.0.1', - headers: {}, - adminAuth: { sub: users[0] }, - } as unknown as Request, - caller: { isOperator: true, actorKey: 'operator:', subjects: new Set(['operator:']) }, - drive: resource === resources[0] ? drive : `${drive}-b`, - resourceId: resource, - subjects: targets, - permissions: ['read'] as const, - } - const result = - kind === 'grant' - ? await writeGfsGrantBatchInTransaction(client, { ...params, inherit: false }) - : await writeGfsShareBatchInTransaction(client, { - ...params, - includeDescendants: false, - }) - expect(result.error).toBeNull() - } - await stagedWriters( - client => write(client, resources[0]!, [...subjects].reverse()), - client => write(client, resources[1]!, subjects), - true - ) - for (const [index, subject] of subjects.entries()) { - expect(await revision(subject.type as 'team' | 'user', subject.id!)).toBe( - before[index]! + 2 - ) - } - for (const [index, resource] of resources.entries()) { - expect(await revision('resource', resource)).toBe( - beforeResources[index]! + subjects.length - ) - } - const rows = await databasePool.query<{ count: string }>( - `SELECT count(*)::text FROM gfs_${kind === 'grant' ? 'grants' : 'shares'} - WHERE resource_id = ANY($1::uuid[])`, - [resources] - ) - expect(Number(rows.rows[0]!.count)).toBe(subjects.length * 2) - } - ) - } - - it('accepts exactly the Admin UI usage-team sentinel for INSERT, UPDATE and DELETE', async () => { - const adminId = randomUUID() - const query = vi.spyOn(pool, 'query').mockImplementation(databasePool.query.bind(databasePool)) - let runId: string - try { - const result = await triggerWorkflow({ - gateway: { - getResource: async () => ({ - metadata: { name: 'admin-revision', namespace: config.sandboxNamespace }, - spec: { triggers: { onDemand: { requiresApproval: false } } }, - }), - } as unknown as K8sGateway, - caller: { kind: 'admin-ui', userId: adminId }, - recipeNamespace: config.sandboxNamespace, - recipeName: 'admin-revision', - body: {}, - idempotencyKey: randomUUID(), - }) - expect(result.kind).toBe('run') - if (result.kind !== 'run') throw new Error('expected a real Admin UI run') - expect(result.created).toBe(true) - expect(result.row.usage_team_id).toBe('control-plane-admin-ui') - runId = result.row.run_id - expect( - (await databasePool.query('SELECT run_id FROM workflow_runs WHERE run_id = $1', [runId])) - .rowCount - ).toBe(1) - } finally { - query.mockRestore() - } - expect( - ( - await databasePool.query( - // A phase-only UPDATE skips the run revision trigger, so the sentinel - // is witnessed on UPDATE only when a guarded column changes too. - "UPDATE workflow_runs SET phase = 'Running', actor_id = $2 WHERE run_id = $1 RETURNING run_id", - [runId, randomUUID()] - ) - ).rowCount - ).toBe(1) - expect( - ( - await databasePool.query('DELETE FROM workflow_runs WHERE run_id = $1 RETURNING run_id', [ - runId, - ]) - ).rowCount - ).toBe(1) - const { - users: [user], - teams: [team], - } = await fixture() - const beforeUser = await revision('user', user!) - const beforeTeam = await revision('team', team!) - expect( - ( - await databasePool.query( - `INSERT INTO workflow_runs(recipe_namespace, recipe_name, phase, actor_type, actor_id, - trigger_source, usage_team_id) VALUES ('test', 'uuid-revision', 'Pending', 'user', $1, - 'onDemand', $2) RETURNING run_id`, - [user, team] - ) - ).rowCount - ).toBe(1) - expect(await revision('user', user!)).toBe(beforeUser + 1) - expect(await revision('team', team!)).toBe(beforeTeam + 1) - for (const invalid of ['not-a-team', 'CONTROL-PLANE-ADMIN-UI', 'control-plane-admin-ui ']) { - await expect( - databasePool.query( - `INSERT INTO workflow_runs(recipe_namespace, recipe_name, phase, actor_type, actor_id, - trigger_source, usage_team_id) VALUES ('test', 'invalid-revision', 'Pending', 'admin', - $1, 'onDemand', $2)`, - [adminId, invalid] - ) - ).rejects.toMatchObject({ - code: 'P0001', - message: expect.stringContaining('invalid catalog team subject identifier'), - }) - } - // The sentinel exception is confined to usage_team_id; it is not a valid actor UUID. - for (const type of ['user', 'team']) { - await expect( - databasePool.query( - "SELECT authorization_bump_subject_revision($1, 'control-plane-admin-ui')", - [type] - ) - ).rejects.toMatchObject({ - code: 'P0001', - message: expect.stringContaining(`invalid catalog ${type} subject identifier`), - }) - } - }) - - it('a delivery claim takes no revision row while a run INSERT holds its subjects', async () => { - const { - users: [user], - teams: [team], - } = await fixture() - // Two deliveries, one per audience an approval delivery can name - // (notificationEmitter.ts:18-23). The user row is older, so the claim's - // ORDER BY visits it first: the cycle shape the review probe reproduced as - // P1/P2. - const inserted = await databasePool.query<{ id: string; event_type: string }>( - `INSERT INTO notification_deliveries( - event_type, dedupe_key, audience, payload, status, next_attempt_at, created_at - ) VALUES - ('approval.requested', $1, jsonb_build_object('userId', $2::text), '{}'::jsonb, - 'queued', NOW() - INTERVAL '1 minute', NOW() - INTERVAL '2 minutes'), - ('approval.requested', $3, jsonb_build_object('teamId', $4::text), '{}'::jsonb, - 'queued', NOW() - INTERVAL '1 minute', NOW() - INTERVAL '1 minute') - RETURNING id::text`, - [`claim-user-${randomUUID()}`, user, `claim-team-${randomUUID()}`, team] - ) - const deliveryIds = inserted.rows.map(row => row.id) - - // The claim tail, copied from notificationDeliveryQueueService.ts:299-321 - // (`locked` + `claimed`); only the candidates source is a test fixture. - const claim = ` - WITH candidates AS ( - SELECT nd.id, - 'telegram'::text AS medium, - 'provider-user'::text AS provider_user_id, - NULL::text AS provider_workspace_id, - 'provider-channel'::text AS provider_channel_id, - NULL::text AS communication_channel_ref, - 'sandbox-recipes/claim'::text AS mcp_host_ref, - 1 AS sort_priority, - nd.created_at - FROM notification_deliveries nd - WHERE nd.id = ANY($1::uuid[]) - ), - locked AS ( - SELECT nd.id, - c.medium, - c.provider_user_id, - c.provider_workspace_id, - c.provider_channel_id, - c.communication_channel_ref, - c.mcp_host_ref - FROM notification_deliveries nd - JOIN candidates c ON c.id = nd.id - ORDER BY c.sort_priority ASC, - c.created_at ASC, - nd.id ASC - LIMIT $2 - FOR UPDATE OF nd SKIP LOCKED - ), - claimed AS ( - UPDATE notification_deliveries nd - SET status = 'retrying', - attempts = nd.attempts + 1, - next_attempt_at = NOW() + INTERVAL '30 seconds' - FROM locked c - WHERE nd.id = c.id - RETURNING nd.id - ) - SELECT id::text FROM claimed` - - async function revisionRowHeld(table: string, column: string, id: string): Promise { - const witness = await databasePool.connect() - try { - await witness.query('BEGIN') - await witness.query(`SELECT 1 FROM ${table} WHERE ${column} = $1 FOR UPDATE NOWAIT`, [id]) - return 'acquired' - } catch (error) { - return String((error as { code?: string }).code) - } finally { - await witness.query('ROLLBACK').catch(() => undefined) - witness.release() - } - } - - const beforeUser = await revision('user', user!) - const beforeTeam = await revision('team', team!) - const runInsert = await databasePool.connect() - const claimSession = await databasePool.connect() - let claimSettled = false - try { - // Session B: the competing run INSERT of (team, user), uncommitted. The - // run trigger takes the team revision before the user revision. - await begin(runInsert, 'claim-run-insert') - const runOutcome = observe( - createRun( - { - recipe_namespace: 'test', - recipe_name: 'claim-order', - actor_type: 'user', - team_id: team, - usage_team_id: team, - actor_id: user, - idempotency_key: null, - trigger_source: 'onDemand', - ttl_seconds_after_finished: 2_592_000, - }, - runInsert - ) - ) - // N8 witness: before the claim runs, the uncommitted run INSERT really - // holds both revision rows. `FOR UPDATE NOWAIT` is the direct probe. - const deadline = Date.now() + 5_000 - let held = ['', ''] - while (Date.now() < deadline) { - held = [ - await revisionRowHeld('authorization_user_revisions', 'user_id', user!), - await revisionRowHeld('authorization_team_revisions', 'team_id', team!), - ] - if (held.every(code => code === '55P03')) break - await new Promise(resolve => setTimeout(resolve, 10)) - } - expect(held).toEqual(['55P03', '55P03']) - - // Session A: the claim with a 1 s lock timeout. Without the guard the - // claim's first row (the user audience) waits for the run INSERT's user - // revision and fails with 55P03, as probe P2 measured at 1.5 s (57014). - await begin(claimSession, 'claim-session') - await claimSession.query("SET LOCAL lock_timeout = '1s'") - const claimOutcome = claimSession.query<{ id: string }>(claim, [deliveryIds, 2]).then( - result => ({ status: 'fulfilled' as const, ids: result.rows.map(row => row.id) }), - error => ({ - status: 'rejected' as const, - code: String((error as { code?: string }).code), - ids: [] as string[], - }) - ) - const claimResult = await claimOutcome - await claimSession.query(claimResult.status === 'fulfilled' ? 'COMMIT' : 'ROLLBACK') - claimSettled = true - const runResult = await runOutcome - await runInsert.query('ROLLBACK') - console.info('DELIVERY_CLAIM_OUTCOMES', { - claim: claimResult, - runInsert: runResult.status, - }) - expect(claimResult.status).toBe('fulfilled') - expect(claimResult.ids.sort()).toEqual([...deliveryIds].sort()) - expect(runResult.status).toBe('fulfilled') - - // Write witness: the claim really claimed both rows. - const claimedRows = await databasePool.query<{ - id: string - status: string - attempts: number - }>( - `SELECT id::text, status, attempts FROM notification_deliveries - WHERE id = ANY($1::uuid[])`, - [deliveryIds] - ) - expect( - Object.fromEntries( - claimedRows.rows.map(row => [row.id, { status: row.status, attempts: row.attempts }]) - ) - ).toEqual({ - [deliveryIds[0]!]: { status: 'retrying', attempts: 1 }, - [deliveryIds[1]!]: { status: 'retrying', attempts: 1 }, - }) - // Negative witness: the claim took no revision row, and the run INSERT - // rolled back, so both subjects stay where the fixture left them. - expect(await revision('user', user!)).toBe(beforeUser) - expect(await revision('team', team!)).toBe(beforeTeam) - } finally { - await Promise.resolve() - if (!claimSettled) await claimSession.query('ROLLBACK').catch(() => undefined) - await runInsert.query('ROLLBACK').catch(() => undefined) - claimSession.release() - runInsert.release() - await databasePool.query('DELETE FROM notification_deliveries WHERE id = ANY($1::uuid[])', [ - deliveryIds, - ]) - } - }) -}) diff --git a/control-api/test/routes.adminWorkflows.test.ts b/control-api/test/routes.adminWorkflows.test.ts index 8067487a58..4e28b7cfc4 100644 --- a/control-api/test/routes.adminWorkflows.test.ts +++ b/control-api/test/routes.adminWorkflows.test.ts @@ -732,7 +732,6 @@ describe('routes/admin/workflows', () => { rowCount: 1, }) .mockResolvedValueOnce({ rows: [{}], rowCount: 1 }) - .mockResolvedValueOnce({ rows: [], rowCount: 0 }) .mockResolvedValueOnce({ rows: [{ teamId: null }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [row], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: null }) @@ -843,14 +842,6 @@ describe('routes/admin/workflows', () => { 'test-recipe', `${RECIPE_NS}/test-recipe`, ]) - // The consume prelock runs before the UPDATE that bumps the target. - const statements = txQuery.mock.calls.map(([sql]) => String(sql)) - const prelock = statements.findIndex(sql => - sql.includes('authorization_prelock_workflow_approval_trigger_revisions') - ) - const consumed = statements.findIndex(sql => sql.includes("SET status = 'consumed'")) - expect(prelock).toBeGreaterThan(-1) - expect(prelock).toBeLessThan(consumed) }) it('rejects mcp-host-control triggers without approvalRequestId', async () => { diff --git a/control-api/test/services.directory.teams.test.ts b/control-api/test/services.directory.teams.test.ts index 517c3a3165..a856b470fe 100644 --- a/control-api/test/services.directory.teams.test.ts +++ b/control-api/test/services.directory.teams.test.ts @@ -263,13 +263,11 @@ describe('services/directory team management unit tests', () => { }) it('adminDeleteUser returns not_found when user missing', async () => { - dbMocks.txQuery.mockResolvedValueOnce({ rows: [], rowCount: 1 }) dbMocks.txQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }) await expect(adminDeleteUser('u1')).resolves.toEqual({ error: 'not_found' }) }) - it('adminDeleteUser delegates ordered revision locking to the definer before deleting', async () => { - dbMocks.txQuery.mockResolvedValueOnce({ rows: [], rowCount: 1 }) + it('adminDeleteUser does not inspect team memberships before deleting the user row', async () => { dbMocks.txQuery.mockResolvedValueOnce({ rows: [{ x: 1 }], rowCount: 1 }) dbMocks.txQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }) dbMocks.txQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }) @@ -277,17 +275,11 @@ describe('services/directory team management unit tests', () => { dbMocks.txQuery.mockResolvedValueOnce({ rows: [{ id: 'u1' }], rowCount: 1 }) await expect(adminDeleteUser('u1')).resolves.toEqual({ ok: true, id: 'u1' }) - expect(dbMocks.txQuery).toHaveBeenNthCalledWith( - 1, - 'SELECT public.authorization_prelock_user_retirement_revisions($1::uuid[])', - [['u1']] - ) const sqls = dbMocks.txQuery.mock.calls.map(([sql]) => String(sql)) expect(sqls.some(sql => sql.includes('FROM team_members'))).toBe(false) }) it('adminDeleteUser disables approval mediums before deleting the user row', async () => { - dbMocks.txQuery.mockResolvedValueOnce({ rows: [], rowCount: 1 }) dbMocks.txQuery.mockResolvedValueOnce({ rows: [{ x: 1 }], rowCount: 1 }) dbMocks.txQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }) dbMocks.txQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }) @@ -295,36 +287,35 @@ describe('services/directory team management unit tests', () => { dbMocks.txQuery.mockResolvedValueOnce({ rows: [{ id: 'u1' }], rowCount: 1 }) await expect(adminDeleteUser('u1')).resolves.toEqual({ ok: true, id: 'u1' }) expect(dbMocks.txQuery).toHaveBeenNthCalledWith( - 3, + 2, expect.stringContaining('FROM gfs_desktop_operator_links'), ['u1'] ) expect(dbMocks.txQuery).toHaveBeenNthCalledWith( - 4, + 3, expect.stringContaining('UPDATE workflow_approval_medium_accounts'), ['u1'] ) expect(dbMocks.txQuery).toHaveBeenNthCalledWith( - 5, + 4, expect.stringContaining('UPDATE workflow_approval_medium_challenges'), ['u1'] ) expect(dbMocks.txQuery).toHaveBeenNthCalledWith( - 6, + 5, expect.stringContaining('DELETE FROM users'), ['u1'] ) }) it('refuses the legacy hard-delete when operator-link history is retained', async () => { - dbMocks.txQuery.mockResolvedValueOnce({ rows: [], rowCount: 1 }) dbMocks.txQuery.mockResolvedValueOnce({ rows: [{ x: 1 }], rowCount: 1 }) dbMocks.txQuery.mockResolvedValueOnce({ rows: [{ '?column?': 1 }], rowCount: 1 }) await expect(adminDeleteUser('u1')).resolves.toEqual({ error: 'gfs_operator_link_history_retained', }) - expect(dbMocks.txQuery).toHaveBeenCalledTimes(3) + expect(dbMocks.txQuery).toHaveBeenCalledTimes(2) expect(dbMocks.txQuery).not.toHaveBeenCalledWith(expect.stringContaining('DELETE FROM users'), [ 'u1', ]) diff --git a/control-api/test/services.directory.userRetirement.test.ts b/control-api/test/services.directory.userRetirement.test.ts index be078bb14b..5d808cc3a5 100644 --- a/control-api/test/services.directory.userRetirement.test.ts +++ b/control-api/test/services.directory.userRetirement.test.ts @@ -78,15 +78,14 @@ describe('retireDesktopUser', () => { it('retires an active linked user atomically with exact control-admin actor and audit correlation', async () => { const { retireDesktopUser } = await import('../src/services/directory/users.js') const authorize = vi.fn(async () => { - expect(mocks.txQuery.mock.calls.at(-1)).toEqual([ - 'SELECT public.authorization_prelock_user_retirement_revisions($1::uuid[])', - [[USER_ID]], - ]) + const [sql, params] = mocks.txQuery.mock.calls.at(-1) ?? [] + expect(String(sql)).toContain('FROM users') + expect(String(sql)).toContain('FOR UPDATE') + expect(params).toEqual([USER_ID]) }) mocks.txQuery .mockResolvedValueOnce({ rows: [{ id: OPERATION_ID }], rowCount: 1 }) .mockResolvedValueOnce(activeUser()) - .mockResolvedValueOnce({ rows: [], rowCount: 1 }) .mockResolvedValueOnce({ rows: [{ has_link_history: true }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }) @@ -123,18 +122,18 @@ describe('retireDesktopUser', () => { operationId: OPERATION_ID, }) ) - const userUpdate = String(mocks.txQuery.mock.calls[6]?.[0]) + const userUpdate = String(mocks.txQuery.mock.calls[5]?.[0]) expect(userUpdate).toContain("lifecycle_state = 'retired'") - expect(String(mocks.txQuery.mock.calls[4]?.[0])).toContain( + expect(String(mocks.txQuery.mock.calls[3]?.[0])).toContain( 'UPDATE workflow_approval_medium_accounts' ) - expect(String(mocks.txQuery.mock.calls[5]?.[0])).toContain( + expect(String(mocks.txQuery.mock.calls[4]?.[0])).toContain( 'UPDATE workflow_approval_medium_challenges' ) expect(userUpdate).toContain('retired_by_control_admin_id = $4::uuid') expect(userUpdate).toContain('retired_by_desktop_user_id = $5::uuid') expect(userUpdate).toContain('lifecycle_version = $8') - expect(mocks.txQuery.mock.calls[6]?.[1]).toEqual([ + expect(mocks.txQuery.mock.calls[5]?.[1]).toEqual([ USER_ID, 'policy retirement', 'control_admin', @@ -151,7 +150,6 @@ describe('retireDesktopUser', () => { mocks.txQuery .mockResolvedValueOnce({ rows: [{ id: OPERATION_ID }], rowCount: 1 }) .mockResolvedValueOnce(activeUser()) - .mockResolvedValueOnce({ rows: [], rowCount: 1 }) .mockResolvedValueOnce({ rows: [{ has_link_history: true }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }) @@ -173,7 +171,7 @@ describe('retireDesktopUser', () => { actor: { kind: 'platform_user', desktopUserId: MANAGER_ID }, }) ) - expect(mocks.txQuery.mock.calls[6]?.[1]).toEqual([ + expect(mocks.txQuery.mock.calls[5]?.[1]).toEqual([ USER_ID, 'manager retirement', 'platform_user', @@ -249,7 +247,6 @@ describe('retireDesktopUser', () => { mocks.txQuery .mockResolvedValueOnce({ rows: [{ id: OPERATION_ID }], rowCount: 1 }) .mockResolvedValueOnce(activeUser()) - .mockResolvedValueOnce({ rows: [], rowCount: 1 }) .mockResolvedValueOnce({ rows: [{ has_link_history: false }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }) @@ -272,8 +269,8 @@ describe('retireDesktopUser', () => { replayed: false, }) expect(mocks.retireParentInTransaction).not.toHaveBeenCalled() - expect(String(mocks.txQuery.mock.calls[6]?.[0])).toContain('DELETE FROM users') - expect(String(mocks.txQuery.mock.calls[7]?.[0])).toContain("outcome = 'deleted'") + expect(String(mocks.txQuery.mock.calls[5]?.[0])).toContain('DELETE FROM users') + expect(String(mocks.txQuery.mock.calls[6]?.[0])).toContain("outcome = 'deleted'") }) it('retires a user after the Control UI already revoked the operator generation', async () => { @@ -281,7 +278,6 @@ describe('retireDesktopUser', () => { mocks.txQuery .mockResolvedValueOnce({ rows: [{ id: OPERATION_ID }], rowCount: 1 }) .mockResolvedValueOnce(activeUser()) - .mockResolvedValueOnce({ rows: [], rowCount: 1 }) .mockResolvedValueOnce({ rows: [{ has_link_history: true }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }) @@ -327,7 +323,6 @@ describe('retireDesktopUser', () => { mocks.txQuery .mockResolvedValueOnce({ rows: [{ id: OPERATION_ID }], rowCount: 1 }) .mockResolvedValueOnce(activeUser()) - .mockResolvedValueOnce({ rows: [], rowCount: 1 }) .mockResolvedValueOnce({ rows: [{ has_link_history: true }], rowCount: 1 }) mocks.retireParentInTransaction.mockRejectedValueOnce(new Error('audit unavailable')) @@ -351,7 +346,6 @@ describe('retireDesktopUser', () => { mocks.txQuery .mockResolvedValueOnce({ rows: [{ id: OPERATION_ID }], rowCount: 1 }) .mockResolvedValueOnce(activeUser(7)) - .mockResolvedValueOnce({ rows: [], rowCount: 1 }) .mockResolvedValueOnce({ rows: [{ has_link_history: true }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }) @@ -367,8 +361,8 @@ describe('retireDesktopUser', () => { 'request-cas-1' ) ).rejects.toMatchObject({ code: 'retirement_conflict' }) - expect(mocks.txQuery).toHaveBeenCalledTimes(7) - expect(String(mocks.txQuery.mock.calls[6]?.[0])).toContain('lifecycle_version = $8') + expect(mocks.txQuery).toHaveBeenCalledTimes(6) + expect(String(mocks.txQuery.mock.calls[5]?.[0])).toContain('lifecycle_version = $8') }) it('rejects an actor with the wrong typed identity field before opening a transaction', async () => { @@ -392,7 +386,6 @@ describe('retireDesktopUser', () => { mocks.txQuery .mockResolvedValueOnce({ rows: [{ id: OPERATION_ID }], rowCount: 1 }) .mockResolvedValueOnce(activeUser()) - .mockResolvedValueOnce({ rows: [], rowCount: 1 }) .mockResolvedValueOnce({ rows: [{ has_link_history: false }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 1 }) @@ -421,9 +414,9 @@ describe('retireDesktopUser', () => { expect(mocks.withTransaction).not.toHaveBeenCalled() const sqls = mocks.txQuery.mock.calls.map(([sql]) => String(sql)) expect(sqls.some(sql => sql.includes('DELETE FROM users'))).toBe(false) - expect(sqls[4]).toContain('UPDATE workflow_approval_medium_accounts') - expect(sqls[5]).toContain('UPDATE workflow_approval_medium_challenges') - expect(sqls[6]).toContain("SET lifecycle_state = 'retired'") - expect(sqls[7]).toContain("outcome = 'retired'") + expect(sqls[3]).toContain('UPDATE workflow_approval_medium_accounts') + expect(sqls[4]).toContain('UPDATE workflow_approval_medium_challenges') + expect(sqls[5]).toContain("SET lifecycle_state = 'retired'") + expect(sqls[6]).toContain("outcome = 'retired'") }) }) diff --git a/control-api/test/services.userApprovalRequestArchive.test.ts b/control-api/test/services.userApprovalRequestArchive.test.ts index 7c5f270e4d..57d176cb08 100644 --- a/control-api/test/services.userApprovalRequestArchive.test.ts +++ b/control-api/test/services.userApprovalRequestArchive.test.ts @@ -40,9 +40,6 @@ const mockTransactionQuery = vi.fn(async (sql: unknown, params?: unknown[]) => { const ids = (params?.[0] as string[]) ?? [] return { rows: [], rowCount: ids.length } } - if (/SELECT authorization_prelock_workflow_approval_revisions/i.test(text)) { - return { rows: [], rowCount: 1 } - } return { rows: [], rowCount: 0 } }) @@ -74,38 +71,6 @@ describe('archiveTerminalApprovals', () => { expect(total).toBe(0) // Only the SELECT should fire (one batch → 0 rows → loop exits). expect(mockTransactionQuery).toHaveBeenCalledTimes(1) - // An empty batch must not take the approval subject prelock. - expect( - mockTransactionQuery.mock.calls.filter(([sql]) => - /authorization_prelock_workflow_approval_revisions/i.test(String(sql)) - ) - ).toHaveLength(0) - }) - - it('takes the approval subject prelock between the SELECT and the first write', async () => { - eligibleRows.push({ id: 'id-a' }, { id: 'id-b' }) - - await archiveTerminalApprovals(180, 500) - - const calls = mockTransactionQuery.mock.calls - const selectIndex = calls.findIndex(([sql]) => - /SELECT id FROM workflow_approval_requests/i.test(String(sql)) - ) - const prelockIndex = calls.findIndex(([sql]) => - /SELECT authorization_prelock_workflow_approval_revisions/i.test(String(sql)) - ) - const firstWriteIndex = calls.findIndex(([sql]) => - /INSERT INTO workflow_approval_requests_archive/i.test(String(sql)) - ) - const deleteIndex = calls.findIndex(([sql]) => - /DELETE FROM workflow_approval_requests/i.test(String(sql)) - ) - - expect(selectIndex).toBe(0) - expect(prelockIndex).toBe(1) - expect(firstWriteIndex).toBeGreaterThan(prelockIndex) - expect(deleteIndex).toBeGreaterThan(firstWriteIndex) - expect(calls[prelockIndex]?.[1]).toEqual([['id-a', 'id-b']]) }) it('archives all eligible rows in a single batch when count <= batchSize', async () => { diff --git a/control-api/test/services.userApprovalRequestService.test.ts b/control-api/test/services.userApprovalRequestService.test.ts index 7fcc7bc70c..96db9eb13f 100644 --- a/control-api/test/services.userApprovalRequestService.test.ts +++ b/control-api/test/services.userApprovalRequestService.test.ts @@ -795,7 +795,6 @@ describe('userApprovalRequestService', () => { rows: [{ status: 'pending', isExpired: false }], rowCount: 1, } as any) - .mockResolvedValueOnce({ rows: [], rowCount: 0 } as any) .mockResolvedValueOnce({ rows: [], rowCount: 1 } as any) .mockResolvedValueOnce({ rows: [], rowCount: 0 } as any) @@ -803,34 +802,6 @@ describe('userApprovalRequestService', () => { const result = await recordDecision('id-1', 'approve', { userId: 'u1' }) expect(result).toMatchObject({ ok: true }) - // The approval row is locked, then its revision subjects are taken, and - // only then the decision UPDATE bumps the target. - const statements = mockDb.query.mock.calls.map(([sql]) => String(sql)) - expect(statements[0]).toContain('FOR UPDATE') - expect(statements[1]).toContain('authorization_prelock_workflow_approval_trigger_revisions') - expect(mockDb.query.mock.calls[1]?.[1]).toEqual(['id-1']) - expect(statements[2]).toContain('SET status = $2') - }) - - it('denies without prelocking revision subjects', async () => { - const mockDb = { query: vi.fn() } - mockDb.query - .mockResolvedValueOnce({ - rows: [{ status: 'pending', isExpired: false }], - rowCount: 1, - } as any) - .mockResolvedValueOnce({ rows: [], rowCount: 1 } as any) - - mockedWithTransaction.mockImplementationOnce(async (work: any) => work(mockDb)) - - const result = await recordDecision('id-1', 'deny', { userId: 'u1' }) - expect(result).toMatchObject({ ok: true }) - // A denial bumps one subject, so no prelock is needed; the UPDATE is the - // witness that the decision still ran. - const statements = mockDb.query.mock.calls.map(([sql]) => String(sql)) - expect(statements).toHaveLength(2) - expect(statements.some(sql => sql.includes('authorization_prelock'))).toBe(false) - expect(statements[1]).toContain('SET status = $2') }) it('does not record a team workflow decision from a different requester', async () => { @@ -903,7 +874,6 @@ describe('userApprovalRequestService', () => { rows: [{ status: 'pending', isExpired: false }], rowCount: 1, } as any) - .mockResolvedValueOnce({ rows: [], rowCount: 0 } as any) .mockResolvedValueOnce({ rows: [], rowCount: 1 } as any) .mockResolvedValueOnce({ rows: [ @@ -949,7 +919,6 @@ describe('userApprovalRequestService', () => { rowCount: 1, } as any) .mockResolvedValueOnce({ rows: [{ '1': 1 }], rowCount: 1 } as any) - .mockResolvedValueOnce({ rows: [], rowCount: 0 } as any) .mockResolvedValueOnce({ rows: [{ teamId: null }], rowCount: 1 } as any) .mockResolvedValueOnce({ rows: [runRow], rowCount: 1 } as any) @@ -960,17 +929,6 @@ describe('userApprovalRequestService', () => { ok: true, workflowRun: { row: expect.objectContaining({ run_id: 'run-1' }), created: true }, }) - const statements = mockDb.query.mock.calls.map(([sql]) => String(sql)) - // Both call sites prelock before the UPDATE they precede: the decision - // before the `approved` bump and the consume before the `consumed` one. - const prelocks = statements - .map((sql, index) => ({ sql, index })) - .filter(({ sql }) => sql.includes('authorization_prelock_workflow_approval_trigger')) - expect(prelocks).toHaveLength(2) - const approvedUpdate = statements.findIndex(sql => sql.includes('SET status = $2')) - const consumedUpdate = statements.findIndex(sql => sql.includes("SET status = 'consumed'")) - expect(prelocks[0]!.index).toBeLessThan(approvedUpdate) - expect(prelocks[1]!.index).toBeLessThan(consumedUpdate) expect(mockDb.query).toHaveBeenCalledWith( expect.stringContaining('UPDATE workflow_approval_requests war'), ['id-1', 'ns', 'recipe', 'external-rest-api:user:u1:team:t1'] @@ -1121,11 +1079,6 @@ describe('userApprovalRequestService', () => { mockDb as any ) ).rejects.toThrow("cannot be consumed: status is 'pending'") - // The early throw happens before the prelock: the locked SELECT is the - // only statement, so a rejected consume holds no revision row. - expect(mockDb.query).toHaveBeenCalledTimes(1) - expect(String(mockDb.query.mock.calls[0]?.[0])).toContain('FOR UPDATE OF war') - expect(String(mockDb.query.mock.calls[0]?.[0])).not.toContain('authorization_prelock') }) it("throws if approval status is 'denied'", async () => { @@ -1312,7 +1265,6 @@ describe('userApprovalRequestService', () => { mockDb.query .mockResolvedValueOnce({ rows: [approvalRow()], rowCount: 1 } as any) .mockResolvedValueOnce({ rows: [{}], rowCount: 1 } as any) - .mockResolvedValueOnce({ rows: [], rowCount: 0 } as any) .mockResolvedValueOnce({ rows: [{ teamId: '11111111-1111-4111-8111-111111111111' }], rowCount: 1, @@ -1339,12 +1291,7 @@ describe('userApprovalRequestService', () => { expect(selectSql).toContain('LEFT JOIN workflow_approval_trigger_intents') expect(selectSql).toContain('FOR UPDATE OF war') expect(selectSql).toContain('war.payload') - // The prelock sits between the locked read and the consume UPDATE. - expect(mockDb.query.mock.calls[2]?.[0]).toContain( - 'authorization_prelock_workflow_approval_trigger_revisions' - ) - expect(mockDb.query.mock.calls[2]?.[1]).toEqual(['id-approved']) - const updateSql = mockDb.query.mock.calls[3]?.[0] as string + const updateSql = mockDb.query.mock.calls[2]?.[0] as string expect(updateSql).toContain('workflow_approval_trigger_intents') expect(updateSql).toContain('user_workflow_triggers trigger_uwt') expect(updateSql).toContain('war.target_user_id::text = war.decided_by_user_id') @@ -1363,7 +1310,6 @@ describe('userApprovalRequestService', () => { .mockResolvedValueOnce({ rows: [approvalRow()], rowCount: 1 } as any) .mockResolvedValueOnce({ rows: [{}], rowCount: 1 } as any) .mockResolvedValueOnce({ rows: [], rowCount: 0 } as any) - .mockResolvedValueOnce({ rows: [], rowCount: 0 } as any) await expect( consumeApprovalForTrigger( @@ -1394,7 +1340,6 @@ describe('userApprovalRequestService', () => { rowCount: 1, } as any) .mockResolvedValueOnce({ rows: [{ teamGranted: true }], rowCount: 1 } as any) - .mockResolvedValueOnce({ rows: [], rowCount: 0 } as any) .mockResolvedValueOnce({ rows: [{ teamId: 'team-1' }], rowCount: 1 } as any) await expect( @@ -1411,11 +1356,6 @@ describe('userApprovalRequestService', () => { ).resolves.toEqual({ teamId: 'team-1' }) expect(mockDb.query.mock.calls[1]?.[1]).toEqual(['user-1', 'team-1', 'ns', 'recipe']) - expect(mockDb.query.mock.calls[2]?.[0]).toContain( - 'authorization_prelock_workflow_approval_trigger_revisions' - ) - expect(mockDb.query.mock.calls[2]?.[1]).toEqual(['id-team']) - expect(mockDb.query.mock.calls[3]?.[0]).toContain("SET status = 'consumed'") }) it('rejects target-team approvals when another user decides a requester-bound workflow', async () => { @@ -1476,7 +1416,6 @@ describe('userApprovalRequestService', () => { rowCount: 1, } as any) .mockResolvedValueOnce({ rows: [{}], rowCount: 1 } as any) - .mockResolvedValueOnce({ rows: [], rowCount: 0 } as any) .mockResolvedValueOnce({ rows: [{ teamId: null }], rowCount: 1 } as any) await expect( @@ -1502,46 +1441,13 @@ describe('userApprovalRequestService', () => { }) describe('expirePendingRequests', () => { - it('expires the selected rows in SELECT, prelock and UPDATE order', async () => { - mockedQuery - .mockResolvedValueOnce({ rows: [{ id: 'id-1' }, { id: 'id-2' }], rowCount: 2 } as any) - .mockResolvedValueOnce({ rows: [], rowCount: 1 } as any) - .mockResolvedValueOnce({ rows: [{ id: 'id-1' }, { id: 'id-2' }], rowCount: 2 } as any) - + it('returns count of expired requests', async () => { + mockedQuery.mockResolvedValueOnce({ + rows: [{ id: 'id-1' }, { id: 'id-2' }], + rowCount: 2, + } as any) const count = await expirePendingRequests() - expect(count).toBe(2) - expect(mockedWithTransaction).toHaveBeenCalledTimes(1) - const statements = mockedQuery.mock.calls.map(([sql]) => String(sql)) - expect(statements).toHaveLength(3) - - // The sweep must not re-evaluate its predicate in the UPDATE. - expect(statements[0]).toContain('FROM workflow_approval_requests') - expect(statements[0]).toContain("status = 'pending'") - expect(statements[0]).toContain('expires_at < NOW()') - expect(statements[0]).toContain('FOR UPDATE SKIP LOCKED') - expect(statements[0]).not.toMatch(/\bLIMIT\b/) - - expect(statements[1]).toBe( - 'SELECT authorization_prelock_workflow_approval_revisions($1::uuid[])' - ) - expect(mockedQuery.mock.calls[1]?.[1]).toEqual([['id-1', 'id-2']]) - - expect(statements[2]).toContain('UPDATE workflow_approval_requests') - expect(statements[2]).toContain("SET status = 'expired'") - expect(statements[2]).toContain('WHERE id = ANY($1::uuid[])') - expect(statements[2]).not.toContain("status = 'pending'") - expect(mockedQuery.mock.calls[2]?.[1]).toEqual([['id-1', 'id-2']]) - }) - - it('returns zero without prelock or UPDATE when nothing is expiring', async () => { - mockedQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 } as any) - - await expect(expirePendingRequests()).resolves.toBe(0) - - expect(mockedWithTransaction).toHaveBeenCalledTimes(1) - expect(mockedQuery).toHaveBeenCalledTimes(1) - expect(String(mockedQuery.mock.calls[0]?.[0])).toContain('FOR UPDATE SKIP LOCKED') }) }) }) diff --git a/control-api/test/services.workflowRunsArchive.test.ts b/control-api/test/services.workflowRunsArchive.test.ts index 2a0f1e37f9..6df4e90cd0 100644 --- a/control-api/test/services.workflowRunsArchive.test.ts +++ b/control-api/test/services.workflowRunsArchive.test.ts @@ -138,26 +138,17 @@ describe('archiveTerminalRuns', () => { const total = await archiveTerminalRuns(gateway) expect(total).toBe(2) - // Transaction shape: BEGIN → SELECT → prelock → INSERT audit → INSERT step audit → DELETE → COMMIT, + // Transaction shape: BEGIN → SELECT → INSERT audit → INSERT step audit → DELETE → COMMIT, // then one more drain SELECT returning 0 rows. const selectCalls = clientQuery.mock.calls.filter(([sql]) => /SELECT run_id, child_recipe_name/i.test(String(sql)) ) expect(selectCalls.length).toBe(2) // drain batch + empty-tail expect(String(selectCalls[0]?.[0])).toContain('COALESCE(ttl_seconds_after_finished, $1)') - const prelockCalls = clientQuery.mock.calls.filter(([sql]) => - /SELECT authorization_prelock_workflow_run_revisions/i.test(String(sql)) - ) - expect(prelockCalls).toEqual([ - ['SELECT authorization_prelock_workflow_run_revisions($1::uuid[])', [['uuid-1', 'uuid-2']]], - ]) const insertAuditCalls = clientQuery.mock.calls.filter(([sql]) => /INSERT INTO workflow_runs_audit/i.test(String(sql)) ) expect(insertAuditCalls.length).toBe(1) - const calls = clientQuery.mock.calls - expect(calls.indexOf(prelockCalls[0]!)).toBe(calls.indexOf(selectCalls[0]!) + 1) - expect(calls.indexOf(insertAuditCalls[0]!)).toBe(calls.indexOf(prelockCalls[0]!) + 1) expect(String(insertAuditCalls[0]?.[0])).toContain('wr.started_at') expect(String(insertAuditCalls[0]?.[0])).toContain('wr.completed_at - wr.started_at') expect(String(insertAuditCalls[0]?.[0])).not.toContain('wr.completed_at - wr.created_at') diff --git a/control-api/test/userRetirementRevisionLockOrder.realPostgres.integration.test.ts b/control-api/test/userRetirementRevisionLockOrder.realPostgres.integration.test.ts deleted file mode 100644 index ca3c323bd7..0000000000 --- a/control-api/test/userRetirementRevisionLockOrder.realPostgres.integration.test.ts +++ /dev/null @@ -1,1774 +0,0 @@ -// Real service transactions, triggers and deterministic PostgreSQL contention witnesses. -// Fixed cases observe production SQL unchanged. Only the seven historical -// characterizations suppress prelock calls to reproduce the original cycles. -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' -import express from 'express' -import { AsyncLocalStorage } from 'node:async_hooks' -import { randomBytes, randomUUID } from 'node:crypto' -import { appendFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { Pool, type PoolClient } from 'pg' -import request from 'supertest' -import { initDb, pool } from '../src/db.js' -import { createAdminUsersRouter } from '../src/routes/admin/users.js' -import { applyComposableCatalogRevisionSchema } from '../src/services/access/userAccessFoundationSchema.js' -import { completeControlAdminInvitation } from '../src/services/adminAuthService.js' -import type { ExternalSessionAuthorityContext } from '../src/services/auth/externalSessionAuthentication.js' -import { createUserSession } from '../src/services/auth/userSessionService.js' -import { deleteManagedUserForUser, updateMemberRole } from '../src/services/directory/membership.js' -import { adminDeleteUserInTransaction, retireDesktopUser } from '../src/services/directory/users.js' -import { preferVerifiedMediumAccount } from '../src/services/workflowApprovalMediumPreferenceService.js' -import { createRun } from '../src/services/workflowRunService.js' -import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' - -const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL -const evidence = process.env.PR334_DB1_TRACE_EVIDENCE -const describeRealPostgres = adminUrl ? describe : describe.skip -const target = new URL(adminUrl ?? 'postgresql://localhost/postgres') -const events: any[] = [] -const barrierClass = 334_031 -const database = `pr334_db1_${randomBytes(6).toString('hex')}` -const actorId = randomUUID() -let adminPool: Pool -let databasePool: Pool - -function save(name: string, data: unknown) { - if (evidence) writeFileSync(join(evidence, `${name}.json`), JSON.stringify(data, null, 2) + '\n') -} -function event(data: unknown) { - events.push(data) - if (evidence) appendFileSync(join(evidence, 'trace-events.jsonl'), JSON.stringify(data) + '\n') -} -type Outcome = - | { status: 'fulfilled'; value: unknown; transaction: string } - | { - status: 'rejected' - code: string - errorName?: string - message: string - detail?: string - where?: string - transaction: string - } -function observe(client: PoolClient, write: Promise): Promise { - return write.then( - async value => { - const result = await client.query('COMMIT') - return { status: 'fulfilled', value, transaction: result.command } as const - }, - async error => { - await client.query('ROLLBACK') - return { - status: 'rejected', - code: String(error.code), - message: error.message, - errorName: error.name, - detail: error.detail, - where: error.where, - transaction: 'ROLLBACK', - } as const - } - ) -} -async function pid(client: PoolClient) { - return (await client.query<{ pid: number }>('SELECT pg_backend_pid() AS pid')).rows[0]!.pid -} -async function begin(client: PoolClient, application: string) { - await client.query('BEGIN') - await client.query("SELECT set_config('application_name', $1, true)", [application]) - // Long enough to capture BOTH edges before PostgreSQL chooses a victim. - await client.query("SET LOCAL deadlock_timeout = '2s'") - await client.query("SET LOCAL statement_timeout = '12s'") - await client.query("SET LOCAL idle_in_transaction_session_timeout = '25s'") -} -async function waitForEdges(edges: [number, number][]) { - const deadline = Date.now() + 4_000 - while (Date.now() < deadline) { - const { rows } = await databasePool.query<{ pid: number; blockers: number[] }>( - 'SELECT pid, pg_blocking_pids(pid) AS blockers FROM pg_stat_activity WHERE pid = ANY($1::int[])', - [edges.map(([blocked]) => blocked)] - ) - if ( - edges.every(([blocked, blocker]) => - rows.some(row => row.pid === blocked && row.blockers.includes(blocker)) - ) - ) - return - await new Promise(resolve => setTimeout(resolve, 10)) - } - throw new Error(`Expected pg_blocking_pids edges not seen: ${JSON.stringify(edges)}`) -} -type Fixture = { user: string; team: string; secondTeam: string; label: string } -async function snapshot(label: string, phase: string, pids: number[], fixture: Fixture) { - const activity = ( - await databasePool.query( - ` - SELECT pid, application_name, state, backend_xid::text, wait_event_type, wait_event, - query, pg_blocking_pids(pid) AS blockers - FROM pg_stat_activity WHERE pid = ANY($1::int[]) ORDER BY pid`, - [pids] - ) - ).rows - const locks = ( - await databasePool.query( - ` - SELECT l.pid, a.application_name, l.locktype, l.mode, l.granted, - l.relation::regclass::text AS relation, l.page, l.tuple, - l.virtualxid, l.transactionid::text, l.classid::text, l.objid::text, - l.objsubid, l.fastpath, l.waitstart - FROM pg_locks l LEFT JOIN pg_stat_activity a ON a.pid = l.pid - WHERE l.pid = ANY($1::int[]) - ORDER BY l.pid, l.locktype, relation, l.transactionid::text, l.granted`, - [pids] - ) - ).rows - const xidEdges = ( - await databasePool.query( - ` - SELECT w.pid AS waiter, w.mode AS wait_mode, w.transactionid::text AS xid, - h.pid AS holder, h.mode AS held_mode - FROM pg_locks w JOIN pg_locks h - ON w.locktype = 'transactionid' AND h.locktype = 'transactionid' - AND w.transactionid = h.transactionid AND w.pid <> h.pid - WHERE NOT w.granted AND h.granted AND h.mode = 'ExclusiveLock' - AND w.pid = ANY($1::int[]) ORDER BY w.pid`, - [pids] - ) - ).rows - // pg_locks does NOT enumerate normally held tuple locks. Keep its raw data - // intact and supplement with PostgreSQL's pgrowlocks to identify held rows. - const rowLocks: Record = {} - for (const table of [ - 'users', - 'team_members', - 'authorization_user_revisions', - 'authorization_team_revisions', - 'workflow_approval_medium_accounts', - ]) { - rowLocks[table] = (await databasePool.query(`SELECT * FROM pgrowlocks('${table}')`)).rows - } - const visibleRows = ( - await databasePool.query( - ` - SELECT 'users' AS table_name, id::text AS subject, ctid::text, xmin::text, xmax::text - FROM users WHERE id = $1::uuid - UNION ALL SELECT 'authorization_user_revisions', user_id::text, ctid::text, xmin::text, xmax::text - FROM authorization_user_revisions WHERE user_id = $1::uuid - UNION ALL SELECT 'authorization_team_revisions', team_id::text, ctid::text, xmin::text, xmax::text - FROM authorization_team_revisions WHERE team_id = $2::uuid - UNION ALL SELECT 'team_members', user_id::text, ctid::text, xmin::text, xmax::text - FROM team_members WHERE user_id = $1::uuid`, - [fixture.user, fixture.team] - ) - ).rows - const data = { - label, - phase, - utc: new Date().toISOString(), - fixture, - activity, - locks, - xidEdges, - rowLocks, - visibleRows, - } - save(`${label}.${phase}`, data) - event({ label, phase, activity, xidEdges }) - return data -} -async function fixture(label: string, legacy = false, medium = true): Promise { - const user = randomUUID(), - team = randomUUID(), - secondTeam = randomUUID() - await databasePool.query("INSERT INTO users(id, email, name) VALUES ($1, $2, 'DB1 trace')", [ - user, - `db1-${user}@example.test`, - ]) - for (const id of [team, secondTeam]) - await databasePool.query("INSERT INTO teams(id, name) VALUES ($1, 'DB1 trace')", [id]) - await databasePool.query( - "INSERT INTO team_members(team_id, user_id, role, status) VALUES ($1, $2, 'member', 'active')", - [team, user] - ) - if (medium) - await databasePool.query( - `INSERT INTO workflow_approval_medium_accounts - (user_id, medium, provider_user_id, communication_channel_ref, verified_at) - VALUES ($1, 'telegram', $2, 'db1-test-channel', NOW())`, - [user, `db1-${user}`] - ) - if (legacy) { - // Simulate pre-0134 subjects after all fixture inserts; keep every real - // trigger/FK enabled. No production bump implementation is replaced. - await databasePool.query('DELETE FROM authorization_user_revisions WHERE user_id = $1', [user]) - await databasePool.query('DELETE FROM authorization_team_revisions WHERE team_id = $1', [team]) - } - return { user, team, secondTeam, label } -} -async function revisionBarrier(application: string, table: string, subject: string) { - await databasePool.query('INSERT INTO db1_trace_barriers VALUES ($1, $2, $3)', [ - application, - table, - subject, - ]) -} -function runWrite(client: PoolClient, f: Fixture) { - return createRun( - { - recipe_namespace: 'db1-trace', - recipe_name: f.label, - actor_type: 'user', - actor_id: f.user, - team_id: f.team, - usage_team_id: f.team, - idempotency_key: null, - trigger_source: 'onDemand', - ttl_seconds_after_finished: 2592000, - }, - client - ) -} -function retirementWrite( - client: PoolClient, - f: Fixture, - options: { authorize?: (db: PoolClient) => Promise; tombstone?: boolean } = {} -) { - return retireDesktopUser( - { kind: 'control_admin', controlAdminId: actorId }, - f.user, - 'DB1 local diagnostic', - `db1-${f.label}-${f.user}`, - null, - { - db: client, - retainWithoutLinkHistory: options.tombstone, - authorize: options.authorize ? db => options.authorize!(db as PoolClient) : undefined, - } - ) -} -async function finalState(f: Fixture) { - return { - users: ( - await databasePool.query('SELECT id, lifecycle_state FROM users WHERE id = $1', [f.user]) - ).rows, - memberships: ( - await databasePool.query('SELECT team_id, status FROM team_members WHERE user_id = $1', [ - f.user, - ]) - ).rows, - medium: ( - await databasePool.query( - 'SELECT disabled_at IS NOT NULL AS disabled FROM workflow_approval_medium_accounts WHERE user_id = $1', - [f.user] - ) - ).rows, - runs: ( - await databasePool.query( - 'SELECT run_id, actor_id, phase FROM workflow_runs WHERE recipe_namespace = $1 AND recipe_name = $2', - ['db1-trace', f.label] - ) - ).rows, - operations: ( - await databasePool.query( - 'SELECT status, outcome FROM desktop_user_retirement_operations WHERE target_user_id = $1', - [f.user] - ) - ).rows, - } -} - -// Mutation mode only selects a deterministic failure schedule. Mutations of -// the fix itself are made to the production source, never injected by this suite. -const mutation = process.env.PR334_DB1_MUTATION ?? '' -async function backfillRevisions() { - const c = await databasePool.connect() - try { - await c.query('BEGIN') - await applyComposableCatalogRevisionSchema(c) - await c.query('COMMIT') - } finally { - await c.query('ROLLBACK') - c.release() - } -} -type Spec = { - label: string - legacy?: boolean - writer?: 'retirement' | 'admin' | 'route' | 'managed' | 'handover' - tombstone?: boolean - audit?: boolean - deleted?: boolean - twoTeam?: boolean - baseline?: boolean - order?: 'writer-first' | 'retirement-first' - child?: 'membership' | 'medium' - runTeam?: string -} -type ExpandedFixture = Fixture & { - receiver: string - manager: string - thirdTeam: string - auditId?: string - inviterAdmin?: string - invitation?: string - receiverEmail?: string -} - -async function prelock(client: PoolClient, ids: string[]) { - const role = (await client.query('SELECT current_user')).rows[0].current_user - expect(role).toBe('control_api_runtime') - await client.query('SELECT public.authorization_prelock_user_retirement_revisions($1::uuid[])', [ - ids, - ]) - event({ phase: 'prelock-executed', role, ids }) -} -async function expandedFixture(spec: Spec): Promise { - const f = (await fixture(spec.label, false, !spec.legacy)) as ExpandedFixture - f.receiver = randomUUID() - f.manager = randomUUID() - f.thirdTeam = randomUUID() - for (const id of [f.receiver, f.manager]) { - await databasePool.query("INSERT INTO users(id, email, name) VALUES ($1, $2, 'DB1 fixture')", [ - id, - `db1-${id}@example.test`, - ]) - } - await databasePool.query("INSERT INTO teams(id, name) VALUES ($1, 'DB1 third team')", [ - f.thirdTeam, - ]) - if (spec.deleted) - await databasePool.query("UPDATE team_members SET status='deleted' WHERE user_id=$1", [f.user]) - if (spec.writer === 'managed') { - await databasePool.query( - "INSERT INTO team_members(team_id,user_id,role,status) VALUES ($1,$2,'admin','active')", - [f.team, f.manager] - ) - } - if (spec.writer === 'handover') { - await databasePool.query( - "INSERT INTO team_members(team_id,user_id,role,status) VALUES ($1,$2,'member','active'),($3,$4,'member','active')", - [f.secondTeam, f.user, f.thirdTeam, f.receiver] - ) - f.inviterAdmin = randomUUID() - f.invitation = randomUUID() - f.receiverEmail = `db1-${f.receiver}@example.test` - await databasePool.query( - `INSERT INTO control_admin_users(id,username,email,password_hash,role,status) - VALUES ($1,$2,$3,'scratch-unusable-hash','admin','active')`, - [f.inviterAdmin, `db1-${f.inviterAdmin}`, `db1-${f.user}@example.test`] - ) - await databasePool.query( - `INSERT INTO control_admin_invitations(id,email,invited_by_admin_id,replace_inviter) - VALUES ($1,$2,$3,true)`, - [f.invitation, f.receiverEmail, f.inviterAdmin] - ) - } - if (spec.audit || spec.twoTeam) { - f.auditId = randomUUID() - await databasePool.query( - `INSERT INTO workflow_runs_audit(run_id,run_namespace,run_name,recipe_namespace,recipe_name, - triggerer_user_id,triggerer_actor_type,triggerer_team_id,usage_team_id,trigger_source,idempotency_key, - triggered_at,completed_at,final_phase,step_count,snapshot_sha) - VALUES ($1,'db1-trace',$2,'db1-trace',$2,$3,'user',$4,$5,'onDemand',$2,NOW(),NOW(),'Succeeded',0,'scratch-design')`, - [ - f.auditId, - spec.label, - f.user, - spec.twoTeam ? f.thirdTeam : null, - spec.twoTeam ? f.secondTeam.toUpperCase() : f.secondTeam, - ] - ) - } - if (spec.legacy) { - await databasePool.query('DELETE FROM authorization_user_revisions WHERE user_id=$1', [f.user]) - await databasePool.query('DELETE FROM authorization_team_revisions WHERE team_id=$1', [f.team]) - } - - if (receiverSetFixture && spec.writer === 'handover') { - const additional = randomUUID() - await databasePool.query( - "INSERT INTO users(id,email,name) VALUES($1,$2,'case variant receiver')", - [additional, f.receiverEmail!.toUpperCase()] - ) - await seedLink(f.user, f.inviterAdmin!) - await databasePool.query('UPDATE control_admin_users SET email=$2 WHERE id=$1', [ - f.inviterAdmin, - `changed-admin-${f.inviterAdmin}@example.test`, - ]) - extraReceivers.set(f.label, additional) - } - if (spec.writer === 'managed') await seedAuthority(f.manager) - return f -} -async function expandedFinal(f: ExpandedFixture) { - return { - ...(await finalState(f)), - receiverMemberships: ( - await databasePool.query( - 'SELECT team_id,role,status FROM team_members WHERE user_id=$1 ORDER BY team_id', - [f.receiver] - ) - ).rows, - receiver: ( - await databasePool.query('SELECT id,lifecycle_state FROM users WHERE id=$1', [f.receiver]) - ).rows, - inviter: ( - await databasePool.query( - 'SELECT status,session_version FROM control_admin_users WHERE id=$1', - [f.inviterAdmin ?? actorId] - ) - ).rows, - audit: ( - await databasePool.query( - 'SELECT triggerer_user_id,triggerer_team_id,usage_team_id FROM workflow_runs_audit WHERE run_id=$1', - [f.auditId ?? randomUUID()] - ) - ).rows, - } -} -function actorRun(client: PoolClient, f: ExpandedFixture, spec: Spec) { - let team = spec.audit || spec.deleted ? (spec.deleted ? f.team : f.secondTeam) : f.team - let usage = team - if (spec.twoTeam) { - // Intentionally reverse attribution; trigger order still uses TEXT keys. - team = f.thirdTeam - usage = f.secondTeam.toUpperCase() - } - if (spec.writer === 'handover') team = usage = spec.runTeam ?? f.secondTeam - return createRun( - { - recipe_namespace: 'db1-trace', - recipe_name: f.label, - actor_type: 'user', - actor_id: spec.writer === 'handover' ? f.receiver : f.user, - team_id: team, - usage_team_id: usage, - idempotency_key: null, - trigger_source: 'onDemand', - ttl_seconds_after_finished: 2592000, - }, - client - ) -} -async function runtimeBegin(client: PoolClient, application: string) { - await begin(client, application) - await client.query('SET LOCAL ROLE control_api_runtime') -} -function deletionWrite(client: PoolClient, f: ExpandedFixture, spec: Spec) { - if (spec.writer === 'admin') return adminDeleteUserInTransaction(client, f.user) - if (spec.writer === 'managed') - return deleteManagedUserForUser( - f.manager, - f.user, - { reason: 'DB1 regression', idempotencyKey: `phase1-${f.user}`, requestId: null }, - authorities.get(f.manager) - ) - if (spec.writer === 'handover') - return completeControlAdminInvitation({ - email: f.receiverEmail!, - invitationId: f.invitation!, - username: `receiver-${f.receiver}`, - passwordHash: 'scratch-unusable-hash', - }) - if (spec.writer === 'route') { - const app = express() - app.use(express.json()) - app.use((req, _res, next) => { - ;(req as any).adminAuth = { sub: actorId } - next() - }) - app.use(createAdminUsersRouter({} as any)) - app.use((err: any, _req: any, res: any, _next: any) => res.status(500).json({ code: err.code })) - return request(app) - .delete(`/admin/users/${f.user}`) - .set('Idempotency-Key', `phase1-${f.user}`) - .send({ reason: 'DB1 regression' }) - .then(response => { - if (response.status !== 200) - throw Object.assign(new Error('Retirement route failed'), { - code: response.body.code ?? response.status, - }) - return { status: response.status, body: response.body } - }) - } - return retirementWrite(client, f, { tombstone: spec.tombstone }) -} - -// Only pool ownership and scheduling are test-specific. Every -// internal service query, BEGIN/COMMIT, authorize call and trigger still runs. -const serviceScope = new AsyncLocalStorage() -const authorities = new Map() -let discoveryHook: (() => Promise) | undefined -let beforeCopyHook: (() => Promise) | undefined -let afterCopyValidationHook: (() => Promise) | undefined -function installPoolRouting() { - vi.spyOn(pool, 'connect').mockImplementation((() => { - const client = serviceScope.getStore() - if (!client) throw new Error('Service transaction escaped its test connection') - return Promise.resolve(client) - }) as any) - vi.spyOn(pool, 'query').mockImplementation(((sql: string, values?: unknown[]) => { - const client = serviceScope.getStore() - if (!client) throw new Error('Service query escaped its test connection') - return client.query(sql, values) - }) as any) -} -async function seedAuthority(userId: string) { - const c = await databasePool.connect() - try { - await c.query('BEGIN') - const issued = await createUserSession( - { userId, email: `db1-${userId}@example.test`, authenticationMethods: ['password'] }, - { db: c } - ) - const { sid, jti, sessionVersion } = issued.identity - authorities.set(userId, { contract: 'v2', userId, sid, jti, sessionVersion }) - await c.query('COMMIT') - } finally { - await c.query('ROLLBACK') - c.release() - } -} - -// Observe query boundaries only; do not add prelocks, change predicates, -// revalidate identities, or replace production errors in fixed cases. -function installObservation( - client: PoolClient, - spec: Spec, - enabled: boolean, - afterFirst?: () => Promise -) { - const raw = client.query.bind(client) - const state = { count: 0, events: [] as any[], spy: undefined as any } - let discoveryCount = 0 - let inviter: any, links: any[], sender: any - let expected: any - state.spy = vi.spyOn(client, 'query').mockImplementation((async (sql: string, values?: any[]) => { - const isPrelock = sql.includes('SELECT public.authorization_prelock_user_retirement_revisions(') - if (isPrelock && !enabled) { - event({ phase: 'baseline-prelock-disabled', label: spec.label }) - return { rows: [], rowCount: 0 } - } - if (isPrelock && state.count === 0) await discoveryHook?.() - const discoveryStart = - sql === 'SELECT id::text AS id, email, status FROM control_admin_users WHERE id=$1' - if (discoveryStart && discoveryCount === 2) await beforeCopyHook?.() - const membershipCopy = sql.includes('INSERT INTO team_members(team_id, user_id, role, status)') - if (membershipCopy) { - await afterCopyValidationHook?.() - state.events.push({ placement: 'handover-copy-bound', ids: values![1], sql }) - } - const result = await raw(sql, values) - if (isPrelock) { - state.count++ - state.events.push({ placement: 'production-prelock', ids: values![0] }) - event({ phase: 'prelock-executed', label: spec.label, ids: values![0] }) - if (state.count === 1) await afterFirst?.() - } - if (discoveryStart) { - inviter = result.rows[0] - links = [] - sender = null - } - if ( - sql.includes( - 'SELECT link.id::text AS link_id, link.generation, link.state, link.row_version,' - ) - ) { - links = result.rows - sender = links[0]?.lifecycle_state === 'active' ? links[0] : null - } - if ( - sql.includes('SELECT u.id::text AS id, u.email, u.lifecycle_state') && - sql.includes('l.control_admin_id<>$2::uuid') - ) - sender = result.rows[0] ?? null - if (sql.includes('SELECT id::text AS id, email, lifecycle_state FROM users')) { - const snapshot = { inviter, links, sender, receivers: result.rows } - discoveryCount++ - if (discoveryCount === 1) { - expected = snapshot - event({ phase: 'handover-discovery', label: spec.label, discovered: snapshot }) - } else if (discoveryCount === 2) { - event({ phase: 'handover-revalidated', label: spec.label, snapshot }) - } else if (discoveryCount === 3) { - event({ - phase: 'handover-copy-revalidation', - label: spec.label, - identical: JSON.stringify(expected) === JSON.stringify(snapshot), - expected, - current: snapshot, - }) - } - } - if (membershipCopy) - state.events.push({ placement: 'handover-copy-executed', rows: result.rowCount }) - if ( - sql.includes('FROM external_user_sessions') || - sql.includes("AND lifecycle_state = 'active'") - ) { - event({ - phase: 'internal-authority-query-executed', - label: spec.label, - sql, - rows: result.rowCount, - }) - } - if (sql === 'BEGIN' || sql === 'COMMIT' || sql === 'ROLLBACK') - event({ phase: 'service-transaction', label: spec.label, sql, command: result.command }) - return result - }) as any) - return state -} - -async function contention(spec: Spec, mode: 'baseline' | 'fixed' | 'mutation') { - const f = await expandedFixture(spec) - const actualMutation = mode === 'mutation' ? mutation : '' - if (mode !== 'baseline' && actualMutation !== 'no-backfill') { - const start = performance.now() - const before = ( - await databasePool.query( - 'SELECT user_id,revision FROM authorization_user_revisions ORDER BY user_id' - ) - ).rows - await backfillRevisions() - const covered = ( - await databasePool.query( - 'SELECT user_id,revision,updated_at FROM authorization_user_revisions ORDER BY user_id' - ) - ).rows - await backfillRevisions() - const rerun = ( - await databasePool.query( - 'SELECT user_id,revision,updated_at FROM authorization_user_revisions ORDER BY user_id' - ) - ).rows - save(`${spec.label}.backfill`, { elapsedMs: performance.now() - start, before, covered, rerun }) - expect(rerun).toEqual(covered) - } - const clients = await Promise.all([ - databasePool.connect(), - databasePool.connect(), - databasePool.connect(), - ]) - const [gate, deletion, run] = clients as [PoolClient, PoolClient, PoolClient] - const retain = vi.spyOn(deletion, 'release').mockImplementation(() => undefined) - const pending: Promise[] = [] - let querySpy: ReturnType | undefined - const raw = deletion.query.bind(deletion) - let paused = false - const usePrelock = - mode !== 'baseline' && actualMutation !== 'no-prelock' && actualMutation !== 'late-handover' - const failureSchedule = mode === 'baseline' || mode === 'mutation' - const app = `${spec.label}-delete` - try { - const [gatePid, deletionPid, runPid] = await Promise.all(clients.map(pid)) - await gate.query('SELECT pg_advisory_lock($1,1)', [barrierClass]) - await runtimeBegin(deletion, app) - await runtimeBegin(run, `${spec.label}-run`) - - const observation = installObservation( - deletion, - spec, - usePrelock || actualMutation === 'late-handover', - (failureSchedule && !['no-audit', 'no-deleted'].includes(actualMutation)) || - spec.order === 'retirement-first' - ? async () => { - paused = true - await raw('SELECT pg_advisory_xact_lock($1,1)', [barrierClass]) - } - : undefined - ) - querySpy = observation.spy - if (failureSchedule && (!usePrelock || ['no-audit', 'no-deleted'].includes(actualMutation))) { - if (spec.legacy) { - if (spec.writer === 'admin') - await databasePool.query('INSERT INTO db1_parent_barriers VALUES ($1,$2)', [app, f.user]) - else { - // Take the same real parent lock used by retireDesktopUser; the - // barrier here precedes its medium/cascade work, never a fake bump. - await raw('SELECT id FROM users WHERE id=$1 FOR UPDATE', [f.user]) - } - } else if (spec.writer !== 'handover') - await revisionBarrier(app, 'authorization_user_revisions', f.user) - else await revisionBarrier(app, 'authorization_user_revisions', f.receiver) - } - const deleteOutcome = () => - observe( - deletion, - (async () => { - if (failureSchedule && !usePrelock && spec.legacy && spec.writer !== 'admin') { - await raw('SELECT pg_advisory_xact_lock($1,1)', [barrierClass]) - } - return serviceScope.run(deletion, () => deletionWrite(deletion, f, spec)) - })() - ) - let one: Promise, two: Promise - if (failureSchedule) { - one = deleteOutcome() - pending.push(one) - await waitForEdges([[deletionPid, gatePid]]) - await snapshot(spec.label, '01-delete-held', [gatePid, deletionPid, runPid], f) - if (spec.writer === 'handover' && !usePrelock) { - // INSERT SELECT has no ORDER BY in production. Inspect which source - // team the first real copy has touched, then contend on the OTHER - // source team. Do not replace or order the production copy query. - const held = ( - await databasePool.query( - `SELECT r.team_id FROM pgrowlocks('authorization_team_revisions') p - JOIN authorization_team_revisions r ON r.ctid=p.locked_row WHERE $1=ANY(p.pids)`, - [deletionPid] - ) - ).rows.map(r => r.team_id) - spec.runTeam = [f.team, f.secondTeam].find(id => !held.includes(id)) - expect(spec.runTeam).toBeDefined() - event({ - phase: 'handover-real-copy-order', - label: spec.label, - held, - contendedTeam: spec.runTeam, - }) - } - two = observe(run, actorRun(run, f, spec)) - pending.push(two) - await waitForEdges([[runPid, deletionPid]]) - await snapshot(spec.label, '02-run-blocked', [gatePid, deletionPid, runPid], f) - await gate.query('SELECT pg_advisory_unlock($1,1)', [barrierClass]) - await waitForEdges([ - [runPid, deletionPid], - [deletionPid, runPid], - ]) - await snapshot(spec.label, '03-cycle', [gatePid, deletionPid, runPid], f) - } else if (spec.order === 'retirement-first') { - // Pause after the complete prelock; hard delete without a medium has no - // later user-revision UPDATE on which to put a revision-trigger barrier. - one = deleteOutcome() - pending.push(one) - await waitForEdges([[deletionPid, gatePid]]) - two = observe(run, actorRun(run, f, spec)) - pending.push(two) - await waitForEdges([[runPid, deletionPid]]) - await snapshot(spec.label, '01-serialized-contention', [gatePid, deletionPid, runPid], f) - await gate.query('SELECT pg_advisory_unlock($1,1)', [barrierClass]) - } else { - const firstTeam = - spec.writer === 'handover' - ? f.secondTeam - : spec.twoTeam - ? ( - await databasePool.query( - 'SELECT key FROM unnest($1::text[]) AS keys(key) ORDER BY key LIMIT 1', - [[f.thirdTeam, f.secondTeam.toUpperCase()]] - ) - ).rows[0].key.toLowerCase() - : spec.audit - ? f.secondTeam - : f.team - await revisionBarrier(`${spec.label}-run`, 'authorization_team_revisions', firstTeam) - two = observe(run, actorRun(run, f, spec)) - pending.push(two) - await waitForEdges([[runPid, gatePid]]) - one = deleteOutcome() - pending.push(one) - await waitForEdges([[deletionPid, runPid]]) - await snapshot(spec.label, '01-serialized-contention', [gatePid, deletionPid, runPid], f) - await gate.query('SELECT pg_advisory_unlock($1,1)', [barrierClass]) - } - const outcomes = await Promise.all([one, two]) - const final = await expandedFinal(f) - save(`${spec.label}.outcome`, { - spec, - mode, - mutation: actualMutation, - fixture: f, - deletionPid, - runPid, - prelocked: observation.count > 0, - observations: observation.events, - paused, - outcomes, - final, - runtimeRole: 'control_api_runtime', - }) - console.info( - 'DB1_RESULT', - spec.label, - outcomes.map(o => (o.status === 'rejected' ? o.code : o.status)) - ) - if (mode === 'baseline') { - expect(outcomes.filter(o => o.status === 'rejected' && o.code === '40P01')).toHaveLength(1) - expect(outcomes.filter(o => o.status === 'fulfilled')).toHaveLength(1) - } else { - // Deliberately unchanged under each mutation: a real deadlock turns RED. - expect(outcomes.map(o => o.status)).toEqual(['fulfilled', 'fulfilled']) - expect(observation.count).toBeGreaterThan(0) - expect(outcomes.every(o => o.transaction === 'COMMIT')).toBe(true) - if (spec.tombstone || spec.writer === 'handover') - expect(final.users[0]?.lifecycle_state).toBe('retired') - else { - expect(final.users).toHaveLength(0) - expect(final.memberships).toHaveLength(0) - } - expect(final.medium.every(m => m.disabled)).toBe(true) - expect(final.runs).toHaveLength(1) - if (spec.audit || spec.twoTeam) expect(final.audit[0]?.triggerer_user_id).toBeNull() - if (spec.writer === 'route') - expect(outcomes[0]).toMatchObject({ - value: { status: 200, body: { deleted: true, id: f.user } }, - }) - if (spec.writer === 'managed') - expect(outcomes[0]).toMatchObject({ value: { deleted: { ok: true, id: f.user } } }) - if (spec.writer === 'handover') { - expect(final.receiver[0]?.lifecycle_state).toBe('active') - expect(final.receiverMemberships).toHaveLength(3) - expect(final.inviter[0]?.status).toBe('disabled') - if (extraReceivers.has(f.label)) { - const extra = ( - await databasePool.query( - 'SELECT team_id,status FROM team_members WHERE user_id=$1 ORDER BY team_id', - [extraReceivers.get(f.label)] - ) - ).rows - expect(extra).toHaveLength(2) - expect(extra.every(r => r.status === 'active')).toBe(true) - save(`${spec.label}.receiver-set`, { extra, user: extraReceivers.get(f.label) }) - } - } - } - } finally { - await gate.query('SELECT pg_advisory_unlock_all()') - await Promise.allSettled(pending) - querySpy?.mockRestore() - retain.mockRestore() - for (const c of clients) { - await c.query('ROLLBACK') - c.release() - } - await databasePool.query('TRUNCATE db1_trace_barriers, db1_parent_barriers') - } -} - -describeRealPostgres('user retirement revision lock order on real PostgreSQL 16', () => { - beforeAll(async () => { - adminPool = new Pool({ connectionString: adminUrl }) - const version = Number( - (await adminPool.query('SHOW server_version_num')).rows[0].server_version_num - ) - expect(version).toBeGreaterThanOrEqual(160000) - expect(version).toBeLessThan(170000) - expect(process.version).toMatch(/^v24\./) - await adminPool.query(`CREATE DATABASE "${database}"`) - target.pathname = `/${database}` - databasePool = new Pool({ connectionString: target.toString(), max: 12 }) - await initDb({ connect: () => databasePool.connect() }) - await databasePool.query('CREATE EXTENSION pgrowlocks') - await databasePool.query( - `INSERT INTO control_admin_users(id,username,password_hash,role,status) - VALUES ($1,$2,'scratch-unusable-hash','admin','active')`, - [actorId, `db1-${actorId}`] - ) - await databasePool.query(` - CREATE TABLE db1_trace_barriers(application_name TEXT, revision_table TEXT, subject_id TEXT); - CREATE TABLE db1_parent_barriers(application_name TEXT, subject_id UUID); - CREATE FUNCTION db1_trace_after_bump() RETURNS TRIGGER LANGUAGE plpgsql AS $$ - BEGIN - IF EXISTS (SELECT 1 FROM db1_trace_barriers - WHERE application_name = current_setting('application_name') AND revision_table = TG_TABLE_NAME - AND subject_id = COALESCE(to_jsonb(NEW)->>'user_id',to_jsonb(NEW)->>'team_id')) - THEN PERFORM pg_advisory_xact_lock(${barrierClass},1); END IF; - RETURN NEW; - END; $$; - CREATE TRIGGER zz_db1_trace_after_bump AFTER INSERT OR UPDATE ON authorization_user_revisions - FOR EACH ROW EXECUTE FUNCTION db1_trace_after_bump(); - CREATE TRIGGER zz_db1_trace_after_bump AFTER INSERT OR UPDATE ON authorization_team_revisions - FOR EACH ROW EXECUTE FUNCTION db1_trace_after_bump(); - CREATE FUNCTION db1_before_parent_delete() RETURNS TRIGGER LANGUAGE plpgsql AS $$ - BEGIN - IF EXISTS (SELECT 1 FROM db1_parent_barriers WHERE application_name=current_setting('application_name') AND subject_id=OLD.id) - THEN PERFORM pg_advisory_xact_lock(${barrierClass},1); END IF; - RETURN OLD; - END; $$; - CREATE TRIGGER zz_db1_before_parent_delete BEFORE DELETE ON users FOR EACH ROW EXECUTE FUNCTION db1_before_parent_delete(); - GRANT SELECT ON db1_parent_barriers TO control_api_runtime; - `) - installPoolRouting() - await installChildBarriers() - save('environment', { - node: process.version, - nodeExecPath: process.execPath, - postgres: (await databasePool.query('SELECT version()')).rows, - mutation, - privileges: ( - await databasePool.query(`SELECT relname, - has_table_privilege('control_api_runtime',oid,'SELECT') AS can_select, - has_table_privilege('control_api_runtime',oid,'UPDATE') AS can_update - FROM pg_class WHERE relname IN ('authorization_user_revisions','authorization_team_revisions')`) - ).rows, - function: ( - await databasePool.query(`SELECT proowner::regrole::text,prosecdef,proconfig,proacl FROM pg_proc - WHERE proname='authorization_prelock_user_retirement_revisions'`) - ).rows, - constraints: ( - await databasePool.query(`SELECT conrelid::regclass::text,conname,confrelid::regclass::text,pg_get_constraintdef(oid) - FROM pg_constraint WHERE contype='f' AND confrelid='users'::regclass ORDER BY conname`) - ).rows, - triggers: ( - await databasePool.query(`SELECT tgrelid::regclass::text,tgname,pg_get_triggerdef(oid) - FROM pg_trigger WHERE tgrelid IN ('users'::regclass,'team_members'::regclass,'workflow_runs'::regclass,'workflow_runs_audit'::regclass) - ORDER BY tgrelid,tgname`) - ).rows, - }) - }, 60_000) - afterAll(async () => { - try { - vi.restoreAllMocks() - await endPoolAndWaitForClients(databasePool) - if (adminPool) await adminPool.query(`DROP DATABASE IF EXISTS "${database}"`) - } finally { - await adminPool?.end() - } - }) - for (const spec of [ - { label: 'baseline-A-admin', legacy: true, writer: 'admin' }, - { label: 'baseline-A-hard-delete', legacy: true }, - { label: 'baseline-B-medium' }, - { label: 'baseline-B-admin', writer: 'admin' }, - { label: 'baseline-A-tombstone', legacy: true, tombstone: true }, - { label: 'baseline-B-route', writer: 'route' }, - { label: 'baseline-handover', writer: 'handover' }, - ] as Spec[]) - it(spec.label + ' reproduces 40P01', () => contention(spec, 'baseline')) - for (const spec of [ - { label: 'fixed-A-hard-delete', legacy: true }, - { label: 'fixed-A-admin', legacy: true, writer: 'admin' }, - { label: 'fixed-B-medium' }, - { label: 'fixed-B-admin', writer: 'admin' }, - { label: 'fixed-A-tombstone', legacy: true, tombstone: true }, - { label: 'fixed-B-tombstone', tombstone: true }, - { label: 'fixed-route', writer: 'route' }, - { label: 'fixed-managed', writer: 'managed' }, - { label: 'fixed-handover', writer: 'handover' }, - { label: 'fixed-deleted-membership', deleted: true }, - { label: 'fixed-audit-team', audit: true }, - { label: 'fixed-two-team-run', twoTeam: true }, - ] as Spec[]) - for (const order of ['writer-first', 'retirement-first'] as const) { - const ordered = { ...spec, order, label: `${spec.label}-${order}` } - it(ordered.label + ' serializes under runtime role', () => - contention(ordered, mutation && !mutation.startsWith('toctou-') ? 'mutation' : 'fixed') - ) - } - for (const spec of [ - { label: 'fixed-membership-route', child: 'membership', writer: 'route' }, - { label: 'fixed-membership-managed', child: 'membership', writer: 'managed' }, - { label: 'fixed-membership-handover', child: 'membership', writer: 'handover' }, - { label: 'fixed-medium-hard-delete', child: 'medium' }, - { label: 'fixed-medium-tombstone', child: 'medium', tombstone: true }, - { label: 'fixed-medium-handover', child: 'medium', writer: 'handover' }, - ] as Spec[]) - for (const order of ['writer-first', 'retirement-first'] as const) { - const ordered = { ...spec, order, label: `${spec.label}-${order}` } - it(ordered.label, () => childContention(ordered, false)) - } - registerErrors() - registerOnlineBackfill() - registerToctouTests() - registerCollationTest() - - it('runtime role cannot lock revision tables directly; scoped definer succeeds without revision changes', async () => { - const f = await expandedFixture({ label: 'privilege-witness', deleted: true, twoTeam: true }) - const c = await databasePool.connect() - try { - await runtimeBegin(c, 'privilege-witness') - await c.query('SAVEPOINT privilege_probe') - const forbidden = await c - .query('SELECT * FROM authorization_team_revisions WHERE team_id=$1 FOR UPDATE', [f.team]) - .then( - () => null, - e => ({ code: e.code, message: e.message }) - ) - expect(forbidden?.code).toBe('42501') - await c.query('ROLLBACK TO SAVEPOINT privilege_probe') - const before = ( - await c.query('SELECT * FROM authorization_user_revisions WHERE user_id=$1', [f.user]) - ).rows - await prelock(c, [f.user]) - const after = ( - await c.query('SELECT * FROM authorization_user_revisions WHERE user_id=$1', [f.user]) - ).rows - expect(after).toEqual(before) - save('runtime-privilege-witness', { - forbidden, - before, - after, - role: (await c.query('SELECT current_user')).rows, - held: (await databasePool.query("SELECT * FROM pgrowlocks('authorization_team_revisions')")) - .rows, - }) - await c.query('ROLLBACK') - } finally { - await c.query('ROLLBACK') - c.release() - } - }) - it('union mapping includes deleted memberships and raw audit TEXT while excluding sentinel NULL and empty', async () => { - const f = await expandedFixture({ label: 'union-mapping', deleted: true, twoTeam: true }) - for (const usage of [null, '', 'control-plane-admin-ui']) { - await databasePool.query( - `INSERT INTO workflow_runs_audit(run_id,run_namespace,run_name,recipe_namespace,recipe_name, - triggerer_user_id,triggerer_actor_type,usage_team_id,trigger_source,idempotency_key,triggered_at,completed_at, - final_phase,step_count,snapshot_sha) VALUES (gen_random_uuid(),'db1-trace','mapping','db1-trace','mapping', - $1,'user',$2,'onDemand','mapping',NOW(),NOW(),'Succeeded',0,'scratch-design')`, - [f.user, usage] - ) - } - const keys = ( - await databasePool.query( - `SELECT subject_id FROM ( - SELECT team_id::text AS subject_id FROM team_members WHERE user_id=$1 - UNION SELECT s.subject_id FROM workflow_runs_audit a CROSS JOIN LATERAL (VALUES - (a.triggerer_team_id::text),(CASE WHEN a.usage_team_id='control-plane-admin-ui' THEN NULL ELSE a.usage_team_id END)) s(subject_id) - WHERE a.triggerer_user_id=$1 AND s.subject_id IS NOT NULL AND s.subject_id<>'' - ) affected ORDER BY subject_id`, - [f.user] - ) - ).rows.map(r => r.subject_id) - expect(keys).toHaveLength(3) - expect(keys).toEqual(expect.arrayContaining([f.team, f.thirdTeam, f.secondTeam.toUpperCase()])) - const c = await databasePool.connect() - try { - await runtimeBegin(c, 'union-mapping') - await prelock(c, [f.user]) - const pidValue = await pid(c) - const locks = ( - await databasePool.query( - `SELECT r.team_id::text,p.* FROM pgrowlocks('authorization_team_revisions') p - JOIN authorization_team_revisions r ON r.ctid=p.locked_row WHERE $1=ANY(p.pids) ORDER BY r.team_id`, - [pidValue] - ) - ).rows - expect(locks.map(r => r.team_id).sort()).toEqual([f.team, f.secondTeam, f.thirdTeam].sort()) - save('union-mapping-witness', { fixture: f, keys, locks }) - await c.query('ROLLBACK') - } finally { - await c.query('ROLLBACK') - c.release() - } - }) - it('definer parent lock blocks a new membership at the real users KEY SHARE FK', async () => { - const f = await expandedFixture({ label: 'parent-fk-witness' }) - const clients = await Promise.all([databasePool.connect(), databasePool.connect()]) - const [owner, insert] = clients as [PoolClient, PoolClient] - let pending: Promise | undefined - try { - await runtimeBegin(owner, 'parent-fk-owner') - await prelock(owner, [f.user]) - await runtimeBegin(insert, 'parent-fk-insert') - const ownerPid = await pid(owner), - insertPid = await pid(insert) - pending = observe( - insert, - insert.query( - "INSERT INTO team_members(team_id,user_id,role,status) VALUES($1,$2,'member','active')", - [f.secondTeam, f.user] - ) - ) - await waitForEdges([[insertPid, ownerPid]]) - await snapshot('parent-fk-witness', '01-membership-blocked', [ownerPid, insertPid], f) - await databasePool.query('SELECT pg_cancel_backend($1)', [insertPid]) - const outcome = await pending - expect(outcome).toMatchObject({ - status: 'rejected', - code: '57014', - where: expect.stringContaining('FOR KEY SHARE'), - }) - save('parent-fk-witness.outcome', { outcome }) - } finally { - await owner.query('ROLLBACK') - await pending - for (const c of clients) { - await c.query('ROLLBACK') - c.release() - } - } - }) - it('uncontended control commits real run then retirement', async () => { - const f = await expandedFixture({ label: 'uncontended-control' }) - const c = await databasePool.connect() - try { - await runtimeBegin(c, 'uncontended-run') - const run = await observe(c, runWrite(c, f)) - await runtimeBegin(c, 'uncontended-retirement') - await prelock(c, [f.user]) - const retirement = await observe(c, retirementWrite(c, f)) - expect([run.status, retirement.status]).toEqual(['fulfilled', 'fulfilled']) - save('uncontended-control.outcome', { run, retirement, final: await finalState(f) }) - } finally { - await c.query('ROLLBACK') - c.release() - } - }) -}) - -async function installChildBarriers() { - await databasePool.query(` - CREATE TABLE db1_child_barriers(application_name TEXT, child_table TEXT, user_id UUID); - CREATE FUNCTION db1_before_child_update() RETURNS TRIGGER LANGUAGE plpgsql AS $$ - BEGIN - IF EXISTS (SELECT 1 FROM db1_child_barriers WHERE application_name=current_setting('application_name') - AND child_table=TG_TABLE_NAME AND user_id=NEW.user_id) - THEN PERFORM pg_advisory_xact_lock(${barrierClass},2); END IF; - RETURN NEW; - END; $$; - CREATE TRIGGER db1_child_pause BEFORE UPDATE ON team_members FOR EACH ROW EXECUTE FUNCTION db1_before_child_update(); - CREATE TRIGGER db1_child_pause BEFORE UPDATE ON workflow_approval_medium_accounts FOR EACH ROW EXECUTE FUNCTION db1_before_child_update(); - GRANT SELECT ON db1_child_barriers TO control_api_runtime; - `) -} -async function childContention(spec: Spec, mutated: boolean) { - const f = await expandedFixture(spec) - await backfillRevisions() - const clients = await Promise.all([ - databasePool.connect(), - databasePool.connect(), - databasePool.connect(), - ]) - const [gate, deletion, writer] = clients as [PoolClient, PoolClient, PoolClient] - const retains = [deletion, writer].map(c => - vi.spyOn(c, 'release').mockImplementation(() => undefined) - ) - const pending: Promise[] = [] - const raw = deletion.query.bind(deletion) - const writerFirst = spec.order !== 'retirement-first' - const observation = installObservation( - deletion, - spec, - true, - mutated - ? async () => { - await raw('SELECT pg_advisory_xact_lock($1,1)', [barrierClass]) - } - : undefined - ) - try { - const [gatePid, deletionPid, writerPid] = await Promise.all(clients.map(pid)) - await gate.query('SELECT pg_advisory_lock($1,1), pg_advisory_lock($1,2)', [barrierClass]) - await runtimeBegin(deletion, `${spec.label}-delete`) - await runtimeBegin(writer, `${spec.label}-writer`) - const account = ( - await databasePool.query( - 'SELECT id FROM workflow_approval_medium_accounts WHERE user_id=$1', - [f.user] - ) - ).rows[0]?.id - const write = () => - observe( - writer, - serviceScope.run(writer, () => - spec.child === 'membership' - ? updateMemberRole(f.team, f.user, 'inviter', actorId) - : preferVerifiedMediumAccount({ userId: f.user, accountId: account }) - ) - ) - const retire = () => - observe( - deletion, - serviceScope.run(deletion, () => deletionWrite(deletion, f, spec)) - ) - let one: Promise, two: Promise - if (writerFirst) { - await databasePool.query('INSERT INTO db1_child_barriers VALUES($1,$2,$3)', [ - `${spec.label}-writer`, - spec.child === 'membership' ? 'team_members' : 'workflow_approval_medium_accounts', - f.user, - ]) - two = write() - pending.push(two) - await waitForEdges([[writerPid, gatePid]]) - one = retire() - pending.push(one) - if (mutated) { - await waitForEdges([[deletionPid, gatePid]]) - await snapshot(spec.label, '01-prelock-held', [gatePid, deletionPid, writerPid], f) - await gate.query('SELECT pg_advisory_unlock($1,1)', [barrierClass]) - await waitForEdges([[deletionPid, writerPid]]) - await snapshot(spec.label, '02-child-blocked', [gatePid, deletionPid, writerPid], f) - await gate.query('SELECT pg_advisory_unlock($1,2)', [barrierClass]) - await waitForEdges([ - [deletionPid, writerPid], - [writerPid, deletionPid], - ]) - await snapshot(spec.label, '03-cycle', [gatePid, deletionPid, writerPid], f) - } else { - await waitForEdges([[deletionPid, writerPid]]) - await snapshot(spec.label, '01-serialized-contention', [gatePid, deletionPid, writerPid], f) - await gate.query('SELECT pg_advisory_unlock($1,2)', [barrierClass]) - } - } else { - // Retire first through its actual medium disable / receiver copy. The - // second writer then sees the committed disabled/deleted/retired state. - await revisionBarrier(`${spec.label}-delete`, 'authorization_user_revisions', f.user) - one = retire() - pending.push(one) - await waitForEdges([[deletionPid, gatePid]]) - two = write() - pending.push(two) - await waitForEdges([[writerPid, deletionPid]]) - await snapshot(spec.label, '01-serialized-contention', [gatePid, deletionPid, writerPid], f) - await gate.query('SELECT pg_advisory_unlock($1,1)', [barrierClass]) - } - const outcomes = await Promise.all([one, two]) - const final = await expandedFinal(f) - save(`${spec.label}.outcome`, { - spec, - mutation: mutated ? mutation : '', - fixture: f, - deletionPid, - writerPid, - outcomes, - final, - observations: observation.events, - runtimeRole: 'control_api_runtime', - }) - console.info( - 'DB1_CHILD_RESULT', - spec.label, - outcomes.map(o => (o.status === 'rejected' ? o.code : o.transaction)) - ) - // These fixed assertions remain unchanged for mutations; 40P01 makes RED. - expect(outcomes.map(o => o.status)).toEqual(['fulfilled', 'fulfilled']) - expect(outcomes.every(o => o.transaction === 'COMMIT')).toBe(true) - if (spec.tombstone || spec.writer === 'handover') - expect(final.users[0]?.lifecycle_state).toBe('retired') - else { - expect(final.users).toHaveLength(0) - expect(final.memberships).toHaveLength(0) - } - expect(final.medium.every(m => m.disabled)).toBe(true) - if (spec.writer === 'managed') - expect(outcomes[0]).toMatchObject({ value: { deleted: { ok: true, id: f.user } } }) - if (spec.writer === 'handover') { - expect(final.receiver[0]?.lifecycle_state).toBe('active') - expect(final.receiverMemberships).toHaveLength(3) - expect(final.inviter[0]?.status).toBe('disabled') - } - if (writerFirst) expect((outcomes[1] as any).value).toBeTruthy() - else if (spec.child === 'medium') expect((outcomes[1] as any).value).toBeNull() - } finally { - await gate.query('SELECT pg_advisory_unlock_all()') - await Promise.allSettled(pending) - observation.spy.mockRestore() - retains.forEach(s => s.mockRestore()) - for (const c of clients) { - await c.query('ROLLBACK') - c.release() - } - await databasePool.query('TRUNCATE db1_child_barriers,db1_trace_barriers,db1_parent_barriers') - } -} - -async function withObservedClient( - spec: Spec, - work: (c: PoolClient, observation: ReturnType) => Promise -) { - const c = await databasePool.connect() - const retain = vi.spyOn(c, 'release').mockImplementation(() => undefined) - const observation = installObservation(c, spec, true) - try { - await runtimeBegin(c, spec.label) - return await serviceScope.run(c, () => work(c, observation)) - } finally { - observation.spy.mockRestore() - retain.mockRestore() - await c.query('ROLLBACK') - c.release() - } -} -async function seedLink(userId: string, adminId: string) { - await databasePool.query( - `INSERT INTO gfs_desktop_operator_links - (id,lineage_id,generation,user_id,control_admin_id,state,source,created_by,row_version) - VALUES(gen_random_uuid(),gen_random_uuid(),1,$1,$2,'active','initial_setup',$2,1)`, - [userId, adminId] - ) -} -function registerErrors() { - for (const which of ['target', 'manager'] as const) - it(`managed malformed ${which} preserves error contract and filters principalIds`, async () => { - const spec: Spec = { label: `error-managed-${which}`, writer: 'managed' } - const f = await expandedFixture(spec) - const manager = which === 'manager' ? 'malformed-manager' : f.manager - const target = which === 'target' ? 'not-a-uuid' : f.user.toUpperCase() - const result = await withObservedClient(spec, async (_c, observation) => { - const value = await deleteManagedUserForUser( - manager, - target, - { reason: 'DB1 error', idempotencyKey: randomUUID(), requestId: null }, - which === 'target' ? authorities.get(f.manager) : undefined - ) - const batch = (observation.events[0] as any).ids - expect(batch).toEqual([which === 'target' ? f.manager : f.user]) - return { value, batch } - }) - expect(result.value).toEqual({ error: 'invalid_retirement_input' }) - expect((await expandedFinal(f)).users[0]?.lifecycle_state).toBe('active') - save(spec.label, result) - }) - for (const which of ['not-found', 'retained-history'] as const) - it(`admin-delete ${which} preserves refusal`, async () => { - const spec: Spec = { label: `error-admin-${which}`, writer: 'admin' } - const f = await expandedFixture(spec) - if (which === 'retained-history') await seedLink(f.user, actorId) - const id = which === 'not-found' ? randomUUID() : f.user - const result = await withObservedClient(spec, async (c, observation) => ({ - outcome: await observe(c, adminDeleteUserInTransaction(c, id)), - observations: observation.events, - })) - expect(result.outcome).toMatchObject({ - status: 'fulfilled', - transaction: 'COMMIT', - value: { - error: which === 'not-found' ? 'not_found' : 'gfs_operator_link_history_retained', - }, - }) - const final = await expandedFinal(f) - expect(final.users[0]?.lifecycle_state).toBe('active') - expect(final.medium).toEqual([{ disabled: false }]) - save(spec.label, { ...result, final }) - }) - it('completed-operation replay returns before retirement prelock even after hard delete', async () => { - const spec: Spec = { label: 'error-completed-replay' } - const f = await expandedFixture(spec) - const first = await withObservedClient(spec, async (c, observation) => ({ - outcome: await observe(c, retirementWrite(c, f)), - count: observation.count, - })) - const second = await withObservedClient(spec, async (c, observation) => ({ - outcome: await observe(c, retirementWrite(c, f)), - count: observation.count, - })) - expect(first.outcome).toMatchObject({ - status: 'fulfilled', - value: { replayed: false, outcome: 'deleted' }, - }) - expect(second.outcome).toMatchObject({ - status: 'fulfilled', - value: { replayed: true, outcome: 'deleted' }, - }) - expect(first.count).toBe(1) - expect(second.count).toBe(0) - expect((await expandedFinal(f)).operations).toEqual([ - { status: 'completed', outcome: 'deleted' }, - ]) - save(spec.label, { first, second, final: await expandedFinal(f) }) - }) - for (const which of ['self', 'missing'] as const) - it(`real handover discovery refuses ${which} receiver`, async () => { - const spec: Spec = { label: `error-handover-${which}`, writer: 'handover' } - const f = await expandedFixture(spec) - if (which === 'self') { - await seedLink(f.user, f.inviterAdmin!) - await databasePool.query('UPDATE control_admin_users SET email=$2 WHERE id=$1', [ - f.inviterAdmin, - `other-${f.inviterAdmin}@example.test`, - ]) - f.receiverEmail = `db1-${f.user}@example.test` - await databasePool.query('UPDATE control_admin_invitations SET email=$2 WHERE id=$1', [ - f.invitation, - f.receiverEmail, - ]) - } else await databasePool.query('DELETE FROM users WHERE id=$1', [f.receiver]) - const outcome = await withObservedClient(spec, async c => - observe(c, Promise.resolve(deletionWrite(c, f, spec))) - ) - expect(outcome).toMatchObject({ - status: 'fulfilled', - value: { error: 'replace_inviter_receiver_missing' }, - }) - const final = await expandedFinal(f) - expect(final.users[0]?.lifecycle_state).toBe('active') - expect(final.inviter[0]?.status).toBe('active') - const invitation = ( - await databasePool.query( - 'SELECT status,accepted_admin_id FROM control_admin_invitations WHERE id=$1', - [f.invitation] - ) - ).rows - expect(invitation).toEqual([{ status: 'pending', accepted_admin_id: null }]) - save(spec.label, { outcome, final, invitation }) - }) - for (const which of ['email', 'link'] as const) - it(`real handover discovery fails closed on concurrent ${which} change`, async () => { - const spec: Spec = { label: `error-handover-race-${which}`, writer: 'handover' } - const f = await expandedFixture(spec) - if (which === 'link') await seedLink(f.user, f.inviterAdmin!) - const changer = await databasePool.connect() - let changed!: () => void - const changeStarted = new Promise(resolve => (changed = resolve)) - let pending: Promise | undefined - try { - await begin(changer, `${spec.label}-change`) - discoveryHook = async () => { - // Both operations use existing rows. Email changes hold the receiver - // user row; link version changes hold its real FK-referenced user. - if (which === 'email') - await changer.query('UPDATE users SET email=$2 WHERE id=$1', [ - f.receiver, - `changed-${f.receiver}@example.test`, - ]) - else { - await changer.query('SELECT id FROM users WHERE id=$1 FOR UPDATE', [f.user]) - await changer.query( - 'UPDATE gfs_desktop_operator_links SET row_version=row_version+1 WHERE user_id=$1', - [f.user] - ) - } - changed() - } - pending = withObservedClient(spec, async c => - observe(c, Promise.resolve(deletionWrite(c, f, spec))) - ) - await changeStarted - const changerPid = await pid(changer) - const d = ( - await databasePool.query('SELECT pid FROM pg_stat_activity WHERE application_name=$1', [ - spec.label, - ]) - ).rows[0].pid - await waitForEdges([[d, changerPid]]) - await snapshot(spec.label, '01-identity-change-blocked', [d, changerPid], f) - await changer.query('COMMIT') - const outcome = await pending - expect(outcome).toMatchObject({ - status: 'rejected', - code: 'handover_identity_changed', - transaction: 'ROLLBACK', - }) - const final = await expandedFinal(f) - expect(final.users[0]?.lifecycle_state).toBe('active') - expect(final.inviter[0]?.status).toBe('active') - expect(final.receiverMemberships).toHaveLength(1) - const invitation = ( - await databasePool.query( - 'SELECT status,accepted_admin_id FROM control_admin_invitations WHERE id=$1', - [f.invitation] - ) - ).rows - expect(invitation).toEqual([{ status: 'pending', accepted_admin_id: null }]) - save(spec.label, { outcome, final, invitation }) - } finally { - discoveryHook = undefined - await changer.query('ROLLBACK') - await pending - changer.release() - } - }) - for (const order of ['writer-first', 'retirement-first'] as const) - it(`handover discovers case-insensitive receiver SET and linked sender ${order}`, async () => { - const spec: Spec = { label: `fixed-handover-set-${order}`, writer: 'handover', order } - // The common fixture creates the sender email mapping. Add a second active - // receiver differing only by case; UNIQUE(email) permits both identities. - receiverSetFixture = true - try { - await contention(spec, 'fixed') - } finally { - receiverSetFixture = false - } - }) -} -let receiverSetFixture = false - -function registerOnlineBackfill() { - it('Q1 short teams-before-users backfill aborts lock wait before deadlock and resumes with coverage', async () => { - const spec: Spec = { label: 'online-backfill-timeout', legacy: true } - const f = await expandedFixture(spec) - const clients = await Promise.all([ - databasePool.connect(), - databasePool.connect(), - databasePool.connect(), - ]) - const [gate, retire, batch] = clients as [PoolClient, PoolClient, PoolClient] - const raw = retire.query.bind(retire) - const observation = installObservation(retire, spec, true, () => - raw('SELECT pg_advisory_xact_lock($1,1)', [barrierClass]).then(() => undefined) - ) - const pending: Promise[] = [] - try { - const [gatePid, retirePid, batchPid] = await Promise.all(clients.map(pid)) - await gate.query('SELECT pg_advisory_lock($1,1)', [barrierClass]) - await runtimeBegin(retire, 'online-backfill-retirement') - await begin(batch, 'online-backfill-batch') - await batch.query("SET LOCAL lock_timeout='750ms'") - const retirement = observe(retire, retirementWrite(retire, f)) - pending.push(retirement) - await waitForEdges([[retirePid, gatePid]]) - // Deliberately overlap missing-row coverage with a retirement. A small - // batch and lock_timeout < deadlock_timeout fail closed, then resume. - await batch.query( - `INSERT INTO authorization_team_revisions(team_id,revision,updated_at) - SELECT id,1,clock_timestamp() FROM teams WHERE id=$1 ORDER BY id ON CONFLICT DO NOTHING`, - [f.team] - ) - const backfill = observe( - batch, - batch.query( - `INSERT INTO authorization_user_revisions(user_id,revision,updated_at) - SELECT id,1,clock_timestamp() FROM users WHERE id=$1 ORDER BY id ON CONFLICT DO NOTHING`, - [f.user] - ) - ) - pending.push(backfill) - await waitForEdges([[batchPid, retirePid]]) - await gate.query('SELECT pg_advisory_unlock($1,1)', [barrierClass]) - await waitForEdges([ - [batchPid, retirePid], - [retirePid, batchPid], - ]) - await snapshot(spec.label, '01-timeout-protected-cycle', [gatePid, retirePid, batchPid], f) - const outcomes = await Promise.all([retirement, backfill]) - expect(outcomes[0]).toMatchObject({ status: 'fulfilled', transaction: 'COMMIT' }) - expect(outcomes[1]).toMatchObject({ - status: 'rejected', - code: '55P03', - transaction: 'ROLLBACK', - }) - await backfillRevisions() - const uncovered = ( - await databasePool.query(`SELECT - (SELECT count(*)::int FROM users u LEFT JOIN authorization_user_revisions r ON r.user_id=u.id WHERE r.user_id IS NULL) AS users, - (SELECT count(*)::int FROM teams t LEFT JOIN authorization_team_revisions r ON r.team_id=t.id WHERE r.team_id IS NULL) AS teams`) - ).rows[0] - expect(uncovered).toEqual({ users: 0, teams: 0 }) - save(spec.label, { - outcomes, - uncovered, - lockTimeoutMs: 750, - deadlockTimeoutMs: 2000, - final: await expandedFinal(f), - }) - } finally { - await gate.query('SELECT pg_advisory_unlock_all()') - await Promise.allSettled(pending) - observation.spy.mockRestore() - for (const c of clients) { - await c.query('ROLLBACK') - c.release() - } - } - }) -} - -const extraReceivers = new Map() - -function registerToctouTests() { - for (const phase of [ - 'email-before-copy', - 'link-before-copy', - 'email-after-validation', - ] as const) { - it(`TOCTOU ${phase} closes handover identity set`, async () => { - const spec: Spec = { label: `toctou-${phase}`, writer: 'handover' } - const f = await expandedFixture(spec) - await seedLink(f.user, f.inviterAdmin!) - const phantom = f.manager, - phantomTeam = randomUUID() - await databasePool.query("INSERT INTO teams(id,name) VALUES($1,'TOCTOU phantom team')", [ - phantomTeam, - ]) - await databasePool.query( - "INSERT INTO team_members(team_id,user_id,role,status) VALUES($1,$2,'member','active')", - [phantomTeam, phantom] - ) - await backfillRevisions() - const before = await expandedFinal(f) - const changer = await databasePool.connect() - const changerPid = await pid(changer) - let prelockedIds: string[] = [], - heldTeams: string[] = [] - let observations: unknown[] = [] - try { - await begin(changer, `${spec.label}-change`) - const change = async () => { - const handoverPid = ( - await databasePool.query('SELECT pid FROM pg_stat_activity WHERE application_name=$1', [ - spec.label, - ]) - ).rows[0].pid - prelockedIds = ( - await databasePool.query( - `SELECT u.id FROM pgrowlocks('users') p JOIN users u ON u.ctid=p.locked_row - WHERE $1=ANY(p.pids) ORDER BY u.id`, - [handoverPid] - ) - ).rows.map(r => r.id) - heldTeams = ( - await databasePool.query( - `SELECT r.team_id FROM pgrowlocks('authorization_team_revisions') p - JOIN authorization_team_revisions r ON r.ctid=p.locked_row WHERE $1=ANY(p.pids) ORDER BY r.team_id`, - [handoverPid] - ) - ).rows.map(r => r.team_id) - expect(prelockedIds).toEqual([f.user, f.receiver].sort()) - expect(prelockedIds).not.toContain(phantom) - expect(heldTeams).not.toContain(phantomTeam) - await snapshot(spec.label, '01-after-prelock-before-change', [handoverPid, changerPid], f) - const result = - phase === 'link-before-copy' - ? await changer.query( - 'UPDATE gfs_desktop_operator_links SET row_version=row_version+1 WHERE user_id=$1', - [f.user] - ) - : await changer.query('UPDATE users SET email=$2 WHERE id=$1', [ - phantom, - f.receiverEmail!.toUpperCase(), - ]) - expect(result.rowCount).toBe(1) - await changer.query('COMMIT') - const matching = ( - await databasePool.query( - "SELECT id FROM users WHERE lower(email)=lower($1) AND lifecycle_state='active' ORDER BY id", - [f.receiverEmail] - ) - ).rows.map(r => r.id) - event({ - phase: 'toctou-change-committed', - label: spec.label, - moment: phase, - prelockedIds, - heldTeams, - phantom, - phantomTeam, - matching, - }) - } - if (phase === 'email-after-validation') afterCopyValidationHook = change - else beforeCopyHook = change - const outcome = await withObservedClient(spec, async (c, observation) => { - const result = await observe(c, Promise.resolve(deletionWrite(c, f, spec))) - observations = observation.events - return result - }) - const final = await expandedFinal(f) - const phantomMemberships = ( - await databasePool.query( - 'SELECT team_id,role,status FROM team_members WHERE user_id=$1 ORDER BY team_id', - [phantom] - ) - ).rows - const invitation = ( - await databasePool.query( - 'SELECT status,accepted_admin_id FROM control_admin_invitations WHERE id=$1', - [f.invitation] - ) - ).rows - const newAdmins = ( - await databasePool.query('SELECT id,status FROM control_admin_users WHERE email=$1', [ - f.receiverEmail, - ]) - ).rows - const trace = events.filter(e => e.label === spec.label) - // Persist the actual wrong copy/COMMIT before an unchanged assertion - // rejects the mutation. A RED is not inferred from a test exit alone. - save(spec.label, { - mutation, - phase, - fixture: f, - phantom, - phantomTeam, - prelockedIds, - heldTeams, - outcome, - before, - final, - phantomMemberships, - invitation, - newAdmins, - observations, - trace, - }) - expect(trace.filter(e => e.phase === 'handover-revalidated')).toHaveLength(1) - const comparisons = trace.filter(e => e.phase === 'handover-copy-revalidation') - expect(comparisons).toHaveLength(1) - if (phase === 'email-after-validation') { - expect(comparisons[0].identical).toBe(true) - expect(outcome).toMatchObject({ status: 'fulfilled', transaction: 'COMMIT' }) - expect(final.users[0]?.lifecycle_state).toBe('retired') - expect(final.receiverMemberships).toHaveLength(3) - expect(phantomMemberships).toEqual([ - { team_id: phantomTeam, role: 'member', status: 'active' }, - ]) - const copy = observations.find((e: any) => e.placement === 'handover-copy-bound') as any - expect(copy.ids).toEqual([f.receiver]) - expect(copy.sql).toContain('ON target.id = ANY($2::uuid[])') - } else { - expect(comparisons[0].identical).toBe(false) - expect(outcome).toMatchObject({ - status: 'rejected', - code: 'handover_identity_changed', - errorName: 'HandoverIdentityChangedError', - message: 'handover identity changed: before-membership-copy', - transaction: 'ROLLBACK', - }) - expect(final).toEqual(before) - expect(phantomMemberships).toEqual([ - { team_id: phantomTeam, role: 'member', status: 'active' }, - ]) - expect(invitation).toEqual([{ status: 'pending', accepted_admin_id: null }]) - expect(newAdmins).toEqual([]) - expect(observations.filter((e: any) => e.placement === 'handover-copy-executed')).toEqual( - [] - ) - } - } finally { - beforeCopyHook = undefined - afterCopyValidationHook = undefined - await changer.query('ROLLBACK') - changer.release() - } - }) - } -} - -function registerCollationTest() { - it('COLLATE omission uses the same raw TEXT ordering as both real triggers', async () => { - const definitions = ( - await databasePool.query(`SELECT proname,pg_get_functiondef(oid) AS definition FROM pg_proc - WHERE proname IN ('authorization_bump_workflow_run_revision','authorization_bump_workflow_run_audit_revision', - 'authorization_prelock_user_retirement_revisions') ORDER BY proname`) - ).rows - expect(definitions).toHaveLength(3) - for (const f of definitions) { - expect(f.definition).not.toMatch(/COLLATE\s+"/i) - expect(f.definition).toContain( - f.proname === 'authorization_prelock_user_retirement_revisions' - ? 'array_agg(subject_id ORDER BY subject_id)' - : 'ORDER BY subject_type, subject_id' - ) - } - const ordering = ( - await databasePool.query(`WITH keys(subject_type,subject_id) AS (VALUES - ('team'::text,'aaaaaaaa-0000-4000-8000-000000000001'::text), - ('team','BBBBBBBB-0000-4000-8000-000000000002'), - ('team','bbbbbbbb-0000-4000-8000-000000000002'), - ('team','00000000-0000-4000-8000-000000000000')) - SELECT array_agg(subject_id ORDER BY subject_id) AS prelock, - array_agg(subject_id ORDER BY subject_type,subject_id) AS triggers, - array_agg(DISTINCT pg_collation_for(subject_id)::text) AS collations FROM keys`) - ).rows[0] - expect(ordering.prelock).toEqual(ordering.triggers) - expect(ordering.collations).toEqual(['"default"']) - const db = ( - await databasePool.query( - 'SELECT datcollate,datctype,datlocprovider FROM pg_database WHERE datname=current_database()' - ) - ).rows[0] - save('collation-witness', { - decision: 'omit COLLATE to match unchanged triggers', - db, - ordering, - definitions, - limitation: - 'Local PG16 ordering witness only; no live-cluster locale probe or normalization claim.', - }) - }) -} diff --git a/deploy/scripts/control-api-runtime-access-profiles.tsv b/deploy/scripts/control-api-runtime-access-profiles.tsv index 5b693dc11b..32eaaed49d 100644 --- a/deploy/scripts/control-api-runtime-access-profiles.tsv +++ b/deploy/scripts/control-api-runtime-access-profiles.tsv @@ -1,10 +1,6 @@ # public relationcontrol_api_runtime access profile administrative_events append agent_run_events append -authorization_catalog_environment read -authorization_resource_revisions read -authorization_team_revisions read -authorization_user_revisions read entity_change_feed none entity_change_outbox none entity_change_watermark none @@ -43,7 +39,6 @@ infrastructure_cost_daily read infrastructure_cost_daily_components read infrastructure_price_snapshots read infrastructure_telemetry_events append -invitation_delivery_commands legacy_dml invitation_teams legacy_dml invitations legacy_dml llm_allowed_models legacy_dml @@ -57,11 +52,6 @@ mcp_secret_rollback_permits legacy_dml notification_deliveries legacy_dml dynamic_clients legacy_dml oauth_grants legacy_dml -operational_catalog_source_state legacy_dml -operational_relationships_staging legacy_dml -operational_resource_index legacy_dml -operational_resource_index_staging legacy_dml -operational_resource_relationships legacy_dml password_admission_rollout_state none password_identifier_state legacy_dml password_verification_pace legacy_dml diff --git a/deploy/scripts/workflow-recipes-runtime-access-profiles.tsv b/deploy/scripts/workflow-recipes-runtime-access-profiles.tsv index cb7ada1560..118a4ccd2d 100644 --- a/deploy/scripts/workflow-recipes-runtime-access-profiles.tsv +++ b/deploy/scripts/workflow-recipes-runtime-access-profiles.tsv @@ -1,10 +1,6 @@ # public relationworkflow_recipes_runtime access profile administrative_events none agent_run_events none -authorization_catalog_environment none -authorization_resource_revisions none -authorization_team_revisions none -authorization_user_revisions none entity_change_feed none entity_change_outbox none entity_change_watermark none @@ -43,7 +39,6 @@ infrastructure_cost_daily none infrastructure_cost_daily_components none infrastructure_price_snapshots none infrastructure_telemetry_events none -invitation_delivery_commands none invitation_teams none invitations none llm_allowed_models none @@ -57,11 +52,6 @@ mcp_secret_rollback_permits none notification_deliveries none dynamic_clients none oauth_grants none -operational_catalog_source_state none -operational_relationships_staging none -operational_resource_index none -operational_resource_index_staging none -operational_resource_relationships none password_admission_rollout_state none password_identifier_state none password_verification_pace none diff --git a/scripts/tests/test-control-api-migration-bounds.sh b/scripts/tests/test-control-api-migration-bounds.sh index 922ea60f68..0f70d970e4 100755 --- a/scripts/tests/test-control-api-migration-bounds.sh +++ b/scripts/tests/test-control-api-migration-bounds.sh @@ -44,8 +44,8 @@ grep -q 'applyPendingPr1Migrations' "$DB_FILE" || \ grep -q 'CREATE INDEX CONCURRENTLY' "$INDEX_PLAN_FILE" || \ fail "existing-table indexes are not prepared concurrently" -index_count="$(grep -c "^ migrationVersion: '013[13]_" "$INDEX_PLAN_FILE")" -[ "$index_count" = "25" ] || fail "expected 25 online indexes, found $index_count" +index_count="$(grep -c "^ migrationVersion: '0131_" "$INDEX_PLAN_FILE")" +[ "$index_count" = "5" ] || fail "expected 5 online indexes, found $index_count" grep -q 'activeDeadlineSeconds' "$DEPLOY_FILE" || \ fail "migration Job has no active deadline" diff --git a/scripts/tests/test-control-api-runtime-access-contract.sh b/scripts/tests/test-control-api-runtime-access-contract.sh index fcea7baa52..9912055279 100755 --- a/scripts/tests/test-control-api-runtime-access-contract.sh +++ b/scripts/tests/test-control-api-runtime-access-contract.sh @@ -17,7 +17,6 @@ name_diff="$(comm -3 \ # Existing sentinel rows, plus the three session rows. The expected relation # count is the peer workflow-recipes profile, not a handwritten literal. sentinels=( - $'authorization_catalog_environment\tread' $'dynamic_clients\tlegacy_dml' $'entity_change_feed\tnone' $'entity_change_outbox\tnone' diff --git a/tests/contracts/password-recovery-suites.json b/tests/contracts/password-recovery-suites.json index 67657f483a..637db85224 100644 --- a/tests/contracts/password-recovery-suites.json +++ b/tests/contracts/password-recovery-suites.json @@ -104,7 +104,6 @@ "control-api/test/externalSessionCurrentness.realPostgres.integration.test.ts", "control-api/test/externalSessionSingleTouch.realPostgres.integration.test.ts", "control-api/test/externalSessionV1CanonicalRevocation.realPostgres.integration.test.ts", - "control-api/test/gfsRevisionLockOrder.realPostgres.integration.test.ts", "control-api/test/invitationAcceptedOnboarding.realPostgres.integration.test.ts", "control-api/test/legacyPasswordEpochBackfill.realPostgres.integration.test.ts", "control-api/test/legacySecurityCutoffSites.realPostgres.integration.test.ts", @@ -112,8 +111,6 @@ "control-api/test/legacyV1Generation.realPostgres.integration.test.ts", "control-api/test/membershipInvitationIdempotency.realPostgres.integration.test.ts", "control-api/test/migrationExecutionPolicy.realPostgres.integration.test.ts", - "control-api/test/migrationGfsBackfillBound.realPostgres.integration.test.ts", - "control-api/test/migrationUserRetirementRevisions.realPostgres.integration.test.ts", "control-api/test/migrationPreviousImageCompatibility.realPostgres.integration.test.ts", "control-api/test/pr1MigrationLocks.realPostgres.integration.test.ts", "control-api/test/routes.externalGfsV2Lifecycle.realPostgres.integration.test.ts", @@ -121,9 +118,7 @@ "control-api/test/routes.externalTeamsFinalAuthority.realPostgres.integration.test.ts", "control-api/test/routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts", "control-api/test/userAccessRuntimeContract.realPostgres.integration.test.ts", - "control-api/test/userRetirementRevisionLockOrder.realPostgres.integration.test.ts", "control-api/test/userSessionDatabaseClock.realPostgres.integration.test.ts", - "control-api/test/revisionWritersLockOrder.realPostgres.integration.test.ts", "control-api/test/workflowRunReadAndApprovalV1Compatibility.realPostgres.integration.test.ts" ], "privatePostgres": { From caa7ae6785f3599ffe7046cb602e5ead6711842c Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 4 Nov 2026 21:11:24 +0100 Subject: [PATCH 464/528] fix(control-api): restore cursorless directory search (K14) The team directory search goes back to dev's shape: an escaped ILIKE match ordered by sort key and user id, limited to 25 items, returning { items } without a cursor. The HMAC cursor encoder, its error type and the route's cursor branch are removed; the 100-character query bound stays. Edge and Desktop already send only teamId and q. Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- control-api/src/routes/external/directory.ts | 8 +- control-api/src/services/directory/index.ts | 1 - .../src/services/directory/membership.ts | 89 ++----------------- .../directoryAuthorizationPrivacy.test.ts | 41 ++++----- .../test/routes.externalDirectory.test.ts | 8 +- 7 files changed, 32 insertions(+), 121 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index b27f356800..d6bfaae8df 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.901", + "version": "0.1.902", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.901", + "version": "0.1.902", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 5b93989f69..5d8c8faf39 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.901", + "version": "0.1.902", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/routes/external/directory.ts b/control-api/src/routes/external/directory.ts index 3c398c3d97..214214fa18 100644 --- a/control-api/src/routes/external/directory.ts +++ b/control-api/src/routes/external/directory.ts @@ -8,7 +8,6 @@ import { } from '../../middleware/externalSessionAuth.js' import { DIRECTORY_SEARCH_MAX_QUERY_LENGTH, - DirectorySearchCursorError, searchDirectory, } from '../../services/directory/index.js' @@ -32,17 +31,12 @@ export function createExternalDirectoryRouter(): Router { try { const teamId = String(req.query.teamId || '').trim() const q = String(req.query.q || '').trim() - const cursor = typeof req.query.cursor === 'string' ? req.query.cursor.trim() : undefined if (!teamId || !q || q.length > DIRECTORY_SEARCH_MAX_QUERY_LENGTH) { res.status(400).json({ error: 'Invalid directory search request.' }) return } - return res.status(200).json(await searchDirectory(teamId, q, cursor)) + return res.status(200).json(await searchDirectory(teamId, q)) } catch (error) { - if (error instanceof DirectorySearchCursorError) { - res.status(400).json({ error: 'Invalid directory search cursor.' }) - return - } return next(error) } } diff --git a/control-api/src/services/directory/index.ts b/control-api/src/services/directory/index.ts index 7157c4045c..6e319e8db7 100644 --- a/control-api/src/services/directory/index.ts +++ b/control-api/src/services/directory/index.ts @@ -61,7 +61,6 @@ export { listMembers, listPendingInvitations, searchDirectory, - DirectorySearchCursorError, DIRECTORY_SEARCH_MAX_QUERY_LENGTH, softDeleteMember, startDraftInvitationCleanup, diff --git a/control-api/src/services/directory/membership.ts b/control-api/src/services/directory/membership.ts index 69f1ceadf9..d2a6e9ef4f 100644 --- a/control-api/src/services/directory/membership.ts +++ b/control-api/src/services/directory/membership.ts @@ -1,5 +1,3 @@ -import { createHmac, timingSafeEqual } from 'node:crypto' -import { config } from '../../config.js' import { type DbClient, pool, withTransaction } from '../../db.js' import { registerAndSendInvitation } from '../invitationFlowRegistrationService.js' import { appendControlApiPermissionEventsInTransaction } from '../tracing/controlApiPermissionEvents.js' @@ -946,72 +944,14 @@ export async function acceptInvitationById(email: string, invitationId: string) } export const DIRECTORY_SEARCH_MAX_QUERY_LENGTH = 100 -export const DIRECTORY_SEARCH_PAGE_SIZE = 25 - -export class DirectorySearchCursorError extends Error { - constructor() { - super('invalid directory search cursor') - this.name = 'DirectorySearchCursorError' - } -} function escapeLikeLiteral(value: string): string { return value.replace(/[\\%_]/g, character => `\\${character}`) } -function directorySearchCursorSignature(value: string): string { - return createHmac('sha256', config.sessionJwtPrivateKey).update(value).digest('base64url') -} - -function encodeDirectorySearchCursor(input: { - teamId: string - query: string - sortKey: string - userId: string -}): string { - const body = Buffer.from(JSON.stringify(input)).toString('base64url') - return `ds1.${body}.${directorySearchCursorSignature(body)}` -} - -function decodeDirectorySearchCursor( - value: string, - teamId: string, - query: string -): { sortKey: string; userId: string } { - const [prefix, body, signature, extra] = value.split('.') - if (prefix !== 'ds1' || !body || !signature || extra) throw new DirectorySearchCursorError() - const expected = directorySearchCursorSignature(body) - const suppliedBytes = Buffer.from(signature) - const expectedBytes = Buffer.from(expected) - if ( - suppliedBytes.length !== expectedBytes.length || - !timingSafeEqual(suppliedBytes, expectedBytes) - ) { - throw new DirectorySearchCursorError() - } - try { - const parsed = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as Record< - string, - unknown - > - const sortKey = typeof parsed.sortKey === 'string' ? parsed.sortKey : '' - const userId = typeof parsed.userId === 'string' ? parsed.userId : '' - if (parsed.teamId !== teamId || parsed.query !== query || !sortKey || !userId) { - throw new Error('cursor mismatch') - } - return { sortKey, userId } - } catch { - throw new DirectorySearchCursorError() - } -} - -export async function searchDirectory(teamId: string, q: string, cursor?: string) { +export async function searchDirectory(teamId: string, q: string) { const query = q.trim() - if (!query) return { items: [], nextCursor: null } - if (query.length > DIRECTORY_SEARCH_MAX_QUERY_LENGTH) { - throw new DirectorySearchCursorError() - } - const after = cursor ? decodeDirectorySearchCursor(cursor, teamId, query) : null + if (!query) return { items: [] } const pattern = `%${escapeLikeLiteral(query)}%` const result = await pool.query( @@ -1028,42 +968,23 @@ export async function searchDirectory(teamId: string, q: string, cursor?: string OR COALESCE(u.name, '') ILIKE $2 ESCAPE '\\' OR COALESCE(p.display_name, '') ILIKE $2 ESCAPE '\\' ) - AND ( - $3::text IS NULL - OR ( - LOWER(COALESCE(NULLIF(p.display_name, ''), NULLIF(u.name, ''), u.email)), - u.id - ) > ($3, $4::uuid) - ) ORDER BY sort_key ASC, u.id ASC - LIMIT $5`, - [teamId, pattern, after?.sortKey ?? null, after?.userId ?? null, DIRECTORY_SEARCH_PAGE_SIZE + 1] + LIMIT 25`, + [teamId, pattern] ) const rows = result.rows as Array<{ id: string email: string name: string | null display_name: string | null - sort_key: string }> - const page = rows.slice(0, DIRECTORY_SEARCH_PAGE_SIZE) - const last = page[page.length - 1] return { - items: page.map(row => ({ + items: rows.map(row => ({ id: row.id, email: row.email, name: row.name, displayName: row.display_name, })), - nextCursor: - rows.length > DIRECTORY_SEARCH_PAGE_SIZE && last - ? encodeDirectorySearchCursor({ - teamId, - query, - sortKey: last.sort_key, - userId: last.id, - }) - : null, } } diff --git a/control-api/test/directoryAuthorizationPrivacy.test.ts b/control-api/test/directoryAuthorizationPrivacy.test.ts index d1b3180b2c..3442b2aa32 100644 --- a/control-api/test/directoryAuthorizationPrivacy.test.ts +++ b/control-api/test/directoryAuthorizationPrivacy.test.ts @@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import bcrypt from 'bcryptjs' import { readFileSync } from 'node:fs' import { - DirectorySearchCursorError, acceptInvitationForEmail, createInvitationForTeams, createManagedInvitationForUser, @@ -1017,34 +1016,32 @@ describe('directory privacy and atomic authorization', () => { ).toHaveLength(1) }) - it('treats wildcard characters literally, excludes channels, and publishes a stable cursor', async () => { - mocks.query.mockImplementation(async (sql: string, values: unknown[]) => { - expect(sql).toContain('ILIKE $2 ESCAPE') - expect(sql).not.toContain('p.channels') - expect(values[1]).toBe('%\\%\\_%') - expect(values[4]).toBe(26) - return { - rows: Array.from({ length: 26 }, (_, index) => ({ - id: `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`, - email: `person-${index}@example.com`, - name: `Person ${index}`, - display_name: `Person ${index}`, - sort_key: `person ${String(index).padStart(2, '0')}`, - channels: { emails: ['secret@example.com'] }, - })), - rowCount: 26, - } + it('treats wildcard characters literally, excludes channels, and returns one bounded page without a cursor', async () => { + mocks.query.mockResolvedValue({ + rows: Array.from({ length: 25 }, (_, index) => ({ + id: `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`, + email: `person-${index}@example.com`, + name: `Person ${index}`, + display_name: `Person ${index}`, + sort_key: `person ${String(index).padStart(2, '0')}`, + channels: { emails: ['secret@example.com'] }, + })), + rowCount: 25, }) const result = await searchDirectory(TEAM_A, '%_') + // Witness: the search ran once, with the escaped pattern. + expect(mocks.query).toHaveBeenCalledTimes(1) + const [sql, values] = mocks.query.mock.calls[0] as [string, unknown[]] + expect(sql).toContain('ILIKE $2 ESCAPE') + expect(sql).not.toContain('p.channels') + expect(sql).toMatch(/ORDER BY sort_key ASC, u\.id ASC\s+LIMIT 25$/) + expect(values).toEqual([TEAM_A, '%\\%\\_%']) expect(result.items).toHaveLength(25) - expect(result.nextCursor).toMatch(/^ds1\./) + expect(result).not.toHaveProperty('nextCursor') expect(JSON.stringify(result)).not.toContain('channels') expect(JSON.stringify(result)).not.toContain('secret@example.com') - await expect( - searchDirectory(TEAM_A, '%_', `${result.nextCursor}tampered`) - ).rejects.toBeInstanceOf(DirectorySearchCursorError) }) function functionBody(source: string, name: string): string | null { diff --git a/control-api/test/routes.externalDirectory.test.ts b/control-api/test/routes.externalDirectory.test.ts index 96dfe50c91..52f93059f0 100644 --- a/control-api/test/routes.externalDirectory.test.ts +++ b/control-api/test/routes.externalDirectory.test.ts @@ -60,7 +60,7 @@ describe('external directory authorization', () => { sessionContract: 'v1', authGeneration: 1, }) - mocks.searchDirectory.mockResolvedValue({ items: [], nextCursor: null }) + mocks.searchDirectory.mockResolvedValue({ items: [] }) mocks.checkAndIncrement.mockResolvedValue(allowedRateLimitResult()) }) @@ -77,7 +77,7 @@ describe('external directory authorization', () => { 'team-1', expect.objectContaining({ onDatabaseFailure: expect.any(Function) }) ) - expect(mocks.searchDirectory).toHaveBeenCalledWith('team-1', 'person', undefined) + expect(mocks.searchDirectory).toHaveBeenCalledWith('team-1', 'person') }) it('refuses a caller without a live team membership', async () => { @@ -128,7 +128,7 @@ describe('external directory authorization', () => { .set('x-user-session-token', 'session') expect(response.status).toBe(200) - expect(mocks.searchDirectory).toHaveBeenCalledWith('team-1', 'person', undefined) + expect(mocks.searchDirectory).toHaveBeenCalledWith('team-1', 'person') }) it('keeps dev limits: an allowed search charges no per-user PG bucket', async () => { @@ -145,7 +145,7 @@ describe('external directory authorization', () => { 'team-1', expect.objectContaining({ onDatabaseFailure: expect.any(Function) }) ) - expect(mocks.searchDirectory).toHaveBeenCalledWith('team-1', 'person', undefined) + expect(mocks.searchDirectory).toHaveBeenCalledWith('team-1', 'person') // dev has no per-user directory-search bucket on this route. expect(mocks.checkAndIncrement).not.toHaveBeenCalled() expect(response.headers['x-ratelimit-limit']).toBeUndefined() From 1caef1b21580de937e0d5080a6b618e19fd0402d Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 4 Nov 2026 21:15:47 +0100 Subject: [PATCH 465/528] fix(control-api,profile-ui): use dev's invitation email validator (K15) Both invitation email validators return to dev's pattern, checking the 320-character limit on the raw value first. Tests add a one-character top-level domain positive in both copies so a stricter pattern fails. Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 +-- control-api/package.json | 2 +- .../src/routes/external/invitationEmail.ts | 24 +++--------------- .../test/routes.externalMembers.test.ts | 2 +- .../InviteMemberPage.emailValidation.test.tsx | 9 +++++++ profile-ui/lib/invitationEmail.ts | 25 +++---------------- profile-ui/package-lock.json | 4 +-- profile-ui/package.json | 2 +- 8 files changed, 24 insertions(+), 48 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index d6bfaae8df..12e1fefc01 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.902", + "version": "0.1.903", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.902", + "version": "0.1.903", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 5d8c8faf39..861f827d9f 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.902", + "version": "0.1.903", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/routes/external/invitationEmail.ts b/control-api/src/routes/external/invitationEmail.ts index acb7a81f3a..01d5d29888 100644 --- a/control-api/src/routes/external/invitationEmail.ts +++ b/control-api/src/routes/external/invitationEmail.ts @@ -1,27 +1,11 @@ export const MAX_INVITATION_EMAIL_LENGTH = 320 +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + export function normalizeInvitationEmail(value: unknown): string | null { + // The length bound comes first: EMAIL_PATTERN alone accepts longer addresses. if (typeof value !== 'string' || value.length > MAX_INVITATION_EMAIL_LENGTH) return null const email = value.trim().toLowerCase() - let atIndex = -1 - let atCount = 0 - - for (let index = 0; index < email.length; index += 1) { - const character = email[index] - if (!character || character.trim() === '') return null - if (character === '@') { - atIndex = index - atCount += 1 - } - } - - if (atCount !== 1 || atIndex <= 0 || atIndex >= email.length - 1) return null - - const domainStart = atIndex + 1 - for (let index = domainStart + 1; index < email.length - 1; index += 1) { - if (email[index] === '.') return email - } - - return null + return EMAIL_PATTERN.test(email) ? email : null } diff --git a/control-api/test/routes.externalMembers.test.ts b/control-api/test/routes.externalMembers.test.ts index 5f3f1fa2b4..7f7f1d8d53 100644 --- a/control-api/test/routes.externalMembers.test.ts +++ b/control-api/test/routes.externalMembers.test.ts @@ -120,7 +120,7 @@ describe('external members routes', () => { expect(directoryMock.createManagedInvitationForUser).not.toHaveBeenCalled() }) - it.each(['invitee@.example.com', 'invitee@example.com.'])( + it.each(['invitee@.example.com', 'invitee@example.com.', 'a@b.c'])( 'preserves bounded legacy domain-dot acceptance for %s', async email => { directoryMock.createManagedInvitationForUser.mockResolvedValueOnce({ diff --git a/profile-ui/app/components/__tests__/InviteMemberPage.emailValidation.test.tsx b/profile-ui/app/components/__tests__/InviteMemberPage.emailValidation.test.tsx index b8627917b0..d3dbe848c3 100644 --- a/profile-ui/app/components/__tests__/InviteMemberPage.emailValidation.test.tsx +++ b/profile-ui/app/components/__tests__/InviteMemberPage.emailValidation.test.tsx @@ -51,6 +51,15 @@ describe('InviteMemberPage email validation', () => { expect(screen.getByRole('button', { name: 'Continue' })).toBeEnabled() }) + it("keeps dev's one-character top-level domain usable", () => { + render() + + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Invitee' } }) + fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'a@b.c' } }) + + expect(screen.getByRole('button', { name: 'Continue' })).toBeEnabled() + }) + it('rejects a plausible invitation email longer than 320 characters', () => { render() diff --git a/profile-ui/lib/invitationEmail.ts b/profile-ui/lib/invitationEmail.ts index 4bd1fa1052..391095650b 100644 --- a/profile-ui/lib/invitationEmail.ts +++ b/profile-ui/lib/invitationEmail.ts @@ -1,27 +1,10 @@ export const MAX_INVITATION_EMAIL_LENGTH = 320 +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + export function isInvitationEmailFormatValid(value: string): boolean { + // The length bound comes first: EMAIL_PATTERN alone accepts longer addresses. if (value.length > MAX_INVITATION_EMAIL_LENGTH) return false - const email = value.trim() - let atIndex = -1 - let atCount = 0 - - for (let index = 0; index < email.length; index += 1) { - const character = email[index] - if (!character || character.trim() === '') return false - if (character === '@') { - atIndex = index - atCount += 1 - } - } - - if (atCount !== 1 || atIndex <= 0 || atIndex >= email.length - 1) return false - - const domainStart = atIndex + 1 - for (let index = domainStart + 1; index < email.length - 1; index += 1) { - if (email[index] === '.') return true - } - - return false + return EMAIL_PATTERN.test(value.trim()) } diff --git a/profile-ui/package-lock.json b/profile-ui/package-lock.json index 9833d83f71..fa1683a818 100644 --- a/profile-ui/package-lock.json +++ b/profile-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-profile-ui", - "version": "0.1.196", + "version": "0.1.197", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-profile-ui", - "version": "0.1.196", + "version": "0.1.197", "dependencies": { "@clerum/desktop-app-links": "file:../packages/desktop-app-links", "@clerum/frontend-components": "file:../packages/frontend-components", diff --git a/profile-ui/package.json b/profile-ui/package.json index f182b8a771..ca83766125 100644 --- a/profile-ui/package.json +++ b/profile-ui/package.json @@ -1,6 +1,6 @@ { "name": "clerum-profile-ui", - "version": "0.1.196", + "version": "0.1.197", "private": true, "scripts": { "dev": "next dev", From 9e45e3a03667ed5374855c41ca9d95b2f1b28f1a Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 4 Nov 2026 21:15:54 +0100 Subject: [PATCH 466/528] fix(control-api): remove unused acceptInvitation (K16) acceptInvitation had no production caller. Delete it and its export, and the real-PG case that only exercised its user id parameter. The different-owner case now runs through acceptInvitationForEmail and acceptInvitationById, which still use the guard, and also covers the backfill after the previous owner is deleted. Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- control-api/src/services/directory/index.ts | 1 - .../src/services/directory/membership.ts | 53 ------------------- ...empotency.realPostgres.integration.test.ts | 43 ++++----------- 5 files changed, 14 insertions(+), 89 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 12e1fefc01..28d35a0c89 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.903", + "version": "0.1.904", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.903", + "version": "0.1.904", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 861f827d9f..f9b6ee99bf 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.903", + "version": "0.1.904", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/directory/index.ts b/control-api/src/services/directory/index.ts index 6e319e8db7..b25d06940f 100644 --- a/control-api/src/services/directory/index.ts +++ b/control-api/src/services/directory/index.ts @@ -31,7 +31,6 @@ export { } from './teams.js' export { - acceptInvitation, acceptInvitationById, acceptInvitationForEmail, acceptInvitationForEmailInTransaction, diff --git a/control-api/src/services/directory/membership.ts b/control-api/src/services/directory/membership.ts index d2a6e9ef4f..76715d8fc0 100644 --- a/control-api/src/services/directory/membership.ts +++ b/control-api/src/services/directory/membership.ts @@ -791,59 +791,6 @@ export async function getInvitationByToken(token: string) { return invitationResponse(invitation, user, teams) } -export async function acceptInvitation( - userId: string, - email: string, - token: string, - invitationId: string -) { - return withTransaction(async db => { - const invitation = await getInvitationRecordByTokenOrId(db, token, invitationId, { - lock: true, - }) - if (!invitation) { - return { error: 'not_found' as const } - } - if (invitation.email.toLowerCase() !== email.toLowerCase()) { - return { error: 'forbidden' as const } - } - if (invitation.purpose === 'password_reset') { - return { error: 'not_pending' as const } - } - if (invitation.status === 'revoked' || invitation.status === 'draft') { - return { error: 'not_pending' as const } - } - if (invitation.status === 'pending' && invitation.expires_at.getTime() <= Date.now()) { - return { error: 'expired' as const } - } - - const user = await ensureInvitationUser(db, invitation.email, invitation.invitee_name) - if (invitationUserIsRetired(user)) return { error: 'user_retired' as const } - if (user.id !== userId) { - return { error: 'forbidden' as const } - } - - const acceptance = await applyInvitationAcceptance(db, invitation, user.id) - if (acceptance && 'error' in acceptance) return acceptance - const replay = acceptance?.replay === true - - const refreshed = await getInvitationRecordByToken(db, invitation.token) - if (!refreshed) { - return { error: 'not_found' as const } - } - const teams = await loadInvitationTeams(db, refreshed.id, refreshed) - const payload = invitationResponse(refreshed, user, teams) - - return { - data: { - accepted: true as const, - ...payload, - ...(replay ? { replay: true as const } : {}), - }, - } - }) -} - export async function acceptInvitationForEmailInTransaction( db: Pick, email: string, diff --git a/control-api/test/membershipInvitationIdempotency.realPostgres.integration.test.ts b/control-api/test/membershipInvitationIdempotency.realPostgres.integration.test.ts index be5e3e8f1d..50e6ba9377 100644 --- a/control-api/test/membershipInvitationIdempotency.realPostgres.integration.test.ts +++ b/control-api/test/membershipInvitationIdempotency.realPostgres.integration.test.ts @@ -3,7 +3,6 @@ import { randomBytes, randomUUID } from 'node:crypto' import { Pool } from 'pg' import { initDb } from '../src/db.js' import { - acceptInvitation, acceptInvitationById, acceptInvitationForEmail, resendManagedInvitationForUser, @@ -294,32 +293,6 @@ describeRealPostgres('membership invitation acceptance idempotency on real Postg expect(rejectedState.accepted_at?.toISOString()).toBe(acceptedAt.toISOString()) }) - it('refuses an explicit user id that does not own the invitation email', async () => { - const invitation = await createInvitation({ label: 'explicit-user' }) - const owner = await holder.pool.query<{ id: string }>( - `INSERT INTO users(email, name) - VALUES ($1, 'Invitation Owner') - RETURNING id::text AS id`, - [invitation.email] - ) - const ownerId = owner.rows[0]!.id - - await expect( - acceptInvitation(randomUUID(), invitation.email, invitation.token, invitation.id) - ).resolves.toEqual({ error: 'forbidden' }) - await expect(invitationState(invitation.id)).resolves.toMatchObject({ - status: 'pending', - accepted_user_id: null, - }) - - await expect( - acceptInvitation(ownerId, invitation.email, invitation.token, invitation.id) - ).resolves.toMatchObject({ data: { accepted: true, userId: ownerId } }) - await expect(memberships(ownerId)).resolves.toEqual([ - { team_id: teamId, role: 'member', status: 'active' }, - ]) - }) - it('refuses an accepted invitation recorded against a different user id', async () => { const invitation = await createInvitation({ label: 'accepted-other-owner' }) const other = await holder.pool.query<{ id: string }>( @@ -346,7 +319,7 @@ describeRealPostgres('membership invitation acceptance idempotency on real Postg ) await expect( - acceptInvitation(ownerId, invitation.email, invitation.token, invitation.id) + acceptInvitationForEmail(invitation.email, invitation.token, invitation.id) ).resolves.toEqual({ error: 'forbidden' }) await expect(invitationState(invitation.id)).resolves.toMatchObject({ status: 'accepted', @@ -362,16 +335,22 @@ describeRealPostgres('membership invitation acceptance idempotency on real Postg email: invitation.email, }) await expect( - acceptInvitation(ownerId, invitation.email, witnessInvitation.token, witnessInvitation.id) + acceptInvitationForEmail(invitation.email, witnessInvitation.token, witnessInvitation.id) ).resolves.toMatchObject({ data: { accepted: true, userId: ownerId } }) await expect(memberships(ownerId)).resolves.toEqual([ { team_id: teamId, role: 'member', status: 'active' }, ]) + // The FK clears accepted_user_id when the previous owner is deleted; the + // legacy backfill then records the current owner as a replay. await holder.pool.query(`DELETE FROM users WHERE id = $1`, [previousOwnerId]) - await expect( - acceptInvitation(ownerId, invitation.email, invitation.token, invitation.id) - ).resolves.toMatchObject({ data: { accepted: true, userId: ownerId } }) + await expect(invitationState(invitation.id)).resolves.toMatchObject({ + status: 'accepted', + accepted_user_id: null, + }) + await expect(acceptInvitationById(invitation.email, invitation.id)).resolves.toMatchObject({ + data: { accepted: true, userId: ownerId, replay: true }, + }) await expect(invitationState(invitation.id)).resolves.toMatchObject({ accepted_user_id: ownerId, }) From 64b658578f305d34fb4b982ca8a3b2fe32e149ef Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 4 Nov 2026 21:16:01 +0100 Subject: [PATCH 467/528] fix(control-api): read the V1 session clock inside the lifecycle query (K17) Legacy session validation takes the database clock in the same statement as the lifecycle and revocation checks instead of a separate clock query, so validation issues one query. The lifecycle tests now expect a single pool query. Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../src/services/auth/userSessionService.ts | 9 ++-- control-api/test/app.test.ts | 1 - ...re.externalSessionAuth.legacyToken.test.ts | 37 +++++++-------- ...ware.externalSessionAuth.lifecycle.test.ts | 46 +++++++++++-------- control-api/test/userSessionService.test.ts | 45 ++++++++++++++++++ 7 files changed, 98 insertions(+), 46 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 28d35a0c89..2e33dff9b4 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.904", + "version": "0.1.905", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.904", + "version": "0.1.905", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index f9b6ee99bf..bff661a3d6 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.904", + "version": "0.1.905", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/auth/userSessionService.ts b/control-api/src/services/auth/userSessionService.ts index ea6a43fecc..a96c08f77a 100644 --- a/control-api/src/services/auth/userSessionService.ts +++ b/control-api/src/services/auth/userSessionService.ts @@ -653,22 +653,23 @@ export async function validateLegacyUserSession( ]) if ((locked.rowCount ?? 0) === 0) return { status: 'revoked', reason: 'user_unavailable' } } - const now = await loadSessionDatabaseNow(db) const result = await db.query( - `SELECT u.id, u.lifecycle_state, u.lifecycle_version, + `WITH observed_clock AS (SELECT date_trunc('second', now()) AS db_now) + SELECT u.id, u.lifecycle_state, u.lifecycle_version, epoch.valid_after, EXISTS ( SELECT 1 FROM external_v1_session_revocations revoked WHERE revoked.token_hash = $2 AND revoked.user_id = u.id - AND revoked.expires_at > $3 + AND revoked.expires_at > observed_clock.db_now ) AS token_revoked FROM users u + CROSS JOIN observed_clock LEFT JOIN external_user_session_security_epochs epoch ON epoch.user_id = u.id WHERE u.id = $1 LIMIT 1`, - [claims.userId, legacySessionFingerprint(token), now] + [claims.userId, legacySessionFingerprint(token)] ) const row = result.rows[0] as | { diff --git a/control-api/test/app.test.ts b/control-api/test/app.test.ts index d19051230a..7d9b79ae4c 100644 --- a/control-api/test/app.test.ts +++ b/control-api/test/app.test.ts @@ -294,7 +294,6 @@ describe('app router wiring', () => { expect(transactionQuery.mock.calls.map(([sql]) => String(sql))).toEqual([ 'BEGIN', expect.stringContaining('SELECT id FROM users'), - expect.stringContaining('clock_timestamp()'), expect.stringContaining('external_user_session_security_epochs'), expect.stringContaining('JOIN team_members'), expect.stringContaining('SELECT lifecycle_state, lifecycle_version'), diff --git a/control-api/test/middleware.externalSessionAuth.legacyToken.test.ts b/control-api/test/middleware.externalSessionAuth.legacyToken.test.ts index 6511e0c30f..5cc75811c1 100644 --- a/control-api/test/middleware.externalSessionAuth.legacyToken.test.ts +++ b/control-api/test/middleware.externalSessionAuth.legacyToken.test.ts @@ -67,22 +67,18 @@ function app() { describe('external session legacy token cut-over', () => { beforeEach(() => { poolQuery.mockClear() - poolQuery.mockImplementation(async (sql: string) => - sql.includes('clock_timestamp()') - ? { rows: [{ db_now: new Date() }], rowCount: 1 } - : { - rows: [ - { - id: '11111111-1111-4111-8111-111111111111', - lifecycle_state: 'active', - lifecycle_version: '1', - valid_after: null, - token_revoked: false, - }, - ], - rowCount: 1, - } - ) + poolQuery.mockImplementation(async () => ({ + rows: [ + { + id: '11111111-1111-4111-8111-111111111111', + lifecycle_state: 'active', + lifecycle_version: '1', + valid_after: null, + token_revoked: false, + }, + ], + rowCount: 1, + })) }) it('denies a signature-valid token without a lifecycle generation', async () => { @@ -117,7 +113,12 @@ describe('external session legacy token cut-over', () => { expect(response.status).toBe(200) expect(response.body).toEqual({ ok: true }) - expect(poolQuery).toHaveBeenCalledTimes(2) - expect(String(poolQuery.mock.calls[0]?.[0])).toContain('clock_timestamp()') + expect(poolQuery).toHaveBeenCalledTimes(1) + const lifecycleSql = String(poolQuery.mock.calls[0]?.[0]) + expect(lifecycleSql).toContain("date_trunc('second', now())") + expect(lifecycleSql).toContain('external_user_session_security_epochs') + expect(lifecycleSql).toContain('external_v1_session_revocations') + expect(lifecycleSql).toContain('revoked.expires_at > observed_clock.db_now') + expect(poolQuery.mock.calls[0]?.[1]).toHaveLength(2) }) }) diff --git a/control-api/test/middleware.externalSessionAuth.lifecycle.test.ts b/control-api/test/middleware.externalSessionAuth.lifecycle.test.ts index 807c1aa38b..41d89a5075 100644 --- a/control-api/test/middleware.externalSessionAuth.lifecycle.test.ts +++ b/control-api/test/middleware.externalSessionAuth.lifecycle.test.ts @@ -82,11 +82,10 @@ describe('external session lifecycle gate', () => { token_revoked: false, }, ] - poolQuery.mockImplementation(async (sql: string) => - sql.includes('clock_timestamp()') - ? { rows: [{ db_now: new Date() }], rowCount: 1 } - : { rows: authorityRows, rowCount: authorityRows.length } - ) + poolQuery.mockImplementation(async () => ({ + rows: authorityRows, + rowCount: authorityRows.length, + })) }) it('accepts an active session whose generation matches the user row', async () => { @@ -121,9 +120,9 @@ describe('external session lifecycle gate', () => { expect(response.body).toEqual({ error: 'session_backend_unavailable', retryAfterSeconds: 2 }) expect(response.headers['retry-after']).toBe('2') expect(response.headers['cache-control']).toBe('no-store') - // Witness: session validation reached the database-authoritative clock. + // Witness: session validation reached the lifecycle query that carries the database clock. expect(poolQuery).toHaveBeenCalledTimes(1) - expect(String(poolQuery.mock.calls[0]?.[0])).toContain('clock_timestamp()') + expect(String(poolQuery.mock.calls[0]?.[0])).toContain("date_trunc('second', now())") expect(loggerWarn).toHaveBeenCalledTimes(1) expect(loggerWarn.mock.calls[0]?.[0]).toMatchObject({ event: 'external_session_backend_unavailable', @@ -162,24 +161,31 @@ describe('external session lifecycle gate', () => { // Witness: next() ran and the middleware fixed req.externalAuth. expect(response.body.userId).toBe(claims.userId) expect(poolConnect).not.toHaveBeenCalled() - expect(poolQuery).toHaveBeenCalledTimes(2) + expect(poolQuery).toHaveBeenCalledTimes(1) const statements = poolQuery.mock.calls.map(call => String(call[0])).join('\n') expect(statements).not.toContain('statement_timeout') expect(statements).not.toContain('READ ONLY') }) - it('keeps a successful-query session clock invariant failure on the ordinary 500 path', async () => { - verifyToken.mockReturnValueOnce(claims) - poolQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }) - - const response = await request(app()).get('/protected').set('x-user-session-token', 'session') - - expect(response.status).toBe(500) - expect(response.body).toEqual({ error: 'internal' }) - expect(JSON.stringify(response.body)).not.toContain('database session clock unavailable') - expect(response.headers['retry-after']).toBeUndefined() - expect(response.headers['cache-control']).toBeUndefined() - expect(String(poolQuery.mock.calls[0]?.[0])).toContain('clock_timestamp()') + it('keeps a successful-query session clock invariant failure an ordinary error at session creation', async () => { + const { createUserSession } = await import('../src/services/auth/userSessionService.js') + const { ExternalSessionBackendUnavailableError } = + await import('../src/services/auth/sessionDatabaseFailure.js') + poolQuery + .mockResolvedValueOnce({ rows: [{ id: claims.userId }], rowCount: 1 }) + .mockResolvedValueOnce({ rows: [], rowCount: 0 }) + + const failure = await createUserSession( + { userId: claims.userId, email: claims.email, authenticationMethods: ['pwd'] }, + { db: { query: (...args: unknown[]) => poolQuery(...args) } as never } + ).catch((error: unknown) => error) + + expect(failure).toBeInstanceOf(Error) + expect((failure as Error).message).toBe('database session clock unavailable') + expect(failure).not.toBeInstanceOf(ExternalSessionBackendUnavailableError) + // Witness: creation locked the user and then read the database clock. + expect(poolQuery).toHaveBeenCalledTimes(2) + expect(String(poolQuery.mock.calls[1]?.[0])).toContain('clock_timestamp()') expect(loggerWarn).not.toHaveBeenCalled() }) diff --git a/control-api/test/userSessionService.test.ts b/control-api/test/userSessionService.test.ts index 99f1d521a4..d86ddb1eb2 100644 --- a/control-api/test/userSessionService.test.ts +++ b/control-api/test/userSessionService.test.ts @@ -314,4 +314,49 @@ describe('user session state machine', () => { expect(current.status).toBe('valid') expect(db.query).toHaveBeenCalled() }) + + it('decides a legacy session from one lifecycle query that carries the database clock', async () => { + const claims: AuthClaims = { + userId: randomUUID(), + email: 'legacy@example.com', + teamId: null, + role: 'member', + authGeneration: 1, + exp: Math.floor(now.getTime() / 1000) + 3600, + iat: Math.floor(now.getTime() / 1000) - 1, + } + let tokenRevoked = false + const db = { + query: vi.fn(async () => ({ + rows: [ + { + id: claims.userId, + lifecycle_state: 'active', + lifecycle_version: 1, + valid_after: null, + token_revoked: tokenRevoked, + }, + ], + rowCount: 1, + })), + } + + const current = await validateLegacyUserSession('legacy-v1-token', claims, { db }) + expect(current.status).toBe('valid') + expect(db.query).toHaveBeenCalledTimes(1) + const [sql, values] = db.query.mock.calls[0] as unknown as [string, unknown[]] + expect(sql).toContain("WITH observed_clock AS (SELECT date_trunc('second', now()) AS db_now)") + expect(sql).toContain('CROSS JOIN observed_clock') + expect(sql).toContain('revoked.expires_at > observed_clock.db_now') + expect(values).toHaveLength(2) + expect(values[0]).toBe(claims.userId) + + // The same single query decides: flipping its revocation flag flips the verdict. + tokenRevoked = true + await expect(validateLegacyUserSession('legacy-v1-token', claims, { db })).resolves.toEqual({ + status: 'revoked', + reason: 'logout', + }) + expect(db.query).toHaveBeenCalledTimes(2) + }) }) From c02b82adc203d716c3560198187f15ca55861c61 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 4 Nov 2026 21:16:08 +0100 Subject: [PATCH 468/528] fix(control-api): compile the default user access policy once (K18) resolveEffectiveUserAccessPolicy caches the frozen policy compiled from the process-constant intent and readiness. Explicit intents are still compiled per call, and a compilation that throws is not cached. Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- .../access/userAccessRuntimePolicy.ts | 10 +++++++--- control-api/test/userAccessPolicy.test.ts | 19 +++++++++++++++++++ 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 2e33dff9b4..5134589e9b 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.905", + "version": "0.1.906", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.905", + "version": "0.1.906", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index bff661a3d6..d66d0e14da 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.905", + "version": "0.1.906", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/access/userAccessRuntimePolicy.ts b/control-api/src/services/access/userAccessRuntimePolicy.ts index 0fd7b26568..c72c1c89d6 100644 --- a/control-api/src/services/access/userAccessRuntimePolicy.ts +++ b/control-api/src/services/access/userAccessRuntimePolicy.ts @@ -10,11 +10,15 @@ type RuntimePolicyOptions = Readonly<{ intent?: ConfiguredUserAccessIntent }> +// Valid only while the configured intent and readiness are process constants. +let defaultPolicy: EffectiveUserAccessPolicy | undefined + export async function resolveEffectiveUserAccessPolicy( options: RuntimePolicyOptions = {} ): Promise { - return compileUserAccessPolicy( - options.intent ?? configuredUserAccessIntent, + if (options.intent) return compileUserAccessPolicy(options.intent, reconstructionReadiness) + return (defaultPolicy ??= compileUserAccessPolicy( + configuredUserAccessIntent, reconstructionReadiness - ) + )) } diff --git a/control-api/test/userAccessPolicy.test.ts b/control-api/test/userAccessPolicy.test.ts index 3dcd9af064..4d2c36a9ff 100644 --- a/control-api/test/userAccessPolicy.test.ts +++ b/control-api/test/userAccessPolicy.test.ts @@ -5,6 +5,7 @@ import { UserAccessPolicyConfigurationError, compareSemanticVersions, compileUserAccessPolicy, + configuredUserAccessIntent, loadConfiguredUserAccessIntent, reconstructionReadiness, } from '../src/services/access/userAccessPolicy.js' @@ -216,4 +217,22 @@ describe('central user-access rollout compiler', () => { new UserAccessPolicyConfigurationError('minimum_client_enforcement_unavailable') ) }) + + it('compiles the configured runtime policy once and an explicit intent on every call', async () => { + const first = await resolveEffectiveUserAccessPolicy() + const second = await resolveEffectiveUserAccessPolicy() + expect(second).toBe(first) + expect(first).toEqual( + compileUserAccessPolicy(configuredUserAccessIntent, reconstructionReadiness) + ) + + // Witness: an explicit intent is compiled for that call and reflects that intent. + const explicit = await resolveEffectiveUserAccessPolicy({ + intent: intent({ legacySwitchEndpoint: !first.switchCompatibility }), + }) + expect(explicit).not.toBe(first) + expect(explicit.switchCompatibility).toBe(!first.switchCompatibility) + expect(explicit.policyRevision).not.toBe(first.policyRevision) + await expect(resolveEffectiveUserAccessPolicy()).resolves.toBe(first) + }) }) From 4d49f2a99ca1789f09fc1c4369c3bf5eeac59fde Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 4 Nov 2026 21:16:15 +0100 Subject: [PATCH 469/528] fix(control-api): check notification stream authority once per heartbeat (K19) flushNewEvents no longer observes session currentness before, after and per event. The heartbeat observes once and closes the stream on denial before writing its frame and flushing. Tests hold heartbeat observations so the batch written during a revocation and the closing frame are ordered deterministically. Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../routes/external/notifications.routes.ts | 5 +- ...rrentness.realPostgres.integration.test.ts | 62 ++++++- ....externalNotifications.currentness.test.ts | 56 ++++-- ...es.externalNotifications.heartbeat.test.ts | 170 ++++++++++++++++++ 6 files changed, 266 insertions(+), 33 deletions(-) create mode 100644 control-api/test/routes.externalNotifications.heartbeat.test.ts diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 5134589e9b..815a2b379c 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.906", + "version": "0.1.907", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.906", + "version": "0.1.907", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index d66d0e14da..b6ce15d084 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.906", + "version": "0.1.907", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/routes/external/notifications.routes.ts b/control-api/src/routes/external/notifications.routes.ts index 0fe7f9e96d..593fedb4e4 100644 --- a/control-api/src/routes/external/notifications.routes.ts +++ b/control-api/src/routes/external/notifications.routes.ts @@ -236,19 +236,16 @@ export function createExternalNotificationsRouter(): Router { if (closed || flushInFlight) return flushInFlight = true try { - if (!(await closeForAuthority())) return const events = await listNotificationStreamEventsForUser(claims.userId, { limit: config.notificationStreamSnapshotLimit, after: lastCursor ? parseNotificationCursor(lastCursor) : null, }) - if (!(await closeForAuthority())) return if (events.length === 0) { notificationStreamEventsFilteredTotal.inc({ reason: 'no_new_active_events' }, 1) return } for (const event of events) { if (closed) break - if (!(await closeForAuthority())) break if (event.eventType === 'approval.requested') { writeStreamMessage(res, { type: 'approval.requested', @@ -353,13 +350,13 @@ export function createExternalNotificationsRouter(): Router { heartbeatTimer = setInterval(() => { void (async () => { - void flushNewEvents() if (closed || !(await closeForAuthority())) return writeStreamMessage(res, { type: 'heartbeat', observedAt: new Date().toISOString(), }) notificationStreamEventsSentTotal.inc({ event_type: 'heartbeat' }, 1) + void flushNewEvents() })() }, config.notificationStreamHeartbeatMs) diff --git a/control-api/test/routes.externalNotifications.currentness.realPostgres.integration.test.ts b/control-api/test/routes.externalNotifications.currentness.realPostgres.integration.test.ts index 3b25a7f70b..4bd3fafefb 100644 --- a/control-api/test/routes.externalNotifications.currentness.realPostgres.integration.test.ts +++ b/control-api/test/routes.externalNotifications.currentness.realPostgres.integration.test.ts @@ -19,6 +19,10 @@ const producerDatabase = vi.hoisted(() => ({ listActive: vi.fn(), openListener: vi.fn(), })) +const heartbeatAuthority = vi.hoisted(() => ({ + gate: null as Promise | null, + heldObservations: 0, +})) vi.mock('../src/db.js', async importOriginal => { const actual = await importOriginal() @@ -30,6 +34,32 @@ vi.mock('../src/db.js', async importOriginal => { withTransaction: (work: (db: DbClient) => Promise) => producerDatabase.transaction!(work), } }) +vi.mock('../src/config.js', async importOriginal => { + const actual = await importOriginal() + return { ...actual, config: { ...actual.config, notificationStreamHeartbeatMs: 20 } } +}) +// Every authority observation made after the stream's first event read has +// started (that is, by a heartbeat) waits for the test's gate, then runs the +// real observer against PostgreSQL. Startup observations all finish before that +// first read and pass straight through. +vi.mock('../src/services/auth/externalSessionCurrentnessObserver.js', async importOriginal => { + const actual = + await importOriginal< + typeof import('../src/services/auth/externalSessionCurrentnessObserver.js') + >() + return { + ...actual, + observeExternalSessionCurrentness: async ( + ...args: Parameters + ) => { + if (heartbeatAuthority.gate && producerDatabase.listEvents.mock.calls.length > 0) { + heartbeatAuthority.heldObservations += 1 + await heartbeatAuthority.gate + } + return actual.observeExternalSessionCurrentness(...args) + }, + } +}) vi.mock('../src/middleware/externalClientIdentity.js', () => ({ createExternalClientRateLimiters: () => [ (_req: express.Request, _res: express.Response, next: express.NextFunction) => next(), @@ -134,7 +164,7 @@ describeRealPostgres('mounted notification stream currentness on real PostgreSQL } }) - it('drops a real V2 notification batch after revoke-all during the awaited read', async () => { + it('writes a real V2 notification batch read before revoke-all and closes on the next heartbeat', async () => { const userId = randomUUID() const email = `notification-currentness-${userId}@example.test` await databasePool.query(`INSERT INTO users(id, email, name) VALUES ($1, $2, $3)`, [ @@ -153,6 +183,9 @@ describeRealPostgres('mounted notification stream currentness on real PostgreSQL expect(Number(generation.rows[0]?.lifecycle_version)).toBeGreaterThan(1) const events = deferred() + const heartbeatGate = deferred() + heartbeatAuthority.gate = heartbeatGate.promise + heartbeatAuthority.heldObservations = 0 producerDatabase.listActive.mockResolvedValue([]) producerDatabase.listEvents.mockReset().mockReturnValue(events.promise) producerDatabase.openListener.mockResolvedValue( @@ -176,20 +209,31 @@ describeRealPostgres('mounted notification stream currentness on real PostgreSQL events.resolve([ { eventType: 'approval.updated', - id: 'private-event-id', - cursor: 'private-cursor', - approvalRequestId: 'private-approval-id', + id: 'batch-event-id', + cursor: 'batch-cursor', + approvalRequestId: 'batch-approval-id', status: 'approved', }, ]) + // The batch is written in the microtasks after the resolve. Release the + // held heartbeat observations only once a heartbeat is waiting, so the + // first observation after the batch reads the committed revocation. + await vi.waitFor(() => expect(heartbeatAuthority.heldObservations).toBeGreaterThan(0)) + heartbeatGate.resolve() const response = await responsePromise expect(response.status).toBe(200) expect(response.text).toContain('notification.snapshot') - expect(response.text).toContain('stream.closing') - expect(response.text).toContain('session_expired') - expect(response.text).not.toContain('private-event-id') - expect(response.text).not.toContain('private-cursor') - expect(response.text).not.toContain('private-approval-id') + // The batch read before the revocation is written, and the next heartbeat + // observes the revocation on the real database and closes the stream. + expect(response.text).toContain('batch-event-id') + const frames = response.text + .split('\n') + .filter(line => line.length > 0) + .map(line => JSON.parse(line) as { type: string; reason?: string }) + expect(frames.at(-1)).toMatchObject({ type: 'stream.closing', reason: 'session_expired' }) + expect(response.text.indexOf('batch-event-id')).toBeLessThan( + response.text.indexOf('stream.closing') + ) }) }) diff --git a/control-api/test/routes.externalNotifications.currentness.test.ts b/control-api/test/routes.externalNotifications.currentness.test.ts index 1c90431fd0..13ce837a47 100644 --- a/control-api/test/routes.externalNotifications.currentness.test.ts +++ b/control-api/test/routes.externalNotifications.currentness.test.ts @@ -13,6 +13,10 @@ const streamState = vi.hoisted(() => ({ backendFailure: vi.fn(), })) +vi.mock('../src/config.js', async importOriginal => { + const actual = await importOriginal() + return { ...actual, config: { ...actual.config, notificationStreamHeartbeatMs: 20 } } +}) vi.mock('../src/middleware/externalClientIdentity.js', () => ({ createExternalClientRateLimiters: () => [ (_req: express.Request, _res: express.Response, next: express.NextFunction) => next(), @@ -157,14 +161,16 @@ describe('external notification stream currentness', () => { expect(streamState.openListener).not.toHaveBeenCalled() }) - it('drops an awaited notification batch when original authority is revoked', async () => { + it('writes an in-flight batch and closes on the next heartbeat when original authority is revoked', async () => { const batch = deferred() + const heartbeatAuthority = deferred() + // Three startup observations are current; every heartbeat observation + // waits until the test decides the authority. streamState.observer .mockResolvedValueOnce({ status: 'current' }) .mockResolvedValueOnce({ status: 'current' }) .mockResolvedValueOnce({ status: 'current' }) - .mockResolvedValueOnce({ status: 'current' }) - .mockResolvedValueOnce({ status: 'denied', reason: 'v2_session_revoked_or_changed' }) + .mockImplementation(() => heartbeatAuthority.promise) streamState.listEvents.mockReturnValueOnce(batch.promise) const responsePromise = request(makeApp()).get('/external/notifications/stream') @@ -179,30 +185,36 @@ describe('external notification stream currentness', () => { batch.resolve([ { eventType: 'approval.updated', - id: 'private-event-id', - cursor: 'private-cursor', - approvalRequestId: 'private-approval-id', + id: 'batch-event-id', + cursor: 'batch-cursor', + approvalRequestId: 'batch-approval-id', status: 'approved', }, ]) + await vi.waitFor(() => expect(streamState.observer.mock.calls.length).toBeGreaterThan(3)) + heartbeatAuthority.resolve({ status: 'denied', reason: 'v2_session_revoked_or_changed' }) const response = await responsePromise expect(response.status).toBe(200) + // The batch read before the revocation is written; the stream then closes + // on the next heartbeat, which observes the revocation. + expect(response.text).toContain('batch-event-id') expect(response.text).toContain('stream.closing') expect(response.text).toContain('session_expired') - expect(response.text).not.toContain('private-event-id') - expect(response.text).not.toContain('private-cursor') - expect(response.text).not.toContain('private-approval-id') + expect(response.text.indexOf('batch-event-id')).toBeLessThan( + response.text.indexOf('stream.closing') + ) + expect(response.text).not.toContain('"type":"heartbeat"') }) - it('ends without protected data or cursor when authority becomes unavailable after headers', async () => { + it('writes an in-flight batch and ends on the next heartbeat when authority becomes unavailable after headers', async () => { const batch = deferred() + const heartbeatAuthority = deferred() streamState.observer .mockResolvedValueOnce({ status: 'current' }) .mockResolvedValueOnce({ status: 'current' }) .mockResolvedValueOnce({ status: 'current' }) - .mockResolvedValueOnce({ status: 'current' }) - .mockResolvedValueOnce({ status: 'unavailable', error: new Error('database unavailable') }) + .mockImplementation(() => heartbeatAuthority.promise) streamState.listEvents.mockReturnValueOnce(batch.promise) const responsePromise = request(makeApp()).get('/external/notifications/stream') @@ -217,18 +229,28 @@ describe('external notification stream currentness', () => { batch.resolve([ { eventType: 'approval.updated', - id: 'private-event-id', - cursor: 'private-cursor', - approvalRequestId: 'private-approval-id', + id: 'batch-event-id', + cursor: 'batch-cursor', + approvalRequestId: 'batch-approval-id', status: 'approved', }, ]) + await vi.waitFor(() => expect(streamState.observer.mock.calls.length).toBeGreaterThan(3)) + heartbeatAuthority.resolve({ status: 'unavailable', error: new Error('database unavailable') }) const response = await responsePromise expect(response.status).toBe(200) expect(response.text).toContain('notification.snapshot') - expect(response.text).not.toContain('private-event-id') - expect(response.text).not.toContain('private-cursor') + // The batch read before the outage is written; the next heartbeat ends the + // stream without a closing frame, heartbeat or backend error body. + expect(response.text).toContain('batch-event-id') + const frames = response.text + .split('\n') + .filter(line => line.length > 0) + .map(line => JSON.parse(line) as { type: string }) + expect(frames.at(-1)?.type).toBe('approval.updated') + expect(response.text).not.toContain('stream.closing') + expect(response.text).not.toContain('"type":"heartbeat"') expect(response.text).not.toContain('session_backend_unavailable') }) diff --git a/control-api/test/routes.externalNotifications.heartbeat.test.ts b/control-api/test/routes.externalNotifications.heartbeat.test.ts new file mode 100644 index 0000000000..50790ddf2a --- /dev/null +++ b/control-api/test/routes.externalNotifications.heartbeat.test.ts @@ -0,0 +1,170 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import { EventEmitter } from 'node:events' +import request from 'supertest' +import { createExternalNotificationsRouter } from '../src/routes/external/notifications.routes.js' + +const streamState = vi.hoisted(() => ({ + observer: vi.fn(), + listActive: vi.fn(), + listEvents: vi.fn(), + newestCursor: vi.fn(), + openListener: vi.fn(), + listener: null as (EventEmitter & { release: () => void }) | null, + pendingEvents: [] as unknown[], +})) + +vi.mock('../src/config.js', async importOriginal => { + const actual = await importOriginal() + return { ...actual, config: { ...actual.config, notificationStreamHeartbeatMs: 20 } } +}) +vi.mock('../src/middleware/externalClientIdentity.js', () => ({ + createExternalClientRateLimiters: () => [ + (_req: express.Request, _res: express.Response, next: express.NextFunction) => next(), + ], +})) +vi.mock('../src/middleware/externalSessionAuth.js', () => ({ + requireValidExternalSessionToken: ( + req: express.Request, + _res: express.Response, + next: express.NextFunction + ) => { + const externalReq = req as express.Request & Record + externalReq.externalAuth = { userId: 'user-1' } + externalReq.externalSessionAuthentication = { + status: 'authenticated', + contract: 'v2', + claims: { userId: 'user-1' }, + tokenClaims: { sub: 'user-1', sid: 'sid-1', jti: 'jti-1', sv: 1, exp: 4_000_000_000 }, + authorityContext: { + contract: 'v2', + userId: 'user-1', + sid: 'sid-1', + jti: 'jti-1', + sessionVersion: 1, + }, + policy: {}, + } + next() + }, + handleExternalSessionBackendFailure: vi.fn(), +})) +vi.mock('../src/middleware/rateLimitMiddleware.js', () => ({ + rateLimitMiddleware: + () => (_req: express.Request, _res: express.Response, next: express.NextFunction) => + next(), +})) +vi.mock('../src/middleware/mcpHostHttpMetrics.js', () => ({ + mcpHostHttpMetrics: + () => (_req: express.Request, _res: express.Response, next: express.NextFunction) => + next(), +})) +vi.mock('../src/observability/logger.js', () => ({ + rootLogger: { + child: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), + }, +})) +vi.mock('../src/observability/metrics.js', () => { + const metric = { inc: vi.fn(), dec: vi.fn(), observe: vi.fn() } + return { + notificationStreamConnectionsActive: metric, + notificationStreamDisconnectsTotal: metric, + notificationStreamEventsFilteredTotal: metric, + notificationStreamEventsSentTotal: metric, + notificationStreamSnapshotSize: metric, + } +}) +vi.mock('../src/services/auth/externalSessionCurrentnessObserver.js', () => ({ + observeExternalSessionCurrentness: streamState.observer, +})) +vi.mock('../src/services/notificationAckService.js', () => ({ + acknowledgeDesktopNotificationDelivery: vi.fn(), +})) +vi.mock('../src/services/notificationStreamService.js', () => ({ + listActiveApprovalNotificationsForUser: streamState.listActive, + listNotificationStreamEventsForUser: streamState.listEvents, + newestNotificationCursor: streamState.newestCursor, + openNotificationQueueListener: streamState.openListener, + parseNotificationCursor: (value: string) => ({ createdAt: value, id: 'event-1' }), +})) +vi.mock('../src/services/userNotificationPreferencesService.js', () => ({ + getUserNotificationPreferences: vi.fn(), + upsertUserNotificationPreferences: vi.fn(), +})) + +function makeApp() { + const app = express() + app.use(createExternalNotificationsRouter()) + return app +} + +function approvalUpdated(index: number) { + return { + eventType: 'approval.updated', + id: `event-${index}`, + cursor: `cursor-${index}`, + approvalRequestId: `approval-${index}`, + status: 'approved', + } +} + +describe('external notification stream authority checks per heartbeat', () => { + beforeEach(() => { + vi.resetAllMocks() + streamState.listener = null + streamState.pendingEvents = [] + streamState.observer.mockResolvedValue({ status: 'current' }) + streamState.listActive.mockResolvedValue([]) + streamState.newestCursor.mockReturnValue(null) + // Every flush, whether triggered by a notification or by a heartbeat, + // drains the events queued so far. + streamState.listEvents.mockImplementation(async () => streamState.pendingEvents.splice(0)) + streamState.openListener.mockImplementation(async () => { + const listener = new EventEmitter() as EventEmitter & { release: () => void } + listener.release = vi.fn() + streamState.listener = listener + return listener + }) + }) + + it('observes authority only at startup and once per heartbeat, then closes on the next heartbeat after denial', async () => { + const responsePromise = request(makeApp()).get('/external/notifications/stream') + let responseResult: { status: number; text: string } | undefined + void responsePromise.then(response => { + responseResult = { status: response.status, text: response.text } + }) + await vi.waitFor(() => + expect(streamState.listActive, JSON.stringify(responseResult)).toHaveBeenCalledOnce() + ) + + for (const index of [1, 2, 3]) { + streamState.pendingEvents.push(approvalUpdated(index)) + streamState.listener!.emit('notification') + await vi.waitFor(() => expect(streamState.pendingEvents).toHaveLength(0)) + } + + streamState.observer.mockResolvedValue({ + status: 'denied', + reason: 'v2_session_revoked_or_changed', + }) + const response = await responsePromise + + expect(response.status).toBe(200) + const frames = response.text + .split('\n') + .filter(line => line.length > 0) + .map(line => JSON.parse(line) as { type: string; id?: string; reason?: string }) + const eventIds = frames + .filter(frame => frame.type === 'approval.updated') + .map(frame => frame.id) + expect(eventIds).toEqual(['event-1', 'event-2', 'event-3']) + // Witness: the denial is observed by the next heartbeat, which writes the + // closing frame and ends the stream. + expect(frames.at(-1)).toMatchObject({ type: 'stream.closing', reason: 'session_expired' }) + const heartbeatFrames = frames.filter(frame => frame.type === 'heartbeat').length + // Three startup observations (before headers, before and after the + // snapshot read), one per heartbeat that wrote a frame, and one for the + // heartbeat that closed the stream. Delivering the three events adds none. + expect(streamState.observer).toHaveBeenCalledTimes(3 + heartbeatFrames + 1) + }) +}) From 9d32a660b63417ff2c5b3c58896ff2ad3d3179a0 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 4 Nov 2026 21:16:21 +0100 Subject: [PATCH 470/528] fix(control-api): restore dev's canCallerReadWorkflowRun (K19b) The workflow run read check returns to dev's synchronous version; the unit file that tested the asynchronous variant is removed. Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../workflows/workflowRunReadService.ts | 13 +- ...atibility.realPostgres.integration.test.ts | 4 +- .../test/workflowRunReadAuthorization.test.ts | 127 ------------------ 5 files changed, 9 insertions(+), 141 deletions(-) delete mode 100644 control-api/test/workflowRunReadAuthorization.test.ts diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 815a2b379c..68298f4961 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.907", + "version": "0.1.908", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.907", + "version": "0.1.908", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index b6ce15d084..e8d8b00c3e 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.907", + "version": "0.1.908", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/services/workflows/workflowRunReadService.ts b/control-api/src/services/workflows/workflowRunReadService.ts index 87e1cf1983..89aff020af 100644 --- a/control-api/src/services/workflows/workflowRunReadService.ts +++ b/control-api/src/services/workflows/workflowRunReadService.ts @@ -114,18 +114,13 @@ function auditRunMatchesUserScope(row: Record, scope: UserRunSc return false } -export async function canCallerReadWorkflowRun( +export function canCallerReadWorkflowRun( caller: WorkflowRouteCaller, - row: WorkflowRunRow, - db: Pick = pool -): Promise { + row: WorkflowRunRow +): boolean { const scope = userRunScope(caller) if (!scope) return true - if (row.actor_type === 'user' && row.actor_id && scope.principalIds.includes(row.actor_id)) { - return true - } - if (!runMatchesUserScope(row, scope)) return false - return hasLiveWorkflowTeamGrant(scope, row.recipe_namespace, row.recipe_name, db) + return runMatchesUserScope(row, scope) } export async function canCallerReadWorkflowRunWithApprovalTarget( diff --git a/control-api/test/workflowRunReadAndApprovalV1Compatibility.realPostgres.integration.test.ts b/control-api/test/workflowRunReadAndApprovalV1Compatibility.realPostgres.integration.test.ts index e1fa936a1d..c07ea7b4e2 100644 --- a/control-api/test/workflowRunReadAndApprovalV1Compatibility.realPostgres.integration.test.ts +++ b/control-api/test/workflowRunReadAndApprovalV1Compatibility.realPostgres.integration.test.ts @@ -438,11 +438,11 @@ describeRealPostgres('PR334 S2 workflow run visibility and V1 approval replay', session: {} as never, } - await expect(reader.canCallerReadWorkflowRun(caller, ownerRow)).resolves.toBe(true) + expect(reader.canCallerReadWorkflowRun(caller, ownerRow)).toBe(true) await expect(reader.canCallerReadWorkflowRunWithApprovalTarget(caller, ownerRow)).resolves.toBe( true ) - await expect(reader.canCallerReadWorkflowRun(caller, otherRow)).resolves.toBe(false) + expect(reader.canCallerReadWorkflowRun(caller, otherRow)).toBe(false) await expect(reader.canCallerReadWorkflowRunWithApprovalTarget(caller, otherRow)).resolves.toBe( false ) diff --git a/control-api/test/workflowRunReadAuthorization.test.ts b/control-api/test/workflowRunReadAuthorization.test.ts deleted file mode 100644 index 1361f8c8f6..0000000000 --- a/control-api/test/workflowRunReadAuthorization.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { canCallerReadWorkflowRun } from '../src/services/workflows/workflowRunReadService.js' - -const caller = { - kind: 'user-session' as const, - claims: { - userId: 'user-1', - email: 'user@example.com', - teamId: 'team-1', - role: 'member' as const, - exp: Math.floor(Date.now() / 1000) + 3600, - }, -} - -function run(overrides: Record = {}) { - return { - run_id: 'run-1', - recipe_namespace: 'workflows', - recipe_name: 'recipe-1', - actor_type: 'user', - actor_id: 'user-1', - team_id: null, - usage_team_id: null, - ...overrides, - } as never -} - -describe('workflow run user authorization', () => { - it('keeps a directly attributed user run readable without a team lookup', async () => { - const db = { query: vi.fn() } - - await expect(canCallerReadWorkflowRun(caller, run(), db as never)).resolves.toBe(true) - expect(db.query).not.toHaveBeenCalled() - }) - - it('lets the author read their own historical team-attributed run without a live team grant', async () => { - const db = { query: vi.fn() } - - await expect( - canCallerReadWorkflowRun( - caller, - run({ team_id: 'team-1', usage_team_id: 'team-1' }), - db as never - ) - ).resolves.toBe(true) - expect(db.query).not.toHaveBeenCalled() - }) - - it.each([ - { - label: 'scheduled team-attributed run', - overrides: { actor_type: 'scheduled', actor_id: 'user-1', team_id: 'team-1' }, - }, - { - label: 'user run without an actor ID', - overrides: { actor_type: 'user', actor_id: null, team_id: 'team-1' }, - }, - ])( - 'allows a $label only through a current membership and recipe grant', - async ({ overrides }) => { - const db = { query: vi.fn().mockResolvedValue({ rows: [{ '?column?': 1 }], rowCount: 1 }) } - - await expect( - canCallerReadWorkflowRun( - caller, - run({ ...overrides, usage_team_id: 'team-1' }), - db as never - ) - ).resolves.toBe(true) - expect(db.query).toHaveBeenCalledTimes(1) - expect(String(db.query.mock.calls[0]?.[0])).toContain('FROM team_members tm') - expect(String(db.query.mock.calls[0]?.[0])).toContain('JOIN team_workflow_triggers twt') - } - ) - - it.each([ - { - label: 'scheduled team-attributed run', - overrides: { actor_type: 'scheduled', actor_id: 'user-1', team_id: 'team-1' }, - }, - { - label: 'user run without an actor ID', - overrides: { actor_type: 'user', actor_id: null, team_id: 'team-1' }, - }, - ])('denies a $label after membership removal', async ({ overrides }) => { - const db = { query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }) } - - await expect( - canCallerReadWorkflowRun(caller, run({ ...overrides, usage_team_id: 'team-1' }), db as never) - ).resolves.toBe(false) - expect(db.query).toHaveBeenCalledTimes(1) - }) - - it.each([ - { - label: 'scheduled team-attributed run', - overrides: { actor_type: 'scheduled', actor_id: 'user-1', team_id: 'team-1' }, - }, - { - label: 'user run without an actor ID', - overrides: { actor_type: 'user', actor_id: null, team_id: 'team-1' }, - }, - ])('denies a $label after recipe grant removal', async ({ overrides }) => { - const db = { query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }) } - - await expect( - canCallerReadWorkflowRun(caller, run({ ...overrides, usage_team_id: 'team-1' }), db as never) - ).resolves.toBe(false) - expect(db.query).toHaveBeenCalledTimes(1) - }) - - it.each(['scheduled', 'autonomous'])( - 'does not give a caller the own-run shortcut for a %s run with a matching actor ID', - async actorType => { - const db = { query: vi.fn() } - - await expect( - canCallerReadWorkflowRun( - caller, - run({ actor_type: actorType, actor_id: 'user-1', team_id: null }), - db as never - ) - ).resolves.toBe(false) - expect(db.query).not.toHaveBeenCalled() - } - ) -}) From 9b8cd4e321a3ab6a7742d837b10097f0f78fba74 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 4 Nov 2026 21:16:28 +0100 Subject: [PATCH 471/528] fix(control-api): bind session exchange to the claimed role (K23) The V1 exchange and the V2 session-token path refuse with membership_not_found when the live membership role differs from the role in the presented claims. The RPC token session stage maps classified backend outages to 503 session_backend_unavailable like its sibling routes. Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 +- control-api/package.json | 2 +- control-api/src/routes/external/auth.ts | 5 +- .../services/auth/externalSessionIssuance.ts | 3 +- ...ssionIssuance.exchangeRoleMismatch.test.ts | 123 ++++++++++++++++++ ...hangeRace.realPostgres.integration.test.ts | 44 ++++++- ....externalAuth.authorityUnavailable.test.ts | 29 +++++ ...utes.externalSessionAtomicExchange.test.ts | 72 +++++++++- 8 files changed, 268 insertions(+), 14 deletions(-) create mode 100644 control-api/test/externalSessionIssuance.exchangeRoleMismatch.test.ts diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 68298f4961..65dd6d5f3c 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.908", + "version": "0.1.909", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.908", + "version": "0.1.909", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index e8d8b00c3e..fa754494bd 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.908", + "version": "0.1.909", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/routes/external/auth.ts b/control-api/src/routes/external/auth.ts index e296e0894e..a9c511fc54 100644 --- a/control-api/src/routes/external/auth.ts +++ b/control-api/src/routes/external/auth.ts @@ -162,7 +162,7 @@ async function requireLegacyRpcSessionRateLimitContext( req.externalSessionAuthentication = authentication next() } catch (error) { - next(error) + handleExternalSessionBackendFailure(error, req, res, next) } } @@ -375,6 +375,7 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { userId, email, teamId, + role: role as AuthClaims['role'], }, { policy: authentication.policy, @@ -414,7 +415,7 @@ export function createExternalAuthRouter(gateway: K8sGateway): Router { | { role?: 'admin' | 'inviter' | 'member'; lifecycle_version?: number | string } | undefined )?.role - if (!liveRole) { + if (!liveRole || liveRole !== role) { return res.status(403).json({ error: 'membership_not_found' }) } return res.status(200).json({ diff --git a/control-api/src/services/auth/externalSessionIssuance.ts b/control-api/src/services/auth/externalSessionIssuance.ts index f971b6d618..fe318b45f8 100644 --- a/control-api/src/services/auth/externalSessionIssuance.ts +++ b/control-api/src/services/auth/externalSessionIssuance.ts @@ -150,6 +150,7 @@ export async function exchangeLegacyExternalUserSession( userId: string email: string teamId: string + role: TeamRole }, options: { policy: EffectiveUserAccessPolicy @@ -190,7 +191,7 @@ export async function exchangeLegacyExternalUserSession( const row = membership.rows[0] as | { role?: TeamRole; lifecycle_version?: number | string } | undefined - if (!row?.role) return { status: 'membership_not_found' } + if (!row?.role || row.role !== input.role) return { status: 'membership_not_found' } const issued = await issueExternalUserSession( { diff --git a/control-api/test/externalSessionIssuance.exchangeRoleMismatch.test.ts b/control-api/test/externalSessionIssuance.exchangeRoleMismatch.test.ts new file mode 100644 index 0000000000..74cdd50745 --- /dev/null +++ b/control-api/test/externalSessionIssuance.exchangeRoleMismatch.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { randomUUID } from 'node:crypto' +import type { DbClient } from '../src/db.js' +import type { AuthClaims, TeamRole } from '../src/profileTypes.js' +import type { EffectiveUserAccessPolicy } from '../src/services/access/userAccessPolicy.js' +import { exchangeLegacyExternalUserSession } from '../src/services/auth/externalSessionIssuance.js' +import { verifyExternalSessionToken } from '../src/utils/auth/externalSessionAuthToken.js' + +const signer = vi.hoisted(() => ({ sign: vi.fn() })) + +// issueExternalUserSession is called through a local binding inside the module, +// so the signer is observed through a module mock that delegates to the real one. +vi.mock('../src/utils/auth/externalSessionAuthToken.js', async importOriginal => { + const actual = + await importOriginal() + signer.sign.mockImplementation(actual.signExternalSessionToken) + return { ...actual, signExternalSessionToken: signer.sign } +}) + +const legacyPolicy = { issueV1: true, acceptV1: true } as EffectiveUserAccessPolicy + +function exchangeDatabase(lockedRole: TeamRole) { + const userId = randomUUID() + const membershipQueries: unknown[][] = [] + const now = new Date(Math.floor(Date.now() / 1000) * 1000) + const db = { + query: async (sql: string, values: unknown[] = []) => { + if (sql.includes('clock_timestamp()')) return { rows: [{ db_now: now }], rowCount: 1 } + if (sql.includes('SELECT tm.role, u.lifecycle_version')) { + membershipQueries.push(values) + return { rows: [{ role: lockedRole, lifecycle_version: 1 }], rowCount: 1 } + } + if (sql.includes('SELECT lifecycle_state, lifecycle_version')) { + return { rows: [{ lifecycle_state: 'active', lifecycle_version: 1 }], rowCount: 1 } + } + if (sql.includes('SELECT id FROM users WHERE id = $1 FOR UPDATE')) { + return { rows: [{ id: userId }], rowCount: 1 } + } + if (sql.includes('SELECT u.id, u.lifecycle_state, u.lifecycle_version')) { + return { + rows: [ + { + id: userId, + lifecycle_state: 'active', + lifecycle_version: 1, + valid_after: null, + token_revoked: false, + }, + ], + rowCount: 1, + } + } + if ( + sql.includes('SELECT valid_after') && + sql.includes('external_user_session_security_epochs') + ) { + return { rows: [], rowCount: 0 } + } + throw new Error(`unexpected exchange query: ${sql}`) + }, + } + return { db, userId, membershipQueries, now } +} + +async function exchange(requestedRole: TeamRole, database: ReturnType) { + const email = `exchange-${database.userId}@example.test` + const teamId = randomUUID() + const issuedAt = Math.floor(database.now.getTime() / 1000) - 60 + const claims: AuthClaims = { + userId: database.userId, + email, + teamId: null, + role: 'member', + authGeneration: 1, + iat: issuedAt, + exp: issuedAt + 3600, + } + const result = await exchangeLegacyExternalUserSession( + { + token: 'legacy-session-token', + claims, + userId: database.userId, + email, + teamId, + role: requestedRole, + }, + { + policy: legacyPolicy, + transaction: work => work(database.db as unknown as DbClient), + } + ) + return { result, email, teamId } +} + +describe('legacy session exchange role check', () => { + beforeEach(() => { + signer.sign.mockClear() + }) + + it('answers membership_not_found without signing when the requested role differs from the locked membership', async () => { + const database = exchangeDatabase('member') + + const { result, email, teamId } = await exchange('admin', database) + + expect(result).toEqual({ status: 'membership_not_found' }) + // Witness: the locked membership read ran once for this identity and team. + expect(database.membershipQueries).toEqual([[database.userId, email, teamId]]) + expect(signer.sign).not.toHaveBeenCalled() + }) + + it('issues a token carrying the locked role when the requested role matches', async () => { + const database = exchangeDatabase('member') + + const { result, email, teamId } = await exchange('member', database) + + expect(result).toMatchObject({ status: 'issued', role: 'member' }) + expect(database.membershipQueries).toEqual([[database.userId, email, teamId]]) + expect(signer.sign).toHaveBeenCalledOnce() + expect(signer.sign.mock.calls[0]?.[0]).toMatchObject({ role: 'member', teamId }) + const token = (result as { token: string }).token + expect(verifyExternalSessionToken(token)).toMatchObject({ role: 'member', teamId }) + }) +}) diff --git a/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts b/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts index 1b77481386..ab70542105 100644 --- a/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts +++ b/control-api/test/legacySessionExchangeRace.realPostgres.integration.test.ts @@ -3,7 +3,7 @@ import bcrypt from 'bcryptjs' import { createHash, randomBytes, randomUUID } from 'node:crypto' import { Pool, type PoolClient } from 'pg' import { type DbTransactionClient, type DbTransactionOptions, initDb } from '../src/db.js' -import type { AuthClaims } from '../src/profileTypes.js' +import type { AuthClaims, TeamRole } from '../src/profileTypes.js' import type { EffectiveUserAccessPolicy } from '../src/services/access/userAccessPolicy.js' import { exchangeLegacyExternalUserSession } from '../src/services/auth/externalSessionIssuance.js' import { @@ -123,7 +123,10 @@ describeRealPostgres('legacy replacement exchange serialization on real PostgreS throw new Error('expected real password producer to block on the user security boundary') } - async function principal(label: string): Promise<{ + async function principal( + label: string, + role: TeamRole = 'admin' + ): Promise<{ userId: string teamId: string email: string @@ -141,14 +144,14 @@ describeRealPostgres('legacy replacement exchange serialization on real PostgreS await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, $2)`, [teamId, label]) await databasePool.query( `INSERT INTO team_members(team_id, user_id, role, status) - VALUES ($1, $2, 'admin', 'active')`, - [teamId, userId] + VALUES ($1, $2, $3, 'active')`, + [teamId, userId, role] ) const token = signExternalSessionToken({ userId, email, teamId, - role: 'admin', + role, authGeneration: 1, }) const claims = verifyExternalSessionToken(token) @@ -264,6 +267,7 @@ describeRealPostgres('legacy replacement exchange serialization on real PostgreS userId: source.userId, email: source.email, teamId: source.teamId, + role: 'admin', }, { policy, @@ -301,6 +305,7 @@ describeRealPostgres('legacy replacement exchange serialization on real PostgreS userId: source.userId, email: source.email, teamId: source.teamId, + role: 'admin', }, { policy } ) @@ -333,6 +338,7 @@ describeRealPostgres('legacy replacement exchange serialization on real PostgreS userId: source.userId, email: source.email, teamId: source.teamId, + role: 'admin', }, { policy, @@ -381,6 +387,7 @@ describeRealPostgres('legacy replacement exchange serialization on real PostgreS userId: source.userId, email: source.email, teamId: source.teamId, + role: 'admin', }, { policy } ) @@ -409,6 +416,7 @@ describeRealPostgres('legacy replacement exchange serialization on real PostgreS userId: source.userId, email: source.email, teamId: source.teamId, + role: 'admin', }, { policy, @@ -470,6 +478,7 @@ describeRealPostgres('legacy replacement exchange serialization on real PostgreS userId: source.userId, email: source.email, teamId: source.teamId, + role: 'admin', }, { policy, @@ -508,6 +517,7 @@ describeRealPostgres('legacy replacement exchange serialization on real PostgreS userId: source.userId, email: source.email, teamId: source.teamId, + role: 'admin', }, { policy } ) @@ -518,6 +528,30 @@ describeRealPostgres('legacy replacement exchange serialization on real PostgreS } }) + it('rejects an exchange whose requested role differs from the locked membership role', async () => { + const source = await principal('role-mismatch', 'member') + const input = { + token: source.token, + claims: source.claims, + userId: source.userId, + email: source.email, + teamId: source.teamId, + } + + await expect( + exchangeLegacyExternalUserSession({ ...input, role: 'admin' }, { policy }) + ).resolves.toEqual({ status: 'membership_not_found' }) + + const issued = await exchangeLegacyExternalUserSession({ ...input, role: 'member' }, { policy }) + expect(issued).toMatchObject({ status: 'issued', role: 'member' }) + if (issued.status !== 'issued') throw new Error('exchange did not issue a token') + expect(verifyExternalSessionToken(issued.token)).toMatchObject({ + userId: source.userId, + teamId: source.teamId, + role: 'member', + }) + }) + it('keeps revoke-all rollback safe and monotonic on real PostgreSQL', async () => { const source = await principal('revoke-all-monotonic') const rollback = await databasePool.connect() diff --git a/control-api/test/routes.externalAuth.authorityUnavailable.test.ts b/control-api/test/routes.externalAuth.authorityUnavailable.test.ts index b8484adba7..a144845d70 100644 --- a/control-api/test/routes.externalAuth.authorityUnavailable.test.ts +++ b/control-api/test/routes.externalAuth.authorityUnavailable.test.ts @@ -377,5 +377,34 @@ describe('external auth authority unavailability parity', () => { expect(JSON.stringify(response.body)).not.toContain('boom') expect(response.headers['retry-after']).toBeUndefined() }) + + it('answers the session backend 503 for a classified outage in the session stage', async () => { + sessions.authenticateExternalUserSession.mockRejectedValue(unavailable()) + + const response = await rpcTokenRequest() + + expect(response.status).toBe(503) + expect(response.body).toEqual({ + error: 'session_backend_unavailable', + retryAfterSeconds: 2, + }) + expect(response.headers['retry-after']).toBe('2') + expect(response.headers['cache-control']).toBe('no-store') + expect(sessions.authenticateExternalUserSession).toHaveBeenCalledOnce() + expect(liveTeam.getLiveTeamMembership).not.toHaveBeenCalled() + }) + + it('keeps a plain session-stage error on the sanitized 500 path', async () => { + vi.stubEnv('NODE_ENV', 'production') + sessions.authenticateExternalUserSession.mockRejectedValue(new Error('boom')) + + const response = await rpcTokenRequest() + + expect(response.status).toBe(500) + expect(response.body).toMatchObject({ error: 'Internal Server Error' }) + expect(JSON.stringify(response.body)).not.toContain('boom') + expect(response.headers['retry-after']).toBeUndefined() + expect(sessions.authenticateExternalUserSession).toHaveBeenCalledOnce() + }) }) }) diff --git a/control-api/test/routes.externalSessionAtomicExchange.test.ts b/control-api/test/routes.externalSessionAtomicExchange.test.ts index 484880e27e..46db3e6333 100644 --- a/control-api/test/routes.externalSessionAtomicExchange.test.ts +++ b/control-api/test/routes.externalSessionAtomicExchange.test.ts @@ -84,13 +84,18 @@ describe('legacy external-session replacement exchange', () => { expect(response.status).toBe(401) expect(response.body).toMatchObject({ error: { code: 'invalid_session' } }) expect(issuance.exchangeLegacyExternalUserSession).toHaveBeenCalledWith( - expect.objectContaining({ token: 'synthetic-source-token', teamId: 'team-new', claims }), + expect.objectContaining({ + token: 'synthetic-source-token', + teamId: 'team-new', + role: 'admin', + claims, + }), expect.objectContaining({ policy: expect.any(Object) }) ) expect(issuance.issueExternalUserSession).not.toHaveBeenCalled() }) - it('uses the current locked membership role for the replacement representation', async () => { + it('passes the requested role to the locked exchange and returns its replacement', async () => { issuance.exchangeLegacyExternalUserSession.mockResolvedValue({ status: 'issued', token: 'synthetic-replacement-token', @@ -101,7 +106,7 @@ describe('legacy external-session replacement exchange', () => { .post('/external/auth/session-token') .set('x-forwarded-for', '203.0.113.44') .set('x-user-session-token', 'synthetic-source-token') - .send({ userId: claims.userId, email: claims.email, teamId: 'team-new', role: 'admin' }) + .send({ userId: claims.userId, email: claims.email, teamId: 'team-new', role: 'member' }) .expect(200) expect(response.body).toEqual({ @@ -109,6 +114,67 @@ describe('legacy external-session replacement exchange', () => { sessionContract: 'v1', deprecated: true, }) + expect(issuance.exchangeLegacyExternalUserSession).toHaveBeenCalledWith( + expect.objectContaining({ teamId: 'team-new', role: 'member' }), + expect.objectContaining({ policy: expect.any(Object) }) + ) + }) + + it('rejects a v2 switch whose requested role differs from the live membership role', async () => { + const v2Claims = { + userId: claims.userId, + email: claims.email, + sid: 'source-session', + jti: 'source-jti', + } + const v2Authentication = { + status: 'authenticated', + contract: 'v2', + claims: v2Claims, + authorityContext: { + contract: 'v2' as const, + userId: claims.userId, + sid: v2Claims.sid, + jti: v2Claims.jti, + sessionVersion: 1, + }, + policy: { issueV1: true, switchCompatibility: true }, + } + sessions.authenticateExternalUserSession.mockResolvedValue(v2Authentication) + database.query.mockResolvedValue({ + rows: [{ role: 'member', lifecycle_version: 1 }], + rowCount: 1, + }) + const payload = { userId: claims.userId, email: claims.email, teamId: 'team-new' } + + const mismatch = await request(app()) + .post('/external/auth/session-token') + .set('x-forwarded-for', '203.0.113.44') + .set('x-user-session-token', 'synthetic-source-token') + .send({ ...payload, role: 'admin' }) + + expect(mismatch.status).toBe(403) + expect(mismatch.body).toEqual({ error: 'membership_not_found' }) + // Witness: the live membership read ran for this identity and team. + expect(database.query).toHaveBeenCalledWith(expect.stringContaining('SELECT tm.role'), [ + claims.userId, + claims.email, + 'team-new', + ]) + expect(issuance.exchangeLegacyExternalUserSession).not.toHaveBeenCalled() + + const control = await request(app()) + .post('/external/auth/session-token') + .set('x-forwarded-for', '203.0.113.44') + .set('x-user-session-token', 'synthetic-source-token') + .send({ ...payload, role: 'member' }) + + expect(control.status).toBe(200) + expect(control.body).toEqual({ + token: 'synthetic-source-token', + sessionContract: 'v2', + deprecated: true, + }) }) it('rejects a removed membership without signing a replacement', async () => { From 5f37f62e6aa3e811c811d2c3a781b50d68e6ec22 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 4 Nov 2026 21:16:57 +0100 Subject: [PATCH 472/528] test: mark session token fixtures as synthetic The public boundary check in the Minikube T2 contract rejects credential-shaped literals added by earlier commits of this branch. Prefix those test values with "synthetic-" in the four affected test files; every use of each value is renamed together so equality and cookie round-trip assertions are unchanged. Co-Authored-By: Claude Opus 5.5 --- control-api/package-lock.json | 4 +-- control-api/package.json | 2 +- .../test/gfs.externalAuthority.test.ts | 7 +++-- .../appService.sessionRejection.test.ts | 30 +++++++++---------- external-rest-api/package-lock.json | 4 +-- external-rest-api/package.json | 2 +- external-rest-api/src/releaseManifest.ts | 2 +- external-rest-api/test/routes.auth.test.ts | 8 +++-- .../test/routes.invitations.test.ts | 8 +++-- 9 files changed, 37 insertions(+), 30 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index 65dd6d5f3c..a5a28df75f 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.909", + "version": "0.1.910", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.909", + "version": "0.1.910", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index fa754494bd..8e59c70579 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.909", + "version": "0.1.910", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/gfs.externalAuthority.test.ts b/control-api/test/gfs.externalAuthority.test.ts index d85f88cd1b..d0670e9078 100644 --- a/control-api/test/gfs.externalAuthority.test.ts +++ b/control-api/test/gfs.externalAuthority.test.ts @@ -199,10 +199,13 @@ describe('signCurrentExternalGfsUserToken', () => { rowCount: 1, }) dbMock.withTransaction.mockImplementation(async work => work({ query })) - gfsTokenMock.signGfsToken.mockReturnValue({ token: 'signed-gfs-token', expiresInSeconds: 600 }) + gfsTokenMock.signGfsToken.mockReturnValue({ + token: 'synthetic-signed-gfs-token', + expiresInSeconds: 600, + }) await expect(signCurrentExternalGfsUserToken(authentication, input)).resolves.toEqual({ - token: 'signed-gfs-token', + token: 'synthetic-signed-gfs-token', expiresInSeconds: 600, }) diff --git a/desktop-app/src/__tests__/appService.sessionRejection.test.ts b/desktop-app/src/__tests__/appService.sessionRejection.test.ts index 91aa6bea02..8cf25fd4c0 100644 --- a/desktop-app/src/__tests__/appService.sessionRejection.test.ts +++ b/desktop-app/src/__tests__/appService.sessionRejection.test.ts @@ -54,7 +54,7 @@ function createService(me: SessionMe) { tokenStore: tokenStore as never, onSessionCleared, }) as unknown as TestService - service.sessionToken = 'session-token' + service.sessionToken = 'synthetic-session-token' service.me = me service.suspendDesktopGfsUploadsForAuthBoundary = vi.fn().mockResolvedValue(undefined) return { service, tokenStore, onSessionCleared } @@ -90,10 +90,10 @@ describe('AppService session rejected by /me', () => { await expect(service.runWithTeamContext('team-b', operation)).rejects.toBe(rejection) expect(service.authClient.getMe).toHaveBeenCalledTimes(1) - expect(service.authClient.getMe).toHaveBeenCalledWith('session-token') + expect(service.authClient.getMe).toHaveBeenCalledWith('synthetic-session-token') expect(operation).not.toHaveBeenCalled() // The uploads of the rejected session are fenced before it is cleared, with its identity. - expect(fenced).toEqual([{ token: 'session-token', ownerId: 'user-a' }]) + expect(fenced).toEqual([{ token: 'synthetic-session-token', ownerId: 'user-a' }]) expectSessionEnded(service, tokenStore, onSessionCleared) }) @@ -105,7 +105,7 @@ describe('AppService session rejected by /me', () => { await expect(service.runWithTeamContext('team-b', vi.fn())).rejects.toBe(unavailable) expect(service.authClient.getMe).toHaveBeenCalledTimes(1) - expect(service.sessionToken).toBe('session-token') + expect(service.sessionToken).toBe('synthetic-session-token') expect(tokenStore.clearSessionToken).not.toHaveBeenCalled() expect(onSessionCleared).not.toHaveBeenCalled() }) @@ -172,7 +172,7 @@ describe('AppService session rejected by /me', () => { ], }), switchTeam: vi.fn().mockResolvedValue({ - token: 'session-team-b', + token: 'synthetic-session-team-b', team: { id: 'team-b', name: 'Team B', role: 'member' }, }), getMe: vi.fn().mockResolvedValue(sessionMe({ teamId: 'team-b' })), @@ -211,7 +211,7 @@ describe('AppService session rejected by /me', () => { expect(service.authClient.listWorkflows).toHaveBeenCalledTimes(1) expect(service.authClient.switchTeam).not.toHaveBeenCalled() expect(service.authClient.getMe).toHaveBeenCalledTimes(1) - expect(service.authClient.getMe).toHaveBeenCalledWith('session-token') + expect(service.authClient.getMe).toHaveBeenCalledWith('synthetic-session-token') expectSessionEnded(service, tokenStore, onSessionCleared) }) @@ -235,7 +235,7 @@ describe('AppService session rejected by /me', () => { await expect(service.listWorkflows()).rejects.toBe(rejection) expect(service.authClient.getMe).toHaveBeenCalledTimes(1) - expect(service.sessionToken).toBe('session-token') + expect(service.sessionToken).toBe('synthetic-session-token') expect(tokenStore.clearSessionToken).not.toHaveBeenCalled() expect(onSessionCleared).not.toHaveBeenCalled() expect([...service.workflowTeamByKey]).toEqual([['sandbox-recipes/kept', 'team-a']]) @@ -263,7 +263,7 @@ describe('AppService session rejected by /me', () => { expect(service.authClient.switchTeam).toHaveBeenCalledTimes(1) expect(service.authClient.getMe).not.toHaveBeenCalled() - expect(service.sessionToken).toBe('session-token') + expect(service.sessionToken).toBe('synthetic-session-token') expect(tokenStore.clearSessionToken).not.toHaveBeenCalled() expect(onSessionCleared).not.toHaveBeenCalled() }) @@ -273,7 +273,7 @@ describe('AppService session rejected by /me', () => { const rejection = unauthorized() service.authClient = { switchTeam: vi.fn().mockResolvedValue({ - token: 'session-team-b', + token: 'synthetic-session-team-b', team: { id: 'team-b', name: 'Team B', role: 'member' }, }), getMe: vi.fn().mockRejectedValue(rejection), @@ -282,7 +282,7 @@ describe('AppService session rejected by /me', () => { await expect(service.switchTeam('team-b')).rejects.toBe(rejection) expect(service.authClient.switchTeam).toHaveBeenCalledTimes(1) - expect(service.authClient.getMe).toHaveBeenCalledWith('session-team-b') + expect(service.authClient.getMe).toHaveBeenCalledWith('synthetic-session-team-b') expectSessionEnded(service, tokenStore, onSessionCleared) }) @@ -292,7 +292,7 @@ describe('AppService session rejected by /me', () => { service.suspendDesktopGfsUploadsForAuthBoundary = vi.fn().mockRejectedValue(fenceFailure) service.authClient = { switchTeam: vi.fn().mockResolvedValue({ - token: 'session-team-b', + token: 'synthetic-session-team-b', team: { id: 'team-b', name: 'Team B', role: 'member' }, }), getMe: vi.fn().mockResolvedValue(sessionMe({ teamId: 'team-b', teamName: 'Team B' })), @@ -361,7 +361,7 @@ describe('AppService workflow listing when the session is replaced', () => { const { service, replaceSession } = listingService() let releaseTeamA!: () => void service.authClient.listWorkflows.mockImplementation(async (token: string) => { - if (token !== 'session-token') return { items: [recipe(`via-${token}`)], count: 1 } + if (token !== 'synthetic-session-token') return { items: [recipe(`via-${token}`)], count: 1 } await new Promise(resolve => (releaseTeamA = resolve)) return { items: [recipe('from-a')], count: 1 } }) @@ -372,7 +372,7 @@ describe('AppService workflow listing when the session is replaced', () => { () => 'rejected' ) await vi.waitFor(() => expect(service.authClient.listWorkflows).toHaveBeenCalledTimes(1)) - expect(service.authClient.listWorkflows).toHaveBeenCalledWith('session-token') + expect(service.authClient.listWorkflows).toHaveBeenCalledWith('synthetic-session-token') await replaceSession() releaseTeamA() @@ -387,7 +387,7 @@ describe('AppService workflow listing when the session is replaced', () => { it('rejects a listing whose session was replaced inside a team request', async () => { const { service, replaceSession } = listingService() service.authClient.listWorkflows.mockImplementation(async (token: string) => { - if (token !== 'session-token') return { items: [recipe(`via-${token}`)], count: 1 } + if (token !== 'synthetic-session-token') return { items: [recipe(`via-${token}`)], count: 1 } await replaceSession() return { items: [recipe('from-a')], count: 1 } }) @@ -426,7 +426,7 @@ describe('AppService workflow listing when the session is replaced', () => { ) // The current team was listed with the session that started the listing. - expect(service.authClient.listWorkflows).toHaveBeenCalledWith('session-token') + expect(service.authClient.listWorkflows).toHaveBeenCalledWith('synthetic-session-token') expect(outcome).toBe('rejected') expect([...service.workflowTeamByKey]).toEqual([['sandbox-recipes/b-own', 'team-c']]) }) diff --git a/external-rest-api/package-lock.json b/external-rest-api/package-lock.json index 8441226a57..cb9d3c7e52 100644 --- a/external-rest-api/package-lock.json +++ b/external-rest-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-external-rest-api", - "version": "0.1.171", + "version": "0.1.172", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-external-rest-api", - "version": "0.1.171", + "version": "0.1.172", "dependencies": { "@clerum/jwt-key-policy": "file:../packages/jwt-key-policy", "cors": "^2.8.5", diff --git a/external-rest-api/package.json b/external-rest-api/package.json index 75f4fe3a71..4be942b881 100644 --- a/external-rest-api/package.json +++ b/external-rest-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-external-rest-api", - "version": "0.1.171", + "version": "0.1.172", "description": "External REST API for profile, team management, and RPC token brokerage", "main": "dist/main.js", "engines": { diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index dc126c3918..c49eaf1795 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -8,7 +8,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.12.0', - externalRestApiVersion: '0.1.171', + externalRestApiVersion: '0.1.172', rpcProxyVersion: '0.1.138', desktopVersion: '0.12.0', minimumDesktopVersion: '0.1.252', diff --git a/external-rest-api/test/routes.auth.test.ts b/external-rest-api/test/routes.auth.test.ts index d84852c506..1ae6875572 100644 --- a/external-rest-api/test/routes.auth.test.ts +++ b/external-rest-api/test/routes.auth.test.ts @@ -381,7 +381,7 @@ describe('routes/auth password-login', () => { 'reports no presented session when a logout retry follows a failed logout (%s)', async (status, body) => { authServiceMock.loginWithPassword.mockResolvedValueOnce({ - token: 'composed-session-token', + token: 'synthetic-composed-session-token', me: { id: 'user-1', email: 'user@example.invalid', @@ -404,7 +404,7 @@ describe('routes/auth password-login', () => { .send({ email: 'user@example.invalid', password: 'synthetic-correct-password' }) .expect(200) expect(String(login.headers['set-cookie'])).toContain( - 'profile_session=composed-session-token' + 'profile_session=synthetic-composed-session-token' ) const failed = await agent.post('/api/v1/auth/logout').expect(status) @@ -413,7 +413,9 @@ describe('routes/auth password-login', () => { const retried = await agent.post('/api/v1/auth/logout').expect(200) // The first logout carried the login cookie; the retry reached no session at all. - expect(authServiceMock.logoutUserSession.mock.calls).toEqual([['composed-session-token']]) + expect(authServiceMock.logoutUserSession.mock.calls).toEqual([ + ['synthetic-composed-session-token'], + ]) expect(retried.body).toEqual({ ok: true, sessionPresented: false }) expect(failed.body.ok).toBeUndefined() expect(String(failed.headers['set-cookie'])).toContain( diff --git a/external-rest-api/test/routes.invitations.test.ts b/external-rest-api/test/routes.invitations.test.ts index 266d08ec6c..07a08ba7e2 100644 --- a/external-rest-api/test/routes.invitations.test.ts +++ b/external-rest-api/test/routes.invitations.test.ts @@ -603,7 +603,7 @@ describe('routes/invitations', () => { teamId: 'team-1', teamName: 'Team 1', role: 'member', - token: 'accepted-session-token', + token: 'synthetic-accepted-session-token', }, }) @@ -618,7 +618,9 @@ describe('routes/invitations', () => { userId: 'user-1', email: 'invitee@example.com', }) - expect(String(res.headers['set-cookie'])).toContain('profile_session=accepted-session-token') + expect(String(res.headers['set-cookie'])).toContain( + 'profile_session=synthetic-accepted-session-token' + ) expect(String(res.headers['set-cookie'])).toContain('HttpOnly') }) @@ -646,7 +648,7 @@ describe('routes/invitations', () => { teamId: 'team-1', teamName: 'Team 1', role: 'member', - token: 'accepted-session-token', + token: 'synthetic-accepted-session-token', }, }) From c148252a03c193055d61b15277b6e69ce38ab1ea Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 4 Nov 2026 22:04:53 +0100 Subject: [PATCH 473/528] fix(control-api): lock user rows with FOR NO KEY UPDATE, user first (K25) User-row locks in session issuance, validation, revocation, login and invitation acceptance use FOR NO KEY UPDATE, so foreign-key KEY SHARE locks no longer block them while lifecycle updates and deletes still conflict. Issuance reuses the row already locked by its caller (lockedUser/userLocked) and checks that row's lifecycle, so a retirement that commits first wins. Transactions that lock both a user and that user's team memberships now take the user row first: invitation acceptance, managed invitation manager checks and admin Desktop workspace provisioning. This keeps one lock order with the session exchange. A new real-PostgreSQL suite covers the lock-mode compatibility, the retirement races and concurrent acceptance plus exchange. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci-public.yml | 1 + control-api/package-lock.json | 4 +- control-api/package.json | 2 +- .../src/routes/external/invitations.ts | 8 +- .../services/auth/externalSessionIssuance.ts | 74 +- .../auth/passwordSessionAuthentication.ts | 18 +- .../src/services/auth/userSessionService.ts | 26 +- .../services/directory/adminProvisioning.ts | 7 +- .../src/services/directory/membership.ts | 26 +- control-api/test/app.test.ts | 13 +- .../directoryAuthorizationPrivacy.test.ts | 72 +- ...ssionIssuance.exchangeRoleMismatch.test.ts | 12 +- .../test/legacyV1SessionGeneration.test.ts | 78 +- .../passwordSessionAuthentication.test.ts | 18 +- ...es.externalInvitations.unavailable.test.ts | 26 +- ...rvices.directory.adminProvisioning.test.ts | 4 + .../test/userSessionRevokeAllAtomic.test.ts | 6 +- control-api/test/userSessionService.test.ts | 39 +- ...rLockMode.realPostgres.integration.test.ts | 675 ++++++++++++++++++ tests/contracts/password-recovery-suites.json | 1 + 20 files changed, 1021 insertions(+), 89 deletions(-) create mode 100644 control-api/test/userSessionUserLockMode.realPostgres.integration.test.ts diff --git a/.github/workflows/ci-public.yml b/.github/workflows/ci-public.yml index 42256cad89..9cfffaf664 100644 --- a/.github/workflows/ci-public.yml +++ b/.github/workflows/ci-public.yml @@ -667,6 +667,7 @@ jobs: routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts \ userAccessRuntimeContract.realPostgres.integration.test.ts \ userSessionDatabaseClock.realPostgres.integration.test.ts \ + userSessionUserLockMode.realPostgres.integration.test.ts \ workflowRunReadAndApprovalV1Compatibility.realPostgres.integration.test.ts - name: Install gfs-controller dependencies diff --git a/control-api/package-lock.json b/control-api/package-lock.json index a5a28df75f..c8ed3104cf 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.910", + "version": "0.1.911", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.910", + "version": "0.1.911", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 8e59c70579..7936cffc35 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.910", + "version": "0.1.911", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/src/routes/external/invitations.ts b/control-api/src/routes/external/invitations.ts index 9bb0d893d7..ccfe0cb534 100644 --- a/control-api/src/routes/external/invitations.ts +++ b/control-api/src/routes/external/invitations.ts @@ -343,10 +343,8 @@ export function createExternalInvitationsRouter(): Router { // capability. A replay must stay a success without minting a session, // because the token may already be public knowledge. if ('replay' in result.data) return { data: result.data, replay: true as const } - const lockedUser = await db.query(`SELECT id FROM users WHERE id = $1 FOR UPDATE`, [ - result.data.userId, - ]) - if ((lockedUser.rowCount ?? 0) === 0) return { error: 'not_found' as const } + // The acceptance locked the user row before its membership writes; + // issuance reuses that locked row instead of locking it again. const issued = await issueExternalUserSession( { contract: selection.contract, @@ -357,7 +355,7 @@ export function createExternalInvitationsRouter(): Router { authGeneration: Number(result.data.authGeneration), authenticationMethods: ['invitation'], }, - { db, policy } + { db, policy, lockedUser: result.lockedUser } ) return { data: result.data, issued } }) diff --git a/control-api/src/services/auth/externalSessionIssuance.ts b/control-api/src/services/auth/externalSessionIssuance.ts index fe318b45f8..56f54b26d9 100644 --- a/control-api/src/services/auth/externalSessionIssuance.ts +++ b/control-api/src/services/auth/externalSessionIssuance.ts @@ -28,6 +28,24 @@ export type LegacyExternalSessionExchange = type SessionTransaction = (work: (db: DbClient) => Promise) => Promise +/** + * Lifecycle columns a caller read from the user row while holding that row's + * lock (`FOR NO KEY UPDATE` or stronger) in the transaction of `db`. + */ +export type LockedUserLifecycle = { lifecycleState: unknown; lifecycleVersion: unknown } + +type ExternalSessionIssuanceOptions = + | { + db?: Pick + policy: EffectiveUserAccessPolicy + lockedUser?: undefined + } + | { + db: Pick + policy: EffectiveUserAccessPolicy + lockedUser: LockedUserLifecycle + } + export function selectExternalSessionRepresentation( client: ExternalSessionClient, policy: EffectiveUserAccessPolicy @@ -62,14 +80,12 @@ export async function issueExternalUserSession( teamId: string | null role: TeamRole // Legacy callers may still supply the generation they observed. V1 issuance - // deliberately re-reads it under the canonical user lock below. + // never signs it: it signs the generation read under the user lock, either + // the caller's `lockedUser` row or its own locked read below. authGeneration?: number authenticationMethods: string[] }, - options: { - db?: Pick - policy: EffectiveUserAccessPolicy - } + options: ExternalSessionIssuanceOptions ): Promise<{ token: string; contract: ExternalSessionContract }> { const policy = options.policy if (input.contract === 'v2') { @@ -81,9 +97,11 @@ export async function issueExternalUserSession( email: input.email, authenticationMethods: input.authenticationMethods, } - const session = options.db - ? await createUserSession(sessionInput, { db: options.db }) - : await createUserSession(sessionInput) + const session = options.lockedUser + ? await createUserSession(sessionInput, { db: options.db, userLocked: true }) + : options.db + ? await createUserSession(sessionInput, { db: options.db }) + : await createUserSession(sessionInput) return { token: session.token, contract: 'v2' } } @@ -91,17 +109,22 @@ export async function issueExternalUserSession( throw new Error('legacy user-session issuance is not effective') } + const lockedUser = options.lockedUser const issueLegacy = async (db: Pick) => { - const user = await db.query( - `SELECT lifecycle_state, lifecycle_version - FROM users - WHERE id = $1 - FOR UPDATE`, - [input.userId] - ) - const row = user.rows[0] as - | { lifecycle_state?: unknown; lifecycle_version?: unknown } - | undefined + const row = lockedUser + ? { + lifecycle_state: lockedUser.lifecycleState, + lifecycle_version: lockedUser.lifecycleVersion, + } + : (( + await db.query( + `SELECT lifecycle_state, lifecycle_version + FROM users + WHERE id = $1 + FOR NO KEY UPDATE`, + [input.userId] + ) + ).rows[0] as { lifecycle_state?: unknown; lifecycle_version?: unknown } | undefined) const authGeneration = Number(row?.lifecycle_version) if ( row?.lifecycle_state !== 'active' || @@ -176,7 +199,7 @@ export async function exchangeLegacyExternalUserSession( if (validation.status !== 'valid') return { status: 'invalid_session' } const membership = await db.query( - `SELECT tm.role, u.lifecycle_version + `SELECT tm.role, u.lifecycle_state, u.lifecycle_version FROM users u JOIN team_members tm ON tm.user_id = u.id WHERE u.id = $1 @@ -189,10 +212,12 @@ export async function exchangeLegacyExternalUserSession( [input.userId, input.email, input.teamId] ) const row = membership.rows[0] as - | { role?: TeamRole; lifecycle_version?: number | string } + | { role?: TeamRole; lifecycle_state?: unknown; lifecycle_version?: number | string } | undefined if (!row?.role || row.role !== input.role) return { status: 'membership_not_found' } + // validateLegacyUserSession({ lockUser: true }) holds the user row lock in + // this transaction, so the lifecycle read above is the locked snapshot. const issued = await issueExternalUserSession( { contract: 'v1', @@ -203,7 +228,14 @@ export async function exchangeLegacyExternalUserSession( authGeneration: Number(row.lifecycle_version), authenticationMethods: [], }, - { db, policy: options.policy } + { + db, + policy: options.policy, + lockedUser: { + lifecycleState: row.lifecycle_state, + lifecycleVersion: row.lifecycle_version, + }, + } ) return { status: 'issued', token: issued.token, role: row.role } }) diff --git a/control-api/src/services/auth/passwordSessionAuthentication.ts b/control-api/src/services/auth/passwordSessionAuthentication.ts index f5d6bc0c08..fb14a7bdc7 100644 --- a/control-api/src/services/auth/passwordSessionAuthentication.ts +++ b/control-api/src/services/auth/passwordSessionAuthentication.ts @@ -34,15 +34,18 @@ export async function authenticatePasswordAndIssueSession(input: { return withTransaction(async (db: Pick) => { const credential = await db.query( - `SELECT id + `SELECT id, lifecycle_state, lifecycle_version FROM users WHERE id = $1 AND email = $2 AND password_hash = $3 - FOR UPDATE`, + FOR NO KEY UPDATE`, [authenticatedLogin.user.id, authenticatedLogin.user.email, authenticatedLogin.credentialHash] ) - if ((credential.rowCount ?? 0) === 0) return null + const lockedUser = credential.rows[0] as + | { lifecycle_state: unknown; lifecycle_version: unknown } + | undefined + if (!lockedUser) return null const issued = await issueExternalUserSession( { @@ -54,7 +57,14 @@ export async function authenticatePasswordAndIssueSession(input: { authGeneration: authenticatedLogin.authGeneration, authenticationMethods: ['pwd'], }, - { db, policy: input.policy } + { + db, + policy: input.policy, + lockedUser: { + lifecycleState: lockedUser.lifecycle_state, + lifecycleVersion: lockedUser.lifecycle_version, + }, + } ) return { ...authenticatedLogin, issued } }) diff --git a/control-api/src/services/auth/userSessionService.ts b/control-api/src/services/auth/userSessionService.ts index a96c08f77a..6ecef8dbe8 100644 --- a/control-api/src/services/auth/userSessionService.ts +++ b/control-api/src/services/auth/userSessionService.ts @@ -212,14 +212,23 @@ export async function createUserSession( authenticationMethods: string[] authenticatedAt?: Date }, - options: { db?: SessionDatabase } = {} + // userLocked: the caller already holds this user's row lock (`FOR NO KEY + // UPDATE` or stronger) in the transaction of `db`; that lock serializes the + // session cap below, so only this function's own lock statement is skipped. + options: + | { db?: SessionDatabase; userLocked?: false } + | { db: SessionDatabase; userLocked: true } = {} ): Promise { const sid = randomUUID() const jti = randomUUID() const work = async (db: SessionDatabase) => { - const user = await db.query(`SELECT id FROM users WHERE id = $1 FOR UPDATE`, [input.userId]) - if ((user.rowCount ?? 0) === 0) throw new Error('user session principal does not exist') + if (!options.userLocked) { + const user = await db.query(`SELECT id FROM users WHERE id = $1 FOR NO KEY UPDATE`, [ + input.userId, + ]) + if ((user.rowCount ?? 0) === 0) throw new Error('user session principal does not exist') + } const now = await loadSessionDatabaseNow(db) const authenticatedAt = atSecond(input.authenticatedAt ?? now) const idleExpiresAt = plusSeconds(now, USER_SESSION_IDLE_LIFETIME_SECONDS) @@ -439,12 +448,11 @@ export async function revokeAllUserSessions( options: { onDatabaseFailure?: (error: unknown) => unknown } = {} ): Promise { const work = async (transaction: SessionDatabase) => { - const userLock = expectedLifecycleVersion === undefined ? 'FOR UPDATE' : 'FOR NO KEY UPDATE' const user = await transaction.query( `SELECT id, lifecycle_version FROM users WHERE id = $1 - ${userLock}`, + FOR NO KEY UPDATE`, [userId] ) if ((user.rowCount ?? 0) === 0) { @@ -537,7 +545,7 @@ export async function validateExternalSessionAuthorityContext( FROM users WHERE id = $1 AND lifecycle_state = 'active' - FOR UPDATE`, + FOR NO KEY UPDATE`, [context.userId] ) if ((principal.rowCount ?? 0) === 0) { @@ -564,7 +572,7 @@ export async function validateExternalSessionAuthorityContext( `SELECT id, email, lifecycle_state, lifecycle_version FROM users WHERE id = $1 - FOR UPDATE`, + FOR NO KEY UPDATE`, [context.userId] ) const user = principal.rows[0] as @@ -648,7 +656,7 @@ export async function validateLegacyUserSession( } const work = async (db: SessionDatabase): Promise => { if (options.lockUser) { - const locked = await db.query(`SELECT id FROM users WHERE id = $1 FOR UPDATE`, [ + const locked = await db.query(`SELECT id FROM users WHERE id = $1 FOR NO KEY UPDATE`, [ claims.userId, ]) if ((locked.rowCount ?? 0) === 0) return { status: 'revoked', reason: 'user_unavailable' } @@ -714,7 +722,7 @@ export async function revokeLegacyUserSession( options: { onDatabaseFailure?: (error: unknown) => unknown } = {} ): Promise { const work = async (transaction: SessionDatabase): Promise => { - const user = await transaction.query(`SELECT id FROM users WHERE id = $1 FOR UPDATE`, [ + const user = await transaction.query(`SELECT id FROM users WHERE id = $1 FOR NO KEY UPDATE`, [ claims.userId, ]) if ((user.rowCount ?? 0) === 0) return false diff --git a/control-api/src/services/directory/adminProvisioning.ts b/control-api/src/services/directory/adminProvisioning.ts index 9d509928f8..29743eca69 100644 --- a/control-api/src/services/directory/adminProvisioning.ts +++ b/control-api/src/services/directory/adminProvisioning.ts @@ -138,7 +138,12 @@ export async function provisionAdminDesktopWorkspace( const name = input.displayName.trim() || null const teamLabel = input.displayName.trim() || email const provision = async (db: Pick): Promise<{ userId: string }> => { - const existing = await db.query(`SELECT id FROM users WHERE email = $1 LIMIT 1`, [email]) + // Lock order: an existing user row before its team_members rows + // (ensureDefaultTeamAndGrants) and before the operator link's user lock. + const existing = await db.query( + `SELECT id FROM users WHERE email = $1 LIMIT 1 FOR NO KEY UPDATE`, + [email] + ) let userId = (existing.rows[0] as { id: string } | undefined)?.id const existingUser = Boolean(userId) if (!userId) { diff --git a/control-api/src/services/directory/membership.ts b/control-api/src/services/directory/membership.ts index 76715d8fc0..4c10aba104 100644 --- a/control-api/src/services/directory/membership.ts +++ b/control-api/src/services/directory/membership.ts @@ -819,6 +819,20 @@ export async function acceptInvitationForEmailInTransaction( const user = await ensureInvitationUser(db, invitation.email, invitation.invitee_name) if (invitationUserIsRetired(user)) return { error: 'user_retired' as const } + // Lock order: the user row before any team_members row of that user. The + // team-session exchange locks the user and then its membership, so the + // membership writes below must not precede this lock. A user that + // ensureInvitationUser inserted is invisible to other transactions until + // commit. Session issuance reuses this locked row. + const locked = await db.query( + `SELECT id, lifecycle_state, lifecycle_version FROM users WHERE id = $1 FOR NO KEY UPDATE`, + [user.id] + ) + const lockedRow = locked.rows[0] as + | { lifecycle_state: unknown; lifecycle_version: unknown } + | undefined + if (!lockedRow) return { error: 'not_found' as const } + const acceptance = await applyInvitationAcceptance(db, invitation, user.id) if (acceptance && 'error' in acceptance) return acceptance const replay = acceptance?.replay === true @@ -837,6 +851,10 @@ export async function acceptInvitationForEmailInTransaction( userId: user.id, ...(replay ? { replay: true as const } : {}), }, + lockedUser: { + lifecycleState: lockedRow.lifecycle_state, + lifecycleVersion: lockedRow.lifecycle_version, + }, } } @@ -1109,6 +1127,12 @@ async function getManagerRolesForTeams( lock = false ): Promise> { if (teamIds.length === 0) return new Map() + if (lock) { + // Lock order: the manager's user row before its team_members rows, as the + // team-session exchange does. A single FOR UPDATE over the join would lock + // each team_members row before the user row. + await db.query(`SELECT id FROM users WHERE id = $1 FOR NO KEY UPDATE`, [managerUserId.trim()]) + } const result = await db.query( `SELECT tm.team_id, tm.role FROM team_members tm @@ -1118,7 +1142,7 @@ async function getManagerRolesForTeams( AND tm.status = 'active' AND u.lifecycle_state = 'active' ORDER BY tm.team_id - ${lock ? 'FOR UPDATE' : ''}`, + ${lock ? 'FOR UPDATE OF tm' : ''}`, [managerUserId.trim(), teamIds] ) return new Map( diff --git a/control-api/test/app.test.ts b/control-api/test/app.test.ts index 7d9b79ae4c..255bc86404 100644 --- a/control-api/test/app.test.ts +++ b/control-api/test/app.test.ts @@ -263,15 +263,12 @@ describe('app router wiring', () => { rowCount: 1, } } - if (sql.includes('SELECT lifecycle_state, lifecycle_version')) { + if (sql.includes('JOIN team_members')) { return { - rows: [{ lifecycle_state: 'active', lifecycle_version: 1 }], + rows: [{ role: payload.role, lifecycle_state: 'active', lifecycle_version: 1 }], rowCount: 1, } } - if (sql.includes('JOIN team_members')) { - return { rows: [{ role: payload.role, lifecycle_version: 1 }], rowCount: 1 } - } throw new Error(`unexpected session exchange query: ${sql}`) }) vi.spyOn(pool, 'connect').mockResolvedValue({ @@ -291,12 +288,12 @@ describe('app router wiring', () => { expect(response.status, JSON.stringify(response.body)).toBe(200) }) expect(res.body.token).toBeTruthy() + // Issuance reuses the row validateLegacyUserSession locked: no second user read. expect(transactionQuery.mock.calls.map(([sql]) => String(sql))).toEqual([ 'BEGIN', - expect.stringContaining('SELECT id FROM users'), + expect.stringMatching(/SELECT id FROM users[\s\S]*FOR NO KEY UPDATE/), expect.stringContaining('external_user_session_security_epochs'), - expect.stringContaining('JOIN team_members'), - expect.stringContaining('SELECT lifecycle_state, lifecycle_version'), + expect.stringMatching(/u\.lifecycle_state, u\.lifecycle_version[\s\S]*JOIN team_members/), expect.stringContaining('clock_timestamp()'), expect.stringContaining('SELECT valid_after'), 'COMMIT', diff --git a/control-api/test/directoryAuthorizationPrivacy.test.ts b/control-api/test/directoryAuthorizationPrivacy.test.ts index 3442b2aa32..29de4d8b11 100644 --- a/control-api/test/directoryAuthorizationPrivacy.test.ts +++ b/control-api/test/directoryAuthorizationPrivacy.test.ts @@ -256,10 +256,12 @@ describe('directory privacy and atomic authorization', () => { }) it('locks invitation authority and denies inviter-to-admin assignment before mutation', async () => { - mocks.query.mockResolvedValueOnce({ - rows: [{ team_id: TEAM_A, role: 'inviter' }], - rowCount: 1, - }) + mocks.query + .mockResolvedValueOnce({ rows: [{ id: MANAGER }], rowCount: 1 }) + .mockResolvedValueOnce({ + rows: [{ team_id: TEAM_A, role: 'inviter' }], + rowCount: 1, + }) await expect( createManagedInvitationForUser( @@ -271,8 +273,14 @@ describe('directory privacy and atomic authorization', () => { ).resolves.toEqual({ error: 'forbidden' }) expect(mocks.withTransaction).toHaveBeenCalledTimes(1) - expect(String(mocks.query.mock.calls[0]?.[0])).toContain('FOR UPDATE') - expect(mocks.query).toHaveBeenCalledTimes(1) + // Lock order: the manager's user row, then its team_members rows. + expect(String(mocks.query.mock.calls[0]?.[0])).toBe( + 'SELECT id FROM users WHERE id = $1 FOR NO KEY UPDATE' + ) + expect(mocks.query.mock.calls[0]?.[1]).toEqual([MANAGER]) + expect(String(mocks.query.mock.calls[1]?.[0])).toContain('FROM team_members tm') + expect(String(mocks.query.mock.calls[1]?.[0])).toContain('FOR UPDATE OF tm') + expect(mocks.query).toHaveBeenCalledTimes(2) expect(mocks.registerInvitation).not.toHaveBeenCalled() }) @@ -305,6 +313,9 @@ describe('directory privacy and atomic authorization', () => { } mocks.query.mockImplementation(async (sql: string) => { if (sql.includes("WHERE status = 'draft'")) return { rows: [], rowCount: 0 } + if (sql.includes('SELECT id FROM users WHERE id = $1 FOR NO KEY UPDATE')) { + return { rows: [{ id: MANAGER }], rowCount: 1 } + } if (sql.includes('FROM team_members') && sql.includes('FOR UPDATE')) { return { rows: [{ team_id: TEAM_A, role: 'admin' }], rowCount: 1 } } @@ -397,6 +408,15 @@ describe('directory privacy and atomic authorization', () => { if (sql.includes("$2 = '' AND i.token = $1")) { return { rows: [invitation], rowCount: 1 } } + if ( + sql.includes('SELECT id, lifecycle_state, lifecycle_version FROM users') && + sql.includes('FOR NO KEY UPDATE') + ) { + return { + rows: [{ id: TARGET, lifecycle_state: 'active', lifecycle_version: 1 }], + rowCount: 1, + } + } if (sql.includes('FROM users') && sql.includes('WHERE email = $1')) { return { rows: [ @@ -448,7 +468,22 @@ describe('directory privacy and atomic authorization', () => { const result = await acceptInvitationForEmail(invitation.email, invitation.token) expect(result).toMatchObject({ data: { accepted: true, userId: TARGET } }) - const sql = mocks.query.mock.calls.map(call => String(call[0])).join('\n') + // Lock order: the user row before the membership write; the caller issues + // from that locked row. + expect(result).toMatchObject({ lockedUser: { lifecycleState: 'active', lifecycleVersion: 1 } }) + const statements = mocks.query.mock.calls.map(call => String(call[0])) + const userLock = statements.findIndex( + statement => + statement.includes('SELECT id, lifecycle_state, lifecycle_version FROM users') && + statement.includes('FOR NO KEY UPDATE') + ) + const membershipWrite = statements.findIndex(statement => + statement.includes('INSERT INTO team_members') + ) + expect(userLock).toBeGreaterThanOrEqual(0) + expect(membershipWrite).toBeGreaterThan(userLock) + expect(mocks.query.mock.calls[userLock]?.[1]).toEqual([TARGET]) + const sql = statements.join('\n') expect(sql).toContain('FOR UPDATE OF i') expect(sql).toContain("AND status = 'pending'") expect(sql).not.toContain('UPDATE users') @@ -486,6 +521,15 @@ describe('directory privacy and atomic authorization', () => { rowCount: 1, } } + if ( + sql.includes('SELECT id, lifecycle_state, lifecycle_version FROM users') && + sql.includes('FOR NO KEY UPDATE') + ) { + return { + rows: [{ id: TARGET, lifecycle_state: 'active', lifecycle_version: 1 }], + rowCount: 1, + } + } if (sql.includes('FROM users') && sql.includes('WHERE email = $1')) { return { rows: [ @@ -603,6 +647,9 @@ describe('directory privacy and atomic authorization', () => { rowCount: 1, } } + if (sql.includes('SELECT id FROM users WHERE id = $1 FOR NO KEY UPDATE')) { + return { rows: [{ id: MANAGER }], rowCount: 1 } + } if (sql.includes('FROM team_members') && sql.includes('FOR UPDATE')) { managerCheckStarted() await managerCheckMayFinish @@ -880,6 +927,7 @@ describe('directory privacy and atomic authorization', () => { rows: [{ team_id: TEAM_A, team_name: 'Team A', role: 'member' }], rowCount: 1, }) + .mockResolvedValueOnce({ rows: [{ id: MANAGER }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [{ team_id: TEAM_A, role: 'admin' }], rowCount: 1 }) await expect(resendManagedInvitationForUser(MANAGER, invitation.id)).resolves.toEqual({ @@ -887,8 +935,10 @@ describe('directory privacy and atomic authorization', () => { id: invitation.id, email: invitation.email, }) - expect(mocks.query).toHaveBeenCalledTimes(3) + expect(mocks.query).toHaveBeenCalledTimes(4) expect(String(mocks.query.mock.calls[0]?.[0])).toContain('FOR UPDATE OF i') + expect(String(mocks.query.mock.calls[2]?.[0])).toContain('FOR NO KEY UPDATE') + expect(String(mocks.query.mock.calls[3]?.[0])).toContain('FOR UPDATE OF tm') expect(mocks.registerInvitation).toHaveBeenCalledOnce() }) @@ -915,6 +965,7 @@ describe('directory privacy and atomic authorization', () => { rows: [{ team_id: TEAM_A, team_name: 'Team A', role: 'member' }], rowCount: 1, }) + .mockResolvedValueOnce({ rows: [{ id: MANAGER }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 0 }) const result = @@ -924,8 +975,9 @@ describe('directory privacy and atomic authorization', () => { expect(result).toEqual({ error: 'forbidden' }) expect(String(mocks.query.mock.calls[0]?.[0])).toContain('FOR UPDATE OF i') - expect(String(mocks.query.mock.calls[2]?.[0])).toContain('FOR UPDATE') - expect(mocks.query).toHaveBeenCalledTimes(3) + expect(String(mocks.query.mock.calls[2]?.[0])).toContain('FOR NO KEY UPDATE') + expect(String(mocks.query.mock.calls[3]?.[0])).toContain('FOR UPDATE OF tm') + expect(mocks.query).toHaveBeenCalledTimes(4) expect(mocks.registerInvitation).not.toHaveBeenCalled() } ) diff --git a/control-api/test/externalSessionIssuance.exchangeRoleMismatch.test.ts b/control-api/test/externalSessionIssuance.exchangeRoleMismatch.test.ts index 74cdd50745..6be0de783c 100644 --- a/control-api/test/externalSessionIssuance.exchangeRoleMismatch.test.ts +++ b/control-api/test/externalSessionIssuance.exchangeRoleMismatch.test.ts @@ -26,14 +26,14 @@ function exchangeDatabase(lockedRole: TeamRole) { const db = { query: async (sql: string, values: unknown[] = []) => { if (sql.includes('clock_timestamp()')) return { rows: [{ db_now: now }], rowCount: 1 } - if (sql.includes('SELECT tm.role, u.lifecycle_version')) { + if (sql.includes('SELECT tm.role, u.lifecycle_state, u.lifecycle_version')) { membershipQueries.push(values) - return { rows: [{ role: lockedRole, lifecycle_version: 1 }], rowCount: 1 } - } - if (sql.includes('SELECT lifecycle_state, lifecycle_version')) { - return { rows: [{ lifecycle_state: 'active', lifecycle_version: 1 }], rowCount: 1 } + return { + rows: [{ role: lockedRole, lifecycle_state: 'active', lifecycle_version: 1 }], + rowCount: 1, + } } - if (sql.includes('SELECT id FROM users WHERE id = $1 FOR UPDATE')) { + if (sql.includes('SELECT id FROM users WHERE id = $1 FOR NO KEY UPDATE')) { return { rows: [{ id: userId }], rowCount: 1 } } if (sql.includes('SELECT u.id, u.lifecycle_state, u.lifecycle_version')) { diff --git a/control-api/test/legacyV1SessionGeneration.test.ts b/control-api/test/legacyV1SessionGeneration.test.ts index a9c23bc604..bd6c5ba18e 100644 --- a/control-api/test/legacyV1SessionGeneration.test.ts +++ b/control-api/test/legacyV1SessionGeneration.test.ts @@ -54,7 +54,7 @@ function legacyAuthorityDatabase() { state.revokedFingerprints.add(values[0] as string) return { rows: [{ token_hash: values[0] }], rowCount: 1 } } - if (sql.includes('FROM users') && sql.includes('FOR UPDATE')) { + if (sql.includes('FROM users') && sql.includes('FOR NO KEY UPDATE')) { return { rows: [{ id: state.userId, lifecycle_version: state.lifecycleVersion }], rowCount: 1, @@ -103,6 +103,37 @@ async function issueLegacy( ).token } +// Records every statement V1 issuance sends. Its own users row reports +// generation 7, so a caller-supplied locked row (generation 3) is distinguishable +// from a second read of the user row. +function recordingIssuanceDatabase() { + const now = new Date(Math.floor(Date.now() / 1000) * 1000) + const statements: string[] = [] + const db = { + query: async (sql: string) => { + statements.push(sql) + if (sql.includes('clock_timestamp()')) return { rows: [{ db_now: now }], rowCount: 1 } + if (sql.includes('external_user_session_security_epochs')) return { rows: [], rowCount: 0 } + if (sql.includes('FROM users')) { + return { rows: [{ lifecycle_state: 'active', lifecycle_version: 7 }], rowCount: 1 } + } + throw new Error(`unexpected V1 issuance query: ${sql}`) + }, + } + return { db, statements } +} + +const issuanceInput = { + contract: 'v1' as const, + userId: randomUUID(), + email: 'locked@example.test', + teamId: 'team-1', + role: 'member' as const, + // Observed before any lock; issuance must never sign it. + authGeneration: 1, + authenticationMethods: ['pwd'], +} + function verified(token: string) { const claims = verifyExternalSessionToken(token) if (!claims) throw new Error('expected real V1 signer to produce a verifiable token') @@ -159,3 +190,48 @@ describe('legacy V1 security-event generation and representation identity', () = }) }) }) + +describe('V1 issuance reuses the user row its caller already locked', () => { + it('signs the locked row generation without reading or locking the user again', async () => { + const { db, statements } = recordingIssuanceDatabase() + + const issued = await issueExternalUserSession(issuanceInput, { + db, + policy: legacyPolicy, + lockedUser: { lifecycleState: 'active', lifecycleVersion: 3 }, + }) + + // Witness: issuance ran its clock and epoch reads and signed a token. + expect(verified(issued.token).authGeneration).toBe(3) + expect(statements).toHaveLength(2) + expect(statements[0]).toContain('clock_timestamp()') + expect(statements[1]).toContain('external_user_session_security_epochs') + expect(statements.filter(sql => sql.includes('FROM users'))).toEqual([]) + }) + + it('refuses a locked row that is no longer active', async () => { + const { db, statements } = recordingIssuanceDatabase() + + await expect( + issueExternalUserSession(issuanceInput, { + db, + policy: legacyPolicy, + lockedUser: { lifecycleState: 'retired', lifecycleVersion: 4 }, + }) + ).rejects.toThrow('legacy user-session issuance requires an active lifecycle generation') + // The fake users row is active: only the locked row can have refused issuance. + expect(statements).toEqual([]) + }) + + it('locks the user row itself when the caller holds no lock', async () => { + const { db, statements } = recordingIssuanceDatabase() + + const issued = await issueExternalUserSession(issuanceInput, { db, policy: legacyPolicy }) + + expect(verified(issued.token).authGeneration).toBe(7) + const userReads = statements.filter(sql => sql.includes('FROM users')) + expect(userReads).toHaveLength(1) + expect(userReads[0]).toContain('FOR NO KEY UPDATE') + expect(userReads[0]).not.toMatch(/\bFOR UPDATE\b/) + }) +}) diff --git a/control-api/test/passwordSessionAuthentication.test.ts b/control-api/test/passwordSessionAuthentication.test.ts index 59cf85929b..ad235e4b4e 100644 --- a/control-api/test/passwordSessionAuthentication.test.ts +++ b/control-api/test/passwordSessionAuthentication.test.ts @@ -56,12 +56,18 @@ describe('password session issuance serialization', () => { releasePasswordChange() await expect(login).resolves.toBeNull() - expect(String(mocks.transactionQuery.mock.calls[0]?.[0])).toContain('FOR UPDATE') + const credentialLock = String(mocks.transactionQuery.mock.calls[0]?.[0]) + expect(credentialLock).toContain('lifecycle_state, lifecycle_version') + expect(credentialLock).toContain('FOR NO KEY UPDATE') + expect(credentialLock).not.toMatch(/\bFOR UPDATE\b/) expect(mocks.issueExternalUserSession).not.toHaveBeenCalled() }) it('issues in the locked transaction while the verified credential is current', async () => { - mocks.transactionQuery.mockResolvedValueOnce({ rows: [{ id: 'user-1' }], rowCount: 1 }) + mocks.transactionQuery.mockResolvedValueOnce({ + rows: [{ id: 'user-1', lifecycle_state: 'active', lifecycle_version: 5 }], + rowCount: 1, + }) await expect( authenticatePasswordAndIssueSession({ @@ -71,9 +77,15 @@ describe('password session issuance serialization', () => { }) ).resolves.toMatchObject({ issued: { token: 'synthetic-v2-token', contract: 'v2' } }) + // The issuer reuses the credential lock's row instead of locking the user again. expect(mocks.issueExternalUserSession).toHaveBeenCalledWith( expect.objectContaining({ userId: 'user-1', authenticationMethods: ['pwd'] }), - { db: { query: mocks.transactionQuery } } + expect.objectContaining({ + db: { query: mocks.transactionQuery }, + lockedUser: { lifecycleState: 'active', lifecycleVersion: 5 }, + }) ) + expect(mocks.transactionQuery).toHaveBeenCalledTimes(1) + expect(String(mocks.transactionQuery.mock.calls[0]?.[0])).toContain('FOR NO KEY UPDATE') }) }) diff --git a/control-api/test/routes.externalInvitations.unavailable.test.ts b/control-api/test/routes.externalInvitations.unavailable.test.ts index a3958bfaa1..f7af1d0ad0 100644 --- a/control-api/test/routes.externalInvitations.unavailable.test.ts +++ b/control-api/test/routes.externalInvitations.unavailable.test.ts @@ -130,16 +130,16 @@ describe('external invitation routes when the hub is unavailable', () => { role: 'member', authGeneration: 1, }, + // The acceptance locked the user row before its membership writes; the + // generation differs from the pre-lock authGeneration above. + lockedUser: { lifecycleState: 'active', lifecycleVersion: 4 }, + }) + .mockResolvedValueOnce({ + data: replayData, + lockedUser: { lifecycleState: 'active', lifecycleVersion: 4 }, }) - .mockResolvedValueOnce({ data: replayData }) database.query.mockImplementation(async sql => { const text = String(sql) - if (text.includes('SELECT id FROM users') && text.includes('FOR UPDATE')) { - return { rows: [{ id: '00000000-0000-4000-8000-000000000001' }], rowCount: 1 } - } - if (text.includes('SELECT lifecycle_state, lifecycle_version')) { - return { rows: [{ lifecycle_state: 'active', lifecycle_version: 1 }], rowCount: 1 } - } if (text.includes('clock_timestamp()')) { return { rows: [{ db_now: new Date('2026-10-07T12:00:00.000Z') }], rowCount: 1 } } @@ -162,12 +162,12 @@ describe('external invitation routes when the hub is unavailable', () => { expect(replay.body).not.toHaveProperty('token') expect(replay.body).not.toHaveProperty('sessionContract') expect(tokenSigner.sign).toHaveBeenCalledTimes(1) - // Only the first acceptance reaches the user lock and the issuance reads. - expect(database.query).toHaveBeenCalledTimes(4) - expect(String(database.query.mock.calls[0]?.[0])).toContain('FOR UPDATE') - expect(String(database.query.mock.calls[1]?.[0])).toContain('lifecycle_version') - expect(String(database.query.mock.calls[2]?.[0])).toContain('clock_timestamp()') - expect(String(database.query.mock.calls[3]?.[0])).toContain('SELECT valid_after') + // Only the first acceptance issues. Issuance signs the generation of the + // row the acceptance locked and does not read or lock the user again. + expect(tokenSigner.sign).toHaveBeenCalledWith(expect.objectContaining({ authGeneration: 4 })) + expect(database.query).toHaveBeenCalledTimes(2) + expect(String(database.query.mock.calls[0]?.[0])).toContain('clock_timestamp()') + expect(String(database.query.mock.calls[1]?.[0])).toContain('SELECT valid_after') expect(flow.validateInvitationFlowToken).toHaveBeenCalledTimes(2) }) diff --git a/control-api/test/services.directory.adminProvisioning.test.ts b/control-api/test/services.directory.adminProvisioning.test.ts index d023b608f8..dbdc291181 100644 --- a/control-api/test/services.directory.adminProvisioning.test.ts +++ b/control-api/test/services.directory.adminProvisioning.test.ts @@ -187,6 +187,10 @@ describe('provisionAdminDesktopWorkspace', () => { seedPassword: false, }) + // Lock order: the existing user row is locked before its team_members rows. + expect(String(dbMocks.txQuery.mock.calls[0]?.[0])).toBe( + 'SELECT id FROM users WHERE email = $1 LIMIT 1 FOR NO KEY UPDATE' + ) expect(dbMocks.txQuery).not.toHaveBeenCalledWith( expect.stringContaining('UPDATE users'), expect.anything() diff --git a/control-api/test/userSessionRevokeAllAtomic.test.ts b/control-api/test/userSessionRevokeAllAtomic.test.ts index 52dfa540b2..c7f40d7b04 100644 --- a/control-api/test/userSessionRevokeAllAtomic.test.ts +++ b/control-api/test/userSessionRevokeAllAtomic.test.ts @@ -44,7 +44,8 @@ describe('revoke-all transaction boundary', () => { expect(mocks.withTransaction).toHaveBeenCalledTimes(1) expect(mocks.query).toHaveBeenCalledTimes(5) - expect(String(mocks.query.mock.calls[0]?.[0])).toContain('FOR UPDATE') + expect(String(mocks.query.mock.calls[0]?.[0])).toContain('FOR NO KEY UPDATE') + expect(String(mocks.query.mock.calls[0]?.[0])).not.toMatch(/\bFOR UPDATE\b/) expect(mocks.committedEpoch).toBe(false) }) @@ -66,7 +67,8 @@ describe('revoke-all transaction boundary', () => { const { revokeAllUserSessions } = await import('../src/services/auth/userSessionService.js') await expect(revokeAllUserSessions('user-1', 'security_event')).resolves.toBe(0) - expect(String(mocks.query.mock.calls[0]?.[0])).toContain('FOR UPDATE') + expect(String(mocks.query.mock.calls[0]?.[0])).toContain('FOR NO KEY UPDATE') + expect(String(mocks.query.mock.calls[0]?.[0])).not.toMatch(/\bFOR UPDATE\b/) expect(String(mocks.query.mock.calls[1]?.[0])).toContain('clock_timestamp()') expect(String(mocks.query.mock.calls[2]?.[0])).toContain( 'lifecycle_version = lifecycle_version + 1' diff --git a/control-api/test/userSessionService.test.ts b/control-api/test/userSessionService.test.ts index d86ddb1eb2..07a8021a6b 100644 --- a/control-api/test/userSessionService.test.ts +++ b/control-api/test/userSessionService.test.ts @@ -94,13 +94,47 @@ describe('user session state machine', () => { jti: values[2], sv: 1, }) - expect(String(query.mock.calls[0]?.[0])).toContain('FOR UPDATE') + expect(String(query.mock.calls[0]?.[0])).toContain('SELECT id FROM users') + expect(String(query.mock.calls[0]?.[0])).toContain('FOR NO KEY UPDATE') + expect(String(query.mock.calls[0]?.[0])).not.toMatch(/\bFOR UPDATE\b/) expect(String(query.mock.calls[1]?.[0])).toContain('clock_timestamp()') expect(String(query.mock.calls[2]?.[0])).toContain('DELETE FROM external_user_sessions') expect(String(query.mock.calls[3]?.[0])).toContain('OFFSET $3') expect(query.mock.calls[3]?.[1]?.[2]).toBe(USER_SESSION_MAX_ACTIVE_PER_USER - 1) }) + it('skips only its own user lock when the caller already holds it', async () => { + const inserted = row() + const query = vi.fn(async (sql: string, values?: unknown[]) => { + if (sql.includes('FROM users')) return { rows: [{ id: inserted.user_id }], rowCount: 1 } + if (sql.includes('clock_timestamp()')) return { rows: [{ db_now: now }], rowCount: 1 } + if (sql.includes('INSERT INTO external_user_sessions')) { + return { + rows: [{ ...inserted, sid: values?.[0], user_id: values?.[1], current_jti: values?.[2] }], + rowCount: 1, + } + } + return { rows: [], rowCount: 0 } + }) + + const issued = await createUserSession( + { + userId: String(inserted.user_id), + email: 'User@Example.com', + authenticationMethods: ['pwd'], + }, + { db: { query }, userLocked: true } + ) + + // Witness: the session was inserted and signed, and the cap still ran. + expect(verifyUserSessionV2Token(issued.token)).toMatchObject({ sub: inserted.user_id }) + const statements = query.mock.calls.map(([sql]) => String(sql)) + expect(statements[0]).toContain('clock_timestamp()') + expect(statements.some(sql => sql.includes('OFFSET $3'))).toBe(true) + expect(statements.some(sql => sql.includes('INSERT INTO external_user_sessions'))).toBe(true) + expect(statements.filter(sql => /SELECT id FROM users[\s\S]*\bFOR\b/.test(sql))).toEqual([]) + }) + it('rotates once and returns the same successor to an overlapping renewal', async () => { const original = row() let current = original @@ -229,7 +263,8 @@ describe('user session state machine', () => { .mockResolvedValueOnce({ rows: [], rowCount: 2 }), } await expect(revokeAllUserSessions(userId, 'password_changed', db)).resolves.toBe(2) - expect(String(db.query.mock.calls[0]?.[0])).toContain('FOR UPDATE') + expect(String(db.query.mock.calls[0]?.[0])).toContain('FOR NO KEY UPDATE') + expect(String(db.query.mock.calls[0]?.[0])).not.toMatch(/\bFOR UPDATE\b/) expect(String(db.query.mock.calls[1]?.[0])).toContain('clock_timestamp()') expect(String(db.query.mock.calls[2]?.[0])).toContain( 'lifecycle_version = lifecycle_version + 1' diff --git a/control-api/test/userSessionUserLockMode.realPostgres.integration.test.ts b/control-api/test/userSessionUserLockMode.realPostgres.integration.test.ts new file mode 100644 index 0000000000..9f83292a18 --- /dev/null +++ b/control-api/test/userSessionUserLockMode.realPostgres.integration.test.ts @@ -0,0 +1,675 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import express from 'express' +import bcrypt from 'bcryptjs' +import { randomBytes, randomUUID } from 'node:crypto' +import { Pool, type PoolClient } from 'pg' +import request from 'supertest' +import { initDb } from '../src/db.js' +import type { AuthClaims } from '../src/profileTypes.js' +import { createExternalInvitationsRouter } from '../src/routes/external/invitations.js' +import type { EffectiveUserAccessPolicy } from '../src/services/access/userAccessPolicy.js' +import { exchangeLegacyExternalUserSession } from '../src/services/auth/externalSessionIssuance.js' +import { authenticatePasswordAndIssueSession } from '../src/services/auth/passwordSessionAuthentication.js' +import { createUserSession } from '../src/services/auth/userSessionService.js' +import { + signExternalSessionToken, + verifyExternalSessionToken, +} from '../src/utils/auth/externalSessionAuthToken.js' +import { endPoolAndWaitForClients } from './helpers/realPostgresTeardown.js' + +// Session paths lock the user row FOR NO KEY UPDATE (owner Q-B): an FK +// KEY SHARE holder no longer delays them, while issuance, lifecycle updates and +// deletes still serialize on the row. Login, invitation acceptance and the +// team-session exchange reuse that locked row for issuance (AUTH-5), so a +// retirement that commits while they wait must still win. Every transaction +// that touches a user's row and its team_members rows locks the user row +// first, so acceptance and exchange cannot deadlock. + +const holder = vi.hoisted(() => ({ pool: null as unknown as Pool })) +const flow = vi.hoisted(() => ({ + validateInvitationFlowToken: vi.fn(), + storeDesktopAuthorizationToken: vi.fn(), +})) +const accessPolicy = vi.hoisted(() => ({ resolveEffectiveUserAccessPolicy: vi.fn() })) +const login = vi.hoisted(() => ({ + passwordLoginData: vi.fn(async (): Promise => { + throw new Error('passwordLoginData must be primed with a pre-retirement snapshot') + }), +})) + +vi.mock('../src/services/invitationFlowRegistrationService.js', () => flow) +vi.mock('../src/services/access/userAccessRuntimePolicy.js', () => accessPolicy) +vi.mock('../src/services/directory/login.js', async importOriginal => { + const real = await importOriginal() + return { ...real, passwordLoginData: login.passwordLoginData } +}) +vi.mock('../src/db.js', async importOriginal => { + const real = await importOriginal() + const proxy = { + query: (...args: unknown[]) => (holder.pool.query as Function)(...args), + connect: () => holder.pool.connect(), + } + return { + ...real, + pool: proxy, + rateLimitPool: proxy, + withTransaction: ( + work: Parameters[0], + _txPool?: Parameters[1], + options?: Parameters[2] + ) => real.withTransaction(work, holder.pool, options), + } +}) + +const adminUrl = process.env.CONTROL_API_REAL_PG_ADMIN_URL +const describeRealPostgres = adminUrl ? describe.sequential : describe.skip +const runtimeRoles = [ + 'control_api_runtime', + 'trace_maintenance_runtime', + 'workflow_recipes_runtime', +] as const +const ISSUANCE_REFUSED = 'legacy user-session issuance requires an active lifecycle generation' +const PASSWORD = 'Lock-Mode-Password-1!' +// Every connection of the suite, including the production paths that open +// their own transactions, gives up on a lock after this bound; each case's +// own timeout leaves room for that bound to fire and for the cleanup. +const LOCK_TIMEOUT_MS = 10_000 +const CASE_TIMEOUT_MS = 40_000 + +// Statements the cases wait on, matched against pg_stat_activity.query. +const SESSION_USER_LOCK = /SELECT id FROM users WHERE id = \$1 FOR NO KEY UPDATE/ +const ACCEPTANCE_USER_LOCK = + /SELECT id, lifecycle_state, lifecycle_version FROM users WHERE id = \$1 FOR NO KEY UPDATE/ +const CREDENTIAL_USER_LOCK = /FROM users[\s\S]*password_hash = \$3[\s\S]*FOR NO KEY UPDATE/ +const LIFECYCLE_UPDATE = /UPDATE users SET lifecycle_version = lifecycle_version \+ 1/ +const USER_DELETE = /DELETE FROM users WHERE id = \$1/ + +const legacyPolicy = { + policyVersion: '1', + policyRevision: 'user-session-user-lock-mode', + acceptV1: true, + issueV1: true, + acceptV2: false, + issueV2: false, + renewV2: false, + switchCompatibility: true, + minimumClientVersion: null, + enforceMinimumClient: false, +} as EffectiveUserAccessPolicy + +function databaseUrl(baseUrl: string, database: string): string { + const value = new URL(baseUrl) + value.pathname = `/${database}` + return value.toString() +} + +function quoteIdentifier(value: string): string { + return `"${value.replace(/"/g, '""')}"` +} + +function app(): express.Express { + const a = express() + a.use(express.json()) + a.use(createExternalInvitationsRouter()) + a.use( + (err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + const message = err instanceof Error ? err.message : 'unknown route failure' + res.status(500).json({ error: 'Internal Server Error', message }) + } + ) + return a +} + +type Settled = { value: T } | { error: unknown } + +// Starts an operation whose outcome the case inspects later, and registers it +// so the case's cleanup can wait for it before releasing connections. +function track(pending: Promise[], operation: Promise): Promise> { + const settled = operation.then( + value => ({ value }), + (error: unknown) => ({ error }) + ) + pending.push(settled) + return settled +} + +describeRealPostgres('session user-row lock mode on real PostgreSQL', () => { + const database = `control_api_user_lock_mode_${randomBytes(6).toString('hex')}` + const connectionString = databaseUrl( + adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', + database + ) + const flowTokens = new Map() + let adminPool: Pool + let databasePool: Pool + + async function backendPid(client: PoolClient): Promise { + const result = await client.query<{ pid: number }>('SELECT pg_backend_pid() AS pid') + return Number(result.rows[0]!.pid) + } + + // The given backend waits on the blocker while running the expected statement. + async function waitForBlock( + blockedPid: number, + blockerPid: number, + expected: RegExp + ): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + const result = await databasePool.query<{ blocked: boolean; query: string | null }>( + `SELECT $2::int = ANY(pg_blocking_pids($1::int)) AS blocked, + (SELECT query FROM pg_stat_activity WHERE pid = $1::int) AS query`, + [blockedPid, blockerPid] + ) + const row = result.rows[0] + if (row?.blocked && expected.test(row.query ?? '')) return + await new Promise(resolve => setTimeout(resolve, 10)) + } + throw new Error(`expected backend ${blockedPid} to wait on ${blockerPid} in ${expected}`) + } + + // For production paths that open their own transaction: the backend of this + // database that runs the expected statement while it waits on a lock. When a + // blocker is given, the wait must be on that backend. + async function waitForWaitingStatement(expected: RegExp, blockerPid?: number): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + const result = await databasePool.query<{ pid: number; query: string; blockers: number[] }>( + `SELECT activity.pid, activity.query, pg_blocking_pids(activity.pid) AS blockers + FROM pg_stat_activity activity + WHERE activity.datname = current_database() + AND activity.pid <> pg_backend_pid() + AND cardinality(pg_blocking_pids(activity.pid)) > 0` + ) + const match = result.rows.find( + row => + expected.test(row.query) && + (blockerPid === undefined || row.blockers.map(Number).includes(blockerPid)) + ) + if (match) return Number(match.pid) + await new Promise(resolve => setTimeout(resolve, 10)) + } + throw new Error(`expected a backend running ${expected} to wait on a lock`) + } + + async function seedUser(label: string): Promise<{ userId: string; email: string }> { + const userId = randomUUID() + const email = `${label}-${userId}@example.test` + const passwordHash = await bcrypt.hash(PASSWORD, 4) + await databasePool.query( + `INSERT INTO users(id, email, name, password_hash) VALUES ($1, $2, $3, $4)`, + [userId, email, label, passwordHash] + ) + return { userId, email } + } + + async function seedTeamMember(userId: string, label: string): Promise { + const teamId = randomUUID() + await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, $2)`, [teamId, label]) + await databasePool.query( + `INSERT INTO team_members(team_id, user_id, role, status) + VALUES ($1, $2, 'admin', 'active')`, + [teamId, userId] + ) + return teamId + } + + async function seedInvitation( + email: string, + teamId: string, + role: 'admin' | 'member' + ): Promise<{ invitationId: string; flowToken: string }> { + const capability = `db-token-${randomUUID()}` + const invitation = await databasePool.query<{ id: string }>( + `INSERT INTO invitations(email, role, token, status, purpose, expires_at) + VALUES ($1, $2, $3, 'pending', 'member_invitation', NOW() + INTERVAL '1 hour') + RETURNING id::text AS id`, + [email, role, capability] + ) + const invitationId = invitation.rows[0]!.id + await databasePool.query( + `INSERT INTO invitation_teams(invitation_id, team_id, role) VALUES ($1, $2, $3)`, + [invitationId, teamId, role] + ) + const flowToken = `flow-${randomUUID()}` + flowTokens.set(flowToken, { capability, email }) + return { invitationId, flowToken } + } + + function legacyToken(user: { userId: string; email: string }, teamId: string) { + const token = signExternalSessionToken({ + userId: user.userId, + email: user.email, + teamId, + role: 'admin', + authGeneration: 1, + }) + const claims: AuthClaims | null = verifyExternalSessionToken(token) + if (!claims) throw new Error('test producer failed to verify its V1 token') + return { token, claims } + } + + function accept(flowToken: string, email: string) { + return request(app()) + .post('/external/invitations/accept') + .send({ email, token: flowToken, sessionContract: 'v1' }) + .then(response => response) + } + + // A governed retirement (the users_lifecycle_state_check needs the actor, + // reason and operation columns), left uncommitted on the gate connection. + async function beginRetirement(gate: PoolClient, userId: string): Promise { + await gate.query('BEGIN') + const operation = await gate.query<{ id: string }>( + `INSERT INTO desktop_user_retirement_operations( + actor_type, actor_desktop_user_id, target_user_id, + idempotency_key_hash, request_fingerprint, reason + ) + VALUES ('platform_user', $1::uuid, $1::uuid, repeat('a', 64), repeat('b', 64), + 'session lock mode retirement') + RETURNING id::text AS id`, + [userId] + ) + const retired = await gate.query( + `UPDATE users + SET lifecycle_state = 'retired', + retired_at = NOW(), + retirement_reason = 'session lock mode retirement', + retired_by_type = 'platform_user', + retired_by_desktop_user_id = $1::uuid, + retirement_operation_id = $2::uuid, + lifecycle_version = lifecycle_version + 1 + WHERE id = $1::uuid`, + [userId, operation.rows[0]!.id] + ) + expect(retired.rowCount).toBe(1) + } + + async function lifecycle(userId: string): Promise<{ state: string; version: number }> { + const result = await databasePool.query<{ state: string; version: string }>( + `SELECT lifecycle_state AS state, lifecycle_version::text AS version + FROM users + WHERE id = $1`, + [userId] + ) + const row = result.rows[0] + if (!row) throw new Error(`user ${userId} is missing`) + return { state: row.state, version: Number(row.version) } + } + + async function sessionCount(userId: string): Promise { + const result = await databasePool.query<{ count: number }>( + `SELECT count(*)::int AS count FROM external_user_sessions WHERE user_id = $1`, + [userId] + ) + return result.rows[0]!.count + } + + function issueOn(client: PoolClient, user: { userId: string; email: string }) { + return createUserSession( + { userId: user.userId, email: user.email, authenticationMethods: ['pwd'] }, + { db: client } + ) + } + + // Ends every open transaction, then waits for every tracked operation, so no + // query is still pending when its connection is released or the pool closes. + async function settle(clients: PoolClient[], pending: Promise[]): Promise { + for (const client of clients) await client.query('ROLLBACK').catch(() => undefined) + await Promise.allSettled(pending) + for (const client of clients) client.release() + } + + beforeAll(async () => { + adminPool = new Pool({ connectionString: adminUrl }) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + await adminPool.query(`CREATE DATABASE ${quoteIdentifier(database)}`) + databasePool = new Pool({ connectionString, options: `-c lock_timeout=${LOCK_TIMEOUT_MS}` }) + holder.pool = databasePool + await initDb({ connect: () => databasePool.connect() }) + accessPolicy.resolveEffectiveUserAccessPolicy.mockResolvedValue(legacyPolicy) + flow.validateInvitationFlowToken.mockImplementation(async (token: string) => { + const fixture = flowTokens.get(token) + if (!fixture) throw new Error(`invalid invitation flow token ${token}`) + return { email: fixture.email, invitationUuid: fixture.capability } + }) + }, 60_000) + + afterAll(async () => { + try { + await endPoolAndWaitForClients(databasePool) + if (adminPool) { + await adminPool.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database] + ) + await adminPool.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`) + await adminPool.query(`DROP ROLE IF EXISTS ${runtimeRoles.join(', ')}`) + } + } finally { + await adminPool?.end() + } + }) + + it( + '(a) issues a session while an FK child insert holds KEY SHARE on the user', + async () => { + const user = await seedUser('fk-holder') + const holderClient = await databasePool.connect() + const issuer = await databasePool.connect() + try { + await holderClient.query('BEGIN') + await holderClient.query( + `INSERT INTO workflow_runs_audit( + run_id, run_namespace, run_name, recipe_namespace, recipe_name, + triggerer_user_id, triggerer_actor_type, trigger_source, idempotency_key, + triggered_at, completed_at, final_phase, step_count, snapshot_sha + ) + VALUES (gen_random_uuid(), 'lock-mode', 'fk-holder', 'lock-mode', 'fk-holder', + $1, 'user', 'onDemand', 'lock-mode-fk-holder', + NOW(), NOW(), 'Succeeded', 0, 'lock-mode')`, + [user.userId] + ) + const holderPid = await backendPid(holderClient) + + await issuer.query('BEGIN') + await issuer.query(`SET LOCAL lock_timeout = '2s'`) + const issued = await issueOn(issuer, user) + expect(issued.identity.userId).toBe(user.userId) + + // Witness: the FK holder was still open while issuance returned. + const holderState = await databasePool.query<{ state: string }>( + `SELECT state FROM pg_stat_activity WHERE pid = $1`, + [holderPid] + ) + expect(holderState.rows).toEqual([{ state: 'idle in transaction' }]) + await issuer.query('COMMIT') + expect(await sessionCount(user.userId)).toBe(1) + } finally { + await settle([issuer, holderClient], []) + } + }, + CASE_TIMEOUT_MS + ) + + it( + '(b) serializes a lifecycle update behind an open issuance', + async () => { + const user = await seedUser('lifecycle-update') + const issuer = await databasePool.connect() + const updater = await databasePool.connect() + const pending: Promise[] = [] + try { + await issuer.query('BEGIN') + await issueOn(issuer, user) + const issuerPid = await backendPid(issuer) + + await updater.query('BEGIN') + const updaterPid = await backendPid(updater) + const update = track( + pending, + updater.query( + `UPDATE users SET lifecycle_version = lifecycle_version + 1 WHERE id = $1`, + [user.userId] + ) + ) + await waitForBlock(updaterPid, issuerPid, LIFECYCLE_UPDATE) + await issuer.query('COMMIT') + + expect(await update).toEqual({ value: expect.objectContaining({ rowCount: 1 }) }) + await updater.query('COMMIT') + expect(await lifecycle(user.userId)).toEqual({ state: 'active', version: 2 }) + expect(await sessionCount(user.userId)).toBe(1) + } finally { + await settle([issuer, updater], pending) + } + }, + CASE_TIMEOUT_MS + ) + + it( + '(b) serializes a user delete behind an open issuance', + async () => { + const user = await seedUser('user-delete') + const issuer = await databasePool.connect() + const deleter = await databasePool.connect() + const pending: Promise[] = [] + try { + await issuer.query('BEGIN') + await issueOn(issuer, user) + const issuerPid = await backendPid(issuer) + + await deleter.query('BEGIN') + const deleterPid = await backendPid(deleter) + const deletion = track( + pending, + deleter.query(`DELETE FROM users WHERE id = $1`, [user.userId]) + ) + await waitForBlock(deleterPid, issuerPid, USER_DELETE) + await issuer.query('COMMIT') + + expect(await deletion).toEqual({ value: expect.objectContaining({ rowCount: 1 }) }) + await deleter.query('ROLLBACK') + expect(await sessionCount(user.userId)).toBe(1) + } finally { + await settle([issuer, deleter], pending) + } + }, + CASE_TIMEOUT_MS + ) + + it( + '(c) serializes two issuances for the same user', + async () => { + const user = await seedUser('self-serialize') + const first = await databasePool.connect() + const second = await databasePool.connect() + const pending: Promise[] = [] + try { + await first.query('BEGIN') + await issueOn(first, user) + const firstPid = await backendPid(first) + + await second.query('BEGIN') + const secondPid = await backendPid(second) + // No caller-held lock: createUserSession takes the user lock itself. + const secondIssue = track(pending, issueOn(second, user)) + await waitForBlock(secondPid, firstPid, SESSION_USER_LOCK) + await first.query('COMMIT') + + expect(await secondIssue).toEqual({ + value: expect.objectContaining({ + identity: expect.objectContaining({ userId: user.userId }), + }), + }) + await second.query('COMMIT') + expect(await sessionCount(user.userId)).toBe(2) + } finally { + await settle([first, second], pending) + } + }, + CASE_TIMEOUT_MS + ) + + it( + 'runs an invitation acceptance and a team-session exchange of one user without deadlock', + async () => { + // The user already belongs to the team the invitation targets, so the + // acceptance writes the same team_members row the exchange locks. + const user = await seedUser('accept-exchange') + const teamId = await seedTeamMember(user.userId, 'accept-exchange') + const { flowToken, invitationId } = await seedInvitation(user.email, teamId, 'admin') + const { token, claims } = legacyToken(user, teamId) + + const gate = await databasePool.connect() + const pending: Promise[] = [] + try { + await gate.query('BEGIN') + await gate.query(`SELECT id FROM users WHERE id = $1 FOR NO KEY UPDATE`, [user.userId]) + const gatePid = await backendPid(gate) + + // The exchange queues first on the user row, so it takes the row first + // once the gate ends. + const exchange = track( + pending, + exchangeLegacyExternalUserSession( + { token, claims, userId: user.userId, email: user.email, teamId, role: 'admin' }, + { policy: legacyPolicy } + ) + ) + await waitForWaitingStatement(SESSION_USER_LOCK, gatePid) + // Witness: the acceptance reached its user lock. With the old order it + // would hold the team_members row here, before waiting on the user. + const acceptance = track(pending, accept(flowToken, user.email)) + await waitForWaitingStatement(ACCEPTANCE_USER_LOCK) + await gate.query('COMMIT') + + const exchanged = await exchange + const accepted = await acceptance + expect(exchanged).toEqual({ + value: { status: 'issued', token: expect.any(String), role: 'admin' }, + }) + if (!('value' in accepted)) throw accepted.error + expect(accepted.value.status, JSON.stringify(accepted.value.body)).toBe(200) + expect(accepted.value.body).toMatchObject({ + accepted: true, + userId: user.userId, + sessionContract: 'v1', + token: expect.any(String), + }) + const state = await databasePool.query<{ status: string }>( + `SELECT status FROM invitations WHERE id = $1`, + [invitationId] + ) + expect(state.rows).toEqual([{ status: 'accepted' }]) + } finally { + await settle([gate], pending) + } + }, + CASE_TIMEOUT_MS + ) + + it( + 'retirement wins over a password login waiting on the credential lock', + async () => { + const user = await seedUser('login-retired') + const teamId = await seedTeamMember(user.userId, 'login-retired') + const stored = await databasePool.query<{ password_hash: string }>( + `SELECT password_hash FROM users WHERE id = $1`, + [user.userId] + ) + // The snapshot passwordLoginData returned before the retirement, so the + // decision is taken by the issuance lock and not by the verifier. + login.passwordLoginData.mockResolvedValueOnce({ + user: { id: user.userId, email: user.email, name: 'login-retired', picture: null }, + membership: { team_id: teamId, role: 'admin', team_name: 'login-retired' }, + credentialHash: stored.rows[0]!.password_hash, + authGeneration: 1, + }) + expect(await lifecycle(user.userId)).toEqual({ state: 'active', version: 1 }) + + const gate = await databasePool.connect() + const pending: Promise[] = [] + try { + await beginRetirement(gate, user.userId) + const gatePid = await backendPid(gate) + const outcome = track( + pending, + authenticatePasswordAndIssueSession({ + email: user.email, + password: PASSWORD, + contract: 'v1', + policy: legacyPolicy, + }) + ) + await waitForWaitingStatement(CREDENTIAL_USER_LOCK, gatePid) + await gate.query('COMMIT') + + expect(await outcome).toEqual({ + error: expect.objectContaining({ message: ISSUANCE_REFUSED }), + }) + expect(login.passwordLoginData).toHaveBeenCalledTimes(1) + expect(await lifecycle(user.userId)).toEqual({ state: 'retired', version: 2 }) + } finally { + await settle([gate], pending) + } + }, + CASE_TIMEOUT_MS + ) + + it( + 'retirement wins over an invitation acceptance waiting on the user lock', + async () => { + const user = await seedUser('invitation-retired') + const teamId = randomUUID() + await databasePool.query(`INSERT INTO teams(id, name) VALUES ($1, 'invitation-retired')`, [ + teamId, + ]) + const { flowToken, invitationId } = await seedInvitation(user.email, teamId, 'member') + + const gate = await databasePool.connect() + const pending: Promise[] = [] + try { + await beginRetirement(gate, user.userId) + const gatePid = await backendPid(gate) + const acceptance = track(pending, accept(flowToken, user.email)) + await waitForWaitingStatement(ACCEPTANCE_USER_LOCK, gatePid) + await gate.query('COMMIT') + + const settled = await acceptance + if (!('value' in settled)) throw settled.error + expect(settled.value.status).toBe(500) + expect(settled.value.body).toEqual({ + error: 'Internal Server Error', + message: ISSUANCE_REFUSED, + }) + expect(settled.value.body).not.toHaveProperty('token') + // The refused issuance rolled the acceptance back. + const state = await databasePool.query<{ + status: string + accepted_user_id: string | null + }>(`SELECT status, accepted_user_id FROM invitations WHERE id = $1`, [invitationId]) + expect(state.rows).toEqual([{ status: 'pending', accepted_user_id: null }]) + expect(await lifecycle(user.userId)).toEqual({ state: 'retired', version: 2 }) + } finally { + await settle([gate], pending) + } + }, + CASE_TIMEOUT_MS + ) + + // validateLegacyUserSession({ lockUser: true }) already re-checks the + // lifecycle under this same lock, so this case is a regression guard: a + // mutation of the exchange's AUTH-5 issuance is caught by the statement + // sequence pinned in app.test.ts (plan Addendum V2-M11). + it( + 'retirement wins over a team-session exchange waiting on the user lock', + async () => { + const user = await seedUser('exchange-retired') + const teamId = await seedTeamMember(user.userId, 'exchange-retired') + const { token, claims } = legacyToken(user, teamId) + + const gate = await databasePool.connect() + const pending: Promise[] = [] + try { + await beginRetirement(gate, user.userId) + const gatePid = await backendPid(gate) + const exchange = track( + pending, + exchangeLegacyExternalUserSession( + { token, claims, userId: user.userId, email: user.email, teamId, role: 'admin' }, + { policy: legacyPolicy } + ) + ) + await waitForWaitingStatement(SESSION_USER_LOCK, gatePid) + await gate.query('COMMIT') + + expect(await exchange).toEqual({ value: { status: 'invalid_session' } }) + expect(await lifecycle(user.userId)).toEqual({ state: 'retired', version: 2 }) + } finally { + await settle([gate], pending) + } + }, + CASE_TIMEOUT_MS + ) +}) diff --git a/tests/contracts/password-recovery-suites.json b/tests/contracts/password-recovery-suites.json index 637db85224..728dd558b3 100644 --- a/tests/contracts/password-recovery-suites.json +++ b/tests/contracts/password-recovery-suites.json @@ -119,6 +119,7 @@ "control-api/test/routes.externalTeamsLegacyAdminRevocation.realPostgres.integration.test.ts", "control-api/test/userAccessRuntimeContract.realPostgres.integration.test.ts", "control-api/test/userSessionDatabaseClock.realPostgres.integration.test.ts", + "control-api/test/userSessionUserLockMode.realPostgres.integration.test.ts", "control-api/test/workflowRunReadAndApprovalV1Compatibility.realPostgres.integration.test.ts" ], "privatePostgres": { From 5db1ed53317be7605deb4c0a96943870214b97c2 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Wed, 4 Nov 2026 22:04:59 +0100 Subject: [PATCH 474/528] fix(ci,scripts): harden certification harness and E2E credentials (K26) - run the recovery suite selection tests through run-node-test-files; - document the pinned producer inputs and point both refusals to it; - add pre-gate packages to certification inputs only for T2; - record the T1 evidence path in the T2 phase detail; - check package and lane in the T1 reporter report test; - keep credentials out of argv in the RPC gateway E2E harness: bodies on stdin, bearer headers on fd 3, curl without curlrc, xtrace off, status-only auth failures, and a static plus behaviour test in CI. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci-public.yml | 5 +- AGENTS.md | 4 + docs/testing/minikube-t2-runbook.md | 38 ++++++ scripts/ci/certification-inputs.mjs | 3 +- scripts/e2e/e2e-rpc-gateway-host-paths.sh | 59 +++++----- scripts/e2e/e2e-rpc-http-lib.sh | 44 +++++++ scripts/e2e/minikube-real-postgres.sh | 2 +- scripts/minikube/t2.sh | 4 +- scripts/tests/certification-inputs.test.mjs | 34 +++++- .../password-recovery-suite-contract.test.mjs | 21 ++++ scripts/tests/test-e2e-credential-argv.sh | 109 ++++++++++++++++++ .../tests/test-minikube-t1-reporter-report.sh | 11 ++ scripts/tests/test-minikube-t2-contract.sh | 46 ++++++++ 13 files changed, 341 insertions(+), 39 deletions(-) create mode 100644 scripts/e2e/e2e-rpc-http-lib.sh create mode 100755 scripts/tests/test-e2e-credential-argv.sh diff --git a/.github/workflows/ci-public.yml b/.github/workflows/ci-public.yml index 9cfffaf664..e6e14086e6 100644 --- a/.github/workflows/ci-public.yml +++ b/.github/workflows/ci-public.yml @@ -556,7 +556,7 @@ jobs: env: CODEX_SCRATCH_ROOT: ${{ runner.temp }} run: >- - node --test + node scripts/tests/run-node-test-files.mjs scripts/tests/password-recovery-suite-contract.test.mjs scripts/tests/certification-inputs.test.mjs scripts/tests/t1-reporter-projection.test.mjs @@ -941,6 +941,9 @@ jobs: - name: Test WRC egress oracle, DNS and lifecycle (unit/integration, cluster-free) run: bash scripts/tests/test-wrc-egress-degradation-gate.sh + - name: Test E2E credentials stay out of argv (RPC gateway journey) + run: bash scripts/tests/test-e2e-credential-argv.sh + - name: Test Minikube mutation and WRC recovery admission (hermetic, cluster-free) run: bash scripts/tests/test-minikube-mutation-boundary.sh diff --git a/AGENTS.md b/AGENTS.md index 12e3e342af..625c83856d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -183,6 +183,10 @@ only the internal planner so `full-bootstrap` is reachable). `make minikube-pre-gate-sync` alone never constitutes T2 evidence. Follow the rule `.cursor/rules/minikube-t0-t1-t2.mdc` and the skill `.cursor/skills/minikube-t0-t1-t2/SKILL.md` for the certification workflow. +T1 needs the two pinned producer checkouts named by +`TASK106_R61_B1_DEV_SOURCE_DIR` and `TASK106_R61_B1_LATEST_DEV_SOURCE_DIR`; +prepare them as in `docs/testing/minikube-t2-runbook.md` ("Pinned producer +inputs") before launching. A T1 `next:` line or `REAL_PG_REQUIRED_BUT_UNAVAILABLE` is not a license to operate Docker: do not `docker run` probes, `docker desktop restart`, or experiment with published ports. Re-enter `make minikube-t2` or diff --git a/docs/testing/minikube-t2-runbook.md b/docs/testing/minikube-t2-runbook.md index 8c9e293274..b4d9b0ab79 100644 --- a/docs/testing/minikube-t2-runbook.md +++ b/docs/testing/minikube-t2-runbook.md @@ -379,6 +379,44 @@ The executable composition contract in `sync-auth-key → settle → reconcile-with-shim-on-PATH → converge` and the non-T2 scope guard. +### Pinned producer inputs + +T1 runs migration-compatibility suites against two historical Control API +builds pinned in `tests/contracts/certification-inputs.json`. `make +minikube-t2` (with T1 enabled) and the standalone Real PostgreSQL lane refuse +before T0 unless `TASK106_R61_B1_DEV_SOURCE_DIR` and +`TASK106_R61_B1_LATEST_DEV_SOURCE_DIR` name clean checkouts of those commits +that carry a matching build receipt. CI prepares them in the +`control-api-migration` job; locally, run the same sequence from the active +worktree with Node 24, before the lane starts: + +```bash +node scripts/ci/certification-inputs.mjs pins +# prints observed_commit= and latest_commit= +PRODUCERS= +git worktree add --detach "$PRODUCERS/observed-dev" +git worktree add --detach "$PRODUCERS/latest-dev" +for producer in observed-dev latest-dev; do + (cd "$PRODUCERS/$producer/packages/workflow-runtime-core" && npm ci && npm run build) + (cd "$PRODUCERS/$producer/control-api" && npm ci) +done +TASK106_R61_B1_DEV_SOURCE_DIR="$PRODUCERS/observed-dev" \ + node scripts/ci/certification-inputs.mjs build-producer --producer observedDev +TASK106_R61_B1_LATEST_DEV_SOURCE_DIR="$PRODUCERS/latest-dev" \ + node scripts/ci/certification-inputs.mjs build-producer --producer latestDev +export TASK106_R61_B1_DEV_SOURCE_DIR="$PRODUCERS/observed-dev" +export TASK106_R61_B1_LATEST_DEV_SOURCE_DIR="$PRODUCERS/latest-dev" +``` + +`build-producer` refuses a checkout whose `HEAD` is not the pinned commit, a +dirty checkout, or source files whose hashes differ from the contract, and +writes the receipt only after its own builds. The receipt binds the lockfiles +and build outputs, so rebuild a producer after any change inside it. The +producer worktrees are detached at fixed commits: do not fetch, pull or check +out inside them, and do not run `git fetch` in any worktree of the repository +while a lane runs. Confirm the inputs with +`node scripts/ci/certification-inputs.mjs check --scope t1` before launching. + ## T0, T1, and T2 boundaries - **T0** — shell syntax/ShellCheck where available, contract tests, affected diff --git a/scripts/ci/certification-inputs.mjs b/scripts/ci/certification-inputs.mjs index 37b226c877..b838ea1832 100644 --- a/scripts/ci/certification-inputs.mjs +++ b/scripts/ci/certification-inputs.mjs @@ -292,7 +292,8 @@ export function inspectCertificationInputs(options) { if (scope !== 'ci-t1') for (const pkg of contract.localT1ExtraPackages) packages.add(pkg) } if (scope === 't2' || scope === 't0') { - for (const pkg of listPreGatePackages(root, contract)) packages.add(pkg) + // T0 tests only the affected packages; the pre-gate set runs only in T2. + if (scope === 't2') for (const pkg of listPreGatePackages(root, contract)) packages.add(pkg) for (const pkg of listAffectedPackages(root, base, head)) { if (!/^tests\/e2e(?:\/|$)/.test(pkg) && !/\/tests\/e2e(?:\/|$)/.test(pkg)) packages.add(pkg) } diff --git a/scripts/e2e/e2e-rpc-gateway-host-paths.sh b/scripts/e2e/e2e-rpc-gateway-host-paths.sh index f028cc9fa5..a3a04d8a52 100755 --- a/scripts/e2e/e2e-rpc-gateway-host-paths.sh +++ b/scripts/e2e/e2e-rpc-gateway-host-paths.sh @@ -38,11 +38,14 @@ # the run fails when it expires. A timed-out kubectl call can take up to the # 2 s kill grace of run-with-deadline.mjs to terminate. # ====================================================================== +# The journey handles a password and bearer tokens: `bash -x` must not trace them. +{ set +x; } 2>/dev/null set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" source "${SCRIPT_DIR}/e2e-lib.sh" +source "${SCRIPT_DIR}/e2e-rpc-http-lib.sh" if [ -z "${E2E_KUBECONTEXT:-}" ] || ! is_branch_scoped_e2e_context "$E2E_KUBECONTEXT"; then echo "J4 requires an explicit branch-scoped KUBECONTEXT (or E2E_K8S_CONTEXT); the global current-context is never used" >&2 @@ -138,31 +141,16 @@ kctl() { --request-timeout="${budget}s" "$@" } -http_call() { - # http_call